有个做风控的朋友前几天问我:训练样本被下采样之后,AUC到底还怎么算?他手里有一份正负样本比例被人工改过的数据,直接套roc_auc_score总觉得心里没底,翻了不少文章也没看到有人把“带权重的AUC”这件事彻底讲清楚。
这个问题其实一点都不偏门。凡是做过采样、加权损失、样本重要性赋权的人,迟早都会撞上。先说结论:带权重的AUC,不是把每个样本的权重乘到某个聚合指标上就完事,而是要在“正负样本两两比较”这个最底层把权重乘进去。这篇文章我会从AUC的本质开始,把加权AUC的定义、公式推导、numpy实现、常见坑一次性讲透,最后给你一份可以复制到项目里直接用的代码。
1. 先把普通AUC这件事彻底捋清楚
1.1 AUC的本质:一次概率游戏
AUC的全称是“Area Under the ROC Curve”,但比这个几何定义更重要的,是它的概率解释:
AUC = 随机抽一个正样本、再随机抽一个负样本,正样本的预测分数大于负样本预测分数的概率。
这句话是理解所有后续内容的钥匙。你可以想象有两拨人站在操场上:一拨是正样本,一拨是负样本,每个人脑门上贴着一个预测分数。现在从两拨人里各随机抓一个出来比分数,正样本那边的人分数更高的比例,就是AUC。
如果两个分数恰好相等,按约定算“一半赢”。所以更严谨的写法是:
AUC = P(S_pos > S_neg) + 0.5 * P(S_pos = S_neg)
其中 S_pos 是正样本的预测分数,S_neg 是负样本的预测分数。
这种“概率”视角的最大好处是:当样本有了权重,我们只需要把“随机抽一个”改成“按权重随机抽一个”,整件事就说得通了。后面会反复用到这个思路。
1.2 排序视角:从正负样本配对看AUC
除了概率解释,AUC还有一个更利于编程的等价形式。假设有 m 个正样本、n 个负样本,把所有正负样本对都列出来,一共 m*n 对。对每一对比较分数:
- 正样本分数 > 负样本分数,记 1 分;
- 正样本分数 = 负样本分数,记 0.5 分;
- 正样本分数 < 负样本分数,记 0 分。
把所有分数加起来,除以 m*n,就是AUC。
这就是所谓的 Mann-Whitney U 统计量。这个形式看起来简单,但如果你真用两层 for 循环去写,复杂度是 O(m*n),数据量上万就非常痛苦。好在它有排序加速的方法:先把所有样本按预测分数从高到低排序,然后扫描一遍,维护“已经见过多少个负样本”或者“已经见过多少个正样本”,就可以把复杂度降到 O(n log n)。
普通AUC的排序算法大致长这样:按分数降序遍历,每遇到一个正样本,就统计它前面已经出现了多少个负样本,累加后除以 m*n 即可。同分样本要单独处理,不能把同分正负样本对直接算成 1 或 0,否则结果会偏。
2. 加了权重以后,哪些场景会逼着你算加权AUC
2.1 三种最常见的加权场景
先说第一种:采样导致的分布偏移。二分类问题里正负样本比例经常失衡,不少人会把负样本大量下采样来加速训练。但训练集的正负比例一旦被人为改过,模型在训练集上的AUC就和真实分布对不上了。如果你希望评估指标反映的是“原始分布下的排序能力”,就必须给被采样掉的样本补回权重。比如原始负样本 100 万,你只保留了 10 万,那每个保留的负样本权重可以近似设为 10。这种场景下,带权重的AUC不是锦上添花,而是修正统计口径的必需品。
第二种:业务重要性不同。典型例子是电商和风控场景。用户在平台上有点击、加购、下单、退款等行为,一个模型如果只看“是否转化”,把所有行为一视同仁,业务上往往会失真:下单样本和点击样本的重要性显然不一样。给每个样本按照业务价值赋权,然后计算加权AUC,能让指标更贴近真实收益。风控场景里更常见:同样是一笔坏账,损失金额差几十倍,你自然希望模型在小额和大额样本之间体现出不同的排序能力。
第三种:倾向性加权和分层抽样。有些场景会用倾向性得分给样本加权,或者用分层抽样后有意识地调整层内权重。这些权重最终都要落到模型评估上。如果评估时还是普通AUC,就相当于把精心设计的权重又扔掉了。
2.2 加权AUC的严格定义
在加权场景下,每个样本除了有标签 y 和预测分数 s,还有一个权重 w。加权AUC的严格定义是:
按权重随机抽一个正样本、按权重随机抽一个负样本,正样本的预测分数大于负样本预测分数的概率。
写成公式就是:
AUC_weighted = [ Σ_i Σ_j w_i * w_j * I(s_i > s_j) + 0.5 * w_i * w_j * I(s_i = s_j) ] / [ (Σ_i w_i) * (Σ_j w_j) ]
其中 i 遍历所有正样本,j 遍历所有负样本。分母是所有正样本权重之和乘以所有负样本权重之和,作用是归一化,让结果保持在 0 到 1 之间。
这个形式其实和普通AUC长得一模一样,只不过每一对正负样本的贡献从“1”变成了“这个正样本的权重乘以这个负样本的权重”。换句话说,加权AUC的“加权”发生在两两比较的层面,不是发生在样本聚合之后。
这里有个很容易踩的误区:有人以为加权AUC等于“先把每个样本的权重乘到预测分数上,再算普通AUC”,或者“把加权后的预测分数带入 roc_auc_score”。这两种做法都是错的。预测分数是模型输出,权重是样本属性,这两个东西不能混在一起。
2.3 为什么说“复制样本”不是好方案
既然权重本质上是样本在概率抽样里的相对重要程度,那自然会有人问:我能不能把权重为 3 的样本复制成 3 份,权重为 2 的样本复制成 2 份,然后直接用普通AUC?
数学上,如果权重都是正整数,这种方法在理论上确实等价于加权公式。但实际操作中问题很多:
- 权重左上角可能不是整数,比如 0.3、2.7,复制没法处理;
- 数据量会爆炸。本来 100 万样本,权重一乘可能变成 500 万份,排序和存储都不划算;
- 同一样本复制出来的多条记录,在数据抽样和排序时会产生不必要的“内部配对”风险,虽然最后结果正确,但中间过程让人心里没底。
所以工程上不会有人真的去复制样本,而是直接把权重写进计算公式。下面要讲的排序算法,就是干这件事的。
3. 两个等价算法与推导:靠累计权重算AUC
3.1 方法A:负样本视角,正样本权重累计
先把所有样本按预测分数从高到低排序。然后从高到低遍历。每到一个分数块,块里可能同时有正样本和负样本。我们关注的是:当前这个分数块里的每一个负样本,它应该和哪些正样本配对?
可以直接看出规则:
- 分数严格高于当前块的每个正样本,和当前块里某个负样本配对时,正样本稳赢,贡献 1;
- 分数与当前块相同的正样本,和这个负样本配对时,算平局,贡献 0.5;
- 分数更低的那些正样本,和这个负样本配对时,正样本必输,贡献 0。
所以,如果当前组内负样本权重总和是 W_neg_eq,当前组内正样本权重总和是 W_pos_eq,已经遍历过的、分数严格更高的正样本权重总和是 W_pos_above,那么这一组对所有配对命中的总贡献是:
W_neg_eq * (W_pos_above + 0.5 * W_pos_eq)
把所有组的贡献加起来,就是分子;分母是总正样本权重乘以总负样本权重。
这个算法最棒的地方在于,只需要一趟降序遍历,过程中不断累加“已经见过的正样本权重”即可,连“总正样本权重”都不需要预先知道,最后算分母时再统计就行。
3.2 方法B:正样本视角,负样本权重累计
对称地,你也可以从正样本视角出发。按分数从低到高遍历,或者从高到低但维护负样本累计权重。每个正样本会和以下负样本配对:
- 分数严格更低的负样本,正样本赢,贡献 1;
- 同分的负样本,算半局,贡献 0.5;
- 分数更高的负样本,正样本输,贡献 0。
所以如果当前组内正样本权重总和是 W_pos_eq,当前组内负样本权重总和是 W_neg_eq,已经遍历过的、分数更低的负样本权重总和是 W_neg_lower,贡献就是:
W_pos_eq * (W_neg_lower + 0.5 * W_neg_eq)
两种方法殊途同归。实现上我推荐方法A,因为降序排序后可以直接用np.cumsum做正样本权重的累计,代码更自然。
3.3 同分样本为什么按一半计
很多人第一次写普通AUC时,最容易漏掉的就是同分处理。两个样本分数一模一样,一个正一个负,你说这局谁赢?严格讲没有赢家,只能算 0.5 分。
放到加权场景里也是一样:同分的一对正负样本,贡献要写成 0.5 * w_i * w_j。在分数块层面,这正好对应公式里的 “0.5 * W_pos_eq”,或者是方法B里的 “0.5 * W_neg_eq”。这个 0.5 不能用别的值替代,否则会出现两个问题:一是所有分数都相同的退化数据会得到 1 或者 0,而不是合理的 0.5;二是模型输出如果大量出现并列分数,普通AUC和加权AUC会被明显高估或低估。
顺便说一句,ROC曲线视角下这也说得通:同分样本在阈值扫描时同时进入正负两边的累计,画出来的曲线会在一个方块内走一条对角线,这条对角线下的面积正好是三角形的一半,也就是 0.5 的由来。
4. 一份可直接落地的numpy实现与验证
4.1 排序版本实现
下面这份代码我放在自己项目里用了很久,依赖只有 numpy,没有任何框架耦合。核心思路和上一节的方法A完全一致:降序排序,按分数分块,用np.add.reduceat做分块累计。
import numpy as np def weighted_auc(y_true, y_score, sample_weight=None): """计算带权重样本的二分类AUC。 参数 ---- y_true : array-like, shape (n,) 二分类标签,取值 0 或 1 y_score : array-like, shape (n,) 预测分数,分数越大代表越倾向正类 sample_weight : array-like or None 每个样本的权重,非负;为 None 时等价于普通AUC 返回 ---- float 加权AUC,取值范围 [0, 1] """ y_true = np.asarray(y_true) y_score = np.asarray(y_score) if sample_weight is None: sample_weight = np.ones(len(y_true), dtype=np.float64) sample_weight = np.asarray(sample_weight, dtype=np.float64) if len(y_true) == 0: raise ValueError("样本集为空,无法计算AUC") pos_mask = y_true == 1 neg_mask = ~pos_mask pos_total = sample_weight[pos_mask].sum() neg_total = sample_weight[neg_mask].sum() if pos_total == 0 or neg_total == 0: raise ValueError("只有一个类别时,AUC无定义") # 按预测分数降序排序,注意 merge sort 保证稳定性 order = np.argsort(-y_score, kind="mergesort") score_sorted = y_score[order] weight_sorted = sample_weight[order] pos_sorted = pos_mask[order].astype(np.float64) # 同分样本分到同一个组。eps 是对浮点误差的容忍,可自行调整 diff = np.diff(score_sorted) eps = 1e-12 group_starts = np.concatenate(( [0], np.where(np.abs(diff) > eps)[0] + 1 )) # 每个分数组内,正样本权重之和、负样本权重之和 pos_w_group = np.add.reduceat(weight_sorted * pos_sorted, group_starts) neg_w_group = np.add.reduceat(weight_sorted * (1.0 - pos_sorted), group_starts) # 当前分数组之前,已经出现过的正样本权重累计(即分数严格更高的正样本) pos_cum_before = np.cumsum(pos_w_group) - pos_w_group numerator = np.sum(neg_w_group * (pos_cum_before + 0.5 * pos_w_group)) denominator = pos_total * neg_total return numerator / denominator这段代码有几个细节值得说:
- 排序用
kind="mergesort"不是为了追求速度,而是为了稳定。稳定排序在同分样本很多时,能保证分组逻辑不受原始顺序干扰。 np.add.reduceat是一把好手。它把“按组做加法”这个操作在一行里完成,不需要写 for 循环。每组内部的权重求和,效率比 Python 循环高一个量级。- 分组边界用
np.abs(diff) > eps判断。这个 eps 是浮点误差的兜底,但要注意它不能太大,否则本应不同的分数会被错误地并到一组。
4.2 暴力对拍:小样本下证明自己没写错
排序版算法虽然快,但它经过了好几道变换,谁敢保证没写错?最稳妥的验证方法就是写一个直接按照定义计算的暴力版,在小样本上对拍。
def weighted_auc_bruteforce(y_true, y_score, sample_weight=None): """暴力版本:严格按定义计算,只用于小样本验证。""" y_true = np.asarray(y_true) y_score = np.asarray(y_score) if sample_weight is None: sample_weight = np.ones(len(y_true), dtype=np.float64) sample_weight = np.asarray(sample_weight, dtype=np.float64) pos_idx = np.where(y_true == 1)[0] neg_idx = np.where(y_true == 0)[0] hit = 0.0 total = 0.0 for i in pos_idx: for j in neg_idx: w = sample_weight[i] * sample_weight[j] if y_score[i] > y_score[j]: hit += w elif y_score[i] == y_score[j]: hit += 0.5 * w total += w if total == 0: raise ValueError("正负样本权重总和为 0,无法计算AUC") return hit / total这个暴力版完全没有技巧,就是按照加权AUC的定义把每一对正负样本都算一遍。虽然慢,但它是检验排序版正确性的金标准。
4.3 与sklearn的sample_weight对照实测
如果你的环境里已经有 scikit-learn,还有更省事的验证方式:直接调用roc_auc_score(y_true, y_score, sample_weight=w)。sklearn 在二分类下也支持 sample_weight,底层实现同样是加权ROC曲线积分,理论结果和我们的公式一致。
我自己实测下来,两者在绝大多数数据下完全相等,偶尔出现 1e-15 级别的浮点差异,那只是累加顺序不同造成的,可以直接忽略。
不过我还是建议你至少在自己项目里保留一份上面的 numpy 实现。原因有两个:一是 sklearn 的roc_auc_score在不同版本里对多分类、平局、非单调ROC曲线的处理细节有细微差异,万一线上环境升级导致指标口径变化,你能有个底层的对照实现;二是当你需要在流式计算、数据库 UDF 或者不依赖 Python 生态的环境里算AUC时,这段代码可以非常容易地翻译成 SQL 或 Java,而 sklearn 就没法照搬。
下面是一段完整的对拍测试脚本,你可以直接跑:
from sklearn.metrics import roc_auc_score rng = np.random.default_rng(42) n = 2000 y = rng.integers(0, 2, size=n) s = rng.random(n) # 不带权重的排序版,和 sklearn 对照 print("不加权,排序版:", weighted_auc(y, s)) print("不加权,sklearn :", roc_auc_score(y, s)) # 全部权重为 1,应该和不加权完全一致 ones = np.ones(n) print("权重全1,排序版:", weighted_auc(y, s, ones)) # 随机权重下,排序版和暴力版对照 n_small = 80 y_s = rng.integers(0, 2, size=n_small) s_s = rng.random(n_small) w_s = rng.random(n_small) + 0.01 # 避免出现 0 权重 print("随机权重,排序版:", weighted_auc(y_s, s_s, w_s)) print("随机权重,暴力版:", weighted_auc_bruteforce(y_s, s_s, w_s)) print("随机权重,sklearn:", roc_auc_score(y_s, s_s, sample_weight=w_s)) # 权重归一化后,结果不应该变 w_scaled = w_s / np.sum(w_s) print("权重归一化后:", weighted_auc(y_s, s_s, w_scaled))我在几个不同随机种子下跑过,排序版和暴力版之间的绝对误差基本在 1e-14 以内。这说明公式推导、分组逻辑和代码实现是对得上的。
5. 实战中的坑与排查速查表
5.1 同分判断:浮点误差是你最大的敌人
前面那张 eps 参数看起来不起眼,但它可能是整个函数里最阴险的一个点。真实场景里,两个样本的预测分数理论上应该相同,比如模型输出经过 sigmoid 后都是 0.731,但 float 累加误差让一个变成了 0.7310000000000001,另一个是 0.731。如果你的 eps 设成 0,这两个样本会被分到不同分数块,导致它们之间的配对从“平局 0.5”错误地变成“一方全赢”。
这种误差在小数据集上可能不明显,但如果预测分数有很多并列档位,累积起来足以让你的AUC偏移几个千分点。
我常用的处理策略是:先想清楚预测分数的业务精度。如果模型输出保留到小数点后 6 位就够,那就先做np.round(y_score, 6),再做分组。round 之后,并列关系是确定的,不会再被浮点噪声干扰。如果不想改变分数本身,就把 eps 设成一个很小的数值,比如 1e-10,让“视觉上相等”的分数归到同一组。
这里还有一个隐蔽的坑:用相邻 diff 做分组,天然假设分数是“完全有序且相邻可比较”的。如果三个分数 A=0.1001、B=0.1002、C=0.1003,你的 eps=0.0002,那么 A 和 B 可能被分到一组,B 和 C 也可能被分到一组,但三者在一次np.diff扫描里并不保证一定归为同一组。这种“传递性”问题虽然极端,但确实存在。所以更稳妥的做法是先按业务精度 floor/round 再做组切分,而不是单纯依赖 eps。
5.2 权重非负、全零、单类别等边界情况
加权AUC的权重在语义上是“抽样概率”或“重要程度”,负数没有实际意义。如果权重为负,某些正负样本对贡献为负数,分子可能变成负数,最终AUC会跌破 0,这明显违背了概率解释。
代码里目前没有强制校验权重非负,因为有些应用场景确实会用零权重来“屏蔽”某个样本。但如果有权重为负,我建议直接抛异常或者至少打日志,不要静默计算出一个莫名其妙的值。
另一个常见情况是某一类别的权重总和为零。比如负样本权重都是 0,分母为 0,函数会崩。我在上面的实现里已经抛了 ValueError。实际业务里遇到这种情况,通常说明评估样本本身构造有问题,应该回头检查数据质量。
单类别问题也值得一提。如果 eval 集合里全是正样本或全是负样本,AUC根本不存在。有些框架会返回 0.5,有些会直接报错。我倾向于显式报错,因为静默返回 0.5 会掩盖数据切片问题。
5.3 数据量变大后的性能与精度取舍
排序版算法的复杂度是 O(n log n),其中 n 是样本量。百万级样本在普通笔记本上跑一次也就几秒钟,性能不是问题。真正要留意的是权重数值范围。
如果某个样本权重特别大,比如 1e200,和另一个同样大的权重相乘,得到 1e400,这在 float64 下直接溢出为 inf。虽然最后分子分母会同时放大,但中间一旦出现 inf,整个结果就废了。
解决方案很朴素:计算之前先把权重做一次标准化,比如除以权重的均值或最大值。因为加权AUC对权重整体缩放是不变的,标准化只为了数值稳定性。我在生产环境里默认会对权重做sample_weight = sample_weight / np.mean(sample_weight),这样平均权重为 1,数值都在安全范围内。
如果你的数据是流式产出,没法一次性拿到全部样本,也可以用这个公式拆成分布式累加:每个 partition 内部按分数排序,把正样本权重累计值传下去,最后汇总分子和分母。因为这个算法的核心只是“累加”和“分组”,分布式实现并不复杂。
5.4 指标口径:给业务方怎么解释加权AUC
即使你代码算得再对,如果业务方不理解加权AUC的含义,也很难买单。我通常这样解释:
普通AUC是“随机抽一个正样本,它的分数比随机抽一个负样本高的概率”;加权AUC是“按权重去抽一个正样本、按权重去抽一个负样本,前者分数比后者高的概率”。
这里的“按权重抽样”可以理解为:把重要样本在抽样时拆成多份。权重越大的样本,越容易被抽到,也越能代表它所在的那一簇样本。加权AUC比普通AUC更贴近真实分布,是因为它试图还原采样之前或业务真实重要性下的排序能力。
实际汇报时我会配合一个例子:比如风控模型,原始样本里大额坏账只占 1%,但损失金额占 40%。普通AUC会把这 1% 的大额坏账淹没在 99% 的小额坏账里,加权AUC则让它们的声音被听见。这样解释,业务方一般马上就懂了。
5.5 我自己踩过的三个印象深刻的坑
第一个坑是我第一次实现时忘了处理同分。当时模型输出了很多 0.5 的阈值分数,正负样本大量并列,算出来的AUC比预期高了将近 0.03。我一度以为模型效果突然变好了,后来排查才发现是平局样本被错误地算成全赢。从那以后我再也不写不带同分处理的AUC实现。
第二个坑是权重归一化顺序。有一回评估的样本权重来自不同批次,每个批次的平均权重不一样,我没做归一化直接累加,结果某些批次的样本在全局里的相对权重被自动放大,导致AUC偏低。后来我把权重标准化放到评估入口统一处理,问题立刻消失。
第三个坑是 sklearn 的版本差异。有一次项目从旧环境迁到新环境,同样的数据和权重,roc_auc_score的结果差了 0.002。排查了半天,发现是 scikit-learn 在平局样本上的处理细节变了。这让我下定决心,凡是核心指标计算,一定要有一份自己完全掌控的实现作为基准,不能把底全交给黑盒。
如果你也要在项目里引入加权AUC,我的建议是:先写暴力版对拍,再上排序版,再接入 sklearn 对照。三个数对上了,才说明公式、代码和数据流都没问题。这套流程看着笨,但能替你在线上省掉很多半夜排查指标异常的功夫。