跳到正文

梯度提升:XGBoost 与 LightGBM

表格数据上你应该第一个跑的模型。它只有一个机制——每棵新树修正前面树没修对的部分——这个机制同时解释了它的每一个超参和每一处失效。

第 7 篇 · 共 8 篇 约 15 分钟

梯度提升:赢下一切的算法

ML 里有个玩笑:你不用试新东西,把数据扔进 XGBoost 就行了。对表格数据来说,这句话还真不算错。

大约五年(2014–2019)里,XGBoost 统治了 Kaggle 的表格竞赛。Chen & Guestrin 报过 2015 年 29 个获胜方案里有 17 个用了 XGBoost,而且这个 pattern 跨领域保持——不同队伍、不同大陆,都走到同一个答案。表格数据?XGBoost。信用评分?XGBoost。点击预测?XGBoost。这是机器学习在表格问题上最接近”默认选择”的东西,作为量化研究员忽略它就是渎职。

不过这篇文章不打算把它讲成一张特性清单。梯度提升只有一个机制:每一棵新树,去修正前面所有树加起来还没修对的那部分。这一句话往下推,就是本文剩下的全部内容——为什么它的树那么浅、为什么学习率要压小、为什么它在噪声标签上炸得比谁都惨、为什么它不会外推、为什么它的交叉验证比线性模型更容易写错。数学只保留脑子里装得下的那些。

直觉:一本书请一个编辑写一次

梯度提升的实践是:写一本书,每一轮雇一个编辑只修最差的那段,然后雇下一个编辑修下一段最差的,永远雇下去。

这就是整个算法,换成比喻讲。每一个编辑看到当前手稿的样子,找出剩下的最严重的问题,修掉它们。下一个编辑做同样的事。几百个编辑之后,手稿能看了。没有哪个单独的编辑写得出来这本书;一堆平庸编辑组成的集成打败了任何一个天才编辑。

翻成数学是五步:

  1. 从一个弱模型开始——常常就是一个常数。预测 yy 的均值。
  2. 算残差:当前模型错的地方、错了多少。
  3. 拟合一个新模型去预测这些残差。
  4. 把新模型按一个小的步长加进集成。
  5. 重复。

关键在第 2 步和第 3 步之间那道缝:新树看到的不是原始目标,是前面所有树留下的烂摊子。集成的错误就这样单调(或近单调,如果你对步长有耐心)地向那个不可消除的噪声底限逼近。

换个说法,这是函数空间里的梯度下降。你不是在更新一个参数向量,而是在更新一个函数——从特征映射到预测的那个函数。每一步你算损失对预测的梯度,拟合一棵树去近似这个梯度的负方向,再把这棵树的预测加到当前估计上。

数学,不带痛

设 L(y,f)L(y, f) 是一个损失函数——比如平方误差 L(y,f)=(y−f)2/2L(y, f) = (y - f)^2 / 2。设 ftf_t 是 tt 轮 boosting 之后模型的预测。损失对预测的负梯度是:

−∂L∂f=y−ft-\frac{\partial L}{\partial f} = y - f_t

那就是残差。所以”拟合一棵树去预测残差”这件事,在平方误差下,恰好就是在拟合负梯度。换别的损失(logistic、hinge、quantile),梯度不同,但算法完全一样——这也是为什么同一套库能同时做回归、分类和分位数预测。

更新步是:

ft+1(x)=ft(x)+η⋅ht(x)f_{t+1}(x) = f_t(x) + \eta \cdot h_t(x)

其中 hth_t 是新树,η\eta 是学习率(也叫 shrinkage)。η\eta 越小,每棵树的贡献越小,你需要更多棵树才能达到同样的训练损失。更小的学习率通常泛化更好,代价是更多算力。

这个公式归功于 Friedman(1999, 2001),是每个现代 boosting 库的概念核心。XGBoost 和 LightGBM 都实现了这个思想的不同变体,再加上各自的工程润色。(Hastie, Tibshirani, Friedman, The Elements of Statistical Learning, 第 2 版, 第 10 章。ISBN 978-0387848570。)

三个旋钮,以及它们为什么是这三个

一个梯度提升库有几十个超参。大部分你可以留默认。三个不能——而且这三个之所以重要,理由都能从”每棵树只修残差”这一句里直接推出来。

