qinghuan
← 回到知识探索

AI 直觉 / 动手实验

AUC 一样,为什么前几条的命中结果不同?

同一批七条记录、两个相同的AUC,用十二次正负配对和ROC面积复核,再看前一条与前三条的命中率。

展开本文目录
  1. 下载完整练习包
  2. 先动手看看
  3. 先固定同一批记录和分数方向
  4. 先做十二次正负配对,不急着画曲线
  5. 处理名额改变,两个排序的优势也会改变
  6. 换一条计算路线,用梯形面积交叉核验
  7. 并列必须成组移动,不能偷偷用标签打破
  8. 运行完整程序,留下三张可复查的账本
  9. 把指标定义与工具实现的范围分开
  10. 先写决策问题,再决定保留哪些指标
  11. 记住这些要点
  12. 别把例子用过头
  13. 自己试一小步
  14. 查原始资料
开始前,会这些就够了
  • 会算比例和简单分数
  • 理解正类标签与预测分数不同
  • 能运行Python标准库脚本

两个模型的AUC都是三分之二,就能任选一个吗?如果你每天只处理排名第一的记录,答案可能与每天处理前三条完全不同。一个汇总数字能比较某种整体表现,却没有替你定义实际处理多少条。

我们固定七条记录和标签,只改变两套分数。不训练模型,也不抽取随机数据:把每个正负样本对列出来,再独立计算ROC曲线下面积,最后检查不同处理名额下的命中率。所有数字都能逐项重算。

把例子带到自己的电脑上

下载完整练习包

需要 Python 3.10 或更新版本,无第三方依赖;制作时实测 Python 3.14.0。使用标准库,不需要安装第三方包。下载 ZIP 后选择“全部提取”,进入能看到 README.md 的文件夹。

下载 ZIP · 5 个文件
包里有什么
  • auc_pairs.py:完整示例
  • README.md:环境、范围与独立练习
  • observed.stdout.txt:制作时的实际输出
  • verification.json:源码与产物验证记录
  • SHA256SUMS.txt:文件内容摘要

在这个文件夹打开 PowerShell,逐行运行

python --version
python -B auc_pairs.py

解压到独立练习文件夹,在含源文件的目录打开终端运行。程序会在当前目录写入教学报告;先预测结果,再查看当次输出。五组虚构二分类标签与分数的精确配对及 ROC 计算;不训练模型,不衡量真实业务收益或概率校准。

找不到命令或文件时,先按 README 检查环境和所在目录。文中标出的阅读时间只估算文章篇幅,安装环境和动手练习可按自己的节奏进行。

AUC 相同,排在最前面的结果也一样吗?

P 表示正例,N 表示负例,分数越大排得越靠前。先保持 k = 1,切换排序 A 和 B;再把 k 改成 3。观察全部正负配对与前 k 条各自回答什么问题。

全部配对的 AUC:8 ÷ 12 = 0.6667

前 1 条的正例比例:1 ÷ 1 = 100.00%

按分数排好的候选

  1. P1正例 · 分数 7已选入前 1 条
  2. N1负例 · 分数 6未选入
  3. N2负例 · 分数 5未选入
  4. P2正例 · 分数 4未选入
  5. P3正例 · 分数 3未选入
  6. N3负例 · 分数 2未选入
  7. N4负例 · 分数 1未选入
正例分数更高记 1,同分记 0.5,更低记 0
正例 / 负例N1(6)N2(5)N3(2)N4(1)
P1(7)1111
P2(4)0011
P3(3)0011

移动 k 只改变选中范围,不改变全部正负配对,因此本例 AUC 保持不变。统一变换分数后,继续检查配对关系和排序是否改变。

这是正文五组虚构标签与分数的页面计算,没有训练或调用模型。样本等权;同分候选按 ID 字母顺序显示,因此全部同分时 N 在 P 前面,前 k 条结果取决于这一约定。AUC 的同分配对仍记半分,不依赖显示顺序。数值显示经过舍入。

先固定同一批记录和分数方向

数据只有三个正例P1、P2、P3,以及四个负例N1到N4。正类标签为一,负类为零;分数越高越优先。这里分数只是排序依据,不必是零到一之间的概率,也不能把分数七解释成七成机会。

按P1、P2、P3、N1、N2、N3、N4的次序,方案A分数为7、4、3、6、5、2、1,方案B为6、5、3、7、4、2、1。记录身份和标签保持一致,因此之后的差异确实来自排序变化。

本文只算这批等权、二分类记录的经验指标。样本权重、多类别平均、置信区间和未来数据表现需要另行设计。七条人为数据足以反驳一个推断,但不足以证明哪个真实模型更值得上线。

先做十二次正负配对,不急着画曲线

