bobofamilyNIU CHUN
17/36

学习率该除以 3:四张图把「试出来」变成「算出来」

2026-09-0921 分钟—阅读

第十六篇结尾说,BatchNorm 把第十四篇那张诊断清单的前两级架空了:σ_W 差一千倍都无所谓, 饱和在现场被拉回。清单还剩什么?

往下推一级。 前两步现在是自动的,真正要看的是后面三张图, 而第四张给出的是整个诊断流程里唯一一个可以直接照做的动作。


四张图是怎么来的

四张图不是四段独立的代码 —— 前三张读的是同一次前向反向留下的三样东西,第四张是训练时一路记下来的。 所以先把来源说清楚:之前那份代码一样都拿不到,forward 只返回 logits,h、z 算完就扔。 先重构,才有得画。

先把网络换个形状

这个形状第五篇已经写过一遍了。那时候是在 Value 上搭 Neuron / Layer / MLP: self.layers = [...]、for layer in self.layers: x = layer(x)、 parameters() 把每层的参数拍平收上来。这次是同一个形状换成矩阵版,不是新想法。

三个类,每个只管三件事:

方法干什么为什么需要
__init__创建并保存自己的参数每层管好自己的东西
__call__前向计算,并把结果存进 self.out让 layer(x) 直接可调;四张图全靠这个 out
parameters()报告自己有哪些参数外面一行代码收齐

__call__ 第五篇点过:b(5) 就是 b.__call__(5) 的语法糖,PyTorch 的 nn.Module 也重载了它。这里只补一个当时没说的词 —— Tanh 明明没有参数,也得写 parameters():

class Tanh:
def parameters(self):
return []

为的是让外面这行不用特判:

parameters = [C] + [p for layer in layers for p in layer.parameters()]

这叫鸭子类型:Python 不看你是什么类,只看你能做什么。只要实现了 __call__ 和 parameters(),就能塞进 layers。接口统一,收集才能一行写完。

网络长这样

17 个对象 = 6 个 Linear + 6 个 BatchNorm1d + 5 个 Tanh。每 3 个一组、最后一组没有 Tanh —— 这就是为什么 Tanh 落在 2、5、8、11、14。后面图①②画的都是这五条线。

⚠️ 这个网络的 n_hidden 是 100,不是第十四到十六篇那个 200。 那三篇讲的是 30 → 200 → 27 的两层网络,这一篇是六层 ×100。 参数形状 (30,100) / (100,100) / (100,27) 只有这样,参数量才对得上 47024。

两处初始化调整:

with torch.no_grad():
layers[-1].gamma *= 0.1 # 输出层压小 → 初始 loss ≈ ln(27)
for layer in layers[:-1]:
if isinstance(layer, Linear):
layer.weight *= 5/3 # tanh 的 gain

第一行就是第十四篇那个「修正初始 loss」,只是换了作用点:之前改 W2 * 0.01, 现在最后一层是 BN,所以改它的 γ。

第二行解释了一件容易被当成疏漏的事 —— gain 为什么不写进 Linear.__init__。 类里只除 √fan_in,gain 留到建网络时统一乘。因为不同激活函数的 gain 不同 (tanh 是 5/3、ReLU 是 √2),类本身不该假设自己后面接的是什么。 两半合起来才是完整的 σ_W = gain / √fan_in。

四张图分别读哪个张量

图读的是什么时候记下来形状
① 前向激活值layer.out,五个 Tanh 层诊断那一格的前向(32, 100)
② 反向梯度layer.out.grad,同样五层同一次 backward(32, 100)
③ 参数梯度p.grad,七个二维张量同一次 backward(30,100)、(100,100)…
④ update/data训练循环里每步记的 ud整个训练过程20000 步 × 每步一组

前三张是同一次前向反向的三个切面 —— 所以它们必须在同一格里跑完再画,中间不能插别的。 第四张不一样,它得在训练循环里就开始记:

with torch.no_grad():
ud.append([((lr*p.grad).std() / p.data.std()).log10().item()
for p in parameters])

layer.out 这个字段就是上面那次重构换来的 —— 没有它,前两张图根本无从画起。

❗ 先记一个坑:evaluate 会把 layer.out 冲掉

一上来 layer.out.grad 全是 None,原因在这行:

print(f"train {evaluate(Xtr,Ytr):.4f} val {evaluate(Xdev,Ydev):.4f}")

evaluate 内部又跑了一次 forward,把 layer.out 全部覆盖成新的 —— 而那次在 @torch.no_grad() 里,产生的张量不在计算图中,永远不会有 .grad。

这是 self.out 这种设计的固有坑:它记录的永远是「最后一次前向」, 任何中途调用 forward 的地方都会把它冲掉。PyTorch 用 hook 机制绕开,这里是简化版。

解法是诊断单独跑一格,训练循环里不加 retain_grad()(反正会被覆盖,还白占内存):

ix = torch.randint(0, Xtr.shape[0], (batch_size,))
loss = F.cross_entropy(forward(Xtr[ix]), Ytr[ix])
for p in parameters:
p.grad = None
for layer in layers:
layer.out.retain_grad() # ← 必须在 backward 之前
loss.backward()

跑完这格立刻画图,中间不要再调 evaluate。

为什么要 retain_grad():W1.grad 这类参数梯度默认保存,而 ∂L/∂h 这类中间梯度 算完就扔 —— 因为没人会写 h.data += -lr * h.grad,它只是算出 ∂L/∂W1 的必经之路。 第十三篇手算反传、要把中间梯度打印出来核对时,用过一次这个开关。它就是 3B1B 说的那根接力棒: 本身不更新任何东西,但下一层要靠它继续往前推。

❗ no_grad 和 training=False 是两回事

第十一篇用过 @torch.no_grad() 写 evaluate,当时网络里还没有 BN,所以不构成问题。 现在有了,这两个开关必须分清:

控制什么
torch.no_grad()要不要记计算图 —— 省内存和时间,不影响数值
layer.training = FalseBN 用当前 batch 的统计量还是 running —— 直接影响结果

评估时两个都要。 只写 no_grad 不切 training,BN 会拿验证集自己的统计量去标准化 —— 等于让模型偷看了验证集的分布,结果会偏乐观。

实测

参数量 47024 层数 17
0/20000 loss 3.3298 ← 初始 loss 直接就在 ln(27) = 3.2958
5000/20000 loss 2.2419
10000/20000 loss 2.1913
15000/20000 loss 2.1039
train 2.1339 val 2.1531 耗时 40.8 秒

没有 hockey stick —— 模型从「诚实的无知」起步,没有拿前几百步去纠正初始化错误。 第十四篇那条曲线开头那一段陡降,这次没有。

模型层数步数val
手调 0.2213 万2.0994
BN + running213 万2.1106
6 层 + BN62 万2.1531

六层反而更差 —— 步数差 6.5 倍,而且 15000 步时 loss 还在往下走。 这一段的目的不是刷分,是建立结构。 有了 layer.out,四张图才画得出来。


图 ①:前向激活值

matplotlib 折线图,标题「前向激活值分布」。五条彩色曲线分别标为 layer 2 / 5 / 8 / 11 / 14 (Tanh),横轴 −1 到 1,纵轴密度 0.2 到 1.4。五条线互相缠绕在一起,整体是一条很浅的 U 形:中间约 0.3,两端升到约 1.0,左右大致对称
跑出来的第一张图。五条线是五个 Tanh 层,纠缠在一起没有分层 —— 这就是「分布没有逐层漂移」的样子。U 很浅(两端约为中间的 4 倍),左右对称。看原图 ↗

这张图画的是五个 Tanh 层的输出 h,横轴是 h 的取值(被 tanh 限死在 −1 到 1),纵轴是密度。

