1. 为什么3D-ResNet50在医学影像里是个“看起来很美”的选择
搞医学影像深度学习的人,大概都经历过这样一个心理过程:一开始用2D CNN处理CT或者MRI的切片,效果凑合,但总觉得哪里不对——明明病灶是一个立体的东西,为什么我要一张一张切片去看?于是很自然地,目光就转向了3D卷积网络。而在3D网络里,3D-ResNet50几乎是所有人第一个想到的架构:ResNet本身足够经典,50层深度在2D领域已经被验证过无数次,扩展到3D看起来只是把卷积核从2D变成3D的事。
我当初也是这么想的。手头有一批肺部CT的NIFTI数据,任务是对结节进行良恶性分类。2D方案做到AUC 0.82左右就卡住了,于是决定上3D-ResNet50。结果从数据准备到模型跑通,整整折腾了三周,踩的坑比我预想的多得多。这篇文章就是把这整个过程里所有值得说的陷阱、弯路和解决方案整理出来,给准备走同一条路的人省点时间。
先说清楚这篇文章适合谁看:如果你已经具备PyTorch或TensorFlow的基础,了解CNN的基本原理,现在手头有三维医学影像数据(CT、MRI、PET等),想用3D-ResNet50做分类或特征提取,那这篇内容基本覆盖了你接下来会遇到的绝大多数问题。如果你还没接触过NIFTI格式或者3D卷积的基本概念,也没关系,我会在必要的地方补充背景知识,保证你能跟上。
核心关键词先摆出来:3D-ResNet50、医学影像、3D-CNN、NIFTI、迁移学习。这几个词贯穿全文,每一个背后都有坑。
2. 数据准备阶段:NIFTI格式的“暗坑”比你想象的多
2.1 NIFTI文件读取:别以为nibabel.load就完事了
医学影像数据和自然图像最大的区别之一就是格式。自然图像你用PIL或者OpenCV读进来,RGB三个通道,像素值0-255,干净利落。但医学影像的NIFTI格式(.nii或.nii.gz)里面包含的信息远不止像素值——还有仿射矩阵(affine matrix)、体素间距(voxel spacing)、方向信息(orientation)等等。这些东西如果你不注意,后面会出大问题。
最基础的读取方式是用nibabel:
import nibabel as nib img = nib.load('patient_001.nii.gz') data = img.get_fdata() # shape: (H, W, D) affine = img.affine header = img.header看起来很简单对吧?但这里有几个隐藏问题。
第一个坑:数据方向不一致。不同设备、不同扫描协议导出的NIFTI文件,其体素数据的轴向排列可能是不同的。有的数据是RAI方向(Right-Anterior-Inferior),有的是LPS方向(Left-Posterior-Superior)。如果你直接把不同方向的数据混在一起训练,模型学到的空间特征是混乱的。解决方案是统一重定向到标准方向,通常用RAS(Right-Anterior-Superior):
import nibabel as nib from nibabel.orientations import axcodes2ornt, ornt_transform, apply_orientation def reorient_to_ras(img): current_ornt = nib.orientations.io_orientation(img.affine) ras_ornt = axcodes2ornt(('R', 'A', 'S')) transform = ornt_transform(current_ornt, ras_ornt) reoriented = img.as_reoriented(transform) return reoriented这一步看起来不起眼,但我实测过,不做方向统一的话,同一个模型在验证集上的AUC波动能到0.05以上,而且训练loss曲线会非常不稳定。
第二个坑:体素间距不同。不同CT设备的层厚可能从0.5mm到5mm不等,面内分辨率也各不相同。这意味着同样大小的一个结节,在不同患者的数据里占据的体素数量可能差好几倍。如果你直接resize到固定尺寸(比如128×128×128),实际上是在做各向异性的缩放,会扭曲病灶的形态特征。
我的做法是两步走:先用scipy.ndimage.zoom或者torch.nn.functional.interpolate把数据重采样到统一的体素间距(比如1mm×1mm×1mm),然后再裁剪或填充到固定尺寸。重采样的时候注意用三阶样条插值(order=3)来保持图像质量,标签数据用最近邻插值(order=0)。
import numpy as np from scipy.ndimage import zoom def resample_to_spacing(data, current_spacing, target_spacing=(1.0, 1.0, 1.0)): zoom_factors = [c / t for c, t in zip(current_spacing, target_spacing)] resampled = zoom(data, zoom_factors, order=3) return resampled注意:重采样之后的图像尺寸会变,所以后续的裁剪/填充策略要跟着调整。建议先重采样,再统一尺寸,顺序不要反。
2.2 数据增强:3D空间里能做的事情比2D少得多
2D图像增强里常用的翻转、旋转、缩放、色彩抖动,到了3D数据里有一半不能用或者要小心用。
翻转方面,左右翻转(沿矢状面)在大部分场景下是安全的,但前后翻转和上下翻转就要谨慎——因为人体解剖结构有明确的上下和前后方向,翻转之后产生的图像在解剖上是不合理的。旋转方面,绕轴旋转(axial plane内的旋转)一般没问题,但涉及前后方向倾斜的旋转要控制角度范围,通常±15度以内比较安全。
我常用的3D增强组合是这样的:
import torch from torchvision.transforms import Compose class RandomFlip3D: def __init__(self, p=0.5): self.p = p def __call__(self, volume): if torch.rand(1) < self.p: return torch.flip(volume, dims=[-1]) # 仅左右翻转 return volume class RandomRotate3D: def __init__(self, max_angle=15): self.max_angle = max_angle def __call__(self, volume): angle = torch.empty(1).uniform_(-self.max_angle, self.max_angle).item() # 绕Z轴旋转的实现略,可用affine_grid return volume另外,强度增强在医学影像里特别重要。不同设备的HU值分布可能有偏移,加一点随机亮度/对比度扰动能提升泛化性。但要注意CT的HU值是有物理意义的(空气约-1000,水约0,骨密度高的地方可以到1000以上),增强的时候要在这个范围内操作,不要盲目归一化到0-1之后就不管了。
2.3 类别不平衡:医学数据的老大难问题
医学影像数据集里,阳性样本(有病灶的)往往远少于阴性样本。我手头的数据集里,良恶性比例大概是3:1,这还算好的,有些任务能做到10:1甚至更极端。
处理类别不平衡的手段无非几种:重采样、加权损失、focal loss。我的经验是,加权损失 + 适度重采样的组合最稳。单纯用重采样容易过拟合少数类,单纯用加权损失又可能训练不稳定。
from torch.nn import CrossEntropyLoss # 假设类别0有3000样本,类别1有1000样本 weights = torch.tensor([1.0, 3.0]) criterion = CrossEntropyLoss(weight=weights)实操心得:加权系数的设置不要简单地用类别频率的倒数,那样往往过于激进。我通常用
sqrt(N_max / N_c)作为权重,效果比直接用N_max / N_c更平滑,训练也更稳定。
3. 模型搭建:3D-ResNet50的参数量陷阱与显存优化
3.1 参数量与显存:为什么你的GPU总是OOM
ResNet50在2D下的参数量大约是25.6M。扩展到3D之后,参数量会显著增加——主要增加在卷积核上。2D的3×3卷积核变成3D的3×3×3,参数量变成原来的3倍。整体算下来,3D-ResNet50的参数量大约在45M-50M左右(取决于具体实现)。
但参数量还不是最要命的,激活值占用的显存才是。3D特征图的尺寸是H×W×D×C,同样输入尺寸下,3D特征图的元素数量是2D的D倍(D是深度)。如果你输入一个128×128×128的volume,第一层卷积之后的特征图就是64×64×64×64,这个数字是2D情况下的64倍。
我一开始用单张RTX 3090(24GB显存),batch size只能设到2,梯度累积到8才能等效batch size 16。后来做了几件事把显存压下来了:
第一,混合精度训练(AMP)。这个几乎是必开的,能省30%-40%的显存,而且速度也有提升:
from torch.cuda.amp import autocast, GradScaler scaler = GradScaler() for data, target in dataloader: optimizer.zero_grad() with autocast(): output = model(data) loss = criterion(output, target) scaler.scale(loss).backward() scaler.step(optimizer) scaler.update()第二,梯度检查点(gradient checkpointing)。用时间换空间,把中间激活值不保存,反向传播时重新计算。显存能再省一半左右,但训练速度会慢20%-30%。对于3D-ResNet50这种深网络,我建议在显存吃紧的时候开:
from torch.utils.checkpoint import checkpoint # 在forward里对每个bottleneck block使用checkpoint def forward(self, x): x = checkpoint(self.layer1, x) x = checkpoint(self.layer2, x) ...第三,输入尺寸的取舍。128×128×128是一个比较常用的选择,但如果你的病灶本身比较小,可以考虑降到96×96×96甚至64×64×64。尺寸减半,显存占用大约降到1/8。当然,前提是降尺寸之后病灶仍然清晰可辨。
3.2 迁移学习:2D预训练权重到底能不能用
这是被问得最多的问题之一:ImageNet上预训练的ResNet50权重,能不能迁移到3D-ResNet50上?
答案是:能用,但要讲究方法。直接把2D卷积核复制到3D卷积核上(比如在深度维度上重复),效果通常比随机初始化好,但提升有限。更好的做法是膨胀(inflate)策略:把2D卷积核在新增的维度上取平均,而不是简单复制。
def inflate_conv2d_to_conv3d(conv2d_weight): # conv2d_weight shape: (out_c, in_c, kh, kw) # target shape: (out_c, in_c, kd, kh, kw) kd = 3 # 3D卷积核的深度 weight_3d = conv2d_weight.unsqueeze(2).repeat(1, 1, kd, 1, 1) weight_3d = weight_3d / kd # 归一化,保持输出尺度一致 return weight_3d除了权重膨胀,还有一种思路是直推式迁移学习——不直接迁移权重,而是用一个在大型医学影像数据集上预训练好的3D模型(比如在Kinetics-700上预训练的3D-ResNet,或者在公开医学数据集上训练的模型)作为起点。这种方式的效果通常比从2D膨胀要好,但前提是你能找到合适的预训练模型。
我实测下来的对比是这样的:
| 初始化方式 | 验证集AUC | 收敛epoch |
|---|---|---|
| 随机初始化 | 0.79 | 80+ |
| 2D权重复制膨胀 | 0.83 | 50 |
| 2D权重平均膨胀 | 0.85 | 45 |
| 3D医学预训练模型 | 0.88 | 25 |
可以看到,好的初始化能省一半以上的训练时间,最终效果也更好。
3.3 Batch Normalization的坑:小batch size下的灾难
3D网络因为显存限制,batch size往往很小(2-8)。而ResNet里的BatchNorm层在小batch下统计量估计非常不准,会导致训练不稳定、验证集表现差。
解决方案有几个:
方案一:用GroupNorm替换BatchNorm。GroupNorm不依赖batch维度,在小batch下表现稳定。但注意,替换之后就不能直接加载预训练权重了(BN层的running mean/var没法用),需要重新训练。
方案二:用SyncBN。如果你有多张GPU,SyncBN可以跨卡同步统计量,等效增大batch size。但单卡就没用了。
方案三:冻结BN层。加载预训练权重后,把BN层设为eval模式,不更新running stats。这个方案最简单,但要求预训练权重的BN统计量和你的数据分布比较接近。
我个人的选择是:如果batch size能到8以上,用BN问题不大;如果只能到2-4,果断换GroupNorm,虽然要重新训练,但稳定性提升非常明显。
4. 训练策略:学习率、正则化与早停的实战参数
4.1 学习率调度:warmup不是可选项
3D-ResNet50因为参数量大、batch size小,训练初期非常容易发散。warmup(学习率预热)几乎是必须的。我的标准配置是:
- Warmup epochs: 5
- 初始学习率: 1e-4(warmup结束后)
- 调度策略: CosineAnnealing
- 最小学习率: 1e-6
from torch.optim.lr_scheduler import CosineAnnealingLR, LambdaLR def warmup_lambda(epoch): if epoch < 5: return (epoch + 1) / 5 return 1.0 warmup_scheduler = LambdaLR(optimizer, lr_lambda=warmup_lambda) cosine_scheduler = CosineAnnealingLR(optimizer, T_max=100, eta_min=1e-6)实操心得:warmup的初始学习率不要设成0,设成最终学习率的1/10或者1/100就行。设成0的话前几个epoch基本没学到东西,浪费计算资源。
4.2 权重衰减与Dropout:3D网络更容易过拟合
3D-ResNet50的参数量大,而医学影像数据集通常不大(几百到几千个样本),过拟合是必然要面对的问题。
权重衰减我一般设1e-4到5e-4之间。Dropout的话,在最后的全连接层前面加一个p=0.5的Dropout,中间层一般不加(ResNet本身有BN做正则)。如果过拟合还是很严重,可以考虑在bottleneck block里加Dropout(p=0.1-0.2),但不要加太多,否则欠拟合。
另外,数据增强是最有效的正则化手段,比调Dropout和weight decay都管用。如果过拟合了,先想想增强够不够狠,再去调其他超参。
4.3 早停策略:别等到过拟合了才停
早停的patience我一般设15-20个epoch,监控指标用验证集AUC而不是loss。因为医学影像任务里,loss和最终评价指标(AUC、敏感度、特异度)的相关性不一定强,有时候loss还在降但AUC已经开始掉了。
best_auc = 0 patience = 20 counter = 0 for epoch in range(max_epochs): train_loss = train_one_epoch(model, train_loader) val_auc = evaluate(model, val_loader) if val_auc > best_auc: best_auc = val_auc torch.save(model.state_dict(), 'best_model.pth') counter = 0 else: counter += 1 if counter >= patience: print(f'Early stopping at epoch {epoch}') break5. 常见问题与排查技巧实录
5.1 训练loss不下降或者震荡严重
这是最常见的问题,排查顺序如下:
第一步,检查数据标签是否对齐。我遇到过好几次因为DataLoader的shuffle和标签文件读取顺序不一致,导致标签错位的情况。表现就是loss完全不降。排查方法很简单:取一个batch,把图像和标签可视化出来,人眼确认一下。
第二步,检查学习率是否过大。3D网络对学习率比2D敏感得多。如果loss震荡,先把学习率降10倍试试。
第三步,检查BN层状态。如果用了预训练权重但BN层没冻结,而你的数据分布和预训练数据差异很大,BN统计量会乱跑,导致训练不稳定。
第四步,检查输入数据归一化。CT的HU值范围很大(-1000到3000),如果不做归一化直接送进网络,梯度会爆炸。我一般用窗宽窗位截断到[-1000, 400](肺部)或者[-200, 300](腹部),然后归一化到[0, 1]或者标准化到均值0方差1。
5.2 验证集表现远差于训练集
这是过拟合的典型表现,但也可能是其他原因:
- 数据泄露:如果同一个患者的数据同时出现在训练集和验证集里,验证集表现会虚高。反过来,如果训练集和验证集的患者群体差异很大(比如不同医院的数据),验证集表现会偏低。一定要按患者ID划分数据集,不能按切片或者volume随机划分。
- 增强过强:如果训练时用了很强的增强,而验证时没有,两者分布差异大会导致验证表现差。验证时的预处理要和训练时的“基础预处理”保持一致(重采样、归一化),但不需要增强。
- BN层问题:训练时BN用batch统计量,验证时用running stats。如果running stats估计不准(小batch),验证表现会明显差于训练。
5.3 推理速度太慢
3D-ResNet50的推理速度确实不快。一个128×128×128的volume,在V100上大概需要0.5-1秒。如果要做实时推理或者处理大批量数据,可以考虑:
- 模型剪枝:把不重要的通道剪掉,参数量能减30%-50%,速度提升明显。
- 知识蒸馏:用一个大模型教一个小模型,小模型推理快,效果也不差。
- ONNX/TensorRT导出:推理框架的优化能带来2-3倍的速度提升。
# ONNX导出示例 dummy_input = torch.randn(1, 1, 128, 128, 128).cuda() torch.onnx.export(model, dummy_input, 'model.onnx', input_names=['input'], output_names=['output'], dynamic_axes={'input': {0: 'batch'}, 'output': {0: 'batch'}})5.4 常见问题速查表
| 问题现象 | 可能原因 | 排查方法 | 解决方案 |
|---|---|---|---|
| loss不下降 | 标签错位 | 可视化batch数据 | 检查DataLoader和标签读取逻辑 |
| loss震荡 | 学习率过大 | 降10倍学习率试 | 加warmup,降初始lr |
| 验证AUC远低于训练 | 过拟合 | 对比训练/验证曲线 | 加增强,加正则,早停 |
| 验证AUC虚高 | 数据泄露 | 检查患者ID划分 | 按患者划分数据集 |
| 显存OOM | batch太大/模型太大 | 看nvidia-smi | 开AMP,开checkpoint,降batch |
| 训练速度慢 | 数据加载瓶颈 | 看GPU利用率 | 增加num_workers,用缓存 |
| BN层不稳定 | batch size太小 | 看BN running stats | 换GroupNorm或冻结BN |
独家避坑技巧:在正式训练之前,先拿10个样本做一次“过拟合测试”——用这10个样本训练,看模型能不能把训练loss降到接近0。如果连10个样本都过拟合不了,说明模型或者数据管道有bug,不用浪费时间跑完整训练。
6. 一些关于迁移学习的进阶思考
直推式迁移学习在医学影像领域最近被讨论得很多。它的核心思想是:不直接微调预训练模型的权重,而是利用预训练模型提取的特征来做下游任务。具体到3D-ResNet50,你可以把预训练模型当作一个特征提取器,把中间层的特征拿出来,后面接一个简单的分类器(比如SVM或者逻辑回归)。
这种方式的优势在于:当你的目标数据集非常小(几十到几百个样本)时,微调整个网络几乎必然过拟合,而直推式迁移学习只训练分类器,参数量少,不容易过拟合。缺点是特征的质量完全取决于预训练模型和你的任务的匹配程度。
我试过的一个折中方案是:部分微调。只微调ResNet的最后两个stage(layer3和layer4),前面的层冻结。这样既能利用预训练特征,又能让模型适应你的数据分布。实测下来,在样本量500左右的时候,部分微调的效果比全量微调好2-3个百分点的AUC。
# 冻结前两个stage for name, param in model.named_parameters(): if 'layer1' in name or 'layer2' in name: param.requires_grad = False另外,迁移学习里有一个容易被忽略的点:预训练数据的域和你的目标域是否匹配。ImageNet是自然图像,和医学影像的纹理、对比度差异很大。如果有条件的话,尽量找在医学影像上预训练的3D模型(比如在公开的医学影像数据集上训练的),效果会比ImageNet预训练好很多。
7. 数据管道的性能优化:别让IO成为瓶颈
3D医学影像的数据管道性能问题比2D严重得多。一个NIFTI文件动辄几十MB到几百MB,读取和预处理都很耗时。如果DataLoader的num_workers设得不够,GPU利用率可能只有30%-40%,大部分时间都在等数据。
我的优化策略是:
第一,预处理离线化。把重采样、方向统一、归一化这些操作提前做好,保存成numpy数组或者h5文件。训练时直接读预处理好的数据,速度能快5-10倍。
第二,用内存映射或者LMDB。如果数据集不大(能全部放进内存),直接全部加载到内存里。如果放不下,用LMDB或者h5py做内存映射,比反复读NIFTI文件快得多。
第三,num_workers设置。一般设成CPU核心数的70%-80%。比如16核的机器,设12个worker。但注意,每个worker都会占用内存,如果数据集体积大,worker太多会导致内存OOM。
第四,pin_memory和prefetch。这两个是PyTorch DataLoader的基本优化,一定要开:
dataloader = DataLoader(dataset, batch_size=4, num_workers=12, pin_memory=True, prefetch_factor=4, shuffle=True)实操心得:如果你发现GPU利用率忽高忽低(比如在0%和90%之间跳),基本可以确定是数据管道的问题。先用
nvidia-smi -l 1看GPU利用率,如果波动大,就去优化数据加载。
8. 评估指标的选择:AUC不是万能的
医学影像任务里,AUC是最常用的评估指标,但它不是万能的。特别是在类别不平衡的场景下,AUC可能会给出过于乐观的印象。
我一般会同时看这几个指标:
- AUC:整体排序能力
- 敏感度(Sensitivity):在固定特异度下的检出率
- 特异度(Specificity):在固定敏感度下的排除率
- F1 Score:综合精确率和召回率
- 校准曲线(Calibration Curve):预测概率是否和实际概率一致
特别是在临床场景下,敏感度和特异度的取舍比AUC更重要。比如肺癌筛查,你宁可假阳性多一点(高敏感度),也不能漏掉真正的病灶(低敏感度)。所以评估的时候一定要结合具体临床需求来选指标。
from sklearn.metrics import roc_auc_score, confusion_matrix, f1_score def evaluate_metrics(y_true, y_pred_prob, threshold=0.5): auc = roc_auc_score(y_true, y_pred_prob) y_pred = (y_pred_prob >= threshold).astype(int) tn, fp, fn, tp = confusion_matrix(y_true, y_pred).ravel() sensitivity = tp / (tp + fn) specificity = tn / (tn + fp) f1 = f1_score(y_true, y_pred) return {'auc': auc, 'sensitivity': sensitivity, 'specificity': specificity, 'f1': f1}9. 模型可解释性:医生不会接受一个黑盒子
在医学影像领域,模型的可解释性不是锦上添花,而是刚需。医生需要知道模型为什么做出这个判断,才能信任它。
对于3D-ResNet50,常用的可解释性方法有:
- Grad-CAM 3D:把2D的Grad-CAM扩展到3D,生成热力图,看模型关注的是哪个区域。
- 遮挡敏感度分析:把输入volume的某个区域遮挡掉,看预测概率的变化。
- 特征可视化:把中间层的特征图可视化出来,看模型学到了什么。
Grad-CAM 3D的实现思路和2D类似,只是把卷积特征图和梯度的空间维度从2D变成3D:
def grad_cam_3d(model, input_volume, target_layer): # 前向传播 features = [] def hook_fn(module, input, output): features.append(output) handle = target_layer.register_forward_hook(hook_fn) output = model(input_volume) handle.remove() # 反向传播 model.zero_grad() output[0, target_class].backward() # 计算权重 gradients = target_layer.weight.grad # 简化写法,实际要用hook weights = gradients.mean(dim=(2, 3, 4), keepdim=True) cam = (weights * features[0]).sum(dim=1) cam = torch.relu(cam) return cam注意:Grad-CAM 3D生成的热力图分辨率比较低(通常是输入尺寸的1/32),如果要叠加到原始图像上,需要上采样。上采样之后的热力图会比较模糊,这是正常现象,不要以为是bug。
10. 部署上线的那些事
模型训练好了,下一步就是部署。医学影像模型的部署有几个特殊要求:
第一,推理延迟。临床场景下,医生等不了太久。一个volume的推理时间最好控制在5秒以内。如果模型太大跑不动,考虑蒸馏或者剪枝。
第二,输入预处理的一致性。训练时的预处理管道(重采样、方向统一、归一化)必须在推理时完全复现。我见过好几次因为推理时忘了做方向统一,导致预测结果完全错误的情况。建议把预处理代码封装成一个独立的模块,训练和推理共用。
第三,异常输入处理。医学影像数据里经常有各种异常情况:空volume、尺寸不对、方向不对、HU值超出正常范围等等。推理管道里要加足够的异常检测和兜底逻辑,不能让模型直接崩掉。
第四,版本管理。模型权重、预处理参数、配置文件要一起版本化。每次更新模型都要记录清楚改了什么、在什么数据上训练的、评估指标是多少。这在医学场景下尤其重要,因为可能涉及到合规和审计。
11. 我踩过的几个印象最深的坑
最后分享几个我在实际项目中踩过的坑,都是那种“如果没人告诉我,我可能要多花一周才能发现”的类型。
第一个坑:NIFTI的affine矩阵被忽略。我一开始读数据只用了get_fdata(),完全没管affine。结果不同患者的数据在空间上是对不齐的,模型学到的空间位置信息全是乱的。后来加了方向统一和重采样,效果直接提升了5个百分点。
第二个坑:验证集划分没按患者。同一个患者可能有多次扫描,如果按volume随机划分,同一个患者的不同扫描可能同时出现在训练集和验证集里,导致验证AUC虚高。改成按患者ID划分之后,验证AUC掉了3个点,但那个才是真实的泛化性能。
第三个坑:BN层没冻结。用预训练权重的时候,BN层的running stats是ImageNet的,和医学影像差异很大。我一开始没冻结BN,训练了十几个epoch之后发现BN的running stats完全乱了,模型输出全是NaN。后来把BN冻结了,问题解决。
第四个坑:数据增强把病灶转没了。我用了比较大的旋转角度(±30度),结果有些小病灶在旋转之后被裁掉了。后来把旋转角度控制在±15度以内,并且确保旋转之后病灶还在视野内,问题才解决。
第五个坑:学习率太大导致loss爆炸。3D-ResNet50对学习率非常敏感。我用2D任务里常用的1e-3作为初始学习率,结果第一个epoch loss就炸了。后来降到1e-4,加了warmup,才稳定下来。
这些坑说到底都是细节问题,但医学影像3D深度学习就是这样一个“细节决定成败”的领域。每一个环节——数据读取、预处理、模型搭建、训练策略、评估、部署——都有它自己的坑,而且这些坑往往是串联的,一个环节出问题,后面全白搭。
我现在做新项目的时候,会先花一两天时间专门做数据管道的验证:读一批数据,可视化出来,确认方向对、间距对、归一化对、标签对。这个时间花得绝对值,比后面训练到一半发现数据有问题再回头改要省事得多。