刚接触PyTorch时,很多人都会有这样一个瞬间:模型结构写好了,损失函数也选定了,结果到optimizer = torch.optim.Adam(model.parameters(), lr=0.001)这一行就卡住了——不知道学习率该设多少,不知道SGD和Adam到底差在哪,更不知道后面那一堆momentum、weight_decay、betas参数都是干什么用的。网上搜了一圈,要么是文档翻译腔,要么就是一上来就甩一堆公式。这篇文章打算换一种讲法,结合我这些年用PyTorch做模型训练的实际经验,把torch.optim这个库从头到尾拆一遍:它到底是什么、每类优化器在干什么、代码层面怎么写才对,以及哪些坑是我真的踩过的。无论你是刚入门深度学习的小白,还是已经跑过不少模型但一直对优化器“知其然不知其所以然”的选手,这篇应该都能给你一些启发。
1. 优化器不是“一行代码”,而是训练过程的“方向盘”
1.1 优化器到底在解决什么问题
先抛开所有术语,回到最根本的问题:训练一个神经网络,本质上是在做什么?
答案是找一组参数,让损失函数的值尽量小。模型里的每一个权重张量都是多维的,几百万甚至几千万个参数构成一个极高维的空间,损失函数在这个空间里是一个起伏不平的曲面。训练的过程,就是在这个曲面上一路摸索着往下走,找到那个让损失值足够低的“谷底”。
这个“往下走”的动作,由谁来执行?就是优化器。
神经网络前向传播算出损失,loss.backward()算出每个参数对应的梯度——梯度指向的是损失上升最快的方向,取反就是下降方向。但算出来的这些梯度只是“一个方向”,具体怎么沿着这个方向走、走多远、要不要借鉴以前走过的路、要不要每个参数区别对待,这些策略全部由优化器决定。
torch.optim就是PyTorch官方提供的优化器集合。它不是一个单独的算法,而是一个统一的管理框架:你传入模型的参数,它负责维护参数状态,在每一步step()时根据梯度对参数做更新。
1.2 从 loss.backward() 到 optimizer.step() 的完整链路
我见过不少新手把loss.backward()和optimizer.step()当成一个固定搭配,少写一个就报错,或者干脆loss.backward()都不写,模型的loss就是不降。这里把这条链路完整拉一遍:
- 前向传播:输入数据经过网络每一层,算出预测值和损失值。
loss.backward():PyTorch的自动微分引擎从损失开始反向遍历计算图,把每个参数的梯度param.grad填上。optimizer.step():优化器读取param.data和param.grad,根据自己内部维护的状态(比如动量缓冲、二阶矩估计),按更新规则修改param.data。optimizer.zero_grad():把上一步留下的梯度清零。如果不做这一步,下一次backward()时新梯度会和旧梯度累加——这在某些特殊场景(比如梯度累积)是故意为之,但普通训练里必须清零。
注意,backward()和step()是严格分开的。这意味着你可以对所有参数统一求梯度,然后用不同的优化策略、不同的学习率去更新不同层。这一点在后面的参数组配置中非常有用。
1.3 没有优化器会怎样
为了让你更直观地理解优化器干了什么活,可以看一眼“没有优化器”的训练怎么写:
# 手动实现梯度下降,不用 torch.optim learning_rate = 0.01 for epoch in range(num_epochs): loss = loss_fn(model(x), y) loss.backward() with torch.no_grad(): for param in model.parameters(): param -= learning_rate * param.grad model.zero_grad()这段代码其实就是最朴素的SGD。你会发现它和torch.optim.SGD在做的事本质一样,只不过后者把“每个参数怎么更新”的策略集中管理了。一旦你想加动量、加自适应学习率、对每层用不同学习率、在更新时加入权重衰减,手写代码将迅速失控。torch.optim的价值不在于“能更新参数”,而在于把各种被验证有效的优化策略都封装好了,你只需要换一个类名,就能切换完全不同的训练行为。
2. 先搞懂梯度下降的三种形态,选型才不纠结
2.1 三种梯度下降的直觉理解
优化器家族里所有算法,本质上都是梯度下降的变体。梯度下降按“每次用多少数据来算梯度”分成三派:
批量梯度下降(BGD):每次更新用全量训练数据。这个做法在数据集很大时根本不现实——几百万张图算一次梯度,显存和算力都吃不消,而且更新方向固定,容易卡在局部极小点附近出不来。
随机梯度下降(SGD):每次只用一条样本算梯度。计算开销小,而且单样本梯度噪声大,反而有机会跳出局部极小。但问题是梯度噪声太大,损失曲线会像心电图一样疯狂跳动,收敛路径蜿蜒曲折。
小批量梯度下降(Mini-batch GD):每次用一个batch的数据算梯度。这是工业界和学术界实际采用的形式。PyTorch里的DataLoader每次产出一个batch,配合优化器更新,本身就是小批量梯度下降。所以你在PyTorch里写的torch.optim.SGD,严格来说是Mini-batch SGD。
2.2 动量机制到底在做什么
单纯的小批量SGD有个明显毛病:在损失曲面的狭长山谷地带,梯度在某个方向上来回振荡、进退两难,收敛极慢。为了抑制振荡、加速收敛,动量(Momentum)机制被引入——它不只看当前这一步的梯度,还把历史梯度的“惯性”一起考虑进来。
物理类比是这样的:你推一个很重的铁球下坡,铁球不会每走一步都完全改变方向,而是保持着之前运动的一部分惯性。如果梯度方向在某个维度上反复变化,惯性会让振幅减小;如果持续朝一个方向走,惯性会帮助加速。
PyTorch里SGD的momentum参数就是控制这个“惯性”的强度,经验上设在0.9附近。加了动量的收敛速度通常比普通SGD快得多,也顺滑得多。
2.3 学习率是最需要花心思的超参数
学习率可能是整个优化器里最重要的超参数,没有之一。它决定了每一步沿着梯度方向迈多大步子。
步子太小:训练极度缓慢,而且很容易陷入极小点附近,因为梯度越来越小,走不动了。
步子太大:可能在损失曲面两侧来回震荡,甚至直接“跨过”谷底,Loss变成一个巨大的NaN——我在训练图像分类模型时的确见过,Adam配合0.1的学习率,第一个epoch结束loss直接变NaN,检查了很久才发现是学习率问题。
学习率的选择通常跟优化器有关:SGD系一般从0.01到0.1起步,配合Learning Rate Scheduler做衰减;Adam系一般默认0.001,在大多数任务上都算一个稳定的起点。但这个值不是绝对的,需要根据loss曲线来调整。我见过有人用0.01的Adam在Transformer类模型上训练得很好,也见过0.001的SGD完全跑不动。关键是要理解学习率的含义,然后学会观察loss曲线,而不是死记硬背哪个数值。
3. torch.optim核心API拆解:从创建到参数组的完全指南
3.1 构造优化器的两种写法
torch.optim的构造方式非常简单,把需要优化的参数传进去,再指定学习率:
import torch import torch.nn as nn model = nn.Linear(10, 2) # 方式一:传入所有参数 optimizer = torch.optim.SGD(model.parameters(), lr=0.01, momentum=0.9) # 方式二:传入参数组列表(后面细说) optimizer = torch.optim.Adam([ {'params': model.features.parameters()}, {'params': model.classifier.parameters(), 'lr': 0.0001} ], lr=0.001)第一种写法就是全模型统一策略。第二种则把网络不同部分拆成不同的“参数组”,每组可以有自己的学习率、权重衰减等设置——这在迁移学习和微调场景中几乎是必备技能。比如用预训练模型做下游任务时,通常希望backbone的学习率小一些(保留已学特征),而新加的分类头学习率大一些(更快适应新任务)。
3.2 param_groups:优化器内部的数据结构
优化器内部维护一个optimizer.param_groups列表,每个元素是一个dict,描述一组参数的优化策略。常见配置项包括:
params:这个参数组包含的参数列表。lr:该组的学习率。momentum、betas、eps:动量或自适应算法的参数。weight_decay:权重衰减系数(L2正则化的等价实现)。amsgrad:Adam变体,是否使用AMSGrad。
这个结构最实用的地方在于:训练中途可以动态修改学习率,不用重新创建优化器。
# 训练到一半,把第一个参数组的lr从0.001降到0.0001 optimizer.param_groups[0]['lr'] = 0.0001许多学习率调度器(torch.optim.lr_scheduler)内部就是靠修改param_groups里的lr字段来实现衰减的。理解了这一点,你对调度器的工作原理就基本有数了。
3.3 三个必须记住的方法:zero_grad、step、state_dict
optimizer.zero_grad():清除梯度。注意它默认是set_to_none=False,也就是把梯度清零时实际上是把梯度张量置零(不是置None)。因为梯度是稀疏的,直接置None更省内存,所以现在有个写法是optimizer.zero_grad(set_to_none=True)。在一些模型里,把set_to_none=True能减少不必要的计算。optimizer.step():执行一次参数更新。绝大多数情况直接调用即可。但有一个特殊场景叫“梯度累积”(gradient accumulation),意思是当显存不够、无法用大的batch_size时,可以用多个小batch累积梯度再更新。做法是:
# 每4个batch做一次更新 for i, batch in enumerate(dataloader): loss = loss_fn(model(batch)) loss = loss / 4 # 平均化累积的梯度 loss.backward() if (i + 1) % 4 == 0: optimizer.step() optimizer.zero_grad()这种写法非常实用——相当于用多个小batch模拟一个大batch的效果,代价是训练总时间略增。
optimizer.state_dict()和optimizer.load_state_dict():保存和加载优化器的内部状态。注意,这里保存的不只是学习率,还包括动量缓冲、Adam的一阶/二阶矩估计等。如果训练中断要在断点续训,光保存模型权重是不够的,还要保存优化器状态,否则恢复训练后loss曲线的行为会出现明显的“跳变”。
3.4 weight_decay与L2正则化的那点事
weight_decay是每个优化器都有的参数,它实现的效果是:在每次更新时,把权重朝着0的方向拉一点。数学上,它等价于在原始损失函数上加上一个L2惩罚项。
很多人担心一个问题:Adam里加了weight_decay是不是就等价于L2正则化?严格来说,传统Adam的实现是把weight_decay直接加到梯度上,再进入自适应计算流程,这和真正的L2正则存在细微差别;而AdamW换了一种实现——把权重衰减从自适应流程中解耦,直接对权重做衰减。这就是为什么现在预训练大模型普遍用AdamW而不是Adam,它对权重衰减的处理更“干净”,在实际任务里表现也更稳定。
经验值上,weight_decay在图像分类任务里常用1e-4到5e-4,在AdamW里常用0.01到0.1。但这个值对任务很敏感,我不建议无脑套,最好在验证集上做个小范围搜索。
4. 主流优化器横向对比:SGD、RMSprop、Adam、AdamW的实测选型逻辑
4.1 SGD与SGD+Momentum:简单可靠但需要调参
PyTorch里的torch.optim.SGD实际是带有很多选项的SGD族:
| 参数 | 说明 | 经验值 |
|---|---|---|
| lr | 学习率 | 0.01~0.1,配合衰减率 |
| momentum | 动量系数 | 0.9附近 |
| dampening | 动量阻尼 | 一般0 |
| weight_decay | 权重衰减 | 1e-4~5e-4 |
| nesterov | 是否使用Nesterov动量 | 常True |
SGD+Momentum是我在图像分类模型上的首选之一。它的优点是对超参数不那么敏感,可解释性强,训练到后期配合余弦退火效果很稳。缺点是需要比较精细的学习率调度,起步阶段如果没有好的lr范围搜索,收敛速度比Adam类慢不少。
4.2 RMSprop:针对不同参数“因地施策”
SGD对每个参数都用同一个学习率,这在网络各层梯度尺度差异很大时会很痛苦——某些参数梯度很陡,某些参数梯度很平,统一的步长很难兼顾。
RMSprop的思路是:对每个参数,用它的历史梯度的平方的指数移动平均来归一化当前的更新步长。梯度大的方向步长自动缩小,梯度小的方向步长自动放大。这个策略让训练更平稳,尤其适合RNN这类梯度尺度波动大的结构。不过RMSprop默认学习率一般设0.001,但要真的想让模型快速收敛,往往得手动调到一个合适的范围。
4.3 Adam与它的 betas 参数:自适应学习率的典型代表
Adam(Adaptive Moment Estimation)是RMSprop和动量机制的合体:既保留了每个参数的梯度平方归一化,又加入了一阶动量作为“速度”。它在绝大多数任务上开箱即用,学习率默认0.001基本能跑到一个不错的水平,所以成了研究者的默认选择。
Adam的公式核心是维护两个状态:
- 一阶动量估计
exp_avg:历史梯度的指数移动平均,反映梯度方向。 - 二阶动量估计
exp_avg_sq:历史梯度平方的指数移动平均,反映梯度尺度。
betas就是这两个移动平均的衰减系数,默认是(0.9, 0.999)。
betas[0]=0.9意味着一阶动量平均最近10步左右的梯度。betas[1]=0.999意味着二阶动量平均最近1000步左右的梯度平方。
eps是为了防止除零加的极小常数,默认1e-8。在混合精度训练时,eps可能需要调大一点(比如1e-7或1e-6),否则数值稳定性可能出现问题。
Adam在NLP、Transformer类模型、生成模型等场景里通常是首选。它的缺点是后期训练时可能因为自适应学习率步长过大而难以收敛到极小值,所以在一些CV任务里,人们会先用Adam快速探索,再切SGD做精调。
4.4 AdamW:解决Adam与权重衰减的矛盾
AdamW和Adam的差异我在3.4里提过——解耦权重衰减。简单回顾一下:
在传统Adam里,weight_decay是加在梯度上的,然后和梯度一起被exp_avg_sq归一化。这意味着权重衰减力度会被自适应机制影响,衰减量不再是“和参数大小成固定比例”。AdamW直接把权重衰减从梯度流程中拿出来,在更新参数时单独执行:
param.data -= lr * weight_decay * param.data # 解耦的权重衰减这种做法让权重衰减的行为可预测,训练更稳定。事实上,Hugging Face的transformers库训练BERT、GPT系列时,默认优化器就是AdamW,学习率调度大多用带warmup的线性衰减。如果是做NLP或大模型相关的工作,请优先考虑AdamW。
4.5 我的选型经验总结
| 场景 | 推荐优化器 | 为什么 |
|---|---|---|
| 图像分类/经典CV,训练时间充足 | SGD(Momentum=0.9) | 配合CosineAnnealingLR,收敛位置好,泛化强 |
| NLP/Transformer/大模型预训练 | AdamW | 数值稳定,配合warmup效果好 |
| 快速验证一个模型能否跑通 | Adam | 开箱即用,参数少,收敛快 |
| 强化学习/时序模型 | RMSprop或Adam | 对梯度噪声和尺度变化更鲁棒 |
| 显存有限、batch较小 | Adam/AdamW | 自适应机制能容忍噪声梯度 |
这份表格不是“唯一正确答案”,它只是我经历过的项目里最常走通的组合。优化器选型没有银弹,关键是你理解了自己的模型和数据,然后愿意花时间看loss曲线。
5. 我踩过的优化器相关的坑,以及排查链路
5.1 Loss变成NaN的第一现场排查
几乎每个炼丹师都经历过:训练到某个step,loss突然变成NaN,之后所有参数也跟着变NaN,整个训练彻底报废。
我遇到的最常见原因是学习率过大。以Adam为例,如果设置lr=0.1,在前面提到的自适应机制下,某些参数的方向步长会剧烈震荡,数值溢出是分分钟的事。排查链路应该是:
- 先把学习率降到1e-4甚至1e-5,看是否还出现NaN。
- 检查输入数据是否有NaN或无穷值(
torch.isnan(x).any())。 - 检查梯度是否有NaN(在
optimizer.step()前打印param.grad)。 - 检查损失函数是否对数值不稳定,比如计算softmax时对数值溢出,可以考虑换用
log_softmax加NLLLoss,或者直接用CrossEntropyLoss。 - 如果用了混合精度,检查
scaler.scale(loss)的操作顺序,以及scaler.step(optimizer)和scaler.update()是否配对。
实际上,一旦loss变成NaN,模型的所有参数基本都污染了。想恢复几乎不可能,最好的办法是从上一个正常保存的checkpoint重新开始训练,并修正超参数。所以我的习惯是:训练时每几个epoch保存一次checkpoint,并且把“最近一个正常loss的样本”记录到日志里,方便判断是从哪个点开始出问题的。
5.2 梯度消失与梯度爆炸:查看参数梯度的范数
优化器调参时,我强烈建议在训练前几步加一段调试代码,打印梯度范数:
total_norm = 0.0 for p in model.parameters(): if p.grad is not None: param_norm = p.grad.data.norm(2) total_norm += param_norm.item() ** 2 total_norm = total_norm ** 0.5 print(f"gradient norm: {total_norm:.6f}")梯度范数能直观地反映训练是否健康。如果梯度范数突然飙升到1e5以上,那就是梯度爆炸,常见的处理手段有:
- 降低学习率。
- 使用梯度裁剪(gradient clipping),PyTorch里内置了:
torch.nn.utils.clip_grad_norm_(model.parameters(), max_norm=1.0)。 - 检查是否有残差连接、归一化层,避免深层网络信号逐层放大。
如果梯度范数小到1e-8以下,那就是梯度消失了,常见原因包括激活函数选择不当(比如深层网络全用sigmoid)、初始化方式不合适。RAdam、AdamW这类自适应优化器能在一定程度上缓解梯度消失——因为自适应机制会放大梯度较小的方向的更新步长。
5.3 用错了参数组,微调模型效果还不如随机初始化
有一段时间我微调BERT做文本分类,直接把整个模型的所有参数用同一个学习率喂给AdamW,跑了20个epoch,准确率反而比从头训练的基线差。
后来排查才发现问题不小:BERT底层的预训练表示其实已经很好了,学习率太大会把它们“洗掉”,而新加的分类头因为随机初始化,本来就需要更大的学习率快速收敛。用一个统一的学习率,要么底被洗坏,要么头部学习太慢。
解决办法就是参数组:
optimizer = torch.optim.AdamW([ {'params': model.encoder.parameters(), 'lr': 2e-5}, {'params': model.classifier.parameters(), 'lr': 2e-4} ], weight_decay=0.01)这样backbone用较小的学习率微调,分类头用较大的学习率快速适配。经验上,backbone学习率如果设成1e-5到3e-5,头部设成1e-4到3e-4,绝大多数微调任务都比较稳。如果你对这块不太确定,可以在param_groups里设置不同组,然后打印每一组的lr确认生效。
5.4 保存和恢复优化器状态断点续训的坑
有一次训练一个GAN,大概跑到第40个epoch时服务器被重启了。我从第30个epoch的checkpoint恢复,只加载了model权重,没有加载optimizer的state_dict。结果恢复后的loss曲线和之前完全不连续,甚至出现了反复的周期性波动。
原因在于Adam内部的一阶/二阶动量状态丢失了。从头开始等于“失忆”,自适应学习率需要重新积累历史信息,所以恢复后会有一个明显的适应期。
正确的断点保存方式是:
torch.save({ 'epoch': epoch, 'model_state_dict': model.state_dict(), 'optimizer_state_dict': optimizer.state_dict(), 'scheduler_state_dict': scheduler.state_dict() if scheduler else None, 'loss': loss.item(), }, checkpoint_path)加载时:
checkpoint = torch.load(checkpoint_path) model.load_state_dict(checkpoint['model_state_dict']) optimizer.load_state_dict(checkpoint['optimizer_state_dict']) if scheduler and checkpoint.get('scheduler_state_dict'): scheduler.load_state_dict(checkpoint['scheduler_state_dict'])另外还要注意:如果在保存状态下变化了学习率(比如已经跑了几个epoch的CosineAnnealingLR),加载后调度器的状态也会影响后续学习率变化轨迹。所以调度器状态最好一并保存。
6. 可直接抄作业的训练模板与学习率搭配方案
6.1 一个完整的训练代码骨架
下面这个模板基本覆盖了我日常训练的需求,从优化器创建到学习率调度、断点保存,一步到位:
import torch import torch.nn as nn from torch.optim.lr_scheduler import CosineAnnealingLR model = nn.Sequential(nn.Linear(784, 256), nn.ReLU(), nn.Linear(256, 10)) optimizer = torch.optim.AdamW(model.parameters(), lr=1e-3, weight_decay=0.01) # 使用余弦退火,总训练轮数设为50 scheduler = CosineAnnealingLR(optimizer, T_max=50, eta_min=1e-5) criterion = nn.CrossEntropyLoss() num_epochs = 50 start_epoch = 0 # 如果有checkpoint,就加载进来 checkpoint_path = "checkpoint.pt" try: ckpt = torch.load(checkpoint_path) model.load_state_dict(ckpt['model_state_dict']) optimizer.load_state_dict(ckpt['optimizer_state_dict']) scheduler.load_state_dict(ckpt['scheduler_state_dict']) start_epoch = ckpt['epoch'] + 1 except FileNotFoundError: pass for epoch in range(start_epoch, num_epochs): model.train() train_loss = 0.0 for x, y in train_loader: optimizer.zero_grad(set_to_none=True) out = model(x) loss = criterion(out, y) loss.backward() torch.nn.utils.clip_grad_norm_(model.parameters(), max_norm=1.0) optimizer.step() train_loss += loss.item() * x.size(0) scheduler.step() avg_loss = train_loss / len(train_loader.dataset) print(f"Epoch {epoch+1}/{num_epochs} | Loss: {avg_loss:.6f} | LR: {optimizer.param_groups[0]['lr']:.2e}") if (epoch + 1) % 5 == 0: torch.save({ 'epoch': epoch, 'model_state_dict': model.state_dict(), 'optimizer_state_dict': optimizer.state_dict(), 'scheduler_state_dict': scheduler.state_dict(), 'loss': avg_loss, }, checkpoint_path)这个模板有几个细节值得注意:
optimizer.zero_grad(set_to_none=True)比默认的置零方式省内存、省时间。- 梯度裁剪放在
backward()之后、step()之前,防止梯度爆炸。 scheduler.step()在epoch结束时调用,但像StepLR、ReduceLROnPlateau这类调度器可能需要传入验证指标或调整调用位置,务必看清楚文档。- 学习率打印用的是
optimizer.param_groups[0]['lr'],这样任何时候想确认实际生效的学习率都很方便。
6.2 学习率调度器与优化器的搭配思路
优化器和学习率调度器是“前轮后轮”的关系:优化器决定怎么走,调度器决定每一步走多快。常见搭配有:
StepLR/MultiStepLR:每隔固定epoch把学习率乘以一个因子(比如0.1)。适合SGD类,简单粗暴。ReduceLROnPlateau:当验证集指标不再下降时,降低学习率。适合大多数任务,尤其没有确定总epoch数的场景。CosineAnnealingLR/CosineAnnealingWarmRestarts:余弦退火,训练后期学习率平滑趋近一个最小值。配合SGD表现很好,也是很多CV比赛的标配。- 带warmup的线性衰减:大模型预训练标配。早期学习率从0线性升到一个指定值,再线性衰减到0。PyTorch里可以用
LinearLR配合SequentialLR或LambdaLR手动实现。
我这里想额外提醒一下:warmup不是只有大模型才需要。如果你的模型比较深,或者batch比较大,直接用大学习率起步很容易在最初几个step把参数推到一个很差的位置,之后再怎么调都难以回来。加一个几百步的warmup,让学习率从小到大过渡,训练的稳定性会显著提升。
# 一个简单的线性warmup + 余弦退火组合 def warmup_cosine_lr(epoch, warmup_epochs=5, total_epochs=50, lr_max=1e-3, lr_min=1e-5): if epoch < warmup_epochs: return lr_max * (epoch + 1) / warmup_epochs else: progress = (epoch - warmup_epochs) / (total_epochs - warmup_epochs) return lr_min + 0.5 * (lr_max - lr_min) * (1 + torch.cos(torch.tensor(progress * 3.1415926))) scheduler = torch.optim.lr_scheduler.LambdaLR(optimizer, lr_lambda=lambda e: warmup_cosine_lr(e) / lr_max)注意,LambdaLR的lr_lambda返回的是学习率缩放因子,而不是直接的学习率。所以我把目标学习率除以lr_max,让它相对于初始学习率做缩放。写成这样之后,每个epoch调度器会按这个函数更新学习率。
6.3 我的实际经验和一点习惯
最后分享几个我自己的习惯,大家参考:
第一个,训练前先跑过拟合检查。用一小部分训练数据(比如1到2个batch),把模型训到loss趋近于0。如果这一步都做不了,先别怪优化器,模型结构或者数据管道大概率有问题。
第二个,关注学习率在训练中的实际走势。不要只盯着初始学习率。很多问题藏在调度器里,比如ReduceLROnPlateau的patience设得太大,导致学习率下降过慢;或者CosineAnnealingLR的T_max设得比实际epoch数小,导致学习率提前降到最低点,最后几个epoch在无意义地“滑行”。
第三个,也是最重要的:优化器和学习率要一起调,不能只动其中一个。换优化器不是换一行代码那么简单——从Adam切到SGD时,学习率必须相应改变,学习率调度策略也要重新设计,甚至权重衰减值也要重新考虑。我见过有人把Adam换成SGD,其他参数原封不动,结果loss曲线乱得没法看,然后得出结论说“SGD真难用”。
第四个,保存checkpoint时多保留一份“最近一次正常状态”。训练中途出现NaN或者loss异常时,可以用它快速回滚,而不是从头再来。这个习惯帮我在无数次实验事故里省下了大量时间。