§1.3.26

SGD/Momentum/Nesterov/Adagrad/RMSProp/Adam/AdamW/Lion/Adafactor 的更新公式?

好的,我们来详细剖析深度学习中常用的优化器。

核心概念

优化器(Optimizer)是在深度学习中用于调整模型参数(如权重和偏置)以最小化损失函数的算法。其核心思想是根据模型参数的梯度(损失函数关于参数的导数)来计算一个更新量,并按照这个更新量迭代地更新参数。不同的优化器采用不同的策略来计算这个更新量,旨在解决梯度下降过程中的各种挑战,如学习率选择、局部最小值、鞍点、收敛速度和内存消耗等问题。

原理与推导

我们首先定义一些通用符号:

  • θt\theta_t: 模型在第 tt 步的参数。
  • L(θ)L(\theta): 损失函数。
  • gt=θL(θt)g_t = \nabla_{\theta} L(\theta_t): 损失函数在 θt\theta_t 处的梯度。
  • η\eta: 学习率(learning rate),一个控制更新步长的超参数。
  • \odot: 向量或矩阵的元素级乘法(element-wise product)。

1. SGD (Stochastic Gradient Descent)

原理:SGD 是最基础的优化器。它每次只使用一小批(mini-batch)数据计算梯度,并沿着梯度的反方向更新参数。这使得更新过程带有随机性,有助于跳出局部最小值,但也会带来震荡。

更新公式

θt+1=θtηgt\theta_{t+1} = \theta_t - \eta g_t

几何解释:想象在一个崎岖的山谷中寻找最低点。SGD 就像一个蒙着眼睛的人,每走一步,就感受一下脚下哪个方向坡度最陡(梯度的反方向),然后朝那个方向迈一小步。由于每次只看一小块区域(mini-batch),所以看到的坡度不完全是全局最陡的,因此路径会摇摇晃晃。

复杂度

  • 时间复杂度:O(N)O(N),其中 NN 是模型参数量,用于计算梯度和更新。
  • 空间复杂度:O(1)O(1) 的额外空间(除了存储参数和梯度本身)。

2. Momentum

原理:Momentum 旨在加速 SGD 并抑制震荡。它引入了一个“动量”或“速度”项 vtv_t,该项是过去梯度的指数加权平均。这使得参数更新方向不仅取决于当前梯度,还取决于历史梯度,就像一个从山坡上滚下来的球,它会保持之前的速度。

更新公式

vt=βvt1+gtθt+1=θtηvtv_t = \beta v_{t-1} + g_t \\ \theta_{t+1} = \theta_t - \eta v_t

其中 β\beta 是动量系数(通常取 0.9 左右),v0v_0 初始化为 0。

物理/几何解释:这就像一个有质量的球在山谷中滚动。当梯度方向一致时,动量累积,球加速滚动;当梯度方向改变时,动量会抵消部分改变,使得更新方向更加平滑,减少在狭窄谷底的来回震荡。


3. Nesterov Accelerated Gradient (NAG)

原理:Nesterov 是对 Momentum 的一种改进。Momentum 先计算当前梯度,再在累积的动量方向上前进一大步。而 Nesterov 则更有“预见性”:它先在已有的动量方向上“预走”一小步,到达一个“未来”的位置,然后计算该未来位置的梯度,再用这个梯度来修正最终的更新方向。

更新公式(PyTorch 等框架中的常见实现形式):

vt=βvt1+gtθt+1=θtη(gt+βvt)v_t = \beta v_{t-1} + g_t \\ \theta_{t+1} = \theta_t - \eta (g_t + \beta v_t)

推导与动机:原始公式是 vt=βvt1+L(θtηβvt1)v_t = \beta v_{t-1} + \nabla L(\theta_t - \eta \beta v_{t-1})θt+1=θtηvt\theta_{t+1} = \theta_t - \eta v_t。可以理解为,更新量 vtv_t 包含了对动量 vt1v_{t-1} 的一个“修正”。如果动量把我们带到了一个错误的方向,在那个“预走”位置计算的梯度会把我们拉回来。上述实现形式是原始公式的一个等价变形,更易于实现。


