想象一下,你是一名“淘金老手”,被请到一座名叫“肺野山”的大矿场。矿场里堆着八百多块灰扑扑的岩芯——它们是体检人群的胸部CT扫描数据。你的任务只有一个:在每块岩芯里找出那些藏在石头缝中、直径只有几毫米的“金豆”——肺结节。它们可能是早期肺癌的信号,找得越早,越能救人一命。 问题是,岩芯里有太多“长得像金豆”的废铁:血管的横截面、支气管的切片、肺裂的阴影,稍不留神就会让你空欢喜一场。而LUNA16,就是2016年一场全世界淘金高手同台竞技的“挖矿锦标赛”——它给出统一的矿场(888例CT)、统一的金豆标准(标注规范)和统一的计分板(评价指标),让各路算法一决高下。 这篇教程,我会带你完整复盘这场锦标赛:从洗矿(数据预处理)、金属探测器粗扫(候选检测)、老师傅挑金(假阳抑制)、看懂计分板(评价指标),到顶尖高手的套路(冠军方案)和把流程搬进医院流水线(工程落地)。学完你会发现,医学AI检测并不神秘,它本质上是一门“先广撒网、再细甄别”的找矿手艺。

岗位关联

对AI从业者来说,LUNA16是医学影像检测的“教科书级”任务:吃透它的数据、指标与两阶段方案,你就能把同一套方法论迁移到任何“小目标、大背景、高噪声”的检测场景,这是简历和面试中最硬的工程底气。

目录
  1. 可视化演示
  2. 任务与数据:先读懂比赛规则
  3. 数据预处理:先把矿石磨光滑
  4. 候选检测:金属探测器粗扫全场
  5. 假阳抑制:老师傅逐个验货
  6. 评价指标:比赛计分板怎么读
  7. 冠军方案:高手们的共同套路
  8. 工程落地:从比赛现场到量产工厂
  9. AI 应用场景
  10. Python 演示
  11. 常见误区
  12. 面试题

可视化演示

展示LUNA16肺结节检测从CT输入到3D CNN候选生成、假阳抑制的完整流水线,并对比不同方案的FROC曲线性能。

本节配有交互动效(进入视口后自动加载,约 3KB)。

LUNA16肺结节检测流水线 3D CNN:对CT体素提取3D patch进行候选结节检测 假阳抑制:分类器过滤非结节候选,降低FP/scan 最终输出:保留高置信度结节,灵敏度达95%+ CPM = avg(sens@{0.25,0.5,1,2,4,8} FP/scan) LUNA16采用FROC评价替代传统ROC。

任务与数据:先读懂比赛规则

淘金比赛开赛前,每个勘探员都会先翻一遍规则书:矿场边界在哪?什么样的“金豆”才算合格?裁判怎么判定你找到了?LUNA16(LUng Nodule Analysis 2016)就是医学影像界一份著名的“淘金规则书”。它从LIDC/IDRI公开数据库中整理出888例胸部CT扫描作为“矿岩”,要求参赛算法找出所有直径不小于3毫米的肺结节。

技术上,每例CT是一个由上百张512×512断层切片堆叠而成的三维体数据,体素值反映人体组织密度。为了消除主观偏差,规则书请四位资深放射科医生独立标注,只有至少3位医生都认可的结节才算“真金豆”(阳性样本),最终共标出约1186个有效结节。裁判判定“找到”的规则很宽松:算法输出的候选点中心,落在标注球心的一定范围内即可,不必像素级重合:

为什么这规则让比赛“难而公平”?因为CT里肺野区域有几百毫米见方,而目标金豆只有3~30毫米,正负体素比例往往超过1:1000——就像在十个足球场里找几粒黄豆。这就是LUNA16留给所有参赛者的核心挑战:既要把藏在血管、支气管阴影里的结节全找出来,又不能被漫山遍野的“假金”骗过。读懂了规则书,接下来就该准备矿石了。

