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

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')