4. Adagrad (Adaptive Gradient Algorithm)

原理:Adagrad 引入了自适应学习率,为每个参数独立地调整学习率。它根据参数至今为止所有梯度的平方和来缩放学习率:对于梯度一直很大的参数,其学习率会变小;对于梯度一直很小的参数,其学习率会相对较大。

更新公式: 定义一个累积平方梯度 sts_t,初始化为 0。

st=st1+gtgtθt+1=θtηst+ϵgts_t = s_{t-1} + g_t \odot g_t \\ \theta_{t+1} = \theta_t - \frac{\eta}{\sqrt{s_t + \epsilon}} \odot g_t

其中 ϵ\epsilon 是一个很小的常数(如 10810^{-8}),用于防止分母为零。

信息论解释:对于稀疏特征(如 one-hot 编码的词向量),大部分时候梯度为 0,偶尔非零。Adagrad 能在这些特征罕见出现并产生梯度时,给予一个较大的更新步长,从而有效学习。但其缺点是 sts_t 会不断累积,导致学习率最终会变得过小,提前停止学习。


5. RMSProp (Root Mean Square Propagation)

原理:RMSProp 是对 Adagrad 的改进,旨在解决其学习率过早衰减的问题。它不再累积所有的历史平方梯度,而是使用指数加权移动平均来计算平方梯度,从而只关注最近一段时间的梯度大小。

更新公式: 定义平方梯度的移动平均 sts_t,初始化为 0。

st=β2st1+(1β2)(gtgt)θt+1=θtηst+ϵgts_t = \beta_2 s_{t-1} + (1 - \beta_2) (g_t \odot g_t) \\ \theta_{t+1} = \theta_t - \frac{\eta}{\sqrt{s_t + \epsilon}} \odot g_t

其中 β2\beta_2 是衰减率(通常取 0.999)。

动机:通过指数衰减,RMSProp “忘记”了遥远过去的梯度,使得分母 sts_t 不会无限增长。这让它在非凸优化问题中表现更稳定。


6. Adam (Adaptive Moment Estimation)

原理:Adam 是目前最流行的优化器之一,它结合了 Momentum 和 RMSProp 的优点。它既使用动量(一阶矩估计)来加速梯度下降,又使用自适应学习率(二阶矩估计)来为每个参数调整步长。

更新公式

  1. 更新一阶矩估计(动量)mt=β1mt1+(1β1)gtm_t = \beta_1 m_{t-1} + (1 - \beta_1) g_t
  2. 更新二阶矩估计(平方梯度)vt=β2vt1+(1β2)(gtgt)v_t = \beta_2 v_{t-1} + (1 - \beta_2) (g_t \odot g_t)
  3. 偏差修正(Bias Correction):由于 m0m_0v0v_0 初始化为 0,在训练初期 mtm_tvtv_t 会偏向于 0。Adam 通过以下方式进行修正: m^t=mt1β1tv^t=vt1β2t\hat{m}_t = \frac{m_t}{1 - \beta_1^t} \\ \hat{v}_t = \frac{v_t}{1 - \beta_2^t}
  4. 参数更新 θt+1=θtηm^tv^t+ϵ\theta_{t+1} = \theta_t - \eta \frac{\hat{m}_t}{\sqrt{\hat{v}_t} + \epsilon}

常用超参数默认值为:β1=0.9,β2=0.999,ϵ=108\beta_1=0.9, \beta_2=0.999, \epsilon=10^{-8}


7. AdamW (Adam with Decoupled Weight Decay)

原理:标准的 Adam 在实现 L2 正则化时存在一个问题。在 SGD 中,L2 正则化(在损失函数中加入 λ2θ2\frac{\lambda}{2}\|\theta\|^2)等价于在参数更新时减去一项 ηλθt\eta \lambda \theta_t(权重衰减)。但在 Adam 中,由于每个参数的学习率是自适应的(被 v^t\sqrt{\hat{v}_t} 缩放),L2 正则化的效果会受到影响,导致大梯度的权重衰减得更少。AdamW 将权重衰减从梯度更新中解耦出来,直接在参数更新的最后一步执行。

更新公式: AdamW 的前三步与 Adam 完全相同(计算 mt,vtm_t, v_t 和进行偏差修正)。区别在于最后一步:

θt+1=θtη(m^tv^t+ϵ+λθt)\theta_{t+1} = \theta_t - \eta \left( \frac{\hat{m}_t}{\sqrt{\hat{v}_t} + \epsilon} + \lambda \theta_t \right)

注意,这里的权重衰减项 λθt\lambda \theta_t 是与学习率 η\eta 相乘后直接从 θt\theta_t 中减去的,而不是先加到梯度 gtg_t 里。这使得权重衰减的效果更加稳定和可预测。


8. Lion (EvoLved Sign Momentum)

原理:Lion 是 Google Brain 通过符号演化发现的一种新优化器,其结构比 Adam 更简单。它只跟踪动量(一阶矩),并使用动量的符号(sign)来决定更新方向,所有参数的更新大小是统一的(由学习率 η\eta 控制),而不是像 Adam 那样逐参数自适应。

更新公式

  1. 插值更新ct=β1mt1+(1β1)gtc_t = \beta_1 m_{t-1} + (1 - \beta_1) g_t
  2. 参数更新θt+1=θtηsign(ct)\theta_{t+1} = \theta_t - \eta \cdot \text{sign}(c_t)
  3. 动量更新mt=β2mt1+(1β2)gtm_t = \beta_2 m_{t-1} + (1 - \beta_2) g_t

这里的 β1\beta_1β2\beta_2 作用不同:β1\beta_1 用于插值生成更新方向,β2\beta_2 用于动量衰减。论文建议 β1=0.9,β2=0.99\beta_1=0.9, \beta_2=0.99。由于没有二阶矩和开方运算,Lion 在计算上比 Adam 更高效。


9. Adafactor

原理:Adafactor 是为解决大规模模型训练中 Adam 优化器内存占用过高问题而设计的。Adam 需要为每个参数存储一阶和二阶矩,对于拥有数十亿参数的模型,这会消耗大量显存。Adafactor 通过两个主要技巧来节省内存:

  1. 因子分解(Factorization):它不存储完整的二阶矩矩阵 vtv_t,而是将其分解为行和列的统计量,大大减少了存储量。对于一个形状为 (m, n) 的参数矩阵,Adam 需要 O(mn) 的空间存 vtv_t,而 Adafactor 只需要 O(m+n)
  2. 可选地省略一阶矩:论文发现,如果 β1\beta_1 足够小,可以不存储一阶矩 mtm_t,而是直接使用梯度的平滑版本,进一步节省内存。

更新公式(概念性): 由于其公式较为复杂,这里给出核心思想而非完整公式。

  • 二阶矩vtv_t 被分解为行向量 RtR_t 和列向量 CtC_t 的移动平均。
  • 更新规则:更新步长由 1/Factorized(vt)1 / \sqrt{\text{Factorized}(v_t)} 决定。
  • 学习率:Adafactor 使用一个相对学习率方案,会根据参数的尺度自动调整,因此对外部学习率 η\eta 的设置不那么敏感。

代码实现

下面使用 NumPy 从零开始实现这些优化器,并用于一个简单的二次函数优化问题,以直观展示它们的工作方式。

