从“拿到一批肺部CT影像、想做一个结节自动检测系统”这个念头开始,到真正跑通一个可复现、可评估、可进一步迭代的机器学习基线,中间隔着一条巨大的数据工程鸿沟。网上现成的肺结节检测教程,十有八九直接跳到深度学习模型,把DICOM文件扔给卷积神经网络,训练几个epoch就开始汇报精度。但真实项目里,模型结构从来不是第一个要解决的问题,数据和特征才是。我写这个系列文章,就是想把这套“混合机器学习 + CT图像分析”方案从零到一完整拆开,本篇先谈最重要、也最不性感的部分——基础架构与数据工程。
这个系列适合两类人。一类是有机器学习基础、刚进入医学影像分析方向的研究者或工程师,需要一份可以照着落地的工程参考;另一类是医学影像或生物医学工程背景、想转型做AI辅助诊断的同学,需要先搞清楚这套系统的数据是从哪来的、标注是怎么做的、特征又该怎么提。第一篇不会碰任何高深网络结构,只做一件事:把“原始CT扫描”变成“干净的样本数据集”,并解释每一步背后的医学与工程原因。基础架构搭得稳,后面做模型才不是空中楼阁。
1. 整体架构设计与方案选型:为什么不直接上深度学习
1.1 混合机器学习的精度与可解释性平衡
做肺结节检测时,“混合机器学习”听起来像是技术选型上的保守,但实际是工程风险控制的必然结果。纯端到端深度学习方案在公开数据集上确实能拿到漂亮的ROC曲线,但在真实临床场景中会遇到两个棘手问题:第一,结节类型多样,早期结节在CT影像上往往只有几毫米,特征极其微弱,卷积网络在少量标注数据下很容易过拟合;第二,医生和监管方不完全信任“黑盒子”,他们希望看到“为什么这个区域被判定为阳性”的量化依据。
混合方案的基本逻辑是:利用传统图像处理手段把肺部结构解析出来,提取人工设计的体征特征(密度、形态、纹理、边缘),再用传统机器学习分类器(随机森林、梯度提升树、支持向量机)做初级筛查,最后用轻量级卷积网络作为辅助判定分支。这样一来,每个环节都能独立验证、单独调优,模型出错时也能定位到具体是哪一层特征失效,而不是面对一个端到端网络束手无策。
1.2 系统总体架构设计
我在这个项目里采用的架构大致分五层,每层各司其职,层与层之间用标准格式衔接,方便替换和扩展。
| 层级 | 职责 | 核心组件 |
|---|---|---|
| 数据源层 | 采集和接入不同机构导出的CT扫描 | PACS导出、DICOM文件批量导入 |
| 数据工程层 | 解析DICOM、重采样、窗口化、肺部分割、ROI提取 | pydicom、SimpleITK、nibabel |
| 特征工程层 | 提取结节候选区域的形态/纹理/密度特征,以及深度学习嵌入特征 | radiomics库、CNN嵌入分支 |
| 模型层 | 初级筛查 + 精分类两级流水线 | 随机森林 + 轻量CNN辅助分支 |
| 评估与解释层 | ROC、校准曲线、DICE系数、特征重要性分析 | scikit-learn、SHAP |
这套架构的关键决策在于“两级流水线”:第一级用传统机器学习保证召回率,宁过勿漏;第二级用分类模型提升精确率,降低假阳性。结构性筛查和精分类分离,既缓解了单模型负担,也方便后续加入更多候选框生成算法而不影响分类器。
1.3 算法选型的工程考虑
在传统机器学习分类器里,我对比过逻辑回归、支持向量机和梯度提升树。实测下来,随机森林和梯度提升树在“特征维度中等、样本量几千至几万、存在部分冗余特征”的条件下表现最稳定。逻辑回归虽然可解释性好,但对非线性特征组合的把握不足;支持向量机在样本量增大后训练时间明显上升,并且调参空间太大,不便于自动化调优。最终选用的梯度提升树(XGBoost和LightGBM都试过)胜在以下三点:能自动处理缺失值、训练时间可控、特征重要性输出方便排查。
深度学习部分,我故意选了一个轻量化卷积网络作为特征提取分支,而不是直接替换整个决策链路。这样做的好处是,当计算资源有限或标注数据不足时,可以把CNN分支去掉,系统退化为纯传统机器学习模式,依然能跑通流程;反过来,等数据积累够了,也可以把CNN分支升级为主分类器,传统特征作为辅助。这种渐进式演进思路,是混合架构最大的工程价值。
2. 数据工程的核心任务:从DICOM到干净训练样本
2.1 数据来源整理与合规处理
医学影像数据的获取是整个项目里最耗时、最容易踩坑的环节。我这里使用的是某合作机构脱敏后的胸部低剂量CT筛查数据,一共拿到多少例不在这里展开,但我要强调一个容易被忽略的事实:原始DICOM文件只是“数字胶片”,不等于可直接学习的样本。每例扫描包含200到500张不等的二维切片,加上重建间隔、层厚、窗宽窗位等参数各不相同,必须经过统一预处理之后的二维切片横断面图,才有资格进入模型。
对这种数据工程任务,我第一推荐的做法是先建立一份“数据清单”:每一例扫描对应一个唯一ID,记录机构来源、扫描日期、层厚、像素间距、结节标注是否存在、病理信息(如果可获取)等元信息。这份清单放在项目根目录的metadata目录下,用CSV管理,后续所有脚本都从这个清单出发,不要把文件散落到各处,否则等样本量过万时回溯极其痛苦。
2.2 DICOM解析与像素阵列提取
DICOM的解析在Python生态里首选pydicom,配合SimpleITK做重采样和方向校正。下面是一段我在项目早期写下的读取代码,不算复杂,但几个关键字段必须认真确认。
import pydicom import numpy as np def load_dicom_series(dicom_dir): """ 读取一个DICOM序列,返回三维体素数组和关键元信息字典。 """ slices = [pydicom.dcmread(f) for f in sorted(dicom_dir_files(dicom_dir))] slices.sort(key=lambda x: float(x.ImagePositionPatient[2])) pixel_spacing = slices[0].PixelSpacing slice_thickness = slices[0].SliceThickness # 注意:有些切片层厚为0,务必做缺失值兜底 if slice_thickness is None or slice_thickness == 0: slice_thickness = abs(slices[1].ImagePositionPatient[2] - slices[0].ImagePositionPatient[2]) array = np.stack([s.pixel_array for s in slices], axis=-1) array = apply_rescale(array, slices[0]) return array, pixel_spacing, slice_thickness踩过的坑有好几个。第一个坑是切片的排序,必须按ImagePositionPatient的Z轴坐标,而不是文件名,因为不同设备导出的文件名命名规则千奇百怪。第二个坑是Rescale Slope和Rescale Intercept,这两个DICOM标签把存储值转换为真实CT值(HU单位),如果前面漏掉了,整个影像的密度分布就错了。第三个坑是扫描方向,有些序列是头先进,有些是脚先进,重采样和后续肺部分割前必须统一方向。
2.3 重采样到各向同性体素
原始CT的层厚往往在1mm到5mm之间,像素间距也随设备变化。模型训练时,如果样本的空间分辨率不一致,网络实际上是在“不同尺度”下看同一组织,特征完全没有可比性。所以我统一重采样到1mm × 1mm × 1mm的各向同性体素空间,这样每个结节的形态尺寸才有统一的物理意义。
重采样在SimpleITK里实现很简单,但要注意插值方法的选择。医学影像做重采样时,对CT值做线性或BSpline插值即可,不要用最近邻,否则边缘会变得锯齿状,影响后续形态特征提取。不过,当执行标签图(分割掩膜)重采样时,要在代码里特殊处理。
import SimpleITK as sitk def resample_to_iso(img_sitk, target_spacing=[1.0, 1.0, 1.0], is_label=False): original_spacing = img_sitk.GetSpacing() original_size = img_sitk.GetSize() target_size = [ int(round(orig_sz * orig_spc / target_sp)) for orig_sz, orig_spc, target_sp in zip(original_size, original_spacing, target_spacing) ] interpolator = sitk.sitkNearestNeighbor if is_label else sitk.sitkLinear resampler = sitk.ResampleImageFilter() resampler.SetSize(target_size) resampler.SetOutputSpacing(target_spacing) resampler.SetOutputOrigin(img_sitk.GetOrigin()) resampler.SetOutputDirection(img_sitk.GetDirection()) resampler.SetInterpolator(interpolator) return resampler.Execute(img_sitk)重采样之后,每例扫描的体素矩阵大小会统一到大约512 × 512 × Z(Z取决于扫描范围)。这个环节还有一个隐藏收益:在统一坐标系下计算结节体积、最大径、球形度等形态特征,才有跨样本可比性。
3. CT影像预处理和特征工程的底层逻辑
3.1 HU值:让密度特征不再是玄学
CT影像里每一个像素的数值,本质上对应的是组织对X射线的衰减系数。为了标准化表达,医学上定义了亨氏单位(Hounsfield Unit,HU),将水的CT值定义为0,空气定义为-1000,骨骼大致在+400以上。这个物理单位是整个特征工程的基石。用数学语言表达:
HU = 像素值 × Rescale Slope + Rescale Intercept
下面是不同组织的参考HU范围,开发时经常要用到:
| 组织类型 | HU范围 | 备注 |
|---|---|---|
| 空气 | -1000 | 肺部背景的主要构成 |
| 肺实质 | -900 ~ -500 | 密度随吸气程度变化 |
| 脂肪 | -150 ~ -50 | 软组织降低区域 |
| 软组织/肌肉 | +30 ~ +80 | 常与结节混淆 |
| 钙化 | +100 ~ +400 | 良性结节常见特征 |
| 骨骼 | +400以上 | 胸部肋骨和脊柱 |
这里我踩过的最大一个坑,就是把窗口化处理做在了HU值转换之前。很多入门教程直接对原始像素值做窗宽窗位操作,导致不同设备扫描的图像看起来“风格迥异”,模型在A设备上训练、B设备上测试时性能崩盘。正确流程永远先转HU,再做窗口化,这样所有扫描都回到同一种物理含义的数值空间。
3.2 窗宽窗位设置:影像观察方式也是特征
窗宽(Window Width,WW)决定CT值显示的对比度范围,窗位(Window Level,WL)决定显示的中心位置。放射科医生看肺部常用“肺窗”,窗位约-600、窗宽约1500,用于观察肺实质里细微的结构;看纵隔和软组织用“纵隔窗”,窗位约+40、窗宽约400。
做机器学习训练时,我建议把多个窗口的影像都生成出来,作为三通道或并行特征输入。比如,把肺窗、纵隔窗、骨窗的灰度图叠成一个多通道图像,这样同一区域在不同观察条件下的密度特征被同时保留。实际测试中,这种方式比起只用单一窗口,分类器的AUC大约能提升3到5个百分点。原因也好理解:结节的钙化、空泡、毛刺在不同窗口下呈现的对比度截然不同,模型需要这些信息才能做出稳健判断。
窗口化的代码并不复杂,核心是截断和线性映射:
def window_hu_ct(hu_array, window_width, window_level): lower = window_level - window_width / 2 upper = window_level + window_width / 2 windowed = np.clip(hu_array, lower, upper) normalized = (windowed - lower) / (upper - lower) return (normalized * 255).astype(np.uint8)3.3 特征设计:给传统机器学习喂什么
混合机器学习方案里,传统机器学习部分依赖人工设计的特征。特征不是拍脑袋选出来的,而是基于结节在CT影像上的定性描述转化而来。医生诊断肺结节时主要看:大小、形态(圆形还是分叶状)、边缘(光滑还是毛刺)、密度(实性、部分实性、磨玻璃)、钙化模式、空泡征、胸膜凹陷等。每一项都能转化为可计算的量化特征。
我这边整理了一组最常用、效果最稳定的特征分组:
形态特征:结节体积、最大三维径、球形度(球体体积与实际体积之比)、表面积体积比、紧凑度。这类特征用SimpleITK的LabelShapeStatisticsImageFilter可以直接计算。
密度特征:CT值均值、方差、偏度、峰度、最低/最高HU值、实性成分占比(HU > -300的体素比例)。部分实性结节的实性成分占比是良恶性判断的重要参考。
纹理特征:灰度共生矩阵(GLCM)能量、对比度、相关性、逆差矩;灰度游程矩阵(GLRLM)长游程强调等。这类特征对磨玻璃结节的细微纹理非常敏感,计算时建议在2D切片上分方向计算后取均值。
边缘特征:径向梯度均值、边缘过渡宽度、毛刺指数。毛刺在恶性结节中非常常见,但量化起来麻烦,我采用的是结节的边缘法向量方向的梯度变化率近似模拟。
这些特征的提取全部用Python实现,形态和密度特征基于SimpleITK,纹理特征借用pyradiomics库。特征工程这层做好了,后面训练分类器只是水到渠成。
4. 标注策略与数据集划分:医学数据工程最敏感的环节
4.1 标注数据的获取与验证
没有可靠的标注,再好的特征工程也只是把噪声拟合得更加精致。这个项目的标注来自两个渠道:一部分由合作单位提供的已有报告和标注文件,另一部分是请两位影像科医生在统一标注规范下对疑点区域重新勾画。整个过程做的事情本质上就是“gold standard”的建立,必须谨慎。
标注规范需要写清楚很多细节:结节最大径小于3mm的微小结节是否标注?纯磨玻璃结节边界不清晰时如何处理?部分实性结节中实性成分和磨玻璃成分怎么分别勾勒?如果不是来自真实临床协议,而是自己拍脑袋定的规则,后面跨医生标注一致性一定出问题。
借此机会提醒一句:做医学影像项目时,第一优先级永远不是模型精度,而是标注质量验证。就算两位医生分别标注,也要算一下DICE系数或体积重叠率,低于某个阈值的地方必须回炉讨论。我这里医生的标注DICE系数平均在0.82左右,属于比较理想的情况,但不同结节类型差异很大,纯磨玻璃结节的DICE甚至能掉到0.65以下。
4.2 结节分类正负样本策略
肺结节检测本质上是一个目标检测加分类的问题,但绝大多数CT体素都是正常组织,直接拿所有体素做训练样本,正负比可能达到1:10000以上,任何分类器都会崩溃。正确做法是从CT中提取“候选区域”,再对候选区域做分类。
候选区域提取我采用两阶段策略。第一阶段用传统图像处理中的球状结构增强滤波器(基于Hessian矩阵的特征值分析)筛出疑似结节区域;第二阶段把所有候选区域裁剪成固定尺寸的3D patch,配上医生标注的标签。这样正样本来自结节标注框中心,负样本来自增强响应高但标注为阴性的区域,类别比例控制在1:3到1:5之间,模型既见过足够多的负样本,又不会被极端不平衡带偏。
4.3 数据集划分:宁可牺牲数量也不要数据泄露
医学影像数据集划分有一个和自然图像完全不同的铁律:按患者划分,而不是按图像切片划分。原因是同一个患者的几百张切片之间高度相关,如果一部分切片进训练集、另一部分进测试集,模型其实已经“见过”了同一个患者的信息,测试精度会虚高到毫无参考价值。
我在项目里严格按照患者ID分组,先随机把患者按7:2:1的比例分成训练、验证、测试三份,再在每个患者内部生成切片和patch。测试集从生成到最终评估前都不能被开发人员碰,这一点和机器学习竞赛的原则一致。踩过的教训是某次为了提升验证集指标,无意中把同一患者的两张切片分到了不同集合里,验证AUC直接虚高了0.08,发现后重新划分布局全部重跑,浪费了整整两天。从那以后,数据划分脚本里强制加入“患者独立”校验,任何cross_val_score都不允许跨患者切分。
5. 数据版本管理与存储设计
5.1 数据清单的工程化落地
项目开始阶段,我随手把DICOM文件放在网盘目录里,文件名也是原始设备生成的乱码,结果一个月后自己也分不清哪个文件对应哪个病例。后来下定决心,把全部数据工程环节纳入严格版本管理:每个患者的原始DICOM、预处理后的npy数组、标注掩膜、特征表都放到统一目录结构下。
目录结构示意如下:
data/ raw_dicom/ patient_0001/ series_001/ patient_0002/ series_001/ preprocessed/ patient_0001.npy masks/ patient_0001_label.npy metadata/ patient_info.csv scan_parameters.csv annotations.csv features/ patient_features.csv每份文件在清单里记录md5哈希值、来源路径、生成时间、预处理参数。这样可以保证任何人、任何时间在任意机器上都能复现同一份数据,不然你根本说不清楚当前模型用的“第几版数据”。
5.2 版本化清单与指标绑定
数据一旦发生变化(比如修了标注边界、增加了新病例),对应训练出来的模型指标就全部作废。我的做法是把每次数据更新都打上版本号,例如v2.3_data,然后在每次实验时记录完整的“数据版本 + 代码版本 + 参数配置”三元组。实验管理系统里,每次运行日志自动保存这些信息。过了一个月再回头复现某次精度,直接翻日志就能锁定当时的文件清单。
这个习惯在合作多人开发的场景下尤其重要。某位同学在预处理脚本里加了一个降噪滤波,没有同步更新版本号,导致训练跑出来的结果和旧数据对不上,排查了将近一周才定位到问题。从那以后,数据管线里任何处理函数的修改都强制升级版本号,不得静默变更。
6. 常见问题与排查技巧
6.1 高频问题速查表
下面是这个项目在基础架构和数据工程阶段真实遇到过的几个高频问题,我整理成速查表方便后来者对照。
| 现象 | 可能原因 | 排查方案 |
|---|---|---|
| 读取后图像是全黑的 | 漏了Rescale Intercept,存储值未转为HU | 检查DICOM头标签,转HU后再窗口化 |
| 3D体积重建后肺部上下颠倒 | 切片排序按文件名而非Z轴位置 | 用ImagePositionPatient排序,统一方向 |
| 重采样后模型指标大幅下降 | 重采样插值方法错误或方向未统一 | 标签图强制最近邻,体数据用线性插值 |
| 验证集AUC虚高 | 同一患者切片跨集合划分 | 按患者ID划分,并写脚本强制校验 |
| 特征表中出现大量NaN | 某个特征在部分patch上无法计算 | 用常数或全局均值填充,并记录缺失标记 |
| 训练集和测试集分布差异大 | 不同采集设备或重建参数混用 | 统计HU直方图和体素间距,分设备做域分析 |
6.2 三个独家的坑点经验
第一个经验是,肺部分割比想象的难得多。网上常用固定HU阈值(比如-1024到-512)就提取肺实质,但遇到肺不张、胸水或严重粘连时,简单阈值会留下大块伪影区域。后来我加入了连通域分析和基于形态学的支气管/血管去除步骤,配合医学先验约束,分割质量才算稳定。
第二个经验是,不要忽略CT床板和扫描野外的噪声。重建出的体素矩阵往往包含患者身体以外的背景区域,这些区域的HU接近-1000,如果直接整图做直方图统计特征,会严重干扰结节区域的密度分析。统一裁剪到包含肺部的目标区域再做特征提取,是个必要的工程步骤。
第三个经验是,标注文件必须做体素空间对齐。不同医生可能在不同重建层厚的影像上标注,直接叠加后结节掩膜会出现错位。所有标注都要重采样到和训练体数据完全相同的坐标系里,不然最后算DICE和AUC的时候,误差都被掩膜错位稀释了,模型真实的精度反而看不清楚。
7. 接下来可以怎么做
这篇只覆盖了基础架构和数据工程。整个系列后面还有几块内容可以继续展开:候选区域生成的完整实现、特征工程的详细计算代码、分类流水线的调参与评估、轻量CNN与特征融合的方案。数据工程这部分完成后,模型部分的迭代速度才真正提得起来,后面遇到“算法效果不如意”的问题,就能回头追溯到是特征的问题、标注的问题,还是分类器本身的问题。我个人在项目里的体会是,数据工程阶段多花的时间,会在模型调优阶段数倍返还。先把地基夯实,再谈高楼。