ARTICLE · INTELLIGENCE

战地情报 · 详情页

来自尧图项目组的一线实战观察与深度解析

模型评估避坑指南:混淆矩阵、ROC/PR曲线与阈值选择

模型评估避坑指南:混淆矩阵、ROC/PR曲线与阈值选择

1. 评估指标到底在衡量什么

前阵子帮一位做风控的朋友看模型,他给我看了一份评估报告,准确率 0.97,AUC 0.93,报告写得很漂亮。但真正上线跑了两周,业务方反馈"几乎没拦到该拦的人"。我们把样本拉出来重新切片一看,正样本占比只有 1.2%,模型把所有样本都判成负类,准确率就能有 98.8%。那份 0.93 的 AUC,是因为训练集和测试集切分时用了随机切分,同一个人在不同时间点的记录被分到了两边,特征里又混进了带时间信息的字段,等于提前把答案漏给了模型。

这件事之后我形成了个习惯:拿到任何一份模型评估报告,先不看指标数值,先看三件事——正负样本比例、测试集是怎么切的、指标是在哪个集合上算的。这三件事没搞清楚,后面的 AUC、PR 曲线、F1 全都是空中楼阁。

模型评估这件事的本质,是回答一个问题:模型在没见过的数据上,把"该挑出来的"挑出来了多少,同时把"不该挑的"误伤了多少。所有指标都是在这两个数字之间做权衡的不同表达方式。ROC 曲线、PR 曲线不过是把这个权衡过程在阈值维度上完整地铺开,让你看清楚"我换个阈值会发生什么"。所以想真正读懂这两条曲线,得先把最底层的四个格子吃透。

1.1 混淆矩阵:四个格子决定了后面所有事

二分类问题的评估起点永远是混淆矩阵。模型输出一个分数,你定一个阈值,高于阈值判正、低于判负,于是就有了四种结果:

实际为正实际为负
预测为正TP(真正例)FP(假正例)
预测为负FN(假负例)TN(真负例)

这四个数看着简单,但绝大多数评估困惑都源于没分清"分母是谁"。举个具体例子:1000 个样本里 20 个正、980 个负,模型挑出 30 个判正,其中 15 个是真的正例。那 TP=15,FP=15,FN=5,TN=965。

  • 准确率 = (15+965)/1000 = 98%,看起来很美。
  • 精确率 = 15/30 = 50%,也就是说模型说是坏人的,一半是冤枉的。
  • 召回率 = 15/20 = 75%,20 个真坏人只抓到 15 个。

同一组预测,三个指标讲出三个完全不同的故事。准确率的分母是全体样本,精确率的分母是"模型判正的那部分",召回率的分母是"实际为正的那部分"。分母不同,指标服务的对象就不同——精确率关心的是"我出手的这批质量如何",召回率关心的是"该抓的我漏了多少"。业务上你到底更怕哪种错,决定了你该看哪个指标。

这里有个特别容易被忽略的点:sklearn里precision_score和recall_score默认pos_label=1,也就是把标签 1 当正类。如果你的标签是 0/1 编码但业务上"0 才是异常",指标会整个反过来,而且不会报错。我见过有人跑了一周实验,最后发现精确率和召回率全反了,白折腾。

1.2 精确率与召回率的拉扯关系

精确率和召回率不是独立的,它们被同一个阈值绑在一起,一动全动。阈值调高,模型更谨慎,判正的少了,精确率通常上升、召回率下降;阈值调低,模型更激进,召回率上升、精确率下降。这种此消彼长的关系,画出来就是 PR 曲线。

理解这个拉扯关系,最好的方式是把它想成门卫查证件:查得严(阈值高),进来的基本都是有证的,但队伍排到门外;查得松(阈值低),人都进来了,混进来几个没证的。你没法同时要"效率高"和"零漏检",只能选一个平衡点。

F1 就是把这两个指标揉成一个数:F1 = 2 × P × R / (P + R),本质是精确率和召回率的调和平均数。用调和平均而不是算术平均的原因很实在——调和平均对小的那个值更敏感。P=1.0、R=0.1 时算术平均是 0.55 看着还行,调和平均只有 0.18,直接把"偏科"暴露出来。这也解释了为什么 F1 在类别不平衡场景下比准确率靠谱得多。