d=(xc−xg)2+(yc−yg)2+(zc−zg)2≤Rg\displaystyle d = \sqrt{(x_c - x_g)^2 + (y_c - y_g)^2 + (z_c - z_g)^2} \le R_g
这个公式说的是:算法报出的位置点,离医生标注的中心越近越好,只要不超过标注半径 R_g 就算命中。
  1. 坐标差把“算法候选点”和“医生标注中心”的三维坐标分别作差,得到三个方向的偏差量:
    Δx=xc−xg,Δy=yc−yg,Δz=zc−zg\displaystyle \Delta x = x_c - x_g,\quad \Delta y = y_c - y_g,\quad \Delta z = z_c - z_g
  2. 平方求和三个方向差值的平方相加,既消除负号,也放大大偏差的惩罚:
    Δx2+Δy2+Δz2\displaystyle \Delta x^2 + \Delta y^2 + \Delta z^2
  3. 开根号对和式开平方,得到两点之间的空间直线距离:
    d=Δx2+Δy2+Δz2\displaystyle d = \sqrt{\Delta x^2 + \Delta y^2 + \Delta z^2}
  4. 半径判定当距离 d 不超过标注半径 R_g 时,候选点记为命中(真阳性):
    d≤Rg\displaystyle d \le R_g

数据预处理:先把矿石磨光滑

老道的淘金师傅绝不会抡起锤子就砸整块岩石,而是先冲洗矿石、磨平切片,让金子的纹路浮现出来。CT数据也是这样:直接把原始体素丢给神经网络,模型会被各种“杂质”干扰,训练又慢又差。

第一道工序是各向同性重采样。不同医院的CT层厚和像素间距五花八门:层厚可能是0.6毫米也可能是2.5毫米,就像有些矿石纹理细密、有些粗糙稀疏。用三线性插值把体素统一成各向同性(例如1×1×1毫米),模型见到的几何形状才一致,3D卷积的卷积核才能学到统一的空间尺度。

第二道工序是HU窗宽窗位。CT值的单位是亨氏单位(HU),空气约-1000,水是0,骨骼上千。肺结节藏在软组织里,直接看全范围会被亮瞎眼。CT阅片有个“肺窗”经验值:窗位 L 约-500,窗宽 W 约1500,把窗口之外的值全部截断:

这三道工序做完,矿石表面光滑、纹理清晰,金子(结节)和废铁(血管)的差别才真正显露出来,可以交给下一步的探测器了。

I′=min⁡(max⁡(I, L−W2), L+W2)\displaystyle I' = \min\left(\max\left(I,\ L - \frac{W}{2}\right),\ L + \frac{W}{2}\right)
可以把它想成给CT图像换一副“肺部专用放大镜”:只保留肺组织感兴趣的灰度段,其余全部压平,血管和结节的对比度立刻清晰起来。
  1. 计算窗口上下限由窗位 L 与窗宽 W 算出灰度窗口的区间,窗位决定亮度中心,窗宽决定对比度范围:
    [L−W/2, L+W/2]\displaystyle [L - W/2,\ L + W/2]
  2. 截断下限小于下限的体素一律压平为下限,先做一次 max 操作:
    Ilow=max⁡(I, L−W/2)\displaystyle I_{\text{low}} = \max(I,\ L - W/2)
  3. 截断上限再对上一步结果做 min 操作,把过亮的部分压平:
    I′=min⁡(Ilow, L+W/2)\displaystyle I' = \min(I_{\text{low}},\ L + W/2)
  4. 归一化与增强截断后再做 z-score 归一化,让网络输入稳定在零均值附近;训练时随机翻转、旋转、弹性形变、灰度扰动,相当于给模型看“不同角度、不同光线下的矿石”,增强对不同设备的鲁棒性。

候选检测:金属探测器粗扫全场

矿场太大,直接趴在地上用放大镜一寸寸找,人会累死。有经验的勘探队会先用金属探测器全场粗扫一遍,策略是“宁可错挖一千,不可放过一个”——听到滴滴声就插旗标记。LUNA16 的两段式方案,第一阶段干的就是这件事:候选检测。

几乎所有顶尖方案都采用“候选检测 + 假阳抑制”两段式。候选检测阶段通常用一个3D卷积网络(如3D U-Net)对每个体素预测“这是结节”的概率,得到一张密集的概率图;再对概率图取局部极大值或连通域,每个峰值就是一个候选点。由于目标是高召回,探测器异常慷慨:每例扫描常常产出成百上千个候选,其中绝大多数是血管断面、支气管壁等“废铁”。

3D卷积与2D卷积不同,它同时沿着 x、y、z 三个方向滑动,能利用相邻切片的上下文,避免把血管的横截面误看成结节。为了应对“正负体素极度不平衡”(结节只占极少体积),训练时常用 Dice 损失:

探测器把整座矿山插满了旗子,其中绝大多数是废铁。但这没关系——粗扫的目的从来不是直接交付答案,而是为下一关的“老师傅”圈定需要仔细甄别的区域。

