Loading

8-树回归

🌴 树回归


上一章介绍的线性回归方法创建模型需要拟合所有的样本点(局部加权线性回归除外)。当数据拥有众多特征并且特征之间关系十分复杂的时候,构建全局模型显然十分困难。而且,实际问题很多都是非线性的,不可能使用全局线性模型来拟合任何数据。

一种可行的方法就是将数据集切分成很多份容易建模的数据,然后利用上一章线性回归的技术来建模。如果首次切分后仍然难以拟合线性模型,就继续切分。

1. 树构建算法比较

我们在 第2章 决策树中使用的树构建算法是 ID3ID3 的做法是每次选取当前最佳的特征来分割数据,并按照该特征的所有可能取值来切分。也就是说,如果一个特征有 4 种取值,那么数据将被切分成 4 份。一旦按照某特征切分后,该特征在之后的算法执行过程中将不会再起作用,🚨① 所以有观点认为这种切分方式过于迅速

除了切分过于迅速外, 🚨② ID3 算法还存在另一个问题,它不能直接处理连续型特征。只有事先将连续型特征转换成离散型,才能在 ID3 算法中使用。但这种转换过程会破坏连续型变量的内在性质。

⭐ 另外一种方法是二元切分法,即每次把数据集切分成两份。如果数据的某特征值等于切分所要求的值,那么这些数据就进入树的左子树,反之则进入树的右子树(解决 ID3 问题 1)。使用二元切分法易于对树构造过程进行调整以处理连续型特征。具体的处理方法是: 如果特征值大于给定值就走左子树,否则就走右子树(解决 ID3 问题 2)。

💡 另外,二元切分法也节省了树的构建时间,但这点意义也不是特别大,因为这些树构建一般是离线完成,时间并非需要重点关注的因素。

CART (Classification And Regression Trees, 分类回归树) ** 是十分著名且广泛记载的树构建算法,它使用二元切分来处理连续型变量。相比于ID3算法,显然CART算法更有优势,因为我们知道CART算法不仅可以用于分类,还可以用于回归**,这里的回归即是处理连续型特征的体现。

回归树与分类树的思路类似,但是叶节点的数据类型不是离散型,而是连续型

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

在树的构建过程中,需要解决多种类型数据的存储问题。与第 2 章决策树类似,这里将使用一部字典来存储树的数据结构,该字典包含以下 4 个元素:

  • 待切分的特征
  • 待切分的特征值
  • 右子树。当数据不能再切分时,也可以是叶节点
  • 左子树。当数据不能再切分时,也可以是叶节点

这与第 2 章决策树有点不同,第 2 章中用一部字典存储所有切分,可以包含两个及以上的值。而 CART 算法只做二元切分,所以这里可以固定树的数据结构。

本章我们将构建两种树:

  • 第一种是回归树 regression tree,其每个叶节点包含单个值
  • 第二种是模型树 model tree,其每个叶节点包含一个线性方程

🔺 树构建算法,函数 createTree() 伪代码大致如下(该函数是回归树和模型树的公用树构造函数):

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

Python 具体实现如下:

import numpy as np

def loadDataSet(fileName):
    dataMat = []
    fr = open(fileName)
    for line in fr.readlines():
        curLine = line.strip().split('\t')
        fltLine = list(map(float,curLine)) # 将每行的内容保存成一组浮点数
        dataMat.append(fltLine)
    return dataMat

def binSplitDataSet(dataSet,feature,value):
    """
    Desc:
        根据第feature列的value值进行切分,该函数通过数组过滤方式将数据集切分成两个子集并返回
    Args:
        dataSet: 数据集
        feature: 待切分的特征
        value: 切分该特征的阈值
    """
    # nonzero(dataSet[:, feature] > value)  返回结果为true行的index下标
    mat0 = dataSet[np.nonzero(dataSet[:,feature] > value)[0], :]
    mat1 = dataSet[np.nonzero(dataSet[:,feature] <= value)[0], :]
    return mat0, mat1

