在上一篇文章里,我们用决策树构建了一个分类器,预测病人是否患有心脏病。决策树同样可以用于回归任务——预测一个数字而不是一个类别:薪水、温度、房价。本文将取用 Hitters 数据集,构建一棵决策树,根据球员打过的赛季数和上赛季的安打数预测他的薪水。

分类器按基尼不纯度下降为分裂评分。平方误差回归则使用方差下降,并在叶子中预测均值。将方差下降乘以父节点样本数,就得到总平方误差的下降。

分类器回归器
目标列一个类别——有病或没病一个数字——以千美元计的薪水
度量一堆数据基尼不纯度方差
给分裂打分信息增益总平方误差的下降量
叶子存什么落到它这里的标签计数落到它这里的行的均值
树回答什么一个类别,附带概率一个数字

分裂准则和每个叶子里存的值,是同一个决定的两面。一个节点的不纯度,就是它的叶子将要给出的那个答案的训练误差。 基尼衡量的是为一堆标签预测类别比例的代价;方差衡量的是为一堆薪水预测均值的代价。叶子的预测一变,与之相配的误差度量也随之改变。

候选生成、递归分裂和停止规则都保持不变。需要改的只有不纯度的计算和叶子的预测。

下载完整 Python 示例,运行 python3 hitters_tree.py。

预测均值的叶子也限制了外推:所有预测都位于训练目标的范围内。后面的示例将说明,为什么增加树深也无法让模型在观测特征范围外延续上升趋势。

同一套方法

保留分类器中的简化 CART 流程。每个内部节点选择一个二元问题,评估得到的两组,再递归处理。当没有候选能带来超过数值容差的正向改善时,就以叶子结束递归。

候选的生成方式和之前完全一样——每个特征与它在当前这些行中取到的每个值配对,一对生成一个问题,数值列问 >=,类别列问 ==——变的只是目标列的类型,而预测变量依然可以是任意一种。生成器完全不知道、也不关心目标列里装的是什么:五行数据、两列各有四个不同取值,会产生八个候选,无论你预测的是疾病还是薪水。

需要改变的是数据组的度量方式,也就是如何衡量其中各行的差异。 在分类器中,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)

该函数从父节点方差中减去按样本数加权的子节点方差,结果是此节点平均平方误差的下降。这里工资以千美元计,因此方差和 SSE 的单位都是千美元的平方。

五名球员

数据集是 Hitters 研究中的五名球员——两个数值预测变量,一个数值目标:

#球员年数安打薪水
1BillyJo Robidoux24167.5
2Jack Howell24195.0
3Alvin Davis3130480.0
4Mike Marshall677670.0
5Lloyd Moseby7149787.5

years 是在大联盟打过的赛季数,hits 是上赛季的安打数,salary 是他 1987 赛季的薪水,以千美元计。这些都是那项真实研究中的真实数据行,已与完整文件核对过。

度量一个数据集的分散程度

为了评价候选分裂,需要衡量工资的分散程度。先计算平方误差和(SSE),再除以行数,得到方差。

假设你需要判断下面两份薪资表哪一份更分散:

队伍薪水
A400, 410, 420, 430, 440
B67.5, 95.0, 480.0, 670.0, 787.5

B 队来自我们真实的数据集——就是上面五名球员的薪水;A 队则是一个假想的俱乐部,所有人赚得差不多。

一种办法是把两者都画到图上:

Which payroll is more scattered?
mean 420.0squad A400440squad B67.5787.50200400600800

一眼就能看出第二支队伍更分散。但分裂搜索需要一个它能计算出来的数字。首先想到的是均值,它已经作为虚线画在图里了——两支队伍的均值落在完全相同的位置。

这说明均值无法衡量一堆数据有多分散——两边它都报 420。作为替代,我们可以衡量每个薪水离均值有多远:

队伍相对 420 的偏差和
A−20, −10, 0, +10, +200
B−352.5, −325.0, +60.0, +250.0, +367.50

我们不能直接使用这些偏差,因为两组的和都是零——这是均值的一个性质:它是这组数的平衡点,所以高于它的部分恰好抵消低于它的部分。

