nnU-Net:自配置医学分割框架,让UNet发挥极致
引言
在前面的文章中,我们学习了众多UNet变种:Attention UNet、UNet++、TransUNet、Swin-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的三大原则:
- No Novelty, Just Engineering
- 不追求新颖架构
- 用标准UNet + 最佳实践
- Domain Knowledge Rules
- 利用医学图像的先验知识
- 针对不同场景选择配置
- 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]
- 自配置方法论
- 将”调参艺术”转化为”系统工程”
- 证明了正确配置比新架构更重要
- 实用主义
- 不追求新颖性,追求可用性
- “No new UNet” - 标准架构+最佳实践
- 广泛验证[1]
- 23个医学分割任务SOTA
- 成为医学分割的事实标准
- 开源生态
- 完整代码 + 文档
- 预训练模型
- 活跃社区
适用场景
| 场景 | 推荐度 | 原因 |
|---|---|---|
| 新任务快速baseline | ✅✅✅ | 无需调参,开箱即用 |
| 生产环境部署 | ✅✅✅ | 鲁棒性强,可复现 |
| 缺乏调参经验 | ✅✅✅ | 自动配置 |
| 有限时间/资源 | ✅✅ | 3-7天达到SOTA |
| 探索新架构 | ⚠️ | 架构固定,难以修改 |
局限与展望
局限:
- ❌ 架构固定,难以集成新模块(如Transformer)
- ❌ 计算量大(3配置×5折=15个模型)
- ❌ 对极小数据集(<20例)效果有限
未来方向:
- nnU-Net v2(2022)[2]:支持更多架构(ResNet、Transformer)
- 轻量化:自动剪枝和蒸馏
- Few-shot nnU-Net:结合Meta Learning
参考资料
- [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] Isensee, F. et al. "nnU-Net revisited: A call for rigorous validation in 3D medical image segmentation", arXiv, 2022.
代码与资源
数据集
- Medical Segmentation Decathlon - 10任务benchmark
- MONAI Model Zoo - 集成nnU-Net