def createTree(dataSet,leafType=regLeaf, errType=regErr, ops=(1,4)):
    """
    Desc:
        树构建函数(递归函数)
    Args:
        dataSet:数据集
        leafType:建立叶节点的函数
        errType:误差计算函数
        ops:包含树构建所需其他参数的元组
    """
    feat, val = chooseBestSplit(dataSet, leafType, errType, ops) # 将数据集分成两个部分
    if feat == None:  # 终止条件
        return val 
    # 如果不满足停止条件,继续递归调用 createTree 函数
    retTree = {}
    retTree['spInd'] = feat
    retTree['spVal'] = val
    lSet, rSet = binSplitDataSet(dataSet, feat, val)
    retTree['left'] = createTree(lSet, leafType, errType, ops)
    retTree['right'] = createTree(rSet, leafType, errType, ops)
    return retTree  

createTree 中很多函数暂未实现,后面会陆续实现,我们先看前两个函数:

testMat = np.mat(np.eye(4))

这样就创建了一个简单的矩阵,现在按照指定列的某个值来切分该矩阵:

mat0,mat1=binSplitDataSet(testMat,1,0.5)

OK,下面将给出回归树的 chooseBestSplit 函数 👇

3. 回归树

回归树假设叶节点是常数值,认为数据中的复杂关系可以用树结构来概括。

🔺 误差计算准则:平方误差的总值(总方差):首先计算所有数据的均值,然后计算每条数据的值到均值的差值,为了正负差值同等对待,一般使用绝对值或平方值来代替上述差值。

① 构建树

接下来我们补充 createTree 中缺失的函数:chooseBestSplit。即给定某个误差计算方法,该函数会找到数据集上最佳的二元切分方式。另外,该函数还要明确什么时候停止切分,一旦停止切分就会生成一个叶节点。因此:chooseBestSplit 函数所需要做的两件事如下:

  • 用最佳方式切分数据集
  • 生成相应的叶节点

其中,chooseBestSplit 函数拥有的三个参数:

  • leafType:建立叶节点的函数
  • errType:误差计算函数
  • ops:包含树构建所需其他参数的元组

📄 chooseBestSplit 的伪代码如下:

对每个特征:
    对每个特征值: 
        将数据集切分成两份(小于该特征值的数据样本放在左子树,否则放在右子树)
        计算切分的误差
        如果当前误差小于当前最小误差,那么将当前切分设定为最佳切分并更新最小误差
返回最佳切分的特征和阈值

✍ Python 实现如下:

def regLeaf(dataSet): # 建立叶节点的函数
    return np.mean(dataSet[:,-1])

def regErr(dataSet): # 误差计算函数
    # np.var 均方差函数
    return np.var(dataSet[:,-1]) * np.shape(dataSet)[0] # 均方差 * 样本个数

def chooseBestSplit(dataSet, leafType=regLeaf, errType=regErr, ops=(1,4)):
    """chooseBestSplit(用最佳方式切分数据集 和 生成相应的叶节点)

    Args:
        dataSet   加载的原始数据集
        leafType  建立叶子点的函数
        errType   误差计算函数(求总方差)
        ops       [容许误差下降值,切分的最少样本数]。
    Returns:
        bestIndex 最佳切分特征的下标
        bestValue 切分的最优值
    """
    tolS = ops[0]  # 最小误差下降值,划分后的误差减小小于这个差值,就不用继续划分
    tolN = ops[1] # 切分的最小样本数,小于这个大小,就不继续划分了
    
    # 如果结果集最后一列为1个变量,就返回退出
    # .T 对数据集进行转置
    # .tolist()[0] 转化为数组并取第0列
    if len(set(dataSet[:,-1].T.tolist()[0])) == 1: # 终止条件 1
        return None, leafType(dataSet)
    
    m,n = np.shape(dataSet) # 计算行列值
    S = errType(dataSet) # 无分类误差的总方差和
    bestS = np.inf
    bestIndex = 0
    bestValue = 0
    # 循环处理每一列对应的feature值 
    for featIndex in range(n-1):   # 对于每个特征
        # [0]表示这一列的[所有行],不要[0]就是一个array[[所有行]]
        for splitVal in set(dataSet[:,featIndex].T.tolist()[0]): 
            # 对该列进行二元切分
            mat0, mat1 = binSplitDataSet(dataSet, featIndex, splitVal) 
            # 判断二元切分的方式的元素数量是否符合预期
            if (np.shape(mat0)[0] < tolN) or (np.shape(mat1)[0] < tolN): 
                continue
            # 如果二元切分,算出来的误差在可接受范围内,那么就记录切分点,并记录最小误差
            # 如果划分后误差小于 bestS,则说明找到了新的bestS
            newS = errType(mat0) + errType(mat1)
            if newS < bestS: 
                bestIndex = featIndex
                bestValue = splitVal
                bestS = newS
   # 判断二元切分的方式的元素误差是否符合预期
    if (S - bestS) < tolS:  # 终止条件 2
        return None, leafType(dataSet)
    
    mat0, mat1 = binSplitDataSet(dataSet, bestIndex, bestValue)
    
    # 对整体的成员进行判断,是否符合预期
    # 如果集合的 size 小于 tolN 
    if (np.shape(mat0)[0] < tolN) or (np.shape(mat1)[0] < tolN):   # 终止条件 3
        return None, leafType(dataSet)
    
    return bestIndex,bestValue