平方可以避免正负偏差相互抵消,并给予大误差更高权重。绝对误差是另一种选择,但对应的叶子预测应为中位数而非均值。树的分裂搜索并不要求可微性。

把偏差平方后相加,得到的就是 SSE;再除以个数,得到的就是方差:

队伍偏差的平方合计(SSE)个数方差
A400, 100, 0, 100, 4001,0005200.00
B124,256.25, 105,625, 3,600, 62,500, 135,056.25431,037.5586,207.50

SSE 是总量,方差是每行的 SSE。 将每行复制一遍会使 SSE 翻倍,方差不变;添加任意新行则可能改变两者。这里 B 队的方差约为 A 队的 431 倍:86,207.50 对比 200.00。

把这套算术写成公式,方差就是:

Var=1n∑i=1n(yi−yˉ)2\text{Var} = \frac{1}{n}\sum_{i=1}^{n} (y_i - \bar{y})^2

其中 nn 是这堆数据有多少行,yiy_i 是其中一行的薪水,yˉ\bar{y} 是它们的均值——所以 yi−yˉy_i - \bar{y} 就是某一行的偏差,也就是上面表格里那一列。

去掉 1n\frac{1}{n},剩下的 ∑i=1n(yi−yˉ)2\sum_{i=1}^{n}(y_i - \bar{y})^2 就是 SSE。

我们刚刚搭出来的这条公式叫作总体方差,查资料时你会看到它旁边还有第二个版本,样本方差,两者只在分母上不同:

σ2=1N∑i=1N(yi−μ)2s2=1n−1∑i=1n(yi−yˉ)2\sigma^2 = \frac{1}{N}\sum_{i=1}^{N} (y_i - \mu)^2 \qquad\qquad s^2 = \frac{1}{n - 1}\sum_{i=1}^{n} (y_i - \bar{y})^2

这里用 nn 作分母,因为衡量的是该节点中各行的平均平方误差。n−1n-1 修正用于另一目的:通过随机样本估计总体方差。计算这里的训练损失不需要这种修正。

为什么这是正确的不纯度

现在来理解,为什么偏偏是这个度量适合用来给节点打分。树建好之后,每个叶子存着自己那堆数据的均值;到了预测的时候,这个均值就是它为每一个落到它这里的新球员预测的薪水——所有人共用一个数字。一堆分散很大的数据会让这一个数字对其中很多成员都错得离谱,这正是分裂搜索想要尽可能不分散的堆的原因。

假设树根本没有分裂过——一个叶子装着整支队伍,为其中每个球员都预测平均薪水 420.0。

对 A 队来说这是个好答案:那里没人的薪水离它超过 20,所以叶子最差也就错 20。对 B 队来说这是个坏答案:Robidoux 赚 67.5,Moseby 赚 787.5,两人都被告知 420.0,分别错了 352.5 和 367.5。把这些误差平方再取平均,你就回到了表格里的那两个数字,200.00 和 86,207.50——同样的方差,现在被读作每个叶子会犯的误差。所以一个节点内部的分散程度就是该节点的答案会犯的误差——而这正是树在决定问哪个问题时所比较的数字,它偏好那个让两堆数据剩下误差最少的问题。

这一切其实都已经写在公式 1n∑i=1n(yi−yˉ)2\frac{1}{n}\sum_{i=1}^{n}(y_i - \bar{y})^2 里了。这里的 yˉ\bar{y} 就是叶子为每个落到它这里的人预测的 420.0。每个 (yi−yˉ)(y_i - \bar{y}) 是某个球员的误差:Robidoux 是 −352.5-352.5,Moseby 是 +367.5+367.5,而 A 队里任何人都不会差过 ±20\pm 20。把这些误差平方再取平均,就得到 86,207.50 和 200.00。因此方差就是一个预测平均值的叶子的均方训练误差——即使是最好的常数叶子也仍然会犯的那个误差。

与线性回归相同的损失,作用在另一族函数上

平方误差也正是普通最小二乘法(OLS)所要最小化的东西,而树对它的处理并无不同。两者的度量方式相同,都是相对真实值来算:yiy_i 减去模型对这一行的预测。不同的是模型被允许预测什么——线性回归给每一行一个属于它自己的数字,从直线上该行的 xx 处读出;而树给同一个叶子里的所有行同一个数字。