学习率 η\eta 典型值在 0.01 到 0.3 之间。在算力预算允许的范围内,越小越好。η\eta 降一半,你大概要把树的数目翻倍才能达到同样的训练损失,但得到的模型泛化更好。原因就在机制里:η\eta 小意味着每棵树只被允许拿走残差的一小口,剩下的留给后面的树反复确认;η\eta 大意味着某一棵树可以把一段残差一口吞掉,而那段残差里有多少是噪声,它是不知道的。著名的「0.1 还是 0.01」之争,说到底就是速度换质量。

树的数目 TT 树越多,训练损失一路往下,不会回头。验证损失不是——它先降后升,那个拐点就是你该停的地方,再往后加树就是在拟合噪声。所以这个旋钮实际上不是你手调的,而是用早停替你调:拿一个 holdout 验证集,训练到验证损失连续 NN 轮不再改善就停。典型的耐心值 50 到 200 轮,看数据集大小。

树的深度(或者最大叶子数) boosting 树是浅的,典型深度 4 到 8。对比随机森林常用的 15 到 30,这个差距大得需要解释。解释还是那句话:boosting 里每棵树只需要修正前面树错的部分。pattern 里”简单”的那部分早就被前几棵树学完了,后面的树是在打磨残差——打磨残差不需要深树。每一步一棵浅树就够,也顺便挡住了集成过拟合。

剩下的旋钮属于第二层。它们救不了一组烂特征,但能从一个已经 work 的模型里再榨 5–15%。

min_child_weight(XGBoost)或 min_child_samples(LightGBM)规定一个叶子最少需要多少样本(或多少 Hessian 之和)。值调大,树就学不出”只在这 3 个样本上成立”的极特殊 pattern——那种叶子正是过拟合的产地。典型值 10 到 100,随数据集大小缩放;量化面板用日度股票观察的话,50 是个合理起点。

行子采样和按树的列子采样让每一轮的新树只看到随机的一部分行和一部分列。子采样比例 0.6 到 0.9、bagging 频率 5(每 5 轮重新采样)是常见配置。这注入随机性、降低方差,而且几乎免费;LightGBM 的默认值就已经合理。

min_split_gain(XGBoost 里写作 γ\gamma)是做一次分裂最少需要的损失下降,起软深度限制的作用,劝退那些收益微薄的分裂。典型 0 到 0.1。大部分数据集对它不敏感,但目标特别噪声时它能帮着剪掉边际分裂。叶子权重上的 L1 / L2 正则化在 LightGBM 里是 lambda_l1 和 lambda_l2,从 lambda_l2 = 1.0 起步,按 10 倍步调试。

最后注意一件容易混的事:LightGBM 的 max_leaves 和 XGBoost 的 max_depth 是形状不同的约束。限制深度是给”从根到叶的最长路径”封顶,限制叶子是给”树的总大小”封顶。LightGBM 按叶子生长的特性(下面会讲)意味着对它来说限叶子比限深度重要得多。

实话:大部分边际价值在特征工程和目标定义上,不在这些旋钮上。如果你的验证 Sharpe 是 1.5、想搞到 1.7,回去修特征。想搞到 1.55,才轮到调 min_child_samples。

XGBoost:把树做小的那个

XGBoost(Chen & Guestrin, 2016)在 Friedman 的算法上加了几件事,让它更快、也更难过拟合。第一件是把上面那个”浅树”的直觉从建议变成了显式的惩罚项:

L=∑iL(yi,y^i)+∑tΩ(ht),Ω(h)=γT+12λ∑jwj2\mathcal{L} = \sum_i L(y_i, \hat{y}_i) + \sum_t \Omega(h_t), \quad \Omega(h) = \gamma T + \frac{1}{2}\lambda \sum_j w_j^2

其中 TT 是树的叶子数,wjw_j 是叶子取值,γ\gamma 和 λ\lambda 是正则化强度。它同时惩罚叶子数量(通过 γ\gamma)和叶子取值的大小(通过 λ\lambda)。效果是 XGBoost 天然偏好小树和小叶子值,等于给整个集成加了一层刹车。

第二件是二阶 Taylor 近似。XGBoost 不只用损失的梯度,而是同时用梯度和 Hessian。平方误差下这跟普通梯度提升是一回事;换别的损失,二阶信息给出更好的近似、也就训出更好的树。

第三件跟数学无关,纯粹是脏数据的实务:稀疏感知的分裂查找。XGBoost 原生处理缺失值——对每个特征,它在每个分裂点学一个默认方向,“值缺失就送左,否则跟阈值比”。当你手上某些列有 30% 缺失时,这个能力是真金白银。

