1. 医学影像实验复现的挑战与工程化思维

在医学影像人工智能研究中,"复现"往往比"创新"更难。同一份代码、同一批数据,在不同实验室、不同时间点跑出的Dice系数可能相差5到10个百分点。这种差异并非来自算法本身,而是来自数据、设备、标注和运行环境四个层面的系统性变异。理解这些变异的来源,并建立以版本控制、环境隔离、流水线自动化为核心的工程化实验管理思维,是让实验结果可被自己和他人在数月后重新获得的前提。

一、复现失败的四个真实来源

数据异质性是最容易被低估的一环。以肺部CT为例,同一批"公开数据集"中,层厚可能从0.625 mm到5 mm不等,重建核可能是肺算法或标准算法,像素间距在0.6~0.9 mm之间浮动。若预处理阶段统一重采样到1 mm×1 mm×1 mm,不同原始层厚带来的部分容积效应差异依然存在,肺结节边界的灰度梯度会明显不同。更隐蔽的是窗宽窗位:有的数据以HU值存储,有的已被截断到[-1024, 3071]之外,直接归一化到[0,1]会引入非线性压缩。

设备差异体现在扫描参数与重建链上。GE、Siemens、Philips、Canon四家CT的噪声功率谱不同,同一体模在相同剂量下的图像纹理存在可测量的差异。当训练集以某一厂商为主、测试集混入其他厂商数据时,模型性能下降往往不是过拟合,而是域偏移。MRI更复杂:场强(1.5T vs 3T)、线圈通道数、序列参数(TR/TE/TI)都会改变信号强度分布,而DICOM中的RescaleSlope/Intercept在不同设备上语义并不完全一致。

标注变异直接决定监督信号的质量。同一批肺结节,三位放射科医师勾画的边界在磨玻璃成分区域可能相差2~3 mm;若以Dice衡量,观察者间一致性可能只有0.75~0.85。如果实验只报告单一标注者的结果,复现者换用另一位标注者的标签,指标波动会掩盖算法改进的真实幅度。标注协议是否规定"包含胸膜牵拉"、"是否纳入血管贴边"等边界规则,必须在数据卡中明确记录。

运行环境的隐性影响同样不可忽视。PyTorch 1.12与2.0在相同随机种子下,由于CUDA卷积算法选择策略变化,结果可能有微小差异;cuDNN的benchmark模式会引入非确定性;甚至同一显卡在不同驱动版本下的浮点累加顺序都可能改变。若实验未固定随机种子、未关闭非确定性算子、未记录依赖版本,"复现"就退化为"重跑"。

医学影像实验复现的变异来源与工程化控制 四个变异源 → 三类工程化手段 → 可复现的实验产物 数据异质性 层厚 0.625~5 mm 重建核 / 窗宽窗位 / HU截断 设备差异 厂商噪声谱 / 场强 / 线圈 RescaleSlope 语义不一致 标注变异 观察者间 Dice 0.75~0.85 边界规则未写入数据卡 运行环境 cuDNN 非确定性 / 驱动版本 随机种子未固定 工程化实验管理三支柱 版本控制(数据/代码/模型) · 环境隔离(容器/依赖锁定) · 流水线自动化(可重跑) 可复现实验产物 数据卡 层厚/厂商/标注协议 DICOM 标签快照 环境锁文件 requirements.lock CUDA/cuDNN 版本 流水线脚本 预处理→训练→评估 固定随机种子 实验记录 指标+配置 可追溯

二、工程化思维:把"实验"当作"产品"来管理

面对上述变异,仅靠"仔细记录"远远不够。工程化的核心是把一次实验视为一个可交付、可追溯、可重建的产物,用软件工程中已经成熟的三种手段来约束它。

版本控制要覆盖三类对象。代码用Git管理,但必须同时记录commit hash与分支;数据用DVC或Git-LFS管理,至少记录数据集的哈希指纹与划分文件(train/val/test的样本ID列表),因为随机划分本身就是复现差异的一大来源;模型权重与配置文件一并归档,配置文件建议用YAML而非命令行参数,便于diff。一个实用做法是在训练脚本启动时自动生成run_manifest.json,写入git commit、数据哈希、随机种子、GPU型号、CUDA版本和完整超参数。

环境隔离的目标是让"在我机器上能跑"变成"在任何机器上都能跑"。推荐使用Docker或Apptainer(集群环境)封装运行环境,基础镜像固定到具体tag(如pytorch/pytorch:2.1.0-cuda12.1-cudnn8-runtime),Python依赖用pip-compile生成带哈希的锁文件。对于需要GPU的实验,在镜像中显式声明CUDA与cuDNN版本,并在实验记录中保存nvidia-smi输出。若无法使用容器,至少用conda env export --no-builds导出环境,并记录驱动版本。

流水线自动化要求从原始DICOM到最终指标的每一步都可由脚本一键重跑。典型结构是:01_preprocess.py(重采样、归一化、缓存为npy/npz)→ 02_split.py(固定种子划分)→ 03_train.py(训练并保存checkpoint)→ 04_evaluate.py(加载最佳权重计算Dice/HD95)。每一步的输入输出路径写入配置,中间产物带版本号。这样当复现者发现预处理参数不同时,只需修改配置重跑,而不必手工干预。

示例:一个最小可复现实验的配置片段

# config.yaml
seed: 42
data:
  root: /data/lung_ct_v1
  manifest: splits_v1.csv      # 固定划分,含 patient_id, split
  spacing: [1.0, 1.0, 1.0]     # 重采样目标间距 (mm)
  hu_window: [-1000, 400]      # 肺窗截断
model:
  arch: unet3d
  in_channels: 1
  base_features: 32
train:
  batch_size: 4
  lr: 1e-4
  epochs: 200
  amp: true
env:
  cudnn_deterministic: true    # 关闭非确定性算子
  cudnn_benchmark: false

需要强调的是,cudnn_deterministic: true会牺牲一定速度,但在复现场景下是必要的。若实验目标是追求极致性能而非严格复现,可以关闭它,但必须在记录中注明"非确定性模式",并报告多次运行的指标均值与标准差。

三、从"能跑通"到"可复现"的判断标准

如何判断一个实验是否达到了工程化复现的要求?可以用三个可检验的问题来评估。第一,换一台机器、换一个时间点,能否在不询问原作者的情况下,仅凭仓库中的脚本和文档,得到与论文/报告一致的指标?第二,当指标出现偏差时,能否快速定位是数据、环境还是代码变更导致的?第三,实验的中间产物(预处理缓存、checkpoint、日志)是否带有足够的元信息,使得半年后自己也能看懂?

如果三个问题的答案都是肯定的,说明版本控制、环境隔离和流水线自动化已经形成了闭环。反之,若实验依赖手工点击、依赖未记录的本地路径、依赖"我记得当时改了这里",那么复现失败只是时间问题。医学影像实验的工程化,本质上是用可验证的流程替代不可验证的记忆,让每一次实验都成为可被继承的资产,而不是一次性的消耗。