☰
医学图像分类实战:从DICOM到EfficientNet的乳腺癌良恶性诊断全流程
2026/10/3 1:36:28 网站建设 项目流程

我前阵子刚把一个乳腺癌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_splits=1, test_size=0.2, random_state=42) train_idx, test_idx = next(gss.split(images, labels, groups=patient_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_weight="balanced", classes=np.array([0, 1]), y=labels ) class_weights = torch.tensor(weights, dtype=torch.float32).to(device)

数据增强这里说一下我的思路:医学图像和ImageNet不一样,不能随便做垂直翻转、大角度旋转。乳腺钼靶图像有固定的解剖学方向,垂直翻转会把钙化特征的位置关系完全颠覆,模型学到了错误先验。水平翻转在左右乳腺对称的前提下对分类影响不大,但也有文献说会降低模型的定位能力,我的建议是能不做就不做,用下面这套更克制也更安全的组合:

import albumentations as A train_transform = A.Compose([ A.RandomResizedCrop(height=224, width=224, scale=(0.8, 1.0), ratio=(0.9, 1.1)), A.Rotate(limit=10), A.RandomBrightnessContrast(brightness_limit=0.1, contrast_limit=0.1), A.HorizontalFlip(p=0.5), ]) test_transform = A.Compose([ A.Resize(height=224, width=224), ])

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(weights=models.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会更稳。

优化器我用的AdamW,weight_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(model=model, target_layers=[model.features[-1]]) grayscale_cam = cam(input_tensor)[0] visualization = show_cam_on_image(normalized_image, grayscale_cam, use_rgb=True)

热力图上高亮区域如果集中在病灶附近,说明模型学到了有意义的特征。如果高亮在图像边缘、文字水印或者背景上,基本可以断定模型已经过拟合到了非医学特征。做项目时我把一部分测试集的热力图拼接输出,和真值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(seed=42): 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大开混合精度、梯度累积、减小batch
batch_size小导致BN不稳定显存限制只能设小batch替换BN为GroupNorm,或用梯度累积增大等效batch
单通道灰度图喂进去效果差预训练权重不匹配复制成三通道,保留ImageNet预训练信息

写在最后,简单分享一个我的体会:整条项目做完,最花时间和精力的部分,一个是数据侧的解析与防泄漏,一个是评估侧的指标纠偏,反而是模型训练本身跑起来很快。所以如果你准备做医学图像分类,我给的建议是先把数据管线做扎实,把按患者划分、DICOM解析、增强策略这些基本功练到位,模型选个EfficientNet起步就够了。另一个小技巧是,每个epoch的验证结果都打印出灵敏度、特异度、AUC这组指标,比单看一个loss曲线有意义得多,模型状态一目了然。

需要专业的网站建设服务?

联系我们获取免费的网站建设咨询和方案报价,让我们帮助您实现业务目标

立即咨询