把训练时常用的几个优化算法放在一起记一下。先从梯度下降开始,再看动量和自适应学习率,最后接一下Pytorch的调用方式。

统一一下记号:\(w_{t-1}\)表示第\(t\)次更新前的参数,\(g_t\)表示这一步算出的梯度,\(\eta_t\)为学习率。下面的平方、开方、乘法和除法,作用在向量上时都按元素计算。

梯度下降

假设要最小化的目标为:

\[ L(w)=\frac{1}{N}\sum_{i=1}^{N}\ell_i(w) \]

梯度为\(g_t=\nabla_w L(w_{t-1})\),更新公式为:

\[ w_t=w_{t-1}-\eta_t g_t \]

梯度表示当前位置函数增长最快的方向,因此沿负梯度方向走一小步,有机会让loss下降。这里要注意“走一小步”:学习率太大时,可能越过低点,在两边震荡,甚至发散。负梯度方向也不等于直接指向最小值的方向。

全量梯度下降每次使用全部训练数据计算梯度。数据量很大时,更新一次参数的成本就比较高。

随机梯度下降SGD

严格来说,SGD每次随机取一个样本计算梯度;实际训练中更常见的是mini-batch SGD,即每次取一小批样本:

\[ g_t=\frac{1}{|B_t|}\sum_{i\in B_t}\nabla_w\ell_i(w_{t-1}) \]

参数更新公式仍然是\(w_t=w_{t-1}-\eta_t g_t\),变化的是梯度的计算方式。均匀采样且满足相应条件时,这个梯度可以看作全量梯度的无偏估计。

batch之间的数据不同,梯度会有波动,所以训练曲线不一定每一步都下降。batch size也会改变一轮训练里的更新次数。比较两个实验时,只看epoch可能不够,还要看总更新步数。

Pytorch中的torch.optim.SGD负责拿已经算好的梯度更新参数,不负责随机抽样。样本怎么分batch、是否打乱顺序,由数据加载部分决定。

optimizer = torch.optim.SGD(net.parameters(), lr=0.01)

动量法

普通SGD每次主要看当前梯度。动量法会保留过去的方向信息:

\[ b_t=\mu b_{t-1}+g_t,\qquad w_t=w_{t-1}-\eta_t b_t \]

其中\(b_0=0\),\(\mu\)为动量系数。连续几步方向相近时,历史梯度会累积;如果某个方向来回变化,正负项会有一部分抵消。

可以用一个比较窄的谷底理解:横向的梯度一会儿向左、一会儿向右,纵向的梯度虽然小,但可能一直向前。累积之后,两个方向的表现会不同。

也有资料写成\(b_t=\mu b_{t-1}+(1-\mu)g_t\)。这是另一种缩放约定,不能直接照搬同一个学习率。上面保留的是Pytorch在dampening=0时的常用形式;具体实现还有初始化等细节。SGD文档专门说明了这些差异。

optimizer = torch.optim.SGD(
    net.parameters(), lr=0.01, momentum=0.9
)

Nesterov

Nesterov动量常被解释成“先沿着已有动量看一下前面的位置,再用那里的梯度修正方向”。看公式时需要注意,不同资料可能使用不同的参数坐标和动量定义。

如果直接对照Pytorch,在忽略weight decay、设置dampening=0时,可以记为:

\[ b_t=\mu b_{t-1}+g_t \]

\[ w_t=w_{t-1}-\eta_t(g_t+\mu b_t) \]

和前一节相比,真正用于更新的方向从\(b_t\)变成了\(g_t+\mu b_t\)。这里的\(g_t\)仍然由当前参数计算,不需要在代码里手动把参数移动一次再求梯度。关于初始化和动量的论文里有相关讨论。

optimizer = torch.optim.SGD(
    net.parameters(), lr=0.01, momentum=0.9, nesterov=True
)

AdaGrad

前面的方法主要改变更新方向。AdaGrad会按每个参数过去的梯度大小,调整它的有效步长:

\[ s_t=s_{t-1}+g_t\odot g_t \]

\[ w_t=w_{t-1}-\frac{\eta_t}{\sqrt{s_t}+\epsilon}\odot g_t \]

其中\(s_0=0\),\(\epsilon\)用于避免分母为0。经常出现大梯度的坐标,累积量\(s_t\)会更大,后面的更新就会受到更多缩放。这也是它和稀疏特征联系比较紧的原因之一。AdaGrad原论文从自适应梯度方法的角度进行了推导。

