
简介围绕3D-UNet、3D-VNet与2D-UNet三种主流分割网络资源完整覆盖脑肿瘤MRI影像的分割算法与生存预测建模适用于高校人工智能、电子信息、自动化等专业学生完成毕业设计或课程设计。压缩包共50个文件、约3MB其中21个ipynb分步展示各模型训练与结果对比8个py脚本为可直接运行的训练与数据生成代码2个joblib保存了训练好的生存预测模型另有报告pdf、项目说明文档、数据文件及多张网络结构与训练效果示意图。资源涵盖2D单类/多类切面模型、3D-UNet、3D-VNet多种实验路线并附带回归与分类两套生存预测方案便于对照不同网络在同一任务上的表现差异。已有93人学习下载适合需要快速复现脑肿瘤分割实验、撰写毕设论文或在此基础上扩展算法的读者。1. 脑肿瘤分割用 3D-UNet 还是 2D-UNet一份毕设项目拆出的落地答案手里这份「3D-UNet3D-VNet2D-UNet脑肿瘤分割算法研究」项目包放在毕设选题里是典型的中上游难度任务定义清晰BraTS 公开数据集随手能下评价指标Dice、Hausdorff 距离固定不用自己造评测标准。难的是三套网络怎么组织对比实验、3D 模型怎么在单卡上跑起来、以及生存预测模型如何从分割结果里接出特征。很多同学卡在同一个地方代码能跑通但换一个数据集或换一张卡就全线崩溃。这篇文就按「数据预处理 → 3D-UNet 训练 → 3D-VNet 对比 → 生存预测建模 → 避坑清单 → 验证技巧」的顺序把整套方案讲透。2. 数据与预处理把 BraTS 格式转换成模型能吃的 NIfTI patch2.1 四模态 MRI 的读取顺序与 label 标注约定BraTS 数据集中每个病例包含四个模态T1、T1ce增强后 T1、T2、FLAIR全部以 NIfTI 格式存储为 .nii.gz 文件。label 文件里用整数区分三个肿瘤子区域1 代表坏死和水肿核心NCR/NET、2 代表水肿ED、4 代表增强肿瘤ET。多数模型不会直接预测 0/1/2/4而是把标签重映射成 0/1/2/3 四类或者拆成二分类任务分别预测整个肿瘤、肿瘤核心、增强肿瘤。import nibabel as nib import numpy as np from pathlib import Path # 按 BraTS 命名规则读取一个病例的全部模态 case_dir Path(./BraTS_001) modalities { t1: nib.load(str(case_dir / BraTS_001_t1.nii.gz)).get_fdata(), t1ce: nib.load(str(case_dir / BraTS_001_t1ce.nii.gz)).get_fdata(), t2: nib.load(str(case_dir / BraTS_001_t2.nii.gz)).get_fdata(), flair: nib.load(str(case_dir / BraTS_001_flair.nii.gz)).get_fdata(), } label nib.load(str(case_dir / BraTS_001_seg.nii.gz)).get_fdata() # 把 BraTS 的 4 类标签压缩成 0/1/2/3避免类别编号之间留空洞 label_reduced np.zeros(label.shape, dtypenp.uint8) label_reduced[label 1] 1 # NCR/NET label_reduced[label 2] 2 # ED label_reduced[label 4] 3 # ET这段代码的核心是先把四模态叠加成模型的输入通道再把 label 重映射为连续整数。BraTS 原始 label 里没有 3 这个值直接用 0/1/2/4 做交叉熵会让类别索引错位这是最常见的低级错误。读取顺序建议固定为 T1 → T1ce → T2 → FLAIR后续训练、推理都按这个顺序堆叠通道不要随意调换。如果你拿到的是已经预处理好的版本依然要写一段脚本核对 shape 和体素取值范围很多压缩包里的数据被人手动裁剪过和原始标签对不上。2.2 预处理管线N4 偏置场校正、z-score 归一化与 patch 采样参数脑部 MRI 存在低频偏置场同一组织在不同位置的信号强度不一样。BraTS 官方预处理里通常会做 N4 偏置场校正但如果你从原始数据开始最好在训练前把这一步补上。此外不同病例的 MRI 强度没有统一单位需要在脑区 mask 内做 z-score 归一化不能直接用全局 min-max。import SimpleITK as sitk from scipy import ndimage def preprocess_volume(volume, sitk_image, brain_mask): # 1. N4 偏置场校正输入必须转成 float32 corrected sitk.N4BiasFieldCorrection( sitk.Cast(sitk_image, sitk.sitkFloat32) ) corrected sitk.GetArrayFromImage(corrected) # 2. 只对脑区做 z-score 归一化背景保持不变 brain_voxels volume[brain_mask 0] mean, std brain_voxels.mean(), brain_voxels.std() normalized (volume - mean) / (std 1e-8) normalized[brain_mask 0] 0 return normalized # 3. 各向同性重采样到 1mm避免不同扫描分辨率影响分割边界 def resample_to_isotropic(volume, image, target_spacing(1.0, 1.0, 1.0)): original_spacing image.GetSpacing() original_size image.GetSize() new_size [int(round(sz * spc / target)) for sz, spc, target in zip(original_size, original_spacing, target_spacing)] resampler sitk.ResampleImageFilter() resampler.SetSize(new_size) resampler.SetOutputSpacing(target_spacing) resampler.SetInterpolator(sitk.sitkLinear) return resampler.Execute(image)N4 校正用 SimpleITK 实现比较省事但要注意它有两个坑一是必须用 float32int16 会静默失败二是校正结果偶尔会出现极端离群值归一化前建议做一次百分位裁剪1%/99%。重采样到各向同性 1mm 是让模型能学出尺度不变特征的关键但会消耗显存显存紧的时候可以退到 2mm。预处理完成后把结果缓存成 .npy 或新的 .nii.gz千万不要在每次训练启动时重跑一遍医学影像单例预处理要几十秒两百例就是两三个小时。3. 用 3D-UNet 跑通最小训练patch size、损失函数和训练收敛的三处关键3.1 3D-UNet 的编码器结构为什么天然适合脑肿瘤脑肿瘤在 MRI 里是典型的三维空间连续结构水肿区域在 FLAIR 上呈不规则片状增强区域在 T1ce 上呈环状强化。2D-UNet 逐切片分割会把轴向的连续性切断单张切片上看起来合理的边界在三维检查时常常发现上下层错位。3D-UNet 把卷积核从 2×2 换成 3×3×3直接用体积 patch 做输入编码器下采样四到五次后最深层的感受野能覆盖几十毫米的范围正好匹配肿瘤核心加水肿的尺度。这也是标题里三套网络摆在对比位置的根基2D-UNet 做基线3D-UNet 做主力3D-VNet 做结构改进验证。3.2 最小训练脚本Dice Loss 与批量采样怎么配训练脚本里最值得抄的是 patch 采样器。全脑体积通常是 240×240×155直接整图输入单卡 24G 也放不下必须切成 128×128×128 的 patch。随机裁剪时必须保证每个 patch 里有足够的肿瘤体素否则几百个迭代下来全是背景Dice Loss 直接掉到 0。import torch from torch.utils.data import Dataset import random class BraTSPatchDataset(Dataset): def __init__(self, volumes, labels, patch_size128, tumor_bias0.7): self.volumes volumes # list of [4, H, W, D] self.labels labels # list of [H, W, D] self.patch_size patch_size self.tumor_bias tumor_bias def __len__(self): return len(self.volumes) def __getitem__(self, idx): vol, lab self.volumes[idx], self.labels[idx] c, h, w, d vol.shape ps self.patch_size # 70% 的概率强制以肿瘤中心为锚点采样防止训练集全是背景 tumor_coords (lab 0).nonzero() if len(tumor_coords[0]) 0 and random.random() self.tumor_bias: # 随机取一个肿瘤体素做中心再向各方向偏移半个 patch 长度 anchor tumor_coords[:, random.randint(0, len(tumor_coords[0]) - 1)] z int(random.uniform(max(0, anchor[0] - ps//2), min(h - ps, anchor[0] - ps//2))) y int(random.uniform(max(0, anchor[1] - ps//2), min(w - ps, anchor[1] - ps//2))) x int(random.uniform(max(0, anchor[2] - ps//2), min(d - ps, anchor[2] - ps//2))) else: z random.randint(0, h - ps) y random.randint(0, w - ps) x random.randint(0, d - ps) patch_vol vol[:, z:zps, y:yps, x:xps] patch_lab lab[z:zps, y:yps, x:xps] return torch.FloatTensor(patch_vol), torch.LongTensor(patch_lab)采样器里的 tumor_bias 是毕设里最容易被忽略的超参数。设成 0.7 表示 70% 的 patch 保证包含肿瘤30% 是纯背景这样模型既能学到肿瘤边界又不会见过太多背景导致误检。如果你把 bias 设成 1.0验证集上会疯了一样地把正常脑组织预测成水肿设成 0 则模型根本学不到肿瘤结构。同一个 128×128×128 patch在 16G 显存上用 batch size 2 已经是极限后面讲怎么降配。损失函数推荐组合结构主损失用多类 Dice Loss辅损失用加权的交叉熵。Dice Loss 对类别不平衡不敏感增强区域可能只占整个体积的 0.3%普通交叉熵会被背景淹没。但纯 Dice Loss 在训练初期梯度噪声很大加 0.1 权重的交叉熵能稳定收敛方向。学习率用 1e-3 的 AdamW配合余弦退火调度器总训练轮次 150 到 200。class DiceLoss(nn.Module): def __init__(self, smooth1.0): super().__init__() self.smooth smooth def forward(self, pred, target): # pred: [B, C, H, W, D] 已过 softmax # target: [B, H, W, D] 整数标签 target_onehot F.one_hot(target, num_classespred.shape[1]).permute(0, 4, 1, 2, 3).float() intersection (pred * target_onehot).sum(dim(2, 3, 4)) union pred.sum(dim(2, 3, 4)) target_onehot.sum(dim(2, 3, 4)) dice (2 * intersection self.smooth) / (union self.smooth) return 1 - dice.mean()Dice Loss 实现时注意 smooth 参数不要加得过大1.0 足够过大的 smooth 会掩盖小肿瘤的梯度信号。另外多类 Dice 是逐类算 Dice 再取平均不是把整个预测张量当作一个整体算。很多人踩坑就是把 channel 维卷进去算成「平均体素重叠率」那种损失收敛出来的分割边界明显更粗糙。3.3 显存不够时把 patch 从 128 降到 64 要连带改什么这是我被问得最多的一个问题。很多同学的卡是 8G 或 10G 显存128² patch 根本塞不进去于是直接把 patch_size 改成 64。结果训练倒是能跑验证集 Dice 从 0.85 掉到 0.7还找不到原因。降 patch size 不是一个参数的事至少改四个地方patch 深度从 64 降到 64 时网络最深一层的感受野从 100mm 左右缩到 40mm水肿的上下文信息丢失需要把编码器中的最大通道数从 256 提到 320 来弥补特征表达能力。采样时的 tumor_bias 要相应从 0.7 提到 0.85因为 patch 变小后随机采样包含肿瘤的概率天然降低。学习率要同步下降推荐从 1e-3 降到 7e-4否则小 patch 上的梯度噪声会让损失曲线震荡得很厉害。推理阶段不能再用 128 的滑窗要么把训练和推理统一到 64要么在推理时对整图做镜像 padding 后分块重叠预测重叠率设 0.5 再做高斯加权融合。如果显存还是不够优先开 AMP 混合精度而不是继续降 patch。PyTorch 的 GradScaler 在 3D 卷积上通常能省 30% 显存且损失不掉点。用 bfloat16 的卡在脑肿瘤分割上基本无损float16 需要在损失函数里手动加一个梯度裁剪防止 Dice Loss 除零产生的 inf。2D-UNet 作为对比基线时也有它的价值训练速度快、显存占用小、增强容易做2D 旋转/翻转/弹性形变都比 3D 简单。但你要在项目报告里交代清楚2D 基线丢失了 z 轴的上下文结果差是预期的拿它做消失实验的对照即可不用试图调参调得和 3D 一样好浪费时间。4. 从分割结果到生存预测模型3D-VNet 的输出怎么变成预测标签4.1 VNet 与 UNet 的差异残差连接和 Dice 损失的配合VNet 和 UNet 的最大区别在两点VNet 在每个编码器阶段内部用残差连接即每个 stage 的输出是「卷积层输出 输入本身」同时 VNet 从头到尾都用 Dice Loss 做监督而不是交叉熵。残差连接让梯度可以直接穿过深层结构传到浅层对脑肿瘤这种前景占比极小的任务有明显优势。实际训练时会发现 VNet 在训练初期的损失下降比 UNet 慢但后期精度上限通常更高尤其是对增强肿瘤这种小目标的分割边界更干净。如果你的项目包里同时有 3D-UNet 和 3D-VNet 的实现建议共享同一套数据加载和预处理代码只替换网络主体这样才能让对比实验的说服力足够强——只改网络结构这一个变量数据增强、损失函数、采样策略、训练轮次全部保持一致。否则报告里没法回答「VNet 的提升到底是结构带来的还是损失函数带来的」这个必考题。4.2 把分割概率转成特征向量的固定套路生存预测模型的输入不是原始图像而是从分割结果中提取的结构化特征。常见做法是把分割 mask 按照三个子区域分别计算体积、表面积、体积占比、边界复杂度再加上整瘤的体积和位置信息。注意体积计算要把体素数乘以体素尺寸BraTS 重采样到 1mm 等体素后1 个体素就是 1mm³可以近似当作最终结果。from scipy import ndimage def extract_tumor_features(seg_mask, spacing1.0): # seg_mask 是重映射后的整数标签4 个类别0背景 1NCR 2ED 3ET feature_dict { tumor_vol_mm3: (seg_mask 0).sum() * spacing**3, necrosis_vol: (seg_mask 1).sum() * spacing**3, edema_vol: (seg_mask 2).sum() * spacing**3, enhance_vol: (seg_mask 3).sum() * spacing**3, } # 表面粗糙度用体素级表面积/体积比近似 surface 0 for cls in [1, 2, 3]: binary (seg_mask cls) eroded ndimage.binary_erosion(binary, iterations1) surface (binary ~eroded).sum() feature_dict[surface_vol_ratio] surface / feature_dict[tumor_vol_mm3] # 体积占比肿瘤核心占整体肿瘤的比例能反映恶性程度 feature_dict[core_ratio] ( feature_dict[necrosis_vol] feature_dict[enhance_vol] ) / feature_dict[tumor_vol_mm3] return feature_dict表面与体积比里用的是一层腐蚀近似严格写法应该用 Marching Cubes 提取三角网格算表面积但那需要额外装 scikit-image 的 measure 库在毕设工程里一层腐蚀的近似已经够用。核心占比core_ratio是生存预测里很强的单变量特征因为坏死和增强区域的比例和肿瘤的侵袭性直接相关。4.3 生存预测模型的输入组织与标签处理BraTS 的生存预测任务给的是每位患者的实际生存天数但这批数据里有相当一部分患者到数据收集截止时还没去世这类样本在统计上叫右删失right-censored。也就是说你不能直接拿生存天数做回归否则删失样本会被错误地当成「恰好活了这么长时间」。常见做法是使用随机生存森林Random Survival Forest或 Cox 比例风险模型它们在训练时会把删失样本当作「至少存活了这么久」来处理。from sksurv.ensemble import RandomSurvivalForest from sksurv.util import Surv # features: DataFrame列包含 extract_tumor_features 的结果和年龄等临床变量 # y_surv: 结构化数组cens 表示是否删失1事件发生0删失time 是生存天数 y Surv.from_arrays( eventnp.array([1 if c 0 else 0 for c in censoring]), timesurvival_days ) rsf RandomSurvivalForest( n_estimators500, min_samples_split10, random_state42 ) rsf.fit(features, y)生存预测模型里不要用普通神经网络硬刚。BraTS 数据集患者数量只有一两百例深度模型没有足够的数据学生存模式反而是随机生存森林这种经典方法在小样本删失数据上表现更稳定。报告中可以加一句用随机生存森林 分割特征体积、核心占比、表面比作为生存预测基线比直接在 MRI 上做 3D 分类的稳定性好很多。评估指标用 C-index也就是预测风险分数和实际生存时间排序的一致性0.5 等于随机猜测0.7 以上就是可用的模型。5. 避坑清单从数据到部署的 5 个翻车现场与排查5.1 训练时显存 OOM但别人说 16G 就能跑 128 patch现象同样的 patch size 和 batch size别人 16G 能跑你的卡一启动就 OOM。原因多半是你把四模态作为四个独立通道输入本身没错但你的网络第一层卷积输出通道设置偏大或者你开了梯度累积却忘了清空计算图。解决先关闭 AMP 之外的任何梯度累积把 batch size 降为 1 确认能跑再逐步往上加。如果 batch size 1 都 OOM把编码器第一层通道数从 32 降到 16深层通道按比例减半参数量降成原来的四分之一Dice 通常只掉 0.02 左右。5.2 验证集 Dice 很高但肉眼预测的肿瘤区域明显偏移现象指标有 0.85斜着切一张 FLAIR 切片叠加预测结果发现预测的肿瘤和真实的完全不在一个脑回上。原因验证时只用了整图滑窗推理而训练时的 patch 中心大多锚定肿瘤模型对全脑范围背景区域的表现没有经过充分训练滑窗扫到远离肿瘤的区域时会输出一个「幻觉肿瘤」。解决推理时把肿瘤 bias 采样改成 0.5让模型见过更多纯背景 patch或者推理时对滑窗结果做形态学后处理去掉体积小于 1cm³ 的连通域。5.3 同一个 .nii 文件两次读出来的数组方向不一样现象你用了 nibabel同事用了 SimpleITK预处理出来的数组形状一样但对应切片内容上下颠倒或左右翻转。原因NIfTI 文件头里 qform/sform 定义了体素到解剖坐标的仿射变换nibabel 默认保持原始体素存储方向而 SimpleITK 会把数据重排到标准方向通常是 RAS。解决预处理阶段统一用 SimpleITK 读取并重采样所有环节都用同一套 I/O不要混用对比结果时只看叠加图不查坐标系是发现不了这个问题的务必在预处理脚本里打印 direction 和 origin 核对。5.4 生存预测模型无论怎么调参C-index 都停在 0.55 附近现象从分割结果提取了七八个特征随机生存森林各参数都试了一圈评价指标就是上不去。原因特征只拿了体积类特征缺少肿瘤在脑区中的位置信息和多模态影像特征比如增强环的厚度、FLAIR 高信号是否跨过中线。解决额外加入增强环厚度对 ET 区域做距离变换取骨架、肿瘤质心到脑中线平面的距离、以及 T1ce 信号在增强区域内的均值与标准差。这三个特征能把 C-index 从 0.55 拉到 0.65 以上。如果还不够检查删失比例若事件发生样本太少C-index 的方差会很大换用时间依赖 AUC 更合适。5.5 训练和推理的预处理不一致导致结果忽好忽坏现象训练时数据归一化只对脑区 mask 内做推理时图省事对整图做了 min-max结果在验证集上时好时坏有些病例 Dice 掉 20 个点。原因分布偏移推理时的输入分布和训练分布不一致。解决把预处理封装成一个函数训练、验证、推理全部调用同一份代码不要复制粘贴再改参数。更隐蔽的版本是训练时做了N4推理时忘了或者训练时四模态通道顺序是 T1/T1ce/T2/FLAIR推理时按 FLAIR 开头。这类坑查起来很费时最好的办法是训练脚本结束时把预处理参数 json dump 到模型目录推理时强制读取。6. 验证与进阶用 5 折交叉验证把你的毕设结果写进报告6.1 五折交叉验证与报告里要交代的指标单次划分训练集和验证集在医学影像小数据集上说服力不够BraTS 一个模态一两百例随便划分运气成分太大。建议做 5 折交叉验证每折训练完保存模型最后把 5 个模型的结果合起来算 Dice、Hausdorff 距离95%、敏感度和特异性。Hausdorff 距离这个指标很多人只写在报告里但没真正实现用 medpy.metric.binary 可以一行算出来但它对异常体素很敏感报告中记得写明用 95 百分位而不是最大值。6.2 一个能省一半训练时间的技巧无论你最终用 3D-UNet 还是 VNet第一次跑通时都建议把训练轮数减半、用 2D-UNet 验证整套数据管线没有问题再上 3D 模型调显存和 patch 参数。我见过太多同学拿着 3D-UNet 调了两周显存最后发现是数据路径读错了——先跑通再调优是最省时间的方式虽然听起来像废话但踩坑最多的恰恰是这一步。分割模型选型时我也习惯先看项目报告的实验结果表格如果 3D-UNet 和 3D-VNet 的 Dice 差距在 0.02 以内直接选结构和代码更简单的那套把时间留给生存预测模型的调优。我自己做这套方案时最大的教训是太早追求 3D 网络的 SOTA 指标忽略了基线模型和数据处理的可视化验证后来省悟过来才把重心放到「先把一条完整链路跑通」上。希望帮到你。本文还有配套的精品资源点击获取