
简介这是一份来自2019年天池“数字人体”赛场一的肺部CT多病种智能诊断比赛代码资源面向医学影像AI学习者、算法工程师及参赛开发者适合复现比赛方案、研究CT图像分类与病灶检测的典型技术路线。压缩包共25个文件以14个Python脚本为主体涵盖数据预处理、图像与标签提取、模型构建、训练与测试等环节另配有YOLOv3配置文件、项目说明文档、示例图片及6个pyc编译文件整体仅147KB轻量精炼便于逐行研读。目前已有149人学习浏览。资源内容覆盖ResNet分类、YOLO检测、k-means锚框聚类等核心模块可帮助读者快速搭建从数据处理到模型评估的完整流程尤其适合希望借鉴竞赛级代码组织方式、深入实践深度学习在医学影像中应用的中高级开发者。1. 天池肺部CT多病种智能诊断难的不是分类器是让数据先“说人话”2019年天池全球数据智能大赛“数字人体”赛场一拿到手的是一个以“肺部CT多病种智能诊断”为核心任务的影像数据包。很多人第一反应是拉一个预训练CNN直接上但真正动手后会发现这个比赛最耗时间的不是调模型而是把zip包里的CT序列、标注文件和患者维度对齐。低剂量CT图像本身噪声大、病灶尺寸小、标签多且共现单标签分类思路在这里天然吃亏。这个方向适合两类人想在医疗影像上验证自己方案的算法工程师以及想啃多标签、长尾、序列数据处理的竞赛玩家。下面不聊冠军方案只讲一个普通队伍从解压到推理怎么走通。2. 拿到“数字人体”赛场一的压缩包后先理清数据再谈模型常见的数据包一般压缩了训练集、测试集和一个标注文件但里面可能是按患者ID分文件夹每个文件夹放一组CT二维切片。直接用深度学习框架读数据很容易踩到文件名编码、标签粒度不一致、同一患者切片被随机切分等暗坑。我一般会先花半天时间把数据目录、标签分布和分组关系摸清楚再碰模型。2.1 解压与中文文件名乱码先用脚本看穿zip内部结构这类比赛zip大多在Windows下打包文件名常是GBK编码。Python的zipfile默认按CP437把文件名读成字符串直接extractall后中文名会变成“å¼”一类乱码最稳妥的方式是先遍历内部成员把文件名修正后再解压。import zipfile from pathlib import Path zip_path Path(天池比赛-肺部CT多病种智能诊断-全球数据智能大赛(2019)“数字人体”赛场一.zip) out_dir Path(ct_data) def fix_zip_name(raw: str) - str: # zipfile 已按 cp437 读入还原字节再用 gbk 解能救回大部分 Windows 打包文件名 try: return raw.encode(cp437).decode(gbk) except (UnicodeEncodeError, UnicodeDecodeError): return raw with zipfile.ZipFile(zip_path) as z: for info in z.infolist()[:30]: print(repr(info.filename), -, fix_zip_name(info.filename))这段代码先不急着解压只打印前30个内部成员名观察根目录结构和乱码情况。fix_zip_name的核心在于zipfile已经把字节解码成了字符串再用encode(cp437)找回原始字节然后按GBK解码。如果文件名本身是纯英文或UTF-8这个转换通常不会有破坏性。确认没有异常后再提取整个包with zipfile.ZipFile(zip_path) as z: for info in z.infolist(): src info.filename dst fix_zip_name(src) z.extract(src, out_dir) if src ! dst: (out_dir / src).rename(out_dir / dst)注意先用zipfile按原始乱码名extract出来再通过rename改成可读路径。如果压缩包里是多级目录rename时需要先确保父目录存在。比赛包往往很大提取时间较长建议解压时顺手在日志里统计文件总数。提示如果解压软件本身能正常显示中文但Python读出乱码不必怀疑数据坏了只是编码不一致。2.2 多标签统计与共现矩阵别被类别数量骗了肺部CT多病种诊断的标签往往一个样本对应多个类别常见的标注文件是CSV每行一个病例或一个切片。直接value_counts()只能看单标签分布看不到“哪些病经常一起出现”而这对后续的损失函数设计和后处理阈值很有帮助。import pandas as pd from collections import Counter from itertools import combinations label_csv next(Path(ct_data).rglob(*label*.csv)) df pd.read_csv(label_csv) print(df.shape, df.columns.tolist()) id_col [c for c in df.columns if id in c.lower() or case in c.lower()] label_col [c for c in df.columns if label in c.lower()][0] print(ID列候选:, id_col, 标签列:, label_col)多标签字段的分隔符并不统一常见的有点号、顿号、竖线、空格。先用一个兼容函数拆开def split_labels(x): if not isinstance(x, str): return [] for sep in [|, 、, ;, ,, ]: if sep in x: return [s.strip() for s in x.split(sep) if s.strip()] return [x.strip()] df[label_list] df[label_col].apply(split_labels) label_counter Counter(v for lst in df.label_list for v in lst) print(类别数:, len(label_counter)) print(label_counter.most_common(10))这一步的价值在于你会看到训练集里有些类别只出现几十次有些类别动不动就是几千次。如果不做任何处理模型会把多数类学得很好少数类几乎没召回。更值得关注的是共现情况pair_counter Counter() for lst in df.label_list: for pair in combinations(sorted(set(lst)), 2): pair_counter[pair] 1 print(pair_counter.most_common(10))如果某两个标签几乎永远同时出现可以考虑把它们合并成一个人工标签减少输出维度如果有些标签互斥比如“正常”与任何病变同时出现那说明标注逻辑里存在脏数据需要进一步清洗。2.3 按患者维度做GroupKFold第一个影响线上分数的细节如果标注文件是切片级但每个病例对应多个切片随机切分会导致同一个患者的切片同时出现在训练集和验证集。模型会直接记忆患者噪声而不是学习病灶特征线上分数和本地分数就会严重脱节。正确做法是始终按case_id分组切分。from sklearn.model_selection import GroupKFold # 如果没有现成的case_id可以从文件路径中提取 df[case_id] df[image_path].apply(lambda p: p.split(/)[0]) df[fold] -1 gkf GroupKFold(n_splits5) for fold, (_, val_idx) in enumerate(gkf.split(df, groupsdf[case_id])): df.loc[val_idx, fold] fold print(df.groupby(fold)[case_id].nunique())GroupKFold保证case_id不交叉但每个fold里类别比例可能与全集不一致。如果类别极不平衡建议在GroupKFold之后再统计每个fold的类别频次必要时做分层修正。天池这类比赛线上评测通常隐藏了患者信息所以本地验证策略越接近线上后期越省心。3. 从CT序列到可训练样本预处理管线与关键参数模型结构再强输入图像不对也白搭。肺部CT多病种诊断的预处理第一个问题是数据里到底是CT原始值还是已经转好的8位PNG。这决定了后面能不能做窗宽窗位调整也决定了预训练模型该用什么输入口径。3.1 先确认数据格式DICOM转HU还是直接PNG训练如果压缩包里是DICOM文件你需要把它转成Hounsfield Unit数值才能正确应用窗宽窗位。常见做法是用pydicom读取每个病人序列再按切片顺序组织成三维数组。import pydicom import numpy as np def dcm_to_hu(path): ds pydicom.dcmread(path, forceTrue) arr ds.pixel_array.astype(np.float32) slope float(getattr(ds, RescaleSlope, 1)) intercept float(getattr(ds, RescaleIntercept, 0)) # 低剂量CT图像也要先恢复到真实的CT值范围再处理 return arr * slope interceptRescaleSlope和RescaleIntercept是DICOM里把像素存储值映射到HU的两个关键参数缺失时按1和0处理。为什么这么重视因为很多CT序列是16位存储直接当成8位图喂给模型会丢失大量灰度细节特别是肺小结节这种本来就几个像素的目标。如果主办方已经把所有DICOM转成了PNG或JPG你就要接受一个现实HU精度已经被映射过一次之后再做窗宽窗位只是像素级对比度缩放已经不能还原原始CT值。这种情况下常规做法是放弃“伪HU处理”直接对像素值做归一化。判断方法很简单查看图像的位深和像素值范围。如果一张“CT图”最大像素值只有255那基本就是8位图别再纠结窗宽窗位。3.2 窗宽窗位参数肺窗为主必要时多个窗并联对于真正的HU数据窗宽窗位是预处理里最不能拍脑袋的参数。肺窗常用窗宽1500、窗位-600纵隔窗常用窗宽400、窗位40。多病种任务里不同病灶的对比度范围差异很大单窗容易漏掉信息。def apply_window(hu, ww1500, wl-600): low wl - ww / 2.0 high wl ww / 2.0 clipped np.clip(hu, low, high) # 将窗内数值映射到 [0, 255] return ((clipped - low) / (high - low) * 255.0).astype(np.uint8)参数说明ww1500, wl-600适合看肺实质和肺结节ww400, wl40适合看纵隔淋巴结和胸腔积液。如果你想给模型多一点线索可以生成两个或三个窗位图然后在通道维拼接lung_window apply_window(hu, 1500, -600) mediastinum_window apply_window(hu, 400, 40) bone_window apply_window(hu, 1000, 300) # 堆叠成三通道注意每个通道的语义不同 multi_window np.stack([lung_window, mediastinum_window, bone_window], axis-1)多窗输入可以明显提升少数类别的召回但训练显存会涨。第一个版本先用单肺窗跑通之后再把多窗作为加分包。3.3 重采样与肺部裁剪消除设备差异但不破坏空间先验CT设备的层厚和像素间距不一致常见做法是重采样到各向同性。对三维序列来说可以沿z轴、x轴、y轴分别缩放。from scipy.ndimage import zoom def resample_volume(volume, old_spacing, new_spacing(1.0, 1.0, 1.0)): factors [old / new for old, new in zip(old_spacing, new_spacing)] # 三阶样条对医学影像算是一种折中 return zoom(volume, factors, order1)这里old_spacing顺序一定要和数组轴对应比如数组shape是(z, h, w)那么spacing也要是(z_spacing, h_spacing, w_spacing)。如果顺序错了重采样出来的解剖结构会变形。重采样之后可以做一次简单的前景裁剪。肺部CT背景黑且几乎无信息用阈值找到前景区域再裁剪能节省显存也能让模型更聚焦。def crop_foreground(hu): mask hu -300 if mask.sum() 100: return hu z, y, x np.where(mask) z0, z1 z.min(), z.max() 1 y0, y1 y.min(), y.max() 1 x0, x1 x.min(), x.max() 1 return hu[z0:z1, y0:y1, x0:x1]裁剪边界不要太多我一般会保留整个肺野区域甚至稍微外扩几个像素。如果把肺边界切掉某些靠近胸膜的病灶会直接被丢。3.4 低剂量CT下的数据增强克制比花哨重要低剂量CT图像噪声高增强幅度过大会把模型推向“噪声拟合”。常用增强是轻微随机旋转、小幅度平移、水平翻转和亮度对比度扰动。from torchvision import transforms ct_aug transforms.Compose([ transforms.RandomAffine(degrees3, translate(0.03, 0.03), scale(0.98, 1.02)), transforms.ColorJitter(brightness0.15, contrast0.15), transforms.RandomHorizontalFlip(p0.5), ])强调一下不要用大幅旋转比如30度、90度会破坏肺叶的解剖方位先验不要用随机擦除擦掉的小区域可能正是一个肺结节高斯噪声加在低剂量CT上等于刻意放大采集噪声收益很小。增强的目的是模拟采集差异不是制造新样本。4. 模型选型与训练多病种分类为什么不直接套用单标签CNN肺部CT一个病例可能同时存在肺结节、肺炎、肺气肿等多种病变类别之间不是互斥的。单标签CNN用softmax加交叉熵模型只能选一个概率最大的类别这在多病种任务里是结构性的错误选择。常见方案是把最后一层改成多输出logits每个类别单独用sigmoid损失统一用BCE。4.1 基线模型2D切片编码加序列平均池化一个容易跑通的方案是每个CT序列包含多层切片先对每层切片用2D CNN提特征再对所有切片特征做平均。这样模型能看见整个序列的空间分布又不会像3D CNN那样把显存吃光。import torch import torch.nn as nn from torchvision.models import resnet34 class CaseCTModel(nn.Module): def __init__(self, num_classes17): super().__init__() base resnet34(pretrainedTrue) # 去掉原分类头只留卷积特征 self.features nn.Sequential(*list(base.children())[:-1]) self.fc nn.Linear(512, num_classes) def forward(self, x): # x: [B, S, 3, H, W]S为每个case采样到的切片数 B, S, C, H, W x.shape x x.view(B * S, C, H, W) feat self.features(x) # [B*S, 512, 1, 1] feat feat.view(B, S, -1).mean(dim1) return self.fc(feat)逻辑说明features输出每个切片的512维特征mean(dim1)把所有切片平均成病例级特征。用平均池化稳定用最大池化对局部病灶更敏感更适合检测小病灶。如果显存允许可以同时拼接平均池化和最大池化结果再送进分类头。参数建议初始S24~32个切片H224, W224B4。这样每个batch实际跑了96张2D图ResNet34在16G显存的卡上能勉强训练。如果显存不够可以把S降到16而不是把图片分辨率降到128否则对小结节太不友好。预训练权重在CT灰度图上不一定完美但还是有帮助。如果输入是单通道灰度图可以在Dataset里复制成三通道也可以自定义第一个卷积层把预训练权重求平均放到单通道上不过后者落地要改网络结构不划算。4.2 多标签损失与类别平衡BCE加pos_weight而不是改采样多标签任务常用BCEWithLogitsLoss它内部会把logits转成概率再和0/1标签算交叉熵。类别不平衡时直接给loss加权重比删样本更稳。criterion nn.BCEWithLogitsLoss(pos_weightpos_weight) # 每个类别的pos_weight 负样本数 / 正样本数 pos_weight torch.tensor( [neg_count[i] / max(pos_count[i], 1) for i in range(num_classes)], dtypetorch.float32, )pos_weight的含义是当某个类别正样本很少时提高正样本loss的权重强迫模型多关注这些少数类。但别把权重拉太高比如正负比1:100就给100模型会普遍预测为正最后阈值怎么调都拉不回precision。我一般会把权重压缩到min(pos_weight, 5)给少数类一定照顾但不至于让多数类崩掉。训练时还要注意每个case的切片数量不同DataLoader里的case不一定等长。常见做法是在采样时固定每个case随机选取S层切片保证batch shape固定。4.3 阈值搜索模型输出的是logits不是最终诊断验证时不要直接以0.5为阈值。多标签类别不平衡严重0.5对很多类别来说太高。正确做法是在验证集上对每个类别做一次阈值搜索。import numpy as np from sklearn.metrics import f1_score best_thresholds {} for cls in range(num_classes): best_t, best_score 0.5, 0.0 for t in np.arange(0.10, 0.90, 0.05): pred y_score[:, cls] t f1 f1_score(y_true[:, cls], pred) if f1 best_score: best_score, best_t f1, t best_thresholds[cls] best_t print(best_thresholds)如果官方指标不是F1而是AUC阈值搜索可以用约登指数或实际线上反馈调整。无论如何验证集的指标要先和线上指标口径对齐否则阈值搜索会变成自嗨。4.4 伪标签用测试集提升少数类鲁棒性但要设置信度门槛赛事测试集通常没有标签但可以利用模型对测试集的预测把高置信度样本当作训练数据补充少数类。常见做法是先训一个基线模型对测试集预测保留概率超过0.9的样本加入训练数据重新训练。proba model.predict_proba(test_loader) high_conf (proba.max(axis1) 0.9).reshape(-1) pseudo_test test_df[high_conf] pseudo_test[label_list] proba[high_conf] 0.5伪标签的风险在于模型会把错误高置信度预测当成真理。我一般规定伪标签数量不超过训练集的30%且只有基线在验证集上表现足够好时才会用。如果加了伪标签后验证集分数没有提升果断放弃别在这个方向上恋战。5. 避坑指南从zip解压到推理阶段的高频翻车点这个比赛翻车点很密集且大多不是模型本身的问题。下面几条是我见过的高频踩坑每条都按现象、原因、解决三步说清楚。5.1 解压后文件名乱码或者解压工具弹出密码框现象zip包解压后目录全是乱码部分解压软件还会提示输入密码但网上并没有给密码。原因天池比赛这类zip多由Windows打包文件名是按GBK存储的Python zipfile按CP437解码就会乱码。所谓“伪加密”是zip文件头里的加密位被误置位数据本身并没有加密导致部分工具误判。解决用7-Zip打开如果7-Zip能正常显示和解压直接用7-Zip解压。如果必须在Python里解压用前面写的fix_zip_name脚本。对伪加密换用7-Zip通常能绕过去不需要去破解密码。5.2 GroupKFold没按患者分组导致模型“背答案”现象本地验证AUC到0.99线上分数却只有别人的一半。原因同一个患者的多个切片被随机分到了训练集和验证集。模型在训练时见过同一个人的其他切片验证时自然表现好到了线上面对没见过的患者就崩了。解决创建case_id然后严格用GroupKFold切分。如果数据只给了切片文件名就从文件名前缀或父目录名提取患者ID。这一步应该在读取CSV时就做而不是临时在模型训练前补。5.3 对8位PNG反复做窗宽窗位调整现象预处理脚本里apply_window执行完图像看起来没什么变化模型也没有提升。原因数据已经是8位PNGHounsfield Unit精度早就丢完了。窗宽窗位只能改变前端的映射范围但无法恢复信息。解决先看图像位深如果只有8位就不要再做窗宽调整。此时可以把归一化改成简单的img / 255.0或者按数据集的灰度分布做标准化。只有拿到DICOM原始值窗宽窗位才有意义。5.4 低剂量CT图像增强过度验证损失不降反增现象加了旋转、裁剪、高斯噪声等增强后训练loss下降更慢验证指标反而变差。原因低剂量CT本身噪声高大幅增强等于在噪声上叠加噪声模型学习到的空间结构被破坏尤其是肺结节这种小目标。解决增强幅度控制在医用范围内旋转不超过5度平移不超过3%缩放不超过2%水平翻转看任务是否需要左右对称。测试时用TTA也不要加旋转一般只用水平翻转和极小幅缩放。5.5 只看AUC不调阈值提交成绩一直不上不落现象验证AUC很不错但线上提交结果几乎全是False Positive分数很低。原因AUC是基于排序的指标不依赖最终阈值。线上提交的是0/1标签而不是概率值0.5这个默认阈值在类别不平衡情况下通常太保守。解决把验证集预测结果存成概率矩阵对每个类别做阈值搜索选择使F1或官方指标最大的阈值。多标签任务里每个类别都有自己的最优阈值不能用同一个0.5。5.6 显存不够就降低分辨率结果什么都看不见现象一开始用256x256跑不动降到128x128后模型完全学不到病灶。原因肺部结节本身只有几毫米160x120的CT图像上病灶可能只占几个像素下采样到128后直接消失人工标注都看不清模型自然也学不到。解决保分辨率降切片数。先保证每张图有足够空间分辨率再把每个case的切片从32减到16或8。如果还显存不够用半精度训练或者切GPU做梯度累积。6. 把模型压到线上推理的时间预算内混合精度与OOF阈值到这一步模型已经能正常训练但离可靠提交还差两件事用交叉验证产出稳定阈值以及让推理速度撑住线上时间限制。6.1 五折OOF预测用全部袋外数据调阈值不靠单折玄学单折验证的阈值经常在换一折后完全失效。更稳的是一开始就用5折训练每个fold存下验证集的预测概率最后把所有fold的预测拼成一个OOF矩阵再基于OOF矩阵做阈值搜索。oof_preds np.zeros((len(df), num_classes)) oof_labels np.zeros((len(df), num_classes)) for fold in range(5): trn_idx df[df.fold ! fold].index val_idx df[df.fold fold].index model.fit(train_loader_fold[fold], valid_loader_fold[fold]) oof_preds[val_idx] predict_probability(model, valid_loader_fold[fold]) oof_labels[val_idx] labels[val_idx]OOF的好处是每一条样本都来自没有参与该样本训练的模型分布接近线上评价比单折调阈值靠谱得多。调阈值时别太贪心找一个对多个类别都稳健的区间。6.2 半精度推理配TTACT图像上的低成本加速比赛线上推理通常有时间限制。PyTorch模型转成FP16后在支持半精度计算的GPU上能获得明显加速内存占用也会下降。model.half().eval() with torch.no_grad(): for batch in test_loader: batch_h batch.half().cuda() logits model(batch_h)注意model.half()会把所有参数转成FP16BatchNorm层最好保持FP32否则容易出NaN。更安全的方式是用AMP的autocast来推理。TTA方面我只推荐水平翻转和0.95到1.05之间的尺度缩放不要做旋转TTA。CT图像方向性强旋转TTA容易把解剖结构弄乱。代码如下with torch.no_grad(): logits torch.zeros(B, num_classes, devicedevice) for flip in [False, True]: x batch if flip: x torch.flip(x, dims[-1]) logits model(x.half().cuda()) logits / 2推理阶段的多窗口输入也可以作为TTA的一种同一层切片分别用肺窗和纵隔窗推理把结果平均。我一般会在验证集上测一下TTA收益如果提升不到0.1个点就放弃毕竟推理时间会翻倍。做完混合精度和TTA后最后再做的事是重新跑一次OOF验证确认最终提交的阈值没有被TTA改变。把预测概率文件、阈值文件和模型权重放一起存档避免在提交时出现“模型对不上阈值”这种低级事故。那次比赛结束前我因为换了TTA版本忘了同步阈值白白丢了一个名次。后来养成的习惯是每次改推理代码都同步重算阈值并顺手把thr和engine文件打成同一个zip命名。希望帮到你。本文还有配套的精品资源点击获取