树回归算法个人总结

当数据拥有众多特征并且特征之间关系十分复杂的时候,构建全局模型就十分困难,也略显笨拙。而且,实际生活中有很多的问题都是非线性的,不可能使用全局线性模型来拟合任何数据。
一种解决办法就是将数据集切分成很多份易于建模的数据,然后利用线性回归技术来建模。如果首次切分后仍难以拟合线性模型就继续切分。在这种切分下,树结构和回归法就相当有用。

复杂数据的局部性建模

分类回归树(CART)是一种树构建方法。该方法既可以用于分类还可以用于回归。它是非常著名且广泛记载的树构建算法,它使用二元切分来处理连续性遍历。对CART稍作修改就可以处理回归问题。之前用到的决策树使用香农熵来度量集合的无组织程度。在这里选用其它的方法来代替香农熵,就可以使用树结构算法来完成回归。
下面将用代码来实现CART算法和回归树。回归树与分类树的思路类似,但叶子节点的数据类型不是离散型,而是连续型。

连续和离散型特征的树的构建

在树的构建过程中,使用字典来存储树的数据结构,该字典包含以下元素:

  1. 待切分的特征
  2. 该特征的特征值
  3. 左子树。当不需要切分的时候,该子树可以是一个值
  4. 右子树。与左子树类似

由于CART算法只做二次切分,所以这里可以固定树的数据结构。树包含左右两个分支,他们可以存储另一棵子树,也可以存储单个数值。同时字典中包含的特征和特征值,可以利用它们来切分数据。

回归树的构建

构建算法之前首先要将数据引入到python中,数据的格式如下:
树回归算法个人总结_第1张图片
创建数据集合的详细代码如下:

def create_data(fname):
    datam=[]
    f=open(fname)
    for line in f.readlines():
        curline=line.strip().split('\t')
        fltline=list(map(float,curline))    #首先将每行的数据使用map映射成浮点型,在将其转换成list类型
        datam.append(fltline)
    return datam

获取完数据后我们需要将数据进行切分。由于树整体上是二叉树的形式,对于待切分的特征和特征值,把整体数据集中特征值大于该值的放到右子树上,把小于该值的放到左子树上。具体的代码实现如下:

def binSplit(datas,feature,value):
    mat0 = datas[np.nonzero(datas[:, feature] <= value)[0], :]
    mat1 = datas[np.nonzero(datas[:, feature] > value)[0], :]
    return mat0,mat1

以mat0的的获取为例,首先通过datas[:, feature] <= value(记作e1)来获取到当前数据集特征fetrue中小于value的子集,函数返回值为布尔类型,符合不等式的为True,不符合的为False。然后使用numpy库中的nonzero方法,该方法用于将布尔型转换成索引值,它将返回所有True所在的索引。由于e1的返回值是一个列向量,即为m*1维矩阵,nonzero的返回值有两个,分别是按行返回和按列返回,由于按列返回结果全为0(所有符合要求的数据都在第一列上),所以我们只要第一个返回值即可。最后再利用数组过滤的方式获取满足不等式的所有子集。
得到划分函数后,接下来就是确立最佳划分,划分函数的伪代码如下:

对每个特征:
	对每个特征值:
		将数据集划分成两类
		计算划分误差
		如果当前误差小于当前最小误差,那么将当前切分设定为最佳切分并更新最小误差
返回最佳切分的特征和阈值

具体代码如下:

def regLeaf(datas):     #处理叶子节点
    return np.mean(datas[:,-1])     #返回当前数据集目标值的平均值

def regErr(datas):      #处理误差
    return np.var(datas[:,-1])*datas.shape[0]   #返回目标值的平方误差总方差

