乳腺癌图像分类数据集实操:从数据预处理到ResNet18迁移学习
简介这是面向深度学习和医学图像分类场景的乳腺癌症图像二分类数据集适用于需要训练卷积神经网络、进行迁移学习或验证分类算法的研究者和开发者目前已有286人学习使用。类别由JSON类别文件定义图像按训练集、验证集、测试集目录存放训练集约480张、验证集约140张、测试集约70张便于直接划分训练与评估。资源包共692个文件以689张JPG图像为主另附1个JSON类别文件、1个Python脚本和1个PNG示意文件整体约17.85MB。JSON文件帮助快速读取类别映射Python脚本可辅助数据加载与预处理PNG示意文件则用于查看目录结构或样例说明。使用者下载后即可获得一套组织清晰、可直接用于深度学习实验的乳腺癌症图像数据省去自行采集、清洗和标注的繁琐流程能快速开展模型训练、效果对比和分类性能验证适合课程设计、毕业设计及科研入门使用。1. 乳腺癌图像分类数据集上手先看清东西再谈训练「乳腺癌图像分类」听起来像个标准任务但真拿一份只有 690 张左右的图像分类数据集动手时你很快会发现深度学习最考验人的地方不在前向传播而在数据有没有被正确解读。这份资源是一个已经按目录分好类的乳腺癌症二分类数据集训练集约 480 张、验证集约 140 张、测试集约 70 张类别个数为 2具体类别名以随包附带的 JSON 类别文件为准。它解决的是「拿到一份可复现的图像分类数据集不用自己清洗和划分」的诉求适合两类人一是正在跑图像分类全流程入门实践的初学者二是需要在课题或算法验证里快速评估迁移学习、数据增强、伪标签等思路的从业者。下面按我实际拆包和复现的顺序把目录结构、预处理参数、训练代码与避坑点完整过一遍。2. 读懂这份数据集目录组织、Roboflow 命名与 JSON 标签2.1 目录组织方式先弄清楚三个文件夹各自装什么拿到压缩包先别急着解压训练第一步是打印目录结构。这个数据集保留了「按类别分文件夹」的导出结构和那种「一张 manifest.txt 配一堆散图」的扁平数据集完全不一样。import os root ./breast_cancer_cls for split in [train, valid, test]: split_path os.path.join(root, split) if not os.path.isdir(split_path): print(f[缺失] {split_path}) continue for class_name in sorted(os.listdir(split_path)): class_path os.path.join(split_path, class_name) if os.path.isdir(class_path): n len([f for f in os.listdir(class_path) if f.lower().endswith(.jpg)]) print(f{split:5} / {class_name:8} : {n:4} 张)这段代码会输出类似train / cancer : 260的分布表。为什么第一步就做这件事因为「训练集约 480 张」是一个总数量两个类别各自多少必须亲眼确认。如果打印出来发现 cancer 只有 120 张、normal 有 360 张那就是明显的不平衡后面损失函数就不能默认用不带权重的 CrossEntropyLoss。我习惯把扫描结果直接存成 manifest.csv后面加载数据统一走这个清单而不是每次重新读目录。这样既避免了 Windows 和 Linux 路径分隔符带来的差异也能让 DataLoader 在训练中断续跑的时候行为完全一致。路径含义使用建议train/cancer/癌症类样本参与梯度更新先统计数量决定是否做类别加权train/normal/正常类样本参与梯度更新同上valid/cancer/验证样本只用于选模型和调参绝不参与训练包括 BatchNorm 统计valid/normal/验证样本正常类只在验证阶段访问test/测试样本最终统一评测一次训练过程中一次都不碰2.2 文件名拆解_png_jpg.rf.后缀在说什么文件名在训练脚本里可能只是一个 path 字符串但放到真实工作流里它是追踪数据血缘的唯一线索。以1877249993_png_jpg.rf.f195e5e3c8d3964bf27194ac97ec7d32.jpg为例拆开看有三层含义前面1877249993是源图在标注平台内部的 ID相当于「源图主键」。中间_png_jpg表示原始上传是 PNG 格式导出时转成了 JPG这个信息在 OpenCV 读取时没有任何影响但它在提醒你这份数据集里的部分图像经历过有损压缩如果后续做图像质量过滤这个字段是一个可参考的信号。最后的.rf.加 32 位十六进制是这条数据在数据集导出时的唯一哈希适合当作行级去重键。我在项目里只用这串 hash 做去重。原因很简单Roboflow 系数据集经常出现同一张源图被导出两次的情况两个.rf.哈希不同但源图 ID 相同下一步数据清洗时可以用源图 ID 去重而不是用文件名整体去重。2.3 类别文件读法把标签写死在代码里是给自己留坑项目描述里明确写着「具体类别参考 json 类别文件」。这类导出数据的标注信息可能叫_classes.json也可能叫data.yaml不同工具命名习惯不同核心都是一个「类别名 → 索引」的映射。我的做法是先解析成字典再传给训练脚本而不是在代码里写死0代表癌症、1代表正常。import json with open(./breast_cancer_cls/classes.json) as f: label_map json.load(f) print(label_map) # 输出示例{cancer: 0, normal: 1} class_names [name for name, idx in sorted(label_map.items(), keylambda x: x[1])] print(class_names)这里有个细节值得说JSON 的键顺序在 Python 3.7 之后虽然是插入序但不同来源的 JSON 文件键顺序可能不一样所以取class_names时一定要按 value 排序而不是直接依赖字典的遍历顺序。后面画混淆矩阵、写分类报告时都靠这个排序保证类别名和索引始终对得上。3. 数据预处理与加载把目录结构变成可复现的训练管线3.1 用清单文件固化数据集有了目录扫描结果下一步就是把 train/valid/test 三套数据全部固化成一份 CSV。这个文件会成为训练管线的唯一数据入口。import json, csv from pathlib import Path root Path(./breast_cancer_cls) with open(root / classes.json) as f: label_map json.load(f) rows [] for split in [train, valid, test]: for class_name, label in label_map.items(): class_dir root / split / class_name if not class_dir.exists(): continue for img_path in sorted(class_dir.glob(*.jpg)): rows.append({ split: split, path: str(img_path), label: label, class_name: class_name, }) with open(manifest.csv, w, newline) as f: writer csv.DictWriter(f, fieldnames[split, path, label, class_name]) writer.writeheader() writer.writerows(rows) print(f共 {len(rows)} 条样本已写入 manifest.csv)这里有三件事值得展开。第一glob(*.jpg)只收 JPG 后缀解压过程中如果混入 PNG 或者损坏的 JPG后面我会用PIL.Image.verify()再统一校验一次。第二class_name和label同时存下来是为了最后画混淆矩阵时能直接显示可读的类别名而不是 0 和 1。第三CSV 里的路径在 Windows 下是反斜杠在 Linux 下是正斜杠后续代码统一用pathlib.Path处理避免在字符串拼接时踩分隔符的坑。3.2 预处理参数尺寸、归一化、增强怎么设才不翻车这一节是整个预处理管线里最容易翻车的地方。先给一组「不保证最优但能跑得很稳」的参数再逐个解释为什么这么设。from torchvision import transforms train_transform transforms.Compose([ transforms.Resize(256), transforms.RandomResizedCrop(224, scale(0.7, 1.0)), transforms.RandomHorizontalFlip(), transforms.RandomRotation(10), transforms.ToTensor(), transforms.Normalize( mean[0.485, 0.456, 0.406], std[0.229, 0.224, 0.225] ), ]) eval_transform transforms.Compose([ transforms.Resize(256), transforms.CenterCrop(224), transforms.ToTensor(), transforms.Normalize( mean[0.485, 0.456, 0.406], std[0.229, 0.224, 0.225] ), ])输入尺寸设为 224×224 是第一个要点。ResNet 这类迁移学习模型的 ImageNet 预训练权重其卷积核设计就是为 224 附近的输入服务的不按这个尺寸走预训练特征的空间结构就承接不上。第二个要点是Resize(256)后接RandomResizedCrop(224)而不是直接Resize(224)前者等价于随机裁剪相当于免费给模型加了一种尺度变换比简单缩放更能抵抗小数据集的过拟合。第三个要点是Normalize必须沿用 ImageNet 的均值标准差因为预训练权重是在这个归一化分布上收敛的换成自算的统计量反而会破坏预训练特征。注意这份乳腺癌症图像数据如果以灰度纹理为主建议去掉ColorJitter这类彩色扰动或者把强度压得很低。灰度结构对颜色抖动非常敏感随便调 hue 和 saturation 就可能把对比度信息洗掉这一点在第 5 章会专门展开。3.3 保持官方划分别拿验证集和测试集去凑训练量480/140/70 的划分是导出时就定好的我强烈建议不要为了凑训练量把验证集或测试集重新混进去。验证集的作用是选模型、调超参测试集的作用是最终评测两套数据的分布一旦被打乱后面跑的指标就失去了对比价值。配套的 Dataset 封装可以这样写import pandas as pd from PIL import Image from torch.utils.data import Dataset class ImageListDataset(Dataset): def __init__(self, df, transformNone): self.df df.reset_index(dropTrue) self.transform transform def __len__(self): return len(self.df) def __getitem__(self, idx): row self.df.iloc[idx] img Image.open(row[path]).convert(RGB) if self.transform is not None: img self.transform(img) return img, int(row[label]) train_df pd.read_csv(manifest.csv) train_df train_df[train_df[split] train] train_ds ImageListDataset(train_df, train_transform) train_loader DataLoader(train_ds, batch_size32, shuffleTrue, num_workers4)Image.open之后必须convert(RGB)因为数据集里可能混入灰度 PNG 转出的 JPG不做三通道统一PyTorch 在 batch 拼接时会直接报 shape 不一致。shuffleTrue只放在训练集上验证和测试的 DataLoader 保持shuffleFalse这样每次评估顺序稳定调试时对比结果更方便。4. 用 ResNet18 迁移学习跑出第一个准确率完整训练脚本解读4.1 为什么选 ResNet18小数据集的默认开局面对 690 张图像的二分类任务我一般不会从头训练一个深层 CNN这几乎等于让模型在黑匣子里重新发明视觉特征。常见做法是迁移学习使用在 ImageNet 上预训练的 ResNet18替换最后的全连接层然后微调。ResNet18 在「特征表达能力」和「过拟合风险」之间是最稳的平衡点ResNet50 在小数据集上反而更容易把训练集的纹理细节背下来验证集准确率却纹丝不动。还有一个实际考量ResNet18 即使只用 CPU 也能在十几分钟内跑完一个 epoch 的验证这对调参阶段的快速迭代非常友好。如果后续发现模型容量不够再换 EfficientNet-B0 或 ResNet34 也不迟。4.2 完整训练脚本骨架分组学习率与余弦退火import torch import torch.nn as nn from torchvision import models device torch.device(cuda if torch.cuda.is_available() else cpu) model models.resnet18(weightsmodels.ResNet18_Weights.IMAGENET1K_V1) model.fc nn.Linear(model.fc.in_features, 2) model model.to(device) optimizer torch.optim.AdamW([ {params: model.fc.parameters(), lr: 1e-3}, {params: [p for n, p in model.named_parameters() if fc not in n], lr: 1e-5}, ], weight_decay5e-4) criterion nn.CrossEntropyLoss() scheduler torch.optim.lr_scheduler.CosineAnnealingLR( optimizer, T_max30, eta_min1e-6 ) for epoch in range(30): model.train() total_loss 0.0 for imgs, labels in train_loader: imgs, labels imgs.to(device), labels.to(device) optimizer.zero_grad() out model(imgs) loss criterion(out, labels) loss.backward() optimizer.step() total_loss loss.item() scheduler.step() print(fepoch {epoch1:02d} / loss {total_loss / len(train_loader):.4f})这段代码的核心是分组学习率新替换的fc层用 1e-3 从零学起预训练主干用 1e-5 做微调避免一上来就把 ImageNet 学到的底层边缘纹理特征冲掉。weight_decay5e-4是调参时最先尝试的正则化强度在小数据集上能明显抑制过拟合。CosineAnnealingLR配合T_max30让学习率在 30 个 epoch 内从初始值平滑降到 1e-6相比每 10 个 epoch 直接除以 10 的 StepLR曲线更顺滑也省去反复试下降节点的精力。超参数取值为什么这么定batch_size32480 张训练图每轮约 15 步BN 统计量更新足够稳定fc 层学习率1e-3新分类头没有预训练需要更大的更新步长主干学习率1e-5预训练特征只做微调学习率过大会灾难性遗忘weight_decay5e-4几百张图的规模下L2 正则是最便宜的防过拟合手段T_max30训练总 epoch 设为 30与余弦周期对齐末尾自动收敛4.3 训练中的观察点怎么判断模型「真的在学」我判断一个训练是否正常的习惯是同时盯三个信号训练 loss 是否在下降验证集准确率是否在最初几个 epoch 内就从随机水平约 50%爬升以及验证集的 loss 是否在训练 loss 降到 0.1 附近时还保持在 0.4 以上。第三个信号尤其关键。如果训练 loss 已经降到 0.03验证 loss 还在 0.5 上下震荡说明模型开始背训练集了。此时先做两件事一是把主干学习率再降一个量级二是把增强中的随机裁剪 scale 下界从 0.7 调到 0.6从数据侧增加难度。都不要同时做一次只变一个变量否则你根本分不清是哪个改动带来的收益。训练结束后把验证集上准确率最高的 checkpoint 单独保存成best_model.pt而不是用最后一个 epoch 的权重这是最朴素的早停手段。5. 避坑与排查五件这个数据集会翻车的事5.1 模型预测全落在某一类上准确率卡在比例基线现象训练了 20 个 epoch验证准确率一直躺在 55%62% 不动查看预测结果发现模型把几乎所有样本都判成「normal」cancer 类几乎全军覆没。原因两个类别的样本数量不一致默认的 CrossEntropyLoss 在类别不平衡时会偏向学「全猜多数类」这种懒策略。二分类在阈值 0.5 附近尤其容易被这种假象骗到。解决先在 manifest.csv 里统计每类数量给损失函数加类别权重。常见做法是weight n_samples / (n_classes * n_class_samples)然后传入CrossEntropyLoss(weighttorch.tensor([w0, w1]))。如果训练已经跑完也可以不动损失函数直接在验证集上遍历阈值 0.450.55选 F1 最高的那个阈值作为最终决策线。5.2 训练 loss 接近 0验证集准确率却反复震荡现象训练 loss 一路跌到 0.03看起来一切正常但验证准确率在 68%72% 之间来回跳怎么都不往上走。原因典型过拟合。模型把训练集里不变的背景纹理、拍摄亮度当成了判别特征到了验证集这些伪特征一变预测就乱了。690 张图的规模对 ResNet18 来说本来就偏少不做够强的增强过拟合几乎是必然。解决把增强力度加上去重点改RandomResizedCrop的scale下界比如从(0.7, 1.0)放宽到(0.55, 1.0)让每次裁剪差异更大同时给CrossEntropyLoss加label_smoothing0.1把「硬标签 0/1」换成软标签模型就不会把置信度押得太满。这个组合在小数据集上通常能立刻看到验证曲线从震荡变成单边爬升。5.3 打开 ColorJitter 后训练 loss 变大增强也可能帮倒忙现象在预处理里加入了ColorJitter(brightness0.3, contrast0.3, saturation0.3)训练 loss 从 0.4 涨到 1.0 以上验证准确率不升反降。原因这份乳腺癌症图像很多是从灰度源转出来的颜色通道本身信息量很弱。ColorJitter 对 saturation 和 hue 的随机扰动等于在图像里注入噪声模型反而学不到稳定特征。解决按灰度优先的思路处理把颜色类增强全部去掉只保留RandomRotation(10)和RandomHorizontalFlip这类几何增强如果确实想加只保留亮度扰动强度压在 0.15 以内。这也是乳腺影像和自然图像在预处理上最大的差别。5.4 验证集准确率比训练集高一个容易误读的信号现象训练集准确率 76%验证集反而有 81%看起来「模型泛化得很好」但测试集一跑又回落到 72%。原因验证集和训练集在类别比例上的随机差异导致曲线波动更隐蔽的原因是验证集和测试集虽然都来自同一批导出但拍摄条件或预处理细节不完全一致验证集恰好分布在模型擅长的那部分空间里。690 张图的小规模下这个现象不需要过度惊慌但它提醒你别被某个单次验证分数绑架。解决训练期间不频繁用测试集而是固定一个验证集用于模型选择最终只用测试集的结果下结论。同时观察验证集 loss 而不是只看准确率准确率对概率校准不敏感loss 能反映更多预测置信度的变化。5.5 重新划分导致的数据泄漏一个必须反复强调的坑现象训练结果在验证集上刷到 92%换到另一台机器复现同一份数据准确率掉到 75%。原因有人为了「充分利用数据」把验证集或测试集合进训练集重新洗牌。验证集和测试集的任务本来就是扮演「从没见过的数据」一旦参与训练所有后续指标都失去意义跨机器复现时自然露馅。解决只使用官方划分保持 train/valid/test 三层结构不变。如果嫌训练数据少正确做法是第 6 章要讲的伪标签自训练而不是偷偷合并数据集。我自己的习惯是每次训练前先跑一遍目录扫描脚本确认三个文件夹的图片数量与项目描述一致再动手。6. 榨干 690 张图伪标签自训练与置信度阈值训练完基线模型后690 张训练图显然不够满意。与其去网上再找一份来路不明的数据合并我更喜欢先用伪标签自训练把测试集的剩余价值挖掘出来整个过程不需要额外的标注成本。第一步用训练好的best_model.pt对测试集做推理得到 softmax 概率向量。只把最大概率 ≥ 0.95 的样本挑出来打上伪标签。置信度阈值很关键定高了挑不出几张图定低了会把错误标签混进训练集。0.95 是我在二分类小数据集上的默认起点。第二步把这些高置信伪标签样本追加到训练集尾部把训练载体的train_loader重建一次用很保守的学习率继续微调主干 1e-6、fc 层 1e-4训练 1015 个 epoch每个 epoch 结束后都回到验证集上检查分数。第三步是迭代控制。每轮微调后记录验证集准确率如果相比之前的最优值没有提升直接丢弃这一轮的新伪标签停止迭代如果提升了可以尝试把阈值从 0.95 降到 0.90 再试一轮。这里最忌讳的就是贪心一次把全部测试集都吞进训练集错误的伪标签会直接把模型带偏。我在实际项目里每轮只接受几十张高置信样本迭代两到三轮后收益就会明显衰减这时候就该停了。顺便提醒一句这类乳腺癌症图像分类数据集是算法研究和教学复现的载体不宜直接当作临床诊断依据训练出的模型要做落地评估必须拿到目标设备上的真实数据重新验证。从那以后我每次拿到小规模分类数据集都会强制走一遍固定流程官方划分不动、增先从几何出发、迁移学习起步、伪标签只吃高置信样本。这套流程帮我挡掉了好几次「模型刷分很高但一部署就翻车」的尴尬。希望帮到你。本文还有配套的精品资源点击获取
上一篇/下一篇内容由系统自动关联
返回资讯列表 →