从零实现平方损失 GBDT
目标、环境与输入
实现上一节的加法模型,而不是调用现成 boosting API。Python 3.9+ 标准库、CPU、四个固定样本,无网络与输出文件。预计编码和检查 45 分钟,脚本运行通常不到一秒。
待验证命题:训练时维护的逐轮预测,应与训练后重新经过所有树计算出的预测一致。
1. 只增加三类状态
复用 CART 实验中的完整源码,重点看 SquaredGBDT:
base保存标签均值;trees保存按轮次训练的修正树;learning_rate控制每棵树贡献。
train_mse 和 trace 是教学用观察记录,不是预测所需参数。重新调用 fit 必须清空旧树,否则会把两个训练过程混在一起。
2. 训练循环的因果顺序
先创建 prediction=[训练标签均值]*样本数
每一轮:
residual = y - prediction
新树.fit(X, residual)
correction = 新树.predict(X)
prediction += learning_rate * correction
保存这棵树、预测轨迹和误差树的输入仍是原始 X,训练标签换成当前残差。对每个样本,prediction 代表整个模型的累计结果。
预测函数从 base 重新开始,依次加上 learning_rate * tree.predict(X)。不要给初始常数乘学习率,也不要把树的输出求平均。
3. 执行与核对
python3 examples/python/tree_boosting.py2026-09-07 实际运行得到:
| 轮次 | 训练标签,也就是本轮残差 | 更新后预测 | 训练 MSE |
|---|---|---|---|
| 0 | 尚未训练树 | [2,2,2,2] | 1 |
| 1 | [-1,-1,1,1] | [1.5,1.5,2.5,2.5] | 0.25 |
| 2 | [-0.5,-0.5,0.5,0.5] | [1.25,1.25,2.75,2.75] | 0.0625 |
脚本断言训练轨迹的具体值及最终 predict 的值。第三方实验还与 scikit-learn 的 GradientBoostingRegressor(n_estimators=2, learning_rate=0.5, max_depth=1) 比较,包括训练区间外的探针输入。运行命令见 CART 实验。
4. 改一个条件,再解释结果
- 学习率改为 0.1,保持两轮:预期误差比 0.5 的两轮模型大,本例应为
。 - 学习率保持 0.5,轮数改为 3:预期 MSE 为 0.015625。
- 在同一对象上重新训练
y_new=2*y+5:树的数量仍为 2,预测变为[7.5,7.5,10.5,10.5]。 - 故意固定第一轮残差、不再更新:第二轮会重复第一棵修正树,本例输出恰好成为真值,看起来更好,但算法已经错误。用逐轮残差检查识别它,不能只要求 loss 下降。
前三类中的学习率与重新训练行为已进入 self_check。第三轮是独立实现迁移题,先手算再执行。第四项只在自己的练习副本中注入,不修改正文的共享基线实现。
5. 失败定位与边界
| 症状 | 优先检查 |
|---|---|
| 第一次更新向错误方向移动 | 残差是否写成 prediction-y |
| 两次 fit 后树数翻倍 | 是否重置 trees |
| 训练记录正确、predict 不同 | 初始常数、学习率、累加轮数是否一致 |
| 分类数据上产生任意实数 | 这是平方损失回归器,没有分类链接函数 |
本实现没有样本权重、早停、分箱、缺失值、类别特征和模型存盘。它的任务是验证最小提升闭环;下面会在成熟实现中学习这些能力。即使它在四行数据上与库一致,也不能推导出速度、数值鲁棒性或全部边界行为等价。
自测、清理与下一步
不看代码,解释新样本 x=5 在两轮模型中的计算路径。
参考答案
初始预测 2;第一棵树的右叶输出 1,增加 0.5;第二棵右叶输出 0.5,再增加 0.25;最终为 2.75。答案来自训练时保存的规则和叶子,不来自新样本标签。
脚本无需清理。保存自己的代码、环境版本和三个变式的结果,才算完成独立实现。下一步:LightGBM 如何计算叶子值与分裂增益。