财务舞弊检测实践 · 第 6

XGBoost:Boosting 与 Bagging 的差异

第 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 个样本的残差为 10.01=0.991 - 0.01 = 0.99 而非舞弊样本残差只有 00.01=0.010 - 0.01 = -0.01。他训练一棵浅树专门去拟合这组残差,对那 6 家公司输出一个比较大的正修正,比如 +0.20+0.20,对其它公司输出接近 0 的修正。第二位的产出叠加到第一位之后,那 6 家公司的预测分数变成 0.01+0.20=0.210.01 + 0.20 = 0.21,离真值 1 更近了。这种"残差驱动"的迭代就是梯度提升的内核。

定义梯度提升

设损失函数为 L(y,y^)L(y, \hat{y}),初始预测为 F^0(x)\hat{F}_0(x)。第 tt 轮迭代在前一轮预测 F^t1(x)\hat{F}_{t-1}(x) 的基础上,让一棵新的弱学习器 ft(x)f_t(x) 去拟合损失对当前预测的负梯度,即"伪残差" rti=L(yi,y^)/y^y^=F^t1(xi)r_{ti} = -\partial L(y_i, \hat{y}) / \partial \hat{y}\big|_{\hat{y} = \hat{F}_{t-1}(x_i)}。更新规则为 F^t(x)=F^t1(x)+ηft(x)\hat{F}_t(x) = \hat{F}_{t-1}(x) + \eta f_t(x),其中 η\eta 是学习率。最终模型是 TT 棵树的叠加:F^T(x)=F^0(x)+t=1Tηft(x)\hat{F}_T(x) = \hat{F}_0(x) + \sum_{t=1}^{T} \eta f_t(x)

XGBoost 把这个一阶梯度框架推到了二阶。它在每一轮同时使用一阶导与二阶导对损失做泰勒近似,然后把目标函数改写成关于叶子节点权重的二次形式,使每个叶子的最优输出有闭式解。配上一组对树结构的正则化项,整个目标函数变成

L(t)=i=1n[gift(xi)+12hift2(xi)]+Ω(ft),\mathcal{L}^{(t)} = \sum_{i=1}^{n} \bigl[g_i f_t(x_i) + \tfrac{1}{2} h_i f_t^2(x_i)\bigr] + \Omega(f_t),

其中 gig_ihih_i 分别是损失对当前预测的一阶和二阶导,Ω(ft)=γTt+12λw2\Omega(f_t) = \gamma T_t + \tfrac{1}{2}\lambda \|w\|^2 是对叶子数 TtT_t 与叶子权重 ww 的双重惩罚。这一套组合让每一刀分裂的"信息增益"里同时包含了拟合贡献与复杂度成本。

学习率、深度、子采样

XGBoost 的关键超参数可以分四组。第一组控制学习节奏,主要是 eta。把 eta 调到 0.05 意味着第 tt 棵树的输出被压缩到原来的 5%,模型每步迈得很小,需要更多树补齐进度。第二组控制每棵树的复杂度,包括最大深度 max_depth、叶子最小样本权重和 min_child_weight、分裂前必须达到的最小损失下降 gamma。第三组控制随机性,每棵树取多少比例样本由 subsample 决定,每棵树取多少比例特征由 colsample_bytree 决定。第四组控制正则化,主要是 L2L_2lambdaL1L_1alpha

etamax_depth 两个参数有相互替代的关系。学习率小、树多但每棵浅,相当于走一条平稳的路;学习率大、树少但每棵深,相当于一脚油门到底。两条路在训练集上都能拟合得很好,但前者通常在测试集上更稳。Bao 数据上的网格搜索给了一个具体的判据:在 max_depth=7、eta=0.1、subsample=0.7、colsample_bytree=1.0 的组合下,验证集 AUC 在第 79 棵树之后就不再提升,早停触发;如果硬要让它跑到 2000 棵树,验证集 AUC 反而开始往下走,这是过拟合的典型信号。

gamma 这个参数容易被忽略,但它的含义直白。一刀切下去,左右两边的二阶近似目标函数下降不到 gamma,就放弃这次分裂。它的功能与 rpartcp 类似,给每棵树一个"宁可不分裂也不要勉强分裂"的容错门槛。在不平衡数据上把 gamma 调高一点,可以避免树在少量稀疏正样本上长出"50% 舞弊率但只有 11 个样本"那种极端叶子。

早停作为最重要的正则化

很多文献把 L2L_2lambda 写在最显眼的位置,但实操里真正决定 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 设成 n/n+n_{-}/n_{+},让损失函数在正样本上的贡献被放大相同的倍数。Bao 训练集上 n/n+=63,393/537=118.05n_{-}/n_{+} = 63{,}393 / 537 = 118.05,也就是说每错分一个真舞弊样本,损失要乘以 118 倍,让模型看见少数类的痛感和多数类对齐。这是本书首次显式引入"以损失放大代替样本平衡"的处理思路,第 7 章的 RUSBoost 会换成对多数类做欠采样的另一条路径。

