ROC曲线
statistic
本文字数:3.4k 字 | 阅读时长 ≈ 15 min

ROC曲线

statistic
本文字数:3.4k 字 | 阅读时长 ≈ 15 min

1. 什么是 ROC 曲线

ROC 曲线(Receiver Operating Characteristic Curve)用于评估二分类模型区分正负样本的能力。
以“违规图片识别”为例,模型通常不会直接输出“违规/不违规”,而是输出一个分数,分数越高表示越可能违规。业务再设置一个分类阈值:分数高于阈值就判为“违规”,否则判为“正常”。
随着阈值不断变化,模型的召回能力和误报程度也会变化。ROC 曲线就是把这些不同阈值下的表现画在同一张图上,用来回答一个核心问题:

当我们不断调整分类阈值时,模型为了获得更高召回率,需要付出多少误报率?

下面是混淆矩阵,可以根据这个表来对应着下面看,这四个类别相加就是所有的数据

预测为正类 预测为负类
实际为正类 TP(真正例) FN(假负例)
实际为负类 FP(假正例) TN(真负例)

因此,ROC 曲线越靠近左上角越好:越靠左表示误报越少,越靠上表示召回越高。

下面是一个roc曲线的例子

对应 Python 代码

import matplotlib.pyplot as plt
import numpy as np
from sklearn.metrics import auc, roc_curve

rng = np.random.default_rng(42)
y_true = rng.integers(0, 2, 1000)
y_score = np.clip(rng.normal(0.3 + 0.4 * y_true, 0.2), 0, 1)

fpr, tpr, thresholds = roc_curve(y_true, y_score)
roc_auc = auc(fpr, tpr)

target_threshold = 0.5
finite_mask = np.isfinite(thresholds)
valid_thresholds = thresholds[finite_mask]
valid_fpr = fpr[finite_mask]
valid_tpr = tpr[finite_mask]
point = np.argmin(np.abs(valid_thresholds - target_threshold))

plt.figure(figsize=(6, 5))
plt.plot(fpr, tpr, label=f"ROC (AUC = {roc_auc:.3f})")
plt.plot([0, 1], [0, 1], "--", label="Random")
plt.scatter(valid_fpr[point], valid_tpr[point], s=70, label=f"Threshold ≈ {target_threshold}")
plt.annotate(
    f"(FPR={valid_fpr[point]:.2f}, TPR={valid_tpr[point]:.2f})",
    (valid_fpr[point], valid_tpr[point]),
    xytext=(10, -15),
    textcoords="offset points"
)

plt.xlabel("False Positive Rate")
plt.ylabel("True Positive Rate")
plt.title("ROC Curve")
plt.legend()
plt.grid(alpha=0.3)
plt.savefig("roc_curve_basic.webp", dpi=160, bbox_inches="tight")
plt.show()

2. 一个完整例子:违规图片识别

下面通过一个完整的例子来加深理解

假设现在有 10 张图片,其中 5 张确实需要拦截,5 张可以正常放出。模型给每张图片打了一个分数,分数越高表示越可能违规。

图片 模型分数 真实标签
A 0.95 需要拦截
B 0.90 可以放出
C 0.85 需要拦截
D 0.80 需要拦截
E 0.70 可以放出
F 0.60 需要拦截
G 0.55 可以放出
H 0.40 可以放出
I 0.30 需要拦截
J 0.10 可以放出

如果把阈值设为 0.80,那么 A、B、C、D 会被判为“违规”,其余样本判为“正常”。
此时四种结果分别是:

于是:\(TPR=\frac{TP}{TP+FN}=\frac{3}{5}=0.6\)。\(FPR=\frac{FP}{FP+TN}=\frac{1}{5}=0.2\)
这说明,在阈值为 0.80 时,模型召回了 60% 的违规图片,同时误伤了 20% 的正常图片。这个阈值在 ROC 平面上就对应一个点:(FPR,TPR)=(0.2,0.6)
样本分数与阈值示意图

对应 Python 代码

import matplotlib.pyplot as plt
import numpy as np

samples = [
    ("A", 0.95, 1),
    ("B", 0.90, 0),
    ("C", 0.85, 1),
    ("D", 0.80, 1),
    ("E", 0.70, 0),
    ("F", 0.60, 1),
    ("G", 0.55, 0),
    ("H", 0.40, 0),
    ("I", 0.30, 1),
    ("J", 0.10, 0),
]

samples = sorted(samples, key=lambda x: x[1], reverse=True)
names = [x[0] for x in samples]
scores = np.array([x[1] for x in samples])
labels = np.array([x[2] for x in samples])  # 1=需要拦截, 0=可以放出
x = np.arange(len(samples))
threshold = 0.80

plt.figure(figsize=(8, 4.5))
plt.scatter(x[labels == 1], scores[labels == 1], s=70, label="Positive: need block")
plt.scatter(x[labels == 0], scores[labels == 0], s=70, marker="x", label="Negative: can pass")
plt.axhline(threshold, linestyle="--", label=f"Threshold = {threshold}")

for i, name in enumerate(names):
    plt.text(x[i], scores[i] + 0.03, name, ha="center", fontsize=9)