LDice=1−2∑igipi+ϵ∑igi+∑ipi+ϵ\displaystyle \mathcal{L}_{\text{Dice}} = 1 - \frac{2\sum_i g_i p_i + \epsilon}{\sum_i g_i + \sum_i p_i + \epsilon}
Dice 损失衡量“预测的结节区域”与“真实的结节区域”重叠得有多好:重叠越高,损失越小。它天然对“小目标、大背景”不敏感,是医学影像分割的标配。
  1. 交集计算对每个体素 i,把真实标签 g_i(0或1)与预测概率 p_i 逐点相乘后求和,得到两区域的“重叠量”:
    ∑igipi\displaystyle \sum_i g_i p_i
  2. 规模求和分别把真实区域和预测区域内的响应加起来,得到两者的“总体积”:
    ∑igi+∑ipi\displaystyle \sum_i g_i + \sum_i p_i
  3. 平滑系数分子分母各加一个小常数 ε(如1),避免除零并稳定训练:
    ϵ\displaystyle \epsilon
  4. 组合成损失用 1 减去“两倍交集除以总体积”的Dice系数,得到越小越好的损失:
    LDice=1−2∑igipi+ϵ∑igi+∑ipi+ϵ\displaystyle \mathcal{L}_{\text{Dice}} = 1 - \frac{2\sum_i g_i p_i + \epsilon}{\sum_i g_i + \sum_i p_i + \epsilon}

假阳抑制:老师傅逐个验货

探测器圈出的可疑点太多,全部开挖成本太高。于是矿场请来最有经验的老师傅,对着每个插旗的位置挖出一个小样块,翻来覆去地看纹理、看光泽、看周边环境,最后拍板:这是金豆,还是废铁?这个环节在LUNA16里叫假阳性抑制(False Positive Reduction)。

技术上,它把候选检测当作“前置检索引擎”,输出的每个候选点都会裁剪出一个三维小块(例如48×48×48体素),送给一个独立的3D卷积分类网络,输出“是结节/不是结节”的二分类概率。相比全图分割,这种“裁剪-分类”模式只处理稀疏的候选块,计算量小得多,还能单独优化分类精度。

老师傅的经验有两个关键:一是多尺度融合——同时看小尺度的高清纹理(想成用放大镜看金豆表面的光泽)和大尺度的周边环境(想成看这块岩石周围有没有矿脉),把两种信息拼接后分类;二是级联与难例挖掘——先用一个轻量网络快速扔掉八成明显的废铁,再用一个更深的网络对付剩余的“高仿废铁”,并把训练中那些“分数很高但其实是血管断面”的难负样本反复强化训练。训练目标就是标准的二分类交叉熵:

探测器广撒网,老师傅精挑细选,二者配合默契。但怎么知道这套流程到底好不好?这就需要比赛计分板登场了。

LBCE=−1N∑i=1N[ yilog⁡p^i+(1−yi)log⁡(1−p^i) ]\displaystyle \mathcal{L}_{\text{BCE}} = -\frac{1}{N}\sum_{i=1}^{N}\left[\, y_i \log \hat{p}_i + (1-y_i)\log(1-\hat{p}_i) \,\right]
交叉熵想表达的意思是:如果真实是结节(y=1),模型预测概率越接近1损失越小;如果真实是废铁(y=0),预测概率越接近0损失越小。模型被一步步“拧”向正确的判断。
  1. 单样本损失对每个候选块 i,根据真实标签 y_i 与模型输出概率 p̂_i 计算交叉熵,真实为1时只看前一项、为0时只看后一项:
    −[ yilog⁡p^i+(1−yi)log⁡(1−p^i) ]\displaystyle -[\, y_i \log \hat{p}_i + (1-y_i)\log(1-\hat{p}_i) \,]
  2. 全体平均对所有 N 个候选块的损失求和并取平均,得到一批数据的整体损失:
    1N∑i=1N[⋯ ]\displaystyle \frac{1}{N}\sum_{i=1}^{N}[\cdots]
  3. 梯度下降驱使优化器沿梯度方向更新权重,让正样本预测趋向1、负样本预测趋向0:
    min⁡θLBCE\displaystyle \min_{\theta} \mathcal{L}_{\text{BCE}}

评价指标:比赛计分板怎么读

