ARTICLE · INTELLIGENCE

战地情报 · 详情页

来自尧图项目组的一线实战观察与深度解析

nnUNet训练自己的数据:从数据准备到推理部署全流程实战指南

nnUNet训练自己的数据:从数据准备到推理部署全流程实战指南

1. 为什么我最终选择了 nnUNet 来训练自己的数据

如果你正在读这篇文章,大概率你手里已经有一批标注好的医学图像数据,可能是某个器官的 CT 分割、可能是 MRI 上的病灶勾画,也可能是超声或者病理切片。你试过自己搭 U-Net,试过调参调到怀疑人生,试过换损失函数、加注意力模块、改数据增强策略,结果 Dice 系数就是卡在某个数字上不去。这个时候,你大概率会听到一个名字:nnUNet。

nnUNet 不是一个普通的网络结构,它是一整套“框架”。它的全称是 no-new-Net,意思很直白——它不追求发明新网络,而是把现有组件组合到极致,然后靠一套自动化的流程去适配你的数据集。你给它一堆图像和对应的标注,它会自动分析图像尺寸、体素间距、类别数量、类别不平衡程度,然后自动决定用 2D 还是 3D 网络、用多大的 patch、用什么样的归一化方式、用哪种损失函数。说白了,它把“调参”这件事从你手里拿走,交给一套经过大量实验验证的规则。

我第一次用 nnUNet 是在一个腹部多器官分割的任务上。当时自己搭的 3D U-Net 跑了三天,Dice 只有 0.78,换 nnUNet 之后,同样的数据,默认配置,跑完直接到 0.86。这个差距让我意识到,很多时候不是网络不够好,而是预处理、后处理、训练策略这些“脏活累活”没做到位。nnUNet 的价值就在于,它把这些脏活累活全部标准化了。

这篇文章适合谁看?如果你刚拿到一批医学图像数据,想快速得到一个靠谱的 baseline,nnUNet 是最省心的选择。如果你已经用过 nnUNet 但总是卡在数据转换或者训练配置上,这篇文章会帮你把整个流程串起来。如果你只是想了解医学图像分割的工程化实践,nnUNet 也是一个极好的学习样本。接下来我会从数据准备、环境配置、训练流程、常见坑四个维度,把“nnUNet 训练自己的数据”这件事讲透。

2. 数据准备:从原始图像到 nnUNet 能吃的格式

2.1 理解 nnUNet 的数据组织逻辑

nnUNet 对数据的组织方式有非常严格的要求,这是很多人第一次用就卡住的地方。它不直接读你的 NIfTI 文件,而是要求你先按照一套固定的目录结构存放数据,然后运行它提供的脚本进行转换。这套结构的核心思想是:原始数据、预处理数据、训练结果、模型权重全部隔离,互不干扰。

具体来说,你需要准备三个环境变量:nnUNet_raw、nnUNet_preprocessed、nnUNet_results。这三个路径分别存放原始转换后的数据、预处理后的数据、训练输出和最终模型。我强烈建议你在磁盘上单独建一个目录,比如/data/nnunet,然后在下面建三个子目录。不要把它们混在一起,否则后期清理和迁移会非常痛苦。

在nnUNet_raw下面,你需要创建一个数据集文件夹,命名规则是DatasetXXX_名字,其中 XXX 是三位数字,比如Dataset001_Liver。这个文件夹里面必须有两个子文件夹:imagesTr和labelsTr。imagesTr放训练图像,labelsTr放对应的标注。图像和标注的文件名必须一一对应,比如liver_001_0000.nii.gz对应liver_001.nii.gz。注意图像文件名后面的_0000是模态编号,如果你有多个模态,比如 T1 和 T2,那就是_0000和_0001。