从上述代码中,我们不难看出,在选取最佳切分特征和特征值过程中,有三种情况不会对数据集进行切分,而是直接创建叶节点。

  • 如果数据集切分之前,该数据集样本所有的目标变量值相同,那么不需要切分数据集,而直接将目标变量值作为叶节点返回

  • 当切分数据集后,误差的减小程度不够大(小于 tolS),就不需要切分,而是直接求取数据集目标变量的均值作为叶节点值返回

  • 当数据集切分后如果某个子集的样本个数小于 tolN,也不需要切分,而直接生成叶节点

② 运行代码

首先,我们看看在简单数据集上构建的回归树。我们的数据集如下所示:

myDat = loadDataSet('ex00.txt')
myMat = np.mat(myDat)
createTree(myMat)

再看一个复杂的数据集:

myDat1 = loadDataSet('ex0.txt')
myMat1 = np.mat(myDat1)
createTree(myMat1)

4. 树剪枝

一棵树如果节点过多,表明模型可能对数据进行了 “过拟合”。

通过降低决策树的复杂度来避免出现过拟合的过程称为剪枝 pruning。在函数 chooseBestSplit() 中提前终止条件,实际上是在进行一种所谓的预剪枝(prepruning)操作。另一个形式的剪枝需要使用测试集和训练集,称作 后剪枝(postpruning)

① 预剪枝 prepruning

顾名思义,预剪枝就是及早的停止树增长,在构造决策树的同时进行剪枝。

例如我们上面的树构建算法其实对提前终止条件中的参数 tolS tolN 是非常敏感的,比如说:

createTree(myMat,ops=(0,1))

这里构建的树过于臃肿了。

我们修改该停止条件的参数:

效果显著提升。😒 显然,通过不断修改停止条件来得到合理结果并不是很好的办法。下节我们将讨论后剪枝,即利用测试集来对树进行剪枝。由于不需要用户指定参数,后剪枝是一个更理想化的方法。

② 后剪枝 postpruning

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

📑 后剪枝 prune() 的伪代码如下:

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

Python 具体实现:

# 判断节点是否是一个字典
def isTree(obj):
    """
    Desc:
        测试输入变量是否是一棵树,即是否是一个字典
    Args:
        obj -- 输入变量
    Returns:
        返回布尔类型的结果。如果 obj 是一个字典,返回true,否则返回 false
    """
    return (type(obj).__name__ == 'dict')

# 计算左右枝丫的均值
def getMean(tree):
    """
    Desc:
        从上往下遍历树直到叶节点为止,如果找到两个叶节点则计算它们的平均值。
        对 tree 进行塌陷处理,即返回树平均值。
    Args:
        tree -- 输入的树
    Returns:
        返回 tree 节点的平均值
    """
    if isTree(tree['right']):
        tree['right'] = getMean(tree['right'])
    if isTree(tree['left']):
        tree['left'] = getMean(tree['left'])
    return (tree['left']+tree['right'])/2.0
    
