饱和从 62.3% 到 1.4%:四行初始化,和一套查错顺序
第十篇结尾留了三个毛病,第十二、十三篇又各承诺了一次:初始 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。
链条上的每一步:
① 查表 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.2958x 健康是有原因的:它直接抄自 C,而 C 就是 randn。病是从第三步开始的。
六个数就是全部问题的缩影
把 z 的前六项和它们过完 tanh 的结果并排放:
z: 2.87 4.30 4.51 5.57 0.40 −7.70h: 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²| h | 1 − h² | 学习速度 |
|---|---|---|
| 0 | 1.00 | 正常 |
| 0.9 | 0.19 | 慢 5 倍 |
| 0.99 | 0.02 | 慢 50 倍 |
| 0.999 | 0.002 | 基本停止 |
饱和的神经元就是死神经元(dead neuron)。第十篇那个隐藏层动画里, 柱子下面标红的「已饱和」说的就是这件事。
要区分两个阶段。 训练后期出现饱和是正常的 —— 模型确实学到了强规律, 该确信的地方就该确信。初始化就饱和是病:模型什么都没学过, 凭什么这么确信?它是被随机数推到那个位置的。
指标三:逐层标准差比值
目标是 σ_out / σ_in ≈ 1。这是最根本的一条,因为误差会逐层累乘:
| 每层比值 | 10 层之后 |
|---|---|
| 1.5 | 57 倍 → 爆炸 |
| 1.0 | 1 倍 ✅ |
| 0.5 | 0.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 | 不压缩 |
| tanh | 5/3 ≈ 1.667 | 补偿压缩 |
| ReLU | √2 ≈ 1.414 | 砍掉一半负值,方差减半 |
这就是 Kaiming 初始化,何恺明 2015 年提出,同一篇工作后来发展成 ResNet。 在此之前是 Xavier,没考虑 ReLU 的不对称性。
fan_in 不是 fan_out
W1.shape # (30, 200) # ↑ ↑ # fan_in fan_outn 是求和的项数 —— 30 项相加。200 只是「这样的计算做了 200 遍」,每遍互不影响。
| 模式 | 保证什么 |
|---|---|
fan_in(默认) | 前向传播时激活值方差不变 |
fan_out | 反向传播时梯度方差不变 |
两者不能同时满足,除非 fan_in = fan_out。Xavier 取了折中:√(2/(fan_in+fan_out))。
输出层是特例
W2 用 0.01 而不是 Kaiming,因为目标根本不同:
| 层 | 目标 | 手段 |
|---|---|---|
| 隐藏层 | σ_out = σ_in | Kaiming |
| 输出层 | logits ≈ 0 | 压到很小 |
输出层后面接的是 softmax 不是 tanh —— 它不怕小,就怕大。logits 全是 0 时
softmax 给出均匀分布,loss 正好是 ln 27,也就是那个「诚实的无知」。
但不能设成 0:
∂L/∂h = ∂L/∂logits · W₂ᵀ = 0W2 全零,梯度就传不回去,前面所有层永远不更新。这叫对称性破缺问题。
要小,但不能是 0。
通用原则:最后一层永远是特例。 分类任务压 logits,回归任务对齐目标均值, 生成任务另有讲究 —— 前面各层讲的是「信号别变形」,最后一层讲的是「起点在哪」。
一个诚实的结果:Kaiming 输了
四个版本实测:
| 版本 | σ(W1) | σ(W2) | 初始 loss | 饱和 | val |
|---|---|---|---|---|---|
| V3 原始 | 1.0 | 1.0 | 17 | 62.3% | 2.1752 |
| 仅修 W2 | 1.0 | 0.01 | 3.3394 | 62.3% | — |
| 手调 W1 | 0.2 | 0.01 | 3.3187 | 1.4% | 2.0994 ✅ |
| Kaiming | 0.304 | 0.01 | 3.32 | 9.4% | 2.1087 |
公式反而不如拍脑袋。 原因不难看:Kaiming 优化的是「信号传过多层不衰减」, 而这个网络只有 2 层。
| h/x | 2 层后 | 10 层后 | |
|---|---|---|---|
| 手调 0.2 | 0.60 | 0.36 | 0.006 |
| Kaiming | 0.78 | 0.61 | 0.08 |
收益要等到深了才兑现,代价(饱和 1.4% → 9.4%)当场就付。
初始化没有「最优」,只有「匹配」。 浅网络可以手调得更极致; 深网络必须用公式,否则根本训不动。
两个目标本身就在打架:不饱和希望
z小,不衰减希望z大。 Kaiming 只能折中 —— 真正同时解决两者的是 BatchNorm。
诊断清单
上面所有东西合起来,就是一张「拿到新模型怎么查」的清单。
第 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。
| Kaiming | BatchNorm | |
|---|---|---|
| 初始时方差合适 | ✅ | ✅ |
| 训练中方差漂移 | ❌ | ✅ |
| 换配置要重算 | 要算 fan_in | ✅ 完全不用 |
| 能堆多深 | 十几层 | 上百层 |
BatchNorm 的思路是「与其算准,不如强制」—— 每层输出直接减均值除标准差, 不管前面发生了什么。那是下一段的事。
这一篇改的只有四行,降的只有 0.076。真正带得走的是三件: 初始 loss 有理论值可对、饱和比例能直接读出梯度传不传得动、 以及查错要从底往上,因为每一层的问题都伪装成下一层的。
下一篇是数学补课:σ_z = √n · σ_W · σ_x 这个公式,
以及为什么诊断盯的是方差而不是均值或最大值。这一篇用了它就往下走了,
下一篇从期望的定义把它推出来。