简介:面向医学影像分析与深度学习方向的高校学生、科研工作者,这份毕设资源系统实现了三种脑肿瘤分割算法,并包含生存预测模型和项目报告,主要解决从算法理论到代码落地、从结果评估到论文撰写的完整需求。资源共五十个文件,以脚本和交互式笔记文档为主,涵盖模型结构定义、数据预处理、指标计算、训练验证等关键环节,同时附有设计报告、项目说明文档、可直接调用的预测模型以及实验数据文件,整体压缩包仅三点零五兆,轻量便于下载复现。目前已有九十三人学习下载。内容兼顾二维多类别分割、三维网络集成与生存分析回归分类等模块,既有基础模型也有改进思路,既适合作为毕业设计、课程设计的完整参照,也适合医学图像入门者逐步读懂分割与预测的整体流程,还可以基于现有代码扩展其他模态或组织分割实验。
1. 毕设做脑肿瘤分割:为什么是2D-UNet、3D-UNet、3D-VNet三件套
医学图像方向的毕业论文里,“脑肿瘤分割算法”永远是个热门题目:数据集公开、评估指标成熟,而且从分割掩膜往下还能继续做生存预测,等于一个题目串起图像处理和临床分析两段故事。这套方案选三个模型——2D-UNet、3D-UNet、3D-VNet——不是随便凑数:2D-UNet负责把整个训练流程跑通,3D-UNet验证体积上下文的价值,3D-VNet则用残差结构和Dice Loss给出更稳的收敛路线。对于预算有限的个人毕设,用BraTS这类公开数据做基准,按“预处理→三个模型对比→生存预测→统计分析”的路径做下来,成果可复现、故事完整,答辩时每个环节都有东西可讲。下面按这个顺序展开,代码和参数给到可以直接改着跑的粒度。
2. 多模态MRI预处理:BraTS格式、四个序列与训练输入
2.1 四个MRI序列在分割里各管什么
BraTS挑战赛是目前脑肿瘤分割研究的事实性基准,每个病例提供四个模态的MRI:T1、T1ce(钆对比剂增强后T1)、T2和FLAIR(液体衰减反转恢复)。简单理解:T1给出大致解剖轮廓;T1ce让血脑屏障被破坏的活跃肿瘤区域高亮;T2突出血管源性水肿;FLAIR能压掉脑脊液信号,让水肿区域与周围组织的对比更干净。但这四组图像不是天然对齐的,不同扫描时间点的层厚、像素间距都可能不同,拿到数据第一件事就是确认方向、分辨率和空间位置是否一致。
BraTS官方发布的数据已完成去颅骨和重采样到1mm×1mm×1mm的预处理,所以拿到就能直接用。如果换成别的数据集或自己收集的临床数据,配准和重采样这两步必须自己做。我一般先读header里的affine矩阵,检查四个模态的原点(origin)和像素间距(pixdim)是否一致,不一致就用T1作为参考模态做刚性配准,再统一缩放到相同分辨率。这个步骤偷懒的话,后面模型学的全是错位特征,Dice会卡在很低的水平上不去。
2.2 NIfTI读取与预处理:从原始体素到网络输入
常见做法是用nibabel读取NIfTI文件,再把四个模态堆叠成多通道数据。第二步是预处理:MRI体素值没有绝对的物理意义,不同扫描仪的数值范围差异很大,通常做z-score归一化,按每个模态的非零区域计算均值和标准差。也有人先做1%到99%分位数裁剪再归一化,防止个别极亮体素拉偏分布,这个看数据集情况,BraTS上直接z-score就够了。
import nibabel as nib import numpy as np def load_volume(path): img = nib.load(path) data = img.get_fdata() return data.astype(np.float32) def preprocess_case(t1, t1ce, t2, flair, label, spacing): # 四个模态堆叠成 C x H x W x D,C=4 x = np.stack([t1, t1ce, t2, flair], axis=0) # 逐模态z-score,只统计非零体素,避免背景拖低均值和方差 for c in range(4): mask = x[c] > 0 if mask.sum() > 0: mean = x[c][mask].mean() std = x[c][mask].std() + 1e-8 x[c] = (x[c] - mean) / std # 生成三通道二值标签:WT/TC/ET,顺序固定 wt = (label > 0).astype(np.int64) # 1+2+4 tc = np.isin(label, [1, 4]).astype(np.int64) # 坏死+增强 et = (label == 4).astype(np.int64) # 只保留增强 y = np.stack([wt, tc, et], axis=0) return x, y参数说明:z-score里加1e-8是防止某个模态全为0时除零报错,这在数据缺失的病例里会出现。标签不转成单通道多类,而是转成三通道二值,因为BraTS的三个区域是嵌套关系(ET在TC内,TC在WT内),用softmax输出互斥类别在逻辑上说不通,用sigmoid逐通道判断每个体素属于哪个区域才对。输出通道顺序固定为WT、TC、ET,后续Dice计算和评估都按这个顺序来。
预处理完成后建议直接存成npy格式,不要每次训练都重新读nii.gz再归一化。BraTS一例数据四个模态加起来约50MB,重采样后是240×240×155,如果每次epoch都现读现处理,一个epoch下来光I/O就要等很久。存npy虽然占用磁盘,但是换来训练速度翻倍,毕设期间这点磁盘开销很值。
2.3 评估指标与对拍策略:Dice、HD95和嵌套区域
BraTS官方的评估指标主要是Dice和HD95(95%豪斯多夫距离)。Dice衡量区域重叠,对体积大的区域友好;HD95衡量边界差异,对边缘误差敏感,但实现细节多,不同的连通域定义会带来不小的数值差异。毕设报告里建议对三个区域分别报Dice和HD95,不要只写一个“平均Dice”,答辩时老师一定会追问“哪个区域的Dice”。
对拍也有讲究:三个模型必须用同一套验证集、同一个采样策略、同样的数据增强,才谈得上公平比较。另一个细节是,验证时2D模型按切片推理,3D模型直接推理patch,两者结果的切片连续性天然不同,所以验证推理时都要用滑动窗口或overlap-tile,保证评估口径一致。我在项目里把验证统一成“固定patch网格”的方式,先记录所有验证patch的坐标,三个模型用同一份坐标推理。
2.4 数据划分:按患者拆,不要按切片拆
这是新手最容易犯的错。脑肿瘤数据同一患者相邻切片高度相似,如果按切片随机划分训练集和验证集,同一个患者的部分切片出现在两边,相当于验证集泄漏进训练集,指标虚高。正确做法是按患者ID划分,所有切片或patch要么全在训练集,要么全在验证集。我会把划分结果存成一个JSON文件,里面固定好训练和验证的患者ID列表,并记录随机种子,这样三个模型共用同一份划分,实验结果可复现。
import json, random patient_ids = sorted(set(all_patients)) random.seed(42) random.shuffle(patient_ids) split = { 'train': patient_ids[:int(len(patient_ids) * 0.8)], 'val': patient_ids[int(len(patient_ids) * 0.8):] } with open('split.json', 'w') as f: json.dump(split, f, indent=2)随机种子固定成42只是习惯,关键是三个模型训练前都读取同一个split.json,不要让每次运行重新划分。这份split文件相当于整个项目的“后悔药”,后期加模型、跑消融实验都靠它保证公平性,否则重跑一遍实验结果对不上,还得从头排查。
3. 三种网络从零搭建:2D-UNet、3D-UNet、3D-VNet的差异与选型
3.1 2D-UNet:切片级baseline,为什么不能省
2D-UNet是Ronneberger等人2015年提出的结构,编码器逐层下采样提取语义,解码器逐层上采样恢复分辨率,跳跃连接把浅层细节传给深层。脑肿瘤分割里,2D版本一般按横断面(轴向)逐切片训练,它的优势是显存占用小、训练快、调试方便,最适合先把整个数据pipeline和训练循环跑通。
import torch import torch.nn as nn class DoubleConv(nn.Module): def __init__(self, in_ch, out_ch): super().__init__() self.conv = nn.Sequential( nn.Conv2d(in_ch, out_ch, 3, padding=1), nn.BatchNorm2d(out_ch), nn.ReLU(inplace=True), nn.Conv2d(out_ch, out_ch, 3, padding=1), nn.BatchNorm2d(out_ch), nn.ReLU(inplace=True), ) def forward(self, x): return self.conv(x) class UNet2D(nn.Module): def __init__(self, in_ch=4, out_ch=3): super().__init__() self.enc1 = DoubleConv(in_ch, 64) self.enc2 = DoubleConv(64, 128) self.enc3 = DoubleConv(128, 256) self.enc4 = DoubleConv(256, 512) self.pool = nn.MaxPool2d(2) self.enc5 = DoubleConv(512, 1024) self.up4 = nn.ConvTranspose2d(1024, 512, 2, stride=2) self.dec4 = DoubleConv(1024, 512) self.up3 = nn.ConvTranspose2d(512, 256, 2, stride=2) self.dec3 = DoubleConv(512, 256) self.up2 = nn.ConvTranspose2d(256, 128, 2, stride=2) self.dec2 = DoubleConv(256, 128) self.up1 = nn.ConvTranspose2d(128, 64, 2, stride=2) self.dec1 = DoubleConv(128, 64) self.outc = nn.Conv2d(64, out_ch, 1) def forward(self, x): e1 = self.enc1(x) e2 = self.enc2(self.pool(e1)) e3 = self.enc3(self.pool(e2)) e4 = self.enc4(self.pool(e3)) e5 = self.enc5(self.pool(e4)) d4 = self.dec4(torch.cat([self.up4(e5), e4], 1)) d3 = self.dec3(torch.cat([self.up3(d4), e3], 1)) d2 = self.dec2(torch.cat([self.up2(d3), e2], 1)) d1 = self.dec1(torch.cat([self.up1(d2), e1], 1)) return self.outc(d1)这段代码里,in_ch=4对应四个模态,out_ch=3对应WT/TC/ET三个区域,输出直接是未过sigmoid的logits。跳跃连接用torch.cat按通道拼接,保留原始UNet的设计。通道数从64起步逐层翻倍到1024,在2D输入下显存还可以接受,但要注意输入切片尺寸必须是16的倍数,否则下采样到最后一层时尺寸对不上。
2D-UNet推理时按轴向逐切片预测,再堆叠回三维体。缺点是切片之间缺少上下文,容易出现层间不连续,所以推理时最好在z方向做overlap,比如一次输入连续3张切片,只取中间那张的预测结果。这个技巧能明显减少“跳变伪影”,代码里用一个滑动窗口实现即可。做毕设对比时,2D-UNet的结论价值在于证明“3D网络比2D强多少”,所以它必须存在。
3.2 3D-UNet:直接吃体积数据,显存与patch怎么平衡
3D-UNet就是把2D结构中的二维卷积全部换成三维卷积,直接对体积patch做分割。实现上可以复用DoubleConv的代码,把nn.Conv2d改成nn.Conv3d、nn.MaxPool2d改成nn.MaxPool3d、nn.ConvTranspose2d改成nn.ConvTranspose3d,其余结构不变。但实践上不能照搬,因为三维卷积的显存占用随patch尺寸立方级增长,照抄2D的通道配置会直接显存溢出。
我的经验是3D版本把encoder最大通道数限制在256或320,而不是2D的1024。输入patch常用96×96×96或128×128×64,显存紧张时优先降patch到64×64×64,而不是把batch size降到1后还硬开混合精度。batch size太小时BatchNorm统计不稳定,训练曲线抖动很厉害,调参变得非常被动。
class UNet3D(nn.Module): def __init__(self, in_ch=4, out_ch=3, base_ch=32): super().__init__() # 通道数:32 -> 64 -> 128 -> 256,最深层封顶 self.enc1 = DoubleConv3D(in_ch, base_ch) self.enc2 = DoubleConv3D(base_ch, base_ch * 2) self.enc3 = DoubleConv3D(base_ch * 2, base_ch * 4) self.enc4 = DoubleConv3D(base_ch * 4, base_ch * 8) self.pool = nn.MaxPool3d(2) self.dec3 = DoubleConv3D(base_ch * 8, base_ch * 4) self.dec2 = DoubleConv3D(base_ch * 4, base_ch * 2) self.dec1 = DoubleConv3D(base_ch * 2, base_ch) self.up = nn.ConvTranspose3d(base_ch * 8, base_ch * 4, 2, stride=2) self.outc = nn.Conv3d(base_ch, out_ch, 1) self.eps = 1e-8 def forward(self, x): e1 = self.enc1(x) e2 = self.enc2(self.pool(e1)) e3 = self.enc3(self.pool(e2)) e4 = self.enc4(self.pool(e3)) u3 = self.dec3(torch.cat([self.up(e4), e3], 1)) u2 = self.dec2(torch.cat([nn.functional.interpolate(u3, scale_factor=2, mode='trilinear', align_corners=False), e2], 1)) u1 = self.dec1(torch.cat([nn.functional.interpolate(u2, scale_factor=2, mode='trilinear', align_corners=False), e1], 1)) return self.outc(u1)上面的实现里base_ch=32时,最深一层是256通道,配合96³的patch在12GB显存上能跑起来。如果你照搬2D的64起步、底层1024通道,96³的patch在同样显卡上直接OOM,这是换3D时最常见的翻车点。上采样用了trilinear插值配合conv,替代部分转置卷积,减少棋盘伪影,显存也比转置卷积略省。
另外提醒一句:3D-UNet的权重文件比2D大很多,训练时间也长,别用默认的“每epoch保存一次”策略,否则磁盘写满不说,路径管理也乱。我一般只保存验证集Dice最高的权重和一个最近的checkpoint,覆盖式写入。
3.3 3D-VNet:残差连接和Dice Loss带来什么变化
3D-VNet是Milletarì等人2016年提出的,和3D-UNet最关键的差异有两处:编码器每阶段用残差块,损失函数直接采用Dice Loss。残差块让梯度可以跨层传播,网络加深时不容易出现梯度消失;Dice Loss让优化目标和评估指标保持一致。在脑肿瘤这类前景占比很小的任务上,普通交叉熵容易被背景体素淹没,Dice Loss受类别不平衡影响小得多。
class VNetBlock(nn.Module): def __init__(self, in_ch, out_ch): super().__init__() self.conv1 = nn.Conv3d(in_ch, out_ch, 3, padding=1) self.bn1 = nn.BatchNorm3d(out_ch) self.conv2 = nn.Conv3d(out_ch, out_ch, 3, padding=1) self.bn2 = nn.BatchNorm3d(out_ch) self.shortcut = nn.Conv3d(in_ch, out_ch, 1) if in_ch != out_ch else nn.Identity() self.relu = nn.ReLU(inplace=True) def forward(self, x): h = self.relu(self.bn1(self.conv1(x))) h = self.bn2(self.conv2(h)) return self.relu(h + self.shortcut(x))这个代码块里,shortcut在输入通道和输出通道不一致时用1×1×1卷积对齐,通道一致时直接恒等映射。残差连接的加法发生在第二个BN之后、ReLU之前,这是常见做法,梯度回流比直接相加后过ReLU更稳。
def dice_loss(pred, target, smooth=1.0): # pred是sigmoid后的概率,target是0/1二值标签 inter = (pred * target).sum(dim=(2, 3, 4)) union = pred.sum(dim=(2, 3, 4)) + target.sum(dim=(2, 3, 4)) dice = (2 * inter + smooth) / (union + smooth) return 1 - dice.mean()Dice Loss的参数说明:smooth取1.0是经验值,防止某个体素全是背景时分子分母同时为0;求均值时对三个通道分别算Dice再取平均,而不是把所有通道展平成一个向量,这样小区域(ET)不会完全被WT主导。注意pred要先经过sigmoid,不做softmax,因为三个输出通道不是互斥的。
VNet另一个细节是下采样用步长为2的卷积而不是MaxPooling,这样做能保留更多位置信息,但显存占用稍高。毕设里如果你用我上面的3D-UNet结构作为对照,把损失函数换成Dice Loss、编码器加深为残差块,就能复现一份“3D-VNet风格”的实现,报告里说明改进来源即可。
3.4 训练配置:patch、batch、学习率与混合精度
训练配置我一般固定为:AdamW优化器,初始学习率1e-3,3D模型降到5e-4;用余弦退火调度器,最小学习率设为1e-5;数据增强只用随机翻转和随机90度旋转,不做弹性形变,因为脑肿瘤边界评估对形变敏感,形变会让边界指标失真。2D模型输入切片尺寸取192×192,3D模型patch取96×96×96,batch size根据显存调整。
混合精度(AMP)在3D模型上能省大约30%显存,PyTorch里直接用torch.autocast包住前向和loss计算即可。但有个坑:BatchNorm在fp16下统计不稳定,我一般会关闭amp对BN层的自动混合,或者用tf32模式代替。如果你用单卡训练,torch.backends.cudnn.benchmark设为True,输入尺寸固定后卷积会选更快的算法,训练速度能提升10%左右。
scaler = torch.cuda.amp.GradScaler() for epoch in range(max_epochs): for x, y in train_loader: x, y = x.cuda(), y.cuda() with torch.autocast(device_type='cuda', dtype=torch.float16): logits = model(x) loss = dice_loss(torch.sigmoid(logits), y) scaler.scale(loss).backward() scaler.step(optimizer) scaler.update() optimizer.zero_grad(set_to_none=True)这个循环里,scaler和autocast配对出现,否则梯度会下溢。每次batch结束后zero_grad,set_to_none=True比默认的置零省内存。lr_scheduler放在epoch末尾更新,验证集Dice提高时把模型权重用deepcopy保存下来,防止后续epoch过拟合把最优权重覆盖掉。
模型训练完保存时,我习惯连同patch大小、归一化参数、通道顺序一起存成一个config.json,推理时加载同一个配置文件。这个文件就是你的实验“后悔药”,不然过了两周再跑推理,你根本想不起来当时用的patch是96还是80。
4. 生存预测模型:从分割结果到风险分组的完整链路
4.1 特征工程:体积、位置、强度统计
BraTS的生存预测子任务要预测患者属于长期生存、中期生存还是短期生存。最常用的方法是从分割掩膜中提取一组手工特征,再喂给经典机器学习模型。分割结果的质量直接决定特征质量,所以这个环节必须用验证集上最优的模型权重做推理,不能用训练最后一轮的权重。
我提取的特征包括三类:体积特征、位置特征、强度特征。体积特征先统计WT、TC、ET三个区域的体素数,再乘每个体素的物理体积(mm³或cm³),不能直接用像素数,因为不同病例的spacing可能不同。位置特征计算肿瘤重心与大脑重心的偏移量,以及肿瘤是否跨过中线,跨中线是临床预后相关的重要信号。强度特征则在T1ce和FLAIR的肿瘤区域内计算均值和标准差,反映肿瘤的异质性。
import numpy as np def extract_features(vol_modal, seg, spacing): # vol_modal: HxWxDx4,T1,T1ce,T2,FLAIR # seg: 三通道二值 (WT,TC,ET) 或单通道多类,我统一用三通道 voxel_volume = np.prod(spacing) / 1000.0 # mm^3 -> cm^3 feats = {} for name, ch in zip(['WT', 'TC', 'ET'], range(3)): mask = seg[ch] > 0.5 feats[f'{name}_vol_cm3'] = mask.sum() * voxel_volume if mask.sum() > 10: feats[f'{name}_t1ce_mean'] = vol_modal[..., 1][mask].mean() feats[f'{name}_t1ce_std'] = vol_modal[..., 1][mask].std() feats[f'{name}_flair_mean'] = vol_modal[..., 3][mask].mean() else: feats[f'{name}_t1ce_mean'] = 0.0 feats[f'{name}_t1ce_std'] = 0.0 feats[f'{name}_flair_mean'] = 0.0 return feats参数说明:体积转换成cm³是为了让特征数值落在可解释的量级,WT体积通常几十cm³,ET体积几cm³,这个量级对随机森林和Cox回归都友好。mask.sum()小于10的病例视为该区域预测为空,强度特征置零,避免用噪声体素算平均。特征最终拼成一个向量,先做标准化再进模型,因为体积特征和强度特征量纲不同,不标准化的话树模型影响不大,但对Cox回归影响明显。
4.2 模型对比:Cox回归、随机森林与XGBoost
生存预测有两种建模思路:一种把生存月数当回归目标,用随机森林、XGBoost这类树模型直接预测数值;另一种用Cox比例风险模型输出风险分数,然后根据分数分组。毕设里两个都做、互相印证,说服力最强。Cox回归的好处是可解释性好,能给每个特征一个风险比;随机森林对噪声和小样本鲁棒;XGBoost容错强但对超参数敏感。
from sklearn.ensemble import RandomForestRegressor from sklearn.model_selection import cross_val_score from sklearn.pipeline import make_pipeline from sklearn.preprocessing import StandardScaler X = feature_matrix # 形状 (n_samples, n_features) y = survival_months # 连续值,生存月数 model = make_pipeline( StandardScaler(), RandomForestRegressor(n_estimators=500, max_depth=6, min_samples_leaf=3, random_state=42) ) scores = cross_val_score(model, X, y, cv=5, scoring='neg_mean_absolute_error') print('MAE:', -scores.mean())n_estimators=500、max_depth=6、min_samples_leaf=3是我在小样本(通常不到200例)上的常用配置。depth限制在6防止过拟合,min_samples_leaf=3保证叶子节点有一定样本量。cv=5是因为样本少,5折比10折更稳,且每次折的数据量够模型学习。
树模型在这个任务上通常比线性模型表现好,因为体积特征与生存期的关系不是纯线性的。但小数据集上树模型方差大,训练时固定random_state,否则你会看到同一份数据两次训练结果差异不小,答辩时这种不可复现会被挑刺。
4.3 评估:C指数与分组显著性检验
预测生存期不能只看MAE,临床上更常用的是C-index(一致性指数),衡量模型给任意两位患者排序时,预测相对风险顺序和真实生存顺序一致的概率。C-index=0.5表示随机水平,0.7以上有临床应用价值。它天然处理删失数据,所以比分类准确率更适合这个任务。
三分类做完之后,还要验证分组是否有统计学意义:把样本按预测分成高风险组和低风险组,用log-rank检验比较两组生存曲线是否有显著差异,p<0.05才说明模型是真的有区分度。这一步不做,评委大概率会问“你的生存预测到底有没有临床意义”。
from lifelines import KaplanMeierFitter from lifelines.statistics import logrank_test kmf = KaplanMeierFitter() kmf.fit(durations=df['months'], event_observed=df['event'], label='high') ax = kmf.plot_survival_function() high = df[df['group'] == 1] low = df[df['group'] == 0] result = logrank_test(high['months'], low['months'], event_observed_A=high['event'], event_observed_B=low['event']) print('p-value:', result.p_value)这个代码里,df['event']是删失事件标记,1表示观察到终点事件,0表示删失;df['group']是模型预测的分组标签。lifelines库自己实现了一种针对右删失数据的log-rank检验,直接输出p值。画生存曲线时别忘了给两条曲线加标注置信区间,截图放进论文里就是标准的Kaplan-Meier图。
5. 避坑清单:显存、样本不平衡、实验公平性与复现问题
5.1 显存溢出:batch降到1仍然爆显存
现象:3D模型一训练就报CUDA out of memory,把batch size从4降到2再降到1,还是爆。
原因:编码器通道数翻倍太快是主要元凶。直接把2D-UNet的通道配置复制到3D,底层通道动辄512或1024,patch又是96³,显存必然不够。另一个次要原因是把验证和训练的模型同时加载在显存里,或者验证时没包torch.no_grad。
解决:先查通道数,把3D模型底层通道封顶在256或320;再查验证代码是否漏了no_grad;最后才考虑降patch尺寸。patch从96降到64,显存占用能降大约70%,但精度会有轻微损失。调整顺序按“通道数→验证代码→patch”来,前两个是白捡的优化,最后一个是取舍。
5.2 训练loss下降但Dice卡在0.3以下
现象:Dice Loss从0.9降到0.5就再也不动,验证集Dice只有0.25左右,可视化发现模型把几乎所有体素都预测为背景。
原因:这是典型的类别不平衡加上优化目标不一致。虽然用了Dice Loss,但如果网络输出层对三个通道做了softmax而不是sigmoid,嵌套区域在概率上互相抑制,模型只能选一个区域预测,ET几乎学不出来。还有一种情况是数据增强里对图像和标签用了不同的随机变换,导致标签和输入对不上。
解决:输出层改成3通道sigmoid,每个通道独立二值化,loss用每个通道Dice的均值。检查增强代码里是不是对image和mask用了同一个随机种子,或者用torchvision的from_dict这类同步增强接口。另外,把背景通道从损失计算里去掉,只对WT/TC/ET三个前景通道算Dice,模型学得更快。
5.3 三个模型验证集指标差不多,报告没有说服力
现象:2D-UNet、3D-UNet、3D-VNet验证集Dice都在0.85左右,差距不到0.01,不知道结论怎么写。
原因:对比不公平是常见原因。比如2D模型用切片采样,3D模型用patch采样,两者看到的训练数据分布完全不同;或者2D的输入分辨率和3D不一致,2D输入192²切片,3D输入96³patch,信息量本来就不一样。
解决:把三个模型的输入patch都统一到同样的物理覆盖范围,2D的切片也裁到和3D patch相近的区域。固定相同的epoch数、相同的优化器参数、相同的数据增强种子。做消融时一次只变一个变量:比结构就都用Dice Loss,比损失函数就都默认UNet结构。报告里写“网络结构提升Dice约0.8个点,Dice Loss进一步带来0.5个点”这种结论,每一步都有依据。
5.4 验证集Dice波动大,早停选不准节点
现象:训练过程中验证Dice每几个epoch波动0.05以上,早停时保存的权重换个时间点评估结果差异很大。
原因:验证集的候选patch每次epoch重新随机采样,不同epoch验证集本身就不一样,评估结果自然不稳定。另一个原因是验证集样本太少,几十个病例的Dice方差本身就大。
解决:固定验证采样。在训练前把验证集每个病例切成固定网格的patch,存好坐标,每个epoch用同一组patch做评估。如果显存允许,验证时对整例数据做滑动窗口推理,完全避免patch采样带来的波动。早停的patience设大一点,比如15或20个epoch,不要看到一次下降就停下来。
5.5 生存预测结果很好但复现不出来
现象:交叉验证时的C-index有0.75,单独跑一次测试集只有0.55,差距巨大。
原因:交叉验证的每一折都做了特征标准化,但如果你把标准化器在整个数据集上fit之后再交叉验证,相当于把测试集信息泄漏进了训练过程,C-index虚高。另一个原因是特征里有突变特征(比如某些患者ET区域为空导致强度特征为0),统计模型容易过拟合这部分。
解决:特征标准化严格放在CV循环内部,StandardScaler只fit训练折,再transform验证折。把特征里方差接近0的列删掉,或者在树模型里限制max_features。报告里同时报交叉验证均值和标准差,并注明特征筛选是在训练折内完成的,不然审稿老师一眼就能看出bug在哪里。
6. 进阶玩法:把分割和生存预测串成端到端流水线
分割和生存预测在项目里通常分开做,但实际交付时最好串成一条流水线:输入一个病例的四模态MRI,输出分割掩膜、三个区域的体积报告和生存风险分组。我在毕设里把三个分割模型分别跑完TTA,作为“系统版”的最终输出。TTA就是推理时把输入做8个方向的翻转和90度旋转,得到8个概率图后取平均,再对平均概率图做阈值。这一步一般能提升0.5到1个Dice点,代价是推理时间变长,报告里加一张计算开销对比表说明清楚。
分割结果后处理还有一个常见技巧:用连通域分析去掉孤立的小预测块。ET区域尤其容易出现散布的假阳性体素,我通常保留最大连通域,或者保留体积超过某个阈值(比如0.1cm³)的连通域,其余全部清零。这个处理必须在Dice评估之前做,并且要对三个模型一视同仁,否则又制造了新的不公平。
生存预测部分,除了手工特征,也可以把分割模型的编码器特征利用起来:对3D模型的深层特征图做全局平均池化,拉成一个低维向量,拼接到手工特征里,再一起进XGBoost。这样网络学到的纹理信息也参与预后预测。但要注意,如果病例数只有一两百,深度特征维度不能太高,拼之前先用PCA降到10到20维,防止维度灾难。
我自己的检查习惯是,每个模型训练完,至少随机挑5个病例,把预测掩膜和T1ce、FLAIR在3D Slicer或itk-snap里叠加着看一遍。指标会骗人,但视觉效果不会——ET区域覆盖到坏死区这种事,Dice可能只掉0.02,肉眼看却非常明显。看切片还有一个好处,能确认预处理阶段有没有把左右方向搞反、标签有没有错位,这些是任何指标都查不出来的低级错误。每次看完把截图存档,论文里的qualitative结果图就有了。
这条流水线串完之后,你手上其实是一个完整的量化分析工具,而不只是三个训练好的模型。遇到新的MRI数据,改一下路径参数就能直接跑出分割和预后结果。这个方向值不值得投入:如果你需要快速出成果且可复现,脑肿瘤分割加生存预测是医学图像毕设里性价比最高的一类题目,公开数据、公开基线、评估指标明确,哪怕模型效果不SOTA,整条链路做得规范、对比公平、统计分析完整,答辩就站得住。希望这些踩坑经验能帮你少走点弯路,把精力留到真正有意思的地方去。
本文还有配套的精品资源,点击获取