问题是\(s_t\)一直累加,不会忘记很早以前的梯度。长时间训练后,某些坐标的有效步长可能变得很小。

optimizer = torch.optim.Adagrad(net.parameters(), lr=0.01)

RMSprop

RMSprop把梯度平方的累加,换成指数移动平均:

\[ s_t=\alpha s_{t-1}+(1-\alpha)(g_t\odot g_t) \]

\[ w_t=w_{t-1}-\frac{\eta_t}{\sqrt{s_t}+\epsilon}\odot g_t \]

这样旧梯度的影响会逐渐衰减,分母不再像AdaGrad那样一直累加。\(\alpha\)越大,保留的历史越长。

上面写的是不带动量、centered=False的基本形式。Pytorch还提供了带动量以及centered的变体,查看实现时要先看清楚参数。RMSprop文档中的公式也把这些分支列了出来。

optimizer = torch.optim.RMSprop(
    net.parameters(), lr=0.001, alpha=0.99
)

Adam

Adam同时保留梯度的一阶矩估计和二阶原始矩估计:

\[ m_t=\beta_1m_{t-1}+(1-\beta_1)g_t \]

\[ v_t=\beta_2v_{t-1}+(1-\beta_2)(g_t\odot g_t) \]

由于\(m_0=v_0=0\),前几步的估计会偏向0,因此还要做偏差修正:

\[ \hat m_t=\frac{m_t}{1-\beta_1^t},\qquad \hat v_t=\frac{v_t}{1-\beta_2^t} \]

最后更新:

\[ w_t=w_{t-1}-\eta_t\frac{\hat m_t}{\sqrt{\hat v_t}+\epsilon} \]

分子保留方向信息,分母根据每个坐标的梯度尺度做缩放。这里\(v_t\)是梯度平方的移动平均,不是减去均值之后的方差。Adam原论文给出了完整算法。

optimizer = torch.optim.Adam(
    net.parameters(), lr=0.001, betas=(0.9, 0.999), eps=1e-8
)

自适应学习率也不代表不用设置学习率,\(\eta_t\)仍然在控制整体步长。另外,如果要加weight decay,需要区分把L2项加进梯度和AdamW的解耦权重衰减,这两种更新一般不等价。比较实验时要把这个设置也记下来。Adam文档和AdamW文档分别列出了对应算法。

学习率调整策略StepLR

优化器决定这一步怎么更新,scheduler决定学习率什么时候改变。StepLR每经过step_size次调度,将学习率乘以gamma。

下面约定每个epoch结束时调用一次scheduler.step()。这样step_size=10就是每10个epoch衰减一次。如果把调用放在每个batch后面,计数单位就变成batch了。

用一个简单的线性回归接起来:

import torch
from torch import nn


torch.manual_seed(7)
x = torch.linspace(-1, 1, 128).reshape(-1, 1)
y = 3 * x + 0.5
net = nn.Linear(1, 1)

optimizer = torch.optim.SGD(
    net.parameters(), lr=0.1, momentum=0.9
)
scheduler = torch.optim.lr_scheduler.StepLR(
    optimizer, step_size=10, gamma=0.1
)
loss_fn = nn.MSELoss()

for epoch in range(30):
    net.train()
    used_lr = optimizer.param_groups[0]["lr"]

    optimizer.zero_grad()
    loss = loss_fn(net(x), y)
    loss.backward()
    optimizer.step()

    # 在本轮参数更新之后,设置下一轮使用的学习率
    scheduler.step()
    print(epoch + 1, used_lr, float(loss.detach()))

按这里的调用顺序,第1到10轮使用0.1,第11到20轮使用0.01,第21到30轮使用0.001。scheduler.step()放在optimizer.step()之后,和Pytorch文档中的调用方式一致。

还有两点比较容易混在一起:

  • zero_grad()清空的是梯度,不会清掉优化器已经保存的动量或Adam状态。不要在每个epoch里重新创建优化器,否则这些历史信息也会丢掉。
  • StepLR只按次数调整,不会看验证集loss。如果希望根据验证指标调整,需要另外选择对应的调度器。

先把每个方法到底保存了什么状态、这些状态怎么参与下一步更新弄清楚,再比较效果会容易一些。不同优化器直接使用同一个学习率,并不能保证是公平的比较。

参考

原始简记与配图

梯度下降

\(w_{t+1} = w_t-\eta \bigtriangledown w_t\)

随机梯度下降SGD

动量法

Nesterov

AdaGrad

RMSprop

Adam

https://pytorch.org/docs/stable/generated/torch.optim.Adam.html

学习率调整策略StepLR