简介:面向肺炎胸片四分类识别任务的数据集,适合医学图像处理学习者和深度学习入门者直接使用。资源覆盖 COVID、Lung_Opacity、Normal、Viral_Pneumonia 四类 PNG 胸片图像,训练集 16933 张、测试集 4232 张,按类别分文件夹存放,便于 PyTorch、TensorFlow 等框架直接读取;附带的 classes JSON 字典完成类别与标签名映射,省去手写标签的麻烦,解压后无需额外清洗与预处理即可开始训练。包内共 2000 个文件,以 PNG 图像为主,包含 1 个 show.py 脚本用于随机展示样本并保存预览图,方便快速检查图像质量与类别分布;资源包约 743MB,目录结构清晰,已有 698 人浏览学习。针对需要跑通肺炎分类基线模型或开展医学图像分类实验的场景,这份数据集能显著减少数据收集和整理时间,并将训练集与测试集预先划分好,让使用者直接聚焦模型设计与调参。
1. 一份4分类肺炎胸片数据集,卡住你的往往不是模型而是数据
做医学图像分类的同行应该都有体会:模型选型、调参这些事,折腾两三个晚上总能出个能看的数字;真正让项目反复翻车的,是数据本身。肺炎胸片图像识别4分类数据集,就是把“正常、细菌性肺炎、病毒性肺炎、新冠”这四类标签落到一张张X光胸片上,听起来只是比二分类多两类,实际做起来,类别之间的视觉差异远比想象中小——细菌性和病毒性肺炎在胸片上都是片状阴影,连放射科医生都经常拿不准。这篇文章基于我处理这类数据集的实战经验,把数据整理、按患者分流、训练baseline到验证模型的可复现流程拆开讲,适合正在做医学图像分类课题、准备用公开数据集起步的同学,也适合想评估这类数据能不能支撑自己业务场景的工程师。
2. 从原始胸片到可训练数据集:目录组织与按患者分流
2.1 4分类标签怎么定:临床分型和类别平衡
先说标签。市面上的肺炎胸片公开数据集,常见标签体系有两类:一类是二分类(正常/肺炎),另一类是细分到病原体的多分类。4分类一般指正常、细菌性肺炎、病毒性肺炎、新冠(COVID-19)这四类,也有少数数据集把“支原体肺炎”单列出来。选数据集之前,先想清楚你要解决什么问题——如果只是做课程设计,二分类改四分类的边际收益很低,因为二分类的准确率天花板更高;如果是要发论文做临床试验的前置验证,四分类才有意义,因为它逼着模型去学不同病原体在影像上的细微差异。
拿到原始数据后,第一件事不是写模型,而是统计类别分布。公开数据集的类别不平衡问题普遍存在:正常和细菌性肺炎往往几千张,病毒性肺炎可能只有几百张,新冠部分则要看采集时间段。我见过一个所谓的4分类数据集,新冠类只有不到200张,训练时占比不到5%,模型对这类别的recall经常掉到0.6以下。统计分布用一行代码就能看明白:
import pandas as pd import os data_root = "./pneumonia_xray4" categories = ["normal", "bacterial", "viral", "covid"] dist = {} for c in categories: dist[c] = len(os.listdir(os.path.join(data_root, c))) df = pd.DataFrame(list(dist.items()), columns=["category", "count"]) df["ratio"] = (df["count"] / df["count"].sum()).round(4) print(df)这段代码做的事情很简单:遍历每个类别目录,统计图片数量并算出占比。逻辑上需要注意的一点是,os.listdir拿到的只有文件名,如果原数据集是按子目录组织的,还需要用os.walk递归统计。拿到分布后,如果最少的类别占总样本比例低于10%,后面训练就必须要加类别权重或者做针对性的增强,否则测试时即使整体准确率有90%以上,你去看混淆矩阵会发现小类别几乎全军覆没。
从文件整理到标签文件,我习惯把类别映射写成一个CSV而不是直接用目录结构,原因有两个:一是后续做按患者分流时,直接在DataFrame里筛病人ID比挪文件方便得多;二是深度学习框架的Dataset类读CSV的泛化性更强,换数据集时不用改目录结构。映射代码长这样:
import csv from pathlib import Path rows = [] for label_idx, category in enumerate(categories): img_dir = Path(data_root) / category for img_path in img_dir.glob("*.png"): rows.append({"image_path": str(img_path), "label": label_idx, "category": category}) with open("pneumonia_labels.csv", "w", newline="") as f: writer = csv.DictWriter(f, fieldnames=["image_path", "label", "category"]) writer.writeheader() writer.writerows(rows)glob("*.png")只匹配了PNG文件,实际情况里胸片数据集常见格式是PNG和JPG两种,建议改成glob("*.*")再做后缀白名单过滤。另外,公开数据集里偶尔混着损坏的图片文件,读图时报错会让你整个训练中断。我一般会在整理脚本里顺手做一次完整性检查,用PIL.Image.open验证图片能正常解码,当场过滤掉坏图,免得训练到一半才翻车。
2.2 整理脚本:统一尺寸、去重和按患者切分
胸片数据的预处理比自然图像更讲究。自然图像你可以大胆做随机裁剪、翻转、色彩抖动,胸片不行——因为诊断信息可能分布在肺野的任何位置,盲目的随机裁剪有可能把病灶区域裁掉,导致模型学到错误的对应关系。同时,胸片本质是灰度图,不同设备采集的图像分辨率差异极大,从512×512到3000×3000都有。不统一尺寸的话,训练时每次都要动态resize,batch之间的计算开销差异会拖慢速度。
我的做法是先统一到224×224或者256×256,这是ResNet和EfficientNet的标准输入尺寸,后面接预训练权重也方便:
from PIL import Image from tqdm import tqdm resized_root = "./pneumonia_xray4_resized" os.makedirs(resized_root, exist_ok=True) for c in categories: os.makedirs(os.path.join(resized_root, c), exist_ok=True) for row in tqdm(rows, desc="Resizing"): src = row["image_path"] dst = os.path.join(resized_root, row["category"], os.path.basename(src)) try: img = Image.open(src).convert("L") # 强制转灰度 img = img.resize((224, 224), Image.BILINEAR) img.save(dst, "PNG") except Exception as e: print(f"Failed: {src}, error: {e}").convert("L")是胸片处理里容易忽略的一步:有些公开数据集的PNG其实是三通道,但三通道数值完全一样,不转灰度会白白增加三倍的计算量,还可能在归一化时引入意外的通道偏差。Image.BILINEAR双线性插值对胸片来说是够用的,不要用最近邻插值,那个会把肺纹理的细节搞出锯齿。
接下来是整份数据里最关键的一步:按患者分流。很多公开数据集的文件名里带有患者ID,比如person123_virus_456.png这种格式。按图片级别随机切分训练集和验证集是新手最常见的错误:同一个患者的正位片和侧位片可能一张进了训练集、一张进了验证集,模型相当于看到过“答案”,验证集指标被严重虚高。医疗影像的标准做法是按患者切分,确保同一个病人的所有图片只出现在一个集合里:
import re from sklearn.model_selection import GroupShuffleSplit df = pd.read_csv("pneumonia_labels.csv") df["patient_id"] = df["image_path"].apply(lambda p: re.search(r"person(\d+)", p).group(1)) split = GroupShuffleSplit(n_splits=1, test_size=0.2, random_state=42) train_idx, val_idx = next(split.split(df, groups=df["patient_id"])) train_df = df.iloc[train_idx] val_df = df.iloc[val_idx] print(f"train patients: {train_df['patient_id'].nunique()}, val patients: {val_df['patient_id'].nunique()}")re.search(r"person(\d+)", p)这个正则只适用于文件名里带person数字格式的数据集,如果你拿到的数据命名是纯数字ID或者有下划线分隔的编码,正则表达式要相应调整。正则匹配不到时会抛异常,建议改成先用字符串分割取第二段再尝试转数字,拿不准时直接打印文件名看一眼格式。GroupShuffleSplit的groups参数传患者ID,这保证了同一个人不会同时出现在两个集合里。
切分完之后还有一件容易被忽略的事:验证集里每个类别的占比。患者级别的切分天然会导致验证集类别分布跟训练集略有差异,如果差异太大,要回头检查是不是某个类别本身只来自极少数的患者——这种情况我在处理病毒性肺炎数据时遇到过,一个类别的100张图全部来自30个患者,患者级分流后验证集可能只分到几例,指标波动会非常大。这种时候只能做数据增强扩充,或者考虑Few-shot学习的思路,硬着头皮用小样本训练。
3. 用预训练ResNet在胸片4分类上跑通baseline:预处理与训练参数
3.1 灰度图怎么喂给ImageNet预训练模型
胸片数据集预处理里争议最多的一个点:灰度图要不要复制三通道再喂给ImageNet预训练模型。常见的做法确实是把单通道灰度图复制三次变成三通道,因为预训练权重的第一个卷积层接受的是3通道输入。这里有个细节值得注意:ImageNet预训练是在自然图像上做的,RGB通道的统计特性和灰度胸片完全不同。如果你直接用了ImageNet的mean和std([0.485, 0.456, 0.406]),灰度图的数值分布会被扭曲——胸片像素值集中在较窄的范围内,肺野区域和背景区域对比度极强,用自然图像的归一化参数反而会压制有效特征。
我的做法是自己计算数据集的mean和std,或者干脆对胸片做直方图均衡化后再归一化。直方图均衡化对胸片特别有效,因为原始胸片经常出现整体偏暗或者曝光不足,对比度不高,均衡化之后肺纹理和病灶阴影的边界会清晰很多。
import cv2 import numpy as np class ChestXrayDataset(torch.utils.data.Dataset): def __init__(self, df, img_dir, augment=False): self.df = df.reset_index(drop=True) self.img_dir = img_dir self.augment = augment def __len__(self): return len(self.df) def __getitem__(self, idx): row = self.df.iloc[idx] img_path = os.path.join(self.img_dir, row["image_path"]) img = cv2.imread(img_path, cv2.IMREAD_GRAYSCALE) # 直方图均衡化提升对比度 img = cv2.createCLAHE(clipLimit=2.0, tileGridSize=(8, 8)).apply(img) # 灰度转三通道:复制通道,适配ImageNet预训练权重 img = np.stack([img] * 3, axis=-1) # 归一化:胸片自行统计的mean/std img = img.astype(np.float32) / 255.0 img = (img - np.array([0.456, 0.456, 0.456])) / np.array([0.224, 0.224, 0.224]) if self.augment: img = self.augment_fn(img) label = row["label"] return torch.from_numpy(img).permute(2, 0, 1), torch.tensor(label, dtype=torch.long)这里用了cv2.createCLAHE做有限对比度自适应直方图均衡化,clipLimit=2.0、tileGridSize=(8,8)是胸片上的常用经验参数,比你直接用cv2.equalizeHist整个图均衡要好,因为CLAHE是按局部区域增强的,不会让背景噪声一起被放大。归一化的mean/std用0.456/0.224是我在几个胸片数据集上统计出来的近似值,不同数据集会有出入,严格的做法是在训练前跑一遍全量统计。np.stack([img] * 3, axis=-1)复制通道时要注意类型:先转成float32再stack,避免uint8溢出。
数据增强策略在胸片上要克制。我见过有人照搬ImageNet那套随机旋转30度、随机裁剪、颜色抖动的组合,结果模型在验证集上掉点明显。原因很直接:胸片的方向是有临床意义的——心脏影在左侧,主动脉弓的位置是固定的,旋转30度会破坏解剖结构的位置信息。可用的增强只有三个:轻微随机水平翻转(X光正位片左右翻转不改变诊断)、小角度旋转(±5度)、轻微缩放平移。用一个5度以内的仿射变换就够,多了全是副作用。
3.2 训练参数:batch size、学习率、早停和类别权重
Baseline我一般用ResNet18或者ResNet34,不建议一上来就上EfficientNet-B4这种大模型。医学图像分类的训练范式是“小模型先跑通pipeline,再放大模型提精度”,因为胸片分类的特征量级不大,ResNet18就能学到肺野纹理和阴影区域的高层特征,这个阶段的目标是验证数据pipeline没问题、指标能到合理范围,而不是刷排行榜。
用PyTorch写训练循环时,有几个参数值得专门说明:
import torch import torch.nn as nn import torch.optim as optim from torchvision import models from torch.utils.data import DataLoader model = models.resnet18(weights=models.ResNet18_Weights.IMAGENET1K_V1) model.fc = nn.Linear(model.fc.in_features, 4) for param in model.parameters(): param.requires_grad = False for param in model.fc.parameters(): param.requires_grad = True train_loader = DataLoader(train_dataset, batch_size=32, shuffle=True, num_workers=4) val_loader = DataLoader(val_dataset, batch_size=32, shuffle=False, num_workers=4) class_counts = train_df["label"].value_counts().sort_index().values class_weights = 1.0 / class_counts class_weights = torch.tensor(class_weights, dtype=torch.float32) class_weights = class_weights / class_weights.sum() * len(class_counts) criterion = nn.CrossEntropyLoss(weight=class_weights) optimizer = optim.Adam(model.fc.parameters(), lr=1e-3) scheduler = optim.lr_scheduler.ReduceLROnPlateau(optimizer, mode="max", factor=0.5, patience=2) best_acc = 0.0 patience_counter = 0 for epoch in range(30): model.train() for images, labels in train_loader: out = model(images) loss = criterion(out, labels) optimizer.zero_grad() loss.backward() optimizer.step() model.eval() val_preds, val_labels = [], [] with torch.no_grad(): for images, labels in val_loader: out = model(images) val_preds.extend(out.argmax(dim=1).tolist()) val_labels.extend(labels.tolist()) val_acc = (np.array(val_preds) == np.array(val_labels)).mean() scheduler.step(val_acc) if val_acc > best_acc: best_acc = val_acc torch.save(model.state_dict(), "pneumonia_resnet18_best.pth") patience_counter = 0 else: patience_counter += 1 if patience_counter >= 5: break print(f"best val_acc: {best_acc:.4f}")参数说明这里重点讲几处。class_weights按类别样本数的倒数计算并做了归一化,这让损失函数自动放大少数类别的梯度贡献,比简单的过采样稳一些,适合类别比例差距在10倍以内的场景。ReduceLROnPlateau在验证集准确率两个epoch没提升时把学习率减半,配合patience=3的早停,能省掉大半无意义的训练时间。optimizer = optim.Adam(model.fc.parameters(), lr=1e-3)只优化最后一层,是迁移学习的标准做法——冻结主干,只让分类头从头学,这能避免微调初期梯度过大把预训练特征破坏掉。
如果数据集比较大(比如单类超过3000张),可以解冻主干最后两个stage做全量微调,学习率要降到1e-4以下。需要注意一个容易被忽略的点:DataLoader的num_workers在Windows上设大于0时要把训练代码包在if __name__ == "__main__":里,否则会递归创建子进程报错;在Linux服务器上设4到8没问题,本地笔记本设2就行。
整个训练流程跑完,ResNet18在这个任务上的典型表现是验证集准确率85%到90%——如果低于75%,先别急着换模型,回头检查预处理和数据分流;高于93%反而要警惕,看看是不是按图片切分导致的数据泄露。这个数字范围是我在多个公开数据集上测试的平均水平,不同数据集差异主要集中在病毒性肺炎这个类别上,它是四分类里的主要难点。
4. 胸片4分类的避坑手记:从数据泄露到玄学过拟合
4.1 同一个病人的多张片子混进了两个集合
现象:验证集acc到了92%,但你把预测结果逐张翻出来看,发现很多“预测正确”的图的病灶区域和训练集里的某张图几乎一模一样。进一步查文件名,才意识到同一个患者的前后几次复查片分别被划进了训练集和测试集。
原因:公开数据集里,一个患者往往有多张胸片——治疗前、治疗后或者不同角度投影。按图片随机划分,同一个人的重复采集信息同时出现在两个集合里,模型实际是在做“人脸识别”而不是“病灶识别”,测试集会骗你。
解决:回到2.2节说的按患者分流,用GroupShuffleSplit按患者ID切分。如果文件名里没有患者ID,就要从文件名模板里提取,常见的模板是patientID_报告号_view_时间戳.png。实在提取不出来,可以退而求其次用图片去重——计算感知哈希,把相似度过高的图片归到同一个患者近似组,但这是下策,不如原始元数据可靠。
4.2 准确率高但少数类recall全是泡沫
现象:四分类验证集acc是90%,矩阵一看,正常类和细菌性肺炎类的recall都在93%以上,但病毒性肺炎recall只有61%,新冠recall只有55%。整体数字被大头类别撑起来了,小类别的预测几乎没有参考价值。
原因:类别不平衡。特别是病毒性肺炎,在公开数据里经常只有五六百张,而且影像表现和细菌性肺炎高度重叠——两者都是局灶性实变影,模型倾向于把所有带阴影的图都判成样本量大的那个类。
解决:训练时用class_weights加权损失,这是最直接的手段。如果加权后recall还是上不去,要做针对性的数据增强:对少数类做小幅随机裁剪、亮度扰动、加少量高斯噪声,把训练样本的有效多样性撑起来。这里注意别用SMOTE这类合成少数类采样,它在图像上基本没什么效果,还可能把肺纹理生成出伪影。最后,评估指标不要只看acc,加一个macro-F1,这个指标对类别不平衡不敏感,能反映四分类的真实水平。
4.3 数据增强把病灶抹没了
现象:用了大角度的随机旋转和随机裁剪增强,训练集loss下降很慢,验证集acc比不做增强还低了3到4个点。把增强后的图可视化出来,发现有些图里肺野被裁掉了三分之一。
原因:胸片里病灶区域可能发生在肺尖、肋膈角、心脏后方的任何位置,强裁剪会让模型看不到完整肺野;大角度旋转则破坏了心脏、纵隔、膈肌这些解剖标志物的相对位置关系。这些信息对诊断是有决定性的,跟ImageNet里“不管物体在哪个位置都能分类”的假设完全不同。
解决:胸片的增强字典只有三样东西是安全的:水平翻转(概率0.3到0.5)、±5度以内的旋转、±10%以内的平移缩放。任何涉及大范围裁剪或颜色变换的操作,都在改动诊断特征本身。真觉得数据不够,正确的方向是去补充公开数据源,而不是靠强增强硬造——医学图像的噪声容忍度极低,造出来的“新图”很可能让模型学到错误的纹理。
4.4 验证集loss反复横跳,训练永远不收敛
现象:训练到第12个epoch时,验证集acc在86%和82%之间来回跳,val loss曲线像锯齿。调低学习率后变好一点,但很快又出现。再训练几个epoch,发现val loss不降反升,但train loss还在平稳下降。
原因:这是典型的两类问题叠加:一是学习率偏高,模型在损失曲面最优点附近震荡;二是正则化不足,模型开始过拟合训练集的噪声。胸片数据集的噪声本来就不小——不同设备采集的亮度、分辨率、是否有金属伪影,都会让模型记住不该记的细节。
解决:先把学习率降到当前值的四分之一,看两三个epoch是否稳定。如果还跳,就开weight_decay=1e-4的Adam优化器,这是ResNet加L2正则的标准配置。另外一个容易被忽视的操作是调整ReduceLROnPlateau的patience:从2加到3,给模型更多时间在scale上探索,不然学习率降太快会导致模型永远到不了局部最优。如果以上都做了还是跳,回到数据侧检查是不是某几个batch里混入了异常图片(比如侧位片混进了正位片集、或者术中透视片混进了普通胸片),我碰到过一次val acc骤降的情况,排查下来是数据整理时漏了一个子目录,把几十张侧位片混进了验证集。
5. 用混淆矩阵和Grad-CAM给模型“验尸”:不只是看acc
第3章跑完的baseline只能说明pipeline通顺了,距离“可以用”还差一步验证:模型到底在靠什么做判断。这一步我一般用两个工具:混淆矩阵定位类别间的混淆模式,Grad-CAM定位模型看的是不是真正的病灶区域。
混淆矩阵里重点盯两个格子:细菌性肺炎被预测为病毒性肺炎的比例,以及病毒性肺炎被预测为正常的比例。前者是影像本身的固有难度——两种肺炎在X光上都表现为磨玻璃影和实变影,靠X光区分本来就有极限;后者如果是大比例,要怀疑模型根本没有学习到病毒性肺炎的典型特征,而是靠背景、位置等捷径信息分类。
Grad-CAM的可视化实现并不复杂,PyTorch里可以借助hook拿到特征图梯度:
from torchvision import transforms import matplotlib.pyplot as plt model.eval() img, label = val_dataset[0] img = img.unsqueeze(0) out = model(img) # 注册hook,拿到最后一层卷积的输出和梯度 activation = {} def forward_hook(module, input, output): activation["feat"] = output model.layer4[-1].register_forward_hook(forward_hook) pred = out.argmax(dim=1).item() out[:, pred].backward() grad = model.layer4[-1].weight.grad # 更精确应该用gradient hook实际生产级的Grad-CAM实现推荐用pytorch-grad-cam这个库,比自己写hook省事得多,几行就能输出叠加热力图。验证的标准是:正常的胸片热力图应该集中在肺野中央和血管纹理区域,肺炎胸片应该集中在病灶阴影所在位置。如果热力图集中在图像角落、胸腔以外的区域,说明模型在走捷径——它在通过片子里的文字标注位置或者设备边框做判断,这种模型换个采集设备马上失效。
修复“走捷径”的办法不多,最有效的还是回到数据:裁掉图片边缘的文字信息、统一归一化策略、排除非肺野区域再训练。这一步做完,模型的发热位置才会和临床先验对上,这份4分类数据集才算真正被吃透。我从第一个医学图像分类项目到现在,一直保留“每个模型先验尸再看acc”的习惯,它救过我好几次,希望帮到你。
本文还有配套的精品资源,点击获取