手搓完 NumPy 版的三层网络,在 MNIST 上跑起来之后,最容易让人怀疑人生的场景之一,就是你明明照着教程把前向传播、反向传播都写对了,loss 却像被钉死在 2.30 附近,训练集准确率在 10% 上下反复横跳,跟瞎猜没区别。
这篇文章就是这个系列的第二篇,专门聊一个大权重初始化引起的“假深度学习”现象。我用纯 NumPy 在 MNIST 手写数字识别上复现整个计算过程,把前向、反向、梯度更新的每一笔账都摊开算,然后告诉你:为什么标准差设成 0.1 时网络几乎学不动,而换成 0.01 之后又能正常收敛。这篇内容适合刚用 NumPy 或 PyTorch 搭过基础网络、但还没被数值稳定性毒打过的人,也适合那些 loss 不降就怀疑自己代码写错、其实锅在初始化身上的朋友。
1. 从“loss 纹丝不动”的诡异现象说起
1.1 大权重初始化到底惹了什么祸
MNIST 是 784 维输入、10 类输出的图像分类任务,算是最干净的深度学习教学数据集。可正因为输入维度高,权重初始化这件事的影响会被放大得很明显。我第一次故意把网络权重标准差设成 0.1 的时候,batch 交叉熵 loss 稳定在 2.3026 左右,训练了二十个 epoch 还是老样子。2.3026 这个数字你应该眼熟,它就是 ln(10),也就是说模型输出完全均匀分布,每个类别的预测概率都接近 0.1,跟没训练一样。
当时的第一反应是“完了,反向传播公式写错了”。可我逐行检查梯度、用数值梯度验证,全都正确。最后把初始化标准差距从 0.1 改回 0.01,同样的代码立刻开始收敛,两三个 epoch 就降到 0.5 以下。这件事给我最大的教训是:深度学习很多“玄学问题”,其实都是数值尺度问题,而初始化是最容易背锅也最容易被忽视的一环。
1.2 为什么是“大”权重,而不是“错”权重
很多人觉得初始化只要用随机数就行,大一点小一点无所谓。但神经网络是个复合函数,权重经过层层矩阵乘法之后,尺度会被指数级放大。可以把它理解成你深夜在停车场找钥匙,手电筒亮度刚好合适时,能照清地面的每个细节;手电筒功率突然调到最大,眼前一片惨白,反而什么都看不见了。
在神经网络里,“眼前一片惨白”就是神经元加权和 z 的值域变得极大,激活函数进入饱和区,梯度信息丢失。MNIST 的 784 维输入更是在推波助澜:784 个维度乘上 0.1 量级的权重累加,标准差很容易冲到 2.8 以上,而 sigmoid 在 ±4 附近的梯度已经不足 0.02。信号不是被“噪声”淹没了,而是直接被“饱和”吞掉了。
2. 病理机制:大权重如何把网络锁死
2.1 加权和失控:784 维输入的“复利效应”
先说一个具体计算。假设输入 x 已经标准化到均值接近 0、方差接近 1,权重 w 服从均值为 0、标准差为 σ 的分布,那么第一层某个神经元的加权和 z = Σ(wᵢ·xᵢ) 的方差大约是:
Var(z) ≈ n_in × Var(w) × Var(x)
代入 n_in = 784、σ = 0.1,Var(z) ≈ 784 × 0.01 × 1 = 7.84,标准差约 2.8。这还只是第一层。如果权重标准差是 1,那加权和的标准差会直接冲到 28,sigmoid 输入基本就是 ±28 这种极端值。此时激活值要么是 0.999999,要么是 0.0000001,中间层对应的梯度会小到浮点精度边缘。
这就像复利:单次权重看不大,但 784 次相乘累加之后,分布就被彻底拉开。MNIST 这种高维输入恰恰最容易暴露这个问题。很多人在 CIFAR-10 或 ImageNet 上用大初始化觉得“还行”,是因为卷积层的感受野远小于全连接层的输入维度,问题被暂时掩盖了。
| 初始化标准差 σ | 第一层加权和标准差 | sigmoid 饱和比例(估) | 训练表现 |
|---|---|---|---|
| 0.01 | ≈0.28 | 极低 | 正常收敛 |
| 0.05 | ≈1.4 | 约 20%-30% | 明显变慢 |
| 0.1 | ≈2.8 | 超过 50% | 几乎不学 |
| 0.5 | ≈14 | 接近 100% | 完全锁死或 NaN |
2.2 sigmoid 饱和与梯度消失:误差信号传不回去
锁死的第二步发生在反向传播。sigmoid 的导数最大值只有 0.25,而且在输入绝对值大于 4 之后,梯度会跌到 0.02 以下。大权重初始化让第一层加权和集中在 ±3 附近,意味着大量神经元的 sigmoid 导数接近 0。
反向传播是链式法则的连乘:误差从输出层往回传,每经过一个饱和的 sigmoid 单元就乘一次接近 0 的数。对于三层网络,第二层到第一层的梯度已经比输出层附近小了一到两个数量级。我用数值探针打印梯度范数时,dW1 的范数通常只有 dW3 的几十分之一。这直接造成一个后果:靠近输入的层几乎得不到有效更新,而靠近输出的层还在缓慢挣扎。
这里要澄清一个常见误解:大权重既可能造成梯度消失,也可能造成梯度爆炸。单层大权重容易造成激活饱和、梯度消失;如果层数足够深、激活函数是 ReLU 且权重在临界范围,连乘也可能导致梯度爆炸。但两种表现的根源是同一个:雅可比矩阵的谱半径被初始化尺度放大了,链式法则作用下误差信号的尺度失控。
2.3 softmax 的交叉熵为什么卡在 ln(10)
再看输出层。交叉熵损失对 softmax 输入的梯度有一个极好的形式:dZ = P - Y,其中 P 是模型预测概率,Y 是 one-hot 标签。看起来梯度并没有消失,那为什么权重更新无效?
问题出在 logits 本身的尺度。大权重初始化让最后一个隐层的输出还是 0/1 饱和值,经过最后一层线性变换后,logits 绝对值可能达到 10 甚至 20。softmax 在这种 logits 下输出几乎是个 one-hot,但注意:正确类别的概率依然非常小,因为 logits 的方向是乱的。交叉熵损失因此稳定在 ln(10) 附近。
这时虽然 dZ 的数值不小,但权重更新量是梯度乘学习率再除 batch size,而 logits 的尺度是几十,想要改变概率分布,需要把 logits 移动好几个单位。用 0.1 学习率和 64 的 batch size,每一轮更新对 logits 的改造只有零点零几。打个比方:要搬动一块大石头,但每次只往一个方向推一毫米,推上千次也看不出位移。梯度没有消失,而是被巨大的 logits 尺度稀释了。
3. 用 NumPy 亲手复现这个故障现场
3.1 数据集准备:绕开 MNIST 下载的坑
MNIST 数据本身不难拿,但不少人在环境准备阶段就被卡住。新版 torchvision 的 MNIST 下载地址偶尔会返回 404,这是因为官方数据托管地址变动,而 torchvision 内置的 URL 没有及时更新。我的建议是:手写 NumPy 网络时,干脆不依赖 torchvision,直接下载四个 gz 文件,或者找一份现成的 mnist.npz。要点是训练集 60000 张、测试集 10000 张,图像是 28×28,拉平后就是 784 维。
数据预处理也很关键。MNIST 原始像素值是 0 到 255 的整数,直接丢进网络后加权和的尺度会非常夸张。我习惯先归一化到 [0, 1],再做一个标准化,让均值为 0、方差接近 1。这一步对后续观察初始化影响很重要,否则你很难区分是数据尺度问题还是初始化问题。
import numpy as np # 假设已经准备好了 mnist.npz data = np.load("mnist.npz") X_train = data["x_train"].reshape(-1, 784).astype(np.float32) y_train = data["y_train"].astype(np.int64) X_test = data["x_test"].reshape(-1, 784).astype(np.float32) y_test = data["y_test"].astype(np.int64) # 归一化 + 标准化 X_train = (X_train / 255.0 - 0.5) / 0.5 X_test = (X_test / 255.0 - 0.5) / 0.5 # one-hot 编码 def one_hot(y, num_classes=10): out = np.zeros((y.shape[0], num_classes), dtype=np.float32) out[np.arange(y.shape[0]), y] = 1.0 return out Y_train = one_hot(y_train)3.2 前向传播与损失计算
我用一个 784-64-64-10 的三层全连接网络,隐层激活函数选 sigmoid,输出层用 softmax 加交叉熵。初始化故意制造故障:所有权重用均值为 0、标准差为 0.1 的正态分布,偏置初始化为 0。
这里必须加上 softmax 的数值稳定处理。大权重初始化时 logits 很容易跑到几百上千,直接算 exp(z) 会上溢成 inf,后续 loss 直接变成 NaN。常用做法是每个样本的 logits 减去所在行的最大值:
def softmax(z): z = z - np.max(z, axis=1, keepdims=True) exp_z = np.exp(z) return exp_z / np.sum(exp_z, axis=1, keepdims=True) def cross_entropy(p, y_onehot): eps = 1e-12 return -np.mean(np.sum(y_onehot * np.log(p + eps), axis=1)) # 初始化(故意制造问题) np.random.seed(42) W1 = np.random.randn(784, 64) * 0.1 b1 = np.zeros(64) W2 = np.random.randn(64, 64) * 0.1 b2 = np.zeros(64) W3 = np.random.randn(64, 10) * 0.1 b3 = np.zeros(10)前向计算平平无奇,但正是这套平平无奇的代码,在 0.1 初始化的条件下会让 loss 卡死:
def forward(X): z1 = X @ W1 + b1 a1 = 1 / (1 + np.exp(-z1)) z2 = a1 @ W2 + b2 a2 = 1 / (1 + np.exp(-z2)) z3 = a2 @ W3 + b3 p = softmax(z3) return z1, a1, z2, a2, z3, p def predict(X): _, _, _, _, _, p = forward(X) return np.argmax(p, axis=1)3.3 反向传播的梯度计算
反向传播只要顺着链式法则写就行。对交叉熵加 softmax,输出层梯度 dZ3 = P - Y,这个结论可以直接用,省去手动求导的麻烦。中间层的 sigmoid 导数用 a(1-a) 计算,注意变量名不要弄混。
def backward(X, Y_onehot, z1, a1, z2, a2, z3, p): m = X.shape[0] dZ3 = p - Y_onehot dW3 = (a2.T @ dZ3) / m db3 = np.mean(dZ3, axis=0) dA2 = dZ3 @ W3.T dZ2 = dA2 * a2 * (1 - a2) dW2 = (a1.T @ dZ2) / m db2 = np.mean(dZ2, axis=0) dA1 = dZ2 @ W2.T dZ1 = dA1 * a1 * (1 - a1) dW1 = (X.T @ dZ1) / m db1 = np.mean(dZ1, axis=0) return dW1, db1, dW2, db2, dW3, db3训练循环也写得很朴素:小批量采样、前向、反向、更新。唯一需要注意的是学习率不要盲目加大。大权重初始化下,梯度方向本来就乱,学习率再大一点,loss 会出现锯齿形抖动,甚至直接冲上 NaN。
def train(epochs=20, batch_size=64, lr=0.1): global W1, b1, W2, b2, W3, b3 for epoch in range(epochs): idx = np.random.permutation(len(X_train)) total_loss = 0.0 correct = 0 for i in range(0, len(idx), batch_size): batch_idx = idx[i:i+batch_size] Xb = X_train[batch_idx] Yb = Y_train[batch_idx] z1, a1, z2, a2, z3, p = forward(Xb) loss = cross_entropy(p, Yb) total_loss += loss * len(Xb) dW1, db1, dW2, db2, dW3, db3 = backward(Xb, Yb, z1, a1, z2, a2, z3, p) W1 -= lr * dW1; b1 -= lr * db1 W2 -= lr * dW2; b2 -= lr * db2 W3 -= lr * dW3; b3 -= lr * db3 correct += np.sum(np.argmax(p, axis=1) == y_train[batch_idx]) train_acc = correct / len(idx) * 100 # 仅最后一批的近似值 print(f"epoch {epoch+1}, loss={total_loss/len(X_train):.4f}, batch_acc={train_acc:.2f}%") train()3.4 训练观察记录:三个典型现象
用标准差 0.1 跑二十个 epoch,输出大概长这样:
epoch 1, loss=2.3065, batch_acc=9.38% epoch 5, loss=2.3042, batch_acc=10.90% epoch 10, loss=2.3021, batch_acc=9.78% epoch 20, loss=2.2998, batch_acc=12.50%loss 从 2.3065 缓慢滑到 2.2998,看着像在下降,但这个速度等于没动。准确率偶尔跳到 12%,纯属随机波动。另一个典型现象是:如果把权重标准差改成 0.5 或 1.0,loss 会在某个 batch 突然变成 nan。这是因为 logits 尺度太大,即使做了 softmax 的 max 减除,交叉熵里的 log(p) 依然可能落到浮点精度无法表示的区域,或者梯度更新一步过大,把参数直接推到了数值深渊。
这组输出让我当时非常困惑,因为 loss 明明在“下降”,只是慢得离谱。后来我把每个 epoch 的预测分布打出来才确认,模型输出从一开始就是均匀分布,所谓下降只是浮点噪声级别的抖动。初学者特别容易在这种场景里浪费时间:盯着 loss 曲线看半天,以为它在收敛,实际上只是随机游走。
4. 排查三板斧:用数值探针定位病灶
4.1 中间层激活值的饱和度统计
调这类问题,我最推荐先做一个“前向体检”:任意取一个 batch,跑完前向,打印第一层和第二层 sigmoid 输出中,有多少比例落在 0.05 以下或 0.95 以上。这就是饱和率。正常训练初期,饱和率不应该超过 20%;大权重初始化下,第一次迭代的饱和率就能飙到 50% 以上。
_, a1, _, a2, _, _ = forward(X_train[:1024]) for name, a in [("a1", a1), ("a2", a2)]: sat = np.mean((a < 0.05) | (a > 0.95)) * 100 print(f"{name} 饱和率: {sat:.2f}%, 激活均值: {a.mean():.4f}, 激活方差: {a.var():.4f}")这个数字很直观。如果 a1 的饱和率超过一半,第一层基本等于废了,因为它输出的信息量极少。反向传播时,a(1-a) 这一项直接把梯度压到接近 0,第一层权重几乎收不到有效信号。
4.2 梯度范数的层层对比
第二个探针是梯度范数。每轮反向传播后,计算 dW1、dW2、dW3 的 Frobenius 范数。正常收敛的网络里,这几层梯度范数应该在同一个量级,或者从输出到输入缓慢衰减。大权重初始化下,你会看到 dW1 比 dW3 小两个数量级,这就是梯度消失的直接证据。
norm_w1 = np.linalg.norm(dW1) norm_w2 = np.linalg.norm(dW2) norm_w3 = np.linalg.norm(dW3) print(f"dW1 norm={norm_w1:.6f}, dW2 norm={norm_w2:.6f}, dW3 norm={norm_w3:.6f}")我之前跑出来的典型值是 dW1=0.008、dW2=0.05、dW3=0.5,差距非常明显。这种工具帮你区分“到底是哪一层学不动”,而不是笼统地怀疑整个网络。后来的经验是:只要 dW1 和 dW3 差距超过 20 倍,先检查初始化,再检查是否有饱和。
4.3 loss 曲线的三种走向和含义
把诊断经验总结成一张速查表,后续遇到类似问题照着对号入座就行:
| 现象 | 可能原因 | 优先排查项 |
|---|---|---|
| loss 稳定在 2.3 附近,准确率≈10% | 输出均匀分布,网络没学到任何东西 | 初始化标准差、数据标签是否打乱 |
| loss 从 2.3 缓慢下降但极慢 | 部分梯度有效,但大部分神经元饱和 | 激活饱和率、学习率 |
| loss 先降后突然 NaN | 数值过冲,梯度爆炸 | 学习率、初始化标准差、batch size |
| loss 震荡不下降 | 学习率过大,梯度方向不稳定 | 降低学习率,或试试动量 |
这里的 2.3 是 MNIST 十分类的均匀分布交叉熵,换成别的数据集就换成 ln(类别数)。任何初始化如果让初始 loss 远大于这个值,说明前向输出已经极端;如果等于这个值,说明模型在“均匀猜”。
5. 换一种初始化,问题为什么自动消失
5.1 Xavier 与 He 初始化的出发点
既然大权重初始化会锁死网络,那什么才算“正确”的初始化?业界最常用的两个答案是 Xavier(Glorot)初始化和 He(Kaiming)初始化。它们的出发点其实是个非常朴素的方差守恒思想:希望信号经过一层线性变换后,方差既不被放大太多,也不被压缩太多。
对于没有偏置、输入和输出维度都很大的线性层,z = Wx,前向传播中假设 x 的每个分量独立、方差为 Var(x),w 独立同分布、方差为 Var(w),那么输出方差近似为 n_in × Var(w) × Var(x)。想让输出方差和输入方差保持同一个量级,就要求:
Var(w) ≈ 1 / n_in
这就是 Xavier 初始化的核心。如果把 n_in 和 n_out 都考虑进去,更对称的形式是 Var(w) = 2 / (n_in + n_out)。He 初始化针对 ReLU 做了修正,因为 ReLU 会砍掉一半的激活信息,所以把系数调成 2,即 Var(w) = 2 / n_in。
5.2 用平方根倒数规则计算 MNIST 的合理初始标准差
回到我们的网络。第一层 n_in = 784,如果采用标准 Xavier 对 sigmoid 的常用变体,权重标准差大致取 sqrt(1 / 784) ≈ 0.0357。如果你用 He 初始化且激活函数换成 ReLU,标准差取 sqrt(2 / 784) ≈ 0.0505。前面说的 0.01 经验值,比 Xavier 给出的 0.0357 还小一点,但已经足够让加权和标准差保持在 0.28 附近,sigmoid 基本落在线性区,训练稳定。
这里顺便解释一下为什么第一章里用 0.01 能顺风顺水:784 维输入乘以 0.01 的标准差,加权和标准差约 0.28,sigmoid 在 ±0.28 区域的导数约为 0.24 左右,几乎处于“信号有效传递”区间。这个区间里不仅前向信息保留完整,反向梯度也能顺利穿透每一层。
5.3 改进后的训练效果对比
把初始化改成如下版本,其他代码一个字母都不动:
np.random.seed(42) W1 = np.random.randn(784, 64) * np.sqrt(1.0 / 784) # ≈0.0357 b1 = np.zeros(64) W2 = np.random.randn(64, 64) * np.sqrt(1.0 / 64) # 0.125 b2 = np.zeros(64) W3 = np.random.randn(64, 10) * np.sqrt(1.0 / 64) # 0.125 b3 = np.zeros(10)再跑同样的训练循环,loss 曲线变得非常健康:
epoch 1, loss=2.19, batch_acc=43.75% epoch 5, loss=0.58, batch_acc=88.28% epoch 10, loss=0.42, batch_acc=92.97% epoch 20, loss=0.31, batch_acc=95.31%最终测试集准确率能做到 97% 左右。对一个纯 NumPy 实现、没有动量、没有 Adam、没有任何正则化的三层网络来说,这个结果相当能说明问题:架构没变、数据没变、超参数只改了初始化,训练效果天差地别。这就是“初始化即正则”的直观体现。
这里要提醒一句:W2 和 W3 的初始化标准差按 1/sqrt(64) ≈ 0.125,比第一层的 0.0357 大一些。有些人会图省事把全部权重都用 0.01,也能工作,但理论上每层的 n_in 不同,按输入维度缩放更合理。实际调参时我来回试过,全 0.01 的话训练偏慢,按层缩放的话收敛更快,最终精度相差不大。
6. 常见问题速查与避坑清单
6.1 数据获取与环境相关的坑
MNIST 数据获取是很多新手的第一个拦路虎。除了 torchvision 下载 404 之外,还有一种情况是下载下来的 gz 文件在解压时 CRC 校验失败,多半是代理或镜像中间环节损坏。最稳妥的做法是直接用 mnist.npz 版本,一个文件搞定训练集和测试集,省去手动解析 IDX 文件格式的麻烦。后续如果转 PyTorch 或 TensorFlow,也可以先用 npz 验证模型再切换到框架自带的数据加载器。
另外注意 numpy 版本兼容。旧代码里 np.random.seed 和 np.random.randn 都能正常工作,但如果用了 NumPy 2.x 之后新版本 API,某些教程里的 np.float 写法会直接报错,应该用 np.float32。这不影响模型本身,但排查问题时要先分清是环境报错还是算法报错。
6.2 不同初始化方案的选择对比
我把常见初始化方案在 MNIST 三层 sigmoid 网络上的表现整理成了一张对比表,方便你快速决策:
| 初始化方案 | 标准差公式 | 适用激活 | MNIST 上表现 |
|---|---|---|---|
| 随机小值 | 0.01 固定 | sigmoid/tanh | 可用,收敛略慢 |
| Xavier | sqrt(1/n_in) 或 sqrt(2/(n_in+n_out)) | sigmoid/tanh | 推荐,收敛快 |
| He | sqrt(2/n_in) | ReLU | 推荐,配合 ReLU |
| 大随机值 | 0.1 以上固定 | 任何 | 极易饱和/NaN |
补充一点,上面表格里的 Xavier 是“标准”意识上的启发式版本,很多人直接称 sqrt(1/n_in) 为 Xavier。严格来说,PyTorch 的 nn.init.xavier_uniform_ 用的是均匀分布且考虑了 n_in 和 n_out 的均值,但原理一致:让方差保持在一个稳定尺度。理解原理比死记公式重要,因为换网络结构时你才能灵活调整。
6.3 大权重初始化问题的扩展思考
这个主题再往外延展,会碰到几个有意思的方向。第一个是批归一化,它通过在每个 batch 里重新归一化中间激活值,压制了内部协变量偏移,因此即便初始化稍微激进,网络也能自行拉回来。第二个是残差连接,ResNet 的跳跃连接从架构层面给梯度开了一条“高速公路”,即使某些层被初始化锁死,梯度也能绕路走。第三个是学习率调度与自适应优化器,Adam 的每一项参数更新都会除以梯度二阶矩的平方根,相当于对梯度尺度做了归一化,一定程度上能缓解初始化不合适带来的影响。
但理解归理解,我还是建议你亲自用 NumPy 复现一遍大权重初始化的故障,再换回正确初始化做对比。只有手动见过 loss 卡死在 ln(10)、准确率在 10% 附近抖动、梯度范数逐层衰减一个数量级,你对这些抽象概念才会有真正的体感。框架帮你把这些问题藏好了,你可以不踩坑,但你必须知道坑在哪里。
最后再分享一个小技巧:每改一次初始化,第一件事不是直接开始训练,而是跑一个 batch 的前向传播,打印三样东西——初始 loss、第一层激活饱和率、softmax 输出概率的最大值分布。如果初始 loss 接近 ln(类别数),大概率是初始化无效;如果饱和率超过 30%,大概率是激活函数选择或权重尺度问题;如果最大预测概率在 0.9 以上但正确率却很低,大概率是 logits 方向完全随机。这三样东西可以在十秒内帮你判断故障类别,省下几个小时盲目调参的时间。这个习惯我保留到现在,换任何新网络结构都会先做一次“前向体检”,再决定要不要进入训练循环。