一次医学分割评测偏差排查
问题从异常指标开始

最近做医学图像分割对比实验,数据集是 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
- SCRWKV 论文总结:用结构校准 RWKV 做轻量裂缝分割 2026-07-27
- MambaLiteUNet:用 Mamba、双门控与净化跳连做轻量皮肤病变分割 2026-07-26
- UTANet:用任务自适应专家混合重构 U-Net 跳跃连接 2026-07-25
- DSC:让 U 型网络的跳跃连接在测试时适配医学图像 2026-07-23
- TG-HEM:用拓扑困难样本挖掘改进拥挤病理细胞检测 2026-07-22