
简介这份资源面向机器学习课程学习者与期末大作业需求者提供一套基于3D卷积神经网络的医学图像分类完整实现方案适合新手对照理解与直接部署使用。压缩包共48个文件约11.48MB以18个Python源码文件为核心辅以pickle与h5模型权重、csv结果记录、txt与md说明文档等覆盖数据加载、模型定义、训练与测试全流程。项目目录按dataloader、models、utils等模块划分并附有README说明与结果文件代码注释较为完整便于快速理清3D卷积在医学影像分类中的实现思路。目前已有440人学习下载可作为课程设计或期末大作业的参考模板帮助读者掌握数据预处理、模型搭建、训练调参与结果输出等关键环节具备较高的实际应用与借鉴价值。1. 3D卷积做医学图像分类课程大作业里最容易翻车的那条路医学图像分类这个题目几乎每个学期都会出现在机器学习课程大作业的选题清单里。大多数人第一反应是拿 CT 或 MRI 的二维切片套一个 ResNet 或者 VGG 就交差了。但如果你拿到的数据本身是三维体数据——比如肺部 CT 的整个容积、脑部 MRI 的多个连续层面——只取单张切片就等于把空间结构信息扔掉了。3D 卷积神经网络要解决的就是这个问题把卷积核从二维平面扩展到三维立方体让网络在长、宽、深三个方向上同时提取特征。这个方案适合已经掌握 PyTorch 基础、想在大作业里做出区分度的同学也适合需要快速验证一个医学影像分类 pipeline 是否跑得通的从业者。源代码和文档说明的价值不在于代码有多长而在于它能不能让你在有限时间内跑出一个有说服力的结果。2. 从体数据到 3D 卷积为什么不能直接套 2D 网络2.1 医学图像的三维本质与 2D 切片的局限CT 和 MRI 设备输出的原始数据是三维体素阵列。一次胸部 CT 扫描通常产生 512×512×200 到 512×512×400 的体数据每个体素记录的是组织对 X 射线的衰减系数。当你只取其中一层切片送入 2D 卷积网络时网络看到的是一个孤立的截面它无法判断这个截面上出现的圆形高密度影在相邻层面上是连续延伸还是突然消失。连续延伸往往意味着血管断面突然消失则更可能是结节。这个判断依赖的是深度方向上的上下文2D 网络天然拿不到。有人会说那我把相邻几层堆叠成多通道输入不就行了这确实是一种折中方案常见做法是把 3 层或 5 层相邻切片拼成 3 通道或 5 通道输入。但这样做有两个问题第一通道数固定无法自适应地学习深度方向上的特征第二浅层卷积核在通道维度上的权重是独立学习的它不会像 3D 卷积那样在深度方向上共享空间卷积核。换句话说多通道堆叠只是把深度信息当作颜色通道来处理而 3D 卷积是把深度当作和长宽同等地位的空间维度来处理。2.2 3D 卷积核的计算逻辑与显存代价一个 3D 卷积层的卷积核尺寸是 $D_k \times H_k \times W_k$它在输入特征图的 $D \times H \times W$ 三个方向上滑动。假设输入通道数为 $C_{in}$输出通道数为 $C_{out}$那么这一层的参数量是 $C_{in} \times C_{out} \times D_k \times H_k \times W_k$。对比 2D 卷积的 $C_{in} \times C_{out} \times H_k \times W_k$参数量多了一个 $D_k$ 因子。如果 $D_k3$参数量直接变成 2D 卷积的 3 倍。更麻烦的是激活值显存。一个 3D 卷积层的输出特征图尺寸是 $D_{out} \times H_{out} \times W_{out} \times C_{out}$而 2D 卷积是 $H_{out} \times W_{out} \times C_{out}$。当输入体数据为 128×128×128 时第一层卷积如果输出 32 个通道激活值就是 128×128×128×32 个浮点数按 float32 算就是 256MB。这还只是一层。所以 3D 网络的第一条铁律是输入尺寸必须降下来通常裁切或缩放到 64×64×64 或 96×96×96 再送入网络。2.3 一个最小可跑的 3D CNN 结构下面这段代码定义了一个用于二分类的 3D CNN输入尺寸固定为 64×64×64输出为单个 logit。结构不复杂但每一层的参数都经过显存估算。import torch import torch.nn as nn class Simple3DCNN(nn.Module): def __init__(self, in_channels1, num_classes2): super().__init__() # 输入: (B, 1, 64, 64, 64) self.features nn.Sequential( # 第1个3D卷积块: 64^3 - 32^3 nn.Conv3d(in_channels, 16, kernel_size3, padding1), nn.BatchNorm3d(16), nn.ReLU(inplaceTrue), nn.MaxPool3d(kernel_size2, stride2), # 32^3 # 第2个3D卷积块: 32^3 - 16^3 nn.Conv3d(16, 32, kernel_size3, padding1), nn.BatchNorm3d(32), nn.ReLU(inplaceTrue), nn.MaxPool3d(kernel_size2, stride2), # 16^3 # 第3个3D卷积块: 16^3 - 8^3 nn.Conv3d(32, 64, kernel_size3, padding1), nn.BatchNorm3d(64), nn.ReLU(inplaceTrue), nn.MaxPool3d(kernel_size2, stride2), # 8^3 # 第4个3D卷积块: 8^3 - 4^3 nn.Conv3d(64, 128, kernel_size3, padding1), nn.BatchNorm3d(128), nn.ReLU(inplaceTrue), nn.AdaptiveAvgPool3d((4, 4, 4)), # 固定到4^3 ) self.classifier nn.Sequential( nn.Linear(128 * 4 * 4 * 4, 256), nn.ReLU(inplaceTrue), nn.Dropout(0.5), nn.Linear(256, num_classes) ) def forward(self, x): x self.features(x) x x.view(x.size(0), -1) # 展平 x self.classifier(x) return x # 显存估算验证 if __name__ __main__: model Simple3DCNN().cuda() dummy torch.randn(2, 1, 64, 64, 64).cuda() # batch2 out model(dummy) print(out.shape) # torch.Size([2, 2]) print(f参数量: {sum(p.numel() for p in model.parameters()) / 1e6:.2f}M)这段代码的关键参数有三个。第一in_channels1对应单模态灰度体数据如果是多模态 MRI 可以改成 4。第二每个卷积块后接MaxPool3d(2,2)这是控制显存的核心手段每经过一次池化体数据边长减半激活值体积变为原来的八分之一。第三AdaptiveAvgPool3d((4,4,4))把任意尺寸的特征图压到固定大小这样分类头的输入维度就是确定的不用手算。参数量大约在 1.5M 到 2M 之间单卡 8GB 显存跑 batch size 2 到 4 没问题。3. 数据预处理从 NIfTI 文件到网络输入张量3.1 读取与重采样为什么统一 spacing 比统一尺寸更重要医学图像原始文件常见格式是 NIfTI.nii 或 .nii.gz和 DICOM 序列。不同设备的体素间距不一样有的 CT 是 0.7×0.7×1.0 mm有的 MRI 是 1.0×1.0×1.0 mm。如果你直接 resize 到 64×64×64物理尺寸大的器官会被压缩小的会被拉伸网络学到的形状特征就乱了。正确做法是先重采样到各向同性的 spacing比如统一到 1.5×1.5×1.5 mm然后再裁切或缩放到目标尺寸。import nibabel as nib import numpy as np from scipy.ndimage import zoom def load_and_resample(nii_path, target_spacing(1.5, 1.5, 1.5)): 读取NIfTI文件并重采样到目标spacing img nib.load(nii_path) data img.get_fdata() # 得到float64的3D数组 original_spacing img.header.get_zooms()[:3] # 原始spacing # 计算缩放因子: 原始spacing / 目标spacing zoom_factors [orig / target for orig, target in zip(original_spacing, target_spacing)] resampled zoom(data, zoom_factors, order1) # 线性插值 return resampled.astype(np.float32) def crop_or_pad(data, target_shape(64, 64, 64)): 中心裁切或补零到目标尺寸 result np.zeros(target_shape, dtypenp.float32) # 计算每个维度的裁切/填充起始位置 starts [] for i in range(3): if data.shape[i] target_shape[i]: starts.append((data.shape[i] - target_shape[i]) // 2) else: starts.append(-(target_shape[i] - data.shape[i]) // 2) # 逐维处理 slices_src [] slices_dst [] for i in range(3): if starts[i] 0: slices_src.append(slice(starts[i], starts[i] target_shape[i])) slices_dst.append(slice(0, target_shape[i])) else: slices_src.append(slice(0, data.shape[i])) slices_dst.append(slice(-starts[i], -starts[i] data.shape[i])) result[tuple(slices_dst)] data[tuple(slices_src)] return resultzoom函数的order1表示线性插值速度比order3的三次样条快很多对分类任务精度影响很小。crop_or_pad采用中心裁切策略因为医学图像中感兴趣区域通常位于中心。如果你的数据里病灶偏一侧可以改成基于 mask 的裁切。3.2 强度归一化CT 的窗宽窗位与 MRI 的 z-scoreCT 值的单位是 HU范围从 -1000 到 3000 左右。直接送进网络会导致数值过大梯度爆炸。常见做法是截断到某个窗宽窗位比如肺部窗是 [-1000, 400]然后线性映射到 [0, 1]。MRI 没有标准单位通常对每个样本做 z-score 归一化即减去均值除以标准差。def normalize_ct(volume, window_level-600, window_width1500): CT窗宽窗位归一化到[0,1] lower window_level - window_width // 2 upper window_level window_width // 2 volume np.clip(volume, lower, upper) volume (volume - lower) / (upper - lower) return volume.astype(np.float32) def normalize_mri(volume): MRI z-score归一化 mean volume.mean() std volume.std() if std 1e-8: return volume - mean return (volume - mean) / std窗宽窗位的选择取决于你要观察的组织。肺部窗常用 level-600, width1500纵隔窗用 level40, width400脑窗用 level40, width80。大作业里如果数据是肺部 CT用肺部窗即可。MRI 的 z-score 是按每个样本独立做的不要在整个数据集上算全局均值方差因为不同样本的绝对强度没有可比性。3.3 数据增强3D 场景下哪些操作真正有效2D 图像增强里的旋转、翻转、裁剪在 3D 里同样适用但要注意方向。医学图像有解剖学方向约定比如轴向翻转左右翻转在脑部图像里可能改变左右半球的语义但在肺部结节检测里通常没问题。随机旋转建议只在轴向平面内小角度旋转±15度不要绕所有轴大角度旋转否则会引入不真实的解剖结构。import random import torch from scipy.ndimage import rotate class Random3DAugment: def __init__(self, angle_range15, flip_prob0.5): self.angle_range angle_range self.flip_prob flip_prob def __call__(self, volume): # 轴向平面内随机旋转 angle random.uniform(-self.angle_range, self.angle_range) volume rotate(volume, angle, axes(0, 1), reshapeFalse, order1) # 随机左右翻转 if random.random() self.flip_prob: volume np.flip(volume, axis0).copy() # 随机前后翻转 if random.random() self.flip_prob: volume np.flip(volume, axis1).copy() return volumeaxes(0,1)表示在长宽平面内旋转不碰深度轴。reshapeFalse保持输出尺寸不变旋转后超出边界的部分被裁掉。翻转操作后要.copy()否则 numpy 的 flip 返回的是视图后续修改会影响原数据。4. 训练策略与显存优化让 3D 网络在单卡上跑起来4.1 混合精度训练与梯度累积3D 网络显存占用大混合精度训练AMP是最直接的优化手段。PyTorch 的torch.cuda.amp可以把卷积和矩阵乘法的计算精度降到 float16显存占用减少约 40%速度提升 20% 到 30%。配合梯度累积可以在小 batch 下模拟大 batch 的训练效果。from torch.cuda.amp import autocast, GradScaler def train_one_epoch(model, dataloader, optimizer, criterion, accum_steps4): model.train() scaler GradScaler() total_loss 0.0 optimizer.zero_grad() for step, (inputs, labels) in enumerate(dataloader): inputs inputs.cuda() labels labels.cuda() with autocast(): outputs model(inputs) loss criterion(outputs, labels) / accum_steps scaler.scale(loss).backward() if (step 1) % accum_steps 0: scaler.step(optimizer) scaler.update() optimizer.zero_grad() total_loss loss.item() * accum_steps return total_loss / len(dataloader)accum_steps4表示每 4 个 batch 更新一次参数等效 batch size 是实际 batch size 的 4 倍。注意 loss 要除以accum_steps否则梯度会累积成 4 倍。GradScaler自动处理 float16 的梯度下溢问题不需要手动调 scale 因子。4.2 学习率调度与早停3D 网络参数量大容易过拟合。学习率用余弦退火从 1e-3 降到 1e-6配合早停策略在验证集 loss 连续 10 个 epoch 不下降时停止训练。from torch.optim.lr_scheduler import CosineAnnealingLR optimizer torch.optim.AdamW(model.parameters(), lr1e-3, weight_decay1e-4) scheduler CosineAnnealingLR(optimizer, T_max100, eta_min1e-6) best_val_loss float(inf) patience 10 counter 0 for epoch in range(100): train_loss train_one_epoch(model, train_loader, optimizer, criterion) val_loss validate(model, val_loader, criterion) scheduler.step() if val_loss best_val_loss: best_val_loss val_loss torch.save(model.state_dict(), best_model.pth) counter 0 else: counter 1 if counter patience: print(f早停于 epoch {epoch}) breakweight_decay1e-4是 AdamW 的默认推荐值对 3D 网络这种参数量大的模型能起到正则化作用。T_max100表示余弦周期为 100 个 epocheta_min1e-6是学习率下限。4.3 类别不平衡的处理加权损失与采样医学图像分类里阳性样本往往远少于阴性样本。比如肺结节数据集中有结节的样本可能只占 10%。直接用交叉熵损失会导致模型偏向预测多数类。两种处理方式一是在损失函数里给少数类更高权重二是用 WeightedRandomSampler 在采样时过采样少数类。from torch.utils.data import WeightedRandomSampler # 假设标签列表 labels 中 0 为阴性1 为阳性 class_counts np.bincount(labels) class_weights 1.0 / class_counts sample_weights class_weights[labels] sampler WeightedRandomSampler( weightstorch.DoubleTensor(sample_weights), num_sampleslen(labels), replacementTrue ) train_loader DataLoader(dataset, batch_size4, samplersampler, num_workers4)replacementTrue表示有放回采样少数类样本会被重复抽到。num_samples设为数据集大小保证每个 epoch 的样本数与原始一致。如果不想改采样器也可以在损失函数里用nn.CrossEntropyLoss(weighttorch.tensor([1.0, 5.0]))把阳性类的权重设为阴性的 5 倍。5. 避坑与排查3D 医学图像分类里最常见的五个翻车点5.1 显存溢出但不知道哪一层爆了现象训练开始几秒后报CUDA out of memory但不知道是模型太大还是输入太大。原因3D 卷积的激活值显存占用与输入尺寸的立方成正比第一层卷积的输出往往是显存大户。解决在模型 forward 里逐层打印输出尺寸和显存占用。用torch.cuda.memory_allocated()查看当前分配量。如果第一层就爆把输入从 128³ 降到 64³如果中间层爆减少该层的输出通道数或插入额外的池化层。5.2 验证集准确率震荡大无法判断是否收敛现象验证集准确率在 0.5 到 0.9 之间来回跳loss 曲线锯齿严重。原因batch size 太小导致梯度噪声大或者学习率过高。解决先检查 batch size 是否小于 4。如果显存允许用梯度累积模拟更大的 batch。如果显存不允许把学习率降到 1e-4 并增加 warmup 阶段。另外3D 网络的 BatchNorm 在小 batch 下统计量不稳定可以换成 GroupNorm把nn.BatchNorm3d替换为nn.GroupNorm(num_groups8, num_channels16)。5.3 数据泄露同一个病人的切片同时出现在训练集和验证集现象训练集准确率 99%验证集准确率 60%差距巨大。原因医学图像数据集里一个病人可能有多次扫描或多次重建如果按切片随机划分同一个病人的数据会同时进入训练集和验证集模型记住了病人特征而不是病灶特征。解决按病人 ID 划分数据集确保同一个病人的所有数据只出现在一个集合里。如果数据里没有病人 ID用文件名的前缀或目录结构推断。5.4 归一化参数在训练集和测试集上不一致现象测试时模型输出全为同一类但训练时正常。原因训练时对每个样本做了 z-score测试时忘了做或者用了训练集的全局均值方差但测试集分布不同。解决把归一化参数CT 的窗宽窗位、MRI 的均值和标准差保存下来测试时用完全相同的参数。如果 MRI 用 z-score测试时也要对每个样本独立做不要用训练集的全局统计量。5.5 模型保存了但加载后结果不对现象训练时验证集准确率 85%加载保存的模型重新测试只有 50%。原因保存的是state_dict但加载时模型结构不一致或者保存时用了torch.save(model)但加载时环境变了。解决统一用torch.save(model.state_dict(), path)保存加载时先实例化模型再model.load_state_dict(torch.load(path))。如果模型里用了 Dropout 或 BatchNorm加载后要调用model.eval()。6. 进阶技巧用 3D Grad-CAM 验证模型到底在看哪里训练完一个 3D CNN 之后你可能会好奇模型到底是根据病灶区域做判断还是根据某个无关的伪影3D Grad-CAM 可以生成一个热力图叠加在原始体数据上直观展示模型的注意力区域。这个技巧在大作业答辩时非常加分因为它把黑匣子变成了可解释的证据。实现思路是取模型最后一个卷积层的输出特征图对目标类别的 logit 求梯度对梯度做全局平均得到每个通道的权重然后用权重对特征图加权求和再经过 ReLU 得到热力图。最后把热力图缩放到原始输入尺寸叠加在 CT 或 MRI 上。import torch import torch.nn.functional as F import numpy as np 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_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, target_class): self.model.eval() output self.model(input_tensor) # (1, num_classes) self.model.zero_grad() # 对目标类别的score反向传播 score output[0, target_class] score.backward() # 全局平均池化梯度得到权重 weights self.gradients.mean(dim(2, 3, 4), keepdimTrue) # (1, C, 1, 1, 1) # 加权求和 cam (weights * self.activations).sum(dim1, keepdimTrue) # (1, 1, D, H, W) cam F.relu(cam) # 归一化到[0,1] cam cam - cam.min() cam cam / (cam.max() 1e-8) return cam.squeeze().cpu().numpy() # 使用示例 target_layer model.features[9] # 最后一个Conv3d层 grad_cam GradCAM3D(model, target_layer) input_tensor torch.randn(1, 1, 64, 64, 64).cuda() heatmap grad_cam.generate(input_tensor, target_class1) print(f热力图尺寸: {heatmap.shape}) # (64, 64, 64)target_layer选最后一个卷积层因为它的感受野覆盖整个输入同时保留了空间信息。weights的维度是(1, C, 1, 1, 1)表示每个通道的重要性。cam经过 ReLU 后只保留对目标类别有正贡献的区域。归一化到 [0,1] 是为了方便叠加显示。拿到热力图后可以沿轴向取若干层把热力图以半透明红色叠加在灰度 CT 上。如果高亮区域集中在病灶附近说明模型学到了正确的特征如果高亮在图像边缘或背景说明模型可能走了捷径需要检查数据预处理或增强策略。我自己的习惯是每次训练完一个 3D 分类模型先跑一遍 Grad-CAM随机抽 5 个验证集样本看热力图。如果热力图看起来合理再去看准确率如果热力图明显不对准确率再高也不敢用。这个习惯帮我省过好几次后悔药——有一次模型在验证集上 AUC 0.92但热力图全在图像角落后来发现是数据里混入了一批带有扫描床标记的样本模型学的是床的边缘而不是病灶。希望帮到你。本文还有配套的精品资源点击获取