4. ROC曲线的绘制原理:从阈值到坐标点
上一章我们聊了AUC为什么比准确率靠谱。这章深入一点——ROC曲线到底是怎么画出来的?说白了,就是我们把模型的输出分数切成一个个小段,每个小段算一组真假阳性率,然后把这些点连成线。我在项目里亲手画过几百条这种曲线,坑踩了不少,今天一次性说清楚。
4.1 阈值是什么?
分类模型输出的往往是概率(比如0.82),而不是直接的0/1标签。你得定一个“切割点”——概率高于这个点的算正类,低于的算负类。这个切割点就是阈值(threshold)。
默认情况下,很多库把阈值设为0.5。但实际业务中,阈值可以任意取。比如我做过一个欺诈检测项目,宁可误报也不能漏报,就把阈值降到0.3。你想想看,阈值一变,预测结果全变,ROC曲线就要重新算。
4.2 混淆矩阵:一切计算的起点
给定一个阈值,我们先统计四格表。以二分类为例:
| 实际\预测 | 正类 (1) | 负类 (0) |
|---|---|---|
| 正类 (1) | TP (真正例) | FN (假负例) |
| 负类 (0) | FP (假正例) | TN (真负例) |
然后算两个核心指标:
- TPR (True Positive Rate) = TP / (TP + FN) —— 也叫召回率,代表“所有正样本中,我抓对了多少”。
- FPR (False Positive Rate) = FP / (FP + TN) —— 代表“所有负样本中,我错抓了多少”。
这两个值就是ROC曲线的横纵坐标。我曾犯过一个低级错误:把精确率(Precision)当成纵坐标,画出来的图完全不对,到处问才知道弄混了。
4.3 从阈值到坐标点——手动走一遍
假设我们有5个样本的真实标签和预测概率:
| 样本 | 真实标签 | 预测概率 |
|---|---|---|
| A | 1 | 0.90 |
| B | 1 | 0.80 |
| C | 0 | 0.70 |
| D | 0 | 0.30 |
| E | 1 | 0.10 |
现在我们把阈值从1.0逐渐降到0.0,每一步算一个(TPR, FPR)点。
- 阈值 = 1.0:没有样本被预测为正,TP=0, FP=0, FN=3, TN=2。TPR=0/3=0,FPR=0/2=0。得到点(0,0)。
- 阈值 = 0.9:只有A高于阈值,A真实为1→TP=1, FP=0, FN=2, TN=2。TPR=1/3≈0.33,FPR=0/2=0。点(0,0.33)。
- 阈值 = 0.8:A和B都高于阈值,A和B都是正→TP=2, FP=0, FN=1, TN=2。TPR=2/3≈0.67,FPR=0。点(0,0.67)。
- 阈值 = 0.7:A、B、C高于阈值。A和B是正,C是负→TP=2, FP=1, FN=1, TN=1。TPR=2/3≈0.67,FPR=1/2=0.5。点(0.5,0.67)。
- 阈值 = 0.3:A、B、C、D高于阈值。A、B、E是正→TP=2(A和B), FP=2(C和D), FN=1(E), TN=0。TPR=2/3≈0.67,FPR=2/2=1。点(1,0.67)。
- 阈值 = 0.1:全为正→TP=3, FP=2, FN=0, TN=0。TPR=1,FPR=1。点(1,1)。
把这些点连起来,就是一条从(0,0)到(1,1)的折线。每一个阈值对应一个离散点。样本越多、概率越密,曲线就越平滑。
4.4 为什么阈值要从高往低扫?
你可能会问:为什么不能从低往高?其实都可以。但习惯上我们从最高阈值开始,因为此时模型最“谨慎”——只有极度信任才判为正,所以TPR和FPR都接近0。然后逐渐放松标准,曲线往右上角走。我用matplotlib画过很多次,最后发现库函数底层也是这么干的。
4.5 代码实现:自己画一遍更踏实
我个人习惯先手写一段循环,看看每个阈值对应的坐标,再用sklearn验证。这样理解深刻。
import numpy as np
import matplotlib.pyplot as plt
# 真实标签和预测概率
y_true = np.array([1, 1, 0, 0, 1])
y_scores = np.array([0.9, 0.8, 0.7, 0.3, 0.1])
# 所有可能的阈值(从高到低)
thresholds = np.sort(np.unique(y_scores))[::-1]
# 加上两个边界
thresholds = np.concatenate([[1.0], thresholds, [0.0]])
tpr_list = []
fpr_list = []
for thr in thresholds:
pred = (y_scores >= thr).astype(int)
tp = np.sum((pred == 1) & (y_true == 1))
fn = np.sum((pred == 0) & (y_true == 1))
fp = np.sum((pred == 1) & (y_true == 0))
tn = np.sum((pred == 0) & (y_true == 0))
tpr = tp / (tp + fn) if (tp + fn) > 0 else 0
fpr = fp / (fp + tn) if (fp + tn) > 0 else 0
tpr_list.append(tpr)
fpr_list.append(fpr)
plt.plot(fpr_list, tpr_list, marker='o')
plt.xlabel('False Positive Rate (1 - Specificity)')
plt.ylabel('True Positive Rate (Sensitivity)')
plt.title('ROC Curve (Manual)')
plt.grid(True)
plt.show()
输出是一张折线图,每个拐角处就是我们刚才算出来的6个点。你把它和sklearn的`roc_curve()`对比一下,结果完全一致。
4.6 关于“至少取多少阈值”的一点经验
理论上,有多少个样本就有多少个不同的概率值(加上边界)。但实际应用时,如果样本量很大(比如10万+),阈值点太多会导致曲线锯齿严重。我的做法是先等间距取100~200个阈值点,既保证曲线平滑,又节省计算时间。当然,如果做严格评估,用全部的样本阈值。
4.7 一个常见的陷阱:标签不平衡时的坐标点
当正负样本严重不均衡时,比如正样本只有1个,负样本999个,你会发现FPR的分母很大,导致FPR的步长非常小。我曾经在信用卡欺诈数据上画ROC,阈值从0.9降到0.5时,FPR几乎纹丝不动,因为分母太大,分子(FP)增加几个根本看不出来。这时候曲线会贴着左边界上升,直到阈值极低才突然右拐。这不是bug,是数据特性造成的。你看到这种形状,心里要有数。
4.8 核心流程总结
下面这张图把整个绘制过程串起来,我建议你收藏,下次写代码时心里先过一遍这张图。
画ROC这件事,本质上就是把连续的概率预测转化成离散的决策点,再把这些点的表现画出来。你掌握了从阈值到坐标点的映射逻辑,后面理解AUC的计算、多分类ROC扩展,都会轻松很多。