但 F1 也有它的局限:它默认精确率和召回率同等重要。实际业务里往往不是这样。内容审核漏放一条违规内容的代价,和误删一条正常内容的代价,可能差几十倍。这时候该用F-beta:F_β = (1+β²) × P × R / (β² × P + R),β>1 更看重召回率,β<1 更看重精确率。β 取多少不是拍脑袋,得从业务成本倒推,这块后面讲阈值选择时会展开。

1.3 为什么准确率在类别不平衡时会骗人

再回到开头那个 98.8% 准确率的例子。这里的问题不是"准确率这个指标不好",而是它的分母选错了。准确率把每个样本看成等权重的,但在不平衡场景下,负样本的数量优势会直接淹没正样本的表现。1000 个样本里 988 个负样本,你全判负,就拿到 98.8%。

判断数据是否不平衡,别只看比例,还要看绝对数量。正样本占比 1% 但总量 100 万,那还有 1 万个正样本,模型完全学得动;正样本占比 30% 但总量只有 200 条,那 60 个正样本根本不够训练,这时候的评估波动会大到没有参考价值。我一般的判断标准是:验证集里正样本少于 100 条,所有指标的置信区间都会宽到没法做决策,这时候要么扩充数据,要么老老实实做交叉验证取均值。

还有一个隐蔽的坑:阈值默认 0.5 在不平衡数据上几乎总是错的。模型在 1:99 的数据上训练完,输出的分数分布会整体偏向 0,绝大多数样本分数落在 0.1 以下,你拿 0.5 当阈值,等于要求模型极度自信才肯判正,召回率自然惨不忍睹。这一点是很多人第一次做不平衡分类时最想不通的地方——AUC 明明 0.9,怎么召回只有 0.2?因为 AUC 衡量的是排序能力,跟阈值无关;而召回率是定阈值之后的结果。两者根本不是一回事。

2. ROC 曲线是怎么一步步画出来的

ROC 曲线的全称是 Receiver Operating Characteristic,名字来自二战时的雷达信号检测分析,跟机器学习没半点关系,后来被搬过来描述"在噪声中检测信号"的能力,这个历史背景其实很能说明它的本质:在负样本的干扰下,你能把正样本排到多前面。

它的横轴是 FPR = FP / (FP + TN),也就是所有负样本里被误判为正的比例;纵轴是 TPR = TP / (TP + FN),就是召回率。两个轴的分母分别是负样本总数和正样本总数,所以 ROC 曲线描述的是"模型对正负两类的相对区分能力",跟正负样本比例无关——这正是它最大的优点,也是它在极端不平衡场景下最大的缺点。

2.1 阈值滑动如何把离散点连成曲线

很多人第一次看 ROC 曲线会觉得它是"算出来的",其实更准确的说法是"扫出来的"。模型给每个样本一个分数,你把所有分数从高到低排个序,然后从无穷大开始,一点点往下挪阈值,每挪一次就重新算一遍 TPR 和 FPR,得到一个坐标点,所有点连起来就是 ROC 曲线。

举个 8 个样本的小例子,标签和分数是:

y_true = [0, 0, 1, 1, 0, 1, 0, 1] y_score = [0.1, 0.35, 0.4, 0.8, 0.2, 0.9, 0.6, 0.7]

总共 4 正 4 负。按分数降序排:0.9(正)、0.8(正)、0.7(正)、0.6(负)、0.4(正)、0.35(负)、0.2(负)、0.1(负)。依次往下扫,每遇到一个样本就更新 TP 或 FP:

阈值位置TPFPTPRFPR
起始(全判负)0000
≥0.9100.250
≥0.8200.500
≥0.7300.750
≥0.6310.750.25
≥0.4411.000.25
≥0.35421.000.50
≥0.2431.000.75
≥0.1441.001.00

把 (FPR, TPR) 这些点画出来,再按梯形法算面积:从 (0,0) 到 (0,0.75) 这一段横坐标没变,面积为 0;从 (0,0.75) 到 (0.25,0.75) 面积 = 0.25 × 0.75 = 0.1875;之后 (0.25,1)→(0.5,1)→(0.75,1)→(1,1) 三段各是 0.25 × 1 = 0.25。加起来AUC = 0.1875 + 0.75 = 0.9375。

这个手算过程建议每个人都至少跑一遍,跑完你对 AUC 的理解会完全不一样。用代码验证也很简单:

