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 Normalization3.1 BN的数学原理和直觉Batch Normalization以下简称BN在一个batch的训练数据上对每个特征维度做标准化。假设某一层有 (d) 个神经元一个batch里有 (m) 个样本那么对第 (j) 个特征维度来说[\mu_j \frac{1}{m}\sum_{i1}^{m}x_{ij}, \quad \sigma_j^2 \frac{1}{m}\sum_{i1}^{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(inplaceTrue)) 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_dim784, hidden_dims[512, 256], num_classes10) 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. 核心技巧二Dropout4.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(p0.2), nn.Linear(512, 256), nn.ReLU(), nn.Dropout(p0.2), nn.Linear(256, 10) )代码看起来平淡无奇但有几个细节容易被忽略。第一nn.Dropout放在ReLU之后。这个顺序和BN有点像先经过激活函数再随机丢弃。你可以把Dropout移到ReLU之前效果差异不是特别大但主流的做法是激活之后再丢弃因为这样丢弃的是激活后的特征对信号的破坏更干净。第二nn.Dropout只在训练时生效。它的内部实现会根据self.training判断是否执行丢弃。推理时即model.eval()之后它变成恒等映射直接输入等于输出。这一点跟BN类似需要依赖model.eval()来切换。第三p值设置不是越大越好。p0.5是经验里比较激进的值用于神经元冗余程度很高的场景。对于宽度不大的网络过大的p值会直接失活太多信息导致欠拟合。我的经验是全连接层宽度在256以上时p0.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))来保持整体的期望不变。例如p0.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(), lr1e-3, weight_decay1e-4) # 对比标准Adam # optimizer optim.Adam(model.parameters(), lr1e-3, weight_decay1e-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(), lr1e-3, weight_decay1e-4) scheduler optim.lr_scheduler.ReduceLROnPlateau( optimizer, modemin, factor0.5, patience5, verboseTrue ) 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实现一个简单的线性warmupdef 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, modefan_in, nonlinearityrelu) 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_size128, shuffleTrue) val_loader DataLoader(TensorDataset(X_val, y_val), batch_size128, shuffleFalse) class SimpleMLP(nn.Module): def __init__(self, in_dim20, num_classes10): super().__init__() self.net nn.Sequential( nn.Linear(in_dim, 128), nn.BatchNorm1d(128), nn.ReLU(inplaceTrue), nn.Dropout(0.3), nn.Linear(128, 64), nn.BatchNorm1d(64), nn.ReLU(inplaceTrue), 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_epochs20): device torch.device(cuda if torch.cuda.is_available() else cpu) model.to(device) criterion nn.CrossEntropyLoss() optimizer optim.AdamW(model.parameters(), lr1e-3, weight_decay1e-4) scheduler optim.lr_scheduler.ReduceLROnPlateau( optimizer, modemin, factor0.5, patience3 ) 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(dim1) 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(dim1) 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(fEpoch {epoch1:3d} | fTrain Loss {train_loss:.4f} | Train Acc {train_acc:.4f} | fVal Loss {val_loss:.4f} | Val Acc {val_acc:.4f} | fLR {optimizer.param_groups[0][lr]:.2e}) model SimpleMLP() train(model, train_loader, val_loader, num_epochs20)看懂这个循环对后续所有PyTorch实战类项目都受用。有几个代码细节是个人的偏好也顺便解释一下。criterion nn.CrossEntropyLoss()已经包含了softmax操作模型的最后一层直接输出未归一化的logits即可不需要自己再单独加一层LogSoftmax。预测时通过outputs.argmax(dim1)取最大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不能设为1BN在单个样本上无法计算有效的均值/方差如果某个batch只有一条数据输入维度又恰好是1BN会跑出来一堆NaN或者直接报错。对于全连接网络如果你的数据总数不能整除batch size最后一个batch可能很小甚至极端情况下batch size直接为1。解决办法是设置DataLoader的drop_lastTrue让最后一个不足的batch直接丢弃或者用BatchNorm1d时把batch size保持在2以上。这个问题在小批量训练时特别容易踩。很多云环境里显存紧张大家习惯把batch size设成1或者2来做数值调试全连接网络结构简单倒还好一旦引入BNbatch 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切换、确认优化器和学习率、确认初始化——大概率能定位到问题所在。如果你在实际操作中遇到这篇笔记没覆盖到的坑欢迎带着代码和具体报错信息来交流动手调一遍的收获远大于多看十遍教程。