☰
大权重初始化毁掉MNIST训练?从方差原理到Xavier/He初始化实战
2026/10/7 3:14:59 网站建设 项目流程

1. 大权重初始化是怎么毁掉MNIST训练的:一次可复现的“翻车”现场

1.1 为什么继续用NumPy硬啃这些细节

这个系列上一篇,我们用纯NumPy在MNIST手写数字识别数据集上,把两层神经网络的前向传播、反向传播、梯度下降完整跑通了。今天这篇,我想专门复盘自己踩得最深的一个坑:大权重初始化。很多人一开始接触深度学习,直接用PyTorch或者TensorFlow,nn.Linear、kaiming_uniform_这些封装早就把初始化安排好了,所以对“权重初始化不对”这件事几乎没有体感。但当你像我一样偏执地扔掉框架,用NumPy从零实现网络时,W1、W2的每一个元素都是自己赋值的,初始化这层窗户纸就立刻捅破了。

我在第一篇里用的是np.random.randn生成权重,然后直接训练。最开始没太在意幅度问题,因为MNIST任务简单,怎么跑都像能收敛。直到有一次我为了对比不同初始化对训练的影响,把权重标准差从0.01调到了1.0,结果损失函数整轮训练都卡在2.3附近,准确率稳定在10%左右,和随机猜测一样。我又把标准差调到10.0,损失直接变成NaN。那一刻我才真正意识到,深度学习里的“调参”,第一步不是学习率,而是初始化。

这一篇不是讲概念,是把“大权重初始化为什么会翻车”这件事掰开揉碎,用NumPy代码、数学推导和调试过程完整呈现一遍。适合两类人看:一类是正在用手写神经网络理解深度学习的初学者,另一类是已经会用框架但想知道初始化背后原理的人。读完你会明白,为什么深度学习框架里默认的初始化方式不是1.0这种“大数”,而是一堆看起来很小的随机数。

1.2 只改权重标准差,损失曲线从“能学”变成“纹丝不动”

我们先做一个最小实验,只用两层网络:输入层784维,隐藏层128个神经元,激活函数sigmoid,输出层10个神经元,最后一层softmax交叉熵。MNIST像素值归一化到0到1之间,全批量梯度下降,学习率0.1。代码结构完全一样,只是初始化方式不一样。

第一种是“小初始化”:

np.random.seed(42) W1_small = np.random.randn(784, 128) * 0.01 W2_small = np.random.randn(128, 10) * 0.01

第二种是“大初始化”:

np.random.seed(42) W1_large = np.random.randn(784, 128) * 1.0 W2_large = np.random.randn(128, 10) * 1.0

这两种初始化,权重矩阵里的数值范围差了100倍。训练同样的epoch数之后,小初始化的损失从0.8左右一路降到0.2附近,准确率能到92%以上。大初始化的损失大概从2.35变成2.34,几乎没有任何变化,准确率也一直徘徊在10%。注意,这不是学习率太小,也不是网络结构有问题,只是初始化权重太大了。

为什么会发生这种事?原因可以从两个方向看:前向传播中,输入信号经过一个权重矩阵后会被放大,导致后续激活函数进入饱和区;反向传播中,梯度穿回每一层时也会被权重矩阵再次放大或压制。大初始化就像在信号链路上装了一个不合尺寸的放大器,还没到输出端,信号就已经变成一片混乱了。

1.3 别把NaN全算在大权重头上:先分清楚两种“烂法”

很多人一听说大权重初始化会导致网络不收敛,脑海里第一反应是“梯度爆炸,loss变成NaN”。这个说法不算错,但不完整。依据我的实测,大权重初始化带来的失败其实有两种典型形态,背后的机制不太一样。

第一种叫“假死”。如果网络层数不深,比如就两层隐藏层,激活函数是sigmoid,权重标准差为1.0左右,loss不会变NaN,它更像一块铁板,训练很久都不动弹。原因是sigmoid在输入绝对值较大的时候,输出会非常接近0或1,梯度极其接近0。参数在反向传播时几乎收不到有效的更新信号,于是整个网络被“冻住”了。表面上loss还在2.3左右,好像是一个正常的起点,但怎么训练都下不去。