import numpy as np from sklearn.metrics import roc_curve, auc y_true = np.array([0, 0, 1, 1, 0, 1, 0, 1]) y_score = np.array([0.1, 0.35, 0.4, 0.8, 0.2, 0.9, 0.6, 0.7]) fpr, tpr, thresholds = roc_curve(y_true, y_score) print("AUC:", auc(fpr, tpr)) # 0.9375 print("阈值:", thresholds) # 注意第一个阈值是 inf

roc_curve返回的thresholds[0]是inf,对应的点是 (0,0),代表"全部判负"这个极端状态。这就是为什么thresholds的长度永远比fpr/tpr多一个——第一个是虚拟的起点。

2.2 手写 ROC 绘制:并列分数是个隐形杀手

自己实现一遍 ROC 计算,最能暴露问题。最朴素的写法是这样的:

def naive_roc(y_true, y_score): order = np.argsort(-y_score) y = np.asarray(y_true)[order] P = y.sum() N = len(y) - P tps = np.cumsum(y) fps = np.cumsum(1 - y) return fps / N, tps / P

这个版本在分数没有并列的情况下是对的,但一旦出现并列分数,就会画出一段其实不存在的"锯齿"。原因很直观:如果有两个样本分数完全相同,按上面的写法,排序后它们会被当成先后扫过,产生了中间状态。而真实情况是,阈值没法把这两个样本分开——要么都判正,要么都判负,不可能一个正一个负。

所以正规实现必须先按分数分组,同一组分数一次性全部纳入,再取坐标点。sklearn内部就是这么处理的。这个细节在日常使用中一般不会出问题,但如果你在做模型融合、对分数做了四舍五入、或者分数本身就是离散的(比如某些树模型输出的叶子索引),并列分数会非常普遍,算出来的 AUC 会偏高。

还有一种情况也得注意:样本量很小的时候,ROC 曲线是一条阶梯状的折线,不是平滑曲线。这时候用梯形法算面积会引入系统偏差。样本量少于几百的时候,我更倾向于直接看曲线形状和关键阈值点,而不是盯着那个 AUC 数值。

2.3 AUC 到底在说什么

AUC 的官方解释是"ROC 曲线下的面积",但这个定义没什么用,因为它没告诉你这个数字代表什么。我个人最常用的两种解读方式:

第一种,概率解读。AUC 等于"随机抽一个正样本和一个负样本,模型给正样本打分高于负样本的概率"。AUC=0.9375 意味着,你随便拿一对正负样本,模型有 93.75% 的概率把正的那个排前面。这个解读特别适合向非技术同事解释——你不需要讲什么 FPR、TPR,就说"模型的排序准确率"。

第二种,排序质量解读。AUC 只关心排序,不关心分数的绝对值。你把所有预测分数取对数、开平方、甚至做个单调递增的任意变换,AUC 一点不变。这既是优点(模型输出的分数不需要校准就能算 AUC),也是缺点(AUC 好不代表分数可以直接当概率用)。

顺带提一个常被问到的换算:Gini 系数 = 2 × AUC − 1。AUC=0.5 时 Gini=0,AUC=1 时 Gini=1。风控领域经常用 Gini 或者 KS 统计量,本质都是在描述"模型把两类分开的能力",跟 AUC 是同一件事的不同刻度。

关于"多高的 AUC 算好",网上流传的 0.7/0.8/0.9 分档纯属经验值,没有普适性。同样是 0.85,在医学影像里可能已经很不错,在推荐排序里可能完全不可用。真正有意义的对比基准只有两个:随机猜的 AUC(0.5)和现有线上系统的 AUC。比 0.5 高多少说明模型学到了多少,比线上高多少说明这次迭代有没有价值,其他都是虚的。

2.4 ROC 在极端不平衡下为什么会失真

现在讲 ROC 最要命的短板。看 FPR 的分母:FP + TN,也就是所有负样本的数量。当负样本有 100 万条的时候,你把 FP 从 100 增加到 10000,FPR 只从 0.0001 变成 0.01,在图上几乎看不出变化。但实际业务里,多出来的 9900 个误报可能已经让你的审核团队崩溃了。

这就是 ROC 在极度不平衡场景下的失真机制:它把负样本的绝对数量摊薄了。曲线依然漂亮,AUC 依然 0.95,但模型在"高精确率区域"的表现完全被掩盖了。

