nnU-Net:自配置医学分割框架,让UNet发挥极致

引言

在前面的文章中,我们学习了众多UNet变种:Attention UNetUNet++TransUNetSwin-UNet等。这些方法通过架构创新不断刷新SOTA。

但在实际应用中,却面临一个尴尬的现实:

“算法论文很酷,但我的任务怎么调参?”

典型研究者的困境:

任务:分割新器官(如前列腺MRI)

问题清单:
❓ Patch size用多大?128? 192? 256?
❓ Batch size设多少?2? 4? 8?
❓ 学习率从何开始?1e-3? 1e-4?
❓ 数据增强用什么?旋转?弹性变形?
❓ 网络深度?3层?4层?5层?
❓ 损失函数?Dice? CE? 组合?

调参周期:
- 尝试1:Dice=0.65(patch太小)
- 尝试2:Dice=0.70(学习率太大)
- 尝试3:Dice=0.75(数据增强不足)
- ...
- 尝试20:Dice=0.82(终于收敛)

耗时:2-3个月(经验+运气)

nnU-Net(no-new-UNet,2018-2021)提出了颠覆性思路:

不是发明新架构,而是自动配置标准UNet

核心理念:

  • 自适应配置:根据数据特性自动调整所有超参数
  • 无需调参:拿到数据,一键运行,达到SOTA
  • 鲁棒性强:在23个医学分割挑战赛中均名列前茅[1]
  • 可复现:消除”调参艺术”,科学且系统

nnU-Net:核心思想

📄 论文信息
作者 Fabian Isensee, et al. (DKFZ, German Cancer Research Center)
发表 Nature Methods (2021)
arXiv 1809.10486

什么是Self-Configuring?

自配置(Self-Configuring):根据数据集属性,自动推断最优超参数。

输入:
- 训练数据(图像 + 标注)
- 数据集指纹(fingerprint):
  ├─ 模态(CT/MRI/...)
  ├─ 分辨率(spacing)
  ├─ 图像尺寸
  ├─ 前景/背景比例
  └─ 类别数量

nnU-Net自动配置:
├─ 预处理(重采样、归一化)
├─ 网络架构(2D/3D/Cascade)
├─ Patch size
├─ Batch size
├─ 训练策略(优化器、学习率)
├─ 数据增强
└─ 后处理

输出:
- 训练好的模型
- 最优配置文件

设计哲学

nnU-Net的三大原则:

  1. No Novelty, Just Engineering
    • 不追求新颖架构
    • 用标准UNet + 最佳实践
  2. Domain Knowledge Rules
    • 利用医学图像的先验知识
    • 针对不同场景选择配置
  3. Empirical > Heuristic
    • 基于大量实验总结规则
    • 非手工设计的启发式

nnU-Net架构

整体流程

┌─────────────────────────────────────────────┐
│  步骤1:数据集分析(Dataset Fingerprint)    │
│  - 提取spacing、尺寸、强度分布等特征        │
└─────────────────────────────────────────────┘
              ↓
┌─────────────────────────────────────────────┐
│  步骤2:规则推断(Rule-based Inference)     │
│  - 根据fingerprint确定配置                  │
└─────────────────────────────────────────────┘
              ↓
┌─────────────────────────────────────────────┐
│  步骤3:三种配置(3 Configurations)         │
│  - 2D UNet                                  │
│  - 3D Full Resolution UNet                  │
│  - 3D Low Resolution + 3D Cascade           │
└─────────────────────────────────────────────┘
              ↓
┌─────────────────────────────────────────────┐
│  步骤4:5折交叉验证训练                      │
│  - 每种配置训练5个模型                      │
└─────────────────────────────────────────────┘
              ↓
┌─────────────────────────────────────────────┐
│  步骤5:集成预测(Ensemble)                 │
│  - 平均多个配置和折的预测                   │
└─────────────────────────────────────────────┘

5步自配置流程

graph LR
    DATA[Raw Dataset] --> FPR["Step 1<br/>Dataset Fingerprint<br/>Spacing/Size/Intensity"]
    FPR --> RULE["Step 2<br/>Rule-based Inference<br/>Patch/Batch/LR"]
    RULE --> CFG["Step 3<br/>3 Configs<br/>2D/3D/Cascade"]
    CFG --> TRAIN["Step 4<br/>5-fold CV × 3<br/>= 15 Models"]
    TRAIN --> ENS["Step 5<br/>Ensemble<br/>Average 15 Models"]

数据集指纹(Dataset Fingerprint)

