开始前,会这些就够了
- 会算比例和简单分数
- 理解正类标签与预测分数不同
- 能运行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%
按分数排好的候选
- P1正例 · 分数 7已选入前 1 条
- N1负例 · 分数 6未选入
- N2负例 · 分数 5未选入
- P2正例 · 分数 4未选入
- P3正例 · 分数 3未选入
- N3负例 · 分数 2未选入
- N4负例 · 分数 1未选入
| 正例 / 负例 | N1(6) | N2(5) | N3(2) | N4(1) |
|---|---|---|---|---|
| P1(7) | 1 | 1 | 1 | 1 |
| P2(4) | 0 | 0 | 1 | 1 |
| P3(3) | 0 | 0 | 1 | 1 |
移动 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优化选项时运行。
展开收起代码 · 小实验
长行可在代码区域内左右滚动,按 Tab 可把焦点移入代码区。
"""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。
- 把七条合成记录说成真实模型效果测试。
自己试一小步
你能构造另一对整体面积相同、前段表现不同的排序吗?
- 自行选择至少八条记录及正负比例,保留相同记录身份,为两套方案赋分。
- 选择两个不同的处理名额,完整记录命中数与分母,再尝试改变两方案的优势关系。
- 分别用正负配对和阈值面积独立核验,并单独加入跨类并列及缺类输入。
- 仅根据导出的CSV写第二个检查程序,提交输入、排序、面积分解和适用边界。
做到这些,就算完成
- 练习使用自己的数据与名额,不复制正文七条排名。
- 说明正类、分数方向、并列与无定义规则。
- 报告包含逐对得分与面积的可重算过程,不只给最终 AUC。
查原始资料
想确认一个细节,先看这份资料对应的部分,不必从头读完所有文档。
- An introduction to ROC analysis (新标签页)↗
第5节并列处理、第7节AUC的排序解释与局部表现限制。
- scikit-learn 1.7:roc_auc_score (新标签页)↗
二分类可以输入概率估计或未阈值化的决策值。
- scikit-learn 1.7:roc_curve (新标签页)↗
分数大于等于阈值、FPR
/TPR以及无穷大起点。