春江暮客

春江暮客的个人学习分享网站

蛋白质分类器如何选阈值:用 Python 检查精确率与召回率

2026-09-28 技术
蛋白质分类器如何选阈值:用 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 日。

友情链接

其它