python
1import numpy as np
2import matplotlib.pyplot as plt
3
4# --- 1. 定义优化问题 ---
5# 目标函数: f(x, y) = x^2 + 10*y^2,这是一个拉长的碗状,梯度在y方向更大
6def loss_function(params):
7 x, y = params[0], params[1]
8 return x**2 + 10 * y**2
9
10# 目标函数的梯度
11def gradient(params):
12 x, y = params[0], params[1]
13 return np.array([2 * x, 20 * y])
14
15# --- 2. 实现各个优化器更新逻辑 ---
16
17def sgd_update(params, grads, state, hyperparams):
18 lr = hyperparams['lr']
19 return params - lr * grads, state
20
21def momentum_update(params, grads, state, hyperparams):
22 lr = hyperparams['lr']
23 beta = hyperparams['beta']
24 v = state.get('v', np.zeros_like(params))
25
26 # 为什么这样做: 更新动量项v,v是历史梯度的指数加权平均
27 v = beta * v + grads
28
29 new_params = params - lr * v
30 state['v'] = v
31 return new_params, state
32
33def nesterov_update(params, grads, state, hyperparams):
34 lr = hyperparams['lr']
35 beta = hyperparams['beta']
36 v = state.get('v', np.zeros_like(params))
37
38 # 为什么这样做: 这是Nesterov的常见实现形式。
39 # v_prev是旧动量,v_new是当前梯度加旧动量。
40 # 更新方向是当前梯度和新动量的组合。
41 v_prev = v
42 v = beta * v + grads
43
44 # 核心区别:更新时同时使用了当前梯度和更新后的动量
45 new_params = params - lr * (grads + beta * v)
46 state['v'] = v
47 return new_params, state
48
49def adagrad_update(params, grads, state, hyperparams):
50 lr = hyperparams['lr']
51 eps = hyperparams['eps']
52 s = state.get('s', np.zeros_like(params))
53
54 # 为什么这样做: 累积历史梯度的平方
55 s += grads**2
56
57 # 为什么这样做: 根据累积的平方梯度调整每个参数的学习率
58 new_params = params - lr * grads / (np.sqrt(s) + eps)
59 state['s'] = s
60 return new_params, state
61
62def rmsprop_update(params, grads, state, hyperparams):
63 lr = hyperparams['lr']
64 beta2 = hyperparams['beta2']
65 eps = hyperparams['eps']
66 s = state.get('s', np.zeros_like(params))
67
68 # 为什么这样做: 使用指数移动平均来计算平方梯度,防止学习率过早衰减
69 s = beta2 * s + (1 - beta2) * grads**2
70
71 new_params = params - lr * grads / (np.sqrt(s) + eps)
72 state['s'] = s
73 return new_params, state
74
75def adam_update(params, grads, state, hyperparams):
76 lr = hyperparams['lr']
77 beta1, beta2 = hyperparams['beta1'], hyperparams['beta2']
78 eps = hyperparams['eps']
79 t = state.get('t', 0) + 1
80
81 m = state.get('m', np.zeros_like(params))
82 v = state.get('v', np.zeros_like(params))
83
84 # 为什么这样做: 更新一阶动量(动量项)
85 m = beta1 * m + (1 - beta1) * grads
86 # 为什么这样做: 更新二阶动量(自适应学习率项)
87 v = beta2 * v + (1 - beta2) * grads**2
88
89 # 为什么这样做: 进行偏差修正,缓解训练初期的冷启动问题
90 m_hat = m / (1 - beta1**t)
91 v_hat = v / (1 - beta2**t)
92
93 new_params = params - lr * m_hat / (np.sqrt(v_hat) + eps)
94
95 state['t'], state['m'], state['v'] = t, m, v
96 return new_params, state
97
98def adamw_update(params, grads, state, hyperparams):
99 lr = hyperparams['lr']
100 beta1, beta2 = hyperparams['beta1'], hyperparams['beta2']
101 eps = hyperparams['eps']
102 weight_decay = hyperparams['weight_decay']
103 t = state.get('t', 0) + 1
104
105 m = state.get('m', np.zeros_like(params))
106 v = state.get('v', np.zeros_like(params))
107
108 # 与Adam相同的动量和自适应学习率计算
109 m = beta1 * m + (1 - beta1) * grads
110 v = beta2 * v + (1 - beta2) * grads**2
111 m_hat = m / (1 - beta1**t)
112 v_hat = v / (1 - beta2**t)
113
114 # 为什么这样做: 这是AdamW的核心,将权重衰减从梯度更新中解耦
115 # 首先计算Adam的更新步长
116 adam_step = lr * m_hat / (np.sqrt(v_hat) + eps)
117 # 然后应用解耦的权重衰减
118 new_params = params - adam_step - lr * weight_decay * params
119
120 state['t'], state['m'], state['v'] = t, m, v
121 return new_params, state
122
123def lion_update(params, grads, state, hyperparams):
124 lr = hyperparams['lr']
125 beta1, beta2 = hyperparams['beta1'], hyperparams['beta2']
126
127 m = state.get('m', np.zeros_like(params))
128
129 # 为什么这样做: 插值生成更新方向
130 c = beta1 * m + (1 - beta1) * grads
131
132 # 为什么这样做: 使用符号函数决定更新方向,更新大小由lr决定
133 new_params = params - lr * np.sign(c)
134
135 # 为什么这样做: 更新动量项,为下一步做准备
136 m = beta2 * m + (1 - beta2) * grads
137
138 state['m'] = m
139 return new_params, state
140
141# Adafactor的实现过于复杂,不适合在此作为简单示例
142
143# --- 3. 运行优化过程并可视化 ---
144optimizers = {
145 "SGD": (sgd_update, {'lr': 0.05}),
146 "Momentum": (momentum_update, {'lr': 0.05, 'beta': 0.9}),
147 "Nesterov": (nesterov_update, {'lr': 0.05, 'beta': 0.9}),
148 "Adagrad": (adagrad_update, {'lr': 0.5, 'eps': 1e-8}), # Adagrad需要更大的初始lr
149 "RMSProp": (rmsprop_update, {'lr': 0.1, 'beta2': 0.99, 'eps': 1e-8}),
150 "Adam": (adam_update, {'lr': 0.1, 'beta1': 0.9, 'beta2': 0.99, 'eps': 1e-8}),
151 "AdamW": (adamw_update, {'lr': 0.1, 'beta1': 0.9, 'beta2': 0.99, 'eps': 1e-8, 'weight_decay': 0.01}),
152 "Lion": (lion_update, {'lr': 0.05, 'beta1': 0.9, 'beta2': 0.99})
153}
154
155# 可视化设置
156plt.figure(figsize=(12, 10))
157x_grid, y_grid = np.meshgrid(np.linspace(-10, 10, 100), np.linspace(-3, 3, 100))
158params_grid = np.stack([x_grid, y_grid])
159loss_grid = params_grid[0]**2 + 10 * params_grid[1]**2
160plt.contour(x_grid, y_grid, loss_grid, levels=np.logspace(0, 3, 10), cmap='viridis')
161
162# 运行每个优化器
163initial_params = np.array([-9.0, 2.5])
164num_steps = 50
165
166for name, (update_fn, hyperparams) in optimizers.items():
167 params = initial_params.copy()
168 state = {}
169 path = [params]
170 for i in range(num_steps):
171 grads = gradient(params)
172 params, state = update_fn(params, grads, state, hyperparams)
173 path.append(params)
174
175 path = np.array(path)
176 plt.plot(path[:, 0], path[:, 1], 'o-', label=name, markersize=3)
177
178plt.title("Optimizer Trajectories on a Quadratic Bowl")
179plt.xlabel("x")
180plt.ylabel("y")
181plt.legend()
182plt.axis('equal')
183plt.grid(True)
184plt.show()