判断什么时候 ROC 会失真,可以用一个粗略的经验:正负比例超过 1:10 就该警惕,超过 1:100 就该换 PR 曲线做主看板。具体阈值没有定论,取决于你的业务能容忍多少误报。广告点击率预估的正负比常常是 1:100 到 1:1000,欺诈检测更极端,这时候 ROC 只能当个参考,真正的决策依据得看 PR。

还有一个衍生问题:ROC 曲线对"模型在头部排序的表现"不敏感。因为它从阈值正无穷一路扫到负无穷,每个样本都只贡献一次,头部那几百个高分样本的表现被淹没在几十万个样本里。而业务上我们往往只关心"推给用户的 Top 100"质量如何,这时候该看的是Precision@K、Recall@K、NDCG这类排序指标,ROC 和 PR 都不太够用。

3. PR 曲线:正样本稀缺时该盯的地方

如果说 ROC 是从"负样本视角"看模型,那 PR 曲线就是从"正样本视角"看模型。横轴是召回率 R = TP/(TP+FN),纵轴是精确率 P = TP/(TP+FP)。注意这两个轴的分母里都只有正样本相关的量(TP+FN 是实际正样本数,TP+FP 是预测正样本数),负样本只通过 FP 间接参与。

这个坐标设计的差别,直接决定了 PR 曲线在不平衡场景下的表现:当正样本极少时,任何一个 FP 都会让精确率明显下降,所以 PR 曲线对误报的惩罚被放大了,正好补上了 ROC 的短板。

3.1 两条曲线的形状差异说明了什么

拿同一组数据同时画 ROC 和 PR,最容易观察到的现象是:ROC 曲线通常从 (0,0) 出发一路向右上走,而 PR 曲线通常从左上角出发(或者从某个较高的精确率开始)向右下走。这不是画错了,是坐标系方向决定的。

  • ROC 的起点是"全判负":TPR=0,FPR=0,在左下角。
  • PR 的起点是"全判负":召回率=0,精确率按定义为 1(因为没预测任何正样本,不存在误报),但sklearn会返回精确率=1、召回率=0 这个点作为起点。

一个特别值得记的结论:PR 曲线的"随机基线"不是 0.5,而是正样本比例。如果你的数据里正样本占比 5%,那一个随机分类器的 PR 曲线大致会落在 P≈0.05 的水平线上。所以在不平衡数据上,AP=0.3 看起来很惨,但如果基线只有 0.01,那 0.3 已经是 30 倍的提升,相当能打了。不报基线的 PR 指标没有任何意义,这一点我在评审别人的报告时一定会问。

ROC 的基线就简单得多,永远是 0.5 那条对角线,跟数据分布无关。这也是 ROC 用起来省心的地方。

3.2 AP 的两种算法:插值和不插值差很多

PR 曲线下的面积一般叫 AP(Average Precision),但这个词在文献里有好几种算法,互相之间不能直接比较,这是个大坑。

第一种,梯形积分法。直接对 (recall, precision) 做数值积分:

import numpy as np from sklearn.metrics import precision_recall_curve, auc, average_precision_score precision, recall, thresholds = precision_recall_curve(y_true, y_score) # 方式一:梯形积分 ap_trapz = auc(recall, precision) # 方式二:阶梯法(sklearn 的 average_precision_score) ap_step = average_precision_score(y_true, y_score)

这两个数是不相等的,而且差值可能不小。原因在于 PR 曲线本身是阶梯状的:阈值每挪一次,召回率可能一口气跳好几个百分点,而精确率在跳变前后差异很大。梯形法会在跳变的两个点之间画一条斜线,把中间那些"其实并不存在"的操作点也算进面积里,所以通常会偏高。

第二种,阶梯法(也叫非插值 AP)。这才是average_precision_score的实现方式,公式是:

AP = Σ (R_n − R_{n−1}) × P_n

意思是:每当召回率往上走一段,就用走完之后那一刻的精确率乘以这段召回率的增量,累加起来。回到 2.1 节那个 8 样本的例子,按这个公式算:

  • 召回率 0→0.25,此时精确率 1.0,贡献 0.25 × 1.0 = 0.25
  • 召回率 0.25→0.50,精确率 1.0,贡献 0.25 × 1.0 = 0.25
  • 召回率 0.50→0.75,精确率 1.0,贡献 0.25 × 1.0 = 0.25
  • 召回率 0.75→1.00,精确率降到 0.8,贡献 0.25 × 0.8 = 0.20

