简介:医学图像分类领域的肾脏结节与肿瘤识别数据集,面向需要训练分类网络(如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对应标签0,malignant对应标签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', encoding='utf-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 写一个通用的 MedicalImageDataset
PyTorch 自带的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, transform=None): self.root_dir = root_dir self.transform = transform with open(class_dict_path, 'r', encoding='utf-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或DICOM,DICOM需要先转成常见格式,这个Dataset默认读常规图像,如果是.dcm文件,可以在__getitem__里加一个分支用pydicom读取。
Image.open(path).convert('RGB')这里默认转成RGB三通道。肾脏CT如果是灰度图,转RGB会把同一个值复制到三个通道,虽然模型能跑,但浪费算力且可能影响BN统计。更严谨的做法是检测图像模式,如果是'L',就保留单通道,再在transform里重复成三通道。不过,为了不把代码写复杂,很多现成方案直接转RGB,也可以工作。这个点在第五章避坑里还会提。
在使用这个Dataset时,DataLoader建议设置shuffle=True,num_workers=4或8,pin_memory=True。如果你的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(p=0.5), transforms.RandomRotation(degrees=10), 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, axis=0) std = np.mean(stds, axis=0)不过医疗影像常用预训练模型,我一般先用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, dtype=torch.float) class_weight = make_class_weight(train_dataset) criterion = nn.CrossEntropyLoss(weight=class_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(pretrained=True) 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(), lr=3e-4, weight_decay=1e-4) scheduler = torch.optim.lr_scheduler.CosineAnnealingLR(optimizer, T_max=30, eta_min=1e-6)batch_size我一般先用32,如果GPU显存不足就减半。医学图像单张通常在224x224,32张大约占用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_size=32, shuffle=True, num_workers=4, pin_memory=True) val_loader = torch.utils.data.DataLoader( val_dataset, batch_size=32, shuffle=False, num_workers=2, pin_memory=True) 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(f'epoch {epoch+1}, 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小于8,BN会变得很不稳定,建议改用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, annot=True, fmt='d', cmap='Blues') plt.xlabel('pred') plt.ylabel('true') plt.show()再看CAM热力图。用torchcam或自己写一个Grad-CAM,能把模型关注的区域叠加到原图上。如果恶性样本的热力图高亮块不在肾脏区域,而是在图像边框、水印或设备型号上,那这个模型大概率吃了数据集bias,交到临床就是个黑匣子。
我自己踩过这个坑:肾脏超声分类模型验证集F1有0.9,CAM显示它聚焦在图像角落的品牌标识上。后来把所有图像按医生标注ROI裁剪,去掉标识信息,准确率掉到0.7,但这才接近真实。现在每次训练完,我会先抽出每个类别几张典型样本,把CAM图打出来贴到验证报告里,再决定要不要换模型或重新预处理。这张图比任何指标都诚实。希望帮到你。
本文还有配套的精品资源,点击获取