这里有一个非常关键的细节:nnUNet 默认要求图像和标注的几何信息完全一致,包括尺寸、体素间距、方向。如果你在标注软件里导出时不小心改了方向矩阵,nnUNet 在转换时可能不会报错,但训练时会出现图像和标注错位的情况。我踩过这个坑,当时标注文件的方向和原图差了 90 度,训练出来的模型预测结果全是旋转的。后来我养成了一个习惯:在转换之前,先用 SimpleITK 或者 nibabel 检查每一对图像的GetDirection()和GetSpacing()是否一致。

2.2 数据集 JSON 文件的编写要点

除了图像和标注,你还需要在DatasetXXX_名字文件夹下放一个dataset.json文件。这个文件描述了数据集的元信息,包括模态名称、标签类别、训练测试划分等。很多人随便写写就过去了,但其实这个文件里的几个字段直接影响训练行为。

channel_names字段定义了每个模态的名称,比如{"0": "CT"}或者{"0": "T1", "1": "T2"}。这个名称只是标识,不影响训练,但建议写清楚,方便后期查看。labels字段定义了标签的类别,背景必须是 0,前景从 1 开始连续编号。如果你的标注里有不连续的标签,比如 1、3、5,nnUNet 会报错。你需要先把标签重新映射成连续的。numTraining字段是训练样本数量,这个数字必须和imagesTr里的文件数量一致,否则转换脚本会报错。

还有一个容易被忽略的字段是file_ending。默认是.nii.gz,但如果你用的是.nrrd或者.mha,需要在这里改。我建议统一用.nii.gz,兼容性最好,压缩率也高。另外,如果你有测试集,可以放在imagesTs文件夹里,但labelsTs不是必须的。如果没有测试集,dataset.json里可以不写test字段。

提示:dataset.json里的labels字段顺序不重要,但键必须是字符串形式的数字。我见过有人写成{"background": 0, "liver": 1},nnUNet 会直接报错。必须写成{"0": "background", "1": "liver"}。

2.3 数据转换与预处理的实际操作

数据转换的命令是nnUNetv2_convert_dataset,但更常用的方式是直接用nnUNetv2_plan_and_preprocess,它会自动完成转换和预处理。不过,我建议分两步走:先用nnUNetv2_convert_dataset把原始数据转成 nnUNet 的内部格式,检查无误后再跑预处理。这样如果转换出错,你不需要重新跑整个预处理流程。

转换完成后,你会在nnUNet_preprocessed/DatasetXXX_名字下面看到几个文件:nnUNetPlans.json、dataset_fingerprint.json、nnUNetData_plans_v2.1_stage0等。dataset_fingerprint.json是 nnUNet 对你数据集的分析结果,包括图像尺寸分布、体素间距分布、类别不平衡比例等。这个文件非常值得一看,它能告诉你 nnUNet 为什么选择了某种配置。比如,如果它发现你的数据体素间距差异很大,它会自动决定是否重采样到统一间距。

预处理阶段最耗时的部分是重采样和归一化。nnUNet 默认使用 Z-score 归一化,对于 CT 数据,它会先做裁剪,把灰度值限制在某个范围内,然后再归一化。这个裁剪范围是根据你的数据集自动计算的,不是固定的。如果你做的是 MRI 数据,nnUNet 会使用不同的归一化策略。这些细节你不需要手动干预,但了解它们有助于你在结果异常时排查问题。

注意:预处理阶段会生成大量中间文件,如果你的数据集很大,nnUNet_preprocessed目录可能会占用几百 GB 甚至上 TB 的空间。提前规划好磁盘容量,别像我第一次那样跑到一半磁盘满了,前功尽弃。

3. 训练配置:从计划文件到实际训练命令

3.1 理解 nnUNet 的 plans 文件

nnUNetPlans.json是 nnUNet 的核心配置文件,它决定了网络结构、训练超参数、数据增强策略等。这个文件是自动生成的,但你可以手动修改。我强烈建议你在第一次训练时不要改任何东西,先用默认配置跑一遍,得到一个 baseline。然后根据结果再决定是否调整。