AP = 0.95。而梯形法算出来的值会略高于这个数。所以报告里写 AP 的时候,一定要标明用的是哪种算法,否则跟别人的数字对不上是常事。

还有第三种更老的算法:11 点插值法,在召回率 0、0.1、…、1.0 这 11 个点上取右侧最大精确率求平均。这是早期目标检测评测用的,现在的标准已经改成全点插值了。如果看到论文里写 "mAP@0.5" 但没说明插值方式,多半是全点插值。

3.3 什么情况下该用 PR 而不是 ROC

我给一个比较实用的判断流程:

场景特征推荐主指标理由
正负比例在 1:1 到 1:10 之间ROC-AUC曲线稳定,解释直观
正负比例超过 1:100PR-APROC 会被负样本数量稀释
业务只关心 Top-K 结果Precision@K / Recall@K全局指标无法反映头部质量
关心具体阈值下的表现精确率/召回率/F1(定阈值)曲线是全局视角,不能代替点估计
需要向非技术方汇报ROC-AUC + 业务口径指标单一数字易沟通

一个经常被忽略的事实是:当正样本占比很低的时候,PR 曲线本身的波动会很大。因为精确率的分母是预测正样本数,早期可能只有 5 个、10 个,多一个 FN 就能让精确率从 0.6 跳到 0.4。这种波动不是模型不稳定,是统计噪声。我通常会配合 bootstrap 给 AP 算个置信区间,如果区间宽度超过 0.05,那这个数字就不该拿来比较模型优劣。

4. 阈值怎么定:从曲线回到具体决策

前面所有讨论都是"阈值无关"的:AUC 衡量排序,AP 衡量排序,都不涉及具体阈值。但模型上线必须定一个阈值,这一刻所有全局指标都失效了,你面对的是一个非常具体的取舍问题。

我在项目里反复强调一句话:阈值 0.5 是数学上的中点,不是业务上的中点。模型输出的 0.5 到底代表什么,取决于模型的校准程度。逻辑回归在均衡数据上训练完,输出大概能当概率看;但随机森林、XGBoost 在严重不平衡数据上训练完,输出分数会整体偏低,0.5 可能对应着 99 分位。这时候拿 0.5 当阈值,等于自废武功。

4.1 用代价矩阵把阈值算出来

最直接的定量方法是期望代价最小化。假设误报一个的代价是 C_FP,漏报一个的代价是 C_FN,那么在阈值 t 下:

Cost(t) = FP(t) × C_FP + FN(t) × C_FN

把所有候选阈值都扫一遍,取代价最小的那个。实现起来二十行代码:

import numpy as np from sklearn.metrics import confusion_matrix def best_threshold(y_true, y_score, cost_fp=1.0, cost_fn=10.0, n_steps=200): thresholds = np.linspace(y_score.min(), y_score.max(), n_steps) best_t, best_cost = 0.5, float('inf') for t in thresholds: y_pred = (y_score >= t).astype(int) tn, fp, fn, tp = confusion_matrix(y_true, y_pred, labels=[0, 1]).ravel() cost = fp * cost_fp + fn * cost_fn if cost < best_cost: best_cost, best_t = cost, t return best_t, best_cost

这里的关键是cost_fn和cost_fp怎么来。不要拍脑袋填 10 和 1,得去问业务方。比如内容审核场景,可以这么问:"漏放 100 条违规内容,和误删 100 条正常内容,哪个你更不能接受,差多少倍?" 得到的倍数关系就是代价比。有些团队会把它换算成钱或者人力工时,更容易达成共识。

我在实际项目里踩过一个坑:代价比不能只看单次成本,还要看处理量。假设误报的代价是每条 5 元人工复核费,漏报的代价是每条约 500 元的潜在损失,看起来 1:100,但如果你把阈值调低,误报从每天 100 条涨到 5000 条,人工团队直接排不过来,这时候的"代价"就不是线性的了。所以算完理论最优阈值后,还要做一次产能约束检查:这个阈值下的日均触发量,运营团队接不接得住。

4.2 分位数阈值与产能约束

当代价比很难量化时,一个很实用的替代方案是按分位数定阈值。比如业务目标是"每天处理 2000 条告警",那就把当天所有样本的预测分数排序,取第 99.8 分位对应的分数作为阈值。这样触发量天然可控,不用反复调。

