深度学习中的参数更新方法
- [1. 动量法`Momentum`](#1. 动量法
Momentum) - [2. 学习率衰减](#2. 学习率衰减)
-
- [2.1 等间隔衰减](#2.1 等间隔衰减)
- [2.2 指定间隔衰减](#2.2 指定间隔衰减)
- [2.3 指数衰减](#2.3 指数衰减)
- [3. 自适应梯度算法](#3. 自适应梯度算法)
-
- [3.1 `AdaGrad` 自适应梯度算法](#3.1
AdaGrad自适应梯度算法) - [3.2 `RMSprop`](#3.2
RMSprop)
- [3.1 `AdaGrad` 自适应梯度算法](#3.1
- [4. Adam 自适应距估计](#4. Adam 自适应距估计)
- [5. 总结](#5. 总结)
在深度学习中,随机梯度下降法是优化的最常见方式,其公式表达为:
θ t + 1 = θ t − η ⋅ g t \theta_{t+1} = \theta_t - \eta \cdot g_t θt+1=θt−η⋅gt
- θ \theta θ 是参数
- η \eta η 表示学习率
- g t g_t gt 为当前梯度
根据SGD方法,可以让参数沿着负梯度方向改变,但是在比较平滑的位置,比如局部最小值或者鞍点等附近可能导致反复震荡,最终甚至导致根本无法找到最小值点。
为了克服上面提到的缺点,提出了一些改进的方法。
1. 动量法Momentum
在SGD的基础上,动量法将计算优化为了两个步骤:
v t = γ ⋅ v t − 1 + η ⋅ g t v_t = \gamma \cdot v_{t-1} + \eta \cdot g_t vt=γ⋅vt−1+η⋅gt
θ t + 1 = θ t − v t \theta_{t+1} = \theta_t - v_t θt+1=θt−vt
类似于给参数的修改增加一个速度,这个速度除了需要完整的学习率和梯度相乘,还保留了以前历史速度的影响,而且从公式上看,这个速度受到以前的影响是在递减的,也就是距离越远受到以前的影响就越小。
我们可以用一个函数的例子来实践并观察下不同的参数优化方法:
python
import torch
import numpy as np
import matplotlib.pyplot as plt
# 定义函数
def f(X):
return 0.05 * X[0] ** 2 + X[1] ** 2
# 定义梯度下降过程
def gradient_descent(X, optimizer, iter_num):
# 保留画图需要的历史值
X_arr = X.detach().numpy().copy()
for epoch in range(iter_num):
# 前向传播
y = f(X)
# 反向传播
y.backward()
# 更新参数并清零
optimizer.step()
optimizer.zero_grad()
# 要注意的是这里都是取出X的值,不能在其他操作中修改了本来的值
X_arr = np.vstack([X_arr, X.detach().numpy()])
return X_arr
# 定义主流程
X = torch.tensor([-7, 2], dtype=torch.float, requires_grad=True)
X_clone = X.clone().detach().requires_grad_()
# 随机梯度下降法
optimizer_sgd = torch.optim.SGD([X_clone], lr=0.01)
X_arr1 = gradient_descent(X_clone, optimizer_sgd, iter_num=500)
plt.plot(X_arr1[:, 0], X_arr1[:, 1], 'r')
X_clone = X.clone().detach().requires_grad_()
# 动量法
optimizer_sgd = torch.optim.SGD([X_clone], lr=0.01, momentum=0.9)
X_arr2 = gradient_descent(X_clone, optimizer_sgd, iter_num=500)
plt.plot(X_arr2[:, 0], X_arr2[:, 1], 'b')
# 画出等高线
x1_grid, x2_grid = np.meshgrid(np.linspace(-7, 7, 100), np.linspace(-2, 2, 100))
y_grid = 0.05 * (x1_grid ** 2 + x2_grid ** 2)
plt.contour(x1_grid, x2_grid, y_grid, levels=10, colors='k')
plt.legend(["SGD", "Momentum"])
plt.show()
2. 学习率衰减
单纯的动量法存在一个缺点:随着迭代次数,可能动能持续偏大,导致点的震荡很大。
通过逐渐减小学习率可以一定程度上克服这个缺点。有三种常见的学习率衰减方案:
- 等间隔衰减
- 指定间隔衰减
- 指数衰减
2.1 等间隔衰减
在pytorch中可以通过torch.optim.lr_scheduler.StepLR(optimizer, step_size, gamma)来实现学习率的等间隔衰减。
其中:
- optizimer:实现学习率衰减的优化器
- step_size:间隔
- gamma:衰减比率
等间隔衰减示例代码:
python
import torch
import matplotlib.pyplot as plt
import torch.optim as optim
import numpy as np
# 定义函数
def f(X):
return 0.05 * X[0] ** 2 + X[1] ** 2
# 定义主流程
X = torch.tensor([-7, 2], dtype=torch.float, requires_grad=True)
X_clone = X.clone().detach().requires_grad_()
# 优化器
optimizer = torch.optim.SGD([X], lr=0.3)
# 定义学习率衰减器
lr_scheduler = torch.optim.lr_scheduler.StepLR(optimizer, step_size=5, gamma=0.7)
# 保留画图需要的历史值
X_arr = X.detach().numpy().copy()
lr_list = []
for epoch in range(500):
# 前向传播
y = f(X)
# 反向传播
y.backward()
# 更新参数并清零
optimizer.step()
optimizer.zero_grad()
# 要注意的是这里都是取出X的值,不能在其他操作中修改了本来的值
X_arr = np.vstack([X_arr, X.detach().numpy()])
lr_list.append(optimizer.param_groups[0]['lr'])
lr_scheduler.step()
# 画图
plt.rcParams['font.sans-serif'] = ['SimHei']
plt.rcParams['axes.unicode_minus'] = False
fig, ax = plt.subplots(1, 2, figsize=(12, 4))
# 画出等高线
x1_grid, x2_grid = np.meshgrid(np.linspace(-7, 7, 100), np.linspace(-2, 2, 100))
y_grid = 0.05 * x1_grid ** 2 + x2_grid ** 2
ax[0].contour(x1_grid, x2_grid, y_grid, levels=10, colors='k')
ax[0].plot(X_arr[:, 0], X_arr[:, 1], color='r')
ax[0].set_title("SGD")
# 画学习率衰减曲线
ax[1].plot(lr_list, color='b')
ax[1].set_title("学习率衰减")
plt.show()
2.2 指定间隔衰减
在pytorch中可以通过torch.optim.lr_scheduler.MultiStepLR(optimizer, milestones, gamma)来实现学习率的指定间隔衰减。
其中:
milestones: 是一个列表,表示指定的衰减间隔,如[10, 50, 200]。
2.3 指数衰减
在pytorch中可以通过torch.optim.lr_scheduler.ExponentialLR(optimizer, gamma)来实现学习率的指数衰减。
这里要注意gamma是底数,学习率计算方式:
γ = γ ∗ γ e p o c h \gamma = \gamma * \gamma^{epoch} γ=γ∗γepoch
3. 自适应梯度算法
标准的SGD算法按照固定的学习率更新参数,存在两个场景:
- 面对稀疏特征,就是那些出现频率低的情况,会导致学习的很慢,即梯度更新的慢,收敛慢;
- 面对频繁更新的特征,就是那些变化多且快的情况,会导致在最优解附近震荡;
另外,学习率衰减的更新方式呢?
- 由于学习率会一直衰减,那么可能到最后学习率已经接近0了,但是还是没学习完成,最终"学不动"了;
- 由于学习率衰减的间隔是比较固定的,如果某个时刻距离最优解还很远,此时刚好又该对学习率进行衰减,那么会在本该"步子迈大点的时候,强行走小碎步",这就降低了学习的效率;
- 另一种情况,我们可能在存在众多"坑"的面上寻找最低点,如果某个时候刚好找到了某个坑的边缘,同时在这里降低了学习率,会导致掉入局部最优解;
所以为了克服上面两类参数更新方法的缺点,提出了自适应梯度算法。
3.1 AdaGrad 自适应梯度算法
算法思想:
- 如果更新频繁,变化大的特征,梯度变化就很快,所以:历史梯度累积就很大,将其作为分母可以让学习率较小,也就是"步子迈小点";之前理解错误的地方:可以让学习率变小,其实无论如何,学习率都是在减小的(除了最开始)。
- 针对稀疏特征,历史梯度累积的自然就小,将其作为分母可以让学习率较大,加速收敛;直接理解错误的地方:可以让学习率变大,和上面一点一样,学习率因为 G t G_t Gt 会越来越大,一定是越来越小的,只是自适应可以控制这个程度和速度。
公式:
θ t + 1 = θ t − η G t + ϵ ⊙ g t \theta_{t+1} = \theta_t - \frac{\eta}{\sqrt{G_t + \epsilon}} \odot g_t θt+1=θt−Gt+ϵ η⊙gt
其中 θ t \theta_t θt是参数在 t t t时刻的值, G t G_t Gt 是历史梯度平方和的累积, ϵ \epsilon ϵ 是防止分母为 0 的平滑项, g t g_t gt 是梯度。
缺点:
- 其实和学习率衰减类似,因为 G t G_t Gt 是平方和,注定是越来越大,即分母越来越大,学习率就会越来越小,极端情况下就是减少到 0 了还没找到最优解,最终"学不动了"。
示例代码:
python
import torch
import numpy as np
import matplotlib.pyplot as plt
# 定义函数
def f(X):
return 0.05 * X[0] ** 2 + X[1] ** 2
# 定义梯度下降过程
def gradient_descent(X, optimizer, iter_num):
# 保留画图需要的历史值
X_arr = X.detach().numpy().copy()
for epoch in range(iter_num):
# 前向传播
y = f(X)
# 反向传播
y.backward()
# 更新参数并清零
optimizer.step()
optimizer.zero_grad()
# 要注意的是这里都是取出X的值,不能在其他操作中修改了本来的值
X_arr = np.vstack([X_arr, X.detach().numpy()])
return X_arr
# 定义主流程
X = torch.tensor([-7, 2], dtype=torch.float, requires_grad=True)
X_clone = X.clone().detach().requires_grad_()
# 随机梯度下降法
optimizer_sgd = torch.optim.SGD([X_clone], lr=0.7)
X_arr1 = gradient_descent(X_clone, optimizer_sgd, iter_num=50)
plt.plot(X_arr1[:, 0], X_arr1[:, 1], 'r')
X_clone = X.clone().detach().requires_grad_()
# 动量法
optimizer_sgd = torch.optim.Adagrad([X_clone], lr=0.7)
X_arr2 = gradient_descent(X_clone, optimizer_sgd, iter_num=50)
plt.plot(X_arr2[:, 0], X_arr2[:, 1], 'b')
# 画出等高线
x1_grid, x2_grid = np.meshgrid(np.linspace(-7, 7, 100), np.linspace(-2, 2, 100))
y_grid = 0.05 * x1_grid ** 2 + x2_grid ** 2
plt.contour(x1_grid, x2_grid, y_grid, levels=10, colors='k')
plt.legend(["SGD", "Adagrad"])
plt.show()
3.2 RMSprop
因为 Adagrad 方法中的 G t G_t Gt 会越来越大,类似于把所有的错误全部累积,同时终身不忘,可能导致的学习率过早接近 0 的情况。针对这点,提出了 RMSprop 均方根传播方法。
公式:
θ t + 1 = θ t − η E [ g 2 ] t + ϵ g t \theta_{t+1} = \theta_t - \frac{\eta}{\sqrt{E[g^2]_t + \epsilon}} g_t θt+1=θt−E[g2]t+ϵ ηgt
其中
E [ g 2 ] t = β E [ g 2 ] t − 1 + ( 1 − β ) g t 2 E[g^2]t = \beta E[g^2]{t-1} + (1 - \beta) g_t^2 E[g2]t=βE[g2]t−1+(1−β)gt2
通常可以把 β \beta β 取值 0.9。这种指数加权移动平均的方式会使学习率的变化逐渐遗忘曾经的错误,增加近期梯度信息的权重,能够有效避免学习率过早死亡的问题。
在Pytorch中直接使用 torch.optim.RMSprop([X_clone], lr=0.1, alpha=0.9) 调用。
4. Adam 自适应距估计
Adam 方法结合了动量法和 RMSprop 的优点,既拥有惯性,又拥有自适应学习率的能力。
核心原理:同时利用了梯度的一阶矩 (均值,动量法使用的)和二阶矩(未中心化的方差,均方根传播法用的)来动态调整参数的学习率。
步骤与公式:
- 更新一阶动量 m t m_t mt和二阶动量 v t v_t vt:
m t = β 1 m t − 1 + ( 1 − β 1 ) g t m_t = \beta_1 m_{t-1} + (1-\beta_1)g_t mt=β1mt−1+(1−β1)gt
v t = β 2 v t − 1 + ( 1 − β 2 ) g t 2 v_t = \beta_2v_{t-1} + (1-\beta_2){g_t}^2 vt=β2vt−1+(1−β2)gt2
-
校正偏差(修正初始值为 0 带来的偏差):
训练初期,由于动量变量初始化为 0,会导致估计值偏向于 0。
m ^ t = m t 1 − β 1 t \hat{m}_t = \frac{m_t}{1 - \beta_1^t} m^t=1−β1tmt
v ^ t = v t 1 − β 2 t \hat{v}_t = \frac{v_t}{1 - \beta_2^t} v^t=1−β2tvt
- 参数更新:
θ t + 1 = θ t − η v ^ t + ϵ m ^ t \theta_{t+1} = \theta_t - \frac{\eta}{\sqrt{\hat{v}_t} + \epsilon} \hat{m}_t θt+1=θt−v^t +ϵηm^t
优点:
- 收敛速度快
- 对超参数不敏感
- 鲁棒性强
缺点:
- 在标准的 Adam 中,权重衰减是通过
L2正则化实现的。但由于Adam的自适应学习率机制,L2正则化的效果会被扭曲,导致正则化失效或效果不佳;
因此,实际中非常建议使用Adam的优化版:AdmW,其将权重衰减从梯度计算中解耦出来,直接在参数更新时进行衰减。在现代深度学习任务(尤其是使用 Transformer、BERT、ViT 等大模型)中,强烈推荐使用 AdamW 代替标准的 Adam,通常能获得更好的泛化性能。
5. 总结
SGD:基础款,手动挡,容易震荡。SGD + Momentum:加了惯性,跑得快,但还得手动调学习率。AdaGrad:能自动调学习率,但容易过早停止(学习率降太快)。RMSProp:解决了过早停止问题,只关注近期梯度。Adam = Momentum + RMSProp:既有惯性,又能自动调速,全能选手。