自动提取的关键特征

def extract_fingerprint(dataset):
    fingerprint = {
        # 基本信息
        'modality': get_modality(dataset),  # CT, MRI, etc.
        'num_classes': count_classes(dataset),
        'num_samples': len(dataset),
        
        # 空间特性
        'median_spacing': np.median([img.spacing for img in dataset], axis=0),
        'median_shape': np.median([img.shape for img in dataset], axis=0),
        'size_reduction': compute_size_reduction_by_cropping(dataset),
        
        # 强度特性
        'intensity_properties': {
            'mean': np.mean(...),
            'std': np.std(...),
            'percentiles': np.percentile(..., [0.5, 50, 99.5])
        },
        
        # 类别特性
        'class_locations': get_class_locations(dataset),
        'foreground_ratio': compute_foreground_ratio(dataset)
    }
    return fingerprint

示例

数据集:Liver Tumor CT
Fingerprint:
{
    'modality': 'CT',
    'num_classes': 3,  # 背景、肝脏、肿瘤
    'median_spacing': [0.7, 0.7, 5.0] mm,  # X, Y, Z
    'median_shape': [512, 512, 130],
    'foreground_ratio': {
        'liver': 0.25,  # 肝脏占25%
        'tumor': 0.02   # 肿瘤占2%
    }
}

→ nnU-Net推断:
  - 使用3D UNet(Z轴spacing大,3D建模重要)
  - Patch size: 128×128×128
  - 强数据增强(肿瘤小,需要增强)
  - 下采样因子: (2, 2, 1)(Z轴保留更多细节)

三种网络配置

nnU-Net为每个数据集训练3种配置,自动选择最优:

2D UNet

class UNet2D(nn.Module):
    """标准2D UNet"""
    def __init__(self, in_channels, num_classes, base_num_features=32):
        super().__init__()
        
        # 编码器
        self.conv1 = StackedConvLayers(in_channels, base_num_features)
        self.conv2 = StackedConvLayers(base_num_features, base_num_features * 2)
        self.conv3 = StackedConvLayers(base_num_features * 2, base_num_features * 4)
        self.conv4 = StackedConvLayers(base_num_features * 4, base_num_features * 8)
        
        # Bottleneck
        self.bottleneck = StackedConvLayers(base_num_features * 8, base_num_features * 16)
        
        # 解码器(省略)
        # ...
    
    def forward(self, x):
        # ... 标准UNet前向传播
        pass


class StackedConvLayers(nn.Module):
    """nnU-Net的标准卷积块"""
    def __init__(self, in_ch, out_ch, kernel_size=3, num_convs=2):
        super().__init__()
        layers = []
        for i in range(num_convs):
            layers.append(nn.Conv2d(
                in_ch if i == 0 else out_ch,
                out_ch,
                kernel_size,
                padding=kernel_size // 2
            ))
            layers.append(nn.InstanceNorm2d(out_ch))  # 使用Instance Norm
            layers.append(nn.LeakyReLU(0.01, inplace=True))
        
        self.convs = nn.Sequential(*layers)
    
    def forward(self, x):
        return self.convs(x)

适用场景

  • Z轴spacing很大(如CT,Z方向分辨率低)
  • 内存受限

3D Full Resolution UNet

class UNet3D(nn.Module):
    """3D UNet(全分辨率)"""
    def __init__(self, in_channels, num_classes, base_num_features=32):
        super().__init__()
        
        # 编码器(3D卷积)
        self.conv1 = StackedConvLayers3D(in_channels, base_num_features)
        self.pool1 = nn.Conv3d(base_num_features, base_num_features, 
                               kernel_size=3, stride=2, padding=1)
        
        # ... 更深的编码器
        
    def forward(self, x):
        # 3D前向传播
        pass

适用场景

  • 各向同性spacing(X/Y/Z分辨率相近)
  • GPU内存充足
  • 需要3D上下文(如MRI)

3D Cascade(级联)

第一阶段:3D Low Resolution UNet
- 输入:下采样的整张图像
- 输出:粗糙的分割mask

第二阶段:3D Full Resolution UNet
- 输入:原始图像 + 第一阶段mask
- 输出:精细化的mask
class Cascade3D:
    def __init__(self):
        self.stage1 = UNet3D(...)  # 低分辨率
        self.stage2 = UNet3D(...)  # 高分辨率
    
    def predict(self, image):
        # 阶段1:下采样预测
        image_lowres = downsample(image, factor=2)
        mask_lowres = self.stage1(image_lowres)
        mask_upsampled = upsample(mask_lowres, target_size=image.shape)
        
        # 阶段2:精细化
        input_stage2 = torch.cat([image, mask_upsampled], dim=1)
        mask_final = self.stage2(input_stage2)
        
        return mask_final

