☰
PyTorch参数初始化与正则化:从训练崩溃到稳定泛化的关键技巧
2026/10/14 4:07:29 网站建设 项目流程

刚接触深度学习的时候,我踩过最莫名其妙的一个坑:网络结构没问题、学习率调得中规中矩,可训练集 loss 就是降不下去,要么震荡,要么直接 loss 变成 NaN。查来查去,最后发现是参数初始化方式不对。还有个更隐蔽的问题:模型在训练集上表现不错,一到验证集就现原形,调了半天结构无用,最后是加上权重衰减和 Dropout 才稳住。这两个东西,一个决定模型“起跑线”,一个决定模型“能在验证集上跑多远”,是 PyTorch 里最容易忽视却又最能左右结果的关键细节。

这篇内容不绕弯子,直接讲参数初始化和正则化在 PyTorch 里的原理、用法、踩坑记录,以及我实际调试时用到的对比方案。适合带着模型训练不起来、过拟合明显、想给网络加正则化但不知道从哪下手的读者。我把能落地的写法都写出来,照着抄就行,抄完再根据你自己的任务调参。

1. 参数初始化:决定模型起跑线的东西

1.1 全零初始化、对称性陷阱和“随机”不能乱来

很多刚入门的朋友会有个朴素想法:参数初始值看不懂,索性全设成 0 不就行了?损失函数在 0 附近也算平滑,对吧?实际跑一轮你就知道问题在哪了。

如果同一层所有神经元的权重都是 0,那么在正向传播时,这一层每个神经元接收到的输入信号完全相同;反向传播时,每个神经元计算出的梯度也完全相同。也就是说,不管网络多宽,学习多少轮,同一层内的所有神经元始终在学一模一样的东西。这就是神经网络的“对称性”问题。全 0 初始化的网络,宽度等于摆设,特征表达能力被直接砍到“只有一个神经元”级别。

那随机初始化总行了吧?随机数也有讲究。如果权重数值太大,经过多层线性映射后会迅速放大,到最后一层激活值就爆了;如果权重数值太小,信号层层衰减,深层梯度直接消失。大家可以做个简单推导:假设每层有 n 个输入,权重满足均值为 0、方差为 σ² 的分布,那么输出方差大约等于 nσ²。想让信号在层层传递中保持同一个量级,就必须要求 nσ² ≈ 1,也就是 σ ≈ 1/sqrt(n)。这个平方根缩放,就是 Xavier 和 He 初始化在干的事情。

我调试时习惯在权重初始化后看一眼每一层激活值以及梯度的标准差。正常的网络,靠近输出的激活值标准差应当和靠近输入的量级差不多。如果很快就缩小几个数量级,那基本可以判断初始化选得不对,再调学习率也白搭。

1.2 PyTorch 常用初始化方法及适用场景

PyTorch 在torch.nn.init里提供了一套现成的初始化函数,不需要自己手写随机数生成器。常用的是下面这几个:

初始化方法核心公式/思想适用激活函数典型调用
xavier_uniform_/xavier_normal_保持方差在传播过程中恒定,均匀分布或正态分布tanh、sigmoid 等饱和激活函数nn.init.xavier_uniform_(w)
kaiming_uniform_/kaiming_normal_针对 ReLU 及其变体设计,考虑激活函数的非线性压减ReLU、LeakyReLUnn.init.kaiming_normal_(w, a=0.01)
orthogonal_生成正交矩阵,能将梯度稳定在某个方向RNN、GRU、LSTM 等循环结构nn.init.orthogonal_(w)
zeros_/ones_常量初始化偏置、特定模块的固定分支nn.init.zeros_(b)

光记表格不够,得明白为什么这么分。Xavier 初始化的前提是把激活函数近似成线性函数。比如 tanh 在 0 附近可以用一条直线近似,信号传输时方差能维持住。但 ReLU 会把一半的神经元输出压成 0,信号的方差天然折半,如果还用 Xavier 那种基于 1/sqrt(n) 的缩放,实际信号就会越传越小。He 初始化在推导时把 ReLU 的这个“减半效应”纳入考量,因此方差调整成 2/n。逻辑其实很朴素:神经元既然要死一半,那么活下来的信号密度就得加倍。

