第3章 混淆矩阵与TPR、FPR

聊完富集因子的计算,咱们得先停一下。你想想看,不管做什么分类或富集分析,最后怎么评价结果好不好?光看预测对了多少个,远远不够。我早年做过一个基因功能注释的项目,模型准确率95%,结果却一塌糊涂——后来才发现,负样本占了绝大多数,准确率根本是骗人的。

所以这一章,咱们把评价指标的老底翻一翻。混淆矩阵 是所有分类问题的基石。而 TPR(真阳性率)和 FPR(假阳性率) 更是 ROC 曲线的原料。搞不懂这俩,后面的 AUC 就是空中楼阁。

3.1 混淆矩阵:四个格子,说清全部

说白了,混淆矩阵就是一张 2×2 的表格,把真实标签和预测结果摆在一起对比。其实你平时做实验,对比真值和测量值,也是这个思路。

拿一个简单的疾病诊断来说:

  • Positive (P):真实患病
  • Negative (N):真实健康
  • Positive (P'):模型预测患病
  • Negative (N'):模型预测健康

交叉一对,就出来四个数:

真实 \ 预测预测 P'预测 N'
真实 PTP (真阳性)FN (假阴性)
真实 NFP (假阳性)TN (真阴性)

我的记忆习惯: 首字母“真/假”对应预测是否正确,“阳/阴”对应预测结果。TP = 预测阳性且预测对了;FP = 预测阳性但预测错了。

实际项目里,我经常收到这样的反馈:“我的模型查出来50个阳性,全是准的。” 你一问 FN 是多少,对方就愣住了。光看 TP 不看 FN,就像只报喜不报忧。

3.2 TPR 和 FPR:灵敏度与假阳性率的真面目

有了四个格子的数,就可以算出两个核心比率:

  • TPR (Recall / 灵敏度 / 真阳性率) = TP / (TP + FN)
    表示“真实的阳性样本里,有多少被我们揪出来了”。
  • FPR (假阳性率) = FP / (FP + TN)
    表示“真实的阴性样本里,有多少被我们冤枉成了阳性”。

我曾经踩过的坑: 一次做基因集富集分析,我把FPR写成 FP / (TP + FP) 了。那是精确率(Precision)的公式!结果论文化完数据都算错了,连夜重跑。记住:FPR 的分母是真实阴性的总数,不是预测阳性的总和。

你想想看,TPR 越大越好,FPR 越小越好。但现实是——你调高模型的灵敏度,往往就会多抓一些无辜的阴性进来,FPR 跟着涨。这就是 TPR 和 FPR 之间的博弈。

咱们用一张流程图来梳理清楚:

混淆矩阵 → TPR & FPR 流程 真实标签 + 预测结果 TP 真阳性 FN 假阴性 FP 假阳性 TN 真阴性 TPR = TP / (TP + FN) 灵敏度 / Recall FPR = FP / (FP + TN) 假阳性率 越高越好 越低越好
核心理解: TPR 回答“漏掉的患者有多少”,FPR 回答“误抓的健康人有几个”。富集分析里的 p-value 排序,本质上就是在调节 TPR 与 FPR 的平衡。

3.3 为什么这两个指标是 ROC 曲线的原料?

做过富集分析的朋友都知道,我们通常用 p-value 或 FDR 来卡阈值。阈值设得严,只有非常显著的基因集才被选进来——TPR 可能很低(漏了很多真的富集集),但 FPR 也低(假阳性少)。阈值放宽,TPR 上去了,FPR 也涨了。

ROC 曲线就是:遍历所有可能的阈值,算出每个阈值下的 (FPR, TPR) 点,连成一条曲线。横轴是 FPR,纵轴是 TPR。曲线越靠近左上角,说明模型越强。

我自己的调试习惯: 在 Python 里用 sklearn.metrics.roc_curve 之前,我会先手动算一遍混淆矩阵。比如用 10 个样本模拟,一步步写出 TP、FN、FP、TN,再算 TPR、FPR。这样不管函数返回什么,我心里都有底。

举个代码片段的例子,千万别直接拿去跑大数据——先在小样本上理解:

import numpy as np

# 假设真实标签和预测概率
y_true = np.array([1, 1, 0, 0, 1, 0, 1, 0, 1, 0])
y_scores = np.array([0.9, 0.8, 0.6, 0.4, 0.7, 0.3, 0.85, 0.2, 0.95, 0.1])

# 选一个阈值
threshold = 0.5
y_pred = (y_scores >= threshold).astype(int)

# 手动计算混淆矩阵
TP = np.sum((y_true == 1) & (y_pred == 1))
FN = np.sum((y_true == 1) & (y_pred == 0))
FP = np.sum((y_true == 0) & (y_pred == 1))
TN = np.sum((y_true == 0) & (y_pred == 0))

TPR = TP / (TP + FN)
FPR = FP / (FP + TN)
print(f"TPR = {TPR:.3f}, FPR = {FPR:.3f}")

# 改变阈值 0.3 再算一遍
y_pred2 = (y_scores >= 0.3).astype(int)
TP2 = np.sum((y_true == 1) & (y_pred2 == 1))
FN2 = np.sum((y_true == 1) & (y_pred2 == 0))
FP2 = np.sum((y_true == 0) & (y_pred2 == 1))
TN2 = np.sum((y_true == 0) & (y_pred2 == 0))
TPR2 = TP2 / (TP2 + FN2)
FPR2 = FP2 / (FP2 + TN2)
print(f"Threshold 0.3 -> TPR = {TPR2:.3f}, FPR = {FPR2:.3f}")

你看,阈值一降,TPR 从 0.6 可能升到 0.8,但 FPR 也跟着从 0.25 升到 0.5。这就是 ROC 曲线上的两个点。

3.4 在富集分析中到底怎么用?

做过 GSEA 的朋友应该不陌生:GSEA 用排序后的基因列表,滑窗统计每个基因集的富集分数(ES),然后算归一化的 NES 和 p-value。这里其实就隐藏了一个混淆矩阵的逻辑:

  • 真实“阳性”基因集 = 在数据库里已知与表型相关的通路;
  • 预测“阳性”基因集 = 被我们选出来的高富集通路;
  • TP = 选出来且确实是已知通路;FP = 选出来但假阳性;
  • FN = 漏掉的已知通路;TN = 没选且确实不相关。

当然实际富集分析没有绝对的金标准,但我们做模拟实验或基准测试时,就可以套用这套评价体系。我曾经参与过一个通路数据库的评估,用了 50 个已知机制模拟数据集,分别跑三种工具,最后画 ROC 曲线来对比不同工具的召回能力。那次让我深刻体会到:没有混淆矩阵思维,对比结果就像没有标尺的尺子。

3.5 本章小结

四个格子——TP、FN、FP、TN。两个比率——TPR 与 FPR。一个平衡——灵敏度与特异度(特异度 = 1 - FPR)。再后面就是怎么用这些点画出 ROC 曲线。

一句话总结: 混淆矩阵是所有分类评价的“账本”,TPR 和 FPR 是 ROC 曲线的“原材料”。账目清了,曲线才有意义。