bobofamilyNIU CHUN
14/36

饱和从 62.3% 到 1.4%:四行初始化,和一套查错顺序

2026-09-0811 分钟—阅读

第十篇结尾留了三个毛病,第十二、十三篇又各承诺了一次:初始 loss 该是 3.30 却是 17; h 里贴着 ±1 的比例远超应有;梯度穿过 tanh 只剩一半。这一篇修。

改动一共四行,全在初始化上。val 从 2.1752 降到 2.0994 —— 只降了 0.076。 所以这一篇的价值不在这个数,在于修的过程给出了一套可复用的东西: 拿到一个不收敛的模型,该按什么顺序查。


先分清 z 和 h

后面全篇在谈 σ、z、h。这三个字母不分清,后面的推导会一直打滑。

名字形状是什么类型
X(1, 3)3 个字符编号数据
C(27, 10)嵌入表,每行一个字符参数
x(1, 30)3×10 拼成的输入向量激活值
W1, b1(30,200), (200,)隐藏层参数参数
z(1, 200)tanh 之前的加权和激活值
h(1, 200)tanh 之后,必在 (−1, 1)激活值
W2, b2(200,27), (27,)输出层参数参数
logits(1, 27)27 个字符的打分激活值

z 是 tanh 的输入,可以是任意实数;h 是 tanh 的输出,永远在 −1 到 1 之间。 下面说「z 太大」指的是输入,说「h 饱和」指的是输出被压到了 ±1 —— 两件事,一个因一个果。


一个样本走一遍

取上下文 e m m(编号 [5, 13, 13]),正确答案 a。全部参数用 randn,也就是标准差 1。

两次放大分别发生在两个矩阵乘上,两处修改正好落在这两点。中间 tanh 那一步不放大,它只做截断 —— 但截断的代价是六成神经元再也动不了。

链条上的每一步:

① 查表 C[5] 和 C[13] 各是 10 个普通实数,初始时毫无意义
② 拼接 x = 3×10 = 30 维 σ_x = 0.867 ✅ 健康
③ @W1 z (1, 200) σ_z = 4.916 ❌ 放大了 5.7 倍
④ tanh h (1, 200) σ_h = 0.904 ❌ |h| > 0.99 占 61.5%
⑤ @W2 logits (1, 27) σ = 11.74 ❌ 又放大一次
⑥ softmax loss = 45.84 理论值 ln 27 = 3.2958

x 健康是有原因的:它直接抄自 C,而 C 就是 randn。病是从第三步开始的。

六个数就是全部问题的缩影

把 z 的前六项和它们过完 tanh 的结果并排放:

z: 2.87 4.30 4.51 5.57 0.40 −7.70
h: 0.9936 0.9996 0.9998 1.0000 0.378 −1.0000
└───────── 全都贴死在 ±1 ─────────┘ ↑活着 └死┘

z = 5.57 那个,h = 1.0000,梯度系数 1 − h² = 0 —— 这个神经元永远不会再更新了。 而 z = 0.40 那个,h = 0.378,梯度系数 0.857,它是健康的。

一个 batch 里 200 个神经元有 62.3% 处在第一种状态。换算成可用容量: 200 × 37.7% ≈ 75 个真正在学,剩下 125 个只是在耗计算量。

45.84 和 17 不矛盾

上面这个样本的 loss 是 45.84,而第十篇说的是 17。两个都对: 45.84 是单个样本,它恰好倒霉,模型把概率全压在了另一个字符上;batch 平均之后是 17。

第十篇也已经把 17 这个数拆开过了 —— e⁻¹⁷ ≈ 4.1×10⁻⁸, 模型给正确答案的概率大约是两千四百万分之一,它不是在瞎猜,是在非常自信地给出错误答案。 这一篇不重复那段推理,只补一句第十篇没说的:loss 比 3.30 小反而更可疑, 那通常意味着数据泄漏,或者理论值算错了。

顺带一笔:调参把病调重了

第十篇那个网络是 6 个输入、100 个隐藏神经元,σ_z 大约 2.6。 第十二篇为了降 val,把 n_embd 从 2 调到 10、n_hidden 从 100 调到 200 —— 输入维度跟着从 6 变成 30。

于是放大因子从 √6 = 2.4 变成 √30 = 5.5,σ_z 从 2.6 涨到 4.9, 饱和比例从大约一半涨到 62.3%。第十二篇那次调参确实把 val 降下去了,同时也把这个病加重了。 两件事互不知情 —— 这正是「先修初始化再调超参数」的理由。