体检中心关心两件事:一是漏诊——该找到的金豆没找到;二是折腾——明明不是结节却让医生白看一堆假阳性。FROC曲线就是同时展示这两件事的计分板。

FROC(Free-response ROC)曲线的横轴是平均每扫描假阳数(FP/scan),纵轴是敏感度(Sensitivity = 真阳性数 / 真实结节总数)。检测模型对每个候选输出一个分数,我们不断调整判定阈值:阈值放低,抓到的真结节变多,假阳也变多;阈值升高,假阳减少,但小结节也开始漏掉。把每个阈值下得到的(FP/scan,敏感度)点连起来,就是整条FROC曲线。

LUNA16官方排名不看单点,而是用CPM(Competition Performance Metric):在7个预设假阳率上分别取曲线能达到的最高敏感度,再求平均:

其中每个考核点的敏感度定义为:

看懂计分板之后,我们该向高手偷师了:那些在排行榜上名列前茅的勘探队,到底用了什么套路?

CPM=17∑r∈RSens(r),R={18, 14, 12, 1, 2, 4, 8}\displaystyle \text{CPM} = \frac{1}{7}\sum_{r \in \mathcal{R}} \text{Sens}(r),\qquad \mathcal{R} = \left\{\frac{1}{8},\ \frac{1}{4},\ \frac{1}{2},\ 1,\ 2,\ 4,\ 8\right\}
Sens(r)=max⁡θ: FPscan(θ)≤rTP(θ)P\displaystyle \text{Sens}(r) = \max_{\theta:\ \text{FP}_{\text{scan}}(\theta) \le r} \frac{\text{TP}(\theta)}{P}
CPM的巧妙之处在于:它同时奖励“在极严格(每8个扫描才允许1个假阳)下不放过结节”和“在较宽松(每扫描8个假阳)下尽可能全找到”的模型。单一阈值好坏不重要,整条曲线的“质量”才是排名依据。
  1. 阈值扫描生成曲线按分数从高到低逐点扫描阈值 θ,每个候选按“分数≥θ即判阳性”产生一组(FP/scan,Sens)点:
    θ↦(FPscan(θ), TP(θ)P)\displaystyle \theta \mapsto \left( \text{FP}_{\text{scan}}(\theta),\ \frac{\text{TP}(\theta)}{P} \right)
  2. 固定假阳率取最优对给定考核假阳率 r,取所有 FP≤r 的点中敏感度最高的作为该点成绩:
    Sens(r)=max⁡θ: FPscan(θ)≤rTP(θ)P\displaystyle \text{Sens}(r) = \max_{\theta:\ \text{FP}_{\text{scan}}(\theta) \le r} \frac{\text{TP}(\theta)}{P}
  3. 七个考核点官方规定 r 取 1/8 到 8 共七个值,覆盖从“近乎不允许误报”到“允许较多误报”的操作区间:
    R={18, 14, 12, 1, 2, 4, 8}\displaystyle \mathcal{R} = \left\{\tfrac18,\ \tfrac14,\ \tfrac12,\ 1,\ 2,\ 4,\ 8\right\}
  4. 取平均得CPM七个成绩的算术平均就是最终排名分,满分1分:
    CPM=17∑r∈RSens(r)\displaystyle \text{CPM} = \frac{1}{7}\sum_{r\in\mathcal{R}}\text{Sens}(r)

冠军方案:高手们的共同套路

观摩顶尖淘金队作业,你会发现他们从不靠什么“神器”,而是把每个环节都打磨到极致。公开论文中,DeepLung(Liao等人,2019)曾在LUNA16上达到0.951的CPM,属于顶尖水平;Ding等人(2017)用3D Faster R-CNN拿到约0.891;Dou等人(2017)以3D FCN加难例挖掘拿到约0.839。它们的共同点是“三段式”:

  • 第一段:3D卷积网络(U-Net/FPN/RPN类结构)做候选检测,保证高召回;
  • 第二段:深度3D分类网络做假阳抑制,把假阳率压到每扫描个位数;
  • 第三段:多模型集成,把不同结构的打分融合成最终判决。

集成是冠军们拉开差距的关键。不同网络结构(如3D DPN、3D ResNet、带空洞卷积的变体)犯错的模式不同:有的对毛刺型结节敏感,有的对磨玻璃型结节更在行。把它们的概率加权平均,委员会决策永远比单个队员稳健:

