后一棵树怎样修正前一棵?从残差到梯度提升树
从随机森林无法继续降低共同偏差出发,手算两轮残差拟合,推导负梯度与逐步加法模型,并实现可诊断的梯度提升回归。
上一篇的随机森林让许多深树并行生长,再平均它们的预测。它擅长降低单棵树的高方差,但所有树若都漏掉同一种规律,平均只会稳定地保留这份共同偏差。
例如房价真实关系包含一个细小的“学区边界”,而每棵树都因叶子限制没有学到它。再增加 1,000 棵相似的树,遗漏不会自动消失。我们需要让新树看到旧模型还错在哪里,而不是从头回答同一个问题。
梯度提升(Gradient Boosting)采取顺序纠错:当前模型先预测,下一棵浅树拟合损失最希望修正的方向,再把这份修正加回模型。本文只讲透这条数据流,以及它为什么从“拟合残差”推广为“拟合负梯度”。
01 从并行平均转向顺序加法#
随机森林中的树可以独立训练:
同一训练集
├── 随机数据/特征 ─► Tree 1 ─┐
├── 随机数据/特征 ─► Tree 2 ─┼─► 平均
└── 随机数据/特征 ─► Tree M ─┘text梯度提升树(Gradient Boosted Decision Trees,GBDT)则有严格的先后依赖:
X [N,D] ─► 当前模型 F₀ ─► prediction₀ [N]
│ 与 y [N] 比较
▼
correction₁ [N]
│ 拟合
▼
Tree 1
│ 加到 F₀
▼
F₁ = F₀ + η Tree 1
│
重复 M 轮
▼
F_M(x) = F₀(x) + ηΣ Tree_m(x)text第 轮的模型写成逐步加法模型(Stage-wise Additive Model):
- :前 轮树的总和;
- :第 棵回归树输出的修正值;
- :学习率(Learning Rate),也叫收缩系数;
- :加入新树后的预测。
这里每棵树通常很浅。单棵树只负责一小块可解释的修正,复杂函数由许多小步累积出来。
02 为什么平方误差下会拟合残差?#
先考虑回归和半平方误差:
定义当前残差:
若下一棵树能精确输出 ,且 ,新预测就会变成真实值:
树无法逐点记住所有残差时,会把特征空间切成若干叶子,在每个叶子输出相近残差的平均值。因此它学到的是:“什么样的样本,目前被系统性低估或高估?”
当前预测太高:r < 0 ─► 新树输出负修正 ─► 预测下降
当前预测正确:r ≈ 0 ─► 新树输出接近 0 ─► 基本不动
当前预测太低:r > 0 ─► 新树输出正修正 ─► 预测上升text03 用四个样本手算两轮提升#
给定一个特征 和四个回归目标:
| 样本 | ||
|---|---|---|
| 1 | 1 | 3 |
| 2 | 2 | 5 |
| 3 | 3 | 4 |
| 4 | 4 | 8 |
第 0 轮:从常数预测开始#
平方误差下最好的常数是目标均值:
因此:
y: [ 3, 5, 4, 8 ]
prediction_0: [ 5, 5, 5, 5 ]
residual_1: [-2, 0,-1, 3 ]text初始均方误差为:
第 1 轮:用树桩拟合残差#
假设树桩选择 :左叶残差均值为 ,右叶为 。
令学习率 :
h_1(x): [-1,-1, 1, 1]
η h_1(x): [-0.5,-0.5, 0.5, 0.5]
prediction_1: [ 4.5, 4.5, 5.5, 5.5]
residual_2: [-1.5, 0.5,-1.5, 2.5]text新均方误差为 。第一棵树没有直接预测房价,它只把低 区域整体向下修正、高 区域整体向上修正。
第 2 轮:只看剩下的错误#
第二棵树拟合 residual_2。假设它选择 :
乘上 后:
η h_2(x): [-0.417,-0.417,-0.417, 1.250]
prediction_2: [ 4.083, 4.083, 5.083, 6.750]
residual_3: [-1.083, 0.917,-1.083, 1.250]text均方误差降到约 。两棵简单树叠加后,已经能表达任何一棵树桩单独无法表达的三级预测。
交互手算:若第一轮 η=1,会怎样?
第一轮预测会变成 [4,4,6,6],MSE 为 2,比 η=0.5 的 2.75 更低。但单步下降更多不保证验证误差更好;较小学习率让后续树有机会用更细的步骤修正,通常需要更多轮数。
04 “负梯度”怎样统一不同损失?#
残差只在平方误差下恰好成立。若目标改成稳健回归的绝对误差、二分类的对数损失,临时目标该是什么?
把每个训练点当前的预测 看成一个可调整变量。损失对它的导数是最陡上升方向,所以最陡下降方向为:
g_{im} =-left. \frac{\partial L(y_i,F(x_i))}{\partial F(x_i)} \right|_{F=F_{m-1}}这叫伪残差(Pseudo-residual)或负梯度。第 棵树拟合训练对:
对半平方误差:
负梯度正好就是普通残差。于是“拟合残差”不是一条孤立技巧,而是函数空间梯度下降(Gradient Descent in Function Space)在平方误差下的具体形式。
| 损失 | 当前模型最关注的方向 | 直觉 |
|---|---|---|
| 平方误差 | 大残差得到更大修正 | |
| 绝对误差 | 残差符号(不可导点取次梯度) | 降低异常值支配 |
| 二元对数损失 | (以 logit 为模型输出) | 修正预测概率与标签的差 |
05 训练与推理的完整伪代码#
input:
X [N,D], y [N]
rounds M, learning rate η
initialize F_0 as the best constant for the loss
for m = 1 ... M:
prediction = F_{m-1}(X) # [N]
pseudo_residual = -dL(y, prediction)
/ d prediction # [N]
fit a shallow regression tree h_m:
X [N,D] -> pseudo_residual [N]
optionally solve the best value per leaf
F_m(x) = F_{m-1}(x) + η h_m(x)
return F_Mtext推理时没有残差、标签或反向传播:
X_query [Q,D]
├── F_0 [Q]
├── η h_1(X_query) [Q]
├── η h_2(X_query) [Q]
└── ... + η h_M(X_query) [Q]
│ element-wise sum
▼
prediction [Q]text这也解释了为什么提升树难以像随机森林那样并行训练,却可以在树建好后批量并行计算部分节点和样本。
06 不依赖黑盒,写出最小平方误差提升#
下面只用 NumPy 写两轮“决策树桩 + 残差”,把关键状态全部暴露出来:
import numpy as np
X = np.array([[1.0], [2.0], [3.0], [4.0]]) # [N=4,D=1]
y = np.array([3.0, 5.0, 4.0, 8.0]) # [N]
learning_rate = 0.5
def fit_stump(feature, target):
"""返回使叶内平方误差最小的阈值和两个叶值。"""
candidates = (feature[:-1] + feature[1:]) / 2
best = None
for threshold in candidates:
left = feature <= threshold
right = ~left
left_value = target[left].mean()
right_value = target[right].mean()
output = np.where(left, left_value, right_value)
squared_error = np.sum((target - output) ** 2)
if best is None or squared_error < best['error']:
best = {
'threshold': threshold,
'left_value': left_value,
'right_value': right_value,
'error': squared_error,
}
return best
prediction = np.full_like(y, y.mean()) # [N]
trees = []
for round_id in range(2):
negative_gradient = y - prediction # [N]
stump = fit_stump(X[:, 0], negative_gradient)
correction = np.where(
X[:, 0] <= stump['threshold'],
stump['left_value'],
stump['right_value'],
) # [N]
prediction += learning_rate * correction
trees.append(stump)
mse = np.mean((y - prediction) ** 2)
print(round_id + 1, stump, prediction, mse)python真实实现需要处理多特征、深树、任意损失、叶值优化、采样和高效直方图,但训练语义没有改变:先计算当前模型的方向,再拟合一棵树并缩小后相加。
07 用当前 scikit-learn API 落地并观察每一轮#
scikit-learn 1.9 的 GradientBoostingRegressor 是经典的精确梯度提升实现。下面显式留出测试集;估计器内部再从训练数据中留出早停验证集:
import numpy as np
from sklearn.ensemble import GradientBoostingRegressor
from sklearn.metrics import root_mean_squared_error
from sklearn.model_selection import train_test_split
X_train, X_test, y_train, y_test = train_test_split(
X, y, test_size=0.20, random_state=42
)
model = GradientBoostingRegressor(
loss='squared_error',
learning_rate=0.05,
n_estimators=1000,
max_depth=2,
min_samples_leaf=5,
subsample=0.8,
validation_fraction=0.15,
n_iter_no_change=30,
tol=1e-5,
random_state=42,
)
model.fit(X_train, y_train)
test_prediction = model.predict(X_test) # [num_test]
print('trees actually used:', model.n_estimators_)
print('test RMSE:', root_mean_squared_error(y_test, test_prediction))python关键参数不是彼此独立的:
n_estimators:最大提升轮数;回归的每轮通常增加一棵树;learning_rate:每棵树加入总模型前的缩放;更小通常需要更多树;max_depth:单棵修正树的交互复杂度;深度 2 最多直接表达二阶特征交互;min_samples_leaf:阻止极小叶对少数样本给出激进修正;subsample < 1:每轮用部分训练样本,形成随机梯度提升并降低相关性;n_iter_no_change:内部验证损失连续若干轮没有足够改善就停止。
使用 staged_predict 可以看到模型随树数增加的验证轨迹:
validation_rmse = []
for round_id, prediction in enumerate(model.staged_predict(X_test), start=1):
validation_rmse.append(root_mean_squared_error(y_test, prediction))
best_round = int(np.argmin(validation_rmse)) + 1
print('best observed round:', best_round)python这段示例为了展示 API 使用 X_test 画轨迹;正式项目中应改用验证集,测试集只能在轮数和全部超参数冻结后评估一次。
08 数据变大时为什么常用直方图提升?#
精确枚举许多连续特征的候选阈值代价较高。直方图梯度提升(Histogram-based Gradient Boosting)先把连续值分箱,再在箱边界上累计梯度统计量。
原始特征值:0.13 0.18 0.19 0.44 0.47 0.91 ...
│ quantile / histogram bins
▼
箱编号: 0 0 0 1 1 3 ...
│ 每箱聚合梯度
▼
只比较有限箱边界textscikit-learn 1.9 对中大型数据推荐 HistGradientBoostingRegressor;文档给出的经验起点是 。它使用 max_iter 表示轮数,并原生处理数值缺失值:
from sklearn.ensemble import HistGradientBoostingRegressor
hist_model = HistGradientBoostingRegressor(
loss='squared_error',
learning_rate=0.05,
max_iter=1000,
max_leaf_nodes=15,
min_samples_leaf=20,
l2_regularization=1.0,
early_stopping=True,
validation_fraction=0.15,
n_iter_no_change=30,
random_state=42,
)
hist_model.fit(X_train, y_train)
print('iterations:', hist_model.n_iter_)
print('prediction shape:', hist_model.predict(X_test).shape)python原生接收 NaN 只表示算法能学习缺失值路由,并不证明缺失机制合理。训练与线上缺失率差异、把“未知”编码成 0、类别列类型漂移,仍会让模型失效。
09 学习率、树深与轮数如何一起控制容量?#
| 旋钮 | 调大后的直接变化 | 常见风险 |
|---|---|---|
learning_rate | 单棵树修正更强 | 很快追逐噪声、验证损失反弹 |
| 树深 / 叶数 | 单轮可表达更复杂交互 | 小叶、不稳定、内存与延迟增加 |
| 提升轮数 | 累积更多修正 | 训练损失继续降而验证损失升 |
min_samples_leaf | 每个叶子证据更多 | 过大时遗漏局部结构 |
subsample 降低 | 每轮数据更随机 | 太低时单树方向噪声过大 |
最稳妥的流程是:先用较浅的树和较小学习率,给足较大的最大轮数,再用独立验证信号早停。不要只按“100 棵树是默认值”决定训练长度。
training loss ╲________________
validation loss ╲______╱
▲
最佳轮数附近text10 常见错误与最短调试路径#
- 把每轮目标固定成原始标签。 第二棵树开始必须依赖当前预测的负梯度;否则只是训练了很多重复树。
- 认为残差适用于任意损失。 非平方误差要按损失对当前输出求负梯度,并可能重新求每个叶子的最佳值。
- 用训练损失选择树数。 训练损失通常随轮数继续下降;树数应由验证曲线和业务指标决定。
- 同时使用深树、高学习率和很多轮。 三个容量旋钮会叠加,常导致快速过拟合。
- 早停验证集发生泄漏。 时间、患者或用户分组任务不能由内部随机切分代替结构化验证。
- 把特征重要性当因果解释。 分裂增益只描述模型使用了什么;相关特征、高基数和泄漏列都会误导。
- 线上漏加部分树。 推理模型必须保存初始常数、树顺序、学习率和实际最佳轮数,任一不一致都会改变输出。
- 只监控平均延迟。 提升树按顺序累加大量成员;同时测模型大小、P50/P99 延迟和峰值内存。
最小数值检查:
assert X_train.ndim == 2 and y_train.ndim == 1
assert X_train.shape[0] == y_train.shape[0]
assert np.isfinite(y_train).all()
assert model.n_estimators_ <= model.n_estimators
assert np.isfinite(model.predict(X_test)).all()python若第一棵树后训练损失完全不变,检查目标是否为常数、特征是否都缺失、min_samples_leaf 是否大到无法分裂;若训练损失下降而验证损失从第一轮就上升,优先检查数据划分、泄漏、分布偏移和目标定义,不要先堆更多树。
11 它与相近方法有什么区别?#
| 方法 | 成员关系 | 每个成员学什么 | 主要改善 |
|---|---|---|---|
| 单棵决策树 | 无集成 | 直接拟合标签 | 可追踪规则 |
| 随机森林 | 并行、近似独立 | 各自拟合标签 | 通过平均降低方差 |
| AdaBoost | 顺序 | 提高错分样本权重 | 聚焦困难样本 |
| GBDT | 顺序 | 拟合当前损失的负梯度 | 逐轮降低一般可导损失 |
| 神经网络梯度下降 | 同一网络内迭代 | 更新参数张量 | 在固定可微结构中优化 |
GBDT 在表格数据、非线性阈值和特征交互上通常很强,但它不会自然向训练范围外做平滑线性外推;面对超高维稀疏文本、图像原始像素、序列结构或需要端到端表示学习的任务,线性模型和神经网络往往有更合适的归纳偏置。
12 今天真正需要记住什么?#
- 梯度提升不是平均许多独立树,而是让新树顺序修正当前模型仍犯的错误。
- 平方误差下,损失对预测的负梯度恰好等于残差,所以新树拟合残差。
- 一般损失下,每轮树拟合伪残差,即损失对当前函数输出的负梯度。
- 学习率缩小单轮修正,树深控制单轮交互,轮数控制累积容量;三者必须联合验证。
- 训练损失持续下降不代表应该继续加树,结构化验证和早停决定可用轮数。
13 思考题与小练习#
练习 1:完成第三轮手算
从本文第二轮残差 [-1.083, 0.917, -1.083, 1.250] 出发,分别尝试阈值 1.5、2.5、3.5,计算左右叶均值与叶内平方误差。选出最佳树桩,再用 η=0.5 更新预测。
练习 2:推导二分类伪残差
令模型输出 logit ,概率 ,二元交叉熵为 。证明损失对 的负梯度是 ,并解释为什么“自信地预测错”会得到较大修正。
练习 3:画出验证轨迹
固定 max_depth=2,比较 (learning_rate, n_estimators) 为 (0.2,100)、(0.05,400)、(0.01,2000) 的训练与验证 RMSE。记录最佳轮数、模型大小和推理时间,而不只比较最低 RMSE。
相关工作#
- Friedman: Greedy Function Approximation—A Gradient Boosting Machine ↗:把 Boosting 表述为函数空间数值优化的奠基论文。
- Friedman: Stochastic Gradient Boosting ↗:引入行采样,讨论随机性对准确率与稳健性的作用。
- Mason et al.: Boosting Algorithms as Gradient Descent ↗:从函数空间梯度下降统一理解 Boosting。
- scikit-learn: GradientBoostingRegressor ↗:当前逐步加法、负梯度、早停与参数接口。
- scikit-learn: HistGradientBoostingRegressor ↗:当前直方图、缺失值、类别特征和约束接口。
14 下一篇预告#
经典 GBDT 用一阶负梯度告诉新树“往哪里改”,但没有直接利用损失曲率回答“应该改多大”,树的叶值与分裂也缺少统一的正则化评分。下一篇将从二阶泰勒展开推导 XGBoost 的叶权重和分裂增益,手算 reg_lambda、gamma 与 min_child_weight 究竟拦住了什么。