一、AUC的理解

1、理解角度一

AUC 可以把它理解成:模型把正样本排在负样本前面的能力有多强。

或者

AUC = 随机抽一个正样本和一个负样本,模型把正样本分数排得更高的概率

更严谨地说,要加上“分数相同算对一半”:

[ AUC=P(score_{pos}>score_{neg})+ 0.5P(score_{pos}=score_{neg}) ]

所以若没有同分,AUC 就完全等于“正样本排在负样本前面的概率”。


以广告 CTR 预估为例:

  • 正样本:用户点击了广告
  • 负样本:用户没有点击广告
  • 模型给每个广告一个预测点击分 pred

AUC 不关心模型预测的具体数值是不是 0.1、0.8,它主要问:

  • 随机抽一个点击广告和一个未点击广告,模型有多大概率把点击广告的分数排得更高?

这个概率就是 AUC。

例如:

样本        是否点击    模型预估分
广告 A          1          0.90
广告 B          0          0.70
广告 C          1          0.60
广告 D          0          0.20

正样本是 A、C,负样本是 B、D,一共有 2 × 2 = 4 对正负样本:

A(0.90) vs B(0.70):正样本更高,正确
A(0.90) vs D(0.20):正样本更高,正确
C(0.60) vs B(0.70):正样本更低,错误
C(0.60) vs D(0.20):正样本更高,正确

4 对里排对 3 对:

[ AUC = 3 / 4 = 0.75 ]

含义就是:随机抽一对“点击/未点击”样本(或者说 正负样本对 排列组合),模型有 75% 的概率把点击样本排在前面。

AUC 取值可以这样理解:

  • 1.0:所有正样本都排在所有负样本前,完美排序。

  • 0.5:和随机猜测差不多,没有排序能力。

  • < 0.5:排序方向基本反了;将分数取反后可能变成大于 0.5。

  • 实际广告/推荐模型一般关注 AUC 的微小增量,例如 0.001 的提升在大流量下也可能有业务价值。

如果正负样本预测分相同,例如:

正样本:0.6
负样本:0.6

无法说谁排得更前,通常记为“猜对一半”,贡献 0.5。这就是代码里:

elif pre[i] == pre[j]:
    auc += 0.5

的含义。

最后要区分两个概念:

  • AUC 衡量排序能力:点击样本是否整体排得更靠前。
  • 预估偏差:预估值为 0.1 的样本,真实点击率是否也接近 10%。

因此,一个模型可能 AUC 高但预估偏差高,也可能预估偏差低但排序能力弱。广告精排通常两个都需要关注。

2、理解角度2

“AUC 是随机抽一对正负样本,模型排对的概率”和“AUC 是曲线下面积”是 同一个指标的两种视角。

这里的曲线特指 ROC 曲线。

先假设模型给广告预测点击分数:

样本       真实标签    预测分
A             1         0.90
B             0         0.80
C             1         0.60
D             0         0.30

然后不断调一个“判为正样本”的阈值:

  • 预测分 >= 阈值,就预测为正(比如认为会点击)。

每取一个阈值,就会得到两个量:

  • TPR / 召回率:真实正样本中,有多少被判成正
    [ TPR = \frac{TP}{TP+FN} ]
  • FPR / 假正率:真实负样本中,有多少被误判成正
    [ FPR = \frac{FP}{FP+TN} ]

例如阈值设为 0.85,只有 A 被判为正:

A 是正样本且被选中:TP = 1
B、D 是负样本且没被选中:TN = 2
C 是正样本但没被选中:FN = 1

TPR = 1 / 2 = 0.5
FPR = 0 / 2 = 0

所以 ROC 曲线上有一点:

(FPR, TPR) = (0, 0.5)

把阈值从最高分慢慢降低,越来越多样本被判为正,得到一串点:

阈值从高到低
(0, 0) -> (0, 0.5) -> (0.5, 0.5) -> (0.5, 1) -> (1, 1)

横轴是 FPR,纵轴是 TPR,连起来就是 ROC 曲线:

TPR
1.0 |          ┌──────●
    |0.5 |     ●────●
    |0.0 |─────┴──────────── FPR
    0    0.5           1

AUC 就是这条 ROC 曲线下方的面积。

为什么面积能衡量排序能力?

  • 好模型会优先把正样本排在前面。
  • 因此降低阈值时,先增加的是 TP,TPR 很快升高。
  • 同时负样本较少被误选,FPR 仍较低。
  • ROC 曲线就会尽可能靠近左上角,面积变大。

反过来:

随机模型:ROC 接近对角线,AUC ≈ 0.5
好模型:ROC 向左上角弯曲,AUC 接近 1

两种解释的对应关系是:

排序视角:

  • 随机挑一个点击样本和一个未点击样本,
  • 正样本得分更高的概率。

曲线视角:

  • 遍历所有可能阈值后,
    TPR-FPR 轨迹形成的 ROC 曲线下面积。

它们数学上相等:

[ AUC=P(score_{pos}>score_{neg})+ 0.5P(score_{pos}=score_{neg}) ]

1. score_pos > score_neg:模型把正样本排在前面,完全正确,记 12. score_pos = score_neg:无法区分正负样本,算猜对一半,记 0.53. score_pos < score_neg:模型排反了,记 0

所以,AUC 就是所有正负样本对的“平均得分”。

举例,有 2 个正样本和 2 个负样本:

正样本分数:0.9, 0.6
负样本分数:0.8, 0.6

一共有 2×2=4 个正负样本对:

0.9 vs 0.8:正样本更高,得 1
0.9 vs 0.6:正样本更高,得 1
0.6 vs 0.8:正样本更低,得 0
0.6 vs 0.6:两者相同,得 0.5

平均分是:

