Keyboard shortcuts

Press ← or → to navigate between chapters

Press S or / to search in the book

Press ? to show this help

Press Esc to hide this help

决策树与集成学习

直线模型会把所有输入放进同一个表达式。但有些规律更像一串问题:是不是彩印?页数是否超过某个值?队列里是否已经有很多任务?

决策树就沿着这样的提问往下走。它把输入空间分成若干区域,每个区域给一个预测。我们先看一棵很小的树,再看看许多棵树怎样一起工作。

1. 一个问题,把数据分成两边

假设只有六条记录:

输入 xx标签 yy
11
21
32
48
59
69

如果所有记录都给同一个预测,平方误差最小时应该预测平均值 5,平方误差之和为 82。

现在问一句:“x≤3.5x\le3.5 吗?”左边标签是 1、1、2,右边是 8、9、9。两边分别预测自己的均值 4/34/3 与 26/326/3,总平方误差降为 4/34/3。

图 1:末端的区域叫叶子。采用平方误差的回归树,在每个叶子预测该叶训练标签的均值。

训练算法会枚举可用特征与候选阈值,寻找误差下降最多的切法。对一个叶子里的样本集合 RR,预测值与误差为:

yˉR=1∣R∣∑i∈Ryi,SR=∑i∈R(yi−yˉR)2 \bar y_R=\frac1{|R|}\sum_{i\in R}y_i, \qquad S_R=\sum_{i\in R}(y_i-\bar y_R)^2

∣R∣|R| 表示这个集合的样本数。每次分裂比较的是分裂前的 SRS_R 与分裂后两个子区域误差之和。常见算法采取贪心策略:当前这一步选最好的切法,再继续往下分,不保证找到所有可能树结构中最优的那一棵。

2. 树长得越深,就一定越聪明吗

如果继续切到每个叶子只有一条记录,训练误差可以变成零。但新样本落进某个小区域时,得到的可能只是附近一条带噪声记录的值。

限制树的深度、要求叶子至少有若干样本,或对长好的树进行剪枝,都能控制复杂度。深度为 3 表示从根到最深叶子最多经过三次分裂,不是“总共只有三个节点”。

分类树的叶子可以记录各类别比例,输出类别概率。例如一个叶子里 8 个正例、2 个负例,经验正例比例为 0.8。这个小叶子给出的 0.8 仍可能不稳定,不能只因输出了小数就认为它已经校准。

分类时常用基尼不纯度来选择分裂:

G=1−∑k=1Kpk2 G=1-\sum_{k=1}^K p_k^2

pkp_k 是当前节点中第 kk 类的比例。全是同一类时 G=0G=0;二分类各占一半时 G=0.5G=0.5。分裂时比较子节点按样本数加权后的不纯度。第六章用到的连续标签平方误差,和这里的类别不纯度,是两种不同任务的选择标准。

3. 随机森林:让多棵树分别做判断

一棵深树很容易随样本的细小变化而改变。随机森林训练很多棵有差异的树,再把它们的预测合在一起。

常见做法包括:每棵树从训练集有放回抽样;每次分裂只考虑随机选出的一部分特征。不同树因此不会总犯完全相同的错误。回归森林通常取各树预测的平均值:

y^=1T∑t=1Tft(x) \hat y=\frac1T\sum_{t=1}^T f_t(\boldsymbol x)

TT 为树的数量,ftf_t 是第 tt 棵树。平均能减轻部分随机波动,但如果所有树都缺少同一个关键信息,大家一起投票也补不回来。

训练这些树可以相互独立进行,方便并行。更多树通常使平均更稳定,也需要更多训练时间、存储空间和预测计算。树之间的相关性、深度和叶子大小,仍然影响结果。

本章示例只有一个输入特征,回归森林也使用全部特征,因此树之间的差异主要来自有放回抽样。多特征任务中可以调整 max_features,引入特征选择的随机性。

4. 梯度提升:下一棵树去补前面的不足

随机森林让树分别拟合不同抽样;梯度提升则一棵接一棵地训练,后面的树针对当前模型还没处理好的部分。

先看平方误差。当前预测为 F(xi)F(x_i),标签为 yiy_i,把残差定义为:

ri=yi−F(xi) r_i=y_i-F(x_i)

这与前几章“预测减实际”的误差恰好相反,正残差表示还应该往上补。训练一棵小树 hh 去拟合这些残差,再更新:

F新(x)=F旧(x)+ηh(x) F_{\text{新}}(x)=F_{\text{旧}}(x)+\eta h(x)

η\eta 是缩减每次修正幅度的学习率。例如原预测为 5,新树建议加 1.5,学习率 0.1,就改为 5.15。

为什么叫“梯度”?平方损失对当前预测的负梯度与 y−F(x)y-F(x) 成正比。换一种损失,就用相应的负梯度作为下一棵树拟合的目标。理解这一点,会发现第二章的梯度思想在这里换了一个工作对象:我们逐步加上函数,改进整套预测。

树的数量、每棵树的复杂度和学习率需要一起考虑。小学习率通常需要更多轮;不断增加轮数也可能过拟合。可以用验证数据选择迭代数,或采用早停。

5. 让三种形状面对同一份数据

我们生成 240 条记录,输入在 0~1 之间。目标在 x=0.55x=0.55 附近有一次跳变:

y=2x+31{x>0.55}+εε∼N(0,0.122) \begin{aligned} y&=2x+3\mathbf1_{\{x>0.55\}}+\varepsilon\\ \varepsilon&\sim\mathcal N(0,0.12^2) \end{aligned}

这只是方便观察的合成任务。直线不容易同时照顾跳变两侧,树则可以直接用阈值把它们分开。

图 2:单棵树形成几段台阶;很多小树相加,可以组合出更细的变化。

下载完整脚本,在第八章的环境运行:

python src/examples/trees.py
"""第 10 章:在相同划分上比较直线、树和集成;合成数据。"""

import numpy as np
from sklearn.dummy import DummyRegressor
from sklearn.ensemble import GradientBoostingRegressor, RandomForestRegressor
from sklearn.linear_model import LinearRegression
from sklearn.metrics import mean_absolute_error
from sklearn.model_selection import KFold, cross_val_score, train_test_split
from sklearn.tree import DecisionTreeRegressor


def make_data():
    rng = np.random.default_rng(2026)
    x = rng.uniform(0, 1, (240, 1))
    y = 2*x[:, 0] + 3*(x[:, 0] > 0.55) + rng.normal(0, 0.12, 240)
    return train_test_split(x, y, test_size=0.25, random_state=2026)


def models():
    return {
        "均值基线": DummyRegressor(),
        "线性回归": LinearRegression(),
        "深度3的树": DecisionTreeRegressor(max_depth=3, min_samples_leaf=5,
                                           random_state=2026),
        "随机森林": RandomForestRegressor(n_estimators=100, max_depth=6,
                                          min_samples_leaf=3, random_state=2026,
                                          n_jobs=1),
        "梯度提升": GradientBoostingRegressor(n_estimators=100, learning_rate=0.05,
                                             max_depth=2, min_samples_leaf=5,
                                             random_state=2026),
    }


def compare(x_train, y_train):
    folds = KFold(n_splits=5, shuffle=True, random_state=2026)
    results = []
    for name, model in models().items():
        scores = -cross_val_score(model, x_train, y_train, cv=folds,
                                  scoring="neg_mean_absolute_error")
        model.fit(x_train, y_train)
        results.append((name, mean_absolute_error(y_train, model.predict(x_train)),
                        scores, model))
    return results


def main():
    x_train, x_test, y_train, y_test = make_data()
    results = compare(x_train, y_train)
    for name, train_mae, scores, _ in results:
        print(f"{name}:训练 MAE={train_mae:.3f},五折 MAE={scores.mean():.3f}")
    name, _, _, selected = min(results, key=lambda row: row[2].mean())
    errors = selected.predict(x_test)-y_test
    print(f"固定模型:{name};测试 MAE={np.abs(errors).mean():.3f}")
    for label, mask in [("阈值附近", np.abs(x_test[:, 0]-0.55) < 0.08),
                        ("其余位置", np.abs(x_test[:, 0]-0.55) >= 0.08)]:
        print(f"{label}:{mask.sum()} 条,MAE={np.abs(errors[mask]).mean():.3f}")


