4. ROC 曲线的基本概念:真正例率(TPR)与假正例率(FPR)
好,咱们今天聊 ROC 曲线。很多人一听到这个名字就发怵,觉得是高大上的统计理论。其实没那么复杂。
说白了,ROC 曲线就是帮你看清楚:你的模型到底有没有把“坏的”和“好的”分清楚。尤其是 RNA-seq 这类组学数据,正负样本常常不平衡,光看准确率很容易被忽悠。
我记得自己刚入行时,拿 RNA-seq 数据做癌症亚型分类,准确率干到了 97%。心里挺美,结果导师看了一眼就说:“你回去画个 ROC 吧。” 画完之后我直接傻眼了 —— AUC 才 0.52,跟瞎猜差不多。原来 97% 的准确率是因为 95% 的样本都是正常对照,模型学到的只是一个“什么也不做”的默认规则。
4.1 从混淆矩阵说起
要理解 TPR 和 FPR,先得弄明白混淆矩阵。它就像一张判决书,把模型的预测结果分成了四类:
| 实际 \ 预测 | 正类(预测) | 负类(预测) |
|---|---|---|
| 正类(实际) | 真阳性(TP) | 假阴性(FN) |
| 负类(实际) | 假阳性(FP) | 真阴性(TN) |
举个例子。咱们在 RNA-seq 里预测某个基因是否差异表达。模型说“是差异的”,实际情况也是差异的,那就是 TP,好样的。模型说“不是”,但实际上是差异的,那就漏了,FN。模型说“是”,但实际不是,那就是 FP,误报。模型说“不是”,实际也不是,TN,正常工作。
关键点:在 ROC 的语境里,我们通常把“疾病”、“差异”、“异常”这类更关心的状态定义为正类(Positive)。
4.2 真正例率(TPR):我们到底抓住了多少好东西?
真正例率,英文叫 True Positive Rate,简称 TPR。也叫灵敏度或召回率。
它的公式很简单:
TPR = TP / (TP + FN)
我习惯把它理解为“查全率”。在你的 RNA-seq 差异基因列表里,模型帮你找到了多少个真正有差异的基因?
举个例子。假设实际有 100 个真正差异表达的基因,模型只找出了 60 个,那 TPR 就是 0.6。剩下的 40 个都被漏掉了。对于关键的生物标志物筛选来说,漏掉一个都可能意味着项目失败。
我曾经在项目里吃过 TPR 的亏。做一个肺癌早筛模型,TPR 只有 0.7 左右,觉得还行。后来发现漏掉的那 30% 患者恰恰都是早期无症状的 —— 等于说模型对最该发现的人群视而不见。从那之后我就把 TPR 的底线调到了 0.9 以上。
4.3 假正例率(FPR):你报了多少假警?
假正例率,False Positive Rate,也叫降噪比。它的公式是:
FPR = FP / (FP + TN)
FPR 反映的是模型把“没事”当成“有事”的比例。在 RNA-seq 里,就是模型误报为差异表达的基因数量,占所有真正不差异基因的比例。
你想想看,FPR 高,意味着你的实验结果里充满了“假线索”。后续的湿实验验证,全都得为这些假线索买单 —— 时间、经费、试剂,烧得一干二净。
有一年我做转录组项目,模型 FPR 到了 0.3,也就是每 10 个阴性基因里有 3 个被误判为差异。最后挑了 20 个候选基因去做 qPCR 验证,只有 5 个是对的。老板问我为什么,我说“FPR 高了”。他当时说了句我至今记得的话:“那你为什么不早点告诉我?” 嗯,从那以后我就把 FPR 当作最优先检查的指标之一。
4.4 为什么要把 TPR 和 FPR 放在一起看?
如果一个模型只追求高 TPR,怎么办?很简单,把所有样本都判为正类就行。这样 TP = 所有实际正类样本,FN = 0,TPR 直接飙到 1.0。但代价是 FPR 也会变成 1.0 —— 所有阴性样本全被误报。
反过来,如果只追求低 FPR,把所有样本都判为负类,那 FPR = 0,但 TPR 也变成 0 了。
所以 TPR 和 FPR 是一对矛盾体。ROC 曲线干的活,就是把不同阈值下这对矛盾体的平衡关系画出来,让你一目了然。
在 RNA-seq 里,阈值的概念随处可见。比如当某个基因的 log2FoldChange > 1 且 padj < 0.05 时,我们判它为差异表达基因。这个 0.05 就是阈值。调低它,TPR 上升但 FPR 也上升;调高它,FPR 下降但 TPR 也随之下降。
为什么 ROC 曲线比单点指标更靠谱?因为它考察的是模型在所有可能阈值下的综合表现,而不是被你选定的那个阈值所绑架。
4.5 一张图看懂核心逻辑
下面这张图是我自己一直留着的结构图,每次讲课都拿出来用。它把 TPR、FPR、阈值和 ROC 的关系串在了一起。
4.6 手算一个例子
光说不练假把式。咱们拿一个手算的例子来理解。
假设一个 RNA-seq 差异基因分类器在不同阈值下输出了以下结果:
| 阈值 | TP | FN | FP | TN | TPR | FPR |
|---|---|---|---|---|---|---|
| 0.1 | 98 | 2 | 80 | 20 | 0.98 | 0.80 |
| 0.3 | 85 | 15 | 30 | 70 | 0.85 | 0.30 |
| 0.5 | 70 | 30 | 10 | 90 | 0.70 | 0.10 |
| 0.7 | 40 | 60 | 2 | 98 | 0.40 | 0.02 |
| 0.9 | 10 | 90 | 0 | 100 | 0.10 | 0.00 |
你看这个趋势。 阈值很低时(0.1),模型很“慷慨”,几乎把所有基因都判为差异。TPR 确实很高(0.98),但 FPR 也爆炸了(0.80)。阈值提高到 0.5 时,TPR 降到了 0.70,但 FPR 也摔到了 0.10。阈值到 0.9 时,FPR 为 0,但 TPR 也只剩 0.10,基本等于废了。
把上面五组 (FPR, TPR) 点画在坐标系里,你就得到了一条 ROC 曲线。曲线越靠近左上角,模型的综合性能越好。
我有一个小习惯:项目交付前,一定会看 FPR 在 0.05~0.1 范围内对应的 TPR 是多少。如果在这个区域内 TPR 还能保持在 0.8 以上,那模型才算基本合格。
4.7 代码实现:从预测值到 ROC 点
在 R 或 Python 里画 ROC 非常简单。不过咱们重点理解的是它背后的逻辑。下面是一段 Python 的示例,展示了如何从一个概率得分列表计算不同阈值下的 TPR 和 FPR:
import numpy as np
# 假设我们有 5 个样本的真实标签和模型预测概率
y_true = np.array([1, 1, 0, 0, 1])
y_pred_prob = np.array([0.9, 0.7, 0.2, 0.6, 0.3])
# 设定一组阈值
thresholds = np.linspace(0.0, 1.0, 11)
tpr_list = []
fpr_list = []
for thresh in thresholds:
y_pred = (y_pred_prob >= thresh).astype(int)
tp = sum((y_true == 1) & (y_pred == 1))
fp = sum((y_true == 0) & (y_pred == 1))
fn = sum((y_true == 1) & (y_pred == 0))
tn = sum((y_true == 0) & (y_pred == 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)
# 输出几个关键点的值
for i in range(0, len(thresholds), 2):
print(f"阈值: {thresholds[i]:.2f}, TPR: {tpr_list[i]:.3f}, FPR: {fpr_list[i]:.3f}")
你看,每个阈值都对应一对 (FPR, TPR)。把这些点坐标画出来,就是我们常说的 ROC 曲线。
但请注意:手动循环算 TPR/FPR 只适用于教学。实际项目中直接用 sklearn.metrics.roc_curve 就好,它连最优阈值都帮你算好了。咱们搞清楚原理就够了,不必重复造轮子。
4.8 本章小总结
- TPR:真正例率,衡量模型找正样本的能力,越高越好。
- FPR:假正例率,衡量模型误报负样本的严重性,越低越好。
- 阈值:一个调解螺旋,往左拧 TPR 涨 FPR 也涨,往右拧两者都降。
- ROC 曲线:所有阈值下 TPR 对 FPR 的轨迹图,帮你全局评估模型。
嗯,讲到这里,ROC 最核心的两个概念应该已经清楚了。它们组合在一起,就是后面我们聊交叉验证、AUC 计算的底盘。记住这四点,后面的路就好走了。