这个文件里最重要的几个参数是:batch_size、patch_size、spacing、normalization。batch_size和patch_size是相互制约的,patch 越大,batch 就越小。nnUNet 会根据你的 GPU 显存自动估算一个合理的组合。如果你显存不够,它会降低 batch 或者 patch。但有时候自动估算的结果不是最优的,比如它可能给你一个很小的 patch,导致网络看不到足够的上下文信息。这个时候你可以手动调大 patch,同时降低 batch,只要不爆显存就行。

spacing是重采样的目标体素间距。nnUNet 默认会选择数据集中位数的体素间距作为目标。如果你的数据体素间距差异很大,比如有的层厚 1mm,有的 5mm,重采样到统一间距是必须的。但重采样会引入插值误差,尤其是对标注进行重采样时,最近邻插值可能导致小结构丢失。nnUNet 对标注使用最近邻插值,对图像使用三阶样条插值,这是一个经过验证的折中方案。

3.2 训练命令与 GPU 选择

训练的命令是nnUNetv2_train DatasetXXX_名字 配置 折数。配置可以是2d、3d_fullres、3d_lowres、3d_cascade_fullres。对于大多数各向同性的数据,3d_fullres是首选。如果数据层厚很厚,比如 CT 的层厚是 5mm,那么2d可能更合适,因为 3D 卷积在层厚方向上学不到太多信息。3d_lowres和3d_cascade_fullres是级联策略,先低分辨率定位,再高分辨率细化,适合非常大的数据。

折数是指交叉验证的折数,默认是 5 折。你可以只训练其中一折,比如0,用于快速验证。如果要做最终模型,建议训练所有折,然后用nnUNetv2_ensemble做集成。集成通常能提升 1-2 个百分点的 Dice。

GPU 选择方面,nnUNet 默认使用第一块 GPU。如果你有多块 GPU,可以通过CUDA_VISIBLE_DEVICES环境变量指定。我试过用两块 GPU 并行训练不同的折,效率很高。但注意,nnUNet 本身不支持多 GPU 训练同一个模型,它只支持数据并行。如果你想加速单折训练,只能靠更大的显存和更快的 GPU。

提示:训练过程中 nnUNet 会在nnUNet_results目录下生成日志文件,包括训练损失、验证 Dice、学习率变化等。这些日志可以用 TensorBoard 可视化。我习惯在训练开始后每隔一段时间看一下验证 Dice 的曲线,如果它长时间不上升,可能是学习率太大或者数据有问题。

3.3 训练过程中的监控与中断恢复

nnUNet 的训练默认跑 1000 个 epoch,每个 epoch 包含 250 个 iteration。对于中等规模的数据集,3D 全分辨率训练可能需要几天时间。这期间你可能会遇到各种意外:断电、GPU 过热、磁盘写满。nnUNet 支持断点续训,它会定期保存 checkpoint。如果你中断了训练,重新运行同样的命令,它会自动从最新的 checkpoint 恢复。

但有一个坑:如果你修改了 plans 文件或者数据集,checkpoint 可能不兼容。这个时候你需要删除nnUNet_results下对应的训练目录,重新开始。我建议在训练开始前,把 plans 文件和 dataset.json 备份一份,免得后期想复现时找不到原始配置。

监控训练过程还有一个实用技巧:用nvidia-smi查看 GPU 利用率和显存占用。如果 GPU 利用率长期低于 50%,说明数据加载是瓶颈。这个时候你可以增加num_workers,或者把预处理后的数据放到更快的磁盘上,比如 SSD。我试过把数据从机械硬盘移到 NVMe SSD,训练速度提升了将近 30%。

4. 推理与后处理:从模型到最终结果

4.1 推理命令与滑动窗口策略

训练完成后,推理的命令是nnUNetv2_predict。你需要指定输入图像文件夹、输出文件夹、模型路径、配置和折数。nnUNet 默认使用滑动窗口推理,窗口大小就是训练时的 patch 大小,步长是 patch 大小的一半。这种策略可以覆盖整幅图像,同时保证边界区域有足够的上下文。