这个做法的好处是自动适应分布漂移。模型上线后,用户行为会变、样本分布会变,如果阈值写死成 0.73,可能过两个月触发量就翻十倍了。用分位数的话,只要保证每天重算一次分位点,触发量就稳住了。

坏处也很明显:它把"模型发现了多少"变成了"我们只能处理多少"。如果某天真的出现了大规模异常,分位数阈值会把大量真实告警压下去。所以我一般建议双阈值方案:

  • 主阈值用代价矩阵算出来的固定值,触发就推送;
  • 兜底阈值用分位数动态调整,防止触发量爆炸;
  • 两个阈值之间的样本进入"低优先级队列",不实时推送但保留记录。

4.3 阈值上线后的分数漂移监控

阈值定完不是结束,而是开始。模型分数分布会随着时间漂移,最常见的原因是特征的上游数据源发生了变化。比如某个统计类特征依赖近 7 天的行为数据,节假日期间用户行为模式一变,这个特征的分布就整个偏移了,模型输出的分数也跟着偏。

监控方法很简单,每天算一次线上预测分数的分位数(比如 P50、P90、P99),和训练时的基准分位数对比。偏离超过一定幅度就告警。这个指标比监控特征分布更灵敏,因为它直接把所有特征的偏移聚合到了一个维度上。

我还会额外监控两个东西:每天的判正率和每天的 TP 估算值。判正率就是预测为正的样本占比,如果它在没有任何业务事件的情况下突然从 2% 跳到 8%,那基本可以确定是模型或数据出了问题。TP 估算值需要一点人工抽检,每天从判正的样本里随机抽 50 条人工核对,算出精确率,再乘以判正总量,就能得到 TP 的近似值。这个数字比 AUC 更贴近业务,因为它是实打实的"今天抓对了多少个"。

5. 复现评估结果时最容易翻车的几个细节

指标算错、曲线画歪,很多时候不是理解问题,而是流程上的细节没处理好。这一节我把自己和同事踩过的坑整理一下,都是那种"不看不知道,一看就觉得蠢"的类型,但确实反复出现。

5.1 交叉验证里的数据泄漏

最常见也最致命的一类错误。看下面这段代码:

from sklearn.preprocessing import StandardScaler from sklearn.model_selection import cross_val_score # 错误做法:先在全量数据上标准化,再做交叉验证 scaler = StandardScaler() X_scaled = scaler.fit_transform(X) scores = cross_val_score(model, X_scaled, y, cv=5)

问题在于,fit_transform用到了全量数据的均值和方差,包括每一折的验证集。验证集的信息"提前"参与了特征变换,这在统计上就是泄漏。对于标准化这种线性变换,泄漏的影响通常不大,但如果换成SelectKBest做特征选择、或者用 SMOTE 做采样,泄漏会非常严重。

正确的做法是用Pipeline把预处理和模型打包,让cross_val_score在每一折内部单独 fit:

from sklearn.pipeline import Pipeline from sklearn.feature_selection import SelectKBest, f_classif pipe = Pipeline([ ("select", SelectKBest(f_classif, k=20)), ("scaler", StandardScaler()), ("clf", model), ]) scores = cross_val_score(pipe, X, y, cv=5, scoring="roc_auc")

还有一个更容易被忽略的泄漏来源:分组信息。如果同一个用户有多条记录,随机切分会让同一个用户同时出现在训练集和测试集里。模型只要记住这个用户的特征,就能在测试集上蒙对。这种情况下应该用GroupKFold,按用户 ID 分组切分。同理,如果数据带时间维度,必须用TimeSeriesSplit或者手动按时间切,绝对不能随机打乱。

我见过一个时序预测项目,AUC 从 0.88 掉到 0.62,团队查了两周,最后发现是数据切分时打乱了时间顺序,模型用到了未来信息。改成时间切分后,那个 0.88 就再也没出现过。

5.2 采样和评估的先后顺序

处理类别不平衡时,SMOTE 是最常用的手段。但很多人会在整个数据集上做完 SMOTE 再切分,这直接导致测试集里的合成样本和训练集高度相关,指标虚高到离谱。

正确顺序永远是:先切分,只在训练集上采样,测试集保持原始分布。

from imblearn.pipeline import Pipeline as ImbPipeline from imblearn.over_sampling import SMOTE pipe = ImbPipeline([ ("smote", SMOTE(random_state=42)), ("clf", model), ])