每个正例都与四个负例比较,共三乘四等于十二对。正例分数更高记一分,更低记零分,相同记半分;把所有得分相加,再除以十二,就是本例的ROC AUC。比较只发生在正负两类之间,同类内部换序不会直接增加一项配对。

方案A中,P1胜过全部四个负例,P2和P3各胜过两个,总计八分。方案B中,P1与P2各胜过三个负例,P3胜过两个,同样八分。两者AUC都为8/12,也就是2/3,但这八分落在了不同的比较上。

这个数可以解释为均匀抽取一个正例、一个负例后,正例排前的比例,并按约定给并列半分。它不是七条记录中有三分之二被正确分类,因为我们还没有指定一个把分数切成正负预测的阈值。

配对表还保留正例和负例的身份,便于看出得分来自哪里。若只存一个八,便无法追查究竟哪些记录被交换了优先级。相同总分可以由不同配对贡献构成,这正是需要继续阅读完整排名的原因。

处理名额改变,两个排序的优势也会改变

A从高到低排成P1、N1、N2、P2、P3、N3、N4;B排成N1、P1、P2、N2、P3、N3、N4。只处理第一条,A命中正例,B没有命中,因此Precision@1分别是一和零。

若改为处理前三条,A只有P1一个正例,命中率为1/3;B有P1与P2两个,命中率为2/3。这次B更好。两次比较使用完全相同的分数,只改变名额,足以说明相同AUC并不决定某个名额下的效果。

Precision@k的分母是被选中的k条,召回率的分母是全部正例数。本例恰好有三个正例,所以k等于三时两种数值碰巧一样;换个样本数就不能互换。比较实际方案前,应先明确名额、正例收益和误选成本。

换一条计算路线,用梯形面积交叉核验

ROC横轴是假正例率,即已经选中的负例数除以四;纵轴是真正例率,即已经选中的正例数除以三。从高于所有分数的阈值开始,先得到原点,再逐个降低阈值,把分数不小于阈值的整组记录选入。

方案A的四次水平前进分别发生在纵坐标1/3、1/3、1、1处,每次宽度1/4,面积相加是2/3。方案B对应纵坐标为0、2/3、1、1,面积也为2/3。竖直前进没有宽度,不额外贡献面积。

代码还对一般相邻点计算梯形面积:横坐标差乘以前后纵坐标的平均。每组数据都同时走配对和面积两条路线,并断言结果相等。交叉核验比只保存一个最终小数更容易发现阈值方向、分母或端点的错误。

并列必须成组移动,不能偷偷用标签打破

另一个四条记录的对照中,两个正例分数为二和一,两个负例分数为二和零。四对比较的得分是一半、一、零、一,总计5/2,除以四得到5/8。最高分的正负例并列,ROC应一次移动整组,再连接两端点。

如果为了画曲线而把并列的正例总放前面,会得到偏乐观的面积;总把负例放前则偏悲观。成组移动后的直线插值与半分配对一致。所有记录都同分时,只有原点到右上角的对角线,AUC为1/2。

前k条遇到并列则是另一个选择问题。脚本用记录ID升序打破并列,确保可复现;它不代表模型区分出了这些记录。报告名额指标时应写明这个规则,必要时另报并列情况下的最好、最坏或随机打散期望。

运行完整程序,留下三张可复查的账本

auc_pairs.py生成输入和汇总JSON,以及配对、ROC点、排序三张CSV。五组输入包括A、B、A的严格递增变换、混合并列和全部并列,共四十四条配对、三十个ROC点、二十九条排名记录。

把A的分数变为原值的两倍再加七,次序与并列关系都不变,AUC与前k条也不变。这说明排名指标不测量概率刻度是否准确。若变换只是非递减且把不同值压成并列,结论就不能直接照搬。

程序拒绝空表、重复ID以及缺少任一类别的输入。缺类时没有可用的正负配对,真正例率或假正例率也会缺少分母,应该报告无定义,不能擅自填零或二分之一。所有断言都应在未启用Python优化选项时运行。

展开收起代码 · 小实验python · 122 行

长行可在代码区域内左右滚动,按 Tab 可把焦点移入代码区。

小实验 · python
"""Exact empirical binary ROC AUC, with ties grouped and half pair credit."""
import csv
import json
from fractions import Fraction as F
from pathlib import Path

