树回归
一,引言
尽管线性回归包含了一些强大的方法,但这些方法创建的模型需要拟合所有的样本数据。当数据拥有众多特征并且特征之间的关系比较复杂时,构建全局线性模型就会非常困难。并且,在实际生活中很多问题都是非线性的,很难通过全局线性模型来拟合所有数据。
解决上述非线性数据的拟合问题的一个可行的方法是,将数据集切分成很多份容易建模的数据,然后再利用线性回归方法来对切分后的数据子集分别建模,如果切分后仍难以拟合线性模型就继续切分。这样,就可以比较好的拟合全局数据。
二,CART算法
CART算法,即分类回归树算法,该算法既可以用于分类,也可以用于回归。该算法数据的建模方法为二元切分法。
1 复杂数据的局部性建模--二元切分法
ID3决策树算法通过不断将数据切分成小数据集,知道所有目标变量完全相同,或者数据不能再切分为止;决策树是一种贪心算法,要在给定时间内找到最佳选择,并并不关心全局最优问题。
ID3决策树算法存在以下两个问题:
(1)ID3算法每次选取最佳特征来分割数据,并按照该特征所有的取值来切分,即有几种可能的取值,数据就会被切分成几份。而一旦按该特征切分后,该特征在之后的算法执行过程中便不再起作用了,所以存在一些观点认为该算法过于迅速。
(2)ID3算法并不能处理连续型特征,只能事先将连续性特征转化为离散型,才能使用。显然,在转换过程中会破坏连续型变量的内在性质。
相比于ID3算法,显然CART算法更有优势,因为我们知道CART算法不仅可以用于分类,还可以用于回归,这里的回归即是处理连续型特征的体现。CART算法之所以能够处理连续型特征,就在于采用了二元切分法,而使用二元切分法易于对树构建过程进行调整以处理连续型特征。二元切分法的具体过程为:每次将数据集划分为两份,如果数据的某特征值大于给定值就进入左子树,否则就进入右子树。
2 CART算法树的构建
CART算法既可以用于分类也可以用于回归,回归树与分类树思路类似,但叶节点的数据不是类型不是离散型,而是连续型。这里,我们将通过CART算法构建两种树,一种是回归树,其每个叶节点包含单个值;另外一种是模型树,其每个叶节点包含一个线性方程。
首先,这里采用跟ID3算法中使用相同的字典结果来存储构建树的数据结构。在这里,CART算法构建树包含四个主要元素,待切分特征,待切分的特征值,左子树(当数据不能再切分时,也可以是叶节点),右子树。
事实上,我们也可以像C++一样,采用面向对象的方式来建立树的数据结构,比如,建立如下树节点结构:
class treeNode(): def __init__(self,feat,val,right,left): featureToSplitOn=feat valueOfSplit=val rightBranch=right leftBranch=left
这里,直接采用字典的数据结构,显然我们无须定义一个类,从而有效的减少了代码量。
CART算法构建树函数createTree()伪代码如下:
找到最佳的切分特征: 如果该节点不能再分,将该节点存为叶节点 执行二元切分 在左子树递归调用createTree()方法 在右子树递归调用createTree()方法
具体代码如下:
#解析文本数据 def loadDatabase(filename): dataMat=[] fr=open(filename) for line in fr.readlines(): curLine=line.strip().split('\t') #将每行数据映射为浮点数 fltLine=map(float,curLine) dataMat.append(fltLine) return dataMat #拆分数据集函数,二元拆分法 #@dataSet:待拆分的数据集 #@feature:作为拆分点的特征索引 #@value:特征的某一取值作为分割值 def binSplitDataSet(dataSet,feature,value): #采用条件过滤的方法获取数据集每个样本目标特征的取值大于 #value的样本存入mat0 #左子集列表的第一行 #mat0=dataSet[nonzero(dataSet[:,feature]>value)[0],:][0] #左子集列表 mat0=dataSet[nonzero(dataSet[:,feature]>value)[0],:] #同上,样本目标特征取值不大于value的样本存入mat1 mat1=dataSet[nonzero(dataSet[:,feature]<=value)[0],:] #返回获得的两个列表 return mat0,mat1 #创建树函数 #@dataSet:数据集 #@leafType:生成叶节点的类型 1 回归树:叶节点为常数值 2 模型树:叶节点为线性模型 #@errType:计算误差的类型 1 回归错误类型:总方差=均方差*样本数 # 2 模型错误类型:预测误差(y-yHat)平方的累加和 #@ops:用户指定的参数,包含tolS:容忍误差的降低程度 tolN:切分的最少样本数 def createTree(dataSet,leafType=regLeaf,errType=regErr,ops=(1,4)): #选取最佳分割特征和特征值 feat,val=chooseBestSplit(dataSet,leafType,errType,ops) #如果特征为none,直接返回叶节点值 if feat == None:return val #树的类型是字典类型 retTree={} #树字典的一个元素是切分的最佳特征 retTree['spInd']=feat #第二个元素是最佳特征对应的最佳切分特征值 retTree['spval']=val #根据特征索引及特征值对数据集进行二元拆分,并返回拆分的两个数据子集 lSet,rSet=binSplitDataSet(dataSet,feat,val) #第三个元素是树的左分支,通过lSet子集递归生成左子树 retTree['left']=createTree(lSet,leafType,errType,ops) #第四个元素是树的右分支,通过rSet子集递归生成右子树 retTree['right']=createTree(rSet,leafType,errType,ops) #返回生成的数字典 return retTree
上面的第一个函数是我们熟知的文本文件的数据解析函数,该函数读取一个以tab键位分隔符的文件,然后通过map(float,curLine)方法将每行内容保存为一组浮点数;
第二个函数是数据切分函数,通过数组过滤的方法将数据集切分得到两个子集并返回;
最后一个函数为树构建函数,是一个递归函数,只要数据集满足切分条件,就会将数据集采取递归的形式继续切分下去。它有四个参数:数据集和其他3个可选参数。三个可选函数决定了树的类型:leafType给出建立叶节点的函树;errType()代表误差计算函数;而ops是一个包含树构建所需参数的元组。具体地,函数首先将数据集分为两个部分,通过chooseBestSplit()函数找出切分的最佳特征和特征值,如果满足停止条件,则直接返回某类模型的值,当构建的是回归树时,叶节点值为一个常数,而构建模型树时,叶节点模型则是一个线性方程。
3 CART算法用于回归
前面我们已经有了树的创建算法,也知道了树构建算法中除了数据集,还需要另外三个可选参数,即叶节点生成类型,计算误差方法,以及切分终止需要的参数元组。下面,分别一一解释:
(1)leafType:叶节点的生成类型有两种,一种是对应于回归树的类型,即叶节点包含的是一个常数值;另一种对应模型树类型,即叶节点包含一个线性方程
(2)errType:误差计算类型,该参数应用于最佳切分特征及特征值得选取中,通过选择误差最小的切分特征及特征值,来对数据集执行二元切分。这里,回归树和模型树在计算误差的方式上有所不同。回归树计算误差的方法是,首先计算数据集目标变量值的均方差,再乘以数据集的样本数;而模型树的误差计算方法为:首先计算数据集各个样本目标变量的真实值与预测值得差值的平方,然后再进行累加,即类似于前面的平方损失函数
(3)终止条件参数元组ops:两个参数,tolS,即容忍误差的下降值;tolN,最少的切分样本数。这两个参数决定了树构建的终止条件,相当于在树构建的过程中,边构建边剪枝,经常应用于预剪枝
显然,这里我们要构建的是回归树,所以需要选择合适的叶节点生成类型,误差计算方法。此外,还需要给出最佳切分特征及最佳切分特征值的函数,这样,我们才能完整的构建回归树
选取最佳特征及特征值函数的伪代码如下:
对每个特征: 对每个特征值: 将数据集切分为两份 计算切分误差 如果当前误差小于当前最小误差,那么将当前切分设定为最佳切分并更新最小误差 返回最佳切分的特征和阈值
具体代码为:
#回归树的切分函数 #叶节点生成函数 def regLeaf(dataSet): #数据集列表最后一列特征值的均值作为叶节点返回 return mean(dataSet[:,-1]) #误差计算函数 def regErr(dataSet): #计算数据集最后一列特征值的均方差*数据集样本数,得到总方差返回 return var(dataSet[:,-1])*shape(dataSet)[0] #选择最佳切分特征和最佳特征取值函数 #@dataSet:数据集 #@leafType:生成叶节点的类型,默认为回归树类型 #@errType:计算误差的类型,默认为总方差类型 #@ops:用户指定的参数,默认tolS=1.0,tolN=4 def chooseBestSplit(dataSet,leafType=regLeaf,errType=regErr,ops=(1,4)): #容忍误差下降值1,最少切分样本数4 tolS=ops[0];tolN=ops[1] #数据集最后一列所有的值都相同 if len(set(dataSet[:,-1].T.tolist()[0])==1): #最优特征返回none,将该数据集最后一列计算均值作为叶节点值返回 return none,leafType(dataSet)) #数据集的行与列 m,n=shape(dataSet) #计算未切分前数据集的误差 S=errType(dataSet) #初始化最小误差;最佳切分特征索引;最佳切分特征值 bestS=inf;bestIndex=0;bestValue=0 #遍历数据集所有的特征,除最后一列目标变量值 for featIndex in range(n-1): #遍历该特征的每一个可能取值 for splitVal in set(dataSet[:,featIndex]): #以该特征,特征值作为参数对数据集进行切分为左右子集 mat0,mat1=binSplitDataSet(dataSet,featIndex,splitVal) #如何左分支子集样本数小于tolN或者右分支子集样本数小于tolN,跳出本次循环 if (shape(mat0)[0]<tolN) or (shape(mat1)[0]<tolN):continue #计算切分后的误差,即均方差和 newS=errType(mat0)+errType(mat1) #保留最小误差及对应的特征及特征值 if newS<bestS: bestIndex=featIndex bestValue=splitVal bestS=newS #如果切分后比切分前误差下降值未达到tolS if (S-bestS)<tolS: #不需切分,直接返回目标变量均值作为叶节点 return None,leafType(dataSet) #检查最佳特征及特征值是否满足不切分条件 mat0,mat1=binSplitDataSet(dataSet,bestIndex,bestValue) if(shape(mat0)[0]<tolN) or (shape(mat1)[0]<tolN): return None,leafType(dataSet) #返回最佳切分特征及最佳切分特征取值 return bestIndex,bestValue
从上述代码中,我们不难看出,在选取最佳切分特征和特征值过程中,有三种情况不会对数据集进行切分,而是直接创建叶节点。
(1)如果数据集切分之前,该数据集样本所有的目标变量值相同,那么不需要切分数据集,而直接将目标变量值作为叶节点返回
(2)当切分数据集后,误差的减小程度不够大(小于tolS),就不需要切分,而是直接求取数据集目标变量的均值作为叶节点值返回
(3)当数据集切分后如果某个子集的样本个数小于tolN,也不需要切分,而直接生成叶节点
有了回归树的构建代码,下面就来利用实际的数据集来测试一下回归树的效果,先看一下简单的数据集构建的回归树:
再看一下稍微复杂一点的数据集构建的回归树:
4 树剪枝
上面我们利用回归树构建算法构建了回归树,虽然看起来效果不错,但是我们还要需要某种有效的措施来检查构建过程是否得当。这就是树剪枝技术,它通过对决策树剪枝的方式来达到更好的预测效果
一棵树如果节点过多,表明该模型可能对数据进行了过拟合,一旦发生了过拟合,就表明该模型对训练数据拟合效果非常好,而对其他的测试数据拟合效果很差的情况。所以,为避免过拟合,我们需要通过剪枝的方式来降低模型的复杂度。
剪枝包括预剪枝和后剪枝两种方法,而我们在之前的树构建过程中,实际上就采用了预剪枝的方法,即通过设置合理的切分终止条件tolS,tolN,在树构建的过程中进行剪枝过程,从而防止过拟合。而后剪枝过程,需要训练集合测试集两个数据集,首先利用训练集来产生复杂度较大的回归树模型,然后利用测试集则来对决策树进行剪枝,从而降低决策树复杂度。
(1)预剪枝
预剪枝的方法,即通过设置合理的切分终止条件tolS,tolN,在树构建的过程中进行剪枝过程,从而防止过拟合。在预剪枝过程中,我们需要不断的修改停止条件tolS,tolN来得到较好的结果,这显然不能算作是一种好的办法,因为,寻找到合适的停止条件意味着需要更多的时间损耗。而后剪枝则不需要用户指定参数,是一种比较理想的剪枝方法
(2)后剪枝
后剪枝的方法意味着我们需要两个不同的数据集,一个作为训练集,另外一个作为测试集。训练集构建出来的树需要足够大,足够复杂,这样才能便于后面的剪枝。接下来,从上而下找到叶节点,用测试集判断将这些叶节点合并是否能降低测试误差。如果是那就将这些叶节点合并。
函数后剪枝的伪代码如下:
基于已有的树切分测试数据: 如果存在任一子集是一棵树,则在该子集上递归剪枝过程 计算将当前两个叶节点合并后的误差 计算不合并的误差 如果合并会降低误差,就将两个叶节点合并
再来看实际的代码:
上面代码的第一个函数,用于判断当前树是否为叶节点。因为,树的存储结构是字典类型,所以对该树的类型进行检测,如果是字典类型那么就是非叶节点的树,返回true,否则是叶节点返回false
第二个函数是求两个相邻叶节点的均值函数,函数采用递归的方法,从根节点开始从上到下,找到叶节点,如果某棵树的左分支和右分支都是叶节点,那么就将这两个叶节点计算均值返回
第三个函数就是具体的后剪枝过程,还是采用递归的方式,从上到下,找到叶节点,当找到两个相邻的叶节点,那么将其合并并计算误差,如果合并后的误差小于不合并的误差,那么就将这两个叶节点合并成一个,返回节点值为两个叶节点值均值。否则,就不合并,直接返回树
我们能看到,很多的叶节点发生了合并,但没有想预期的那样剪枝成两部分,所以预剪枝在构建树的效果上要比后剪枝好。而后剪枝又比预剪枝更容易实现。所以在实际构建树过程中可以采用二者结合的方法寻求最佳模型
5 模型树构建
上面的构建的回归树,是将叶节点设定为常数值来进行建模。还有一种方法是把叶节点设为分段线性函数,这里的分段线性是指模型由多个线性片段组成。因为,数据集不是线性的,那么我们很难通过全局线性函数来拟合数据,所以采用将数据集分段,分段后的数据都满足线性要求,这样就分别构建出相应的线性方程,再将分段线性模型组合起来就是全局的模型。比如,下图分段线性数据,显然使用两条直线组合来拟合会比一条直线拟合效果要好。
模型树的构建,与回归树的构建,除了叶节点的类型不同外。选取最佳切分特征及特征值中计算误差的方法也存在差别,对于给定的数据集,先用线性模型来对其进行拟合,然后计算真实目标值和模型预测值得差值。最后将这些差值的平方求和就得到了所需的误差。
模型树叶节点生成函数
#模型树叶节点生成函数 def linearSolve(dataSet): #获取数据行与列数 m,n=shape(dataSet) #构建大小为(m,n)和(m,1)的矩阵 X=mat(ones((m,n)));Y=mat(ones((m,1))) #数据集矩阵的第一列初始化为1,偏置项;每个样本目标变量值存入Y X[:,1:n]=dataSet[:,0:n-1];Y=dataSet[:,-1] #对数据集矩阵求内积 xTx=X.T*X #计算行列式值是否为0,即判断是否可逆 if linalg.det(xTx)==0.0: #不可逆,打印信息 print('This matrix is singular,cannot do inverse,\n\ try increasing the second value if ops') #可逆,计算回归系数 ws=(xTx).I*(X.T*Y) #返回回顾系数;数据集矩阵;目标变量值矩阵 return ws,X,Y #模型树的叶节点模型 def modelLeaf(dataSet): #调用线性回归函数生成叶节点模型 ws,X,Y=linearSolve(dataSet) #返回该叶节点线性方程的回顾系数 return ws #模型树的误差计算函数 def modelErr(dataSet): #构建模型树叶节点的线性方程,返回参数 ws,X,Y=linearSolve(dataSet) #利用线性方程对数据集进行预测 yHat=X*ws #返回误差的平方和,平方损失 return sum(power(y-yHat,2))
上面的叶节点生成函数,就是之前用到的简单线性回归方法;模型树的叶节点返回的是线性方程的回归系数;
6 树回归和标准回归的比较
模型树,回归树和前面的线性回归方法构建的模型,具体哪一种更好呢?这就需要我们采用合适的度量标准,来客观比较各个模型构建方法的优劣。显然,前面用到的相关系数计算方法是一个不错的选择,模型拟合的结果和实际的结果相关系数越接近1.0,显然拟合效果就越好
下面看一下,用树回归进行预测的代码:
#用树回归进行预测代码 #回归树的叶节点为float型常量 def regTreeEval(model,inDat): return float(model) #模型树的叶节点浮点型参数的线性方程 def modelTreeEval(model,inDat): #获取输入数据的列数 n=shape(inDat)[1] #构建n+1维的单列矩阵 X=mat(ones((1,n+1))) #第一列设置为1,线性方程偏置项b X[:,1:n+1]=inDat #返回浮点型的回归系数向量 return float(X*model) #树预测 #@tree;树回归模型 #@inData:输入数据 #@modelEval:叶节点生成类型,需指定,默认回归树类型 def treeForeCast(tree,inData,modelEval=regTreeEval): #如果当前树为叶节点,生成叶节点 if not isTree(tree):return modelEval(tree,inData) #非叶节点,对该子树对应的切分点对输入数据进行切分 if inData[tree['spInd']]>tree['spval']: #该树的左分支为非叶节点类型 if isTree(tree['left']): #递归调用treeForeCast函数继续树预测过程,直至找到叶节点 return treeForeCast(tree['left'],inData,modelEval) #左分支为叶节点,生成叶节点 else: return modelEval(tree['left'],inData) #小于切分点值的右分支 else: #非叶节点类型 if isTree(tree['right']): #继续递归treeForeCast函数寻找叶节点 return treeForeCast(tree['right'],inData,modelEval) #叶节点,生成叶节点类型 else: return modelEval(tree['right'],inData) #创建预测树 def createForeCast(tree,testData,modelEval=regTreeEval): #测试集样本数 m=len(testData) #初始化行向量各维度值为1 yHat=mat(zeros((m,1))) #遍历每个样本 for i in range(m): #利用树预测函数对测试集进行树构建过程,并计算模型预测值 yHat[i,0]=treeForeCast(tree,mat(testData[i]),modelEval) #返回预测值 return yHat
上面代码中回归树和模型树计算模型预测值的方法有所不同,对于树的一个叶节点,回归树得到的预测值是一个float型的常数值,而模型树的叶节点返回的是线性方程的拟合输出值。
接下来利用实际的数据分别利用线性回归,回归树,模型树来拟合数据模型,然后分别计算模型预测目标值和目标真实值得相关系数;然后进行比较系数的大小
回归树:
模型树:
显然,从上面的相关系数可以看出,模型树的预测效果要比回归树的预测效果更好。实施上,我们利用线性回归方法对数据进行拟合的模型的相关系数为:
那么我们可以得到以下结论,当数据集比较复杂时,树回归方法要比简单线性回归模型更加有效,并且在树回归中,模型树要比回归树的效果更好。
三,使用tikinfer创建GUI
python中有很多GUI框架,而tkinder是其中易于使用的一个。tkinder的GUI有一些小部件(Widget)组成。包括文本框(Test Box),文本输入框(Entry)按钮(Button),标签(Label)没和复选按钮(Check Button)等对象。此外,当对象调用grid()方法时,就等于把该对象告诉布局管理器,grid()方法将小部件安排在一个二维的表格中,用户可以设定每个小部件所在的行列位置。
下面就先来看一下用于构建树管理器界面的tkinder小部件
from numpy import * #3.0之前Tkinder,3.0之后(包括3.0)tkinder from tkinter import * import regTrees def reDraw(tolS,tolN): pass def drawNewTree(): pass #标签部件 Label(root,test="Plot Place Holder").grid(row=0,columnspan=3) Label(root,text="tolN").grid(row=1,column=0) #文本输入框部件 tolNentry=Entry(root) tolNentry.grid(row=1,column=1) tolNentry.insert(0,'10') Label(root,text="tols").grid(row=2,column=0) tolSentry=Entry(root) tolSentry.grid(row=2,column=1) tolSentry.insert(0,'1.0') #按钮部件 Button(root,text="ReDraw",command=drawNewTree).grid(row=1,\ column=2,rowspan=3) #复选按钮部件 chkBtnVal=IntVal() chkBtn=checkbutton(root,text="Model Tree",variable=chkBtnVal) chkBtn.grid(row=3,column=0,columnspan=2) reDraw.rawDat=mat(regTrees.loadDataSet('sine.txt')) reDraw.testDat=arrange(min(reDraw.rawDat[:,0]),\ max(reDraw.rawDat[:,0],0.01)) reDraw(1.0,10) root.mainloop()
接下来,就结合matplotlib和tkinder来将图像直接放在GUI上,即通过修改Matplotlib后端,达到在tkinder的GUI上绘图的目的。
先用画布来替换绘制占位符,删除对应标签并添加以下代码:
reDraw.f=Figure(figsize=(5,4),dpi=100) reDraw.canvas=FigureCanvasTkAgg(reDraw.f,master=root) reDraw.canvas.show() reDraw.canvas.get_tk_widget().grid(row=0,columnspan=3)
再将matplotlib和tkinder代码集成
#导入matplotlib工具 import matplotlib #将matplotlib后端设置为TkAgg matplotlib.use('TkAgg') from matplotlib.backends.backend_tkagg import FigureCanvasTkAgg from matplotlib.figure import Figure #以用户输入的终止条件为参数绘图 def reDraw(tolS,tolN): reDraw.f.clf() reDraw.a=reDraw.f.add_subplot(lll) if chkBtnVal.get(): myTree=regTrees.createTree(reDraw.rawDat,regTrees.modelLeaf,\ regTrees.modelEval,(tolS,tolN)) yHat=regTrees.createForeCast(reDraw.rawDat,ops=(tolS,tolN)) else: myTree=regTrees.createTree(reDraw.rawDat,ops=(tolS,tolN)) yHat=regTrees.createForeCast(myTree,ops=(tolS,tolN)) #绘制真实值 reDraw.a.scatter(reDraw,rawDat[:,0],reDraw.rawDat[:,1],s=5) #绘制预测值 reDraw.a.plot(reDraw.testDat,yHat,linewidth=2.0) reDraw.canvas.show() #从文本输入框中获取树创建终止条件,没有则用默认值 def getInputs(): try:tolN=int(tolNentry.get()) except: tolN=10 print('enter Integer for tolN') tolNentry.delete(0,END) tolNentry.insert(0,'10') try:tolS=int(tolSentry.get()) except: tolS=1.0 print('enter Integer for tolS') tolSentry.delete(0,END) tolSentry.insert(0,'1.0') return tolS,tolN def drawNewTree(): tolN,tolS=getInputs() reDraw(tolS,tolN)
上面代码中,真实值采用scatter()方法绘制,预测值采用plot()方法绘制,因为scatter()方法构建的是离散型散点图,而plot()方法则构建连续曲线。下面开看一下实际的效果:
四,小结
在实际生活中,数据集经常会包含一些复杂的相互关系,输入数据与目标变量之间呈现出非线性关系。对这些复杂数据建模,我们可以采用树回归的方法来对数据进行分段线性预测,分段线性函数包括分段常数(回归树模型)和分段直线方程(模型树);二者的区别在于构建树的叶节点模型上,若叶节点采用分段常数则是回归树,若使用的模型是线性回归方程则称为模型树。
上面用到的树构建方法,是基于CART算法的二元切分法,通过计算最小误差的方法得到切分该数据集的最佳特征以及最佳的特征取值,从而来对数据集进行二元划分。
很显然,CART算法是一种贪心算法,更多的是关心最佳切分特征及特征值的选取上,而不关注全局模型的最优解。所以,大多时候,该算法构建的树会产生过拟合情况,即对训练数据拟合很好,而对测试数据拟合效果较差
预剪枝和后剪枝是两种对决策树剪枝从而降低模型复杂度的方法,前者在树构建的过程中边构建边剪枝,后者则是先利用训练集构建出树,在对构建的树利用测试集进行剪枝处理;从效果来看,预剪枝要比后剪枝要好;但是,预剪枝需要用户提供合理的输入,对用户输入的参数较为敏感,从这方面来看,后剪枝因为不需要用户的参与而更加理想。在实际的构建树过程中,往往采用两种方法结合的方法对于寻求最佳模型效果较好
tkinder是python的一个GUI工具包。利用tkinder我们可以轻松的绘制各种部件并灵活的安排他们的位置。此外,可以通过设置matplotlib后端为TkAgg的方法,来集成matplotlib和tkinder,从而在tkinder中显示matplotlib绘出的图。