为什么用密度不用个数:五层要叠在一起比。如果某层维度不同,用「个数」画它天然矮一截, 比的就成了层大小而不是分布形状。密度 = 个数 ÷(总数 × 格宽),面积恒为 1。 换算一下:3200 个数、格宽 0.04,某格 160 个 → 密度 = 160 / (3200 × 0.04) = 1.25。

先读数字,再看图

layer 2 (Tanh): mean +0.00 std 0.64 saturated 5.4%
layer 5 (Tanh): mean -0.01 std 0.66 saturated 6.2%
layer 8 (Tanh): mean -0.01 std 0.67 saturated 5.4%
layer 11 (Tanh): mean +0.00 std 0.68 saturated 4.3%
layer 14 (Tanh): mean -0.01 std 0.69 saturated 3.5%

这五行比图更快给出结论。 三列各回答一件事:mean 是中心在哪(该 ≈ 0)、 std 是散得多开(逐层不变最重要)、saturated 是多少比例学不动了(1—10% 算健康)。

mean 全是 0 是 BN 的功劳。tanh 是奇函数,均值偏在正半边等于只用了它的一半, 而 BN 的第一步就在强制拉回 0。那个 ±0.01 是抽样噪声。

⚠️ 每层比值是 1.019,不是 1.08

0.64 → 0.66 → 0.67 → 0.68 → 0.69,五层累计涨 8%。这个数对。

但五个值之间只有 4 次传递,所以每层比值是 (0.69/0.64)^(1/4) = 1.019,不是 1.08。 按 1.08 推 50 层会得到 48 倍,按 1.019 推是 2.6 倍:

每层比值20 层后30 层后50 层后
0.63.7e−52.2e−7信号没了
1.019(实测)1.461.762.56
1.533251.9e5爆炸

结论没变 —— 看一眼 std 那一列,就知道这个架构能不能堆深,答案仍然是能。 但数字要写对,后面图②还要拿它做对照。

⚠️ 这一批数字是 0.97 口径

饱和是这么算的:

saturated = (t.abs() > 0.97).float().mean() * 100

阈值是人定的。 第十四篇那一批用的是 0.99,这一段 Karpathy 换成了更严格的 0.97。 atanh(0.97) = 2.092,而 atanh(0.99) = 2.647 —— 同一个模型,换把尺子数字就变。 所以这一篇的 5.4% 不能和第十四篇的任何饱和数字直接比。

阈值本身选在哪,看的是梯度还剩多少:

|h|1 − h²梯度剩多少
01.000100%
0.90.19019%
0.970.0595.9%
0.990.0202%

❗ 饱和不是越少越好

两种失败长得不一样,但都是没区分度:一个挤在 0(tanh 退化成恒等映射,深度白加),一个挤在 ±1(梯度被掐掉)。曲线由正态密度解析推出,不是采样;饱和比例用 erf 现算。

左边那格是关键:饱和 0%,但 σ_h / σ_z = 0.96 —— tanh 在这一段几乎就是恒等函数。 六层叠起来等价于一次矩阵乘,深度白加了。看到 0% 要反过来查 std,别以为越低越好。 这也正是 Kaiming 的目标是 σ = 1 而不是「越小越好」的原因。

中间那格的 σ_z = 1.10 给出 σ_h = 0.655、饱和 5.7%,和实测的 σ_h ≈ 0.65、5.4% 对得上。

U 形本身不是病,是 tanh 的必然结果:

z 的区间宽度映射到 h宽度
−0.5 到 0.51.0−0.46 到 0.460.92
2 到 31.00.964 到 0.9950.031

大的 z 被挤进 h 靠近 ±1 的一小段,那里密度自然高。关键是深浅 (下面这三个倍数是从直方图分箱量出来的,和上图解析算出的比例不是同一个量,不要并排比):

版本两端 / 中间饱和
V4 最初~50 倍62.3%
手调 0.2~2.5 倍1.4%
现在~4 倍~5%

还要分清两个说法

「5.4% 的激活值处于饱和」对;「5.4% 的神经元死了」不对。 同一个神经元对不同样本输出不同 —— 可能对样本 A 饱和、对 B 正常。真正死掉的是对所有样本都饱和的:

dead = (h.abs() > 0.97).all(0) # 沿样本方向全部为 True

图 ②:反向梯度

和图①只差一个字:

t = layer.out.grad # 图 ① 是 layer.out

这张图最容易卡在「layer.out.grad 全是 None」上 —— 那个坑和它的解法 在上面「四张图是怎么来的」里已经说清楚了,诊断要单独跑一格。

matplotlib 折线图,标题「反向梯度分布」。五条曲线标为 layer 2 / 5 / 8 / 11 / 14 (Tanh),横轴约 −0.025 到 0.022,纵轴 0 到 190。五条线几乎完全重合,合成一条对称的钟形:峰值在 0 附近约 175 到 190,两侧迅速衰减,到 ±0.02 已经贴近 0
换成 layer.out.grad 之后画出来的样子。和图①最大的差别一眼可见:这是钟形不是 U 形,而且横轴只有 ±0.02 —— 比图①窄了 100 倍。五条线依旧重合。看原图 ↗

形状:钟形,不是 U 形

这是图②和图①最大的视觉差异,原因很直接:梯度没有边界限制。 它是自由的实数,不被 tanh 压缩,很多项相加,中心极限定理给出正态形状。 尖峰在 0 附近说明大部分位置梯度很小,只有少数位置需要大幅调整 —— 这是正常的。

顺带一提,横轴范围也差了 100 倍:图①是 −1 到 1,图②是 −0.02 到 0.02。梯度本来就是很小的数。

实测

layer 2 (Tanh): mean -0.000000 std 4.116130e-03
layer 5 (Tanh): mean +0.000000 std 3.720852e-03
layer 8 (Tanh): mean -0.000000 std 3.545590e-03
layer 11 (Tanh): mean +0.000000 std 3.342206e-03
layer 14 (Tanh): mean +0.000000 std 3.152745e-03

五层全在 e-03,最大最小只差 1.3 倍。对照灾难性情况,梯度消失时两端能差 10 万倍。

注意方向反过来了:反向传播从 loss 往前走,起点是 layer 14(3.15e−3),终点是 layer 2(4.12e−3)。 每层比值 (4.12/3.15)^(1/4) = 1.069。

mean 精确为 0,比图①那个 ±0.01 更彻底 —— 梯度的正负是对称的, 有些样本要调大这个神经元、有些要调小,整体抵消。如果 mean 明显偏离 0, 说明所有样本都想往同一个方向推,通常意味着还有很大改进空间(训练早期会这样)。

⚠️ 两个方向都健康,但不是「几乎一样」

每层比值20 层后30 层后50 层后
前向(图①)1.019×1.46×1.76×2.56
反向(图②)1.069×3.79×7.39×28.0

两个都不爆炸也不消失,「这个 6 层网络可以堆到 20—30 层」成立。 但前向每层涨 1.9%、反向涨 6.9%,反向的漂移是前向的 3.6 倍, 说「两个方向几乎相同」是把「五层累计 8%」和「每层 6.9%」放在一起比了。

为什么图②比图①重要

衰减了会怎样能补救吗
前向信号后面的层收到的信息弱能 —— 后面的层可以放大回来
反向梯度前面的层收不到更新信号不能 —— 学不了就是学不了

梯度消失意味着前几层的参数永远停在初始值。这就是 2015 年之前深网络训不动的直接原因, 也是第十二篇提到的残差连接要解决的问题。


图 ③:参数梯度

前两张画的是层的中间结果,这张画的是参数本身的梯度 —— p.grad, 那个真正用来更新的东西:p.data += -lr * p.grad。

matplotlib 折线图,标题「参数梯度分布」。七条曲线标为 0 (27,10)、1 (30,100)、4 / 7 / 10 / 13 (100,100)、16 (100,27),横轴 −0.08 到 0.08,纵轴 0 到 175。0 号在 0 处窜起一根又高又窄的尖峰约 177;16 号约 165,尾巴铺得最宽,一直到 ±0.08;中间四层的峰都落在 60 到 80 之间,形状彼此接近
那根窜到 177 的尖峰是 0 号,嵌入表 C —— 一个 batch 只碰到 27 行里的一部分,没被碰到的行梯度是精确的 0,全堆在中心。中间四层的曲线彼此贴合,这就是「学得一样快」的样子。看原图 ↗

❗ 这是四张里唯一一张「图不如数字」的。 图上画的是梯度值的分布, 而这一节真正要读的是 grad/data —— 那是梯度 std 除以参数自身 std, 图上没有这个量,它在打印出来的那一列里。图的作用只剩一个:把 C 那根尖峰给你看见。

七个编号为什么跳着走

代码里只挑二维的:

if p.ndim == 2: # 只挑权重矩阵
下标形状是谁
0(27, 10)C 嵌入表
1(30, 100)第 1 层 Linear
2, 3(100,)第 1 层 BN 的 γ、β ← 被跳过
4, 7, 10, 13(100, 100)第 2—5 层 Linear
16(100, 27)输出层 Linear

间隔 3 = 1 个 Linear 权重 + 2 个 BN 参数。 跳过 γβ 是因为它们是一维的、量级不同,混在一起看会乱。

❗ 看 grad/data,不看 std

ratio = σ(梯度) / σ(参数本身)

读作:这个参数张量,每一步大约要被改动百分之几。

weight (27, 10) | std 1.194477e-02 | ratio 1.183552e-02
weight (30, 100) | std 9.806189e-03 | ratio 2.992686e-02
weight (100,100) | std 7.715235e-03 | ratio 4.201583e-02
weight (100,100) | std 6.782562e-03 | ratio 3.722516e-02
weight (100,100) | std 6.164674e-03 | ratio 3.422855e-02
weight (100,100) | std 5.627475e-03 | ratio 3.148335e-02
weight (100, 27) | std 1.065568e-02 | ratio 5.203685e-02

看梯度 std:C 最大。看 ratio:C 最小。结论完全相反。 这就是要除一下的理由 —— 绝对的改动量没有意义,相对变化率才有。

中间四层非常整齐:4.20 → 3.72 → 3.42 → 3.15(×10⁻²), 四个 (100,100) 层量级完全一致,没有哪一层在狂奔或停滞。

两头偏离,各有各的原因

用 ratio = 梯度 std ÷ 参数 std 反推一下每个张量自身的 std,两头就清楚了:

ratio反推的参数 std梯度 std
C 偏小1.18e−21.0091.19e−2(最大)
中间四层~3.5e−2~0.181~6e−3
输出层偏大5.20e−20.2051.07e−2

C 的 ratio 最小,主因是分母:C 是 randn 未缩放的,自身 std ≈ 1.0, 是中间层的 5.5 倍,而它的梯度只比中间层大约 2 倍,除完自然最小。 梯度稀疏(一个 batch 只碰到 27 行里的一部分,大量精确的 0)让分子没能涨得更多 —— 这正是第十三篇那张全是 0 和 1 的雅可比矩阵:∂L/∂C 只在被用到的行上非零。

⚠️ 输出层这一头,笔记给的第一个理由不成立。 笔记说它「被 gamma *= 0.1 压小过,分母小」。但反推出来,输出层自身的 std 是 0.205, 是六个权重矩阵里最大的,不是最小的 —— gamma *= 0.1 压的是 BN 的 γ,不是 Linear 的权重。 它 ratio 大是分子大:梯度 std 1.07e−2,是相邻那层的 1.89 倍。 第二个理由才是真的:它离 loss 最近,梯度天然更强。

病态会是什么样:某层 ratio 小 100 倍 = 那层几乎不学;大 100 倍 = 可能震荡发散; 逐层递减 = 梯度消失的参数版,这时候要回去修图②,调 lr 没用。

⚠️ 但图③只是一步的快照。 两万步里任何一步都可能是特例。


图 ④:update/data

前三张是快照,这张是整个训练过程的走势 —— 四张里最实用的一张。

matplotlib 时间序列图,标题「update : data 比值(log10)」。七条带状曲线标为 param 0 (27,10)、1 (30,100)、4 / 7 / 10 / 13 (100,100)、16 (100,27),横轴训练步数 0 到 20000,纵轴 −2.0 到约 −4.2,−3.0 处一条黑色水平虚线。七条线都从约 −3.8 起步,几百步内迅速爬升,之后长期平稳:16 号最高约 −2.3,中间四层约 −2.4,1 号约 −2.6,0 号约 −2.9 正好压在虚线上。15000 步处七条线同时整齐下跌约一格,之后停在 −3.3 到 −4.0 之间。每条都是有明显抖动的带子,不是细线
15000 步那一跌整整一格,就是取 log10 的全部理由 —— lr ÷ 10 在对数坐标下是减 1。七条线同时跌、跌幅一样,一眼就能确认衰减真的生效了;表格给得出跌前跌后的数值,给不出「整齐」这个视觉。看原图 ↗

先说一件图上才看得见的事:七条都是带子,不是线。 每一步换一个 mini-batch, 比值本来就在抖,带子的厚度就是这个抖动。它也是一条诊断线索 —— 速查表里「抖动幅度突然变宽 = lr 过大」说的就是这个厚度,这里从头到尾没变宽。

公式里每个量是什么

纵轴 = log10( σ(lr × grad) / σ(data) )

以 W1 为例:参数自身 std ≈ 0.33,这一步的梯度 std ≈ 9.8e−3, 乘上 lr = 0.1 得到实际改动量 ≈ 9.8e−4,比值 0.30%,取 log10 就是 −2.52。

为什么要除以 data:「改动 0.001」这个数本身没有意义,要看它相对参数自身有多大。

参数 std改动量比值判断
0.300.0010.33%✅
0.0010.001100%❌ 一步翻倍,要炸
1000.0010.0001%❌ 一万步才动 1%

为什么取 log10:三个理由,最后一个才是关键。跨几个数量级时线性坐标下 0.0001、0.001、0.01 全挤在一起;经验值 1e-3 变成一条整数线 -3,直接 axhline(-3); 而乘除变加减 —— lr ÷ 10 在图上就是整齐跌 1 格。这和第十一篇那个对数扫描是同一个道理: 倍数关系在对数坐标下变成等距。

四个阶段

阶段位置解释
0—2500 步从约 −3.8 爬到 −2.5初始 gamma *= 0.1 压小了输出,梯度也小;随着模型开始学,比值爬到稳定水平。图上这一段几乎全发生在前几百步,之后只是慢慢抬到位
2500—15000稳定在 −2.5✅ 平稳
15000 步整齐跌 1 格lr 从 0.1 降到 0.01
15000—20000稳定在 −3.5⚠️ 偏低

七条线在衰减前的位置:输出层 −2.35、中间四层 −2.4~−2.5、第一层 −2.6、 C 在 −2.95,正好压在基线上。

图④ 就是图③ 乘上 lr

这句话可以直接对账。把图③的 ratio 乘上 lr = 0.1 再取 log10:

参数由图③ 推出图④ 实际位置
C−2.93−2.95
第一层−2.52−2.60
中间四层−2.38 … −2.50−2.4 ~ −2.5
输出层−2.28−2.35

逐条吻合,最大差 0.08 —— 而图③是某一步的快照、图④是整段的水平, 这个量级的差正是应该有的。七条线的位置差异完全来自分母,和图③的 ratio 是同一件事。