ROOT = Path(__file__).resolve().parent
BASE = [
    {"id": "P1", "label": 1, "a": 7, "b": 6},
    {"id": "P2", "label": 1, "a": 4, "b": 5},
    {"id": "P3", "label": 1, "a": 3, "b": 3},
    {"id": "N1", "label": 0, "a": 6, "b": 7},
    {"id": "N2", "label": 0, "a": 5, "b": 4},
    {"id": "N3", "label": 0, "a": 2, "b": 2},
    {"id": "N4", "label": 0, "a": 1, "b": 1},
]
CASES = [
    {"id": "rank_a", "rows": [{"id": r["id"], "label": r["label"], "score": str(r["a"])} for r in BASE]},
    {"id": "rank_b", "rows": [{"id": r["id"], "label": r["label"], "score": str(r["b"])} for r in BASE]},
    {"id": "rank_a_rescaled", "rows": [{"id": r["id"], "label": r["label"], "score": str(2 * r["a"] + 7)} for r in BASE]},
    {"id": "mixed_ties", "rows": [
        {"id": "P1", "label": 1, "score": "2"}, {"id": "P2", "label": 1, "score": "1"},
        {"id": "N1", "label": 0, "score": "2"}, {"id": "N2", "label": 0, "score": "0"}]},
    {"id": "all_tied", "rows": [
        {"id": "P1", "label": 1, "score": "0"}, {"id": "P2", "label": 1, "score": "0"},
        {"id": "N1", "label": 0, "score": "0"}, {"id": "N2", "label": 0, "score": "0"}]},
]


def prepare(rows):
    if not rows or len({r["id"] for r in rows}) != len(rows):
        raise ValueError("nonempty unique IDs required")
    if any(type(r["label"]) is not int or r["label"] not in (0, 1) for r in rows):
        raise ValueError("binary labels 0/1 required")
    if {r["label"] for r in rows} != {0, 1}:
        raise ValueError("ROC AUC undefined without both classes")
    return [{**r, "score": F(r["score"])} for r in rows]


def analyze(case):
    rows = prepare(case["rows"])
    positives = [r for r in rows if r["label"] == 1]
    negatives = [r for r in rows if r["label"] == 0]
    pairs = []
    for pos in positives:
        for neg in negatives:
            credit = F(1) if pos["score"] > neg["score"] else F(1, 2) if pos["score"] == neg["score"] else F(0)
            pairs.append({"case": case["id"], "positive_id": pos["id"], "negative_id": neg["id"],
                          "positive_score": str(pos["score"]), "negative_score": str(neg["score"]),
                          "credit": str(credit)})
    pair_auc = sum(F(row["credit"]) for row in pairs) / len(pairs)
    points = [{"case": case["id"], "threshold": "+infinity", "tp": 0, "fp": 0,
               "tpr": "0", "fpr": "0", "area_from_previous": "0"}]
    old_x, old_y, area = F(0), F(0), F(0)
    for threshold in sorted({r["score"] for r in rows}, reverse=True):
        selected = [r for r in rows if r["score"] >= threshold]
        tp = sum(r["label"] for r in selected)
        fp = len(selected) - tp
        x, y = F(fp, len(negatives)), F(tp, len(positives))
        piece = (x - old_x) * (y + old_y) / 2
        area += piece
        points.append({"case": case["id"], "threshold": str(threshold), "tp": tp, "fp": fp,
                       "tpr": str(y), "fpr": str(x), "area_from_previous": str(piece)})
        old_x, old_y = x, y
    assert (old_x, old_y) == (1, 1) and area == pair_auc
    ranking, found = [], 0
    for k, row in enumerate(sorted(rows, key=lambda r: (-r["score"], r["id"])), 1):
        found += row["label"]
        ranking.append({"case": case["id"], "rank": k, "id": row["id"], "label": row["label"],
                        "score": str(row["score"]), "positives_in_prefix": found, "precision_at_k": str(F(found, k))})
    summary = {"case": case["id"], "positive_count": len(positives), "negative_count": len(negatives),
               "pair_count": len(pairs), "pair_auc": str(pair_auc), "roc_auc": str(area),
               "precision_at_1": ranking[0]["precision_at_k"], "precision_at_3": ranking[2]["precision_at_k"],
               "ranking": [r["id"] for r in ranking]}
    return summary, pairs, points, ranking


def write_csv(name, rows):
    with (ROOT / name).open("w", encoding="utf-8", newline="") as handle:
        writer = csv.DictWriter(handle, fieldnames=list(rows[0]))
        writer.writeheader()
        writer.writerows(rows)


