ARTICLE DETAIL

资讯详情

深耕郑州网站建设与运营推广的一线实战洞察。

肺炎胸片4分类实战:从数据划分到模型评估的完整指南

肺炎胸片4分类实战:从数据划分到模型评估的完整指南 简介这份肺炎胸片图像识别四分类数据集专为医学影像分类与深度学习训练而整理面向医疗人工智能初学者、算法工程师及相关课题研究者数据已做好分类与目录划分无需额外预处理即可直接用作训练数据解决医学影像场景中数据整理与标签映射的常见痛点。包内共2000个文件以1998张PNG图像为主体另附1个Python可视化脚本和1个JSON类别字典压缩包约743MB。数据集划分了训练集与测试集训练集包含16933张图像测试集包含4232张图像覆盖COVID新型冠状肺炎阳性、Lung_Opacity肺部浑浊、Normal正常、Viral_Pneumonia病毒性肺炎四个类别解压后每个子文件夹按类别名存放对应图像便于快速构造数据集。配套的classes JSON字典提供类别索引show.py脚本可随机抽图预览并保存至当前路径帮助检查图像质量与类别分布。目前已有698人浏览学习适合作为肺炎胸片分类、医学影像预训练及模型验证的入门与实验数据。1. 肺炎胸片4分类数据集在解决什么问题从二分类到多分类先定数据再定模型临床上最关心的问题往往是“有没有肺炎”但直接训练二分类模型中期大概率翻车。细菌性、病毒性、真菌性肺炎在胸片上的纹理和分布差异很大混进同一个标签模型只能学到粗糙共性正常胸片与早期肺炎在很多视角下又高度相似二分类的决策边界很难画干净。肺炎胸片图像识别4分类数据集要做的就是把“有没有病”升级成“属于哪一类病变”让监督信号更清晰。我在带人和自己做这类项目时通常把这个数据集当作医学图像分类的基准任务来用它为算法工程师提供一条验证迁移学习与多分类管线的具体路径为科研学生提供从数据清洗、训练到评估的完整闭环也为个人开发者提供一个能横向对比骨干网络的中等规模样本集。它的价值不是样本量够大而是病理标签与影像特征被稳定对齐标签质量不保后面所有训练技巧都会失真。2. 从原始胸片到4分类训练集数据组织、划分脚本与最小加载管线拿到一批胸片图像最常见的冲动是直接跑训练但多数经验告诉我这一步省下的时间后面都会加倍还回去。医学影像多分类的调试开销不在网络结构而在数据管线的三个环节类别目录、患者级划分、可复现加载脚本。这三件事先固化下来之后的实验才有一个稳定的比较基准。2.1 四分类的类目怎么定先看标签体系再定模型任务胸片4分类没有行业统一的标签规范数据集本身的标签体系直接决定你能不能沿用。常见的标签体系有两套一套按病因划分例如正常、细菌性肺炎、病毒性肺炎、COVID-19肺炎另一套按影像征象划分例如正常、实变、磨玻璃影、结节。选哪套取决于你希望模型最后回答什么问题。按病因划分的优点是与临床报告对齐医生容易理解代价是COVID-19与病毒性肺炎在胸片上高度重叠按征象划分的优点是与放射科读片习惯吻合代价是同一张胸片可能同时存在实变和磨玻璃影归属类别时需要你预先定一条优先级规则比如“磨玻璃影为主且实变范围小于某个阈值时归入磨玻璃影”。如果你的数据是从多个公开来源拼出来的还要先把异构标签映射成统一的4类比如“pneumonia-bacterial”和“bacteria”都要映射到同一文件夹。这一步看起来机械却直接决定后续混淆矩阵的语义是否正确。2.2 用 train/val/test 三级文件夹组织数据最不容易出错的做法目录结构在PyTorch里就是接口协议。torchvision的ImageFolder默认按文件夹名推导类别Keras的flow_from_directory也一样所以不要再手写一套CSV加载器文件夹能表达的信息就不要绕路。我一般会在一开始就建成下面的结构dataset/ ├── train/ │ ├── normal/ │ ├── bacterial/ │ ├── viral/ │ └── covid/ ├── val/ │ ├── normal/ │ ├── bacterial/ │ ├── viral/ │ └── covid/ └── test/ ├── normal/ ├── bacterial/ ├── viral/ └── covid/三个硬性要求类别目录名统一用英文小写避免中文与空格带来的编码问题文件命名保留患者标识例如patient_001_t1.jpg命名规则决定后面的患者级划分是否好写只保留predicted class name的目录不要放原始压缩包和CSV防止加载器误读。这三级目录还有个容易被忽略的好处test目录只会被你用来做最终评估避免调参时反复看它而“过拟合验证集”。我在项目里如果只有train和val往往忍不住一遍遍调val上的阈值模型最终在外部数据上的表现会打折扣。2.3 一份可复制的患者级划分脚本胸片数据不能按文件随机划分到train和val原因很直接同一个患者可能有多张不同时期的胸片随机切分会把同一患者的多张图拆散到多个split里模型等于提前见过这个患者的成像特征验证指标虚高。这一点和做 yolo 类目标检测时按视频或按个体去重划分是同一个逻辑数据切分粒度错了后面的指标全都不算数。下面这份脚本按患者ID把原始未分组文件划分成三级目录import re import shutil from pathlib import Path from collections import defaultdict from sklearn.model_selection import train_test_split src_root Path(raw_data) # 原始图片目录每个类别一个子目录 dst_root Path(dataset) # 目标三级目录 RATIOS {train: 0.7, val: 0.15, test: 0.15} # 从文件名中提取患者ID这里假设文件名形如 patient_001_t1.jpg def extract_patient_id(filename: str) - str: m re.match(r(patient_\d), filename) return m.group(1) if m else filename for cls_dir in src_root.iterdir(): if not cls_dir.is_dir(): continue cls_name cls_dir.name patient_to_files defaultdict(list) for img_path in cls_dir.iterdir(): pid extract_patient_id(img_path.name) patient_to_files[pid].append(img_path) patient_ids sorted(patient_to_files.keys()) train_pids, test_pids train_test_split( patient_ids, test_sizeRATIOS[test], random_state42 ) train_pids, val_pids train_test_split( train_pids, test_sizeRATIOS[val] / (RATIOS[train] RATIOS[val]), random_state42, ) for split_name, split_pids in [ (train, train_pids), (val, val_pids), (test, test_pids), ]: for pid in split_pids: for img_path in patient_to_files[pid]: out_dir dst_root / split_name / cls_name out_dir.mkdir(parentsTrue, exist_okTrue) shutil.copy2(img_path, out_dir / img_path.name)这段脚本的核心是两次train_test_split第一次在全部患者ID集合上划出test第二次在剩余患者ID里再分出val。为什么要分两步而不是一次三分因为直接用train_test_split(test_size0.15)再二次切分很容易把比例算错尤其当原始目录里各类别样本不均衡时两级切分能让每个类别的患者ID都单独走一遍同样比例。random_state42必须固定否则每次执行划分结果不同模型对比就没有基线。另一个容易漏的环节是脚本执行前先确认raw_data里没有潜在的重复文件否则同一个文件以不同文件名进入两个split依然构成泄漏。提示原始数据如果完全没有患者ID只有单张独立图片退而求其次的做法是用文件MD5去重至少避免同一张图出现在两个split里。2.4 最小训练加载管线ImageFolder 与第一次迭代目录划分完成后用torchvision.datasets.ImageFolder可以在一行内把类别读出来先跑通不急着加复杂增强from torchvision import datasets, transforms 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]), ]) train_set datasets.ImageFolder(dataset/train, transformtransform) val_set datasets.ImageFolder(dataset/val, transformtransform) print(train_set.classes) # [bacterial, covid, normal, viral] print(train_set.class_to_idx) # {bacterial: 0, covid: 1, normal: 2, viral: 3}注意ImageFolder按字母序给类别编号所以打印出来的类别顺序不一定是你目录排列的顺序。后面计算混淆矩阵时一定要用class_to_idx把编号映射回名称只记顺序迟早会在某个夜里出错。这个阶段的transform故意做得很朴素目的只有一个确定数据管道是通的。先看到一批图像能正确出入模型再谈增强与调参排查速度会快很多。3. 医学影像预处理的3个关键参数尺寸、通道、窗宽窗位数据组织妥当后预处理参数决定模型见到的是什么样的“胸片”。胸片与自然图像的本质差异在于它是灰度影像、密度分布高度集中、采集设备差异大。机械套用ImageNet式预处理轻则对比度被吃掉重则模型学到拍摄条件而不是病变。3.1 灰度还是三通道别让预训练模型把你带偏胸片本质是单通道灰度图像但主流预训练模型都吃三通道输入。两种常见做法一是把灰度图复制成三通道加载ImageNet预训练权重做迁移学习二是改第一层输入为单通道重新训练或部分微调第一层。复制成三通道的好处是迁移学习起步快预训练权重大部分可以直接复用。代价同样明显预训练模型的前几层学到的是自然图像的彩色边缘和纹理响应把它们用在“三个通道内容相同”的灰度图上等于第一层在做无效计算需要更多微调轮次来纠正。我的判断标准是样本量每个类别超过3000张复制通道后微调的效果就足够好样本量很小时单通道输入配合适度数据增强反而更容易收敛。一个折中做法是把灰度图先做伪彩色映射再转三通道例如映射成类似骨骼窗的暖色系。伪彩色不会增加新的解剖信息但它把软组织与骨组织的灰度梯度拉开有时能让预训练模型的浅层特征在微调阶段更快对齐。3.2 图像尺寸与裁剪策略保持纵横比比加大尺寸更重要分辨率不是越高越好更重要的是不要破坏胸片的整体比例。胸片的肺野占比、肋骨走向、心影位置都是全局线索直接按默认设置压缩成正方形会让这些比例失真。我常用的组合是对短边Resize到288再做RandomResizedCrop到224具体如下from torchvision import transforms train_transform transforms.Compose([ transforms.Resize((288, 288)), transforms.RandomHorizontalFlip(p0.5), transforms.RandomResizedCrop(224, scale(0.70, 1.00), ratio(0.75, 1.33)), transforms.ToTensor(), transforms.Normalize(mean[0.485, 0.456, 0.406], std[0.229, 0.224, 0.225]), ]) val_transform transforms.Compose([ transforms.Resize((288, 288)), transforms.CenterCrop(224), transforms.ToTensor(), transforms.Normalize(mean[0.485, 0.456, 0.406], std[0.229, 0.224, 0.225]), ])这里最值得留意的是scale参数。它表示随机裁剪时保留原图面积的百分比0.7到1.0意味着每次最多裁掉30%的区域。对胸片来说裁剪比例太激进会把肺尖或肋膈角直接切掉那些区域恰恰是某些类型肺炎的关键影像区域。水平翻转可以安全使用肺部左右基本对称垂直翻转绝对禁用心尖位置会被翻到上方模型学到错误的解剖方位。旋转角度也不宜大正负10度基本是上限超过之后锁骨和肋骨的投影关系就开始失真。3.3 归一化与窗宽窗位把影像调成标准化形态再进网络胸片的像素密度分布与普通照片完全不同很多原始胸片是16位TIFF或DICOM直接转成8位JPG再按ImageNet的标准归一化会丢失软组织层次。这里要引入一个医学影像特有的概念窗宽窗位。DICOM里的窗宽Window Width和窗位Window Level决定了把像素值范围内的细节映射到灰阶的方式设置不当病灶与正常组织的对比度就出不来。对胸片来说最实用的做法是用分位数裁剪替代min-max归一化。我一般取2%到98%分位数作为截断区间把极端高亮的金属伪影和极暗的背景去掉再线性拉伸到[0,1]import numpy as np from PIL import Image def normalize_cxr(img: np.ndarray, low: float 2.0, high: float 98.0) - np.ndarray: lo, hi np.percentile(img, [low, high]) clipped np.clip(img, lo, hi) out (clipped - lo) / (hi - lo) return (out * 255).astype(np.uint8) im Image.open(xray.jpg).convert(L) arr np.array(im) arr normalize_cxr(arr) Image.fromarray(arr).save(xray_processed.jpg)这里的low和high就对应着窗宽窗位的一种简化形式。2%和98%是多数胸片数据集的常用起点但如果你的数据对比度本身很弱可以先把high降到95%再人工看几张输出图觉得太亮的区域削减过头了再回调。我在项目里还有一个习惯把训练集上统计到的分位数保存成一个.npy文件测试阶段加载同一个分位数做变换。训练和推理的预处理必须完全一致否则模型上线后会看到一个完全不同的输入分布。4. 肺炎胸片4分类训练避坑5个高频翻车现场与对策这类数据集的坑基本都集中在数据层面模型结构反而是最不需要焦虑的部分。以下五个翻车现场是我在实际项目中反复遇到过的按出现频率从高到低排列。4.1 患者级数据泄漏同一个人出现在训练集和验证集现象训练准确率95%验证准确率93%整体指标非常好看。把模型换到另一批外部胸片上测试准确率掉到70%出头。原因划分时按文件随机切分同一个患者多次就诊拍的多张胸片被拆进了训练集和验证集。模型相当于见过同一个患者不同时间的胸片提前背住了答案。解决必须回到2.3节的逻辑先按文件名提取患者ID再对患者ID集合做切分。没有patient id的数据集也要想办法用MD5或感知哈希做相似去重。我在每个新数据集上做的第一件事就是跑一遍患者数量统计确认num_patients和num_files不是同一个数字。4.2 标签噪声原数据集的标注并不可靠现象损失正常下降但混淆矩阵里某个类别始终被分到相邻类别人工抽查图像后发现一批样本的标注本来就是错的。原因公开数据集和众包标注经常存在误标多个来源拼接后标签口径不一致的问题会被放大。解决清洗标签不能只靠肉眼。更有效的做法是把模型预测与原始标注不一致的样本提取出来按置信度排序优先人工审核其中最不确定的一批。import torch model.eval() with torch.no_grad(): for batch in val_loader: logits model(batch[img]) probs torch.softmax(logits, dim1) max_prob, pred probs.max(dim1) for i in range(len(batch[path])): if pred[i].item() ! batch[label][i].item(): log_samples(batch[path][i], batch[label][i].item(), max_prob[i].item())这段代码不直接改标签而是生成一份高不确定度样本清单。经验上处理top 10%最不确定的样本就能把大多数错标压下去剩下的交给模型在训练中去鲁棒地消化。4.3 类别严重不平衡小类别被大类别淹没现象整体准确率超过85%但样本最少的viral类别召回率只有四成因为它在整体样本里占比太小即使全预测错也不影响总准确率。原因模型的训练目标被大类别主导小类别的决策边界基本不存在。解决两件事同时做最稳。一是CrossEntropyLoss的weight参数权重取各类别样本总数的倒数再归一化二是用WeightedRandomSampler让每个epoch里小类别被采样到的概率提升import torch from torch.utils.data import WeightedRandomSampler class_counts [4000, 3200, 800, 600] weights [1.0 / c for c in class_counts] sample_weights [weights[label] for _, label in train_set.samples] sampler WeightedRandomSampler( sample_weights, num_sampleslen(sample_weights), replacementTrue ) train_loader torch.utils.data.DataLoader( train_set, batch_size32, samplersampler, num_workers4 )replacementTrue表示同一张图在一个epoch里可能被重复采样这会轻微改变数据分布但对小样本类别来说比直接改loss权重更平稳。注意这个sampler要和ImageFolder的.samples属性配合标签顺序不能错位。4.4 图像里的文字、金属和遮挡物模型学了伪影而不是病变现象分类准确率很高但热力图显示模型重点关注的是胸片角落的文字说明、金属纽扣或监护设备线路而不是肺部区域。原因预处理没做统一去边或者不同采集设备在数据里留下了比病变更强的分布规律。解决先把图像四周的黑边自动裁掉再做分位数归一化。对文字信息可以用固定区域裁剪或简单的形态学操作移除。还有一条经验训练集、验证集、测试集最好来自同一设备协议做不到时至少按设备ID做一次分组交叉验证量化设备差异带来的准确率波动。4.5 模型不收敛loss居高不下先怀疑数据管道再怀疑网络现象训练了10个epochloss稳定在2.0附近准确率纹丝不动。原因绝大多数情况下不是网络结构问题而是数据管道出错。常见的三类灰度图转单通道后与三通道模型维度不匹配标签映射错误所有样本的label都一样数据增强太激进图像被裁剪成了大片背景模型根本没看到肺。解决先用一个batch做自检打印图像的shape、标签分布、像素值范围再把增强后的图像保存到本地人工看一眼。这个检查跑一遍比盲目换优化器有效得多。我会固定保持这个习惯换模型之前先换数据数据对了模型很少不收敛。5. 别只看准确率多分类胸片模型的混淆矩阵、类别F1与置信度校准准确率处理类别不平衡的4分类任务时是偏的。一个类别样本占去一半的测试集模型把那个类全预测对准确率也能过80但临床完全没法用。多分类医学影像模型要评估三个层面混淆矩阵里的错误模式、类别级指标、以及概率值的可信度。5.1 用混淆矩阵定位4个类别之间的混淆模式先跑一段标准代码把模型在测试集上的预测结果转成混淆矩阵from sklearn.metrics import confusion_matrix import numpy as np y_true, y_pred [], [] model.eval() with torch.no_grad(): for batch in test_loader: logits model(batch[img]) y_pred.extend(torch.argmax(logits, dim1).cpu().tolist()) y_true.extend(batch[label].tolist()) cm confusion_matrix(y_true, y_pred) print(cm) # 行是真实类别列是预测类别读混淆矩阵时我习惯先看对角线之外的次高峰它往往对应最容易被错分的两个类别。例如viral和covid经常互相误判这说明两个类别在影像特征上本来就接近如果你的业务场景要求某个类别不能漏诊就要回到这个类别单独调阈值而不是笼统地优化整体准确率。对误判集中区还可以做一次失败案例画像把预测错的样本按拍摄角度、设备类型、病灶位置分组找到错判集中的子群。这一步常常比换个更大的模型更解决问题。5.2 类别F1、敏感性、特异性医学影像报告要用的指标医学场景下准确率只是辅助参考真正被写进报告的是敏感性recall和特异性。这两个指标反映的是“漏了多少患者”和“误报了多少正常情况”比整体准确率更贴近代价模型。指标计算方式在肺炎4分类中的含义敏感性召回率TP / (TP FN)某一类病变被发现的比例漏诊相关特异性TN / (TN FP)非本类样本被正确排除的比例误诊相关宏平均F1各类F1的算术平均给4个类别同等权重不被大类掩盖准确率(TP TN) / 总数整体正确比例在不平衡时容易失真代码上可以直接用sklearn.metrics.classification_report一次性输出每一类的precision、recall、F1from sklearn.metrics import classification_report report classification_report( y_true, y_pred, target_namestrain_set.classes, digits3 ) print(report)后面你会做一个决策优化哪些类的recall、允许牺牲哪些类的precision。通常normal类别需要极高的precision否则把正常人报成肺炎会让复查量爆炸相反viral和covid这类传染性强的类别宁可precision低一点也要把recall顶上去。5.3 置信度校准让4分类概率值真正可用训练后的softmax输出往往过度自信模型预测viral的概率是0.95但实际错误率可能达到15%。这中间有一条经验规则如果模型在验证集上正确概率的平均值与准确率差距超过5个百分点就该做置信度校准。常用方法是温度缩放Temperature Scaling在softmax前除以一个温度系数import torch def temperature_scale(logits: torch.Tensor, temperature: float) - torch.Tensor: return torch.softmax(logits / temperature, dim1) # 在验证集上搜索让负对数似然最小的温度值 import scipy.optimize as opt def nll(t): probs temperature_scale(val_logits, t) return -torch.log(probs.gather(1, val_labels.unsqueeze(1)) 1e-12).mean().item() best_t opt.minimize_scalar(nll, bounds(0.5, 3.0), methodbounded).x温度通常落在1.2到2.0之间。温度大于1表示模型原来过于自信校准后的概率会整体降低但排序关系不变。临床场景里这个校准很重要因为概率值要拿来和医生沟通当模型说“viral 0.9”和“0.6”时决策完全不同。提示温度缩放只改变概率分布不改变argmax的结果。如果你只关心类别判断就不用做校准但凡概率值要参与任何风险判断校准就别跳过。6. 从4分类数据集到可解释模型用CAM热力图给胸片预测一个依据模型在4分类测试集上跑出好看的指标只是第一步医学图像分类要落地还得有人愿意为模型的判断背书。这时候最重要的不是再刷两个点的准确率而是知道模型为什么给出这个类别。Grad-CAM是我在胸片任务里固定会做的一个验证步骤把最后一个卷积层的梯度回传到feature map上生成一张与肺部区域对齐的热力图。以ResNet为例取出layer4的输出来做CAM计算import torch import torch.nn.functional as F def grad_cam(model, x: torch.Tensor, target_layer): 简化版Grad-CAM只支持单样本输入 features [] def hook_fn(module, input, output): features.append(output) handle target_layer.register_forward_hook(hook_fn) logits model(x.unsqueeze(0)) handle.remove() score logits[0, model_logits.argmax(dim1)] grad torch.autograd.grad(score, features[-1], retain_graphTrue)[0] weights grad.mean(dim(2, 3), keepdimTrue) # 全局平均池化 cam (weights * features[-1]).sum(dim1, keepdimTrue) cam F.relu(cam) cam F.interpolate(cam, sizex.shape[1:], modebilinear, align_cornersFalse) return cam.squeeze()热力图的解读规则也有一套经验。一个正常可归因的模型热力图应当集中在肺野区域并随着类别不同出现位置差异细菌性肺炎的实变倾向下肺野病毒性肺炎的磨玻璃影分布更弥漫。如果热力图固定落在肺门或心脏轮廓上说明模型可能学到了解剖结构的先验信息而不是病理变化。这个验证步骤还有一个额外作用测出模型的“下限”。在真实部署环境里如果新样本的预测置信度低热力图又不在肺部区域我会干脆拦截输出让模型拒绝回答而不是强行给一个标签。做这种拒绝策略时前面讲到的温度校准就是前提条件置信度本身不校准拦截阈值就是空中楼阁。我现在的习惯是任何胸片4分类模型上线前先在test集上随机抽50个样本跑一次热力图和影像科同事一起过一遍。卡在这个环节的项目问题大多不在于网络不够深而是数据组织或预处理没有尊重胸片本身的解剖属性。希望这个数据集方向能帮你少走一段弯路。本文还有配套的精品资源点击获取
返回列表