调用初始化函数一般放在__init__里,或者在构建完模型之后用model.apply(init_fn)批量处理。我的建议是:不要散落着写,定义一个统一的初始化函数,比如init_weights(m),然后model.apply(init_weights)。这样改起来方便,也能避免某层忘记初始化导致默认权重的坑。PyTorch 卷积层默认的初始化方式实际上也是经过设计的,不是随意赋值,但显式指出来,会让实验可复现性更好。

2. 正则化:从“会背答案”到“真会做题”

2.1 L2 正则化与权重衰减的等价关系

L2 正则化的数学形式是在损失函数后面加一项 λ/2 * ||w||²。这一项的意思很直白:不希望某个权重变得过大,如果某个维度上的权重数值特别突出,那它在总损失里占的惩罚就高,这样模型就不敢把赌注全押在少数几个特征上,迫使它把权重分散到更多有意义的特征里。

在 PyTorch 里,你不需要手动往 loss 里加这个正则项。所有主流优化器,比如 SGD、Adam,都有一个叫weight_decay的参数。你设置weight_decay=1e-4,就等价于给代价函数加了一个 L2 正则项。这里有一个容易理解错的地方:权重衰减本来是指每次更新参数时都给权重乘一个略小于 1 的系数,类似w = w * (1 - learning_rate * weight_decay),这和 L2 正则的梯度项在数学上可以互相推导。对标准 SGD,两者完全等价。但对 Adam 这类自适应学习率的优化器,直接设置weight_decay实现的是“将权重减小”,和严格意义上的 L2 正则略有不同。PyTorch 为了区分,还单独提供了AdamW,它的解耦权重衰减在 Transformer 领域几乎成了标配。

实际操作中我的经验是:SGD 搭配weight_decay就可以;Adam 训练大模型时,更推荐AdamW,它能避免自适应学习率和 L2 正则互相干扰。如果使用BatchNorm,要注意weight_decay会对 BN 层的缩放系数 γ 和偏移 β 也做惩罚。有些框架里会把 BN 参数单独分组,不给它们加权重衰减,因为你不太希望 BN 的归一化能力被限制。PyTorch 里可以通过参数分组实现:

optimizer = torch.optim.SGD([ {'params': model.features.parameters()}, {'params': model.classifier.parameters(), 'weight_decay': 1e-4}, ], lr=0.01)

这里我为了简单只分了两组,实际要根据网络结构决定把哪部分排除在正则化之外。虽然权重衰减只会让参数整体变小,但它的影响会通过反向传播反馈到整个网络的优化过程,不是简单调高就能万事大吉。

2.2 Dropout 实操与误用提醒

Dropout 的思路相当直观:训练时,每个神经元有一定概率 p 临时不参与工作,相当于每次前向传播都在使用一个“瘦身后”的子网络;这些子网络共享权重,最后近似于把很多不同子网络的预测结果融合在一起。因为神经元不能确定自己会不会被丢弃,它就不得不把信息分散到多个连接上,而不是依赖某个特定连接,这正是对抗过拟合需要的。

PyTorch 使用nn.Dropout(p=0.5),这里的p是“丢弃概率”,也就是说每个神经元有 50% 的概率被置零。注意,不是保留概率。有些框架的keep_prob语义恰好相反,换了框架容易踩坑。

另一个高频坑是忘了切换训练/评估状态。model.train()时会启用 Dropout,model.eval()时 Dropout 层会自动变成恒等映射。如果你在验证集评估时忘了写model.eval(),那么每次跑验证得到的准确率都会不一样,甚至会低不少。这个我身边已经不止一个人踩过,排查半天才发现是 Dropout 没关。

Dropout 的位置也很重要。对于全连接层,放在激活函数之后是常见做法。对于卷积层,情况要复杂一些。nn.Dropout2d是按通道丢弃,而不是逐元素丢弃。一个特征图有多个通道,nn.Dropout2d会随机把某个通道整体置零,这对于卷积层的语义保留很重要。如果误用普通nn.Dropout去处理四维特征图,它会把每个像素单独丢弃,这会破坏空间局部性,让特征图变成“噪点图”,反而影响网络学习空间的连续性。虽然性能不一定崩,但总归不是首选。

