3D医学影像分类大作业实战:从nii.gz数据管线到3D ResNet推理全链路 简介这份资源面向计算机、电子信息工程、数学等专业的大学生用于课程设计、期末大作业或毕业设计中的3D医学影像分类任务属于机器学习方向的完整项目模板。压缩包共596个文件以582个npz数据文件为主另含8个Python源码、3个csv标签与提交文件、2个pyc及1个md说明文档整体约446.14MB数据与代码分层清晰便于直接复现与二次开发。代码采用参数化编程关键参数可灵活调整注释详尽、思路明确并附有运行结果均经过测试验证。资源内含训练验证与提交样例等结构化数据可帮助读者快速理解3D医学影像的读取、预处理、模型训练与评估流程掌握从数据组织到结果输出的完整实现路径。目前已有360人学习适合需要可运行参考方案、希望节省环境搭建与调试时间的学生与开发者。1. 3D医学影像分类大作业从数据到推理一条能跑通的工程链路3D医学影像分类这个题目很多同学第一次拿到手是懵的——2D图像分类跑过CIFAR、跑过猫狗大战但换成CT、MRI这类三维体数据输入从(H, W, 3)变成(D, H, W)整个数据管线、模型结构、显存占用全变了。更现实的问题是大作业通常只给几周时间数据集可能是nii.gz格式的几十例样本标注还未必均衡而你需要在有限算力上跑出一个能写进报告、能答辩、最好还能复现的结果。这篇笔记面向正在做机器学习大作业、选题落在3D医学影像分类的读者也适合想从2D迁移到3D的从业者。我会按一条真实可跑的链路来讲数据怎么读、预处理怎么做、模型怎么选、训练怎么调、结果怎么验证。代码基于Python和PyTorch数据集以常见的.nii/.nii.gz格式为例不依赖特定竞赛或私有数据。整套流程在一张8GB显存的消费级显卡上就能跑通不需要多卡。2. 数据管线从nii.gz到模型能吃的张量2.1 为什么3D医学影像不能直接套2D的读取方式医学影像的原始格式和普通图片完全不同。CT和MRI通常存为DICOM序列或NIfTI.nii/.nii.gz文件前者是一个切片一个文件后者把整个三维体数据打包成一个文件。NIfTI文件里除了体素矩阵还带仿射矩阵affine记录了体素坐标到世界坐标的映射关系——这个信息在分类任务里通常用不到但在做配准、分割或者多模态融合时是必须的。直接套2D的Image.open会翻车。你需要用nibabel或SimpleITK来读。读进来之后拿到的是一个numpy数组形状通常是(X, Y, Z)注意这个顺序和PyTorch卷积期望的(D, H, W)不一定一致取决于扫描方向和存储约定。我一般会先打印shape和affine确认方向再决定要不要转置。另一个关键点是体素间距spacing。不同设备的扫描层厚可能从1mm到5mm不等同一个器官在不同病例里的物理尺寸一样但体素数量差好几倍。如果不做重采样模型学到的可能是“这个病例层厚大”而不是“这个病灶是什么”。常见做法是把所有样本重采样到统一的spacing比如(1.0, 1.0, 1.0)mm。2.2 读取与预处理的完整代码下面这段代码覆盖了从读取、重采样、强度归一化到裁剪/填充的完整流程。依赖nibabel、numpy、scipy、torch。import nibabel as nib import numpy as np import torch from scipy.ndimage import zoom def load_nifti(path): 读取nii/nii.gz返回体素数组和spacing img nib.load(path) data img.get_fdata().astype(np.float32) # (X, Y, Z) spacing img.header.get_zooms()[:3] # 物理间距单位mm return data, spacing def resample_to_spacing(data, src_spacing, dst_spacing(1.0, 1.0, 1.0)): 按物理间距重采样保证不同病例体素尺寸一致 factors [s / d for s, d in zip(src_spacing, dst_spacing)] return zoom(data, factors, order1) # 线性插值order1 def normalize_intensity(data, hu_min-1000, hu_max400): CT常用HU窗截断归一化MRI需改用z-score data np.clip(data, hu_min, hu_max) data (data - hu_min) / (hu_max - hu_min) return data.astype(np.float32) def pad_or_crop(data, target_shape(64, 128, 128)): 统一到固定尺寸不足补零超出中心裁剪 result np.zeros(target_shape, dtypenp.float32) slices_src, slices_dst [], [] for i in range(3): if data.shape[i] target_shape[i]: start (target_shape[i] - data.shape[i]) // 2 slices_dst.append(slice(start, start data.shape[i])) slices_src.append(slice(0, data.shape[i])) else: start (data.shape[i] - target_shape[i]) // 2 slices_src.append(slice(start, start target_shape[i])) slices_dst.append(slice(0, target_shape[i])) result[tuple(slices_dst)] data[tuple(slices_src)] return result def preprocess(path, target_shape(64, 128, 128)): data, spacing load_nifti(path) data resample_to_spacing(data, spacing) data normalize_intensity(data) data pad_or_crop(data, target_shape) return torch.from_numpy(data).unsqueeze(0) # (1, D, H, W)逻辑说明load_nifti负责读文件和拿spacingresample_to_spacing用scipy.ndimage.zoom按物理间距比例缩放order1是线性插值速度和质量平衡较好normalize_intensity对CT用HU窗截断这是放射科常用的窗宽窗位思路MRI没有固定HU值应该改成减均值除标准差的z-scorepad_or_crop把不同大小的体数据统一到固定尺寸补零而不是补边缘避免引入虚假结构。参数说明target_shape的选择要权衡。64×128×128 在8GB显存上跑3D ResNet大约占4-5GB如果显存更小可以降到 32×96×96。hu_min和hu_max针对CT腹部常用 -100到200肺部用 -1000到400具体看你的解剖区域。重采样目标spacing不一定要1mm层厚方向2mm、面内1mm也是常见折中。2.3 数据增强在3D里怎么做才不引入伪影2D常用的随机旋转、翻转在3D里同样适用但要注意方向。医学影像有解剖方位约定横断面axial、矢状面sagittal、冠状面coronal。左右翻转在多数分类任务里是安全的但前后翻转可能把肝脏位置翻到不对的地方——如果你的任务依赖器官位置就别用。我一般用三种增强随机翻转仅左右轴、随机小角度旋转±10度以内、随机强度扰动乘一个0.9到1.1的系数。旋转用scipy.ndimage.rotate按轴做注意旋转后要重新裁剪到目标尺寸。强度扰动模拟不同设备的曝光差异对CT尤其有用。提示增强只加在训练集上验证集和测试集必须用和推理时完全一致的预处理否则验证指标会虚高。3. 模型选型3D CNN、2.5D和ViT到底怎么选3.1 3D ResNet是最稳的起点3D医学影像分类的模型选择绕不开三个方向纯3D CNN如3D ResNet、DenseNet3D、2.5D方法把每个切片当2D图跑再聚合、以及3D Vision Transformer。对于大作业场景我的建议是先用3D ResNet-18或ResNet-10跑通基线再考虑换。原因很实际3D ResNet结构成熟PyTorch生态里有现成实现预训练权重虽然不如2D丰富但医学影像领域有MedicalNet等公开权重可用。2.5D方法实现简单但丢失了层间信息对需要判断病灶跨层连续性的任务效果差。3D ViT对数据量要求高几十例样本训Transformer基本是过拟合的命。下面是一个3D ResNet的简化实现输入通道为1灰度体数据输出类别数可配。import torch.nn as nn class BasicBlock3D(nn.Module): def __init__(self, in_planes, planes, stride1): super().__init__() self.conv1 nn.Conv3d(in_planes, planes, 3, stride, 1, biasFalse) self.bn1 nn.BatchNorm3d(planes) self.conv2 nn.Conv3d(planes, planes, 3, 1, 1, biasFalse) self.bn2 nn.BatchNorm3d(planes) self.relu nn.ReLU(inplaceTrue) self.downsample None if stride ! 1 or in_planes ! planes: self.downsample nn.Sequential( nn.Conv3d(in_planes, planes, 1, stride, biasFalse), nn.BatchNorm3d(planes)) def forward(self, x): identity x out self.relu(self.bn1(self.conv1(x))) out self.bn2(self.conv2(out)) if self.downsample is not None: identity self.downsample(x) return self.relu(out identity) class ResNet3D(nn.Module): def __init__(self, num_classes2, width32): super().__init__() self.stem nn.Sequential( nn.Conv3d(1, width, 7, 2, 3, biasFalse), nn.BatchNorm3d(width), nn.ReLU(inplaceTrue), nn.MaxPool3d(3, 2, 1)) self.layer1 self._make_layer(width, width, 2, 1) self.layer2 self._make_layer(width, width*2, 2, 2) self.layer3 self._make_layer(width*2, width*4, 2, 2) self.layer4 self._make_layer(width*4, width*8, 2, 2) self.pool nn.AdaptiveAvgPool3d(1) self.fc nn.Linear(width*8, num_classes) def _make_layer(self, in_c, out_c, blocks, stride): layers [BasicBlock3D(in_c, out_c, stride)] for _ in range(1, blocks): layers.append(BasicBlock3D(out_c, out_c, 1)) return nn.Sequential(*layers) def forward(self, x): x self.stem(x) x self.layer1(x); x self.layer2(x) x self.layer3(x); x self.layer4(x) x self.pool(x).flatten(1) return self.fc(x)逻辑说明BasicBlock3D是标准残差块的三维版本downsample在通道数变化或步长不为1时对齐维度。ResNet3D的width控制基础通道数默认32比2D ResNet的64小一半目的是控制显存。AdaptiveAvgPool3d(1)把任意空间尺寸压成1所以输入尺寸只要能被16整除即可不强制固定。参数说明width32时参数量约2.8M8GB显存能跑batch size 4-8。如果显存够width64效果通常更好。num_classes按你的任务设二分类用2多分类用实际类别数。损失函数二分类用BCEWithLogitsLoss输出1维或CrossEntropyLoss输出2维多分类统一用CrossEntropyLoss。3.2 迁移学习预训练权重怎么用才不白费医学影像的3D预训练权重不像2D ImageNet那么唾手可得但也不是没有。常见做法有两种一是用MedicalNet等公开的3D ResNet权重初始化主干分类头随机初始化二是把2D ImageNet权重在深度方向复制扩展成3D卷积核再微调。第一种更靠谱。加载时注意键名匹配通常需要写一个映射函数把预训练权重里和当前模型同名的层加载进去分类头和尺寸不匹配的层跳过。第二种适合数据量特别小比如少于50例的情况复制扩展能提供一定的边缘、纹理先验但深度方向的物理意义是错的微调时要给主干较小的学习率。我一般会先冻结主干训分类头5个epoch再解冻全部用较小学习率比如1e-4微调。这样比一上来就全量微调稳定尤其当batch size小的时候。3.3 类别不均衡医学数据绕不开的坑医学影像分类数据集里阳性样本往往远少于阴性。比如100例里只有15例有病灶直接训模型会倾向于全预测阴性准确率看着有85%但敏感度接近0。这种模型在答辩时一问就露馅。处理方式有三类重采样对少数类过采样或对多数类欠采样、损失加权给少数类更高权重、以及Focal Loss。大作业场景我推荐损失加权实现最简单效果也稳。CrossEntropyLoss(weighttorch.tensor([1.0, 5.0]))这种写法权重按类别频率的倒数来设。如果类别极度不均衡比如1:20再考虑Focal Loss。注意不要只看准确率。医学分类任务必须报告敏感度、特异度、AUC。验证集上按AUC选最优模型而不是按准确率。4. 训练与调参小样本3D分类的实操参数4.1 学习率、batch size和优化器的组合3D医学影像分类的样本量通常不大几十到几百例。这种规模下训练超参的选择和2D大数据集很不一样。我一般用AdamW学习率从1e-4起步weight decay设1e-4。SGD也能用但需要更仔细地调学习率和动量大作业时间紧的话AdamW更省事。batch size受显存限制8GB卡上3D ResNet-18跑64×128×128输入batch size大概4-8。batch小的时候BatchNorm的统计量不稳定可以考虑换成GroupNorm或者用梯度累积模拟大batch。梯度累积的写法很简单每算完一个batch的loss除以累积步数再backward累积够了再step。学习率调度用余弦退火CosineAnnealingLR比StepLR更平滑配合warmup前几个epoch线性升温能避免初期震荡。总epoch数我一般设100-150早停patience设20。如果验证集AUC连续20个epoch不涨就停。4.2 完整训练循环与验证指标下面这段代码把数据加载、训练、验证、指标计算串起来。假设你已经把预处理后的数据存成了.pt文件或内存里的Dataset。import torch from torch.utils.data import DataLoader from sklearn.metrics import roc_auc_score, confusion_matrix def train_one_epoch(model, loader, optimizer, criterion, device): model.train() total_loss 0 for x, y in loader: x, y x.to(device), y.to(device) optimizer.zero_grad() logits model(x) loss criterion(logits, y) loss.backward() optimizer.step() total_loss loss.item() * x.size(0) return total_loss / len(loader.dataset) torch.no_grad() def evaluate(model, loader, device): model.eval() all_probs, all_labels [], [] for x, y in loader: x x.to(device) logits model(x) probs torch.softmax(logits, dim1)[:, 1] # 阳性类概率 all_probs.extend(probs.cpu().numpy()) all_labels.extend(y.numpy()) auc roc_auc_score(all_labels, all_probs) preds (np.array(all_probs) 0.5).astype(int) tn, fp, fn, tp confusion_matrix(all_labels, preds).ravel() sens tp / (tp fn 1e-8) spec tn / (tn fp 1e-8) return auc, sens, spec # 主循环 device torch.device(cuda if torch.cuda.is_available() else cpu) model ResNet3D(num_classes2, width32).to(device) criterion torch.nn.CrossEntropyLoss(weighttorch.tensor([1.0, 3.0]).to(device)) optimizer torch.optim.AdamW(model.parameters(), lr1e-4, weight_decay1e-4) scheduler torch.optim.lr_scheduler.CosineAnnealingLR(optimizer, T_max100) best_auc 0 for epoch in range(150): loss train_one_epoch(model, train_loader, optimizer, criterion, device) auc, sens, spec evaluate(model, val_loader, device) scheduler.step() if auc best_auc: best_auc auc torch.save(model.state_dict(), best_model.pth) print(fEpoch {epoch}: loss{loss:.4f}, AUC{auc:.4f}, Sens{sens:.4f}, Spec{spec:.4f})逻辑说明train_one_epoch做标准的前向、反向、更新loss按样本数加权平均。evaluate里取softmax后阳性类概率算AUC阈值0.5算混淆矩阵。主循环按验证AUC保存最优模型而不是最后一个epoch。参数说明weighttorch.tensor([1.0, 3.0])里的3.0要根据实际类别比例调阴性:阳性3:1时设3.0左右。T_max100和总epoch一致。如果验证集太小少于30例AUC波动大可以改用交叉验证把5折的AUC平均作为选模型依据。4.3 显存不够时的三个降级方案8GB显存跑3D分类稍微大一点的输入就OOM。我踩过的降级路径按优先级排第一降低输入尺寸从64×128×128降到32×96×96显存大约降到1/3第二把ResNet的width从32降到16参数量降到1/4第三用混合精度训练AMP显存省30%左右速度还快。AMP的写法是with torch.cuda.amp.autocast():包住前向scaler.scale(loss).backward()和scaler.step(optimizer)替代普通反向。注意AMP下BatchNorm和softmax一般没问题但自定义loss要检查数值稳定性。如果这三招还不够那就只能减小batch size到1配合梯度累积。batch size1时BatchNorm基本失效换成GroupNormnn.GroupNorm(8, channels)会稳很多。5. 避坑与排查3D医学分类最常见的5个翻车点5.1 现象训练loss正常下降验证AUC一直在0.5附近原因最常见的是数据泄漏或预处理不一致。比如训练集和验证集用了不同的归一化参数或者同一个病例的切片被分到了训练和验证两边。3D数据如果按切片划分同一病例的不同切片高度相关验证集等于变相泄漏。解决按病例划分训练/验证/测试不是按切片。预处理参数HU窗、spacing在划分前统一确定不要分别算。检查一下验证集的输入分布打印均值和方差和训练集对比。5.2 现象模型预测全为阴性准确率等于阴性比例原因类别不均衡 损失未加权。模型发现全预测阴性就能拿到高准确率梯度方向自然往那边走。解决加损失权重按类别频率倒数设。同时把模型选择指标从准确率换成AUC或F1。如果加权后还是全阴性检查一下阳性样本的预处理是不是有问题比如病灶区域被裁剪掉了。5.3 现象训练时loss突然变成NaN原因3D卷积小batch高学习率容易梯度爆炸。另外如果输入数据里有NaN或Inf也会传染。解决先检查输入数据np.isnan(data).any()和np.isinf(data).any()过一遍。然后加梯度裁剪torch.nn.utils.clip_grad_norm_(model.parameters(), max_norm1.0)。学习率降到1e-5再试。如果用了AMP检查scaler的初始scale是不是太大。5.4 现象换了台机器或重新跑结果差很多原因随机种子没固定。PyTorch、numpy、Python内置random都要设。另外cuDNN的benchmark模式会让卷积算法不确定。解决在代码开头统一设种子并且设torch.backends.cudnn.deterministic True。注意这样会牺牲一点速度但换来确定性。如果用了DataLoader的num_workers0每个worker的种子也要设。5.5 现象推理时单例预测和验证集上的表现不一致原因推理时的预处理和验证时不一致。常见的是忘了做同样的重采样、归一化或者输入尺寸没对齐。解决把预处理封装成一个函数或类训练、验证、推理都调同一个。推理时打印输入张量的shape、均值、方差和验证集的一个样本对比确认一致。6. 进阶技巧用Grad-CAM验证模型到底在看哪里模型跑出高AUC不代表它学到了正确的特征。3D医学影像里模型可能因为扫描仪型号、层厚、甚至背景噪声的差异做出判断而不是病灶本身。Grad-CAM能生成热力图告诉你模型关注的是哪个区域。对于大作业答辩一张标注了病灶位置的热力图比一堆数字有说服力得多。3D Grad-CAM的实现思路和2D一样取最后一个卷积层的特征图对目标类别求梯度梯度全局平均后作为权重加权求和特征图再ReLU。区别在于特征图和梯度都是5D的(N, C, D, H, W)平均要在空间三个维度上做。class GradCAM3D: def __init__(self, model, target_layer): self.model model self.target_layer target_layer self.gradients None self.activations None target_layer.register_forward_hook(self._save_activation) target_layer.register_full_backward_hook(self._save_gradient) def _save_activation(self, module, input, output): self.activations output.detach() def _save_gradient(self, module, grad_input, grad_output): self.gradients grad_output[0].detach() def generate(self, input_tensor, class_idx): self.model.eval() logits self.model(input_tensor) self.model.zero_grad() logits[0, class_idx].backward() weights self.gradients.mean(dim(2, 3, 4), keepdimTrue) # (N,C,1,1,1) cam (weights * self.activations).sum(dim1, keepdimTrue) cam torch.relu(cam) cam cam - cam.min() cam cam / (cam.max() 1e-8) return cam # (1,1,D,H,W)逻辑说明register_forward_hook存前向特征图register_full_backward_hook存反向梯度。generate里对目标类别得分反向得到梯度后按空间维度平均得到通道权重加权求和特征图ReLU去掉负响应最后归一化到0-1。参数说明target_layer一般选最后一个残差块的最后一个卷积层分辨率太低的热力图没意义。class_idx是你要解释的类别索引。生成的cam可以按深度切片叠加到原始影像上挑病灶所在的那几层看。用的时候注意Grad-CAM是定性工具不是定量指标。热力图亮的地方不一定是病灶也可能是模型学到的伪影。我一般会挑几个真阳性、假阳性、假阴性的案例分别看如果假阳性的热力图集中在扫描床或体表轮廓上说明模型学到了不该学的东西需要回去检查预处理是不是没做背景裁剪。我自己的习惯是每次训完一个模型先不看AUC先抽5个验证样本跑Grad-CAM。如果热力图集中在解剖合理区域再去看指标如果热力图散得到处都是指标再高我也不敢用。这个习惯帮我避开了好几次“指标好看但模型没学到东西”的坑。希望帮到你。本文还有配套的精品资源点击获取