训练循环与 99.7% 的假象:第一仗打败了,而报表说赢了
这一章讲三件事: 一个真实项目的训练代码长什么样(不再是 notebook 里的十几行);分类模型怎么搭、怎么记账;以及全书最值钱的一次失败——99.7% 的正确率,实际战绩是阳性样本一个都没答对。 位置:三步流水线的第 ③ 步(分类)第一次跑起来。教训直接逼出第 14 章的精确率/召回率。
1. 从 notebook 到命令行应用
之前的训练循环都写在 notebook 里,十几行。肺癌项目的复杂度(事情的规模与牵扯面)上了台阶,书里把它重构成一个带命令行参数的 Python 应用 LunaTrainingApp:启动时解析参数(--epochs=20 --balanced 这类),再进主流程1。
书里有句方法论值得单独记住:项目的基础设施要跟得上项目的复杂度——但「等基础设施完美了再动手」是一种拖延症1。脚手架够用就好,这章的脚手架就是:一个解析参数的入口、一个模型、两个数据加载器、一个训练循环、一套指标。
三个工程细节,每条都有坑:
- 多 GPU 透明包装:单机多卡时用
nn.DataParallel(model)把模型包一层,数据自动切到各卡再合并结果,训练代码一行不改。跨机器的DistributedDataParallel留到第 16 章2。 - 优化器的创建顺序:
model.to(device)之后才能建优化器——优化器握着的是参数张量的引用,先建优化器再搬家,它更新的就是 CPU 上的旧副本。这条第 08 章上 GPU 那一节踩过,这里重申2。 - 默认超参数:SGD、
lr=0.001、momentum=0.99。动量(momentum)是给梯度下降加的「惯性」:每次更新不光看当前梯度,还带上一步更新的九成方向,小坑小洼直接滚过去。书里的说法是这组值「是安全的起跑点」,不是调出来的最优3。
数据加载侧两个提速旋钮:num_workers 开后台进程提前取下一批(GPU 算当前批时,CPU 在备下一批);pin_memory 把数据锁在「直通内存」里,CPU 到 GPU 的拷贝少走一道。开起来的效果:训练 495958 个样本、验证 55107 个,一轮约 1900 多个批次4。
2. 模型:tail、backbone、head
LunaModel 是第 08 章那个 2D 卷积网的 3D 版,输入是第 12 章切好的 32×48×48 小方块。书里给分类网络的通用三段式命了名5:
输入 [1, 32, 48, 48]
│
tail BatchNorm3d ← 先把输入归一化到骨干适应的范围
│
backbone 4 × LunaBlock ← 每块:Conv3d→ReLU→Conv3d→ReLU→MaxPool3d
│ 每块后分辨率减半:32×48×48 → … → 2×3×3
│
head Linear(1152, 2) ← 拉直成 1152 个数,判两类
Softmax
图说:数据从「尾」流向「头」(和成语「从头到尾」相反——书里特地吐槽了这点)。
为什么每层卷积用两个 3×3×3 叠着,而不是一个 5×5×5?感受野(receptive field,第 08 章:一个输出单元能「看见」的输入范围)账:两个 3×3×3 叠起来,最里层那个输出体素受 5×5×5=125 个输入体素影响,和单个 5×5×5 一样;但参数量是 2×27=54 对 125(不计通道),同样的视野,一半不到的参数——还多赚一次中间的非线性6。
模型还有两个不显眼但讲究的设计:
- forward 返回两样东西:未归一化的原始分数(logits,第 07 章讲过的概念)和 softmax 后的概率。训练时用 logits 配 CrossEntropyLoss(一步算完,数值上更稳);给人看、做指标时用概率。训练时的输出和展示时的输出不同,是正常的7。
- 初始化自己动手:按第 09 章讲的 Kaiming 初始化给每个卷积层赋初值(扇出模式、配 ReLU),而不是用 PyTorch 的默认8。
3. 指标记账:每样本损失与三行账
这章最重要的工程决策不在模型里,在记账方式里。
computeBatchLoss 建损失函数时传了 reduction='none':不让 CrossEntropyLoss 把一批 32 个样本的损失压成一个平均数,而是保留每个样本各自的损失,再自己求平均。为什么多此一举?因为要按类别分开算账——平均损失一个数,看不出模型对哪一类在学9。
配套的是一块三行的指标数组 metrics_g,每列对应一个样本,三行分别是:真实标签、模型预测(概率 >0.5 判阳性)、该样本的损失。一个 epoch 结束后,拿 0.5 做阈值切出「阴性样本掩码」「阳性样本掩码」,就能分别报:全体/阴性/阳性三组各自的损失和正确率9。
验证循环则是第 05 章的标准动作收进工程里:model.eval()(关掉 dropout、让批归一化用运行统计)加 torch.no_grad()(不建计算图,快一截),只记账,不更新10。
这套记账是后面「大揭露」的全部前提。 只报总正确率,这章的事故根本不会被发现。
4. 主走查:第一个 epoch 的日志,逐行读
训练跑起来了。走查就是读第一个 epoch(E1)打出来的日志——每个数字都是书里真实的运行输出11:
E1 trn 2.4576 loss, 99.7% correct
E1 trn_neg 0.1936 loss, 99.9% correct (494289 of 494743)
E1 trn_pos 924.34 loss, 0.2% correct (3 of 1215)
E1 val 0.0172 loss, 99.8% correct
E1 val_pos ..., 0.0% correct (0 of 136)
图说:第一行是喜报,下面四行是验尸报告。
逐行拆:
- 总账 99.7%:494743 个阴性 + 1215 个阳性,全答「阴性」也有 494743/495958 ≈ 99.75%。这个分数不训练也拿得到。
- trn_neg 99.9%:阴性样本里几乎全对——模型确实在学,学的是「反正说不是」。
- trn_pos 0.2%(3/1215):训练集 1215 个真结节,只认出 3 个。924 的平均损失说明它答「不是」时还非常自信。
- val_pos 0.0%(0/136):验证集 136 个真结节,一个没认出来。
书里给这个失败模式配了一个考试比喻:100 道判断题,99 道答案是「错」,全答「错」得 99 分;另一个学生答对了唯一那道「对」,反而得分更低——但谁都知道谁更懂12。放到癌症筛查上更直白:一个永远说「没事」的模型,漏掉的就是肿瘤。书里原话:这种失败模式在真实世界是最危险的一种11。
为什么会塌成这样?先记住现象,机制留给下一章:495958 个样本里阳性只有 1215 个(约 400:1),梯度几乎全被阴性样本攥着,模型最快降低损失的方式就是对所有输入输出「阴性」——它找到了一个我们没打算让它找的解。
顺便:两个看得见进度的工具
训练中怎么知道还要多久?书里用了 tqdm(一个进度条库,把数据加载器包一层 就显示「16/1938,预计还要 X」),作者们的梗是用预估时间决定「来不来得及去巴黎吃晚饭」13。
训练完怎么复盘?TensorBoard(Google 出的训练可视化面板,PyTorch 用 SummaryWriter 往里写):每行指标 add_scalar(标签, 值, global_step) 记一个点,浏览器里看趋势线。两个细节:横轴用累计样本数而不是 epoch 号——epoch 长度一变,两条曲线就没法对齐;斜杠分组标签(loss/train、loss/val)让曲线自动归堆14。
5. 作者的判断与证据
| 说法 | 性质 |
|---|---|
| SGD lr=0.001 momentum=0.99 是安全起点 | 作者经验,明说「不是最优,是起跑点」3 |
| 两个 3×3×3 比单个 5×5×5 省参数 | 有据,感受野与参数账可复算6 |
| 训练用 logits、展示用概率 | 有据(数值稳定性),第 07 章已论证7 |
| 99.7% 是假象、该模式最危险 | 有据,日志数字在书里;「最危险」是作者的强调,但医学语境下成立11 |
| 「基础设施拖延症是陷阱」 | 作者经验论,无证据,但便宜1 |
| 横轴用样本数不用 epoch | 工程判断,理由是可比性;无人反对过14 |
6. 边界与局限
DataParallel是单机多卡的省事方案,书上自己也说它效率不如分布式方案——真要快,看第 16 章。- 指标阈值取 0.5 是默认,不是调出来的;下一章会看到阈值本身就是个旋钮。
- tqdm 的进度估计在批次耗时不均时会跳变,书里没提——别拿它当精确倒计时。
- 这章的「失败」其实是设计好的教学时刻:作者们早知道会这样(下一章开头就揭)。真实项目里,这种时刻没人给你预告。