plt.xticks(x, names)
plt.ylim(0, 1.05)
plt.xlabel("Samples sorted by score")
plt.ylabel("Model score")
plt.title("Scores and Threshold")
plt.legend()
plt.grid(alpha=0.3)
plt.savefig("score_threshold_example.webp", dpi=160, bbox_inches="tight")
plt.show()

3. 从一个点到一条曲线

单个阈值只能得到一个点,而 ROC 曲线是把所有可能阈值下的 (FPR, TPR) 都算出来,再连成一条线。还是上面的例子,当阈值很高时,模型只会拦截少量高分样本,因此误报通常较少,但也容易漏掉真正违规的内容;当阈值逐渐降低时,更多样本会被判为违规,召回率会上升,但误报率也往往会上升。也就是说:

ROC 曲线本质上就是在展示这种权衡关系。下面可以看一下不同阈值下 TPR/FPR 的变化

对应 Python 代码

import matplotlib.pyplot as plt
import numpy as np
from sklearn.metrics import roc_curve

y_true = np.array([1, 0, 1, 1, 0, 1, 0, 0, 1, 0])
y_score = np.array([0.95, 0.90, 0.85, 0.80, 0.70, 0.60, 0.55, 0.40, 0.30, 0.10])

fpr, tpr, thresholds = roc_curve(y_true, y_score)

mask = np.isfinite(thresholds)
thresholds = thresholds[mask]
fpr = fpr[mask]
tpr = tpr[mask]

plt.figure(figsize=(7, 4.5))
plt.plot(thresholds, tpr, marker="o", label="TPR")
plt.plot(thresholds, fpr, marker="o", label="FPR")
plt.gca().invert_xaxis()

plt.xlabel("Threshold (high → low)")
plt.ylabel("Rate")
plt.title("TPR / FPR under Different Thresholds")
plt.legend()
plt.grid(alpha=0.3)
plt.savefig("threshold_vs_tpr_fpr.webp", dpi=160, bbox_inches="tight")
plt.show()

4. ROC 曲线和 ROC-log 曲线

普通 ROC 曲线的横轴 FPR 使用线性刻度,从 0 到 1 均匀展开。这种画法适合观察模型的整体区分能力,但在一些业务中,我们真正关心的往往只是非常靠近 0 的一小段。例如,审核系统每天需要处理 100 万张正常图片,即使 FPR 只有 0.1%,也可能误伤 1000 张图片。因此,模型在 FPR=0.001 附近的表现,可能比它在 FPR=0.1 附近的表现更重要。

但是在线性坐标中,0.0001、0.001、0.01 都挤在横轴最左边,几乎看不出区别。这时可以把 FPR 轴改成对数刻度,得到 ROC-log 曲线。对数坐标会把低 FPR 区域展开,让我们更容易比较模型在低误报要求下还能保留多少召回率。

可以简单理解为:加 log 之后,FPR 本身没有发生任何变化,变化的只是这些数值在图上的横向距离。例如:

FPR 百分比 \(\log_{10}(FPR)\)
\(10^{-4}\) 0.01% -4
\(10^{-3}\) 0.1% -3
\(10^{-2}\) 1% -2
\(10^{-1}\) 10% -1
\(10^0\) 100% 0

普通横轴按照“相差多少”安排位置,所以这些很小的 FPR 会全部挤在 0 附近;log 横轴则更关注“相差多少倍”。从 \(10^{-4}\) 到 \(10^{-3}\) 扩大了 10 倍,从 \(10^{-3}\) 到 \(10^{-2}\) 也扩大了 10 倍,因此这两个区间在 log 横轴上占据相同的宽度。

需要注意,ROC-log 并不是一种新的评价指标,也没有改变 FPR、TPR 和 AUC 的定义。它和普通 ROC 使用的是同一批点,只是横轴的展示方式不同:

下面把两种画法放在一起对照。左图是普通 ROC,右图是 ROC-log。两张图中的数据和 AUC 完全相同,右图只是把靠近 0 的区域展开了。

对应 Python 代码

import matplotlib.pyplot as plt
import numpy as np
from sklearn.metrics import auc, roc_curve

# =========================
# 1. 构造一组可复现的二分类结果
# =========================
rng = np.random.default_rng(42)

n_samples = 20000

# 真实标签:0 / 1
y_true = rng.integers(0, 2, n_samples)

# 模型分数:
# 正样本整体分数更高,但两类仍有较多重叠
y_score = rng.normal(
    loc=0.9 * y_true,
    scale=1.0,
    size=n_samples,
)

# =========================
# 2. 计算 ROC
# =========================
fpr, tpr, thresholds = roc_curve(y_true, y_score)

# 标准 ROC-AUC
roc_auc = auc(fpr, tpr)

# 负样本数量
n_negative = np.sum(y_true == 0)

# 测试集能够分辨的最小非零 FPR
# 1 个 FP 对应 1 / N_negative
min_fpr = 1 / n_negative

print(f"Number of samples: {n_samples}")
print(f"Number of negatives: {n_negative}")
print(f"Minimum resolvable non-zero FPR: {min_fpr:.6f}")
print(f"ROC-AUC: {roc_auc:.4f}")

