1. 项目缘起与整体思路拆解
1.1 为什么选择BraTS作为主攻方向
脑部肿瘤影像分割这个方向,我在读研期间前前后后投入了将近两年时间,BraTS 2018、2019、2020三届数据集我都完整跑过一遍。说实话,一开始选这个方向并不是因为什么高大上的理由,纯粹是因为它是医学影像分割领域少有的、标注质量足够高、评测体系足够规范、参赛队伍足够多的公开基准。对于想入门医学图像分割的人来说,BraTS几乎是绕不开的一块试金石。
BraTS全称Brain Tumor Segmentation Challenge,是MICCAI会议下面的一个经典挑战赛。它提供的是多模态磁共振影像,每个病例包含四个模态:T1、T1ce、T2、FLAIR。标注区域分为三类:增强肿瘤区(ET)、肿瘤核心区(TC,包含ET和坏死区NCR)、以及整个肿瘤区(WT,包含TC和水肿区ED)。这个三层嵌套的标注体系是BraTS最核心的设计,也是后面所有模型结构、损失函数设计都要围绕的核心约束。
我之所以把2018到2020三届放在一起总结,是因为这三届之间的变化非常典型,能完整反映出一个医学影像项目从“能跑通”到“跑得好”再到“稳定复现”的整个演进过程。2018年数据量相对小,评测指标以Dice和Hausdorff距离为主;2019年引入了更严格的验证流程和更多样的病例;2020年则进一步扩大了数据规模,并且对标注一致性做了更细致的处理。把这三届串起来看,你会发现很多工程上的坑是重复出现的,而模型结构上的创新反而没有想象中那么关键。
1.2 整体技术路线的三次迭代
我最初的做法非常朴素:直接拿一个3D U-Net,把四个模态堆成4通道输入,输出3通道的softmax,用Dice损失训练。这个方案在2018数据上能跑到WT的Dice大概0.85左右,但ET的Dice只有0.65上下,而且Hausdorff距离非常难看。问题出在两个地方:一是ET区域本身很小,类别极度不平衡;二是3D卷积对显存要求太高,batch size只能开到1或者2,训练极不稳定。
第二次迭代我做了三件事:第一,把损失函数换成Dice加交叉熵的加权组合,并且对ET类别单独加权;第二,引入深度监督,在解码器的多个层级都加辅助损失;第三,把patch size从128×128×128降到96×96×96,同时用随机裁剪做数据增强。这一轮下来,ET的Dice提到了0.72左右,WT稳定在0.88。但新的问题出现了:验证集和测试集的差距开始变大,过拟合迹象明显。
第三次迭代也就是2020年那一版,我重点解决泛化问题。具体做法包括:更激进的数据增强(随机弹性形变、随机亮度对比度扰动、随机模态丢弃)、在训练中动态调整patch采样策略(保证每个batch里至少有一定比例的肿瘤区域)、以及用五折交叉验证来做模型集成。最终在本地验证集上,WT的Dice做到0.90,TC做到0.82,ET做到0.76,Hausdorff距离也降到了可接受范围。
这里我想强调一个观点:BraTS这类任务,模型结构带来的收益往往没有数据增强和训练策略带来的收益大。我见过太多人一上来就堆Transformer、堆注意力机制,结果Dice还不如一个调好参数的3D U-Net。先把数据管线、损失函数、采样策略这三件事做扎实,再考虑换 backbone。
1.3 项目适合谁来参考
这篇总结适合三类人:第一类是想入门医学图像分割的学生或者转行者,BraTS是一个非常好的练手项目,数据干净、评测标准明确;第二类是在做类似多模态分割任务的工程师,BraTS里关于多模态融合、类别不平衡、小目标分割的经验可以直接迁移;第三类是做模型部署或者工程化落地的同学,BraTS的预处理、后处理、模型集成流程有很强的参考价值。
需要提前说明的是,我下面讲的所有内容都是基于我自己的实操经验,参数和结论是在我当时的硬件条件下得到的,你直接照搬不一定能复现同样的数字,但思路和避坑点是通用的。
2. 数据预处理与核心细节解析
2.1 四个模态的物理含义与融合策略
很多人做BraTS的时候直接把四个模态当四个通道塞进网络,这当然可以跑,但如果你不理解每个模态到底在表达什么,后面做模态融合、做可解释性分析的时候就会很被动。
T1模态主要反映解剖结构,灰白质对比清晰,但对肿瘤的敏感性一般。T1ce是打了造影剂之后的T1,增强肿瘤区域会明显高亮,这是区分ET区域最关键的模态。T2模态对水肿和脑脊液敏感,水肿区域在T2上呈现高信号。FLAIR则是抑制了脑脊液信号的T2,能更清晰地显示水肿边界和肿瘤浸润区域。
我在做模态融合的时候试过三种方案:早期融合(直接堆通道)、中期融合(每个模态单独过几层再拼接)、晚期融合(每个模态单独出一个预测再投票)。实测下来,早期融合在数据量充足的时候效果最好,中期融合在小数据量或者模态缺失的情况下更鲁棒,晚期融合几乎没有优势,因为四个模态之间的互补性太强了,分开处理反而丢掉了跨模态的交互信息。
一个容易被忽略的细节:BraTS的四个模态是已经配准好的,但不同病例之间的强度分布差异很大。如果你不做强度归一化,直接送进网络,模型会花大量精力去拟合强度分布而不是学习解剖结构。我的做法是对每个模态单独做z-score归一化,只在前景区域(非零区域)内计算均值和标准差,这样能避免大量背景零值拉偏统计量。
2.2 数据增强的取舍与参数设置
数据增强这块我踩过的坑最多。一开始我用了很猛的增强,随机旋转、随机缩放、随机弹性形变全上,结果训练loss降得很漂亮,验证集Dice却上不去。后来我逐个做消融实验,才发现问题出在弹性形变上——医学影像的解剖结构是有严格拓扑约束的,过度的弹性形变会破坏肿瘤和周围组织的空间关系,模型学到的是扭曲后的假模式。
我最终保留的增强策略是这样的:随机翻转(三个轴各50%概率)、随机旋转(-15度到15度)、随机缩放(0.9到1.1)、随机亮度对比度扰动(幅度控制在0.1以内)、随机模态丢弃(每个模态10%概率置零,模拟模态缺失)。弹性形变我只在2019年之后的版本里保留了一个非常温和的版本,形变幅度控制在2个像素以内。
另外有一个技巧我想单独提一下:随机patch采样。BraTS的病例里,肿瘤区域占整个脑体积的比例很小,如果你完全随机裁剪patch,大部分patch里可能根本没有肿瘤。我的做法是维护两个采样池,一个是有肿瘤的patch,一个是无肿瘤的patch,每个batch里保证至少60%的patch包含肿瘤区域。这个策略对ET这种小目标的提升非常明显,我实测下来ET的Dice能提升3到5个百分点。
2.3 预处理流程的标准化步骤
我把整个预处理流程固化成了一个脚本,每次换数据集或者换机器的时候直接跑一遍,保证输入一致。具体步骤是这样的:
- 读取四个模态的NIfTI文件,检查尺寸和仿射矩阵是否一致。
- 对每个模态做前景区域内的z-score归一化。
- 如果某个模态缺失,用其余模态的均值填充,并在输入中增加一个模态存在性标记通道。
- 将四个模态堆叠成4通道的3D数组,保存为numpy的npy格式,方便后续快速读取。
- 对标注做同样的空间变换,确保图像和标注严格对齐。
这里有一个坑我要特别提醒:BraTS的标注里,标签1对应NCR,标签2对应ED,标签4对应ET。很多人在做多分类的时候直接把标签当成0、1、2、3来处理,结果类别映射错了,训练出来的模型完全不对。正确的做法是先做标签重映射,把1、2、4映射成0、1、2,然后再做one-hot编码。
还有一个细节:BraTS的验证集标注是不公开的,你只能通过官方在线评测来获取结果。所以本地验证一定要自己划分出一部分训练数据作为验证集,并且要保证划分的随机性。我见过有人用固定的前20个病例做验证,结果模型在验证集上表现很好,在线评测却很差,就是因为验证集和训练集分布不一致。
3. 模型结构与训练策略实操
3.1 3D U-Net的改进细节
我的基础模型是3D U-Net,但在原始结构上做了几处关键修改。第一,把编码器的每个stage从两层卷积改成三层卷积,并且加入了残差连接。这个改动让模型在深层能提取到更丰富的特征,同时残差连接缓解了梯度消失的问题。第二,解码器上采样之后,用concatenation而不是addition来融合编码器的特征,这样能保留更多的空间细节。第三,在瓶颈层加入了一个轻量的注意力模块,对通道维度做重加权,让模型更关注肿瘤相关的特征通道。
具体的网络配置是这样的:输入patch大小96×96×96,编码器通道数从32开始,每个stage翻倍,最深到256。解码器对称,最终输出3通道的softmax。整个模型参数量大概在1200万左右,在单张24G显存的卡上,batch size可以开到4,配合混合精度训练,显存占用大概在18G左右。
这里我想说一下为什么没有用更深的网络。我试过把编码器加深到6个stage,通道数最大到512,结果显存直接爆了,batch size只能降到1,训练极其不稳定,最终Dice反而下降了。医学影像分割和自然图像分割不一样,数据量小、解剖结构相对固定,过深的网络很容易过拟合。我的经验是,3到4个下采样stage足够了,再深就是浪费算力。
3.2 损失函数的设计与权重调优
损失函数这块我做了很多实验,最终定下来的方案是Dice损失加交叉熵损失,并且对三个类别分别加权。具体公式是这样的:
def combined_loss(y_true, y_pred): dice_loss = 1 - dice_coefficient(y_true, y_pred) ce_loss = categorical_crossentropy(y_true, y_pred) # 类别权重:WT, TC, ET class_weights = [0.2, 0.3, 0.5] weighted_ce = ce_loss * class_weights return dice_loss + 0.5 * weighted_ce为什么ET的权重给到0.5?因为ET区域最小,在Dice损失里贡献的梯度最少,如果不额外加权,模型会倾向于忽略ET。但权重也不能给太大,我试过给到1.0,结果模型在ET上过拟合,WT和TC的Dice明显下降。0.5是我在验证集上试出来的一个比较平衡的值。
另外,深度监督的辅助损失我加在了解码器的三个层级上,权重分别是0.1、0.2、0.3,主损失权重是1.0。辅助损失的作用是让浅层特征也具备一定的判别能力,加速收敛。实测下来,加了深度监督之后,训练前期收敛速度大概快了30%,最终精度也有1到2个百分点的提升。
3.3 训练过程的监控与调参记录
训练过程我一般会监控四个指标:训练loss、验证loss、验证集Dice、以及学习率。学习率我用的是余弦退火,初始值1e-4,最小到1e-6,每个epoch更新一次。优化器用AdamW,weight decay设成1e-5。
这里有一个经验:BraTS训练的前20个epoch非常关键,如果这20个epoch里验证Dice没有明显上升,后面基本很难救回来。我一般会在第20个epoch的时候做一次评估,如果WT的Dice低于0.80,我就会停下来检查数据管线或者调整学习率。我遇到过好几次是因为数据增强太猛导致前20个epoch完全学不动,把增强幅度降下来之后立刻就正常了。
还有一个细节是模型权重的保存策略。我不用最好的验证Dice来保存模型,而是用验证Dice的滑动平均值。因为单次评估的波动可能很大,用滑动平均能避免保存到偶然表现好的模型。具体做法是维护一个长度为5的队列,每次评估后把Dice加入队列,取平均值,只有平均值超过历史最佳才保存模型。
训练过程中我还会定期可视化中间特征图,看看模型到底在关注哪些区域。有一次我发现模型在ET区域上的注意力很弱,检查后发现是ET的采样比例太低,调整采样策略后问题就解决了。可视化这个习惯帮我省了很多盲目调参的时间。
4. 后处理与模型集成实战
4.1 连通域分析与小区域过滤
模型输出的原始预测往往会有一些孤立的、很小的预测区域,这些区域大概率是噪声。我的后处理第一步就是做连通域分析,把小于一定体积的连通域去掉。具体阈值我是这样定的:WT的最小体积设为100个体素,TC设为50,ET设为30。这些阈值是我在验证集上通过网格搜索找到的,不同的数据集可能需要微调。
但这里有一个坑:ET区域本身就可能很小,如果你把阈值设得太大,会把真正的ET区域也过滤掉。我的做法是对ET单独处理,先保留所有预测的ET区域,然后只过滤掉那些与WT区域没有重叠的孤立ET预测。因为从解剖学上讲,ET一定在WT内部,如果预测出的ET和WT完全不重叠,那这个ET预测基本可以认为是错的。
4.2 五折交叉验证与模型集成
单模型的性能始终有限,我最终上线用的是五折交叉验证的集成模型。具体做法是把训练数据分成五份,每次用其中四份训练,剩下一份做验证,这样得到五个模型。推理的时候,五个模型分别对同一个病例做预测,然后把五个概率图做平均,再取argmax得到最终分割结果。
这个方案听起来简单,但实际做的时候有几个细节要注意。第一,五个模型的结构和超参数要完全一致,只有训练数据的划分不同,这样才能保证集成的有效性。第二,推理时的数据增强也要一致,我一般会用TTA(测试时增强),对每个模型做8次不同翻转和旋转的预测,再平均。第三,集成后的概率图要做一次高斯平滑,减少边界处的锯齿。
实测下来,五折集成相比单模型,WT的Dice能提升1.5个百分点左右,ET的Dice能提升2到3个百分点。提升幅度不算特别大,但稳定性提升非常明显,不同病例之间的方差变小了。
4.3 推理速度优化与显存管理
BraTS的推理阶段有一个很现实的问题:一个病例的完整3D体积是240×240×155,如果直接整卷推理,显存根本不够。我的做法是滑窗推理,窗口大小和训练时一致(96×96×96),步长设为48,也就是50%的重叠。重叠区域用高斯权重做加权平均,这样能避免拼接处的接缝。
滑窗推理的速度优化我做了两件事:第一,用混合精度推理,速度大概能提升40%;第二,把模型转成TorchScript或者ONNX,减少Python层面的开销。经过优化之后,一个病例的推理时间从原来的3分钟左右降到了1分钟以内,五个模型集成加上TTA,总时间大概在5到8分钟,对于离线评测来说完全可以接受。
显存管理还有一个技巧:推理的时候不要一次性把所有窗口的中间特征都保留在显存里,而是每算完一个窗口就把结果写到CPU内存,最后统一做加权平均。这样显存占用只和单个窗口有关,和病例大小无关。
5. 常见问题与排查技巧实录
5.1 训练不收敛的典型原因
训练不收敛是BraTS项目里最常见的问题,我总结下来主要有四个原因。第一,学习率太大,loss直接飞掉,这时候把学习率降到1e-5再试。第二,数据归一化没做对,不同模态的强度范围差异太大,模型学不动,检查一下是不是做了前景区域的z-score。第三,标签映射错了,把1、2、4直接当成类别索引,导致类别混乱,重新做标签重映射。第四,batch size太小,梯度噪声太大,如果显存不够,可以用梯度累积来模拟大batch。
5.2 验证集Dice远低于训练集Dice
这个问题一般有两个原因:过拟合或者数据泄露。过拟合的话,增加数据增强、加dropout、加weight decay都能缓解。数据泄露的话,检查一下验证集和训练集有没有重叠的病例,或者预处理的时候有没有把验证集的信息泄露到训练集。我遇到过一次是因为在计算归一化参数的时候用了全部数据,导致验证集的信息间接泄露到了训练过程。
5.3 ET区域Dice始终上不去
ET区域小、类别不平衡,Dice上不去很正常。除了前面说的类别加权和采样策略,还有一个技巧是两阶段训练:第一阶段先训练一个WT的分割模型,第二阶段在WT预测的区域内单独训练ET的分割模型。这样ET模型只需要关注WT内部区域,搜索空间小了很多,Dice通常能提升3到5个百分点。
5.4 常见问题速查表
| 问题现象 | 可能原因 | 排查方法 | 解决方案 |
|---|---|---|---|
| 训练loss不下降 | 学习率过大、数据未归一化 | 检查学习率和输入分布 | 降低学习率、做前景z-score |
| 验证Dice波动大 | batch size太小、验证集太小 | 增加batch size或验证集比例 | 用梯度累积、扩大验证集 |
| ET Dice低 | 类别不平衡、采样偏差 | 统计ET体素占比 | 类别加权、定向采样 |
| 推理显存不足 | 整卷推理、窗口太大 | 检查推理窗口大小 | 滑窗推理、混合精度 |
| 在线评测分数低 | 过拟合、后处理不当 | 对比本地和在线结果 | 加强正则、调整后处理阈值 |
6. 工程化落地的几点个人体会
6.1 代码结构的模块化设计
做BraTS项目最大的教训就是一定要把代码模块化。我第一版代码把所有逻辑写在一个文件里,后来想换损失函数、换数据增强,改一处就要动全身。第二版我拆成了数据模块、模型模块、损失模块、训练模块、推理模块五个部分,每个模块之间通过配置文件交互。这样我想做消融实验的时候,只需要改配置文件里的一个参数,不用动代码。
配置文件我用的是YAML格式,把数据路径、模型参数、训练超参数、增强参数全部写在一个文件里。每次实验保存一份配置文件,这样后面复现的时候直接加载对应的配置就行。这个习惯帮我省了大量“这个参数当时设的多少来着”的时间。
6.2 实验记录与版本管理
医学影像项目的实验周期长,参数组合多,如果没有好的实验记录,很快就会乱掉。我的做法是用一个简单的CSV文件记录每次实验的关键信息:实验编号、日期、模型结构、损失函数、数据增强、验证Dice、备注。每次跑完实验就填一行,后面分析的时候直接看表格就行。
版本管理我用的是Git,但只管理代码和配置文件,不管理数据和模型权重。数据和权重单独存在一个目录里,用实验编号命名。这样代码仓库很干净,数据和权重的管理也很清晰。
6.3 从BraTS迁移到其他任务的注意事项
BraTS上验证有效的方案,迁移到其他医学影像分割任务时,有几个地方需要调整。第一,模态数量和含义不同,融合策略要重新设计。第二,类别体系不同,损失函数的类别权重需要重新调。第三,数据规模不同,如果目标数据集比BraTS小很多,模型要相应缩小,增强要更保守。第四,评测指标不同,有些任务用IoU而不是Dice,后处理的阈值也要重新搜。
我后来把BraTS的经验迁移到一个腹部器官分割任务上,模型结构基本没变,但数据增强幅度降了一半,类别权重重新调了一轮,最终效果也不错。这说明核心思路是通用的,但具体参数一定要根据任务特点来调。
6.4 一些零散但有用的实操技巧
最后分享几个零散但很实用的技巧。第一,训练的时候把验证集评估频率设成每5个epoch一次,太频繁会拖慢训练速度,太稀疏会错过最佳保存时机。第二,模型权重的EMA(指数移动平均)对最终性能有稳定提升,我一般设decay为0.999。第三,推理的时候对概率图做一次温度缩放,能让softmax输出更平滑,边界更自然。第四,如果显存实在不够,可以用梯度检查点,牺牲一点速度换显存。
这些技巧单独看可能只提升零点几个百分点,但累积起来对最终结果的影响还是很可观的。做医学影像分割就是这样,大框架大家差不多,最后拼的就是这些细节。