肺部CT多病种智能诊断:从数据解压到多标签分类实战
发布时间:2026/9/25 23:24:28来源:尧图网络
简介2019年天池“数字人体”赛场一的肺部CT多病种智能诊断赛题资源包面向医疗图像分析与深度学习竞赛开发者。包内主要是比赛相关Python源码、模型训练与测试脚本、YOLO配置与说明文档能够帮助读者梳理从CT数据预处理、图像标注到ResNet/YOLO模型训练与测试的完整流程并借鉴竞赛中的算法调优思路理解肺部疾病在影像上的识别难点。资源共25个文件以14个py脚本为主辅以6个pyc编译文件、2个txt、1个jpg、1个cfg和1个md整体仅147KB轻量紧凑方便直接阅读和运行参考。目前已有149人浏览学习适合作为入门医疗AI竞赛、复盘肺部CT多病种识别方案的学习样例尤其适合希望快速了解天池赛题代码组织与模型调用方式的开发者。1. 天池“数字人体”肺部CT多病种智能诊断这个zip包里到底是什么“天池比赛-肺部CT多病种智能诊断-全球数据智能大赛(2019)“数字人体”赛场一.zip”这个标题看着长拆开就是三件事天池平台、肺部CT多病种诊断、2019“数字人体”赛场一。你下载到的这个zip包就是当年这个比赛的完整数据解压后是一批肺部CT切片图和配套的标注文件任务是给定一张切片同时预测它是否存在多种肺部病灶——不是单标签的二分类而是多标签联合诊断一张图上可能同时命中好几个病种。这个方向解决的是影像AI从“单病种识别”走向“多病种共判”的落地问题适合正在做医学图像分类、想参加天池这类数据竞赛、或需要搭一套CT多标签影像管线的从业者。数据量不算小解压、预处理、建模这一整条链路恰好能把“拿到zip到提交csv”的完整流程走一遍。2. 解压与数据校验伪加密、体积核对与目录结构确认先别急着双击解压。比赛数据包通常有几个GB解压时间长不说一旦中途报错你很难判断是下载断了、压缩包损坏还是文件本身带了加密标志。我一般会先做两件事看压缩包内部清单、验证文件完整性然后再决定用什么工具去解压。2.1 解压前先看压缩包信息文件数、体积和加密标志位拿到zip包的第一步是用工具列出内部文件清单而不是直接解压。Linux下用unzip或7-Zip的命令行都行Windows下我习惯用7-Zip自带的7z.exe因为它能看到加密标志位和解压后总大小。# 列出zip内部文件清单、文件数和总大小只看不解压 7z l 天池比赛-肺部CT多病种智能诊断-全球数据智能大赛\(2019\)“数字人体”赛场一.zip # 用unzip也能看-l参数只列内容不落地 unzip -l 天池比赛-肺部CT多病种智能诊断-全球数据智能大赛\(2019\)“数字人体”赛场一.zip | head -60 # 校验下载完整性记录md5值判断是否断点下载不完整 md5sum 天池比赛-肺部CT多病种智能诊断-全球数据智能大赛\(2019\)“数字人体”赛场一.zip7z l的输出里有个“Encrypted”列如果它是“”号说明压缩包里至少有一部分文件带了加密标志位解压后总大小在列表最后一行也会给出来。md5sum是给整个zip文件算指纹同一份数据包官方如果公布过md5核对一致就说明下载没断。这里有一个关键判断逻辑zip里的“加密标志位”不等于“这个包真的要密码”。后面会专门讲伪加密的情况但你在解压前至少应该知道文件数、解压后体积、加密标志位这三个信息决定了你接下来用普通解压还是绕开异常。一般比赛数据包解压后体积是zip的2到5倍如果7z列出的解压后体积和你磁盘剩余空间不匹配先清磁盘再动手解压到一半空间不足的翻车现场我见过不止一次。2.2 伪加密与解压报错遇到“要密码”时的判断路径很多人卡在解压这一步双击zip弹出对话框要求输入密码或者解压到一半提示“文件损坏密码错误”。先别急着去找密码这个现象大概率是zip伪加密而不是数据真的被加了密。zip伪加密的意思是说压缩包里的文件数据其实没有被加密但文件头里“encrypted”标志位被置成了1解压工具看到标志位就要求你输入密码。这种问题常见于某些打包工具或传输过程对zip头做了改写比赛数据本身不大可能故意加密。判断方法很简单用7-Zip打开这个zip点击“测试”按钮如果测试过程不要求密码且能正常通过那基本就是伪加密如果真的要求密码且输入任何密码都报错那要么是下载损坏要么是文件头标志位错误。# 修复zip伪加密把general purpose bit flag中的bit0加密标志清零 # 只用于你自己下载的数据包真加密包请走正规密码渠道 def fix_fake_encryption(zip_path, output_path): data bytearray(open(zip_path, rb).read()) # zip文件由多个entry组成每个entry以PK\\x03\\x04本地文件头或 # PK\\x01\\x02中央目录头开头加密标志在签名后第6个字节的bit0 sigs (bPK\x03\x04, bPK\x01\x02) n len(data) i 0 fixed 0 while i n - 4: if data[i:i4] in sigs: flag_offset i 6 # 跳过signature(4字节)version(2字节) data[flag_offset] 0xFE # 清除最低位去掉加密标志 fixed 1 i 4 else: i 1 open(output_path, wb).write(data) print(f处理了 {fixed} 个文件头) # 用法先测试确认是伪加密后再执行 fix_fake_encryption(original.zip, fixed.zip)这段脚本的核心逻辑是扫描zip的二进制结构找到本地文件头和中央目录头把各自对应的general purpose bit flag最低位从1改回0。flag_offset的计算基于zip规范签名占4字节version占2字节第7字节开始才是bit flag。参数说明data是字节数组修改会直接写回fixed用于确认脚本确实匹配到了文件头如果输出fixed0说明这个zip本身没有伪加密问题别强行套用。这个办法只能处理“标志位错误但数据没加密”的包遇到真加密请用正确密码解压不要尝试其它绕过手段。如果md5对不上、测试报CRC错误那是下载损坏需要重新下载完整包修标志位解决不了。2.3 解压后的目录结构与标注文件核对解压完成后先别急着写训练代码用两分钟核对目录结构能避免后面踩大坑。天池这类比赛的通用布局一般是train目录放训练图像test目录放测试图像train.csv放训练标注sample_submission.csv放提交模板。但具体到“数字人体”赛场一目录怎么命名、图片是切片还是dicom序列要以你解压后的实际结构为准。# 解压到指定目录-q安静模式减少输出 unzip -q 天池比赛-肺部CT多病种智能诊断-全球数据智能大赛\(2019\)“数字人体”赛场一.zip -d ./dataset # 看解压后的总体积确认没有中途中断 du -sh ./dataset # 列出前30个文件初步确认图片格式和目录层级 find ./dataset -maxdepth 2 -type f | head -30import pandas as pd train pd.read_csv(./dataset/train.csv) print(shape:, train.shape) print(train.head(3).to_string()) print(columns:, train.columns.tolist())这一段做的事是把标注文件的形状、前几行、列名打印出来。重点关注三件事第一标注是“一图一标签”还是“一患者多图一标签”这决定后面按什么粒度划分数据第二标签列是0/1整数还是概率多数比赛是0/1多标签第三csv里的图像id和train目录下的文件名能否一一对上。如果发现csv有5000行但目录里只有4900个文件说明解压不完整回到第一步重新解压别偷懒。我就见过有人拿着不完整数据跑到第30个epoch才意识到某个类别的图片少了三分之一那种从头再来的感觉相当难受。3. 标签分布与CT预处理为什么原图直接进网络会翻车把zip解压成干净目录只是第一步。接下来要面对的是医学图像和普通自然图像的根本差异CT切片是单通道灰度图数值是HU亨氏单位范围通常在-1000到3000左右直接拿cv2.imread读进来当成普通图片扔给ImageNet预训练模型等于让模型在完全陌生的数值分布上做迁移学习收敛慢且精度上不去。3.1 先统计标签分布多标签不平衡决定损失函数怎么选拿到train.csv第一件事是统计每一列的正样本占比。这个数字直接影响后续的损失函数设计和评估策略千万别跳过。import pandas as pd train pd.read_csv(./dataset/train.csv) # 假设前几列是id/路径其余都是标签列实际按你的csv列名调整 label_cols [c for c in train.columns if c not in (id, image_path, patient_id)] print(标签列数:, len(label_cols)) print(每列正样本占比:) print(train[label_cols].mean(axis0).sort_values().round(4)) # 统计每张图同时命中几个病种 train[pos_num] train[label_cols].sum(axis1) print(train[pos_num].value_counts().sort_index())这段代码的输出能让你一眼看清两个问题标签稀疏程度和共病情况。医学CT多标签数据集的典型现象是常见病种正样本占比可能到30%以上稀缺病种可能不到1%大多数样本只有0到1个正标签同时命中3种以上病灶的样本非常少。如果你是第一次做这类数据看到这个分布不要慌这决定了两个后续选择一是损失函数用BCEWithLogitsLoss并配置pos_weight而不是普通的CrossEntropyLoss二是验证指标优先看AUC或带阈值的F1而不是整体准确率。label_cols的过滤条件要根据实际列名改patient_id如果存在就单独剔除避免被当成标签训练。正样本占比打印出来后建议把样本量极少的标签单独记下来。这些稀缺病种在训练时几乎不会对loss产生贡献后面要么靠pos_weight拉权重要么靠伪标签补充否则模型会稳定地输出全零预测——这个问题在第5章会专门展开。3.2 用窗宽窗位把CT切片转到模型能学的分布CT图像的处理核心是窗宽窗位。人眼看CT要看“窗”模型也一样。肺部诊断常用的窗位大约是-450到-600窗宽1400到1600对应到数值范围就是把-1000到400这个区间映射到0到255或0到1。低于窗位下限的全是黑高于上限的全是白中间才是肺实质、血管、病灶的对比信息。import cv2 import numpy as np def preprocess_ct(image_path, target_size256, hu_min-1000, hu_max400): # 以单通道灰度图读入CT切片没有颜色信息 img cv2.imread(image_path, cv2.IMREAD_GRAYSCALE) if img is None: raise FileNotFoundError(f读取失败: {image_path}) # 若原图已经是8bit灰度数值范围是0~255需要先转回HU范围 # 这个映射关系视数据集制作方式而定常见做法是线性反推 if img.dtype np.uint8: img img.astype(np.float32) img img / 255.0 * (hu_max - hu_min) hu_min # 窗宽窗位裁剪落在窗口外的CT值压到边界 img np.clip(img, hu_min, hu_max) # 线性映射到[0,1]让预训练模型输入分布不至于太离谱 img (img - hu_min) / (hu_max - hu_min) # 统一尺寸缩小用INTER_AREA抗锯齿 img cv2.resize(img, (target_size, target_size), interpolationcv2.INTER_AREA) return img.astype(np.float32)预处理函数里最关键的参数是hu_min和hu_max。用-1000到400这个窗口是因为空气的HU值是-1000致密骨和钙化在400以上肺实质和早期病灶的对比信息基本都落在这个区间里。img.dtype的判断分支是为了兼容“数据集已经把CT值转成了8bit图”的情况如果数据集给的是16bit PNG或直接是DICOM序列这个分支要改成对应的读取方式不要硬套。clip是把两端截断避免个别高密度金属伪影把整个图像的数值范围拉爆。resize用INTER_AREA是因为我们做的是缩小这个插值方式在缩小时比INTER_LINEAR更不容易产生锯齿。这段预处理逻辑要做到训练和推理完全一致。很多人训练时做了裁剪和归一化推理时却只用ImageNet的mean/std去归一化结果线上分数对不上这种问题放到第5章细说。3.3 按患者ID划分数据堵住第一条泄漏通道医学影像比赛和普通CV比赛最大的不同是同一患者的相邻切片之间高度相似。如果随机切分训练集和验证集同一患者的几十张切片很可能一部分在训练集、一部分在验证集模型等于提前看到了“熟人”线下AUC虚高得离谱到线上立刻露馅。from sklearn.model_selection import GroupShuffleSplit # 假设train.csv里有patient_id列没有的话用文件名的患者前缀提取 gss GroupShuffleSplit(n_splits1, test_size0.2, random_state42) train_idx, val_idx next(gss.split(train, groupstrain[patient_id])) train_part train.iloc[train_idx].reset_index(dropTrue) val_part train.iloc[val_idx].reset_index(dropTrue) # 校验两个集合的患者ID不能有交集 overlap set(train_part[patient_id]) set(val_part[patient_id]) print(患者交集数量:, len(overlap)) print(train患者数:, train_part[patient_id].nunique()) print(val患者数:, val_part[patient_id].nunique())GroupShuffleSplit按patient_id分组切分保证同一个患者的全部切片只出现在一个集合里。检查两个集合患者ID交集是否为0是这一步的关键验证动作。test_size0.2表示拿出20%的患者做验证random_state固定下来方便后续复现和对照实验。这里的train_part和val_part后续在DataLoader里按索引取图注意务必reset_index否则用iloc取值时索引错乱查半天都不知道数据哪来的。这一步做到位后面训练出的AUC才有参考价值。我见过有人不做按患者划分线下AUC做到0.97提交线上只有0.88最后发现是泄漏导致的虚高白白浪费了一版有效实验。4. 跑通多标签分类baseline从预训练模型到提交csv预处理和数据划分做完就到了动手训练的时候。这个比赛任务本质是多标签图像分类baseline不需要花哨先把一套稳定可复现的流程跑通拿到一个真实可信的分数再逐步迭代。4.1 模型选型与输入尺寸先让ResNet34出线模型选择上我一般先用ResNet34而不是直接上EfficientNet或Swin Transformer。原因有三个ResNet的ImageNet预训练权重最稳定训练曲线可预期网络结构简单出现问题时容易定位显存占用可控可以把batch size撑大。医学图像比赛里模型精度差异往往不如数据预处理和训练技巧带来的差异大先跑通比先跑高更重要。import torch import torch.nn as nn from torchvision import models def build_model(num_classes, use_pretrainedTrue): model models.resnet34(pretraineduse_pretrained) # 把第一层卷积从3通道改为1通道适配CT灰度图 old_conv1 model.conv1 model.conv1 nn.Conv2d(1, 64, kernel_size7, stride2, padding3, biasFalse) with torch.no_grad(): # 用预训练RGB权重的通道均值初始化单通道权重 model.conv1.weight.copy_(old_conv1.weight.mean(dim1, keepdimTrue)) # 替换分类头从1000类改成num_classes输出logits in_features model.fc.in_features model.fc nn.Linear(in_features, num_classes) return model这段代码做了两件关键改造。第一件是把conv1从接受3通道RGB图改成接受1通道灰度图同时用原有权重的RGB通道均值做初始化等价于把“彩色预训练知识”压缩成灰度版本比随机初始化收敛快得多。第二件是替换最后的全连接层输出维度从1000改成num_classes这里输出的是logits而不是概率后续交给BCEWithLogitsLoss处理。参数说明kernel_size7、stride2、padding3是ResNet34原始conv1的配置改输入通道时这三个参数必须保持不变否则第一个卷积输出的feature map尺寸就对不上后续的BatchNorm和池化层。num_classes由第3章统计出的label_cols长度决定不要手写死。输入尺寸我建议从256开始。512的输入确实能保留更多CT细节但显存占用翻四倍训练时间拉长而且ResNet34在256输入上已经能提取到足够分辨率的肺部纹理特征。先把256跑通拿到基线再回头试320或384做对比实验。4.2 训练参数BCE损失、pos_weight与学习率调度多标签分类的损失函数用BCEWithLogitsLoss它在内部做了sigmoid和交叉熵的数值稳定版计算不要在模型输出前手动sigmoid再算BCE那样数值会不稳。由于第3章已经发现标签分布极度不平衡必须给每个标签单独设置pos_weight否则整个模型会趋向于把所有标签都预测为阴性。import torch from torch import nn # 用训练集正样本占比计算每个标签的pos_weight pos_ratio train_part[label_cols].mean(axis0).values pos_weight torch.tensor( (1.0 - pos_ratio) / (pos_ratio 1e-6), dtypetorch.float32, devicedevice ) criterion nn.BCEWithLogitsLoss(pos_weightpos_weight)pos_weight的计算公式是负样本数除以正样本数再加一个极小值防止除零。正样本占比1%的标签pos_weight大约是99正样本占比30%的标签pos_weight大约是2.3。这样稀缺病种在loss里得到充分放大模型不会一味输出全零。pos_ratio必须基于训练集计算不能用验证集否则信息泄漏。优化器我优先选SGD配Nesterov动量学习率用余弦退火。Adam在自然图像上收敛快但医学数据量相对小、标签稀疏SGD的泛化表现通常略好且更容易调出稳定结果。optimizer torch.optim.SGD( model.parameters(), lr1e-3, momentum0.9, nesterovTrue, weight_decay1e-4 ) scheduler torch.optim.lr_scheduler.CosineAnnealingLR( optimizer, T_max30, eta_min1e-5 )学习率1e-3是ResNet34配batch size 64的常用起点如果你的显存只够跑batch size 16学习率建议降到5e-4左右否则大batch和小batch的梯度噪声差异会让训练不稳定。Weight_decay设为1e-4做轻量正则防止医学小数据上过拟合。CosineAnnealing把学习率在30个epoch内从1e-3平滑降到1e-5后期小学习率让模型在loss曲面底部充分收敛比固定学习率更稳。训练循环体内还有一个常用细节保存验证AUC最高的checkpoint而不是最后一轮的权重。最后一轮通常已经过拟合到验证集保存最高点等于给模型留了后悔药。4.3 推理与提交sigmoid输出怎么变成符合要求的csv训练结束后进入推理阶段。测试集没有标签我们要为每一张图输出每个病种的预测概率。多标签比赛的提交格式一般要求提交概率值而不是0/1预测线上平台自己算AUC所以推理时输出sigmoid概率并保持列名和sample_submission完全一致即可。import numpy as np import pandas as pd import torch def predict(model, test_loader, device): model.eval() all_logits [] all_ids [] with torch.no_grad(): for images, img_ids in test_loader: images images.to(device) logits model(images) probs torch.sigmoid(logits).cpu().numpy() all_logits.append(probs) all_ids.extend(img_ids) return np.concatenate(all_logits, axis0), all_ids # 生成提交文件 probs, ids predict(model, test_loader, device) submission pd.DataFrame(probs, columnslabel_cols) submission.insert(0, id, ids) submission.to_csv(submission.csv, indexFalse)推理时需要把model切到eval模式同时关掉gradient计算否则显存会被反向传播图占掉一大块。all_ids的顺序必须和test_loader返回的图片顺序一一对应不能从DataLoader外另取一份独立列表在多进程采样下索引顺序可能被打乱。提交csv的行顺序和列名顺序要和官方sample_submission对齐row顺序错位线上直接判零分这个检查务必做一次肉眼对比sample_submission和submission.csv前5行的id顺序。以下是参考训练参数表参数推荐值说明输入尺寸256显存充裕后可上调到320batch size64取决于显存16显存建议32学习率1e-3 (SGD)小batch改5e-4调度器CosineAnnealingT_max30, eta_min1e-5pos_weight按标签分布计算正样本占比倒数验证指标每标签AUC平均同时记录macro F15. 验证与提交避坑泄漏、全零预测和虚高AUC的三类现场训练管线跑通只是开始。真正让参赛者反复翻车的往往是那些数据层面和验证策略层面的“玄学”问题。这一章把最常见的四个坑按现象、原因、解决的顺序写清楚每条都是我见过至少两次的现场。5.1 同一患者的切片跨集合泄漏AUC虚高的头号原因现象按普通随机切分训练验证集线下AUC轻松到0.97甚至0.99验证集loss也一路降低但提交线上后分数突然掉到0.9以下且怎么调参都回不来。原因CT检查是连续扫描同一患者相邻切片之间的纹理几乎一样随机切分后验证集里大量切片能在训练集里找到“同一来源”的近似副本。模型学到的是“这张图像某种特定噪声纹理属于患者A”而不是真正的病灶特征验证集AUC自然虚高。解决严格按照第3章的GroupShuffleSplit按患者ID划分保证验证集患者完全未见。如果你的数据没有patient_id列就从文件名的前缀提取天池这类比赛一般在文件名前几位隐含患者编号提取规则要根据实际文件名调整。划分后确认两个集合患者ID交集为0再训练一版看AUC是否明显下降——下降是正常的那个低一点的数字才是你线上分数的真实预期。5.2 标签稀疏导致模型全预测0pos_weight不够或缺失现象训练loss在前几个epoch下降后陷入平台期预测概率全部趋近于0稀缺病种的验证AUC在0.5左右徘徊等于随机猜测。原因负样本占绝对主导地位普通BCE损失里每个负样本对梯度的贡献被反复累计正样本的信号被淹没模型发现“全部预测为负”已经能把loss压得很低。解决确认pos_weight是按训练集计算并传入BCEWithLogitsLoss而不是手动乘一个全局系数。稀疏度差异大的标签pos_weight可能差几十倍必须逐标签设置。如果加了pos_weight后稀缺病种仍然学不动再叠加Focal Lossgamma设1.5到2之间让模型注意力集中在难分正样本上。另外训练中监控每个标签的预测均值如果某个标签的预测概率全为0且AUC不涨单独调它的pos_weight比全局调参更有效。5.3 训练和推理的预处理不一致模型输入分布对不上现象本地验证AUC稳定但拆开不同病例看预测概率分布发现整体偏移——有的患者所有概率都偏高有的所有概率都偏低排序结果还行但绝对数值不可信。原因训练阶段使用了随机裁剪、随机翻转、随机对比度等数据增强而推理阶段没有做相应处理或者训练时做了窗宽窗位裁剪推理时直接按ImageNet的mean/std归一化输入数值范围完全不同。解决把预处理封装成同一个类训练时在类内加随机增强分支推理时只走确定性分支确保resize尺寸、clip上下界、归一化公式完全一致。推理增强这项先不做宁可稳定也不要引入额外方差。一个简单验证方法从训练集抽10张图用训练流程的确定性预处理跑一遍和推理预处理跑一遍逐像素对比输出数组完全一致才说明没有偏差。5.4 解压目录不完整导致训练数据悄悄减少现象训练过程中发现loss下降曲线正常但某个类别的正样本始终学不好回查数据才发现该类别的图片数量比csv标注少了几百张。原因第一遍解压时磁盘空间不足或中途手动中断unzip默认遇到已存在文件会跳过不报错后续继续解压覆盖时也没有校验文件数。解决解压前用7z l记录zip内部总文件数解压后find统计落地文件数两个数字必须一致。磁盘空间按解压后体积的1.5倍预留解压过程记录日志不要用静默模式。这个坑最隐蔽因为它不影响训练启动只在特定标签上留下“学不动”的后果排查成本极高。6. 提分技巧伪标签、TTA与多折平均的实测思路baseline稳定出分之后往上走的路径有三条按投入产出比排序多折平均、TTA、伪标签。多折平均是把训练集按患者切成5份训5个模型推理时取5份概率的平均值通常能带来0.5到1个点的AUC提升且几乎不引入过拟合风险。TTA是推理时对同一张图做水平翻转两次预测取平均等于白拿一个集成模型代价只是推理时间翻倍。def predict_tta(model, image_tensor, device): 推理时对输入做水平翻转取平均提升预测稳定性 model.eval() with torch.no_grad(): p1 torch.sigmoid(model(image_tensor.to(device))) flipped torch.flip(image_tensor, dims[-1]) # 4D张量[N,C,H,W]最后一维是宽 p2 torch.sigmoid(model(flipped.to(device))) return (p1 p2) / 2.0flip的dims参数要注意image_tensor是四维[N, C, H, W]水平翻转对应最后一维W所以dims[-1]如果你写成dims[-2]就变成垂直翻转了肺部CT上下翻转后的解剖结构完全不对预测结果没有意义。p1和p2取平均能压掉模型对翻转方向的偏好。伪标签的用法是第一版模型训练完对测试集做预测把高置信度样本挑出来——比如某个标签预测概率大于0.9或小于0.1——连同测试集图像一起加入训练集再训第二版。它本质上是利用模型对易分样本的自信来扩充训练数据稀缺病种尤其受益。做法上我一般只加一次不加第二轮因为迭代过深会把第一版模型的错误当成标签固化下去反而伤精度。这三条路径都跑完之后再回头检查预处理细节窗宽窗位是否按病种微调、输入尺寸是否值得从256升到320、标签相关性是否可以引入共病约束。我做医学影像比赛最大的教训是数据层面的功夫永远比换模型值钱解压校验和按患者划分这两步没做扎实后面训练全是空中楼阁。这版方案从zip到提交csv的完整链路希望帮到你。本文还有配套的精品资源点击获取
网站建设高端定制企业官网