XGBoost 的竞赛记录是离谱的,前面那个 17/29 就是它自己文档里的数字。到 2016 年它已经是默认选项。2018 年前后它的胜率放缓,但接手的不是神经网络——表格竞赛的顶端换成了 LightGBM 加上各式 stacking 和 blending。在很长一段时间里,「扔进 XGBoost」是真的好建议。(Chen & Guestrin, “XGBoost: A Scalable Tree Boosting System,” KDD 2016。)

LightGBM:把树做快的那个

如果说 XGBoost 的重点是把每棵树管住,LightGBM(Ke et al., 2017)的重点是让每棵树便宜到你可以多种几千棵。它是今天大多数量化机构在用的那个,因为在多数数据集上它比 XGBoost 快、准确度差不多。

快的第一个来源是基于直方图的分裂。评估每一个可能的分裂阈值是每个特征每次分裂 O(n)O(n);LightGBM 把特征值分桶到固定数量的桶里(默认 256),只评估桶之间的分裂。这在大数据集上快大约一个数量级,准确度损失几乎为零——现代表格数据的精度本来就是冗余的,256 个桶足够表达。

第二个来源是 leaf-wise(按叶子)生长。XGBoost 按层长树,把整层长完再往下。LightGBM 每一步挑”分裂后损失下降最大”的那个叶子,只分它。结果是一棵不对称的树,更贴合数据——但也更容易过拟合,因为它会一直往数据最密的那个方向钻。这就是为什么 LightGBM 通常要把 num_leaves 限制在 31 或 63,而 XGBoost 常用的 max_depth 是 6。

还有两个技巧值得知道名字。GOSS(基于梯度的单边采样)保留所有梯度大的样本——那些是难例——再从梯度小的简单样本里采样,在”简单例子占多数”的数据集上明显加速。EFB(互斥特征捆绑)把稀疏且互斥的特征合成一个特征,在高维稀疏数据上加速训练;对典型量化特征没那么相关,但对 one-hot 编码的类别变量有用。

工业界的表格 ML 默认就是 LightGBM,sklearn 的 HistGradientBoosting 在文档里明说自己借鉴了它,大部分量化特征库内部用的也是它。选择的判据基本只有数据量一条:数据小(小于 ~1 万行)时 XGBoost 通常稍准,数据大(超过 100 万行)时 LightGBM 快得不是一点半点。(Ke et al., “LightGBM: A Highly Efficient Gradient Boosting Decision Tree,” NeurIPS 2017。)

类别特征:别 one-hot

leaf-wise 生长这件事还有个连带好处,就是类别特征的处理方式。大多数 boosting 库原生支持类别特征,你应该用这个而不是自己 one-hot。XGBoost 用近似的基于分区的编码;LightGBM 按每个类别累计的梯度统计量(大致相当于按目标均值)给类别排序,再在排好的序列上找最优分区。

量化场景里最常见的类别特征是「行业」或「行业分类」。让库直接处理它,几乎总比 one-hot 成 11 个哑变量强,因为库能把行为相似的行业排在一起,学出尊重这种类序数结构的分裂——银行的行为更像其他银行,而不是更像公用事业。

高基数类别是例外。如果一个字段有成千上万个唯一值(比如股票代码),你大概率该扔掉这个类别,换数值编码:组内排名,或者用正确的 out-of-fold 计算做 target-mean 编码。库学不出跨一千个类别的有意义分裂,硬塞给它只会得到一堆各自只覆盖几个样本的叶子。

一个具体的量化例子

假设你有一个 500 只股票、20 年的面板,每天每只股票 100 个特征(价格比、技术指标、基本面、行业哑变量)。目标是 5 日前瞻收益,在 ±10% 处缩尾。你训一个 XGBoost 去预测这个目标。

在规规矩矩的验证下,已发表的量化 ML 文献报告的量级大致是这样:仅信号的测试 Sharpe 年化 1.5 到 2.5,信息系数(IC)0.03 到 0.06,胜率 52% 到 55%。同样特征换成随机森林,测试 Sharpe 通常比 XGBoost 低 0.5 到 1.0;换成线性模型(ridge 或 lasso)通常低 1.5 到 2.0;朴素的等权预测器接近零。

这些数字不是承诺,是量级参照。你的实际结果会随因子库、信噪比、以及你多小心地避开未来信息而大幅变化。

一个起步用的代码片段,秉承「少写代码」的精神:

import lightgbm as lgb