三层判定:走势 > 位置 > 层间差距

先看走势。 启动爬升后平稳,或者一直平稳,都是 ✅。 持续上漂说明参数在缩小或梯度在变大,要失控;持续下沉说明学习在停滞; 非 lr 衰减的突然跳变则是有异常。

再看位置,相对 −3 这条基线:

偏离倍数判断lr 该乘几
±0.3 以内0.5~2✅ 理想不用改
±0.3~0.72~5可接受(本次)可选微调
高 0.7~1.55~30⚠️ 偏大÷ 10^(偏离量)
低 0.7~1.55~30⚠️ 偏小× 10^(偏离量)
超过 ±1.530 倍以上❌ 明确有问题先回去查图①②

最后看层间差距。 本次跨度是 −2.35 到 −2.95,0.60,也就是不到 10 倍,健康。 跨度超过 2(100 倍)时要单独调那一层的初始化,不是调全局 lr。

补上第十一篇的缺口

第十一篇学对数学习率扫描时,结论是一句很硬的限制:「它的价值是排除,不是选优。」 U 形曲线有历史依赖,只能把范围从 ×1000 压到 ×40 就到头了。

update/data 补的正是这一段:

新 lr = 旧 lr × 10^(−3 − 当前位置)

当前 −2.5,目标 −3,代进去就是 × 10^(−0.5) = ÷ 3.2。

从「试出来」变成「算出来」。 扫描曲线负责粗筛,这个比值负责精调。

这张图给出的行动项

前三张全绿,第四张是唯一给出动作的:前段偏高 0.5,而且衰减跨度太大。

lr = 0.05 if step < 15000 else 0.02 # 两段都更贴近 -3
# 或者平滑衰减
lr = 0.1 * (0.1 ** (step / max_steps))

衰减后停在 −3.5,意味着每步只把参数改动 10^(−3.5) ≈ 0.032%, 而衰减前是 10^(−2.5) ≈ 0.32% —— 后 5000 步干的活,大约相当于之前的 500 步。 loss 在最后那段几乎不动,原因就在这儿:0.1 直接砍到 0.01,跨度太大。

🔑 这正是这套工具该有的样子:不是告诉你「模型好不好」,而是告诉你下一步该调什么。


诊断手册

四张图读完,把结论压成一页,调模型时直接查:

一张竖版中文速查海报「神经网络诊断手册 · 四张图速查」。顶部一条七步流程条:小数据过拟合到 0 → 初始 loss = ln(类别数) → 图① 前向激活值 → 图② 反向梯度 → 图③ 参数梯度 → 图④ update:data → 都正常则调容量或数据。下面四个分区各是一张「现象 / 诊断 / 处方」三列表,健康项标绿、异常项标红、需要留意的标橙;底部是三个必须记住的前提和两段可直接复用的代码
调模型时从上往下依次查 —— 上一层没修好之前,下一层的指标不可信。窄屏上点开看原图。看原图 ↗

海报顶部那条流程就是第十四篇那张查错顺序,这里不重讲;下面按图逐张展开, 补的是第十四篇没有的部分:每张图具体看什么。

图① 前向激活值

看三个数:std、saturated、mean。

现象诊断处方
std 逐层变小(如 0.6→0.1)信号衰减调大 gain 或加 BN
std 逐层变大信号放大调小 gain
std 稳定(比值 0.9~1.1)✅—
饱和 > 30%大量神经元学不动缩小权重或加 BN
饱和 1~10%✅—
饱和 ≈ 0% 且 std 很小⚠️ 退化成线性,深度白加调大 gain
mean 明显偏离 0分布偏斜,tanh 只用了一半加 BN,或检查 BN 是否生效

图② 反向梯度

只看一件事:五层 std 是不是同一量级。 方向是 layer 14 → layer 2, 衰减的话第一层(layer 2)的 std 最小。