适用场景

  • 高分辨率图像(如512×512×512)
  • 内存不足以训练全分辨率3D UNet
  • 需要平衡全局上下文和局部细节

自适应配置规则

Patch Size自动推断

def determine_patch_size(median_shape, median_spacing, target_spacing):
    """根据图像尺寸和spacing确定patch size"""
    
    # 目标:使用约128×128×128的patch(GPU内存平衡)
    reference_patch = np.array([128, 128, 128])
    
    # 计算重采样后的尺寸
    new_shape = np.round(median_shape * median_spacing / target_spacing).astype(int)
    
    # 调整patch size,确保不超过图像尺寸
    patch_size = np.minimum(reference_patch, new_shape)
    
    # 确保是2的倍数(方便pooling)
    patch_size = np.round(patch_size / 16) * 16
    
    return patch_size.astype(int)

# 示例
median_shape = [512, 512, 130]
median_spacing = [0.7, 0.7, 5.0]
target_spacing = [1.0, 1.0, 1.0]  # 重采样到1mm

patch_size = determine_patch_size(median_shape, median_spacing, target_spacing)
# 输出:[192, 192, 128]

Batch Size自动调整

def determine_batch_size(network_type, patch_size, gpu_memory=11):
    """根据GPU内存和patch size确定batch size"""
    
    # 估算单个样本的内存占用
    if network_type == '2D':
        memory_per_sample = patch_size[0] * patch_size[1] * 4 / (1024**2)  # MB
        max_batch_size = 12
    elif network_type == '3D':
        memory_per_sample = np.prod(patch_size) * 4 / (1024**2)
        max_batch_size = 2
    
    # 根据GPU内存调整
    available_memory = gpu_memory * 1024  # GB -> MB
    estimated_batch_size = int(available_memory * 0.6 / memory_per_sample)
    
    batch_size = min(estimated_batch_size, max_batch_size)
    batch_size = max(batch_size, 2)  # 至少2
    
    return batch_size

数据增强策略

def get_augmentation_pipeline(dataset_properties):
    """自适应数据增强"""
    
    transforms = []
    
    # 基础增强(总是使用)
    transforms.append(A.RandomRotate90(p=0.5))
    transforms.append(A.Flip(p=0.5))
    
    # 弹性变形(3D数据)
    if dataset_properties['is_3d']:
        transforms.append(A.ElasticTransform(
            alpha=30,
            sigma=5,
            p=0.3
        ))
    
    # 旋转(根据模态调整)
    if dataset_properties['modality'] == 'CT':
        # CT对旋转敏感度低
        transforms.append(A.Rotate(limit=30, p=0.5))
    elif dataset_properties['modality'] == 'MRI':
        # MRI更敏感
        transforms.append(A.Rotate(limit=15, p=0.3))
    
    # 强度增强
    transforms.append(A.RandomBrightnessContrast(p=0.3))
    transforms.append(A.RandomGamma(p=0.3))
    
    # 缩放(根据spacing各向异性)
    anisotropy = np.max(dataset_properties['spacing']) / np.min(dataset_properties['spacing'])
    if anisotropy > 2:
        # 各向异性大,限制Z轴缩放
        transforms.append(A.RandomScale(scale_limit=(0.7, 1.3), p=0.2))
    else:
        # 各向同性,自由缩放
        transforms.append(A.RandomScale(scale_limit=(0.5, 1.5), p=0.5))
    
    return A.Compose(transforms)

性能表现

Medical Segmentation Decathlon

10个任务,10种模态,2000+病例[1]

任务 模态 nnU-Net Dice 第二名 提升
脑肿瘤 MRI 0.68 0.63 +5%
心脏 MRI 0.93 0.90 +3%
肝脏 CT 0.96 0.94 +2%
海马体 MRI 0.90 0.88 +2%
前列腺 MRI 0.76 0.71 +5%
肺部 CT 0.69 0.66 +3%
胰腺 CT 0.62 0.56 +6%
肝血管 CT 0.68 0.64 +4%
脾脏 CT 0.96 0.95 +1%
结肠癌 CT 0.56 0.51 +5%
平均 - 0.77 0.73 +4%