def chooseBeatSplit(dataset,leafType=regLeaf,errType=regErr,ops=(1,4)):
    tols=ops[0];toln=ops[1]     #tols为容许误差的最小值,toln为切分的最小样本数
    if len(set(dataset[:,-1].T.tolist()[0]))==1:    #数据集最后一行为目标值,通过set去重,若去重后数组长度为1,则表明当前数据集为一类数据,当作叶节点处理
        return None,leafType(dataset)
    m,n=dataset.shape
    S=errType(dataset)      #获取当前数据集的误差
    bestS=np.inf;bestIndex=0;bestValue=0    #初始化最小误差,划分最优特征和特征值
    for featIndex in range(n-1):        #遍历所有特征
        for splitVal in set(dataset[:,featIndex].T.tolist()[0]):    #遍历该特征下的每一个特征值
            mat0,mat1=binSplit(dataset,featIndex,splitVal)          #按照该特征对应的特征值划分数据
            if (mat0.shape[0]<toln) or (mat1.shape[0]<toln):continue    #如果划分后的数据个数小于最小样本数,则跳过该循环
            newS=errType(mat1)+errType(mat0)            #计算划分数据后新的误差值
            if newS<bestS:          #当新的误差值小于最小误差时更新相关变量
                bestS=newS
                bestIndex=featIndex
                bestValue=splitVal
    #经过两次循环已经找到了最优特征和对应的特征值
    if (S-bestS)<tols:      #如果当前误差变化的不是很大,则不划分,将整个数据集当作叶子节点处理
        return None,leafType(dataset)
    mat0,mat1=binSplit(dataset,bestIndex,bestValue)     #获得划分后的两个子集
    if (mat1.shape[0]<toln) or (mat1.shape[0]<toln):    #如果划分后的子集个数很小则不划分,将整个数据集当作叶子节点处理
        return None,leafType(dataset)
    return bestIndex,bestValue          #若不存在上述情况,返回最优特征和对应的特征值

在确定了最佳划分后,最后一步就是构建这棵树,伪代码大体如下:

找到最佳的待切分特征:
	如果该节点不能再分,将该节点存为叶节点
	执行二元切分
	在左子树调用该方法
	在右子树调用该方法

下面是具体的代码实现:

def createTree(dataset,leafType=regLeaf,errType=regErr,ops=(1,4)):
    feat,val=chooseBeatSplit(dataset,leafType,errType,ops)
    if feat==None:  #表明当前是叶子节点,只需要返回数值
        return val
    retTree={
     }      #创建树
    retTree['spInd']=feat
    retTree['spVal']=val
    lSet,rSet=binSplit(dataset,feat,val)    #划分子集
    retTree['left']=createTree(lSet,leafType,errType,ops)   
    retTree['right']=createTree(rSet,leafType,errType,ops)  #在左右子树上递归调用原函数
    return retTree  #返回创建好的树

使用上面的数据集执行代码。
整个数据集的分布如图所示:
树回归算法个人总结_第2张图片
执行完代码后的返回值就是所建成的树,结构如下:

{
     
    'spInd': 0,
    'spVal': 0.48813,
    'left': -0.04465028571428572,
    'right': 1.0180967672413792
}

可以看出数据集按第一个特征划分,阈值是0.48813,小于它的进入左子树中,值为-0.04,大于它的进入到右子树中,值为1.018。整棵树共有两个叶子节点,所以将数据整体分成两类,与图像相符合。
下面引入新的一组数据,它的分布如下:
树回归算法个人总结_第3张图片
对于这个数据再次执行函数返回值如下:

{
     
    'spInd': 1,
    'spVal': 0.39435,
    'left': {
     
        'spInd': 1,
        'spVal': 0.197834,
        'left': -0.023838155555555553,
        'right': 1.0289583666666666
    },
    'right': {
     
        'spInd': 1,
        'spVal': 0.582002,
        'left': 1.980035071428571,
        'right': {
     
            'spInd': 1,
            'spVal': 0.797583,
            'left': 2.9836209534883724,
            'right': 3.9871632
        }
    }
}

可以看出该树共有5个叶子节点,即将数据集分出5分,同样与图像相符。
到现在为止,已经完成了回归树的构建,下面将通过某种措施检查构建过程是否得当。

树剪枝

一棵树如果节点过多,表明该模型可能对数据产生了过拟合。对于决策树来说,通过降低决策树的复杂度来避免过拟合的过程称为剪枝。其实在选择最佳切分的时候就已经使用过剪枝处理,函数参数中的ops一个是误差最小值,一个是切分最少样本数,这两个参数可以防止数据切分的过细,这种方法称为预剪枝。另一种形式的剪枝需要使用测试集和训练集,成为后剪枝。

