如何从零实现决策树回归器
在上一篇文章里,我们用决策树构建了一个分类器,预测病人是否患有心脏病。决策树同样可以用于回归任务——预测一个数字而不是一个类别:薪水、温度、房价。本文将取用 Hitters 数据集,构建一棵决策树,根据球员打过的赛季数和上赛季的安打数预测他的薪水。
在那里挑选每个分裂的关键机制是基尼不纯度和信息增益。到了回归,我们把基尼不纯度换成方差。我们不再问标签有多混杂,而是问目标值有多分散。好的分裂就是能减小这种分散的分裂,这里用总平方误差的下降量来衡量。
| 分类器 | 回归器 | |
|---|---|---|
| 目标列 | 一个类别——有病或没病 | 一个数字——以千美元计的薪水 |
| 度量一堆数据 | 基尼不纯度 | 方差 |
| 给分裂打分 | 信息增益 | 总平方误差的下降量 |
| 叶子存什么 | 落到它这里的标签计数 | 落到它这里的行的均值 |
| 树回答什么 | 一个类别,附带概率 | 一个数字 |
分裂准则和每个叶子里存的值,是同一个决定的两面。一个节点的不纯度,就是它的叶子将要给出的那个答案的训练误差。 基尼衡量的是为一堆标签预测类别比例的代价;方差衡量的是为一堆薪水预测均值的代价。叶子的预测一变,与之相配的误差度量也随之改变。
候选生成、递归分裂和停止规则都保持不变。需要改的只有不纯度的计算和叶子的预测。
本文还会讨论一个只有在目标为数值时才明显的局限:回归树的每个预测,都是训练数据中一组薪水的平均值。因此,无论输入的赛季数或安打数有多大,它都无法预测出高于训练集最大值的薪水。分类任务不存在这个问题,因为答案本来就限定在已有的类别中;数值却可以超出观测范围,而回归树无法沿着趋势继续外推。
同一套方法
算法仍然是 CART:它构建一棵二叉树,每个节点提出一个是非问题,并且恰好有两个子节点。整个流程几乎不变。构建树时,我们生成候选问题,用每个问题把数据分成两组——回答 True 的一组和回答 False 的一组——再根据分裂降低了多少不纯度来评分。选出最佳问题后,对两组数据分别递归,直到所有问题都无法继续改善结果。
候选的生成方式和之前完全一样——每个特征与它在当前这些行中取到的每个值配对,一对生成一个问题,数值列问 >=,类别列问 ==——变的只是目标列的类型,而预测变量依然可以是任意一种。生成器完全不知道、也不关心目标列里装的是什么:五行数据、两列各有四个不同取值,会产生八个候选,无论你预测的是疾病还是薪水。
需要改变的是数据组的度量方式,也就是如何衡量其中各行的差异。 在分类器中,gini 衡量标签的混杂程度;目标变为数值后,我们改用 variance,衡量各个薪水偏离组内均值的程度。
有意思的是,我们用来给问题打分的信息增益公式并不需要跟着改。这是我们在分类器里给一个分裂打分的方式:
def info_gain(left, right, current_uncertainty):
p = float(len(left)) / (len(left) + len(right))
return current_uncertainty - p * gini(left) - (1 - p) * gini(right)而这是回归器的版本,同一个函数,只改了一个名字:
def info_gain(left, right, current_uncertainty):
p = float(len(left)) / (len(left) + len(right))
return current_uncertainty - p * variance(left) - (1 - p) * variance(right)这个减法就是分类器那篇文章完整推导过的:我们出发时的那一堆,减去每个子节点按落入其中的行的比例加权后的值。这里的关键在于,它从不追问目标列里装的是什么;它只问每一堆里的行彼此相差多少。喂给它 gini,这个差就是信息增益;喂给它 variance,同一个差就成了平方误差的下降,单位是平方美元而不是不纯度。
当目标是数字时,这个准则究竟意味着什么,有一个比这条公式更简单的说法,等我们把不纯度搭起来之后就会讲到。
五名球员
数据集是 Hitters 研究中的五名球员——两个数值预测变量,一个数值目标:
| # | 球员 | 年数 | 安打 | 薪水 |
|---|---|---|---|---|
| 1 | BillyJo Robidoux | 2 | 41 | 67.5 |
| 2 | Jack Howell | 2 | 41 | 95.0 |
| 3 | Alvin Davis | 3 | 130 | 480.0 |
| 4 | Mike Marshall | 6 | 77 | 670.0 |
| 5 | Lloyd Moseby | 7 | 149 | 787.5 |
years 是在大联盟打过的赛季数,hits 是上赛季的安打数,salary 是他 1987 赛季的薪水,以千美元计。这些都是那项真实研究中的真实数据行,已与完整文件核对过。
度量一个数据集的分散程度
候选问题的列表和之前完全一样地构建,所以接下来要弄明白的是如何给它们打分、决定哪一个成为该节点的问题。为此我们要用到一种衡量一堆数字有多分散的度量,它叫方差,以及构成它的基本材料——误差平方和(SSE)。在分类器那篇文章里,扮演这个角色的是基尼不纯度,它衡量一堆标签有多混杂。
和之前一样,我们按一个问题去掉了多少分散来给它打分,使得按该问题分裂之后,两堆数据比它们来源的那一堆更不分散。这个分数就是总平方误差的下降量,也就是信息增益的对应物,下降最多的问题获胜。
先来看方差,它衡量一组数字有多分散——用一个数字说明它们是彼此挨得很近,还是从一个极端延伸到另一个极端。
假设你需要判断下面两份薪资表哪一份更分散:
| 队伍 | 薪水 |
|---|---|
| A | 400, 410, 420, 430, 440 |
| B | 67.5, 95.0, 480.0, 670.0, 787.5 |
B 队来自我们真实的数据集——就是上面五名球员的薪水;A 队则是一个假想的俱乐部,所有人赚得差不多。
一种办法是把两者都画到图上:
一眼就能看出第二支队伍更分散。但分裂搜索需要一个它能计算出来的数字。首先想到的是均值,它已经作为虚线画在图里了——两支队伍的均值落在完全相同的位置。
这说明均值无法衡量一堆数据有多分散——两边它都报 420。作为替代,我们可以衡量每个薪水离均值有多远:
| 队伍 | 相对 420 的偏差 | 和 |
|---|---|---|
| A | −20, −10, 0, +10, +20 | 0 |
| B | −352.5, −325.0, +60.0, +250.0, +367.5 | 0 |
我们不能直接使用这些偏差,因为两组的和都是零——这是均值的一个性质:它是这组数的平衡点,所以高于它的部分恰好抵消低于它的部分。
所以我们需要的是大小,把符号丢掉。 有两种做法:取绝对值,或者取平方。我们先着重看平方,这是惯例的选择,原因有两个——它处处光滑可导,而绝对值在零处有一个尖角;而且它对一次大的失误的惩罚,远重于对若干次小失误的惩罚。不过绝对值确实是一个真正的备选方案,后面我们会看到选它会让模型变成什么样。
把偏差平方后相加,得到的就是 SSE;再除以个数,得到的就是方差:
| 队伍 | 偏差的平方 | 合计(SSE) | 个数 | 方差 |
|---|---|---|---|---|
| A | 400, 100, 0, 100, 400 | 1,000 | 5 | 200.00 |
| B | 124,256.25, 105,625, 3,600, 62,500, 135,056.25 | 431,037.5 | 5 | 86,207.50 |
SSE 表示一组数据的总离散程度,方差表示平均离散程度,也就是 SSE 除以数据行数。两者的单位都是原单位的平方,因此数值看起来很大。加入更多球员后,SSE 通常会继续增长,而方差仍能保持在相近的尺度。对这两支队伍,两种指标得出的结论相同:A 队为 200.00,B 队为 86,207.50,后者是前者的 431 倍。
把这套算术写成公式,方差就是:
其中 是这堆数据有多少行, 是其中一行的薪水, 是它们的均值——所以 就是某一行的偏差,也就是上面表格里那一列。
去掉 ,剩下的 就是 SSE。
我们刚刚搭出来的这条公式叫作总体方差,查资料时你会看到它旁边还有第二个版本,样本方差,两者只在分母上不同:
树在任何地方做除法,除的都是 ——也就是总体形式 ,其中 是这堆数据的行数。(它经常干脆不做除法,直接用总量计算——这个后面再说。)样本方差用的是 ,因为它要从抽出来的少量行去估计一个更大总体的分散程度。而叶子给出的答案是恰好这些行的均值,所以不纯度必须是恰好这个答案的代价。 也就是贝塞尔校正,它的作用是去掉那个估计中的偏差,而一个节点根本没有什么总体需要估计。
为什么这是正确的不纯度
现在来理解,为什么偏偏是这个度量适合用来给节点打分。树建好之后,每个叶子存着自己那堆数据的均值;到了预测的时候,这个均值就是它为每一个落到它这里的新球员预测的薪水——所有人共用一个数字。一堆分散很大的数据会让这一个数字对其中很多成员都错得离谱,这正是分裂搜索想要尽可能不分散的堆的原因。
假设树根本没有分裂过——一个叶子装着整支队伍,为其中每个球员都预测平均薪水 420.0。
对 A 队来说这是个好答案:那里没人的薪水离它超过 20,所以叶子最差也就错 20。对 B 队来说这是个坏答案:Robidoux 赚 67.5,Moseby 赚 787.5,两人都被告知 420.0,分别错了 352.5 和 367.5。把这些误差平方再取平均,你就回到了表格里的那两个数字,200.00 和 86,207.50——同样的方差,现在被读作每个叶子会犯的误差。所以一个节点内部的分散程度就是该节点的答案会犯的误差——而这正是树在决定问哪个问题时所比较的数字,它偏好那个让两堆数据剩下误差最少的问题。
这一切其实都已经写在公式 里了。这里的 就是叶子为每个落到它这里的人预测的 420.0。每个 是某个球员的误差:Robidoux 是 ,Moseby 是 ,而 A 队里任何人都不会差过 。把这些误差平方再取平均,就得到 86,207.50 和 200.00。因此方差就是一个预测平均值的叶子的均方训练误差——即使是最好的常数叶子也仍然会犯的那个误差。
与线性回归相同的损失,作用在另一族函数上
平方误差也正是普通最小二乘法(OLS)所要最小化的东西,而树对它的处理并无不同。两者的度量方式相同,都是相对真实值来算: 减去模型对这一行的预测。不同的是模型被允许预测什么——线性回归给每一行一个属于它自己的数字,从直线上该行的 处读出;而树给同一个叶子里的所有行同一个数字。
看清这层联系最利落的方式是:一个叶子就是一个只有截距的回归。在完全不带预测变量的情况下拟合 OLS,估计值就是 ——正是叶子所存的那个常数,最小化的也是同一个平方和。一棵树就是一堆这样的回归,每个区域一个,而分裂搜索承担了挑选区域的工作。这也是为什么一堆数据的方差在这里就是它的训练误差:方差是到均值的平均平方距离,而均值正是叶子所预测的东西。
对算法而言真正重要的差别在于自由度落在哪里。线性回归有连续的旋钮——系数——你不断拧动它们,直到误差不再下降。树完全没有连续的旋钮。 一旦区域被固定下来,它的最佳常数就被唯一确定了,因为在那里最小化平方误差的正是均值,所以剩下能选的只有区域本身。而区域只能通过选择问题来选,这就是为什么整个最小化过程归结为枚举候选的“特征—阈值”对,并保留使误差下降最多的那一个。没有什么可微分的;可自由做的选择是离散的。第二个离散选择是何时停止,而这个准则根本无从决定。
分类和回归用的是同一套结构。叶子存的是最佳常数预测,而不纯度衡量的是该预测的误差。对分类来说,它们是类别比例和基尼不纯度;对回归来说,则是均值和方差。不纯度会在每个节点上为每个候选计算一遍,所以换掉它可能会改变树中的每一个分裂。叶子的统计量在挑选分裂时并不参与:它决定的是建成后的树预测什么,而不是树长成什么形状。
均值还是中位数:选择准则
我们在前面几段就选定了平方误差,那时偏差需要丢掉符号,而我们选择了平方而不是绝对值。这也是各个库的默认设置,例如 scikit-learn 在你不另行指定时使用 criterion="squared_error"。不过它并不是唯一的选项,而且这个选择的影响比看上去更深远:我们在构建时用来给分裂打分的损失,同时也决定了叶子必须存什么用于预测,因为两者是同一个问题的答案——哪一个常数能最小化这个损失。
- 最小化平方误差 → 叶子存均值。
- 最小化绝对误差 → 叶子存中位数。
取一个装着 [10, 12, 14, 16, 200] 的叶子,其中 200 是一个离群值或者录入错误:
| 叶子预测 | 总平方误差 | 总绝对误差 |
|---|---|---|
| 均值 = 50.40 | 27,995.2 | 299.2 |
| 中位数 = 14.00 | 34,620.0 | 194.0 |
每个常数都在自己的损失下获胜,本该如此。但请看均值是多少:50.40,比叶子里五个值中的四个都大。一个离群值把预测拽离了它本该服务的几乎每一个点。中位数则完全无视它。
所以我们选的不纯度决定了叶子存的常数,这正是下面这两个函数被写成一对的原因。
def mean(rows):
return sum(row[-1] for row in rows) / float(len(rows))
def variance(rows):
targets = [row[-1] for row in rows]
m = sum(targets) / len(targets)
return sum((t - m) ** 2 for t in targets) / len(targets)variance 是我们用来给节点打分的,mean 是叶子将要存的。 这就是平方误差所要求的一对。绝对误差要求的则是另一对:绝对偏差之和,以及叶子里的中位数。我们现在得到的是描述单个节点的一个数字,也就是基尼在回归里的对应物。把它变成一个分裂的分数是下一步,也是信息增益的对应物。
给分裂打分
现在我们知道了如何衡量单独一堆数据有多分散,可以来看怎么用这个度量去给一个问题打分。我们的目标是弄清楚,在问题把这些行分开之后还剩下多少平方误差,或者等价地说,这个问题去掉了多少平方误差。做这件事有两条路,用总量或者用平均量:
| 总量 | 平均量 | |
|---|---|---|
| 一堆数据的误差 | SSE | 方差,也就是 SSE 除以 ——也叫 MSE |
| 分裂后剩下的误差 | 两个方差,按堆的大小加权 | |
| 分裂去掉的误差 | 同样的减法,加权形式 |
它们给候选问题排出的顺序完全一致,所以选哪个都无所谓。总量的算术更简单,我们就从它开始;平均量的形式则是代码实际计算的东西,等准则立住之后我们再回到它。
一个问题把节点分成两堆,每堆用自己的均值作答——一边是 ,另一边是 。所以我们可以把 SSE 公式分别应用到每一堆上,各自相对自己的均值。把这两个 SSE 加起来,得到的就是残差平方和(RSS)——这个分裂留下的误差:
所以要从候选中挑出最好的问题,我们对每一个都算一遍,然后取 RSS 最小的那个——而这已经足以构建一棵回归树了:给每个候选打分,保留最低的那个,再在它造出的两堆数据上递归。
看着这条公式,也许有人会想,这个准则是不是在设法让树变小,或者把行聚成整齐的组。它两者都不做:它贪心地寻找由特征定义的划分,使得每个子节点里的目标更容易用单个叶子值来预测——分类中是更小的类别异质性,回归中是围绕均值更小的平方波动。它在一个节点上给两堆数据打分,取胜者,然后递归;建成后的树整体上的代价从来不在考虑之列。
按分裂去掉了多少来打分
还有另一种用 SSE 给问题打分的方式。把同一条公式用在父节点这一堆上——也就是分裂前的那些行,下标为 ——相对它自己的均值 ,你会得到 ——即这堆数据照原样用一个数字回答每一行时所犯的误差。于是,与其问一个分裂留下了多少误差,我们可以问它去掉了多少——父节点的误差减去两个子节点仍然背着的那部分:
这就是信息增益的形状,只是写成了总量而非加权平均。
而且这和之前是同一个选择,因为在同一个节点上比较各个候选时 是固定的:从每个分数里减去同一个常数,会让它们整体平移,顺序不变。假设父节点的误差是 100,两个候选分别留下 60 和 25:
| 候选 | 剩下的误差 | 去掉的误差 |
|---|---|---|
| A | 60 | 100 − 60 = 40 |
| B | 25 | 100 − 25 = 75 |
留下最少的候选,就是去掉最多的候选。最小化 RSS 和最大化增益,是从两端做同一件事。
增益永远不可能为负。每个子节点本可以继续用父节点的均值,但它却用了自己的均值——而按定义,那正是最小化它自己平方误差的那个常数。所以在训练数据上,分裂总是不会更差,这就是为什么 gain == 0 的含义是“没有任何问题有帮助”,而不是“有些问题有害”。这也是训练误差永远无法告诉一棵树何时该停的原因:放任不管的话,递归会一直进行到几乎每一行都独占一个叶子,所以停止规则必须来自准则之外——深度上限、每个叶子的最少行数,或者事后剪枝。
用方差记法写出的同一个准则
增益是用总量表述的,而 info_gain——分类器那篇文章里的函数,把 gini 换成了 variance——是用平均量表述的,按每个子节点占的行数比例加权:
def info_gain(left, right, current_uncertainty):
p = float(len(left)) / (len(left) + len(right))
return current_uncertainty - p * variance(left) - (1 - p) * variance(right)p 是父节点中落到左边那堆的行的比例——五行中的三行使 p = 0.6,剩下 1 - p = 0.4 给右边那堆——它之所以存在,是因为我们在用平均量工作。一个方差本身完全不说明它是由多少行算出来的,于是只有一行的子节点会和有一百行的子节点等量齐观;而如果我们还想继续用方差,就得手工把大小信息放回去,这正是 p 和 1 - p 所做的事。
写成总量形式则完全不需要权重:
def gain_sse(rows, left, right):
return sse(rows) - (sse(left) + sse(right))两者给同样的候选排出同样的顺序,而平均量形式不过就是分类器早已有的那个,因为基尼本身也是一个平均量。二者之间靠一个恒等式互相转换,因为方差就是每行摊到的 SSE:
把它代入全部三堆数据,增益就变成
再整体除以 ——同样是这个节点上的一个常数,同样无害——就得到分类那篇文章用的形式,也就是 info_gain 所计算的:
在平均量形式里看起来像是某种设计决策的那些权重,其实就是早已藏在总量里的行数。所以这三种形式你想用哪个都行:它们报出的数字不同,但给候选排的顺序完全一致,胜出的是同一个问题。 增益形式让分类器的代码一字不改;RSS 形式则是你在教科书和各种库里通常会看到的——scikit-learn 把这个准则叫作 squared_error 正是出于这个原因。本文在引用代码时用增益,在解释它在做什么时用 RSS。
给根节点的候选打分
现在把这个准则跑在一棵真实的树的第一个节点上——根节点,它装着全部五名球员,此时还没问过任何问题。它的均值是 ,方差是 86,207.50,所以按 ,我们要设法减少的平方误差是 。下面每个候选都以平均量形式相对这个数字打分——也就是我们代码打印出的 gain,即 减去两个按大小加权的子节点方差——之后我们再把胜者换算成 RSS 来读。
候选问题的生成方式和之前完全一样——每个特征与它取到的每个值配对。两列各有四个不同取值,给出八个候选,其中两个根本没能把行分开。这里按从优到劣排列,尽管代码从不排序;它只是一路保留当前的胜者:
| 候选 | 增益 | 左 / 右 |
|---|---|---|
Is years >= 3? | 76501.0417 | 3 / 2 |
Is hits >= 77? | 76501.0417 | 3 / 2 |
Is years >= 6? | 63551.0417 | 2 / 3 |
Is years >= 7? | 33764.0625 | 1 / 4 |
Is hits >= 149? | 33764.0625 | 1 / 4 |
Is hits >= 130? | 30459.3750 | 2 / 3 |
Is years >= 2? | 没有分开 | 5 / 0 |
Is hits >= 41? | 没有分开 | 5 / 0 |
被跳过的那两个候选用的是各自列里的最小值——years 取值为 2、2、3、6、7,hits 取值为 41、41、77、130、149——所以每一行对它们的回答都是“是”。全部去了 True 那一侧,False 那一侧什么都没有,这就是 5 / 0 的由来:根本没有分裂可打分,于是它们被丢弃。
gain 这一列是代码报出来的,因为打分的是 info_gain 这个函数。把胜者改读成总平方误差,这个准则会更容易看清。Is hits >= 77? 把 Davis、Marshall 和 Moseby 送到一边,把两名完全相同的球员送到另一边:
而根节点是 。一个问题就处理掉了数据集中 89% 的平方误差,而且再没有别的候选问题能留下更少。(增益那一列说的是同一件事,只是摊到每个观测上:去掉了 ,而 。)
有两个候选在榜首打成完全的平手——Is years >= 3? 和 Is hits >= 77?,都是 76501.0416666667,因为它们把五名球员切成了相同的两组。find_best_split 里的 >= 把胜利判给最后被扫描到的那一列,和分类那篇文章里的情形一模一样。
建成的树,以及它的叶子装着什么
把递归跑到底,当没有任何一堆数据还能再分时它就停下来:
三个叶子各自恰好装着一名球员,并且完美复现了他的薪水。第四个装着那对完全相同的球员,回答 81.25,即 67.5 和 95.0 的均值。
把五名球员放回建成的树里走一遍——和一名新球员会走的路径相同,每个人跟着问题走到某个叶子,取走它所存的数字——得到的是:
BillyJo Robidoux 实际 67.5 预测 81.25
Jack Howell 实际 95.0 预测 81.25
Alvin Davis 实际 480.0 预测 480.00
Mike Marshall 实际 670.0 预测 670.00
Lloyd Moseby 实际 787.5 预测 787.50这是教科书式的死记硬背——树不停地分裂,直到几乎每一行都有了属于自己的叶子,和分类器所做的完全一样,原因也相同:gain == 0 是唯一能让它停下来的东西。
那个 81.25 清楚地说明了我们为什么在叶子里存均值,而不是别的什么——比如两个薪水中较小的那个,或者较大的那个。 叶子必须给出一个数字,记作 ,而我们要最小化的损失是平方误差,所以问题就是哪个 能让 尽可能小。它是 的光滑函数,所以最小值出现在导数为零的地方:
所以均值并不是若干合理选择中的一个,也不是什么约定俗成:它是唯一满足这个条件的常数,而且它正是分裂准则所进行的同一个最小化的解。不纯度和叶子值出自同一个损失函数。
那个叶子也是树停止改进的地方。Robidoux 和 Howell 的 years 与 hits 完全相同,所以任何问题都永远无法把他们分开:无论深度多大他们都共用一个叶子,而不管那个叶子回答什么数字,对其中至少一人来说都是错的。回答 81.25 会留下 的平方误差,而任何只读这两列的树都无法把它压得更低——这是训练误差之下的一层地板,再往深处生长也越不过去。
而且这并不是五行凑巧数据造成的假象:263 名球员中出现了九对这样的组合,其中最糟的一对相差 $310,000——所以仅凭这两列,即便是完美的模型在那一对上平均也会错 $155,000。完整文件里还有另外十七个预测变量,足以把他们区分开;这里要说的是,在任何固定的特征集合下都存在一层地板,而叶子里的均值正是树找到它的方式。
数据之内:一段楼梯
现在来看建成的树画出的形状,然后看看越过训练数据边缘之后会发生什么。这有助于我们看清回归树能表达什么、不能表达什么。回归树是分段常数的:它的问题把输入空间划分成若干区域,同一区域内的每个点都得到相同的预测。作为特征的函数,它的输出是一组平坦的台面,在阈值处垂直跳跃——任何深度下都不存在任何斜率。下文我们把这个性质称为平坦性。
为了便于演示,下面我们画的是一个合成数据集而不是 Hitters:40 行数据,只有一个特征 ,在 0 到 10 之间均匀分布,目标沿着一条带少量噪声的光滑波形变化。
| x | 0 | 0.256 | 0.513 | 0.769 | … | 9.744 | 10 |
|---|---|---|---|---|---|---|---|
| y | 45.90 | 52.29 | 57.36 | 58.86 | … | 79.48 | 82.41 |
两列数据,外加一段供树去逼近的弯曲关系,这张图需要的全部就是这些。它把一个完整的 CART 回归器拟合到这些行上。竖直虚线是它分裂时用的阈值,所以相邻两条线之间的那段图就是一个叶子,画成一条平线,高度是落在其中的点的均值。提高深度上限,看着树把这个范围切成越来越多的片段:
深度为 1 时只有两个台面,拟合糟糕透顶。到深度 6 时有 27 个台面,平方误差从 5,816 降到 89——这个数字是整个拟合的误差,40 个点每一个都走下这棵树,并相对它落入的叶子所存的均值来计分,正是我们一直称作 RSS 的那个和,只不过现在是在 27 个叶子上而不是两个。树在向那条曲线收敛,但它从不弯曲——它靠把光滑函数切成越来越窄的常数片段来逼近它。
这是它与线性模型之间的结构性差别,而且是双向的。树不需要假设关系是线性的、单调的或光滑的,而且它还免费获得了列与列之间的交互作用。它放弃的是紧凑表达哪怕最简单的连续趋势的能力:表示 在线性模型里只需一个系数,在树里却需要无穷多级台阶。
有两个特征时,楼梯变成地形
一个特征给出楼梯,是因为有一根轴用来排列台阶,另一根轴放预测值。加上第二个特征后两根轴都被占了,预测值只好另找地方:树把平面切成一个个矩形,原先台阶的高度就变成了每个矩形上方平屋顶的高度。
下面两幅图是同一个模型。左边是从上往下看的划分,预测值体现为底色和每个矩形里的数字——正是分类那篇文章为决策区域画过的那种图。右边是把同样的方块抬升到那个数字的高度,于是高度承担了楼梯图里 轴所承担的角色:
每个屋顶都是平的,每面墙都是垂直的,这就是分段常数在看得见时的样子。提高深度,地形增加方块的方式和楼梯增加台阶一样——2 个区域,然后 4 个、8 个、16 个——用平坦的小平面去逼近数据的形状,永远不用斜面。
数据之外:一层天花板
再看一眼那个楼梯组件,看右侧最后一个训练点之外的阴影区域。楼梯在那里没有继续。它变平,并且永远平下去,停在最右边那个叶子所存的值上。这意味着,凡是超出训练集中出现过的取值的输入,都会被预测为同一个目标值。
这背后的机制并非回归独有。分类器的区域同样是平的,它对训练数据之外的任何东西,回答的也是它最外侧叶子所存的内容。对回归而言这个局限格外显眼,因为目标值是有序的。如果薪水在训练范围之外继续上涨,树无法跟上;它会一直返回最外侧叶子里存的那个值。标签则没有对应的方向——不存在比“有病”更高的类别——所以在分类中同样的行为不那么明显。
这个行为直接源自预测的工作方式:一行带着 到来,沿途对每个阈值问题都回答“是”,落进最右边的叶子,然后拿到落在那里的训练行的均值。不存在任何机制能让叶子的值取决于这一行越过阈值有多远。
下面是最尖锐的一个演示——一个完全没有噪声的严格线性关系 ,在 上采样,分别用深度为 3 的树和普通线性回归来拟合:
X = np.linspace(0, 10, 60).reshape(-1, 1)
y = 2.5 * X.ravel() + 3
tree = DecisionTreeRegressor(max_depth=3).fit(X, y)
linear = LinearRegression().fit(X, y)两个模型拟合的是同样的 60 行数据,全都落在 之内。现在分别问它们这个范围之内、以及远在这个范围之外的取值:
| x | 真实值 | 树 | 线性回归 |
|---|---|---|---|
| 2 | 8.00 | 7.45 | 8.00 |
| 5 | 15.50 | 13.81 | 15.50 |
| 8 | 23.00 | 23.34 | 23.00 |
| 12 | 33.00 | 26.52 | 33.00 |
| 20 | 53.00 | 26.52 | 53.00 |
| 50 | 128.00 | 26.52 | 128.00 |
| 1000 | 2503.00 | 26.52 | 2503.00 |
画到 ,而训练范围止于 10:
线性模型精确地还原了这条规则,在 处依然正确。树在 、 和 处都返回 26.52——对它经验之外的每一个输入都给同一个数字,而这还是在没有噪声、且关系只需两个参数就能被一条直线抓住的数据上。
即便在训练范围之内,树也从不完全正确:真值是 8.00,它却说 7.45。这又是平坦性。一条直线被切成了八个台面,每个台面用自己的平均值作答。
比平坦性更糟的是那个上界。叶子存的是落到它这里的训练目标的均值,而均值不可能落在被平均的那些值之外。所以回归树所能给出的每一个预测,都被困在训练目标的取值范围之内。 无论深度多大、数据是什么,它都无法预测出破纪录的高点或低点。
在上面那条直线上,树能给出的最大值是 26.5169,而训练最大值是 28.0——它甚至够不到自己见过的最大数字。不过这一点并不普遍成立,Hitters 就说明了原因。把一棵树拟合到那些薪水上,它的天花板是 2127.3,这个值恰好就是训练最大值,而且从深度 2 起的每个深度都是如此:
深度 2 最高可能预测 2127.3 由 1 名球员达到
深度 3 最高可能预测 2127.3 由 1 名球员达到
深度 5 最高可能预测 2127.3 由 1 名球员达到
完整深度 最高可能预测 2127.3 由 1 名球员达到薪水最高的那名球员离群得如此厉害,以至于贪心搜索会花掉整整一个分裂把他隔离进一个属于他自己的叶子——哪怕在只有四个叶子的树里也是如此——因为去掉那么多平方误差是当下最划算的交易。在目标平滑的地方,天花板严格低于最大值;而在存在孤立极端值的地方,树会把它切出来并够到它。
无论哪种情形,天花板都落在训练数据中最大目标值处或更低——永远不会更高,无论你喂给树什么。让同一棵深度为 3 的树给一名生涯数据是数据集中最佳球员两倍的球员估价:
深度 3 的树 预测 1169.8
线性回归 预测 5836.3对于一个比史上最佳还好一倍的球员,树给出的是一个它早已见过的数字——一份完全普通、稳稳落在已观测范围之内的薪水。
当外推本身就是任务的一部分时,这一点最为要紧:
- 趋势与时间序列。 一旦时间特征越过了所有学到的阈值,树就会一直预测同一个平台值。如果趋势本身很重要,先做去趋势处理再用树来建模残差,或者改用能表达斜率的模型。
- 价格与增长。 树无法把一个上升的模式延续到它学到的阈值所覆盖的特征取值之外。
- 集成模型。 随机森林继承了同样的局限。提升树可以产生原始目标范围之外的值,但一旦每个特征都越过了学到的分裂阈值,它们的预测同样会变成常数。
这个局限来自叶子里装的东西,而不是来自分裂本身。有些树的变体在每个叶子里拟合一个线性模型而不是一个常数。那样就可以外推了,不过远离数据的预测届时会强烈依赖于拟合出来的斜率。
从一棵树到一个集成
把分类器变成回归器只需要两处改动:用方差给节点打分,在每个叶子里存均值。得到的模型在训练范围之内很灵活,但它分段常数的预测无法把趋势延伸到范围之外。
回归树几乎所有的实际用途,都是作为组件而非模型。把几百棵树平均起来,每棵长在不同的样本上,你就得到了随机森林——那里没有任何东西被联合优化,每棵树只在自己的样本上最小化自己的平方误差,剩下的交给平均。
改成让它们依次拟合彼此的误差,你得到的就是梯度提升,此时被最小化的平方误差属于整个集成。每一轮衡量目前为止的集成还有哪些地方做错了,并把下一棵树拟合到这些残差上,所以每棵树执行的正是本文里的那套分裂搜索,只不过针对的目标是由当前的错误构成的,而不是原始薪水。这也是为什么即便问题是分类,干活的仍然是这些树:它们被拟合的是一列实数值的梯度,而不是标签。
本文里的这棵树,正是那些集成赖以搭建的基本单元——而那个警告也随之一同传递下去。树靠生长把训练数据上的平方误差压到零,提升靠不断增加轮数把它压到零,这正是两者都配有若干旋钮、而这些旋钮唯一的职责就是让它们停下来的原因。