现象诊断处方
各层都在 e-03,比值 < 2✅ 梯度传得动—
layer 2 比 layer 14 小 100 倍以上❌ 梯度消失加 BN / 调 gain / 加残差连接
layer 2 比 layer 14 大 100 倍❌ 梯度爆炸调小 gain / 梯度裁剪
某一层突然断崖那层有问题单独查那层的初始化和激活
全是 None没调 retain_grad(),或被 evaluate 冲掉诊断单独跑一格

图③ 参数梯度

只看 grad/data 那一列,不看 std。

现象诊断处方
中间层 ratio 量级一致✅ 学习速度均衡—
某层小 100 倍那层几乎不学查那层参数是不是太大
某层大 100 倍可能震荡发散查那层参数是不是太小
逐层递减梯度消失的参数版先回去修图②

两个可以忽略的偏离:嵌入表 C 的 ratio 偏小、直方图有尖峰(参数本身大 + 梯度稀疏); 输出层的 ratio 偏大(离 loss 最近)。

图④ update/data

三层判定按优先级:走势 > 位置 > 层间差距,三张表在上一节。这里只补衰减检查:

现象含义
衰减点整齐跌 ∆✅ 生效,∆ = log10(衰减倍数)
衰减后掉到 −3.5 以下⚠️ 衰减跨度太大,后段白跑
衰减后仍在 −2.5衰减太小,没起作用

❗ 三个必须记住的前提

前提说明
前三张图得先绿梯度消失时图④会贴在 −5 以下,这时候调 lr 没用
−3 不是铁律SGD 小网络 −3;Adam 可能 −2.5;微调通常 −4 左右
最终标准是 val loss四张图都绿但 val 不降 → 问题在容量或数据,不在这四张图上

可以直接抄的代码

诊断前置,单独一格,跑完立刻画图:

ix = torch.randint(0, Xtr.shape[0], (batch_size,))
loss = F.cross_entropy(forward(Xtr[ix]), Ytr[ix])
for p in parameters: p.grad = None
for layer in layers: layer.out.retain_grad()
loss.backward()

训练时记录 ud(图④需要):

with torch.no_grad():
ud.append([((lr*p.grad).std() / p.data.std()).log10().item()
for p in parameters])

不画图,一键扫一眼四组数字:

print("—— 图① 前向激活 ——")
for i, l in enumerate(layers[:-1]):
if isinstance(l, Tanh):
t = l.out
print(f" {i:>2} std {t.std():.3f} 饱和 {(t.abs()>0.97).float().mean()*100:.1f}%")
print("—— 图② 反向梯度 ——")
for i, l in enumerate(layers[:-1]):
if isinstance(l, Tanh):
print(f" {i:>2} std {l.out.grad.std():e}")
print("—— 图③ 参数梯度 ——")
for p in parameters:
if p.ndim == 2:
print(f" {str(tuple(p.shape)):<12} ratio {p.grad.std()/p.std():e}")
print("—— 图④ update/data ——")
for i, p in enumerate(parameters):
if p.ndim == 2:
print(f" param {i:>2} 最后一步 {ud[-1][i]:+.2f}")

还没做的两件事

V4 到此完结。两项对照实验留着,这里照实记下,不替它们补结论:

  • gain 改成 0.5 跑一遍,看病态的四张图长什么样 —— 现在只见过健康的, 没有对照就说不上「会读」
  • 按图④的建议重调学习率(0.05 / 0.02),看 val 能不能降到 2.1531 以下 —— 这是把「算出来」这句话真正兑现的一步

从第十四篇到这一篇,V4 这四篇讲的是同一件事的四个侧面: 先知道要量什么(三个指标),再知道怎么让它自动成立(BatchNorm), 最后知道怎么把量出来的东西读成一个动作。

前三张图的价值是让第四张可信 —— 梯度都传不到的时候,调学习率是在调一个不存在的问题。