工程实践

  • 默认选择:在大多数现代深度学习应用(如 CV, NLP)中,AdamW 是最安全、最强大的默认选项。它结合了 Adam 的快速收敛和正确的权重衰减,通常能取得很好的效果。
  • 超参数选择
    • 学习率 (η\eta):最关键的超参数。对于 Adam/AdamW,常见的初始范围是 1e-43e-3。通常需要配合学习率调度器(如 Cosine Annealing, Warmup)。Lion 论文建议使用比 Adam 更大或更小的学习率,具体取决于任务,但其对权重衰key更敏感。
    • Betas (β1,β2\beta_1, \beta_2):Adam/AdamW 的默认值 (0.9, 0.999) 非常鲁棒,通常不需要调整。Lion 的 (0.9, 0.99) 也是其推荐值。
    • Weight Decay (λ\lambda):对于 AdamW,这是一个非常重要的正则化超参数,需要仔细调整。常见范围是 0.11e-5。对于大型 Transformer0.1 是一个常见的起始点。
  • 性能/显存权衡
    • 显存占用:SGD (最低) < Momentum/NAG < Adagrad/RMSProp < Lion < Adam/AdamW < Adafactor (配置为节省模式时)。
    • 对于参数量超过百亿的巨型模型,优化器状态的显存占用(AdamW 需要 2 倍模型参数的浮点数存储)会成为瓶颈。这时 AdafactorLion 是很好的选择。ZeRO 优化等技术也可以解决这个问题。
  • 调试技巧
    • 梯度爆炸/消失:如果损失变为 NaNinf,通常是梯度爆炸。检查数据预处理、模型初始化,并使用梯度裁剪(Gradient Clipping)。
    • 训练停滞:如果损失长时间不下降,可能是学习率太低,或者陷入了糟糕的局部最小值/鞍点。尝试提高学习率或换用 Adam 等自适应优化器。对于 Adagrad,可能是学习率衰减过快。
    • 震荡严重:损失上下剧烈波动。可能是学习率太高,可以降低学习率或增加 Momentum 的 β\beta 值来平滑更新。

