【AI 算法精讲 01】梯度下降:从 SGD 到 Momentum 到 Nesterov 的完整推导与实现

文章目录

    • 一、为什么需要梯度下降
    • 二、算法原理
      • [2.1 损失函数与梯度](#2.1 损失函数与梯度)
      • [2.2 SGD:随机梯度下降](#2.2 SGD:随机梯度下降)
      • [2.3 Momentum:动量加速](#2.3 Momentum:动量加速)
      • [2.4 Nesterov Accelerated Gradient (NAG)](#2.4 Nesterov Accelerated Gradient (NAG))
      • [2.5 学习率调度策略](#2.5 学习率调度策略)
      • [2.6 收敛性分析](#2.6 收敛性分析)
      • [2.7 鞍点逃逸](#2.7 鞍点逃逸)
    • [三、Python 实现](#三、Python 实现)
      • [3.1 基础优化器实现](#3.1 基础优化器实现)
      • [3.2 完整训练示例:在二次函数上对比三种优化器](#3.2 完整训练示例:在二次函数上对比三种优化器)
      • [3.3 学习率调度器实现](#3.3 学习率调度器实现)
    • 四、参数调优与变体对比
      • [4.1 核心参数说明](#4.1 核心参数说明)
      • [4.2 SGD / Momentum / NAG 对比](#4.2 SGD / Momentum / NAG 对比)
      • [4.3 学习率调度策略对比](#4.3 学习率调度策略对比)
    • 五、在推荐系统中的实际应用
    • 六、常见陷阱
    • 七、总结

一、为什么需要梯度下降

在机器学习和深度学习中,模型训练的本质是求解一个优化问题:找到一组参数 θ \theta θ,使得损失函数 L ( θ ) L(\theta) L(θ) 最小。当参数维度达到百万甚至十亿级别时,传统的解析解(如最小二乘法)完全不可行------计算 Hessian 矩阵的逆需要 O ( n 3 ) O(n^3) O(n3) 时间复杂度。

梯度下降是解决这个问题的核心方法:它只需要一阶导数信息,每次迭代计算量为 O ( n ) O(n) O(n),通过沿梯度反方向逐步更新参数来逼近最优解。但它远非完美------学习率太大导致震荡发散,太小则收敛极慢;在鞍点附近会停滞;在非凸优化中可能陷入局部最优。

从最基础的 SGD 到 Momentum 再到 Nesterov Accelerated Gradient,每一次改进都针对了梯度下降的一个具体缺陷。理解这些算法的数学原理和工程权衡,是所有深度学习实践者的必修课。

二、算法原理

2.1 损失函数与梯度

给定训练集 D = { ( x 1 , y 1 ) , ( x 2 , y 2 ) , ... , ( x N , y N ) } D = \{(x_1, y_1), (x_2, y_2), \ldots, (x_N, y_N)\} D={(x1,y1),(x2,y2),...,(xN,yN)},模型的损失函数定义为:

L ( θ ) = 1 N ∑ i = 1 N ℓ ( f θ ( x i ) , y i ) L(\theta) = \frac{1}{N} \sum_{i=1}^{N} \ell(f_\theta(x_i), y_i) L(θ)=N1i=1∑Nℓ(fθ(xi),yi)

其中 θ \theta θ 是模型参数, f θ ( x i ) f_\theta(x_i) fθ(xi) 是模型预测值, ℓ \ell ℓ 是单样本损失(如均方误差或交叉熵)。

梯度是损失函数对参数的偏导数向量:

∇ L ( θ ) = ∂ L ∂ θ 1 , ∂ L ∂ θ 2 , ... , ∂ L ∂ θ d T \nabla L(\theta) = \left\\frac{\\partial L}{\\partial \\theta_1}, \\frac{\\partial L}{\\partial \\theta_2}, \\ldots, \\frac{\\partial L}{\\partial \\theta_d}\\right^T ∇L(θ)=∂θ1∂L,∂θ2∂L,...,∂θd∂LT

梯度的几何意义是函数值上升最快的方向,因此负梯度方向是函数值下降最快的方向。

2.2 SGD:随机梯度下降

标准梯度下降(BGD)每次使用全部训练数据计算梯度,计算开销大。SGD 每次随机抽取一个小批量(mini-batch)计算梯度近似:

g t = ∇ L B t ( θ t ) = 1 ∣ B t ∣ ∑ i ∈ B t ∇ ℓ ( f θ t ( x i ) , y i ) g_t = \nabla L_{B_t}(\theta_t) = \frac{1}{|B_t|} \sum_{i \in B_t} \nabla \ell(f_{\theta_t}(x_i), y_i) gt=∇LBt(θt)=∣Bt∣1i∈Bt∑∇ℓ(fθt(xi),yi)

其中 B t B_t Bt 是第 t t t 步的 mini-batch, ∣ B t ∣ |B_t| ∣Bt∣ 是批量大小。

参数更新规则:

θ t + 1 = θ t − η ⋅ g t \theta_{t+1} = \theta_t - \eta \cdot g_t θt+1=θt−η⋅gt

其中 η \eta η 是学习率。

SGD 的关键性质 : E g t = ∇ L ( θ t ) Eg_t = \nabla L(\theta_t) Egt=∇L(θt),即小批量梯度是全梯度的无偏估计; V a r ( g t ) ∝ 1 ∣ B t ∣ Var(g_t) \propto \frac{1}{|B_t|} Var(gt)∝∣Bt∣1,批量越大方差越小。

为什么 SGD 能工作? 虽然每一步的梯度方向可能不准确,但期望方向是正确的。噪声反而有助于逃离局部最优和鞍点。

2.3 Momentum:动量加速

SGD 在两个场景下收敛慢:

  1. 高曲率方向:损失函数在某些方向变化剧烈,SGD 在这些方向反复震荡
  2. 小梯度方向:在平坦区域,梯度很小导致 step size 极小

Momentum 的思路:累积历史梯度方向,平滑震荡方向、加速一致方向。

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

其中 γ ∈ [ 0 , 1 ) \gamma \in [0, 1) γ∈[0,1) 是动量系数,通常设为 0.9。

展开递推式理解 Momentum

v t = η ∑ i = 0 t γ t − i g i = η ⋅ g t + γ ⋅ η ⋅ g t − 1 + γ 2 ⋅ η ⋅ g t − 2 + ⋯ v_t = \eta \sum_{i=0}^{t} \gamma^{t-i} g_i = \eta \cdot g_t + \gamma \cdot \eta \cdot g_{t-1} + \gamma^2 \cdot \eta \cdot g_{t-2} + \cdots vt=ηi=0∑tγt−igi=η⋅gt+γ⋅η⋅gt−1+γ2⋅η⋅gt−2+⋯

可以看到, v t v_t vt 是历史梯度的指数加权移动平均。在梯度方向一致的方向上,历史梯度累加形成"惯性",有效 step size 最大可达 η 1 − γ \frac{\eta}{1-\gamma} 1−γη;在梯度方向反复震荡的方向上,正负梯度互相抵消,有效 step size 趋近于 0。

当 γ = 0.9 \gamma = 0.9 γ=0.9 时,有效学习率扩大为 η 1 − 0.9 = 10 η \frac{\eta}{1-0.9} = 10\eta 1−0.9η=10η,这就是 Momentum 加速的本质。

2.4 Nesterov Accelerated Gradient (NAG)

Momentum 有一个缺陷:它在"看到"当前梯度之前就已经"冲"了一段距离。如果当前位置已经过了最优点,Momentum 会因为惯性继续冲过头。

NAG 的改进:先按动量方向"前瞻"一步,在展望的位置计算梯度。

v t = γ ⋅ v t − 1 + η ⋅ ∇ L ( θ t − γ ⋅ v t − 1 ) v_t = \gamma \cdot v_{t-1} + \eta \cdot \nabla L(\theta_t - \gamma \cdot v_{t-1}) vt=γ⋅vt−1+η⋅∇L(θt−γ⋅vt−1)

θ t + 1 = θ t − v t \theta_{t+1} = \theta_t - v_t θt+1=θt−vt

对比 Momentum:

  • Momentum:先计算当前梯度,再加动量 → γ v t − 1 + η g ( θ t ) \gamma v_{t-1} + \eta g(\theta_t) γvt−1+ηg(θt)
  • NAG:先加动量到"展望位置",再计算梯度 → γ v t − 1 + η g ( θ t − γ v t − 1 ) \gamma v_{t-1} + \eta g(\theta_t - \gamma v_{t-1}) γvt−1+ηg(θt−γvt−1)

NAG 在凸优化中的理论优势 :在凸函数上,NAG 的收敛率为 O ( 1 / t 2 ) O(1/t^2) O(1/t2),而 Momentum 为 O ( 1 / t ) O(1/t) O(1/t)。但在深度学习的非凸场景中,NAG 和 Momentum 的差异通常不大,NAG 稍快一些。

2.5 学习率调度策略

固定学习率无法适应训练全程的需求。常见调度策略:

策略 公式 特点
Step Decay η t = η 0 ⋅ γ ⌊ t / s ⌋ \eta_t = \eta_0 \cdot \gamma^{\lfloor t/s \rfloor} ηt=η0⋅γ⌊t/s⌋ 每 s s s 步衰减为 γ \gamma γ 倍,简单实用
Cosine Annealing η t = η m i n + 1 2 ( η m a x − η m i n ) ( 1 + cos ⁡ ( π t T ) ) \eta_t = \eta_{min} + \frac{1}{2}(\eta_{max} - \eta_{min})(1 + \cos(\frac{\pi t}{T})) ηt=ηmin+21(ηmax−ηmin)(1+cos(Tπt)) 平滑衰减,末期收敛更稳
OneCycle 先从 η m i n \eta_{min} ηmin 线性升到 η m a x \eta_{max} ηmax,再余弦退火到 η m i n \eta_{min} ηmin 前期快速探索,后期精细收敛
Warmup 前 T w a r m u p T_{warmup} Twarmup 步线性增长,之后按其他策略 避免训练初期梯度方差大导致发散

Cosine Annealing 推导

η t = η m i n + 1 2 ( η m a x − η m i n ) ( 1 + cos ⁡ ( π t T ) ) \eta_t = \eta_{min} + \frac{1}{2}(\eta_{max} - \eta_{min})\left(1 + \cos\left(\frac{\pi t}{T}\right)\right) ηt=ηmin+21(ηmax−ηmin)(1+cos(Tπt))

当 t = 0 t=0 t=0 时, cos ⁡ ( 0 ) = 1 \cos(0) = 1 cos(0)=1, η 0 = η m a x \eta_0 = \eta_{max} η0=ηmax;当 t = T t=T t=T 时, cos ⁡ ( π ) = − 1 \cos(\pi) = -1 cos(π)=−1, η T = η m i n \eta_T = \eta_{min} ηT=ηmin。整个过程从最大学习率平滑下降到最小学习率。

2.6 收敛性分析

对于凸函数 L ( θ ) L(\theta) L(θ) 满足 β \beta β-Lipschitz 连续梯度(即 ∥ ∇ L ( θ ) − ∇ L ( θ ′ ) ∥ ≤ β ∥ θ − θ ′ ∥ \|\nabla L(\theta) - \nabla L(\theta')\| \leq \beta \|\theta - \theta'\| ∥∇L(θ)−∇L(θ′)∥≤β∥θ−θ′∥):

BGD 收敛率 :当 η ≤ 1 β \eta \leq \frac{1}{\beta} η≤β1 时,

L ( θ T ) − L ( θ ∗ ) ≤ ∥ θ 0 − θ ∗ ∥ 2 2 η T L(\theta_T) - L(\theta^*) \leq \frac{\|\theta_0 - \theta^*\|^2}{2\eta T} L(θT)−L(θ∗)≤2ηT∥θ0−θ∗∥2

收敛率为 O ( 1 / T ) O(1/T) O(1/T)。

SGD 收敛率

E L ( θ T ) − L ( θ ∗ ) ≤ ∥ θ 0 − θ ∗ ∥ 2 2 η T + η σ 2 2 EL(\\theta_T) - L(\theta^*) \leq \frac{\|\theta_0 - \theta^*\|^2}{2\eta T} + \frac{\eta \sigma^2}{2} EL(θT)−L(θ∗)≤2ηT∥θ0−θ∗∥2+2ησ2

其中 σ 2 \sigma^2 σ2 是梯度估计方差。SGD 的收敛率同样是 O ( 1 / T ) O(1/T) O(1/T),但多了一个与方差相关的噪声项。这说明:学习率需要随时间衰减以消除噪声项;增大 batch size 可以减小 σ 2 \sigma^2 σ2,但收益递减。

2.7 鞍点逃逸

在非凸优化中,鞍点比局部最优更常见。鞍点处 ∇ L ( θ ) = 0 \nabla L(\theta) = 0 ∇L(θ)=0,但 Hessian 矩阵既有正特征值也有负特征值。

SGD 如何逃逸鞍点? 靠梯度噪声。在鞍点附近,真实梯度为零,但小批量梯度的随机噪声会把参数推向负曲率方向(下降方向)。逃逸时间取决于 Hessian 负特征值的大小和梯度方差。

Momentum 如何帮助逃逸? 动量累积了历史梯度方向,如果历史梯度在某个方向有分量,即使当前梯度为零,动量也会推动参数继续移动,帮助穿越鞍点区域。

三、Python 实现

3.1 基础优化器实现

python 复制代码
import numpy as np
from typing import Callable, Optional
from dataclasses import dataclass


class SGD:
    """随机梯度下降优化器"""

    def __init__(self, lr: float = 0.01):
        self.lr = lr

    def step(self, params: np.ndarray, grads: np.ndarray) -> np.ndarray:
        """单步参数更新:θ = θ - η·g"""
        return params - self.lr * grads


class Momentum:
    """带动量的随机梯度下降"""

    def __init__(self, lr: float = 0.01, momentum: float = 0.9):
        self.lr = lr
        self.momentum = momentum
        self.v: Optional[np.ndarray] = None

    def step(self, params: np.ndarray, grads: np.ndarray) -> np.ndarray:
        if self.v is None:
            self.v = np.zeros_like(params)
        # 动量累积:v = γ·v + η·g
        self.v = self.momentum * self.v + self.lr * grads
        return params - self.v


class NAG:
    """Nesterov Accelerated Gradient"""

    def __init__(self, lr: float = 0.01, momentum: float = 0.9):
        self.lr = lr
        self.momentum = momentum
        self.v: Optional[np.ndarray] = None

    def step(self, params: np.ndarray,
             grads_fn: Callable[[np.ndarray], np.ndarray]) -> np.ndarray:
        if self.v is None:
            self.v = np.zeros_like(params)
        # 在展望位置计算梯度
        look_ahead = params - self.momentum * self.v
        grads = grads_fn(look_ahead)
        self.v = self.momentum * self.v + self.lr * grads
        return params - self.v

3.2 完整训练示例:在二次函数上对比三种优化器

python 复制代码
@dataclass
class OptimResult:
    """优化过程记录"""
    name: str
    trajectory: list  # 参数轨迹
    losses: list      # 损失轨迹


def optimize(optimizer_name: str, fn: Callable, grad_fn: Callable,
             x0: np.ndarray, n_iter: int = 200,
             lr: float = 0.01) -> OptimResult:
    """通用优化函数,支持 SGD / Momentum / NAG"""
    x = x0.copy()
    trajectory = [x.copy()]
    losses = [fn(x)]

    if optimizer_name == "sgd":
        opt = SGD(lr=lr)
        for _ in range(n_iter):
            g = grad_fn(x)
            x = opt.step(x, g)
            trajectory.append(x.copy())
            losses.append(fn(x))

    elif optimizer_name == "momentum":
        opt = Momentum(lr=lr, momentum=0.9)
        for _ in range(n_iter):
            g = grad_fn(x)
            x = opt.step(x, g)
            trajectory.append(x.copy())
            losses.append(fn(x))

    elif optimizer_name == "nag":
        opt = NAG(lr=lr, momentum=0.9)
        for _ in range(n_iter):
            x = opt.step(x, grad_fn)
            trajectory.append(x.copy())
            losses.append(fn(x))

    return OptimResult(optimizer_name, trajectory, losses)


if __name__ == "__main__":
    # 椭圆型损失函数(条件数大):f(x, y) = 10x² + y²
    def loss_fn(p: np.ndarray) -> float:
        return 10 * p[0]**2 + p[1]**2

    def grad_fn(p: np.ndarray) -> np.ndarray:
        return np.array([20 * p[0], 2 * p[1]])

    x0 = np.array([5.0, 5.0])

    for name in ["sgd", "momentum", "nag"]:
        result = optimize(name, loss_fn, grad_fn, x0,
                         n_iter=100, lr=0.01)
        print(f"{name:10s}: final_loss={result.losses[-1]:.6f}, "
              f"final_pos=({result.trajectory[-1][0]:.4f}, "
              f"{result.trajectory[-1][1]:.4f})")

3.3 学习率调度器实现

python 复制代码
import math


class StepLR:
    """阶梯衰减:每 step_size 步乘以 gamma"""

    def __init__(self, initial_lr: float, step_size: int = 30,
                 gamma: float = 0.1):
        self.initial_lr = initial_lr
        self.step_size = step_size
        self.gamma = gamma

    def get_lr(self, epoch: int) -> float:
        return self.initial_lr * (self.gamma ** (epoch // self.step_size))


class CosineAnnealingLR:
    """余弦退火"""

    def __init__(self, eta_max: float, eta_min: float = 0.0,
                 T_max: int = 200):
        self.eta_max = eta_max
        self.eta_min = eta_min
        self.T_max = T_max

    def get_lr(self, epoch: int) -> float:
        return self.eta_min + 0.5 * (self.eta_max - self.eta_min) * \
               (1 + math.cos(math.pi * epoch / self.T_max))


class OneCycleLR:
    """OneCycle 调度:warmup + cosine annealing"""

    def __init__(self, eta_max: float, eta_min: float = 0.0,
                 total_steps: int = 1000, warmup_pct: float = 0.3):
        self.eta_max = eta_max
        self.eta_min = eta_min
        self.total_steps = total_steps
        self.warmup_steps = int(total_steps * warmup_pct)
        self.anneal_steps = total_steps - self.warmup_steps

    def get_lr(self, step: int) -> float:
        if step < self.warmup_steps:
            # 线性 warmup
            return self.eta_min + (self.eta_max - self.eta_min) * \
                   step / self.warmup_steps
        else:
            # cosine annealing
            t = step - self.warmup_steps
            return self.eta_min + 0.5 * (self.eta_max - self.eta_min) * \
                   (1 + math.cos(math.pi * t / self.anneal_steps))


if __name__ == "__main__":
    print("StepLR:")
    for e in [0, 29, 30, 59, 60, 99]:
        print(f"  epoch {e:3d}: lr = {StepLR(0.1).get_lr(e):.6f}")

    print("\nCosineAnnealingLR:")
    for e in [0, 50, 100, 150, 200]:
        print(f"  epoch {e:3d}: lr = "
              f"{CosineAnnealingLR(0.1, T_max=200).get_lr(e):.6f}")

    print("\nOneCycleLR:")
    for e in [0, 75, 150, 300, 700, 999]:
        print(f"  step {e:4d}: lr = "
              f"{OneCycleLR(0.1, total_steps=1000).get_lr(e):.6f}")

四、参数调优与变体对比

4.1 核心参数说明

参数 典型范围 作用 调参建议
学习率 η \eta η 0.0001 ~ 1.0 控制 step size 最重要参数,从 0.01 开始试
动量系数 γ \gamma γ 0.5 ~ 0.99 历史梯度权重 默认 0.9,高曲率用 0.99
Batch Size 16 ~ 256 梯度估计精度 显存允许越大越好
Weight Decay 1e-5 ~ 1e-3 L2 正则化 防过拟合,与学习率联动

4.2 SGD / Momentum / NAG 对比

特性 SGD Momentum NAG
计算量/步 1×梯度 1×梯度 + 1×向量乘加 1×梯度(展望位置)+ 1×向量乘加
内存开销 无额外 额外 v v v(同参数量) 额外 v v v(同参数量)
震荡抑制
收敛速度 快(~ 1 1 − γ \frac{1}{1-\gamma} 1−γ1×加速) 略快于 Momentum
凸函数收敛率 O ( 1 / T ) O(1/T) O(1/T) O ( 1 / T ) O(1/T) O(1/T) O ( 1 / T 2 ) O(1/T^2) O(1/T2)
超参敏感度 高(对学习率敏感) 中(动量缓冲了学习率波动)
适用场景 简单模型、大 batch 通用默认选择 理论偏好、凸优化

4.3 学习率调度策略对比

策略 优点 缺点 适用场景
Step Decay 简单、可解释 衰减点突变、需人工设定 通用、快速实验
Cosine 平滑衰减、末期稳定 需预设 T m a x T_{max} Tmax 训练周期固定
OneCycle 自动 warmup、收敛快 超参多、不灵活 迁移学习、微调
Warmup + Cosine 稳定启动、平滑收敛 需调 warmup 步数 大模型训练

五、在推荐系统中的实际应用

以推荐系统的 Embedding 训练为例。假设有用户特征 u ∈ R d u \in \mathbb{R}^d u∈Rd 和物品特征 v ∈ R d v \in \mathbb{R}^d v∈Rd,预测点击概率为 y ^ = σ ( u T v ) \hat{y} = \sigma(u^T v) y^=σ(uTv),损失函数为交叉熵:

L = − 1 N ∑ i = 1 N y i log ⁡ y \^ i + ( 1 − y i ) log ⁡ ( 1 − y \^ i ) L = -\frac{1}{N} \sum_{i=1}^{N} y_i \\log \\hat{y}_i + (1-y_i) \\log(1-\\hat{y}_i) L=−N1i=1∑Nyilogy\^i+(1−yi)log(1−y\^i)

实际工程中的配置选择

  1. Embedding 维度 d=64,batch_size=256:用 Momentum(lr=0.01, momentum=0.9),训练 100 epoch + Cosine Annealing
  2. 冷启动特征多、梯度方差大:加 Warmup(前 5% steps 线性升温),避免初期梯度噪声导致 Embedding 分布崩坏
  3. 特征稀疏、部分 ID 出现频率低:用 SGD 而非 Momentum(稀疏特征下动量几乎没有累积效果),或用自适应学习率方法如 Adam
python 复制代码
# 推荐系统 Embedding 训练示例(简化版)
import numpy as np


class EmbeddingModel:
    """简化版双塔推荐模型"""

    def __init__(self, n_users: int, n_items: int, dim: int = 64):
        self.user_emb = np.random.randn(n_users, dim) * 0.01
        self.item_emb = np.random.randn(n_items, dim) * 0.01
        self.dim = dim

    def forward(self, user_ids: np.ndarray,
                item_ids: np.ndarray) -> np.ndarray:
        u = self.user_emb[user_ids]  # (batch, dim)
        v = self.item_emb[item_ids]  # (batch, dim)
        return 1.0 / (1.0 + np.exp(-np.sum(u * v, axis=1)))

    def loss(self, y_pred: np.ndarray, y_true: np.ndarray) -> float:
        eps = 1e-7
        return -np.mean(y_true * np.log(y_pred + eps) +
                        (1 - y_true) * np.log(1 - y_pred + eps))

    def grad(self, user_ids: np.ndarray, item_ids: np.ndarray,
             y_pred: np.ndarray, y_true: np.ndarray):
        """计算 Embedding 梯度"""
        diff = (y_pred - y_true).reshape(-1, 1)  # (batch, 1)
        u_grad = np.zeros_like(self.user_emb)
        v_grad = np.zeros_like(self.item_emb)
        for i in range(len(user_ids)):
            u_grad[user_ids[i]] += diff[i] * self.item_emb[item_ids[i]]
            v_grad[item_ids[i]] += diff[i] * self.user_emb[user_ids[i]]
        return u_grad, v_grad


if __name__ == "__main__":
    n_users, n_items, dim = 1000, 5000, 64
    model = EmbeddingModel(n_users, n_items, dim)

    np.random.seed(42)
    train_users = np.random.randint(0, n_users, 10000)
    train_items = np.random.randint(0, n_items, 10000)
    train_labels = (train_users + train_items) % 2

    opt = Momentum(lr=0.01, momentum=0.9)
    opt.v = np.zeros(n_users * dim + n_items * dim)

    batch_size = 256
    for epoch in range(50):
        idx = np.random.permutation(len(train_users))
        epoch_loss = 0
        for start in range(0, len(train_users), batch_size):
            batch_idx = idx[start:start + batch_size]
            u_ids = train_users[batch_idx]
            i_ids = train_items[batch_idx]
            y_true = train_labels[batch_idx].astype(float)

            y_pred = model.forward(u_ids, i_ids)
            epoch_loss += model.loss(y_pred, y_true) * len(batch_idx)

            u_grad, v_grad = model.grad(u_ids, i_ids, y_pred, y_true)
            params = np.concatenate([model.user_emb.ravel(),
                                      model.item_emb.ravel()])
            grads = np.concatenate([u_grad.ravel(),
                                    v_grad.ravel()])
            params = opt.step(params, grads)
            model.user_emb = params[:n_users * dim].reshape(n_users, dim)
            model.item_emb = params[n_users * dim:].reshape(n_items, dim)

        if (epoch + 1) % 10 == 0:
            print(f"Epoch {epoch + 1:3d}, "
                  f"Loss: {epoch_loss / len(train_users):.4f}")

六、常见陷阱

陷阱 1:学习率设置过大导致 Loss 爆炸

--- 当 Loss 突然变成 NaN 或 inf,通常是学习率过大。梯度爆炸后参数更新到极端值,激活函数进入饱和区或数值溢出。解决方案:加梯度裁剪(gradient clipping),或用 warmup 从小学习率开始。
陷阱 2:动量系数设为 1.0 导致不收敛

--- γ = 1.0 \gamma=1.0 γ=1.0 意味着动量不衰减,历史梯度永远累积。在震荡方向上梯度无法抵消,参数飞发散。 γ \gamma γ 必须严格小于 1,通常 0.9 或 0.99。
陷阱 3:NAG 实现错误

--- 常见错误是在 Momentum 代码上"加一点"改动但搞错了展望位置。正确的 NAG 是在 θ t − γ v t − 1 \theta_t - \gamma v_{t-1} θt−γvt−1 处计算梯度,而不是在 θ t − v t − 1 \theta_t - v_{t-1} θt−vt−1 处。注意 PyTorch 中 Nesterov=True 的实现方式做了等价变形,并非直接照搬原公式。
陷阱 4:忽略了 batch size 与学习率的 scaling

--- 线性缩放规则(Linear Scaling Rule):当 batch size 扩大 k k k 倍,学习率也扩大 k k k 倍。但这只在 batch size 不太大时成立。大 batch 训练(>8K)通常需要更精细的 warmup 策略。
陷阱 5:在稀疏梯度场景下用 Momentum

--- 当大部分参数每步梯度为零(如 NLP 中低频词的 Embedding),Momentum 的动量向量也全为零,等于退化为 SGD。此时应使用自适应学习率方法(如 Adam),或对稀疏参数单独设置学习率。

七、总结

维度 SGD Momentum NAG
核心公式 θ t + 1 = θ t − η g t \theta_{t+1} = \theta_t - \eta g_t θt+1=θt−ηgt v t = γ v t − 1 + η g t v_t = \gamma v_{t-1} + \eta g_t vt=γvt−1+ηgt; θ t + 1 = θ t − v t \theta_{t+1} = \theta_t - v_t θt+1=θt−vt v t = γ v t − 1 + η ∇ L ( θ t − γ v t − 1 ) v_t = \gamma v_{t-1} + \eta \nabla L(\theta_t - \gamma v_{t-1}) vt=γvt−1+η∇L(θt−γvt−1)
关键参数 η \eta η(学习率) η \eta η、 γ \gamma γ(动量系数) η \eta η、 γ \gamma γ
优势 简单、噪声有助于逃离鞍点 震荡抑制、加速收敛 理论收敛率更优、前瞻减少冲过头
降级策略 学习率衰减 + 梯度裁剪 降低 γ \gamma γ 或退回 SGD 退回 Momentum
选型建议 稀疏梯度、大 batch 通用默认选择 凸优化、理论偏好
常用学习率 0.01 ~ 0.1 0.001 ~ 0.01 0.001 ~ 0.01

核心建议:没有"最优"的优化器,只有"最适合当前场景"的优化器。从 SGD + Momentum(lr=0.01, momentum=0.9) + Cosine Annealing 开始是 90% 场景下的安全选择。遇到不收敛先查学习率,遇到收敛慢再调调度策略。