梯度提升分类树这东西很多人用起来贼顺手一个GradientBoostingClassifier丢进去调两轮参数就出结果了。但真有人问你为什么每一轮拟合的是y - p而不是y - F损失函数是怎么一步步化简成这玩意的能完整答上来的人我见到的连三成都没有。我之前也属于调包为主、推导为辅的那一挂直到有一次要自定义一个非对称损失时被源码里二阶导Hessian的处理方式卡了一天才咬牙把从原始损失函数到负梯度的每一步化简重新在草稿纸上推了一遍。推完才发现整套逻辑通顺得可怕而且一旦掌握这个化简路径再看 XGBoost、LightGBM 甚至 sklearn 的源码等于拿着地图逛街。这篇文章我尽量把整个化简过程写得像跟人唠嗑一样从二分类的交叉熵开始一路推到梯度提升里那个每天被调用的负梯度公式把每一步为什么要做这一步都交代清楚。看完你至少能解决三个问题GBDT分类树到底在拟合什么、树叶子上的输出值是怎么算出来的、以及以后你想自定义一个损失函数时该怎么下手。1. 从拟合残差说起加法模型的损失到底在优化什么很多人在学GBDT时记住的第一句话是每一轮拟合残差。如果你用的是平方损失这没问题因为平方损失的负梯度恰好就是残差但它掩盖了一个更本质的事实GBDT每一轮拟合的根本不是某个固定的量而是损失函数在当前模型输出处的负梯度方向。只是恰好在平方损失下这个方向恰好等于残差。1.1 加法模型的形式化定义先摆出模型形式。梯度提升树维护的是一个加法模型F_m(x) F_{m-1}(x) eta * f_m(x)其中f_m是第m轮新训练出的决策树eta是学习率收缩系数。最终预测时直接输出F_M(x)如果是分类任务再套一个Sigmoid或Softmax转成概率。训练目标是让经验损失最小化L (1/N) * sum_i L(y_i, F(x_i))这里y_i是标签F(x_i)是模型当前对第i个样本的原始输出L是我们选定的损失函数比如对数损失。问题来了为什么不直接在整个函数空间上对这个目标做梯度下降因为在函数空间里做梯度下降理论上要求对每一个可能的x取值都要更新对应的F(x)把F当成一个逐点定义的向量来优化。但训练集里只有N个样本模型却需要在训练集之外的x上也有预测能力。直接优化N个样本上的F(x_i)值等于放弃泛化——在新样本上没有定义。所以必须把每次要加的那个增量f_m(x)限制在决策树这种有泛化能力的函数族里。1.2 泰勒展开化简的数学合法性那怎么求f_m这里就是整篇文章最关键的一步把损失函数在当前模型输出位置做一阶泰勒展开。对单个样本加上新树f_m(x_i)后损失变成L(y_i, F_{m-1}(x_i) f_m(x_i))。对F_{m-1}处做泰勒展开忽略二阶及以上项L(y_i, F_{m-1} f_m) ≈ L(y_i, F_{m-1}) g_i * f_m(x_i)其中g_i ∂L(y_i, F) / ∂F在 F F_{m-1} 处取值对整个数据集求和总损失的变化量近似为ΔL ≈ sum_i g_i * f_m(x_i) g, f_m如果想最小化这个内积而f_m的L2范数受限制别忘了决策树每轮加的都是一棵有限深度的树改变量有限那最优方向就是负梯度。因此让每棵新树去拟合-g_i负梯度就是当前情形下让损失下降最快的选择。实际的工程实现里树不是直接输出负梯度本身而是用平方误差去拟合负梯度值。为什么可以这样替换因为sum_i (-g_i - f_m(x_i))^2展开之后交叉项恰好就是2 * sum_i g_i * f_m(x_i)外加一个常数项最小化平方误差近似等同于最小化g, f_m的内积。这个用最小二乘替代内积优化的技巧在梯度提升里被反复使用处理起来非常方便——直接用现成的回归树拟合就能逼近梯度下降方向。到这里化简完成了第一步一个看起来复杂的损失最小化问题被等价转化成了用回归树拟合一组实数标签-g_i的问题。所有后续工作都围绕这个g_i展开。2. 二分类的笔算全流程从交叉熵到 y - p 伪标签二分类是最常见的场景也是推导最干净利落的。我把每一步化简都写在下面建议拿草稿纸跟着算一遍几分钟时间换来对整个算法本质的理解这笔账很划算。2.1 原始损失函数与概率转换二分类的标签y ∈ {0, 1}模型输出是F(x)先经过Sigmoid转成正类概率p sigmoid(F) 1 / (1 exp(-F))对数损失交叉熵定义L(y, F) -[ y * log(p) (1 - y) * log(1 - p) ]这是所有推导的起点。很多人到这步就停了反正把p换成sigmoid(F)再求导就行了。但直接对复合函数求导容易乱最好先把L展开成只含F的形式后面每一步都清爽。2.2 损失函数展开化简把Sigmoid代进去把p的两个分量写开log(p) log(1 / (1 exp(-F))) -log(1 exp(-F)) log(1 - p) log(exp(-F) / (1 exp(-F))) -F - log(1 exp(-F))代入原损失L -y * (-log(1 exp(-F))) - (1 - y) * (-F - log(1 exp(-F))) y * log(1 exp(-F)) (1 - y) * (F log(1 exp(-F)))合并同类项L log(1 exp(-F)) (1 - y) * F这个形式简洁得多。注意它只在y ∈ {0,1}时成立这也是GBDT二分类标准约定。有些资料写成log(1 exp(-F)) (1-y)F有的写成log(1 exp(F)) - yF把(1-y)F拆成F - yF再吸收进log项两种写法本质一样求导结论一致。验证一下特殊情况当y1损失是log(1 exp(-F))——预测越自信F越大损失越接近0当y0损失是log(1 exp(F))——F越小损失越接近0。符合直觉。2.3 求一阶导数化简的核心产物对化简后的L求关于F的偏导∂L/∂F -exp(-F) / (1 exp(-F)) (1 - y)第一项化简-exp(-F) / (1 exp(-F)) -1 / (1 exp(F)) -(1 - sigmoid(F))所以∂L/∂F -(1 - p) (1 - y) p - y一阶导是p - y那么负梯度就是-g y - p这就是GBDT分类树每一轮要拟合的伪标签真实标签减去当前预测概率。当y1时拟合的是1 - p是0到1之间的连续值当y0时拟合的是-p。所以分类提升树拟合的不是离散残差而是连续的概率残差。这也是为什么我前面说拟合残差这个说法有误导性。如果是回归的平方损失残差是y - F——中心化后可能正可能负值域不受限而分类的y - p值域是[-1, 1]区间准确说正类时是[0,1)负类时是(-1,0]。被回归树拟合后输出经过Sigmoid永远在合理概率区间附近打转不会出现exp爆炸。2.4 叶子输出值的推导二阶导也要跟上拟合出树结构、把样本分到叶子后每个叶子应该输出什么值这个值必须让损失最小化。先把叶子输出记为w该叶子上样本集合为{i | i ∈ leaf}。目标是在已有模型输出F上叠加w最小化叶子内样本的损失和min_w sum_{(i ∈ leaf)} L(y_i, F_i w)对w在0处做二阶泰勒展开sum_i L(y_i, F_i w) ≈ sum_i [ L(y_i, F_i) g_i * w (1/2) * h_i * w^2 ]其中g_i p_i - y_i是刚才求的一阶导h_i是二阶导。对w求导并令其为零sum_i g_i sum_i h_i * w 0 w - sum_i g_i / sum_i h_i注意开头的负号g是p-y不是y-p所以实际计算时经常写成w sum_i (y_i - p_i) / sum_i h_i这就是负梯度求叶子值的经典公式。在LogLoss下二阶导很好算h ∂²L/∂F² ∂(p - y)/∂F dp/dF p * (1 - p)于是叶子值变成w sum_i (y_i - p_i) / ( sum_i p_i(1 - p_i) lambda )lambda是正则化项对应XGBoost里的reg_lambda防止分母接近0。这个公式的直觉很美分子是残差总和分母是当前预测不确定性的总量——预测越保守p接近0.5分母越大更新步子越稳预测越自信可调整空间越小。这里要特别提醒一句如果你在框架里看到叶子值算出来是sum(y-p) / (sum(p*(1-p)) lambda)那它用的就是这里推出来的牛顿步如果看到像旧版sklearn的GradientBoostingClassifier它用的可能是简单的一步梯度步长再乘学习率甚至干脆用预定义的收缩系数。两种化简级别不同会直接影响收敛速度这是后面踩坑的重点之一。3. 多分类化简Softmax矩阵求导的意外简洁结果多分类看起来比二分类复杂一个数量级因为要处理K个输出之间还有Softmax归一化的耦合。但化简到最后结果会干净得让你吃惊每个类别的负梯度就是y_k - p_k和二分类形式完全一致。3.1 多分类的模型输出与损失K个类别模型对每个样本输出一个K维向量F(x) [F_1(x), ..., F_K(x)]经过Softmax转成概率向量p_k exp(F_k) / sum_j exp(F_j)标签用One-hot表示y_k ∈ {0,1}且sum_k y_k 1。交叉熵损失L - sum_k y_k * log(p_k)因为One-hot真正起作用的只有标签对应的那一项。但求导时不能只看那一项——p_k的表达式里分母含有所有F_j每个F_j的变化都会牵连所有概率。这就是为什么直接求导容易把人绕晕。3.2 全导数展开关键的矩阵求导过程硬着头皮算。对某个指定类别j目标求∂L / ∂F_j∂L / ∂F_j - sum_k y_k * (1 / p_k) * (∂p_k / ∂F_j)先算核心项∂p_k / ∂F_j。由Softmax定义∂p_k / ∂F_j p_k * (δ_kj - p_j)其中δ_kj是克罗内克记号kj时为1否则为0。这其实是Softmax求导的标准结论推导可以自己展开商法则得到重点是后面。代入回去∂L / ∂F_j - sum_k y_k * (1 / p_k) * p_k * (δ_kj - p_j) - sum_k y_k * (δ_kj - p_j) - sum_k y_k * δ_kj p_j * sum_k y_ksum_k y_k * δ_kj y_j只有kj时非零而sum_k y_k 1One-hot标签总和为1。于是∂L / ∂F_j - y_j p_j p_j - y_j负梯度就是-g_j y_j - p_j3.3 化简结果的工程含义每个类别j得到一组伪标签y_j - p_j所以多分类GBDT在每一轮要对每个类别各训练一棵树一共K棵树拟合各自的y_k - p_k。以sklearn的GradientBoostingClassifier为例multi-class模式下每轮就是暴力循环类别建树的LightGBM提供了num_class参数底层内部循环逻辑也是类似思路。你可能会问既然每个类别的负梯度形式跟二分类一样那直接把多分类拆成K个独立的二分类问题不就行了区别在于Softmax的归一化效应体现在p_k本身是由所有F_j通过Softmax算出来的而不是各自Sigmoid算出来的。这导致每个类别的伪标签在数学上是耦合的——一个类别概率高了另一个类别的p会自然下降。所以在拟合阶段虽然有K棵树但它们的输出值在叶子计算时必须用Softmax对应的概率密度重新校准不能各算各的。具体到叶子值的二阶导化简对SoftmaxCEHessian矩阵不是对角阵完整二阶信息是∂²L / (∂F_j ∂F_l) p_j * (δ_jl - p_l)这是一个K×K稠密矩阵。工程实现XGBoost、LightGBM、sklearn普遍采用的对角近似直接取h_jj p_j * (1 - p_j)忽略非对角项。为什么可以这样偷懒从损失角度看忽略非对角项损失了一部分抢概率的耦合力但换来每棵树的叶子值计算可以独立进行训练复杂度从O(K²)降到O(K)。实践表明在大多数分类任务上这个近似带来的精度损失可以忽略正则化和学习率能消化掉这点误差。如果你见过某些实现里多分类叶子值也是sum(y-p) / sum(p*(1-p))别惊讶这就是对角近似的产物。记住它的代价是每个类别的树都在独立更新更新完入模后才会通过Softmax产生交互。4. 化简中踩过的坑初始化、符号约定、梯度方向与学习率公式推得再漂亮落地到代码一样会翻车。以下是我在实际写代码和改框架源码时撞过的坑每一种都真实发生过列出来供参考。4.1 初始预测值F0不是随便填的很多人用GBDT时会忽略一个细节模型的初始输出F_0是怎么定的初看我以为就是0看了源码才发现最优初始值是让整体损失最小的常数。二分类时假设正类占比为p0 mean(y)最优化目标min_c sum_i L(y_i, c)解出来c log(p0 / (1 - p0))这就是logit先验。为什么不是0如果F_00则sigmoid(0)0.5对于类别不平衡的数据第一轮所有样本的初始预测都是50%相当于强制所有样本从不确定开始——这在正类仅占1%的数据集上会让前几轮树花费大量力气先扳正整体偏移量。多分类的初始值类似每类的初始输出直接设log(p_k)各类别先验占比的对数。别忘了Softmax里常数平移不变所以设成log(p_k)而不是log(p_k / something)效果上等价于让初始概率等于样本类别频率。我在代码里见过一个bug初始化直接把F_0设成全零向量然后跑了50轮准确率迟迟上不去。后来把初值改成对数先验同样50轮马上收敛。模型对初值异常敏感特别是类别不平衡时更明显。4.2 一阶导符号约定各框架之间的隐藏差异这是最容易踩、也最难查的坑。化简结果是∂L/∂F p - y所以负梯度是y - p这是数学推导的顺序。但不同框架在接口层面返回的东西不一样有的框架如xgboost的binary:logistic内部返回的grad就是p - y靠树内部的负号处理统一有的框架如sklearn的_loss_gradient返回的是y - p即负梯度本身因为后面拟合直接用自定义损失函数时官方文档往往会明确告诉你返回一阶导数grad ∂L/∂F二阶导数hess ∂²L/∂F²然后框架里再用-grad拟合。我遇到过一位朋友自定义损失时文档要求返回一阶导他按着负梯度 y - p的直觉写成了返回p - y结果训练曲线直接逆天——损失不降反升而且伴随NaN。排查了半天才发现符号反了。记牢这条规则在通用GBDT接口里你需要返回导数值而不是负梯度值除非框架文档明确说返回负梯度。grad p - y拟合目标才是-grad y - p。每个样本打印出来对一对就不会错。4.3 一阶近似与牛顿步的区别为什么不直接用负梯度当叶值推导叶子值时有两条路线纯梯度下降步叶子值直接用负梯度的均值乘一个学习率这正是很多GBDT基础实现的做法。公式为w (-sum g_i) * lr / n_leaf。这个做法简单但收敛较慢。牛顿步带二阶导Hessianw -sum(g_i) / (sum(h_i) lambda)。因为分母含二阶信息一步下降更逼近最优收敛更快对学习率的依赖也降低。用g p - y代入牛顿步得到w sum(y-p) / sum(p*(1-p))注意负号正好被消化掉。这也是XGBoost和LightGBM在默认参数下采用的思路其实LightGBM还支持纯梯度模式但这个公式是基准。实操中我建议用牛顿步而不是纯梯度步不仅迭代轮数更少而且自带按置信度加权的效果。遇到叶子节点上正负样本极不均衡的情况p(1-p)自动给置信度更高的样本更大分母从而抑制个别离群样本的过度修正。4.4 学习率与叶子输出乘在哪里的讲究公式里叶子值是w模型更新是F lr * w。有些实现是w本身包含了负梯度缩放有些是把lr乘在外面。两者在数学上等价只要一致但调试时会看到不同规模的值。一个常见误区以为learning_rate只是调整步长的表面参数。实际上GBDT的lr承担了正则化角色——lr越小每棵树的影响越弱整体需要更多树但过小的lr加过多的树又容易过拟合噪声梯度。经验值在0.05~0.2之间配合早停调整树数。我自己常用的模式先用lr0.1、固定树数1000跑基线再调lr0.05、n_estimators1500对比验证集多数场景后者更好。这其实就是利用了学习率收缩正则这个数学关系。5. 化简过程的逆向工程自定义损失函数该怎么下手掌握了从L(y,F)到g,h的化简路径最大的实际收益是以后看任何损失函数都能快速写出它在GBDT框架里的接入代码。这个过程本质就是正向推导的逆操作。5.1 自定义损失的三步走假设你想用一个加权交叉熵给正样本更高权重替代标准LogLoss。原始损失L -[ w_pos * y * log(p) (1 - y) * log(1 - p) ]注意此时y仍然是0或1。代入sigmoid(F)展开化简跟前面一模一样的套路L w_pos * y * log(1exp(-F)) (1-y)*(F log(1exp(-F)))对F求导∂L/∂F -w_pos * y * (1-p) (1-y)*p整理成方便检查的形式g p - w_pos * y * p - (1-y)*p等等这里直接展开容易乱。换个更稳的算法利用复合函数求导的另一种写法。原损失可以看作两个损失加权L1 -log(p) 的导数 p - 1 L2 -log(1-p) 的导数 p加权组装g w_pos * y * (p - 1) (1-y) * p p * (w_pos*y 1 - y) - w_pos*y当w_pos1时g p - y恰好退回标准LogLoss。h的导数可以从g继续求不过g表达式中权重部分不含F因此直接对g的显式部分求导即可。这里就不展开完整式子了思路很清晰先写损失再化简到只含F的形式最后求一阶导和二阶导。5.2 用数值微分校验自定义梯度自定义损失最容易错的地方就是手算梯度。我有一个屡试不爽的办法写一段小代码用中心差分验证梯度公式。对一个随机向量Feps 1e-6 g_numeric (L(Feps) - L(F-eps)) / (2*eps) g_symbolic 你自己算出来的梯度对比两者差值应小于1e-4量级。注意输入输出都是一维数组逐元素对比。这样的代码二三十行就能写完比如import numpy as np def symbolic_grad(y, F, w_pos): p 1 / (1 np.exp(-F)) return p * (w_pos * y 1 - y) - w_pos * y def loss(y, F, w_pos): p 1 / (1 np.exp(-F)) return -(w_pos * y * np.log(p 1e-15) (1 - y) * np.log(1 - p 1e-15)) # 数值差分验证 y np.array([1, 0, 1, 1, 0], dtypefloat) F np.random.randn(5) * 0.1 # 注意F的初始尺度要小避免sigmoid饱和 w_pos 2.0 eps 1e-6 numeric_g np.zeros_like(F) for i in range(len(F)): F_plus F.copy(); F_plus[i] eps F_minus F.copy(); F_minus[i] - eps numeric_g[i] (loss(y, F_plus, w_pos) - loss(y, F_minus, w_pos)) / (2 * eps) print(symbolic max diff:, np.max(np.abs(symbolic_grad(y, F, w_pos) - numeric_g)))第一次运行如果diff超过1e-4别怀疑数值差分的精度老老实实检查自己的导数推导。这个小脚本帮我抓出过至少三处笔误。5.3 框架里的g和h到底该返回什么接入框架时还要搞清接口语义。以主流GBDT实现为例它们的自定义目标函数约定通常是二分类接收y_true和当前F不是p是原始输出返回grad一阶导和hess二阶导多分类返回grad形状是(n_samples, n_classes)因此要对每个样本每个类别都算出g和h有个常见误解框架里拟合用的目标是不是y - p其实只要你的grad返回p - y ∂L/∂F框架会自动算出负梯度为-(p-y) y-p来拟合。有些中文教程把这两步混在伪标签概念里一起讲容易让新手误以为框架内部直接拿y-p去拟合树却不明白这正是对grad取了相反数。如果框架文档要求的是负梯度而非梯度那就直接返回y-p。绝大部分框架在自定义接口里要的是原始梯度因为负梯度是统一在内部处理的但也确有例外。最靠谱的办法翻一眼框架某一内置目标的源码看它返回的是梯度还是负梯度照葫芦画瓢。5.4 调试技巧逐样本检查g和h自定义目标接入后模型不收敛最常见原因就是某个样本的g算炸了。尤其是sigmoid饱和区当F绝对值超过15exp(-F)会出现下溢或上溢导致p被截断到0或1附近g值失去有效梯度。对策是稳数值的写法避免直接用1 / (1 exp(-F))这种原始形式def stable_sigmoid(F): return np.where(F 0, 1 / (1 np.exp(-F)), np.exp(F) / (1 np.exp(F)))这个写法我再三强调真实应用中如果训练初期F被推到±20的极端区它的p会被截断成0或1p(1-p)变成0叶子值分母为0直接产生NaN或Inf。稳定sigmoid能保证p在数值极限处依旧连续可导注意实际上在F0处的一阶精度问题可以通过阈值判断规避但这里先不谈那种极端情况。另外给日志加一条每轮训练完成后统计grad和hess的绝对值分位数。如果hess大量等于0说明样本处于概率极端区要及时检查初始化或特征尺度。写在最后化简这条路越走越顺我在实际写代码时最深的体会损失函数化简不是一种数学表演而是调试和自定义的必备工具。以前看到报错堆栈里飘过grad、hess字样第一反应是看不懂、绕过去现在能顺着化简路径直接定位到是哪一步的符号或数值处理出了偏差排查时间至少缩短一半。如果你刚接触这些推导建议亲手把二分类那段落里的展开步骤在草稿纸上走一遍再用数值差分验证自己定的自定义损失。这个训练做扎实了再去看XGBoost的叶子权重公式、LightGBM的多分类训练流程会自然产生原来如此的串联感而不是零散地记公式。顺便说个后续可以扩展的方向把二分类化简中代入Sigmoid再合并的手法迁移到Focal Loss、带权重的Dice Loss等更复杂的损失上你会发现流程完全一样——先展开成F的显式函数再求导、取对角近似。流程一旦固化自定义损失就再也不是难事。