三个指标为什么是这三个

不是随便挑的,每个对应一个具体的失败模式。

指标一:初始 loss

L_init = −ln(1/|V|) = ln|V|

27 个字符 → 3.2958。这是理论值,不是经验值:一个什么都没学过的模型, 最诚实的输出就是均匀分布。

实测含义
= 3.30✅ 诚实的无知
≫ 3.30自信地犯错 → 输出层权重太大
< 3.30数据泄漏,或者理论值算错了

这个指标最该先查:一行 print 就能拿到,理论值确定,不依赖任何超参数。

指标二:饱和比例

目标是 |h| > 0.99 的比例 低于 5%。盯它是因为它直接决定梯度能不能传回去:

∂h/∂z = 1 − h²
h1 − h²学习速度
01.00正常
0.90.19慢 5 倍
0.990.02慢 50 倍
0.9990.002基本停止

饱和的神经元就是死神经元(dead neuron)。第十篇那个隐藏层动画里, 柱子下面标红的「已饱和」说的就是这件事。

要区分两个阶段。 训练后期出现饱和是正常的 —— 模型确实学到了强规律, 该确信的地方就该确信。初始化就饱和是病:模型什么都没学过, 凭什么这么确信?它是被随机数推到那个位置的。

指标三:逐层标准差比值

目标是 σ_out / σ_in ≈ 1。这是最根本的一条,因为误差会逐层累乘:

每层比值10 层之后
1.557 倍 → 爆炸
1.01 倍 ✅
0.50.001 → 消失

前两个是症状,这个是病根。 初始 loss 太大是因为 logits 那一层的比值失控, 饱和是因为 z 那一层的比值失控 —— 两个症状同一个原因。


σ_z = √n · σ_W · σ_x

一个神经元在做的事:

zⱼ = Σᵢ xᵢ · wᵢⱼ,一共 n = 30 项

两条统计规则就够推:

规则内容前提
相乘Var(xw) = σ_x² σ_W²独立 + 均值 0
相加Var(A+B) = Var(A) + Var(B)独立

第二条的关键是交叉项消失:Var(A+B) = E[A²] + 2E[AB] + E[B²], 独立时 E[AB] = E[A]E[B] = 0。

反例:Var(a+a) = 4Var(a),不是 2 —— 因为 a 跟自己完全相关,交叉项没消失。 这个反例后面还会再用一次。

合起来:

Var(z) = n · σ_x² σ_W² ⟹ σ_z = √n · σ_W · σ_x

√n 来自「方差相加、标准差开方」。 30 项相加只放大 5.5 倍,不是 30 倍, 因为有正有负会互相抵消。随机游走是同一回事:抛 100 次硬币, 最后离原点大约 √100 = 10 步,不是 100 步。

对一遍:√30 × 1.0 × 0.867 = 4.75,实测 4.92 —— 差 3.5%。 单个样本只有 30 个数,而公式给的是分布的性质;同一个 batch(960 个数)上, 理论 4.99、实测 4.96,才是严丝合缝的那组。

这一节只把公式用起来了。 期望和方差的定义、三条规则各自怎么推、 为什么可加的是方差而不是标准差 —— 下一篇整篇在讲。

反解出 σ_W

想让 σ_z = σ_x,直接把公式倒过来:

σ_W = 1 / √n

再补一个系数。tanh 会压缩信号(tanh(1) = 0.76),所以要预先放大一点:

σ_W = gain / √fan_in
激活gain理由
线性1不压缩
tanh5/3 ≈ 1.667补偿压缩
ReLU√2 ≈ 1.414砍掉一半负值,方差减半

这就是 Kaiming 初始化,何恺明 2015 年提出,同一篇工作后来发展成 ResNet。 在此之前是 Xavier,没考虑 ReLU 的不对称性。

fan_in 不是 fan_out

W1.shape # (30, 200)
# ↑ ↑
# fan_in fan_out

n 是求和的项数 —— 30 项相加。200 只是「这样的计算做了 200 遍」,每遍互不影响。

模式保证什么
fan_in(默认)前向传播时激活值方差不变
fan_out反向传播时梯度方差不变

两者不能同时满足,除非 fan_in = fan_out。Xavier 取了折中:√(2/(fan_in+fan_out))。


输出层是特例

W2 用 0.01 而不是 Kaiming,因为目标根本不同:

层目标手段
隐藏层σ_out = σ_inKaiming
输出层logits ≈ 0压到很小