滑动窗口推理的一个关键参数是step_size,默认是 0.5。如果你把它调小,比如 0.25,推理会更精细,但速度会慢很多。对于大多数任务,0.5 已经足够。如果你发现预测结果在窗口边界处有明显的拼接痕迹,可以尝试调小 step_size。另外,nnUNet 支持测试时增强,也就是对输入图像做翻转、旋转等变换,然后对预测结果取平均。这个功能可以通过--tta开启,通常能提升一点点精度,但推理时间会翻倍。

4.2 后处理:小连通域去除与类别重映射

nnUNet 的推理结果默认是每个体素一个类别标签。但有时候会出现一些孤立的、很小的预测区域,这些通常是噪声。nnUNet 提供了一个后处理脚本nnUNetv2_apply_postprocessing,可以去除小于某个体积的连通域。这个体积阈值需要根据你的目标结构大小来定。比如肝脏分割,小于 100 体素的连通域基本可以认为是噪声;但如果是小病灶,阈值就要设得很小,甚至不设。

还有一个常见的后处理是类别重映射。如果你的模型输出的是 1、2、3,但你需要的是 1、3、5,可以用nnUNetv2_change_labels脚本进行映射。这个脚本也可以用来合并类别,比如把左右肺合并成一个肺。后处理不是必须的,但合理使用可以明显改善视觉效果和定量指标。

注意:后处理脚本会直接修改输出文件,建议先备份原始预测结果。我见过有人跑完后处理发现阈值设错了,想回退却找不到原始文件,只能重新推理。

4.3 评估指标与结果解读

nnUNet 自带评估脚本nnUNetv2_evaluate_folder,可以计算 Dice、IoU、HD95 等指标。Dice 是最常用的,但它对大类别的分割效果更敏感,小类别可能被忽略。如果你的任务里小结构很重要,建议同时看 HD95 和敏感度。HD95 是 95% 豪斯多夫距离,反映的是边界误差,对小结构更敏感。

评估结果会输出一个 JSON 文件,里面包含每个类别、每个病例的指标。我习惯把 JSON 转成 CSV,然后用 pandas 做统计分析。比如看看哪些病例的 Dice 特别低,然后回去检查这些病例的图像和标注,往往能发现标注质量问题或者图像伪影。这个过程虽然繁琐,但它是提升模型性能最直接的方式。

5. 常见问题与排查技巧实录

5.1 数据转换阶段的典型报错

报错一:FileNotFoundError: No such file or directory。这通常是因为dataset.json里的numTraining和实际文件数量不一致,或者文件名不匹配。检查imagesTr和labelsTr里的文件是否一一对应,注意大小写和后缀。

报错二:ValueError: Label values must be consecutive。这说明你的标注里有不连续的标签。用nnUNetv2_convert_dataset之前,先用 SimpleITK 或者 nibabel 把标签重新映射成连续的。比如把 1、3、5 映射成 1、2、3。

报错三:RuntimeError: Spacing mismatch。图像和标注的体素间距不一致。用sitk.ReadImage读取后,检查GetSpacing()是否相同。如果不相同,用sitk.Resample把标注重采样到图像的间距,插值方式选最近邻。

5.2 训练阶段的显存与速度问题

显存不足是最常见的问题。nnUNet 会自动估算 batch 和 patch,但有时候估算结果仍然会爆显存。这个时候你可以手动修改nnUNetPlans.json里的batch_size和patch_size。一般来说,把 patch 的某一维减小,比如从[128, 128, 128]改成[112, 112, 112],就能省下不少显存。但注意,patch 太小会影响网络感受野,可能导致大结构分割不完整。

训练速度慢的原因有很多。如果 GPU 利用率低,检查数据加载是否用了多进程。nnUNetv2_train默认的num_workers是 8,如果你的 CPU 核心少,可以调小。如果磁盘 IO 是瓶颈,把预处理数据放到 SSD 上。如果 GPU 本身算力不够,那就只能换卡或者减少 patch 大小。