2.3 Batch Normalization 作为隐式正则化

BatchNorm 的核心工作是让每一层的输入分布尽量稳定。具体做法是在一个 batch 内计算均值方差,然后对数据做标准化,再通过可学习的缩放和偏移把数据从标准正态空间变回到合适的分布范围。

很多人只把 BN 当成加速训练的手段,其实它也有正则化效果。原因是每个样本的归一化统计量受同 batch 其他样本影响,相当于给训练过程注入了一种随机扰动。这个扰动有类似 Dropout 的不确定性,可以稍微弱化某个权重的过度依赖。但它的正则化强度很有限,如果模型过拟合严重,不能只指望 BN 救命,还是得靠 L2 和 Dropout。

BN 在使用上有个容易忽略的环境差异:训练时用 batch 统计量,推理时用全局统计量。这就是为什么模型训完保存为部署模式后,BN 层里的running_mean和running_var必须正确保存和更新。如果你在训练中途频繁切换train/eval,要确保 BN 的统计量更新是连续一致的,否则评估结果会像“过山车”。在 PyTorch 里,BN 的momentum控制全局统计量的更新速度,默认是 0.1,大部分情况下不用改。

3. 实操:手写一个初始化+正则化调参流程

3.1 准备一个可复现的基准模型

为了把这套东西串起来,我们用一个简单的图像分类 Demo 来做。模型结构按常见的卷积块来搭:卷积层、BN、ReLU、池化、再加两个全连接层。这里不追求刷榜,只想跑通“初始化 + 正则化”的对比流程。

import torch import torch.nn as nn class SimpleCNN(nn.Module): def __init__(self, num_classes=10): super().__init__() self.features = nn.Sequential( nn.Conv2d(3, 32, kernel_size=3, padding=1), nn.BatchNorm2d(32), nn.ReLU(inplace=True), nn.MaxPool2d(2), nn.Conv2d(32, 64, kernel_size=3, padding=1), nn.BatchNorm2d(64), nn.ReLU(inplace=True), nn.MaxPool2d(2), ) self.classifier = nn.Sequential( nn.Flatten(), nn.Linear(64 * 8 * 8, 128), nn.ReLU(inplace=True), nn.Dropout(p=0.5), nn.Linear(128, num_classes), ) def forward(self, x): return self.classifier(self.features(x))

这里为了方便直接用nn.Sequential。跑实验之前记得设随机种子,让初始化结果可复现:

def set_seed(seed=42): torch.manual_seed(seed) torch.cuda.manual_seed_all(seed)

种子不固定,你对比“有正则 vs 无正则”的时候可能会被随机波动干扰,得出错误结论。

3.2 初始化函数的选择和接入方法

我给这个模型写一个统一的初始化函数,用model.apply一次性搞定。因为模型里主要用的是 ReLU,所以卷积层和全连接层统一采用 Kaiming 初始化;BN 层的缩放系数 γ 初始化为 1,偏置 β 初始化为 0;偏置 b 这一项实际在 Kaiming 初始化里可以由函数带上参数处理,但更稳妥的做法是显式赋值。

def init_weights(module): if isinstance(module, nn.Conv2d) or isinstance(module, nn.Linear): nn.init.kaiming_normal_(module.weight, a=0.01, mode='fan_in', nonlinearity='leaky_relu') if module.bias is not None: nn.init.constant_(module.bias, 0) elif isinstance(module, nn.BatchNorm2d): nn.init.constant_(module.weight, 1) nn.init.constant_(module.bias, 0) model = SimpleCNN() model.apply(init_weights)

a=0.01对应 LeakyReLU 的负斜率。如果用的是标准 ReLU,可以写成nn.init.kaiming_normal_(module.weight, mode='fan_in', nonlinearity='relu')。如果网络结构里有残差连接,可能会用到mode='fan_out',因为残差块希望反向传播时梯度方差更稳定。这些细节没必要死记,理解成“告诉初始化函数我的激活函数长什么样,让它决定怎么缩放”就好。

3.3 正则化项的具体接入方式