看清这层联系最利落的方式是:一个叶子就是一个只有截距的回归。在完全不带预测变量的情况下拟合 OLS,估计值就是 yˉ\bar{y}——正是叶子所存的那个常数,最小化的也是同一个平方和。一棵树就是一堆这样的回归,每个区域一个,而分裂搜索承担了挑选区域的工作。这也是为什么一堆数据的方差在这里就是它的训练误差:方差是到均值的平均平方距离,而均值正是叶子所预测的东西。

线性回归优化系数,而这棵树搜索有限个候选划分。阈值和叶子均值都是学习得到的数值,但算法并不通过梯度下降更新它们。对于固定划分,最佳叶子常数可以直接取均值。

分类和回归用的是同一套结构。叶子存的是最佳常数预测,而不纯度衡量的是该预测的误差。对分类来说,它们是类别比例和基尼不纯度;对回归来说,则是均值和方差。不纯度会在每个节点上为每个候选计算一遍,所以换掉它可能会改变树中的每一个分裂。叶子的统计量在挑选分裂时并不参与:它决定的是建成后的树预测什么,而不是树长成什么形状。

均值还是中位数:选择准则

我们在前面几段就选定了平方误差,那时偏差需要丢掉符号,而我们选择了平方而不是绝对值。这也是各个库的默认设置,例如 scikit-learn 在你不另行指定时使用 criterion="squared_error"。不过它并不是唯一的选项,而且这个选择的影响比看上去更深远:我们在构建时用来给分裂打分的损失,同时也决定了叶子必须存什么用于预测,因为两者是同一个问题的答案——哪一个常数能最小化这个损失。

  • 最小化平方误差 → 叶子存均值。
  • 最小化绝对误差 → 叶子存中位数。

取一个装着 [10, 12, 14, 16, 200] 的叶子,其中 200 是一个离群值或者录入错误:

叶子预测总平方误差总绝对误差
均值 = 50.4027,995.2299.2
中位数 = 14.0034,620.0194.0

均值最小化平方误差,中位数最小化绝对误差。若将最大值从 200 继续提高,均值还会移动,中位数仍是 14。这种对极端值的抵抗力,是绝对误差叶子在存在离群值时可能有用的原因。

所以我们选的不纯度决定了叶子存的常数,这正是下面这两个函数被写成一对的原因。

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 除以 nn——也叫 MSE
分裂后剩下的误差SSEL+SSER\text{SSE}_L + \text{SSE}_R两个方差,按堆的大小加权
分裂去掉的误差SSEP−(SSEL+SSER)\text{SSE}_P - (\text{SSE}_L + \text{SSE}_R)同样的减法,加权形式

它们给候选问题排出的顺序完全一致,所以选哪个都无所谓。总量的算术更简单,我们就从它开始;平均量的形式则是代码实际计算的东西,等准则立住之后我们再回到它。

一个问题把节点分成两堆,每堆用自己的均值作答——一边是 yˉL\bar{y}_L,另一边是 yˉR\bar{y}_R。所以我们可以把 SSE 公式分别应用到每一堆上,各自相对自己的均值。把这两个 SSE 加起来,得到的就是残差平方和(RSS)——这个分裂留下的误差:

RSS=∑i=1nL(yi−yˉL)2⏟左子节点的 SSE  +  ∑i=1nR(yi−yˉR)2⏟右子节点的 SSE\text{RSS} = \underbrace{\sum_{i=1}^{n_L} (y_i - \bar{y}_L)^2}_{\text{左子节点的 SSE}} \;+\; \underbrace{\sum_{i=1}^{n_R} (y_i - \bar{y}_R)^2}_{\text{右子节点的 SSE}}

所以要从候选中挑出最好的问题,我们对每一个都算一遍,然后取 RSS 最小的那个——而这已经足以构建一棵回归树了:给每个候选打分,保留最低的那个,再在它造出的两堆数据上递归。

