AUC 与 GAUC:从全局排序到用户内排序的理解

一、为什么需要 AUC
评估一个推荐模型,最直觉的想法是看准确率------预测对了多少条。但准确率有一个致命缺陷:它高度依赖正负样本的比例。在推荐场景中,用户点击的内容往往只占曝光内容的百分之几,样本极度不平衡,一个把所有样本都预测为"不点击"的模型,准确率也能高达 95% 以上。这样的指标毫无意义。
AUC 的出现解决了这个问题。其概率含义直接而深刻:从正样本中随机抽取一个,从负样本中随机抽取一个,模型给正样本打出更高分数的概率。这与样本比例无关,它衡量的是模型的排序能力,而非绝对的预测准确性。在数学上,AUC 等价于 Wilcoxon-Mann-Whitney 统计量,即正确排序的正负样本对数除以全部正负样本对数,同分时每对贡献 0.5。
二、全局 AUC 的内在假设与局限

全局 AUC 的计算方式是:把所有用户的所有样本混在一起,统一在分数轴上排列,然后统计正确排序的正负样本对。这里隐含着一个重要假设------任何一个正样本都可以与任何一个负样本配对,无论它们来自谁。
这个假设在某些场景下是合理的,例如二元分类任务,样本之间确实没有归属关系。但在推荐系统中,这个假设与业务目标产生了根本性的偏差。推荐系统真正关心的问题是:对于某一个具体的用户,模型能不能把他感兴趣的内容排在他不感兴趣的内容之前。用户 A 喜欢的内容得分高于用户 B 不喜欢的内容,这件事在业务上没有任何意义,但全局 AUC 会将其计入"正确排序对",拉高分数。
更严重的问题在于,全局 AUC 会被高活跃用户主导。一个日活达数千次的高频用户贡献了大量样本,他的排序质量在全局 AUC 中占据极高权重。而大量普通用户每次只有几条曝光,他们的排序体验被彻底淹没。模型可能对少数高活跃用户拟合极好,却对大多数普通用户表现糟糕,但全局 AUC 依然很高,给出了虚假的好信号。
三、GAUC 的诞生背景

