同一个模型、同一份数据,换个优化器结果可能差很多。这件事光看公式不容易理解,但把它们在损失曲面上走过的路径画出来就很直观——梯度下降在陡峭的方向来回震荡、在平缓的方向几乎不动;动量把震荡抵消掉;Adam 给每个参数各自的步长。
下面用一个二维二次函数做例子,因为它的等高线是椭圆,”某个方向陡、另一个方向平”这个导致麻烦的性质可以直接看到。起点和目标都固定,三种优化器的差别就完全来自它们各自怎么处理曲率、历史梯度和尺度。第一步会手算一遍,之后的轨迹用 NumPy 跑出来。
一、损失曲面的几何与病态曲率
在深度学习中,损失曲面极少是各向同性的(即完美的球形)。相反,它通常是高度病态的,充满了峡谷和狭长的山谷。让我们来看一个典型的用来演示这个问题的函数:
L(x, y) = 1/2 * (8x^2 + y^2) + 0.8xy
grad L = [8x + 0.8y, y + 0.8x]
这个函数的 Hessian 矩阵具有差异极大的特征值。该曲面在 x 方向非常陡峭(高曲率),而在 y 方向则较为平缓(低曲率)。这就造成了所谓的“病态曲率(Pathological Curvature)”问题。普通的梯度下降算法会在陡峭的峡谷两侧来回震荡,而在通向极小值的平缓谷底方向上进展极其缓慢。