关键发现[1]

  • 所有任务第一名
  • ✅ 平均提升4%
  • 无需调参,开箱即用

与SOTA方法对比[1]

数据集 UNet Attention UNet UNet++ TransUNet nnU-Net
Synapse Multi-organ 76.85 77.77 78.32 81.87 82.10
ACDC (心脏) 87.48 88.06 - 90.00 90.34
LiTS (肝脏) 94.2 - - - 96.3
KiTS (肾脏) 84.6 - - - 87.5

分析

  • nnU-Net用标准UNet达到或超过复杂架构
  • 关键不在于新架构,而在于正确配置

nnU-Net的优势

零调参

传统方法:
研究者需要:
- 深度学习专业知识
- 医学图像理解
- 大量调参经验
- 2-3个月时间

nnU-Net:
用户需要:
- 准备数据(按格式)
- 运行一行命令:
  $ nnUNet_train DATASET_NAME

结果:
- 3-7天训练(GPU)
- 达到SOTA

可复现

问题:论文声称Dice=0.85
      自己复现:Dice=0.75

原因:
- 论文未公开所有超参数
- 调参过程不透明
- "调参艺术"难以复制

nnU-Net:
- 所有配置自动化
- 规则明确
- 开源代码+预训练模型
→ 100%可复现

鲁棒性

传统UNet:
任务A(肝脏):Dice=0.95(调参后)
任务B(胰腺):Dice=0.45(用任务A的配置)

nnU-Net:
任务A(肝脏):Dice=0.96(自动配置)
任务B(胰腺):Dice=0.62(自动配置)
→ 任何任务都稳定

集成学习

# nnU-Net自动集成多个模型
predictions = []

# 3种配置
for config in ['2d', '3d_fullres', '3d_cascade']:
    # 5折交叉验证
    for fold in range(5):
        model = load_model(f"nnUNet/{config}/fold_{fold}")
        pred = model.predict(image)
        predictions.append(pred)

# 平均15个预测(3配置 × 5折)
final_pred = np.mean(predictions, axis=0)

提升:集成通常+2-3% Dice vs. 单模型


使用nnU-Net

安装

# 安装nnU-Net
pip install nnunet

# 设置环境变量
export nnUNet_raw_data_base="/path/to/nnUNet_raw_data_base"
export nnUNet_preprocessed="/path/to/nnUNet_preprocessed"
export RESULTS_FOLDER="/path/to/nnUNet_trained_models"

数据准备

nnUNet_raw_data_base/
└── nnUNet_raw_data/
    └── Task001_LiverTumor/
        ├── imagesTr/        # 训练图像
        │   ├── liver_001_0000.nii.gz
        │   ├── liver_002_0000.nii.gz
        │   └── ...
        ├── labelsTr/        # 训练标签
        │   ├── liver_001.nii.gz
        │   ├── liver_002.nii.gz
        │   └── ...
        ├── imagesTs/        # 测试图像(可选)
        └── dataset.json     # 数据集描述

dataset.json

{
    "name": "Liver Tumor",
    "description": "Liver and liver tumor segmentation",
    "modality": {
        "0": "CT"
    },
    "labels": {
        "0": "background",
        "1": "liver",
        "2": "tumor"
    },
    "numTraining": 131,
    "numTest": 70,
    "training": [
        {"image": "./imagesTr/liver_001.nii.gz", "label": "./labelsTr/liver_001.nii.gz"},
        ...
    ],
    "test": ["./imagesTs/liver_001.nii.gz", ...]
}

训练流程

# 步骤1:数据集指纹提取和预处理计划
nnUNet_plan_and_preprocess -t 1 --verify_dataset_integrity

# 输出:
# - Task001_LiverTumor/nnUNetPlansv2.1_plans_2D.pkl
# - Task001_LiverTumor/nnUNetPlansv2.1_plans_3D.pkl
# - 预处理后的数据

# 步骤2:训练(3种配置)
# 2D UNet
nnUNet_train 2d nnUNetTrainerV2 Task001_LiverTumor 0  # fold 0
nnUNet_train 2d nnUNetTrainerV2 Task001_LiverTumor 1  # fold 1
...

# 3D Full Res UNet
nnUNet_train 3d_fullres nnUNetTrainerV2 Task001_LiverTumor 0

# 3D Cascade UNet
nnUNet_train 3d_cascade_fullres nnUNetTrainerV2 Task001_LiverTumor 0

# 步骤3:自动选择最优配置
nnUNet_find_best_configuration -m 2d 3d_fullres 3d_cascade_fullres -t 1