看着这条公式,也许有人会想,这个准则是不是在设法让树变小,或者把行聚成整齐的组。它两者都不做:它贪心地寻找由特征定义的划分,使得每个子节点里的目标更容易用单个叶子值来预测——分类中是更小的类别异质性,回归中是围绕均值更小的平方波动。它在一个节点上给两堆数据打分,取胜者,然后递归;建成后的树整体上的代价从来不在考虑之列。

按分裂去掉了多少来打分

还有另一种用 SSE 给问题打分的方式。把同一条公式用在父节点这一堆上——也就是分裂前的那些行,下标为 PP——相对它自己的均值 yˉP\bar{y}_P,你会得到 SSEP=∑i=1nP(yi−yˉP)2\text{SSE}_P = \sum_{i=1}^{n_P}(y_i - \bar{y}_P)^2——即这堆数据照原样用一个数字回答每一行时所犯的误差。于是,与其问一个分裂留下了多少误差,我们可以问它去掉了多少——父节点的误差减去两个子节点仍然背着的那部分:

GainSSE=SSEP−(SSEL+SSER)\text{Gain}_{\text{SSE}} = \text{SSE}_P - (\text{SSE}_L + \text{SSE}_R)

这就是信息增益的形状,只是写成了总量而非加权平均。

在同一节点比较候选时,父节点 SSE 固定。用这个固定值减去各候选的残差,会反转排序:残差越小,增益越大。

候选剩下的误差去掉的误差
A60100 − 60 = 40
B25100 − 25 = 75

留下最少的候选,就是去掉最多的候选。最小化 RSS 和最大化增益,是从两端做同一件事。

在精确算术中,平方误差增益不会为负:对子节点中的行,其自身均值至少与父节点均值一样好。即时增益为零并不意味着更深的分裂永远无用。我们的贪心实现会在增益极小时停止;深度限制、最小叶子大小和剪枝提供额外的复杂度控制。

用方差记法写出的同一个准则

增益是用总量表述的,而 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 sse(rows):
    m = mean(rows)
    return sum((row[-1] - m) ** 2 for row in rows)

def gain_sse(rows, left, right):
    return sse(rows) - (sse(left) + sse(right))

两者给同样的候选排出同样的顺序,而平均量形式不过就是分类器早已有的那个,因为基尼本身也是一个平均量。二者之间靠一个恒等式互相转换,因为方差就是每行摊到的 SSE:

Var=SSEn⟹SSE=n⋅Var\text{Var} = \frac{\text{SSE}}{n} \qquad\Longrightarrow\qquad \text{SSE} = n \cdot \text{Var}

把它代入全部三堆数据,增益就变成

GainSSE=nPVarP−nLVarL−nRVarR\text{Gain}_{\text{SSE}} = n_P \text{Var}_P - n_L \text{Var}_L - n_R \text{Var}_R

再整体除以 nPn_P——同样是这个节点上的一个常数,同样无害——就得到分类那篇文章用的形式,也就是 info_gain 所计算的:

Gain=VarP−nLnPVarL−nRnPVarR\text{Gain} = \text{Var}_P - \frac{n_L}{n_P}\text{Var}_L - \frac{n_R}{n_P}\text{Var}_R

最小化子节点 SSE、最大化 SSE 下降、最大化方差下降,在固定父节点内会选出相同的分裂。数值之间相差一个常数或父节点样本数这一因子。代码采用方差下降,以保留分类器的评分结构。

给根节点的候选打分

现在把这个准则跑在一棵真实的树的第一个节点上——根节点,它装着全部五名球员,此时还没问过任何问题。它的均值是 2100/5=420.02100/5 = 420.0,方差是 86,207.50,所以按 SSE=n⋅Var\text{SSE} = n \cdot \text{Var},我们要设法减少的平方误差是 SSEP=5×86,207.50=431,037.5\text{SSE}_P = 5 \times 86{,}207.50 = 431{,}037.5。下面每个候选都以平均量形式相对这个数字打分——也就是我们代码打印出的 gain,即 VarP\text{Var}_P 减去两个按大小加权的子节点方差——之后我们再把胜者换算成 RSS 来读。

候选问题的生成方式和之前完全一样——每个特征与它取到的每个值配对。两列各有四个不同取值,给出八个候选,其中两个根本没能把行分开。这里按从优到劣排列,尽管代码从不排序;它只是一路保留当前的胜者:

候选增益左 / 右
Is years >= 3?76501.04173 / 2
Is hits >= 77?76501.04173 / 2
Is years >= 6?63551.04172 / 3
Is years >= 7?33764.06251 / 4
Is hits >= 149?33764.06251 / 4
Is hits >= 130?30459.37502 / 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 送到一边,把两名完全相同的球员送到另一边:

RSS=48,154.1667⏟{480, 670, 787.5}+378.1250⏟{67.5, 95}=48,532.2917\text{RSS} = \underbrace{48{,}154.1667}_{\{480,\,670,\,787.5\}} + \underbrace{378.1250}_{\{67.5,\,95\}} = 48{,}532.2917

而根节点是 431,037.50431{,}037.50。一个问题就处理掉了数据集中 89% 的平方误差,而且再没有别的候选问题能留下更少。(增益那一列说的是同一件事,只是摊到每个观测上:去掉了 76,501.0417×5=382,505.2176{,}501.0417 \times 5 = 382{,}505.21,而 431,037.5−382,505.21=48,532.29431{,}037.5 - 382{,}505.21 = 48{,}532.29。)

有两个候选在榜首打成完全的平手——Is years >= 3? 和 Is hits >= 77?,都是 76501.0416666667,因为它们把五名球员切成了相同的两组。find_best_split 里的 >= 把胜利判给最后被扫描到的那一列,和分类那篇文章里的情形一模一样。

建成的树,以及它的叶子装着什么

当没有候选能带来超过数值容差的拟合改善时,递归停止。这五行数据得到如下结果:

flowchart TD n0{"Is hits >= 77?"} n1{"Is years >= 6?"} n2{"Is hits >= 149?"} n3["787.5"] n4["670.0"] n5["480.0"] n6["mean(67.5, 95.0) = 81.25"] n0 -->|True| n1 n0 -->|False| n6 n1 -->|True| n2 n1 -->|False| n5 n2 -->|True| n3 n2 -->|False| n4 classDef pure fill:#dcf5e3,stroke:#3ba55c,color:#1c1c22; classDef mixed fill:#fdf0d0,stroke:#d9a514,color:#1c1c22; class n3,n4,n5 pure; class n6 mixed;

三个叶子各自恰好装着一名球员,并且完美复现了他的薪水。第四个装着那对完全相同的球员,回答 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

三名球员的训练工资被准确复现,特征相同的两名球员得到共同均值。这描述的是训练拟合,尚未衡量对新球员的表现。

那个 81.25 清楚地说明了我们为什么在叶子里存均值,而不是别的什么——比如两个薪水中较小的那个,或者较大的那个。 叶子必须给出一个数字,记作 cc,而我们要最小化的损失是平方误差,所以问题就是哪个 cc 能让 ∑i(yi−c)2\sum_i (y_i - c)^2 尽可能小。它是 cc 的光滑函数,所以最小值出现在导数为零的地方:

ddc∑i(yi−c)2=−2∑i(yi−c)=0⟹∑iyi=nc⟹c=yˉ\frac{d}{dc}\sum_i (y_i - c)^2 = -2\sum_i (y_i - c) = 0 \qquad\Longrightarrow\qquad \sum_i y_i = n c \qquad\Longrightarrow\qquad c = \bar{y}

所以均值并不是若干合理选择中的一个,也不是什么约定俗成:它是唯一满足这个条件的常数,而且它正是分裂准则所进行的同一个最小化的解。不纯度和叶子值出自同一个损失函数。

那个叶子也是树停止改进的地方。Robidoux 和 Howell 的 years 与 hits 完全相同,所以任何问题都永远无法把他们分开:无论深度多大他们都共用一个叶子,而不管那个叶子回答什么数字,对其中至少一人来说都是错的。回答 81.25 会留下 (67.5−81.25)2+(95−81.25)2=378.125(67.5 - 81.25)^2 + (95 - 81.25)^2 = 378.125 的平方误差,而任何只读这两列的树都无法把它压得更低——这是训练误差之下的一层地板,再往深处生长也越不过去。

