ARTICLE · INTELLIGENCE

战地情报 · 详情页

来自尧图项目组的一线实战观察与深度解析

医学图像分类实战:从DICOM到EfficientNet的乳腺癌良恶性诊断全流程

医学图像分类实战:从DICOM到EfficientNet的乳腺癌良恶性诊断全流程 我前阵子刚把一个乳腺癌X光片良恶性分类项目完整跑通从原始DICOM到最终给出AUC、混淆矩阵和热力图。今天这篇就想把整条链路摊开说清楚尤其是那些踩过坑才知道的细节数据集怎么选、模型怎么挑、数据划分为什么不能拍脑袋、评估指标为什么不能只看准确率。项目本身是典型的“基于深度学习卷积神经网络的医学图像乳腺癌分类”输入是乳腺钼靶图像的ROI区域输出是良性还是恶性框架用的PyTorch。这类项目的难点其实不在模型有多新而在数据有多脏、评估有多容易自欺欺人。医学图像和自然图像完全是两套玩法样本量小、类别不平衡、标注噪声高稍不留神就会做出一个“测试集上很漂亮、实际用起来崩溃”的模型。下面我把整个项目从选型到落地的完整过程写出来直接照着做可以省掉大部分弯路。1. 项目整体设计与技术选型思路1.1 先把任务拆明白二分类还是多分类做乳腺癌分类第一步不是急着找模型而是把任务定义清楚。我见过不少同学拿到数据集就开始调ResNet调了半天才发现连目标是几分类都没定。临床上常见的乳腺癌图像分析任务可以拆成几类BI-RADS分级根据影像学特征把病灶分成0到6级这是一个多分类问题级别之间有顺序关系有时也当作回归问题处理。良恶性二分类只区分“良性”和“恶性”这是最常用、也最适合作为入门和落地的任务。病灶检测与分割不仅要判断有没有癌还要把病灶位置框出来或分割出来这属于目标检测和语义分割的范畴。我这次做的是良恶性二分类模型输出一个概率大于阈值判定为恶性。这个任务看起来简单但有个好处可以专注于把数据管线、评估体系、模型训练这套链路跑通后面再往检测、分割方向扩展时前面的经验全部通用。1.2 数据集选型CBIS-DDSM、BreakHis 和 MIAS数据集是整个项目的根基。医学图像常用数据集有这几个各有各的脾气数据集图像类型规模标注形式适合场景CBIS-DDSM乳腺X光钼靶约2600个ROI正常/良性/恶性含ROI mask经典二分类、ROI分类BreakHis组织病理图像7909张良/恶性4种放大倍数病理图像块分类MIAS乳腺X光322张良性/恶性含背景图像小规模练手、算法验证我最后选了CBIS-DDSM。原因是它和实际临床场景最贴近输入是钼靶图像中的可疑区域ROI医生看的也是这种图像而且数据自带ROI mask省去了自己标注的成本。BreakHis的病理图像显微镜特征更突出放大倍数不同还会引入尺度差异对依赖颗粒级纹理的模型挑战更大。MIAS规模太小做深度学习实验只是用来跑通流程做不了可信的评估。CBIS-DDSM的坑是数据格式是DICOM不能像普通图片那样直接读需要先解析。ROI mask也是单独的DICOM文件要自己提取目标区域坐标。这部分放到下一节详细讲。1.3 模型选型为什么我最终选了 EfficientNet模型选型这事很多教程直接上ResNet没问题但未必是最优解。我对比过VGG、ResNet、EfficientNet和Swin Transformer最后选择了EfficientNet-b0作为主力模型原因也很实在VGG参数太多全连接层动辄上百兆医学数据集就几千张图像非常容易过拟合。ResNet是个好基线但它的深度、宽度、分辨率是按经验堆出来的没有考虑三者之间的协同关系。EfficientNet引入了compound scaling把深度、宽度、输入分辨率放在一起统一缩放在参数效率和精度之间平衡得更好。Swin Transformer很强但在医学小数据集上没有大规模医学预训练权重的情况下从ImageNet迁移过来的Transformer并不比CNN更有优势反而更容易过拟合训练也不够稳定。我实际测试下来在CBIS-DDSM上EfficientNet-b0用ImageNet预训练权重迁移能够稳定收敛验证集AUC比同条件下ResNet50高出2到3个点而且显存占用更小。如果硬件资源允许往上换b1、b2还会有小幅提升但收益递减。小样本医学分类场景EfficientNet-b0是我目前最推荐的起点。2. 数据准备与预处理医学图像的坑远比想象中多2.1 DICOM解析与ROI裁剪CBIS-DDSM给的是DICOM格式直接读会出现一片黑或者一片白的情况。DICOM和普通图片最大的区别是像素值不是0到255的RGB而是可能是16位灰度范围可以到几千甚至上万。处理逻辑是先查看窗宽窗位Window Center / Window Width把感兴趣范围内的灰度映射到0到255再复制成三通道给CNN用。ROI裁剪的做法是去读对应的mask文件找到mask里非零区域的外接矩形然后从原图中抠出这块区域。下面是我用的解析逻辑import numpy as np import pydicom from skimage import measure def load_dicom_and_roi(dcm_path, mask_path): dcm pydicom.dcmread(dcm_path) mask pydicom.dcmread(mask_path) image dcm.pixel_array.astype(np.float32) mask_arr mask.pixel_array.astype(np.uint8) # 简单的窗宽窗位映射医学图像常用 wc 1024 # 可根据图像属性调整 ww 4096 min_val wc - ww // 2 max_val wc ww // 2 image np.clip(image, min_val, max_val) image (image - min_val) / (max_val - min_val) * 255.0 image image.astype(np.uint8) # 找mask外接框 contours measure.find_contours(mask_arr, 0.5) all_points np.vstack(contours) min_row int(all_points[:, 0].min()) max_row int(all_points[:, 0].max()) min_col int(all_points[:, 1].min()) max_col int(all_points[:, 1].max()) roi image[min_row:max_row 1, min_col:max_col 1] return roi, (min_row, max_row, min_col, max_col)提示DICOM中像素值如果带了Rescale Slope和Rescale Intercept属性需要先做线性变换才能得到真实物理值。我处理CBIS-DDSM时发现它的像素值已经被预处理过直接读就行但换成其他数据集时一定要先检查这两个属性。ROI抠出来后先存成单独的图像文件再进入数据管线。这里有个经验不要直接把整张钼靶图resize到模型输入尺寸。整张图像尺寸动辄2000x3000直接缩小会丢失病灶细节小病灶可能缩成几个像素就没了。先裁剪ROI再resize等于让模型集中看专家圈出来的可疑区域。2.2 按患者划分数据不然测试集就是假的这是整个项目里最容易踩、也可能最严重的坑数据泄漏。CBIS-DDSM是一批患者的左右乳腺图像同一个患者可能同时有良性和恶性病灶。如果直接随机切分train/test同一个患者的不同图像可能同时出现在两边模型在训练时见过这个患者的特征测试时自然会“认识”它AUC虚高。正确的做法是按患者ID划分数据保证同一个患者的任何图像都只出现在train或test之一。我一开始偷懒随机划了一次验证AUC直接到0.97我以为模型天下无敌后来发现数据泄漏认真按患者划分之后AUC掉到0.90左右这才是真实水平。from sklearn.model_selection import GroupShuffleSplit gss GroupShuffleSplit(n_splits1, test_size0.2, random_state42) train_idx, test_idx next(gss.split(images, labels, groupspatient_ids)) train_imgs [images[i] for i in train_idx] test_imgs [images[i] for i in test_idx]我把这个经验放最前面说因为它在任何医学图像项目里都成立。组织病理、CT、MRI、X光都一样同一个患者的数据天然相关按图像分布而不按患者分最后得到的模型大概率是自欺欺人。2.3 类别不平衡与数据增强的正确姿势CBIS-DDSM里良性和恶性样本比例没有特别极端大概在1:1到1:1.5之间但很多医学数据集恶性样本只占10%到20%。解决不平衡有一个最简单的思路给损失函数加权。计算每个类别的权重有个通用公式class_weight 总样本数 / (类别数量 * 该类别样本数)实现代码如下from sklearn.utils.class_weight import compute_class_weight weights compute_class_weight( class_weightbalanced, classesnp.array([0, 1]), ylabels ) class_weights torch.tensor(weights, dtypetorch.float32).to(device)数据增强这里说一下我的思路医学图像和ImageNet不一样不能随便做垂直翻转、大角度旋转。乳腺钼靶图像有固定的解剖学方向垂直翻转会把钙化特征的位置关系完全颠覆模型学到了错误先验。水平翻转在左右乳腺对称的前提下对分类影响不大但也有文献说会降低模型的定位能力我的建议是能不做就不做用下面这套更克制也更安全的组合import albumentations as A train_transform A.Compose([ A.RandomResizedCrop(height224, width224, scale(0.8, 1.0), ratio(0.9, 1.1)), A.Rotate(limit10), A.RandomBrightnessContrast(brightness_limit0.1, contrast_limit0.1), A.HorizontalFlip(p0.5), ]) test_transform A.Compose([ A.Resize(height224, width224), ])RandomResizedCrop的scale设为0.8到1.0意味着最多只裁掉20%的边缘区域这个幅度对ROI来说是安全的。旋转限制在10度以内避免改变解剖结构的相对位置。这些增强手段的共同原则是保留诊断所需的结构信息只引入在临床中可能出现的轻度变异。3. 模型搭建与训练把训练管线完整跑通3.1 加载ImageNet预训练模型输入层怎么处理医学图像需要迁移学习这个应该没有争议了。但医学图像是灰度图ImageNet预训练的模型默认输入是三通道RGB这里有一个关键决策。我的做法是直接把灰度图复制成三通道以此复用ImageNet的预训练权重import torchvision.models as models model models.efficientnet_b0(weightsmodels.EfficientNet_B0_Weights.IMAGENET1K_V1) num_ftrs model.classifier[1].in_features model.classifier[1] nn.Linear(num_ftrs, 1) # 二分类单输出 for param in model.features.parameters(): param.requires_grad False复制成三通道的做法虽然让每个通道看到的内容完全一样但ImageNet预训练权重至少提供了底层边缘、纹理检测器迁移过来比从零训练快得多效果也好得多。如果非要用单通道输入也不是不行但需要把第一层卷积的权重在通道维上求平均来初始化等于把预训练信息压缩成单通道。实际操作里收益不大还增加了一堆代码量我测试下来直接复制三通道是最省心且效果最稳的方案。冻结backbone的策略也值得说一下刚开始训练时先冻结所有特征提取层只训练最后的分类头等loss下降到平台期后再解冻全部层用一个更小的学习率微调。这个策略尤其适合小样本医学图像可以有效避免早期训练就把预训练特征破坏掉。3.2 损失函数与优化器选择二分类场景最常用的损失函数是BCEWithLogitsLoss也就是把最后一层输出过一个sigmoid的概率值和真实标签做二元交叉熵。如果类别不平衡比较严重可以在损失函数里传入类别权重或者用Focal Loss。我之前对比过在CBIS-DDSM这种不平衡程度不算极端的数据上加权BCE和Focal Loss差距不大但加权的实现成本更低。如果换到只有5%正样本的任务Focal Loss会更稳。优化器我用的AdamWweight_decay设为1e-4。早期我用过SGD训练速度慢对学习率很敏感AdamW基本不用怎么调就能跑出不错的结果。学习率策略上迁移学习场景有一个经验值冻结backbone的时候学习率可以给到1e-3或者2e-3解冻之后学习率要降到1e-4或者5e-5否则很容易把预训练权重冲崩。我习惯配合CosineAnnealingLR在训练后期让学习率平缓下降。3.3 训练循环早停、checkpoint与混合精度训练循环是项目中代码量比较集中的部分。除了常规的forward/backward有几个细节很重要混合精度训练torch.cuda.amp可以在不降低精度的前提下大幅降低显存占用batch size可以翻倍。早停监控验证集AUC超过设定轮数没有提升就停止训练并加载最佳权重。Checkpoint保存保存的不只是模型权重还有优化器状态、epoch、当前学习率这样中断后可以无缝恢复。下面是一个简化但完整的训练循环骨架from torch.cuda.amp import autocast, GradScaler scaler GradScaler() best_auc 0.0 patience 0 for epoch in range(train_epochs): model.train() for imgs, labels in train_loader: imgs imgs.to(device) labels labels.to(device) optimizer.zero_grad() with autocast(): outputs model(imgs).squeeze(1) loss criterion(outputs, labels) scaler.scale(loss).backward() scaler.step(optimizer) scaler.update() val_auc evaluate(model, val_loader) if val_auc best_auc: best_auc val_auc torch.save({ model: model.state_dict(), optimizer: optimizer.state_dict(), epoch: epoch, }, best_model.pt) patience 0 else: patience 1 if patience patience_epochs: break注意如果batch size很小比如排名GPU只有4到6张图像BatchNorm的均值和方差会非常不稳定。我的经验是batch size在8以上才比较可靠实在上不去可以用梯度累积来模拟更大的batch或者把backbone里的BN层替换成GroupNorm。踩过一次坑后我就养成了检查batch size的习惯。4. 模型评估与结果分析准确率会骗人4.1 医学场景到底该看哪些指标医学图像分类最常见的大坑就是只看accuracy。假设测试集里90%是良性、10%是恶性模型把所有样本都预测为良性准确率也有90%但这个模型没有任何临床价值因为恶性肿瘤一个都找不出来。在医学筛查场景里漏掉一个恶性病例假阴性的成本远高于把良性误判为恶性假阳性。所以灵敏度Sensitivity也就是召回率是首要关注的指标。特异度Specificity代表正确识别阴性的比例同样不能丢否则就把所有样本都判成恶性了。这两个指标映射到混淆矩阵上Sensitivity TP / (TP FN)恶性患者被找出来的比例Specificity TN / (TN FP)良性患者被判对的比例AUC-ROC不需要设定阈值也可以反映模型整体排序能力适合用来做模型之间的对比。F1受类别不平衡影响较大在医学任务里可以作为参考但不应该替代上面两个指标。4.2 阈值怎么选约登指数与最佳截断点模型输出的概率默认用0.5作为阈值但在医学场景里这个默认值很可能不合适。如果临床上宁可多看不能漏看就需要把阈值调低让更多样本被判为恶性从而提高灵敏度。这个阈值应该从验证集上选择不能直接在测试集上选否则也会引入泄漏。选择阈值时可以用约登指数Youden‘s J index最大化来做J Sensitivity Specificity - 1代码如下from sklearn.metrics import roc_curve fpr, tpr, thresholds roc_curve(y_true, y_prob) youden_idx np.argmax(tpr - fpr) # 和上面的J等价 best_threshold thresholds[youden_idx] print(最佳阈值:, best_threshold)我的项目里默认0.5阈值对应的灵敏度是0.86特异度0.91。用约登指数选出的阈值在0.42左右灵敏度升到0.91特异度微降到0.87。在乳腺癌筛查场景里这个交换是值得的因为漏诊的成本远高于复查带来的额外成本。4.3 用Grad-CAM让模型“开口说话”做医学图像分类光给一个AUC是不够的。临床医生不会只看数字就相信你的模型他们需要知道模型关注的是病灶区域还是背景噪声。Grad-CAM可以通过最后一层卷积特征图的梯度生成一张热力图标出模型做出判断时主要看图像里的哪些区域。我用的是pytorch-grad-cam库调用非常方便from pytorch_grad_cam import GradCAM from pytorch_grad_cam.utils.image import show_cam_on_image cam GradCAM(modelmodel, target_layers[model.features[-1]]) grayscale_cam cam(input_tensor)[0] visualization show_cam_on_image(normalized_image, grayscale_cam, use_rgbTrue)热力图上高亮区域如果集中在病灶附近说明模型学到了有意义的特征。如果高亮在图像边缘、文字水印或者背景上基本可以断定模型已经过拟合到了非医学特征。做项目时我把一部分测试集的热力图拼接输出和真值mask对比如果发现高亮区域和对不上就会回头去检查数据预处理有没有问题。模型跑偏的另一个常见原因是数据预处理方式不对导致特征不一致。比如训练和评估时的resize方式不同或者灰度映射的窗宽窗位前后不一致模型虽然apply了但心里没底。Grad-CAM能快速暴露出这类问题用好它对整个项目周期都有帮助。5. 常见问题与避坑指南5.1 过拟合医学小数据的头号敌人症状是训练loss持续下降验证AUC先升后降两者之间的差距越拉越大。医学图像数据量少这个现象太常见了。我的解决梯次先加强数据增强和dropout再考虑减少模型复杂度最后才是增加数据。Dropout在EfficientNet里默认只有一个dropout层夹在特征提取和分类头之间。如果发现过拟合可以在分类头前面再加一个dropout层rate从0.2起调。另一个思路是冻结backbone只训练最后几层因为医学图像小数据不适合对整个预训练网络做全面微调。我也做过一个极端对照实验400张小样本直接微调VGG16验证AUC从0.83开始一路崩塌到0.74换成EfficientNet-b0并冻结backbone验证AUC稳定在0.87。模型选型和训练策略对医学小数据的影响比想象中大得多。5.2 数据泄漏验证AUC高得离谱时要警觉如果模型在验证集上AUC到了0.97以上先别高兴第一步检查是不是数据泄漏了。最容易犯的错就是随机切分数据而不是按患者ID划分。还需要检查输入图像上有没有嵌入信息比如采集设备的标签、患者姓名或医院名称如果这些信息被模型学到特征会非常强也足够让AUC虚高。我处理过另一个隐蔽的泄漏数据增强时使用ROI的mask做裁剪但因缩放or编码不一致训练和验证阶段得到的图像内容不完全一致导致验证AUC虚高。所以在构建数据集时就定了两条规则一是必须使用专业的DICOM解析逻辑确保所有环节读到的像素值一致二是按患者划分必须写在最前面谁都不要动。5.3 复现困难seed、worker与库版本同一个模型换一台机器跑结果对不上这个是深度学习的家常便饭。固定随机种子至少能保证同一台机器上的可复现性import random import numpy as np import torch def set_seed(seed42): random.seed(seed) np.random.seed(seed) torch.manual_seed(seed) torch.cuda.manual_seed_all(seed) torch.backends.cudnn.deterministic True torch.backends.cudnn.benchmark False注意torch.backends.cudnn.benchmark设为False会牺牲一些训练速度但对保证复现性很重要。如果使用多进程DataLoader每个worker的随机种子不是一开始就固定的需要在collate_fn里额外设置。还有一个容易忽略的点Albumentations库的随机性也要依赖numpy和python的random三者一起固定才能保证一致。数据增强、模型、库版本三者的组合本身就是实验变量的一部分做实验记录时把版本号也随手记录下来比什么召回都管用。5.4 踩坑速查表现象可能原因解决办法训练loss降但验证AUC崩过拟合增强数据增强、加dropout、冻结backbone验证AUC极高但换数据就崩数据泄漏按患者划分、检查图像水印/嵌入信息训练多个epoch不收敛学习率不合适用1e-4起步配合warmup和cosine退火显存不够输入分辨率高、batch大开混合精度、梯度累积、减小batchbatch_size小导致BN不稳定显存限制只能设小batch替换BN为GroupNorm或用梯度累积增大等效batch单通道灰度图喂进去效果差预训练权重不匹配复制成三通道保留ImageNet预训练信息写在最后简单分享一个我的体会整条项目做完最花时间和精力的部分一个是数据侧的解析与防泄漏一个是评估侧的指标纠偏反而是模型训练本身跑起来很快。所以如果你准备做医学图像分类我给的建议是先把数据管线做扎实把按患者划分、DICOM解析、增强策略这些基本功练到位模型选个EfficientNet起步就够了。另一个小技巧是每个epoch的验证结果都打印出灵敏度、特异度、AUC这组指标比单看一个loss曲线有意义得多模型状态一目了然。
RELATED READING

延伸阅读

更多一线实战笔记与深度复盘,助您持续精进