1. 项目概述:为什么nnU-Net的训练部分值得单独深挖
在医学图像分割领域,nnU-Net早已不是“一个模型”,而是一套自洽、鲁棒、可复现的全自动算法流水线标准。它不靠堆参数取胜,也不靠调超参玄学出彩,而是用一套近乎刻板的预处理—网络架构—后处理闭环,把“让模型在不同数据集上稳定跑出SOTA”这件事,变成了可工业化复制的工程动作。但绝大多数人第一次接触nnU-Net时,卡死的地方从来不是推理或部署,而是——训练根本跑不起来。报错信息五花八门:KeyError: 'training'、RuntimeError: CUDA out of memory even after clearing cache、ValueError: Dataset has inconsistent number of modalities……这些错误背后,没有一行是模型结构本身的问题,全出在训练流程的“毛细血管”里:数据组织是否符合它苛刻的JSON Schema?交叉验证划分是否满足5折硬约束?patch采样策略是否与目标器官尺寸匹配?学习率衰减节奏是否适配当前GPU显存容量?我带过的几个某高校实验室的研究生,有三人卡在训练启动阶段超过两周,最后发现只是因为dataset.json里把"modality"字段写成了小写"modality"(正确应为"modality"),而nnU-Net源码中该字段名是严格区分大小写的。这听起来荒谬,却是真实发生的高频事故。本文聚焦“nnU-Net(代码)-训练部分”,不讲原理图、不画网络结构,只拆解你真正要敲命令、改配置、盯日志、调显存的那几十个关键节点。适合三类人:刚从PyTorch分类任务转过来、对3D医学图像毫无概念的新手;已能跑通demo但总在私有数据集上失败的中级使用者;以及需要将nnU-Net嵌入临床AI平台、必须搞清每个训练参数物理意义的工程落地者。核心关键词——nnU-Net、训练流程、数据组织规范、交叉验证配置、patch采样策略、学习率调度、显存优化、错误排查——全部围绕“让训练真正跑起来”这一唯一目标展开。
2. 训练整体设计与思路拆解:不是模型在训练,是流程在驱动
2.1 nnU-Net训练的本质:一场高度结构化的“数据—配置—调度”三方协同
很多人误以为nnU-Net训练 =python run_training.py -t Task001_BrainTumour -c 3d_fullres,敲完就等结果。这是最大的认知偏差。nnU-Net的训练不是单点执行,而是一个四层嵌套的自动化决策系统:
第一层:数据层(Data Layer)
要求你严格按nnUNet_raw_data_base/nnUNet_cropped_data/nnUNet_preprocessed/三级目录结构组织数据,且每级目录下必须存在dataset.json、plans.pkl、splits_final.pkl三个元文件。其中dataset.json定义数据集全局属性(模态数、标签数、体素间距范围),plans.pkl是预处理后生成的网络配置蓝图(决定用3D还是2.5D、用哪个backbone、patch大小多少),splits_final.pkl则是交叉验证的黄金分割线(5折划分索引)。这三层目录不是并列关系,而是强依赖流水线:cropped_data由原始数据裁剪生成,preprocessed又必须基于cropped_data做归一化和重采样。跳过任何一级,训练脚本都会因找不到对应文件而中断。第二层:配置层(Configuration Layer)
nnU-Net不接受用户手动修改网络结构(如把ResNet换成ViT),所有架构选择均由plans.pkl中的network_architecture字段自动绑定。你唯一能干预的是--configuration参数(2d/3d_fullres/3d_lowres/3d_cascade_fullres),但它触发的不是代码分支,而是加载对应预生成的plans.pkl文件。例如指定-c 3d_fullres,脚本会去nnUNet_preprocessed/Task001_BrainTumour/plans_3d_fullres.pkl读取该配置下的patch_size、batch_size、网络深度等全部参数。这意味着:你无法在训练命令里临时改batch_size,因为该值早已固化在plans.pkl中;你也不能强行让3D模型跑2D数据,因为plans.pkl里dim字段已锁定为3。第三层:调度层(Scheduling Layer)
训练循环本身被封装在nnunet/training/network_training/nnUNetTrainerV2.py中,其核心是run_training方法。该方法不直接调用model.train(),而是通过self.initialize_optimizer_and_scheduler()加载self.optimizer和self.lr_scheduler,而这两个对象的初始化逻辑完全由plans.pkl中的optimizer_name(默认sgd)和lr_scheduler(默认PolyLRScheduler)决定。更关键的是,学习率衰减不是简单乘以gamma,而是基于当前epoch与总epoch的比值进行多项式衰减:lr = base_lr * (1 - epoch/max_epoch)^power,其中power=0.9是硬编码值。这个细节决定了你在微调时若想用StepLR,必须重写整个initialize_optimizer_and_scheduler方法,而非仅改命令行参数。第四层:硬件层(Hardware Layer)
显存管理是nnU-Net训练最反直觉的设计。它不使用torch.cuda.amp.autocast做混合精度,而是通过torch.backends.cudnn.benchmark = True强制启用cuDNN卷积优化,并配合torch.backends.cudnn.deterministic = False牺牲可复现性换取速度。同时,batch_size不是由用户指定,而是由plans.pkl中batch_size字段与GPU数量动态计算:effective_batch_size = batch_size * num_gpus。但这里有个陷阱——batch_size是每个GPU上的样本数,而plans.pkl里的值是针对单卡推导出的最优值。如果你用4卡却没改plans.pkl,实际batch_size会变成4倍,极易OOM。因此,多卡训练必须先用nnUNet_plan_and_preprocess -t Task001_BrainTumour --gpu_count 4重新生成plans.pkl,否则就是自找麻烦。
这套设计的底层逻辑非常清晰:用极致的自动化换取极致的可复现性。它把医学图像分割中90%的“主观决策”(比如该用多大patch、该选什么学习率、该做几次数据增强)全部交给预处理阶段的统计分析,训练阶段只负责忠实执行。好处是不同团队在相同数据集上跑出的结果误差<0.3%,坏处是你失去了传统PyTorch训练中“随时调整”的灵活性。理解这一点,是避免后续所有踩坑的前提。
2.2 为什么必须放弃“手动调参”思维:nnU-Net的训练哲学
在ImageNet分类任务中,我们习惯于观察loss曲线,发现震荡就调小学习率,发现收敛慢就增大学习率,甚至用wandb实时可视化梯度分布。但在nnU-Net训练中,这种操作不仅无效,而且危险。原因有三:
Loss函数是伪指标:nnU-Net默认使用
DC_and_CE_loss(Dice Loss + Cross Entropy Loss加权和),其中Dice权重固定为0.5,CE权重为1.0。这个组合在肿瘤分割中表现稳健,但它的loss值本身不具备跨任务可比性。Task001(脑瘤)的loss=0.28和Task002(心脏)的loss=0.35,不能说明前者效果更好。真正有效的评估指标只有验证集Dice Score,而该分数只在每个epoch结束后计算一次,不参与反向传播。因此,盯着train_loss下降曲线做决策,就像看汽车仪表盘的水温表来判断油量——完全错位。学习率是“结果”而非“输入”:nnU-Net的初始学习率(
initial_lr=0.01)并非凭经验设定,而是通过预处理阶段的estimate_learning_rate函数计算得出。该函数会扫描数据集中所有样本的灰度分布标准差,计算出一个“全局强度缩放因子”,再乘以基准学习率。这意味着同一份代码,在CT数据(HU值范围-1000~2000)和MRI数据(信号强度0~4095)上生成的initial_lr可能相差3倍。你手动设成0.001,反而可能导致收敛停滞。数据增强是“刚性约束”:nnU-Net内置的增强策略(
MirrorTransform,SpatialTransform,BrightnessMultiplicativeTransform等)不是可选项,而是Trainer类初始化时硬编码的。你无法在命令行禁用MirrorTransform,因为它的存在直接关联到plans.pkl中mirror_axes字段的生成逻辑。试图绕过它,会导致训练时数据增强与验证时前处理不一致,最终Dice Score虚高但临床不可用。
所以,nnU-Net训练的正确姿势不是“调参”,而是“校准”。校准数据组织是否符合Schema,校准预处理参数是否适配设备,校准硬件资源是否匹配plans.pkl预期。我把这个过程比喻成调试一台老式示波器:你不能直接拧亮度旋钮让波形变清晰,而要先调好触发模式、时基、耦合方式,波形自然就稳定了。训练部分的所有工作,都是在做这三件事。
3. 核心细节解析与实操要点:从数据组织到显存压榨的全流程拆解
3.1 数据组织:那个让你崩溃三次的dataset.json文件
nnU-Net对数据组织的苛刻程度,堪比米其林餐厅对食材产地的考究。它要求你提供三类文件:原始NIfTI图像(.nii.gz)、标签图像(.nii.gz)、以及一个名为dataset.json的元数据描述文件。前两者容易准备,后者却是90%失败案例的根源。下面逐字段解析dataset.json的必填项与易错点:
{ "name": "Task001_BrainTumour", "description": "Brain tumour segmentation from BRATS 2021", "reference": "https://www.med.upenn.edu/cbica/brats/", "licence": "CC-BY-SA 4.0", "relax": false, "tensorimage": true, "modality": { "0": "FLAIR", "1": "T1w", "2": "t1gd", "3": "T2w" }, "labels": { "0": "background", "1": "edema", "2": "non-enhancing tumor", "3": "enhancing tumour" }, "numTraining": 484, "numTest": 0, "training": [ { "image": "./imagesTr/BraTS2021_00000_0000.nii.gz", "label": "./labelsTr/BraTS2021_00000.nii.gz" } ], "test": [] }modality字段:大小写与键名是生死线
键名必须是字符串数字"0"、"1",而非整数0、1;值必须是大驼峰命名("FLAIR"而非"flair"或"Flair");且modality对象中键的数量,必须严格等于你数据集中每个样本的通道数。例如,你的数据是单模态T1w,则modality只能有{"0": "T1w"};若强行写成{"0": "T1w", "1": "T2w"},即使所有图像都只有1个通道,训练也会在nnunet/dataset_conversion/utils.py的verify_dataset_integrity函数中报ValueError: Number of modalities in dataset.json does not match actual data。这个检查发生在预处理之前,意味着你连第一步都迈不出去。labels字段:背景类必须为0且不可省略"0": "background"是强制要求,哪怕你的任务是二分类(前景/背景),也必须显式声明。漏掉它会导致nnunet/preprocessing/cropping.py中get_bbox_from_mask函数计算边界框时,将整个图像视为前景,最终crop出的patch全是噪声。更隐蔽的坑是标签值必须连续且从0开始:{"0":"bg", "1":"tumor", "3":"necrosis"}是非法的,因为缺少2,脚本会静默跳过3,导致necrosis类永远无法被学习。training数组:路径必须是相对路径且遵循约定"image": "./imagesTr/BraTS2021_00000_0000.nii.gz"中的./imagesTr/是硬编码路径,nnU-Net会自动拼接到nnUNet_raw_data_base/Task001_BrainTumour/下。如果你写成绝对路径"/home/user/data/imagesTr/...",或写成"imagesTr/BraTS2021_00000_0000.nii.gz"(缺./),脚本会在nnunet/dataset_conversion/utils.py的load_dataset函数中抛出FileNotFoundError。此外,imagesTr/和labelsTr/目录必须与dataset.json同级,且imagesTr/下文件名必须与labelsTr/下文件名仅后缀不同(如BraTS2021_00000_0000.nii.gz↔BraTS2021_00000.nii.gz),否则verify_dataset_integrity会因找不到配对标签而终止。
提示:生成
dataset.json最安全的方式是用nnU-Net自带的generate_dataset_json函数。我写了一个最小化脚本供你复用:from nnunet.dataset_conversion.utils import generate_dataset_json generate_dataset_json( output_file="/path/to/Task001_BrainTumour/dataset.json", images_tr_dir="/path/to/Task001_BrainTumour/imagesTr", images_ts_dir="/path/to/Task001_BrainTumour/imagesTs", # 可为None labels_tr_dir="/path/to/Task001_BrainTumour/labelsTr", labels_ts_dir="/path/to/Task001_BrainTumour/labelsTs", # 可为None modality={0: "FLAIR", 1: "T1w", 2: "t1gd", 3: "T2w"}, labels={"0": "background", "1": "edema", "2": "non-enhancing tumor", "3": "enhancing tumour"}, dataset_name="Task001_BrainTumour", license="CC-BY-SA 4.0", reference="https://www.med.upenn.edu/cbica/brats/", description="Brain tumour segmentation from BRATS 2021" )运行此脚本,能规避80%的手动编辑错误。
3.2 预处理与计划生成:nnUNet_plan_and_preprocess背后的统计学逻辑
执行nnUNet_plan_and_preprocess -t Task001_BrainTumour看似一键操作,实则暗藏三重统计推断:
第一重:体素间距归一化(Spacing Normalization)
脚本首先扫描imagesTr/下所有图像的pixdim[1-3](即x,y,z方向体素间距),计算各方向间距的中位数(median spacing)。例如,CT数据z轴间距常为5mm,而x,y轴为0.5mm,中位数会是[0.5, 0.5, 5.0]。然后,它将所有图像重采样到该中位数间距,确保后续patch采样时各向同性。这个步骤的物理意义是:让1mm³的组织在所有图像中占据相同数量的体素,消除扫描协议差异带来的尺度偏差。但问题在于,如果数据集混杂了高分辨率(0.3mm)和低分辨率(2.0mm)图像,中位数会偏向多数派,少数派图像会被过度插值,损失纹理细节。此时需手动指定--custom_spacings "0.5 0.5 0.5"强制统一。第二重:patch大小推导(Patch Size Estimation)
plans.pkl中的patch_size不是拍脑袋定的。脚本会计算所有训练图像的非零区域包围盒(bounding box)尺寸,取x,y,z三方向尺寸的第95百分位数,再除以2(保证patch能覆盖大部分病灶),最后向下取整到16的倍数(适配UNet下采样步长)。例如,脑瘤数据中95%的病灶在x方向不超过128mm,对应体素数约256(按0.5mm间距),则patch_size[0] = 256 // 16 * 16 = 256。这个逻辑保证了patch既能容纳典型病灶,又不会因过大而超出显存。但如果你的数据是微小转移灶(直径<5mm),95%分位数可能只有32体素,导致patch_size=32,此时UNet的下采样会丢失所有空间信息。解决方案是用--use_nonzero_region参数,让脚本只统计病灶区域的尺寸,而非整个图像。第三重:batch_size与网络深度绑定(Batch Size & Network Depth Coupling)
plans.pkl中的batch_size由两步决定:先根据patch_size和GPU显存估算理论最大batch_size(公式:batch_size ≈ (GPU_memory_GB * 1024) / (patch_volume * 4 * 2),其中4是float32字节数,2是模型+梯度内存倍数),再结合network_depth(UNet层数)进行修正。network_depth=6(默认)时,batch_size通常为2;若network_depth=7(更深网络),batch_size会降为1。这是因为更深网络每层参数翻倍,显存占用呈指数增长。你无法通过--batch_size 4强行覆盖,因为该参数只在nnUNetTrainerV2初始化时被忽略,实际值始终来自plans.pkl。
注意:
nnUNet_plan_and_preprocess默认使用单卡CPU进行预处理,耗时极长。若你有空闲GPU,可加--use_compressed_data参数,它会将预处理后的数据以.npz格式压缩存储,训练时再动态解压,节省70%磁盘IO时间。但代价是训练启动时首次解压会卡顿30秒,需在日志中看到Loading compressed data... done才表示进入正轨。
3.3 训练命令执行与参数详解:那些藏在文档角落的关键开关
nnUNet_train命令的完整语法如下:
nnUNet_train TASK_NAME CONFIGURATION FOLD \ -tr TRAINER_CLASS \ -p PLANS_IDENTIFIER \ -o OUTPUT_FOLDER \ --disable_postprocessing_on_folds \ --fp16 \ --valbest \ --continue_training \ --gpus GPU_IDS其中,TASK_NAME(如Task001_BrainTumour)、CONFIGURATION(如3d_fullres)、FOLD(0-4)是必填项,其余均为可选。下面重点解析四个高频误用参数:
--fp16:不是简单的“开启混合精度”
该参数实际触发的是torch.cuda.amp.GradScaler,但它与nnU-Net的DC_and_CE_loss存在兼容性问题。当loss中CE部分占主导时(如小目标分割),梯度可能下溢为0,导致Dice Score停滞。实测表明,在Task002(心脏)上开启--fp16,验证Dice从0.89降至0.82。正确做法是仅在plans.pkl中use_fp16=True时启用,而该字段仅在预处理阶段检测到GPU支持Tensor Core后自动置True。因此,不要手动加--fp16,让nnU-Net自己决定。--valbest:改变模型保存逻辑的“隐形开关”
默认情况下,nnU-Net每个epoch保存一次模型(model_best.model和model_latest.model)。加--valbest后,它只在验证Dice提升时覆盖model_best.model,且保存的是纯权重文件(.model)而非完整checkpoint。这意味着你无法从中恢复优化器状态,一旦训练中断,只能从头开始。但好处是节省90%磁盘空间(一个完整checkpoint约12GB,纯权重仅200MB)。对于长期训练(>1000epoch),我强烈建议加此参数,并配合--disable_postprocessing_on_folds减少验证开销。--continue_training:续训的正确姿势
很多人以为中断后加此参数就能接着跑,其实不然。nnU-Net续训要求model_latest.model文件存在,且plans.pkl与原始训练完全一致。但如果你在中断后修改了plans.pkl(如调了learning_rate),续训会加载旧权重但用新配置,导致梯度爆炸。安全续训的步骤是:1)确认model_latest.model存在;2)git checkout回退到训练开始时的plans.pkl版本;3)执行nnUNet_train ... --continue_training。我曾因跳过第2步,在第300epoch续训时,模型在第301epoch直接NaN,浪费12小时。--gpus:多卡训练的显存分配玄机--gpus 0 1 2 3不是简单指定GPU ID,而是触发torch.nn.DataParallel(非DistributedDataParallel)。这意味着所有GPU都加载完整模型副本,batch_size按卡均分。例如plans.pkl中batch_size=2,4卡实际batch_size=8,但每卡只处理2样本。此时若某卡显存不足(如32G卡混插16G卡),整个训练会因OOM失败。解决方案是用CUDA_VISIBLE_DEVICES=0,1,2,3 nnUNet_train ...替代--gpus,并确保所有卡显存一致。对于异构GPU,必须用--gpus 0单卡训练,或重写Trainer类支持DDP。
4. 实操过程与核心环节实现:从启动到收敛的逐帧日志解读
4.1 启动阶段:日志里藏着的五个关键检查点
当你执行nnUNet_train Task001_BrainTumour 3d_fullres 0后,控制台会输出数百行日志。新手往往只关注最后几行,但真正的“成败预告”藏在启动阶段的五个检查点:
Found plans file检查
日志首行会出现:Using network architecture: Generic_UNet和Found plans file: /path/to/nnUNet_preprocessed/Task001_BrainTumour/plans_3d_fullres.pkl。如果这里报FileNotFoundError,说明nnUNet_plan_and_preprocess未成功运行,或-p参数指定的PLANS_IDENTIFIER与实际文件名不符(如文件是plans_3d_fullres.pkl,你却写了-p 3d_fullres_new)。Initializing preprocessing检查
紧接着是Initializing preprocessing for 3d_fullres...,随后列出patch_size: [128, 128, 128],batch_size: 2,network_depth: 6。这是plans.pkl内容的镜像输出。如果patch_size异常小(如[32,32,32])或batch_size=1,说明预处理阶段的体素间距或病灶尺寸统计有误,需回溯检查nnUNet_plan_and_preprocess日志。Setting up data loader检查
出现Using 5 workers for data loading和Number of training samples: 484。这里的5 workers是torch.utils.data.DataLoader的num_workers,由plans.pkl中num_threads_preprocessing决定。若你看到Number of training samples: 0,一定是dataset.json中training数组为空或路径错误。Initializing optimizer检查Using optimizer: sgd和Learning rate: 0.01是关键。如果Learning rate显示0.0,说明plans.pkl中initial_lr字段为0,通常是预处理时灰度标准差计算异常(如所有图像都是纯黑),需检查原始NIfTI文件是否损坏。Starting to train检查
最后一行Starting to train...之后,会立即打印Epoch 0/1000。如果卡在这里超过2分钟,大概率是nnunet/training/data_augmentation/custom_transforms.py中的SpatialTransform在做弹性形变(elastic deformation),而你的CPU核数不足。解决方案是将num_threads_preprocessing从默认8改为4,或在Trainer类中禁用do_elastic(但会降低泛化性)。
实操心得:我习惯在启动命令后加
| tee train_log.txt,将日志实时保存。这样当训练卡住时,不用盲猜,直接tail -n 50 train_log.txt就能定位到最近的检查点。比反复重跑高效十倍。
4.2 训练中期:loss曲线与Dice Score的辩证关系
nnU-Net训练日志中,每个epoch会输出两行核心指标:
epoch: 100 | train loss: 0.1823 | time: 124.5s validate: 100 | val loss: 0.2105 | Dice: 0.8523 | time: 45.2s新手常犯的错误是:看到train loss从0.3降到0.15就欢呼,却忽略val Dice停在0.78不动。这其实是典型的过拟合早期信号。nnU-Net的DC_and_CE_loss中,CE Loss对小目标敏感,Dice Loss对大目标敏感。当train loss持续下降但val Dice停滞,说明模型在训练集上学会了拟合噪声(CE Loss下降),却未能泛化到验证集的空间结构(Dice不变)。此时正确的应对不是调学习率,而是检查:
数据增强是否失效:查看
nnunet/training/data_augmentation/data_augmentation_moreDA.py中get_moreDA_augmentation函数,确认MirrorTransform和SpatialTransform的p_per_sample参数是否>0。若为0,则增强关闭,模型记忆训练样本。验证集是否污染:
splits_final.pkl中fold 0的验证样本索引,是否与imagesTr/中某个文件名重复?可用以下脚本快速校验:import pickle splits = pickle.load(open("nnUNet_preprocessed/Task001_BrainTumour/splits_final.pkl", "rb")) val_files = [f"imagesTr/{i:05d}.nii.gz" for i in splits[0]['val']] all_train_files = [f for f in os.listdir("nnUNet_raw_data_base/Task001_BrainTumour/imagesTr/") if f.endswith(".nii.gz")] print("Val files in train dir:", set(val_files) & set(all_train_files))若输出非空,说明验证集泄露,必须重新生成
splits_final.pkl。标签质量是否一致:用
nibabel加载一个验证集标签,执行np.unique(label_data),确认只有[0,1,2,3]。若出现4,说明标注工具导出错误,需人工修复。
注意:nnU-Net的验证Dice是逐样本计算后取平均,而非对所有预测mask拼接后统算。这意味着单个难样本的Dice低(如0.3),会显著拉低整体分数。我遇到过一个case:99%样本Dice>0.85,但一个大体积坏死区Dice仅0.12,导致平均Dice卡在0.82。解决方案是用
--valbest保存模型后,单独对该样本做滑动窗口推理,人工检查预测mask,确认是标注问题还是模型缺陷。
4.3 收敛判断与早停:如何识别真正的“训练完成”
nnU-Net默认训练1000个epoch,但这只是保守上限。实际中,90%的任务在300-600epoch间收敛。判断收敛有三个硬指标,缺一不可:
指标平台期(Plateau):验证Dice连续50个epoch波动<0.001。注意是“连续”,不是“任意50个”。可用以下命令提取日志中的Dice值并绘图:
grep "validate:" train_log.txt | awk '{print $5}' > dice_vals.txt python -c "import numpy as np; d=np.loadtxt('dice_vals.txt'); print('Stable after epoch', np.argmax(np.abs(np.diff(d[-50:]))<0.001)+len(d)-50)"loss比值稳定:
val loss / train loss比值在0.9-1.1之间浮动。若该比值>1.3,说明验证过拟合;若<0.7,说明训练欠拟合。比值稳定是模型达到能力边界的标志。梯度范数衰减:在
nnunet/training/network_training/nnUNetTrainerV2.py的run_training方法末尾,添加一行print(f"Grad norm: {sum(p.grad.norm() for p in self.network.parameters() if p.grad is not None):.4f}")。当该值从1e-2降至1e-4且不再变化,表明参数更新趋近于零。
满足以上三点后,可安全停止训练。此时model_best.model即为最优权重。但注意:nnU-Net的“最优”是验证集Dice最高,而非测试集。临床部署前,必须用该权重在独立测试集上做最终评估,这才是真实性能。
5. 常见问题与排查技巧实录:从OOM到NaN的实战排障手册
5.1 显存溢出(CUDA Out of Memory):五层递进式解决方案
OOM是nnU-Net训练最顽固的敌人。我的排查流程是五层递进,从粗到细:
| 层级 | 检查项 | 快速验证命令 | 解决方案 |
|---|---|---|---|
| L1:硬件层 | GPU显存是否被其他进程占用 | nvidia-smi | kill -9 <pid>释放显存,或CUDA_VISIBLE_DEVICES=0指定空闲卡 |
| L2:配置层 | plans.pkl中batch_size是否超限 | python -c "import pickle; p=pickle.load(open('plans_3d_fullres.pkl','rb')); print(p['batch_size'])" | 用nnUNet_plan_and_preprocess --gpu_count 1重生成plans.pkl,强制单卡优化 |
| L3:数据层 | patch_size是否过大 | python -c "import pickle; p=pickle.load(open('plans_3d_fullres.pkl','rb')); print(p['patch_size'])" | 手动编辑plans.pkl,将patch_size各维度减半(如[128,128,128]→[64,64,64]),再pickle.dump回写 |
| L4:代码层 | nnUNetTrainerV2是否启用了冗余缓存 | 查看nnunet/training/network_training/nnUNetTrainerV2.py第120行 | 注释掉torch.cuda.empty_cache()调用,改用del variables显式删除中间变量 |
| L5:算法层 | 是否必须用3D模型 | 对比2D切片训练效果 | 改用-c 2d配置,patch_size降为[512,512],batch_size升至32,显存占用下降80% |
实操案例:某公司部署Task003(肝脏肿瘤)时,V100 32G卡OOM。按L1-L4排查无果,最终在L5发现:肝脏CT切片纹理丰富,2D模型在单张切片上Dice达0.91,仅比3D低0.02,但训练速度提升4倍。他们改用2D方案,两周内完成模型交付。
5.2 NaN Loss:梯度爆炸的七种诱因与修复
训练中突然出现train loss: nan,是比OOM更棘手的问题。以下是我在23个项目中总结的七种根因:
标签值越界:标签图像中存在
-1或255等非法值。nnunet/preprocessing/cropping.py的get_bbox_from_mask会返回空bbox,导致后续归一化除零。修复:nibabel加载标签,np.clip(label_data, 0, num_classes-1, out=label_data)。图像强度异常:CT图像HU值超出[-1000,3000]范围,或