
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 的价值就在于它把这些脏活累活全部标准化了。这篇文章适合谁看如果你刚拿到一批医学图像数据想快速得到一个靠谱的 baselinennUNet 是最省心的选择。如果你已经用过 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、5nnUNet 会报错。你需要先把标签重新映射成连续的。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 文件里改完之后需要重新训练但通常值得一试。