
简介面向深度学习中医学图像分类方向的学习者与研究者乳腺癌症图像分类数据集提供了完整的二分类任务数据类别仅两类任务聚焦明确。数据集按目录存放同一类别样本集中在对应目录下并附带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 提取和类别不均衡处理不再是本文要展开的常规图像分类流程。维度BreaKHisCBIS-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, magnificationNone, transformNone): 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 或 256x256BreaKHis 原始图像是 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(p0.5), A.HorizontalFlip(p0.5), A.VerticalFlip(p0.5), A.ShiftScaleRotate(shift_limit0.05, scale_limit0.05, rotate_limit10, p0.5), A.RandomBrightnessContrast(brightness_limit0.1, contrast_limit0.1, p0.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 是 224B4 是 380。实验做深之后想验证分辨率对精度的影响直接换 PyTorch 的工厂函数就能切换不用重写训练循环。超参数取值说明模型EfficientNet-B0 预训练输出层改为 1 个神经元输入尺寸224x224与 ImageNet 预训练对齐优化器AdamW解耦权重衰减比 Adam 稳初始学习率3e-4backbone 层与分类头分层Batch size32显存不足时优先减到 16训练轮数30早停 patience 设为 8学习率调度Cosine Annealing配合 warmup 5 个 epoch损失函数BCEWithLogitspos_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_decay1e-4) criterion nn.BCEWithLogitsLoss(pos_weighttorch.tensor([0.55])) scheduler torch.optim.lr_scheduler.CosineAnnealingLR(optimizer, T_max30, eta_min1e-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_sampleslen(weights)) return sampler这段代码计算每个样本的采样权重时用的是它的类别样本数的倒数。恶性样本少恶性样本的权重就高Dataloader 就会更频繁地抽到它们。注意num_samples设为样本总数这样每个 epoch 的总步数和原来基本一致不会出现一个 epoch 太长或太短的问题。我从不会同时用pos_weight2.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_splits1, test_size0.2, random_state42) train_idx, val_idx next(group_split.split(patient_ids, groupspatient_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_norm1.0)花不了几行代码能避免偶发的大梯度毁掉整个训练过程。5.5 倍率组合要分开看40x 和 400x 不是同一个域现象混合所有倍率训练验证集 F1 0.83。单独用 40x 训练再测 40xF1 0.87。单独用 400x 训练再测 400xF1 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, modemean): 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正准备提交结果把热力图调出来发现模型在关注切片边缘的组织皱褶而不是细胞核区域。后来检查发现是数据增强里没有加随机裁剪模型学会用边缘位置特征蒙混过关。从那以后热力图检查和指标检查成了我的固定环节。乳腺癌症图像分类这个方向数据集的组织方式决定了实验的下限预处理和倍率策略决定了上限训练只是把这两件事兑现的过程。希望你少走点弯路希望帮到你。本文还有配套的精品资源点击获取