输出层后面接的是 softmax 不是 tanh —— 它不怕小,就怕大。logits 全是 0 时 softmax 给出均匀分布,loss 正好是 ln 27,也就是那个「诚实的无知」。

但不能设成 0:

∂L/∂h = ∂L/∂logits · W₂ᵀ = 0

W2 全零,梯度就传不回去,前面所有层永远不更新。这叫对称性破缺问题。 要小,但不能是 0。

通用原则:最后一层永远是特例。 分类任务压 logits,回归任务对齐目标均值, 生成任务另有讲究 —— 前面各层讲的是「信号别变形」,最后一层讲的是「起点在哪」。


一个诚实的结果:Kaiming 输了

四个版本实测:

版本σ(W1)σ(W2)初始 loss饱和val
V3 原始1.01.01762.3%2.1752
仅修 W21.00.013.339462.3%—
手调 W10.20.013.31871.4%2.0994 ✅
Kaiming0.3040.013.329.4%2.1087

公式反而不如拍脑袋。 原因不难看:Kaiming 优化的是「信号传过多层不衰减」, 而这个网络只有 2 层。

h/x2 层后10 层后
手调 0.20.600.360.006
Kaiming0.780.610.08

收益要等到深了才兑现,代价(饱和 1.4% → 9.4%)当场就付。

初始化没有「最优」,只有「匹配」。 浅网络可以手调得更极致; 深网络必须用公式,否则根本训不动。

两个目标本身就在打架:不饱和希望 z 小,不衰减希望 z 大。 Kaiming 只能折中 —— 真正同时解决两者的是 BatchNorm。


诊断清单

上面所有东西合起来,就是一张「拿到新模型怎么查」的清单。

右边那组箭头刻意错开一格 —— 错位本身就是「顺序为什么不能反」的理由。前四步只用 print,不用训练。

第 0 步:确认代码没错。 5 个样本训 200 步,loss 能不能降到 ~0? 降不下去就别调超参数了,先找 bug。两分钟,能省几小时。

第 1 步:查初始 loss。

print(f"初始 loss {loss.item():.4f} 理论 {math.log(n_classes):.4f}")

远大于理论值 → 输出层权重太大 → W_last *= 0.01,b_last = 0。

第 2 步:查逐层激活值分布。

for name, h in activations.items():
print(f"{name} σ={h.std():.3f} 饱和={100*(h.abs()>0.99).float().mean():.1f}%")

要逐层看,不是只看第一层。 关键是比值随层数的趋势:逐层放大说明 σ_W 太大, 逐层缩小说明太小,稳定就往下走。

第 3 步:查梯度。

for name, p in model.named_parameters():
print(f"{name} grad σ={p.grad.std():.2e} ratio={p.grad.std()/p.std():.2e}")

grad/data 比值是最有用的单一指标,经验值大约 1e-3。远小于它是学不动, 远大于它是要炸。

第 4 步:才轮到调学习率。

顺序不能反。初始化没搞对就去调学习率,等于在一个坏起点上找最优步长 —— 调出来的值换个初始化就作废。第十一篇那条 U 形扫描曲线也是从随机初始化开始测的, 换一组初始化,那条曲线整条都会挪位置。

每一层的问题都会伪装成下一层的问题: 初始化坏 → 看起来像「学习率不对」;学习率不对 → 看起来像「模型容量不够」; 容量不够 → 看起来像「数据不够」。从底往上查,不要从表象下手。


这套方法的边界

它假设了输入的统计性质是已知且稳定的。这个假设在深网络里会破:

第 2 层的输入 = 第 1 层的输出

第 1 层的权重在训练中不断变化,它的输出分布也就跟着漂移 —— 初始化时算好的一切,1000 步之后就不成立了。这叫 internal covariate shift。

KaimingBatchNorm
初始时方差合适✅✅
训练中方差漂移❌✅
换配置要重算要算 fan_in✅ 完全不用
能堆多深十几层上百层

BatchNorm 的思路是「与其算准,不如强制」—— 每层输出直接减均值除标准差, 不管前面发生了什么。那是下一段的事。


这一篇改的只有四行,降的只有 0.076。真正带得走的是三件: 初始 loss 有理论值可对、饱和比例能直接读出梯度传不传得动、 以及查错要从底往上,因为每一层的问题都伪装成下一层的。

下一篇是数学补课:σ_z = √n · σ_W · σ_x 这个公式, 以及为什么诊断盯的是方差而不是均值或最大值。这一篇用了它就往下走了, 下一篇从期望的定义把它推出来。