ARTICLE DETAIL

资讯详情

深耕郑州网站建设与运营推广的一线实战洞察。

XGBoost原理推导与调参实战:从目标函数到分裂增益

XGBoost原理推导与调参实战:从目标函数到分裂增益 第一次真正弄明白 XGBoost不是读完哪篇教程之后而是我拿张纸把目标函数自己推了一遍再回头去看 Gain 公式里那一堆 G、H 到底是什么那一刻才通。在那之前我对它的认知就是一个比 GBDT 快、调出来的分还高的黑盒n_estimators 从 100 试到 2000max_depth 从 3 试到 10learning_rate 固定 0.1最后挑一组验证集 AUC 最高的交差。这样能应付课程大作业也能把 Kaggle 的基线跑出来但换一个数据集立刻失效线上分数抖一下也完全不知道该往哪调。这篇笔记想做的事是把 XGBoost 的原理推导链条完整串一遍从加法模型的原始目标函数到二阶泰勒展开、叶子权重闭式解、结构分数与分裂增益再到精确贪心、加权分位草图、稀疏感知分裂这三套找分裂点的路子最后落到参数调优的排查顺序和几个真实踩过的坑。它适合两类人一类是要交机器学习期末作业、需要把公式讲清楚的学生另一类是把 XGBoost 当生产力工具、但调参一直靠感觉的工程同学。推导部分我会尽量写全中间步骤不跳步工程部分我会给出可以直接抄的顺序和参数区间也会指出哪些经验之谈其实是错的。1. 把目标函数摊开XGBoost 到底在优化什么东西很多人对 XGBoost 的第一印象来自那句它是 GBDT 的高效实现。这句话不算错但会误导人因为它把注意力引向了实现而 XGBoost 真正有意思的地方在目标函数的写法上。它没有沿用 GBDT 那种用负梯度拟合残差的启发式思路而是把整个集成模型当成一个可微的目标直接对它做二阶近似然后解析地求出每棵树的最优结构。理解这一点后面所有的公式都是顺水推舟。1.1 加法模型与逐步修正的直觉XGBoost 的模型形式是标准的加法模型假设一共训练 K 棵树第 i 个样本的预测值是$$\hat{y}i \sum{k1}^{K} f_k(x_i), \quad f_k \in \mathcal{F}$$这里的 $\mathcal{F}$ 是回归树函数空间每个 $f_k$ 把样本映射到一个叶子权重上。写成这样有个隐含前提每棵树都是为了修正前面所有树的遗留问题而存在的。第一棵树拟合原始标签第二棵树拟合第一棵树的偏差第三棵再拟合前两棵的偏差以此类推。打个生活化的比方这就像一个团队做估算第一个人给出初稿第二个人只负责修正初稿的偏差第三个人再修正前两个人剩下的偏差。关键在于修正的方式——GBDT 的做法是让新树去拟合当前模型损失函数的负梯度也就是伪残差这本质上是一阶近似XGBoost 的做法是把损失函数在当前预测值处做二阶泰勒展开同时保留了新树本身的复杂度惩罚。这一步差别看起来很小但它带来两个后果优化更准以及目标函数可以被改写成只依赖每个叶子里样本一阶导之和、二阶导之和的形式从而得到闭式解。这个改写成 G、H 之和的过程是整篇原理的核心也是我当初卡住最久的地方。第一次看论文里突然冒出 $G_j$、$H_j$ 两个符号时我完全不理解为什么损失函数能这样聚合。后来发现其实只用到两件事一是泰勒展开把每个样本的贡献变成独立项二是同一叶子内的样本共享同一个权重 $w_j$。1.2 二阶泰勒展开怎么把损失函数折叠成二次式先写下第 t 轮的目标函数。前 t-1 轮的预测值记作 $\hat{y}^{(t-1)}_i$这一轮要新增一棵树 $f_t$$$\text{Obj}^{(t)} \sum_{i1}^{n} l\left(y_i,\ \hat{y}^{(t-1)}_i f_t(x_i)\right) \Omega(f_t) \text{const}$$$\Omega(f_t)$ 是新树的正则项const 是前面已经确定的部分。现在对这个式子做二阶泰勒展开。回忆一下标量形式对函数 $l$ 在点 $a$ 附近展开$l(a \Delta) \approx l(a) l(a)\Delta \frac{1}{2}l(a)\Delta^2$。把 $\Delta$ 换成 $f_t(x_i)$$a$ 换成 $\hat{y}^{(t-1)}_i$就得到$$\text{Obj}^{(t)} \approx \sum_{i1}^{n}\left[ l(y_i, \hat{y}^{(t-1)}_i) g_i f_t(x_i) \frac{1}{2} h_i f_t^2(x_i) \right] \Omega(f_t)$$其中 $g_i \partial_{\hat{y}} l(y_i, \hat{y})|{\hat{y}\hat{y}^{(t-1)}i}$$h_i \partial^2{\hat{y}} l(y_i, \hat{y})|{\hat{y}\hat{y}^{(t-1)}_i}$。第一项 $l(y_i,\hat{y}^{(t-1)}_i)$ 与 $f_t$ 无关是常数可以直接丢掉。丢掉之后剩下的部分每一个样本的贡献都是 $g_i f_t(x_i) \frac{1}{2}h_i f_t^2(x_i)$只跟自己的梯度、二阶导和新树的取值有关样本之间已经解耦了。这一步值得停一下。为什么可以用二阶近似而不担心误差因为在 boosting 的框架里每一轮新增的树都被学习率约束得比较小后面会讲收缩$f_t(x_i)$ 是一个小量泰勒展开的余项是 $O(f_t^3)$相对于二阶项可以忽略。如果某一轮新增的树幅度很大二阶近似就会失真这也解释了为什么学习率设得过大时 XGBoost 的表现会明显不如预期——不是模型坏了而是近似假设被破坏了。顺便给出两个最常用损失的梯度后面手算时反复用得到损失函数一阶导 $g_i$二阶导 $h_i$平方损失 $\frac{1}{2}(y-\hat{y})^2$$\hat{y} - y$$1$逻辑损失二分类$p - y$$p\sigma(\hat{y})$$p(1-p)$平方损失的 $h_i \equiv 1$ 是个很重要的特例它意味着在回归任务里二阶导不携带信息模型退化得跟 GBDT 差别不大而逻辑损失的 $h_i p(1-p)$ 最大只有 0.25这个数字后面在解释 min_child_weight 时会直接用到。1.3 叶子权重闭式解从求和到配方现在把样本按落在哪个叶子重新分组。设第 j 个叶子里的样本集合为 $I_j$$f_t(x_i) w_j$$i \in I_j$令 $G_j \sum_{i \in I_j} g_i$$H_j \sum_{i\in I_j} h_i$把正则项写成 $\Omega(f_t) \gamma T \frac{1}{2}\lambda \sum_{j1}^{T} w_j^2$T 是叶子数代入$$\text{Obj}^{(t)} \sum_{j1}^{T}\left[ G_j w_j \frac{1}{2}(H_j \lambda) w_j^2 \right] \gamma T$$这时候每个叶子已经是独立的了对 $w_j$ 求导令其为零得到$$w_j^\star -\frac{G_j}{H_j \lambda}$$代回去得到每个叶子能贡献的最小损失$$\text{Obj}^\star -\frac{1}{2}\sum_{j1}^{T} \frac{G_j^2}{H_j \lambda} \gamma T$$这个 $-\frac{1}{2}\frac{G^2}{H\lambda}$ 就是所谓的结构分数structure score值越小说明这棵树的结构越好。我第一次推到这里的时候有个疑问$w_j^\star$ 前面为什么有个负号想清楚之后发现它很直观——$G_j$ 是所有样本一阶导之和一阶导的方向是损失增大的方向所以要使损失下降叶子权重必须朝着梯度的反方向走负号就是这么来的。对平方损失$G_j \sum(\hat y - y)$ 正是当前预测值减去真实值的总和也正好是负的残差和。这个闭式解是 XGBoost 相对 GBDT 最大的形式上的优势GBDT 里叶子权重的确定依赖具体实现的线搜索或者直接用均值近似而 XGBoost 直接给出了带正则的最优值。1.4 分裂增益结构分数之差有了结构分数判断一个节点该不该分裂、怎么分就顺理成章了。设当前节点未分裂的结构分数是 $-\frac{1}{2}\frac{(G_LG_R)^2}{H_LH_R\lambda} \gamma$分裂成左右两个子节点之后是 $-\frac{1}{2}\left[\frac{G_L^2}{H_L\lambda} \frac{G_R^2}{H_R\lambda}\right] 2\gamma$。分裂带来的损失下降增益就是前者减后者$$\text{Gain} \frac{1}{2}\left[\frac{G_L^2}{H_L\lambda} \frac{G_R^2}{H_R\lambda} - \frac{(G_LG_R)^2}{H_LH_R\lambda}\right] - \gamma$$这个公式我建议每个学 XGBoost 的人都手抄一遍。它把三件事同时编码进去了左子节点够不够纯第一项、右子节点够不够纯第二项、分裂本身值不值第三项减 $\gamma$。$\gamma$ 在这里的角色是分裂的门槛价只有当两边的结构分数之和比不分裂时高出 $\gamma$ 以上这个分裂才会被接受。这就是后面调参时把 gamma 当成预剪枝强度的理论依据。还有个容易被忽略的点Gain 公式里的第二项是 $\frac{G_R^2}{H_R\lambda}$注意分子是平方后求和所以即使 $G_R$ 是负数它对增益的贡献也是正的。这解释了为什么 XGBoost 的分裂不会偏向某一侧的梯度方向左右两边的纯度是对称衡量的。2. 找分裂点精确贪心、加权分位草图与稀疏感知推导出 Gain 之后剩下的问题是怎么在所有候选分裂点里找到 Gain 最大的那个。这个问题看起来简单但特征维度高、样本量大、数据里还有缺失值的时候暴力枚举会非常慢。XGBoost 针对不同场景给了三套方案小数据用精确贪心大数据用加权分位草图做近似缺失值用稀疏感知分裂单独处理。这三套方案的选择其实对应着tree_method参数的取值理解它们才能明白什么时候该用exact、什么时候该切到hist。2.1 精确贪心一次排序换来的全局最优精确贪心的逻辑非常朴素对每个特征先把样本按这个特征的取值排一遍序然后从左到右扫描每次把游标往右移一个样本就把这个样本从右子节点挪到左子节点用增量方式更新 $G_L$、$H_L$ 和 $G_R$、$H_R$然后用 Gain 公式算一次全局记录最好的那个切点。扫描一遍就覆盖了所有取值相邻样本之间的切点所以它是精确的能找到单特征上的最优切点。它的代价也很实在。每个节点都要对每个特征重新扫一遍一个节点上扫描的总工作量是 $O(d \cdot n \log n)$这里是排序成本如果预排序过则是 $O(d \cdot n)$ 的扫描整棵树递归地做总代价随着深度增长。这也是 XGBoost 在中等规模数据上比后来者慢的原因之一。不过 XGBoost 在这里做了一件很关键的工程优化提前把特征按列排好序存成块Block。这样每次找切点时不用重新排序直接顺序读内存而且是列式存储方便做多线程并行——不同特征之间相互独立可以分给不同线程各自找自己的最优切点最后汇总。这个设计比 GBDT 的实现快了一个档次也是并行这个词经常跟 XGBoost 绑在一起的原因。需要澄清的是这个并行是特征级并行不是树级并行boosting 的串行本质没变。精确贪心还有一个容易被忽略的加速点扫描到某个位置时如果已经可以判断出剩下的切点不可能超过当前最优就可以提前终止。这个剪枝在实际实现里存在但对普通用户来说观测到的现象就是小数据上exact和hist差别不大。2.2 加权分位草图为什么权重是 h 而不是样本数数据量一大精确贪心的内存和时间都不够用这时候需要近似。近似算法的核心问题是怎么在不知道数据分布的前提下选出少量有代表性的候选切点最朴素的方案是等频分箱——把特征值排序后切成若干份每份样本数相同。但 XGBoost 没有这么做它用的是加权分位草图Weighted Quantile Sketch权重取的是样本的二阶导 $h_i$。为什么是 $h_i$回到目标函数。把目标按样本重写$$\sum_i \left[ g_i w \frac{1}{2}h_i w^2\right] \sum_i \frac{1}{2}h_i \left(w - \frac{g_i}{h_i}\right)^2 \text{const}$$这一步是配方把常数项提出来。这样一来目标函数的每一项都变成了以 $h_i$ 为权重的、到 $-g_i/h_i$ 的平方距离。换句话说$h_i$ 越大的样本对最终目标的影响越大。所以分箱时按 $h_i$ 加权才能保证每个箱对目标的贡献大致相等而不是每个箱的样本数相等。这个细节我觉得特别值得记如果你拿一个回归任务去看$h_i \equiv 1$加权分位退化成等频分箱看不出区别但在逻辑损失下 $h_i p(1-p)$那些预测概率接近 0.5 的难样本 $h$ 最大0.25会被分箱算法重点照顾而已经被分得很开的简单样本 $h$ 接近 0被压缩。这实际上是一种隐式的难样本聚焦跟 AdaBoost 的样本权重调整思路有相似之处但推导路径完全不同。草图算法的效果是给定精度参数 $\epsilon$对应sketch_eps默认 0.05 左右它能构造出大约 $1/\epsilon$ 个候选切点并保证每个候选点之间的加权分位差不超过 $\epsilon$。$\epsilon 0.05$ 意味着大约 20 个候选点但实际实现里为了精度通常会生成更多。这个参数调小的收益是有限的大多数情况下不用动它除非你的数据有非常细碎的特征分布而且效果不理想。2.3 稀疏感知分裂与缺失值默认方向XGBoost 最被称道的一个特性是原生处理缺失值。这句话经常被理解成不需要填补缺失值更准确的说法是它会在分裂时把缺失样本和一个默认方向绑定默认方向由数据自己学出来。具体做法是扫描切点时每个样本有两个身份——特征值存在和特征值缺失。算法提出两种假设缺失的样本全部走左或者全部走右。对每一种假设把有值样本按正常方式扫描并求出最优切点然后把这个假设下的缺失样本的 $G$、$H$ 合并到对应一侧算出 Gain。两种假设算完后取 Gain 更大的那个方向作为这个特征在这个节点上的缺失默认方向。这个做法的好处是它把缺失本身当成了一条信息。举个我实际碰到的例子在用户行为数据里最近 30 天登录次数这个字段缺失本身往往意味着这个用户近期没怎么活跃跟取值 0 是不同的语义。如果提前用 0 填补模型就看不到这个区别而让 XGBoost 自己学默认方向它可能把缺失样本整块推到反映低活跃的那一侧效果明显更好。但这里有三个坑要提醒不填不等于随便填。如果你的缺失是用-999、-1这类哨兵值填充的XGBoost 会把它当成一个正常的数值参与排序而且这个值往往是最小的会导致大量样本挤在分布的最左端严重扭曲切点选择。这种数据要先转成np.nan。缺失和真实的 0要区分。有些业务字段的 0 是有意义的消费金额为 0缺失是另一回事。混淆之后模型学出来的东西会自相矛盾。缺失比例过高时默认方向会不稳定。如果某个特征 80% 都缺失剩下的 20% 样本学出来的默认方向可能只是噪声这时候更好的做法是先构造一个缺失指示变量把是否缺失显式喂给模型再让这个特征本身缺失。顺便说一下稀疏感知不只处理缺失还处理稀疏特征。One-Hot 出来的稀疏矩阵里大量是 0XGBoost 只需要遍历非零项所以它在稀疏数据上的效率比稠密实现高很多。这也是它在文本特征、推荐特征上表现不错的原因之一。3. 正则化、收缩与工程结构XGBoost 相对 GBDT 的差异化优势理解了目标函数和分裂算法就能回答那个经典面试题XGBoost 和 GBDT 的区别是什么。网上流传的答案经常是XGBoost 用了二阶导、支持并行、有正则这三条都对但都太空。我更愿意从它对过拟合的控制手段和它的计算结构两个角度来讲因为这两件事才是你实际调参时每天要碰的。3.1 gamma、lambda、alpha 三者在目标函数里的分工XGBoost 的正则项是 $\Omega(f) \gamma T \frac{1}{2}\lambda \sum_j w_j^2 \alpha \sum_j |w_j|$其中 $\alpha$ 是 L1 项默认 0需要手动开。三个参数管的事情完全不同但经常被混着用。$\lambda$ 出现在 $w_j^\star -G_j/(H_j\lambda)$ 的分母上它的作用是压缩叶子权重。$\lambda$ 越大$w_j^\star$ 越接近 0每棵树的嗓门就越小。注意这不只是让输出变小它还改变了分裂增益公式里每一项的大小关系从而影响树的结构选择。默认值是 1这个默认值在实践中偏保守但通常不用大改我一般会在 1 到 5 之间试。$\gamma$ 出现在 Gain 公式的最后一项是分裂的准入价格。只有当分裂带来的纯度提升超过 $\gamma$分裂才被接受。$\gamma$ 越大树越浅、叶子越少。默认值 0意味着不限制。这里有个很常见的误用很多人把 $\gamma$ 当成防止过拟合的主要手段一上来就设成 5、10结果模型严重欠拟合。我的建议是先不动 $\gamma$用max_depth和min_child_weight控制复杂度只在发现树明显长得太细碎时才回头调 $\gamma$。原因是 $\gamma$ 的效果高度依赖损失的尺度逻辑损失的 Gain 量级大概在 0.0x 到 0.x 之间平方损失的 G、H 量级完全不同同一个 $\gamma$ 在两个任务上等价于完全不同的强度。换个数据集就要重新试很不划算。$\alpha$ 是 L1它的效果是让一部分叶子权重刚好为 0稀疏解对特征选择有一点帮助但因为 L1 在叶子权重上是不可导的实现上用的是近端梯度那一套。实践中 $\alpha$ 用得比 $\lambda$ 少得多而且开了之后收敛会变慢不建议一开始就加。3.2 收缩与列采样慢一点为什么反而更准learning_rate别名eta做的事情是给每棵新树的叶子权重乘一个系数 $\eta$$\hat y^{(t)} \hat y^{(t-1)} \eta f_t(x)$。这个操作叫收缩shrinkage。为什么降学习率反而效果更好回到 1.2 节的泰勒展开。二阶近似成立的前提是 $f_t(x_i)$ 足够小余项 $O(f_t^3)$ 可忽略。$\eta$ 直接降低了新树的幅度让近似更准。同时它也改变了训练的动力学每棵树只走一小步模型有更多机会用后面的树去微调前面树留下的系统性偏差而不是让第一棵树就把大部分结构吃掉。经验上$\eta$ 从 0.1 降到 0.02 通常能提升一点点泛化性能代价是训练轮数要涨 5 倍左右。这是个很明确的时间换精度的交换。subsample是行采样每棵树训练前随机抽一部分样本配合colsample_bytree、colsample_bylevel、colsample_bynode三个列采样参数。这四个参数的作用机制已经被讲烂了但有个细节值得说采样是在树级别还是节点级别生效影响完全不同。subsample是每棵树抽一次之后这棵树的所有节点都用同一批样本colsample_bylevel是每往下一层重新抽一次特征。粒度越细单棵树的随机性越强方差下降越明显但也会让单棵树更弱。我的默认组合是subsample0.8, colsample_bytree0.8另外两个保持在 1如果数据特征数特别大几百上千colsample_bytree可以压到 0.3 到 0.5效果往往比加正则更明显。值得一提的是subsample还有一个不太为人知的副作用它能让 XGBoost 变成一种随机森林式的 bagging boosting 混合体在某些噪声较大的数据集上即便早停点没找好也不至于过拟合得太离谱。但它会让训练结果的方差变大同一组参数跑两次的验证指标差异可能到 0.002 量级如果做对比实验记得固定随机种子。3.3 块结构与缓存友好为什么它在大数据上更快前面提过 Block 结构这里补充完整。XGBoost 把预处理后的数据按列存成块块里的每一列是按特征值排序过的精确算法或者按分位点压缩过的近似算法。这个设计的收益有三层第一层是避免重复排序。树是递归生长的同一个特征在不同节点上可能被反复考察如果每次都重新排序代价极高预排序之后节点分裂时只需要按已经排好的顺序扫描并从父节点继承排序好的样本索引。第二层是缓存与并行。排序后的列块在内存里是连续的扫描时缓存命中率高不同特征之间无依赖天然适合多线程。第三层是外存支持。数据大到内存放不下时可以把块写到磁盘用一个独立的线程预取训练线程继续计算实现流式的 out-of-core 训练。这个特性在当年的硬件条件下很有价值现在因为内存便宜、加上hist算法本身就省内存用得少了但理解它能帮你明白 XGBoost 的架构思路。说到hist这里必须提一下tree_method的三种取值因为它是现在最该关注的选项tree_method原理适用场景内存exact精确贪心预排序块样本量小于十万级、追求极致精度高approx加权分位草图 预排序块中等规模、需要控制内存中hist一次性把特征离散成直方图桶节点分裂时复用直方图做减法大规模数据、现在的默认选择低hist的核心技巧是直方图减法一个节点的直方图等于父节点直方图减去兄弟节点的直方图只需要构建较小那一侧的直方图另一侧用减法反推。这一步让构建直方图的开销直接减半是它比approx快得多的原因。hist目前的默认桶数是 256max_bin对绝大多数数据已经够用。顺便说一句 GPU现在的接口里用devicecuda配合tree_methodhist会走 GPU 直方图构建。我在几张卡上试下来的经验是样本量大、特征维度中等几十到几百时收益最明显加速比能到 5 到 15 倍但如果特征维度只有十几个、样本几千行GPU 的启动和数据传输开销会让它比 CPU 还慢这种情况老老实实用 CPU。4. 调参不是网格搜索一套可复现的排查顺序这是我最想分享的部分。前面所有原理最终都要落到这组参数该往哪调上。我见过太多人用GridSearchCV把四五维参数交叉组合跑一整晚最后挑一个最好的然后对这个结果说不出所以然。更糟的是这套流程在小数据上有效换到大数据上直接没法跑。我的做法是沿着一条有方向的路径调每一步只动一到两个参数每次都能说出为什么这么调而不是等网格搜索告诉我答案。4.1 先定学习率与树数量的耦合关系第一个决定是学习率和树数量的配对。这两个参数是强耦合的learning_rate减半n_estimators大概要翻倍才能达到接近的拟合程度。所以不要同时搜这两个而是先固定一个用早停决定另一个。我的固定策略是这样先设learning_rate 0.1配n_estimators 2000加early_stopping_rounds 50然后看早停在第几轮触发。如果早停点在 200 轮以内说明这个学习率偏大可以降到 0.05 再跑一次如果早停点在 1500 轮以上还没收敛说明模型容量不够或者早停窗口设得太窄。这里的交互细节值得展开early_stopping_rounds设成多少合适它相当于连续多少轮验证集指标没有改善就停。设太小比如 10模型会在验证指标的一次正常波动里被误杀设太大比如 200等于没早停。经验值是验证集评估频率的 20 到 50 倍。如果评估集只有几百行验证指标本身抖得厉害早停窗口要相应放大或者干脆换一个更大的验证集——验证集太小导致的早停误判是我见过最常见的莫名其妙调不动的原因。还有一个必须强调的纪律早停用的验证集不能同时用来报最终分数。用验证集早停相当于用验证集参与了模型选择你在这个集合上看到的分数是乐观偏置的。正确做法是切三层训练集、早停验证集、独立测试集只用测试集报数。这一点在课程作业里被扣分的人特别多。4.2 深度、min_child_weight、gamma 的联动这三个参数都控制树的复杂度但它们的作用点不同用错了会互相打架。max_depth是最直接的控制限制树的层数。它的特点是一刀切同一层里所有节点不管样本量多少都硬性截断。所以深度小了样本量大的区域被强行限制欠拟合深度大了样本量小的区域被允许继续分裂过拟合。深度和数据的结构有关我一般的起点是max_depth 6然后根据验证曲线调整训练集分数远高于验证集往下调两边都低往上调。min_child_weight是按叶子内的二阶导之和设门槛。这个参数的正确理解需要用到 1.2 节那个表逻辑损失下 $h_i p(1-p) \le 0.25$所以min_child_weight 1大致意味着一个叶子至少要放 4 个预测概率在 0.5 附近的样本。如果样本已经被分得很开$p$ 接近 0 或 1$h_i$ 接近 0那么 1 的门槛可能需要几十个样本才能满足。**所以这个参数的实际强度是随训练过程变化的这就是它难以凭直觉设定的原因。**实践中我会从 1 开始发现树长得太深太乱时按 1、5、10、20 的顺序往上试。gamma作为分裂门槛粒度比前两个更细但它对损失尺度敏感3.1 节讲过。所以我的顺序是先用max_depth定性再用min_child_weight细化最后才考虑gamma。如果gamma调到 2 以上才开始有明显效果通常说明min_child_weight设得太松了。这里还有一个反直觉的经验min_child_weight调大不一定会降低过拟合。它的作用是阻止小样本叶子生成但如果你的数据本身有稀疏区域强行把这些区域的样本聚在一起反而会学出一个不稳定的高权重叶子。所以调这个参数时要同时看验证集指标和叶子的权重分布可以直接把dump_model输出出来看不能只看一条曲线。4.3 一组可复现的调参顺序含具体区间把上面的东西整理成一个可以照着做的流程。假设是一个典型的表数据二分类任务几万到几十万行几十到几百个特征固定learning_rate0.1、max_depth6、min_child_weight1、subsample0.8、colsample_bytree0.8、lambda1、gamma0加上早停跑一遍记录早停轮数和验证 AUC。这是你的基线后面每一步都要跟它比。调learning_rate与n_estimators把learning_rate降到 0.05 和 0.02 各跑一次看验证 AUC 有没有稳定提升注意是看最优轮附近多次评估的均值不是那一轮的峰值。有提升就采用更低的学习率同时把n_estimators上限放大。调max_depth在 4、6、8、10 上各跑一次观察训练 AUC 与验证 AUC 的差距。差距超过 0.02视任务而定就往小调。调min_child_weight在 1、3、5、10 上试。如果上一步已经把过拟合压住了这一步的收益会很小别硬调。调列采样colsample_bytree从 0.8 往下试到 0.3。特征数多的任务这一项收益最明显。最后考虑gamma和lambdagamma在 0、0.1、0.5、1 上试lambda在 1、3、10 上试。如果这两步收益都不到 0.002就停手。整个流程下来实验次数大概在 15 到 25 次比盲目网格搜索少一个数量级而且每一步都有明确的解释。附带一句每一步的实验都要记录在同一个表格里参数、早停轮数、验证指标、训练指标、耗时否则三天后你完全记不清哪组是哪组。5. 真实数据上的边界问题缺失、不平衡与解释性原理和调参讲完了剩下的是那些在文档里很少写清楚、但实际项目里一定会撞上的问题。这一节的内容几乎都来自我自己踩过的坑。5.1 缺失值真的不用填吗严格来说不用填成立的前提是你的缺失在数据里以缺失的形式存在。工程实践里至少有三种情况会让这个前提失效。第一种是哨兵值伪装成缺失。这部分在 2.3 节讲过-999、-1、0填充是最常见的错误来源。判断方法很直接如果某个数值特征的取值分布上有一个极端的尖峰而尖峰位置恰好是某个看起来很人工的值那基本就是哨兵。处理方式统一转成np.nan。第二种是缺失有系统性含义但不该让它走默认方向。举个例子一个风控特征近半年最大逾期天数缺失可能意味着这个人从来没有借款记录。让 XGBoost 把这个缺失块的样本推到某一侧和显式构造一个是否有借款记录的 0/1 特征效果是不同的。后者更稳定也更容易向业务方解释。我的做法是**当缺失率超过 20% 时先加一个缺失指示特征再观察特征重要性。**如果指示特征的重要性排到前几名说明缺失本身携带了强信息这时候继续让模型裸处理是浪费的。第三种是缺失的机制在训练集和线上不同。这是最危险的。训练集里某个字段因为采集故障大面积缺失线上采集正常模型学到的缺失 → 走右侧 → 高分这个规律在线上永远不会被触发等于白学了一个特征反过来线上缺失比训练集多模型的默认方向没被充分训练过预测会漂移。这个只能靠数据监控解决建模阶段没什么好办法但至少要知道它在。5.2 类别不平衡scale_pos_weight 与 max_delta_stepXGBoost 对不平衡数据有一个专门参数scale_pos_weight作用是给正样本的梯度乘一个放大系数。理论上最优值是负样本数除以正样本数。很多教程到这里就结束了但有三点它没讲。第一这个值不一定要取满。取满意味着完全按样本量配平但你的目标指标可能不是准确率而是 AUC 或者召回率。在我的经验里把scale_pos_weight设成负正比的平方根或者负正比的一半在 AUC 上往往比取满更好。原因在于过度的权重缩放会让模型把大量负样本判成正类虽然召回上去了但排序质量AUC 衡量的东西反而会下降。这个参数应该按最终指标来调而不是按公式来算。第二改scale_pos_weight等价于改损失函数的尺度所以它会连带影响早停轮数和 Gain 的量级。如果一个流程里改了它前面调好的gamma、min_child_weight都要重新看一遍。第三逻辑回归损失在极端不平衡下会出现无法收敛的情况——叶子权重被推得非常大。这时候可以设max_delta_step为一个小的正数常见取值 1限制每棵树叶子权重的最大步长。这个参数平时几乎没人用但在正样本比例低于 1% 的时候确实能救场。另外提醒一下跨框架的差异scale_pos_weight在 XGBoost 里是作用在梯度上的如果你后来换用了某些把类别权重作用在损失上的实现两者的数值不完全等价迁移时要做一点微调。5.3 特征重要性有三个口径别只看一个model.feature_importances_在 sklearn 接口下默认返回的是weight也就是某个特征被用作分裂点的次数。这个口径的偏置非常明显取值多、基数高的特征会被反复用来切分哪怕它对预测的贡献很小。一个典型的坏例子是样本 ID 或者高基数的类别编码特征weight重要性经常排到前列但拿掉它对模型没有任何影响。三个口径的区别整理如下口径含义适合回答的问题主要陷阱weight被用作分裂点的次数特征被使用的频繁程度偏好高基数特征容易误导gain该特征所有分裂带来的平均增益特征对损失下降的贡献对尺度/分布敏感稀疏特征可能虚高cover该特征分裂覆盖的样本比例加权特征影响的样本范围大类特征天然占优我的实际做法是同时看gain和cover把weight只当作参考。而且更可靠的做法是直接做置换重要性permutation importance把某一列随机打乱看验证集指标掉多少。这个口径最贴近这个特征对模型有多重要的直觉代价是要多跑几十次预测在数据量不大时完全值得。如果你用的是较新版本的 XGBoost可以直接调model.get_booster().get_score(importance_typegain)拿到字典形式的结果比 sklearn 接口方便。5.4 概率输出不一定校准XGBoost 二分类输出的是经过 sigmoid 的概率这个概率不一定等于真实的经验频率。Boost 类模型有个共同倾向把预测概率推向 0 和 1 两端过度自信因为它是一步步沿着梯度方向压缩损失的。如果你下游要做的是排序取 Top K这不影响但如果你要把概率直接当风险值用比如定价、额度就必须校准。最常用的两个校准方法是 Platt 缩放在输出上再拟合一个逻辑回归和等渗回归单调分段拟合。选择上有个简单规则校准集样本少于 1000 时用 Platt多于 1000 且关系明显非线性时用等渗回归。等渗回归的缺点是容易过拟合小样本所以样本少的时候反而更差。校准集必须是从训练集里单独切出来的、模型没见过的数据而且不能和早停验证集是同一份——否则你是在用已经参与模型选择的数据去校准得到的概率会偏乐观。6. 从公式回到代码一份最小可复现的训练与评估骨架前面全是原理最后给一份我自己一直在用的骨架代码。它不复杂但把几个关键点都照顾到了分层切分、早停、评估指标、特征重要性口径、以及可复现性。6.1 数据切分与 DMatrix 构建import numpy as np import pandas as pd import xgboost as xgb from sklearn.model_selection import train_test_split # 1. 先把哨兵值统一转成真正的缺失 df df.replace([-999, -1, 9999], np.nan) X df.drop(columns[label]) y df[label] # 2. 切三层训练 / 早停验证 / 独立测试 X_tr, X_tmp, y_tr, y_tmp train_test_split( X, y, test_size0.3, stratifyy, random_state42 ) X_val, X_test, y_val, y_test train_test_split( X_tmp, y_tmp, test_size0.5, stratifyy_tmp, random_state42 ) # 3. 用 DMatrix 包一层XGBoost 内部会做稀疏和缺失的优化 dtrain xgb.DMatrix(X_tr, labely_tr, missingnp.nan) dvalid xgb.DMatrix(X_val, labely_val, missingnp.nan) dtest xgb.DMatrix(X_test, labely_test, missingnp.nan)这里有几个细节值得说。missingnp.nan显式声明缺失标记虽然默认就是 NaN但写出来更清楚而且如果你数据里用别的值表示缺失这里必须改。stratifyy在不平衡数据上是必要的否则可能切出一个正样本极少的验证集早停会完全不可靠。三层切分的比例不一定要 7:1.5:1.5样本少的时候可以更激进地留验证集比如 6:2:2。还有一个容易被忽略的点类别特征的编码要在切分之后做或者用只在训练集上拟合的编码器。如果在切分前对整个数据集做目标编码验证集的信息会泄漏进训练集验证指标虚高早停会在错误的轮数触发。这类泄漏非常隐蔽因为代码本身没有任何报错。6.2 早停、监控指标与训练循环params { objective: binary:logistic, eval_metric: [auc, aucpr], # 不平衡数据加 aucpr eta: 0.05, max_depth: 6, min_child_weight: 3, subsample: 0.8, colsample_bytree: 0.8, lambda: 1.0, gamma: 0.0, tree_method: hist, max_bin: 256, seed: 42, nthread: 8, } evals_result {} bst xgb.train( params, dtrain, num_boost_round3000, evals[(dtrain, train), (dvalid, valid)], early_stopping_rounds100, verbose_eval50, evals_resultevals_result, ) print(best_iteration:, bst.best_iteration) print(best_score:, bst.best_score) pred bst.predict(dtest, iteration_range(0, bst.best_iteration 1))几个关键点。第一eval_metric里我同时放了auc和aucpr。在不平衡数据上auc可能已经很漂亮但业务上没用因为负样本太多aucpr更能反映正类的识别质量。但要注意早停默认是按第一个评估指标或者最后一个来判定的一般是最后一个所以两个指标的相对位置要确认清楚别让它在错误的指标上早停。更稳妥的做法是用custom_metric自定义或者干脆只用你真正关心的那一个。第二iteration_range这个参数很重要。早停之后模型对象里存了所有轮次的树你直接bst.predict(dtest)用的是全部轮次包括那些已经让验证指标变差的轮次结果会明显变差。必须显式指定用到best_iteration。这个错误我见过太多次包括我自己早期也犯过明明早停找到了第 300 轮最好最后预测却用了 2000 轮的模型。第三verbose_eval50是为了让训练日志可读。如果你在做自动化实验把它关掉然后从evals_result里读曲线。evals_result里的曲线值得画出来看一眼理想形态是训练损失持续下降、验证损失先降后平或微微上升如果验证损失从第 20 轮就开始上升说明模型容量严重超了如果验证损失一直在降但降得很慢、早停触发在最大轮数附近说明轮数上限还不够或者学习率太大了。6.3 该不该换 LightGBM这是个绕不开的问题。LightGBM 在大多数表数据任务上的训练速度确实比 XGBoost 快内存占用也更友好主要靠三个技术基于梯度的单边采样保留梯度大的样本对梯度小的做下采样、互斥特征捆绑把稀疏的互斥特征塞进一个特征里降维、以及叶子优先的树生长策略。但更快不等于更好几个取舍点值得清楚维度XGBoostLightGBM树生长默认按层生长结构更规整默认按叶子生长同样叶子数下更容易过拟合小数据表现稳健叶子生长在小数据上容易过拟合需要压num_leaves大数据hist之后差距缩小通常更快、内存更低类别特征需要编码新版本支持enable_categorical原生类别特征支持更成熟生态与迁移接口稳定历史项目多参数命名差异较大迁移要重调我的实际选择习惯是数据在几十万行以内、特征维度不高的时候两个都试看验证指标和稳定性往往差别在 0.002 以内这时候优先选团队更熟悉、部署链路更成熟的那个数据上到千万行、特征上千我会优先 LightGBM 看能不能压住训练时间但会额外盯着num_leaves和min_data_in_leaf因为叶子优先的生长方式在小数据或者噪声大的数据上确实更容易翻车。最后说个我自己的体会。XGBoost 这套东西公式推导的价值不在于面试能背出来而在于遇到问题时你知道该动哪个旋钮。有一次线上的模型换了一批特征之后指标掉得厉害我第一反应是特征有问题排查半天没找到后来画了一下 Gain 公式里 $H$ 的分布发现新特征让大量样本的 $h_i$ 变得极小预测概率被推到了两端导致叶子权重 $w_j^\star -G_j/(H_j\lambda)$ 的分母几乎全被 $\lambda$ 主导模型实际上退化成了一个很弱的模型。把 $\lambda$ 降下来之后问题就缓解了。这个排查路径完全来自对公式的理解任何调参手册都不会写。所以我的建议是先把 1.3 和 1.4 那两段推导自己手推三遍再回头看参数你会发现那些参数忽然都变得有形状了。
返回列表