SGD/Momentum/Nesterov/Adagrad/RMSProp/Adam/AdamW/Lion/Adafactor 的更新公式?
好的,我们来详细剖析深度学习中常用的优化器。
核心概念
优化器(Optimizer)是在深度学习中用于调整模型参数(如权重和偏置)以最小化损失函数的算法。其核心思想是根据模型参数的梯度(损失函数关于参数的导数)来计算一个更新量,并按照这个更新量迭代地更新参数。不同的优化器采用不同的策略来计算这个更新量,旨在解决梯度下降过程中的各种挑战,如学习率选择、局部最小值、鞍点、收敛速度和内存消耗等问题。
原理与推导
我们首先定义一些通用符号:
- : 模型在第 步的参数。
- : 损失函数。
- : 损失函数在 处的梯度。
- : 学习率(learning rate),一个控制更新步长的超参数。
- : 向量或矩阵的元素级乘法(element-wise product)。
1. SGD (Stochastic Gradient Descent)
原理:SGD 是最基础的优化器。它每次只使用一小批(mini-batch)数据计算梯度,并沿着梯度的反方向更新参数。这使得更新过程带有随机性,有助于跳出局部最小值,但也会带来震荡。
更新公式:
几何解释:想象在一个崎岖的山谷中寻找最低点。SGD 就像一个蒙着眼睛的人,每走一步,就感受一下脚下哪个方向坡度最陡(梯度的反方向),然后朝那个方向迈一小步。由于每次只看一小块区域(mini-batch),所以看到的坡度不完全是全局最陡的,因此路径会摇摇晃晃。
复杂度:
- 时间复杂度:,其中 是模型参数量,用于计算梯度和更新。
- 空间复杂度: 的额外空间(除了存储参数和梯度本身)。
2. Momentum
原理:Momentum 旨在加速 SGD 并抑制震荡。它引入了一个“动量”或“速度”项 ,该项是过去梯度的指数加权平均。这使得参数更新方向不仅取决于当前梯度,还取决于历史梯度,就像一个从山坡上滚下来的球,它会保持之前的速度。
更新公式:
其中 是动量系数(通常取 0.9 左右), 初始化为 0。
物理/几何解释:这就像一个有质量的球在山谷中滚动。当梯度方向一致时,动量累积,球加速滚动;当梯度方向改变时,动量会抵消部分改变,使得更新方向更加平滑,减少在狭窄谷底的来回震荡。
3. Nesterov Accelerated Gradient (NAG)
原理:Nesterov 是对 Momentum 的一种改进。Momentum 先计算当前梯度,再在累积的动量方向上前进一大步。而 Nesterov 则更有“预见性”:它先在已有的动量方向上“预走”一小步,到达一个“未来”的位置,然后计算该未来位置的梯度,再用这个梯度来修正最终的更新方向。
更新公式(PyTorch 等框架中的常见实现形式):
推导与动机:原始公式是 和 。可以理解为,更新量 包含了对动量 的一个“修正”。如果动量把我们带到了一个错误的方向,在那个“预走”位置计算的梯度会把我们拉回来。上述实现形式是原始公式的一个等价变形,更易于实现。
4. Adagrad (Adaptive Gradient Algorithm)
原理:Adagrad 引入了自适应学习率,为每个参数独立地调整学习率。它根据参数至今为止所有梯度的平方和来缩放学习率:对于梯度一直很大的参数,其学习率会变小;对于梯度一直很小的参数,其学习率会相对较大。
更新公式: 定义一个累积平方梯度 ,初始化为 0。
其中 是一个很小的常数(如 ),用于防止分母为零。
信息论解释:对于稀疏特征(如 one-hot 编码的词向量),大部分时候梯度为 0,偶尔非零。Adagrad 能在这些特征罕见出现并产生梯度时,给予一个较大的更新步长,从而有效学习。但其缺点是 会不断累积,导致学习率最终会变得过小,提前停止学习。
5. RMSProp (Root Mean Square Propagation)
原理:RMSProp 是对 Adagrad 的改进,旨在解决其学习率过早衰减的问题。它不再累积所有的历史平方梯度,而是使用指数加权移动平均来计算平方梯度,从而只关注最近一段时间的梯度大小。
更新公式: 定义平方梯度的移动平均 ,初始化为 0。
其中 是衰减率(通常取 0.999)。
动机:通过指数衰减,RMSProp “忘记”了遥远过去的梯度,使得分母 不会无限增长。这让它在非凸优化问题中表现更稳定。
6. Adam (Adaptive Moment Estimation)
原理:Adam 是目前最流行的优化器之一,它结合了 Momentum 和 RMSProp 的优点。它既使用动量(一阶矩估计)来加速梯度下降,又使用自适应学习率(二阶矩估计)来为每个参数调整步长。
更新公式:
- 更新一阶矩估计(动量):
- 更新二阶矩估计(平方梯度):
- 偏差修正(Bias Correction):由于 和 初始化为 0,在训练初期 和 会偏向于 0。Adam 通过以下方式进行修正:
- 参数更新:
常用超参数默认值为:。
7. AdamW (Adam with Decoupled Weight Decay)
原理:标准的 Adam 在实现 L2 正则化时存在一个问题。在 SGD 中,L2 正则化(在损失函数中加入 )等价于在参数更新时减去一项 (权重衰减)。但在 Adam 中,由于每个参数的学习率是自适应的(被 缩放),L2 正则化的效果会受到影响,导致大梯度的权重衰减得更少。AdamW 将权重衰减从梯度更新中解耦出来,直接在参数更新的最后一步执行。
更新公式: AdamW 的前三步与 Adam 完全相同(计算 和进行偏差修正)。区别在于最后一步:
注意,这里的权重衰减项 是与学习率 相乘后直接从 中减去的,而不是先加到梯度 里。这使得权重衰减的效果更加稳定和可预测。
8. Lion (EvoLved Sign Momentum)
原理:Lion 是 Google Brain 通过符号演化发现的一种新优化器,其结构比 Adam 更简单。它只跟踪动量(一阶矩),并使用动量的符号(sign)来决定更新方向,所有参数的更新大小是统一的(由学习率 控制),而不是像 Adam 那样逐参数自适应。
更新公式:
- 插值更新:
- 参数更新:
- 动量更新:
这里的 和 作用不同: 用于插值生成更新方向, 用于动量衰减。论文建议 。由于没有二阶矩和开方运算,Lion 在计算上比 Adam 更高效。
9. Adafactor
原理:Adafactor 是为解决大规模模型训练中 Adam 优化器内存占用过高问题而设计的。Adam 需要为每个参数存储一阶和二阶矩,对于拥有数十亿参数的模型,这会消耗大量显存。Adafactor 通过两个主要技巧来节省内存:
- 因子分解(Factorization):它不存储完整的二阶矩矩阵 ,而是将其分解为行和列的统计量,大大减少了存储量。对于一个形状为
(m, n)的参数矩阵,Adam 需要O(mn)的空间存 ,而 Adafactor 只需要O(m+n)。 - 可选地省略一阶矩:论文发现,如果 足够小,可以不存储一阶矩 ,而是直接使用梯度的平滑版本,进一步节省内存。
更新公式(概念性): 由于其公式较为复杂,这里给出核心思想而非完整公式。
- 二阶矩: 被分解为行向量 和列向量 的移动平均。
- 更新规则:更新步长由 决定。
- 学习率:Adafactor 使用一个相对学习率方案,会根据参数的尺度自动调整,因此对外部学习率 的设置不那么敏感。
代码实现
下面使用 NumPy 从零开始实现这些优化器,并用于一个简单的二次函数优化问题,以直观展示它们的工作方式。
1import numpy as np2import matplotlib.pyplot as plt34# --- 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**2910# 目标函数的梯度11def gradient(params):12 x, y = params[0], params[1]13 return np.array([2 * x, 20 * y])1415# --- 2. 实现各个优化器更新逻辑 ---1617def sgd_update(params, grads, state, hyperparams):18 lr = hyperparams['lr']19 return params - lr * grads, state2021def momentum_update(params, grads, state, hyperparams):22 lr = hyperparams['lr']23 beta = hyperparams['beta']24 v = state.get('v', np.zeros_like(params))2526 # 为什么这样做: 更新动量项v,v是历史梯度的指数加权平均27 v = beta * v + grads2829 new_params = params - lr * v30 state['v'] = v31 return new_params, state3233def nesterov_update(params, grads, state, hyperparams):34 lr = hyperparams['lr']35 beta = hyperparams['beta']36 v = state.get('v', np.zeros_like(params))3738 # 为什么这样做: 这是Nesterov的常见实现形式。39 # v_prev是旧动量,v_new是当前梯度加旧动量。40 # 更新方向是当前梯度和新动量的组合。41 v_prev = v42 v = beta * v + grads4344 # 核心区别:更新时同时使用了当前梯度和更新后的动量45 new_params = params - lr * (grads + beta * v)46 state['v'] = v47 return new_params, state4849def adagrad_update(params, grads, state, hyperparams):50 lr = hyperparams['lr']51 eps = hyperparams['eps']52 s = state.get('s', np.zeros_like(params))5354 # 为什么这样做: 累积历史梯度的平方55 s += grads**25657 # 为什么这样做: 根据累积的平方梯度调整每个参数的学习率58 new_params = params - lr * grads / (np.sqrt(s) + eps)59 state['s'] = s60 return new_params, state6162def 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))6768 # 为什么这样做: 使用指数移动平均来计算平方梯度,防止学习率过早衰减69 s = beta2 * s + (1 - beta2) * grads**27071 new_params = params - lr * grads / (np.sqrt(s) + eps)72 state['s'] = s73 return new_params, state7475def 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) + 18081 m = state.get('m', np.zeros_like(params))82 v = state.get('v', np.zeros_like(params))8384 # 为什么这样做: 更新一阶动量(动量项)85 m = beta1 * m + (1 - beta1) * grads86 # 为什么这样做: 更新二阶动量(自适应学习率项)87 v = beta2 * v + (1 - beta2) * grads**28889 # 为什么这样做: 进行偏差修正,缓解训练初期的冷启动问题90 m_hat = m / (1 - beta1**t)91 v_hat = v / (1 - beta2**t)9293 new_params = params - lr * m_hat / (np.sqrt(v_hat) + eps)9495 state['t'], state['m'], state['v'] = t, m, v96 return new_params, state9798def 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) + 1104105 m = state.get('m', np.zeros_like(params))106 v = state.get('v', np.zeros_like(params))107108 # 与Adam相同的动量和自适应学习率计算109 m = beta1 * m + (1 - beta1) * grads110 v = beta2 * v + (1 - beta2) * grads**2111 m_hat = m / (1 - beta1**t)112 v_hat = v / (1 - beta2**t)113114 # 为什么这样做: 这是AdamW的核心,将权重衰减从梯度更新中解耦115 # 首先计算Adam的更新步长116 adam_step = lr * m_hat / (np.sqrt(v_hat) + eps)117 # 然后应用解耦的权重衰减118 new_params = params - adam_step - lr * weight_decay * params119120 state['t'], state['m'], state['v'] = t, m, v121 return new_params, state122123def lion_update(params, grads, state, hyperparams):124 lr = hyperparams['lr']125 beta1, beta2 = hyperparams['beta1'], hyperparams['beta2']126127 m = state.get('m', np.zeros_like(params))128129 # 为什么这样做: 插值生成更新方向130 c = beta1 * m + (1 - beta1) * grads131132 # 为什么这样做: 使用符号函数决定更新方向,更新大小由lr决定133 new_params = params - lr * np.sign(c)134135 # 为什么这样做: 更新动量项,为下一步做准备136 m = beta2 * m + (1 - beta2) * grads137138 state['m'] = m139 return new_params, state140141# Adafactor的实现过于复杂,不适合在此作为简单示例142143# --- 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需要更大的初始lr149 "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}154155# 可视化设置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]**2160plt.contour(x_grid, y_grid, loss_grid, levels=np.logspace(0, 3, 10), cmap='viridis')161162# 运行每个优化器163initial_params = np.array([-9.0, 2.5])164num_steps = 50165166for 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)174175 path = np.array(path)176 plt.plot(path[:, 0], path[:, 1], 'o-', label=name, markersize=3)177178plt.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 的快速收敛和正确的权重衰减,通常能取得很好的效果。
- 超参数选择:
- 学习率 ():最关键的超参数。对于 Adam/AdamW,常见的初始范围是
1e-4到3e-3。通常需要配合学习率调度器(如 Cosine Annealing, Warmup)。Lion 论文建议使用比 Adam 更大或更小的学习率,具体取决于任务,但其对权重衰key更敏感。 - Betas ():Adam/AdamW 的默认值
(0.9, 0.999)非常鲁棒,通常不需要调整。Lion 的(0.9, 0.99)也是其推荐值。 - Weight Decay ():对于 AdamW,这是一个非常重要的正则化超参数,需要仔细调整。常见范围是
0.1到1e-5。对于大型Transformer,0.1是一个常见的起始点。
- 学习率 ():最关键的超参数。对于 Adam/AdamW,常见的初始范围是
- 性能/显存权衡:
- 显存占用:SGD (最低) < Momentum/NAG < Adagrad/RMSProp < Lion < Adam/AdamW < Adafactor (配置为节省模式时)。
- 对于参数量超过百亿的巨型模型,优化器状态的显存占用(AdamW 需要 2 倍模型参数的浮点数存储)会成为瓶颈。这时 Adafactor 或 Lion 是很好的选择。ZeRO 优化等技术也可以解决这个问题。
- 调试技巧:
- 梯度爆炸/消失:如果损失变为
NaN或inf,通常是梯度爆炸。检查数据预处理、模型初始化,并使用梯度裁剪(Gradient Clipping)。 - 训练停滞:如果损失长时间不下降,可能是学习率太低,或者陷入了糟糕的局部最小值/鞍点。尝试提高学习率或换用 Adam 等自适应优化器。对于 Adagrad,可能是学习率衰减过快。
- 震荡严重:损失上下剧烈波动。可能是学习率太高,可以降低学习率或增加 Momentum 的 值来平滑更新。
- 梯度爆炸/消失:如果损失变为
常见误区与边界情况
-
误区1:Adam 中的 L2 正则化等同于权重衰减
- 这是最经典的误区。在 Adam 中,加入 L2 正则项到 loss 里,会导致大梯度的参数权重衰减得更少,小梯度的参数权重衰减得更多。这通常不是我们想要的行为。AdamW 通过解耦权重衰减解决了这个问题,使其成为一个独立的、与梯度大小无关的正则化项。
- 面试追问:“请解释为什么 Adam 中的 L2 正则化效果不佳?”
- 回答要点:Adam 的更新步长是 。L2 正则项的梯度是 。当这个梯度项被加到 中并参与 和 的计算后,它同样会被分母 缩放。对于近期梯度很大的参数, 也很大,这会不成比例地减小 L2 正则项(即权重衰减)的效果。
-
误区2:Adagrad 已经过时,完全没用
- 虽然在大多数深度学习任务中 Adagrad 已被 RMSProp 和 Adam 取代,但它在处理极其稀疏的数据(如广告点击率预测中的大量 ID 特征)时仍然非常有效,因为其学习率单调递减的特性在这种场景下反而可能是稳定的。
-
误区3:Nesterov 总是比标准 Momentum 好
- 理论上 Nesterov 有更好的收敛保证,实践中也常常表现更优。但在某些情况下,尤其是在使用复杂学习率调度和正则化技巧时,两者的差距可能不明显,标准 Momentum 仍然是 SGD 的一个非常强大的变体。
-
边界情况:Epsilon () 的作用
- 主要为了数值稳定性,防止除以零。它的取值通常不敏感。但如果设得过大(如
1e-3),它会显著地平滑自适应学习率的效果,使优化器行为趋近于 Momentum;如果设得过小,在半精度(FP16)训练中可能会因为分母下溢而导致数值问题。
- 主要为了数值稳定性,防止除以零。它的取值通常不敏感。但如果设得过大(如
-
面试追问:“什么时候你会选择 SGD+Momentum 而不是 AdamW?”
- 回答要点:
- 追求极致性能:在一些研究中发现,经过精细调优的 SGD+Momentum 最终可能找到比 Adam 更“平坦”的最小值,从而获得更好的泛化性能。但这需要大量的超参数搜索和学习率调度设计。
- 内存极度受限:当模型非常大,且无法使用 Adafactor 或 ZeRO 等技术时,SGD+Momentum 的内存占用远小于 AdamW。
- 成熟的领域:在某些计算机视觉的子领域,研究人员已经为特定架构(如 ResNet)找到了非常成熟的 SGD+Momentum 训练方案,可以直接复用。
- 回答要点: