从 Python 调用追踪到 LightGBM 的一次分裂
完成训练实验后,再读源码就有了可验证的问题:梯度在哪里生成,阈值由谁选择,学习率在哪里应用,为什么导出的第一棵树叶子不是残差?
本页只跟踪 v4.6.0、CPU、单机、内置平方损失、普通 GBDT 的路径。你需要能阅读函数调用、循环和数组;不要求先理解所有 C++ 模板,也不要求本地编译。
1. 先画出职责,而不是打开整个仓库
Python engine.train:管理轮次和回调
→ Python Booster.update:请求训练一轮
→ C API LGBM_BoosterUpdateOneIter:跨语言入口
→ GBDT::TrainOneIter:计算梯度、训练新树、更新总分数
→ 目标函数 GetGradients:每行的 g、h
→ SerialTreeLearner::Train:一棵树内部的叶子生长
→ 直方图候选搜索:统计、叶子输出、增益
→ Shrinkage、UpdateScore、AddBias
→ Python 验证指标与早停回调这里两个循环不能混淆:外层每次增加一轮模型,内层不断增加当前树的叶子。在本页回归设置下一轮对应一棵树;多分类等设置不能机械沿用“一轮就是一棵”的说法。
2. Python 控制一轮何时开始和结束
打开 engine.py 的训练循环,定位 booster.update。它的前后分别是迭代前回调、验证计算和迭代后回调。
因此,早停不是“树长到某个叶子就触发验证”。在本实验的 Python 训练接口中,它在轮次边界检查验证结果。record_evaluation 记录的也是逐轮指标。
再读 basic.py 的 Booster.update。内置目标使用 LGBM_BoosterUpdateOneIter;自定义目标有另外的梯度入口。先只跟踪本实验选中的分支。
3. C API 把请求交给提升模型
在 c_api.cpp 找到同名入口,沿内部对象调用到 TrainOneIter。这一层处理跨语言接口,最优阈值不在这里计算。
进入 GBDT::TrainOneIter,依次识别:初始分数、梯度生成、采样分支、树学习器、新树输出调整、学习率与累计分数更新。
把前面的手写实现放在旁边,做如下对应:
| 手写对象或动作 | 成熟实现中的职责 |
|---|---|
base=mean(y) | 初始化分数,内置回归目标给出初始值 |
residual=y-prediction | 目标函数按当前 score 产生梯度与 Hessian |
CARTRegressor.fit | 树学习器根据梯度统计训练结构 |
learning_rate * correction | 新树输出 shrinkage |
prediction += ... | 累计训练及验证分数更新 |
表中是语义对应,不代表代码行数、数据布局或全部目标行为相同。
4. 在目标函数里找回四个数
打开 RegressionL2loss::GetGradients。在无权重分支,score 减 label 生成梯度,Hessian 为 1。
对本例初始 score=2,必须得到 g=[1,1,-1,-1]、h=[1,1,1,1]。如果你把梯度解释为 y-score,这里就会与源码相反;负号在叶子输出公式里出现,不能在两个位置重复反转。
5. 找到“选哪个叶子”的确切位置
在 SerialTreeLearner::Train,循环次数受叶子预算控制。FindBestSplits 更新候选,然后 ArgMax(best_split_per_leaf_) 挑出最佳叶子;没有正增益时停止,否则调用 Split。
这是“leaf-wise”的可核查证据。只看类名,或者看到 max_depth 参数,都不足以推断它是逐层生长。
继续到 feature_histogram.hpp 的叶子输出 和 GetLeafGain,对应
6. 为什么 dump 的第一棵树已经是 1.5 和 2.5
回到 新树更新段,观察顺序:输出更新、Shrinkage、UpdateScore,随后必要时 AddBias。
本例的第一棵修正树是 -1、1。乘学习率 0.5 后为 -0.5、0.5,再吸收初始常数 2,保存为 1.5、2.5。第二棵树保存的贡献是 -0.25、0.25;两树相加得到 1.25、2.75。
所以解释模型文件时,不能再次给每片已缩放的叶子乘学习率,也不能再额外加一遍初始常数。这是本实验路径的存储行为,不应脱离初始化配置推广到所有模型。
7. 源码阅读实验
重新运行上一页脚本,保存打印的第一棵树。只回答五个问题,并在上述固定 tag 链接中圈出依据:
- 一轮训练在哪个 Python 循环开始?
- 无权重平方损失的 h 在哪里设为 1?
- 哪一步选择所有叶子中的最佳候选?
- 学习率和初始常数分别在哪一步进入树值?
split_gain=4为什么不等于半平方损失下降 2?
评分与参考答案
每题 2 分:必须同时说清职责和找到函数,只有函数名给 1 分。
engine.train的轮次循环调用booster.update。RegressionL2loss::GetGradients的无权重分支。SerialTreeLearner::Train对best_split_per_leaf_求 ArgMax。GBDT::TrainOneIter中Shrinkage缩放,随后相应初始化分支的AddBias加常数。- 普通叶子增益分数省略公共 1/2;需要统一损失尺度。
以上路径在 2026-09-07 对照 v4.6.0 源码核查,数字由本章脚本验证;本次没有编译或逐指令调试 C++。升级版本后应复核函数和分支,不把行号当永久接口。
下一步:第 1 章复习与验收。参数和公式随时查 树模型与 LightGBM 速查。