第二种叫“真死”。当网络层数更多,或者权重标准差再大一些,比如5.0、10.0,前向传播的输出可能直接超过浮点数能表达的范围,softmax里np.exp算出来是inf,loss变成NaN。或者即使我使用了数值稳定的softmax,反向传播的梯度在逐层回传时也会因为矩阵连乘而指数级爆炸,一步梯度下降下来,权重更新量巨大,训练彻底崩溃。

这两种形态经常混在一起,给调试带来很大迷惑性。所以下一节,我先从数学上把“信号放大”这件事说清楚,再进入实际代码逐层观察。

2. 大权重引发雪崩的数学原理:前向传播里的方差放大与激活饱和

2.1 线性层的方差传递:权重、输入维度和标准差三者如何联动

一个全连接层做的事情可以写成:

[ z = W x + b ]

假设输入x的每个维度独立且均值为0、方差为σ_x²,权重w的每个元素独立且均值为0、方差为σ_w²。那么输出z的方差是:

[ \text{Var}(z) = n_{in} \cdot \sigma_w^2 \cdot \sigma_x^2 ]

这个公式非常好用。它说明三件事:第一,输入维度越高,信号方差被放大的倍数就越大。MNIST输入是784维,这个数本身就不小。第二,权重标准差越大,输出方差以平方级别增长。第三,输入数据本身的尺度也参与其中,所以数据归一化不是可选项,是必选项。

举个例子。假设输入归一化后,x均值大概在0.5左右,标准差约0.29。如果你用np.random.randn(784, 128)直接生成权重,标准差就是1.0,那z的方差是多少?约784 * 1 * 0.29² ≈ 66,标准差差不多8.1。再把z送进sigmoid,绝大部分值都落在正负十几的区间,sigmoid的输出自然就饱和了。

这还只是一层。如果网络有三层、五层,每一层都乘一个大权重矩阵,那么信号方差会变成:

[ \sigma_{z_L}^2 = \sigma_x^2 \cdot \prod_{l=1}^{L} (n_{l-1} \sigma_{w_l}^2) ]

只要每层放大的倍数略大于1,深层信号的方差就会指数级膨胀。这就是“大权重引发雪崩”的根源。

2.2 sigmoid的非线性:为什么信号一旦放大反而会“消失”

很多人有个直觉误区:信号放大了,信息应该更明显才对。但放到神经网络里,情况恰恰相反。以sigmoid为例:

[ \sigma(z) = \frac{1}{1 + e^{-z}} ]

它的导数是:

[ \sigma'(z) = \sigma(z)(1 - \sigma(z)) ]

当z接近0时,sigmoid输出约0.5,导数约0.25,信息量最大。但当z的绝对值达到4以上,sigmoid输出就超过0.98或者低于0.02,导数只有0.02左右。z再大到10以上,导数已经接近10的负五次方量级。

所以在反向传播中,即使上层传来的梯度很大,一旦经过饱和的sigmoid导数,梯度也会被乘上一个极小的数。大权重在前向把信号推到饱和区,又在反向把梯度压缩到接近0,这两件事同时发生在同一个网络里,直接造成“参数不更新”的假死状态。

我在第一次跑出这种现象时,一度以为是反向传播代码写错了。查了半天公式,最后打印隐藏层的梯度范数,发现数值是1e-12级别,这才意识到问题出在初始化,不是算法实现。所以如果你也遇到loss纹丝不动,先别急着重构代码,先检查一下激活值的分布。

2.3 反向传播同样受权重影响:梯度爆炸或梯度衰减

大权重并不仅仅影响前向输出。反向传播时,每一层误差信号的计算公式也包含权重矩阵的转置:

[ \delta_{l-1} = (W_l)^T \delta_l \odot \sigma'(z_{l-1}) ]

这里面的核心运算是矩阵乘法。如果权重矩阵的元素比较大,那么梯度在逐层回传时会被不断放大;如果中间某层激活函数的导数又特别小,放大和缩小会在不同层之间拉扯。例如你有一个五层网络,前面四层权重标准差是5,最后一层用sigmoid饱和了,那么前几层的梯度可能巨大,最后一层的梯度几乎为零。这种“同一批梯度,有人饿死有人撑死”的现象,比单纯的前向爆炸更隐蔽。

从方差角度看,反向传播的误差信号经过权重矩阵转置后,方差同样会被放大。对第二个权重矩阵W2,它影响的输入维度是隐藏层的宽度n_hidden,所以后向方差正比于n_hiddenσ_w²。为了避免前向和后向同时放大,最理想的情况是让每层权重在“前向传播”和“反向传播”两个方向上都保持方差守恒。这直接引出了后面要说的Xavier初始化。

3. 在NumPy训练循环里“解剖”大权重:每一层的激活、梯度和loss是怎么烂掉的

3.1 埋点机制:每次迭代打印关键张量的均值、标准差、梯度范数

光讲数学不直观。实际操作中,我会在训练循环里加一段临时调试代码,把每一层的关键统计量打印出来。调试大权重问题,我最常看四个量:损失值、隐藏层激活值z1的均值与标准差、sigmoid输出a1的均值、以及两个权重矩阵的梯度范数。

代码可以这样写:

for step in range(100): # 前向传播 z1 = X @ W1 + b1 a1 = sigmoid(z1) z2 = a1 @ W2 + b2 p = softmax(z2) loss = cross_entropy(p, y_onehot) # 反向传播 dz2 = p - y_onehot grad_W2 = a1.T @ dz2 / N grad_b2 = np.mean(dz2, axis=0) dz1 = dz2 @ W2.T * sigmoid_derivative(z1) grad_W1 = X.T @ dz1 / N grad_b1 = np.mean(dz1, axis=0) if step % 10 == 0: print(f"step {step}, loss {loss:.4f}, " f"z1 std {np.std(z1):.3f}, " f"a1 mean {np.mean(a1):.3f}, " f"grad_W1 norm {np.linalg.norm(grad_W1):.3e}, " f"grad_W2 norm {np.linalg.norm(grad_W2):.3e}")

这段代码的关键在于,它不只看损失值,还同时看激活分布和梯度范数。大权重初始化时,打印结果会呈现非常典型的异常模式,下面两个小节就是我遇到的最常见情况。

3.2 观察一:隐藏层激活值全部“钉”在0/1附近,梯度范数趋近0

用标准差1.0的初始化,第一次前向传播打印出来,z1的标准差会在8到10之间。sigmoid作用以后,a1的均值会接近0.5,但a1的标准差很小,因为大量元素都已经饱和在0或1附近。进一步观察,如果算一下a1中严格小于0.02或大于0.98的比例,通常会超过60%。

这意味着隐藏层真正“灵活”工作的神经元数量非常少。反向传播时,dz1会乘上sigmoid导数,而导数值在饱和区几乎为0,所以grad_W1的范数往往只有1e-10量级。W1几乎收不到更新信号,网络就永远停留在“随机映射”状态。

那为什么输出层W2好像还能动?因为dz2直接是softmax交叉熵的残差,它不经过饱和激活,数值相对正常。但W2只是在一堆杂乱无章的隐藏层特征上做线性分类,MNIST的类别模式被完全打乱了,所以准确率也上不来。这种现象,我建议你在自己的代码里一定要亲手打印一次,这种体感比看十篇文章都有用。

3.3 观察二:softmax原始实现exp溢出,loss直接inf

第二种现象更暴躁。当权重标准差继续加大到5.0,或者网络层数加深以后,z2的数值会非常分散。我调试时曾经见过z2的标准差超过80,最大元素达到几百。如果你实现softmax时用的是最朴素的写法:

exp_z = np.exp(z) p = exp_z / np.sum(exp_z, axis=1, keepdims=True)

那么np.exp几百的输入,在float64下其实还能勉强撑住,因为float64的最大值约1.8e308,exp(709)才会超过这个上限。但z2的极端元素如果上千,就会直接变成inf。一旦出现inf,交叉熵损失大概率也是NaN,梯度全是NaN,整个训练直接崩溃。

解决这个问题的办法是用数值稳定的softmax:

z = z - np.max(z, axis=1, keepdims=True) exp_z = np.exp(z) p = exp_z / np.sum(exp_z, axis=1, keepdims=True)

但我要强调一点:数值稳定softmax只是让“exp计算”不再爆掉,它并不会修复大权重带来的网络失效。你只是从“NaN”变成了“loss死活在2.3左右不动”。所以调试顺序应该是:先把NaN消除了,再去看激活分布是否正常,最后再判断初始化是否合理。

4. 对症下药:Xavier和He初始化是怎么把信号方差“守恒”住的

4.1 从“方差守恒”倒推初始化公式

明白了大权重为什么毁掉训练,解决方案的核心就一个字:稳。我们不希望每层信号方差在前向和反向传播中无限放大,也不希望它缩小到零,最好是每一层过后,方差保持在同一量级。这个思路叫“方差守恒”。

先看前向。要让第l层的输出方差等于第l层输入方差,需要满足:

[ n_{in} \sigma_w^2 = 1 ]

也就是权重标准差取:

[ \sigma_w = \frac{1}{\sqrt{n_{in}}} ]

再者,反向传播的误差信号穿过第l层时,还受输出维度的影响。要同时兼顾前后向两个方向,Glorot等人提出了Xavier初始化,标准差取:

[ \sigma_w = \sqrt{\frac{2}{n_{in} + n_{out}}} ]

如果激活函数是ReLU,输出中有一半左右的神经元会被置零,相当于信号方差天然减半,所以He初始化把标准差调成:

[ \sigma_w = \sqrt{\frac{2}{n_{in}}} ]

这个“2”就是在补偿ReLU带来的减半效应。sigmoid/tanh没有这个特性,所以用Xavier更合适。

4.2 手写LeCun/Xavier/He三种初始化

在NumPy里,这三种初始化其实就是一行代码的事。我习惯把它们写成三个小函数,方便在实验中切换。

def lecun_init(n_in): return np.random.randn(n_in) / np.sqrt(n_in) def xavier_init(n_in, n_out): return np.random.randn(n_in, n_out) * np.sqrt(2.0 / (n_in + n_out)) def he_init(n_in, n_out): return np.random.randn(n_in, n_out) * np.sqrt(2.0 / n_in)

用的时候注意,矩阵形状。比如W1的shape是(784, 128),那么就是xavier_init(784, 128)。不要搞混fan_in和fan_out,否则会差几十倍。Xavier初始化还有一种常见写法是均匀分布:

limit = np.sqrt(6.0 / (n_in + n_out)) W = np.random.uniform(-limit, limit, (n_in, n_out))

它和正态分布版本的方差略有不同,但背后逻辑一致。我个人在NumPy手写网络时更喜欢用正态分布版本,因为和randn生成的随机数无缝衔接,调试起来也更直观。

4.3 换成合理初始化之后,MNIST训练发生了什么

我把上一篇的两层网络初始化改成Xavier后,同一份数据、同一个学习率,效果是天壤之别。W1的标准差从1.0变成了0.090左右,W2的标准差从1.0变成了0.120左右。打印每个epoch的loss会看到很健康的下降曲线:第一个epoch结束时loss能下到0.5以下,三个epoch之后到达0.2左右,测试集准确率在92%到95%之间。

这组数据不是某个神秘技巧带来的,而是因为每一层的输入信号方差没有失控。z1的标准差保持在1附近,sigmoid输出没有大面积饱和,梯度范数维持在正常量级,网络终于可以真正学习特征了。

在我看来,Xavier和He初始化并不会让模型“变强”,它只是不干扰模型“变强”。前面的“大权重”等于给网络套上了沉重的枷锁,你后面无论怎么调学习率、换优化器,都只是在枷锁里挣扎,很难有实质进展。

5. 与大权重初始化纠缠不清的三个隐性坑

5.1 偏置项:别让“大权重”和“大偏置”联手

权重初始化是重灾区,但偏置初始化同样不可忽略。在MNIST那个两层网络里,如果我把b1初始化为全1,甚至全10,会产生什么效果?偏置会额外抬高z1的整体水平,让sigmoid更早进入饱和区。也就是说,即使权重本身用了Xavier初始化,一个大偏置仍然能毁掉整个前向传播。

实践中的惯例是偏置初始化为0,或者很小的常数。原因也很简单:权重矩阵已经提供了足够的随机扰动,信号方差不需要偏置再来“添柴”。如果你的网络层数很深,某些情况下可以给偏置设一个非零小值来控制激活状态,但前提是你清楚自己在干什么。对MNIST这个任务,b1和b2都是零向量就够用了。

调试时如果发现激活层非饱和但梯度分布明显偏移,记得看一眼偏置的梯度。我遇到过一个问题:loss下降很慢,打印z1的均值一直是1.2左右,后来发现是b1初始化为0.1,经过多层累积导致均值漂移。把b1改回0以后,问题立刻消失。这种问题很容易被忽略,因为它看起来和权重初始化无关,但机制完全一样。

5.2 输入数据没归一化,等于把784维数据的方差又放大了一轮

MNIST数据集原始像素值在0到255之间,很多教程代码会直接把图像reshape成784维向量,然后喂给神经网络。如果你没有做归一化,输入x的方差会非常大。0到255均匀分布数据的标准差大概为73.7,而0到1归一化后的标准差约为0.29,两者相差254倍。

还记得第2节的公式吗?输出方差正比于输入方差。输入方差扩大254倍,相当于权重标准差扩大了约16倍。也就是说,即使用Xavier初始化,没做数据归一化,信号照样会被推到激活函数的饱和区。这也是很多初学者“照着正确初始化代码写,但依然不收敛”的常见原因。

我是这么处理MNIST数据的:

X_train = X_train.reshape(-1, 784).astype(np.float64) / 255.0 X_test = X_test.reshape(-1, 784).astype(np.float64) / 255.0

除以255看似简单,但它是和初始化同级别的关键步骤。如果切换其他数据集,最好先计算每个特征的均值和标准差,做标准化或者归一化。总之,输入尺度和初始化是配套的,只看其中一个都会踩坑。

5.3 学习率是放大器的旋钮:初始化差+学习率大=一步爆掉

最后一个隐形坑,我必须单独拿出来说,因为它和大权重初始化经常一起出现。学习率和初始化不是独立的,参数更新的公式是:

[ W = W - \eta \cdot \text{grad_W} ]

在大权重初始化下,梯度范数可能本身就偏大。比如当反向传播出现梯度爆炸时,grad_W的范数可能接近1e2,如果学习率再设成0.5,那一步更新之后,权重的变化量就是50。这比初始化权重本身还要大,下一轮前向传播必然彻底失控。

即使网络没有梯度爆炸,大权重 + 大学习率也会造成严重的参数震荡。我在实验里见过一种有趣的“假象”:用标准差1.0初始化,学习率设为0.5,前10步loss会大幅下降,然后突然冲高变成NaN。原因是初始阶段大权重产生的大梯度刚好把参数“推”到更合理的区域,但随后权重进入更大的量级,梯度再度爆炸,最终崩溃。这种骗人的早期收敛很容易误导人,让你以为大初始化也没关系。

正确的排查顺序应该是:先用Xavier/He初始化,再确认输入归一化,最后调学习率。如果顺序反了,你会发现调学习率很难调明白,有时候调小有用,有时候调小反而训练不动。把初始化修好,学习率才是一个可以理性调试的参数。

6. 一份可直接运行的对照代码:复现问题,再亲手救回来

6.1 完整实现:两层MLP、交叉熵、三种初始化对照

这一节我把能直接跑的NumPy实现放在下面。为了聚焦初始化问题,代码故意写得朴素,不用BatchNorm、不用Adam、不加正则化。你可以把init_mode分别改成small、large、xavier跑三遍,观察差异。

import numpy as np def sigmoid(x): return 1.0 / (1.0 + np.exp(-x)) def sigmoid_derivative(a): return a * (1.0 - a) 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): return -np.mean(np.sum(y_onehot * np.log(p + 1e-12), axis=1)) def train_with_init(X_train, y_train, X_test, y_test, hidden=128, epochs=5, lr=0.1, init_mode='xavier'): n_in = X_train.shape[1] n_out = 10 N = X_train.shape[0] if init_mode == 'small': W1 = np.random.randn(n_in, hidden) * 0.01 W2 = np.random.randn(hidden, n_out) * 0.01 elif init_mode == 'large': W1 = np.random.randn(n_in, hidden) * 1.0 W2 = np.random.randn(hidden, n_out) * 1.0 elif init_mode == 'xavier': W1 = np.random.randn(n_in, hidden) * np.sqrt(2.0 / (n_in + hidden)) W2 = np.random.randn(hidden, n_out) * np.sqrt(2.0 / (hidden + n_out)) else: raise ValueError('unknown init_mode') b1 = np.zeros(hidden) b2 = np.zeros(n_out) y_onehot = np.zeros((y_train.size, n_out)) y_onehot[np.arange(y_train.size), y_train] = 1.0 for epoch in range(epochs): # 前向传播 z1 = X_train @ W1 + b1 a1 = sigmoid(z1) z2 = a1 @ W2 + b2 p = softmax(z2) loss = cross_entropy(p, y_onehot) # 反向传播 dz2 = p - y_onehot grad_W2 = a1.T @ dz2 / N grad_b2 = np.mean(dz2, axis=0) dz1 = (dz2 @ W2.T) * sigmoid_derivative(a1) grad_W1 = X_train.T @ dz1 / N grad_b1 = np.mean(dz1, axis=0) # 参数更新 W1 -= lr * grad_W1 b1 -= lr * grad_b1 W2 -= lr * grad_W2 b2 -= lr * grad_b2 # 测试准确率 test_z1 = X_test @ W1 + b1 test_a1 = sigmoid(test_z1) test_z2 = test_a1 @ W2 + b2 preds = np.argmax(softmax(test_z2), axis=1) acc = np.mean(preds == y_test) print(f"epoch {epoch + 1}, loss {loss:.4f}, test acc {acc:.4f}") return W1, W2

你还需要加载MNIST数据。框架自带的API很方便,但如果你遇到网络下载404之类的问题,可以手动下载mnist.npz文件放本地读取。数据加载完成后,直接调用上面的函数:

# X_train: (60000, 784), y_train: (60000,) # X_test: (10000, 784), y_test: (10000,) train_with_init(X_train, y_train, X_test, y_test, init_mode='large')

6.2 运行这个脚本后,你应该观察到的三件事

第一,large模式在5个epoch后,loss大概率还在2.3附近,测试准确率接近10%。如果第一次跑的时候连NaN都出现了,别慌,把softmax稳定版本换成朴素版本,或者把权重标准差再调大到5.0,就能稳定复现NaN。第二,small模式会慢慢下降,但速度偏慢,5个epoch后准确率可能只有80%多,这个版本的问题是每层信号方差过小,导致有效表达能力不足。第三,xavier模式最健康,loss稳步下降,测试准确率能到93%上下。

我个人在实际操作中的体会是,大权重初始化这个问题,只背结论不够,你一定要亲手把权重标准差改成1.0,然后把z1的标准差打印出来看一眼。当你看到sigmoid输出大面积钉在0和1上、梯度范数小到10的负几百次方时,你才算真正理解为什么深度学习框架里的初始化函数会有那些看起来“难以理解”的默认取值。把这个坑踩明白,后面再看更深的网络、CNN或者Transformer,你都能解释清楚“为什么这里要用这种初始化”了。

需要专业的网站建设服务?

联系我们获取免费的网站建设咨询和方案报价,让我们帮助您实现业务目标

立即咨询