注意这里要用imblearn的Pipeline,不是sklearn的,因为imblearn的版本知道 SMOTE 只在 fit 阶段执行,不做 transform 到测试集。

还有一个更根本的问题值得思考:测试集是否应该保持原始分布?我的答案是必须保持。因为你上线后面对的就是这个分布,如果测试集被人为平衡了,那算出来的精确率、召回率全都是假的。有个例外是当你想评估"模型在平衡场景下的纯判别能力",那可以额外做一份平衡测试集,但要明确标注这是参考值,不能用于阈值决策。

5.3 小样本下的指标波动有多大

AUC 和 AP 都是点估计,没有方差信息。测试集只有 200 条样本、其中 20 条正样本的时候,AUC 的 95% 置信区间可能有 0.15 那么宽。这意味着两个模型的 AUC 差 0.05,很可能只是噪声。

估算区间最实用的方法是bootstrap:对测试集做有放回重采样,重复计算指标,取 2.5% 和 97.5% 分位点。

import numpy as np from sklearn.metrics import roc_auc_score def bootstrap_auc(y_true, y_score, n_boot=1000, seed=42): rng = np.random.default_rng(seed) n = len(y_true) aucs = [] for _ in range(n_boot): idx = rng.integers(0, n, n) if len(np.unique(y_true[idx])) < 2: continue aucs.append(roc_auc_score(y_true[idx], y_score[idx])) return np.percentile(aucs, [2.5, 97.5]) lower, upper = bootstrap_auc(y_true, y_score) print(f"AUC 95% CI: [{lower:.3f}, {upper:.3f}]")

这里有个循环里的continue很关键——重采样有可能抽不到正样本或负样本,这时候roc_auc_score会直接报错。这个细节很多现成的代码片段都漏了。

我的经验是:如果两个模型的 AUC 置信区间有明显重叠,就不要急着宣布谁更好。要么扩样本,要么做配对检验(比如 DeLong 检验),要么直接看它们在实际阈值下的业务指标差异。

5.4 多分类场景下的平均方式

二分类讲完了,多分类的坑其实更深。sklearn的roc_auc_score支持average参数,常见的有macro、weighted、micro三种,算出来的值差别不小。

  • macro:每个类单独算 AUC,然后算术平均。小类和大类等权重,能反映"模型对所有类一视同仁的表现"。
  • weighted:按每类的样本数加权平均。大类主导,更接近"整体准确率"的直觉。
  • micro:把所有类的预测展平后统一算,实际上会被样本最多的类主导。

选哪个取决于你的业务关心什么。如果小类才是重点(比如罕见病识别),必须用macro,否则小类的糟糕表现会被大类掩盖。如果只是想知道整体效果,weighted更合适。报告里写多分类 AUC 的时候,一定要注明平均方式,这是基本要求。

还有个细节:多分类的 ROC 曲线需要 one-vs-rest 展开,对每个类画一条曲线。如果类别有十几个,曲线图画出来会糊成一团,这时候我一般只画 AUC 最高和最低的两个类,或者干脆用柱状图展示各类 AUC,比曲线清楚得多。

5.5 报告里必须写清楚的那几件事

最后列一份我自己用的评估报告检查清单,每次交付前逐条过一遍:

  • 测试集的样本量、正负比例、是否与线上分布一致;
  • 数据切分方式(随机 / 分层 / 分组 / 时序),有没有泄漏风险;
  • 所有指标的置信区间或多次重复实验的标准差;
  • ROC 和 PR 曲线同时给出,并标明 AP 的计算方式;
  • 关键阈值下的混淆矩阵,而不只是全局指标;
  • 与基线模型(随机、现有线上系统)的对比。

这几条写清楚,报告基本就不会被人挑出问题。反过来,如果一份评估报告里这些信息一个都没有,只甩一个 AUC=0.93,那这个数字到底可不可信,就得打个问号。

我个人在实操中的体会是,模型评估这件事最难的不是算指标,而是想清楚这个指标对应着哪个业务决策。AUC 对应不了任何具体动作,它只是个排序质量的摘要;真正指导行动的,永远是"在这个阈值下,我会误伤多少人、漏掉多少人"。所以每次做评估,我都会逼自己往前走一步:把这个模型放到真实流程里,算一遍人工成本、算一遍漏检损失,然后回头看那些曲线,很多原本纠结的选择就变得很清楚了。

RELATED READING

延伸阅读

更多一线实战笔记与深度复盘,助您持续精进