
1. 从一次模型评估的困惑说起sensitivity 到底在算什么很多人在深度学习项目里第一次接触 sensitivity 这个词是在模型评估报告或者医学影像分类的论文里。我最初也一样看到别人代码里写sensitivity TP / (TP FN)第一反应是这不就是 recall 吗然后随手用 sklearn 的recall_score一算发现数值对得上就觉得这事没什么好深究的。直到有一次做多分类的病理切片项目评估指标里同时出现了 sensitivity、specificity、PPV、NPV 四个值我才意识到自己之前对这个概念的理解有多粗糙。Sensitivity中文通常叫敏感度或者召回率Recall在二分类混淆矩阵里定义为真正例除以实际正例的总数。它回答的问题是在所有真正属于正类的样本中模型成功找出了多少这个指标在医学筛查、缺陷检测、风控拦截这类场景里极其关键因为漏掉一个正样本的代价往往远高于误报一个负样本。比如癌症初筛你宁可把健康人误判为高风险去做进一步检查也不能把真正的患者判为低风险放回家。但问题在于PyTorch 本身并没有像 sklearn 那样提供一个开箱即用的sensitivity函数。PyTorch 的定位是张量计算和自动求导框架评估指标的计算需要你自己动手。这就导致很多人在 PyTorch 项目里算 sensitivity 时写法五花八门有的用 sklearn 的接口有的手写混淆矩阵有的在多分类场景下直接算错。这篇文章就是把我这些年在这个看似简单的指标上踩过的坑、总结的写法、以及不同场景下的取舍完整地梳理一遍。不管你是刚入门 PyTorch 的学生还是已经做过几个项目的从业者只要你的工作涉及分类模型的评估尤其是对漏检敏感的任务这篇内容都能给你一套可以直接复用的方案。我会从最基础的手写实现讲起逐步过渡到多分类、批量累积、GPU 张量处理、以及和 sklearn 结果对齐的验证方法中间穿插那些文档里不会写的注意事项。2. 手写 sensitivity 的第一版为什么你的实现可能在悄悄出错2.1 最朴素的二分类实现及其隐患先看一个最直觉的写法。假设你有一个 batch 的预测结果preds和真实标签targets都是 0/1 的二值张量import torch def sensitivity_naive(preds, targets): tp ((preds 1) (targets 1)).sum().item() fn ((preds 0) (targets 1)).sum().item() return tp / (tp fn)这段代码逻辑上没问题但它有几个隐藏的坑。第一个坑是除零如果这个 batch 里一个正样本都没有tp fn就是 0直接抛ZeroDivisionError或者返回nan。在训练早期或者类别极度不平衡的数据集里这种情况比你想象的常见。第二个坑是数据类型如果你的preds是概率值经过 sigmoid 之后的浮点数preds 1永远为 False结果全错。必须先做阈值化比如preds (probs 0.5).long()。第三个坑更隐蔽preds和targets的设备不一致。如果你在 GPU 上做推理preds在 cuda 上而targets还在 cpu 上这个比较操作会直接报错。我见过不少人在验证循环里忘了把 target 搬到 GPU训练时没事一到评估就崩。提示任何手写指标函数的第一行都应该先做输入校验——检查形状是否一致、设备是否一致、是否需要阈值化。这三件事花不了几行代码但能省掉大量调试时间。2.2 用混淆矩阵统一计算四个指标与其每次单独算 sensitivity不如一次性把混淆矩阵的四个量都算出来这样 specificity、precision、F1 都能顺手得到。下面这个写法是我在项目里用得最多的import torch def confusion_matrix_binary(preds, targets): preds: 已经阈值化后的 0/1 张量 targets: 0/1 张量 返回 tp, fp, tn, fn preds preds.view(-1).long() targets targets.view(-1).long() tp ((preds 1) (targets 1)).sum().item() fp ((preds 1) (targets 0)).sum().item() tn ((preds 0) (targets 0)).sum().item() fn ((preds 0) (targets 1)).sum().item() return tp, fp, tn, fn def metrics_from_cm(tp, fp, tn, fn, eps1e-8): sensitivity tp / (tp fn eps) specificity tn / (tn fp eps) precision tp / (tp fp eps) npv tn / (tn fn eps) return { sensitivity: sensitivity, specificity: specificity, precision: precision, npv: npv, }这里加了一个eps防止除零这是工程上的常规做法。但要注意eps会让结果略微偏离真实值在样本量很大时可以忽略在样本量很小的时候要心里有数。如果你需要严格精确的结果应该用条件判断而不是加 eps。.view(-1)这一步也很关键。实际项目里preds可能是[B, 1]或者[B, H, W]的形状直接比较虽然也能得到逐元素的布尔张量但.sum()出来的维度可能不符合预期。统一拉平成一维是最省心的做法。2.3 阈值选择对 sensitivity 的直接影响Sensitivity 不是一个固定值它强依赖于你选择的分类阈值。同一个模型阈值取 0.5 和取 0.3sensitivity 可能差十几个百分点。这一点在写评估代码时必须有清醒认识。举个实际例子。假设某二分类模型输出的概率分布中正样本的概率大多集中在 0.4 到 0.7 之间负样本集中在 0.1 到 0.5 之间。如果你用默认的 0.5 做阈值会漏掉大量概率在 0.4 附近的真正例sensitivity 偏低。把阈值降到 0.35sensitivity 立刻上升但 specificity 会下降。这就是所谓的ROC 曲线上的权衡。所以在报告 sensitivity 时一定要同时说明阈值是多少。我见过一些项目报告只写sensitivity 达到 0.92却不提阈值这个数字几乎没有参考价值。正确的做法是要么固定一个业务上有意义的阈值并说明理由要么报告 AUC 或者绘制完整的 ROC 曲线。def sensitivity_at_threshold(probs, targets, threshold): preds (probs threshold).long() tp ((preds 1) (targets 1)).sum().item() fn ((preds 0) (targets 1)).sum().item() return tp / (tp fn 1e-8)如果你想看不同阈值下的 sensitivity 变化可以扫一遍阈值thresholds [i / 100 for i in range(10, 91, 5)] for t in thresholds: sens sensitivity_at_threshold(probs, targets, t) print(fthreshold{t:.2f}, sensitivity{sens:.4f})这段代码在调阈值的时候非常实用能让你直观看到敏感度随阈值的变化曲线从而选出一个业务上可接受的平衡点。3. 多分类场景下的 sensitivitymacro、micro 与 weighted 的取舍3.1 多分类 sensitivity 的本质是每个类各算各的二分类的 sensitivity 定义很清晰但到了多分类事情就变得微妙了。假设你有 5 个类别sensitivity 到底指什么答案是对每一个类别把它当作正类其余所有类别当作负类分别计算 sensitivity然后再用某种方式聚合。这个某种方式就是 macro、micro、weighted 的区别所在。先看单类的计算。对于类别 cdef per_class_sensitivity(preds, targets, num_classes): preds, targets: [N] 的整数张量取值 0..num_classes-1 返回每个类别的 sensitivity 列表 result [] for c in range(num_classes): tp ((preds c) (targets c)).sum().item() fn ((preds ! c) (targets c)).sum().item() sens tp / (tp fn 1e-8) result.append(sens) return result这段代码是理解多分类 sensitivity 的基础。注意fn的定义真实是 c 但预测不是 c 的样本数。这等价于把 c 当正类其他都是负类。3.2 三种聚合方式的差异与适用场景拿到每个类的 sensitivity 之后聚合方式决定了最终报告的数字聚合方式计算逻辑特点适用场景Macro各类 sensitivity 的算术平均每类权重相同受小类影响大类别重要性均衡关注小类表现Micro全局 TP 除以全局 (TPFN)等价于整体准确率的一种形式样本量差异大关注整体Weighted按各类样本数加权平均大类主导反映整体分布类别不平衡关注主流类别Micro 平均在多分类里有个特殊性质全局 TP 除以全局正例总数实际上等于整体准确率因为每个样本要么被正确分类要么被错误分类TP 总和就是正确分类数TPFN 总和就是总样本数。所以如果你只报告 micro sensitivity其实就是在报告 accuracy信息量有限。Macro 平均则相反它对每个类一视同仁。如果某个小类只有 10 个样本模型全预测错这个类的 sensitivity 是 0macro 平均会被明显拉低。这在医学罕见病分类里是好事因为罕见类的漏检代价高你希望指标能反映出来。Weighted 平均是最温和的它按样本数加权大类说了算。在类别极度不平衡时weighted sensitivity 可能看起来很漂亮但小类的表现被掩盖了。def multiclass_sensitivity(preds, targets, num_classes, averagemacro): per_class per_class_sensitivity(preds, targets, num_classes) if average macro: return sum(per_class) / num_classes elif average weighted: counts torch.bincount(targets, minlengthnum_classes).float() weights counts / counts.sum() return sum(s * w for s, w in zip(per_class, weights)).item() elif average micro: tp_total (preds targets).sum().item() return tp_total / len(targets) else: raise ValueError(funknown average: {average})3.3 一个容易算错的细节argmax 之后别忘了对齐多分类里最常见的错误来源是预测张量和标签张量的形状不匹配。模型输出通常是[B, C]的 logits你需要先argmax(dim1)得到[B]的预测类别。而标签可能是[B]或者[B, 1]。如果标签是[B, 1]直接和[B]的预测比较会广播成[B, B]结果完全错误而且不会报错非常隐蔽。logits model(x) # [B, C] preds logits.argmax(dim1) # [B] targets targets.view(-1) # 确保是 [B]而不是 [B, 1] assert preds.shape targets.shape, shape mismatch!我强烈建议在评估函数里加一句assert把形状检查显式化。这个习惯帮我省过至少两次深夜调试。另外如果标签是 one-hot 编码需要先转成类别索引targets targets.argmax(dim1)。这个转换在数据加载阶段就该做好不要留到评估函数里否则容易在多个地方重复转换导致混乱。4. 把 sensitivity 集成进训练循环累积、设备与数值稳定性4.1 为什么不能每个 batch 单独算再平均这是新手最容易犯的错误之一。很多人写验证循环时对每个 batch 算一个 sensitivity然后把这些值平均起来当作整个验证集的 sensitivity。这个做法是错的。原因很简单sensitivity 是一个比值比值的平均不等于总体的比值。假设第一个 batch 有 100 个正样本sensitivity 是 0.9第二个 batch 只有 5 个正样本sensitivity 是 0.2。直接平均得到 0.55但真实的整体 sensitivity 是 (901)/(1005) ≈ 0.867。差距巨大。正确的做法是累积混淆矩阵的四个量最后一次性计算def evaluate(model, dataloader, device, threshold0.5): model.eval() tp fp tn fn 0 with torch.no_grad(): for x, y in dataloader: x x.to(device) y y.to(device).view(-1).long() logits model(x) probs torch.sigmoid(logits).view(-1) preds (probs threshold).long() tp ((preds 1) (y 1)).sum().item() fp ((preds 1) (y 0)).sum().item() tn ((preds 0) (y 0)).sum().item() fn ((preds 0) (y 1)).sum().item() sens tp / (tp fn 1e-8) spec tn / (tn fp 1e-8) return {sensitivity: sens, specificity: spec, tp: tp, fp: fp, tn: tn, fn: fn}这个模式可以推广到多分类累积一个[C, C]的混淆矩阵最后从矩阵里读出每个类的 TP 和 FN。def evaluate_multiclass(model, dataloader, device, num_classes): model.eval() cm torch.zeros(num_classes, num_classes, dtypetorch.long) with torch.no_grad(): for x, y in dataloader: x x.to(device) y y.to(device).view(-1).long() preds model(x).argmax(dim1) for t, p in zip(y.view(-1), preds.view(-1)): cm[t.long(), p.long()] 1 return cm拿到混淆矩阵cm之后第 c 类的 TP 是cm[c, c]FN 是cm[c, :].sum() - cm[c, c]。这个写法清晰且不易出错。4.2 GPU 张量与 Python 标量的性能陷阱上面代码里我用了.item()把张量转成 Python 标量再累加。这样做在验证集不大时没问题但如果你的验证集有几十万样本每个 batch 都做多次.item()会触发 GPU 到 CPU 的同步显著拖慢速度。优化的做法是在 GPU 上累积张量最后统一转一次tp torch.tensor(0, devicedevice) # ... tp ((preds 1) (y 1)).sum() # 循环结束后 tp tp.item()这样整个验证循环里只有最后一次同步。对于大规模评估这个改动能带来肉眼可见的加速。不过要注意GPU 上的整数张量累加不会溢出int64 范围极大不用担心。4.3 数值稳定性eps 该加在哪里前面几处我都用了 1e-8来防除零。这里要展开说一下 eps 的正确用法。eps 应该加在分母上而不是分子或整个分式上。tp / (tp fn eps)是对的tp / (tp fn) eps是错的后者在除零时仍然会崩。另外eps 的大小要合理。1e-8 对于 float32 来说接近精度极限对于 float64 来说太小。如果你用 float64 计算建议用 1e-12。但更稳妥的做法是显式判断def safe_divide(numerator, denominator): if denominator 0: return float(nan) # 或者返回 0取决于业务约定 return numerator / denominator返回nan还是 0 是个业务决策。在医学场景里如果某个类一个样本都没有sensitivity 无定义返回nan更诚实能提醒你数据有问题。返回 0 则会误导人以为模型表现差。5. 和 sklearn 对齐验证你手写实现的正确性5.1 用 recall_score 做交叉验证自己写的 sensitivity 函数到底对不对最可靠的验证方式是拿 sklearn 的recall_score对一遍。sklearn 的 recall 就是 sensitivity这是官方定义。from sklearn.metrics import recall_score, confusion_matrix import numpy as np # 假设 preds_np, targets_np 是 numpy 数组 sk_sens recall_score(targets_np, preds_np, pos_label1) my_sens sensitivity_naive(torch.from_numpy(preds_np), torch.from_numpy(targets_np)) assert abs(sk_sens - my_sens) 1e-6, fmismatch: {sk_sens} vs {my_sens}多分类时sklearn 的recall_score支持averagemacro、micro、weighted三种模式正好和前面讲的三种聚合对应。用同一份数据跑一遍结果应该完全一致。如果对不上八成是聚合方式理解错了或者类别标签的映射有问题。我建议在项目里专门写一个test_metrics.py用随机生成的小数据把自写函数和 sklearn 对一遍。这个测试跑起来只要几秒钟但能在你改动评估逻辑时立刻发现回归。5.2 一个真实的对不上的案例有一次我写的 macro sensitivity 和 sklearn 差了 0.02排查了半天最后发现是类别标签不连续。我的数据里标签是[0, 1, 3]跳过了 2。我的per_class_sensitivity里用range(num_classes)遍历把不存在的类别 2 也算进去了它的 sensitivity 是 0因为没有任何样本拉低了 macro 平均。而 sklearn 只对实际出现的类别求平均。修复方法有两种要么确保标签连续在数据预处理阶段做 label remapping要么在计算时只遍历实际出现的类别present_classes torch.unique(targets).tolist() per_class [per_class_sensitivity(preds, targets, c) for c in present_classes] macro_sens sum(per_class) / len(present_classes)这个坑很典型尤其是在多任务或者合并多个数据集的项目里标签不连续是常态。养成检查torch.unique(targets)的习惯。5.3 混淆矩阵的排列顺序陷阱sklearn 的confusion_matrix默认按标签的排序顺序排列行列而不是按你想象的顺序。如果你的标签是字符串或者非连续整数行列顺序可能和你的预期不符。用labels参数显式指定顺序cm confusion_matrix(targets_np, preds_np, labels[0, 1, 2, 3])这样cm[i, j]就明确表示真实是 i预测是 j。自己手写混淆矩阵时也要注意这个约定别把行列搞反了。我见过有人把cm[pred, true]和cm[true, pred]混用导致 sensitivity 和 specificity 算反报告出来的数字完全误导。6. 进阶场景不平衡数据、Top-K 与多标签下的 sensitivity6.1 类别极度不平衡时的处理策略在缺陷检测这类任务里正样本可能只占千分之一。这时候 sensitivity 的计算本身没问题但评估的稳定性会受影响。如果验证集里只有 20 个正样本sensitivity 的分辨率就是 0.05波动很大。应对策略有几个。第一增大验证集确保正样本数量足够经验上至少几百个。第二报告置信区间用 bootstrap 重采样估计 sensitivity 的方差def bootstrap_sensitivity(preds, targets, n_boot1000): n len(targets) sens_list [] for _ in range(n_boot): idx np.random.randint(0, n, n) s sensitivity_naive(preds[idx], targets[idx]) sens_list.append(s) return np.mean(sens_list), np.percentile(sens_list, [2.5, 97.5])第三同时报告 PR 曲线。在极度不平衡时PR 曲线比 ROC 曲线更能反映模型的实际表现因为 ROC 的 FPR 在负样本极多时会被稀释。6.2 Top-K sensitivity 在推荐与检索中的应用有些任务不要求模型给出唯一的预测而是给出 K 个候选只要真正例出现在前 K 个里就算命中。这就是 Top-K sensitivity也叫 recallK。def topk_sensitivity(logits, targets, k5): logits: [N, C] targets: [N] _, topk_preds logits.topk(k, dim1) # [N, k] hit (topk_preds targets.view(-1, 1)).any(dim1) return hit.float().mean().item()这个指标在推荐系统里是标配。注意topk_preds targets.view(-1, 1)这一步利用了广播[N, k]和[N, 1]比较得到[N, k]的布尔张量再any(dim1)得到每个样本是否命中。这个写法比循环高效得多。6.3 多标签分类每个标签独立算 sensitivity多标签任务里一个样本可以同时属于多个类别sigmoid 输出每个标签独立的概率。这时候 sensitivity 要对每个标签分别计算def multilabel_sensitivity(probs, targets, threshold0.5): probs, targets: [N, L]L 是标签数 返回每个标签的 sensitivity preds (probs threshold).long() tp ((preds 1) (targets 1)).sum(dim0).float() fn ((preds 0) (targets 1)).sum(dim0).float() return tp / (tp fn 1e-8) # [L]返回的是一个长度为 L 的向量每个元素是对应标签的 sensitivity。如果只要一个总体数字可以对 L 求平均macro或者按标签频率加权weighted。多标签场景下标签之间的相关性很强单独看每个标签的 sensitivity 往往比看平均值更有诊断价值。注意多标签的 threshold 通常不用 0.5而是对每个标签单独调优。因为不同标签的先验概率差异很大统一阈值会让稀有标签的 sensitivity 极低。7. 我在实际项目里总结的几条经验先说一个反直觉的观察sensitivity 高不一定代表模型好但 sensitivity 低一定说明模型在漏检。所以在优化模型时如果业务对漏检零容忍就把 sensitivity 当作硬约束在满足它的前提下再去优化其他指标。我做过的一个工业质检项目客户明确要求 sensitivity 不低于 0.98我们就是先卡这个线再在剩下的模型里挑误报率最低的。第二条经验是关于阈值调优的时机。不要在训练过程中频繁调阈值那样会让验证指标失去可比性。正确做法是训练结束后在独立的验证集上扫一遍阈值选定一个然后固定下来去测试集上评估。如果测试集上还要再调那说明验证集和测试集分布不一致这是另一个需要排查的问题。第三条是关于日志记录。评估函数返回的字典里除了 sensitivity 本身一定要把 TP、FP、TN、FN 四个原始计数也记下来。我吃过亏某次报告里 sensitivity 是 0.95看起来很漂亮后来发现验证集里只有 20 个正样本TP19这个 0.95 的置信度极低。如果当时记了原始计数一眼就能看出问题。现在我的评估函数一律返回完整计数写进日志或者 TensorBoard方便回溯。第四条是关于多分类的类别映射。如果你的项目里类别标签经过多次转换原始标签、remap 后的标签、模型输出索引一定要在代码里维护一份映射表并且在评估时用同一份。我见过因为映射表不一致导致 sensitivity 算到别的类上去的事故排查起来非常痛苦因为数字看起来正常只是对应错了类别。最后一条关于和业务方沟通 sensitivity 的含义。技术人员觉得 sensitivity 就是 recall没什么好解释的。但业务方往往不理解为什么 sensitivity 和 precision 会此消彼长。我的做法是画一张简单的图横轴是阈值纵轴是两个指标让业务方直观看到调阈值的效果然后一起决定取哪个点。这比丢一个数字过去有效得多。这套评估代码我在多个项目里反复用过从二分类到多标签从 CPU 到多卡基本没再出过指标算错的问题。核心就三点累积原始计数而不是平均比值、显式处理除零和形状、用 sklearn 做交叉验证。把这三件事做到位sensitivity 的计算就不会再成为你的困扰。