肾脏结节医学图像分类:数据划分与PyTorch训练避坑指南
发布时间:2026/10/5 7:16:17 作者:尧图编辑部 阅读量:1,286

简介医学图像分类领域的肾脏结节与肿瘤识别数据集面向需要训练分类网络如YOLOv5分类头或CNN模型的开发者与研究人员也可用于医学影像相关的课程设计、毕业设计或科研实验。资源内含完整的训练集、验证集、测试集划分三类图片总数分别为2800、800、400并附有正常、结节、肿瘤三个类别的JSON字典文件目录按文件夹组织同一类图片存放于各自目录便于直接加载和训练无需额外清洗。压缩包共2000个文件主体为1998张JPG图像另含1个Python可视化脚本和1个JSON类别映射文件整体大小约151.5MB已有300人学习下载。配合show脚本可快速预览样本分布与划分情况省去手动整理标签的步骤既支持YOLOv5的分类任务也适用于PyTorch、TensorFlow等常见CNN分类网络是开展医学图像分类项目时可直接使用的现成数据基础适合入门与中期验证。数据集划分明确、目录结构清晰可直接开展模型训练与效果对比减少从原始图像到可训练数据集的中间环节。1. 为什么肾脏结节分类要先解决数据划分而不是先调模型接手肾脏结节、肿瘤医学图像分类项目最耽误进度的往往不是模型选型而是数据本身。很多人拿到一批CT或超声图像随手扔进一个文件夹里再用脚本按比例随机切分最后连类别字典文件都没有全靠硬编码类别名。这种图像识别流程在医学场景里走不通肾脏影像里的结节、肿瘤和正常组织形态相近类别边界模糊数据划分一旦出问题后续所有训练和评估都失去意义。我见过一个真实项目算法工程师用文件名哈希做划分结果同一个病人的几十张切片同时出现在训练集和测试集验证准确率做到0.98模型看着很能打一上独立采集的数据就掉到0.6。问题不在网络结构而是数据泄漏。所以这篇实战笔记把一条可复现的路径拆开文件夹保存怎么组织、类别字典文件怎么写、PyTorch训练基线怎么搭、哪些坑必须提前规避。适合正在做医学图像分类、需要把方案落到自己数据上的算法和工程团队。2. 肾脏影像分类的第一步文件夹保存结构如何定类别字典怎么对得上2.1 文件夹保存三套目录与三类标签的硬约定在医学图像分类里数据按文件夹组织是最直观、排查效率最高的方式。一个分类项目至少要有train、val、test三个大目录每个目录里再按类别名建子文件夹类别名和类别字典文件保持严格一致。这样不管是人工随机抽查还是用脚本快速统计都只需要看路径字符串不需要去翻CSV里的绝对路径对不对。我一般会要求数据目录长这样data/ ├── train/ │ ├── benign/ # 良性结节或囊肿 │ │ ├── 001.png │ │ └── 002.png │ ├── malignant/ # 恶性或可疑 │ │ ├── 003.png │ │ └── 004.png │ └── normal/ # 正常组织 │ ├── 005.png │ └── 006.png ├── val/ └── test/注意这里的类名只是举例实际以你的类别字典文件为准。用文件夹保存而不是CSV路径列表最大的好处是如果你在Linux服务器上远程训练可以用tree或find快速看到分布顺着路径就能找到某个样本不用额外开一个文件索引服务。另外一个硬约定是文件夹里只放图像不放缩略图、日志、遮罩之类。如果图像识别任务里还包含分割掩码那就单独建一个masks平行目录用同样的文件名前缀不要混在类别文件夹里否则训练时读到损坏文件会报错。在划分数据时要小心肾脏CT序列的场景。常见做法是每个病人的多个slice属于同一个病例应该把同一病人的所有图像放在同一边避免跨数据集。如果文件夹结构是patient_id/类别/图像.png可以先按病人ID分组再切分。但很多公开数据集已经按类别分好了像本标题提到的“划分好的数据【文件夹保存】”默认就是良性、恶性等类别子目录。这种情况下至少要确认一个病人不会同时出现在train和test。我会写一个快速筛查脚本find data/train -name *.png | awk -F/ {print $4} | sort -u train_pids.txt find data/test -name *.png | awk -F/ {print $4} | sort -u test_pids.txt comm -12 train_pids.txt test_pids.txt如果输出不为空就说明有病人ID重叠需要重新划分。awk的字段分隔符/$4取决于你的路径层级修改到对应位置即可。另外val和test不要共用一套目录。很多经验不足的工程师会把测试集当验证集用导致早停失效模型又退化成在测试集上调参这也是文件夹没分开的祸根。测试集应该只在最终评估时打开训练过程中一遍都不要看。如果你发现自己的val acc和test acc总是一模一样先怀疑是不是在跑着跑着把test路径顺手填了进去。2.2 类别字典文件JSON 映射的三种写法与校验类别字典文件是医学图像分类项目里的“标定基准”。它明确告诉训练代码文件夹名benign对应标签0malignant对应标签1。没有这个文件你只能在DataLoader里写死[benign,malignant]换数据就得改代码很脆弱。常见的有三种写法。第一种是类别名到整数ID的映射最常用{ benign: 0, malignant: 1, normal: 2 }第二种是顺序敏感的列表靠列表下标当ID{ classes: [benign, malignant, normal] }第三种是带中文显示名的完整字典适合需要在界面展示的场景{ 0: {name: benign, display: 良性}, 1: {name: malignant, display: 恶性} }哪种更可靠我倾向于第一种。加载时可以直接做class_to_idx生成预测时可以用idx_to_class反向映射。如果后期增加类别只需要在JSON里加一行。无论用哪种写法训练前都要做一次目录和字典的一致性校验。写一个小脚本读取JSON后再用操作系统接口扫描每个文件夹下的文件数对比类名是否对得上。import json, os with open(class_dict.json, r, encodingutf-8) as f: class_dict json.load(f) for split in [train, val, test]: split_path os.path.join(data, split) actual_dirs [d for d in os.listdir(split_path) if os.path.isdir(os.path.join(split_path, d))] missing set(class_dict.keys()) - set(actual_dirs) extra set(actual_dirs) - set(class_dict.keys()) if missing: print(f{split} 缺少类别目录: {missing}) if extra: print(f{split} 有多余目录: {extra})这里class_dict.keys()在第一种和第二种JSON里拿到的都是类别名。对于第三种则需要先取出name字段再比对。校验跑完没有输出才说明文件夹保存和类别字典文件是配对的。类别字典文件还要纳入版本管理。我见过有人把类别字典写在训练代码同一个Python文件里线上改了一版后线下模型还在用旧字典预测出来的标签全错了。正确做法是把它复制进训练输出目录随模型一起存档方便回溯。医生提出“这堆病例算什么类”时你能对着字典拿出一个明确的解释而不是去代码里翻magic number。3. 用 PyTorch 读取文件夹数据Dataset 实现与图像增强参数3.1 写一个通用的 MedicalImageDatasetPyTorch 自带的torchvision.datasets.ImageFolder天然支持按文件夹读取但它把类别名按字母排序映射成ID或者接受classes参数。可是我们的类别字典文件是JSON如果完全依赖ImageFolder每次都要传classes列表而且和类别字典的对应关系不透明。所以更常见的做法是自己写一个Dataset显式加载类别字典这样全项目共用一套标签映射。一个能直接用的实现如下import json import os from PIL import Image from torch.utils.data import Dataset class MedicalImageDataset(Dataset): def __init__(self, root_dir, class_dict_path, transformNone): self.root_dir root_dir self.transform transform with open(class_dict_path, r, encodingutf-8) as f: self.class_dict json.load(f) self.class_to_idx self.class_dict # {benign: 0, malignant: 1, ...} self.idx_to_class {v: k for k, v in self.class_dict.items()} self.samples [] # 每个元素是 (image_path, label_index) for class_name, label in self.class_to_idx.items(): class_dir os.path.join(root_dir, class_name) if not os.path.isdir(class_dir): print(f警告: 目录不存在 {class_dir}) continue for fname in sorted(os.listdir(class_dir)): if fname.lower().endswith((.png, .jpg, .jpeg, .tif, .bmp)): self.samples.append((os.path.join(class_dir, fname), label)) def __len__(self): return len(self.samples) def __getitem__(self, idx): path, label self.samples[idx] image Image.open(path).convert(RGB) if self.transform: image self.transform(image) return image, label这里有几个关键设计。class_to_idx直接引用JSON里维护的映射而不是在代码里跳过字典获取。idx_to_class反过来用于推理时把网络输出转成文本标签。samples列表在初始化时一次性扫出来避免每次__getitem__都去遍历文件夹这在数据量大时有明显性能差。医学图像很多是PNG或DICOMDICOM需要先转成常见格式这个Dataset默认读常规图像如果是.dcm文件可以在__getitem__里加一个分支用pydicom读取。Image.open(path).convert(RGB)这里默认转成RGB三通道。肾脏CT如果是灰度图转RGB会把同一个值复制到三个通道虽然模型能跑但浪费算力且可能影响BN统计。更严谨的做法是检测图像模式如果是L就保留单通道再在transform里重复成三通道。不过为了不把代码写复杂很多现成方案直接转RGB也可以工作。这个点在第五章避坑里还会提。在使用这个Dataset时DataLoader建议设置shuffleTruenum_workers4或8pin_memoryTrue。如果你的GPU比较紧张把batch_size调小不要增加num_workers因为worker过多在某些老旧Linux系统上反而会因为文件句柄不足报错。医学图像数据集的图片数量可能不大但如果图片很大比如512x512以上要先做一次全量缩略缓存否则训练时每次都要读大文件GPU会一直等CPU。3.2 数据增强与归一化医学影像要克制医学图像分类的数据增强和自然图像不一样。自然图像可以旋转、剪切、翻转、色彩抖动因为物体语义不变。但肾脏结节和肿瘤的影像中上下翻转可能改变解剖位置过强的对比度扰动可能让医生都看不清病灶。所以我一般在训练里只加小角度旋转、水平翻转和轻微缩放测试阶段只做Resize和归一化。以224x224输入为例具体transform配置from torchvision import transforms train_transform transforms.Compose([ transforms.Resize((256, 256)), transforms.RandomCrop(224), transforms.RandomHorizontalFlip(p0.5), transforms.RandomRotation(degrees10), transforms.ToTensor(), transforms.Normalize(mean[0.485, 0.456, 0.406], std[0.229, 0.224, 0.225]) ]) val_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]) ])这里的mean和std用的是ImageNet预训练模型的常用值。如果你的骨干网络是从ImageNet预训练来的保留这组值通常没问题。如果网络是完全从头训练的医学影像模型更可靠的做法是用训练集的统计值计算mean和std。计算脚本大概是import numpy as np from PIL import Image means [] stds [] for path, _ in train_dataset.samples: img np.array(Image.open(path).convert(RGB)) / 255.0 means.append(img.mean(axis(0, 1))) stds.append(img.std(axis(0, 1))) mean np.mean(means, axis0) std np.mean(stds, axis0)不过医疗影像常用预训练模型我一般先用ImageNet均值跑小样本预实验如果损失收敛稳定就不折腾。注意训练和验证/测试的预处理必须一致否则模型看到的输入分布和训练时不同。另外类别不平衡在肾脏肿瘤数据集里很常见。比如良性结节数量是恶性的5倍。单纯的RandomCrop和翻转不会改变类别比例但能缓解过拟合。真正的处理手段放在损失函数里这是第4章的重点。数据增强的另一个细节是不要用Resize((224,224))直接压扁先放大到256再随机裁剪224相当于增加了平移扰动模型会稍微鲁棒一点。验证集统一Resize到224保证可复现。提示如果你只有一份没有划分的数据建议先按病人ID分组再切分不要直接随机切分。随机切分在医学图像识别里几乎必出数据泄漏后面所有指标都不可信。医疗图像的通道统计和自然图像差异很大如果使用ImageNet预训练网络而mean/std不匹配第一层卷积输出分布可能偏移。但这通常是可容忍的因为预训练网络后续层学习到的特征仍然有泛化能力。若发现收敛慢再根据自己的数据计算归一化参数。4. 训练参数与类别不平衡把肾脏结节识别从能跑到好用4.1 损失函数交叉熵、加权交叉熵与标签平滑肾脏结节、肿瘤分类数据集里良性样本数量通常远大于恶性。如果直接使用nn.CrossEntropyLoss()模型会偏向多数的良性类别少数类别的Recall很低。对于医学图像识别漏诊恶性通常比误报良性更严重所以需要给少数类更高的权重。一种做法是根据训练集中每个类别的样本数反比设置权重。代码import torch import torch.nn as nn def make_class_weight(dataset): label_counts {} for _, label in dataset.samples: label_counts[label] label_counts.get(label, 0) 1 total sum(label_counts.values()) weight [0.0] * len(label_counts) for label, count in label_counts.items(): weight[label] total / (len(label_counts) * count) return torch.tensor(weight, dtypetorch.float) class_weight make_class_weight(train_dataset) criterion nn.CrossEntropyLoss(weightclass_weight)这里用总样本数除以类别数的平均作为均衡基准。比如三类样本数分别是1000、200、200总数为1400那么每类权重为1400/(31000)0.466少数类为1400/(3200)2.333。这样的好处是各类的加权样本数尽量接近。类权重需要在训练集上计算不能在测试集上计算这是原则。CrossEntropyLoss的weight参数只影响loss计算不改变模型输出分布。如果你想同时兼顾多数类准确率可以将权重适当打折比如weight^0.5调和一下precision和recall。这个参数在医学场景被调得很多我一般先用严格反比看验证集混淆矩阵再决定要不要软化。另外推荐标签平滑label smoothing。医学图像标注存在主观差异相同的一组切片不同影像科医生可能给出不同结论硬标签会让模型过度置信。nn.CrossEntropyLoss自带label_smoothing参数从PyTorch 1.10开始支持。常见值设为0.1即标签0或1被平滑成0.9/0.1。这个值不需要调太大否则模型的预测概率会变得模糊不利于阈值判断。4.2 学习率、批大小与早停一套不容易翻车的起步参数训练一个肾脏图像分类基线优化器、学习率、批大小组合很重要。我推荐一套保守起步配置适合ResNet18/ResNet50这类常用结构。model torchvision.models.resnet18(pretrainedTrue) num_classes len(train_dataset.class_to_idx) model.fc nn.Linear(model.fc.in_features, num_classes) model model.to(device) optimizer torch.optim.AdamW(model.parameters(), lr3e-4, weight_decay1e-4) scheduler torch.optim.lr_scheduler.CosineAnnealingLR(optimizer, T_max30, eta_min1e-6)batch_size我一般先用32如果GPU显存不足就减半。医学图像单张通常在224x22432张大约占用6GB左右显存很多老显卡能跑。学习率3e-4是AdamW适配预训练微调的一个安全点如果从头训练一般要降到1e-4且需要更长训练轮数。weight_decay权重衰减设置不当也很常见。我遇到过把weight_decay设为0.01结果模型欠拟合验证loss一直下不去。对于ResNet微调1e-4是比较稳妥的默认值如果你发现模型严重过拟合训练集loss很低但验证集不降可以先增加到5e-4同时配合早停。训练循环本身不用写得太复杂但要记录每个epoch的train loss、val loss和验证集指标。下面是一段常见的最小训练代码骨架train_loader torch.utils.data.DataLoader( train_dataset, batch_size32, shuffleTrue, num_workers4, pin_memoryTrue) val_loader torch.utils.data.DataLoader( val_dataset, batch_size32, shuffleFalse, num_workers2, pin_memoryTrue) best_acc 0.0 patience 0 for epoch in range(30): model.train() total_loss 0.0 for images, labels in train_loader: images, labels images.to(device), labels.to(device) optimizer.zero_grad() outputs model(images) loss criterion(outputs, labels) loss.backward() optimizer.step() total_loss loss.item() * images.size(0) model.eval() correct, total 0, 0 with torch.no_grad(): for images, labels in val_loader: images, labels images.to(device), labels.to(device) outputs model(images) _, predicted torch.max(outputs, 1) total labels.size(0) correct (predicted labels).sum().item() acc correct / total print(fepoch {epoch1}, train_loss{total_loss/len(train_dataset):.4f}, val_acc{acc:.4f}) if acc best_acc: best_acc acc torch.save(model.state_dict(), best_model.pth) patience 0 else: patience 1 if patience 5: print(early stop) break这里有几个关键点。scheduler.step()我没放在循环里因为我倾向于用ReduceLROnPlateau的早停风格即验证损失不降时降低学习率。上面代码用了最直接的早停5个epoch验证准确率没有刷新就停。CosineAnnealingLR需要每个epoch后调用如果你只复制这段可以在循环末尾加上scheduler.step()。训练前一定要把模型切到model.train()验证时切到model.eval()否则BatchNorm会引入训练集统计噪声导致验证集准确率波动。这是老生常谈但翻车的人依旧很多。类别不平衡时验证指标只看accuracy会误导。因为如果恶性占10%全猜良性准确率就有90%。所以要在验证集上打印每一类的precision、recall、f1至少打印混淆矩阵。相关代码放最后一章这里先提个醒。batch size不只影响显存还影响BatchNorm的统计量。医学影像数据集如果很小比如每个类别只有几百张batch size设为32已经够用如果batch size小于8BN会变得很不稳定建议改用GroupNorm或者增加梯度累积。5. 肾脏图像分类避坑训练过程与数据读取的5个常见问题5.1 训练损失不降但验证集准确率很稳现象loss在0.7左右纹丝不动验证准确率却一直保持85%以上。原因可能是类别不平衡和多数类压过了少数类。网络学到的策略是每个样本都预测多数类此时交叉熵loss可能并不低因为少数类全部算错但准确率看起来很高。另一个常见原因是学习率太小或模型没有正确进入train模式BatchNorm更新不动loss被卡在某个平台。解决先打印预测分布看输出中是否所有样本都集中到某一类。如果是给损失函数加类别权重或改用Focal Loss如果不是把学习率调大一倍再跑前10个epoch看趋势。同时检查代码里是否有model.eval()没有切回model.train()的地方。5.2 类别字典和文件夹名称不一致导致标签全错现象训练时loss下降到接近0但测试集准确率只有50%。查看预测结果发现模型把“结节”和“正常”混为一谈。原因最常见的是类别字典文件里的类名和文件夹名不一致比如文件夹叫benign字典里叫normal导致Dataset某几个类扫描不到或者把别的类的内容加载成了一个新类。如果只返回警告而不是异常标签对应关系会整体错位。解决在训练前运行第2.2节的校验脚本。另外建议在数据集类里加一个初始化日志把class_to_idx打印出来人工核对一次。还有一种隐蔽情况机器上文件系统大小写不敏感文件夹Benign和字典benign在Windows上能对上在Linux上却无法识别项目因为环境差异所以最好规定所有类名一律小写并且用os.listdir看到的实际名称反查字典。5.3 灰度图被强行复制成三通道推理时却用单通道现象训练时一直用convert(RGB)各种准确率都不错部署时换成原始单通道图像预测随机且概率很低。原因很多医学影像本来就是灰度或单通道CT值矩阵虽然在Dataset里转成了RGB但三通道内容完全一样。推理时若加载原始单通道并直接输入预训练网络形状对不上或者被网络当作单通道输入分布完全变了。这是图像识别项目里典型的训练/推理不一致。解决统一处理。要么在训练和推理两端都使用同一套预处理要么在代码里明确检测图像模式。我的习惯是网络输入固定三通道时训练、验证、推理全部用convert(RGB)阅片工具也改成三通道省得脑子转不过来。如果CT是12位灰度直接转8位RGB会丢信息但这是另一个层面的话题至少先保证输入格式一致。5.4 batch 大小与图片尺寸不一致导致 DataLoader 崩溃现象训练中突然报错RuntimeError: stack expects each tensor to be equal size通常发生在某个epoch中途。原因数据集中混入了不同尺寸的图片比如有DICOM转出来的PNG分辨率是512x512另一些手机拍摄的截图分辨率是1280x960。虽然在transform里写了Resize但如果有画像在__getitem__中被错误过滤或分支返回了未resize的张量就会崩。解决检查transform是否作用在了图像上。最简单的方式是写个自检遍历dataset.samples逐个跑__getitem__观察有没有异常尺寸。另一个容易忽略的是灰度图在convert(RGB)后尺寸不变但某些批次的张量如果来自不同分支仍会不齐。最稳妥的方案是在Dataset初始化里统一检查所有图像尺寸只保留符合范围的避免训练中途翻车。5.5 同一个病人的切片泄漏到训练和测试集现象验证集准确率极高测试集也很高但在外部公开数据集上准确率明显下降。原因没有按病人ID划分或划分时使用了随机种子但种子固定导致同一病人的多个slice被分到不同集合。肾脏CT一个序列可能包含几十层层与层之间高度相关这种泄漏会让评估虚高。解决如果数据有病人ID目录严格按ID切分如果没有至少将文件名前缀或元数据中的病人ID提取出来做分组。sklearn的StratifiedGroupKFold可以派上用场但很多人不知道医学场景需要group。更简单的做法是在文件夹结构里加入病人ID层级即data/train/patient_001/benign/xx.png然后再拉平。本标题中的“划分好的数据【文件夹保存】”可能已经是扁平类目录这时你要用文件名中的病人标识做二次校验避免泄漏。6. 落地进阶用混淆矩阵和 CAM 热力图验证模型真的在看肾脏区域分类准确率不是医学图像识别的终点你还要回答医生一个问题模型是靠病灶特征分类还是靠扫描伪影、曝光角度之类的中看不中用的线索。我会做两件事混淆矩阵和CAM热力图。混淆矩阵能帮你找出最容易混的类别。假设你的标签是良性、恶性、正常三类医生最关心的是恶性被误判成良性也就是假阴性。单独看accuracy看不出来但混淆矩阵里的一行会很清楚。代码from sklearn.metrics import confusion_matrix import seaborn as sns import matplotlib.pyplot as plt # 在测试集上推理得到 y_pred, y_true cm confusion_matrix(y_true, y_pred) sns.heatmap(cm, annotTrue, fmtd, cmapBlues) plt.xlabel(pred) plt.ylabel(true) plt.show()再看CAM热力图。用torchcam或自己写一个Grad-CAM能把模型关注的区域叠加到原图上。如果恶性样本的热力图高亮块不在肾脏区域而是在图像边框、水印或设备型号上那这个模型大概率吃了数据集bias交到临床就是个黑匣子。我自己踩过这个坑肾脏超声分类模型验证集F1有0.9CAM显示它聚焦在图像角落的品牌标识上。后来把所有图像按医生标注ROI裁剪去掉标识信息准确率掉到0.7但这才接近真实。现在每次训练完我会先抽出每个类别几张典型样本把CAM图打出来贴到验证报告里再决定要不要换模型或重新预处理。这张图比任何指标都诚实。希望帮到你。本文还有配套的精品资源点击获取