考试通知
简介2019年天池全球数据智能大赛“数字人体”赛场一的“肺部CT多病种智能诊断”赛题配套代码包面向医疗影像AI学习者、竞赛参与者与深度学习开发者聚焦CT图像中多种肺部疾病的自动识别与诊断。资源共25个文件以Python源码14个py为核心辅以YOLOv3配置、说明文档、标注图片等压缩包仅147KB其中包含ResNet训练与测试、YOLO检测、图像生成/标注转换、数据预处理等脚本覆盖从数据准备到模型调优的主要流程。已有149人学习下载。这套代码提供了完整的赛题解题框架尤其适合希望快速上手医疗图像分类与目标检测任务的读者参考README与cfg配置可理解模型搭建、训练参数及YOLO框架在CT影像上的应用思路是一份轻量但信息密度较高的实战学习素材。1. 肺部CT多病种诊断赛这套数字人体题目到底在考什么2019年全球数据智能大赛数字人体赛场一的题目是肺部CT多病种智能诊断给定一批肺部CT影像数据让算法判断每位患者的肺部到底存在哪一类病变。它跟普通图像分类最大的区别在于CT记录的是人体组织对X射线的衰减系数整个肺部以几十到几百张切片组成的三维体积呈现再加上多病种之间样本数严重失衡直接套自然图像那套预处理和训练流程很难拿到像样的线上分数。对想进入医疗影像方向的一线工程师来说这场比赛的价值在于把数据读取、窗宽窗位处理、三维建模、类别不均衡评估这一整套链路完完整整走一遍练完再去做肺结节筛查、影像辅助诊断这类真实业务项目会发现流程和方法基本是平移过去的。2. 数据拆包与画像先摸清zip里CT数据的真实形态2.1 解压与目录勘察DICOM、PNG、NIfTI三种格式的判断方法拿到zip之后第一件事不是写模型是先把目录结构摸清楚。我一般先用一条命令解压再用三行命令做统计搞清楚文件总量、格式分布和目录层级后才决定后续用哪一套读取方案。unzip 天池比赛-肺部CT多病种智能诊断-全球数据智能大赛\(2019\)\数字人体\赛场一.zip -d lung_ct find lung_ct -type f | sed s/.*\.// | sort | uniq -c find lung_ct -type f | head -30第一行把压缩包解到lung_ct目录文件名里带了括号和引号 shell 里要用反斜杠转义。第二行统计所有文件的扩展名分布这一步直接告诉你数据是什么形态.dcm是DICOM原始序列、.png是已经切片好的灰度图、.nii.gz是打包好的三维体数据。第三行列出前30个文件路径用来确认目录层级和患者ID的命名规则。这类医学影像竞赛的数据布局基本逃不出三种形态每个患者一个文件夹、里面放一套切片文件所有切片平铺在一个目录、靠文件名前缀区分患者或者每个患者直接给一个NIfTI三维体文件。判断依据就看两点文件名里有没有患者ID字段一个患者对应一个文件还是几十个文件。如果是第三种形态就省事了不需要逐张读取切片SimpleITK一把梭。如果是前两种就要考虑切片顺序和层厚问题。注意这类比赛的数据规模和评估单位通常都是「患者」而不是「单张切片」。从数据拆包这一步开始后续所有DataLoader设计都要以患者ID为最小操作单位而不是以图像文件为最小单位否则后面验证集划分和信息泄漏的坑迟早要踩。2.2 用pydicom重建三维体数据按Z轴排序别被文件名误导DICOM是老牌医学影像格式一个扫描序列通常由几十到几百个.dcm文件组成每个文件是一层横断面。读取的时候最容易翻车的操作是直接按文件名排序因为文件名里的序号只是采集时的流水号和真实的物理扫描位置经常不一致正确做法是按ImagePositionPatient字段排序它是这一层切片在物理空间里的Z坐标。import pydicom import numpy as np from glob import glob def load_dicom_series(path): slices [pydicom.dcmread(f) for f in glob(f{path}/*.dcm)] # 按物理Z坐标排序而不是按文件名排序 slices.sort(keylambda s: float(s.ImagePositionPatient[2])) # 每个切片都可能有独立的Rescale参数必须逐张换算 hu np.stack([ s.pixel_array * float(s.RescaleSlope) float(s.RescaleIntercept) for s in slices ]).astype(np.float32) return hu, slices hu, slices load_dicom_series(lung_ct/data/patient_001) print(hu.shape, hu.min(), hu.max())pixel_array读出来是设备采集的原始整数乘以RescaleSlope再加上RescaleIntercept才能换算成标准的Hounsfield单位。HU值的定义是水的衰减为0、空气为-1000、骨骼几百到上千。这一步不做的话不同设备扫出来的数值范围完全不可比模型学到的多半是设备差异而不是病灶差异。读完之后顺手把shape和HU范围打出来如果发现某位患者的HU最大值异常偏高说明可能有金属伪影或增强扫描这类样本要在后面单独标记防止它们干扰归一化参数的统计。2.3 患者维度统计切片数、层厚与HU分布决定预处理策略在写任何预处理代码之前先把整个数据集的体数据维度统计做一遍。我要统计每一位患者的切片数、体素间距和HU分位数分布这三个指标直接决定后面的采样策略、要不要重采样、以及归一化范围怎么设。import SimpleITK as sitk import pandas as pd stats [] for pid in patient_ids: img sitk.ReadImage(flung_ct/data/{pid}.nii.gz) arr sitk.GetArrayFromImage(img) # 返回shape为 (Z, H, W) spacing img.GetSpacing() # 返回 (dx, dy, dz)单位mm stats.append({ id: pid, slices: arr.shape[0], dz_mm: spacing[2], hu_median: float(np.median(arr)), hu_min: float(arr.min()), hu_max: float(arr.max()), }) df pd.DataFrame(stats) print(df.describe())这段代码输出几个关键信息slices这一列如果出现「有的患者只有十几张、多数患者有三四百张」的极端分布后面切片采样就不能用固定数量必须按比例抽取dz_mm如果既有1mm也有3mm说明层厚不统一三维卷积和2.5D方案就必须先重采样到统一物理间距hu_min和hu_max能帮忙排查有没有扫描范围外的大块背景区域。这步统计花不了二十分钟但能省掉后面好几天的反复试错。3. 预处理与标签工程窗宽窗位、重采样与多病种编码3.1 窗宽窗位把医生眼中的灰度范围翻译给卷积网络CT的HU值范围从-1000一直延伸到三四千直接整体归一化到0-1的话肺部软组织之间那点灰度差异会被压缩得几乎看不见。医生阅片并不是看完整范围而是用「窗技术」只看某个区间这就是窗宽窗位。肺部病灶筛查常用肺窗窗位-500、窗宽1500这一档能看清肺纹理和结节内部结构看纵隔淋巴结和钙化灶则用纵隔窗窗位40、窗宽350。工程上这个操作就是一次clip加线性拉伸。def window(hu, level, width): lo, hi level - width / 2, level width / 2 return (np.clip(hu, lo, hi) - lo) / (hi - lo) lung_v window(hu, -500, 1500) # 肺窗突出肺纹理和病灶细节 med_v window(hu, 40, 350) # 纵隔窗突出淋巴结和钙化我一般把两种窗的结果堆成双通道输入或者只取肺窗单通道。双窗输入在早期基线上能带来两到三个点的提升因为纵隔窗能提供肺窗里看不到的密度信息到了后期模型变强差距会缩小但它作为基线策略非常稳。注意不要贪多叠加三四种窗会把输入通道数翻三倍训练时间和显存开销同步上涨收益并不线性。3.2 重采样与归一化让不同扫描参数的患者数据对齐如果统计阶段发现层厚不统一三维数据在物理空间里就是被压扁或拉长的同一个结节在不同患者图像里的大小失去了可比性。重采样的目标是把所有体数据插值到统一的物理间距比如1mm×1mm×1mm这样模型看到的病灶大小才接近真实尺寸。import SimpleITK as sitk def resample_to_spacing(img, target_spacing(1.0, 1.0, 1.0)): orig_spacing img.GetSpacing() orig_size img.GetSize() # 新尺寸由物理范围除以目标间距推算保证物理范围不变 new_size [ int(round(orig_size[i] * orig_spacing[i] / target_spacing[i])) for i in range(3) ] resampler sitk.ResampleImageFilter() resampler.SetOutputSpacing(target_spacing) resampler.SetSize(new_size) resampler.SetOutputDirection(img.GetDirection()) resampler.SetOutputOrigin(img.GetOrigin()) resampler.SetInterpolator(sitk.sitkLinear) return resampler.Execute(img)参数说明target_spacing的三个值对应x、y、z三个轴的方向顺序不能和size搞反new_size必须用原来的size乘上spacing比例推算直接写死尺寸会让输出体数据的物理范围变化相当于图像被裁剪了插值器用sitkLinear对灰度图像就够了sitkNearestNeighbor只在处理标签掩膜时才用。归一化方面我习惯先把HU值clip到[-1000, 600]再做线性归一化到0-1这样把空气和水都包含进来上界留一点给骨骼但又不让骨骼的极端值主导全局统计量。不要用ImageNet的mean和std来归一化CT图那是基于自然图像三通道统计出来的对单通道CT完全不适用直接归一化到0-1反而更干净。3.3 多病种标签编码先分清多分类与多标签再动手「多病种」三个字不代表一定是多标签问题。很多CT比赛给的是单标签多分类每位患者只有一个主要诊断例如正常、良性结节、恶性结节、肺炎这类互斥类别也有比赛允许一个患者同时患多种病这才叫多标签。判断方式很简单打开标签文件看同一个患者ID是否出现多行或者一个标签字段里是否用分隔符列了多个病种。import pandas as pd from sklearn.preprocessing import MultiLabelBinarizer df pd.read_csv(lung_ct/label.csv) # 假设标签列里多个病种用逗号分隔如良性结节,肺炎 df[labels] df[disease].str.split(,) mlb MultiLabelBinarizer() y mlb.fit_transform(df[labels]) print(y.shape, mlb.classes_)如果确认是多标签后面损失函数要用BCE评估指标用多标签宏平均F1如果是多分类则对应交叉熵损失和单标签F1。这两类问题搞混了轻则损失函数与输出层不匹配导致训练不收敛重则评估脚本报维度错误直接在初赛阶段就被淘汰。我在实际处理中还会加一步校验解析出的类别集合必须和赛题说明里的病种数量完全一致多一个少一个都说明分隔符处理有遗漏宁可停下来修数据也先不训练。4. 模型选型与训练管线从2D切片基线到患者级预测4.1 单通道ResNet改造预训练权重的正确迁移方式CT体数据是三维的但绝大多数参赛队的基线却是2D网络原因非常现实3D卷积显存开销大、收敛慢而这个体量的CT数据集撑不起动辄上亿参数的3D网络。常见做法是沿Z轴均匀抽取N张切片每张切片送进改造过的2D网络做分类推理时把同一患者全部切片的输出概率做平均得到患者级预测。import torch.nn as nn from torchvision import models class CT2DModel(nn.Module): def __init__(self, num_classes4): super().__init__() self.backbone models.resnet18(weightsmodels.ResNet18_Weights.IMAGENET1K_V1) # 把输入卷积改成单通道CT是灰度图 self.backbone.conv1 nn.Conv2d(1, 64, kernel_size7, stride2, padding3, biasFalse) self.fc nn.Linear(1000, num_classes) def forward(self, x): # x 的 shape 为 [B, 1, H, W] return self.fc(self.backbone(x))改输入通道是关键一步ResNet的第一个卷积层原本接收3通道RGBCT是单通道不改的话这层权重和输入对不上。改成1通道后conv1这层会随机初始化但它后面的几十层预训练权重仍然有效迁移学习的收益大部分都保留着这是医学灰度图任务里性价比最高的做法。加载权重时注意用weights参数直接拉取避免手动下载导致环境问题。切片采样上我一般先把患者的切片按Z轴顺序分组每组8张训练时从组内随机抽一张。这样做的好处是同一个患者在不同epoch贡献的切片不完全相同相当于天然的在线数据增强同时缓解了相邻切片高度相关带来的过拟合。分组数太少每轮看到的切片都差不多分组数太多可能抽到完全没有肺组织的边缘空气层需要后续实验里试。4.2 切片采样与2.5D三视图空间信息的取舍2D模型只看了横断面肺结节在冠状面和矢状面上的形态对良恶性判断同样重要。2.5D方案的做法是分别沿轴向、冠状、矢状三个方向切图各训一个2D模型推理时把三个模型的输出融合。相当于用三个正交方向的剖面信息去逼近三维感知显存开销大约三倍但对结节边缘形态这类细粒度特征收益明显。# 用numpy转置从体数据里抽冠状面切片 def get_coronal_slices(vol_hu): # vol_hu的shape是 (Z, H, W)要切出 (H, W, Z) 视角的序列 coronal np.transpose(vol_hu, (1, 0, 2)) return [coronal[i] for i in range(coronal.shape[0])]transpose就是把维度顺序换一下本质是换一个方向做剖面切分。实际使用时不会全量切所有剖面通常围绕肺部中心区域抽固定数量的切片因为病灶大概率在肺实质内部边缘全是空气的切片喂进去只会稀释信号。三个视图的融合权重可以在验证集上搜索比如轴向0.5、冠状0.3、矢状0.2比拍脑袋平均稳定。2.5D适合在2D基线已经调稳之后作为第二阶段的进阶方案一上来就上三视图会拖慢调试周期。4.3 类别不均衡类权重、Focal Loss与评估指标肺部多病种诊断的样本分布通常很悬殊常见病种和罕见病种差几十倍是常态。不处理的话模型会倾向于把所有样本预测成多数类单体Accuracy可能不低但宏观F1惨不忍睹。应对手段按投入从轻到重排列类权重、过采样、Focal Loss。我通常先上类权重一行代码的事大概率能稳住局面。import numpy as np import torch.nn as nn counts np.bincount(y_train) weights 1.0 / (counts 1e-6) # 反比于样本数加eps防除零 weights weights / weights.sum() * len(counts) alpha torch.tensor(weights, dtypetorch.float32).cuda() criterion nn.CrossEntropyLoss(weightalpha)counts是每个类别的样本数权重反比计算让少数类在损失函数里占更大的比例。注意别把权重设得太极端当少数类权重超过多数类十倍时训练loss会震荡模型可能反过来过拟合那几个少数类样本。如果类权重的效果不明显再考虑在DataLoader里做类别过采样让每个batch内多数类和少数类的比例接近1比1左右。评估指标也要跟着换。类别不均衡时单看Accuracy是假象模型全预测多数类也能拿到很高的准确率。我习惯用宏平均F1并且把每个类别的precision、recall拆开看才知道模型到底漏掉了哪类病灶。医疗诊断场景里漏诊的代价远高于误诊所以recall低的类别才是后续优化的重点对象。5. 避坑CT多病种诊断赛最典型的五个翻车现场5.1 采样抽不到病灶层面模型在学正常肺现象训练loss正常下降验证F1也不差但推理结果里几乎所有样本都集中在正常类别附近少数类全被吞掉。原因病灶在CT体数据里只占很小的体积沿Z轴均匀抽切片时随机采样经常抽不到带病灶的层面模型实际学习的分布是「正常肺长什么样」而不是「病灶长什么样」。解决训练时改用病灶加权采样。已知病灶位置标注的话优先抽取病灶中心附近的切片没有标注时把相邻切片打包成小组再从组内抽抽中病灶层的概率会显著提高。另外可以统计每个切片与前后切片的像素差异差异大的层面通常是解剖结构变化明显的区域病灶断面也往往落在这些地方。5.2 验证集按文件切患者信息泄漏导致分数虚高现象本地验证集宏平均F1有0.9提交到线上直接掉到0.7以下差距大到没法用正常波动解释。原因训练集和验证集在文件层面随机切分同一个患者的几十张切片一部分进了训练、一部分进了验证。模型在验证集上看到的其实是训练时见过的同一个病人的切片只是角度略不同特征高度相似验证分数自然虚高。解决按患者ID做GroupShuffleSplit保证一位患者的全部数据只落在训练或验证的一侧。这个坑在医学影像比赛里几乎人人都会踩一次越早发现越省钱正确划分之后再回头看之前的调参记录很多决策都要推翻重来。5.3 标签分隔符不统一病种数对不上现象标签编码之后类别数和赛题说明对不上或者训练时某个类别一直报「只有一个样本导致权重异常」之类的警告。原因标签文件里混用了逗号、顿号、竖线甚至空格来分隔多个病种单独用某一种分隔符做split就会把同一个病种拆成两个不同类别或者把多个病种黏在一起。解决读取标签后先把所有唯一的原始字符串打印出来肉眼扫一遍能看到的全部分隔符变体再用正则统一替换成分隔符最后做一致性校验解析出的类别集合必须和赛题病种数量完全一致。这个校验我一般放在预处理脚本最前面一旦不通过直接抛异常停止训练。5.4 改单通道后预训练权重没加载进去现象训练loss完全不下降或者第一个epoch结束模型输出全是同一个值调小学习率也没用。原因改成单通道后conv1的权重shape从[3,64,7,7]变成[1,64,7,7]和预训练权重对不上。如果加载逻辑是strict模式整个state_dict加载失败所有预训练权重都没进去如果静默跳过不匹配层则只有conv1没加载后面层其实有效但很多人不打印加载日志根本不知道自己处于哪种情况。解决加载时打印state_dict的匹配统计确认除conv1外的其他层确实加载成功。对conv1的处理可以随机初始化或者把预训练权重在RGB三个通道上取平均填进去显然后者更好至少保留了边缘检测等底层特征。5.5 小batch下BatchNorm统计量漂移现象训练loss和验证loss都正常但推理阶段把模型切到eval模式后线上效果反而比验证时差。原因显存不足batch size被迫压到8以下BatchNorm在这么小的统计批量里估算的均值和方差噪声极大很多batch之间统计量来回跳模型的行为在训练和推理时不一致。解决先用混合精度训练把batch size翻倍这是性价比最高的手段还不行就换更小的backbone比如ResNet18代替ResNet34或者减少输入切片分辨率。验证时如果想更贴近线上可以用训练阶段的滑动平均统计量而不是当前batch统计量但根治方案还是让batch size回到一个稳定的量级。6. 进阶调优TTA、异构集成与阈值搜索的最后一把火当模型跑通、分数稳定在中游时我通常还会做三件事按投入产出比排列。第一件是测试期数据增强。推理时对每个患者的切片组做水平翻转把原始图和翻转图的输出概率平均更讲究一点可以对切片抽样的起始偏移做几种不同取法逐个跑推理再平均。TTA不改变模型参数,纯粹是摊平推理阶段的随机性通常能带来0.5到1个点的提升而且没有过拟合风险。第二件是模型集成。集成要拉开差异性才有用同一个backbone换五个随机种子做平均提升很有限。更好的做法是混不同结构一个ResNet系列加一个EfficientNet系列或者一个纯轴向2D模型加上一个2.5D三视图模型。两类模型的错误模式不同融合之后互补性才体现得出来。融合方式用最简单的概率平均就够了在小样本医学场景里上Stacking容易把验证集拟合过头。第三件是阈值调整。多分类任务里argmax并不总是最优解尤其对少数类而言直接取最大概率会牺牲它的召回。我在验证集上对每个类别的判定阈值做网格搜索目标是最大化宏平均F1。这个操作在医疗场景里有个正当说法根据临床需求调整灵敏度与特异度的平衡。只要阈值是在验证集上选出来的而不是在测试集上偷看的就完全合法。我自己的习惯是每个阶段末尾都把这根链条从头到尾过一遍患者级划分、窗宽窗位、类别权重、TTA、异构集成、阈值搜索每调一个环节记录一次验证集F1绝不做没有记录的单点实验。这个习惯让整个调参过程始终有迹可循不会陷入东一榔头西一棒子的试错循环。CT多病种诊断这类任务拼到最后其实是数据工程和实验管理的细节谁把流水线打磨得干净谁就能用同样的模型结构多拿几个点。希望帮到你。本文还有配套的精品资源点击获取
肺部CT多病种智能诊断:从数据拆包到模型训练的完整实践指南
NEXT STEP
看完公告,下一步怎么走?
把报考交给靠谱的人:材料预审、批次抢报、考前辅导、复审提醒,全程有人跟。