GAUC(Group AUC)正是为解决上述局限而提出的。它的核心思想极为简洁:先把样本按用户分组,在每个用户内部独立计算 AUC,再将各用户的 AUC 加权平均,得到最终的 GAUC。
这一改变带来了本质上的不同。正负样本配对的边界从"全局"收缩到了"用户内",跨用户的正负对被完全丢弃。GAUC 问的问题从"模型对任意正负样本能否正确排序",变成了"对每一个用户,模型能否在他的曝光列表内正确排序"。前者是统计意义上的区分能力,后者是业务意义上的个性化排序能力。两者貌似相近,实则差异可以非常显著。
一个典型的场景是:某用户的历史行为较为小众,模型对他的预测分数整体偏低,但用户内部的相对顺序是准确的。全局 AUC 会因为他的正样本分数低于其他用户的负样本而惩罚模型;GAUC 则只看他自己内部的排序,给出客观评价。GAUC 因此更能反映模型在每个用户视角下的真实排序质量。
四、两种加权方式的业务含义
GAUC 的加权策略是一个容易被忽视但至关重要的设计选择,直接影响指标对齐的业务目标。
曝光量加权是最常用的方式,每个用户的权重等于其曝光样本数。这意味着曝光多的用户在最终 GAUC 中占据更高比重,模型需要在高流量用户身上表现好,才能获得高 GAUC。从平台整体收益的角度看,这与业务逻辑是对齐的------曝光量大的用户贡献了更多的点击和转化机会,他们的排序体验直接影响平台的整体效率指标(如点击率、GMV)。因此,如果业务目标是优化平台总体转化,曝光加权 GAUC 是更合适的选择。
均匀加权 则给每个用户相同的权重,不论其曝光量多少。这意味着一个只有 5 条曝光的长尾用户与一个有 500 条曝光的高活跃用户对最终指标的贡献相同。这种加权方式传达的业务信号是:每一个用户的体验同等重要。对于强调用户公平性、长尾用户留存或新用户冷启动效果的产品阶段,均匀加权 GAUC 是更能反映真实目标的指标。例如,在评估冷启动策略时,关注的正是那些曝光稀少的新用户能否得到合理的推荐排序,曝光加权反而会稀释这部分信号。
需要特别指出的是,即使使用曝光量加权,GAUC 也与全局 AUC 不等价,两者之间存在不可消除的差距。原因在于:权重只决定了"每条样本的投票分量",而正负对的配对边界------用户内 vs. 全局------是根本性的结构差异,无法通过调整权重来弥合。全局 AUC 包含跨用户的正负对,GAUC 永远不包含,这是两个指标统计对象的本质不同。
五、实践中需要注意的陷阱
理解了 GAUC 的原理,在工程实现和指标使用中还有几点值得关注。
首先是同分(Tie)的处理。当同一用户内两个样本的预测分数完全相同时,不能简单地将其计为正确或错误排序,正确做法是贡献 0.5。这个细节在代码实现中容易被忽略,导致 GAUC 略微偏高。
其次是稀疏用户的过滤。若某用户在一次评估窗口内只有正样本或只有负样本,该用户的 AUC 无法定义,应当从计算中排除。更进一步,单个正负对的用户(1 正 1 负)计算出的 AUC 只有 0 或 1,是极端噪声,在数据量足够时建议设置最低样本阈值(如至少 2 正 2 负)来过滤。
最后,GAUC 与全局 AUC 描述的是模型的不同侧面,二者互为补充。全局 AUC 衡量模型的整体区分能力,GAUC 衡量模型在每个用户视角下的个性化排序能力。在 A/B 实验中两者都应关注:只看全局 AUC 可能遗漏个性化体验的退步,只看 GAUC 可能遗漏整体排序能力的变化。
六、小结
AUC 与 GAUC 本质上都在回答同一个问题------模型有没有把正样本排在负样本前面------区别在于"谁和谁比"。全局 AUC 允许跨用户配对,GAUC 严格限制在用户内部。这一边界的改变,使 GAUC 从一个统计意义上的区分能力指标,转变为一个真正对齐推荐系统业务目标的评估工具。而加权策略的选择,则进一步决定了 GAUC 对齐的是平台整体效率还是用户个体公平,需要结合具体业务阶段做出判断。理解这些层次,才能在模型评估时真正用对指标,而不是被指标数字表面的好看所迷惑。
AUC代码
python
import numpy as np
def auc_rank(q_list, label):
q_list = np.array(q_list)
label = np.array(label)
rank_index = np.argsort(q_list)
q_list_ranked = q_list[rank_index]
label_ranked = label[rank_index]
total_pos = np.sum(label_ranked == 1)
total_neg = np.sum(label_ranked == 0)
l, n= 0, len(label_ranked)
cum_neg, cum_pos = 0, 0
while l < n:
r = l
while r < n and q_list_ranked[l] == q_list_ranked[r]:
r += 1
group_neg = np.sum(label_ranked[l:r] == 0)
group_pos = np.sum(label_ranked[l:r] == 1)
cum_pos += group_pos * cum_neg + group_pos * group_neg * 0.5
cum_neg += group_neg
l = r
return cum_pos / (total_pos * total_neg)
q = [0.1, 0.9, 0.2, 0.8, 1, 0.2, 0.3, 0.8]
label = [0, 1, 0, 0, 1, 0, 0, 1]
auc = auc_rank(q, label)
print(f"auc:{auc}")
GAUC代码
python
import numpy as np
from collections import defaultdict
def _auc_single_user(user_scores, user_labels):
"""单用户 AUC,处理 tie(同分算 0.5)"""
order = np.argsort(user_scores)
sorted_scores = user_scores[order]
sorted_labels = user_labels[order]
n_pos = np.sum(sorted_labels == 1)
n_neg = np.sum(sorted_labels == 0)
cum_neg = 0
correct_pairs = 0.0
l, n = 0, len(sorted_labels)
while l < n:
r = l
# 找到同分的一组
while r < n and sorted_scores[r] == sorted_scores[l]:
r += 1
# [l, r) 为同分组
group_pos = np.sum(sorted_labels[l:r] == 1)
group_neg = np.sum(sorted_labels[l:r] == 0)
# 同分组内正负对算 0.5,组前负样本算完全正确
correct_pairs += group_pos * cum_neg + group_pos * group_neg * 0.5
cum_neg += group_neg
l = r
return correct_pairs / (n_pos * n_neg)
def gauc_rank(user_ids, labels, scores, weight_type='impression'):
# 去掉细节,GAUC 的本质只有两步:
# GAUC = Σ(用户i的AUC × 用户i的权重) / Σ(用户i的权重)
user_ids = np.asarray(user_ids)
labels = np.asarray(labels)
scores = np.asarray(scores)
user_sample_dict = defaultdict(list)
for idx, uid in enumerate(user_ids):
user_sample_dict[uid].append(idx)
user_auc_dict = {}
total_weighted_auc = 0.0
total_weight = 0.0
for uid, indices in user_sample_dict.items():
user_labels = labels[indices]
user_scores = scores[indices]
n_pos = np.sum(user_labels == 1)
n_neg = np.sum(user_labels == 0)
if n_pos == 0 or n_neg == 0:
continue
user_auc = _auc_single_user(user_scores, user_labels)
user_auc_dict[uid] = user_auc
weight = len(indices) if weight_type == 'impression' else \
1.0 if weight_type == 'uniform' else \
(_ for _ in ()).throw(ValueError(f"Unknown weight_type: {weight_type}"))
total_weighted_auc += user_auc * weight
total_weight += weight
gauc = total_weighted_auc / total_weight if total_weight > 0 else 0.5
print(f"gauc: {gauc:.4f}")
for uid, auc in user_auc_dict.items():
n = len(user_sample_dict[uid])
w = n if weight_type == 'impression' else 1.0
print(f" user={uid} auc={auc:.4f} weight={w}")
return gauc, user_auc_dict
label = [0, 1, 0, 1, 1, 0, 0, 0, 1, 1, 0]
q = [0.1, 0.9, 0.2, 0.8, 1, 0.2, 0.3, 0.9, 0.7, 0.9, 0.7]
user_id = [1, 1, 1, 1, 2, 2, 2, 2, 3, 3, 3 ]
gauc_rank(user_id, label, q, weight_type='impression')