正则化在 PyTorch 里很少出现在网络定义里,而是体现在优化器参数和网络层位置两方面。

先看优化器。我用带动量的 SGD 加上weight_decay:

optimizer = torch.optim.SGD( model.parameters(), lr=0.01, momentum=0.9, weight_decay=5e-4, )

weight_decay=5e-4是图像分类任务里比较常见的起点。如果你的输入数据没有做标准化,特征值范围很大,那么这个权重衰减对应的惩罚会被放大,因为权重本身可能更小。所以规范的做法是先对输入做标准化,再谈正则化强度。

Dropout 已经在SimpleCNN的classifier里写好了。有个经常被忽略的问题:Dropout 通常放在全连接层激活函数之后,而不是放在卷积层后面。卷积层后面常常跟 BN,而 BN 已经做了归一化,再加 Dropout 容易让训练不稳定。我见过有人把所有激活后面都加 Dropout,结果模型收敛极慢。Dropout 不是越多越好,一般只在高容量全连接层或者瓶颈层使用。

如果你想要用 L1 正则,PyTorch 没有现成的优化器参数,只能手动把l1_norm加到 loss 上:

def l1_loss(model, lambda_l1=1e-5): return lambda_l1 * sum(p.abs().sum() for p in model.parameters())

这仍然只是一种少见做法,大多数任务 L2 足够,L1 用在稀疏化需求上,比如某些特征选择场景。

3.4 对比实验的观察要点

做一个“裸模型”和“初始化 + 正则化”的对比。裸模型就是把init_weights、Dropout、weight_decay全部去掉,只留默认初始化和普通 SGD。训练同样 batch size、同样 epoch,记录训练集和验证集 loss。

预期你会看到这样的现象:裸模型训练集 loss 降得很快,但是验证集 loss 在若干轮之后开始反弹,并且训练集和验证集差距越来越大,这就是明显的过拟合。加了正则化后的模型,训练集 loss 虽然降得没那么凶猛,但验证集 loss 维持在更低的位置,且和训练集 loss 的间隔更小。

配置训练 loss(约第 20 轮)验证 loss(约第 20 轮)行为观察
无正则 + 默认初始化0.090.87验证 loss 震荡大,后期过拟合
He 初始化0.080.64收敛快,但过拟合仍在
He 初始化 + L20.120.41验证 loss 更稳
He 初始化 + L2 + Dropout0.190.33训练 loss 走高但验证最好

上面的数是我参考常见实验现象写的,不是一个标准 benchmark,不同数据集会有差异。关键是想说明:加了正则化之后,训练 loss 通常不会像以前那样一路压低,但泛化更好。如果看到训练 loss 也下不去,那说明正则化强度太大,可以降低weight_decay或者 Dropout 的p。

4. 常见问题排查与避坑指南

4.1 梯度消失/爆炸与初始化失效

遇到 loss 一开始就是 NaN,或者前几轮正常,后几轮直接变成 NaN,要警惕梯度爆炸。最常见的一个原因是初始化的权重过大,尤其是网络很深时,信号经过数十层累乘后产生溢出。

检查手段很直接。在每次loss.backward()之后,遍历模型参数打印梯度的范数:

for name, param in model.named_parameters(): if param.grad is not None: print(name, param.grad.norm().item())

如果某一层梯度的 norm 是 1e7 以上,就相当异常。这时候不光要检查初始化,还要看learning rate和weight_decay的配合。学习率太大,梯度更新步子迈太大,也可能爆炸。通常多个因素叠加才会出问题,不要只调初始化。

反向的情况是梯度消失:loss 在某个值附近不动了,而且梯度 norm 越来越小。如果确认网络结构没有 bug,可以试试正交初始化,尤其是循环网络。正交矩阵的奇异值都为 1,理论上能最大程度保持梯度在时间的传播不衰减。ReLU 网络则优先检查是否用了 Kaiming,别拿 tanh 那套初始化硬套。

4.2 过拟合暴露出正则化强度失控

如果你已经加了正则化,但验证集 loss 还是高,先区分两种情况。一种是训练集和验证集 loss 都比较高:正则化太强,模型容量被压过头了,应该减少weight_decay或 Dropout 的p。另一种是训练集 loss 很低、验证集 loss 高:正则化强度仍然不足,可以逐步增大weight_decay,比如从 1e-4、5e-4、1e-3 这样试过去。

