一次医学分割评测偏差排查

Date 2026-05-03 · Category tech · Status finished · Confidence likely
论文解读, 医学影像, 图像分割

问题从异常指标开始

一次医学分割评测偏差排查 阅读导航图

最近做医学图像分割对比实验,数据集是 CVC-ClinicDB,共 612 张息肉内窥镜图像。跑完十几个模型后,结果明显不对:

多数模型的 Dice 都接近 0.98,部分经典基线也高得离谱。

CVC-ClinicDB 不算特别困难,但也不应该让所有模型都接近满分。这个信号足够明确:先不要急着写结论,应该回头查评测流程。


第一层问题:验证集被当成测试集

配置文件里只有 train/val 二划分,没有独立测试集:

pre_split: false
train_split_file: "train.txt"   # 489 张
val_split_file: "val.txt"       # 123 张
# 没有 test_split_file

测试脚本的逻辑也很直接:

pre_split = data_config.get("pre_split", False)
split = "test" if pre_split else "val"
split_file = data_config.get("test_split_file") if pre_split else data_config.get("val_split_file")

pre_split=false 时,测试阶段实际加载的是 val.txt。问题在于,训练时的 early stopping 和 best checkpoint 也依赖同一批验证集。

训练:train(489) → val(123) 选 best checkpoint
测试:加载 best checkpoint → 再评测同一批 val(123)

这不是训练集泄漏到测试集,但它会把模型选择偏差带进最终指标。尤其在小数据集上,这个偏差不能忽略。


第二层问题:测试集意外包含了全部数据

我把数据改成三划分,计划对齐论文常见的 392/98/122:

Train samples: 392
Val samples: 98
Test samples: 612   # 应该是 122

612 是全量数据。也就是说,测试集没有按 test.txt 过滤,而是把整个数据目录都扫进来了。

定位到 MedicalImageDataset 后,问题很清楚:

# 修复前
if split in ("train", "val"):
    self._split_data()

split="test" 时,_split_data() 不会执行,test_split_file 也就完全失效。

修复后需要让 test 也进入 split 过滤逻辑:

if split in ("train", "val", "test"):
    self._split_data()

同时,test 只应该按文件列表过滤,不应该继续参与随机比例划分:

def _split_data(self):
    if self.split_file is not None:
        # 读取 split_file,并过滤 samples
        pass

    if self.split == "test":
        return

    # ratio-based split 只用于 train/val

另一个顺手修掉的问题是:test_model.py 虽然声明了 --config 参数,但实际没有使用。这样旧 checkpoint 里的旧配置会继续生效。

# 修复前
model, config = load_checkpoint(args.checkpoint, device)

# 修复后:允许外部 YAML 覆盖数据配置
if args.config is not None:
    with open(args.config, "r") as f:
        override_config = yaml.safe_load(f)
    config["data"] = override_config.get("data", config["data"])
    config["augmentation"] = override_config.get("augmentation", config["augmentation"])
    config["inference"] = override_config.get("inference", config["inference"])

第三层问题:训练数据量不一致

原来的二划分与论文三划分并不等价:

划分方式 训练集 验证集 测试集
原二划分 489 123 无独立测试集
论文三划分 392 98 122

训练集多了约 25%。在 612 张的小数据集上,这会明显影响结果。即使没有代码 Bug,仅凭更多训练数据和 val=test,也不能直接和三划分论文比较。


三个问题叠加后的影响

这次指标虚高不是单点原因,而是三件事叠在一起:

1. val = test:模型选择偏差
2. 训练集多 25%:训练条件不同
3. test split Bug:测试集包含全量数据

所以真正的问题不是“模型突然变强”,而是评测协议发生了变化。分割任务里指标差 1% 都可能被认真比较,6% 甚至 10% 的虚高足以让结论完全失真。


Val=Test 到底算不算数据泄漏?

我更倾向于把它称为“评测偏差”,而不是最严重意义上的训练数据泄漏。

它的问题在于:验证集参与了模型选择。每轮训练都会在验证集上打分,然后选择 Dice 最高的 checkpoint。数学上,这相当于在多个 epoch 中取最大值:

只要你不断观察同一批验证集,并从中选最好的模型,最终分数就会偏乐观。

但在医学图像分割里,情况又比较复杂。很多小数据集样本有限,历史论文也常用固定验证集报告结果。我的判断是:

使用场景 是否可接受
只比较同一划分下的相对排名 可以,但要说明协议
报告绝对指标 需要提醒可能偏高
与三划分论文对比 不建议
投稿或正式 benchmark 应使用独立测试集

一句话:val=test 不是不能出现,但不能和独立测试集结果混在一起比较。


修复方式

以 CVC-ClinicDB 为例,先生成固定三划分:

import random

all_ids = list(range(1, 613))
random.seed(42)
random.shuffle(all_ids)

train_ids = sorted(all_ids[:392])
val_ids = sorted(all_ids[392:392 + 98])
test_ids = sorted(all_ids[392 + 98:])

for name, ids in [("train.txt", train_ids), ("val.txt", val_ids), ("test.txt", test_ids)]:
    with open(name, "w") as f:
        f.write("\n".join(map(str, ids)) + "\n")

再更新配置:

data:
  pre_split: true
  train_split_file: "train.txt"
  val_split_file: "val.txt"
  test_split_file: "test.txt"

如果只是快速复核旧模型,也可以用旧 checkpoint 加新配置评测独立测试集:

python test_model.py \
  --checkpoint results/best_model.pt \
  --config configs/config_cvc_clinicdb.yaml

复盘清单

这次排查后,我给实验代码加了一组最基础但很有用的检查:

  • 打印 train/val/test 的样本数,并与 split 文件行数核对。
  • 检查三个集合是否有交集。
  • 测试脚本必须明确显示当前使用的 split 文件。
  • checkpoint 配置和外部覆盖配置要有清晰优先级。
  • 论文对比时记录完整评测协议,而不只记录最终 Dice。

最终修复项如下:

类型 内容
数据集 Bug _split_data()split="test" 生效
测试脚本 Bug --config 可以覆盖 checkpoint 中的数据配置
数据划分 CVC-ClinicDB 改为 392/98/122 三划分
风险项 Kvasir-Seg、PH2、MMOTU 仍需检查 val=test

最稳妥的做法,是同时报告独立测试集结果和跨数据集泛化结果。前者保证公平比较,后者更接近模型真实可用性。


See also