天池肺部CT多病种诊断:从DICOM预处理到多标签训练避坑指南
发布时间:2026/9/28 4:31:43来源:尧图网络
简介面向医学影像AI入门者与天池竞赛选手这份赛题代码包源自2019全球数据智能大赛“数字人体”赛场一针对肺部CT多病种病灶检测与假阳性消除采用YOLOv3配合ResNet的二阶段方案是结构简单、易读易改的baseline实现。资源共27个文件约147KB其中14个Python脚本构成主线覆盖数据标注转换、锚框聚类、模型训练与测试等环节6个pyc为模块编译缓存4个txt记录标签、类别、锚框及资源说明另有1个cfg模型配置、1个jpg图片与1个README说明目录按data、code、model_data组织层次清楚。已有58人浏览/学习。代码中除主训练推理脚本外还包含kmeans锚框聚类工具、ResNet_test脚本、generate_the_image等配套程序读者可快速跑通“病灶检测假阳性衰减”全流程也能对照源码理解医疗影像目标检测中数据拆分、标注转换、两阶段模型的工程组织方式。1. 拿到天池肺部CT多病种诊断数据包先别急着解压先搞清比赛在问什么你下载的“天池比赛-肺部CT多病种智能诊断-全球数据智能大赛(2019)“数字人体”赛场一.zip”表面是一个压缩包本质是一道医学影像多标签分类题给CT切片或序列判断是否存在多种肺部疾病中的哪几种。这类比赛的数据智能含量集中在两个地方——数据清洗和标签处理模型反而只占三成。适合的读者是计算机视觉基础扎实、但还没碰过DICOM格式和窗宽窗位概念的工程师或者准备参加下一届同类医学影像比赛的选手。先看标注文件长什么样再决定要不要动手解压这是我最开始踩过的坑。2. 拆包与数据体检从zip解压到DICOM读取的完整流程2.1 zip解压先列文件清单再执行别盲解拿到zip包的第一步不是直接解压到当前目录而是先看压缩包里的目录结构。比赛数据包通常包含训练图像、测试图像、标注CSV和数据说明文档文件名可能带患者ID和序列ID。先列出顶层结构确认有没有乱码文件名和隐藏目录。unzip -l data.zip | head -40 unzip -o data.zip -d ./lung_ct_data-l参数只列清单不释放适合先确认目录层级。确认结构合理后再用-o覆盖解压到指定目录避免直接散落在当前目录污染工作区。医学影像数据量大动辄几十万个小文件解压耗时十几分钟很正常。解压过程中最常见的失败是“invalid compressed data”或“CRC failed”。这通常不是下载损坏而是zip伪加密。伪加密的文件用系统自带解压工具会直接报错但用7-Zip打开看属性会发现加密标志和实际行为不一致。判断方法很简单把报错的文件单独用7-Zip解压能解开说明文件本身完整只是标志位被改动过。2.2 DICOM读取pydicom把像素矩阵还原成真实CT值CT数据的标准格式是DICOM每个患者一个文件夹一个序列包含几十到几百张切片。读取DICOM不能直接用图像库必须用pydicom而且要处理RescaleSlope和RescaleIntercept两个标签否则读出来的是存储值而不是真实的HU值。import pydicom import numpy as np def load_dicom_slice(path): dcm pydicom.dcmread(path, forceTrue) pixels dcm.pixel_array.astype(np.float32) if hasattr(dcm, RescaleSlope) and hasattr(dcm, RescaleIntercept): pixels pixels * float(dcm.RescaleSlope) float(dcm.RescaleIntercept) return pixels, dcm pixels, dcm load_dicom_slice(./lung_ct_data/patient_001/CT0001.dcm) print(dcm.PatientID, dcm.SeriesInstanceUID, pixels.shape) print(pixels.min(), pixels.max())pixel_array返回的是原始像素矩阵astype(np.float32)防止后续计算溢出。forceTrue用于跳过某些私有标签导致的读取异常。RescaleSlope和RescaleIntercept把设备记录值转换成标准HU值HU值才是肺窗、纵隔窗能正常显示的基础。DICOM文件里还有SliceThickness、PixelSpacing这类物理参数比赛任务如果只看单张切片这些参数可以暂不考虑如果要做3D模型SliceThickness直接决定要不要做层间插值。先打印几个患者的数据形态确认切片数一致性和方向一致性再做预处理。2.3 标注文件解读多分类还是多标签一眼看清标签文件通常是CSV结构可能有两种一种是一行对应一张切片标签列是病种ID另一种是一行对应一个患者或序列每一列是一个病种值为0或1。第二种是典型的多标签设置也是肺部CT多病种诊断最常见的任务形式。import pandas as pd label_df pd.read_csv(./lung_ct_data/labels.csv) print(label_df.head()) print(label_df.shape) # 统计每一列的正样本数量 positive_counts label_df.iloc[:, 1:].sum(axis0) print(positive_counts)label_df.iloc[:, 1:]跳过ID列对每个病种列做正样本计数。这一步输出的意义远大于模型选型如果某个类别的正样本只有几十个而总样本有几万张那这个类别基本不可能靠单纯加大模型解决必须考虑采样策略或损失函数权重。统计完正样本数再统计每个患者的切片数。常见坑是同一个患者的切片被分成训练集和验证集导致验证指标虚高后面避坑章节会细讲。先在这里把患者ID到切片的映射关系存进字典后续划分数据集时直接用。3. 把CT切片变成可训练数据集窗宽窗位与格式转换细节3.1 窗宽窗位调整不调窗模型学到的全是噪声CT的原始HU值范围大约是-1024到3071但这个范围对肺部病灶识别没有意义。医生看CT时通过调节窗宽窗位把目标组织映射到可见的灰度范围。肺窗的常见设定是窗位-600、窗宽1500到1700这个范围能让肺实质、血管和结节同时可见。def apply_window(hu, center-600, width1600): lower center - width / 2.0 upper center width / 2.0 return np.clip((hu - lower) / (upper - lower), 0, 1) windowed apply_window(pixels) print(windowed.min(), windowed.max(), windowed.dtype)(hu - lower) / (upper - lower)把窗宽内的HU值线性映射到0到1区间超出上下界的值被clip截断。这个函数输出的是浮点数后续转成uint8时需要乘以255再转换。窗宽窗位的选择可以做成超参数的一部分。竞赛里常见做法是对同一张切片生成多个窗的版本——肺窗、纵隔窗、骨窗——分别送进模型或者在通道维拼接。我一般保留肺窗作为主输入因为肺结节和肺炎病灶在肺窗下对比度最明显。窗宽窗位不统一模型会因为图像整体亮度分布漂移而泛化变差。3.2 归一化与分辨率统一224×224是ResNet系模型的默认输入预处理做归一化时如果用ImageNet预训练权重均值方差必须沿用ImageNet统计值用0.485、0.456、0.406和0.229、0.224、0.225。但CT是单通道灰度图需要复制成三通道再归一化。import cv2 def preprocess_ct_slice(windowed, target_size224): img8 np.uint8(windowed * 255) resized cv2.resize(img8, (target_size, target_size), interpolationcv2.INTER_AREA) rgb cv2.cvtColor(resized, cv2.COLOR_GRAY2RGB) return rgb img preprocess_ct_slice(windowed) print(img.shape, img.dtype)INTER_AREA对缩小图像比INTER_LINEAR更稳能减少高频纹理混叠伪影。如果数据集中某些切片本身分辨率只有512×512缩放两次会损失细节更合理的做法是先做ROI裁剪再缩放。归一化不放到这条链路里的原因是PyTorch的Normalize层可以直接挂在数据加载器后面和模型参数一起做GPU上的张量运算。预处理函数里只负责把CT转成0到255的三通道图训练脚本再做标准化。NTU类比赛通常不要求提交原始图像按这个流程导出PNG或npy都能兼容主流训练框架。3.3 按序列组织目录同一患者的切片不能跨进训练集和验证集医学影像数据的划分逻辑和普通图像分类不同不能直接对切片随机划分否则同一个患者的多张相似切片会同时出现在训练集和验证集验证指标虚高线上评估直接崩溃。要按患者分组划分。from sklearn.model_selection import GroupKFold import numpy as np patient_ids label_df[patient_id].values X np.arange(len(label_df)) y label_df.iloc[:, 1:].values group_kfold GroupKFold(n_splits5) for fold, (train_idx, val_idx) in enumerate(group_kfold.split(X, y, groupspatient_ids)): train_df label_df.iloc[train_idx] val_df label_df.iloc[val_idx] print(fFold {fold}: train {len(train_df)}, val {len(val_df)})GroupKFold的第三个参数groups传入患者ID数组保证同一个患者的所有切片只会进训练或验证中的一侧。切片的物理相邻性很强同一患者的相邻层几乎相同不分组划分的后果是验证集损失低得离谱。划分完数据集后把划分结果保存成CSV训练脚本直接读取路径列表。不要每次训练前重新划分否则模型调试过程中会因为划分随机性而难以定位是模型问题还是数据问题。保存划分文件的路径、命名规则也要固定方便多条实验线对比。4. 多病种分类的训练配置损失函数、采样器与预训练权重4.1 模型选型从ResNet50起步不要直接上3D网络医学图像竞赛里2D模型打底是常见做法ResNet50足够应付绝大多数单切片分类任务。先跑通单切片多标签再考虑3D卷积对序列建模。3D模型参数量大对显存要求高而且容易在小数据集上过拟合。import torch import torchvision model torchvision.models.resnet50(pretrainedTrue) num_classes label_df.shape[1] - 1 # 去掉ID列 model.fc torch.nn.Linear(model.fc.in_features, num_classes) print(model)pretrainedTrue加载ImageNet预训练权重model.fc被替换成输出维度等于病种数量的全连接层。多标签任务没有Softmax输出层保持线性即可损失函数用BCEWithLogitsLoss内部自带Sigmoid处理。如果病种数量超过20个ResNet50的输出头会偏弱可以换成ResNet101或者EfficientNet-b3。但不要一开始就追求大模型先验证小模型能不能过拟合一小批数据再逐步扩大。模型参数量和数据集规模不匹配时验证集波动会非常大。4.2 损失函数与正负样本权重BCEWithLogitsLoss加pos_weight肺部CT多病种诊断的正负样本比例极其悬殊正常切片占绝大多数有病征的切片可能只有5%到10%个别病种1%都不到。这种数据分布下直接训练模型输出会偏向全零因为准确率照样很高。解决办法是给损失函数加pos_weight参数。positive_counts label_df.iloc[:, 1:].sum(axis0) total_samples len(label_df) neg_counts total_samples - positive_counts pos_weight torch.tensor((neg_counts / (positive_counts 1e-6)).values, dtypetorch.float32) loss_fn torch.nn.BCEWithLogitsLoss(pos_weightpos_weight)pos_weight的计算方式是负样本数除以正样本数每个病种对应一个权重值。当某个病种只有0.5%正样本时权重会到200左右模型必须预测正类才能压住损失否则梯度几乎被负样本淹没。加了pos_weight之后模型输出的概率值会整体变大阈值就不该再用0.5要结合验证集做PR曲线取最优阈值。具体调到多少避坑章节会讲。4.3 训练参数与数据增强学习率1e-4起步增强策略保持几何变换多标签医学图像训练的默认配置是AdamW加1e-4学习率配合Cosine annealing调度器batch size在显卡允许的前提下尽量取16或32。过大的batch size会让类别极少的病种在单个batch里完全没有正样本梯度更新对小类不友好。optimizer torch.optim.AdamW(model.parameters(), lr1e-4, weight_decay1e-4) scheduler torch.optim.lr_scheduler.CosineAnnealingLR(optimizer, T_max20) for epoch in range(20): model.train() for x_batch, y_batch in train_loader: x_batch x_batch.cuda() y_batch y_batch.cuda() logits model(x_batch) loss loss_fn(logits, y_batch) optimizer.zero_grad() loss.backward() optimizer.step() scheduler.step()T_max20配合epoch总数20学习率从头到尾做余弦衰减。weight_decay1e-4起轻微正则作用在数据量只有几万张切片时能压住过拟合。数据增强只保留空间几何变换随机水平翻转、随机旋转10度、随机缩放0.9到1.1倍。不要去用颜色抖动和RandomErasingCT灰度图的色度扰动会破坏窗宽窗位带来的物理一致性模型会学到错误的亮度特征。5. 肺部CT多病种诊断的避坑要点zip伪加密、标签噪声与训练泄漏的5个坑5.1 zip伪加密导致解压失败现象unzip -o data.zip执行到中途报“invalid compressed data”整个流程卡死换系统解压工具报“文件损坏”但压缩包能从网盘正常下载。原因文件头里通用标志位被改成加密位但数据本身没有加密。这是网上常见的伪加密现象不是数据真正损坏。解决用7-Zip打开zip包如果能看到内部文件名并正常解压说明是伪加密。7-Zip对伪加密兼容性较友好。命令行下可用7z x data.zip -o./lung_ct_data直接释放。如果只有个别文件报错用unzip -o data.zip -x corrupted_file.dcm先把其他文件解压出来再单独处理异常文件。5.2 标签与切片数量不一致现象训练时DataLoader报下标越界或者验证时发现正样本数远低于标签文件里的统计值。原因DICOM序列可能包含本地izer扫描的定位像或者同一患者的序列编号混乱预处理脚本没有过滤就全部当成正式切片。标签文件按患者记录切片多的患者读取顺序和标签顺序错位。解决读取DICOM时用SeriesInstanceUID做一次分组确认每个患者有几个序列每个序列多少张切片。过滤掉SliceThickness为0或不存在的文件再按InstanceNumber排序。建议先把整理好的文件索引与原标签做一次笛卡尔积检查患者ID对得上再进训练。5.3 类别不均衡被忽略模型输出全零照样高准确率现象训练完验证集准确率95%但检查预测结果发现所有类别的输出全是0一个病种都没识别出来。原因多标签任务里无病样本占90%以上模型统计上只要输出全零就能降低整体损失。准确率指标在这个场景下没有参考意义。解决引入pos_weight并监控每个病种的召回率而不是全局准确率。同时把评估指标改成宏平均F1或加权F1台账里单独记录正样本占比只有1%的稀有类。如果某个类别epoch训练后召回率始终为0检查pos_weight是否真的生效打印一次损失值做对比。5.4 同一患者序列跨划分导致验证集虚高现象5折交叉验证的验证F1有0.85提交线上跌到0.7。这是典型的训练泄漏。原因划分时直接对切片随机切分同一个患者的多张切片在训练集和验证集同时出现。CT相邻切片极度相似模型在验证集上看到的图像和训练集几乎一样。解决必须用GroupKFold按患者分组或者直接按患者ID做分层采样。划分后打印每个fold里患者ID是否有交集确认无误再保存划分结果。划分结果保存成固定文件方便复现。5.5 推理阶段跳过预处理CT数值范围与训练时不一致现象验证时手动检查模型输出预测概率分布与训练时观察到的差异很大部分图像输出方差极大。原因推理脚本直接读了原始DICOM或PNG没有经过窗宽窗位和归一化处理。网络输入分布变了输出自然不稳定。解决把预处理流程统一成一个函数训练和推理共用同一个入口。这个函数必须包含读取DICOM或PNG、应用窗宽窗位、缩放到模型输入尺寸、转三通道、ImageNet归一化。推理前用训练集抽样图像跑一遍该函数统计输出范围再对比模型输出。6. 提交前最后一步用混淆矩阵与PR曲线决定最终版本6.1 混淆矩阵定位每个病种的薄弱点多标签任务的混淆矩阵不是二维的需要按病种分别画。对每一个类别把预测结果和真实标签做二分类统计能直观看到哪些病种被漏掉哪些被误报。先关注召回率医学诊断场景宁可误报也不要漏诊漏诊在评价指标上惩罚更严重。from sklearn.metrics import confusion_matrix y_true val_df.iloc[:, 1:].values y_pred (torch.sigmoid(logits) 0.5).numpy().astype(int) for i, cls in enumerate(class_names): cm confusion_matrix(y_true[:, i], y_pred[:, i]) print(cls, TN:, cm[0,0], FP:, cm[0,1], FN:, cm[1,0], TP:, cm[1,1])逐个病种打印混淆矩阵四象限比看单一准确率清晰得多。如果多个病种都有高漏报说明模型容量不够或者数据增强里对病灶区域的支持不足如果集中在某一类优先考虑该类别的样本量与标注质量。6.2 PR曲线与置信度阈值调整多标签分类的默认阈值0.5对稀疏正样本并不友好尤其加过pos_weight后模型输出普遍偏高0.5会把大量正常样本判成有病。正确的做法是按病种单独计算PR曲线用最大化F1的位置确定阈值。from sklearn.metrics import precision_recall_curve, f1_score precision, recall, thresholds precision_recall_curve( y_true[:, 3], y_prob[:, 3] ) f1_scores 2 * precision[:-1] * recall[:-1] / (precision[:-1] recall[:-1] 1e-6) best_thr thresholds[np.argmax(f1_scores)] print(f{class_names[3]} best threshold: {best_thr:.3f})precision_recall_curve返回的数组长度比阈值数组多一位计算F1时用precision[:-1]和recall[:-1]对齐。每个病种独立计算阈值并保存在JSON里推理脚本加载后按类阈值判定。6.3 集成与伪标签的应用边界最后版本如果时间充裕可以尝试用五折的logits均值做集成。注意logits平均必须在Sigmoid之前概率平均会削弱类间差异。伪标签只在置信度极高的情况下使用否则会放大标签噪声反而伤害模型。我在这类CT比赛里的习惯是先信数据体检结果再信交叉验证最后才信测试集。提交版本永远保留Train日志、验证脚本和预测概率文件任何玄学波动都能回溯到具体版本。“数字人体”这场的数据规模不算小把避坑点压住把预处理和损失函数调对单模型就能拿到不错的成绩。希望帮到你。本文还有配套的精品资源点击获取
网站建设高端定制企业官网