除了集成,高手们的调优细节同样值得抄作业:10折交叉验证、每折单独训练再综合预测;多尺度裁剪块融合;难例挖掘;学习率warmup与余弦退火。冠军方案练的不是“灵感”,而是把工程细节抠到极致的耐心。比赛会结束,但手艺要进工厂——下一节看它如何落地。

pˉ=∑m=1Mwm pm∑m=1Mwm\displaystyle \bar{p} = \frac{\sum_{m=1}^{M} w_m \, p_m}{\sum_{m=1}^{M} w_m}
集成好比请多位经验不同的老师傅会诊:有人擅长看纹理,有人擅长看边缘,大家投票定结论,比任何单一专家都可靠。
  1. 单模型打分每个模型 m 对同一个候选块输出一个“是结节”的概率 p_m,以及对应的权重 w_m:
    pm,wm\displaystyle p_m,\quad w_m
  2. 加权求和把 M 个模型的概率按权重累加,得到总分:
    ∑m=1Mwm pm\displaystyle \sum_{m=1}^{M} w_m\, p_m
  3. 归一化判决除以权重总和得到最终概率,与业务阈值比较后决定是否报告:
    pˉ=∑mwmpm∑mwm,pˉ≥t⇒结节\displaystyle \bar{p} = \frac{\sum_m w_m p_m}{\sum_m w_m},\qquad \bar{p} \ge t \Rightarrow \text{结节}

工程落地:从比赛现场到量产工厂

比赛里跑得再快,也只是在“模拟矿场”上表演。要把淘金手艺变成24小时运转的医院流水线,还要闯三关:快、稳、能迭代。

第一关是推理加速。3D数据又大又密,一例CT动辄上亿体素,不可能整卷塞进显存。工程上通常用滑动窗口按块推理,配合步长拼接;再把模型导出为ONNX、用TensorRT做INT8量化,或做知识蒸馏,把大老师傅的本事压缩给小学徒。目标是把单例处理时间从几分钟压到几十秒,让放射科医生“看完即得”。

第二关是鲁棒性。LUNA16的矿岩经过筛选,训练集和测试集来自相近的设备;真实世界的扫描仪来自不同厂商、不同协议,灰度分布漂移会让性能明显下跌。对策是收集多中心数据做外部验证,推理时用测试时增强(TTA),并在部署后持续监控指标漂移。

第三关是选好工作点。临床场景不允许每扫描吐出一堆假阳让医生受累,通常限制每例假阳不超过1~4个。于是要在验证集上解一个带约束的优化问题——在假阳率不超限的前提下挑敏感度最高的阈值:

最后一环是迭代闭环:把生产环境中医生复核发现的漏检、误报样本收集回来,定期重新训练、重新评测CPM、重新发布。矿脉会变,淘金的手艺也要跟着变——这才是医疗AI真正能落地生根的工厂哲学。

θ∗=arg⁡max⁡θ Sens(θ)s.t.FPscan(θ)≤τ\displaystyle \theta^{*} = \arg\max_{\theta}\ \text{Sens}(\theta) \quad \text{s.t.} \quad \text{FP}_{\text{scan}}(\theta) \le \tau
翻译成大白话:在“每扫描最多误报几个”的硬约束下,找一个最不放过真结节的判定线。这是从“比赛分数好看”走向“临床好用”的关键一步。
  1. 目标函数在满足临床约束的所有阈值中,让敏感度(找到真结节的比率)最大化:
    max⁡θ Sens(θ)\displaystyle \max_{\theta}\ \text{Sens}(\theta)
  2. 临床约束每扫描平均假阳数不能超过业务阈值 τ(常见取值1或2),否则医生会被淹没在误报里:
    FPscan(θ)≤τ\displaystyle \text{FP}_{\text{scan}}(\theta) \le \tau
  3. 最优阈值在验证集上网格搜索所有候选阈值,取满足约束时敏感度最高的那个作为线上部署的判定线:
    θ∗=arg⁡max⁡θ{Sens(θ) ∣ FPscan(θ)≤τ}\displaystyle \theta^{*} = \arg\max_{\theta} \left\{ \text{Sens}(\theta) \ \big|\ \text{FP}_{\text{scan}}(\theta) \le \tau \right\}

AI 应用场景

体检中心的肺结节AI初筛

阶段:模型推理与辅助诊断

解决的问题:传统阅片靠医生逐层翻看上百张CT切片,小结节极易漏诊,且体检高峰期医生疲于奔命。AI先用两段式流程把可疑结节标出来,再给出坐标和大小,医生只需复核被标记的位置,把“大海捞针”变成“按图索骥”。