def main():
    summaries, pair_rows, roc_rows, rank_rows = [], [], [], []
    for case in CASES:
        summary, pairs, points, ranking = analyze(case)
        summaries.append(summary)
        pair_rows.extend(pairs)
        roc_rows.extend(points)
        rank_rows.extend(ranking)
    assert [s["pair_auc"] for s in summaries] == ["2/3", "2/3", "2/3", "5/8", "1/2"]
    assert [s["precision_at_1"] for s in summaries[:2]] == ["1", "0"]
    assert [s["precision_at_3"] for s in summaries[:2]] == ["1/3", "2/3"]
    assert summaries[0]["ranking"] == summaries[2]["ranking"]
    rejected = []
    for name, rows in [("empty", []), ("no_negative", [{"id": "x", "label": 1, "score": "1"}]),
                       ("no_positive", [{"id": "x", "label": 0, "score": "1"}]),
                       ("duplicate_id", [{"id": "x", "label": 0, "score": "0"}, {"id": "x", "label": 1, "score": "1"}])]:
        try:
            prepare(rows)
        except ValueError:
            rejected.append(name)
        else:
            raise AssertionError(name)
    inputs = {"positive_label": 1, "higher_is_more_positive": True, "sample_weights": "equal",
              "top_k_ties": "ID ascending within equal scores", "roc_ties": "entire score group moves together",
              "cases": CASES}
    report = {"scope": "Fabricated binary labels/scores; exact empirical ranking metrics, no trained classifier",
              "cases": summaries, "pair_rows": len(pair_rows), "roc_rows": len(roc_rows),
              "ranking_rows": len(rank_rows), "rejected_inputs": rejected}
    (ROOT / "auc-inputs.json").write_text(json.dumps(inputs, indent=2) + "\n", encoding="utf-8")
    (ROOT / "auc-report.json").write_text(json.dumps(report, indent=2) + "\n", encoding="utf-8")
    write_csv("auc-pairs.csv", pair_rows)
    write_csv("auc-roc.csv", roc_rows)
    write_csv("auc-rankings.csv", rank_rows)
    print(f"PASS auc_pairs: {len(CASES)} cases, {len(pair_rows)} pairs, {len(roc_rows)} ROC points, {len(rank_rows)} ranking rows")


if __name__ == "__main__":
    main()

怎样运行:Python 3.10+标准库;保存为auc_pairs.py,在空练习目录运行 python auc_pairs.py。不要加-O,验收使用assert。 2026-10-16 已用 Python 3.14.0 执行这份代码并通过自带检查;下方是当次输出。五组虚构二分类标签与分数的精确配对及 ROC 计算;不训练模型,不衡量真实业务收益或概率校准。

运行后对照结果
PASS auc_pairs: 5 cases, 44 pairs, 30 ROC points, 29 ranking rows

把指标定义与工具实现的范围分开

Fawcett原论文第5节讨论同分记录整体处理,第7节解释面积与排序概率,也指出整体面积不能保证每个区域都占优。本文七条记录是独立构造的算例,未复现论文中的分类器实验。

scikit-learn固定版本1.7文档说明二分类AUC可以接收概率或未阈值化的决策分数;ROC文档则给出大于等于阈值的约定与无穷大起点。本文实现采用这些明确约定,但没有调用该库,也不声称覆盖它的多分类、样本权重或部分面积接口。

先写决策问题,再决定保留哪些指标

如果每天只审核固定数量,就把这些名额下的命中和漏掉的正例一起列出;如果必须控制误选比例,就检查对应ROC区域与阈值。全局AUC仍有用,只是应与具体决策放在同一张报告里,避免替代它们。

这个实验的可交付结果是同一批样本上的两份完整排序、十二对比较和阈值账本。不要从AUC相同推出两个模型等价,也不要从某个k下获胜推出处处获胜。下一次看到汇总分相近时,先检查实际会使用的那一段排名。

另外,固定名额与固定分数阈值是两种规则。分数整体平移后,前几名保持不变,沿用旧阈值却可能选中不同数量。评估报告要说明采用哪种规则,不能在两种分母之间悄悄切换后比较命中率。

记住这些要点

  • AUC汇总正负配对,不直接回答固定名额的命中率。
  • 同一AUC下,k改变可以使排序优势反转。
  • ROC同分成组移动,与并列配对记半分相对应。
  • 经验排序结果不能替代概率校准或未来表现验证。

别把例子用过头

  • 把AUC当成指定阈值后的准确率。
  • 用标签顺序打破ROC并列。
  • 缺少一类仍填一个看似正常的AUC。
  • 把七条合成记录说成真实模型效果测试。

自己试一小步

你能构造另一对整体面积相同、前段表现不同的排序吗?

  1. 自行选择至少八条记录及正负比例,保留相同记录身份,为两套方案赋分。
  2. 选择两个不同的处理名额,完整记录命中数与分母,再尝试改变两方案的优势关系。
  3. 分别用正负配对和阈值面积独立核验,并单独加入跨类并列及缺类输入。
  4. 仅根据导出的CSV写第二个检查程序,提交输入、排序、面积分解和适用边界。

做到这些,就算完成

  • 练习使用自己的数据与名额,不复制正文七条排名。
  • 说明正类、分数方向、并列与无定义规则。
  • 报告包含逐对得分与面积的可重算过程,不只给最终 AUC。

查原始资料

想确认一个细节,先看这份资料对应的部分,不必从头读完所有文档。