ARTICLE DETAIL

资讯详情

深耕编程入门与网站建设的一线实战洞察。

肺炎胸片4分类数据集实战:从数据整理到ResNet基线模型

肺炎胸片4分类数据集实战:从数据整理到ResNet基线模型 简介面向肺炎胸片四分类识别任务的数据集适合医学图像处理学习者和深度学习入门者直接使用。资源覆盖 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都有。不统一尺寸的话训练时每次都要动态resizebatch之间的计算开销差异会拖慢速度。我的做法是先统一到224×224或者256×256这是ResNet和EfficientNet的标准输入尺寸后面接预训练权重也方便from PIL import Image from tqdm import tqdm resized_root ./pneumonia_xray4_resized os.makedirs(resized_root, exist_okTrue) for c in categories: os.makedirs(os.path.join(resized_root, c), exist_okTrue) for row in tqdm(rows, descResizing): 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(fFailed: {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(rperson(\d), p).group(1)) split GroupShuffleSplit(n_splits1, test_size0.2, random_state42) train_idx, val_idx next(split.split(df, groupsdf[patient_id])) train_df df.iloc[train_idx] val_df df.iloc[val_idx] print(ftrain patients: {train_df[patient_id].nunique()}, val patients: {val_df[patient_id].nunique()})re.search(rperson(\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, augmentFalse): self.df df.reset_index(dropTrue) 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(clipLimit2.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, dtypetorch.long)这里用了cv2.createCLAHE做有限对比度自适应直方图均衡化clipLimit2.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(weightsmodels.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_size32, shuffleTrue, num_workers4) val_loader DataLoader(val_dataset, batch_size32, shuffleFalse, num_workers4) class_counts train_df[label].value_counts().sort_index().values class_weights 1.0 / class_counts class_weights torch.tensor(class_weights, dtypetorch.float32) class_weights class_weights / class_weights.sum() * len(class_counts) criterion nn.CrossEntropyLoss(weightclass_weights) optimizer optim.Adam(model.fc.parameters(), lr1e-3) scheduler optim.lr_scheduler.ReduceLROnPlateau(optimizer, modemax, factor0.5, patience2) 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(dim1).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(fbest val_acc: {best_acc:.4f})参数说明这里重点讲几处。class_weights按类别样本数的倒数计算并做了归一化这让损失函数自动放大少数类别的梯度贡献比简单的过采样稳一些适合类别比例差距在10倍以内的场景。ReduceLROnPlateau在验证集准确率两个epoch没提升时把学习率减半配合patience3的早停能省掉大半无意义的训练时间。optimizer optim.Adam(model.fc.parameters(), lr1e-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_decay1e-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(dim1).item() out[:, pred].backward() grad model.layer4[-1].weight.grad # 更精确应该用gradient hook实际生产级的Grad-CAM实现推荐用pytorch-grad-cam这个库比自己写hook省事得多几行就能输出叠加热力图。验证的标准是正常的胸片热力图应该集中在肺野中央和血管纹理区域肺炎胸片应该集中在病灶阴影所在位置。如果热力图集中在图像角落、胸腔以外的区域说明模型在走捷径——它在通过片子里的文字标注位置或者设备边框做判断这种模型换个采集设备马上失效。修复“走捷径”的办法不多最有效的还是回到数据裁掉图片边缘的文字信息、统一归一化策略、排除非肺野区域再训练。这一步做完模型的发热位置才会和临床先验对上这份4分类数据集才算真正被吃透。我从第一个医学图像分类项目到现在一直保留“每个模型先验尸再看acc”的习惯它救过我好几次希望帮到你。本文还有配套的精品资源点击获取
返回列表