在工资有记录的 263 名球员中,有九对球员具有相同的 (Years, Hits)。其中最大工资差为 310,000 美元,因此任何共同预测在这对球员上的平均绝对误差至少为 155,000 美元。额外特征可能将他们区分开。这是已有特征的限制,并不意味着工资本身不可预测。

数据之内:一段楼梯

现在来看建成的树画出的形状,然后看看越过训练数据边缘之后会发生什么。这有助于我们看清回归树能表达什么、不能表达什么。回归树是分段常数的:它的问题把输入空间划分成若干区域,同一区域内的每个点都得到相同的预测。作为特征的函数,它的输出是一组平坦的台面,在阈值处垂直跳跃——任何深度下都不存在任何斜率。下文我们把这个性质称为平坦性。

为了便于演示,下面我们画的是一个合成数据集而不是 Hitters:40 行数据,只有一个特征 xx,在 0 到 10 之间均匀分布,目标沿着一条带少量噪声的光滑波形变化。

x00.2560.5130.769…9.74410
y45.9052.2957.3658.86…79.4882.41

组件在这些行上拟合回归树。虚线标记学习到的阈值,每个区间取其中训练目标的均值。增加深度上限,即可看到更多区间:

How deep the tree may grow
no training dataxy

深度为 1 时只有两个台面,拟合糟糕透顶。到深度 6 时有 27 个台面,平方误差从 5,816 降到 89——这个数字是整个拟合的误差,40 个点每一个都走下这棵树,并相对它落入的叶子所存的均值来计分,正是我们一直称作 RSS 的那个和,只不过现在是在 27 个叶子上而不是两个。树在向那条曲线收敛,但它从不弯曲——它靠把光滑函数切成越来越窄的常数片段来逼近它。

树可以近似非线性关系和特征交互,无需预先指定其形式。但具有常数叶子的有限树无法在连续区间上精确表示一条非恒定直线;更精细的近似需要更多台阶。

有两个特征时,楼梯变成地形

一个特征给出楼梯,是因为有一根轴用来排列台阶,另一根轴放预测值。加上第二个特征后两根轴都被占了,预测值只好另找地方:树把平面切成一个个矩形,原先台阶的高度就变成了每个矩形上方平屋顶的高度。

下面两幅图是同一个模型。左边是从上往下看的划分,预测值体现为底色和每个矩形里的数字——正是分类那篇文章为决策区域画过的那种图。右边是把同样的方块抬升到那个数字的高度,于是高度承担了楼梯图里 yy 轴所承担的角色:

How deep the tree may grow
The partition of the feature space
x₁x₂
The prediction surface

每个屋顶都是平的,每面墙都是垂直的,这就是分段常数在看得见时的样子。提高深度,地形增加方块的方式和楼梯增加台阶一样——2 个区域,然后 4 个、8 个、16 个——用平坦的小平面去逼近数据的形状,永远不用斜面。

数据之外:一层天花板

在单特征阶梯中,超过最大学习阈值的输入都到达同一个边缘叶子。若有多个特征,将其中一个增大到超过它的全部阈值,只会使关于该特征的决策不再变化;其他特征仍可能把输入送到不同叶子。

这背后的机制并非回归独有。分类器的区域同样是平的,它对训练数据之外的任何东西,回答的也是它最外侧叶子所存的内容。对回归而言这个局限格外显眼,因为目标值是有序的。如果薪水在训练范围之外继续上涨,树无法跟上;它会一直返回最外侧叶子里存的那个值。标签则没有对应的方向——不存在比“有病”更高的类别——所以在分类中同样的行为不那么明显。

这个行为直接源自预测的工作方式:一行带着 x=106x = 10^6 到来,沿途对每个阈值问题都回答“是”,落进最右边的叶子,然后拿到落在那里的训练行的均值。不存在任何机制能让叶子的值取决于这一行越过阈值有多远。

下面是最尖锐的一个演示——一个完全没有噪声的严格线性关系 y=2.5x+3y = 2.5x + 3,在 x∈[0,10]x \in [0, 10] 上采样,分别用深度为 3 的树和普通线性回归来拟合:

import numpy as np
from sklearn.linear_model import LinearRegression
from sklearn.tree import DecisionTreeRegressor

