
1. 为什么说评价指标选对了主动脉分割项目才算真正落地做医学图像分割的人大概都有过这样一段经历模型在验证集上跑出一个 0.92 的 Dice团队里一片欢腾把结果往报告里一贴觉得这活儿基本收工了。结果把预测结果叠加到原始 CT 上逐层翻看主动脉弓上那几个细小分支断得七零八落降主动脉贴着脊柱的那一段边界像锯齿一样往外毛刺管腔最窄的位置还漏了一小截。这种分数很好看、肉眼看很难受的落差几乎全部来自同一个根源图像分割评价指标选得不够或者选错了。这篇内容聊的就是图像分割评价指标这一整套东西。从最基础的混淆矩阵、Dice、IoU一直到论文里审稿人最爱追问的HD9595% Hausdorff Distance95% 豪斯多夫距离再到 ASSD、MSD 这些表面距离指标最后落到一个具体的场景上——主动脉aorta分割。主动脉是典型的细长管状加分支结构目标相对背景占比小、体素各向异性明显、边界定义本身就有标注分歧它几乎把距离类指标能踩的坑全踩了一遍拿它当例子讲指标比拿一个规规整整的器官有意思得多。适合谁看如果你是刚进医学图像方向、准备写第一篇分割论文的学生这里有一套可以直接跑起来的代码如果你是做工程落地、需要给临床或产品方解释这个模型到底行不行的开发这里有关乎指标口径和报告规范的部分如果你只是想知道 HD95 里那个 95 到底在干什么、为什么要用百分位而不是最大值前面两节会把这个事情说透。我自己的习惯是任何一个分割项目在模型结构定下来之前先把评价脚本写完整。原因很简单指标定义决定了你后面所有的调参方向。你只看 Dice模型就会朝着大目标多猜对一点的方向优化细小分支的断裂它根本不在乎你加上 HD95模型才开始被边界误差惩罚。指标不是给论文凑数的装饰品它是你优化目标的一部分。2. 图像分割评价指标的核心体系逐个拆解2.1 一切从混淆矩阵开始不管指标名字听起来多花哨绝大多数二值分割指标都能从四个数字推出来TP预测为前景、真实也是前景、FP预测前景、真实是背景、FN预测背景、真实是前景、TN都预测为背景。把这四个量摊开就是混淆矩阵。在三维医学图像里这四个数字统计的是体素个数不是像素、不是切片。这一点很容易被忽略尤其是在做切片级推理、最后再拼回三维的时候如果每个切片单独算指标再平均和把整个三维体一次性算结果是不一样的。前者叫 slice-wise 平均后者叫 volume-wise。论文里如果没写清楚复现时经常对不上号。需要提醒的是 TN 在分割任务里的尴尬地位。一张 512×512×300 的 CT主动脉前景体素可能只占千分之几TN 轻轻松松上千万。你拿 TN 去算准确率accuracy永远是 0.99 以上看起来完美实际上模型把所有体素都预测成背景也能拿到这个分数。所以准确率在分割任务里基本没有参考价值除非前景占比极端的另一头——比如几乎整个视野都是目标。这个坑我在早期项目里踩过把 accuracy 写进周报被追问了一句全预测背景是多少当场就哑了。2.2 Dice与IoU重叠率指标的全貌与陷阱Dice 系数Dice Similarity CoefficientDSC是最常见的重叠率指标公式是 2TP / (2TP FP FN)等价于 F1 分数。IoU交并比也叫 Jaccard是 TP / (TP FP FN)。两者的关系是 Dice 2IoU / (1 IoU)单调对应所以报一个就够了但很多论文习惯两个都报实际信息量是重复的。Dice 的直观含义是预测和真值的重叠部分占两者总体积的比例。它对大目标友好对小目标迟钝。举一个主动脉场景里的真实例子整条主动脉大概几十万体素弓上某个分支可能只有两三千体素。分支完全漏掉对整体 Dice 的影响可能只有零点几个百分点肉眼可见的严重错误在分数上几乎看不出来。这就是为什么单看 Dice 会让人产生虚假的安全感。第二个陷阱是空掩码。如果某一张切片或者某个病例里根本没有前景主动脉在某些层确实不出现TP、FP、FN 全是 0公式变成 0/0。不同的库处理方式不同有的返回 0有的返回 1认为都预测对了有的返回 NaN。这个细节在多病例批量评测时必须显式处理否则平均下来结果莫名其妙。我一般的约定是真值为空且预测也为空记为 1真值为空但预测非空记为 0真值非空但预测为空记为 0。并在报告里注明这条规则。第三个陷阱是软标签。模型输出的是 logits 或者概率必须先二值化再算 Dice。阈值取 0.5 是默认做法但在类别极不平衡的场景下0.5 未必最优。我见过有人把概率图直接和 0/1 真值算 Dice得到一堆 0.3 左右的诡异数值排查了半天才发现是忘了 sigmoid 和二值化。2.3 精确率、召回率、特异度不平衡数据下的看家指标精确率Precision TP / (TP FP)回答模型说是前景的体素里有多少真的是前景召回率Recall也叫灵敏度 Sensitivity TP / (TP FN)回答真实前景里有多少被找出来了特异度Specificity TN / (TN FP)回答真实背景里有多少被正确排除了。这三个指标的价值在于它们把错误拆开了。Dice 是一个混合量你看到一个 0.85不知道是过分割还是欠分割。精确率低、召回率高说明模型偏激进到处乱标精确率低、召回率也低那基本是训练没收敛或者数据有问题。在主动脉分割里召回率通常要优先保证——漏掉一段主动脉临床意义远大于多标一点点周围组织后者医生扫一眼就能删掉前者可能影响后续的管径测量和病变定位。特异度在医学场景里经常被写进论文因为它跟假阳性率直接挂钩。对于筛查类任务比如从 CT 里找动脉瘤特异度低意味着大量健康病例被判成阳性后处理工作量爆炸。不过要注意在极端不平衡的数据里特异度会非常接近 1区分度很低报的时候最好同时给出假阳性体素数或者每例平均假阳性个数信息量更大。2.4 Hausdorff距离与HD95从最坏情况到稳健分位数前面几个指标都在数体素属于体积视角。距离类指标换了个视角先把预测和真值的表面提取出来然后量两个表面之间隔多远。Hausdorff 距离的定义是对预测表面上每个点找到它到真值表面的最近距离取最大值反过来对真值表面做同样的操作再取最大值两个方向的最大值再取大。写成公式就是 HD max( max_{a∈A} min_{b∈B} d(a,b), max_{b∈B} min_{a∈A} d(a,b) )。它衡量的是最糟糕的那个点错得有多离谱单位是毫米前提是你传了正确的体素间距。问题在于HD 对离群点极其敏感。标注的时候手法稍微抖一下或者两个标注者在一块模糊边界上意见不同就可能出现一个孤立的假阳性体素直接把这个病例的 HD 顶到几十毫米。整个数据集的平均值就被这一个病例毁掉了。我在一个 40 例的测试集上见过 HD 均值 38mm、中位数 4mm 的情况标准差比均值还大这种数字写进论文没有任何说服力。HD95 就是为解决这个问题设计的把所有表面距离收集起来排序取第 95 百分位扔掉最离谱的那 5%。这样少量离群点不再能主导结果指标变得稳健同时又保留了边界错得远不远这个核心信息。它仍然有单位、有物理意义审稿人一看就知道你在关心边界质量。这里有个必须说清楚的细节95% 到底怎么算不同实现不一样。主流有两种口径。第一种是把预测到真值、真值到预测两个方向的表面距离全部拼在一起取统一的 95 百分位第二种是每个方向各取 95 百分位然后取两者中的较大值。两种口径在大部分情况下数值接近但在方向性差异明显的时候比如一边过分割严重能差出好几毫米。你自己写代码或者复现别人论文时一定要确认用的是哪一种并且在方法部分写清楚。我用得比较多的是分别取 95% 再取 max因为它保留了 Hausdorff 的对称最坏情况语义。2.5 ASSD与MSD平均表面距离的补充视角HD95 关心的是第 95 百分位那个点错多远ASSDAverage Symmetric Surface Distance平均对称表面距离关心的是平均每个表面点错多远。ASSD 就是把两个方向的所有表面距离加起来求平均也叫 ASDAverage Surface Distance。MSDMaximum Surface Distance则是取所有表面距离的最大值本质上就是标准 Hausdorff 距离。这三个指标的组合能刻画边界误差的分布形状。ASSD 小、HD95 大说明绝大部分边界贴得很好只有少数区域崩了——典型情况是分支末端断裂或者某几层完全丢失。ASSD 和 HD95 都大说明整体边界都在漂移可能是定位偏移或者分割结果整体膨胀/收缩。在主动脉这种细长结构上ASSD 有一个天然的问题表面点数量分布不均。主动脉主体表面积大分支表面积小平均下来被主体主导分支的错误又被稀释了。所以如果分支是重点关注对象建议单独把分支区域抠出来算一遍指标或者按解剖分区分别报告。这个做法在论文里很加分因为它说明你真的理解自己的数据。2.6 体积类与其他辅助指标RAVDRelative Absolute Volume Difference相对绝对体积差 |V_pred - V_gt| / V_gt衡量体积偏差。它对形状完全不敏感一个把主动脉整体向外膨胀一圈的预测和一个完美预测只要体积接近RAVD 都很小。但它在评估管径、狭窄程度这类需要绝对体积准确的场景里有价值可以作为 Dice 的补充。还有一类是边界距离的分位数曲线比如 HD50、HD75、HD99。有些工作会一次性报多个分位数画成曲线能看出误差的累积分布。这个做法在方法类论文里越来越常见因为它比单个数字提供了更多信息。另外一个是检出率类的指标比如按连通域算的检测召回真值里有 10 个分支你检出了 8 个检出率 0.8。这个指标在主动脉分支弓上三大分支、腹腔干、肠系膜上动脉、双肾动脉场景里非常实用因为分支漏检是临床最关心的问题而 Dice 对它的惩罚太轻了。我自己在项目里会额外维护一张分支级别的检出表哪个分支漏了几例一目了然。3. 完整代码实现从混淆矩阵到HD95逐行拆解3.1 环境准备与依赖说明整套实现只依赖 numpy 和 scipy不需要装深度学习框架也不需要 GPU。读取 NIfTI 文件用 nibabel如果只是想验证指标逻辑可以直接用 numpy 造数组测试。pip install numpy scipy nibabel如果要用现成的实现做交叉验证还可以装 medpypip install medpy这里有一个注意点medpy 的新版本对 Python 版本有要求老项目锁在 Python 3.7 上装不上新版会退回到一个接口不太一样的旧版本hd95 的参数顺序也可能不同。建议在独立环境里装并且用一个小例子先验证接口行为别直接上批量脚本否则跑了一晚上发现全算错很浪费时间。3.2 重叠类指标的向量化实现先写一个统一的混淆矩阵统计函数。注意二值化这一步很多 bug 都出在这里。import numpy as np def binarize(x, thr0.5): 把概率图或任意取值的标签统一成 0/1 x np.asarray(x) if x.dtype bool: return x return x thr def confusion_counts(pred, gt, thr0.5): pred binarize(pred, thr) gt binarize(gt, thr) tp int(np.count_nonzero(pred gt)) fp int(np.count_nonzero(pred ~gt)) fn int(np.count_nonzero(~pred gt)) tn int(np.count_nonzero(~pred ~gt)) return tp, fp, fn, tn def overlap_metrics(pred, gt, thr0.5, eps1e-7): tp, fp, fn, tn confusion_counts(pred, gt, thr) dice (2 * tp eps) / (2 * tp fp fn eps) iou (tp eps) / (tp fp fn eps) prec (tp eps) / (tp fp eps) rec (tp eps) / (tp fn eps) spec (tn eps) / (tn fp eps) ravd abs(fp - fn) / (tp fn eps) return dict(dicedice, iouiou, precisionprec, recallrec, specificityspec, ravdravd)eps的作用是防止除零但它也带来一个副作用当真值为空、预测也为空时Dice 算出来是接近 1 的数符合我们前面说的约定。但当真值非空、预测为空fn 0, tp 0时dice eps / (fn eps)约等于 0也符合预期。所以这套约定在 eps 的辅助下能自洽工作。如果你需要区分真空空预测和非空空预测的不同语义建议在批量脚本里单独加一层判断别依赖 eps 的副作用。RAVD 这里我用的是 |FP - FN| / GT展开后正好是 |V_pred - V_gt| / V_gt因为 V_pred - V_gt (TPFP) - (TPFN) FP - FN。这个化简在数值上更稳不用单独统计体积。3.3 表面提取与距离变换的核心原理距离类指标的第一步是提取表面第二步是算距离第三步是取分位数。这三步各有各的坑。表面提取的常规做法是对掩码做一次形态学腐蚀然后用原掩码减去腐蚀后的掩码得到的就是最外一层体素。from scipy.ndimage import binary_erosion, generate_binary_structure def extract_surface(mask, connectivity1): mask np.asarray(mask).astype(bool) struct generate_binary_structure(mask.ndim, connectivity) eroded binary_erosion(mask, structurestruct, border_value0) return mask ~erodedconnectivity1是 6 邻域三维connectivity2是 18 邻域connectivity3是 26 邻域。邻域越大腐蚀得越狠表面越薄但漏掉的拐角越多邻域越小表面越完整但可能出现内部孔洞表面。常见的做法是用 1也就是 6 邻域。border_value0这个参数别漏。默认值在边缘处的行为可能导致贴着图像边界的掩码被当作不是表面主动脉如果有一部分被视野截断这个细节会直接影响结果。距离变换用的是distance_transform_edt它计算的是每个非零体素到最近的零体素的欧氏距离。我们想要的表面上的点到另一个表面的最近距离做法是把对方表面取反后做 EDT然后在我们自己的表面位置上采样。from scipy.ndimage import distance_transform_edt def surface_distance_maps(pred, gt, spacing(1.0, 1.0, 1.0), connectivity1): s_pred extract_surface(pred, connectivity) s_gt extract_surface(gt, connectivity) dt_to_gt distance_transform_edt(~s_gt, samplingspacing) dt_to_pred distance_transform_edt(~s_pred, samplingspacing) return dt_to_gt[s_pred], dt_to_pred[s_gt]samplingspacing是关键。不传的话所有方向都按 1 处理距离单位变成体素数在层厚和面内分辨率差得多的 CT 上误差会被放大好几倍。这一段我们在 3.5 节单独算。3.4 HD95的两种实现路线拿到了两个方向的表面距离数组HD95 就是取分位数。def hd95_merged(pred, gt, spacing(1.0, 1.0, 1.0), connectivity1): 两方向距离合并后取 95 百分位 d_p2g, d_g2p surface_distance_maps(pred, gt, spacing, connectivity) if d_p2g.size 0 and d_g2p.size 0: return 0.0 all_d np.concatenate([d_p2g, d_g2p]) return float(np.percentile(all_d, 95)) def hd95_max(pred, gt, spacing(1.0, 1.0, 1.0), connectivity1): 各方向取 95 百分位后再取 max d_p2g, d_g2p surface_distance_maps(pred, gt, spacing, connectivity) if d_p2g.size 0 and d_g2p.size 0: return 0.0 h1 np.percentile(d_p2g, 95) if d_p2g.size else 0.0 h2 np.percentile(d_g2p, 95) if d_g2p.size else 0.0 return float(max(h1, h2)) def assd(pred, gt, spacing(1.0, 1.0, 1.0), connectivity1): d_p2g, d_g2p surface_distance_maps(pred, gt, spacing, connectivity) if d_p2g.size 0 and d_g2p.size 0: return 0.0 return float((d_p2g.sum() d_g2p.sum()) / (d_p2g.size d_g2p.size)) def msd(pred, gt, spacing(1.0, 1.0, 1.0), connectivity1): d_p2g, d_g2p surface_distance_maps(pred, gt, spacing, connectivity) if d_p2g.size 0 and d_g2p.size 0: return 0.0 return float(max(d_p2g.max() if d_p2g.size else 0.0, d_g2p.max() if d_g2p.size else 0.0))注意np.percentile的插值方式默认是线性插值当表面点数很少比如只有十几个体素的极细分支时95 百分位的结果受插值影响会比较明显。想要更保守的话可以指定methodhigher但这么一来结果会和主流库对不上。我一般保持默认然后在方法描述里写清版本和参数。用 medpy 交叉验证的话接口是from medpy.metric.binary import hd95 as medpy_hd95, asd as medpy_asd h medpy_hd95(pred, gt, voxelspacingspacing) a medpy_asd(pred, gt, voxelspacingspacing)medpy 的 hd95 我用下来感觉是偏向 max 那一路口径具体以你装的版本源码为准。建议第一次用的时候拿两个手算得出来的小例子比如两个只差一层的方块对一下数值确认行为符合预期再批量跑。3.5 体素间距对HD95的实际影响与参数计算这一节可能是整篇内容里最容易被忽略、但影响最大的部分。拿一个典型的主动脉 CTA 来说面内重建分辨率常见是 0.7mm×0.7mm 左右层厚常见 1.0mm 到 2.5mm。假设层厚是 2.5mm那么 spacing (0.7, 0.7, 2.5)。现在做个简单的计算。假设预测在某一层上整层漏掉了主动脉相邻层是好的表面点沿 z 方向的偏移就是一层2.5mm。如果同样的情况发生在面内偏移一个像素是 0.7mm。二者相差 3.57 倍。如果你图省事把所有方向都当 1那么沿 z 方向的误差被低估了 2.5 倍2.5mm 记成 1沿 xy 方向的误差被高估了约 1.43 倍0.7mm 记成 1结果是 HD95 这个数字既不是毫米也不是体素数而是一个混合了两种尺度的四不像。更麻烦的是它会系统性地让层间不连续这类错误看起来没那么严重而这恰恰是主动脉分割里最常见的失败模式。我见过一个模型的预测在层间来回跳变隔层断一次Dice 有 0.88看起来还行但把 spacing 正确传进去之后 HD95 直接冲到 20mm 以上问题一下子就暴露了。所以我的建议很直接只要你的数据是各向异性的spacing 必须传而且必须从文件头里读不要手写常量。不同设备、不同重建协议的 spacing 并不一样写死一个值等于给整个评测埋雷。import nibabel as nib def get_spacing(nii_path): img nib.load(nii_path) return tuple(float(z) for z in img.header.get_zooms()[:3])还有一个延伸问题如果不同病例的 spacing 本身就不一致那么每例算 HD95 再平均和把所有表面距离合并后统一算会有差异。前者每例权重相同后者按表面点数加权。对于大小差异大的病例比如有的片段只扫到腹主动脉有的扫了全主动脉这两种口径的结果能差不少。论文里一般报前者per-case mean ± std更符合每个病例同等重要的临床直觉但一定要写清楚。3.6 主动脉分割批量评测脚本把上面的零件拼起来形成一个能直接跑在测试集上的脚本。假设数据组织方式是predictions/case_id.nii.gz和labels/case_id.nii.gz标签里主动脉是 1背景是 0多个分支如果单独标了号需要先合并或者按类别拆分。import os, glob import numpy as np import nibabel as nib from scipy.ndimage import binary_erosion, generate_binary_structure, distance_transform_edt def load_mask(path, label_ids(1,)): img nib.load(path) arr np.asanyarray(img.dataobj) arr np.squeeze(arr) mask np.isin(arr, label_ids) spacing tuple(float(z) for z in img.header.get_zooms()[:3]) return mask, spacing def evaluate_case(pred_path, gt_path, label_ids(1,)): pred, sp_pred load_mask(pred_path, label_ids) gt, sp_gt load_mask(gt_path, label_ids) assert pred.shape gt.shape, fshape mismatch {pred.shape} vs {gt.shape} # 以真值 spacing 为准二者理论上应一致 spacing sp_gt res overlap_metrics(pred, gt) if gt.sum() 0 and pred.sum() 0: res.update(hd950.0, assd0.0, msd0.0) elif gt.sum() 0 or pred.sum() 0: res.update(hd95np.nan, assdnp.nan, msdnp.nan) else: res.update( hd95hd95_max(pred, gt, spacing), assdassd(pred, gt, spacing), msdmsd(pred, gt, spacing), ) res[spacing] spacing res[gt_voxels] int(gt.sum()) res[pred_voxels] int(pred.sum()) return res def evaluate_all(pred_dir, gt_dir, label_ids(1,)): rows [] for gt_path in sorted(glob.glob(os.path.join(gt_dir, *.nii.gz))): case os.path.basename(gt_path).replace(.nii.gz, ) pred_path os.path.join(pred_dir, case .nii.gz) if not os.path.exists(pred_path): print(f[warn] missing prediction: {case}) continue r evaluate_case(pred_path, gt_path, label_ids) r[case] case rows.append(r) keys [dice, iou, precision, recall, hd95, assd, msd, ravd] print(fcases: {len(rows)}) for k in keys: vals np.array([r[k] for r in rows], dtypefloat) vals vals[~np.isnan(vals)] print(f{k:10s}: {vals.mean():.4f} ± {vals.std():.4f} (n{len(vals)})) return rows几个实操层面的建议。第一np.squeeze之后一定要检查维度是不是 3有些预处理脚本会把单例通道留在里面变成 (H, W, D, 1)spacing 就对不上了。第二np.isin处理多标签合并比逐个再取或要快也更不容易写错。第三遇到 shape 不一致直接断言失败不要试图自动 resize那会引入插值误差指标就不可信了。第四缺预测文件的病例要显式打印出来不要静默跳过我吃过这个亏——测试集里少了 3 例平均指标好看了一截对账时才发现。4. 实操中踩过的坑与排查实录4.1 HD95出现NaN、0或天文数字NaN 的常见来源有三个。一是真值为空、预测非空或者反过来表面距离数组一个是空的一个非空百分位算不出来。二是有某一层图像被完全裁掉了前景浮点运算出现 inf。三是用了某个库的实现它在空集上直接返回 nan 而没有文档说明。0 的来源更隐蔽。最常见的错误是extract_surface里的border_value参数没设对导致整个掩码被认为是表面或者不是表面距离变换全 0最后 HD95 就是 0。另一种情况是真值和预测完全一致那 HD95 确实是 0但这时候要回头核对一下是不是不小心拿真值跟真值比了——我在调试脚本时干过这事跑出来一排 0还挺开心后来发现路径变量写反了。天文数字比如几千毫米基本上只有一个原因预测里有一个或几个孤立的假阳性体素离真值特别远。这种情况 HD 会爆表HD95 通常能压住但如果假阳性体素占比超过 5%95 百分位也会被污染。排查方法是先把最大的几个距离值打印出来看位置如果集中在图像边缘或者视野外那多半是模型在无目标区域瞎猜。提示调试阶段建议把表面距离数组存下来画个直方图。95 百分位落在分布的什么位置一眼就能看出这个病例是整体边界漂移还是少数点崩了。4.2 各向异性体素被忽略导致指标失真前面算过层厚 2.5mm、面内 0.7mm 的数据忽略 spacing 会让层间误差被低估 2.5 倍。这个问题最坑的地方在于它不会报错数字看起来也合理只有当你拿另一套正确传入 spacing 的实现去对比才会发现差了一大截。排查方法很简单随便挑一例手动画两个只差一层的方块手动算一下期望的 HD95 应该是 2.5然后看你的函数输出的是 2.5 还是 1。如果不是 2.5spacing 就没生效。还有一种变体spacing 传进去了但顺序错了。nibabel 的get_zooms()返回的是 (x, y, z) 对应的体素尺寸而有些预处理流程会把数组 transpose 成 (z, y, x)这时候 spacing 必须跟着转置。我建议在加载数据的那一层就把 shape 和 spacing 打印出来对一次后面所有环节都基于同一套约定别在不同函数里各转各的。4.3 不同库结果对不上怎么办同一个病例用自己写的 hd95 和用 medpy 的 hd95数值差个零点几毫米到一两毫米是正常的原因可能有表面提取的连通性不同、95 百分位的口径不同合并 vs 分别取 max、边界处腐蚀的行为不同、百分位插值方式不同。差出好几毫米就要警惕了。最常见的原因是其中一个把 spacing 当成 1 了或者两边的 spacing 顺序不一致。其次是标签值的问题——如果你的预测存的是 0/255 而不是 0/1某个库内部做了 0的判断另一个库没做结果就完全不同。我现在的习惯是在评测入口统一做一次binarize所有下游函数只接受 bool 数组从源头掐死这类问题。如果真的需要严格对齐某个已发表的结果最靠谱的办法是把那个库的源码里 hd95 那几十行读一遍别猜。距离类指标的实现细节太多了猜不出来。4.4 多类别与batch聚合的口径问题主动脉分割有时候会把主动脉主体和弓上分支分成不同类别。这时候指标怎么算就有讲究了。几种常见口径micro 平均把所有类别的 TP/FP/FN 加起来算一个总 Dice。大类主导结果。macro 平均每个类别单独算 Dice 再平均。小类别权重被放大能反映分支的表现。per-class 报告每个类别单独列一行最清晰但表格会变长。论文里我更推荐 per-class 报告加一个 macro 平均信息完整也方便读者判断你哪个结构分得不好。只报 micro 平均的话分支崩了 friendly 看不出来容易在审稿意见里被追问。另一个聚合口径问题是先算后平均还是先合并后算。多折交叉验证、多中心数据的时候每一折单独算 Dice 再平均和把所有折的体素合并之后算一个总 Dice数值会不一样。通常报前者但要在方法里写清楚。4.5 常见问题速查表把上面这些整理成一张表出问题的时候对着查比较快。现象最可能的原因排查动作处理方式HD95 为 NaN空掩码或单边为空打印 gt.sum() 和 pred.sum()显式定义空集规则并记录HD95 恒为 0spacing 未生效或表面提取失效用差一层的方块做单元测试修正 spacing / border_valueHD95 上千毫米孤立假阳性体素打印最大距离对应的坐标最大连通域过滤、去除小碎块Dice 高但 HD95 大主体好、边界毛刺或分支断裂分区可视化看误差集中位置加边界损失、形态学后处理与文献数值差很多spacing 单位或顺序不一致打印 shape 与 spacing 对照统一数据加载约定自己实现与库不一致95 百分位口径不同读库源码或小例子对比明确口径并写进方法每例平均与总体差很多病例大小差异大看 gt_voxels 分布两种都报注明口径特异度接近 1 无区分度TN 体素量级过大看每例假阳性体素数改报 FP 体素数或 FPR分支漏检但 Dice 正常小目标被大类稀释按连通域统计检出率增加分支级指标预测形状与真值错位几何配准问题检查仿射矩阵重采样对齐后再评测5. 用指标反推模型问题主动脉分割的调优闭环5.1 指标与误差类型的对应关系指标最大的价值不是排名次是定位问题。几个指标放在一起看能得到相当明确的诊断信息。指标表现对应的误差形态优先处理方向Dice 低 HD95 高 Recall 低整体欠分割目标被漏掉一块检查标签质量、调整损失函数权重、降低阈值Dice 低 HD95 高 Precision 低过分割周围组织被吞进来增加负样本、提高阈值、加难例挖掘Dice 正常 HD95 高主体正确边界毛刺或分支断裂加边界损失、形态学平滑、连通域后处理Dice 正常 ASSD 小 HD95 大绝大部分点很近少数点很远定位离群区域看是否是标注分歧Dice 正常 RAVD 大体积系统性偏大或偏小检查损失函数是否偏向某一方向Recall 高 Precision 低 RAVD 大明显膨胀阈值上调、加入体积约束分支检出率低 主体 Dice 高小结构被忽略分支单独加权、分区域训练这张表我一般是打印出来贴在显示器旁边的。每次跑完一轮实验先不急着调超参对着表看一遍能省掉很多盲目试错。5.2 从HD95高但Dice正常到边界处理这是主动脉分割里最典型的一组组合。Dice 0.90 以上看起来很不错但 HD95 有十几毫米。拆开来看通常是三种情况。第一种是层间断裂。主动脉在 z 方向上是一条连续的管子如果模型在某些层上预测断开断口处的表面点距离就会很大。这种问题在二维切片训练的方式下特别常见因为模型没有层间上下文。解法要么是改三维卷积、要么在推理后做层间插值填补要么加一个沿 z 方向的连续性约束。第二种是分支末端。弓上分支的根部直径可能只有几毫米末端更细模型容易在末端逐渐淡出表面点少但距离远。这种情况要看分支检出率如果确实漏了就得在数据层面处理——检查训练集里分支标注是否完整有些公开数据的分支标注本身就只标到根部模型学不到末端。第三种是标注分歧。主动脉贴着脊柱、心脏、肺组织的地方边界在 CT 上是低对比的不同标注者划出来的位置能差两三个体素。如果测试集是单标注模型学到的可能是平均位置就会有一侧系统性偏移。这种情况 HD95 高但其实模型没错理想做法是找多标注数据算标注者间一致性把它作为 HD95 的下界参考。我在一个项目里做过这个事让两位标注者对同一批 20 例单独标注算他们互相之间的 HD95均值为 3.2mm。然后模型对这个测试集的 HD95 是 4.1mm。这个对比一摆出来说服力比单报一个 4.1 强得多——说明模型已经接近人工标注的波动范围了。论文里如果能量化标注者间一致性审稿人对你的指标数值会更有信心。5.3 论文与报告中指标的规范写法最后聊聊怎么写。指标算对了写不清楚一样吃亏。方法部分至少要交代这几件事用的是哪些指标、每个指标的定义尤其是 HD95 的 95 百分位口径、体素间距怎么来的、空掩码怎么处理、有没有做后处理、指标是在三维体上算的还是切片上算的、多类别用什么平均方式。这些看起来琐碎但复现性全靠它们。结果部分推荐给一张主表列出 Dice、HD95(mm)、ASSD(mm)、Precision、Recall 的均值±标准差如果有分支加一列分支检出率。表格下面注明 spacing 范围和病例数。如果不同病例的 spacing 差异大可以在附录里给出逐例的 spacing 和指标方便读者判断。还有一个我觉得很值得做的给几张典型的失败案例图每张图标注这个病例的 Dice 和 HD95并说明失败原因层间断裂、分支漏检、边界分歧。这种图比任何数字都有说服力读者一眼就能理解你的指标在说什么。最后一个容易被忽略的点报告 HD95 一定要带单位。我审过一些稿子只写HD95: 5.32不说单位读者无法判断这是毫米还是体素。如果做了 spacing 归一化写清楚是 mm如果没做老老实实写体素单位spacing 未归一化至少不误导人。我在实际项目里的体会是指标这套东西的价值不在于算得多花哨而在于它能不能帮你把模型哪里不行这个问题回答清楚。一个能同时告诉你整体重叠不错、边界有毛刺、分支漏了两根的指标组合比十个小数点后四位的单一数字有用得多。至于代码上面这些拼起来就能跑先拿一两个病例手工核对一遍数值确认口径对了再上批量这个顺序别搞反。