Skip to content

s08 优化器:从SGD到Adam — exercise.py 练习指南

Download exercise.py

练习目标

亲手实现 Momentum、RMSProp、NAG(Nesterov 加速梯度)三种优化器的更新规则,理解每种优化器"从 SGD 出发,一步步解决了什么问题"的设计演进脉络。

预备知识

建议先阅读 index.md 并运行 demo.py,确保理解:

概念数学定义
SGDθt+1=θtαgt
指数滑动平均(EMA)mt=βmt1+(1β)gt
Momentummt 定方向,θt+1=θtαmt
RMSPropvt 缩步长,θt+1=θtαgt/vt+ϵ
狭长峡谷损失地形某些方向陡峭、某些平缓,条件数 κ1

任务清单

任务1:实现 Momentum 更新规则

描述:补全 MomentumOptimizerExercise.step() 方法的三个 TODO——初始化速度向量、更新速度、更新参数。

公式回顾

mt=βmt1+(1β)gtθt+1=θtαmt

提示

  • 首次调用时 self.m is None,需要初始化为 np.zeros_like(theta)
  • self.m = self.beta * self.m + (1 - self.beta) * grad 更新速度
  • return theta - self.lr * self.m 更新参数
  • 注意 (1 - self.beta) 不能省略——如果不乘,就变成了对梯度做纯滑动平均而没有衰减

期望行为

  • (3.0,2.5) 出发,经过 5 步后动量向量应指向原点附近
  • 每一步的动量 mt 是历史梯度的加权平均,不会像 SGD 那样每步方向剧烈变化

为什么 Momentum 比 SGD 好? 在狭长峡谷中,SGD 在陡峭方向上来回震荡(一步正向一步反向),而 Momentum 通过平滑历史方向,抵消了这种震荡——正负梯度互相抵消,让 mt 在震荡方向上接近 0。


任务2:实现 RMSProp 自适应步长

描述:补全 RMSPropOptimizerExercise.step() 方法的三个 TODO——初始化 v、更新梯度平方的 EMA、自适应步长更新。

公式回顾

vt=βvt1+(1β)gtgtθt+1=θtαgtvt+ϵ

提示

  • grad ** 2 是逐元素平方,对应公式中的 gtgt
  • np.sqrt(self.v) 计算 vt
  • self.eps(通常 108)加到分母上防止除零
  • 有效学习率 =α/(vt+ϵ) —— 可以打印出来观察

