决策树与集成学习
直线模型会把所有输入放进同一个表达式。但有些规律更像一串问题:是不是彩印?页数是否超过某个值?队列里是否已经有很多任务?
决策树就沿着这样的提问往下走。它把输入空间分成若干区域,每个区域给一个预测。我们先看一棵很小的树,再看看许多棵树怎样一起工作。
1. 一个问题,把数据分成两边
假设只有六条记录:
| 输入 | 标签 |
|---|---|
| 1 | 1 |
| 2 | 1 |
| 3 | 2 |
| 4 | 8 |
| 5 | 9 |
| 6 | 9 |
如果所有记录都给同一个预测,平方误差最小时应该预测平均值 5,平方误差之和为 82。
现在问一句:“ 吗?”左边标签是 1、1、2,右边是 8、9、9。两边分别预测自己的均值 与 ,总平方误差降为 。
图 1:末端的区域叫叶子。采用平方误差的回归树,在每个叶子预测该叶训练标签的均值。
训练算法会枚举可用特征与候选阈值,寻找误差下降最多的切法。对一个叶子里的样本集合 ,预测值与误差为:
表示这个集合的样本数。每次分裂比较的是分裂前的 与分裂后两个子区域误差之和。常见算法采取贪心策略:当前这一步选最好的切法,再继续往下分,不保证找到所有可能树结构中最优的那一棵。
2. 树长得越深,就一定越聪明吗
如果继续切到每个叶子只有一条记录,训练误差可以变成零。但新样本落进某个小区域时,得到的可能只是附近一条带噪声记录的值。
限制树的深度、要求叶子至少有若干样本,或对长好的树进行剪枝,都能控制复杂度。深度为 3 表示从根到最深叶子最多经过三次分裂,不是“总共只有三个节点”。
分类树的叶子可以记录各类别比例,输出类别概率。例如一个叶子里 8 个正例、2 个负例,经验正例比例为 0.8。这个小叶子给出的 0.8 仍可能不稳定,不能只因输出了小数就认为它已经校准。
分类时常用基尼不纯度来选择分裂:
是当前节点中第 类的比例。全是同一类时 ;二分类各占一半时 。分裂时比较子节点按样本数加权后的不纯度。第六章用到的连续标签平方误差,和这里的类别不纯度,是两种不同任务的选择标准。
3. 随机森林:让多棵树分别做判断
一棵深树很容易随样本的细小变化而改变。随机森林训练很多棵有差异的树,再把它们的预测合在一起。
常见做法包括:每棵树从训练集有放回抽样;每次分裂只考虑随机选出的一部分特征。不同树因此不会总犯完全相同的错误。回归森林通常取各树预测的平均值:
为树的数量, 是第 棵树。平均能减轻部分随机波动,但如果所有树都缺少同一个关键信息,大家一起投票也补不回来。
训练这些树可以相互独立进行,方便并行。更多树通常使平均更稳定,也需要更多训练时间、存储空间和预测计算。树之间的相关性、深度和叶子大小,仍然影响结果。
本章示例只有一个输入特征,回归森林也使用全部特征,因此树之间的差异主要来自有放回抽样。多特征任务中可以调整 max_features,引入特征选择的随机性。
4. 梯度提升:下一棵树去补前面的不足
随机森林让树分别拟合不同抽样;梯度提升则一棵接一棵地训练,后面的树针对当前模型还没处理好的部分。
先看平方误差。当前预测为 ,标签为 ,把残差定义为:
这与前几章“预测减实际”的误差恰好相反,正残差表示还应该往上补。训练一棵小树 去拟合这些残差,再更新:
是缩减每次修正幅度的学习率。例如原预测为 5,新树建议加 1.5,学习率 0.1,就改为 5.15。
为什么叫“梯度”?平方损失对当前预测的负梯度与 成正比。换一种损失,就用相应的负梯度作为下一棵树拟合的目标。理解这一点,会发现第二章的梯度思想在这里换了一个工作对象:我们逐步加上函数,改进整套预测。
树的数量、每棵树的复杂度和学习率需要一起考虑。小学习率通常需要更多轮;不断增加轮数也可能过拟合。可以用验证数据选择迭代数,或采用早停。
5. 让三种形状面对同一份数据
我们生成 240 条记录,输入在 0~1 之间。目标在 附近有一次跳变:
这只是方便观察的合成任务。直线不容易同时照顾跳变两侧,树则可以直接用阈值把它们分开。
图 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. 自测与小实验
- 六条示例数据不分裂时,为什么预测均值 5?
- 叶子越小,训练误差可能怎样变化?泛化一定改善吗?
- 随机森林的树怎样产生差异?
- 当前预测 5,新树给出修正 2,学习率 0.1,新预测是多少?
- 二分类节点里两类各一半,基尼不纯度是多少?
- 为什么本章不能推出“梯度提升在所有数据上最好”?
- 普通回归树能否在训练范围之外自动延续线性增长趋势?
展开参考答案
- 对同一叶子的所有标签预测同一个数时,均值使平方误差之和最小。
- 通常更容易降低训练误差,但也更容易跟随噪声,不保证泛化改善。
- 通常通过有放回抽样和分裂时随机选择候选特征。本章单特征示例主要使用前者。
- 为 。
- 为 。
- 这里只比较一个合成任务、固定划分和有限配置,其他任务与调参结果可能不同。
- 不能。叶子预测是常数,离开范围后通常仍使用边缘叶子的值。
选做:把生成规则中的跳变项删除,只保留 和噪声,重新进行完整比较。再把噪声标准差增大,观察浅树与集成的差异。每次只改一个条件,才能看清原因。
下一章暂时放下标签,探索无监督学习与表示。决策树与集成的实现细节,可查阅 scikit-learn 的树模型指南和集成学习指南。