5.3 推理结果异常的排查思路

如果推理结果全是背景,或者全是某个类别,首先检查模型是否真的训练好了。看训练日志里的验证 Dice,如果验证 Dice 也很低,说明训练有问题。如果验证 Dice 正常但推理结果异常,检查推理时的配置是否和训练时一致。比如训练用的是3d_fullres,推理也必须用3d_fullres,不能混用。

如果推理结果在边界处有拼接痕迹,调小step_size。如果结果里有大量小噪声,用后处理去除小连通域。如果结果整体偏移,检查输入图像的几何信息和训练时是否一致。nnUNet 对几何信息非常敏感,方向矩阵、原点、间距任何一个不一致都可能导致预测错位。

5.4 常见问题速查表

问题现象可能原因排查方法解决方案
转换时报文件找不到文件名不匹配或数量不对检查 imagesTr 和 labelsTr统一命名,核对 numTraining
转换时报标签不连续标注标签有跳号用 SimpleITK 读取标签值重新映射为连续标签
训练时爆显存batch 或 patch 太大查看 nvidia-smi手动调小 plans 里的参数
训练速度极慢数据加载瓶颈或磁盘慢看 GPU 利用率增加 workers,换 SSD
推理结果全背景模型未训练好或配置错误看验证 Dice 和推理配置重新训练或统一配置
推理结果有拼接痕迹滑动窗口步长太大观察边界区域调小 step_size
推理结果有小噪声后处理未开启检查输出标签去除小连通域
预测结果整体偏移几何信息不一致对比训练和推理图像统一方向、原点、间距

5.5 我踩过的几个坑与独家建议

第一个坑是标注质量。nnUNet 再强,也救不了垃圾标注。我见过一个数据集,标注里同一个结构在不同切片上边界不一致,有的地方多勾了,有的地方少勾了。nnUNet 训练出来的模型在这种数据上表现很不稳定。后来我花了两周时间重新审核标注,Dice 直接涨了 5 个点。所以,在跑 nnUNet 之前,先花时间检查标注质量,这比调任何参数都管用。

第二个坑是数据划分。nnUNet 默认用 5 折交叉验证,但它是随机划分的。如果你的数据里有来自同一个病人的多个扫描,随机划分会导致同一个病人的数据同时出现在训练集和验证集里,验证 Dice 会虚高。正确的做法是按病人划分,确保同一个病人的所有扫描都在同一折里。nnUNet 支持自定义划分,你可以在dataset.json里指定training和test的病例列表。

第三个坑是学习率。nnUNet 默认的学习率是 0.01,配合多项式衰减。对于大多数任务,这个设置没问题。但如果你的数据集非常小,比如只有几十个病例,默认学习率可能太大,导致训练不稳定。这个时候可以手动把学习率降到 0.001,同时增加 epoch 数。我试过在一个 30 例的数据集上把学习率降到 0.001,训练稳定了很多,最终 Dice 也更高。

第四个坑是集成策略。nnUNet 支持对多折模型做集成,但集成不是万能的。如果某一折的模型特别差,集成反而会拉低整体性能。我建议在集成之前,先看每一折的验证 Dice,把明显差的折去掉。另外,集成的时候可以同时用3d_fullres和2d的模型,这种跨配置集成有时候比单配置集成效果更好。

提示:nnUNet 的官方文档非常详细,但它是英文的,而且有些地方写得比较简略。我建议你在遇到问题时,先去 GitHub 的 issues 里搜一下,大概率有人遇到过同样的问题。另外,nnUNet 的 Discord 频道也很活跃,里面有很多经验丰富的使用者在回答问题。

6. 从 baseline 到生产:nnUNet 的扩展与优化

6.1 自定义网络结构与损失函数

nnUNet 虽然叫 no-new-Net,但它并不阻止你改网络。你可以在nnUNetPlans.json里指定network_class_name,换成你自己实现的网络。比如你想加注意力机制,可以写一个继承自 nnUNet 基础网络的类,然后在 plans 里引用。但要注意,改了网络之后,预训练的权重就不能用了,需要从头训练。