# 输出:Best configuration: 3d_fullres (Dice=0.96)

# 步骤4:预测
nnUNet_predict -i INPUT_FOLDER -o OUTPUT_FOLDER -t 1 -m 3d_fullres

常用命令

# 仅训练特定配置和折
nnUNet_train 3d_fullres nnUNetTrainerV2 Task001_LiverTumor 0

# 使用预训练模型
nnUNet_predict -i ./test_images -o ./predictions \
    -t 1 -m 3d_fullres -chk model_best

# 集成预测(所有配置)
nnUNet_predict -i ./test_images -o ./predictions \
    -t 1 -m 2d 3d_fullres 3d_cascade_fullres

进阶技巧

自定义Trainer

from nnunet.training.network_training.nnUNetTrainerV2 import nnUNetTrainerV2

class CustomTrainer(nnUNetTrainerV2):
    def __init__(self, *args, **kwargs):
        super().__init__(*args, **kwargs)
        
        # 自定义学习率
        self.initial_lr = 5e-4
        
        # 自定义损失函数权重
        self.loss = DC_and_CE_loss({'batch_dice': True, 'smooth': 1e-5}, {})
    
    def initialize_optimizer_and_scheduler(self):
        """自定义优化器"""
        self.optimizer = torch.optim.SGD(
            self.network.parameters(),
            self.initial_lr,
            weight_decay=self.weight_decay,
            momentum=0.99,
            nesterov=True
        )
        
        self.lr_scheduler = torch.optim.lr_scheduler.PolyLR(
            self.optimizer,
            self.num_epochs,
            power=0.9
        )

后处理优化

# nnU-Net自动学习后处理策略
# 在验证集上测试不同后处理的效果

def custom_postprocessing(pred_mask, remove_small_lesions_threshold=100):
    """移除小连通域"""
    import cc3d
    
    # 连通域分析
    labels_out = cc3d.connected_components(pred_mask, connectivity=26)
    
    # 计算每个连通域的大小
    stats = cc3d.statistics(labels_out)
    
    # 移除小连通域
    for label_id in range(1, stats['voxel_counts'].shape[0]):
        if stats['voxel_counts'][label_id] < remove_small_lesions_threshold:
            pred_mask[labels_out == label_id] = 0
    
    return pred_mask

处理不平衡数据

class ImbalancedTrainer(nnUNetTrainerV2):
    def __init__(self, *args, **kwargs):
        super().__init__(*args, **kwargs)
    
    def get_train_transforms(self):
        """增强小类别的采样"""
        transforms = super().get_train_transforms()
        
        # 添加类别平衡采样
        transforms.append(OverSampleForegroundClasses(
            classes_to_oversample=[2],  # 肿瘤类别
            oversample_factor=2.0
        ))
        
        return transforms

总结

nnU-Net的核心贡献[1]

  1. 自配置方法论
    • 将”调参艺术”转化为”系统工程”
    • 证明了正确配置比新架构更重要
  2. 实用主义
    • 不追求新颖性,追求可用性
    • “No new UNet” - 标准架构+最佳实践
  3. 广泛验证[1]
    • 23个医学分割任务SOTA
    • 成为医学分割的事实标准
  4. 开源生态
    • 完整代码 + 文档
    • 预训练模型
    • 活跃社区

适用场景

场景 推荐度 原因
新任务快速baseline ✅✅✅ 无需调参,开箱即用
生产环境部署 ✅✅✅ 鲁棒性强,可复现
缺乏调参经验 ✅✅✅ 自动配置
有限时间/资源 ✅✅ 3-7天达到SOTA
探索新架构 ⚠️ 架构固定,难以修改

局限与展望

局限

  • ❌ 架构固定,难以集成新模块(如Transformer)
  • ❌ 计算量大(3配置×5折=15个模型)
  • ❌ 对极小数据集(<20例)效果有限

未来方向

  • nnU-Net v2(2022)[2]:支持更多架构(ResNet、Transformer)
  • 轻量化:自动剪枝和蒸馏
  • Few-shot nnU-Net:结合Meta Learning

参考资料

  1. [1] Isensee, F. et al. "nnU-Net: a self-configuring method for deep learning-based biomedical image segmentation", Nature Methods, 2021. arXiv:1809.10486
  2. [2] Isensee, F. et al. "nnU-Net revisited: A call for rigorous validation in 3D medical image segmentation", arXiv, 2022.

代码与资源

数据集


文章目录