肺部CT多标签诊断实战:从DICOM到3D DenseNet的完整方案

发布时间:2026/10/12 15:41:14
肺部CT多标签诊断实战:从DICOM到3D DenseNet的完整方案
简介面向医学影像AI开发者与天池比赛参赛者这份压缩包完整收录了2019年“数字人体”赛场一肺部CT多病种智能诊断赛题的参赛代码与说明材料。资源共25个文件容量147KB其中以Python脚本为主14个py涵盖数据预处理、图像生成、标注转换、ResNet训练与测试、YOLOv3推理等环节另有网络配置文件、锚框计算脚本、项目说明文档及示例CT图像结构清晰适合直接对照学习。当前已有149人学习代码中包含了从数据读取到模型训练推断的完整流程并带有说明文档与配置信息便于复现赛题方案、理解医疗影像分类与检测的落地细节。适合具备一定深度学习基础、希望参考完整比赛代码的读者。1. 天池“数字人体”赛场一肺部CT多病种智能诊断到底在比什么2019年天池全球数据智能大赛“数字人体”赛场一的题目是给一批肺部CT序列让选手用一个模型同时预测多个病种标签。放在今天看这套任务已经不新鲜但在当时它把很多做单病种检测的团队整懵了不是让你只找一个结节而是肺炎、肺气肿、胸腔积液、气胸、钙化灶、胸膜增厚等十来个标签可能同时出现在同一个人的CT里。多标签分类听起来只是把最后一层从softmax换成sigmoid但真正动手后发现数据清洗、3D采样、类别不平衡、标签噪音任何一个环节做不干净模型就只会在验证集上自欺欺人。这篇文章按我当时跑通的方案拆开讲从DICOM序列怎么变成能训练的张量到3D DenseNet怎么接多标签头再到线上掉点最狠的几个坑从哪来、怎么填。适合正在跑医学影像分类任务、或者想把一个CT多标签方案落地成代码的人。2. 拆任务与整理数据从DICOM序列到能喂给模型的多标签样本2.1 先读清标签结构多标签分类不是“多分类的加强版”这个赛题给的是CT序列和对应的标签。CT序列是一组DICOM文件按扫描层顺序叠起来就是一个三维体数据。标签不是单值而是若干个0/1构成的向量每个位置代表一个病种。多分类的softmax要求所有类别互斥概率加起来等于1多标签则允许同一个样本同时是“肺炎”又是“胸腔积液”。这个区别直接决定了三件事最后一层用sigmoid而不是softmaxloss用binary cross entropy而不是categorical cross entropy评估指标看每个类别的AUC和整体F1而不是只看准确率。我习惯拿到数据先把label文件读出来看一眼分布。这个动作花不了五分钟但能避免后面至少一天的返工。关键是确认两个信息每个标签的正样本数占比以及标签之间有没有强耦合。比如肺结节和钙化灶经常同时出现那么模型学的可能不是病灶特征而是标签共现关系。这在验证集上会有虚高的F1到外部数据上立刻现原形。我的做法是直接把每个类别的正例数量、共现矩阵打印出来存在项目根目录的eda.csv里训练之前先盯一遍。import pandas as pd import numpy as np df pd.read_csv(train_labels.csv) labels [c for c in df.columns if c.startswith(label_)] pos_count df[labels].sum().sort_values() print(pos_count) co_occur df[labels].T.dot(df[labels]) co_occur co_occur.mask(np.eye(len(labels), dtypebool), np.nan) top co_occur.unstack().dropna().sort_values(ascendingFalse).head(10) print(top)这段代码做两件事先统计每个标签的正样本数量再把两两共现次数排序输出。正样本数少于总样本5%的类别一定要在后续设计里单独处理共现次数异常高的标签组合说明数据集本身存在标注倾向做验证集划分时必须按患者ID分层而不是随机打散。参数上我一般把标签列名统一成“label_”前缀这样后面写训练循环时可以直接用列表推导式取标签矩阵不用每次手工改列名。2.2 把DICOM序列转成3D数组窗宽窗位与重采样顺序不能乱原始DICOM文件不能直接进网络。每个文件的像素值、层厚、像素间距都不一样必须先统一到固定的体素间距和固定尺寸。常见做法是读取一个series内所有DICOM按InstanceNumber排序用pydicom拿到每个文件的ImagePositionPatient确认层与层之间没有跳层或乱序。然后做两件事把像素值转成HUCT值统一重采样到各向同性1mm最后把数组存成npy格式。这套流程看起来简单但重采样这一步翻车率最高resample如果用了简单插值而不是线性插值肺结节这种边缘锐利的病灶会被抹掉反过来层厚太厚时强行插值到1mm会产生假细节模型会把插值伪影当成特征。import pydicom import numpy as np import SimpleITK as sitk def load_ct_volume(dicom_dir): reader sitk.ImageSeriesReader() dicom_names reader.GetGDCMSeriesFileNames(dicom_dir) reader.SetFileNames(dicom_names) reader.MetaDataDictionaryArrayUpdateOn() reader.LoadPrivateTagsOn() image reader.Execute() resample sitk.ResampleImageFilter() resample.SetOutputSpacing([1.0, 1.0, 1.0]) resample.SetSize([512, 512, int(image.GetDepth() * image.GetSpacing()[2] / 1.0)]) resample.SetInterpolator(sitk.sitkLinear) image resample.Execute(image) array sitk.GetArrayFromImage(image) array np.clip(array, -1024, 1024) return (array 1024) / 2048这段代码用SimpleITK读取整个序列重采样到1mm等间距再把HU值线性映射到0到1之间。需要注意两点一是clip范围肺部CT的窗宽窗位一般看-1024到1024HU超出部分直接截断不然胸壁的高密度骨骼会主导归一化二是重采样后的z轴层数可能和小数计算对不上SimpleITK会自动按size参数计算但使用时要检查返回数组的形状防止轴顺序记反。我一般会在转换后打印array.shape确认是(z, 512, 512)而不是(512, 512, z)。2.3 数据划分把同一个患者的多个序列拆进train和val是大忌医学影像数据有一个特点一个患者可能有多条CT序列甚至同一次检查导出了两次扫描。如果按文件名随机划分数据集同一个人的不同序列会同时出现在训练集和验证集里模型记住的是这个人而不是这个病线上等于白考。正确的做法是先提取每个序列对应的患者ID按患者划分保证同一个患者的全部数据只落在训练集或验证集一侧。划分时还要参考标签分布做分层尤其是正样本极少的类别防止小类别在某折里干脆没出现。patient_ids df.groupby(patient_id)[series_id].apply(list).to_dict() all_patients np.array(list(patient_ids.keys())) from sklearn.model_selection import StratifiedKFold skf StratifiedKFold(n_splits5, shuffleTrue, random_state42) strat_y df.groupby(patient_id)[labels].max().sum(axis1).values for fold, (tr_idx, va_idx) in enumerate(skf.split(all_patients, strat_y)): tr_patients all_patients[tr_idx] va_patients all_patients[va_idx] print(fold, len(tr_patients), len(va_patients))这里的strat_y取每个患者在所有标签上取max再求和本质是“这个患者至少有几个阳性标签”用它做分层能保证每个fold里正样本密度接近。K5是这个体量数据最常见的折数再高会导致每个fold验证集样本数过少AUC波动变大。random_state设固定值方便复现和消融对比。这个划分文件我会保存成json训练、验证、测试都用同一份划分防止后面改数据时验证集名存实亡。3. 模型与训练框架3D DenseNet做骨架多标签输出头怎么改3.1 为什么选3D DenseNet而不是ResNet或SENetCT序列是三维体数据最自然的做法是直接用3D卷积。3D ResNet结构简单、容易调参但它在相同深度下参数更多显存压力大3D SENet加了通道注意力对病灶这种“小目标但强信号”的情况有增益但训练更慢。我最后选了3D DenseNet做骨架原因有三个DenseNet的特征复用机制让梯度在各层之间流通更顺畅在医学影像这种样本量不大的任务上不容易过拟合参数效率比ResNet高同样显存能塞更大的batchDenseNet的transition layer自带压缩降采样过程平滑对肺结节这种尺寸跨度大的目标更友好。2019年这个赛场上用现成2D网络做切片级分类、再融合序列预测的团队也不少但效果普遍比不过3D方案原因是很多病种的空间上下文是关键。肺气肿要看全肺的低密度区域分布气胸要看肺边缘的气体带单张切片的信息不够。3D卷积天然能同时看到冠状位、矢状位、轴位三个方向的上下文这也是我当时没走2D路线的核心原因。当然3D输入的代价是显存消耗成倍上涨我的做法是把输入尺寸定为(16, 256, 256)的采样块而不是整个Z轴。3.2 关键实现把单输出分类头改成多标签输出头3D DenseNet可以直接参考公开实现把第一层卷积改成3D版本然后接一个全连接层。这里有两个改动点第一输入通道数是1因为CT是单模态第二最后全连接层的输出维度改成标签数量且不接softmax让模型输出logits训练时用BCEWithLogitsLoss推理时再套sigmoid。这样数值稳定性比手动算sigmoid加BCE好得多。import torch.nn as nn class LungDenseNet(nn.Module): def __init__(self, num_labels13): super().__init__() self.features densenet3d_features(num_init_features32, growth_rate16) self.classifier nn.Sequential( nn.Linear(512, 256), nn.ReLU(inplaceTrue), nn.Dropout(0.3), nn.Linear(256, num_labels) ) def forward(self, x): features self.features(x) out F.adaptive_avg_pool3d(features, (1, 1, 1)) out out.view(out.size(0), -1) return self.classifier(out)这里的densenet3d_features是指把标准DenseNet的二维卷积层替换成Conv3d后的特征提取器growth_rate设为16是为了控制参数量因为3D卷积的参数量随卷积核维数立方增长。classifier中间加了一个256维的隐层和Dropout直接从一个512维特征向量映射到13个标签容易过拟合。forward里用了adaptive_avg_pool3d它的好处是不管输入z轴长度是多少最后池化出来的特征图都是1x1x1这给推理阶段提供了灵活性——训练用固定长度推理可以处理更长序列。3.3 训练参数loss、优化器与lr策略的具体设置训练多标签分类loss用BCEWithLogitsLoss但要给每个类别单独设pos_weight防止少数类被多数类淹没。pos_weight的推荐初值是负样本数除以正样本数但直接照算出来的值训练有时会让模型过度激进的预测阳性我会再乘一个0.5到0.8的阻尼系数。优化器用AdamW初始学习率1e-4weight_decay设1e-5。学习率调度我用ReduceLROnPlateau监控验证集AUC连续3个epoch没有改善就把学习率乘以0.3比固定step下降更适合医学影像这种验证集曲线波动大的场景。pos_weight [] for c in labels: n_pos df[c].sum() n_neg len(df) - n_pos pos_weight.append(n_neg / n_pos) pos_weight torch.tensor(pos_weight, dtypetorch.float32).cuda() criterion nn.BCEWithLogitsLoss(pos_weightpos_weight * 0.7) optimizer torch.optim.AdamW(model.parameters(), lr1e-4, weight_decay1e-5) scheduler torch.optim.lr_scheduler.ReduceLROnPlateau( optimizer, modemax, factor0.3, patience3, verboseTrue )计算pos_weight的代码写在数据准备阶段每次训练前都会打印出来核对一遍防止某个类别正样本数异常时还在闷头训练。乘0.7是经验值加了这个阻尼后模型在保持一定召回率的同时精确率会明显改善。需要注意labels列表的顺序必须和模型输出头的顺序严格一致否则pos_weight和标签对不上错误非常隐蔽表现就是loss数值看起来正常但每个类别的AUC都接近0.5。4. 类别不平衡与标签噪音四个让模型“假收敛”的实战对策4.1 正负样本严重失衡加权BCE和Focal Loss怎么选医疗影像数据集的不平衡通常不是1比10而是1比100甚至更高。有的类别在几百个训练样本里只有个位数正样本直接用普通BCE训练模型会倾向于把所有样本预测为负类因为这样loss最低。pos_weight能解决一部分问题但正负比相差两个数量级时加权BCE会让模型对少数类输出极高置信度把所有边界样本都判成正类产生大量假阳性。这种情况下我一般换Focal Loss它对易分类的负样本做降权让模型把注意力集中在难分样本上。class FocalLoss(nn.Module): def __init__(self, gamma2.0, alphaNone): super().__init__() self.gamma gamma self.alpha alpha def forward(self, logits, targets): bce F.binary_cross_entropy_with_logits(logits, targets, reductionnone) p torch.sigmoid(logits) p_t p * targets (1 - p) * (1 - targets) loss (1 - p_t) ** self.gamma * bce if self.alpha is not None: alpha_t self.alpha * targets (1 - self.alpha) * (1 - targets) loss alpha_t * loss return loss.mean()gamma一般取2这是Focal Loss论文里的默认值对难易样本的区分度最好。alpha的作用类似pos_weight但它是乘在loss前面的全局权重我做了一版alpha与pos_weight都加的实验结果发现alpha调到0.25、同时不用pos_weight时效果最好原因可能是两种加权机制叠加会让梯度过于剧烈。实际使用中我会用小验证集把这两种loss各跑5个epoch对比看每个类别的AUC变化再决定用哪个。4.2 3D数据增强翻转、旋转与缺失切片模拟3D增强比2D麻烦得多。随机水平翻转和90度旋转可以放心用因为CT不存在左右或上下语义翻转的问题。随机裁剪是另一个关键增强训练时随机裁出(16, 256, 256)的块而不是用整个Z轴这样既做了数据增强又变相增加了样本量。但裁剪时要保证至少覆盖病灶所在区域因此我先用标签做了个粗定位在训练集上统计每个阳性样本病灶最密集的切片位置裁剪时以这个位置为中心做随机偏移而不是纯随机裁。class RandomCrop3D: def __init__(self, crop_z16, crop_hw256, center_ratio0.7): self.crop_z crop_z self.crop_hw crop_hw self.center_ratio center_ratio def __call__(self, volume): z, h, w volume.shape if z self.crop_z: if np.random.rand() self.center_ratio: start_z max(0, (z - self.crop_z) // 2) start_z min(start_z, z - self.crop_z) else: start_z np.random.randint(0, z - self.crop_z 1) else: start_z 0 start_h np.random.randint(0, h - self.crop_hw 1) start_w np.random.randint(0, w - self.crop_hw 1) return volume[start_z:start_zself.crop_z, start_h:start_hself.crop_hw, start_w:start_wself.crop_hw]center_ratio设0.7表示70%的概率裁中心区域、30%概率完全随机这是我在实战场上试出来的平衡点。如果全裁中心模型没见过病灶偏离中心的情况推理时目标在边缘就丢如果全随机很多裁剪块里根本没有任何病灶成了纯负样本模型会倾向于学“这块没有病”。CT图像的Z轴间距在重采样后是均匀的裁剪不会留下间距不一致的隐患。但要注意推理时不能用这个裁剪策略要滑窗推理或者直接整卷输入。4.3 标签噪音处理不确定的标注不要硬学医学影像比赛的标签来自人工标注不同医生对同一个病灶的判断不一致很常见。我观察到有的类别里存在大量“边缘阳性”样本特征是模型对它们的输出概率稳定在0.4到0.6之间怎么调参都拉不上去。遇到这种情况不要硬练先把这些样本的预测值分布打出来按类别看有没有明显的双峰分离。如果某个类别的预测分数集中在0.5附近很可能标注本身就有问题。处理方法有两个。第一种是标签平滑把硬标签从1和0改成0.9和0.1抑制模型对噪音标注的过拟合这个改动几乎零成本。第二种是样本加权用一个预训练模型跑一遍训练集把预测概率超过0.9但标签是0的样本挑出来人工检查如果确认是标注漏标直接把标签改成1。当时我用这个方法修正了大约2%的训练样本AUC提升比调任何参数都明显。这个操作要克制只修正置信度极高的漏标不能大批量改标签否则模型会自己给自己喂答案出现严重过拟合。4.4 模型融合和TTA单模型不够但别指望融合救数据比赛后期单模型AUC基本不再涨之后我的做法是做5折交叉验证每折训练一个模型推理时对5个模型的sigmoid输出取平均。这个操作能稳定提升2到3个点的AUC代价是训练时间变成5倍但对比赛来说是值得的。TTA测试时增强我做了水平翻转和5个随机裁剪块的平均效果不如模型集成明显但在部分漏检率高的类别上有帮助。融合的另一个形式是多尺度推理输入训练的裁剪块是(16, 256, 256)推理时用(16, 384, 384)和(16, 512, 512)各跑一遍再和原尺寸结果取平均。因为医学影像中病灶尺寸差异很大肺结节可能只有几个毫米而肺气肿是整个肺叶范围的密度改变多尺度推理能同时抓住两种尺度的特征。我当时跑这个操作花了不少时间换来的是整体AUC提升了约1个百分点在排名靠前的队伍之间这1个百分点可能就是名次的分水岭。5. 验证与调参避坑本地指标好看线上却翻车问题出在哪5.1 现象一外部验证AUC比内部结果低一大截内部验证AUC到了0.95信心满满去外部数据一测掉到0.8这是医学影像任务最经典的翻车场景。原因通常是模型记住了数据来源而不是病灶本身。比如训练数据来自同一台CT设备图像的纹理、噪声模式成了隐形的“设备指纹”模型在内部验证集上表现极好一到别的设备采集的数据就失灵。解决方法是训练时加入随机噪声模拟、随机改变对比度这类不影响病灶语义的扰动把模型的注意力从设备特征上拉开。另一个办法是检查内部验证集和训练集是否真的按患者完全隔离哪怕有少量泄漏验证AUC都会产生虚高。5.2 现象二模型输出全是0但loss还在缓慢下降训练前几个epoch所有标签的预测概率都接近0loss虽然也在降但每个类别的AUC都是0.5。这个问题几乎都是类别不平衡造成的负样本占比99%以上时模型发现全预测为0的loss已经足够低梯度信号被多数类淹没。只加大pos_weight会走到另一个极端——所有样本都预测为1。我的做法是先用一个只跑少数类的二分类模型做权重初始化再加载到多标签模型上微调这样模型一开始就对少数类有基础的判别能力。如果不想做两阶段训练也可以把pos_weight初始值调高然后在前3个epoch里用线性warmup从0逐步加到位给模型一个适应的过程。5.3 现象三同一份代码换台机器结果对不上训练结果不稳定在医学影像任务里特别常见。第一个原因是数据读取顺序不同导致的数据增强序列不同第二个原因是cuDNN的benchmark模式在不同GPU型号上选了不同的卷积实现。我踩过最深的坑是混合精度训练A卡和N卡上同一份fp16代码跑出来的指标差了2个点原因是不同设备对inf和nan的处理策略有差异。解决方法是把随机种子固定、关闭cuDNN benchmark、用deterministic模式。但要注意PyTorch的deterministic模式会拖慢训练速度约10%只在需要复现实验时开平时关上。5.4 现象四窗宽窗位没统一CT值分布分成两派外部验证翻车的另一个高频原因是数据预处理里窗宽窗位设置不一致。肺部CT的标准显示窗是窗宽1500HU、窗位-500HU左右但有的序列本身是纵隔窗窗宽更窄、窗位更高。如果预处理时没按患者的扫描协议做窗宽窗位归一化而是对所有样本用同一套clip范围模型看到的表现形态完全不同的同一种病训练时学到的特征就会出现分裂。我处理的办法是保存每个序列的窗宽窗位信息训练时按信息把像素值映射到统一的显示范围而不是简单clip到-1024到1024。这一步看起来是细节但对线上指标的影响往往比换网络结构更大。5.5 现象五标签噪点让loss曲线震荡怎么兜底训练中期loss出现周期性波动AUC也上蹿下跳不一定是学习率太高更可能是某些样本的标签和图像内容完全不匹配模型在这些样本上的梯度方向和其他样本冲突。我的排查步骤是把每个batch里loss值最高的top 5样本的预测值和标签打出来人工看一眼图像。如果发现确实是标注错误就把这些样本从训练集中剔除或者把标签修正后重新训练。如果找不到明确错误就把它们的loss权重降为正常样本的0.3防止单个噪音样本破坏整个模型的特征学习。这个操作在比赛里帮我稳住了两个原本一直震荡的类别。6. 用Grad-CAM做诊断依据验证模型是真的在看病灶还是在看水印和伪影6.1 给3D CNN加Grad-CAM的两种做法模型训练完不能只把AUC指标提交上去就收工。我习惯对每个标签做一次Grad-CAM可视化确认模型关注的区域确实落在病灶附近。3D Grad-CAM的实现思路和2D完全一样取最后一个卷积层的输出特征图计算它对目标类别的梯度然后做全局平均池化得到每个通道的权重再对特征图做加权求和得到一个3D的激活热力图。def grad_cam_3d(model, volume, target_label): model.eval() features {} gradients {} def hook_feature(module, input, output): features[value] output.detach() def hook_gradient(module, grad_input, grad_output): gradients[value] grad_output[0].detach() last_conv model.features[-1] handle_f last_conv.register_forward_hook(hook_feature) handle_g last_conv.register_backward_hook(hook_gradient) output model(volume.unsqueeze(0)) onehot torch.zeros_like(output) onehot[0, target_label] 1 model.zero_grad() output.backward(onehot) weights gradients[value].mean(dim(2, 3, 4), keepdimTrue) cam (weights * features[value]).sum(dim1, keepdimTrue) cam F.relu(cam).squeeze(0).squeeze(0).cpu().numpy() handle_f.remove() handle_g.remove() return cam这段代码通过注册hook拿到最后一个卷积层的输出和梯度计算每个通道的权重后加权求和得到热力图。可视化时取病灶最明显的切片把热力图用jet颜色映射叠加到CT灰度图上。一个合格的多标签模型不同标签的热力图应该落在不同区域肺炎的热力集中在实变影位置肺气肿的热力分布在全肺低密度区。如果某个标签的热力图集中在图像边缘或固定位置基本可以断定模型在偷看CT边框或设备水印这种模型对线上数据没有任何参考价值。6.2 置信度校准用temperature scaling修正过自信输出多标签sigmoid输出的概率值很多时候不是真实概率。比如模型输出0.95这个样本的真阳性率可能只有0.8。验证的时候如果在0.5阈值上卡F1可能没什么问题但如果线上需要按概率值排序提交校准就很重要。我用的是temperature scaling在验证集上找一个温度参数T把logits除以T后再过sigmoid然后最小化校准误差。import torch.optim as optim def calibrate_temperature(model, val_loader, num_labels): logits_list, targets_list [], [] with torch.no_grad(): for volume, target in val_loader: logits model(volume.cuda()).cpu() logits_list.append(logits) targets_list.append(target) logits torch.cat(logits_list) targets torch.cat(targets_list) T nn.Parameter(torch.ones(1)) optimizer optim.LBFGS([T], lr0.01, max_iter100) loss_fn nn.BCEWithLogitsLoss() def eval(): optimizer.zero_grad() loss loss_fn(logits / T, targets) loss.backward() return loss optimizer.step(eval) return T.item()温度参数T大于1表示模型过自信输出概率被摊平T小于1表示模型欠自信概率被拉向两端。医学影像模型通常T在1.2到2.0之间。这个校准过程只调整一个参数不会改变AUC但会让输出的概率值具备实际参考意义。我的交付习惯是把T值写进模型配置json里推理时先除以T再sigmoid这样医生看输出概率时可以按真实患病风险做判断。6.3 我的收尾习惯一份输入、一个指标、一次复诊方案跑到最后我会固定三个东西数据预处理里窗宽窗位的统一规则、模型推理时滑窗步长、以及每个类别的预测阈值。这三个参数一旦定了就不随便改每次实验只动其中一个否则对比实验没法归因。阈值我会按验证集上的约登指数Youden’s index确定也就是让敏感度和特异度之和最大的点而不是无脑用0.5。这套方案从数据整理到模型融合完整跑下来大概需要一到两周主要时间消耗在DICOM重采样和5折训练上。回看整个项目最值得投入的不是网络结构而是数据预处理和标签清洗这两个地方每做对一步线上指标都有看得见的提升。希望帮到你别在那些看起来酷炫、实际对结果没有帮助的模型结构上浪费太多时间。本文还有配套的精品资源点击获取