模型结构调了无数遍,batch size、学习率都换了,loss就是卡在一个位置纹丝不动;或者换个随机种子,昨天还能稳稳收敛,今天直接变成NaN。如果你也遇到过这些情况,大概率不是你模型写得不对,而是参数初始化这层“地基”没打好。
很多同学刚接触深度学习时,对参数初始化的理解就是“随便给权重赋个值”,要么用全零,要么用np.random.randn一把梭。直到某天发现同样的网络、同样的数据,别人的代码收敛得又快又稳,自己的却像中了邪一样状况百出,才会回头研究这“不起眼”的初始化到底在干什么。这篇文章就是围绕“深度学习参数初始化”这一主题,把主流初始化方法的原理、计算公式、适用场景、PyTorch实操,以及我踩过的那些和初始化有关的坑,一次性讲清楚。
文章适合正在训练模型但总被loss不降、NaN、死神经元折磨的人,也适合想系统梳理初始化方法、准备把网络调得又快又稳的进阶玩家。看完之后,你应该能对“该用Xavier还是Kaiming”“标准差设成多少”“初始化完怎么检查对不对”这些问题有自己的判断。
1. 先搞清楚一个问题:参数初始化到底在解决什么
很多人把初始化当成“拍脑袋选个随机数”的小事,但从数学上看,初始化是在给整个网络的信号传输设定一个初始条件。这个初始条件选得不好,后面跑多少轮迭代都很难拉回来。
1.1 对称性陷阱:全零初始化的“隐形失败”
先说不推荐的做法:全零初始化。理论上,梯度下降会自己调整每个权重的值,那为什么不直接从零开始?问题在于“对称性”。
想象一个两层全连接网络,如果所有权重初始化为0,那么前向传播时,同一个隐层里的所有神经元收到的是完全相同的输入信号和权重,计算出的激活值自然完全相同。反向传播时,它们又收到完全相同的梯度。于是所有神经元的参数更新量一模一样——无论训练多久,同一层的神经元都像克隆体一样保持同步更新,永远学不到不同的特征。
这就相当于你设置了一个容量很大的网络,却只使用了它的一条通路,模型退化成了一种“变宽的逻辑回归”式的结构。更隐蔽的是,如果只用“偏置置零、权重随机”的常见操作,偏置本身没有对称问题,但权重若在某些特殊情况下出现对称分布(比如全为同一个非零常数),同样会触发这种退化。所以核心原则是:权重必须打破对称性,且每个参数最好有不同的随机值。
那是不是把每个参数设成不一样的随机数就万事大吉了?还不够,分布尺度更关键。
1.2 信号在深层网络里的“接力赛”:方差如何被逐层放大或衰减
随机初始化让每个神经元有了不同的起点,但各层的权重尺度没有统一约束时,深层网络会遇到更麻烦的问题:梯度消失和梯度爆炸。
用一个接力赛类比:每一层网络都像一位接力选手,选手的“速度变化”由权重矩阵的方差控制。如果一层权重的方差是0.8倍,经过50层之后,信号强度会变成0.8的50次方,约等于0.000014,基本等于“熄火”。反过来,如果每层方差是1.2倍,50层之后就是1.2的50次方,约等于9100倍,数值直接溢出成NaN。
这就是为什么不能随便选一个标准差。假设你用了简单的高斯初始化,标准差设成0.01,在单层网络里声音很小但能听见;一旦堆到20层以上,前向传播的输出会逐层收缩到零,反向传播时梯度也一路上传边缩边没。反之,标准差设成1.0,浅层网络可能没事,深层网络的前向值很快就涨到天文数字。
从信号传输的角度看,初始化最重要的目标就是:让每一层的输入方差和输出方差保持在一个可控区间,既不指数扩张,也不指数衰减。后来出现的Xavier、Kaiming等初始化方法,本质上都是在数学上求解“方差怎么设定,才能在层间传导时保持不变”这个问题。
1.3 起跑点决定终点:初始化对收敛速度和最终效果的影响
除了信号传输的物理约束,初始化还影响你最终收敛到损失曲面上的哪个“坑位”。深度网络的损失函数是一个高维非凸函数,存在大量局部最优、鞍点和平坦区域。不同初始化相当于从不同起点出发,在相同的学习率、相同的迭代次数下,最后到达的位置可能差别很大。
我做过一个直观的小实验:同一个三层的MLP,只改初始化时用的随机种子,别的超参全不动,训练相同epoch后,验证集准确率可以差出3到8个百分点。这不是玄学,是初始化带来的“有效学习率”差异。权重初始化尺度大时,模型对输入的敏感度很高,一步更新的相对幅度变小,等于隐式降低了学习率;初始化尺度小时则相反。所以初始化不仅决定能不能收敛,还会影响收敛速度和最终精度。
现在你可能明白了:初始化不是一个待调的超参数,而是所有深度学习训练的前置条件。知道它在解决什么问题之后,接下来看各种主流初始化方法,就能看出门道了。
2. 主流初始化方法拆解:原理、参数与适用场景
从最简单的做法开始,逐步过渡到现代框架默认采用的方案。每个方法我会讲清楚它的数学假设、边界参数怎么来的,以及实际使用时的注意点。
2.1 零初始化与常数初始化:不是不能用,而是要用对地方
前面说了全零初始化会引发对称性问题,但有一类参数可以并且应该用常数初始化。偏置项通常初始化为0,这不会造成对称性,因为每个神经元的输入权重不同,打破对称的主力是权重矩阵。
另外,对于某些特定模块,常数初始化反而是标准操作。比如BatchNorm层里的缩放因子γ,一般初始化为1,偏置β初始化为0。这样在训练初期,BN层相当于一个标准的归一化操作,不额外改变信号的幅度,让模型先学会稳定的特征表达,再由梯度来决定是否调整γ和β。
如果你设计了自己的网络层,且该层后面接了强归一化机制(如LayerNorm、BatchNorm),那么权重也可以考虑用一个接近0但非零的固定值来启动,这样前向传播完全是归一化后的标准分布,后续靠训练学出真实分布。但请注意:这只适用于后面有归一化兜底的场景,裸的全连接或卷积层不能这样干。
2.2 随机正态与随机均匀初始化:最简单的方案藏着一个大坑
最直观的初始化方式当然是从某种分布里采样。PyTorch里torch.randn采样标准正态分布,torch.rand采样[0,1)均匀分布,代码写起来十分顺手。但问题是,标准正态的标准差是1,对于大多数深度学习任务来说,这个尺度太大了。除非你的网络只有一两层,否则前向传播很容易爆炸。
均匀分布稍微可控一点,你可以自己设置范围[-limit, limit],然后乘以一个小系数。但这个“小系数”要怎么选?没有理论的支撑就只能靠试。试错多了就会遇到两个典型问题:系数设大,深层的输出方差急剧增加,训练初期就NaN;系数设小,浅层勉强有信号,深层梯度传不回来,loss下降非常缓慢。
还有一个更隐蔽的坑:均匀分布在范围内的方差是limit²/3,很多人会忘记这一点,直接用标准正态的直觉去反推均匀分布的界限。比如你想让方差约等于0.05,那均匀分布的limit应该取sqrt(3 * 0.05)约0.387,而不是凭感觉取0.05。这种细节在工程里很容易埋雷。
所以,简单的随机初始化不是不能用,而是只适合浅层网络、线性模型,以及作为复杂初始化方法的“底料”。一旦网络深度增加,就必须引入有理论支撑的初始化策略。
2.3 Glorot/Xavier初始化:对称激活函数的黄金选择
Glorot初始化(也被称为Xavier初始化)是第一批从数学上回答“方差该怎么设”的方法之一。它的目标很直接:在满足一定假设的前提下,让每一层的输出方差等于输入方差,并且在反向传播时保持梯度方差不变。
推导的核心思路是:考虑一个线性层 y = Wx,如果W的每个元素独立采样且均值为0,x的每个分量也独立且均值为0,那么y的每个输出分量的方差约等于n_in * Var(W) * Var(x),其中n_in是输入维度。
要让输出方差和输入方差一致,就需要n_in * Var(W) = 1,即Var(W) = 1 / n_in。但反向传播时,梯度要乘的是转置权重,梯度方差对应的维度变成输出维度n_out,所以反向要求Var(W) = 1 / n_out。两者通常没法同时成立,Glorot取了个折中:让方差等于2 / (n_in + n_out)。
如果采样均匀分布U(-limit, limit),均匀分布的方差是limit²/3,于是有:
limit² / 3 = 2 / (n_in + n_out) limit = sqrt(6 / (n_in + n_out))这就是Glorot均匀分布初始化里那个sqrt(6)的来源。PyTorch中的nn.init.xavier_uniform_用的就是这个公式;xavier_normal_则直接从均值为0、方差为2 / (n_in + n_out)的正态分布里采样。
Glorot初始化有一个重要假设:激活函数在零点附近是线性的。所以它特别适合tanh、softsign这类关于原点对称、且在0附近近似线性的激活函数。对于Sigmoid,因为输出均值不是0,效果会差一点;对于ReLU,由于负半轴直接截断,会让实际输出方差减半,需要别的方法来修正。
2.4 Kaiming/He初始化:ReLU时代的默认选项
ReLU激活函数在深度网络里大行其道之后,Glorot初始化出现了一个明显问题:ReLU会把负半轴的输出全部变成0,实际信号的方差会比理论值小一半。如果还按照Glorot的公式去初始化,每层信号都会逐渐收缩,深层网络依然有梯度消失的风险。
Kaiming初始化(也常称为He初始化)的思路,是在推导时显式考虑ReLU的截断效应。假设输入x在ReLU前是一个均值为0的对称分布,经过ReLU后,负半轴被置0,但由于对称性,正半轴的分布被完整保留下来,而整体均值变成正的一半。简化计算后,ReLU输出的方差大约只有输入方差的一半。
为了让输出方差维持和输入一致,权重方差需要扩大2倍。于是针对激活函数为ReLU的层,权重方差设定为:
Var(W) = 2 / fan_in对应到标准差就是sqrt(2 / fan_in)。这里fan_in表示输入神经元个数,如果使用fan_out模式,则用输出维度计算。
PyTorch里对应两个常用函数:kaiming_uniform_和kaiming_normal_。其中kaiming_uniform_的边界是limit = sqrt(3) * sqrt(2 / fan_in),也就是从U(-sqrt(6 / fan_in), sqrt(6 / fan_in))中采样;kaiming_normal_则从均值为0、标准差为sqrt(2 / fan_in)的正态分布中采样。
在实操中,fan_in模式通常用于正向信号传播,fan_out模式更关注反向梯度的传递。对于CNN来说,卷积核的fan_in是所有输入通道数乘以卷积核面积,例如一个输入64通道、核尺寸3×3的卷积层,fan_in就是64×3×3=576,初始化标准差就是sqrt(2/576)约0.059。很多人会直接照抄某些博客里的kaiming_normal_,却不检查自己层的fan_in是不是一致,这就是初始化问题的常见隐患。
2.5 正交初始化与预训练初始化:两条特殊路径
除了Xavier和Kaiming这类方差保持初始化,还有几个特定场景常用的初始化方法。
正交初始化(Orthogonal)主要用在RNN、LSTM这类循环网络上。循环网络的问题在于,同一个权重矩阵会被反复相乘很多步,特征值的模长如果偏离1,信号就会指数放大或衰减。正交矩阵的奇异值都是1,能最大程度保持梯度方向。PyTorch中nn.init.orthogonal_可以直接生成正交矩阵,配合RNN的tanh激活效果不错。需要注意,如果输入输出维度不一致,PyTorch会自动对矩阵做填充或截断处理,尽量保持正交性。
预训练初始化则是另一种思路:用大规模数据上训练好的权重作为起点,再做下游任务的微调。这相当于从损失曲面中一个“已经被验证有效”的点出发,比从随机点出发靠谱得多。但微调时通常不要对预训练层重新做随机初始化,否则等于把已经学到的特征清零;只需要对新增的任务头(比如分类头)使用合适的随机初始化即可。
下表简单整理了这些方法的适用场景:
| 初始化方法 | 核心公式/分布 | 适用激活/网络 | 典型场景 |
|---|---|---|---|
| 零初始化 | 全0 | 偏置、BN层γ | 偏置、归一化层 |
| 简单随机 | 自定义标准差 | 浅层线性网络 | 快速原型验证 |
| Glorot/Xavier | 方差=2/(fan_in+fan_out) | tanh、对称激活 | FC层、对称激活网络 |
| Kaiming/He | 方差=2/fan_in | ReLU族 | CNN、MLP、ReLU网络 |
| 正交初始化 | 正交矩阵 | RNN/LSTM | 循环网络 |
| 预训练 | 已有模型权重 | 任意 | 迁移学习 |
3. 实操配置:怎么看网络结构选初始化,PyTorch怎么写
理解了原理之后,实操的核心就是两件事:选对方法,写对代码。选错方法时,训练各种问题层出不穷;代码写漏时,网络里某些层还保持默认初始化,同样影响效果。
3.1 不同网络架构的初始化搭配建议
先说常见的搭配口诀:
- CNN + ReLU:默认用Kaiming初始化,卷积层用
kaiming_normal_,全连接层用kaiming_uniform_都行,关键是nonlinearity='relu'别漏。 - MLP + tanh:优先Glorot/Xavier初始化。tanh是零中心对称的,Xavier的线性假设成立。
- LSTM/GRU:权重用正交初始化,偏置设零。为什么正交更稳?因为循环结构的矩阵连乘次数多,正交矩阵能避免特征值偏离1带来的爆炸或衰减。
- Transformer结构:通常用标准差为
sqrt(2 / (fan_in + fan_out))的截断正态初始化,但这其实不是简单套用Kaiming。Transformer里很多层会配合LayerNorm使用,初始化尺度的容错率更高,实践中很多实现用的是默认均匀初始化+残差连接的组合,硬套Kaiming反而可能让第一个残差块前向值偏大。 - BatchNorm层:γ初始化为1,β初始化为0,跟主网络权重分开来设置。
注意,这里的搭配是“从原理出发的通用起点”,不是绝对真理。如果网络特别深,比如100层以上的ResNet式结构,Kaiming也能工作,但配合残差连接后信号传输特性会变,需要结合是否使用BN、残差缩放系数来整体考虑。
3.2 PyTorch自定义初始化的五种常用写法
在PyTorch里,PyTorch官方文档和各种开源项目给出了不同的写法,我把常见套路整理为五种。
第一种,在模型类里直接初始化。适合自用模型,代码直观:
import torch.nn as nn class TinyNet(nn.Module): def __init__(self): super().__init__() self.fc1 = nn.Linear(784, 256) self.fc2 = nn.Linear(256, 10) nn.init.kaiming_normal_(self.fc1.weight, mode='fan_in', nonlinearity='relu') nn.init.kaiming_normal_(self.fc2.weight, mode='fan_in', nonlinearity='relu') nn.init.zeros_(self.fc1.bias) nn.init.zeros_(self.fc2.bias) def forward(self, x): x = torch.relu(self.fc1(x)) return self.fc2(x)第二种,遍历model.modules()按类型批量初始化。这是最通用、也最常用的方式:
def init_weights(m): if isinstance(m, nn.Linear): nn.init.kaiming_uniform_(m.weight, mode='fan_in', nonlinearity='relu') nn.init.zeros_(m.bias) elif isinstance(m, nn.Conv2d): nn.init.kaiming_normal_(m.weight, mode='fan_in', nonlinearity='relu') model.apply(init_weights)model.apply会递归地对所有子模块调用传入的函数,所以一个函数就能覆盖整个网络。写的时候要注意顺序:apply会先处理父模块再处理子模块,如果父模块也是符合类型的(比如某些命名为Conv的复合模块),可能被子模块覆盖,稳妥起见在函数里做好类型分支。
第三种,自定义初始化函数配合闭包传参。适合需要传非线性类型或激活函数名称的场景:
def make_init_fn(activation='relu'): def init(m): if isinstance(m, (nn.Linear, nn.Conv2d)): nn.init.kaiming_normal_(m.weight, mode='fan_in', nonlinearity=activation) if m.bias is not None: nn.init.zeros_(m.bias) return init model.apply(make_init_fn('relu'))第四种,直接操作model.state_dict()里的张量。适合从已有模型加载权重并选择性覆盖的场合,比如只对分类头做初始化:
state = model.state_dict() state['fc2.weight'].normal_(0, 0.01) model.load_state_dict(state)第五种,在reset_parameters方法里重写。如果你自定义了一个新层类,可以重写层里的reset_parameters,PyTorch在调用apply或新建层时会自动调用它。这是框架层面的支持,建议自定义层都继承这个习惯,保证复用性。
3.3 初始化后的“体检”:手把手验证参数与激活输出
初始化写完之后,别急着开训练。我强烈建议先跑一次“初始化体检”,用一行行代码确认信号强度是否合理。这一步的价值在于能提前暴露问题,省得训练几小时之后才发现方向不对。
先检查参数本身的基本统计量:
for name, param in model.named_parameters(): if param.ndim >= 2: print(name, 'std:', param.data.std().item(), 'mean:', param.data.mean().item())如果某个线性层的std接近0.01以下,而你的网络有几十层,基本可以断定深层信号会熄灭;如果某个卷积层的std超过0.5,又没接BN,那前向很可能爆掉。
更有效的手段是检查前向传播时每一层的激活输出。注册临时hook,把每层的输出mean和std打出来:
import torch def inspect_forward(model, x): results = {} def hook_fn(name): def fn(module, input, output): results[name] = (output.data.std().item(), output.data.mean().item()) return fn hooks = [] for name, module in model.named_modules(): if isinstance(module, (nn.Linear, nn.Conv2d)): hooks.append(module.register_forward_hook(hook_fn(name))) model.eval() with torch.no_grad(): model(x) for h in hooks: h.remove() for name, (std, mean) in results.items(): print(f'{name}: std={std:.4f}, mean={mean:.4f}')运行之后,判断标准很简单:在没有BN、使用ReLU的网络上,前几层输出的std应该在0.5到2之间,并且逐层不会出现指数级的放大或缩小。如果第一层输出std是5,第二层变成25,那就说明初始化的尺度太大;如果第一层是0.01,第二层变成0.0001,那就是信号快熄火了。
我经常在跑大模型前做一次这样的体检,几十秒就能拦截掉80%的训练异常。这个习惯在调新结构、新深度时尤其管用。
4. 训练异常排查实录:loss不降、NaN和随机波动
初始化相关的坑,最终都会以训练异常的形式暴露出来。我整理了几类最常见的现象和排查路径,供你对照使用。
4.1 loss死活不下降,先查前向信号是不是“熄火”了
如果你的网络结构没问题、数据预处理也正常,但loss从一开始就纹丝不动,或者降得极慢,第一步不是调学习率,而是要怀疑初始化让梯度信号消失了。
具体的排查方法就是前面说的激活输出体检。我曾经调试过一个20层的全连接网络,用了简单随机初始化,标准差设了0.02,结果第15层之后输出std变成了0.00001级别。这意味着所有参数更新时,传到前面层的梯度已经小到等于没有——网络实际上只在更新最后几层,前面的层全是摆设。改成Kaiming初始化后,loss立刻开始正常下降。
另一个隐蔽情况是“loss下降一点点然后卡住”。如果中高层输出方差正常,但深层还是偏小,可以考虑把初始化换成fan_out模式,让反向传播梯度更顺畅。很多框架默认用fan_in,但在层数较多时,用fan_out配合relu往往能兼顾两个方向的信号。
4.2 训练刚开始就NaN:初始化方差与学习率的双重失控
NaN是最吓人的异常,但其实也最好定位。通常不是数据里有NaN,就是前向计算炸了。如果模型刚跑第一个batch就NaN,优先怀疑初始化尺度过大加上学习率过大的组合。
举个典型场景:你用nn.init.normal_(weight, 0, 1.0)初始化一个五层卷积网络,学习率设0.1。前向传播的数值可能已经达到几千,反向传播梯度更是天文数字,一步更新直接让参数越过有效区间,输出变成NaN。这时把初始化标准差缩小到0.05、学习率降到0.01,问题往往立刻消失。
我在实践中还有一个经验:遇到NaN时,先看“NaN出现在第几个epoch”。如果第一个batch就NaN,八成是初始化或数据问题;如果训练到中途才NaN,且之前loss正常,通常是学习率过大或数据里有异常样本。两者的排查方向截然不同。前者优先调初始化和lr,后者优先看梯度剪裁和数据清洗。
4.3 死神经元与输出恒定:ReLU网络的特殊陷阱
训练ReLU网络时,常出现一类“看似正常但实际学不动”的情况:网络输出稳定在一个值,梯度几乎为0,很多神经元的激活永远是0,这叫“dead ReLU”。如果初始化分布不合适,让很多神经元在一开始就落入ReLU的负半区且梯度无法把它们拉出来,它们就彻底失去作用。
Kaiming初始化在设计时考虑了ReLU的半数截断问题,但单靠初始化不能完全杜绝dead ReLU。如果排查时发现dead神经元比例超过20%,可以尝试三个方向:
- 把初始化标准差适当加大一些,例如标准差乘1.2或1.5,让更多神经元的输入为正。
- 换用
kaiming_normal_而不是kaiming_uniform_,正态分布中心附近概率更集中,负输入比例可能更低。 - 检查学习率是否过大,过大的lr可能让权重一步就跳到ReLU饱和区,造成批量死亡。
4.4 随机种子导致的“薛定谔的收敛”问题
同一份代码,昨天跑得好好的,今天换个随机种子结果差异大得离谱。这类问题在深层网络中很常见,尤其是当你使用了相对脆弱的初始化方法和较大的学习率时。
如果多次运行结果波动很大,可以先重复跑5次,记录验证集指标和loss曲线。假如最好的和最差的能差出好几个百分点,那你的模型本身就对初始化很敏感。这时有几个控制手段:
- 固定随机种子,让实验可复现,至少在调参阶段保证可比性。
- 尝试把学习率降低一些,给模型更多“纠正起点偏差”的余地。
- 检查是否有层使用了默认初始化而没覆盖,这会造成不同运行间随机变量更多。
我把这些经验和排查过程整理成了一张速查表:
| 现象 | 可能原因 | 优先排查/解决方案 |
|---|---|---|
| loss一直不降 | 初始化标准差过小,信号消失 | 检查激活输出std,换Kaiming/增大std |
| 第一个batch就NaN | 初始化scale过大 + lr过大 | 缩小初始化标准差,降低学习率 |
| 训练中段出现NaN | 学习率过高/数据异常 | 梯度剪裁,检查数据 |
| 大量dead neuron | ReLU负半区过多 | 增大std,检查lr,考虑leaky ReLU |
| 不同种子结果波动大 | 初始化灵敏/lr偏大 | 固定种子,降lr,检查漏初始化层 |
| 深层输出渐近为0 | 没有BN且初始化不匹配激活 | 用fan_in重新匹配激活类型 |
5. 我的一些补充建议与项目习惯
文章最后,再分享几个实操中沉淀下来的习惯和小技巧,不算系统总结,但绝对值得一试。
第一个习惯是,我把初始化当成超参数实验的一部分,而不只是固定写死的代码。开始一个新模型时,我通常跑三组对比:Kaiming正常尺度、Kaiming小尺度(比如标准差乘0.5)、Xavier。只要用一小段训练日志观察前几十个batch的loss变化趋势,基本就能判断哪组初始化更适合当前网络。很多时候通过这种对比,还能侧面判断当前学习率是否匹配。
第二个技巧是关于初始化和学习率的联动。很多人分别调init和lr,但实际两者强烈耦合。我调试一个语义分割模型时,loss一直震荡,各种调lr都没用。后来无意中把初始化标准差从0.05降到0.01,同样的lr,loss一下子稳定下来了。原理就是前面提到的“初始化尺度影响有效学习率”,所以当你觉得lr不好调时,不妨同时缩小初始化尺度试试,而不是非要在lr上一条路走到黑。
第三个建议是,如果你的网络里大量使用BN,初始化可以稍微放宽,但不能完全忽视。BN会把每层输入重新拉回标准分布,因此前向信号传播对初始化不再那么敏感。但第一层卷积/线性层通常不在BN的“保护范围”内,那层的初始化仍然会直接影响后续信号质量,预算资源不足时,优先认真设置第一层的初始化。
最后一个来自踩坑的小建议:使用预训练模型微调时,不要对预训练层重新初始化,也不要把新分类头的初始化尺度设得和预训练主干一样大。新增的分类头只是小样本任务上的“浅层映射”,用一个较小标准差的初始化(比如0.01或0.02)通常更稳妥,避免训练初期新头输出过大,破坏主干回传的梯度质量。
参数初始化看起来只是训练流水线里的一个小步骤,但它决定了整个模型信号传输质量的起点。希望这篇内容能帮你少走一些弯路,下次再遇到loss不降或者NaN的时候,能想起先回头看一眼初始化的参数分布。