# =========================
# 3. 创建画布
# =========================
fig, axes = plt.subplots(
    1,
    2,
    figsize=(12, 4.8),
)

# =========================
# 4. 左图:普通 ROC
# =========================
axes[0].plot(
    fpr,
    tpr,
    label=f"ROC (AUC = {roc_auc:.3f})",
)

# 随机分类器:
# TPR = FPR
axes[0].plot(
    [0, 1],
    [0, 1],
    "--",
    color="gray",
    label="Random",
)

axes[0].set_xlim(0, 1)
axes[0].set_ylim(0, 1.02)

axes[0].set_xlabel("False Positive Rate")
axes[0].set_ylabel("True Positive Rate")

axes[0].set_title("ROC")

axes[0].legend()
axes[0].grid(alpha=0.3)

# =========================
# 5. 右图:Log-FPR ROC
# =========================

# log(0) 不存在,因此过滤掉 FPR = 0 的点
positive_fpr = fpr > 0

axes[1].plot(
    fpr[positive_fpr],
    tpr[positive_fpr],
    label=f"ROC (AUC = {roc_auc:.3f})",
)

# 随机分类器依然满足:
# TPR = FPR
#
# 但因为 x 轴采用 log scale,
# 不能只用两个端点连直线,
# 应该采样多个 FPR 点来画 y = x
random_fpr = np.logspace(
    np.log10(min_fpr),
    0,
    200,
)

axes[1].plot(
    random_fpr,
    random_fpr,
    "--",
    color="gray",
    label="Random",
)

# FPR 横轴改成对数坐标
axes[1].set_xscale("log")

axes[1].set_xlim(min_fpr, 1)
axes[1].set_ylim(0, 1.02)

axes[1].set_xlabel("False Positive Rate (log scale)")
axes[1].set_ylabel("True Positive Rate")

axes[1].set_title("ROC (log-scale FPR)")

axes[1].legend()

# which="both":
# 同时显示 major 和 minor log 网格线
axes[1].grid(
    alpha=0.3,
    which="both",
)

# =========================
# 6. 保存并展示
# =========================
plt.tight_layout()

plt.savefig(
    "roc_vs_log_fpr_roc.webp",
    dpi=160,
    bbox_inches="tight",
)

plt.show()

5. 什么是 AUC

AUC(Area Under the Curve)是 ROC 曲线下方的面积,用一个数来概括模型整体的排序能力。它的直观含义是:

随机抽取一个正样本和一个负样本,模型给正样本的分数高于负样本的概率。

这里的“随机抽取一个正样本和一个负样本”,可以理解成让它们进行一次分数比赛。这个不太好理解,依然的,我们举一个具体的例子,依然以违规图片为例。假设有 3 张违规图片和 3 张正常图片:

样本 真实情况 模型分数
A 违规 0.90
B 违规 0.85
C 违规 0.50
D 正常 0.80
E 正常 0.40
F 正常 0.20

任意拿一张违规图片和一张正常图片比较,一共有:\(3\times3=9\)种组合。

最终 9 次比较中,模型排对了 8 次:\(AUC=\frac{8}{9}\approx0.89\)。这意味着,随机拿一张真实违规图片和一张真实正常图片,模型大约有 89% 的概率给违规图片更高的风险分数。因此,当 AUC = 0.90 时,可以通俗地理解为:随机进行 100 次“正样本和负样本的分数比赛”,模型大约有 90 次能把正样本排在负样本前面。

注意:AUC = 0.90 并不代表 90% 的样本都预测正确,也不代表 90% 的违规图片都能被拦截。即

AUC 表达模型有没有能力让正样本整体排在负样本前面的能力

6. ROC 曲线的局限

ROC 曲线很适合看模型整体的区分能力,但在正负样本极不均衡时,它可能显得过于乐观。例如,在热点发现里,100 万个候选词中可能只有 100 个真正会成为热点。哪怕 FPR 只有 1%,也仍然可能产生将近 1 万个误报,业务上完全无法处理。

7. 工程上最容易出错的问题

1. 绘制 roc 曲线的问题

在绘制 roc 曲线时,标签用 0 还是 1 代表不拦截/拦截并不影响 ROC,一般来讲在分类中,分数大于阈值代表正类,即拦截,小于阈值为负类,即不拦截。

这时候如果分类模型的输出是正好相反,即分数大于阈值代表负类,小于阈值为正类。这时候只需要将TP,TN,FP,FN这些稍微修改一下即可。不耽误正常曲线的绘制

2. 实际业务口径误伤/误杀

FPR 和团队里口头说的“误伤率/误杀率”不一定是同一个口径。ROC 横轴使用的是:\(FPR=\frac{FP}{FP+TN}\)。它的分母是所有真实负样本。而有些业务里说的“误杀率”其实是:\(\frac{FP}{TP+FP}=1-Precision\)。它的分母是所有被模型判为正样本的样本。两者分母不同,不能混用。这个和团队统一口径即可。

Sep 06, 2026
Mar 13, 2026
ufw