# 检查是否适合合并分枝
def prune(tree, testData):
    """
    Desc:
        从上而下找到叶节点,用测试数据集来判断将这些叶节点合并是否能降低测试误差
    Args:
        tree -- 待剪枝的树
        testData -- 剪枝所需要的测试数据 testData 
    Returns:
        tree -- 剪枝完成的树
    """
    # 判断是否测试数据集没有数据,如果没有,就直接返回tree本身的均值
    if np.shape(testData)[0] == 0:
        return getMean(tree)

    # 判断分枝是否是dict字典,如果是就将测试数据集进行切分
    if (isTree(tree['right']) or isTree(tree['left'])):
        lSet, rSet = binSplitDataSet(testData, tree['spInd'], tree['spVal'])
    # 如果是左边分枝是字典(子树),就传入左边的数据集和左边的分枝,进行递归
    if isTree(tree['left']):
        tree['left'] = prune(tree['left'], lSet)
    # 如果是右边分枝是字典,就传入左边的数据集和左边的分枝,进行递归
    if isTree(tree['right']):
        tree['right'] = prune(tree['right'], rSet)

    # 上面的一系列操作本质上就是将测试数据集按照训练完成的树拆分好,对应的值放到对应的节点

    # 如果左右两边同时都不是dict字典,也就是左右两边都是叶节点,而不是子树了,那么就进行合并。
    #   * 对合并前后的误差进行比较
    #   * 如果 合并的总方差 < 不合并的总方差,那么就进行合并
    # 注意返回的结果:  如果可以合并,原来的dict就变为了 数值
    if not isTree(tree['left']) and not isTree(tree['right']):
        lSet, rSet = binSplitDataSet(testData, tree['spInd'], tree['spVal'])
        # power(x, y)表示x的y次方
        errorNoMerge = np.sum(np.power(lSet[:, -1] - tree['left'], 2)) + np.sum(np.power(rSet[:, -1] - tree['right'], 2))
        treeMean = (tree['left'] + tree['right'])/2.0
        errorMerge = np.sum(np.power(testData[:, -1] - treeMean, 2))
        # 如果 合并的总方差 < 不合并的总方差,那么就进行合并
        if errorMerge < errorNoMerge:
            print ("merging")
            return treeMean
        else:
            return tree
    else:
        return tree

🏃‍ 运行代码:

myDat1 = loadDataSet('ex2.txt')
myMat1 = np.mat(myDat1)
myTree = createTree(myMat1,ops=(0,1)) # 树
myDataTest = loadDataSet('ex2test.txt') # 测试数据
myMatTest = np.mat(myDataTest)
prune(myTree,myMatTest)

可以看到,大量的节点已经被剪掉了,但是并没有像预期那样剪枝成两部分,这说明后剪枝可能不如预剪枝有效。一般的,我们同时使用这两种剪枝技术。

5. 模型树

① 构建树

🔺 用树来对数据建模,除了把叶节点简单地设定为常数值之外,还有一种方法是把叶节点设定为分段线性函数,这里所谓的 分段线性(piecewise linear) 是指模型由多个线性片段组成。

我们看一下图 9-4 中的数据,使用两条直线拟合是否比使用一组常数来建模好呢?答案显而易见。可以设计两条分别从 0. 0~ 0.3、从 0.3 ~ 1.0 的直线,于是就可以得到两个线性模型。因为数据集里的一部分数据(0.0 ~ 0.3)以某个线性模型建模,而另一部分数据(0.3 ~ 1.0)则以另一个线性模型建模,因此我们说采用了所谓的分段线性模型。

决策树相比于其他机器学习算法的优势之一在于结果更易理解。很显然,两条直线比很多节点组成一棵大树更容易解释。模型树的可解释性是它优于回归树的特点之一。另外,模型树也具有更高的预测准确度。

