1. 项目概述与整体设计思路
1.1 这个项目本质要解决什么问题
肺部感染识别,听起来像是医学影像科的一个专家课题,但从工程角度拆开看,本质上是一个很经典的图像分类问题:喂给它一张胸部X光片,让它判断“这个人的肺部有没有感染征象”。不需要做像素级勾画,不需要数出感染灶有几个,只需要给出一个概率和一块可解释的热力区域,就已经能帮临床医生省掉大量初筛时间。
我一开始接到这个项目时,手里是一批脱敏后的胸片影像,来源是某医疗机构的合作影像批次加上几个公开的胸部X光数据集。整理完之后大概有一万多张,但里面真正标注“感染”的只有不到四成,剩下的要么是正常胸片,要么是陈旧性病灶。类别不均衡、图像尺寸不统一、图片上还带着各种文字和金属伪影,问题比想象中多得多。这篇文章就把我走通整条链路的过程原原本本写出来,包括数据怎么整理、模型怎么选、训练时踩了哪些坑,以及最后怎么让医生愿意看这个结果一眼。
这套方案适合谁参考?我认为不只是做医疗AI的人。任何做“小样本医学图像分类”的人,哪怕你手里是皮肤镜图片、眼底照片,甚至工业质检图片,只要迁移学习、数据增强、类别不平衡、可解释性这几个词的坑你都踩过,这篇文章都有能直接拿走的经验。核心任务就是:把模型训练出一个可用水准,并且让输出结果能被领域专家信任。
1.2 为什么选择“图像分类+热力图”的技术路线
面对肺部感染识别,很多人第一反应是上目标检测或者语义分割:把感染区域框出来,或者把病灶像素一下一下地抠出来。这个方向当然专业,但对于落地初筛场景来说,实在是太重了。
最核心的原因在标注成本。目标检测需要画边界框,语义分割需要逐像素标注,一张胸片仔细标下来,有经验的医生也要花十几分钟,费用高、周期长。而肺部感染识别在临床上真正高频的需求是“这张片子是否异常,需不需要优先请医生看”。图像分类只需要一个影像级别的标签——正常或者异常,标注效率高得多,标注一致性也比框病灶好控制。
所以我的技术路线定为:用卷积神经网络(CNN)做二分类,再用Grad-CAM生成热力图,把模型关注的区域叠回到原图上。分类负责给出判断,热力图负责说清楚“模型依据哪里做出了判断”。这样审核医生看到的不是冷冰冰的0.82概率,而是一张图上局部区域在发热,能快速判断这个AI是不是又看错了位置。
当时也考虑过用目标检测模型做辅助定位,但后来想通了:初筛场景要的是“尽量不漏掉任何可疑病例”,而不是精确到每个病灶的边界。用一个分类模型把可疑者全部捞出来,再用热力图提醒医生哪里值得仔细看,是性价比最高的路径。
1.3 从临床场景反推的技术指标
项目不能只看模型准确率,更要看指标能不能匹配临床使用习惯。我列了三个硬性要求:
第一,数据划分必须按患者级别隔离。同一个患者的多张胸片,只能同时出现在训练集、验证集或测试集中的一个,否则模型会“记住”病人而不是“学会”病灶。我在查资料时看到很多人因为直接随机划分数据,评估结果虚高了好几个点,这种模型到了新病人面前被打回原形,教训相当惨痛。
第二,敏感度优先。什么是敏感度?就是所有真正感染的人里面,模型成功找出了多少。漏掉一个感染病例,意味着这个病人可能会拖着病情恶化;而多给医生推荐几个疑似病例,只是增加了复核负担,不会造成严重后果。所以我的目标不是把准确率刷到最高,而是把敏感度稳住,同时不把特异度压得太难看。
第三,推理速度要够快。筛查场景下往往是批量看片的,一批可能几百上千张。单张影像推理时间如果超过几百毫秒,部署的时候就显得拖沓。用轻量级网络加合理批处理,把速度控制在100毫秒以内,才真正具备在院内工作站跑起来的潜力。
这些指标定了之后,后面的所有数据增强、模型选择、阈值调整,都是围绕它们转的。一开始我没想清楚这一点,总想刷高AUC,后来才发现方向比精度重要得多。
2. 数据准备与预处理实操要点
2.1 数据来源与划分原则
医疗影像数据最大的问题永远是“不够用”和“不干净”。我手上的这批胸片包含JPEG格式的普通影像和少数DICOM原始文件,有的片子已经做过裁剪,有的还带着大片黑色边框和图像上的文字标记。如果不做统一清洗,模型很容易学到这些噪音而不是肺部的病理特征。
我第一件事是建立严格的目录规范,分成train/val/test三个目录,内部再按normal和infection两个类别放图。这里有个很多人都忽略的细节:划分前先给每个患者分配唯一ID,然后把同一个人名下所有影像放进同一个子集。我见过某同学的代码只是简单把文件按比例shuffle,结果训练集和测试集里出现同一个病人的不同片子,最后报告的准确率高得离谱,换真实数据一测就崩溃。按患者级划分后,虽然指标会比我预想的低一些,但这才接近真实泛化水平。
另外,对类别标签我做了二次校验。刚开始我只看了文件名里的标签,后来抽样人工翻了几百张图,发现里面存在标签颠倒和正常片混入感染组的情况。后来我把所有可疑样本抽出来逐张过,请一位有影像基础的朋友帮忙做抽检,前前后后清掉了一百多张标注存疑的图。数据清洗这件事,做的每一点功夫都会在模型效果上兑现。
2.2 影像读入、灰度归一化与尺寸选择
胸片本质上就是灰度图,虽然很多公开数据集存成三通道JPEG,但三个通道的数值基本雷同。我在预处理阶段直接把它转成单通道灰度图,省掉一部分不必要计算,再通过重复通道的方式喂给预训练网络。
归一化是一个容易想当然的环节。自然图像有固定的R/G/B三通道均值,但胸片是黑白影像,直接套用别的视觉任务的归一化参数会引入偏差。我做的处理是:先把图像缩放到固定尺寸,然后计算整个训练集的灰度均值mean和标准差std,再按(image - mean) / std做标准化。实测下来,这种“数据集自己算出来的归一化参数”比起直接套用某个固定值,模型收敛稳定不少。
尺寸选择也纠结了一阵。胸片里的感染区域往往只占很小一块,尺寸太小会把细节磨没;尺寸太大又显存紧张、训练变慢。我最终在224×224这个常用尺寸上训练主干模型,同时保留一个448×448的推理版本做对比。结论是:对于二分类任务,224尺寸在AUC上已经接近大尺寸模型,但训练速度快了将近三倍,性价比明显更好。
2.3 数据增强的参数权衡
医学图像能不能做数据增强?当然能,但必须克制。我见过有人把图像做30度旋转、大幅平移加随机遮挡,结果模型学会的不是“看肺”,而是“看形态奇怪的图像”,换到真实环境直接翻车。胸片解剖结构本身有固定的方向性,心脏偏左、肺尖在上,过度几何变换会破坏这种先验结构。
我的增强策略是:水平翻转可以有,因为左右胸本来就能对称;旋转控制在±10度以内;随机裁剪的缩放比例控制在0.9到1.0之间,保证不会把肺野裁掉一块;再叠加轻微的对比度扰动和亮度扰动,模拟不同设备成像条件的差异。增强并不是越多越好,哪怕是再好的医院影像,也不会出现上下颠倒或者腰斩一样的畸形图。既然测试分布和训练分布应当尽可能接近,增强就要模拟那些现实中确实存在的变异,而不是制造灾难现场。
我用代码把预处理流程固定下来,每次训练前都对图像做一次随机增强,但验证集和测试集只做resize和归一化,保持评估的稳定性。
3. 模型搭建与训练核心环节实现
3.1 主干网络选择:预训练迁移的两点经验
肺部感染识别的公开研究已经证明,在中小规模医学影像数据集上,从零训练一个深层卷积网络效果往往不好——样本量撑不起来那么大的参数量。我更推荐使用在大型自然图像数据集上预训练过的卷积神经网络作为主干,然后微调。
我用的是经典的残差网络结构,层数选在50层左右。这个选择不是因为越深越好,而是因为它带残差连接,微调时梯度传递更稳定,同时参数量又不会大到让小数据集直接过拟合。
这里有一个细节:自然图像预训练模型期望输入是三通道图,而胸片是灰度图。我做的不是把灰度图当成单通道硬塞进去,而是把同一个灰度值复制到三个通道。这样能保留所有空间结构信息,与预训练权重也兼容。经验是,第一层卷积的权重不管是从头学还是冻结,影响都不大,真正决定效果的是后面深层特征的微调。
训练策略上,我没有一开始就解冻所有层。先冻结主干特征提取层,只训练最后的分类头,跑几个epoch让分类头收敛;然后再解冻后面三个阶段,用很小的学习率微调。这种做法相当于先用分类头去适应新数据分布,再回头微调特征,实测比一上来全量微调更稳,val loss的波动明显减少。
3.2 类别不平衡与损失函数调整
数据里感染图片只占约38%,正常图片偏多。如果直接用普通交叉熵损失,模型会倾向把所有图片预测成正常,因为这样总损失更小。我做了三个层面的调整:
第一个层面是采样。训练时对感染类样本做轻度过采样,让每个batch里两类比例接近1:1。实现上用一个带权重的采样器,而不是简单复制图片,避免模型在同样图片上反复记忆。
第二个层面是损失函数。我用了带类别权重的交叉熵损失,给数量少的感染类别一个更高权重。实验对比发现,权重设置在1.8到2.2之间都能有效提升敏感度,但超过3.0后模型会开始疯狂误报,正常片大面积被预测成感染。
第三个层面是阈值调整。二分类模型输出的是概率,默认以0.5为分界线。但在类别不平衡和临床漏诊代价高的场景下,我会在验证集上调出一个更靠前的阈值。比如模型在0.5阈值下敏感度只有78%,但把阈值降到0.3时,敏感度能到92%,代价是特异度下降十几个点。对于初筛工具来说,敏感度更重要,阈值完全可以后置调整,不需要重新训练模型。
3.3 完整训练流程代码拆解
整个训练流程我封装成一个可复现的Python脚本。从加载数据到输出评估指标,每一步都固定下来,避免“这次能跑通,下次跑不通”的问题。核心代码我整理如下:
import os import numpy as np from torch.utils.data import Dataset, DataLoader, WeightedRandomSampler from torchvision import transforms, models import torch import torch.nn as nn import torch.optim as optim class ChestXrayDataset(Dataset): def __init__(self, root_dir, transform=None): self.samples = [] for label in ['normal', 'infection']: folder = os.path.join(root_dir, label) for fname in os.listdir(folder): if fname.lower().endswith(('.jpg', '.jpeg', '.png')): self.samples.append((os.path.join(folder, fname), 1 if label == 'infection' else 0)) self.transform = transform def __len__(self): return len(self.samples) def __getitem__(self, idx): path, label = self.samples[idx] from PIL import Image img = Image.open(path).convert('L') # 转灰度 if self.transform: img = self.transform(img) return img, label train_transform = transforms.Compose([ transforms.Resize((224, 224)), transforms.RandomHorizontalFlip(), transforms.RandomRotation(10), transforms.RandomResizedCrop(224, scale=(0.9, 1.0)), transforms.ColorJitter(brightness=0.2, contrast=0.2), transforms.ToTensor(), transforms.Normalize([0.5], [0.5]) ]) val_transform = transforms.Compose([ transforms.Resize((224, 224)), transforms.ToTensor(), transforms.Normalize([0.5], [0.5]) ]) train_ds = ChestXrayDataset('data/train', transform=train_transform) val_ds = ChestXrayDataset('data/val', transform=val_transform) # 按类别比例加权采样,缓解不平衡 targets = [s[1] for s in train_ds.samples] class_counts = np.bincount(targets) weights = 1.0 / class_counts[targets] sampler = WeightedRandomSampler(weights, num_samples=len(train_ds), replacement=True) train_loader = DataLoader(train_ds, batch_size=32, sampler=sampler, num_workers=4) val_loader = DataLoader(val_ds, batch_size=32, shuffle=False, num_workers=4) model = models.resnet50(weights=models.ResNet50_Weights.IMAGENET1K_V1) model.conv1 = nn.Conv2d(1, 64, kernel_size=7, stride=2, padding=3, bias=False) # 用预训练权重初始化新卷积层:取三个通道的平均权重 with torch.no_grad(): model.conv1.weight.copy_( model.conv1.weight.mean(dim=1, keepdim=True) ) num_ftrs = model.fc.in_features model.fc = nn.Linear(num_ftrs, 2) device = torch.device('cuda' if torch.cuda.is_available() else 'cpu') model.to(device) criterion = nn.CrossEntropyLoss(weight=torch.tensor([0.7, 1.8], device=device)) optimizer = optim.AdamW(model.parameters(), lr=1e-4, weight_decay=1e-4) scheduler = optim.lr_scheduler.CosineAnnealingLR(optimizer, T_max=30)上面这段代码里有两个容易出错的细节。第一个是conv1权重的替换逻辑,因为原始预训练权重是三个通道,改成单通道输入后我做了一次求平均再复制回去,这样新卷积层不会从零开始学。第二个是WeightedRandomSampler,它本质是按概率从训练集里抽样,保证每个batch中感染样本占比更高,但代价是有可能重复抽到同一张图,所以我在采样器参数上卡了一个上限,避免训练轮数过长时模型过度记忆少数图片。
训练循环本身不复杂,但我会把验证集指标记录下来,每轮结束都保存一次最优模型:
def evaluate(model, loader): model.eval() preds, labels = [], [] with torch.no_grad(): for imgs, labels_batch in loader: imgs = imgs.to(device) logits = model(imgs) prob = torch.softmax(logits, dim=1) preds.extend(prob[:, 1].cpu().numpy()) labels.extend(labels_batch.numpy()) return np.array(preds), np.array(labels) for epoch in range(30): model.train() for imgs, labels_batch in train_loader: imgs, labels_batch = imgs.to(device), labels_batch.to(device) optimizer.zero_grad() logits = model(imgs) loss = criterion(logits, labels_batch) loss.backward() optimizer.step() scheduler.step() probs, y_true = evaluate(model, val_loader) # 用AUC和敏感度来选模型 from sklearn.metrics import roc_auc_score auc = roc_auc_score(y_true, probs) print(f'epoch {epoch+1} val_auc {auc:.4f}')我想强调的是,保存模型的判断标准不是验证准确率最高,而是AUC最高。准确率在类别不平衡时会骗人,可能模型把所有图都预测成正常类,准确率仍然超过60%,但这是完全不可用的。用AUC选模型,再用阈值卡敏感度,两条腿走路才稳。
3.4 评估指标:准确率之外更要看什么
很多初学者训练完就汇报一个准确率,比如“95%准确率”,听着很唬人,但放到肺部感染识别这种场景,这个数字几乎没有参考价值。我最后汇报看的是四件套:AUC、敏感度、特异度、混淆矩阵。
举一个我训练初期的例子:第一次跑出来的模型,准确率有88%,看起来很亮眼,但实际看混淆矩阵吓一跳——感染组180张只找回110张,意味着漏掉了将近39%的感染者。这个模型如果直接丢给临床,后果不堪设想。后来把阈值往前调,让模型更“敏感”,漏诊降到8%,代价是正常组里多了不少假阳性,但初筛场景里医生本来就需要复核这些阳性结果,多复核总比漏出去强。
AUC是评价模型排序能力的指标,反映“感染组的概率是否普遍高于正常组”。它在阈值没定之前先告诉你模型是不是分得开。我在项目里的目标是AUC超过0.92,这个数字在这个数据规模下是比较合理的预期。如果AUC只有0.8,那个别调阈值根本没有意义,该回头改数据或者改模型了。
4. 常见问题与排查技巧实录
4.1 训练Loss下降但验证AUC过低
这个问题我遇到不止一次,典型表现是训练集loss一路向下,但验证集AUC徘徊在0.75上下,甚至出现震荡。出现这种症状,大概率是模型过拟合了训练集上的噪声,没有学到真正的病灶特征。
第一个排查点是数据增强是否做得足够有效。如果只用水平翻转和简单裁剪,胸片之间的厂家标识、扫描边框差异就会被模型当成特征依据。这时我会去掉增强,直接在原始图上单独跑一个短训练,看看是否立刻看到验证集掉点。
第二个排查点是模型容量是否过大。对于一万张出头的医学影像数据,50层的残差网络都算偏大。我会做一个消融实验:把主干网络换成更浅的版本,或者在全连接层加Dropout,看看AUC变化。实在不行,就减少解冻层数,只微调最后几个残差块,限制可学习的参数量。
第三个排查点更有意思:验证集划分是否真的按患者隔离了。我之前有一版实验,验证AUC比测试AUC高一大截,后来检查发现某位患者在不同时间拍的两张片子,一张被分到了验证集,另一张在训练集里,模型等于“提前见过答案”。把这类重复患者从验证集清掉之后,AUC立刻回到合理区间。这个坑最隐蔽,也最需要反复确认。
4.2 模型偏预测“正常”,阳性样本召回很低
如果不做任何处理,类别不平衡会让模型变成“复读机”,整天输出正常。这个问题不是调阈值就能完全解决的,首先要看损失函数和采样是否到位。
我排查顺序是:先看一眼训练集里感染类占比。如果占比本来就低,那就必须在上采样和损失权重之间做一个组合。我实际试过单独用加权损失,敏感度改善有限;单独用重采样,模型容易对少数类过拟合。两个一起上,比如采样器把感染类比例抬到40%左右,同时损失权重给1.5到1.8,效果明显稳定。
第二个常被忽略的地方是数据增强里的颜色扰动。胸片是黑白图,如果我把亮度和对比度扰动设得太大,感染区域的毛玻璃影和正常纹理之间的差别会被削弱,导致模型更难学到关键特征。后面我把颜色扰动幅度缩小了一半,阳性召回立刻回来了。增强不是为了把数据集“变新”,而是让模型在细微纹理变化中保持稳健。
如果以上都调过了,但敏感度还是上不去,我会检查测试集本身的标注。有时候不是模型认不出病灶,而是标注者把一些非常轻微的早期感染标成了正常。这类可学习信号被噪声盖住,模型自然学不到。解决方案是把这批样本抽出来单独看热力图,确认模型是不是注意到了同一片区域但被标签否定了,然后用人工复核修正标签。
4.3 数据质量良莠不齐,预处理如何兜底
胸片数据集里最常见的噪音有三类:图像上的文字标记、金属纽扣或体表伪影、以及曝光过强带来的大片白色区域。CNN非常擅长利用这些非生理特征,如果训练集里所有感染图都出现在同一个角落有文字标记的设备上,模型学到的就可能是“认文字”而不是“认病灶”。
我的预处理器里加了几道保险。第一道是自动裁剪黑色边缘,把图片四周的大块非肺野区域裁掉,减少背景干扰。第二道是统计每个像素位置的均值和方差,如果在某个区域出现高频亮斑,我会在加载阶段做中值滤波,把孤立高斯噪声压下去。第三道也是最重要的,就是在训练完成后用Grad-CAM检查模型关注区域。只要热力图高亮区域集中在肺野外部,比如定位到文字、纽扣、胸廓边缘,就说明模型被伪影骗了,必须回到数据清洗阶段重新处理。
这里有个更务实的经验:直接培养一个“自动清图器”。我花了一天时间把几百张明显有文字覆盖和金属伪影的图片挑出来,在预处理管道里加了一个基于图像纹理特征的简易筛选规则,后面再来的新数据,能自动标出这些可疑样本进入人工审核队列。虽然不能做到100%准确分类,但至少能控制住训练数据里的噪音比例。
4.4 用特征热力图验证模型是否“看对位置”
做肺部感染识别,模型输出一个概率并不能让医生信服。医生最反感的不是AI犯错,而是AI犯错时看不出为什么错。所以我在模型推理阶段加了Grad-CAM,把最后一层卷积的特征图对分类结果的梯度加权,叠加到原图上,生成一张热力图。
实现本身不复杂,简单说就是:前向传播算出类别得分,对要解释的类别做反向传播,取最后一层卷积的梯度,对梯度做全局平均池化得到每个通道的权重,再把权重乘到该层特征图上,过ReLU后归一化到0到1。代码大概长这样:
def grad_cam(model, img_tensor, class_idx=None): model.eval() img_tensor = img_tensor.unsqueeze(0).to(device) img_tensor.requires_grad_() output = model(img_tensor) if class_idx is None: class_idx = output.argmax(dim=1).item() model.zero_grad() output[0, class_idx].backward() features = model.layer4 # 最后一个包含卷积特征的层 gradients = img_tensor.grad # 这里只演示思路,实际应取feature上的梯度 # 实际代码需要hook去取中间特征和梯度 cam = torch.relu(features.detach() * gradients.detach().mean(dim=1, keepdim=True)) cam = cam.mean(dim=1).squeeze().cpu().numpy() cam = np.maximum(cam, 0) cam = (cam - cam.min()) / (cam.max() - cam.min() + 1e-8) return cam这块代码我简化了hook细节,实际项目中会用PyTorch的register_forward_hook和register_full_backward_hook来捕获特征图与梯度,否则拿不到中间层梯度。每张图片推理后,我都会把热力图保存下来,抽样检查。
我自己的经验是:如果模型学到的是感染区域,热力图会集中在单侧或双侧下肺野;如果模型其实在“瞎猜”,热力图会呈现斑驳状、弥散状,或者高亮在某个无关角落。排查阶段看到热力图乱成一团,基本不用看指标都知道这个模型不能用。反而有一种情况很有意思:热力图同时高亮了肺野和心脏边缘,后来人工复核发现,有相当一部分病例确实是同时合并心影增大和肺感染,模型关注多个区域不一定都是错。
把热力图作为校验工具而不是展示工具,是这套流程里最值回票价的一步。它让我在优化模型时多了一个“内眼看判断依据”,而不只是盯着曲线调参。
个人经历里最管用的体会是:医疗影像AI项目的障碍,算法只占一半,另一半在数据治理和信任建立。数据清洗、按患者划分、把模型关注位置用热力图明确画出来,这些工作开始时觉得很繁琐,但最后真正决定模型能不能被采信的,往往正是这些看似不起眼的环节。我后来每次拿到新的影像数据,都会重复一遍“清洗、患者级划分、训练、热力图复核”的流程。这套流程能帮你省下无数个回头重训的夜晚,也能让你的模型在真正面对新病人时,少一点“考试时见过原题,考场上全不会”的尴尬。