期望行为

  • 陡峭方向(θ1,系数 a=20)的有效学习率应该小于平缓方向(θ2,系数 b=1
  • 这是因为 θ1 方向梯度大 → vt 增长快 → 分母大 → 有效步长自动变小
  • 最终输出应显示 θ₁ < θ₂: True

为什么 RMSProp 解决了步长不统一问题? SGD 对所有参数用同一个学习率,但不同参数梯度的尺度可能差几个数量级。RMSProp 让每个参数拥有自己的"自适应学习率"——梯度历史大的参数步长自动变小,历史小的步长自动放大。


任务3:实现 Nesterov 加速梯度 (NAG)

描述:补全 NAGOptimizer.step() 方法的五个 TODO。NAG 是 Momentum 的进阶版——它"先沿动量方向看一步,再在那个位置计算梯度"。

与普通 Momentum 的区别

  • Momentum:在当前点 θt 计算梯度,然后沿 mt 方向更新
  • NAG:先沿 mt1 方向走一步到"前瞻位置",在那个位置计算梯度,再更新

公式

前瞻位置:

θlookahead=θtαβmt1

在"前瞻"位置计算梯度并更新动量:

mt=βmt1+(1β)L(θlookahead)

参数更新:

θt+1=θtαmt

提示

  • 注意:NAG 的 step() 接收的是 grad_fn(一个函数),而不是 grad(一个值)!因为需要在 lookahead 位置重新计算梯度
  • theta_lookahead = theta - self.lr * self.beta * self.m
  • grad_lookahead = grad_fn(theta_lookahead) —— 在"前瞻"位置调梯度函数
  • 动量更新与 Momentum 相同,只是用的是 grad_lookahead 而非当前位置的梯度

直觉理解:NAG 像是一个"会思考的滚球"。普通 Momentum 是"盲人下山"——沿着惯性方向走,走到哪算哪。NAG 是"睁眼下山"——先往前看一眼,发现前面是悬崖(梯度变大),就可以提前调整方向。在数学上这对应更紧的收敛界。

期望行为:从 (3.0,2.5) 出发,经过 10 步后,NAG 的损失通常小于普通 Momentum——因为它更"聪明"地选择了更新方向。


优化器演进总览

优化器改进点要记住的关键行
SGD基线theta - lr * grad
Momentum加惯性,平滑方向m = beta*m + (1-beta)*grad
RMSProp自适应步长v = beta*v + (1-beta)*(grad**2)
Adam方向 + 步长 + 修正m + v + 偏差修正
NAG"会思考的"Momentum先在 lookahead 处算梯度

完整代码

py
# -*- coding: utf-8 -*-
"""
s08 优化器:从 SGD 到 Adam — 练习代码
======================================
请完成以下 TODO 任务,加深对优化器内部机制的理解。

每个 TODO 都有详细的中文指示和预期输出描述。
建议先阅读 README.md 并运行 demo.py,再尝试独立补全代码。
"""

import numpy as np
from typing import Tuple, List


# ============================================================================
# 辅助:损失地形(与 demo.py 一致)
# ============================================================================

class LossLandscape:
    """二维二次型损失函数 L(θ₁, θ₂) = 0.5·(a·θ₁² + b·θ₂²)"""

    def __init__(self, a: float = 20.0, b: float = 1.0):
        self.a = a
        self.b = b

    def __call__(self, theta: np.ndarray) -> float:
        return 0.5 * (self.a * theta[0] ** 2 + self.b * theta[1] ** 2)

    def gradient(self, theta: np.ndarray) -> np.ndarray:
        return np.array([self.a * theta[0], self.b * theta[1]])

    @property
    def optimum(self) -> np.ndarray:
        return np.array([0.0, 0.0])


# ============================================================================
# TODO 1: 实现 Momentum 更新规则
# ============================================================================

class MomentumOptimizerExercise:
    """
    Momentum 优化器(练习版本)。

    公式:
        m_t = β · m_{t-1} + (1-β) · g_t       ← 速度更新(指数滑动平均)
        θ_{t+1} = θ_t - α · m_t                ← 参数更新

    你需要实现 step() 方法。
    """

    def __init__(self, lr: float = 0.02, beta: float = 0.9):
        """
        初始化 Momentum 优化器。

        参数:
            lr: 学习率 α
            beta: 动量衰减系数 β
        """
        self.lr = lr
        self.beta = beta
        self.m = None  # 速度向量,首次 step 时初始化为零
        self.name = "Momentum"

    def step(self, theta: np.ndarray, grad: np.ndarray) -> np.ndarray:
        """
        执行一步 Momentum 更新。

        提示:
          1. 如果 self.m 是 None,初始化为 np.zeros_like(theta)
          2. 更新速度: self.m = β * self.m + (1 - β) * grad
          3. 更新参数: theta = theta - α * self.m
          4. 返回更新后的 theta

        参数:
            theta: 当前参数向量
            grad: 当前梯度向量

        返回:
            更新后的参数向量
        """
        # TODO: 如果 self.m 为 None,初始化为零向量
        if self.m is None:
            pass  # ← TODO: self.m = np.zeros_like(theta)

        # TODO: 更新速度 m_t
        pass  # ← TODO: self.m = self.beta * self.m + (1 - self.beta) * grad

        # TODO: 更新参数
        pass  # ← TODO: return theta - self.lr * self.m

        return theta  # 占位返回


# ---- 测试 TODO 1 ----
def test_momentum():
    """测试 Momentum 优化器的实现"""
    print("=" * 60)
    print("TODO 1 测试: Momentum 更新规则")
    print("=" * 60)

    landscape = LossLandscape(a=20.0, b=1.0)
    theta0 = np.array([3.0, 2.5])
    opt = MomentumOptimizerExercise(lr=0.02, beta=0.9)

    # 手动运行几步
    theta = theta0.copy()
    print(f"\n  初始: θ = {theta}, L = {landscape(theta):.4f}")

    for step in range(5):
        grad = landscape.gradient(theta)
        theta_new = opt.step(theta, grad)

        if theta_new is None or np.allclose(theta_new, theta):
            print("  TODO 未完成,请补全 MomentumOptimizerExercise.step()")
            return

        theta = theta_new
        if step < 5:
            print(f"  Step {step+1}: θ = [{theta[0]:.3f}, {theta[1]:.3f}], "
                  f"L = {landscape(theta):.4f}, m = [{opt.m[0]:.3f}, {opt.m[1]:.3f}]")

    # 验证动量向量的方向大致正确(应该指向原点附近)
    if opt.m is not None:
        m_direction = opt.m / (np.linalg.norm(opt.m) + 1e-10)
        target_direction = -theta0 / (np.linalg.norm(theta0) + 1e-10)
        similarity = np.dot(m_direction, target_direction)
        print(f"\n  动量方向与目标方向的相似度: {similarity:.3f} (>0 表示大致正确)")

    print()


# ============================================================================
# TODO 2: 实现 RMSProp 更新规则(v_t 计算)
# ============================================================================

class RMSPropOptimizerExercise:
    """
    RMSProp 优化器(练习版本)。

    公式:
        v_t = β · v_{t-1} + (1-β) · g_t²       ← 梯度平方的指数滑动平均
        θ_{t+1} = θ_t - α · g_t / (√v_t + ε)    ← 自适应步长更新

    你需要实现 step() 方法。
    特别注意 v_t 的计算——g_t 要先逐元素平方。
    """

    def __init__(self, lr: float = 0.05, beta: float = 0.9, eps: float = 1e-8):
        """
        初始化 RMSProp 优化器。

        参数:
            lr: 学习率 α
            beta: 衰减系数 β
            eps: 数值稳定常数 ε
        """
        self.lr = lr
        self.beta = beta
        self.eps = eps
        self.v = None
        self.name = "RMSProp"

    def step(self, theta: np.ndarray, grad: np.ndarray) -> np.ndarray:
        """
        执行一步 RMSProp 更新。

        提示:
          1. 如果 self.v 是 None,初始化为 np.zeros_like(theta)
          2. 更新 v_t: self.v = β * self.v + (1 - β) * (grad ** 2)
             注意:grad ** 2 是逐元素平方
          3. 更新参数: theta = theta - α * grad / (√v_t + ε)
             注意:np.sqrt(self.v) 也是逐元素操作
          4. 返回更新后的 theta

        参数:
            theta: 当前参数向量
            grad: 当前梯度向量

        返回:
            更新后的参数向量
        """
        # TODO: 如果 self.v 为 None,初始化为零向量
        if self.v is None:
            pass  # ← TODO: self.v = np.zeros_like(theta)

        # TODO: 更新梯度平方的滑动平均 v_t
        pass  # ← TODO: self.v = self.beta * self.v + (1 - self.beta) * (grad ** 2)

        # TODO: 自适应步长更新
        pass  # ← TODO: return theta - self.lr * grad / (np.sqrt(self.v) + self.eps)

        return theta  # 占位返回


# ---- 测试 TODO 2 ----
def test_rmsprop():
    """测试 RMSProp 优化器的实现"""
    print("=" * 60)
    print("TODO 2 测试: RMSProp 自适应步长")
    print("=" * 60)

    landscape = LossLandscape(a=20.0, b=1.0)
    theta0 = np.array([3.0, 2.5])
    opt = RMSPropOptimizerExercise(lr=0.05, beta=0.9)

    theta = theta0.copy()
    print(f"\n  初始: θ = {theta}, L = {landscape(theta):.4f}")

    for step in range(10):
        grad = landscape.gradient(theta)
        theta_new = opt.step(theta, grad)

        if theta_new is None or np.allclose(theta_new, theta):
            print("  TODO 未完成,请补全 RMSPropOptimizerExercise.step()")
            return

        theta = theta_new
        if step < 5 or step == 9:
            effective_lr = opt.lr / (np.sqrt(opt.v) + opt.eps)
            print(f"  Step {step+1:2d}: θ = [{theta[0]:.3f}, {theta[1]:.3f}], "
                  f"L = {landscape(theta):.4f}")
            print(f"         有效学习率: [{effective_lr[0]:.4f}, {effective_lr[1]:.4f}]")

    # 验证:陡峭方向(θ₁)的有效学习率应该小于平缓方向(θ₂)
    if opt.v is not None:
        effective_lr = opt.lr / (np.sqrt(opt.v) + opt.eps)
        print(f"\n  最终有效学习率: θ₁={effective_lr[0]:.5f}, θ₂={effective_lr[1]:.5f}")
        print(f"  θ₁ < θ₂ 吗?{effective_lr[0] < effective_lr[1]} (RMSProp 应该压小陡峭方向的步长)")

    print()


# ============================================================================
# TODO 3: 实现 Nesterov 加速梯度 (NAG)
# ============================================================================

class NAGOptimizer:
    """
    Nesterov 加速梯度 (Nesterov Accelerated Gradient, NAG)。

    NAG 和普通 Momentum 的关键区别:
      - Momentum: 在当前点计算梯度,然后沿动量方向更新
      - NAG:     先沿动量方向"前瞻"一步,在"前瞻位置"计算梯度,再更新

    公式:
        θ_lookahead = θ_t - α · β · m_{t-1}            ← 沿当前动量方向看一步
        m_t = β · m_{t-1} + (1-β) · ∇L(θ_lookahead)   ← 在"前瞻"位置计算梯度
        θ_{t+1} = θ_t - α · m_t                        ← 用前瞻梯度更新

    直观上,NAG 像是"先试着往前走一步,发现不对再调整方向"。

    你需要实现 step() 方法。

    参数:
        lr: 学习率 α
        beta: 动量衰减系数 β
    """

    def __init__(self, lr: float = 0.02, beta: float = 0.9):
        self.lr = lr
        self.beta = beta
        self.m = None
        self.name = "NAG"

    def step(self, theta: np.ndarray, grad_fn) -> np.ndarray:
        """
        执行一步 NAG 更新。

        注意:与普通优化器不同,NAG 需要 grad_fn(梯度计算函数),
        因为需要在"前瞻"位置重新计算梯度。

        提示:
          1. 如果 self.m 为 None,初始化为零向量
          2. 计算前瞻位置: theta_lookahead = theta - lr * beta * m
          3. 在前瞻位置计算梯度: grad_lookahead = grad_fn(theta_lookahead)
          4. 更新动量: m = beta * m + (1 - beta) * grad_lookahead
          5. 更新参数: theta = theta - lr * m

        参数:
            theta: 当前参数向量
            grad_fn: 梯度计算函数,签名为 grad_fn(theta) -> gradient

        返回:
            更新后的参数向量
        """
        # TODO: 初始化动量
        if self.m is None:
            pass  # ← TODO: self.m = np.zeros_like(theta)

        # TODO: 计算前瞻位置
        theta_lookahead = None  # ← TODO: theta - self.lr * self.beta * self.m

        # TODO: 在前瞻位置计算梯度
        grad_lookahead = None  # ← TODO: grad_fn(theta_lookahead)

        # TODO: 更新动量
        pass  # ← TODO: self.m = self.beta * self.m + (1 - self.beta) * grad_lookahead

        # TODO: 更新参数
        pass  # ← TODO: return theta - self.lr * self.m

        return theta  # 占位返回


# ---- 测试 TODO 3 ----
def test_nag():
    """测试 NAG 优化器"""
    print("=" * 60)
    print("TODO 3 测试: Nesterov 加速梯度 (NAG)")
    print("=" * 60)

    landscape = LossLandscape(a=20.0, b=1.0)
    theta0 = np.array([3.0, 2.5])

    # NAG
    nag = NAGOptimizer(lr=0.02, beta=0.9)
    theta_nag = theta0.copy()

    # 普通 Momentum 用于对比
    from types import SimpleNamespace
    mom = SimpleNamespace()
    mom.lr = 0.02
    mom.beta = 0.9
    mom.m = np.zeros_like(theta0)
    theta_mom = theta0.copy()

    print(f"\n  对比 NAG vs Momentum (前10步)")
    print(f"  {'Step':<6} {'NAG L':<14} {'NAG θ₁':<12} {'Mom L':<14} {'Mom θ₁'}")
    print(f"  {'-'*60}")

    for step in range(10):
        # NAG 更新
        grad_fn = landscape.gradient
        theta_nag_new = nag.step(theta_nag, grad_fn)

        if theta_nag_new is None or np.allclose(theta_nag_new, theta_nag):
            print("  TODO 未完成,请补全 NAGOptimizer.step()")
            return

        theta_nag = theta_nag_new

        # 普通 Momentum 更新
        grad_mom = landscape.gradient(theta_mom)
        mom.m = mom.beta * mom.m + (1 - mom.beta) * grad_mom
        theta_mom = theta_mom - mom.lr * mom.m

        print(f"  {step+1:<6} {landscape(theta_nag):<14.6f} {theta_nag[0]:<12.4f} "
              f"{landscape(theta_mom):<14.6f} {theta_mom[0]:.4f}")

    # NAG 通常比 Momentum 收敛更快
    loss_nag = landscape(theta_nag)
    loss_mom = landscape(theta_mom)
    print(f"\n  最终: NAG loss={loss_nag:.6f}, Momentum loss={loss_mom:.6f}")
    print(f"  NAG 是否更快: {'是' if loss_nag < loss_mom else '否'} (预期: 是)")

    print()


# ============================================================================
# 主程序
# ============================================================================
if __name__ == "__main__":
    print("\n╔══════════════════════════════════════════════════════════════╗")
    print("║   s08 优化器:从 SGD 到 Adam — 动手练习                    ║")
    print("║   请依次完成 TODO 1, 2, 3                                   ║")
    print("╚══════════════════════════════════════════════════════════════╝\n")

    test_momentum()
    test_rmsprop()
    test_nag()

    print("=" * 60)
    print("所有测试完成!请检查输出结果。")
    print("如有未通过的测试,请回到对应的 TODO 部分补全代码。")
    print()
    print("优化器设计演进:")
    print("  SGD → 只看当前梯度,简单但有震荡、慢缩等问题")
    print("  Momentum → 加惯性,方向更平滑")
    print("  RMSProp → 自适应步长,每个参数有自己的学习率")
    print("  Adam → Momentum + RMSProp,集两者之长")
    print("  NAG → 比 Momentum 更进一步:先"看一步"再调整方向")
    print("=" * 60)