将之前的回归树的代码稍作修改,就可以在叶节点生成线性模型而不是常数值。下面将利用树生成算法对数据进行划分,且每份切分数据都能很容易被线性模型所表示。这个算法的关键在于误差的计算

⭐ 那么为了找到最佳切分,应该怎样计算误差呢?前面用于回归树的误差计算方法这里不能再用。稍加变化,对于给定的数据集,应该先用模型来对它进行拟合,然后计算真实的目标值与模型预测值间的差值。最后将这些差值的平方求和就得到了所需的误差。

✍ 建立叶子节点函数 和 误差计算函数:

# 得到模型的回归系数: f(x) = x0 + x1*featrue1+ x3*featrue2 ...
def modelLeaf(dataSet):
    """
    Desc:
        当数据不再需要切分的时候,生成叶节点的模型。
    Args:
        dataSet -- 输入数据集
    Returns:
        调用 linearSolve 函数,返回得到的 回归系数ws
    """
    ws, X, Y = linearSolve(dataSet)
    return ws


# 计算线性模型的误差值
def modelErr(dataSet):
    """
    Desc:
        在给定数据集上计算误差。
    Args:
        dataSet -- 输入数据集
    Returns:
        调用 linearSolve 函数,返回 yHat 和 Y 之间的平方误差。
    """
    ws, X, Y = linearSolve(dataSet)
    yHat = X * ws
    # print corrcoef(yHat, Y, rowvar=0)
    return np.sum(np.power(Y - yHat, 2))


 # helper function used in two places
def linearSolve(dataSet):
    """
    Desc:
        将数据集格式化成目标变量Y和自变量X,执行简单的线性回归,得到回归系数ws
    Args:
        dataSet -- 输入数据
    Returns:
        ws -- 执行线性回归的回归系数 
        X -- 格式化自变量X
        Y -- 格式化目标变量Y
    """
    m, n = np.shape(dataSet)
    # 产生一个关于1的矩阵
    X = np.mat(np.ones((m, n)))
    Y = np.mat(np.ones((m, 1)))
    # X的第0列为1,常数项,用于计算平衡误差
    X[:, 1: n] = dataSet[:, 0: n-1]
    Y = dataSet[:, -1]

    # 转置矩阵*矩阵
    xTx = X.T * X
    # 如果矩阵的逆不存在,会造成程序异常
    if np.linalg.det(xTx) == 0.0:
        raise NameError('矩阵的逆不存在')
    # 最小二乘法求最优解
    ws = xTx.I * (X.T * Y)
    return ws, X, Y

② 运行代码

myMat2 = np.mat(loadDataSet('exp2.txt'))
createTree(myMat2,modelLeaf,modelErr,(1,10))

该代码以 0.285477 为界创建了两个模型(图 9-4 的实际分界点是 3.0)。createTree() 生成的这两个线性模型分别是 $y = 0.0016985 + 11.96477x$ 和 $y = 3.46877 + 1.18521x$。

而 exp2.txt 即图 9-4 中的数据实际是由 $y = 3.5 + 1.0x$ 和 $y = 0 + 12x$ 再加上高斯噪声生成的。

6. 根据 R^2 值分析模型效果

模型树,回归树和标准的线性回归,局部加权线性回归,哪一种模型更好呢?一个比较客观的比较方法就是计算相关系数,也称为 $R^2$ 值。该相关系数可以调用 Numpy 库中的:

np.corrcoef(yHat,y,rowvar = 0)

其中,yHat 是预测值,y 是目标变量的实际值。

$R^2$ 判定系数就是拟合优度判定系数,它体现了回归模型中自变量的变异在因变量的变异中所占的比例。如 $R^2=0.99999$ 表示在因变量 y 的变异中有 99.999% 是由于变量 x 引起。当 $R^2=1$ 时表示,所有观测点都落在拟合的直线或曲线上;当 $R^2$=0 时,表示自变量与因变量不存在直线或曲线关系。

⭐ 所以我们看出, $R^2$ 的值越接近 1.0 越好

📚 References

posted @ 2023-01-10 17:53  RuoVea  阅读(20)  评论(0编辑  收藏  举报