具体案例:某三甲医院体检科在低剂量胸部CT筛查中接入AI系统:第一段3D U-Net在30秒内生成每例约200个候选点,第二段3D分类网络把假阳压到每例2个以内,最终医生在阅片工作站上看到绿色标记的结节,用结构化报告记录良恶性倾向,单例阅片时间从8分钟降到2分钟。

医疗AI产品的持续迭代与质控

阶段:数据回流与模型再训练(MLOps)

解决的问题:模型上线后,新设备、新人群会导致性能漂移。传统方式是“上线即终点”,出了问题才发现误报率飙升。LUNA16的FROC/CPM评测体系被搬进质控流程后,团队能定期量化“漏了多少真结节、多报了哪些假阳”,据此决定何时重训。

具体案例:一家AI公司为合作医院建立“假阳病例库”:系统每周自动导出医生驳回的AI标记,由算法团队标注后增量训练,并在固定的1000例回测集上观察CPM是否提升;连续两个季度模型CPM从0.88提升到0.92,假阳率下降40%。

把两段式范式迁移到其他医学检测任务

阶段:算法选型与迁移复用

解决的问题:结肠息肉、肋骨骨折、脑微出血等检测任务与肺结节高度同构——目标小、背景杂、假阳多。直接复用LUNA16的“候选检测+假阳抑制+集成”流水线,可以大幅减少从零起步的开发成本。

具体案例:某初创团队把LUNA16的3D FCN候选检测与3D分类级联方案迁移到CT肋骨骨折检测:把候选检测网络的输入从肺窗改成骨窗,假阳抑制网络的结构几乎原样保留,三个月就完成了POC,召回率从人工初筛的78%提升到90%以上。

Python 演示

python74 行
import numpy as np  class FROCEvaluator:    """LUNA16 风格的 FROC/CPM 评测器     只依赖 numpy, 输入候选检测后输出的分数与标签,    输出 FROC 点集与 CPM 综合得分.    """     def __init__(self, target_rates):        # LUNA16 的 7 个考核假阳率: 每扫描 1/8 到 8 个        self.target_rates = list(target_rates)        self.cand_scores = np.array([], dtype=np.float64)        self.cand_labels = np.array([], dtype=np.int32)        self.scan_count = 0     def load_results(self, scores, labels, num_scans):        self.cand_scores = np.asarray(scores, dtype=np.float64)        self.cand_labels = np.asarray(labels, dtype=np.int32)        self.scan_count = int(num_scans)     def froc_points(self):        # 分数从高到低作为阈值, 逐步累积 TP 与 FP        order = np.argsort(self.cand_scores)[::-1]        sorted_scores = self.cand_scores[order]        sorted_labels = self.cand_labels[order]        total_pos = int(np.sum(self.cand_labels == 1))        points = []        tp_acc = 0        fp_acc = 0        for score in np.unique(sorted_scores)[::-1]:            mask = sorted_scores == score            tp_acc += int(np.sum(sorted_labels[mask] == 1))            fp_acc += int(np.sum(sorted_labels[mask] == 0))            points.append((fp_acc / self.scan_count, tp_acc / total_pos))        return points     def sensitivity_at(self, fp_rate, points):        # 假阳率不超过目标时能到达的最高敏感度        best = 0.0        for fpr, sens in points:            if fpr <= fp_rate:                best = max(best, sens)        return best     def cpm_score(self, points):        sens_list = [self.sensitivity_at(r, points) for r in self.target_rates]        return float(np.mean(sens_list))  def simulate_candidates(seed=42):    # 模拟 88 个扫描: 候选检测阶段的高召回输出    rng = np.random.default_rng(seed)    nodule_scores = rng.normal(4.5, 1.0, 150)    false_scores = rng.normal(0.8, 1.2, 8000)    scores = np.concatenate([nodule_scores, false_scores])    labels = np.concatenate([np.ones(150), np.zeros(8000)])    return scores, labels  def main():    evaluator = FROCEvaluator((0.125, 0.25, 0.5, 1.0, 2.0, 4.0, 8.0))    scores, labels = simulate_candidates()    evaluator.load_results(scores, labels, num_scans=88)    points = evaluator.froc_points()    cpm = evaluator.cpm_score(points)    print("候选总数: %d, 真实结节: %d" % (len(scores), int(np.sum(labels))))    print("FROC 采样点数: %d" % len(points))    print("CPM(综合得分): %.4f" % cpm)  if __name__ == "__main__":    main()
目的:用纯numpy实现LUNA16官方评价指标FROC/CPM,跑一遍就能看到“阈值扫描→FROC点→CPM平均”的完整链路。
  1. order = np.argsort(self.cand_scores)[::-1]把候选按分数从高到低排序,作为阈值扫描的顺序。
  2. points.append((fp_acc / self.scan_count, tp_acc / total_pos))每处理完一个分数层,记录一个FROC点(假阳率,敏感度)。
  3. best = max(best, sens)在目标假阳率内取能到达的最高敏感度,对应公式里的 max。
  4. return float(np.mean(sens_list))对七个考核点求平均,得到CPM。
  5. nodule_scores = rng.normal(4.5, 1.0, 150)模拟真结节的打分普遍偏高,而假阳打分偏低。
  6. evaluator.load_results(scores, labels, num_scans=88)输入候选分数、标签与扫描总数。
  7. cpm = evaluator.cpm_score(points)计算综合得分,作为模型在LUNA16计分板上的成绩。

