第 5 章的随机森林把测试集 AUC 从单棵 rpart 树的 0.548 抬到了 0.709。这一跳的背后是 Bagging 的核心思想:让 500 棵差异化的树各自独立判断,再把结果取平均,靠相互之间的随机性把方差摊薄。Bagging 的所有树是平行训练出来的,谁也不看谁的脸色。本章把视角倒过来:让每一棵新长出来的树专门盯前面那一群树犯错的样本,重点纠错。这就是梯度提升 Boosting 的基本思路。把 Boosting 装进高效的二阶近似与正则化框架里,就是 Chen 与 Guestrin 在 2016 年 KDD 上发表的 XGBoost。
从并行平均到序列纠错
随机森林的画面是 500 个互不通气的会计师同时审 63,930 家公司,每个人凭手里拿到的那一份 bootstrap 样本独立给一份打分清单,最后把 500 张清单的概率取平均。Boosting 的画面则是排成一队的会计师,第一位用很粗的规则给所有公司打一个初步分,第二位拿到第一位的打分单后,专门去看哪些公司被打错了,针对错样本调整一份纠错单;第三位再针对前两位合在一起还没纠好的样本继续纠。每加一位会计师都试图把累计的判断更靠近真实标签。下面的流程把这条排队纠错的链条画成横向流程,方便和上一章的并行森林对照看。
图 6·1 XGBoost 的序列残差拟合示意。每一棵新树都用上一轮预测的残差作为学习目标,乘以学习率 η = 0.1 后叠加进累计预测,验证集 AUC 连续若干轮不再提升时早停触发,把后续仅在拟合训练噪声的轮次剪掉。完整 TikZ 结构图详见 PDF 全文。
举一个数字例子。假设训练集里有 100 家公司,第一位会计师把所有公司打 0.01,相当于全样本舞弊率的粗估,结果有 6 家真舞弊但被打了 0.01。第二位看到这 6 个样本的残差为 而非舞弊样本残差只有 。他训练一棵浅树专门去拟合这组残差,对那 6 家公司输出一个比较大的正修正,比如 ,对其它公司输出接近 0 的修正。第二位的产出叠加到第一位之后,那 6 家公司的预测分数变成 ,离真值 1 更近了。这种"残差驱动"的迭代就是梯度提升的内核。
设损失函数为 ,初始预测为 。第 轮迭代在前一轮预测 的基础上,让一棵新的弱学习器 去拟合损失对当前预测的负梯度,即"伪残差" 。更新规则为 ,其中 是学习率。最终模型是 棵树的叠加:。
XGBoost 把这个一阶梯度框架推到了二阶。它在每一轮同时使用一阶导与二阶导对损失做泰勒近似,然后把目标函数改写成关于叶子节点权重的二次形式,使每个叶子的最优输出有闭式解。配上一组对树结构的正则化项,整个目标函数变成
其中 与 分别是损失对当前预测的一阶和二阶导, 是对叶子数 与叶子权重 的双重惩罚。这一套组合让每一刀分裂的"信息增益"里同时包含了拟合贡献与复杂度成本。
学习率、深度、子采样
XGBoost 的关键超参数可以分四组。第一组控制学习节奏,主要是 eta。把 eta 调到 0.05 意味着第 棵树的输出被压缩到原来的 5%,模型每步迈得很小,需要更多树补齐进度。第二组控制每棵树的复杂度,包括最大深度 max_depth、叶子最小样本权重和 min_child_weight、分裂前必须达到的最小损失下降 gamma。第三组控制随机性,每棵树取多少比例样本由 subsample 决定,每棵树取多少比例特征由 colsample_bytree 决定。第四组控制正则化,主要是 项 lambda 与 项 alpha。
eta 与 max_depth 两个参数有相互替代的关系。学习率小、树多但每棵浅,相当于走一条平稳的路;学习率大、树少但每棵深,相当于一脚油门到底。两条路在训练集上都能拟合得很好,但前者通常在测试集上更稳。Bao 数据上的网格搜索给了一个具体的判据:在 max_depth=7、eta=0.1、subsample=0.7、colsample_bytree=1.0 的组合下,验证集 AUC 在第 79 棵树之后就不再提升,早停触发;如果硬要让它跑到 2000 棵树,验证集 AUC 反而开始往下走,这是过拟合的典型信号。
gamma 这个参数容易被忽略,但它的含义直白。一刀切下去,左右两边的二阶近似目标函数下降不到 gamma,就放弃这次分裂。它的功能与 rpart 的 cp 类似,给每棵树一个"宁可不分裂也不要勉强分裂"的容错门槛。在不平衡数据上把 gamma 调高一点,可以避免树在少量稀疏正样本上长出"50% 舞弊率但只有 11 个样本"那种极端叶子。
早停作为最重要的正则化
很多文献把 项 lambda 写在最显眼的位置,但实操里真正决定 XGBoost 表现的是早停。早停的逻辑非常朴素:训练时一边长树一边在验证集上算 AUC,连续 50 轮验证 AUC 没有提升就停。这条规则本身不假设任何模型形式,等于让数据自己告诉你"该停了"。
停下来想一想。 如果不在验证集上做早停,而是固定让 XGBoost 跑 2000 棵树会怎样?答案分两步。第一步,训练集上 AUC 几乎一定能逼近 1.0,因为 2000 棵深树足以记住 537 个稀疏阳性的全部细节。第二步,测试集上 AUC 通常会先升后降。前几百棵树是在学真信号,后面 1000 多棵在学训练集独有的噪声,甚至学了"这家公司在 1995 年的 ap 是 720.9"这种针对单条记录的死记硬背。早停在 AUC 从升转降的拐点把训练腰斩,把噪声学习的部分剪掉。
回到 Bao 数据,最优组合的早停在第 79 棵树触发。其它组合里,max_depth=3 搭配 eta=0.1、subsample=1.0 的浅树组合一直长到第 334 棵才停。两条路最终的验证集 AUC 都在 0.74--0.75 之间,但第二条路的训练时间是第一条的 4 倍以上,这就是"深而少"对"浅而多"的实际成本差。
scale_pos_weight 在 0.66% 不平衡下的标定
XGBoost 的默认 scale_pos_weight=1 假设训练集类别均衡。Bao 训练集里正负比是 537:63,393,正样本只有 0.84%,把默认值喂进去会让模型把所有样本打分都贴近 0,看似 AUC 还行,前 1% 排序里几乎都是负样本,Recall@1% 趴在 0。这是不平衡数据上 XGBoost 最常见的退化解。
矫正办法是把 scale_pos_weight 设成 ,让损失函数在正样本上的贡献被放大相同的倍数。Bao 训练集上 ,也就是说每错分一个真舞弊样本,损失要乘以 118 倍,让模型看见少数类的痛感和多数类对齐。这是本书首次显式引入"以损失放大代替样本平衡"的处理思路,第 7 章的 RUSBoost 会换成对多数类做欠采样的另一条路径。
在 scale_pos_weight=1 的默认设置下,XGBoost 在 Bao 这种 0.66% 不平衡的数据上会给出几乎全为 0 的概率分布。表面上验证集 AUC 仍可达到 0.6 以上,但前 1% 排序里几乎没有真舞弊样本,Recall@1% 与 Precision@1% 趋近于 0。修正方法有三条:把 scale_pos_weight 设为 ;改用对正样本加权的对偶损失;或像第 7 章那样转向 RUSBoost 这类内嵌欠采样的 Boosting 变体。本章选第一条最简洁的处理,跑一组对照后保持后续章节口径一致。
在 Bao 数据上的实现
R 的 xgboost 二进制在本机不可用,本机 macOS arm64 上 R 4.5 的 xgboost 二进制构建缺失,源码编译需要 OpenMP 与 C++17 工具链的额外配置。本章只用 Python 实现;从第 7 章起所有方法的口径会重新对齐。训练流程沿用前几章的 drop NA + Bao 时间切分协议:1991--2002 训练,2003--2008 验证,2009--2014 测试。
import xgboost as xgb
import numpy as np, pandas as pd
from itertools import product
from sklearn.preprocessing import StandardScaler
from sklearn.metrics import roc_auc_score
np.random.seed(2026)
# X_train / X_val / X_test, y_* 同前几章 drop NA 后的特征矩阵
spw = (y_train == 0).sum() / (y_train == 1).sum() # 118.05
dtrain = xgb.DMatrix(X_train, label=y_train, feature_names=features)
dval = xgb.DMatrix(X_val, label=y_val, feature_names=features)
dtest = xgb.DMatrix(X_test, label=y_test, feature_names=features)
grid = dict(max_depth=[3, 5, 7], eta=[0.05, 0.1],
subsample=[0.7, 1.0], colsample_bytree=[0.7, 1.0])
results = []
for d_, e_, s_, c_ in product(*grid.values()):
params = dict(objective="binary:logistic", tree_method="hist",
eval_metric="auc", scale_pos_weight=spw,
max_depth=d_, eta=e_, subsample=s_, colsample_bytree=c_,
seed=2026, verbosity=0)
bst = xgb.train(params, dtrain, num_boost_round=2000,
evals=[(dval, "val")],
early_stopping_rounds=50, verbose_eval=False)
results.append((bst.best_score, bst.best_iteration + 1, params))
best_auc, best_round, best_params = max(results)
24 组超参在验证集上跑完只用了 27.0 秒。最优组合 max_depth=7、eta=0.1、subsample=0.7、colsample_bytree=1.0,第 79 棵树触发早停,验证集 AUC 0.7541。这套参数与"深而少"的直觉一致:深的树能在 42 个特征里找到组合规则,但每步学习率不能太小,以免在 537 个稀疏阳性上不够"激进"地学习。其它 23 组合验证集 AUC 大多落在 0.72--0.75 区间,说明 XGBoost 对超参的鲁棒性还不错,最优组合并未显著甩开次优组合。
时间序列分组与 forward chaining
读者可能注意到:本书没有像普通 ML 教程那样在训练集上跑 5 折随机交叉验证。这是 Bao 时间切分协议带来的根本约束。如果把 1991--2002 的 73,233 行随机拆成 5 折,模型在第 1 折上看到的训练样本里会包含 2002 年的舞弊案,验证它的是 1995 年的样本,等于让模型用未来去预测过去。穿越未来的训练在论文里当然会出现非常漂亮的 AUC,搬到真实审计部署里立刻露馅。
时间序列上的"诚实"做法是 forward chaining:把训练集按年份递增拆成 7 折,第 1 折用 1991--1992 训练、1993 验证,第 2 折用 1991--1993 训练、1994 验证,依此类推。本书的实现把验证集独立切出来作为 2003--2008,相当于 forward chaining 的最后一折,把 2003--2008 整段 30,777 行作为统一的验证池。这种切分牺牲了一些"重复利用早年训练样本"的统计效率,换来与 SEC 真实部署场景完全一致的时间方向,无穿越未来。
性能评估与时间外推塌陷
把验证集与测试集的 AUC 并排放,Boosting 这一章第一次呈现一个不愉快的事实:验证集 AUC 0.7541,测试集 AUC 0.6480,下挫 0.106。第 5 章的随机森林测试集 AUC 是 0.7087,比本章 XGBoost 高出 0.06 左右。
表 6·1 第 6 章 XGBoost 在 Bao 时间切分上的性能
| 切分 | 样本量 | 阳性 | AUC | 备注 |
|---|---|---|---|---|
| 训练 1991--2002 | 63,930 | 537 | --- | 早停在 79 棵树 |
| 验证 2003--2008 | 30,777 | 250 | 0.7541 | 调参锚点 |
| 测试 2009--2014 | 27,628 | 107 | 0.6480 | 时间外推塌陷 |
为什么 XGBoost 在更近的测试期反而打不过 RF?两条线索值得讲清楚。第一条是 SEC 执法的时间结构。2009--2014 测试期的 107 个真阳性样本是经过 2026 年仍在累积的标签,离 SEC 公告日期更近的舞弊案有相当一部分还没进数据库。1991--2008 训练 / 验证期里学到的"舞弊画像"已经把当时所能见到的 SEC 标记模式吃干净了,但那些模式与 2009 年之后的潜在舞弊画像可能并不一致。第二条是 XGBoost 比 RF 更激进。同样在 1991--2008 上调参,XGBoost 把决策边界绕得更紧,把训练 / 验证期的具体年份特征学得更死,遇到 2009 年之后样本分布漂移时反弹得更厉害。这是非常重要的一课:在时间外推任务上,"调参更精细"不等于"测试集更好"。
回到 AAER 数据。 测试集 1% 名额对应 277 家公司,XGBoost 在这 277 名里命中真舞弊 3 个,Recall@1% 等于 ,比 RF 的 还低 1 个百分点。AUC 与 Recall@1% 在本章一起退步,说明问题不在阈值,而在模型对未来年份的整体排序能力下降。
案例公司打分
时间外推塌陷不意味着 XGBoost 在所有样本上都退步。把 fyear=2000 的 Enron 与 Tyco 喂进训练好的模型,得到下表的概率。
表 6·2 案例公司 XGBoost 舞弊概率,fyear=2000
| 公司 | gvkey | fyear | 真实 misstate | XGB (misstate=1) |
|---|---|---|---|---|
| Enron | 6127 | 2000 | 1 | 0.9839 |
| Tyco International | 10787 | 2000 | 1 | 0.8872 |
Enron 2000 年得分 0.9839,比第 5 章 RF 的 0.6813 高出整整 0.30;Tyco 2000 年得分 0.8872,也比 RF 的 0.5614 高出 0.33。这两家公司的 fyear 都落在训练期,不是测试期;XGBoost 在训练期内把决策边界拟合得更紧,少数类样本被推得更靠前。问题在于这种"训练期内的精准"在 2009--2014 测试期没能延续。
第 10 章会把这两家公司的局部预测拆给 SHAP 解读。本章先记下两个数字:0.9839 与 0.8872。
变量重要性:基于 gain 的排序
XGBoost 默认提供三种变量重要性。weight 统计变量被用作分裂的次数,cover 把每次分裂覆盖的样本量加权求和,gain 把每次分裂带来的目标函数下降之和按变量累加。gain 与 RF 的 MDI 在精神上是同一类指标,都按"贡献"加总,但 XGBoost 的 gain 是基于二阶近似的目标函数下降,比 RF 的 Gini 不纯度下降更精细。下表列出按 gain 排序的 Top-10。
表 6·3 XGBoost Top-10 特征重要性,按 gain 排序
| 排名 | 变量 | gain |
|---|---|---|
| 1 | act | 236.36 |
| 2 | sstk | 221.77 |
| 3 | invt | 197.90 |
| 4 | dltt | 196.97 |
| 5 | dp | 189.71 |
| 6 | ppegt | 187.89 |
| 7 | prcc_f | 180.74 |
| 8 | csho | 177.08 |
| 9 | lct | 171.24 |
| 10 | at | 168.03 |
会计学解读上,这个榜单和第 5 章 RF 的 MDA 高度重合。流动资产 act、流动负债 lct、总资产 at 把规模信号撑住;存货 invt、长期债务 dltt、固定资产 ppegt、折旧 dp 共同描摹资产负债表里"会计估计弹性大"的科目;股票发行 sstk 与流通股 csho、股价 prcc_f 给出资本结构与市场预期的角度。RF MDA 里第一位的 lct 在 XGBoost 里掉到第 9,原因是 XGBoost 用二阶近似让模型对一阶导更敏感的流动资产 act 抢了先。十个变量当中没有一个"魔法变量"独占大半 gain,最高的 act 也不过占 Top-10 总 gain 的 12.4%,说明 XGBoost 的判别仍然依赖整张报表的联合信号。
XGBoost 进入审计学界的时间滞后
XGBoost 在 2016 年发表,但直到 2020 年的 Bao 等人之前,会计 ML 文献几乎没有正面使用过它。这有两个层面的原因。
技术层面,2016--2018 年之间审计学界对"黑箱模型"普遍抵触。一篇审计学顶刊评审在拒稿信里能写下"模型不可解释,审计师无法采用"这种意见。XGBoost 的预测从 500 棵树叠加而来,单棵树本身已经不直观,叠加之后几乎不可能给出"哪些科目导致了这个分数"的口语化解读。Lundberg 与 Lee 在 2017 年提出的 SHAP 框架花了两三年才在跨学科扩散,第 10 章会把 SHAP 用回本章训练好的模型上做局部解释。
数据层面,XGBoost 默认参数在 0.66% 不平衡上给出退化解,前一节的雷区框已经讲过。审计学早期试用 XGBoost 的研究者大多直接套用 sklearn 的入门范例,没有标定 scale_pos_weight,结果"AUC 还行但 Recall 几乎为零"被解读为"机器学习对舞弊问题不适用"。Bao 等人 2020 年的 JAR 论文用 RUSBoost 这种内置欠采样的 Boosting 变体绕开了 scale_pos_weight 的标定问题,加上 NDCG@ 这一对前 排序更敏感的指标,把 Boosting 在舞弊检测上的真实潜力展示出来。这次展示之后,XGBoost 才被正式接纳为审计 ML 的标准工具之一。
本章累积对比表
表 6·4 第 6 章累积方法对比:梯度提升 XGBoost
| 方法 | AUC | NDCG@100 | Recall@1% | Precision@1% | 训练时间 | 局限 |
|---|---|---|---|---|---|---|
| 全部预测为非舞弊 | 0.500 | 0 | 0 | 0 | 0 | 无判别力 |
| Beneish M-Score | 0.540 | 0.000 | 0.011 | 0.005 | 即时 | 规则固定,不学习 |
| Logistic / F-Score | 0.697 | 0.051 | 0.056 | 0.022 | 秒级 | 线性,难捕捉交互 |
| LASSO / Elastic Net | 0.688 | 0.050 | 0.056 | 0.022 | 秒级 | 线性,稀疏可能过强 |
随机森林 ranger | 0.709 | 0.015 | 0.037 | 0.014 | 18.7 秒 | MDI 偏向连续变量 |
| XGBoost 网格 + 早停 | 0.648 | 0.009 | 0.028 | 0.011 | 1.2 秒 | 时间外推塌陷 |
第 6 章打破了"方法越复杂、AUC 越高"的线性叙事。XGBoost 在验证集上把 RF 甩开 0.04 个 AUC 点,但到了测试期反过来被 RF 甩开 0.06 个点。下一章进入 RUSBoost,把"在 Boosting 内部直接处理类别不平衡"作为核心设计,看测试集表现能不能反弹回来。RUSBoost 也是 Bao 等人 2020 年原文的旗舰方法,第 7 章是参考论文复刻章。
本章知识地图
表 6·5 第 6 章核心概念与常见误解
| 核心概念 | 核心内容 | 常见误解 | 为什么错 |
|---|---|---|---|
| Bagging 与 Boosting | Bagging 平行训练独立树再平均;Boosting 串行训练,每棵树纠正前面累计的残差 | Boosting 一定优于 Bagging | Boosting 在时间外推任务上更激进,可能把训练期独有特征学进决策边界,导致测试集塌陷 |
学习率 eta | 控制每棵树对累计预测的贡献比例,与 n_estimators 互相替代 | eta 越小越好 | eta 太小要堆很多棵树才能拟合,训练时间成本陡增;与早停联合调更稳 |
早停 early_stopping_rounds | 连续若干轮验证集性能无改善就停训,最重要的正则化手段 | 正则化主要靠 lambda / alpha | 早停由数据决定停止时机,比固定的 / 项更贴合数据本身的过拟合拐点 |
scale_pos_weight | 把正样本损失放大 倍,校正不平衡 | 保持默认值 1 也能跑出 AUC | 默认值下 XGBoost 把概率全压向 0,前 1% 排序基本失效,Recall@1% 趋近 0 |
| 时间外推塌陷 | 验证集与测试集分属不同年份段时,验证 AUC 与测试 AUC 之间出现明显落差 | 调参再精细就能补上落差 | 落差来自标签分布漂移与 SEC 执法时间结构,不是超参的过失 |
| forward chaining | 按年份递增切分时间序列做交叉验证,绝不让训练样本在年份上晚于验证样本 | 随机交叉验证更标准 | 随机切分让模型看见未来年份样本,估计的泛化能力被严重高估 |
gain 重要性 | 把每次分裂带来的目标函数下降按变量累加,反映该变量对模型预测的累积贡献 | gain 与 weight / cover 排序应一致 | 三种重要性侧重不同,gain 看下降总量,weight 看分裂次数,cover 看样本覆盖 |