ARTICLE DETAIL

资讯详情

深耕郑州网站建设与运营推广的一线实战洞察。

大权重初始化导致loss不降?用NumPy复现MNIST训练故障与解决

大权重初始化导致loss不降?用NumPy复现MNIST训练故障与解决 手搓完 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.1Var(z) ≈ 784 × 0.01 × 1 7.84标准差约 2.8。这还只是第一层。如果权重标准差是 1那加权和的标准差会直接冲到 28sigmoid 输入基本就是 ±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%完全锁死或 NaN2.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_classes10): out np.zeros((y.shape[0], num_classes), dtypenp.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, axis1, keepdimsTrue) exp_z np.exp(z) return exp_z / np.sum(exp_z, axis1, keepdimsTrue) def cross_entropy(p, y_onehot): eps 1e-12 return -np.mean(np.sum(y_onehot * np.log(p eps), axis1)) # 初始化故意制造问题 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, axis1)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, axis0) dA2 dZ3 W3.T dZ2 dA2 * a2 * (1 - a2) dW2 (a1.T dZ2) / m db2 np.mean(dZ2, axis0) dA1 dZ2 W2.T dZ1 dA1 * a1 * (1 - a1) dW1 (X.T dZ1) / m db1 np.mean(dZ1, axis0) return dW1, db1, dW2, db2, dW3, db3训练循环也写得很朴素小批量采样、前向、反向、更新。唯一需要注意的是学习率不要盲目加大。大权重初始化下梯度方向本来就乱学习率再大一点loss 会出现锯齿形抖动甚至直接冲上 NaN。def train(epochs20, batch_size64, lr0.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:ibatch_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, axis1) y_train[batch_idx]) train_acc correct / len(idx) * 100 # 仅最后一批的近似值 print(fepoch {epoch1}, loss{total_loss/len(X_train):.4f}, batch_acc{train_acc:.2f}%) train()3.4 训练观察记录三个典型现象用标准差 0.1 跑二十个 epoch输出大概长这样epoch 1, loss2.3065, batch_acc9.38% epoch 5, loss2.3042, batch_acc10.90% epoch 10, loss2.3021, batch_acc9.78% epoch 20, loss2.2998, batch_acc12.50%loss 从 2.3065 缓慢滑到 2.2998看着像在下降但这个速度等于没动。准确率偶尔跳到 12%纯属随机波动。另一个典型现象是如果把权重标准差改成 0.5 或 1.0loss 会在某个 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(fdW1 norm{norm_w1:.6f}, dW2 norm{norm_w2:.6f}, dW3 norm{norm_w3:.6f})我之前跑出来的典型值是 dW10.008、dW20.05、dW30.5差距非常明显。这种工具帮你区分“到底是哪一层学不动”而不是笼统地怀疑整个网络。后来的经验是只要 dW1 和 dW3 差距超过 20 倍先检查初始化再检查是否有饱和。4.3 loss 曲线的三种走向和含义把诊断经验总结成一张速查表后续遇到类似问题照着对号入座就行现象可能原因优先排查项loss 稳定在 2.3 附近准确率≈10%输出均匀分布网络没学到任何东西初始化标准差、数据标签是否打乱loss 从 2.3 缓慢下降但极慢部分梯度有效但大部分神经元饱和激活饱和率、学习率loss 先降后突然 NaN数值过冲梯度爆炸学习率、初始化标准差、batch sizeloss 震荡不下降学习率过大梯度方向不稳定降低学习率或试试动量这里的 2.3 是 MNIST 十分类的均匀分布交叉熵换成别的数据集就换成 ln(类别数)。任何初始化如果让初始 loss 远大于这个值说明前向输出已经极端如果等于这个值说明模型在“均匀猜”。5. 换一种初始化问题为什么自动消失5.1 Xavier 与 He 初始化的出发点既然大权重初始化会锁死网络那什么才算“正确”的初始化业界最常用的两个答案是 XavierGlorot初始化和 HeKaiming初始化。它们的出发点其实是个非常朴素的方差守恒思想希望信号经过一层线性变换后方差既不被放大太多也不被压缩太多。对于没有偏置、输入和输出维度都很大的线性层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.28sigmoid 在 ±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, loss2.19, batch_acc43.75% epoch 5, loss0.58, batch_acc88.28% epoch 10, loss0.42, batch_acc92.97% epoch 20, loss0.31, batch_acc95.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可用收敛略慢Xaviersqrt(1/n_in) 或 sqrt(2/(n_inn_out))sigmoid/tanh推荐收敛快Hesqrt(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 方向完全随机。这三样东西可以在十秒内帮你判断故障类别省下几个小时盲目调参的时间。这个习惯我保留到现在换任何新网络结构都会先做一次“前向体检”再决定要不要进入训练循环。
返回列表