常见误区

误区我直接训练一个3D分割网络,输出概率图再取个阈值,不就是最终结节了吗?为什么还要费劲做两阶段?

正解医学影像里目标极小、背景极复杂,单阶段直接输出会被大量假阳淹没。LUNA16所有顶尖方案都采用“候选检测+假阳抑制”两段式,就像淘金不能靠一次过筛就分好金子和废铁,必须先用探测器划出可疑区、再逐块甄别。单模型可以做到高召回,但很难同时把“长得像结节”的血管压到极低假阳率。

为何常见:通用目标检测(如YOLO)单阶段效果不错,让人误以为医学小目标同样可行;但CT是三维大图,正负样本失衡远超自然图像。

误区CPM 0.95说明系统已经很完美,直接部署到医院就行。

正解LUNA16数据经过严格筛选(剔除了层厚过大、伪影严重的扫描),标注由资深医生完成;而真实临床数据来自不同厂商设备、不同扫描协议、不同人群,分布漂移会让性能明显下降。比赛高分只是“模拟榜”,真实落地还要做多中心外部验证、人机协同流程设计和持续监控,就像在标准矿场练出的手艺,换一座地质不同的矿山仍需重新试采。

为何常见:竞赛排名给人“货真价实”的错觉,忽视了训练环境与部署环境的差异。

误区数据不够,翻转旋转一下,增强就做完了。

正解增强的本质是注入先验不变性:医学图像中结节在不同方向、不同设备灰度下都应该被判为结节,所以翻转、弹性形变、灰度扰动都有物理意义。但也不能乱用——过大的旋转可能产生解剖学上不可能的样本,反而把模型带偏。增强要与任务先验匹配,而不是盲目堆量。

为何常见:把增强等同于“多来点数据”的粗浅理解,忽略了它是在告诉模型“哪些变化不该改变结论”。

误区FP/scan当然是0最好,一个冤枉的也不要报。

正解FROC用7个假阳率点综合打分,正是因为在检测任务里假阳率和敏感度存在权衡:阈值卡太死会漏掉小结节。把FP/scan压到0,敏感度往往大幅下滑。临床实际目标通常是在每例1~4个假阳内尽量多抓真结节,再由医生人工复核。漏掉一个1厘米的肺结节,比多看两个假阳严重得多。

为何常见:直觉上“错误更少”就是更好,没意识到检测任务里漏检比误报代价更高,指标必须成对看。

面试题

Q1. LUNA16 数据集主要使用什么影像模态?

单选题 · easy

  • A.视网膜彩色眼底照片
  • B.胸部断层CT扫描影像← 正确答案
  • C.脑部磁共振增强影像
  • D.腹部超声动态视频

答案:B。LUNA16是肺结节检测竞赛,数据来自胸部CT断层扫描,在三维体数据中找小结节。眼底、脑MRI、腹部超声都是其他医学任务的数据,与肺结节无关。

Q2. LUNA16 官方排行榜的最终评价指标是什么?

单选题 · easy

  • A.受试者工作特征曲线下面积
  • B.精确率与召回率构成的曲线
  • C.七个假阳率下的平均敏感度← 正确答案
  • D.分割掩膜的戴斯相似系数

