上一章《初始化与训练稳定性》管「一开就炸」。这一章讲的是它不工作时怎么办—— 一张从症状反查病因的可点诊断树、一次亲手把训练调坏的实验,加上五个最高频 bug 的检查清单。
大致的时间分配(Full Stack Deep Learning 第 7 讲;Karpathy——特斯拉前 AI 负责人、OpenAI 创始成员之一——确认过)
从业者 80~90% 的时间花在调试和调参上,只有 10~20% 花在推导数学和实现上。 下面每一节,都是那 80% 里的常规动作。
这一章只回答一个问题:它坏了,怎么修。症状 → 病因,从有 bug 的地方往回查。
如果你的损失在正常下降、只是还没到最好——那不是 bug,是另一半问题, 归后半章管:《超参怎么选》。 两章的交界处只有一条铁律:先在小数据上过拟合一个 batch。如果连它都过拟合不了,那就不必去翻后半章了。
这是这一章最有用的东西。选一个最像你的症状,一层层点下去。 能画出真实曲线的那几片叶子,点开后会直接把那条曲线跑给你看—— 不是示意图,是现场训练出来的损失曲线(横轴轮数、纵轴对数损失)。
互动 · 诊断树(每一个叶子都能点开)
这张树像医院的分诊台:先问「哪儿疼」,再决定去哪个科。 差别在于——模型不会说话,它只会给你一条损失曲线。 所以这一章真正教的不是「照着点」,而是「把曲线的形状翻译成症状」。
动手前,先照下面那张默认配置表把架子搭好。之后这一章的顺序就三步: ① 在小批上过拟合(查 bug)→ ② 扫一遍学习率 → ③ 盯监控表开正式训练。 这一节先讲前两步,第 ③ 步在第 7 节。
因为这两件事排查的是不同的东西。完整数据训不动,可能是 bug 也可能只是没调好; 但几十个样本训不动,只可能是 bug——模型连死记硬背都做不到,说明前向或反向有错。
一个正常的网络应该有记住几十个样本的能力。做不到,说明梯度根本没传对、标签接错了、 或者损失函数用错了。这些都是确定性的 bug——调学习率调到天荒地老也修不好。
第 5 节把这个动作做成了一个可以亲手调坏的实验。先把那张表背下来,再去拖那个滑块。
新手最需要的一栏是「我该用什么起步」。下面这套配置的选取标准只有一条: 让「出问题时能立刻判断是 bug 还是超参数」这件事变得更容易。
| 项 | 起点 | 为什么是这个 |
|---|---|---|
| 优化器 | AdamW | 自适应步长,对学习率不敏感——出问题时少一个嫌疑人。 |
| 学习率 | 3e-4 | 圈内叫「魔法学习率」:绝大多数模型在这个值上既不会立刻炸、也不会一动不动,正好用来判断「代码对不对」 |
| 学习率调度 | 先不用(常数) | 调试阶段不要引入第二个会随时间变的量。等基线跑通了再上 cosine(让学习率按余弦曲线慢慢变小) |
| 正则化 | 先全部关掉 | weight decay 设 0、dropout 设 0。第一步的目标是「能不能过拟合」,正则化只会挡路 |
| 激活 + 初始化 | ReLU + He tanh + Glorot |
这两个必须配套。ReLU 配 Glorot 会让 |
| 归一化 | 有残差就用 LayerNorm(Pre-LN) | 残差 = 把输入直接绕到后面、和这一层的输出相加;Pre-LN = 归一化放在子层前面。深网络的稳定底座,先有它,再谈别的 |
| batch size | 32 ~ 256 | 能塞进显存就行。一开始不用追大批量——大批量还要连带调学习率和 warmup(先小步、再逐步加大步长) |
| 必须一开始就有的 | 固定随机种子 + 记录超参数 | 没有它,第 8 节讲的「不可复现」就无从查起:你连「改了什么导致变化」都不知道 |
| 先别加的 | 复杂增强、多任务损失、 |
每加一样,都让「这到底是 bug 还是设置问题」更难判断。一次只加一个 |
学习率是超参数里最该先扫的那一个。而它有个很好的性质:扫一遍就知道了。
互动 · 拖竖线,看这个学习率会带来什么
| 学习率区间 | 损失曲线 | 你该怎么做 |
|---|---|---|
| 1e-6 ~ 1e-5 | 几乎是一条水平线,缓慢下降 | 太小了。训练到明年也跑不完 |
| 1e-4 ~ 1e-2 | 稳定、明显地往下走 | 就是这里。取曲线最陡处再除以 3~10 |
| 1e-1 | 剧烈震荡,上下横跳 | 略大了。配 warmup 还能救(哪个值算大要看网络) |
| > 1 | 一路上冲,越跑越大 | 炸了。降两个数量级重来 |
同一个学习率,在不同网络上的后果可以完全不同:表里的「大」和「小」取决于那个网络的曲率。 第 5 节那个 8 点小网络,用默认的 1e-1 就能顺利过拟合——它曲率小,同一档步子在那里不会震荡。
下面是一个真的小网络(2 → 16 → 1,tanh 隐层、线性输出、平方误差损失)
和一批真的 8 个点(两个类别,标签 ±1)。
改一下互动里的控件,它就会现场跑一遍梯度下降——前向、反向、更新,全是这个页面里的代码在算。
示意图 · 下面要过拟合的到底是什么
你的任务不是「看它成功」,而是把它调坏:把学习率拖到两端,或者把梯度方向写反。
互动 · 一个 batch 能过拟合吗
这张表是这一节存在的意义:过拟合失败时,曲线的形状直接告诉你病因。 上面那三个控件能把你送进其中三种形状(往上走 / 爆炸 / 不降);第四种「震荡」去第 4 节那张扫描图里看。上面那条损失曲线就是现场训练出来的。
| 现象 | 通常的病因 | 怎么确认 |
|---|---|---|
| 误差往上走 | 损失函数或梯度里符号反了(更新方向写反,等于在爬山) | 把学习率调到很小,如果损失仍然单调上升——基本可以确定是符号或目标函数接错 |
| 误差爆炸 | 数值问题,或学习率太大 | 看爆炸前几轮的形状:量级几轮内就越过 1e10、然后溢出成 NaN = 学习率太大;先平稳、再突然冲上去,更像数值问题 |
| 误差震荡 | 学习率偏大;也可能标签没洗乱、增强写错 | 学习率降 10 倍再看一次。还震荡,去查数据管线 |
| 压根不降 | 学习率太小、正则化太强;也可能损失函数与数据管线接错 | 去掉所有正则、学习率 ×10 再看一次 |
很多人遇到训练不收敛,第一反应是「我的模型不够好」。而这一步用的是一个必须能记住 8 个点的网络—— 如果它记不住,问题一定在实现里,不在想法里。这一步把「模型问题」和「代码问题」彻底分开了。
这五条来自 FSDL 第 7 讲的统计,也是复现别人代码时踩得最多的五个坑。 它们的共同点是:都不报错。代码跑得通、损失在降、结果就是比别人差。
| Bug | 隐蔽在哪里 | 你会看到 | 怎么在 5 分钟内验 |
|---|---|---|---|
| 1 · 张量形状错 | 框架的静默广播:形状不匹配时它不报错,而是「帮你」扩成一个大得多的张量 | 代码跑得通、损失也在降,但结果完全不对 | print(x.shape) 打在每一步。形状是唯一能在 5 秒内确认的东西 |
| 2 · 预处理错 | 归一化用了全量数据(含 |
验证分数好得可疑,上线立刻掉 | 把预处理参数(均值、方差)打印出来;只用 |
| 3 · 损失函数接错 | 最经典的一个:把 softmax 的输出喂给了期望 logits 的损失函数——等于做了两次 softmax | 损失在降,但卡在一个明显偏高的水平,准确率还行——所以很难发现 | 看模型输出的取值范围:概率(0~1)还是 logits(可正可负、无上界) |
| 4 · 忘了切 train / eval 模式 | Dropout 和 BatchNorm 在训练与推理时行为不同。忘了 model.eval() |
同一张图推理两次结果不一样;或者验证结果比训练差一大截 | 同一个输入连跑两次,比输出。不一样 = 模式没切对 |
| 5 · 数值不稳定 | fp16(本节末尾 Bug 5 细讲)下溢——数太小,小到浮点数表示不了,就被当成 0;还有除零、log(0)。它们不抛异常,只产出 inf / NaN |
训练到某一轮突然全变 NaN;或者很小的数莫名其妙变成 0 | 把 dtype(数据类型)换成 bf16 或 fp32 再跑一遍;给 log / 除法加 eps |
下面这个加法是真的按 NumPy 的广播规则算出来的。左边是一个(4, 1),
右边是一个(1, 4)——你想要的可能是逐元素相加,但框架会给你一个(4, 4)。
互动 · 静默广播:形状不匹配时的真实结果
在每个函数的开头写形状断言:assert x.shape == (N, 1)。
PyTorch 里还可以 torch.set_printoptions(precision=3) 配合
print 检查数字量级——广播错误的数字往往大得离谱或完全不符合量纲。
最省事的一招:在第一个 batch 上用 (1, 1) 这种极小形状跑一遍全流程,形状对不对一眼就能看出来。
下面这个演示是现场训练的:一个线性分类器,训练集是一个分布,验证(测试)集是另一个偏移过的分布 (模拟「线上和线下不一样」)。 同一份数据、同一个模型,只改一件事——归一化用的 μ/σ 是从哪批数据上算的。
互动 · 换一个偏移量,真数字跟着变
左边是正确做法(损失吃 logits),右边是把 softmax 的输出又喂给了期望 logits 的损失函数。 两条曲线都是真的训练 180 个样本跑出来的——它们会清楚地分开。
互动 · 拖一下正确类别的 logit,看两次 softmax 把概率压成什么样
下面这个演示是真的按公式算出来的(dropout 真的在丢)。
把「模式」按钮在训练 / 推理之间切一下,看同一个输入会发生什么。
这就是忘了 model.eval() 的后果:同一个输入,六次前向给出六个答案。
这里的演示只覆盖 Dropout 这一半——BatchNorm 也分训练 / 推理两套行为,但它的差别要用一个完整的 batch 才看得出来。
互动 · 同一个输入,六次前向:训练每次不同,推理每次一样
先认识浮点数:一个数 = 有效数字(尾数)× 2 的多少次方(指数)。位数是固定的, 尾数管「数字有多准」,指数管「能表示多大、多小」。 fp16 的尾数有 10 位、指数只有 5 位;bf16 反过来——尾数 7 位,指数 8 位,和 fp32 一样宽。
示意图 · 三种浮点数把位数花在哪
下面这条曲线是真算出来的相对误差:把一批数按 fp16 和 bf16 各自舍入一次,再量误差。 左边那一段竖直上冲就是 fp16 的悬崖——比它小的数直接归零。
互动 · 真的把数字舍入成 fp16 / bf16,再量误差
梯度经常是 1e-8 ~ 1e-10 这个量级。fp16 能表示的最小正数是 5.96e-8;
浮点数四舍五入到最近能表示的值,比它一半还小的数离 0 更近,就一律舍成 0。
而 bf16 的指数范围和 fp32 一样,能表示到 1e-38。
所以「fp16 训练炸了」经常不是炸,是小梯度被抹成了 0——损失不动,但也不报错。
现代做法有两条:① 损失缩放(loss scaling)——先把损失乘 1024,梯度一起放大,更新前再除回去; ② 直接用 bf16。今天的主流选择是后者,因为它的动态范围不用额外操心。
不要只盯着一个 loss 看。这六个量能让你在出问题的当下就定位,而不是等训练完才发现白跑。
| 指标 | 怎么算 | 正常范围(经验值) | 不正常说明 |
|---|---|---|---|
| 训练损失 | 每批损失按 epoch 平均 | 平稳下降 | 不降(代码 bug / 学习率)、震荡(学习率大)、阶跃(数据有问题) |
| 验证损失 | 同一个损失函数,在验证集上算 | 先降后升 | 持续上升 = 过拟合;和训练损失差距巨大 = 数据分布不一致 |
| 梯度范数 | 所有参数的梯度平方和,再开根号 | 1e-2 ~ 1e1 | 突然出现几千 = 出现坏 batch;持续为 0 = 神经元全死了或梯度断了 |
| 各层激活标准差 | 每层输出在 batch 维上的标准差 | 0.1 ~ 10 之间稳定 | 逐层衰减到 e-8 = 梯度消失(见上一章);逐层增长到 e5 = 初始化/学习率问题 |
| 参数更新比例 | 这一步改动的模 ÷ 参数本身的模(|Δθ| / |θ|) | 1e-3 ~ 1e-2 | 远小于 1e-3 = 学习率太小或 |
| 各层权重范数 | 每层权重平方和,再开根号 | 缓慢增长后平稳 | 持续爆炸式增长 = |
互动 ·
它不替代诊断树,而是给诊断树提供读数:出问题的当下就能翻,不用等训练完才发现白跑。
假设有一个 8 层的网络,某一层的权重在某个坏 batch 之后变成了 NaN。 从最后一层往回查是最笨的办法——因为 NaN 会一路污染到输出。正确做法是二分: 把网络从中间切开,看中间那一层的输出是不是有限。8 层对半切三次就够:8 → 4 → 2 → 1。
下面的演示真的每次前向跑一遍(每层都是真的乘加运算)。点「测一次」它就二分一次。
互动 · 二分定位:第几次能找到坏掉的层
训练时每个参数要付的账是:权重 2 字节 + 梯度 2 字节 + fp32 主副本 4 字节 + Adam 的一阶矩 4 字节 + 二阶矩 4 字节 = 16 字节。
混合精度训练里,权重和梯度用 2 字节(fp16 / bf16)存;另外留一份 4 字节的全精度权重当主副本——更新参数时用它,
免得低精度的舍入误差攒起来。另一大块是
互动 · 拖参数量和 batch,看显存怎么涨
① batch size 减半 + 梯度累积(一次只算一半样本、攒两次再更新——等效批量不变,免费);
②
还有一个最容易被忽略的:忘了 zero_grad()。
梯度会一直累加,数值会失控——这也是「训练到一半突然 NaN」的常见原因之一。
下面是真的跑了 5 个随机种子、每个训练 30 轮的结果。 同一个 batch、同一个学习率、只有初始化不同——损失差距可以有好几倍。 这不是 bug,但你必须能控制它。
互动 · 五个种子的真实散布
互动 · 五条真实训练曲线:只有初始化不同
| 要固定的东西 | 怎么固定 | 不固定会怎样 |
|---|---|---|
| 随机种子 | random / numpy / torch / cuda 各自的种子都要设 | 初始化不同 → 结果差好几个点 |
| 数据顺序 | DataLoader 的 shuffle 用独立的生成器并固定种子 | 每个 epoch 的顺序都不同,曲线不可比 |
| 非确定性算子 | 开启确定性模式(会给一点速度损失) | 同样的输入,GPU 的累加顺序不同 → 末位不同 |
| 一次实验的记录 | 把超参数、代码版本、数据版本写进日志 | 你连「到底改了什么」都不知道,这比不可复现更严重 |
⚠️ 推翻一个直觉 即使全部固定,不同随机种子带来的指标波动通常也有 1~2 个点(小实验里更大)。 所以单次实验的微小提升没有意义——看到「涨了 0.3 个点」就改方案, 你很可能只是在追噪声。要跑多个种子取平均,或者只采信差距很大的改动。
第 2 节那张诊断树,教你把曲线的形状翻译成症状。它之所以成立, 是因为所有训练曲线都是同一个动作跑出来的:每走一步,参数朝下坡方向挪一小段。 把这句话写成大白话算术:下一步的误差 ≈ 这一步的误差 × |1 − 步长 × 曲率|。 这个乘数小于 1,误差就一路缩小;大于 1,误差就越来越大、飞出去。 「不降 / 震荡 / 爆炸」不是三个问题,是同一个乘数的三种取值。
互动 · 一个坑、一个小球:一步迈多大
微型图解 · 同样是这条式子,四种步子画出四种曲线
第 4 节的学习率扫描、第 5 节「亲手把它调坏」、第 2 节诊断树里 「上升 / 爆炸 / 震荡 / 不降」四个分支,全都是同一个乘数的四种取值: 步子太小 → 不降;步子合适 → 平稳下降;步子偏大 → 震荡;步子过大 → 爆炸。 你不是有四个问题,你只有一个问题:步子选得对不对。
Google 那本 《Deep Learning Tuning Playbook》给这类配置起了个名字,叫 不可行点(infeasible):发散、损失爆炸、或者根本跑不起来的那些配置。 它要求你在读每一次实验的结果之前,先数一件事:这类点占了多大比例?
下面这个演示真的对每一个学习率跑 60 步(就是上面那同一条式子), 然后数一数有几个真飞出去了。拖滑块改变这个问题的病态程度—— 看「飞掉的比例」怎么跟着变。
互动 · 扫一遍 12 个学习率,数一数有几个飞了
论文的原话是「当一次实验里不可行点占了很大比例时,该做的是调整搜索空间的定义—— 而在某些情况下,大量不可行点本身就意味着训练代码里有 bug」。
直觉是这样的:一个健康的问题,大部分学习率都应该能跑——只是快慢不同。
所以如果你扫 10 个学习率、有 8 个都炸了,那不是「学习率选得不好」,
那是代码或数据有问题:输入忘了归一化?初始化尺度太大?损失函数少了个 log?
学习率乘到了不该乘的地方?
(图里那条 1/3 的线是我加的经验参考线, 论文只说「很大比例」,没给具体数字。它的用处是给你一个可以争的锚点,不是一个标准。)
这就是这一章和《超参怎么选》的分界线。 在那一章里,你假设每个配置都能跑、只是好坏不同; 而在这里,「跑不起来」本身就是症状。见不得光的配置占比一高,就别再往下扫了——回去查代码。
上面所有方法都有同一个前提:你有一个可以反复跑的、诚实的实验循环。它不成立的时候:
| 处境 | 这些方法会怎么骗你 | 更该做的事 |
|---|---|---|
| 一次训练要三天 | 「过拟合一个 batch」 这类动作你根本跑不起,于是你会跳过它们——而它们恰恰是最有效的 | 先做一个小到能反复跑的版本(小模型 + 小数据);调试的成本必须便宜 |
| 数据集本身是坏的(标签错、重复、测试数据混进训练集——这叫泄露) | 模型会绕过坏数据,诊断树上的每一个叶子都「看起来正常」 | 抽样人工看 100 条训练数据:同一条出现两次、标签和内容对不上、测试集里的样本混了进来。这一步的收益经常超过所有调试动作之和 |
| 数据量小到无法留出验证集 | 任何「验证损失」都在抖,你分不清是过拟合还是噪声 | 用 |
| 线上分布和训练集不同 | 训练日志一切正常,上线就掉点——训练时那六个监控量看不到这件事 | 见 《超参怎么选》第 8 节:分布偏移——意思是线上数据和训练数据不是一个分布 |
| 你在调试一个「已经调了三周」的模型 | 你会越来越倾向于相信「就是这样」,而不再做最小的验证实验 | 换一个干净的环境重跑基线。经验值钱,但经验最容易掩盖 bug |
最后是这一章反复说的那个提醒——新手最常浪费时间的,是在影响很小的东西上反复折腾:
柱子的长度代表「这个改动通常能带来多少效果」——这只是主观排序,不是实测。从上面往下改。
⚠️ 反直觉但很重要 优化器、激活函数、层数这些「看起来很核心」的选择,实际上对最终效果的影响远小于数据质量。 一个用 Adam 的小模型在干净数据上,往往打败一个用 Muon(一种新优化器)的大模型在脏数据上——这是圈内经验,不是对照实验。 先把数据搞对,再考虑架构。
回到第 2 节那张「从症状点到病因」的图。先随便挑一个症状(比如「训练损失不降」),顺着箭头走到病因,然后照着那一格去做——你会发现它让你先去做的事,往往不是「加正则」或「调学习率」,而是先看一眼数据、先过拟合一个 batch。那个「先排除最简单的可能」的顺序,就是这一章真正在教的东西。
| 线 | 为什么这章不是它 |
|---|---|
| ① 表达力 vs 泛化 | 调试不改变表达力,它只是确认「代码有没有把该有的表达力用出来」 |
| ② 没有免费午餐 | 这里没有任何「假设世界长什么样」的选择 |
| ③ 规模会赢 | 不涉及——调试是反规模的:能用一个 batch 说明的问题,就不该用整个数据集 |
| ④ 学习即压缩 | 不涉及 |
| ⑤ 高维里的低维 | 不涉及——这一章的高维内容(张量的形状与量级)是尺寸,不是论点 |
| ⑥ 层层组合 | 不涉及——调试不看层数,看的是哪一步把数值搞坏了 |
| ⑦ 拧得动 | ✅ 这一章真正的位置:先默认「梯度下降本来能走通」。 静默广播、双重 softmax、fp16 下溢、学习率写错——它们全都在「优化本身没问题、只是被搞坏了」的前提下发生 |
| 暗线 | 这一章的回答 |
|---|---|
| A 信息流动 | 数据形状是本章第一嫌疑人。第 6 节的 bug 1 就是「形状对不上但不报错」; 第 8 节的 NaN 二分定位则是「沿着信息流动方向,找第一个非有限值」。 调试的一个通用动作就是:把形状和量级打在每一层上 |
| B 什么被牺牲了 · E 它假设了什么 | 为了可诊断性,牺牲了「一次跑到底」的舒适;而整套方法都假设你的实验可控、可重复、可比较——
种子固定、一次只改一个变量。这个假设不成立时,调试会退化成「凭感觉换参数」;
另一个常被忽视的代价:为了排查方便而关掉正则化 / |
| C 参数账本 | 16 字节/参数(权重 2 + 梯度 2 + fp32 主副本 4 + Adam 一阶矩 4 + 二阶矩 4)是训练的显存底价,
第 8 节的显存计算器就是这笔账。加上 |
| D 跑在什么上 | OOM 和 NaN 都是硬件边界反射到算法上的症状。显存爆了不是「模型太大」, 是「16 字节/参数 + 激活」这笔账超了;fp16 下溢不是数学问题,是指数位只有 5 位 |
| F 违背了哪个直觉 | 「代码跑得通」 ≠ 「算得对」。五个高频 bug 全都发生在代码无异常、损失在降的情况下。 所以判断训练对不对,不能看它跑没跑完,只能看两条曲线和每一层的形状 |
损失不降时,按这个顺序走完四步:① 照那套默认配置搭好 → ② 在小批上过拟合(查 bug)→ ③ 扫一遍学习率 → ④ 盯监控表开正式训练。 前两步能解决绝大多数问题,而且都是几分钟就能做完的。
从业者大部分时间花在调试和调参上,而不是在推导上。 所以值得把两张表背下来——「过拟合一个 batch 失败时的四种形状」和「五个最常见的 bug」。 它们覆盖了绝大多数「代码跑得通但结果不对」的情况。
别一上来就换优化器、加层数、调正则——那些是最不重要的旋钮。 上一章治「一开就炸」,这一章治「跑起来却不降」;只是不够好,那是《超参怎么选》。
上面讲的都是「够用」的版本。想往下挖,这里有三个入口—— 它们不是必修内容,是给想再往前走一步的读者准备的。