简介:面向深度学习中医学图像分类方向的学习者与研究者,乳腺癌症图像分类数据集提供了完整的二分类任务数据,类别仅两类,任务聚焦明确。数据集按目录存放,同一类别样本集中在对应目录下,并附带json类别映射文件;全部692个文件中以689张jpg图像为主,另有1个Python脚本与1张示例图片,压缩包大小17.85MB,体量轻、结构清晰。图像已按约480张训练集、140张验证集、70张测试集划分,可直接用于深度学习模型训练与效果评估,节省自行采集图像和整理标注的时间。已有286人学习过该数据集,适合刚接触医疗影像识别的入门者,也可作为图像分类课程的课堂数据。配合现有目录与脚本,可快速上手完成CNN、ResNet等模型搭建、训练与测试,并参考示例图片与Python脚本开展数据预览和预处理。
1. 乳腺癌症图像分类:大多数人没意识到这是数据集问题
乳腺癌症图像分类这个题目,听起来像是一个模型架构的问题,但你把任何一篇乳腺癌症图像分类论文的复现过程跑完一遍就会发现,真正卡住你的不是那个分类头怎么设计,而是数据集的组织方式。常见的公开乳腺病理数据集只有几千张图,怎么划分患者、怎么处理染色差异、怎么防止信息泄漏,这些步骤对最终准确率的影响比重换一个 backbone 大得多。深度学习里的数据质量才是上限,模型结构只是在逼近这个上限。这篇文章把数据集选型、预处理、训练配置、踩坑排查按落地顺序过一遍,适合刚入门想做二分类的学
生、接到医疗影像需求但缺乏经验的开发者,以及想用公开数据快速验证想法的研究者。
2. 选数据集是第一步:BreaKHis 和 CBIS-DDSM 到底该怎么选
2.1 BreaKHis 的目录里到底装了什么
BreaKHis 是乳腺病理图像分类里最常被拿来当基准的数据集。它由巴西巴拉那联邦大学发布,包含 82 个病例的乳腺组织病理图像,分良性和恶性两大类。每张原始图像是 700x460 像素的 RGB 病理切片图,并且同一个病灶区域提供了 40x、100x、200x、400x 四种放大倍率。
这个数据集的特性决定了它特别适合用来做迁移学习验证。
第一,图像本身就是普通的三通道 RGB 图,不需要像处理影像学 DICOM 文件那样做窗宽窗位转换,加载路径短。第二,良恶性二分类任务足够简单清晰,类别定义没有争议,不需要额外的标注工具。第三,它有病例级别的分组信息,可以做严格的患者独立划分,这对后面的模型验证非常重要。
下载解压之后,我一般会先把目录整理成统一格式:
BreaKHis_v1/ benign/ 001/ 40x/ SOB_B_1_1_001_40x.png 100x/ 200x/ 400x/ 002/ ... malignant/ 001/ ...这段命令先按良恶性大类建立顶层目录,再按病例编号建二级目录,最后按放大倍率放图片。整理好目录之后,读取阶段只需要遍历三层结构就能拿到路径、标签和倍率三份信息,避免每次实验都去解析散乱的文件名。
这里有一个容易踩的坑:不要用文件名里的SOB前缀去推断性别或病灶类型。BreaKHis 的文件名结构是SOB_B_1_1_001_40x,其中B代表良性(Benign),如果文件名里是M才是恶性(Malignant),跟性别无关。直接解析最后一个下划线之前的数字作为患者 ID 是更安全的做法,因为同一患者的不同图片在这个位置共享相同的数字段。
2.2 CBIS-DDSM 要不要碰:钼靶与病理切片的差别
除 BreaKHis 之外,另一个高频出现的数据集是 CBIS-DDSM,它的图像是乳腺钼靶 X 光影像,不是组织病理切片。钼靶影像的格式通常是 DICOM,需要先做像素值到密度的转换,图像是灰度大图,病灶区域小且对比度低,分类模型要做区域裁剪或使用目标检测辅助。
如果你的目标是把乳腺癌症图像分类当成一个深度学习入门项目,或者做课程实验,CBIS-DDSM 性价比不高。原因有三条:DICOM 解析和窗宽窗位调整本身就要花掉一两天时间;单张图像尺寸巨大,训练显存压力大;阳性区域在整张图里占比小,不做细粒度标注直接分类,模型很容易学成“有没有拍到位”而不是“有没有病灶”。
如果哪天你碰到的需求本身就是钼靶筛查场景,那 CBIS-DDSM 值得碰,但那时核心工作量变成了 ROI 提取和类别不均衡处理,不再是本文要展开的常规图像分类流程。
| 维度 | BreaKHis | CBIS-DDSM |
|---|---|---|
| 图像类型 | 病理组织切片,RGB | 钼靶 X 光,DICOM |
| 图像尺寸 | 700x460,小图 | 整图数千像素 |
| 任务难度 | 二分类直接可用 | 需 ROI 提取或检测辅助 |
| 预处理成本 | 低 | 高 |
| 适合场景 | 深度学习入门、迁移学习验证 | 影像组学、真实筛查场景 |
2.3 目录组织和文件加载的最小骨架
BreaKHis 的原始发布里带有患者级别信息,这一点在搭建数据管道时必须当成第一优先级。下面这段 PyTorch 代码实现了一个最基础的病理图像 Dataset,它会从整理后的目录读取图片,按患者 ID 记录归属:
import torch from torch.utils.data import Dataset from PIL import Image import os class BreakHisDataset(Dataset): def __init__(self, root, magnification=None, transform=None): self.samples = [] for label_name in ["benign", "malignant"]: label_dir = os.path.join(root, label_name) for patient_id in os.listdir(label_dir): patient_dir = os.path.join(label_dir, patient_id) for mag in os.listdir(patient_dir): if magnification and mag != magnification: continue mag_dir = os.path.join(patient_dir, mag) for fname in os.listdir(mag_dir): if fname.lower().endswith(".png"): path = os.path.join(mag_dir, fname) # 用患者目录名记录归属,而不是文件名 self.samples.append({ "path": path, "label": 1 if label_name == "malignant" else 0, "patient_id": patient_id, "mag": mag }) self.transform = transform def __len__(self): return len(self.samples) def __getitem__(self, idx): info = self.samples[idx] image = Image.open(info["path"]).convert("RGB") if self.transform: image = self.transform(image) return image, info["label"], info["patient_id"]这段代码的关键在于__getitem__返回了patient_id。不要小看这个返回值,后续做病例级别的评估、统计同一患者的多个 patch 投票结果,都需要它。如果这里不把患者 ID 透传出来,后面做任何划分和评估都得回退到重新解析文件名。
3. 预处理与增强:染色归一化、patch 切块和数据增强的一套配置
3.1 为什么要做染色归一化:Reinhard 方法的可落地方案
病理切片图像有一个显著特性:不同实验室、不同批次的染色试剂和切片厚度,会让同一类组织的颜色分布差异巨大。有的切片偏蓝紫色,有的偏粉色。深度学习模型对颜色统计非常敏感,如果训练集全是浅色切片、测试集全是深色切片,模型很容易学到“颜色浅就是良性”这样的伪规律。
Reinhard 染色归一化是解决这类问题最常见的做法。它的思路是把一张待处理图像转换到 LAB 颜色空间,然后让它的均值和标准差对齐到目标图像的均值和标准差。LAB 空间的 L 通道表示亮度,A 和 B 通道表示颜色对立维度,在 LAB 空间做统计匹配比直接在 RGB 空间做更符合人类视觉感知。核心代码如下:
import numpy as np from skimage import color def reinhard_normalize(source_rgb, target_rgb): # 转 LAB 空间 source_lab = color.rgb2lab(source_rgb).astype(np.float32) target_lab = color.rgb2lab(target_rgb).astype(np.float32) # 计算每个通道的均值与标准差 source_mean = source_lab.mean(axis=(0, 1)) source_std = source_lab.std(axis=(0, 1)) target_mean = target_lab.mean(axis=(0, 1)) target_std = target_lab.std(axis=(0, 1)) # 逐通道对齐到 target 的统计分布 normalized_lab = (source_lab - source_mean) / (source_std + 1e-8) * target_std + target_mean # 裁剪回合法范围并转回 RGB normalized_lab = np.clip(normalized_lab, 0, 100) normalized_rgb = color.lab2rgb(normalized_lab) return (normalized_rgb * 255).astype(np.uint8)这段代码执行了三步:先把源图和目标图转到 LAB 空间,然后分别计算两个图的均值与标准差,最后把源图的分布对齐到目标图的分布。1e-8是防止标准差为零导致除零错误。
实际使用时不需要对每张图都做归一化,只需要在数据增强里对输入图像做一次。目标图的选择也有讲究:从训练集中挑一张染色浓度适中的图作为固定参考,整个训练过程都用它做对齐目标,不要每个 batch 都换目标图,否则模型看到的颜色分布会不稳定。
3.2 把原始图像变成训练样本:缩放、切 patch、标签组织
模型输入尺寸一般固定为 224x224 或 256x256,BreaKHis 原始图像是 700x460,缩放是必然操作。但直接整图缩放会带来一个问题:400x 倍率下看到的细胞细节在缩放后可能只剩下模糊的色块。
常见的做法是“按倍率决定策略”。
对于 40x 和 100x 倍率,整图缩放到模型输入尺寸损失不算太大,可以直接用。对于 200x 和 400x,我一般会做 patch 切块。具体方案是把原图均匀切成 4 个不重叠的 patch,每个 patch 独立成为一个训练样本,标签沿用原图标签。推理时对同一张原始图的多个 patch 做投票,能显著提升稳定性。切块代码如下:
import torch from torchvision import transforms from PIL import Image def extract_patches(image, grid=(2, 2)): w, h = image.size pw, ph = w // grid[0], h // grid[1] patches = [] for i in range(grid[0]): for j in range(grid[1]): box = (j * pw, i * ph, (j + 1) * pw, (i + 1) * ph) patches.append(image.crop(box)) return patches # 使用例 image = Image.open("SOB_B_1_1_001_40x.png").convert("RGB") patches = extract_patches(image, grid=(2, 2)) transform = transforms.Compose([ transforms.Resize((224, 224)), transforms.ToTensor(), transforms.Normalize(mean=[0.485, 0.456, 0.406], std=[0.229, 0.224, 0.225]) ]) train_tensors = [transform(p) for p in patches]注意这里用的Normalize参数是 ImageNet 的均值和标准差。因为后面会加载在 ImageNet 上预训练的模型权重,输入数据分布需要和预训练分布保持一致。
3.3 数据增强哪些该开、哪些该关
病理图像的增强策略跟自然图像有明显区别。自然图像里常用的随机擦除和强颜色抖动,在这里要非常谨慎。随机擦除会抹掉可能包含癌变细胞的区域,可能导致模型被迫学习不完整的细胞结构;强颜色抖动则会把前面染色归一化的成果毁掉大半。
推荐的安全增强组合是:随机旋转、水平翻转、垂直翻转、轻微缩放平移、小幅度亮度对比度变化。水平垂直翻转在病理图像里是安全的,因为细胞核的形态判读不依赖绝对方向,癌变特征在旋转和翻转后语义不变。
import albumentations as A from albumentations.pytorch import ToTensorV2 train_transform = A.Compose([ A.Resize(224, 224), A.RandomRotate90(p=0.5), A.HorizontalFlip(p=0.5), A.VerticalFlip(p=0.5), A.ShiftScaleRotate(shift_limit=0.05, scale_limit=0.05, rotate_limit=10, p=0.5), A.RandomBrightnessContrast(brightness_limit=0.1, contrast_limit=0.1, p=0.3), A.Normalize(mean=(0.485, 0.456, 0.406), std=(0.229, 0.224, 0.225)), ToTensorV2() ]) valid_transform = A.Compose([ A.Resize(224, 224), A.Normalize(mean=(0.485, 0.456, 0.406), std=(0.229, 0.224, 0.225)), ToTensorV2() ])我在RandomBrightnessContrast里把幅度限制在 0.1,就是因为病理图像的染色差异已经由归一化处理过了,增强阶段只需要模拟轻微的拍摄光照波动,不需要大幅度改变颜色分布。如果你发现训练集上准确率能到 99%、验证集却只有 85%,优先怀疑的就是增强里颜色扰动太强,模型把染色风格特征当成了分类依据。
4. 用预训练模型做迁移学习:训练配置与实验细节
4.1 选 EfficientNet-B0 当 baseline 的四个理由
第一个理由:预训练权重好找。EfficientNet 系列在 PyTorch 官方和 Timm 库都有可以直接加载的 ImageNet 预训练权重,省去从零训练的漫长等待。
第二个理由:参数量小,显存压力低。EfficientNet-B0 只有 530 万左右参数,在 224x224 输入下跑一个 batch size 64 的训练,8GB 显存就能扛住。对于医疗数据集这种普遍只有几千张图的小规模场景,不需要上 ResNet152 这种大模型,参数太多更容易过拟合。
第三个理由:它在医学图像迁移学习上的表现经过大量论文验证,拿来当 baseline 有足够的参考可比性。第四个理由:EfficientNet 系列的输入分辨率是分档的,B0 是 224,B4 是 380。实验做深之后想验证分辨率对精度的影响,直接换 PyTorch 的工厂函数就能切换,不用重写训练循环。
| 超参数 | 取值 | 说明 |
|---|---|---|
| 模型 | EfficientNet-B0 预训练 | 输出层改为 1 个神经元 |
| 输入尺寸 | 224x224 | 与 ImageNet 预训练对齐 |
| 优化器 | AdamW | 解耦权重衰减,比 Adam 稳 |
| 初始学习率 | 3e-4 | backbone 层与分类头分层 |
| Batch size | 32 | 显存不足时优先减到 16 |
| 训练轮数 | 30 | 早停 patience 设为 8 |
| 学习率调度 | Cosine Annealing | 配合 warmup 5 个 epoch |
| 损失函数 | BCEWithLogits | pos_weight 按样本比设置 |
4.2 训练超参数表与训练循环代码
上面这些是我在乳腺病理二分类上常用的配置,核心思路是“迁移学习用低学习率微调”。如果你是从零训练,3e-4 这个学习率很容易让模型快速过拟合,因为数据量太少。
下面是一段基础训练循环代码。这段代码刻意简化了日志和检查点逻辑,把训练主干暴露出来,方便替换成自己的评估逻辑。
import torch import torch.nn as nn from torch.utils.data import DataLoader from efficientnet_pytorch import EfficientNet model = EfficientNet.from_pretrained("efficientnet-b0") model._fc = nn.Linear(model._fc.in_features, 1) # 分层学习率:分类头学习率大,backbone 学习率小 backbone_params = list(model._conv_stem.parameters()) + list(model._blocks.parameters()) head_params = model._fc.parameters() optimizer = torch.optim.AdamW([ {"params": backbone_params, "lr": 3e-5}, {"params": head_params, "lr": 3e-4} ], weight_decay=1e-4) criterion = nn.BCEWithLogitsLoss(pos_weight=torch.tensor([0.55])) scheduler = torch.optim.lr_scheduler.CosineAnnealingLR(optimizer, T_max=30, eta_min=1e-6) def train_one_epoch(model, loader, optimizer, criterion, device): model.train() total_loss = 0 for images, labels, _ in loader: images = images.to(device) labels = labels.float().unsqueeze(1).to(device) optimizer.zero_grad() logits = model(images) loss = criterion(logits, labels) loss.backward() optimizer.step() total_loss += loss.item() return total_loss / len(loader)pos_weight这里设置为 0.55,是因为 BreaKHis 中恶性样本数量大于良性样本,约 2500 比 5400。pos_weight会放大正样本的损失权重,把它设成良性样本数 / 恶性样本数可以将类别不均衡抵消掉。不同数据集这个数值要重新计算,不要照抄。
Early stopping 我用验证集上的 F1 作为监控指标,而不是 loss。因为在类别不均衡的医疗数据上,loss 下降不代表模型真的变好了,F1 能同时反映对少数类的查全和查准。
4.3 类别失衡怎么处理:从加权损失到重采样
pos_weight是最轻量的做法,只修改损失函数里的权重,不改变采样分布。如果发现pos_weight调起来效果不理想,下一步加重采样。WeightedRandomSampler会在每个 epoch 按权重重新采样少数类样本,让模型每个 batch 里看到的良恶性比例基本平衡。
from torch.utils.data import WeightedRandomSampler def make_balanced_sampler(dataset): labels = [s["label"] for s in dataset.samples] class_counts = torch.bincount(torch.tensor(labels)) weights = 1.0 / class_counts[torch.tensor(labels)].float() sampler = WeightedRandomSampler(weights, num_samples=len(weights)) return sampler这段代码计算每个样本的采样权重时,用的是它的类别样本数的倒数。恶性样本少,恶性样本的权重就高,Dataloader 就会更频繁地抽到它们。注意num_samples设为样本总数,这样每个 epoch 的总步数和原来基本一致,不会出现一个 epoch 太长或太短的问题。
我从不会同时用pos_weight=2.0和WeightedRandomSampler,二者叠加常常造成模型对少数类过度拟合,训练集 F1 很高但测试集泛化反而变差。正确做法是先用一个,监控结果不满意再换另一个。
5. 医疗图像分类的避坑与排查:从数据泄漏到假阴性翻车
5.1 按图像划分训练集:准确率高全是幻觉
现象:使用全量数据随机划分训练集和测试集,训练集准确率 98%,测试集准确率 96%,换到真实场景立刻崩。
原因:BreaKHis 里同一患者的良恶性图像高度相似,不同患者的差异被随机划分藏掉了。随机划分时,同一患者的图片可能同时出现在训练集和测试集,模型实际是在记忆患者级别的纹理特征,而不是在学病变模式。这个问题在医疗图像里称为数据泄漏,是乳腺癌图像分类论文里最常见的拒稿理由。
解决:必须按患者 ID 分组。把病例列表先划分成训练病例和测试病例,再根据患者 ID 把对应图片放进相应集合。BreaKHis 的原始官方划分是这样做的,但你自己二次划分时不能破坏这个约束。
from sklearn.model_selection import GroupShuffleSplit import numpy as np patient_ids = np.array([s["patient_id"] for s in dataset.samples]) group_split = GroupShuffleSplit(n_splits=1, test_size=0.2, random_state=42) train_idx, val_idx = next(group_split.split(patient_ids, groups=patient_ids)) train_samples = [dataset.samples[i] for i in train_idx] val_samples = [dataset.samples[i] for i in val_idx]5.2 染色差异被模型学了:验证集飘、测试集炸
现象:训练时 loss 正常下降,但验证集 F1 在 0.7 附近剧烈震荡,振幅超过 0.1。
原因:不同批次的切片染色深度不同,模型把颜色风格当成判别特征,验证集的颜色分布一旦与训练集差异较大,效果立刻下降。这是病理图像分类的经典问题,玄学色彩很重,很多人第一反应是调模型,其实问题出在数据风格上。
解决:做染色归一化。不需要学多复杂的深度归一化网络,先用 3.1 节的 Reinhard 方法把训练和验证图像统一到同一目标风格。做完之后如果验证集 F1 稳定了 5 个点以上,那就是赚到了。
5.3 类别不均衡导致准确率很好但召回率没法看
现象:测试集准确率 94%,看起来很好,但发现良性样本几乎全对,恶性样本只对了一半。这是典型的“少数类被牺牲”现象。
原因:BreaKHis 本身恶性样本多,如果损失函数不做处理,模型只需要把多数类猜对就能拿到不错的损失值。
解决:看混淆矩阵,别再只看准确率。统计测试结果里的假阴性数量,用 4.3 节的WeightedRandomSampler或pos_weight重新训练,把恶性类的召回率推到 95% 以上。对医疗场景来说,漏掉一个恶性病例比多召回几个良性病例后果严重得多,模型宁可假阳性偏高也不能假阴性高。
5.4 学习率设太大:NaN 和震荡
现象:第一个 epoch 训练 loss 从 0.5 降到 0.2,第二个 epoch 直接变成 NaN,验证集 F1 归零。
原因:预训练模型在迁移初期梯度波动大,学习率设成自然图像常用的 1e-3 会把权重一步推出正常范围,导致梯度爆炸。
解决:预训练迁移学习用两个学习率:backbone 用 3e-5,分类头用 3e-4。如果已经出现 NaN,降低学习率到 1e-5 重跑,不必更换优化器。同时把梯度裁剪加上,clip_grad_norm_(model.parameters(), max_norm=1.0)花不了几行代码,能避免偶发的大梯度毁掉整个训练过程。
5.5 倍率组合要分开看:40x 和 400x 不是同一个域
现象:混合所有倍率训练,验证集 F1 0.83。单独用 40x 训练再测 40x,F1 0.87。单独用 400x 训练再测 400x,F1 0.85。
原因:四个倍率下的图像纹理特征差异很大。400x 图像里细胞核细节丰富,40x 图像里更多是组织结构信息。混合训练时,模型被迫同时拟合四种纹理分布,如果网络容量不足,会学到一种“四不像”的表征。
解决:实验开始时把倍率看成一个条件变量。方案一:按倍率分别训练四个模型,推理时根据输入图像的倍率选择对应模型。方案二:训练时把倍率信息拼接到特征向量上,让模型学一个条件分类器。方案一简单有效,是大多数复现论文的做法。
6. 验证你的模型是不是真的可用:阈值调优、患者级投票和可解释性
训练完的模型只是一个 logits 输出,真正到“能不能用”这一步还差三个动作。
第一个动作是阈值调优。默认阈值 0.5 适合自然图像二分类,但在医疗场景里这个阈值往往不是最优解。对测试集的 logits 做一次阈值扫描,画出一条 ROC 曲线,然后根据合理偏好选择操作点。如果更在意不漏诊恶性,就在保证恶性召回率不低于 95% 的前提下挑准确率最高的阈值;如果更在意整体准确率,就取约登指数最大点。这个操作只需要几十行代码,却能让 F1 涨 2 到 3 个点。
第二个动作是患者级投票。如果用 patch 切块训练,那么同一张原始图会产生多个预测结果。将这些预测 logits 取平均或取最大,作为这个患者的最终输出。经验上取平均比取最大稳定,取最大在恶意样本上更敏感但容易把良性误判成恶性。以患者为评估单元比以 patch 为评估单元更接近真实临床场景,也更公平。
import torch def patient_vote(model, patches_loader, device, mode="mean"): preds = [] with torch.no_grad(): for batch in patches_loader: images = batch[0].to(device) logits = model(images) preds.extend(torch.sigmoid(logits).cpu().numpy().flatten()) if mode == "mean": return float(np.mean(preds)) elif mode == "max": return float(np.max(preds))第三个动作是可解释性验证。我一般会对测试集里预测正确和预测错误各取几张图,跑一遍 Grad-CAM,把热力图叠加回原图。病理科医生看到热力图后能告诉你模型关注的是细胞核区域还是空白背景,这比任何指标都真实。如果热力图大面积集中在图像边缘或染色背景上,说明模型学到的是伪特征,再高的指标也不能信。
我自己吃过大亏:有一版模型测试集 F1 到了 0.92,正准备提交,结果把热力图调出来发现模型在关注切片边缘的组织皱褶,而不是细胞核区域。后来检查发现是数据增强里没有加随机裁剪,模型学会用边缘位置特征蒙混过关。从那以后,热力图检查和指标检查成了我的固定环节。
乳腺癌症图像分类这个方向,数据集的组织方式决定了实验的下限,预处理和倍率策略决定了上限,训练只是把这两件事兑现的过程。希望你少走点弯路,希望帮到你。
本文还有配套的精品资源,点击获取