答案:C。官方排名用CPM,即7个预设假阳率(每扫描1/8到8个)上敏感度的平均值。AUC和PR曲线在检测中也会参考,但不是LUNA16的排名依据;Dice是分割任务的指标。

Q3. LUNA16 中一个候选点被判定为真阳性的匹配规则是什么?

单选题 · mid

  • A.与标注框重叠的戴斯系数
  • B.中心欧氏距离不超过标注半径← 正确答案
  • C.与标注框的像素完全重合
  • D.中心点落在同一切片即可

答案:B。官方规定:候选点中心与标注球心之间的欧氏距离不超过标注半径即算命中,类似“金豆落到圈内就算数”。它不要求像素级重合,也不在意是否同一切片。

Q4. 肺结节检测两阶段流程中“候选检测”阶段的首要目标是什么?

单选题 · mid

  • A.最大化检测的精确率数值
  • B.高召回率并容忍大量假阳← 正确答案
  • C.直接输出精准的最终边界框
  • D.只检测直径十毫米以上结节

答案:B。候选检测相当于金属探测器粗扫,追求把真结节尽量全部圈出来,宁可标记大量假阳,也绝不漏掉。精确率优化和精准边界框是第二阶段假阳抑制与后处理的任务。

Q5. 3D 卷积与 2D 卷积在医学影像中的核心区别是什么?

单选题 · mid

  • A.输入数据必须归一化处理
  • B.同时建模三维空间上下文← 正确答案
  • C.二维网络无需训练参数
  • D.三维卷积仅用于分割任务

答案:B。3D卷积的卷积核沿x/y/z三个方向滑动,能利用相邻切片的立体上下文,看到结节在z轴上的连续形态,这是2D逐层处理做不到的。归一化是通用技巧,2D网络也有参数,3D也不只用于分割。

Q6. 下列哪些是 LUNA16 常用的数据预处理手段?

多选题 · mid

  • A.进行各向同性的重采样← 正确答案
  • B.使用窗宽窗位裁剪数值← 正确答案
  • C.对体素做全局归一化← 正确答案
  • D.直接使用原始影像格式

答案:ABC。各向同性重采样统一体素间距,窗宽窗位突出肺组织对比,归一化稳定训练分布,三者都是标准预处理。原始影像格式的体素值域差异大、噪声多,直接使用会影响模型收敛。

Q7. 关于 FROC 曲线,下列说法正确的是?

单选题 · hard

  • A.横轴是全部假阳数量
  • B.纵轴表示检测精确率
  • C.横轴为每扫描平均假阳数← 正确答案
  • D.曲线仅在训练集有效

答案:C。FROC把横轴设为平均每扫描假阳数,纵轴为敏感度(召回率),通过扫描阈值得到整条曲线。它可以在验证集或测试集上绘制,并非训练集专属;横轴也不是假阳总数(未按扫描数归一化)。

Q8. 为什么 LUNA16 顶级方案普遍采用“候选检测+假阳抑制”级联?

多选题 · hard

  • A.降低三维卷积的计算量← 正确答案
  • B.缓解类别极度不平衡← 正确答案
  • C.标注格式要求两阶段
  • D.高召回同时控制假阳数← 正确答案

答案:ABD。先粗筛出稀疏候选,再逐个精细分类,能大幅降低3D计算量,也让分类阶段面对的正负样本比更可控;两阶段配合实现“高召回+低假阳”。这与标注格式无关,纯粹是工程与算法上的最优解。

Q9. Hard negative mining 在肺结节分类中的作用是什么?

单选题 · mid

  • A.增加训练样本的总数量
  • B.聚焦像结节的难负样本← 正确答案
  • C.减少正样本产生过拟合
  • D.加速模型推理的速度

答案:B。难例挖掘专门挑“分数高但其实是血管断面”的假阳样本反复训练,让模型学会区分最像结节的负样本。它不改变样本总量,也不影响推理速度。

Q10. 模型集成能提升 LUNA16 分数的原因包括哪些?

多选题 · hard

  • A.不同模型错误模式可互补← 正确答案
  • B.平均预测结果方差更小← 正确答案
  • C.集成使每个网络加深
  • D.多架构捕获多尺度特征← 正确答案

答案:ABD。不同架构(如DPN与ResNet变体)对结节形态的偏好不同,错误互补;加权平均让单模型偶然噪声被抵消、方差减小;不同深宽组合还能捕捉不同尺度特征。集成不会改变单个网络的深度,C选项是错误的。