Skip to content

从零实现平方损失 GBDT ​

目标、环境与输入 ​

实现上一节的加法模型,而不是调用现成 boosting API。Python 3.9+ 标准库、CPU、四个固定样本,无网络与输出文件。预计编码和检查 45 分钟,脚本运行通常不到一秒。

待验证命题:训练时维护的逐轮预测,应与训练后重新经过所有树计算出的预测一致。

1. 只增加三类状态 ​

复用 CART 实验中的完整源码,重点看 SquaredGBDT:

  • base 保存标签均值;
  • trees 保存按轮次训练的修正树;
  • learning_rate 控制每棵树贡献。

train_mse 和 trace 是教学用观察记录,不是预测所需参数。重新调用 fit 必须清空旧树,否则会把两个训练过程混在一起。

2. 训练循环的因果顺序 ​

text
先创建 prediction=[训练标签均值]*样本数
每一轮:
    residual = y - prediction
    新树.fit(X, residual)
    correction = 新树.predict(X)
    prediction += learning_rate * correction
    保存这棵树、预测轨迹和误差

树的输入仍是原始 X,训练标签换成当前残差。对每个样本,prediction 代表整个模型的累计结果。

预测函数从 base 重新开始,依次加上 learning_rate * tree.predict(X)。不要给初始常数乘学习率,也不要把树的输出求平均。

3. 执行与核对 ​

bash
python3 examples/python/tree_boosting.py

2026-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. 改一个条件,再解释结果 ​

  1. 学习率改为 0.1,保持两轮:预期误差比 0.5 的两轮模型大,本例应为 0.94=0.6561。
  2. 学习率保持 0.5,轮数改为 3:预期 MSE 为 0.015625。
  3. 在同一对象上重新训练 y_new=2*y+5:树的数量仍为 2,预测变为 [7.5,7.5,10.5,10.5]。
  4. 故意固定第一轮残差、不再更新:第二轮会重复第一棵修正树,本例输出恰好成为真值,看起来更好,但算法已经错误。用逐轮残差检查识别它,不能只要求 loss 下降。

前三类中的学习率与重新训练行为已进入 self_check。第三轮是独立实现迁移题,先手算再执行。第四项只在自己的练习副本中注入,不修改正文的共享基线实现。

5. 失败定位与边界 ​

症状优先检查
第一次更新向错误方向移动残差是否写成 prediction-y
两次 fit 后树数翻倍是否重置 trees
训练记录正确、predict 不同初始常数、学习率、累加轮数是否一致
分类数据上产生任意实数这是平方损失回归器,没有分类链接函数

本实现没有样本权重、早停、分箱、缺失值、类别特征和模型存盘。它的任务是验证最小提升闭环;下面会在成熟实现中学习这些能力。即使它在四行数据上与库一致,也不能推导出速度、数值鲁棒性或全部边界行为等价。

自测、清理与下一步 ​

不看代码,解释新样本 x=5 在两轮模型中的计算路径。

参考答案

初始预测 2;第一棵树的右叶输出 1,增加 0.5;第二棵右叶输出 0.5,再增加 0.25;最终为 2.75。答案来自训练时保存的规则和叶子,不来自新样本标签。

脚本无需清理。保存自己的代码、环境版本和三个变式的结果,才算完成独立实现。下一步:LightGBM 如何计算叶子值与分裂增益。

最近更新