1. 从堆层数到讲技巧:全连接网络的训练瓶颈
这篇笔记原本的标题是“神经网络与全连接层3”,内容自然承接前面两篇。前面我们搭了网络结构,理解了前向传播和反向传播的机制,也知道了怎么用PyTorch快速定义一个全连接网络。但如果你真的动手训练过一个稍微深一点的网络,大概率会遇到这样的现象:层数堆到五层以上,loss开始不降反升,或者验证集准确率怎么都上不去,再或者训练集表现很好、一到测试集就崩盘。
这就是从“会搭网络”到“会训练网络”之间的那道坎。前面的笔记里我们专注于网络本身的骨架,这一篇则要解决一个更现实的问题:怎么让这个网络真正训练得动、训练得好。我们不会引入CNN、RNN那些复杂结构,仍然停留在全连接层的范畴里,但会在网络里加入三样非常关键的东西——Batch Normalization、Dropout和更合理的优化器与学习率策略。这些技巧几乎是所有现代神经网络的基础组件,理解了它们,后面学CNN、Transformer都会轻松很多。
这篇笔记适合谁看?已经掌握了张量操作、autograd和最基本的nn.Module构建流程的读者。如果你刚看完这个系列的第十五篇,正好可以无缝衔接。如果你只是跟着别的教程学了一部分PyTorch基础,这篇也不难跟上,核心的数学原理我会讲得很通俗。
2. 为什么要给全连接层加“特效”?
2.1 深层网络训练的三大痛点
在引入具体技巧之前,先要搞清楚我们到底在解决什么问题。全连接网络是万能的函数逼近器,理论上层数足够多、神经元足够多,可以拟合任何函数。但在实际操作中,随着层数加深,会出现三个非常折磨人的问题。
第一个是梯度消失或爆炸。反向传播时梯度是一层层连乘传回去的,如果每层梯度的范数小于1,连乘十层之后梯度就趋近于零,前面几层的参数根本得不到有效更新,网络实际上只有最后几层在工作。如果梯度的范数大于1,又会指数级放大,导致参数震荡、loss发散。全连接层尤为明显,因为每个神经元对输入的加权求和范围很广,不像CNN有卷积核的天然约束。
第二个是激活函数饱和。早期常用的sigmoid和tanh函数在输入绝对值较大时,导数趋近于零。如果某层输出的数值分布偏移到了饱和区,梯度传到这里就几乎断了。即便我们用ReLU也会有问题——Dead ReLU现象,即某些神经元被零输出之后,梯度永远为零,再也无法激活。
第三个是数据分布漂移。每一层都在更新权重,导致下一层的输入分布持续变化。后面的层必须不断适应前面层的行为变化,这会让训练变得非常不稳定。这个问题有个专门的名词叫Internal Covariate Shift,翻译过来就是内部协变量偏移,通俗点说:你训练模特的时候,背景一直在变,模特还得不停地调整姿态去配合背景,累不累?
2.2 思考路径:从归一化到标准化
面对这些痛点,最直觉的解决办法就是:把每层的输入都拉回到一个相对稳定的分布区间。这就是归一化的思想。我们最熟悉的归一化是Z-score标准化——对每个特征减去均值、除以标准差,让数据分布在0附近、标准差为1。但如果只在输入层做一次标准化,深层网络里的问题依然存在,因为中间层的输出分布依然不断在变。
所以解决思路就很自然了:不光在输入层做标准化,网络的每一层都做标准化。这就是Batch Normalization的基本思路。它把每一层的一批数据在特征维度上进行归一化,然后再通过可学习的缩放和平移参数,让网络自己决定数据应该落在什么分布区间。这个设计既解决了梯度问题和饱和问题,也缓解了内部协变量偏移,训练速度和稳定性都有非常明显的提升。
一个更生活化的类比:标准化相当于给工人统一发放一套标准尺寸的模具,每个工人拿到手的原材料规格都一样,加工效率自然就上来了。没有标准化的情况下,每个工人拿到的材料尺寸各不相同,熟手也得花时间适应原材料。
3. 核心技巧一:Batch Normalization
3.1 BN的数学原理和直觉
Batch Normalization,以下简称BN,在一个batch的训练数据上,对每个特征维度做标准化。假设某一层有 (d) 个神经元,一个batch里有 (m) 个样本,那么对第 (j) 个特征维度来说:
[\mu_j = \frac{1}{m}\sum_{i=1}^{m}x_{ij}, \quad \sigma_j^2 = \frac{1}{m}\sum_{i=1}^{m}(x_{ij}-\mu_j)^2]
[\hat{x}{ij} = \frac{x{ij}-\mu_j}{\sqrt{\sigma_j^2+\epsilon}}, \quad y_{ij} = \gamma_j \hat{x}_{ij}+\beta_j]
(\epsilon)是一个很小的常数,防止除零,默认值是1e-5。(\gamma)和(\beta)是两个可学习参数,每个特征维度各有一对,初始值分别是1和0。(\gamma)的作用是允许网络重置分布的尺度,(\beta)允许重置偏移位置。如果网络觉得标准化后的分布不合适,它可以学回接近原始的分布,这个能力非常重要。
这里有一个很多初学者忽略的细节:BN的标准化是在batch维度上做的。也就是说,每个特征的值使用的是“当前这批数据的均值/方差”。这跟输入层标准化(用的是整个训练集的均值/方差)有本质区别。BN使用的统计量是动态的、随batch变化而变化的。
3.2 PyTorch里的BatchNorm1d
在PyTorch中,针对全连接层,对应的模块是nn.BatchNorm1d。它的构造函数最重要的参数是num_features,也就是输入的特征维数。在全连接网络中,最后一维就是特征数。举个例子:
import torch import torch.nn as nn class MLPWithBN(nn.Module): def __init__(self, in_dim, hidden_dims, num_classes): super().__init__() layers = [] prev_dim = in_dim for hidden_dim in hidden_dims: layers.append(nn.Linear(prev_dim, hidden_dim)) layers.append(nn.BatchNorm1d(hidden_dim)) layers.append(nn.ReLU(inplace=True)) prev_dim = hidden_dim layers.append(nn.Linear(prev_dim, num_classes)) self.net = nn.Sequential(*layers) def forward(self, x): return self.net(x) # 验证输出形状 model = MLPWithBN(in_dim=784, hidden_dims=[512, 256], num_classes=10) x = torch.randn(64, 784) print(model(x).shape) # torch.Size([64, 10])这里需要注意,BatchNorm1d在放入Sequential时,直接放在Linear之后、ReLU之前。这个顺序不是随便定的:BN的目的是稳定线性层输出的分布,让ReLU的输入尽量落在合理的范围内。如果把ReLU放在BN前面,则BN标准化的数据可能已经被截断了,分布被破坏,效果不如前一种顺序。
num_features的数值,就是上一层Linear的out_features。一个常见的低级错误是把num_features写成输入数据的特征数,导致运行时报维度不匹配。
3.3 BN的训练模式与推理模式
BN是少数在训练和推理时行为不一致的模块。训练阶段,它使用当前batch的均值/方差做标准化,同时用滑动平均维护一份全局统计量running_mean和running_var。推理阶段,它不再使用当前输入数据的统计量,而是使用这些训练期间累计的全局统计量。
这个设计的逻辑是:推理时通常只喂一条数据或者很少的几条数据,用小batch统计出来的均值/方差极不稳定,用它做标准化没有意义。所以,用训练时积累的全数据集统计量更可靠。
在PyTorch里,Dropout(下一节的主角)和BN都依赖model.training这个标志位来切换行为。调用model.eval()会把所有模块切到推理模式,调用model.train()则回到训练模式。很多资历尚浅的开发者会在验证集评测时忘记调用model.eval(),花了两小时调参发现验证集准确率一直震荡,最后发现BN用了batch统计量,推理结果被batch的随机性干扰了。这一点请务必养成肌肉记忆:训练循环里跑完一个epoch后,一定先model.eval()再跑验证,下一个epoch开始前再model.train()。
另外,nn.BatchNorm1d的momentum参数默认是0.1,这个值表示滑动平均的更新速率,running_mean = (1-momentum) * running_mean + momentum * batch_mean。0.1意味着新的统计量占比10%,相当激进。在某些框架中,momentum习惯取0.99或0.9。PyTorch这里的0.1设计其实对应了别的框架中的0.9,只是一个scale的差异,不需要额外去改动,知道这一点能避免以后换框架时产生的混淆。
4. 核心技巧二:Dropout
4.1 什么是Dropout,为什么它能防止过拟合
上一节解决了训练稳定性问题。这一节要处理另一个高频问题:训练集表现很好,测试集一塌糊涂,这就是标准的过拟合。
全连接网络参数量巨大,最典型的过拟合温床。一个宽的全连接层可以轻易地记住整个训练集的输入输出映射,但学到的规律并不可迁移。Dropout的思想非常朴素:每次训练时,以概率(p)随机“丢弃”一部分神经元(把它们的输出置零),让网络每次只用一部分神经元做前向传播。这样,每个神经元都不能依赖某一个“同伴”神经元,必须独立学到有用的特征,整个网络的泛化能力就会增强。
打个比方:一个团队本来每次开会人员固定,每个人只需要配合老搭档干活,换个人就抓瞎。Dropout相当于每次开会随机抽掉几个人,逼着每个人都能跟任意成员协作,团队的抗风险能力自然就上去了。
在实际训练中,Dropout还有个附带的好处:可以看作是以一种巧妙的方式同时训练了很多个“子网络”,推理时再把这些子网络集成起来平均,类似于一种廉价的模型集成。这还不是唯一的作用,它同时有点像数据增强,给网络注入了一些噪声,让它不能过于依赖某一条冗余路径。
4.2 PyTorch中Dropout的正确打开方式
PyTorch中对应模块是nn.Dropout。构造参数p是丢弃概率,默认0.5,实际用的时候要自己根据过拟合程度调整。
import torch.nn as nn model = nn.Sequential( nn.Linear(784, 512), nn.ReLU(), nn.Dropout(p=0.2), nn.Linear(512, 256), nn.ReLU(), nn.Dropout(p=0.2), nn.Linear(256, 10) )代码看起来平淡无奇,但有几个细节容易被忽略。
第一,nn.Dropout放在ReLU之后。这个顺序和BN有点像:先经过激活函数,再随机丢弃。你可以把Dropout移到ReLU之前,效果差异不是特别大,但主流的做法是激活之后再丢弃,因为这样丢弃的是激活后的特征,对信号的破坏更干净。
第二,nn.Dropout只在训练时生效。它的内部实现会根据self.training判断是否执行丢弃。推理时(即model.eval()之后),它变成恒等映射,直接输入等于输出。这一点跟BN类似,需要依赖model.eval()来切换。
第三,p值设置不是越大越好。p=0.5是经验里比较激进的值,用于神经元冗余程度很高的场景。对于宽度不大的网络,过大的p值会直接失活太多信息,导致欠拟合。我的经验是:全连接层宽度在256以上时,p=0.2~0.3比较合理;宽度在512以上且过拟合明显时,再试着往0.4以上调。不要一上来就0.5。
还要区分一个常见混淆:nn.Dropout和nn.Dropout2d。nn.Dropout2d针对的是(N, C, H, W)形状的四维输入,也就是卷积网络中常见的特征图。它丢弃的是整个通道,而不是单个元素。如果用在全连接层上,通常会报维度错误。全连接层数据是二维的(N, features),用nn.Dropout就是正确的。
4.3 Dropout与推理阶段的scaling问题
Dropout在推理阶段有一个非常微妙的数学细节。训练时,一部分神经元被置零,保留的神经元输出需要除以((1-p))来保持整体的期望不变。例如p=0.3时,保留的神经元输出会被放大到原来的(1/(0.7))倍。PyTorch的nn.Dropout在训练时已经自动完成了这个scaling操作,你不需要手动处理。如果你自己手动实现一个Dropout,很容易忘了这一点,导致训练和推理阶段的输出尺度不一致。
自己实现一个简单版的话,一般是这样:
def dropout_manual(x, p, training): if not training: return x mask = torch.rand_like(x) > p return x * mask / (1.0 - p)注意mask里的布尔值在乘法中自动转成0/1,除以((1-p))就是维持期望的补偿因子。PyTorch底层就是这么做的,只是它做了很多优化,产生的分布也更稳定。
5. 更合理地更新权重:优化器与学习率
5.1 从SGD到Adam再到AdamW
之前我们的示例代码一直用朴素的SGD。torch.optim.SGD最简单的形式仅仅把梯度乘上学习率更新参数,公式上就是(\theta = \theta - \eta \nabla L)。这算法简单、稳定,但收敛速度慢,而且对学习率极其敏感。学习率稍微调大一点就发散,调小一点就蜗牛爬。
实际入门阶段,我更推荐直接上Adam。Adam可以看成是RMSProp和动量法的结合体:它既记录了过去梯度的指数加权平均(相当于动量),又记录了梯度平方的指数加权平均(相当于自适应调节每一步的学习率)。对每个参数而言,梯度大的方向步子迈小一些,梯度小的方向步子迈大一些。这直接让Adam在默认学习率下就能跑得很稳。
但后来大家发现标准Adam有一个隐蔽的缺点:它对L2正则的处理方式并不正确。在Adam里,L2正则项的梯度同样被累积的历史梯度除了一遍,相当于正则强度被随时间变化的学习率稀释了,最后得到的权重衰减效果不一致。AdamW是修正过的版本:权重衰减直接在参数更新时做,不参与梯度自适应计算,效果更干净。在实践中,AdamW几乎可以无脑替代Adam,尤其配合权重衰减(weight decay)使用时。
PyTorch里用法很直接:
import torch.optim as optim # AdamW是torch.optim里的一个独立类 optimizer = optim.AdamW(model.parameters(), lr=1e-3, weight_decay=1e-4) # 对比标准Adam # optimizer = optim.Adam(model.parameters(), lr=1e-3, weight_decay=1e-4)weight_decay参数就是L2正则化的系数。对全连接网络而言,这个值设在1e-4到1e-2之间都常见。偏大时模型倾向更平滑的决策边界,但可能欠拟合;偏小时正则作用不明显。从1e-4起步,看loss曲线去调。
5.2 学习率怎么调:调度器也有方法论
优化器选好以后,学习率的设定是剩余训练中最重要的超参数。一个普遍有效的策略是使用学习率调度器,在训练过程中动态调整学习率。PyTorch提供了好几种内置的调度器。
最常用的是StepLR,它每隔固定步数把学习率乘以一个因子。比如每隔30个epoch乘0.1,让训练后期步子变慢、更精细地收敛。另一种很实用的ReduceLROnPlateau更聪明:它监控某个指标(比如验证集loss),如果指标连续若干次都不下降,就自动降低学习率。这两种我都常用,后者的成功率更高一点。
import torch.optim as optim model = MLPWithBN(784, [512, 256], 10) optimizer = optim.AdamW(model.parameters(), lr=1e-3, weight_decay=1e-4) scheduler = optim.lr_scheduler.ReduceLROnPlateau( optimizer, mode='min', factor=0.5, patience=5, verbose=True ) for epoch in range(num_epochs): train_one_epoch(model, optimizer) val_loss = evaluate(model) scheduler.step(val_loss) # 传入监控的指标注意ReduceLROnPlateau的step()需要传入一个数值作为监控指标,比如验证集loss。而StepLR的step()则不需要参数,每次调用都更新。
这里我必须强调一个初学者高频出错点:调度器必须在每个epoch调用一次step(),否则学习率永远不变。有的人以为设了调度器就万事大吉,结果训练完了看日志发现学习率从头到尾没动过。这和BN的model.eval()一样,属于“写了就必须调用”的规则。
还有一个更进阶的做法是学习率预热(warmup)。训练最开始用很小的学习率,比如1e-5,然后线性或者余弦地涨到目标学习率。这样可以避免模型参数刚随机初始化时,大步长直接把loss踢飞。我用PyTorch实现一个简单的线性warmup:
def adjust_learning_rate(optimizer, step, warmup_steps, target_lr): if step < warmup_steps: lr = target_lr * (step + 1) / warmup_steps for param_group in optimizer.param_groups: param_group['lr'] = lr预热阶段通常只用几百步,之后让调度器接管。这个技巧在深网络、大模型里非常常见,全连接小模型上用不用就看你的心情和训练总步数了。
5.3 参数初始化:别让一切都从零开始
优化器更新参数是在初始化结果之上做的,初始化本身直接影响训练能不能起步。如果你把所有权重初始化为零,同一层的所有神经元输出相同、梯度相同,网络实际上退化成只有一个有效神经元的模型,完全丧失表达能力。PyTorch的nn.Linear默认自带初始化逻辑,一般直接用就好,但你也可以手动控制。
全连接层常用的是Kaiming初始化(也叫He初始化),专门适配ReLU这类激活函数。它的核心想法是:让每一层输出的方差在传播过程中大致保持恒定。PyTorch中可以这样操作:
def init_weights(m): if isinstance(m, nn.Linear): nn.init.kaiming_normal_(m.weight, mode='fan_in', nonlinearity='relu') if m.bias is not None: nn.init.constant_(m.bias, 0) model.apply(init_weights)理解fan_in和fan_out的区别不需要死记:fan_in是保证前向传播信号的方差稳定,fan_out是让反向传播梯度的方差稳定,具体用哪个取决于你更关心哪个方向。全连接网络通常关注前向传播,所以用fan_in是合理的默认选择。对tanh这类激活函数则更适合用Xavier初始化。这条规则记不住也没事,PyTorch默认帮你处理好了,但知道原理能帮你在遇到训练不收敛时多一个排查方向。
6. 组合实战:一个完整的全连接分类示例
6.1 准备好数据和模型
说了这么多知识点,把它们组装成一个可以运行的示例才有意义。我用一个虚构的模拟数据来演示完整的训练流程,这样你不需要下载任何真实数据集也能跑通。
import torch import torch.nn as nn import torch.optim as optim from torch.utils.data import DataLoader, TensorDataset # 生成模拟数据:输入20维特征,10个类别 torch.manual_seed(42) num_samples = 2000 X = torch.randn(num_samples, 20) y = torch.randint(0, 10, (num_samples,)) # 随机给标签,分布未必可学,但流程完整 # 划分训练集和验证集 train_size = int(0.8 * num_samples) X_train, X_val = X[:train_size], X[train_size:] y_train, y_val = y[:train_size], y[train_size:] train_loader = DataLoader(TensorDataset(X_train, y_train), batch_size=128, shuffle=True) val_loader = DataLoader(TensorDataset(X_val, y_val), batch_size=128, shuffle=False) class SimpleMLP(nn.Module): def __init__(self, in_dim=20, num_classes=10): super().__init__() self.net = nn.Sequential( nn.Linear(in_dim, 128), nn.BatchNorm1d(128), nn.ReLU(inplace=True), nn.Dropout(0.3), nn.Linear(128, 64), nn.BatchNorm1d(64), nn.ReLU(inplace=True), nn.Dropout(0.3), nn.Linear(64, num_classes) ) def forward(self, x): return self.net(x)这里网络结构简单,但BN和Dropout都加上了。如果数据量再大一点,过拟合的效应会更明显,Dropout的威力也更能体现出来。用随机标签数据跑的话loss不会降得很有规律,这个例子主要为了展示代码结构,不必在意最终准确率。
6.2 训练循环的标准写法
训练循环的代码在几乎所有PyTorch项目里都很相似。写顺了之后可以直接复制到各种场景里改改。
def train(model, train_loader, val_loader, num_epochs=20): device = torch.device('cuda' if torch.cuda.is_available() else 'cpu') model.to(device) criterion = nn.CrossEntropyLoss() optimizer = optim.AdamW(model.parameters(), lr=1e-3, weight_decay=1e-4) scheduler = optim.lr_scheduler.ReduceLROnPlateau( optimizer, mode='min', factor=0.5, patience=3 ) for epoch in range(num_epochs): # 训练阶段 model.train() total_loss = 0 correct = 0 total = 0 for x_batch, y_batch in train_loader: x_batch, y_batch = x_batch.to(device), y_batch.to(device) optimizer.zero_grad() outputs = model(x_batch) loss = criterion(outputs, y_batch) loss.backward() optimizer.step() total_loss += loss.item() * x_batch.size(0) preds = outputs.argmax(dim=1) correct += (preds == y_batch).sum().item() total += y_batch.size(0) train_loss = total_loss / total train_acc = correct / total # 验证阶段 model.eval() val_loss = 0 val_correct = 0 val_total = 0 with torch.no_grad(): for x_batch, y_batch in val_loader: x_batch, y_batch = x_batch.to(device), y_batch.to(device) outputs = model(x_batch) loss = criterion(outputs, y_batch) val_loss += loss.item() * x_batch.size(0) preds = outputs.argmax(dim=1) val_correct += (preds == y_batch).sum().item() val_total += y_batch.size(0) val_loss = val_loss / val_total val_acc = val_correct / val_total scheduler.step(val_loss) print(f'Epoch {epoch+1:3d} | ' f'Train Loss {train_loss:.4f} | Train Acc {train_acc:.4f} | ' f'Val Loss {val_loss:.4f} | Val Acc {val_acc:.4f} | ' f'LR {optimizer.param_groups[0]["lr"]:.2e}') model = SimpleMLP() train(model, train_loader, val_loader, num_epochs=20)看懂这个循环对后续所有PyTorch实战类项目都受用。有几个代码细节是个人的偏好,也顺便解释一下。
criterion = nn.CrossEntropyLoss()已经包含了softmax操作,模型的最后一层直接输出未归一化的logits即可,不需要自己再单独加一层LogSoftmax。预测时通过outputs.argmax(dim=1)取最大logits对应的类别下标,非常简单。
optimizer.zero_grad()必须在一个batch计算loss之前调用,清空上个batch积累的梯度。当初次写训练循环时最常犯的错就是忘了清零,导致梯度不断累加、loss一路爆炸。
with torch.no_grad():在验证阶段关闭梯度追踪,省内存也避免意外修改计算图。这是验证阶段的标准屏障。
6.3 关键参数速查与推荐值
参数多了容易乱,我把这一篇涉及到的关键超参数整理成一张表,方便对照着调试:
| 参数 | 作用 | 推荐起始值 | 调整方向 |
|---|---|---|---|
nn.BatchNorm1d的momentum | 滑动统计量更新速率 | 0.1(默认) | 数据分布变化大时可调大 |
nn.Dropout的p | 神经元丢弃概率 | 0.2~0.3 | 过拟合时调大,欠拟合时调小 |
optim选择 | 参数更新算法 | AdamW | 优先用AdamW,而不是SGD初学 |
lr | 基础学习率 | 1e-3 | 不收敛则调小,收敛慢则调大 |
weight_decay | 权重衰减系数 | 1e-4 | 过拟合明显时可尝试调大 |
ReduceLROnPlateau的patience | 监控指标连续几轮不降就降学习率 | 3~5 | 训练波动大时适当调大 |
7. 常见问题与排查经验
7.1 训练loss下降了,验证集准确率却一直在低位徘徊
这个问题的头号嫌疑是忘了model.eval()。BN和Dropout在训练和推理时行为完全不同,如果验证阶段一直让模型处于训练模式,Dropout还在随机丢弃神经元,每次前向传播的预测结果都不一样,准确率自然忽高忽低。我见过很多新手同学被这个问题折磨了一晚上。
其次要看数据增强或者数据泄漏类的逻辑问题。比如训练集和验证集的数据没有分开,或者数据顺序没有shuffle,验证集里混入了训练样本,模型在验证集上表现看起来不错,但换到真实场景就崩。这里建议验证集的数据预处理跟训练集完全一致,但绝不能混用统计量。
最后一个原因是模型的表达能力本身不够强,或训练数据太少,模型在验证集上没有学到足够的通用规律。这时需要检查的是网络结构是否过于简单,以及是否需要再加数据增强。
7.2 加了BN之后,batch size不能设为1
BN在单个样本上无法计算有效的均值/方差,如果某个batch只有一条数据,输入维度又恰好是1,BN会跑出来一堆NaN或者直接报错。对于全连接网络,如果你的数据总数不能整除batch size,最后一个batch可能很小,甚至极端情况下batch size直接为1。解决办法是设置DataLoader的drop_last=True,让最后一个不足的batch直接丢弃,或者用BatchNorm1d时把batch size保持在2以上。
这个问题在小批量训练时特别容易踩。很多云环境里显存紧张,大家习惯把batch size设成1或者2来做数值调试,全连接网络结构简单倒还好,一旦引入BN,batch size过小会带来巨大的数值噪声,哪怕不报错,效果也大受影响。
7.3 模型加BN后训练集loss变得特别慢
BN会把每层的输入分布稳定下来,但它同时也改变了网络优化的“地形”。一个比较流行的解释是:BN使得loss landscape变得更平滑,更有利于梯度下降。理论上训练应该更快,但如果你感觉更慢了,很可能是学习率设置不合适,或者BN被放错了位置。
我实测过一种情况:BatchNorm1d放在Linear之前。输入数据先被BN处理,然后进线性层。这样做的结果是BN在标准化一个尚未被线性层线性组合的特征空间,效果其实不如放在线性层之后。所以哪怕模型能跑出结果,训练效率也会受影响。
另一种可能是没有配合使用适应性的优化器。标准SGD + BN在部分任务上也能工作,但收敛速度和稳定性总归不如Adam系列来得省心。如果你不是想深入研究优化算法本身,入门阶段直接用AdamW能省掉很多调参的痛苦。
7.4 推理阶段保存的模型表现异常
如果你在推理阶段加载训练好的模型,发现结果和训练时验证集表现差异很大,先检查一下推理代码是否调用了model.eval()和torch.no_grad()。model.eval()保证BN用全局统计量、Dropout关闭;torch.no_grad()不仅省内存,还避免因为计算图存在导致的一些意外行为。
另外,保存模型时要注意保存完整的状态字典,而不是只保存权重。推荐这样保存:
torch.save({ 'model_state_dict': model.state_dict(), 'optimizer_state_dict': optimizer.state_dict(), }, 'checkpoint.pt') checkpoint = torch.load('checkpoint.pt') model.load_state_dict(checkpoint['model_state_dict'])这样做的好处是以后想接着训练时,可以把优化器状态也恢复上,学习率调度器的状态再存进去,就能无缝续训。如果只保存权重,续训时动量信息全部丢失,学习率也得重新设一遍,体验很差。
一点自己的实践体会
篇幅到了这里,这一篇的核心内容基本讲完了。网络结构搭建只是深度学习实战的第一步,真正决定一个模型能不能用的是训练手段。BN、Dropout、优化器、学习率调度、初始化方式,这些内容每一块都不复杂,组合在一起却对训练效果产生质的改变。我自己的体会是:与其花大量时间去寻找一个“最优结构”,不如先把这几个基础组件用得滚瓜烂熟。碰到一个模型训练不收敛的情况,按这篇笔记梳理的顺序——检查数据划分、检查train/eval切换、确认优化器和学习率、确认初始化——大概率能定位到问题所在。如果你在实际操作中遇到这篇笔记没覆盖到的坑,欢迎带着代码和具体报错信息来交流,动手调一遍的收获远大于多看十遍教程。