params = {
    "objective": "regression",
    "learning_rate": 0.05,
    "num_leaves": 31,
    "feature_fraction": 0.8,
    "bagging_fraction": 0.8,
    "bagging_freq": 5,
    "min_child_samples": 50,
    "lambda_l2": 1.0,
}

model = lgb.train(
    params,
    lgb.Dataset(X_train, y_train),
    num_boost_round=2000,
    valid_sets=[lgb.Dataset(X_val, y_val)],
    callbacks=[lgb.early_stopping(100), lgb.log_evaluation(100)],
)

提示词

用 LightGBM 训练一个回归模型,用验证集做早停。

耐心 100 的早停是你的朋友。Arithmion SDK 把这套东西暴露为 train_lightgbm(),带合理默认。

早停必须在交叉验证的里层

上面那段代码里的 valid_sets 有个陷阱,一旦你把它塞进交叉验证就会踩上:早停用的验证集必须完全来自外层训练折,不能碰到外层测试折的任何一行。

正确的模式是:

  1. 对每一折:
    • 把 outer-train 切成 inner-train 和 inner-validation。
    • 在 inner-train 上训练,用 inner-validation 早停。
    • 在 outer 测试折上评估。
  2. 在折上平均指标。

如果你在外层训练折上训练,却用一个同时落在外层测试折里的 holdout 来决定什么时候停,你就泄漏了——泄漏的不是标签本身,是”训到第几轮最合适”这个信息,而它足以把测试指标抬上去。Walk-forward CV 是一个道理:在每个 walk-forward 步里,早停的 holdout 要从训练窗口的末端切,不能往未来伸。

线性模型不需要操心这个,因为它没有”训到第几轮”这回事。这个细节是 boosting 的交叉验证比线性模型更容易写错的唯一理由,把它做对。

它做不到的事

梯度提升是表格数据的最佳默认,但不是魔法。有意思的是,它的每一处失效也都能从同一个机制推出来。

最要命的一条:它在噪声标签上过拟合得很厉害。每棵树都在修前面留下的残差,而当目标本身噪声很大——资产收益的噪声非常大——那些残差里绝大部分根本就是噪声。算法不知道,它照修不误。训练损失会一路掉到接近零,测试损失一路往上。这是最常见的失败模式,也是交叉验证存在的理由;我们在讲过拟合的那篇文章里细说。

它不会外推。 一个基于树的集成只能在训练数据的取值范围内给出预测。如果训练数据的收益落在 [−10%,+10%][-10\%, +10\%],模型永远不会预测 −15%-15\% 或 +15%+15\%。市场跑到历史经验之外的 regime 时(2008 那种崩盘、2020 那种飙升),boosting 模型可能在它从没见过的方向上自信地错。线性模型这方面反而更好,它会按线性往外推,至少方向上讲得通。

另外两条是成本而非缺陷。1000 棵树、100 万行的 boosting 模型要训几分钟到几小时,同样数据训一个线性模型只要几秒;低延迟推断或者需要快速迭代的时候,这个差别会变成瓶颈。而 1000 棵树的集成也没法像回归方程那样读——单棵决策树是可解释的,集成不是。

这些约束合起来,其实已经把适用范围划出来了。数据是行列数字为主的表格、规模在一千行到一千万行之间、目标清晰而且噪声可度量、你付得起几分钟到几小时的训练时间——这种情况下梯度提升是一个不花大力气很难打败的强基线,应该先跑它。反过来,数据是图像就用 CNN,是文本就用 Transformer,是带长依赖的纯时间序列就用 LSTM 或 Transformer;数据小到一千行以下,或者你明确需要外推到分布外的输入,那就回到线性模型——不是因为线性模型更聪明,而是因为上面两段说的两个理由正好在这两种情况下变成致命伤。

读懂一个 boosting 模型:SHAP 和特征重要性

1000 棵树不是人读的,但你能从里面抽两种有用的总结。

基于 gain 的特征重要性是:对每个特征,把所有用到它的分裂上的损失减少量在所有树上加起来。这告诉你模型最依赖哪些特征,有用但粗糙——它说不出关系的方向,也说不出形状。

SHAP(SHapley Additive exPlanations)值要细得多,它是基于博弈论的分解,把每一次预测拆成每个特征的贡献。对单个样本,SHAP 精确告诉你每个特征把预测从基线推高或推低了多少;在很多样本上聚合,你就有了全局特征行为的画面。Arithmion SDK 提供 explain_model(model, X),对表格 boosting 模型返回 SHAP 值。在量化研究里,SHAP summary plot 是跟非技术干系人沟通「模型在干啥」的标准方式。它当不了监管审计意义上的完全可解释性,但这是树集成能给的最好东西。

