蛋白质分类器如何选阈值:用 Python 检查精确率与召回率
如果 100 条记录里只有 8 条阳性,把所有候选都判为阴性,也能得到 92% 的准确率。这个数字无法说明模型能否筛出值得做实验的蛋白质。
本文把预测分数变成一条可复现的筛选规则:在验证集上选择阈值,固定下来,再到独立测试集上统计结果。它接续按组划分数据集一文,可用于氨基酸组成基线或 ESM-2 嵌入分类器输出的分数。
1. 先确定一次阳性预测意味着什么
这里规定:标签 1 表示候选达到预先定义的实验标准,0 表示未达到;分数越高,越倾向于 1。没有做过实验的蛋白质,不能直接当作阴性样本。
| 指标 | 回答的问题 |
|---|---|
| 精确率 precision:TP / (TP + FP) | 选中的候选里,有多少是阳性? |
| 召回率 recall:TP / (TP + FN) | 所有已标注的阳性里,找回了多少? |
| 入选数量:TP + FP | 按这条规则,需要安排多少次后续实验? |
TP、FP、FN 分别是真阳性、假阳性、假阴性。精确率和召回率的分母不同,也都不能替代实际实验数量。定义可参见 scikit-learn 文档。
示例要求验证集精确率至少达到 0.60,再从满足条件的阈值里选择召回率最高的一个。这只是演示策略,不是蛋白质筛选的推荐标准。实际要求应由实验预算和漏掉候选的代价决定,并在比较模型之前确定。如果每次只允许挑选固定的前 k 个候选,应另行定义 top-k 规则,包括同分时如何处理。
2. 安装并运行示例
示例使用人为编写的分数和标签:验证集与测试集各有 100 条独立记录,各含 8 条阳性。程序不训练模型,也不代表任何生物实验结果。两组使用相同的分数网格,方便对照计算;记录本身按示例设定彼此独立。
下载 threshold_demo.py,保存到新目录。使用 Python 3.11 或更新版本,在该目录执行:
python3 -m venv .venv
source .venv/bin/activate
python -m pip install "scikit-learn==1.9.1"
python threshold_demo.py --help
python threshold_demo.py --min-precision 0.60
示例已在 Python 3.14.7、NumPy 2.5.3 和 scikit-learn 1.9.1 下运行。预期输出:
Validation precision requirement: 0.600
validation: threshold=0.730 selected=6/100
TP=4 FP=2 FN=4 TN=90
precision=0.667 recall=0.500
AP=0.628 prevalence=0.080
test (frozen threshold): threshold=0.730 selected=6/100
TP=3 FP=3 FN=5 TN=89
precision=0.500 recall=0.375
AP=0.547 prevalence=0.080
test (predeclared 0.5 reference): threshold=0.500 selected=10/100
TP=4 FP=6 FN=4 TN=86
precision=0.400 recall=0.500
AP=0.547 prevalence=0.080
Always-negative test accuracy: 0.920
验证集选出的阈值是 0.730:6 条记录入选,其中 4 条阳性,还有 4 条阳性被漏掉。把相同阈值用于测试集,选中的是 3 条阳性和 3 条阴性,精确率降到 0.500,低于验证集要求。在验证集上满足条件,不代表新候选也一定满足。
3. 在验证集上选阈值,再固定规则
完整脚本如下,与下载文件一致:
"""Synthetic precision/recall tutorial; no trained model or biological results."""
import argparse
import numpy as np
from sklearn.metrics import average_precision_score, precision_recall_curve
def checked_arrays(labels, scores):
y = np.asarray(labels)
s = np.asarray(scores, dtype=float)
if y.ndim != 1 or s.ndim != 1 or y.size != s.size or not y.size:
raise ValueError("Labels and scores must be nonempty 1D arrays of equal length")
if not np.isin(y, [0, 1]).all() or np.unique(y).size != 2:
raise ValueError("Labels must contain both 0 and 1")
if not np.isfinite(s).all():
raise ValueError("Scores must be finite")
return y.astype(int), s
def choose_threshold(labels, scores, min_precision=0.60):
y, s = checked_arrays(labels, scores)
if not np.isfinite(min_precision) or not 0 < min_precision <= 1:
raise ValueError("min_precision must be in (0, 1]")
precision, recall, thresholds = precision_recall_curve(y, s, pos_label=1)
# The final plotting endpoint has no threshold and must not be selected.
eligible = np.flatnonzero(precision[:-1] >= min_precision)
if not eligible.size:
raise ValueError("No observed threshold meets the precision requirement")
# Maximize recall, then precision, then threshold for a deterministic tie rule.
best = max(eligible, key=lambda i: (recall[i], precision[i], thresholds[i]))
return float(thresholds[best])
def report(name, labels, scores, threshold):
y, s = checked_arrays(labels, scores)
if not np.isfinite(threshold):
raise ValueError("Threshold must be finite")
pred = s >= threshold
tp = int(np.sum(pred & (y == 1)))
fp = int(np.sum(pred & (y == 0)))
fn = int(np.sum(~pred & (y == 1)))
tn = int(np.sum(~pred & (y == 0)))
precision = f"{tp / (tp + fp):.3f}" if tp + fp else "undefined"
print(f"{name}: threshold={threshold:.3f} selected={tp + fp}/{len(y)}")
print(f" TP={tp} FP={fp} FN={fn} TN={tn}")
print(f" precision={precision} recall={tp / (tp + fn):.3f}")
print(f" AP={average_precision_score(y, s):.3f} prevalence={y.mean():.3f}")
def demo_data():
# Separate invented validation and test records, ordered by decreasing score.
scores = np.r_[
[.96, .91, .87, .82, .78, .73, .68, .62, .57, .52,
.47, .42, .37, .32, .28, .24, .20, .16, .12, .08],
np.linspace(.07, .001, 80),
]
valid = np.zeros(100, dtype=int)
test = np.zeros(100, dtype=int)
valid[np.array([1, 3, 4, 6, 9, 11, 16, 20]) - 1] = 1
test[np.array([1, 2, 5, 8, 12, 20, 24, 30]) - 1] = 1
return valid, scores.copy(), test, scores.copy()
def main():
parser = argparse.ArgumentParser(description=__doc__)
parser.add_argument("--min-precision", type=float, default=0.60)
args = parser.parse_args()
valid_y, valid_s, test_y, test_s = demo_data()
try:
threshold = choose_threshold(valid_y, valid_s, args.min_precision)
except ValueError as exc:
parser.error(str(exc))
print(f"Validation precision requirement: {args.min_precision:.3f}")
report("validation", valid_y, valid_s, threshold)
report("test (frozen threshold)", test_y, test_s, threshold)
report("test (predeclared 0.5 reference)", test_y, test_s, 0.5)
print(f"Always-negative test accuracy: {(test_y == 0).mean():.3f}")
if __name__ == "__main__":
main()
choose_threshold() 只接收验证集标签和分数。它先在符合要求的阈值中最大化召回率,再按精确率打破平局,最后优先选择较大的阈值。预测使用 score >= threshold,因此边界上的同分记录会得到相同决定。
precision_recall_curve() 返回的精确率、召回率数组,比阈值数组多一个元素。最后一对数值是用于绘图的端点,没有对应阈值。代码用 precision[:-1] 排除它,避免把这个端点误当成可选的工作阈值。数组对应关系见 API 文档。
真实数据中,用训练集拟合模型及需要学习参数的预处理,在验证集预测上选阈值,然后把固定规则用于测试集。整个过程都应保持相关蛋白质的分组约束。阈值调优指南明确提醒,不要用同一批记录同时拟合分类器和调整阈值。
脚本还报告了预先规定的 0.5 参考阈值。它在测试集选中 10 条记录,其中 4 条阳性。这能展示取舍,但不能据此在看过测试结果后改用 0.5。如果测试结果影响了模型或规则,就应使用新的留出数据进行下一次最终评估。
4. 把平均精确率与候选名单一起看
平均精确率 average precision,简称 AP,用多个阈值上的表现概括排序质量。这里调用 average_precision_score,其计算方式不同于对精确率—召回率曲线做梯形积分。报告时写明具体指标,不要把两种算法都笼统标为 PR-AUC。计算定义见 AP 文档。
两份测试报告的 AP 都是 0.547,因为底层测试分数没有改变。调整决策阈值会改变入选名单,不会改变排序。这个人为示例把两个问题分开了:阳性是否倾向于排在前面,以及某个具体筛选规则能否产生有用的一批候选。
报告 AP 和精确率时,也记录阳性比例;这里是 0.080。全部预测为阴性的规则,准确率为 0.920,召回率却是零,找不到任何候选。若实际候选池的组成或实验判定规则不同,也不能用本例的精确率估计其命中率。
本例虽然把分数设在零到一之间,但它们是任意构造的分数,不是校准过的概率。使用真实二分类器时,应通过 model.classes_ 确认 predict_proba() 哪一列对应标签 1。不要把嵌入向量的某一维、余弦相似度或任意决策分数直接解释成概率。
5. 替换成真实预测时检查这些数据
通过稳定的样本 ID 对齐分数与标签;pandas 关联教程演示了严格的对应检查。将验证集标签和分数传入 choose_threshold(),保存返回值,测试报告直接使用这个值。
保存模型标识、数据集与划分版本、阳性定义、分数方向、精确率要求、最终阈值、>= 比较规则以及同分处理方式。报告 TP、FP、FN、TN、入选数量和独立分组数量。8 个阳性足以演示代码,但不足以精确估计筛选命中率。
| 报错或现象 | 处理方法 |
|---|---|
Labels must contain both 0 and 1 |
检查标签映射和划分中的类别组成。本示例主动拒绝单一类别输入,不要编造标签绕过检查。 |
Scores must be finite |
定位缺失或无穷大的预测值,修复原因后再评估。 |
No observed threshold meets the precision requirement |
记录该要求在验证集上不可满足。在开发阶段重新考虑模型或要求,不使用测试标签调节。 |
precision=undefined |
当前阈值没有选中任何记录。报告入选数量为零,不要把空名单描述为精确率完美。 |
| 验证集达标,测试集精确率下降 | 如实报告结果和样本数量,检查不确定性或数据差异,不在测试集上重新挑阈值。 |
6. 保存可以复现的决策
在查看测试结果前确定筛选要求,在验证集预测上选择阈值,并随模型一起保存。发布 AP 的同时,给出混淆计数和入选数量,让读者看到这条规则具体选中了什么。
封面为概念插画,并非实验测得的蛋白质结构。文档核对日期:2026 年 9 月 28 日。
- 原文作者:春江暮客
- 原文链接:https://www.bobobk.com/protein-classifier-precision-recall.html
- 版权声明:本作品采用 知识共享署名-非商业性使用-禁止演绎 4.0 国际许可协议 进行许可,非商业转载请注明出处(作者,原文链接),商业转载请联系作者获得授权。