[ AUC = \frac{1+1+0+0.5}{4}=0.625 ]

将它写成概率:

正样本分更高:2 对,概率 = 2 / 4 = 0.5
正负样本同分:1 对,概率 = 1 / 4 = 0.25
正样本分更低:1 对,不贡献 AUC

因此:

0.5                 :正样本排得更高的概率
0.5 × 0.25 = 0.125 :同分样本只能算“猜对一半”

[ AUC = 0.5 + 0.125 = 0.625 ]

0.5 乘同分概率的原因是:同分时模型无法比较谁更像正样本,等价于随机决定顺序,平均只有一半概率排对。

所以后面写的“两两正负样本比较”代码,本质上是在算 ROC 曲线下面积,只是没有显式地画出 ROC 曲线。

二、AUC代码实现【v1版本】

#!/usr/bin/env python
# coding=utf-8

import numpy as np
from sklearn.metrics import roc_curve
from sklearn.metrics import auc
from sklearn.metrics import roc_auc_score


def auc_calculate(labels,preds,n_bins=100):
    postive_len = sum(labels)
    negative_len = len(labels) - postive_len
    total_case = postive_len * negative_len
    pos_histogram = [0 for _ in range(n_bins)]
    neg_histogram = [0 for _ in range(n_bins)]
    bin_width = 1.0 / n_bins
    for i in range(len(labels)):
        nth_bin = int(preds[i]/bin_width)
        if labels[i]==1:
            pos_histogram[nth_bin] += 1
        else:
            neg_histogram[nth_bin] += 1
    accumulated_neg = 0
    satisfied_pair = 0
    for i in range(n_bins):
        satisfied_pair += (pos_histogram[i]*accumulated_neg + pos_histogram[i]*neg_histogram[i]*0.5)
        accumulated_neg += neg_histogram[i]

    return satisfied_pair / float(total_case)


def AUC(label, pre):
    """
  适用于python3.0以上版本
   """

    # 计算正样本和负样本的索引,以便索引出之后的概率值
    pos = [i for i in range(len(label)) if label[i] == 1]
    neg = [i for i in range(len(label)) if label[i] == 0]

    auc = 0
    for i in pos:
        for j in neg:
            if pre[i] > pre[j]:
                auc += 1
            elif pre[i] == pre[j]:
                auc += 0.5

    return auc / (len(pos) * len(neg))

if __name__ == '__main__':

    label = [1,0,0,0,1,0,1,0]
    pred = [0.9, 0.8, 0.3, 0.1, 0.4, 0.9, 0.66, 0.7]


    fpr, tpr, thresholds = roc_curve(label, pred, pos_label=1)
    print("-----sklearn:",auc(fpr, tpr))  # 0.5666666666666667
    print("-----py1:",auc_calculate(label,pred))  # 0.5666666666666667
    print("-----py2:", AUC(label, pred))  # 0.5666666666666667
	
	# 最推荐这种,最简单
    print("-----py3:", roc_auc_score(y_true=label, y_score=pred))  # 0.5666666666666667

二、AUC代码实现【v2版本】

上一节的代码在二分类、label 严格为 0/1、且正负样本都存在时,计算结果是正确的。它直接实现了 AUC 的正负样本两两比较定义,同分记 0.5 也正确。

主要问题在性能和健壮性。

  • 时间复杂度是 O(N_pos * N_neg)。正负样本各 100 万时,需要约 10^12
    次比较,实际不可用。
  • 没检查 label、pre 长度是否一致。
  • 全正或全负样本会除零;这类数据的 AUC 本身无定义。
  • 只将标签 0/1 识别为负/正,其他标签会被静默忽略,建议显式校验。

可以保留“两两比较”的版本作为面试/验证实现,补齐边界:

def auc_pairwise(labels, preds):
    if len(labels) != len(preds):
        raise ValueError("labels and preds must have the same length")

    pos = [i for i, y in enumerate(labels) if y == 1]
    neg = [i for i, y in enumerate(labels) if y == 0]

    if len(pos) + len(neg) != len(labels):
        raise ValueError("labels must be binary: 0 or 1")
    if not pos or not neg:
        return None  # AUC is undefined without both classes

    correct_pairs = 0.0
    for i in pos:
        for j in neg:
            if preds[i] > preds[j]:
                correct_pairs += 1.0
            elif preds[i] == preds[j]:
                correct_pairs += 0.5

    return correct_pairs / (len(pos) * len(neg))

大样本应改成 排序法,复杂度降为 O(N log N)。下面的实现还正确处理了同分:

def auc(labels, preds):
    if len(labels) != len(preds):
        raise ValueError("labels and preds must have the same length")
    if any(y not in (0, 1) for y in labels):
        raise ValueError("labels must be binary: 0 or 1")

    n_pos = sum(labels)
    n_neg = len(labels) - n_pos
    if n_pos == 0 or n_neg == 0:
        return None

    pairs = sorted(zip(preds, labels), key=lambda x: x[0])

    correct_pairs = 0.0
    neg_seen = 0
    i = 0

    while i < len(pairs):
        j = i
        while j < len(pairs) and pairs[j][0] == pairs[i][0]:
            j += 1

        group = pairs[i:j]
        pos_in_group = sum(y for _, y in group)
        neg_in_group = len(group) - pos_in_group

        # 当前分组的正样本胜过所有更低分的负样本;
        # 与当前同分负样本各计 0.5。
        correct_pairs += pos_in_group * neg_seen
        correct_pairs += 0.5 * pos_in_group * neg_in_group

        neg_seen += neg_in_group
        i = j

    return correct_pairs / (n_pos * n_neg)

Logo

CSDN联合极客时间,共同打造面向开发者的精品内容学习社区,助力成长!

更多推荐