调整weight_decay有一个经验:每次调大不要超过 10 倍,否则模型会从欠拟合直接跳到“啥也学不进去”。Dropout 的p从 0.1、0.3、0.5 这样增加,而不是一下子 0.9。模型丢掉 90% 的神经元之后基本只剩一个普通线性层了,很难学到复杂模式。

另外注意,weight_decay对全体参数生效。如果你的网络里有大量参数接近 0 是好事,但如果有某些旁路参数本身就是想要保持一定幅度,比如用于控制某种门控机制的参数,那就应该把它们从衰减分组中排除。可以通过param.requires_grad_(False)或者优化器分组来实现,但实际上需要慎重,不是所有旁路都值得豁免。

4.3 BatchNorm 和 Dropout 顺序引发的坑

有一段时间研究者们热烈讨论过 BN 和 Dropout 能不能一起用。我这里只分享PyTorch中的实际经验。

在卷积块里,常见的顺序是:Conv -> BN -> ReLU -> Pool。Dropout 通常不放在卷积块中间,而是放在分类头或者某些大全连接层之间。如果你打算在卷积层后使用 Dropout,更好的方式是用nn.Dropout2d,并且放在ReLU之后、Pool之前。从信息保留角度,先 BN 再 ReLU 再 Dropout,能让 dropout 在激活后的特征上操作,语义上更合理。

如果你在 BN 层之前加 Dropout,相当于给归一化层喂了一堆已经被置零的数据。BN 的统计量会被 dropout 打乱,尤其是小 batch size 时,均值和方差极其不稳定,训练会抖得厉害。我见过有人的网络把所有层都加了 dropout 和 BN,loss 曲线像锯齿一样,后来去掉卷积层的 dropout,只留全连接层,训练立刻顺畅。

此外,BN 在小 batch size 下表现会比较差。如果没有办法增大 batch size,可以考虑用GroupNorm代替 BN。GroupNorm 不依赖 batch 内样本,在 batch size 很小的时候更稳。你可以在几乎不改变网络代码的情况下替换它。这不算正则化,但对稳定训练很关键。

4.4 速查表

症状可能原因优先尝试
loss 为 NaN权重初始值过大 / 学习率过大换 Kaiming 初始化、降低 lr、加梯度裁剪
训练和验证 loss 差距大过拟合增加weight_decay、加入 Dropout、增加数据增强
验证 loss 不高但训练 loss 也高欠拟合降低正则化强度、加大模型容量、重查学习率
模型评估结果跳动未调用model.eval()切换到 eval 模式,关 Dropout
卷积层特征图被随机置零过碎用nn.Dropout处理四维张量换成nn.Dropout2d
浅层激活正常、深层激活全为 0ReLU 初始化未用 He使用kaiming_normal_,a=0或a=0.01
加了 BN 后小 batch 崩batch size 过小加大 batch 或改用 GroupNorm

这张表不是万能药,但它能帮你把排查路径固定下来。我遇到问题时,习惯先固定变量,一次性只动一个设置。错误地同时调整初始化、学习率和权重衰减,会导致你不知道是哪一个改好的。

5. 一点私人体会

做了不少模型调试之后,我发现参数初始化和正则化不像网络结构那样能一眼看到效果,它们更像“隐形调音师”。很多人花大量时间改网络层数,不如先花半天时间把初始化写对、把正则化项的位置放对。我自己实际调试中,收益最大的改动不是换了某个新的结构,而是把默认初始化换成了配合激活函数的 Kaiming,然后在最后几个全连接层加了 Dropout,并把 SGD 的weight_decay稳在 1e-4 左右。整个训练立刻从“靠运气”变成了“可预期”。

最后再分享一个技巧:每次实验都用固定随机种子多跑两遍,取平均结果做判断。因为初始化本身有随机性,你瞄一眼某一次跑的曲线可能完全是噪声。跑个三五轮之后,如果趋势一致,再下结论。这比到处找新优化器实在得多。

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

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

立即咨询