预剪枝

上面两组数据看起来划分的效果不错,但背后仍有问题。树构建算法其实对输入的ops这两个参数十分敏感,如果使用到其他数据就不太容易达到这么好的结果。以我们第一组数据为例,在扩大100倍的基础上建立树。

Tree=regTree.createTree(datam)

可以看出树的结构十分庞大

{
     'spInd': 0, 'spVal': 0.499171, 'left': {
     'spInd': 0, 'spVal': 0.457563, 
......
0.560301, 'left': 82.903945, }}}}}

它几乎为每一个节点都创建了分支。当然,我们可以不断修改ops参数来达到较好的结果,但这样做不仅费时费力,而且在实际应用中,不仅仅是二维数据,我们可能完全不知道什么样的结果较好。所以接下来我们采用后剪枝的处理办法,即用测试集来对树进行剪枝。由于不需要人为对参数进行调试, 所有后剪枝是一个更好的处理办法。

后剪枝

使用后剪枝方法需要将数据分成测试集和训练集。首先使得构建出来的树足够大,便于剪枝。接下来从上而下找到叶节点,用测试集来判断这些叶节点合并后能否降低误差,如果可以的话则进行合并。函数伪代码如下:

基于已有的数切分测试数据
	如果存在任一子集是一棵树,则在该子集上递归剪枝过程
	计算当前两个叶子点合并后的误差
	计算不合并的误差
	如果合并会降低误差,将叶节点合并

详细代码如下:

def istree(tree):       #判断该节点是否为一棵树
    return (type(tree).__name__=='dict')

def getMean(tree):      #将该子树的所有子树都删除,返回左右节点的平均值
    if istree(tree['left']) :tree['left']=getMean(tree['left'])
    if istree(tree['right']) :tree['right']=getMean(tree['right'])
    return (tree['left']+tree['right'])/2.0

def prune(tree,dataset):
    if dataset.shape[0]==0: return getMean(tree)    #没有测试数据,对该树塌陷处理
    if (istree(tree['left'])) or (istree(tree['right'])):   #存在子树,则按最佳切分切分测试数据
        ls,rs=binSplit(dataset,tree['spInd'],tree['spVal'])
    if istree(tree['left']): tree['left']=prune(tree['left'],ls)    #左子树是树,在左子树上进行剪枝
    if istree(tree['right']): tree['right'] = prune(tree['right'], rs)#同上
    if not(istree(tree['left'])) and not(istree(tree['right'])):    #该节点左右孩子都是叶子节点
        ls, rs = binSplit(dataset, tree['spInd'], tree['spVal'])    #切分测试集
        errorNomerge=sum(np.power(ls[:,-1]-tree['left'],2))+sum(np.power(rs[:,-1]-tree['right'],2)) #计算不合并的误差
        threeMean=(tree['left']+tree['right'])/2.0
        errorMerge=sum(np.power(dataset[:,-1]-threeMean,2))     #计算合并后的误差
        if errorMerge<errorNomerge:     #如果误差降低
            print('merging')
            return threeMean        #合并叶子节点
        else:return tree        #返回原树
    else:
        return tree         #返回原树

以上面扩大100的数据为例,生成的树在经过剪枝后的结果如下:
树回归算法个人总结_第4张图片
可以看到有大量叶子节点被合并,但是还没有像预期那样被剪成两部分,这说明后剪枝效果可能不如预剪枝效果好。一般情况下,为了寻找最优解,同时使用这两种剪枝办法。

模型树

用树来对数据建模,除了把叶节点简单的设定为常数值之外,还有一种方法是把叶节点设定为分段线性函数,这里所谓的分段线性是指模型由多个线性片段组成。如对下面的数据来说。
树回归算法个人总结_第5张图片
分成两条直线肯定要比很多节点组成的树要方便理解。模型树的可解释性是它优于回归树的特点之一,此外,模型树也具有更高的预测准确度。
前面建立回归树的代码稍加修改就可以用于模型树。模型树和回归树主要的区别就在于叶子节点的处理办法和误差计算方法。下面是两个修改过的两个函数:

def linearSolve(dataset):   #线性模型构建
    m,n=dataset.shape
    x=np.mat(np.ones((m,n)))    #因为要考虑常数列,所以x矩阵要多一列
    y=np.mat(np.ones((m,1)))
    x[:,1:n]=dataset[:,0:n-1]   #第一列用常数列1
    y=dataset[:,-1]
    xtx=x.T*x
    if np.linalg.det(xtx)==0:
        raise NameError('error')
    ws=xtx.I*x.T*y              #构建回归系数
    return ws,x,y       #返回相关变量

def modelLeaf(dataset):     #叶子节点处理办法
    ws,x,y=linearSolve(dataset) #得到回归系数
    print(ws)
    return ws   #返回回归系数

def modelErr(dataset):      #处理误差方法
    ws, x, y = linearSolve(dataset)
    yh=x*ws         #计算预测值
    return sum(np.power(yh-y,2))    #计算总误差

只需要修改在建树函数中的参数,就可以把回归树修改成模型树。

Tree=regTree.createTree(datam,regTree.modelLeaf,regTree.modelErr,(1,10))

得到的返回值如下:

{
     
    'spInd': 0,
    'spVal': 0.285477,
    'left': matrix([[3.46877936], [1.18521743]]),
    'right': matrix([[1.69855694e-03], [1.19647739e + 01]])
}

树以0.28为界创建两个子树,每个子树都是一个线性模型,左子树是3.46+1.18x,右子树为0.001+11.96x。通过绘图可以看出与数据集十分接近,拟合程度较高。
树回归算法个人总结_第6张图片

树回归预测和比较

如何判断模型树和回归树哪一个比较好,一个比较客观的办法是计算相关系数,也称为 R 2 R^{2} R2值。该系数可以通过Numpy库中的corrcoef方法来求解,结果越接近1表明结果越好。
在创建好树之后,下一步的任务就是来预测新的数据,具体的代码如下:

def regTreeEval(model,indat):   #回归树预测
    return float(model)

def modelTreeEval(model,indat): #模型树预测
    n=indat.shape[1]
    x=np.mat(np.ones((1,n+1)))
    x[:,1:n+1]=indat
    return float(x*model)

def treeForceCast(tree,indata,modelEval=regTreeEval):   #预测函数
    if istree(tree):    #当前节点为树
        if indata[tree['spInd']]>tree['spVal']:     #当前特征值大于切分特征值,往右子树中递归查找
            return treeForceCast(tree['right'],indata,modelEval)
        else:
            return treeForceCast(tree['left'],indata,modelEval)     #反之往左子树递归查找
    else:
        return modelEval(tree,indata)   #到达叶子节点后调用函数返回预测值

def createForeCast(tree,testData,modelEval=regTreeEval):        #对整个数据集循环调用函数预测,返回向量形式的预测值
    m=len(testData)
    yh=np.mat(np.zeros((m,1)))
    for i in range(m):
        yh[i,0]=treeForceCast(tree,np.mat(testData[i]),modelEval)
    return yh

下面引入一组新的数据分别使用回归树和模型树依次预测,并比较预测结果好坏。
数据集分布如下:
树回归算法个人总结_第7张图片
首先导入训练集和测试集

trainMat=np.mat(regTree.create_data('bikeSpeedVsIq_train.txt'))
testMat=np.mat(regTree.create_data('bikeSpeedVsIq_test.txt'))

先生成回归树并比较 R 2 R^{2} R2

Tree1=regTree.createTree(trainMat,ops=(1,20))
yh=regTree.createForeCast(Tree1,testMat[:,0])
x=np.corrcoef(yh,testMat[:,1],rowvar=0)[0,1]
print(x)

结果是0.9640852318222141

之后再生成模型树比较 R 2 R^{2} R2

Tree1=regTree.createTree(trainMat,ops=(1,20),errType=regTree.modelErr,leafType=regTree.modelLeaf)
yh=regTree.createForeCast(Tree1,testMat[:,0],modelEval=regTree.modelTreeEval)
x=np.corrcoef(yh,testMat[:,1],rowvar=0)[0,1]
print(x)

结果是0.9760412191380615
R 2 R^{2} R2上来看模型树的预测结果比回归树要好。

你可能感兴趣的:(python,机器学习)