if __name__ == "__main__":
    main()

输出为:

均值基线:训练 MAE=1.978,五折 MAE=1.985
线性回归:训练 MAE=0.643,五折 MAE=0.649
深度3的树:训练 MAE=0.096,五折 MAE=0.123
随机森林:训练 MAE=0.087,五折 MAE=0.133
梯度提升:训练 MAE=0.082,五折 MAE=0.110
固定模型:梯度提升;测试 MAE=0.099
阈值附近:18 条,MAE=0.088
其余位置:42 条,MAE=0.104

所有候选都使用相同的开发集与五折划分,按五折 MAE 选择。保留测试只用于选定的梯度提升模型。这里没有给每个算法进行同样规模的超参数搜索,因此比较的是这几个具体配置,不能据此宣布某个算法永远优于另一个。

错误分析也给了我们一个小提醒:虽然跳变附近看起来可能更难,这次测试中它的 MAE 反而略小。不能先决定一个故事,再让数据替它作证。18 条样本也不足以说明这个区域长期更容易。

6. 怎样选择一个值得继续尝试的模型

方法常见优势需要留意的地方
线性模型参数少,训练和预测通常较轻便,系数易检查需要合适的特征表示,难以直接表达复杂交互
单棵浅树决策路径直观,能自然处理阈值关系对样本变化敏感,深树容易记住噪声
随机森林平均多棵树,常适合作为表格任务的比较对象模型体积与预测成本随树数增长
梯度提升逐步修正误差,能表达细致的非线性关系训练轮次、学习率和树复杂度需要共同选择

具体时间还取决于样本数、特征数、实现和硬件。本章没有做性能基准测试,不能从几行代码推出某个方法一定快多少倍。

树通常不需要把每列标准化,因为单调缩放不改变样本的排序和可分割关系。但缺失值支持、类别支持和未知类别行为取决于具体实现,仍要检查。树也不会自动阻止泄漏。

还有一个重要区别:普通回归树在每个叶子输出常数。输入离开训练范围后,仍落入最外侧叶子,预测不会像直线那样继续上升。森林和常见的树提升模型也有类似限制。拿它们预测远超训练范围的大任务,需要特别检验外推行为。

特征重要性可以帮助提出问题,但不等于因果作用。相关特征可能互相分摊重要性;某些按分裂统计的指标还会偏向可选切分较多的列。可以在独立验证数据上打乱某列观察性能变化,再结合字段含义分析。

7. 自测与小实验

  1. 六条示例数据不分裂时,为什么预测均值 5?
  2. 叶子越小,训练误差可能怎样变化?泛化一定改善吗?
  3. 随机森林的树怎样产生差异?
  4. 当前预测 5,新树给出修正 2,学习率 0.1,新预测是多少?
  5. 二分类节点里两类各一半,基尼不纯度是多少?
  6. 为什么本章不能推出“梯度提升在所有数据上最好”?
  7. 普通回归树能否在训练范围之外自动延续线性增长趋势?
展开参考答案
  1. 对同一叶子的所有标签预测同一个数时,均值使平方误差之和最小。
  2. 通常更容易降低训练误差,但也更容易跟随噪声,不保证泛化改善。
  3. 通常通过有放回抽样和分裂时随机选择候选特征。本章单特征示例主要使用前者。
  4. 为 5+0.1×2=5.25+0.1\times2=5.2。
  5. 为 1−0.52−0.52=0.51-0.5^2-0.5^2=0.5。
  6. 这里只比较一个合成任务、固定划分和有限配置,其他任务与调参结果可能不同。
  7. 不能。叶子预测是常数,离开范围后通常仍使用边缘叶子的值。

选做:把生成规则中的跳变项删除,只保留 2x2x 和噪声,重新进行完整比较。再把噪声标准差增大,观察浅树与集成的差异。每次只改一个条件,才能看清原因。

下一章暂时放下标签,探索无监督学习与表示。决策树与集成的实现细节,可查阅 scikit-learn 的树模型指南和集成学习指南。