二、手算第一步梯度下降
从 (2.2, -2.0) 出发,梯度为:
grad = [8*2.2 + 0.8*(-2.0), -2.0 + 0.8*2.2]
= [16.0, -0.24]
如果学习率是 0.08:
x_new = 2.2 - 0.08 * 16.0 = 0.92
y_new = -2.0 - 0.08 * -0.24 = -1.9808
与 y 方向相比,参数在 x 方向上迈出了一大步,这纯粹是因为 x 方向的梯度绝对值大得多。实验输出也印证了这一点:梯度下降第 1 步的坐标是 x=0.920000、y=-1.980800,loss 从 17.840000 降到了 3.889516。然而,如果学习率稍微再大一点,x 方向的步长就会跨过山谷的另一端,导致发散。
三、Momentum 和 Adam 在改什么
普通梯度下降是没有记忆的;它只看当前的梯度。这正是导致上述震荡的原因。
动量(Momentum) 引入了速度变量,把过去的梯度方向累积起来。想象一个沉重的球滚下山坡。在陡峭的 x 方向上,交替正负的梯度会相互抵消;而在平缓的 y 方向上,方向一致的梯度不断累加,从而加速优化器冲向极小值。
v_t = beta * v_{t-1} + grad_t
theta_t = theta_{t-1} - lr * v_t
Adam(Adaptive Moment Estimation) 则更进一步,它同时维护梯度的一阶矩(均值)和二阶矩(未中心化的方差)。它能够为每一个参数单独动态缩放学习率。通过除以累积梯度平方的平方根,Adam 归一化了步长。它本质上是强迫优化器在平缓的方向迈大步,在陡峭的方向迈小步。
m_t = beta1 * m_{t-1} + (1-beta1) * grad_t
v_t = beta2 * v_{t-1} + (1-beta2) * grad_t^2
theta_t = theta_{t-1} - lr * m_hat / (sqrt(v_hat) + eps)
四、优化器决策流程图解
我们该如何决定使用哪种优化器?下面的图表直观地展示了这些优化算法的架构流程。
graph TD
A[计算当前梯度] --> B{是否需要历史信息?}
B -->|否| C[普通 SGD]
B -->|是| D{是否需要自适应缩放?}
D -->|否| E[带 Momentum 的 SGD]
D -->|是| F[计算一阶和二阶矩]
F --> G[偏差修正]
G --> H[Adam / AdamW]
C --> I[应用参数更新]
E --> I
H --> I
五、核心代码实践与实现
为了真正掌握这些算法,我们应该从零开始构建它们。以下是使用 NumPy 在我们的二次曲面上对比 SGD、Momentum 和 Adam 的核心实现。
import numpy as np
def grad(theta):
x, y = theta
return np.array([8.0 * x + 0.8 * y, y + 0.8 * x])
def run_optimizer(optimizer_name, theta_init, lr=0.08, steps=50):
theta = np.array(theta_init)
# 初始化优化器状态
v = np.zeros_like(theta)
m = np.zeros_like(theta)
beta1, beta2, eps = 0.9, 0.999, 1e-8
trajectory = [theta.copy()]
for t in range(1, steps + 1):
g = grad(theta)
if optimizer_name == 'SGD':
theta -= lr * g
elif optimizer_name == 'Momentum':
v = 0.9 * v + lr * g
theta -= v
elif optimizer_name == 'Adam':
m = beta1 * m + (1 - beta1) * g
v = beta2 * v + (1 - beta2) * (g ** 2)
# 偏差修正
m_hat = m / (1 - beta1 ** t)
v_hat = v / (1 - beta2 ** t)
theta -= lr * m_hat / (np.sqrt(v_hat) + eps)
trajectory.append(theta.copy())
return np.array(trajectory)
# 从起点运行测试
traj_sgd = run_optimizer('SGD', [2.2, -2.0])
traj_adam = run_optimizer('Adam', [2.2, -2.0], lr=0.5)
print(f"Adam 最终位置: {traj_adam[-1]}")
六、动画看什么
重点看两点:陡峭方向上是否震荡,平缓方向上是否前进太慢。注意 Momentum 是如何像钟摆一样大幅度摇摆才最终稳定下来的,而 Adam 则是切出了一条更加直接、可控的路径,完美适应了不断变化的曲率。
七、个人经验与工程师视角
在工程实践中,优化器优雅的数学原理往往会撞上残酷的硬件和系统现实。以下是我在训练大模型时积累的一些经验:
- 显存(Memory)瓶颈: Adam 效果极好,但极其消耗显存。普通的 SGD 只需要为参数和梯度分配显存。而 Adam 还需要存储梯度的滑动平均(一阶矩)和梯度平方的滑动平均(二阶矩)。这使得优化器状态的显存占用直接翻了三倍。在显存受限的 GPU 上训练大型 LLM 时,这往往成为最大瓶颈,迫使工程师转向 Adafactor 或 8-bit Adam 等显存友好型变体。
- 权重衰减(Weight Decay)的陷阱: Adam 和 AdamW 之间有着臭名昭著的区别。标准的 Adam 在自适应缩放之前对梯度应用 L2 正则化。这无意中削弱了对梯度方差较大的权重的惩罚力度,违背了权重衰减的初衷。对于 Transformer 架构,一定要使用 AdamW,它将权重衰减与梯度更新步骤完全解耦。
- Adam 必须配合 Warmup 使用: 因为 Adam 使用了滑动平均,方差项(二阶矩)初始为零,在最初的几步中可能会极其不准确,导致出现极其巨大且破坏性的更新。引入学习率预热(Warmup,即让学习率从接近零开始,在几千步内逐渐攀升)可以防止模型在训练初期直接崩溃。
八、实践建议
- 先画训练 loss 和验证 loss,再判断是否需要换优化器。很多训练问题其实是优化路径不稳定,而不是模型结构错了。
- 学习率通常比优化器的名称更关键。一个经过精心调参的带 Momentum 的 SGD,其泛化能力通常能媲美甚至超越 Adam,尤其是在计算机视觉领域(如 ResNet)。
- Adam 并非万能药,过大的学习率仍然会导致它发散。
- 遇到 loss 曲线抖动时,先尝试降低学习率或加 warmup。
九、优化轨迹审计表
优化器实验不能只报告最终 loss。为了判断路径是否可信,应该同时记录坐标、梯度、状态变量和失败模式。下面的审计表可以帮助读者把“看起来收敛了”拆成可复核的数值证据。
| 审计项 | 应记录的值 | 能回答的问题 |
|---|---|---|
| 初始条件 | 起点、学习率、步数、优化器超参数。 | 不同曲线是否在同一条件下比较? |
| 逐步轨迹 | x、y、loss、梯度向量和步长。 |
路径是在下降,还是在峡谷两侧震荡? |
| 状态变量 | Momentum 的 v,Adam 的 m、v 和 bias correction。 |
历史梯度和自适应缩放是否真的改变了路径? |
| 失败模式 | 发散步、震荡区间、过大 warmup 前更新和最终距离。 | 失败来自学习率、曲率、初始化还是优化器状态? |
下一篇进入卷积层,看看二维局部连接如何变成矩阵计算。