常见误区与边界情况

  • 误区1:Adam 中的 L2 正则化等同于权重衰减

    • 这是最经典的误区。在 Adam 中,加入 L2 正则项到 loss 里,会导致大梯度的参数权重衰减得更少,小梯度的参数权重衰减得更多。这通常不是我们想要的行为。AdamW 通过解耦权重衰减解决了这个问题,使其成为一个独立的、与梯度大小无关的正则化项。
    • 面试追问:“请解释为什么 Adam 中的 L2 正则化效果不佳?”
    • 回答要点:Adam 的更新步长是 ηm^t/(v^t+ϵ)\eta \cdot \hat{m}_t / (\sqrt{\hat{v}_t} + \epsilon)。L2 正则项的梯度是 λθt\lambda \theta_t。当这个梯度项被加到 gtg_t 中并参与 mtm_tvtv_t 的计算后,它同样会被分母 v^t\sqrt{\hat{v}_t} 缩放。对于近期梯度很大的参数,v^t\hat{v}_t 也很大,这会不成比例地减小 L2 正则项(即权重衰减)的效果。
  • 误区2:Adagrad 已经过时,完全没用

    • 虽然在大多数深度学习任务中 Adagrad 已被 RMSProp 和 Adam 取代,但它在处理极其稀疏的数据(如广告点击率预测中的大量 ID 特征)时仍然非常有效,因为其学习率单调递减的特性在这种场景下反而可能是稳定的。
  • 误区3:Nesterov 总是比标准 Momentum 好

    • 理论上 Nesterov 有更好的收敛保证,实践中也常常表现更优。但在某些情况下,尤其是在使用复杂学习率调度和正则化技巧时,两者的差距可能不明显,标准 Momentum 仍然是 SGD 的一个非常强大的变体。
  • 边界情况:Epsilon (ϵ\epsilon) 的作用

    • ϵ\epsilon 主要为了数值稳定性,防止除以零。它的取值通常不敏感。但如果设得过大(如 1e-3),它会显著地平滑自适应学习率的效果,使优化器行为趋近于 Momentum;如果设得过小,在半精度(FP16)训练中可能会因为分母下溢而导致数值问题。
  • 面试追问:“什么时候你会选择 SGD+Momentum 而不是 AdamW?”

    • 回答要点
      1. 追求极致性能:在一些研究中发现,经过精细调优的 SGD+Momentum 最终可能找到比 Adam 更“平坦”的最小值,从而获得更好的泛化性能。但这需要大量的超参数搜索和学习率调度设计。
      2. 内存极度受限:当模型非常大,且无法使用 Adafactor 或 ZeRO 等技术时,SGD+Momentum 的内存占用远小于 AdamW。
      3. 成熟的领域:在某些计算机视觉的子领域,研究人员已经为特定架构(如 ResNet)找到了非常成熟的 SGD+Momentum 训练方案,可以直接复用。
相关题目