X = np.linspace(0, 10, 60).reshape(-1, 1)
y = 2.5 * X.ravel() + 3

tree = DecisionTreeRegressor(max_depth=3, random_state=0).fit(X, y)
linear = LinearRegression().fit(X, y)

两个模型拟合的是同样的 60 行数据,全都落在 x∈[0,10]x \in [0, 10] 之内。现在分别问它们这个范围之内、以及远在这个范围之外的取值:

x真实值树线性回归
28.007.458.00
515.5013.8115.50
823.0023.3423.00
1233.0026.5233.00
2053.0026.5253.00
50128.0026.52128.00
10002503.0026.522503.00

画到 x=20x = 20,而训练范围止于 10:

no training data0510152002040xlinear modeltree

线性模型精确地还原了这条规则,在 x=1000x = 1000 处依然正确。树在 x=12x = 12、x=50x = 50 和 x=1000x = 1000 处都返回 26.52——对它经验之外的每一个输入都给同一个数字,而这还是在没有噪声、且关系只需两个参数就能被一条直线抓住的数据上。

在训练范围内,树也有近似误差:x=2x=2 时返回 7.45,而非 8.00。某些输入的预测可以恰好正确,但八个平台无法在所有位置复现这条直线。

比平坦性更糟的是那个上界。叶子存的是落到它这里的训练目标的均值,而均值不可能落在被平均的那些值之外。所以回归树所能给出的每一个预测,都被困在训练目标的取值范围之内。 无论深度多大、数据是什么,它都无法预测出破纪录的高点或低点。

直线示例的最大叶子值为 26.5169,低于最大训练目标 28.0。若某个叶子只包含目标最大值,树也可以达到该最大值。作为对比,使用 Hitters 的全部 19 个特征,以及 random_state=0 的 70/30 划分所得到的 184 行训练数据进行拟合:

深度 2       最高可能预测  2127.3   由 1 名球员达到
深度 3       最高可能预测  2127.3   由 1 名球员达到
深度 5       最高可能预测  2127.3   由 1 名球员达到
完整深度     最高可能预测  2127.3   由 1 名球员达到

在这次拟合中,深度 2 的树已经单独分出了最高收入球员。是否如此取决于特征和其他行,而不仅是目标有多极端。范围限制始终成立:叶子均值无法超过其内部最大的目标值。

当外推本身就是任务的一部分时,这一点最为要紧:

  • 趋势。 固定其他特征后,当不断增大的时间特征超过全部阈值,树的预测就不再变化。单独建模趋势可能有所帮助。
  • 随机森林。 对均值叶子树取平均,结果仍在训练目标范围内。
  • 提升树。 树的和可以超出该范围,但有限个常数叶子树的集成仍是分段常数。沿固定方向移动,一旦不再跨越分裂边界,预测最终就不再变化。

这个局限来自叶子里装的东西,而不是来自分裂本身。有些树的变体在每个叶子里拟合一个线性模型而不是一个常数。那样就可以外推了,不过远离数据的预测届时会强烈依赖于拟合出来的斜率。

从一棵树到一个集成

把分类器变成回归器只需要两处改动:用方差给节点打分,在每个叶子里存均值。得到的模型在训练范围之内很灵活,但它分段常数的预测无法把趋势延伸到范围之外。

回归树常用于集成。随机森林在自助采样数据上训练树,在分裂时考虑随机特征子集,再对预测取平均,以减少对某一棵拟合树的依赖。

改成让它们依次拟合彼此的误差,你得到的就是梯度提升,此时被最小化的平方误差属于整个集成。每一轮衡量目前为止的集成还有哪些地方做错了,并把下一棵树拟合到这些残差上,所以每棵树执行的正是本文里的那套分裂搜索,只不过针对的目标是由当前的错误构成的,而不是原始薪水。这也是为什么即便问题是分类,干活的仍然是这些树:它们被拟合的是一列实数值的梯度,而不是标签。

更深的树或更多提升轮次都不保证训练误差为零:这里相同特征对应冲突目标已经使零误差不可能。应根据验证表现选择深度、叶子大小和集成规模,而不只看训练拟合。