Boosting 和 Bagging 不是一回事

如果你的 boosting 模型过拟合,一个常见的反射动作是换随机森林。先别急,这两件事的差别是本质的,换过去不一定解决问题。

Bagging(bootstrap aggregating)在数据的 bootstrap 样本上并行训练很多模型,然后平均它们的预测。每个模型看到略有不同的数据集,集成的方差比任何单个模型低。随机森林是典型例子。Boosting 则是串行训练,每一个模型修正前面集成的错误,集成的偏差比任何单个模型低。XGBoost 和 LightGBM 是典型例子。

实践上 boosting 通常在表格数据上打败 bagging,因为表格里的 pattern 往往复杂,能从串行细化里持续受益。Bagging 更适合另一种局面:单个模型已经很强、你只是想把方差压下来——比如一片深决策树,每棵方差都很大,平均之后稳得多。

所以正确的反射动作是:boosting 过拟合,先加大正则化(回到 γ\gamma、λ\lambda、min_child_samples),而不是换随机森林;随机森林欠拟合,先减小树深度或者换 boosting。两种方法是互补,不是替代。

为什么是树,不是别的基学习器

一个自然的问题:为什么用决策树当 boosting 的基学习器?为什么不 boost 线性模型、核 SVM 或者神经网络?

答案完全是实践的。树训得快。它处理混合特征类型(数值和类别)。它优雅地忽略无关特征。它需要的预处理最少,连标准化都不用。它自动抓非线性和交互。

线性模型当然也能 boost,文献里就有「boosting 线性模型」,但收益小——线性模型本身方差就低,串行修正的边际收益不大。神经网络也能 boost,但每个基学习器太贵,集成规模一上去就跑不动了。对表格数据来说,树是对的基学习器。XGBoost 和 LightGBM 的成功不是抽象意义上”boosting 赢了”,是特指 boosting 树赢了。

特征还是特征

Boosting 模型很强,但它们不发明特征。做量化收益预测,有用的特征大致来自几个地方:横截面上的相对位置(行业内或市场内的排名、相对同行的 z-score、过去一年的百分位排名);多个 horizon 上的时间序列量(1d、5d、21d、63d、252d 的收益、已实现波动率、回撤、距高点时间);基本面(账面市值比、盈利收益率、销售市值比、ROE、杠杆倍数,至少滞后一个报告期以避免未来信息);技术面(价格的移动平均、RSI、MACD、布林带位置);以及高频可得的微观结构量(买卖价差、订单簿不平衡、成交量比率)。

横截面排名在这里面格外重要,理由还是回到树本身:树按单个特征值切,跟单调变换较劲。原始的「市值」特征跨 8 个数量级,同一个阈值在小盘那头和大盘那头的含义完全不同;换成「行业内排名」,特征干净地落在 0 到 1,树学起来轻松得多。

这不是一篇特征工程教程,但有一句得说:一个不可用的 GBM 和一个能融到资的 GBM,差距几乎总在特征上,不在算法上。所以,如果你刚开始做量化 ML 研究、却没把梯度提升当基线,你在浪费时间;等你真的打败了 GBM,那时候再去试神经网络。大多数量化研究员在表格数据上用神经网络从来没打败过 GBM,打败了的那些人是在另类数据上——卫星图像、电话会、订单流——那里神经网络有结构性优势。

GBM 不是最刺激的算法,不是被引最多的,也不会让你上会议封面。它是那个真正上线、真正赚钱的算法。(Bishop, Pattern Recognition and Machine Learning, 第 14 章。ISBN 978-0387310732。)

参考文献

  • Hastie, Tibshirani, Friedman, The Elements of Statistical Learning, 第 2 版, 第 10 章(Boosting 与 Additive Trees)。ISBN 978-0387848570。
  • Chen, T., Guestrin, C. (2016). “XGBoost: A Scalable Tree Boosting System.” KDD ‘16.
  • Ke, G. et al. (2017). “LightGBM: A Highly Efficient Gradient Boosting Decision Tree.” NeurIPS 2017.
  • Bishop, C. M., Pattern Recognition and Machine Learning, 第 14 章(Combining Models——§14.3 覆盖了 AdaBoost,历史基础;梯度提升本身在该书之后才出现)。ISBN 978-0387310732。
  • Friedman, J. H. (1999, 2001). Greedy function approximation and stochastic gradient boosting.