损失函数方面,nnUNet 默认用 Dice + CrossEntropy 的组合。对于类别极度不平衡的任务,比如小病灶分割,你可以把 CrossEntropy 的权重调低,把 Dice 的权重调高。或者换成 Tversky Loss,它对假阳性和假阴性有不同的惩罚系数。这些修改都在 plans 文件里,改完之后需要重新预处理和训练。

6.2 迁移学习与预训练权重

如果你的数据集很小,从头训练 nnUNet 可能效果不好。这个时候可以用迁移学习。nnUNet 提供了一些预训练模型,比如在 Medical Segmentation Decathlon 上训练的模型。你可以用这些权重初始化你的网络,然后在小数据集上微调。微调的时候,建议把学习率调小,比如 0.001,同时冻结浅层,只训练深层。

但迁移学习有一个前提:你的任务和预训练任务要有一定的相似性。比如预训练模型是在腹部 CT 上训练的,你拿来做头部 MRI 分割,效果可能还不如从头训练。我试过用腹部 CT 的预训练模型做肝脏分割,微调后 Dice 比从头训练高了 3 个点。但用同样的模型做脑肿瘤分割,效果就很差。

6.3 模型部署与推理加速

训练好的 nnUNet 模型可以导出为 ONNX 或者 TorchScript,然后集成到你的推理流水线里。nnUNet 本身提供了nnUNetv2_export_model_to_onnx脚本,但导出的模型只包含网络前向,不包含预处理和后处理。你需要自己实现预处理和后处理,确保和训练时一致。

推理加速方面,除了用更快的 GPU,还可以用 TensorRT 或者 OpenVINO 做优化。我试过用 TensorRT 加速 nnUNet 的 3D 模型,推理速度提升了将近 2 倍。但 TensorRT 对动态 shape 的支持有限,nnUNet 的滑动窗口推理需要动态 shape,所以需要做一些适配工作。如果你只是做离线推理,对速度要求不高,直接用 PyTorch 就行。

6.4 持续学习与数据迭代

在实际项目中,数据是不断增加的。今天标注了 50 例,明天可能又标注了 30 例。nnUNet 支持在已有模型的基础上继续训练,但直接继续训练可能会导致灾难性遗忘。更好的做法是把新旧数据合并,重新跑一遍预处理和训练。虽然耗时,但效果最稳定。

如果你不想重新训练,可以用增量学习的方法,比如只训练新数据,但加一个蒸馏损失,让新模型在旧数据上的输出尽量接近旧模型。这种方法在 nnUNet 上需要自己实现,有一定的门槛。我个人的建议是,如果数据量不大,直接重新训练;如果数据量很大,再考虑增量学习。

7. 我个人在实际操作中的体会

nnUNet 最大的价值不是它的网络有多深,而是它把医学图像分割的工程化流程标准化了。你不需要再纠结于用 2D 还是 3D,不需要再手动调归一化参数,不需要再写复杂的数据增强。它把这些东西都封装好了,你只需要把数据准备好,剩下的交给它。

但 nnUNet 也不是银弹。它对数据质量的要求很高,标注不一致、几何信息错位、类别不平衡这些问题,它不能自动解决。我见过很多人把 nnUNet 当成黑盒,数据随便扔进去,结果不好就怪框架。其实问题往往出在数据本身。花时间检查数据,比调任何参数都重要。

最后分享一个小技巧:如果你在训练过程中发现验证 Dice 波动很大,可以尝试把oversample_foreground_percent调高。这个参数控制每个 batch 里前景像素的比例,默认是 0.33。对于小结构分割,把它调到 0.5 甚至 0.7,可以让网络更关注前景,训练更稳定。这个参数在 plans 文件里,改完之后需要重新训练,但通常值得一试。

RELATED READING

延伸阅读

更多一线实战笔记与深度复盘,助您持续精进