雷区默认参数下的不平衡退化

scale_pos_weight=1 的默认设置下,XGBoost 在 Bao 这种 0.66% 不平衡的数据上会给出几乎全为 0 的概率分布。表面上验证集 AUC 仍可达到 0.6 以上,但前 1% 排序里几乎没有真舞弊样本,Recall@1% 与 Precision@1% 趋近于 0。修正方法有三条:把 scale_pos_weight 设为 n/n+n_{-}/n_{+};改用对正样本加权的对偶损失;或像第 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--200263,930537---早停在 79 棵树
验证 2003--200830,7772500.7541调参锚点
测试 2009--201427,6281070.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% 等于 3/107=2.80%3 / 107 = 2.80\%,比 RF 的 4/107=3.74%4 / 107 = 3.74\% 还低 1 个百分点。AUC 与 Recall@1% 在本章一起退步,说明问题不在阈值,而在模型对未来年份的整体排序能力下降。

案例公司打分

时间外推塌陷不意味着 XGBoost 在所有样本上都退步。把 fyear=2000 的 Enron 与 Tyco 喂进训练好的模型,得到下表的概率。

表 6·2 案例公司 XGBoost 舞弊概率,fyear=2000

公司gvkeyfyear真实 misstateXGB pp(misstate=1)
Enron6127200010.9839
Tyco International10787200010.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
1act236.36
2sstk221.77
3invt197.90
4dltt196.97
5dp189.71
6ppegt187.89
7prcc_f180.74
8csho177.08
9lct171.24
10at168.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@kk 这一对前 kk 排序更敏感的指标,把 Boosting 在舞弊检测上的真实潜力展示出来。这次展示之后,XGBoost 才被正式接纳为审计 ML 的标准工具之一。

本章累积对比表

表 6·4 第 6 章累积方法对比:梯度提升 XGBoost

方法AUCNDCG@100Recall@1%Precision@1%训练时间局限
全部预测为非舞弊0.5000000无判别力
Beneish M-Score0.5400.0000.0110.005即时规则固定,不学习
Logistic / F-Score0.6970.0510.0560.022秒级线性,难捕捉交互
LASSO / Elastic Net0.6880.0500.0560.022秒级线性,稀疏可能过强
随机森林 ranger0.7090.0150.0370.01418.7 秒MDI 偏向连续变量
XGBoost 网格 + 早停0.6480.0090.0280.0111.2 秒时间外推塌陷

第 6 章打破了"方法越复杂、AUC 越高"的线性叙事。XGBoost 在验证集上把 RF 甩开 0.04 个 AUC 点,但到了测试期反过来被 RF 甩开 0.06 个点。下一章进入 RUSBoost,把"在 Boosting 内部直接处理类别不平衡"作为核心设计,看测试集表现能不能反弹回来。RUSBoost 也是 Bao 等人 2020 年原文的旗舰方法,第 7 章是参考论文复刻章。

本章知识地图

表 6·5 第 6 章核心概念与常见误解

核心概念核心内容常见误解为什么错
Bagging 与 BoostingBagging 平行训练独立树再平均;Boosting 串行训练,每棵树纠正前面累计的残差Boosting 一定优于 BaggingBoosting 在时间外推任务上更激进,可能把训练期独有特征学进决策边界,导致测试集塌陷
学习率 eta控制每棵树对累计预测的贡献比例,与 n_estimators 互相替代eta 越小越好eta 太小要堆很多棵树才能拟合,训练时间成本陡增;与早停联合调更稳
早停 early_stopping_rounds连续若干轮验证集性能无改善就停训,最重要的正则化手段正则化主要靠 lambda / alpha早停由数据决定停止时机,比固定的 L1L_1 / L2L_2 项更贴合数据本身的过拟合拐点
scale_pos_weight把正样本损失放大 n/n+n_{-} / n_{+} 倍,校正不平衡保持默认值 1 也能跑出 AUC默认值下 XGBoost 把概率全压向 0,前 1% 排序基本失效,Recall@1% 趋近 0
时间外推塌陷验证集与测试集分属不同年份段时,验证 AUC 与测试 AUC 之间出现明显落差调参再精细就能补上落差落差来自标签分布漂移与 SEC 执法时间结构,不是超参的过失
forward chaining按年份递增切分时间序列做交叉验证,绝不让训练样本在年份上晚于验证样本随机交叉验证更标准随机切分让模型看见未来年份样本,估计的泛化能力被严重高估
gain 重要性把每次分裂带来的目标函数下降按变量累加,反映该变量对模型预测的累积贡献gainweight / cover 排序应一致三种重要性侧重不同,gain 看下降总量,weight 看分裂次数,cover 看样本覆盖