环境:PyCharm + python3.8

如(1.2. 深度学习中的优化挑战)中所说,

  • 本章关注:优化算法在 最小化目标函数方面的 性能,而非模型的泛化误差。

👉【优化算法】

主流优化算法及其特点
算法核心思想优势局限性适用场景
SGD

随机梯度下降,

按固定学习率更新参数

简单,理论保证强

(凸函数收敛)

收敛慢,需手动调学习率,

易陷入局部最优

资源有限时

(如嵌入式设备)

,简单模型

6. Momentum

动量法

(梯度+记忆)

引入动量项,加速收敛并抑制震荡

(v_{t}=\beta {v_{t-1}}+\eta \nabla _{\theta }J)

缓解梯度震荡,

加快平坦区域收敛

需调动量系数 β ,

学习率仍需手动调整

图像分类、RNN

等长序列任务

7. AdaGrad

(永久记忆)

(根据历史梯度平方和

为每个参数自适应调整学习率)

自调整学习率,

对频繁参数缩小步长,

对稀疏参数放大步长,

( \eta = \frac{\eta}{\sqrt{\mathbf{s}_t + \epsilon}},s是累计的历史梯度平方)

自调整学习率,

适合稀疏数据

(如NLP)

长期累积导致

学习率过早衰减

文本分类、推荐系统

等稀疏数据场景

8. RMSProp

(有限记忆)

改进AdaGrad,

使用指数加权平均梯度平方(代替累积和)

s_{t}=\beta s_{t-1}+(1-\beta)g^{2}_{t}​

解决AdaGrad学习率

衰减过快问题

需调 β 和初始学习率

,对初始值敏感

非平稳目标函数

(如强化学习)

9. AdaDelta

(RMSProp

+自动学习率)​​

​​自适应学习率 + 自适应更新量​​

,无需设置全局学习率

(学习率 ≈ 近期参数变化幅度 / 近期梯度幅度)

\mathbf{g}_t' = \frac{\sqrt{\Delta\mathbf{x}_{t-1} + \epsilon}}{\sqrt{​{\mathbf{s}_t + \epsilon}}} \odot \mathbf{g}_t

\mathbf{g}_t' :调整后的梯度

\Delta\mathbf{x}_t :模型本身中参数变化二阶导数的泄露平均值

\mathbf{s}_t :梯度二阶导数的泄露平均值

​​完全自适应​​,

无需手动设置学习率,

数值稳定

收敛可能稍慢,

超参数敏感度低

但仍有ε需要设置

需"黑盒"优化的场景,调参困难的任务

10. Adam

(动量

+RMSProp

+偏差校正)

结合Momentum和RMSProp,

(结合动量和自适应学习率)

自适应调整:

一阶矩(动量)和

二阶矩(梯度平方)

自动调参,

收敛快,

鲁棒性强

可能收敛到次优解

(需调整 ϵ),内存占用较大

通用场景(CV、NLP、推荐等)
Nadam

Adam + Nesterov动量,

提前预估梯度方向

进一步加速收敛,

理论收敛性更好

计算稍复杂,

超参数更多

需快速收敛的复杂任务

算法演进脉络​

  • 第一代:基础优化器​
    • ​​SGD​​:最基础,但需要精细调参(固定η)
    • Momentum​​:加入惯性(增加动量),改善收敛路径
  • ​​第二代:自适应学习率​
    • ​​AdaGrad​​:首次引入自适应,但学习率单调递减(自适应但衰减快)
    • RMSProp​​:解决AdaGrad的衰减问题(自适应 + 遗忘机制)(自适应不衰减)
  • 第三代:完全自适应​
    • ​​AdaDelta​​:​​无需学习率​​,完全自适应(完全自适应)
    • ​​Adam​​:结合动量和自适应,成为现代标准 (动量 + RMSProp + 偏差校正)(综合优化)
  • 第四代:改进变体​
    • Nadam​​:Adam + Nesterov动量,理论更优

优化器 — 推荐选择策略​

场景

推荐算法

理由

​​新手/快速实验​​

​​Adam​​

默认参数效果好,鲁棒性强

​​稀疏数据(NLP)​​

​​AdaGrad​​ 或 ​​Adam​​

对稀疏特征适应好

​​调参困难任务​​

​​AdaDelta​​

无需设置学习率

​​理论研究​​

​​SGD + Momentum​​

理论清晰,可解释性强

​​生产环境稳定模型​​

​​SGD​​(精心调参)

最终收敛效果可能更好

  • ​​Adam​几乎成为深度学习"默认"优化器
  • 90%的情况直接用Adam,效果不错
  • 剩下10%特殊任务再考虑SGD等

优化器 — 超参数设置参考​

# 常用默认值
Adam: lr=0.001, beta1=0.9, beta2=0.999, epsilon=1e-8
AdaDelta: rho=0.95, epsilon=1e-6
RMSProp: lr=0.001, rho=0.9, epsilon=1e-8

1. 优化和深度学习

  • 对于深度学习问题,通常会先定义损失函数。
  • 一旦有了损失函数,就可以使用优化算法来尝试最小化损失。
  • 在优化中,
    • 损失函数:通常被称为优化问题的目标函数。
    • 按照传统惯例,大多数优化算法都关注的是最小化。
    • 若需要最大化目标:则可在目标函数前加负号。

1.1. 优化的目标

本质上,优化和深度学习的目标是根本不同的:(核心目标差异)

  • 优化算法的目标:
    • 最小化训练数据集上的损失函数(即经验风险)
    • 关注的是模型在已知数据上的拟合能力
  • 深度学习的目标:
    • 在有限数据下寻找泛化能力强的模型,即最小化整个数据分布的预期损失(即风险)
    • 关注的是模型在未知数据上的表现

关键矛盾:

  • 训练误差(优化目标)≠ 泛化误差(深度学习目标)
  • 优化算法可能过度拟合训练数据,导致泛化能力下降(过拟合)

在 (多层感知机实现—— 动手学深度学习4.2~4.9_多层感知机开源代码-CSDN博客中的 模型选择、欠拟合和过拟合)中,详细讨论了这两个目标之间的区别。例如,

  • 训练误差和泛化误差通常不同:
    • 优化算法:
      • 目标函数通常是基于训练数据集的损失函数,
      • 因此优化的目标是减少训练误差。
    • 深度学习(或更广义地说,统计推断):
      • 目标:减少泛化误差。
      • 具体实现:除了使用优化算法来减少训练误差之外,还需要注意过拟合。
import numpy as np
import torch
from mpl_toolkits import mplot3d
import matplotlib.pyplot as plt
import common

为了说明上述不同的目标,引入两个概念风险和经验风险。如 (多层感知机实现—— 动手学深度学习4.2~4.9_多层感知机开源代码-CSDN博客中的 环境和分布偏移—3. 分布偏移纠正—3.1. 经验风险与实际风险) 所述,

  • 经验风险:是 训练数据集 的 平均损失 ,是风险的近似
    • (xi​,yi​) 训练样本,n 样本数量
    • 关键区别:是实际可计算的代理目标,但受限于有限数据,可能不平滑或存在噪声
  • 风险       :是 整个数据群 的 预期损失 ,反映模型在未知数据上的平均表现
    • P 真实数据分布,L 损失函数,θ 模型参数 
    • 关键区别:理论上的全局目标,但不可直接计算(需知道真实分布 P)

下面定义两个函数:

  • 风险函数 f
  • 经验风险函数 g

假设只有 有限的训练数据。因此,这里的g不如f平滑:

def f(x):
    '''风险函数
    理论风险值:x * cos(πx)
    理论风险的简化模拟:
        模拟真实数据分布下的期望损失
        cos(πx)项:反映损失随 参数x 的振荡变化
        全局最小值对应最优模型参数
    '''
    return x * torch.cos(np.pi * x)

def g(x):
    '''经验风险函数
    经验风险值 = 风险函数 + 高频噪声项
    f(x):继承理论风险的基础形态
    0.2*cos(5πx):模拟有限样本导致的波动
        振幅0.2:反映 数据量不足带来的估计误差
        高频5π :反映 经验风险的局部波动性
    '''
    # 在风险函数基础上添加高频振荡项(模拟训练数据噪声)
    return f(x) + 0.2 * torch.cos(5 * np.pi * x)

下图说明,训练数据集的最低经验风险可能与最低风险(泛化误差)不同。

def annotate(text, xy, xytext, ax=None, fontsize=10, arrowprops=None):
    '''在图中添加带箭头的注释(增强版)
    text        : 注释文本
    xy          : 被注释点坐标 (x,y)
    xytext      : 文本位置坐标 (x,y)
    ax          : 目标坐标轴对象,默认为当前轴
    fontsize    : 文本字体大小
    arrowprops  : 箭头属性字典
    '''
    if arrowprops is None: # 默认箭头样式设置
        # 箭头样式,线宽,颜色
        arrowprops = dict(arrowstyle='->', linewidth=2, color='red',
                          shrinkA=0, # 箭头起点不收缩
                          shrinkB=0, # 箭头终点不收缩
                          connectionstyle="arc3,rad=0.3") # 带弧度的连接线
    ax = ax or plt.gca() # 获取或创建坐标轴对象

    # 坐标安全保护:确保注释点不超出图形范围
    xlim = ax.get_xlim() # 获取x轴范围
    ylim = ax.get_ylim() # 获取y轴范围
    xy = (np.clip(xy[0], *xlim), np.clip(xy[1], *ylim)) # 裁剪被注释点坐标
    xytext = (np.clip(xytext[0], *xlim), np.clip(xytext[1], *ylim)) # 裁剪文本位置坐标

    # 添加注释到图形,bbpx注释文本的背景框
    ax.annotate(text, xy=xy, xytext=xytext,
                fontsize=fontsize,
                ha='center', va='center',   # 水平居中,垂直居中
                arrowprops=arrowprops,      # 箭头样式
                bbox=dict(boxstyle="round", # 圆角边框
                          alpha=0.1,        # 背景透明度
                          color="cyan"))    # 背景颜色
# 绘制对比图:风险函数 & 经验风险函数
x = torch.arange(0.5, 1.5, 0.01) # 参数搜索范围
fig1, ax1 = common.plot(x, [f(x), g(x)], 'x', 'risk',
                        legend=['Risk (f)', 'Empirical Risk (g)'], # 添加图例
                        xlim=(0.47, 1.53), ylim=(-1.3, 0.25),
                        figsize=(6, 4), title='Risk Functions Comparison',
                        show_internally = False)
# 添加关键点注释
common.annotate('min of\nempirical risk',  (1.0, -1.2), (0.7, -0.87), ax=ax1) # 经验风险最小值点
common.annotate('min of risk', (1.1, -1.05), (1.05, -0.5), ax=ax1,) # 理论风险最小值点

1.2. 深度学习中的优化挑战

  • 本章关注:优化算法在 最小化目标函数方面的 性能,而非模型的泛化误差。
  • 在 (机器学习 理论相关 (自用笔记)-CSDN博客中的 线性回归)中,区分了优化问题中的解析解和数值解。
  • 深度学习中,大多数目标函数都没有解析解。
  • 相反,我们必须使用数值优化算法。本章中的优化算法都属于此类别。

深度学习优化存在许多挑战。其中最令人烦恼的是:

  • 局部最小值
  • 鞍点
  • 梯度消失

1.2.1. 局部最小值

对于任何目标函数 f(x),若在 x 处对应的 f(x) 值 

  • 小于 在 x 附近任意其他点的 f(x) 值  ===》 f(x) 可能是 局部最小值
  • 是 整个域中目标函数的最小值          ===》 f(x) 是 全局最小值

例如,给定函数(即 代码中,前面对比两种风险时的 风险函数 f(x))

我们可以近似该函数的局部最小值和全局最小值。

# 绘制对比图:局部最小值 & 全局最小值
x = torch.arange(-1.0, 2.0, 0.01)
# 使用前面的风险函数 f(x),对比两种风险时范围是0.5~1.5,现在看最小值的范围的-1.0~2.0
fig2, ax2 = common.plot(x, [f(x), ], 'x', 'f(x)',
                        xlim=(-1.05, 2.05), ylim=(-1.5, 3),
                        title='Local vs Global Minimum',
                        show_internally = False)
common.annotate('local minimum', (-0.3, -0.25), (-0.37, 1.5), ax=ax2)
common.annotate('global minimum', (1.1, -0.95), (0.9, 0.8), ax=ax2)

  • 深度学习模型的目标函数通常有许多局部最优解。
  • 当优化问题的数值解接近局部最优值时,随着目标函数解的梯度接近或变为零,通过最终迭代获得的数值解可能仅使目标函数 局部 最优,而非 全局 最优。
  • 一定程度的噪声可使参数跳出局部最小值。
    • 小批量随机梯度下降中,小批量上 梯度的自然变化 能助参数跳出局部极小值。

1.2.2. 鞍点

  • 除了局部最小值之外,鞍点是梯度消失的另一个原因。
  • 鞍点(saddle point):
    • 指 函数的所有梯度都消失,
    • 但既不是全局最小值,也不是局部最小值 的任何位置。

例如,函数 f(x) = x^3 :

  • 它的一阶和二阶导数在 x=0 时消失。
  • 这时优化可能会停止,但此处并非最小值。
# 绘制鞍点示例图
x = torch.arange(-2.0, 2.0, 0.01) # 扩展参数范围
fig3, ax3 = common.plot(x, [x**3], 'x', 'f(x)',
                      xlim=(-2, 2), ylim=(-8, 8),
                      figsize=(6, 4), title='Saddle Point Example',
                        show_internally = False)
common.annotate('saddle point', (0, -0.2), (-0.52, -5.0),
                ax = ax3, fontsize = 12,
                arrowprops = dict(arrowstyle='fancy', color='green')) # 鞍点注释
# 添加参考线
plt.axhline(y=0, color='k', linestyle='--', linewidth=0.8)  # 添加x轴虚线
plt.axvline(x=0, color='k', linestyle='--', linewidth=0.8)  # 添加y轴虚线

如下例所示,较高维度的鞍点甚至更加隐蔽。

  • 例如, f(x, y) = x^2 - y^2 是典型的鞍点函数:
    • 其鞍点为 (0,0),在(0,0)处梯度为0(一阶导数为2x和-2y)
      • 这是关于 y 的最大值,
      • 也是关于 x 的最小值。
    • Hessian矩阵为[[2,0],[0,-2]],特征值一正一负,符合鞍点定义
    • 此外,它看起来像个马鞍,即 鞍点的名字由来。
# 创建二维网格:在[-1,1]区间生成101x101的网格点坐标矩阵
# torch.linspace创建等差数列,torch.meshgrid将一维坐标扩展为二维网格
x, y = torch.meshgrid(
    torch.linspace(-1.0, 1.0, 101),  # x坐标范围[-1,1],101个点
    torch.linspace(-1.0, 1.0, 101))   # y坐标范围[-1,1],101个点

# 定义鞍点函数:z = x² - y²
# 该函数在(0,0)处Hessian矩阵特征值为(2, -2),是典型的鞍点
z = x**2 - y**2

ax = plt.figure().add_subplot(111, projection='3d') # 创建三维坐标轴对象

# 绘制三维线框图
# plot_wireframe以线框形式绘制曲面
# 通过rstride/cstride控制行/列方向的网格密度(步长)
ax.plot_wireframe(x, y, z, **{'rstride': 10, 'cstride': 10})

# 红色叉号标记鞍点位置(0,0,0),直观展示梯度消失点
ax.plot([0], [0], [0], 'rx') # 第一个列表表示x坐标,第二个y坐标,第三个z坐标

# 设置坐标轴刻度
ticks = [-1, 0, 1]      # 统一设置刻度为[-1,0,1]
plt.xticks(ticks)       # 设置x轴刻度
plt.yticks(ticks)       # 设置y轴刻度
ax.set_zticks(ticks)    # 设置z轴刻度

# 添加坐标轴标签
plt.xlabel('x')
plt.ylabel('y')
ax.set_zlabel('z')  # 补充z轴标签
# plt.savefig('saddle_point.png', dpi=300, bbox_inches='tight') # 保存图像到文件

函数与Hessian矩阵特性:假设函数的输入是 k维向量,其输出是标量,因此其Hessian矩阵(也称黑塞矩阵)将有 k个特征值(参考特征分解的在线附录)。函数的解可能是局部最小值、局部最大值 或 函数梯度为零位置处的鞍点:

  • 当函数在零梯度位置处的Hessian矩阵的特征值:
    • 全为正值 时,我们有该函数的 局部最小值
    • 全为负值 时,我们有该函数的 局部最大值
    • 为负值和正值 时,我们有该函数的一个 鞍点

高维度下鞍点与局部最小值情况:

  • 在高维度问题中,至少部分特征值为负的可能性相当高。
  • 导致鞍点比局部最小值更易出现。

凸函数情况:

  • 凸函数是Hessian矩阵特征值永远不为负值的函数。
  • 是研究优化算法的良好工具。
  • 但 大多数深度学习问题并不属于这一类。

1.2.3. 梯度消失

梯度消失的核心问题:

  • 定义​​:
    • 在DNN的反向传播过程中,
    • 梯度(即导数)通过链式法则 从输出层向输入层 逐层传递时,
    • 若每一层的梯度都小于1(如Sigmoid/Tanh的饱和区),经过多层累积后,
    • 其​​幅度(绝对值)会急剧减小(指数级衰减)​​,以至于接近零。
  • 后果:
    • 靠近输入层的参数更新几乎停滞(权重几乎无法更新),
    • 模型无法有效学习深层特征,导致训练失败或收敛极慢。

典型场景示例:

  • 回想一下在(机器学习 理论相关 (自用笔记)-CSDN博客中 神经网络:多层感知机 的 2. 激活函数)中常用的激活函数及其衍生函数。
  • 例如,
    • 假设想要最小化函数 f(x) = \tanh(x),又恰好从 x=4 开始。
    • 则 f 的梯度接近零。
    • 更具体地说,f'(x) = 1 - \tanh^2(x) ,所以 f'(4) = 0.0013 。
    • 因此,在取得进展前,优化会停滞很长一段时间。
    • 而实际上,在引入ReLU激活函数之前,这就是训练深度学习模型相当棘手的原因之一。
x = torch.arange(-2.0, 5.0, 0.01)
fig4, ax4 = common.plot(x, [torch.tanh(x)], 'x', 'f(x)', show_internally=False)
common.annotate('vanishing gradient', (4, 1), (2, -0.20), ax=ax4)

  • 无需全局最优:局部最优解 或 近似解通常已足够好。
    • (如分类任务中,局部最优的准确率可能接近全局最优)
  • 高维空间的局部最优性质:在高维空间中,鞍点比严格的局部最优解更常见,现代算法(如Adam)可通过扰动逃离。
  • 近似解的实用性:局部最优解的损失值可能仅比全局最优高0.1%,但实际性能差异极小。
  • 经验证据:实验表明,随机初始化的多次训练可能收敛到不同局部最优,但测试性能接近。
    • 例如,ResNet在ImageNet上的不同随机种子训练,Top-1准确率波动通常<0.5%。

小结

  • 最小化训练误差并不能保证我们找到最佳的参数集来最小化泛化误差。

  • 优化问题可能有许多局部最小值。

  • 一个问题可能有很多的鞍点,因为问题通常不是凸的。

  • 梯度消失可能会导致优化停滞,

    • 重参数化通常会有所帮助。

    • 对参数进行良好的初始化也可能是有益的。

2. 凸性

2.1. 定义

  • 凸集(convex sets):集合内 任意两点之间连线 的整条线段 皆包含在该集合内。
    • 交集保持凸性​​:凸集的交集是凸的 
    • 并集不保持凸性​​:凸集的并集不一定是凸的 
  • 凸函数(convex functions):函数值 ≤ 弦的值(弦在函数上方,即 线段在图像上方)。
    • 凸函数的局部极小值也是全局极小值
    • 凸函数的下水平集是凸的(碗形山谷的所有海拔低于100米的区域,肯定是一片连起来的、中间没有空洞的洼地)
    • 判断函数是否 “凸”:如果凸的话→二阶导≥0 / 半正定
      • 一维,一元函数 f(x):“加速度” 二阶导数 f''(x) 必须永远 >= 0
      • 多维,多元函数 f(x, y, z...): 其 ​​Hessian 矩阵​​(即 包含所有方向“加速度”的矩阵)是否 ​​半正定(H ⪰ 0)
        • 半正定:
          • 用于描述 “只升不降” 的性质
          • 一个多元函数是凸的,当且仅当,把它任意“切片”后,得到的一元函数,也都是凸的。

2.1.1. 凸集

凸集(convex set)是凸性的基础。简单地说,

  • 若对于任何 a, b \in \mathcal{X} ,连接 a 和 b 的线段也位于 \mathcal{X} 中,则向量空间中的一个集合 \mathcal{X} 是凸(convex)的。
  • 在数学术语上,这意味着对于所有 \lambda \in [0, 1] ,我们得到
凸集的正式定义

这听起来有点抽象,那我们来看一下 图11.2.1里的例子。

  • 第一组存在不包含在集合内部的线段,所以该集合是非凸的。
  • 而另外两组则没有该问题。
图11.2.1 第一组是非凸的,另外两组是凸的

接下来来看一下交集 图11.2.2。

  • 假设 \mathcal{X} 和 \mathcal{Y} 是凸集,则 \mathcal {X} \cap \mathcal{Y} 也是凸集的。
  • 现在考虑任意 a, b \in \mathcal{X} \cap \mathcal{Y}, 因为 \mathcal{X} 和 \mathcal{Y} 是凸集,所以连接 a 和 b 的线段包含在 \mathcal{X} 和 \mathcal{Y} 中。
  • 鉴于此,它们也需要包含在 \mathcal {X} \cap \mathcal{Y} 中,从而证明我们的定理。
图11.2.2  两个凸集的交集是凸的

我们可以毫不费力地进一步得到这样的结果:

  • 给定凸集 \mathcal{X}_i ,它们的交集 \cap_{i} \mathcal{X}_i 是凸的。
  • 但是反向是不正确的,考虑两个不相交的集合 \mathcal{X} \cap \mathcal{Y} = \emptyset ,取 a \in \mathcal{X} 和 b \in \mathcal{X} 。
    • (\emptyset ​​即 ∅​​:空集 (Empty Set),一个​​不包含任何元素​​的集合)
    • 因为假设 \mathcal{X} \cap \mathcal{Y} = \emptyset ,在 图11.2.3中连接 a 和 b 的线段需包含一部分既不在 \mathcal{X} 也不在 \mathcal{Y} 中(红色部分)。
    • 红色线段不包含在并集 \mathcal{X} \cup \mathcal{Y} 中,因此证明了凸集的并集不一定是凸的,即非凸(nonconvex)的。
图11.2.3  两个凸集的并集不一定是凸的

深度学习中大部分优化问题在凸集上进行。例如,

  • (常见凸集)(深度学习中​​两种常见的优化"舞台")
  • 例子1:整个空间 \mathbb{R}^d 
    • \mathbb{R}^d = 所有可能的d维向量组成的空间。比如 
      • ℝ²:整个平面
      • ℝ³:整个三维空间
    • 即实数的 d-维向量的集合是凸集 (毕竟 \mathbb{R}^d 中任意两点之间的线存在 \mathbb{R}^d )中。
    • 在深度学习中应用:当​​没有约束​​时,参数可以在所有实数范围内自由取值。
  • 例子2:球约束(有界约束)​
    • 在某些情况下,我们使用有界长度的变量,例如球的半径定义为 \{\mathbf{x} | \mathbf{x} \in \mathbb{R}^d 且 \| \mathbf{x} \| \leq r\} 。
      • \{\mathbf{x} |  \| \mathbf{x} \| \leq r\} = "所有长度不超过r的向量的集合"
      • \| \mathbf{x} \| \l = 向量x的长度(就像点到原点的距离)
      • r = 球的半径
    • 具体例子:

2.1.2. 凸函数

现在有了凸集,可以引入凸函数(convex function)f :

  • 给定一个凸集 \mathcal{X} ,若对于所有 x, x' \in \mathcal{X} 和 所有 \lambda \in [0, 1],函数 f: \mathcal{X} \to \mathbb{R} 是凸的,
  • 则可以得到

公式的直观解释​​:

  • 左边 f(t x_{1} + (1-t) x_{2}) :在x₁和x₂之间的某个点(比如中点)的函数值
  • 右边 t f(x_{1}) + (1-t) f(x_{2}) :连接f(x₁)和f(x₂)的线段在该点的值
  • ≤ 表示 :函数值 ≤ 弦的值(弦在函数上方)

具体例子说明​:

为了说明这一点,下面绘制一些函数并检查哪些函数满足要求。

  • 定义一些函数,包括凸函数和非凸函数:
# 定义三个函数
f = lambda x: 0.5 * x**2            # 凸函数  (典型,开口向上的抛物线)
g = lambda x: torch.cos(np.pi * x)  # 非凸函数(余弦波)
h = lambda x: torch.exp(0.5 * x)    # 凸函数  (指数增长)

# 生成数据
x = torch.arange(-2, 2, 0.01)
segment = torch.tensor([-1.5, 1]) # 用于绘制线段的两个端点

# d2l.use_svg_display()
_, axes = plt.subplots(1, 3, figsize=(9, 3))

for ax, func, name in zip(axes, [f, g, h],
                          ['f(x) = 0.5x²', 'g(x) = cos(πx)', 'h(x) = exp(0.5x)']):
    _, ax = common.plot([x, segment], [func(x), func(segment)], axes=ax,
                        title = name, show_internally=False)

plt.tight_layout() # 自动调整子图参数,以避免标签、标题等元素重叠或溢出
plt.show()

如图所示,

  • 余弦函数 → 非凸
  • 抛物线函数 和 指数函数 → 凸
  • 前提条件:定义域 \mathcal{X}必须是凸集。
    • 否则可能无法很好地界定 f(\lambda x + (1-\lambda) x') 的结果。

2.1.3. 詹森不等式(描述凸函数特性)

詹森不等式 (Jensen’s inequality):

  • 对于个凸函数 f ,函数的平均值​​不会小于​​平均值的函数。即
    • 函数的平均值 ≥ 平均的函数值
    • 最终得到的结果值(放大器结合分蛋糕):
      • 先放大后分 ≥ 先分后放大
      • 函数→放大器(假设为计算平方),分蛋糕→平均
    • 通俗理解 1​​:若先平均再计算函数值,得到的结果会比先计算函数值再平均​​要小​​(对于凸函数而言)。
    • 通俗理解 2​​:​​对于凸函数,先算平均再映射的结果,不会比先映射再算平均的结果更好。
      • 也就是 f(平均) <= 平均(f),即 f(E[X]) <= E[f(X)]。
  • 它是凸性定义的一种推广:

其中

  • \alpha_i :满足 \sum_i \alpha_i = 1 的非负实数
  • X :随机变量
  • 换句话说,凸函数的期望不小于期望的凸函数,其中后者通常是一个更简单的表达式。
  • 为了证明第一个不等式,我们多次将凸性的定义应用于一次求和中的一项。

詹森不等式的一个常见应用:用一个较简单的表达式约束一个较复杂的表达式。

  • 核心目的:​​
    • 当我们无法直接计算一个复杂的目标(比如 P(X))时,
    • 可以利用詹森不等式找到一个更简单的、可以计算的下界来替代它进行优化。
  • 例如,它可以应用于部分观察到的随机变量的对数似然。
  • 具体地说,由于 \int P(Y) P(X \mid Y) dY = P(X) ,所以

  • X:原始数据(要找模型的基础数据,即 指纹脚印等)
  • Y :典型的未观察到的随机变量(永远看不到的“隐藏角色”Y​​)
  • P(Y) :它可能如何分布的最佳猜测(对凶手身份的“先验猜测”)
  • P(X) :将 Y 积分后的分布
  • P(X | Y) :若凶手是某个特定的人,他留下这些证据的可能性”
    • (比如,如果是惯犯A,他留下这种脚印的概率有多高)
  • 例如,在聚类中 Y 可能是簇标签,而在应用簇标签时,P(X \mid Y) 是生成模型。
  • 核心难题:​​ 你真正想知道的是 P(X)(即综合所有可能的凶手后,出现这些证据的总概率),但直接计算它需要把所有可能的凶手都考虑一遍,这非常困难。
  • 公式 \int P(Y) P(X \mid Y) dY = P(X) 说的就是这件事:
    • ​​总的证据概率 P(X),等于对每一个可能的凶手 Y,计算“他是凶手的概率” × “他留下证据的概率”,然后把所有结果加起来。

2.2. 性质

看一下凸函数一些有趣的性质。

2.2.1. 局部极小值是全局极小值

凸函数的局部极小值也是全局极小值。下面用反证法给出证明。

  • 假设 x^{\ast} \in \mathcal{X} 是一个局部最小值,则存在一个很小的正值 p ,使得当 x \in \mathcal{X} 满足 0 < |x - x^{\ast}| \leq p 时,有 f(x^{\ast}) < f(x) 。
  • 现在假设局部极小值 x^{*} 不是 f 的全局极小值:存在 x' \in \mathcal{X}使得 f(x') < f(x^{\ast}) 。
  • 则存在 \lambda \in [0, 1) ,比如 \lambda = 1 - \frac{p}{|x^{\ast} - x'|} ,使得 0 < |\lambda x^{\ast} + (1-\lambda) x' - x^{\ast}| \leq p 。

然而,根据凸性的性质,有

这与 x^{\ast} 是局部最小值相矛盾。因此,不存在 x' \in \mathcal{X} 满足 f(x') < f(x^{\ast}) 。 综上所述,局部最小值 x^{\ast} 也是全局最小值。

例如,对于凸函数 f(x) = (x-1)^2 ,有一个局部最小值 x = 1,它也是全局最小值。

f = lambda x: (x - 1) ** 2
common.plot([x, segment], [f(x), f(segment)], 'x', 'f(x)')

  • 凸函数的局部极小值同时也是全局极小值这一性质,意味着:只要最小化函数,就不会“卡住”。
  • 但注意,可能会存在以下两种情况:
    • ① 有多个全局最小值
      • 函数 f(x) = \mathrm{max}(|x|-1, 0) 在 [-1, 1] 区间上都是最小值。
    • ② 不存在一个全局最小值
      • 函数 f(x) = \exp(x) 在 \mathbb{R} 上没有取得最小值。
        • 对于 x \to -\infty ,它趋近于 0,但是没有 f(x)=0 的 x 。

2.2.2. 凸函数的下水平集是凸的

  • 下水平集:\mathcal{S}_b ,所有让函数值 f(x)小于等于某个标准 b的点 x构成的集合。
    • 例子​​:f(x)是海拔高度,b=100米。那么 S_100就是所有海拔不超过100米的区域。

我们可以方便地通过凸函数的下水平集(below sets)定义凸集。 具体来说,给定一个定义在凸集 \mathcal{X} 上的凸函数 f ,其任意一个下水平集

    是凸的。可以快速证明一下:

    • 对于任何 x, x' \in \mathcal{S}_b (在下水平集里取两点)
    • 我们需要证明:当时 \lambda \in [0, 1] ,\lambda x + (1-\lambda) x' \in \mathcal{S}_b (在两点间任意取一点,假设是z)
    • 因为 f(x) \leq b 且 f(x') \leq b (两点海拔皆低于100)
    • 所以

    • 解释下公式:
      • f(z) = f(λx + (1-λ)x')(点z的海拔)
      • <= λf(x) + (1-λ)f(x')(连接点x和点x‘的线段在海平面上的投影高度)​
      • 直接带入前面区间的两点 x, x' ,可计算出结果也是 <= b,证明结束。

    2.2.3. 凸性和二阶导数(判断函数是否凸的方法)

    快速判断一个函数是否凸:其二阶导数(黑塞矩阵)是否 半正定。

    主要思想:

    • 把一个复杂的高维问题,转化为一系列简单的一维问题来解决。

    基本策略:(如果凸的话→二阶导≥0 / 半正定)

    • ​​一维情况(简单):​​ 对于一元函数 f(x),判断凸性很简单:
      • ​​它的“加速度”(二阶导数 f''(x))必须永远 >= 0​​。
      • 这意味着函数不能“向下弯”,只能“向上弯”或保持直线。
    • 多维情况(复杂):​​ 对于多元函数 f(x, y, z...),判断凸性要看它的 ​​Hessian 矩阵​​(可以理解为包含所有方向“加速度”的矩阵)是否 ​​半正定​​(H ⪰ 0)。
      • 半正定:
        • 用于描述 “只升不降” 的性质
        • 一个多元函数是凸的,当且仅当,把它任意“切片”后,得到的一元函数,也都是凸的。

    • 目标:​​ 告诉大家,我们最终要证明 ​​“多元函数f是凸的 ⇔ 它的Hessian矩阵H是半正定的”​​。

    • ​​内容:​​

      • ​​开头:​​ 直接给出了最实用的结论:要检查凸性,就去算 Hessian 矩阵 H,然后看它是否半正定 (H ⪰ 0)。比如 f(x) = 1/2 * ||x||²是凸的,因为它的 H是单位阵,显然是半正定的。

      • ​​中间:​​ 给出了更正式的定义:​​f是凸的 ⇔ 它的所有“一维切片”g(z)是凸的​​。这是连接一维和多维的关键桥梁!

      • ​​最后:​​ 先证明一维情况为什么 f''(x) ≥ 0意味着凸性。它用一个非常巧妙的数学式子(11.2.8)和极限定义,直接推导出了二阶导数必须非负。

    证明一维情况的另一面,并定义“切片函数”​​

    • ​​目标:​​ 补完一维情况的证明,并正式定义连接一维和多维的“桥梁函数”。

    • ​​内容:​​

      • ​​上半部分:​​ 证明了 f''(x) ≥ 0如何直接推导出凸函数的定义式。它利用了导数单调递增的性质和中值定理,通过代数推导,最终得到了凸函数的定义式 λf(b) + (1-λ)f(a) ≥ f(λa + (1-λ)b)。​​(这部分是严格的数学证明,如果觉得难,可以跳过,只需记住“二阶导非负 => 凸”这个结论就行)​​

      • ​​下半部分:​​ 提出了最重要的​​引理​​(桥梁):要判断多元函数 f的凸性,可以看由它定义的的一维函数 g(z) = f(zx + (1-z)y)是否是凸的。​​这其实就是对“任意切片”的数学定义。​

    利用“切片函数”完成最终证明​​

    • ​​目标:​​ 利用前两张图的结论,最终证明核心命题。

    • ​​内容:​​

      • ​​第一部分:​​ 证明了这个“桥梁”是双向的。即,如果多元函数 f是凸的,那么它的任意“切片” g(z)也是凸的。

      • ​​第二部分:​​ 完成了最终的逻辑链条:

        1. ​​已知:​​ 一维时,g(z)是凸的 ⇔ g''(z) ≥ 0。

        2. ​​计算:​​ 对 g(z) = f(zx + (1-z)y)求二阶导数,发现 g''(z) = (x-y)ᵀ H (x-y)。

        3. ​​连接:​​ 因此,g(z)是凸的 ⇔ (x-y)ᵀ H (x-y) ≥ 0对于所有x, y都成立。

        4. ​​结论:​​ 而这正是 ​​Hessian矩阵 H是半正定矩阵​​ 的定义

    2.3. 约束

    • 凸优化的一个很好的特性:能够让我们有效地处理约束(constraints)。
    • 即它使我们能够解决以下形式的约束优化(constrained optimization)问题:

    • f :目标函数
    • c_{i} :约束函数
    • 目标:​​ minimize f(x)-> 目标是​​让某个东西最少​​
      • (如“让总路程最短”或“让过路费最便宜”)
    • 限制条件:​​ c_i(x) ≤ 0-> 你有一堆​​必须遵守的规矩​
    • ​则 优化问题就是:​​在遵守所有规矩的前提下,最好地完成目标。

    例如

    • 第一个约束 c_1(\mathbf{x}) = \|\mathbf{x}\|_2 - 1 ,则参数 \mathbf{x} 被限制为单位球
      • ||x||₂是向量 x的模长,即 ​​“当前位置到坐标原点的距离”
      • ∴ ||x||₂ - 1 ≤ 0就意味着 ||x||₂ ≤ 1
      • 人话:整个行程路线,必须完全包含在一个以家(原点)为中心、半径为1公里的圆形区域内。不能跑到离家超过1公里以外的地方去
    • 第二个约束 c_2(\mathbf{x}) = \mathbf{v}^\top \mathbf{x} + b,那么这对应于半空间上所有的 \mathbf{x} 
      • 描述的是​​一条直线的一侧​​(二维情况下)。v是直线的法向量,决定方向,b是偏移量
      • 人话:比如,规定“行程不能进入城市的北区”。​​ 这条无形的界限就是那条直线,而“北区”就是被禁止的半空间。
    • 同时满足这两个约束等于选择一个球的切片作为约束集

    2.3.1. 拉格朗日函数 (综合考量函数)(解决​​约束优化问题​​)

    • 目标函数的梯度 将被 约束函数的梯度 所抵消
      • ​​“目标函数的梯度”​​ = 重力的方向(球想往哪儿滚)
      • ​​“约束函数的梯度”​​ = 墙壁推力的方向(墙壁阻止球往某个方向滚)
      • ​​“抵消”​​ = 二力平衡,球不动了,达到平衡状态
    • 这个平衡点,就是带约束条件下的最低点。也就是鞍点。
    • 拉格朗日函数 的鞍点 是原始约束优化问题 的最优解。

    就是爬山:

    • 约束是要走规定的山路(约束条件)
    • 我只想最短路径到达山顶(优化目标),所以选择直接走直线
    • 监督员就是拉格朗日乘子:过程中一直在把我往规定的山路上拉
      • 我偏得越严重,监督员就拉的程度越大
        • (c(x) > 0(严重偏离约束)时,需要大的 α来惩罚)
      • 我越没那么偏,监督员就拉的程度越小
        • (当 c(x)接近 0 但还未完全满足时,α会是一个适当的正值)
      • 最后达到平衡(找到拉格朗日函数 L的​​鞍点)

    若发现山路就是最佳路径,我自愿走找路上,无偏离倾向时:

    • 就是:“对于 c_i(x) < 0中任意 x,我们最终会选择 α_i = 0” 。
    • 即 “监督员”就会完全消失(α=0)
      • 因为我已经自觉遵守了规则,他无需施加任何拉力。

    假设

    • 目标:把一个球放地上,让它​​处在最低的位置​​(即 “优化目标”:最小化重力势能)
    • ​​约束条件​​:这个球​​必须被关在盒子里​​
    • 所以任务变成了:​​在这个盒子范围内,找到能让球停留的最低点
    • 效果:球会滚到最低的地方,重力将与盒子两侧对球施加的力平衡。
      • 即 重力与盒子内壁施加的力 相抵消。
      • 目标函数:重力
      • 约束函数:盒子内壁推回的力
      • ∴ 也可以解释为:目标函数(重力)的梯度 将被 约束函数的梯度 所抵消(由于墙壁的“推回”作用,需要保持在盒子内)。

    注意:任何不起作用的约束(即球停在中间没接触壁)都将无法对球施加任何力。

    • 翻译成数学:若最优解离约束边界还很远(即 c(x) < 0),则该约束就是“不起作用的”。此时,对应的拉格朗日乘子 α = 0(因为不需要力)
    • 这被称为​​互补松弛条件​​:
      • 要么约束生效 ( c_i(\mathbf{x}) = 0 ,且 α > 0)
      • 要么约束不起作用 ( c_i(\mathbf{x}) < 0 ,且 α = 0)

    这里我们简略拉格朗日函数 L 的推导,上述推理可以通过以下鞍点优化问题来表示:

    拉格朗日乘数 (Lagrange multipliers):(作用:确保约束被正确地执行)

    • 公式中的变量 \alpha_i(i=1,\ldots,n),代表约束力度。
    • 选择它们的大小足以确保所有 i 的 c_i(\mathbf{x}) \leq 0 。 例如,
      • 对于 c_i(\mathbf{x}) < 0 (最优解离约束边界还很远)中任意 \mathbf{x} ,最终会选择 \alpha_i=0 (约束不起任何作用)
      • 就是:发现山路就是最佳路径,我自愿走找路上,无偏离倾向的情况。
    • 此外,这是一个鞍点(saddlepoint)优化问题:想要使 L  同时满足以下两点
      • 相对于 \alpha_i 最大化(maximize)(\alpha_i 代表约束力度)
      • 相对于 \mathbf{x}  最小化(minimize)(即最小化 f(x))
    • 有大量的文献解释如何得出函数。这里只需知道 L 的鞍点 是原始约束优化问题 的最优解就足矣。

     简单的例子:

    2.3.2. 惩罚 (往回拉)(解决优化约束问题时的作弊技巧)

    解决水龙头流速问题:

    • 精确思路(硬约束):要流速精准恰好
      • 需精密仪器
      • 困难,且容错率低
    • 近似思路(软惩罚)​:尽量接近,允许少许偏差
      • 安装弹性装置,偏离时产生回弹力。偏得越远,回弹力越大
      • 也就是这里描述的内容思路(即 使用 “拉格朗日函数”这个“弹性装置”)
        • 新的目标 = 旧的目标 + 惩罚项​​
        • 即 ​​L(x) = f(x) + α * c(x)

    一种至少近似地满足约束优化问题的方法:采用拉格朗日函数 L 。

    • 除了满足 c_i(\mathbf{x}) \leq 0 之外,只需将 \alpha_i c_i(\mathbf{x}) 添加到目标函数 f(x) 。
    • 这样可以确保不会严重违反约束。

    实际例子:权重衰减

    • (多层感知机实现—— 动手学深度学习4.2~4.9_多层感知机开源代码-CSDN博客中的 权重衰减(L2正则化)防止过拟合)
    • 在目标函数中加入 \frac{\lambda}{2} |\mathbf{w}|^2 (惩罚项),以确保 \mathbf{w} 不会增长太大。
    • (该惩罚项,就是拉格朗日函数中的 α * c(x)!)
      • 若某个权重 w 变得很大,其平方 w²会更大,惩罚项的值就会急剧增大
      • 从而“警告”模型,让其收敛一点。
      • 模型为了降低总的损失,就会自发地把权重值控制在一个合理的范围内。
    • 使用约束优化的观点,可以看到,
      • 对于若干半径 r ,这将确保 |\mathbf{w}|^2 - r^2 \leq 0 
      • 通过调整 \lambda 的值,我们可以改变 \mathbf{w} 的大小

    添加惩罚(软约束/惩罚项)是确保近似满足约束的一种好方法。实践中也确实证明其比精确满足约束更可靠。此外,对于非凸问题,许多使精确方法在凸情况下的性质(例如,可求最优解)不再成立。

    2.3.3. 投影 (找最近)(满足约束条件的另一种策略)

    满足约束条件的另一种策略是投影(projections)。

    例子1:梯度裁剪(防止“步子太大”)

    •  ||g|| :梯度向量的长度
    • θ​:设定的值(界限)
    • ​相当于要求梯度向量 g必须在一个“半径为 θ 的球”内
    • 也就是说:这就是 \mathbf{g} 在半径为 \theta 的球上的投影(projection)。

    例子2:更一般的情况

    • 在凸集 \mathcal{X} 上的投影被定义以下公式,它是 \mathcal{X} 中离 \mathbf{X} 最近的点。

    • \mathcal{X} :允许待的区域(如,呼啦圈)
    • x :当前的位置(如,飞镖落下的点)
    • argmin: 这是一个数学符号,意思是“寻找一个能使后面表达式最小的值”
    • ||x - x'||: 两点之间的距离
    • 所以,整个式子的意思就是:
      • 在允许的区域 X里,找到一个点 x',使得这个点 x'到现在实际的位置 x的距离最小。
      • 这个点 x'就是投影点。

    图像解释:

    图11.2.4 Convex Projections

    如 图11.2.4:有两个凸集,一个圆和一个菱形。

    • 黄色:集合内的点
      • 在投影期间保持不变(因为本身就在集合内)
    • 黑色:集合之外的点
      • 投影到集合中 接近原始黑点 的红点位置(因为没在集合内,需投影回集合内)
    • 虽然对 L_{2} 的球面来说,方向保持不变,但一般情况下不需要这样。

    凸投影的一个用途:计算稀疏权重向量。在本例中,我们将权重向量投影到一个 L_{1} 的球上, 这是 图11.2.4中菱形例子的一个广义版本。

    机制对比:惩罚 vs 投影

    特性

    惩罚法

    投影法

    ​​核心动作​​

    ​​往回拉​​

    ​​找最近​​

    ​​工作方式​​

    ​​持续施加“修正力”​​

    ​​在越界时“瞬移”回边界​​

    ​​执行时机​​

    ​​在整个优化过程中持续作用​​

    ​​在每次迭代后进行检查和修正​​

    ​​约束满足度​​

    ​​软约束​​(近似满足,允许轻微越界)

    ​​硬约束​​(严格满足,绝不允许越界)

    ​​比喻​​

    ​​弹簧/橡皮筋​​

    ​​魔法结界/传送门​​

    小结

    在深度学习的背景下,

    • 凸函数的主要目的:帮助详细了解优化算法。
      • 由此得出梯度下降法和随机梯度下降法是如何相应推导出来的。
    • 凸集的

      • 交点是凸的

      • 并集不是

    • 詹森不等式:

      • “一个多变量凸函数的总期望值” >= “用每个变量的期望值计算这个函数的总值“

    • 一个二次可微函数是凸函数,当且仅当其Hessian(二阶导数矩阵)是半正定的。

    • 凸约束可以通过拉格朗日函数来添加。

      • 在实践中,只需在目标函数中加上一个惩罚即可。

    • 投影:映射到 凸集中 最接近原始点的点。

    3. 梯度下降 GD

    梯度下降(gradient descent)很少直接用于深度学习,但其是理解 随机梯度下降算法 的关键。

    • 例如,学习率过大,可能会导致优化问题发散,这种现象早已在梯度下降中出现。
    • 同样地,预处理(preconditioning)是梯度下降中的一种常用技术,还被沿用到更高级的算法中。 下面从简单的一维梯度下降开始。

    梯度下降算法:(下山策略)

    1. 感受坡度(计算梯度)​​:用脚感受脚下山坡向哪个方向最陡。该“最陡下坡方向”就是​​梯度​​。
      1. 一维时,就是导数 f'(x),它告诉你向左走还是向右走下山更快。
    2. 决定步幅(选择学习率 η)​​:决定每步迈多大。这个步长就是​​学习率 (η)​​。
      1. 步幅太大(η 太大)​​:可能会迈过头,冲到对面的山坡上,甚至越走越高。
      2. 步幅太小(η 太小)​​:下山速度会慢,很久都到不了谷底。
    3. 走一步(参数更新)​​:朝着感觉最陡的下坡方向,迈出一步。(对应 公式11.3.4)
      1. 公式11.3.4:x \leftarrow x - \eta f'(x)
    4. ​​重复​​:到了新位置后,停下来,再次用脚感受坡度,然后继续迈出下一步。如此反复。

    核心思想:​​

    • 沿着当前最陡的下坡方向(负梯度方向)移动,就能保证函数值下降。
    • 不断重复该过程,最终就能接近最低点。

    3.1. 一维梯度下降

    以下用【一维中的梯度下降】来严格证明:

    • 为什么梯度下降算法可以优化目标函数?
    • 即 ​​“为什么朝着负梯度方向迈一步,高度一定会下降”。

    考虑一类连续可微实值函数 f: \mathbb{R} \rightarrow \mathbb{R} , 利用泰勒展开,可以得到

    在当前位置x附近,整个复杂的山形,可 ​近似看成一条直线​​。
    该直线的斜率就是脚下感受到的坡度 f'(x)。
    所以, 移动一小段距离 ε 后,高度大概会变化 ε * f'(x)。
    ——
     f(x + ε) ≈ f(x) + εf'(x)是一个​ ​线性近似​​。它用直线(切线)来模拟函数在 x附近的行为。
    𝓞(ε²)这个项:代表的是“用直线模拟曲线所产生的所有误差”
    是因为函数是​ ​曲线​​而不是直线,所以用“直尺”去量所产生的​ ​误差​​

    比如 f(x) = x²,想知道在 x=1附近,f(1+ε)是多少。

    • 精确值(曲线真正的值)​​:f(1+ε) = (1+ε)² = 1 + 2ε + ε²
    • ​​线性近似(切线给出的值)​​:f(1) + εf'(1) = 1² + ε * (2 * 1) = 1 + 2ε

    现在对比一下:

    • 精确值:1 + 2ε + ε²
    • 近似值:1 + 2ε

    差值正好为 ε²!​​在该例子里,𝓞(ε²)这项就是 ε²。

    ∴ 这个公式更完整的写法是:

    • f(x + ε) = f(x) + εf'(x) + [我们忽略掉的高阶误差项]

    而 𝓞(ε²)就是用来描述这个误差项的大小的。

    𝓞(ε²)是一个数学符号,意思是“​​这个误差的量级大约是 ε 的平方​​”。它告诉我们两件重要的事:

    1. 误差有多小​​。当移动量 ε极小时,ε²会比 ε还要小得 多得多,比如 ε = 0.01,那么 ε² = 0.0001。即 当 ε很小时,线性近似非常精确。
    2. 在梯度下降法中,可以放心地忽略 𝓞(ε²)这项,因为​​学习率 η(也就是这里的 ε)通常很小​​。当选择 ε = -η f'(x)时,我们关心的是函数值下降的方向和趋势。只要一步的步长η足够小,由线性近似带来的误差 𝓞(η²)就会远远小于我们真正关心的那一项 η (f'(x))²,因此不会影响“下山”的大方向。
    • 即在一阶近似中,f(x+\epsilon) 可通过 x 处的函数值 f(x) 和一阶导数 f'(x) 得出。
    • 可以假设在负梯度方向上移动的 \epsilon 会减少 f。
    • 为简单起见,选择固定步长 \eta > 0 ,然后取 \epsilon = -\eta f'(x) 。将其代入泰勒展开式可以得到
    选择步长为  ε = -η f'(x)​​: ​ ​-f'(x)​​,即 ​ ​负的坡度方向​​。
    若坡度为正(f'(x) > 0,意味着 左低右高 /),则 -f'(x)为负,就会向左走(x↓)
    若坡度为负(f'(x) < 0,意味着 左高右低 \),则 -f'(x)为正,就会向右走(x↑)
    总之就是,总是在往“下坡”走。​
    • 即新的高度变成了:新高度 ≈ 旧高度 - η * (坡度)²,如公式11.3.4的标题注释。

    若其导数 f'(x) \neq 0 没有消失,就能继续展开,这是因为 \eta f'^2(x)>0 。此外,我们总是可以令 \eta 小到足以使高阶项变得不相关。因此,

    ​ ​公式 (11.3.2) 和 (11.3.3) f(x - η f'(x)) ≈ f(x) - η (f'(x))²​​:
    把移动的步子和方向代入那个近似公式里,就会发现新的高度变成了:
    ​ ​新高度 ≈ 旧高度 - η * (坡度)²​​
    ∵ η和 (坡度)²永远是正数  ∴ ​ ​新高度 < 旧高度​​ 总是成立!
    ​ ​这就严格证明了:
    只要坡度不为零,按照这个方法走一步,高度就一定会在这一步内下降

    这意味着,若我们使用 以下(公式11.3.4) 来迭代 x ,函数 f(x) 的值可能会下降:

    走一步(参数更新)​​:朝着感觉最陡的下坡方向,迈出一步。
    ​ ​x(新位置) = x(旧位置) - η(步长) * f'(x)(坡度)
    • 因此,在梯度下降中,首先选择初始值 x 和常数 \eta > 0 , 然后使用它们连续迭代 x ,直到停止条件达成。
    • 停止条件可设为:
      • 当梯度 |f'(x)| 的幅度足够小 或
      • 迭代次数达到某个值时

    下面展示如何实现梯度下降。

    • 为简单起见,选用目标函数 f(x)=x^2。
    • 尽管知道 x=0 时 f(x) 能取得最小值,但仍然使用这个简单的函数来观察的变化:
    • 导数(坡度)​​是 f'(x) = 2x
    • ​​更新公式​​是 x = x - η * 2x

    假设从 x=2开始,学习率 η=0.1:

    • 第一步:坡度=2*2=4。更新:x = 2 - 0.1 * 4 = 1.6。
      • 高度:2² = 4 → 1.6² = 2.56
    • 第二步:坡度=2*1.6=3.2。更新:x = 1.6 - 0.1 * 3.2 = 1.28。
      • 高度继续下降:1.6² = 2.56 → 1.28² = 1.6384
    • 如此反复,就会一步步走向谷底 x=0
    # %matplotlib inline
    import numpy as np
    import torch
    
    def f(x):  # 目标函数 f(x) = x²
        return x ** 2
    
    def f_grad(x):  # 目标函数的梯度(导数) f'(x) = 2x
        return 2 * x

    接下来,

    • 使用 x=10 作为初始值,并假设  \eta = 0.2 。
    • 使用梯度下降法迭代 x 共10次。

    可以看到,x 的值最终将接近最优解。

    def gd(eta, f_grad):
        x = 10.0 # 初始值
        results = [x]
        for i in range(10): # 迭代10次
            x -= eta * f_grad(x)
            results.append(float(x))
            print(f"epoch :{i+1},当前x={x:.7f}")
        print(f'epoch 10, x: {x:f}')
        return results
    
    results = gd(0.2, f_grad) # 传入0.2的学习率(步长大小),和对应导数(坡度)

    对进行 x 优化的过程可以绘制如下:

    def show_trace(results, f):
        """
        可视化梯度下降的轨迹
        results: 列表,存储梯度下降过程中每次迭代的x值
        f: 目标函数,接受x作为输入并返回函数值
        """
        # 确定绘图范围:取results中绝对值最大的x值,作为绘图边界
        n = max(abs(min(results)), abs(max(results)))
    
        # 生成平滑的函数曲线x坐标(从-n到n,步长0.01)
        f_line = torch.arange(-n, n, 0.01)
    
        # 绘制两个图形:
        # 1、目标函数的完整曲线(f_line对应的f(x))
        # 2、梯度下降过程中各点的位置(results对应的f(x))
        plot([f_line, results], # x轴数据:函数曲线x值 + 迭代点x值
            [[f(x) for x in f_line],      # 完整函数曲线y值
             [f(x) for x in results]],    # 迭代点对应的y值
            'x', 'f(x)',
            fmts=['-', '-o']) # 线型:实线表示函数,带圆点的实线表示迭代轨迹
    
    common.show_trace(results, f) # 显示梯度下降轨迹

    3.1.1. 学习率:需与批量大小匹配 (大批量→大学习率)

    学习率(learning rate):决定目标函数

    1. 能否收敛到局部最小值
    2. 何时收敛到最小值
    • 学习率 \eta 可由算法设计者设置。
    • 注意,
      • 学习率过小,会导致 x 的更新非常缓慢,需要更多的迭代。
      • 例如,考虑同一优化问题中 \eta = 0.05 的进度。
      • 如下所示,经过10个步骤后,仍然离最优解很远。
    common.show_trace(gd(0.05, f_grad), f) # 学习率改为0.05,可视化梯度下降轨迹

    • 相反,若学习率过高,\left|\eta f'(x)\right| 对于一阶泰勒展开式可能太大。
    • 即,(11.3.1) f(x + \epsilon) = f(x) + \epsilon f'(x) + \mathcal{O}(\epsilon^2) 中的 \mathcal{O}(\eta^2 f'^2(x)) 可能变得显著了。
    • 在这种情况下,x 的迭代不能保证降低 f(x) 的值。
    • 例如,当学习率为 \eta = 1.1 时,x 超出了最优解 x=0 并逐渐发散。
    common.show_trace(gd(1.1, f_grad), f) # 学习率改为1.1,可视化梯度下降轨迹

    3.1.2. 局部最小值

    • 为了演示非凸函数的梯度下降,
      • 考虑函数 f(x) = x \cdot \cos(cx) ,其中 c 为某常数。
      • 该函数
        • 是一个振荡函数,随着|x|增大,振幅线性增长
        • 周期约为 2π/(0.15π) ≈ 13.33
        • 因此,会有无穷多个局部最小值。
      • 根据所选的学习率,最终可能只会得到许多解中的其中一个。
      • 下面的例子说明了(不切实际的)高学习率如何导致较差的局部最小值:

    对函数 f(x) = x * cos(c*x) 求导,得到其梯度 f'(x):

    • 乘积法则:f(x) = u(x) * v(x) ,则 d(u*v)/dx = u' * v + u * v'
      • u(x) = x → u'(x) = 1
      • v(x) = cos(c*x)
    • 链式法则:对 v(x) = cos(c*x) 求导
      • 设内函数 g(x) = c*x,外函数 h(g) = cos(g),
        • g(x) = c*x       → g'(x) = c
        • h(g) = cos(g)  → h'(g) = -sin(c*x)
      • 则导数:v'(x) = h'(g) * g'(x) = -sin(c*x) * c

    f'(x) = u' * v + u * v'
           = 1 * cos(c*x) + x * (-sin(c*x) * c)
           = cos(c*x) - c * x * sin(c*x) = 最终梯度公式

    c = torch.tensor(0.15 * np.pi) # 预定义的常数 c = 0.15π ≈ 0.4712
    
    def f(x):  # 目标函数 f(x) = x * cos(c * x)
        return x * torch.cos(c * x)
    
    def f_grad(x):  # 目标函数的梯度 f'(x) = cos(c*x) - c*x*sin(c*x)
        return torch.cos(c * x) - c * x * torch.sin(c * x)
    
    common.show_trace(gd(2, f_grad), f) # 学习率设为2

    3.2. 多元梯度下降

    • 前面已讲过单变量的情况,现在考虑 \mathbf{x} = [x_1, x_2, \ldots, x_d]^\top 的情况。
    • 即目标函数 f: \mathbb{R}^d \to \mathbb{R} 将向量映射成标量。
    • 相应地,它的梯度也是多元的,它是一个由 d 个偏导数组成的向量:
    ∇f(x) 是一个​ ​由​ ​偏导数​​组成的 向量
    偏导数:“只考虑一个方向时的坡度”,比如:
    ∂f/∂x₁:若 只朝东(x₁方向)​​ 移动一小步,海拔会变化多少。这就是东边的坡度
    ∂f/∂x₂:若 只朝北(x₂方向)​​ 移动一小步,海拔会变化多少。这就是北边的坡度
    把各个方向上的坡度组合起来,就得到了指向“最陡上坡方向”的梯度。
    • 梯度中的每个偏导数元素 \partial f(\mathbf{x})/\partial x_i :代表了当输入 x_i 时 f 在 x 处的变化率

    与先前单变量的情况一样,可以对多变量函数使用相应的泰勒近似来思考。具体来说,

    换句话说,在 \boldsymbol{\epsilon} 的二阶项中,最陡下降的方向由负梯度 -\nabla f(\mathbf{x}) 得出。选择合适的学习率 \eta >0 来生成典型的梯度下降算法:

    知道了最陡的下坡方向(负梯度),开始迈出一步
    • ​​x​​          :当前所处位置(一个坐标,比如 [东经, 北纬])
    • ​​∇f(x)​  ​:在当前所处位置,最陡的​​上坡​​方向
    • ​​-∇f(x)​​:最陡的​​下坡​​方向
    • ​​η(学习率)​​:这一步要​​走多远​​。步长太大可能过头;步长太小则下山太慢
    • x - η∇f(x)​​ :从当前位置,沿着最陡下坡方向走 η 这么远,到达的新位置

    这个算法在实践中的表现如何呢?

    • 构造一个目标函数 f(\mathbf{x})=x_1^2+2x_2^2 ,
    • 输入:二维向量 \mathbf{x} = [x_1, x_2]^\top 
    • 输出:标量
    • 梯度:由 \nabla f(\mathbf{x}) = [2x_1, 4x_2]^\top 给出。
    • 初始位置: [-5, -2]

    通过梯度下降观察 x 的轨迹。此外仍需两个辅助函数:

    1. update函数,将其应用于初始值20次;
    2. 绘图函数 show_trace_2d():用于显示 x 的轨迹
    def train_2d(trainer, steps=20, f_grad=None):  #@save
        """用定制的训练机优化2D目标函数
        trainer : 训练函数,执行单步参数更新
        steps   : 训练步数(迭代次数)
        f_grad  : 梯度计算函数(可选)
        返回: 包含所有迭代点(x1, x2)的列表
        """
        # s1和s2是稍后将使用的内部状态变量
        # 初始化:从点(-5, -2)开始,s1和s2是为后续高级优化器预留的状态变量
        x1, x2, s1, s2 = -5, -2, 0, 0
        results = [(x1, x2)] # 存储轨迹点
        for i in range(steps):
            if f_grad: # 若有梯度函数,传入梯度进行计算
                x1, x2, s1, s2 = trainer(x1, x2, s1, s2, f_grad)
            else: # 否则直接使用训练器
                x1, x2, s1, s2 = trainer(x1, x2, s1, s2)
            results.append((x1, x2)) # 记录新位置
        print(f'epoch {i + 1}, x1: {float(x1):f}, x2: {float(x2):f}')
        return results
    
    def show_trace_2d(f, results):  #@save
        """显示优化过程中2D变量的轨迹
        f      : 目标函数,用于绘制等高线
        results: 优化轨迹点列表 [(x1, x2), ...]
        """
        # 1. 绘制优化轨迹(橙色圆点连线)
        ''' zip(*results)作用:进行数据转置
        把 原始数据(列表的列表):
                [ (x1_1, x2_1),
                  (x1_2, x2_2),
                  ... ]
        转换为 转置后:
                [ (x1_1, x1_2, ...), 
                  (x2_1, x2_2, ...) ]
        *results:将列表解包,相当于把 results的每个元素作为单独参数传递给 zip
        zip     :按位置重新组合元素
        效果:
            x1_coords = (-5, -4.0, -3.2, ...)   即 所有点的x坐标
            x2_coords = (-2, -1.2, -0.72, ...)  即 所有点的y坐标
        '''
        x1_coords, x2_coords = zip(*results)  # 将[(x1,y1),(x2,y2)...]拆分为两个列表
        plt.plot(x1_coords, x2_coords, '-o', color='#ff7f0e')
    
        # 2. 创建网格用于绘制等高线
        x1_range = torch.arange(-5.5, 1.0, 0.1)  # x1从-5.5到1.0,步长0.1
        x2_range = torch.arange(-3.0, 1.0, 0.1)  # x2从-3.0到1.0,步长0.1
        # 通过 torch.meshgrid()生成两个网格坐标矩阵,包含所有网格点的 (x1,x2) 坐标
        x1_grid, x2_grid = torch.meshgrid(x1_range, x2_range, indexing='ij')
    
        # 3. 计算网格点上目标函数值并绘制等高线
        z = f(x1_grid, x2_grid)  # 计算每个网格点的函数值 (在每个网格点计算的函数值矩阵)
        plt.contour(x1_grid, x2_grid, z, colors='#1f77b4') # 蓝色等高线
        ''' 会绘制出一系列蓝色闭合曲线:
        每条曲线表示函数值相等的点(等高线)
        曲线越密集表示坡度越陡
        中心的闭合圈对应最小值点(本例中为 (0,0))
        '''
    
        # 4. 设置坐标轴标签
        plt.xlabel('x1')
        plt.ylabel('x2')
        plt.title('2D Gradient Descent Trajectory')
        plt.grid(True, linestyle='--', alpha=0.7)
        plt.show()
    • 接下来,观察学习率 \eta = 0.1 时优化变量 x 的轨迹。
    • 可以看到:
      • 经过20步之后,x 的值接近其位于 [0, 0] 的最小值。
      • 虽然进展相当顺利,但相当缓慢。

     f(x) = x₁² + 2x₂²的求导过程:

    1. 梯度定义

    2. 对 x₁ 求偏导

    3. 对 x₂ 求偏导

    4. 最终梯度

    将两个偏导数组合成梯度向量:

    这就是函数 f(x) = x₁² + 2x₂²在任意点 (x₁, x₂)处的梯度。

    ∴ 这里的梯度下降更新公式:

    5. 几何意义

    梯度向量 [2x₁, 4x₂]告诉我们:

    • ​​方向​​:函数在点 (x₁, x₂)处最陡上升的方向

    • ​​大小​​:坡度有多陡

    def f_2d(x1, x2):  # 目标函数 f(x) = x₁² + 2x₂²
        return x1 ** 2 + 2 * x2 ** 2
    
    def f_2d_grad(x1, x2):  # 目标函数的梯度:[∂f/∂x1, ∂f/∂x2] = [2x1, 4x2]
        return (2 * x1, 4 * x2)
    
    def gd_2d(x1, x2, s1, s2, f_grad):
        """标准的梯度下降更新规则
        x1, x2: 当前参数值
        s1, s2: 状态变量(在此简单GD中未使用)
        f_grad: 梯度计算函数
        返回: 更新后的参数和状态 (new_x1, new_x2, 0, 0)
        """
        g1, g2 = f_grad(x1, x2) # 计算梯度
        # 梯度下降更新:x ← x - η * ∇f(x)
        new_x1 = x1 - eta * g1
        new_x2 = x2 - eta * g2
        return (new_x1, new_x2, 0, 0)  # 返回新参数,状态重置为0
    
    eta = 0.1 # 学习率
    # 执行梯度下降并可视化结果
    results = common.train_2d(gd_2d, f_grad=f_2d_grad)
    common.show_trace_2d(f_2d, results)

    3.3. 自适应方法

    如(3.1.1. 学习率)中所示,选择“恰到好处”的学习率 \eta 很棘手。

    • 若把它选得太小,就没有什么进展;
    • 若太大,得到的解就会振荡,甚至可能发散。

    我们期望可以自动确定 \eta ,或者完全不必选择学习率。也就是说,除了考虑目标函数的值和梯度、还考虑它的曲率的二阶方法。

    3.3.1. 牛顿法

    牛顿法:不仅要感受坡度,还要感受地面的弯曲程度(二阶导数)​​。

    • 核心思想:​​利用二阶导数(曲率)信息动态调整步长​​(自动计算最优步长​​),相比梯度下降能更智能地确定更新量。
    • 实际应用情况:
      • 简单问题:收敛快
      • 深度学习:计算成本高
      • 但思想启发了许多现代高级优化算法(如AdaGrad, Adam等),这些算法试图以更低的成本模拟牛顿法的优点。
    • 困境:计算代价太高​​
      • ​​Hessian矩阵太大​​
      • 求逆困难​​

    Hessian矩阵(H)​​:用于描述​​各个方向上的弯曲程度​​。

    回顾一些函数 f: \mathbb{R}^d \rightarrow \mathbb{R} 的泰勒展开式,事实上我们可以把它写成

    ε:移动的距离,即位移向量
    ​ ​∇f(x)(梯度)​:​ ​一阶信息​​
    εᵀ ∇f(x):​ ​核心的一阶预测​​。点乘 εᵀ确保了只有沿着风向(从市中心指向小镇)的分量才被计算在内
    ∇²f(x)(Hessian矩阵):二阶信息​​,描述了变化趋势​ ​本身是如何变化的
    1/2 εᵀ ∇²f(x) ε:更精细的二阶修正​
    ​。(修正误差) 
    O(‖ε‖³):无法预料的突发情况影响 (当预测时间不长(ε很小)、区域不大时,该影响极小,可忽略)​
    • ​​一阶近似(梯度下降法的基础):预测温度 ≈ 当前温度 + 风的影响​
      • ​​优点​​:计算简单快捷
      • ​​缺点​​:不够精确,因为它忽略了地形等复杂因素。这就像梯度下降法,只知道往下走,但不知道步子该迈多大
    • 二阶近似(牛顿法的基础)​:预测温度 ≈ 当前温度 + 风的影响 + 地形的修正
      • 优点​​:预测非常精确
      • ​​缺点​​:获取“气候地形图”(Hessian矩阵)和计算修正值非常困难且昂贵
    • 总结:
      • 任何一个平滑的、复杂的函数 f在某个点 x的附近,其行为都可以被分解为:
        • 一个常数(当前值)​
        • ​​一个线性部分(当前的变化趋势/梯度)
        • 一个弯曲部分(趋势的变化率/Hessian)
      • 在优化算法中的应用:​
        • 梯度下降​​ 只用了第1、2部分(知道当前高度和坡度),所以像盲人,只能试探着下山。
        • 牛顿法​​ 用了第1、2、3部分(还知道了地面的弯曲程度),所以能“看见”整个山谷的形状,从而能直接计算出最优的步长,快速到达谷底。

    为避免繁琐的符号,我们将 \mathbf{H} \stackrel{\mathrm{def}}{=} \nabla^2 f(\mathbf{x}) 定义为 f 的Hessian,是 d×d 矩阵。

    • 当 d 的值很小且问题简单时,\mathbf{H} 很容易计算。
    • 但是对于深度神经网络而言,考虑到 \mathbf{H} 可能非常大,\mathcal{O}(d^2) 个条目的存储代价会很高,此外通过反向传播进行计算可能雪上加霜。
    • 然而,姑且先忽略这些考量,看看会得到什么算法。

    毕竟,f 的最小值满足 \nabla f = 0 。 遵循 2.4节中的微积分规则, 通过取 \boldsymbol{\epsilon} 对 (11.3.8)的导数, 再忽略不重要的高阶项,我们便得到

    ε = - H⁻¹ * ∇f(x)
    • ∇f(x):还是原来的坡度(梯度)
    • H⁻¹:这是Hessian矩阵的逆,可以理解为 ​​“自适应步长调节器”​​
      • ​​H⁻¹的作用:根据地形曲率,自动计算出一个“恰到好处”的步长

    也就是说,作为优化问题的一部分,需要将Hessian矩阵 \mathbf{H} 求逆。

    举一个简单的例子:用 f(x) = \frac{1}{2} x^2 这个简单的碗状函数举例。

    • 我们有
      • 梯度(坡度)​​: \nabla f(x) = x 
      • ​​Hessian(曲率)​​: \mathbf{H}=1 (因为二阶导数是1,表示这个碗的弯曲程度是均匀的)
    • 因此,对于任何 x ,我们可以获得 \epsilon = -x 。
      • 牛顿法步长​​:ε = - H⁻¹ * ∇f(x) = - (1)⁻¹ * x = -x
      • 效果​​:
        • 无论从哪个点 x开始,牛顿法都会直接让你一步跳到 x + ε = x - x = 0,也就是碗底最低点。
        • 因为牛顿法通过曲率信息“知道”这个碗是对称且均匀的,它能够精确计算出需要迈多大一步才能直达谷底。
    • 换言之,单单一步就足以完美地收敛,而无须任何调整。 我们在这里比较幸运:泰勒展开式是确切的,因为 f(x+\epsilon)= \frac{1}{2} x^2 + \epsilon x + \frac{1}{2} \epsilon^2 。

    下面看看其他问题。

    示例一:凸双曲余弦函数
    • 给定一个凸双曲余弦函数 c ,其中 c 为某些常数。
    • 可以看到经过几次迭代后,得到了 x=0 处的全局最小值:

    双曲函数求导

    目标函数:f(x) = cosh(c*x)

    • ​​一阶导数​​:

      f'(x) = d/dx [cosh(c*x)] = c * sinh(c*x)

      (双曲余弦的导数是双曲正弦)

    • ​​二阶导数​​:

      f''(x) = d/dx [c * sinh(c*x)] = c² * cosh(c*x)

      (双曲正弦的导数是双曲余弦)

    c = torch.tensor(0.5) # 常数系数
    
    def f(x):  # O目标函数:双曲余弦函数,形状类似开口向上的抛物线
        return torch.cosh(c * x)
    
    def f_grad(x):  # 目标函数的梯度(一阶导数)
        # f'(x) = d/dx [cosh(c*x)] = c * sinh(c*x)
        return c * torch.sinh(c * x) # 双曲正弦函数是双曲余弦的导数
    
    def f_hess(x):  # 目标函数的Hessian(二阶导数,即 Hessian标量)
        # f''(x) = d/dx [c * sinh(c*x)] = c² * cosh(c*x)
        return c**2 * torch.cosh(c * x) # 双曲余弦的二阶导仍是双曲余弦
    
    def newton(eta=1):
        """牛顿法优化器
        eta(η):控制更新步长的缩放因子
                通常设为1,不稳定时可降低0.1-0.5
        """
        x = 10.0  # 初始点
        results = [x]  # 记录轨迹
        for i in range(10):
            # 牛顿法更新公式:x ← x - η * f'(x)/f''(x)
            ''' 牛顿法更新公式解析
            数学形式:x_{k+1} = x_k - η * [∇²f(x_k)]⁻¹ * ∇f(x_k)
            对于单变量情况:
            [∇²f(x_k)]⁻¹简化为 1/f''(x_k)
            因此更新公式简化为 x_k - f'(x_k)/f''(x_k)
            '''
            x -= eta * f_grad(x) / f_hess(x)
            results.append(float(x))
        print('epoch 10, x:', x)
        return results
    
    common.show_trace(newton(), f)

    • 函数形状:平滑的开口向上曲线
    • 牛顿法特性:由于函数是严格凸的,牛顿法会快速收敛到全局最小值
    示例二:非凸函数
    • 比如 f(x) = x \cos(c x) ,c 为某些常数。
    • 注意:
      • 在牛顿法中,最终都要除以Hessian。
      • 也就是说,若二阶导数为负,则 f 的值可能会趋于增加。
      • 这是该算法的致命缺陷!下面看看实践中会发生的情况:

    振荡函数求导

    目标函数:f(x) = x * cos(c*x)

    • ​​一阶导数​​(乘积法则):

      f'(x) = d/dx [x] * cos(c*x) + x * d/dx [cos(c*x)]

      = 1 * cos(c*x) + x * (-c * sin(c*x))

      = cos(c*x) - c*x*sin(c*x)

    • ​​二阶导数​​:

      f''(x) = d/dx [cos(c*x)] - c * d/dx [x*sin(c*x)]

      = -c*sin(c*x) - c*[sin(c*x) + x*c*cos(c*x)]

      = -2c*sin(c*x) - c²x*cos(c*x)

    c = torch.tensor(0.15 * np.pi) # 常数系数
    
    def f(x):  # 目标函数:振荡函数
        # c:控制函数振荡频率(值越大函数振荡越剧烈)
        return x * torch.cos(c * x) # 产生周期性振荡的函数
    
    def f_grad(x):
        ''' 目标函数的梯度(一阶导数)
        目标函数:f(x) = x * cos(c*x)
        一阶导数(乘积法则):
        f'(x) = d/dx [x] * cos(c*x) + x * d/dx [cos(c*x)]
              = 1 * cos(c*x) + x * (-c * sin(c*x))
              = cos(c*x) - c*x*sin(c*x)
        '''
        return torch.cos(c * x) - c * x * torch.sin(c * x)
    
    def f_hess(x):
        ''' 目标函数的Hessian(二阶导数)
        目标函数:f(x) = x * cos(c*x)
        二阶导数:
        f''(x) = d/dx [cos(c*x)] - c * d/dx [x*sin(c*x)]
               = -c*sin(c*x) - c*[sin(c*x) + x*c*cos(c*x)]
               = -2c*sin(c*x) - c²x*cos(c*x)
        '''
        return - 2 * c * torch.sin(c * x) - x * c**2 * torch.cos(c * x)
    
    common.show_trace(newton(), f)      # 学习率为1.0

    这发生了明显的错误。想要修正它有两个策略:

    • 策略一:用取Hessian的绝对值来修正。
    • 策略二:重新引入学习率。
      • 这似乎违背了初衷,但不完全是——拥有二阶信息可使我们
        • 在曲率较大时保持谨慎,
        • 而在目标函数较平坦时则采用较大的学习率。

    下面看看在学习率稍小的情况下它是如何生效的,比如 \eta = 0.5 。如图所示,我们有了一个相当高效的算法。

    common.show_trace(newton(0.5), f)   # 学习率为0.5

    • 函数形状:周期性振荡的波形
    • 牛顿法表现:
      • 当  η=1   时:可能因步长过大而在极值点附近振荡
      • 当 η=0.5 时:减小步长后能更稳定收敛

    黑塞矩阵获取过程

    • 对于单变量函数,Hessian就是二阶导数:
      • 双曲函数:H = c² * cosh(c*x)
      • 振荡函数:H = -2c*sin(c*x) - c²x*cos(c*x)
    • 对于多变量函数时,Hessian是一个对称矩阵:
      H = [[∂²f/∂x₁², ∂²f/∂x₁∂x₂],
           [∂²f/∂x₂∂x₁, ∂²f/∂x₂²]]

    牛顿法 vs. 梯度下降

    特性

    梯度下降法

    牛顿法

    ​​使用信息​​

    一阶导数(坡度)

    一阶导数 + 二阶导数(坡度 + 曲率)

    ​​步长选择​​

    需要手动设定学习率 η

    ​​自动计算​​最优步长 H⁻¹

    ​​收敛速度​​

    较慢,小步迭代

    ​​极快​​,尤其对于简单函数可能一步到位

    ​​计算成本​​

    低

    ​​非常高​​(需计算和求逆Hessian矩阵)

    牛顿法的困境:计算代价太高​​。

    • ​​Hessian矩阵太大​​:深度学习模型可能有数百万甚至数十亿的参数。Hessian矩阵的尺寸是 [参数数量 x 参数数量]。对于一个100万参数的模型,Hessian矩阵将有 ​​1万亿个元素​​,根本无法存储和计算。
    • 求逆困难​​:对如此巨大的矩阵求逆,在计算上是不可行的。

    核心思想:

    1. ​​牛顿法​​是一种更强大的优化算法,它通过利用​​二阶导数(曲率)信息​​,能​​自动计算最优步长​​,从而解决梯度下降中“学习率难选”的问题。
    2. 对于简单问题,牛顿法收敛速度极快。
    3. ​​但对于深度学习,牛顿法直接应用的计算成本高得无法承受。​​
    4. 牛顿法的思想启发了许多现代高级优化算法(如AdaGrad, Adam等),这些算法试图以更低的成本模拟牛顿法的优点。

    所以,牛顿法更像是一个“理想中的完美算法”,为我们指明了改进方向,尽管它本身不适用于深度学习。

    3.3.2. 收敛性分析(牛顿法的收敛速度)

    牛顿法在接近最优解时具有“二次收敛”特性​​,这意味着:

    1. 前期可能较慢​​:离解远时,二阶信息可能不准确
    2. 后期极快​​:一旦进入解的"邻域",误差会以平方速度爆炸性缩小
    3. 需要函数"友好"​​:要求函数光滑、高阶导数有界

    猜价格

    • 梯度下降法(普通人猜)​​:
      • 每次根据“高了”或“低了”的提示,固定调整一定金额(比如每次调100元)。
      • 越接近真实价格时,调整幅度不变,可能反复横跳。
    • 牛顿法(聪明人猜)​​:
      • 不仅听提示,还会​​分析“误差有多大”​​,然后按比例调整。
      • 离得远时大胆调整,离得近时精细微调。

    总结:牛顿法就是,越接近答案,误差会以“平方速度”缩小。

      以部分目标凸函数 f 为例,分析其牛顿法收敛速度。

      • 这些目标凸函数三次可微,且二阶导数不为零,即 f'' > 0 。

      由于多变量情况下的证明是对 以下一维参数情况证明的直接拓展,对理解该问题不能提供更多帮助,因此这里省略了多变量情况的证明。

      • x^{(k)} :x 在第 k^\mathrm{th} 次迭代时的值
      • e^{(k)} \stackrel{\mathrm{def}}{=} x^{(k)} - x^* :表示 k^\mathrm{th} 迭代时与最优性的距离,也就是误差,或者说是差距
        • 定义误差:e⁽ᵏ⁾ = x⁽ᵏ⁾ - x*,表示 第k次猜的价格 x⁽ᵏ⁾ 和 真实价格 x* 的差距
      • 通过泰勒展开,我们得到条件 f'(x^*) = 0 可以写成
      最优解 x*处梯度为0(f'(x*) = 0)
      而 x* = x⁽ᵏ⁾ - e⁽ᵏ⁾,所以把 f'(x*)在 x⁽ᵏ⁾处展开

      这对某些 \xi^{(k)} \in [x^{(k)} - e^{(k)}, x^{(k)}] 成立。 将上述展开除以 f''(x^{(k)}) 得到

      得到误差关系

      回想之前的方程 x^{(k+1)} = x^{(k)} - f'(x^{(k)}) / f''(x^{(k)}) 。 代入这个更新方程,取两边的绝对值,我们得到

      得出收敛速度 ①

      因此,每当我们处于有界区域 \left|f'''(\xi^{(k)})\right| / (2f''(x^{(k)})) \leq c , 我们就有一个二次递减误差

      得出收敛速度 ②

      另一方面,优化研究人员称之为“线性”收敛,而将 \left|e^{(k+1)}\right| \leq \alpha \left|e^{(k)}\right| 这样的条件称为“恒定”收敛速度。

      • 注意:我们无法估计整体收敛的速度,但是一旦我们接近极小值,收敛将变得非常快。
      • 另外,这种分析要求 f 在高阶导数上表现良好,即确保 f 在如何变化它的值方面没有任何“超常”的特性。
      “二次收敛”的魔力,​​举例说明​​:

      假设 c = 1,初始误差 e⁽⁰⁾ = 0.1

      • 第1次迭代:e⁽¹⁾ ≤ (0.1)² = 0.01
      • 第2次迭代:e⁽²⁾ ≤ (0.01)² = 0.0001
      • 第3次迭代:e⁽³⁾ ≤ (0.0001)² = 0.00000001

      如上所示:误差以平方速度缩小,从0.1到1e-8只需3步。

      对比其他方法的收敛速度

      方法

      收敛速度

      比喻

      数学表达

      ​​梯度下降​​

      线性收敛

      0.1 → 0.09 → 0.081 → 0.073 → ...

      (缓慢线性下降)

      每次固定缩短10%误差

      e⁽ᵏ⁺¹⁾ ≈ 0.9 × e⁽ᵏ⁾

      ​​牛顿法​​

      二次收敛

      0.1 → 0.01 → 0.0001 → 0.00000001 → ...

      (急剧平方下降)

      每次误差平方级缩小

      e⁽ᵏ⁺¹⁾ ≈ (e⁽ᵏ⁾)²

      3.3.3. 预处理

      • 使用统一的学习率η的话,会出现梯度下降中的“尺度不匹配”问题:
      • (用同样的力度调两个音响,效果不统一)
        • 音响A​​:调节旋钮灵敏,轻轻一转音量就变化很大(对应​​梯度大​​的参数)
          • 一动就会音量剧变,难以控制
        • ​​音响B​​:调节旋钮迟钝,需要很大力气才能改变音量(对应​​梯度小​​的参数)
          • 动后音量几乎不变,调整无效

      预处理解决方案:给每个参数配个“智能调音师”

      “预处理”:

      • 核心思想:​​不再使用统一的学习率,而是为每个参数定制个性化的步长​​。
      • 作用:改善 “计算和存储完整的Hessian非常昂贵” 这个问题。
      • 它回避了计算整个Hessian,而只计算“对角线”项,即如下的算法更新:

      • ∇f(x):每个参数当前的“音量偏差”(梯度)
      • diag(H):每个音响旋钮的“灵敏度”(Hessian矩阵的对角线元素,代表曲率)
      • diag(H)⁻¹:智能调音师的“调节系数”——对灵敏的音响(曲率大)轻轻调,对迟钝的音响(曲率大)用力调

      虽不如完整的牛顿法精确,但聊胜于无。假设

      • 变量A:以 毫米 表示高度
      • 变量B:以 公里 表示高度
      • 这两种自然尺度:都以 米 为单位

      则此时参数化就出现了严重的不匹配。而使用预处理可以消除这种情况。

      • 梯度下降的有效预处理,相当于为每个变量选择不同的学习率(矢量 \mathbf{x} 的坐标)。
      • 后面一节会看到,预处理推动了随机梯度下降优化算法的一些创新。

      3.3.4. 梯度下降和线搜索

      梯度下降的一个关键问题:可能会 超过目标 或 进展不足

      • 解决这一问题的简单方法:结合使用线搜索和梯度下降。
      • 也就是说,使用 \nabla f(\mathbf{x}) 给出的方向, 然后进行二分搜索,以确定哪个学习率 \eta 使 f(\mathbf{x} - \eta \nabla f(\mathbf{x})) 取最小值。

      线搜索:在迈步前先沿着下坡方向试探几步,找到最佳落脚点​​。具体操作:

      1. 确定方向​​:梯度方向 -∇f(x)(最陡的下坡方向)
      2. 沿线搜索​​:在这个方向上尝试多个步长 η,计算 f(x - η∇f(x))
      3. 选择最优​​:选择让目标函数值最小的那个 η

      有关分析和证明,此算法收敛迅速(请参见 (Boyd and Vandenberghe, 2004))。

      • 深度学习中很少用线搜索。因为其实现方式太昂贵了。
      • 线搜索的每一步都需要评估整个数据集上的目标函数(尝试不同 η),而深度学习的数据集往往巨大,这种计算成本是无法承受的。

      预处理 vs 线搜索

      方法

      解决的问题

      核心思想

      深度学习适用性

      ​​预处理​​

      参数尺度不匹配

      为每个参数定制步长

      ✅ 广泛使用(如Adam优化器)

      ​​线搜索​​

      学习率选择困难

      沿梯度方向找最优步长

      ❌ 很少使用(计算成本太高)

      小结

      • 学习率的大小很重要:

        • 太大 → 使模型发散

        • 太小 → 没有进展

      • 梯度下降会可能陷入局部极小值,而得不到全局最小值。

      • 在高维模型中,调整学习率是很复杂的。

      • 预处理有助于调节比例。

      • 牛顿法在凸问题中一旦开始正常工作,速度就会快得多。

      • 对于非凸问题,不要不作任何调整就使用牛顿法。

      4. 随机梯度下降 SGD

      • 前面在(3. 梯度下降)中描述了梯度下降的基本原则。
      • 本节继续更详细地说明随机梯度下降(stochastic gradient descent)。
      # %matplotlib inline
      import math
      import torch
      import common

      标准梯度下降 vs 随机梯度下降

      特性

      标准梯度下降(全面质检)

      随机梯度下降(抽样质检)

      ​​计算成本​​

      O(n),随数据量线性增长

      ​​O(1),与数据量无关​​

      ​​更新频率​​

      慢(全量数据算完才更新)

      ​​快(单个数据就算完更新)​​

      ​​收敛稳定性​​

      稳定,方向准确

      ​​有噪声,但长期方向正确​​

      ​​适用场景​​

      小数据集

      ​​大数据集(深度学习主流)​

      4.1. 随机梯度更新

      在深度学习中,目标函数通常是 训练数据集中 每个样本的损失函数的平均值。

      • 给定 n 个样本的训练数据集
      • 假设 f_i(\mathbf{x})是关于索引 i 的训练样本的损失函数
      • 其中 \mathbf{x} 是参数向量。

      然后我们得到目标函数(平均损失):

      \mathbf{x} 的目标函数的梯度计算为:(标准梯度下降法,全面质检)

      若使用梯度下降法,则每个自变量迭代的计算代价为 \mathcal{O}(n) ,它随 n 线性增长。因此,当训练数据集较大时,每次迭代的梯度下降计算代价将较高。

      随机梯度下降(SGD):可降低每次迭代时的计算代价。(抽样质检)

      • 在随机梯度下降的每次迭代中,我们对数据样本随机均匀采样一个索引 i(随机抽取)
      • 其中 i\in\{1,\ldots, n\} ,并计算梯度 \nabla f_i(\mathbf{x}) 以更新 \mathbf{x} :

      • η :学习率

      可以看到,每次迭代的计算代价从梯度下降的 \mathcal{O}(n) 降至常数 \mathcal{O}(1) 。此外,我们要强调,随机梯度 \nabla f_i(\mathbf{x}) 是对完整梯度 \nabla f(\mathbf{x}) 的无偏估计(在统计意义上),因为

      ​ ​左式​​:若每天​ ​随机抽检1个零件​​,重复365天,这些抽样结果的平均质量
      ​ ​右式​​:全年所有零件的真实平均质量
      ​ ​结论​​:​ ​长期来看,抽样质检的平均结果等于全面质检的结果

      这意味着,平均而言,随机梯度是对梯度的良好估计。

      • 现在,将 随机梯度下降 与 标准梯度下降进行比较,
      • 方法是向梯度添加均值为0、方差为1的随机噪声,以模拟随机梯度下降。
      def f(x1, x2):  # 目标函数 f(x) = x₁² + 2x₂²
          return x1 ** 2 + 2 * x2 ** 2
      
      def f_grad(x1, x2):  # 目标函数的梯度:[∂f/∂x1, ∂f/∂x2] = [2x1, 4x2]
          return 2 * x1, 4 * x2
      
      def sgd(x1, x2, s1, s2, f_grad):
          """ 随机梯度下降更新
          x1, x2: 当前参数值
          s1, s2: 状态变量(为动量法等预留的,此处未使用)
          f_grad: 梯度计算函数
          返回: 更新后的参数和状态 (new_x1, new_x2, 0, 0)
          """
          g1, g2 = f_grad(x1, x2) # 计算真实梯度
          # 添加均值为0、标准差为1的高斯噪声
          # 模拟有噪声的梯度(添加随机噪声,模拟SGD的批次采样中批次梯度的不确定性)
          g1 += torch.normal(0.0, 1, (1,)).item() # 给x1梯度加噪声
          g2 += torch.normal(0.0, 1, (1,)).item() # 给x2梯度加噪声
          eta_t = eta * lr() # 计算当前步长(此处学习率固定,固定为 0.1 * 1 = 0.1)
          # 参数更新:x ← x - η * (∇f(x) + noise),即带噪声的梯度下降
          return (x1 - eta_t * g1, x2 - eta_t * g2, 0, 0) # 返回新参数和清零状态
      
      def constant_lr():
          return 1 # 固定学习率系数
      
      eta = 0.1         # 基础学习率
      lr = constant_lr  # 学习率函数(此处为常数)
      common.show_trace_2d(f, common.train_2d(sgd, steps=50, f_grad=f_grad))

      (3.2. 多元梯度下降)所演示的效果图

      如图所示,随机梯度下降中变量的轨迹比在(3.2. 多元梯度下降)中观察到的梯度下降中观察到的轨迹嘈杂得多。这是由于梯度的随机性质。

      • 也就是说,即使我们接近最小值,我们仍然受到通过 \eta \nabla f_i(\mathbf{x}) 的瞬间梯度所注入的不确定性的影响。
      • 即使经过50次迭代,质量仍然不那么好。甚至经过额外的步骤,仍没得到改善。
      • 这给我们留下了唯一的选择:改变学习率 η 。但是,
        • 若选择的学习率太小,则一开始就不会取得任何有意义的进展。
        • 若选择的学习率太大,将无法获得一个好的解决方案,如上所示。

      解决这些相互冲突的目标的唯一方法是在优化过程中动态降低学习率。

      这也是在sgd步长函数中添加学习率函数lr的原因。在上面的示例中,暂未启用学习率调度的任何功能,因为我们将相关的lr函数设置为常量。

      4.2. 动态学习率 (随时间推移衰减)

      用 与时间相关的学习率 \eta(t) 取代 η 增加了控制优化算法收敛的复杂性。但是需要弄清 η 的衰减速度:

      • 若太快,则会将过早停止优化。
      • 若减少的太慢,则会在优化上浪费太多时间。

      以下是随着时间推移调整 η 时使用的一些基本策略(稍后将讨论更高级的策略):

      使用 动态衰减的学习率
      • 分段常数 (piecewise constant)(常用实践):
        • 在特定节点降低学习率(如 优化进度停顿时),是训练深度网络的常见策略。
      • 指数衰减 (exponential decay)(谨慎使用):
        • 衰减过快,可能会导致算法收敛之前过早停止,需格外小心。
      • 多项式衰减 (polynomial decay)(首选推荐)(\alpha = 0.5):
        • 在凸优化的情况下,这种速率表现良好。(在理论和实践间取得了很好的平衡)

      策略

      核心思想

      工作方式

      优点

      缺点

      典型应用场景

      ​​1.

      分段常数衰减​​

      在特定节点(如损失不再下降时)​​突然降低​​学习率。

      根据阶段

      (如第30、50个epoch)

      将学习率降至一个新值

      直观、简单、有效;

      便于控制。

      需要人工预设调整时机,

      不够自适应。

      ​​训练深度网络​​的常用策略,

      当验证集损失进入平台期时使用。

      ​​2.

      指数衰减​​

      学习率随训练步数

      ​​连续、快速地衰减​​

      公式:

      η(t) = η₀ * e^(-λt)

      其中 t是步数,

      衰减速度由 λ控制

      衰减平滑,

      理论上有保证。

      ​​衰减过快​​,

      可能导致模型在收敛到良好解之前

      就“过早停止”学习。

      较少用于深度学习,

      在某些理论推导或简单任务中可见。

      ​​3.

      多项式衰减​​

      学习率随步数

      ​​以相对缓慢的速度衰减​​

      公式:

      η(t) = η₀ * (1 + βt)^(-α)

      其中 α=0.5是常见选择

      ​​衰减速度适中​​,

      既保证早期快速下降,又允许后期精细调优。​​

      理论上有保障​​,对于凸优化问题有良好的收敛性证明。

      需要选择超参数 α和 β

      ​​理论研究和实践中都皆受欢迎,

      尤其是在被证明是凸问题或近似凸的问题上。

      指数衰减 实践演示:

      def exponential_lr():
          ''' 指数学习率衰减策略
          学习率按 η = e^(-0.1*t)衰减
          调整指数系数(如-0.1)控制衰减速度
          衰减特点:
              初期下降较快(前几步学习率迅速减小)
              后期趋于平缓(当t较大时,e^(-0.1*t)接近0)
          适用场景:需要快速降低学习率的任务,但可能过早失去学习能力
          '''
          # 在函数外部定义,而在内部更新的全局变量
          global t  # 声明使用全局变量t
          t += 1    # 每次调用递增步数计数器
          return math.exp(-0.1 * t)  # 指数衰减公式: η = e^(-0.1*t)
      
      # 初始化全局变量
      t = 1                # 时间步计数器(从1开始)
      lr = exponential_lr  # 设置学习率函数为指数衰减
      common.show_trace_2d(f, common.train_2d(sgd, steps=1000, f_grad=f_grad))

      指数衰减​​:运行1000步展示长期衰减效果(学习率快速减小)
      • 正如预期的那样,参数的方差大大减少。但是,这是以未能收敛到最优解 \mathbf{x} = (0, 0) 为代价的。
      • 即使经过1000个迭代步骤,结果仍离最优解很远。事实上,该算法根本无法收敛。
      • 另一方面,若使用多项式衰减,其中学习率随迭代次数的平方根倒数衰减,那么仅在50次迭代之后,收敛就会更好。

      多项式衰减 演示:

      def polynomial_lr():
          ''' 多项式学习率衰减策略
          学习率按 η = (1 + 0.1*t)^(-0.5)衰减
          优先尝试 (1 + α*t)^(-0.5),调整α
          衰减特点:
              初期下降平缓(保护早期快速学习能力)
              后期持续稳定衰减(避免后期震荡)
          理论保障:对于凸优化问题可证明收敛性
          参数选择:
              分母中的 0.1控制衰减速度
              指数 -0.5是理论推荐值
          '''
          # 在函数外部定义,而在内部更新的全局变量
          global t  # 声明使用全局变量t
          t += 1    # 每次调用递增步数计数器
          return (1 + 0.1 * t) ** (-0.5)  # 多项式衰减公式: η = (1+0.1*t)^(-0.5)
      
      # 重新初始化全局变量
      t = 1               # 重置时间步计数器(因为切换策略了)
      lr = polynomial_lr  # 设置学习率函数为多项式衰减
      common.show_trace_2d(f, common.train_2d(sgd, steps=50, f_grad=f_grad)) # 迭代次数降到50

      ​ ​多项式衰减​​:仅50步展示初期行为(学习率温和下降)

      关于如何设置学习率,还有更多的选择。例如,

      • 先从较小的学习率开始,然后使其迅速上涨,再让它降低,尽管这会更慢。
      • 甚至可在较小和较大的学习率间切换。

      现在,让我们专注于可以进行全面理论分析的学习率计划,即凸环境下的学习率。

      • 对于真实的神经网络(高度非凸),我们​​无法保证​​训练算法能找到“最好”的解,只能期望找到一个“还不错”的解。
        • 因为总的来说,最大限度地减少非线性非凸问题是NP困难的。有关的研究调查,请参阅例如2015年Tibshirani的优秀讲义笔记。
        • ​​NP困难”​:计算复杂性术语,意思是​​从理论上讲,找到全局最优解的计算成本高得无法承受​​,是所有现代计算机都无法在合理时间内解决的问题。

      4.3. 凸目标的收敛性分析

      (从​​理论​​上告知:只要函数凸且学习率设置得当,SGD就能收敛。它为我们使用SGD提供了信心和基本原则。)

      • 在满足一定条件时,随机梯度下降能够收敛到(或接近)最优解​​。
      • 本章内容:从数学上证明以上观点。
        • 从数学上证明了SGD的有效性,
        • 并指出​​成功收敛的关键在于使用一个适当衰减的学习率计划​​。
        • 即 关键结论:学习率必须逐渐减小​。

      以下对凸目标函数的随机梯度下降的收敛性分析是可选读的,主要用于传达对问题的更多直觉。我们只限于最简单的证明之一 (Nesterov and Vial, 2000)。存在着明显更先进的证明技术,例如,当目标函数表现特别好时。

      假设所有 \boldsymbol{\xi} 的目标函数 f(\boldsymbol{\xi}, \mathbf{x}) 在 \mathbf{x} 中都是凸的。更具体地说,我们考虑随机梯度下降更新:

      其中 f(\boldsymbol{\xi}_t, \mathbf{x}) 是训练样本 f(\boldsymbol{\xi}_t, \mathbf{x}) 的目标函数:\boldsymbol{\xi}_t 从第 t 步的某个分布中提取, \mathbf{x} 是模型参数。用以下公式(11.4.7) 表示期望风险:

      R^* 表示对于 \mathbf{x} 的最低风险。最后让 \mathbf{x}^{*} 表示最小值(我们假设它存在于定义 \mathbf{x} 的域中)。在这种情况下,我们可以跟踪时间 t 处的当前参数 \mathbf{x}_{t} 和风险最小化器 \mathbf{x}^{*} 之间的距离,看看它是否随着时间的推移而改善:

      我们假设随机梯度 \partial_\mathbf{x} f(\boldsymbol{\xi}_t, \mathbf{x}) 的 L2范数受到某个常数 L 的限制,因此我们有

      我们最感兴趣的是 \mathbf{x}_{t} 和 \mathbf{x}^{*} 之间的距离如何变化的期望。事实上,对于任何具体的步骤序列,距离可能会增加,这取决于我们遇到的 \boldsymbol{\xi}_t 。因此我们需要点积的边界。因为对于任何凸函数 f ,所有 \mathbf{x} 和 \mathbf{y} 都满足 f(\mathbf{y}) \geq f(\mathbf{x}) + \langle f'(\mathbf{x}), \mathbf{y} - \mathbf{x} \rangle ,按凸性我们有

      将不等式 (11.4.9)和 (11.4.10)代入 (11.4.8)我们在时间 t + 1 时获得参数之间距离的边界,如下所示:

      这意味着,只要当前损失和最优损失之间的差异超过 \eta_t L^2/2 ,我们就会取得进展。由于这种差异必然会收敛到零,因此学习率 \eta_t 也需要消失。

      接下来,我们根据 (11.4.11)取期望。得到

      最后一步是对 t \in \{1, \ldots, T\} 的不等式求和。在求和过程中抵消中间项,然后舍去低阶项,可以得到

      请注意,我们利用了给定的 \mathbf{x}_1 ,因而可以去掉期望。最后定义

      因为有

      根据詹森不等式(令 (11.2.3)中 i = t ,\alpha_i = \eta_t/\sum_{t=1}^T \eta_t )和 R 的凸性使其满足的 E[R(\mathbf{x}_t)] \geq E[R(\bar{\mathbf{x}})] ,因此,

      将其代入不等式 (11.4.13)得到边界

      其中 r^2 \stackrel{\mathrm{def}}{=} \|\mathbf{x}_1 - \mathbf{x}^*\|^2 是初始选择参数与最终结果之间距离的边界。简而言之,收敛速度取决于随机梯度标准的限制方式(L)以及初始参数值与最优结果的距离(r)。请注意,边界由 \bar{\mathbf{x}} 而不是 \mathbf{x}_T 表示。因为 \bar{\mathbf{x}} 是优化路径的平滑版本。只要知道 r,L 和 T ,我们就可以选择学习率 \eta = r/(L \sqrt{T}) 。这个就是上界 rL/\sqrt{T} 。也就是说,我们将按照速度 \mathcal{O}(1/\sqrt{T}) 收敛到最优解。

      4.4. 随机梯度和有限样本

      (从​​实践​​角度说明:实际采用的​​无放回小批量抽样​​方式是一种更高效、更常用的策略。)

      在实际操作中,从数据集中 获取“随机梯度”的 两种抽样方式:

      • 有放回抽样​​:
        • 操作:从一副扑克牌中随机抽一张,记录后​​放回牌堆​​,再抽下一张。
        • 效果:同一张牌可能在同一次训练周期(epoch)内被多次抽到。
      • 无放回抽样​​:(通常更优) (深度学习默认使用)
        • 操作:洗牌后依次发牌​​,保证训练集中的每个样本在一个epoch内​​都被使用一次,且仅一次​​。
        • 实现方式:
          • 通常通过将数据集随机打乱(shuffle)后分成一个个小批量(mini-batch)来实现。
          • 即 打乱数据后按小批量训练。

      无放回抽样通常优于有放回抽样​​。

      • ​​方差更小​​:
        • 无放回抽样:能确保在一个epoch内更“均匀”地使用所有数据,计算出的梯度估计​​更稳定(方差更小)​​。
        • 有放回抽样:可能因为某些样本被重复抽到而引入更大的随机性。
      • ​​收敛更快​​:梯度估计更稳定意味着优化路径更平滑,往往能带来更快的收敛速度。

      到目前为止,在谈论随机梯度下降时,我们进行得有点快而松散。我们假设从分布 p(x, y) 中采样得到样本 x_i (通常带有标签 y_i ),并且用它来以某种方式更新模型参数。特别是,对于有限的样本数量,我们仅仅讨论了由某些允许我们在其上执行随机梯度下降的函数 \delta_{x_i} 和 \delta_{y_i} 组成的离散分布 p(x, y) = \frac{1}{n} \sum_{i=1}^n \delta_{x_i}(x) \delta_{y_i}(y) 。

      但是,这不是我们真正做的。在本节的简单示例中,我们只是将噪声添加到其他非随机梯度上,也就是说,我们假装有成对的 (x_i, y_i) 。事实证明,这种做法在这里是合理的(有关详细讨论,请参阅练习)。更麻烦的是,在以前的所有讨论中,我们显然没有这样做。相反,我们遍历了所有实例恰好一次。要了解为什么这更可取,可以反向考虑一下,即我们有替换地从离散分布中采样 n 个观测值。随机选择一个元素 i 的概率是 1/n 。因此选择它至少一次就是

      类似的推理表明,挑选一些样本(即训练示例)恰好一次的概率是

      这导致与无替换采样相比,方差增加并且数据效率降低。因此,在实践中我们执行后者(这是本书中的默认选择)。最后一点注意,重复采用训练数据集的时候,会以不同的随机顺序遍历它。

      小结

      • 对于凸问题,我们可以证明,对于广泛的学习率选择,随机梯度下降将收敛到最优解。

      • 对于深度学习而言,情况通常并非如此。但是,对凸问题的分析有助于深入了解如何进行优化,即逐步降低学习率,尽管不是太快。

      • 若学习率太小或太大,就会出现问题。实际上,通常只有经过多次实验后才能找到合适的学习率。

      • 当训练数据集中有更多样本时:

        • 计算梯度下降的每次迭代的代价更高

        • 因此这时候首选 随机梯度下降

      • 随机梯度下降的最优性保证在非凸情况下一般不可用,因为需要检查的局部最小值的数量可能是指数级的。

      5. 小批量随机梯度下降 mini-batch SGD

      到目前为止,我们在基于梯度的学习方法中遇到了两个极端情况:

      • (3. 梯度下降):使用完整数据集来计算梯度并更新参数
      • (4. 随机梯度下降):一次处理一个训练样本来取得进展

      二者各有利弊:

      • 每当数据非常相似时,梯度下降并不是非常“数据高效”。
      • 而由于CPU和GPU无法充分利用向量化,随机梯度下降并不特别“计算高效”。
      • 这暗示了两者之间可能有折中方案,这便涉及到小批量随机梯度下降(minibatch gradient descent)。

      5.1. 向量化和缓存

      深度学习使用小批量(mini-batch)计算​​,而非

      • 逐样本(batch_size=1)或
      • 全批量(batch_size=整个数据集)

      关键在于​​计算效率的优化​​,尤其是​​内存带宽与计算能力的平衡​​。


      性能对比示例  假设:

      • 计算一个样本的前向传播需要1μs
      • Python调用框架开销为10μs

      批量大小

      总计算时间

      有效计算占比

      1

      10 + 1 = 11μs

      9%

      128

      10 + 128 = 138μs

      93%


      小批量计算 对(硬件层面的关键矛盾​)解决方式如下 ↓

      通过​​数据局部性(data locality)​​优化:

      使用小批量的决策的核心是计算效率。 当考虑与多个GPU和多台服务器并行处理时,这一点最容易被理解。在这种情况下,我们需要向每个GPU发送至少一张图像。 有了每台服务器8个GPU和16台服务器,我们就能得到大小为128的小批量。

      (内存层级与带宽瓶颈)

      当涉及到单个GPU甚至CPU时,事情会更微妙一些: 这些设备有多种类型的内存、通常情况下多种类型的计算单元以及在它们之间不同的带宽限制。 例如,

      • 一个CPU有少量寄存器(register),L1和L2缓存,以及L3缓存(在不同的处理器内核之间共享)。
      • 随着缓存的大小的增加,它们的延迟也在增加,同时带宽在减少。
        • 计算单元直接从寄存器/L1缓存读取数据时速度最快
        • 若数据不在缓存中(cache miss),需从主内存加载,等待时间≈计算100次浮点运算
      • 可以说,处理器能够执行的操作远比主内存接口所能提供的多得多。

      存储类型

      容量

      延迟

      带宽

      典型用途

      ​​寄存器​​

      ~1KB

      0.5ns

      极高

      当前计算的临时变量

      ​​L1缓存​​

      ~32KB

      1ns

      高

      高频访问数据

      ​​L2缓存​​

      ~256KB

      3ns

      中

      次级高频数据

      ​​L3缓存​​

      ~50MB

      10ns

      较低

      多核共享数据

      ​​主内存​​

      >16GB

      100ns

      低

      所有数据存储

      (硬件层面的关键矛盾​)

      现代计算设备存在一个根本性矛盾:

      • 1、计算单元(CPU/GPU)的速度极快​
        • 例如:具有16个内核 和 AVX-512向量化的2GHz CPU每秒可处理高达 2 \cdot 10^9 \cdot 16 \cdot 32 = 10^{12} 个字节 (次浮点运算)。
        • 同时,GPU的性能很容易超过该数字100倍,即 GPU算力可达CPU的100倍以上。
      • 2、​​内存带宽严重不足
        • 中端服务器处理器的带宽 通常不超过100Gb/s,即不到处理器满负荷所需的十分之一。 更糟糕的是,并非所有的内存入口都是相等的:内存接口通常为64位或更宽(例如,在最多384位的GPU上)。
        • 因此读取单个字节会导致由于更宽的存取而产生的代价。即 读取单个字节可能需要搬运64位(8字节)或更宽的数据块。
      • 结果​​:处理器经常“饿着等数据”,大部分时间花在等待数据从内存加载到缓存/寄存器。

      (矩阵乘法的优化策略)

      其次,第一次存取的额外开销很大,而按序存取(sequential access)或突发读取(burst read)相对开销较小。 有关更深入的讨论,请参阅此维基百科文章。

      减轻这些限制的方法是使用足够快的CPU缓存层次结构来为处理器提供数据。 这是深度学习中批量处理背后的推动力。

      举一个简单的例子:矩阵-矩阵乘法。 比如 A=BC ,我们有很多方法来计算 A 。例如以下四种:

      计算方式

      内存访问次数

      缓存利用率

      适合场景

      ​​逐元素计算​​(\mathbf{A}_{ij} = \mathbf{B}_{i,:} \mathbf{C}_{:,j}^\top)

      O(mnp)

      极低

      不推荐

      ​​逐列计算​​(\mathbf{A}_{:,j} = \mathbf{B} \mathbf{C}_{:,j}^\top)

      O(mp)

      中等

      小规模数据

      简单地计算 \mathbf{A} = \mathbf{B} \mathbf{C}///

      ​​分块计算​​(将A,B分块后计算)

      O(mp/block_size)

      高

      ​​深度学习常用​

      • ① 逐元素计算(\mathbf{A}_{ij} = \mathbf{B}_{i,:} \mathbf{C}_{:,j}^\top​):通过点积进行逐元素计算
        • 每次计算一个元素 \mathbf{A}_{ij} 时,都需要将一行和一列向量复制到CPU中。
        • 更糟糕的是,由于矩阵元素是按顺序对齐的,因此当从内存中读取它们时,需要访问两个向量中许多不相交的位置。
      • ② 逐列计算(\mathbf{A}_{:,j} = \mathbf{B} \mathbf{C}_{:,j}^\top​):一次计算一列。同样,可以一次计算 A 一行 \mathbf{A}_{i,:}
        • 相对更有利。能在遍历 B 的同时,将列向量 \mathbf{C}_{:,j} 保留在CPU缓存中。
        • 它将内存带宽需求减半,相应地提高了访问速度。
      • ③ 简单地计算 \mathbf{A} = \mathbf{B} \mathbf{C} :表面上是最可取的,然而大多数矩阵可能不能完全放入缓存中。
      • ④ 分块计算(将A,B分块后计算​):将 B 和 C 分成较小的区块矩阵,然后一次计算 A 的一个区块
        • 提供了一个实践上很有用的方案,可以将矩阵的区块移到缓存中然后在本地将它们相乘。

      分块计算示例:

      将A,B分为16×16的块,每次计算:

      1. 将A和B的当前块加载到 L1缓存
      2. 计算该块的所有乘积(256次浮点运算复用同一批数据)
      3. 写回结果到C的对应块

      除了计算效率之外,Python和深度学习框架本身带来的额外开销也是相当大的。

      • 回想一下,每次执行代码时,Python解释器都会向深度学习框架发送一个命令,要求将其插入到计算图中并在调度过程中处理它。这样的额外开销可能是非常不利的。
      • 总而言之,最好用向量化(和矩阵)。
      # %matplotlib inline
      import numpy as np
      import torch
      import common
      from torch import nn
      
      timer = common.Timer()      # 初始化计时器
      A = torch.zeros(256, 256)   # A: 256x256零矩阵(用于存储结果)
      B = torch.randn(256, 256)   # B: 256x256随机矩阵
      C = torch.randn(256, 256)   # C: 256x256随机矩阵

      【逐元素计算】:即 按元素分配。遍历 B的行 和 C的列,计算点积后将值分配给 A 。

      # 测试一:【逐元素】计算A=BC
      timer.start()   # 开始计时
      for i in range(256):      # 遍历行
          for j in range(256):  # 遍历列
              # 计算 B的第i行 与 C的第j列 的点积
              A[i, j] = torch.dot(B[i, :], C[:, j])
      stop_time = timer.stop()    # 停止计时
      print(f"【逐元素】计算耗时:{stop_time}")

      【逐列计算】:执行按列分配,比逐元素计算更快。

      # 测试二:【逐列】计算A=BC
      timer.start()
      for j in range(256):  # 仅遍历列
          # 计算B与C的第j列的矩阵-向量乘积
          A[:, j] = torch.mv(B, C[:, j])  # mv = matrix-vector multiplication
      stop_time = timer.stop()
      print(f"【逐列】计算耗时:{stop_time}")

      【分块计算】:是最有效的方法。在一个区块中执行整个操作。

      • 在深度学习和科学计算中,​​完整的矩阵乘法(如 torch.mm或 A = B @ C),就是经过高度优化的分块计算,而​​不是简单的逐元素计算。

      下面看看它们各自的操作速度是多少。

      # 测试三:【一次性】计算A=BC (一次性完整矩阵乘法)
      timer.start()
      # 直接调用优化后的矩阵乘法
      A = torch.mm(B, C)  # mm = matrix-matrix multiplication
      stop_time = timer.stop()
      print(f"【一次性完整矩阵乘法】计算耗时:{stop_time}")

      计算三种方法的GigaFLOPs:(性能对比)

      min_time = 1e-6  # 1微秒下限(避免除以零,增加最小时间阈值)
      
      # 乘法和加法作为单独的操作(在实践中融合)即
      # 底层计算库(如BLAS、cuBLAS)会将矩阵乘法(GEMM) 和
      # 后续的加法操作(如偏置项相加)合并为一个复合操作,从而显著提升计算效率
      gigaflops = [2/max(i, min_time) for i in timer.times]
      print(f'performance in Gigaflops 性能对比(GFLOPS): \n'
            f'element 逐元素计算: {gigaflops[0]:.3f}, \n'
            f'column 逐列计算 : {gigaflops[1]:.3f}, \n'
            f'full 完整矩阵乘法: {gigaflops[2]:.3f}\n')

      方法

      内存访问模式

      缓存利用率

      框架优化

      逐元素

      每次访问不连续的行和列

      最差

      无

      逐列

      按列连续访问

      中等

      部分

      完整矩阵乘法

      分块访问

      最高

      完全优化

      • 在深度学习和科学计算中,​​完整的矩阵乘法(如 torch.mm或 A = B @ C)并不是简单的逐元素计算,而是经过高度优化的分块计算​​。

      5.2. 小批量

      之所以 读取数据的小批量,而非观测单个数据来更新参数:

      • 处理单个观测值需要执行许多单一矩阵-矢量(甚至矢量-矢量)乘法,这耗费很大,且对应深度学习框架也要巨大的开销。
      • 这既适用于计算梯度以更新参数时,也适用于用神经网络预测。
      • 也就是说,每当执行 \mathbf{w} \leftarrow \mathbf{w} - \eta_t \mathbf{g}_t 时,消耗巨大。其中

      我们可以通过将其应用于一个小批量观测值来提高此操作的计算效率。也就是说,将梯度替换为一个小批量而非单个观测值

      每次迭代随机采样一个批量 B

      下面看看这对 \mathbf{g}_t 的统计属性 产生的影响:

      • 由于 \mathbf{x}_t 和小批量 \mathcal{B}_t 的所有元素都是从训练集中随机抽出的,因此梯度的期望保持不变。另一方面,方差显著降低。
      • 由于小批量梯度由正在被平均计算的 b := |\mathcal{B}_t| 个独立梯度组成,其标准差降低了 b^{-\frac{1}{2}} 。 这本身就是一件好事,因为这意味着更新与完整的梯度更接近了。

      批量大小的选择原则

      1. ​​GPU并行​​:选择2的幂次方(如32/64/128)以适配硬件
      2. ​​内存限制​​:最大不超过显存容量
      3. 经验公式​​:

      直观来说,这表明选择大型的小批量 \mathcal{B}_t 将是普遍可行的。 然而,经过一段时间后,与计算代价的线性增长相比,标准差的额外减少是微乎其微的。 在实践中选择一个足够大的小批量,可提供良好的计算效率同时仍适合GPU的内存。下面,我们来看看这些高效的代码。 在里面执行相同的矩阵-矩阵乘法,但是这次将其一次性分为64列的“小批量”:

      timer.start()
      for j in range(0, 256, 64): # 一次性分为64列的“小批量”
          A[:, j:j+64] = torch.mm(B, C[:, j:j+64])
      timer.stop()
      print(f'performance in Gigaflops: block {2 / timer.times[3]:.3f}')

      显而易见,小批量上的计算基本上与完整矩阵一样有效。

      5.3. 读取数据集

      开始演示如何从数据中有效地生成小批量。

      • 下面使用NASA开发的测试机翼的数据集不同飞行器产生的噪声来比较这些优化算法。
      • 为方便起见,只使用前1500样本。
      • 数据已作预处理:我们移除了均值并将方差重新缩放到每个坐标为1。
      # 下载器与数据集配置
      # 为 time_machine 数据集注册下载信息,包括文件路径和校验哈希值(用于验证文件完整性)
      downloader = common.C_Downloader()
      DATA_HUB = downloader.DATA_HUB  # 字典,存储数据集名称与下载信息
      DATA_URL = downloader.DATA_URL  # 基础URL,指向数据集的存储位置
      
      DATA_HUB['airfoil'] = (DATA_URL + 'airfoil_self_noise.dat',
                                 '76e5be1548fd8222e5074cf0faae75edff8cf93f')

      def get_data_ch11(downloader, batch_size=10, n=1500):
          ''' 数据集加载并预处理:不同飞行器产生的噪音
          batch_size=10, 批量大小
          n=1500 只是用前n个样本
          '''
          # 从CSV加载空气动力学数据集(制表符分隔)
          data = np.genfromtxt(downloader.download('airfoil'),
                               dtype=np.float32, delimiter='\t')
          # 数据标准化:每个特征减去均值除以标准差,并转tensor
          data = torch.from_numpy((data - data.mean(axis=0)) / data.std(axis=0))
          # 创建数据迭代器(特征+标签):前n条样本,最后一列作为标签
          # load_array返回DataLoader对象
          data_iter = load_array((data[:n, :-1], data[:n, -1]),
                                     batch_size, is_train=True)
          return data_iter, data.shape[1]-1  # 返回迭代器和特征维度(总列数-1)

      5.4. 从零开始实现

      (线性回归实现 & softmax回归实现—— 动手学深度学习3.2~3.7_自己动手写线性回归-CSDN博客的 线性回归的从零开始实现)中已经实现过小批量随机梯度下降算法。

      • 这里将它的输入参数变得更加通用,主要是为了方便本章后面介绍的其他优化算法也可以使用同样的输入。具体来说:
        • 添加了一个状态输入states,并将超参数放在字典hyperparams中。
        • 此外,将在训练函数里对各个小批量样本的损失求平均,因此优化算法中的梯度不需要除以批量大小。
      def sgd(params, states, hyperparams):
          '''sgd优化器
          params:需要被优化的变量列表(模型参数列表)
          states:状态
          hyperparams:存放超参数的字典
          '''
          for p in params:
              # .sub_() 原地减法操作,直接修改参数值
              # 等效于 p.data = p.data - η·∇L,但更高效
              p.data.sub_(hyperparams['lr'] * p.grad) # 参数更新
              p.grad.data.zero_() # 梯度清零

      p.data :

      • ​​p​​ 是 torch.Tensor,可能带有梯度计算信息(requires_grad=True)
      • p.data​​ 是纯数值张量,直接修改它不会影响梯度计算图

      p.data.sub_(hyperparams['lr'] * p.grad) :

      • 等效于 p.data = p.data - η·∇L,但更高效
      • .sub_() 原地减法操作,直接修改参数值
        • 下划线 _​​ 表示原地操作(in-place operation),直接修改原数据
        • 对比:
          # 下划线 _​​ 表示原地操作(in-place operation),直接修改原数据
          
          # 非原地操作(生成新Tensor)
          p.data = p.data - lr * p.grad  # 低效,分配新内存
          
          # 原地操作(推荐)
          p.data.sub_(lr * p.grad)       # 高效,直接修改原数据
      • 这句其实就是进行参数更新:如 \mathbf{w} \leftarrow \mathbf{w} - \eta_t \mathbf{g}_t
        • 更新规则(SGD):
        • 对应代码:
          w.data.sub_(lr * w.grad)  # w -= lr * grad_w
          b.data.sub_(lr * b.grad)  # b -= lr * grad_b

      下面实现一个通用的训练函数,以方便本章后面介绍的其他优化算法使用:

      • 初始化一个线性回归模型,
      • 然后可以使用小批量随机梯度下降 以及后续小节介绍的其他算法来训练模型。
      # 定义模型
      def linreg(X, w, b):
          """线性回归模型"""
          return torch.matmul(X, w) + b
      
      # 定义损失函数
      def squared_loss(y_hat, y):  # y_hat预测值, y真实值
          """均方损失"""
          return (y_hat - y.reshape(y_hat.shape)) ** 2 / 2 # /2只是为了与导数的因子2抵消,即为了导数计算更简洁
      
      # 对模型进行训练和测试
      def evaluate_loss(net, data_iter, loss):  #@save
          """评估给定数据集上模型的损失"""
          metric = Accumulator(2)  # 损失的总和,样本数量
          for X, y in data_iter:
              out = net(X)             # 模型预测输出结果
              y = y.reshape(out.shape) # 将实际标签y的形状调整为与模型输出out一致
              l = loss(out, y)         # 模型输出out与实际标签y之间的损失
              metric.add(l.sum(), l.numel()) # 将损失总和 和 样本总数 累加到metric中
          return metric[0] / metric[1] # 损失总和/预测总数,即平均损失
      def train_ch11(trainer_fn, states, hyperparams, data_iter,
                     feature_dim, num_epochs=2):
          ''' 通用训练函数,支持不同的优化器(如SGD、Adam)
          trainer_fn  :优化器函数(如sgd)
          states      :优化器状态(如动量、二阶矩估计)
          hyperparams :超参数字典(如学习率lr)
          data_iter   :数据迭代器(生成(X, y)批次)
          feature_dim :输入特征维度
          num_epochs  :训练轮数(默认2轮)
          '''
          # 初始化模型参数(正态分布权重,零初始化偏置)
          w = torch.normal(mean=0.0, std=0.01, size=(feature_dim, 1), requires_grad=True)
          b = torch.zeros((1), requires_grad=True)
      
          # 定义模型和损失函数
          net = lambda X: linreg(X, w, b) # 线性回归模型
          loss = squared_loss             # 平方损失函数
      
          # 训练模型
          animator = Animator(xlabel='epoch', ylabel='loss',
                                  xlim=[0, num_epochs], ylim=[0.22, 0.35])
          n, timer = 0, Timer() # 样本计数器,计时器
          for _ in range(num_epochs):
              for X, y in data_iter:
                  l = loss(net(X), y).mean()  # 前向传播计算平均损失
                  l.backward()                # 反向传播计算梯度
                  # 优化器更新参数(传入参数列表、状态字典和超参数)
                  trainer_fn([w, b], states, hyperparams) # 优化器更新参数
                  # (梯度清零在优化器中实现)
                  n += X.shape[0]  # 更新样本计数
                  if n % 200 == 0: # 每200样本记录一次损失
                      timer.stop()
                      # 添加记录点:当前迭代次数,验证集损失
                      animator.add(n/X.shape[0]/len(data_iter),
                                   (evaluate_loss(net, data_iter, loss),))
                      timer.start()
          print(f'loss: {animator.Y[0][-1]:.3f}, {timer.avg():.3f} sec/epoch')
          return timer.cumsum(), animator.Y[0] # 返回总耗时和损失曲线

      下面来看批量梯度下降的优化是如何进行的:

      • 可以通过将小批量设置为1500(即样本总数)来实现。
      • 因此,模型参数每个迭代轮数只迭代一次。
      # 训练流程封装
      def train_sgd(lr, batch_size, num_epochs=2):
          '''训练流程封装:入口函数
          1、初始化训练参数:如学习率,批量大小,训练轮数,所用优化器(sgd)
          2、启动训练流程
          '''
          # 获取数据迭代器和特征维度
          data_iter, feature_dim = common.get_data_ch11(downloader, batch_size)
          # 启动训练流程
          return common.train_ch11(
              sgd, None, {'lr': lr}, data_iter, feature_dim, num_epochs)
      
      # 执行训练:
      ''' 全批量梯度下降(GD),每1500样本更新一次
      lr=1:大学习率(全批量梯度更稳定,允许大学习率)
      batch_size=1500 :全批量(使用全部数据计算梯度)
      num_epochs=10   :训练10轮
      行为:
          每轮(epoch)计算所有1500个样本的平均梯度,更新一次参数。
          共更新 10次(每轮1次)
      '''
      # 学习率1,批量大小1500,训练轮数10
      gd_res = train_sgd(1, 1500, 10)

      • 批量大小为1500(全批量梯度下降,GD)如上 ↑
      • ​​批量大小为1(随机梯度下降,SGD)如下 ↓
        • 这里选择了很小的学习率,以简化实现。
          • SGD的梯度噪声大​​(因为每次只用一个样本),所以需要 ​​更小的学习率​​ 避免震荡。
          • 例如​​:批量GD可以用 lr=0.1,但SGD可能需要 lr=0.01甚至更小。
        • 在随机梯度下降的实验中,每当一个样本被处理,模型参数都会更新。
          • ​​SGD的特点​​:每看一个样本就更新一次参数
          • 对比批量GD​​:每看完整批(如1500个样本)才更新一次
        • 在该例子中,这相当于每个迭代轮数有1500次更新。
          • 数据集大小=1500
          • ​​SGD​​:每轮(epoch)遍历所有样本,每个样本单独更新一次 → ​​1500次更新/轮
          • ​​批量GD​​:每轮计算所有样本的平均梯度 → ​​1次更新/轮​​
      • 效果:目标函数值的下降在1个迭代轮数后就变得较为平缓。
        • SGD的收敛曲线:​
          • 初期下降快(因频繁更新)
          • ​后期波动小(因学习率小,接近收敛)
        • 批量GD的收敛曲线:每次更新方向更准确,但更新次数少。

      尽管两个例子在一个迭代轮数内都处理了1500个样本,但实验中随机梯度下降的一个迭代轮数耗时更多。

      方法

      梯度计算次数 / 轮

      参数更新次数 / 轮

      每轮耗时

      收敛曲线

      ​​SGD​​

      1500

      1500

      高

      ​​批量GD​​

      1500

      1

      低

      因为随机梯度下降更频繁地更新了参数,且一次处理单个观测值效率较低。

      ''' 随机梯度下降(SGD),每个样本更新一次
      lr=0.005:极小学习率(单样本梯度噪声大,需小步长)
      batch_size=1:纯SGD(每个样本单独更新)
      num_epochs=2(默认值)
      行为:
          每轮(epoch)遍历1500个样本,每个样本更新一次参数。
          共更新 1500 × 2 = 3000次(每轮1500次,迭代次数默认=2)
      '''
      sgd_res = train_sgd(0.005, 1)

      最后,当批量大小等于100时,使用小批量随机梯度下降进行优化。

      • 每个迭代轮数所需的时间:比 随机梯度下降 和 批量梯度下降 所需的时间短。
      ''' 小批量梯度下降(Mini-batch)
      每轮更新次数 分别为:
      15次/轮(1500/100)
      150次/轮(1500/10)
      但实际只更新了默认2轮(为了快速验证),所以实际总更新次数如下:
      15 × 2 = 30
      150 × 2 = 300
      '''
      mini1_res = train_sgd(.4, 100) # 中等批量

      将批量大小减少到10,每个迭代轮数的时间都会增加,因为每批工作负载的执行效率变得更低。

      mini2_res = train_sgd(.05, 10) # 小批量

      现在可以比较前四个实验的时间与损失。可以看出,

      • 尽管在处理的样本数方面,随机梯度下降的收敛速度快于梯度下降,但与梯度下降相比,它需要更多的时间来达到同样的损失,因为逐个样本来计算梯度并不那么有效。
      • 小批量随机梯度下降能够平衡收敛速度和计算效率。
        • 大小为10的小批量比随机梯度下降更有效;
        • 大小为100的小批量在运行时间上甚至优于梯度下降。
      # zip() 将时间序列和损失序列分离。将所有 时间序列和损失序列 分别整合成元组
      #       元组内每个元素为 某种梯度下降法的 时间序列 或 损失序列
      # map(list, ...) 将zip生成的元组转换为列表(将 转列表 应用到每个zip生成的元组上)
      # *list() 将嵌套列表解包为独立参数(相当于4种梯度下降法的 时间序列/损失序列 都单独拎出来,而不是大的时间序列整体)
      common.plot(*list(map(list, zip(gd_res, sgd_res, mini1_res, mini2_res))),
               'time (sec)', 'loss',
                  xlim=[1e-2, 10], # 时间轴范围(0.01~10秒)
                  xscale='log',    # 时间轴用对数坐标(便于观察初期快速下降)
                  figsize=[6, 3],
                  legend=['gd', 'sgd', 'batch size=100', 'batch size=10'])

      批量大小 vs. 学习率​

      • 大批量(如1500)​​:
        • 梯度估计更准确 → 可用​​大学习率​​(如lr=1)
        • 更新次数少 → ​​收敛慢但稳定​​
      • ​​小批量(如10)​​:
        • 梯度噪声大 → 需​​小学习率​​(如lr=0.05)
        • 更新次数多 → ​​收敛快但波动大​​
      • 单样本(SGD)
        • 梯度噪声极大 → ​​极小学习率​​(如lr=0.005)
        • 更新极频繁 → ​​计算效率低​​

      时间消耗​

      • SGD最慢​​:每轮1500次反向传播和更新(GPU并行利用率低)
      • ​​全批量GD最快​​:每轮1次大规模并行计算(充分利用GPU)

      总结:

      1. 全批量GD​​:稳定但慢,适合小数据集
      2. ​​纯SGD​​:收敛快但噪声大,效率低
      3. 小批量(100)​​:平衡速度和稳定性
      4. 小批量(10)​​:更接近SGD,但效率稍高

      5.5. 简洁实现

      下面用深度学习框架自带算法实现一个通用的训练函数,并在本章中其它小节使用它:

      def train_concise_ch11(trainer_fn, hyperparams, data_iter, num_epochs=4):
          ''' 通用训练函数(简洁版本,调用api)
          trainer_fn  :优化器构造函数(如torch.optim.SGD)可灵活切换
          hyperparams :超参数字典(如学习率lr){'lr': 0.01}
          data_iter   :数据迭代器(生成(X, y)批次)
          num_epochs  :训练轮数(默认4轮)
          '''
          # 初始化模型(固定为单层线性回归
          net = nn.Sequential(nn.Linear(5, 1)) # 输入维度硬编码为5
          def init_weights(m):
              '''权重初始化函数'''
              if type(m) == nn.Linear:
                  torch.nn.init.normal_(m.weight, std=0.01) # 权重~N(0,0.01)
          net.apply(init_weights) # 应用初始化
      
          ''' 使用PyTorch内置优化器(如torch.optim.SGD)
          自动管理状态:PyTorch内部维护动量、二阶矩等状态
          无需手动传入:通过optimizer.step()自动更新
          '''
          optimizer = trainer_fn(net.parameters(), **hyperparams) # 创建优化器
          loss = nn.MSELoss(reduction='none') # 定义损失函数(均方误差,不自动求平均)
          animator = Animator(xlabel='epoch', ylabel='loss',
                                  xlim=[0, num_epochs], ylim=[0.22, 0.35])
          n, timer = 0, Timer()
          for _ in range(num_epochs):
              for X, y in data_iter:
                  optimizer.zero_grad()       # 梯度清零
                  out = net(X)                # 前向传播
                  y = y.reshape(out.shape)    # 确保标签形状匹配
                  l = loss(out, y)            # 计算损失
                  l.mean().backward()         # 反向传播(平均损失)
                  optimizer.step()            # 参数更新
                  n += X.shape[0]             # 更新样本计数
                  if n % 200 == 0: # 每200样本记录一次损失
                      timer.stop()
                      # 注意:PyTorch的MSELoss计算平方误差时不带1/2系数,需手动除以2
                      animator.add(n/X.shape[0]/len(data_iter),
                                   (evaluate_loss(net, data_iter, loss) / 2,))
                      timer.start()
          print(f'loss: {animator.Y[0][-1]:.3f}, {timer.avg():.3f} sec/epoch')

      下面使用这个训练函数,复现之前的实验。

      # 获取数据迭代器,批量大小10(选用小批量训练)
      data_iter, _ = common.get_data_ch11(downloader, batch_size=10)
      trainer = torch.optim.SGD # 指定优化器为PyTorch内置的SGD(随机梯度下降)
      common.train_concise_ch11(trainer, {'lr': 0.01}, data_iter)

      小结

      • 由于减少了深度学习框架的额外开销,使用更好的内存定位以及CPU和GPU上的缓存,向量化使代码更加高效。

      • 随机梯度下降的“统计效率”与大批量一次处理数据的“计算效率”之间存在权衡。

        • 小批量随机梯度下降提供了两全其美的答案:计算和统计效率。

      • 小批量随机梯度下降中,处理的批量数据是通过 训练数据的随机排列 获得的(即每个观测值只处理一次,但按随机顺序)。

      • 在训练期间降低学习率有助于训练。

      • 一般来说,小批量随机梯度下降比随机梯度下降和梯度下降的速度快,收敛风险较小。

      6. 动量法(动量法 = 梯度 + 记忆​)

      • 在(4. 随机梯度下降)中,详述了如何执行随机梯度下降,即在只有嘈杂的梯度可用的情况下执行优化时会发生什么。
      • 对于嘈杂的梯度,选择学习率需要格外谨慎。
        • 若衰减速度太快,收敛就会停滞。
        • 若太宽松,则可能无法收敛到最优解。

      动量法:

      • ​​不是盲目跟随当前梯度​​,而是​​智能结合历史信息​​。
      • 核心思想是:​​参考历史的运动趋势​​,而非只相信眼前的梯度。(给梯度下降加上“惯性”)

      ​​β值:动量系数

      • 控制历史梯度的遗忘速度(动量法的"记忆机制"​​)
      • 决定优化器是"稳健型"还是"灵敏型"(​​β值的实际影响)
      • 需根据问题特性选择合适的记忆强度(超参数选择依据)

      动量法通过指数衰减的权重,让优化器既能看到历史趋势,又能聚焦近期信息。

      • 这也是它比普通梯度下降更强大的根本原因。

      动量法的三大优势

      1. 允许更大的学习率​
        # 对于陡峭地形(λ很大),普通GD需要很小学习率
        普通GD:η必须 < 2/λ(很小)
        
        # 动量法可以承受更大学习率  
        动量法:η可以 < (2+2β)/λ(大得多)
      2.  自动调节不同方向的步长​
        1. 平坦方向​​:动量累积 → ​​有效步长变大​​(加速)
        2. ​​陡峭方向​​:动量抵消震荡 → ​​有效步长变小​​(稳定)
      3. 对噪声更鲁棒:梯度中的随机噪声会被动量​​平均掉​​,更新方向更可靠。

      动量法 工作的数学原理:

      普通梯度下降​

      普通GD​​:只看眼前情况,容易反复改主意。

      会有两个问题:

      1. 平坦区域:进展缓慢(如 f(x)=0.1x₁² + 2x₂²中的 x₁ 方向)
      2. 狭窄山谷中:来回震荡,像皮球在沟里弹跳
      # 每次只看当前坡度
      w = w - learning_rate * gradient

      动量法​

      动量法​​:结合长期经验,决策更稳健、更一致。

      就像​​推一个有惯性的球下山​​:

      • 球会​​冲过平坦区域​​(加速)
      • 球会​​平滑转弯​​,减少震荡(动量缓冲)
      # 记住之前的"运动方向",结合当前坡度
      velocity = momentum * velocity + learning_rate * gradient
      w = w - velocity
      • velocity:速度/动量项,累积历史梯度信息
      • momentum(β):动量系数(通常0.9),控制"记忆强度"
        • 当 β= 0:退化为普通梯度下降
        • 当 β→1:强烈依赖历史方向,冲劲十足

      6.1. 基础

      6.1.1. 泄漏平均值

      泄漏平均值:通常也被称为指数加权移动平均。

      • 是一种计算平均值的方法,它更重视最近的数据点,同时不会完全“忘记”旧的数据点,而是让旧数据点的影响随着时间推移呈指数级衰减。
      • 核心思想:赋予近期数据更高权重

      • 工作原理:指数衰减

      • 旧数据的影响力呈指数级下降,而非突然消失。

      • 通过参数 β 可灵活控制平均值的“记忆长度”和“平滑度”。

      为什么这叫"泄漏平均值"?

      • 比喻:像是一个有漏洞的水桶
        • 新水倒入:(1-γ) 比例
        • 旧水泄漏:通过 γ 系数逐渐流失
        • 保持动态平衡:不会无限积累
      • 好处:既能平滑噪声,又能适应变化

      上一节中讨论了小批量随机梯度下降作为加速计算的手段。它也有很好的副作用,即平均梯度减小了方差。 小批量随机梯度下降可以通过以下方式计算:

      普通小批量梯度下降
      • g_{t, t-1}​​:在 t 时刻,计算出的​​当前小批量的平均梯度​​。即 “脚下”最陡的方向
      • ​​B_t​​:第 t 步用到的一小批(Mini-batch)数据
      • \mathbf{h}_{i, t-1} = \partial_{\mathbf{w}} f(\mathbf{x}_i, \mathbf{w}_{t-1})  :样本 i 的随机梯度下降,使用时间 t - 1 时更新的权重 t - 1 
      • ​​1/|B_t| sum(...)​​:对所有这个小批量里的样本梯度求平均(这比单样本更稳定)

      若能从方差减少的影响中受益,甚至超过小批量上的梯度平均值,那很不错。 完成这项任务的一种选择是用泄漏平均值(leaky average)取代梯度计算:

      V_t:动量项,在 t 时刻的​ ​动量(速度)
      新的速度(方向) = β * 旧的速度 (保持惯性) + 当前算出的梯度
      (根据最新路况微调方向,而不再直接使用刚算出的梯度)
      (融合了“历史方向”和“当前方向”)
      • \beta \in (0, 1)  :动量系数,超参数,代表惯性有多大。
        • 有效地将瞬时梯度替换为多个“过去”梯度的平均值。
        • β越大(如0.99),惯性越强,越能记住之前的运动方向
        • β越小(如0.5),惯性越弱,越容易受当前梯度影响
      • β * v_{t-1}​​:​​历史动量​​。即“惯性”
        • 是上一时刻的速度(方向),按照 β这个比例衰减后保留下来。
      • + g_{t, t-1}​​:​​当前梯度​​。这是当前计算出的方向
      • \mathbf{v} 被称为动量(momentum), 它累加了过去的梯度。

      得到动量 v_t后,用它来更新参数:

      w_t = w_{t-1} - η * v_t(其中 η是学习率)

      展开动量(以便看清动量的本质):递归地将 \mathbf{v}_t 扩展到

      动量的本质: 过去所有梯度的加权和
      • ​​g_{t, t-1}​​:当前梯度,权重是 β0=1
      • β * g_{t-1, t-2}​​:上一步的梯度,权重是 β
      • ​​β^2 * g_{t-2, t-3}​​:上上步的梯度,权重是 β^2
      • ... 以此类推
      • 因为 β < 1,所以 ​​越久远的梯度,权重 (β^τ) 越小​​
      • 动量法相当于:
        • 给最近的梯度更高的权重,
        • 给很久以前的梯度很低的权重,求了一个​​指数衰减的移动平均​​。
      动量法带来的三大好处
      • 较大的 \beta :相当于 长期平均值
      • 较小的 \beta :相对于 梯度法只是略有修正
      • 新的梯度替换不再指向特定实例下降最陡的方向,而是指向过去梯度的加权平均值的方向。

      1、加速通过平坦区域​​:

      • 在平坦区域,当前梯度 g_t很小。
      • 但只要历史动量 v_{t-1}比较大,就能靠着惯性“冲”过这片平坦区,加速训练。

      2、减少震荡,更稳定地通过狭窄峡谷​​:

      • 在狭窄山谷(优化问题条件不佳的情况)中,普通梯度下降会因两侧陡峭而左右震荡。
      • 动量法由于求了平均,方向会更稳定地指向谷底,减少摇摆。

      3、减轻随机梯度噪声的影响​​:

      • 小批量梯度本身带有噪声。对梯度求指数加权平均,可以平滑掉这些噪声,让更新方向更可靠(获得更稳定的下降方向)。

      正如人们所期望的,由于其功效,动量是深度学习及其后优化中一个深入研究的主题。

      • 例如,请参阅文章,观看深入分析和互动动画。动量是由 (Polyak, 1964)提出的。
      •  (Nesterov, 2018)在凸优化的背景下进行了详细的理论讨论。
      • 长期以来,深度学习的动量一直被认为是有益的。
      • 有关实例的详细信息,请参阅 (Sutskever et al., 2013)的讨论。

      6.1.2. 条件不佳的问题

      • 先复习一下梯度下降,以更好地了解动量法的几何属性。
      • 在(3. 梯度下降)中使用了 f(\mathbf{x}) = x_1^2 + 2 x_2^2 ,即中度扭曲的椭球目标。通过向 x_1 方向伸展它来进一步扭曲这个函数
      x₁方向:非常平坦(梯度小)
      x₂方向:非常陡峭(梯度大)
      ​ ​x₁方向​​(系数0.1):梯度很小 → 普通GD进展缓慢​
      动量法​​:靠历史动量"冲过"平坦区

      与之前一样,f 在 (0, 0) 有最小值, 该函数在 x_{1} 方向上非常平坦。看看在这个新函数上执行梯度下降时会发生什么:

      # %matplotlib inline
      import torch
      
      eta = 0.4 # 学习率
      def f_2d(x1, x2):
          """二维目标函数:f(x1, x2) = 0.1*x1² + 2*x2²
          椭圆抛物面,最小值在原点(0,0)
          在x2方向(系数2)比x1方向(系数0.1)陡峭得多
          """
          return 0.1 * x1 ** 2 + 2 * x2 ** 2
      def gd_2d(x1, x2, s1, s2):
          """自定义的梯度下降更新函数(注意:这不是标准形式!)
          x1, x2: 当前参数值
          s1, s2: 预留的状态变量(此处未使用)
          返回:更新后的参数 (x1_new, x2_new, 0, 0)
      
          注意:这里的更新规则很特殊!
              标准梯度下降应为:x = x - η * ∇f(x)
              其中 ∇f(x) = [∂f/∂x1, ∂f/∂x2] = [0.2*x1, 4*x2]
      
          此实现直接写为:x1_new = x1 - η*0.2*x1 = (1 - 0.2η)*x1
                       x2_new = x2 - η* 4 *x2 = (1 - 4η)*x2
          """
          return (x1 - eta * 0.2 * x1, x2 - eta * 4 * x2, 0, 0)
      
      common.show_trace_2d(f_2d, common.train_2d(gd_2d))

      参数在x₂方向震荡但最终收敛到(0,0)
      • 从构造来看,x2方向的梯度 比 水平x1方向的梯度 大得多,变化也快得多。
      • 因此,会陷入两难:
        • 选择 小 学习率(如η=0.4)​:如上
          • ✅ x2方向:稳定不发散(|0.6|<1),
          • ❌ ​​x1方向:收敛极慢(每次只减少8%)
            # 更新规则分析
            x₁更新:x₁ ← x₁ - 0.4×0.2x₁ = 0.92x₁  # 缓慢收敛
            x₂更新:x₂ ← x₂ - 0.4×4x₂ = -0.6x₂    # 震荡但稳定
            
            好比开车​​:为了在陡坡上安全,只能慢速前进,但在平路上就显得太慢了
        • 选择 大 学习率(如η=0.6)​:如下
          • ✅ x1方向:收敛加快(每次减少12%)
          • ❌ ​​x2方向:发散(|1.4|>1,振幅指数增长)
            # 更新规则分析  
            x₁更新:x₁ ← x₁ - 0.6×0.2x₁ = 0.88x₁  # 较快收敛
            x₂更新:x₂ ← x₂ - 0.6×4x₂ = -1.4x₂    # 发散!
            
            好比开车​​:在平路上加速,但在陡坡上失控翻车
      • 下面的例子说明了即使学习率从 0.4 略微提高到 0.6 ,也会发生变化。
        • x1 方向上的收敛有所改善,但整体来看解的质量更差了。
      eta = 0.6 # 学习率略微提高到 0.6
      common.show_trace_2d(f_2d, common.train_2d(gd_2d))

      x₂方向参数值指数级增长,迅速超出合理范围
      (∴ 等高线因极度压缩,而变得不可见)

      f(x) = 0.1x₁² + 2x₂²​

      • 地形特征​​:
        • x₁方向:非常平坦(梯度小)
        • x₂方向:非常陡峭(梯度大)
      • 普通梯度下降的表现​

        # 更新过程(夸张示意)
        x₁更新:0.1 * 2 * x₁ → 很小步长(爬行)
        x₂更新: 2  * 2 * x₂ → 很大步长(可能 overshoot)

        结果:在x₂方向震荡,x₁方向进展缓慢。

      • 动量法的表现​

        # 初期:主要受x₂方向大梯度驱动,获得向下的动量
        # 中期:动量带着参数"冲过"x₁方向的平坦区
        # 后期:动量缓冲使x₂方向的震荡减弱

        结果:更直接、更平滑地走向最小值(0,0)。

      6.1.3. 动量法

      动量法(momentum)能解决上面描述的梯度下降问题。

      • 观察上面的优化轨迹,能直觉到计算过去的平均梯度效果会很好:
        • x1方向:聚合非常对齐的梯度,从而增加每一步中覆盖的距离。
          • 即:发现"一直向左(负方向)"的趋势 → ​​加速向左​​(动量累积)
          • 效果:在平缓的x₁方向​​加速前进
        • x2方向:存在梯度振荡,那么可以相互抵消对方的振荡,聚合梯度将减小步长大小。
          • 即:左右方向的动量​​相互抵消​​ → ​​步长减小
          • 效果:在振荡的x₂方向​​减少震荡​​
      • 使用 \mathbf{v}_t 而不是梯度 \mathbf{g}_t 可以生成以下更新等式:
      计算动量 (加权平均) &   用动量更新参数
      • 注意:对于 \beta = 0 ,相当于恢复常规的梯度下降。
      • 在深入研究其数学属性前,先快速看下算法在实验中的表现:
      def momentum_2d(x1, x2, v1, v2):
          """动量法更新函数
          x1, x2: 当前参数值
          v1, v2: 动量状态(历史梯度累积)
          返回:更新后的参数和动量状态
          """
          # 动量更新(动量累积):v_new = β*v_old + 当前梯度
          v1 = beta * v1 + 0.2 * x1  # x1方向:梯度 = ∂f/∂x1 = 0.2*x1
          v2 = beta * v2 + 4 * x2    # x2方向:梯度 = ∂f/∂x2 = 4*x2
      
          # 参数更新:x_new = x_old - η*v_new
          return x1 - eta * v1, x2 - eta * v2, v1, v2
      
      eta, beta = 0.6, 0.5 # 学习率依旧是0.6,动量系数0.5(保留50%的历史动量)
      common.show_trace_2d(f_2d, common.train_2d(momentum_2d))

      • 如图所示,在学习率与前面相同的情况下,动量法仍然很好地收敛了。
      • 当降低动量参数,将其减半至 \beta = 0.25 时:
        • 会导致一条几乎没有收敛的轨迹。但其仍比没有动量时的发散效果要好。
      eta, beta = 0.6, 0.25 # 动量系数减半至0.25(只保留25%的历史动量)
      common.show_trace_2d(f_2d, common.train_2d(momentum_2d))

      • 注意:可以将动量法与随机梯度下降,特别是小批量随机梯度下降结合起来。
        • 唯一的变化:在这种情况下,将梯度 \mathbf{g}_{t, t-1} 替换为 \mathbf{g}_{t} 。
        • 为方便起见,在时间 t = 0 初始化 \mathbf{v}_0 = 0 。

      6.1.4. 有效样本权重

      核心思想:动量法是在对过去的梯度做​​指数加权平均​

      回想一下 \mathbf{v}_t = \sum_{\tau = 0}^{t-1} \beta^{\tau} \mathbf{g}_{t-\tau, t-\tau-1} 。

      • 极限条件下,当 t→∞(即迭代步数非常多时),若 β<1,则 βt→0,
      • 所以:\sum_{\tau=0}^\infty \beta^\tau = \frac{1}{1-\beta} 。

      换句话说,

      • 梯度下降 / 随机梯度下降:取步长 \eta (基于单步梯度)
        • 只相信​​眼前这一步​​算出来的梯度(g_t)
        • ​​步长​​就是学习率 η。迈出一步的大小是 η∥gt​∥
      • 动量法:取步长 \frac{\eta}{1-\beta} (基于多步梯度的平均),同时该“平均效应”还能平滑掉梯度中的噪声。这就能集两种好处于一身。
        • 不只相信眼前的信息,还会参考​​过去所有步骤的梯度。且近期的梯度拥有更高的权重,远期的梯度更低的权重。
        • 计算的是一个“加权平均”的梯度方向。该平均梯度大致等于过去 \frac{1}{1-\beta} 步梯度的平均。
        • 当 β=0.9时,\frac{1}{1-0.9}=10​。即 动量项 v_t大致相当于​​过去10个梯度的平均方向​​。

      两种好处:

      1. (来自平均)​​:平均梯度使方向更稳定,尤其是在梯度方向来回震荡的维度上。正负梯度会相互抵消,从而​​减小震荡​​,让优化过程更平滑
      2. (来自历史信息)​​:平均梯度聚合了多个历史梯度,在梯度方向一致的维度上动量会累积叠加,从而​​增大了有效的步长​​,加速收敛。

      为了说明 \beta 的不同选择的权重效果如何,请参考下面的图表:

      betas = [0.95, 0.9, 0.6, 0] # 动量系数列表:从强记忆到无记忆
      '''
      纵轴含义:beta ** x
      表示t步前的梯度在当前动量项中的权重系数
      例如:beta=0.9时,10步前的梯度权重是 0.9^10 ≈ 0.35
      '''
      for beta in betas: # 对每个beta值绘制衰减曲线
          # 创建时间序列:0到39(代表过去的时间步)
          x = torch.arange(40).detach().numpy()  # [0, 1, 2, ..., 39]
          # 计算β^t:表示t步前的梯度在当前动量中的权重
          plt.plot(x, beta ** x, label=f'beta = {beta:.2f}')
      plt.xlabel('time')  # x轴:时间(过去的步数)
      plt.legend()        # 显示图例
      β值给得越小,则
      【t步前的梯度 在当前动量项中的权重系数】衰减得越快
      四条曲线对比​​:

      β值

      物理意义

      记忆强度

      衰减速度

      ​​0.95​​

      强记忆

      记住很多历史(更新方向非常平滑)

      缓慢衰减

      ​​0.9​​

      中等记忆

      标准动量(平衡近期与历史信息)

      适中衰减

      ​​0.6​​

      弱记忆

      记住近期几步(主要受最近几步影响,接近普通GD)

      快速衰减

      ​​0​​

      无记忆

      只看当前梯度(退化为普通梯度下降)

      立即衰减

      选择β值的指导原则​
      # 不同场景的推荐β值
      if 数据噪声大:
          beta = 0.95  # 强平滑,抑制噪声
      elif 损失曲面崎岖:
          beta = 0.9   # 标准动量,平衡稳定与响应
      elif 需要快速适应:
          beta = 0.8   # 弱记忆,对新梯度更敏感

      6.2. 实际实验

      下面看看动量法在实验中是如何运作的。为此,需要一个更加可扩展的实现。

      6.2.1. 从零开始实现

      相比于小批量随机梯度下降,动量方法需要维护一组辅助变量,即速度。它与梯度以及优化问题的变量具有相同的形状。在下面的实现中,称这些变量为states。

      def init_momentum_states(feature_dim):
          ''' 动量状态初始化
          根据特征维度,初始化动量法的状态参数
          为每个可训练参数创建对应的动量变量v,初始化为0
          '''
          v_w = torch.zeros((feature_dim, 1)) # 权重w的动量状态(形状同w)
          v_b = torch.zeros(1)                # 偏置b的动量状态(形状同b)
          return (v_w, v_b)
      
      def sgd_momentum(params, states, hyperparams):
          '''动量法优化器'''
          for p, v in zip(params, states): # 同时遍历参数和对应的动量状态
              with torch.no_grad():        # 禁用梯度跟踪(纯数值计算)
                  # 动量更新:v_new = β * v_old + 当前梯度
                  v[:] = hyperparams['momentum'] * v + p.grad # 原地更新动量状态(保持内存引用)
                  # 参数更新:p_new = p_old - η * v_new
                  p[:] -= hyperparams['lr'] * v # 原地更新参数值
              p.grad.data.zero_()           # 清零梯度,准备下一轮计算(防止梯度累积)

      下面看看它在实验中是如何运作的:

      def train_momentum(lr, momentum, num_epochs=2):
          '''训练函数封装'''
          common.train_ch11(
              sgd_momentum,                    # 优化器函数
              init_momentum_states(feature_dim), # 初始化动量状态
              {'lr': lr, 'momentum': momentum}, # 超参数字典
              data_iter,                        # 数据迭代器
              feature_dim,                      # 特征维度
              num_epochs                        # 训练轮数
          )
      # 获取数据集的迭代器和特征维度,批量大小为10
      data_iter, feature_dim = common.get_data_ch11(downloader, batch_size=10)
      # 中等动量,标准学习率。效果:平衡收敛速度与稳定性
      train_momentum(0.02, 0.5) # 学习率0.02,动量系数0.5

      当动量超参数momentum增加到0.9时,它相当于有效样本数量增加到 \frac{1}{1 - 0.9} = 10 。将学习率略微降至 0.01 ,以确保可控:

      # 强动量,降低学习率(避免震荡)。效果:更强记忆效应,需更小心控制步长
      # 动量系数增加到0.9(相当于有效样本数量增加),学习率略微降至0.01以确保可控
      train_momentum(0.01, 0.9)

      降低学习率进一步解决了任何非平滑优化问题的困难,将其设置为 0.005 会产生良好的收敛性能:

      # 强动量,更小学习率(确保收敛)。效果:最稳定收敛,但可能稍慢
      # 学习率再降至0.005,会产生良好的收敛性能
      train_momentum(0.005, 0.9)

      6.2.2. 简洁实现

      由于深度学习框架中的优化求解器早已构建了动量法,设置匹配参数会产生非常类似的轨迹。

      ​​SGD优化器的两种模式​​

      PyTorch的torch.optim.SGD根据是否提供momentum参数,自动选择工作模式:

      # 模式1:普通SGD(无动量)
      optimizer = torch.optim.SGD(model.parameters(), lr=0.01)
      # 内部实现:p = p - lr * grad
      
      # 模式2:动量SGD(提供momentum参数)
      optimizer = torch.optim.SGD(model.parameters(), lr=0.01, momentum=0.9)
      # 内部实现:v = momentum * v + grad; p = p - lr * v
      trainer = torch.optim.SGD # 指定优化器类 (注意:是类引用,而非示例)
      # PyTorch的torch.optim.SGD根据是否提供momentum参数,自动选择工作模式
      # 这里提供了momentum参数,所以自动启用了动量法
      # 内部实现:v = momentum * v + grad; p = p - lr * v
      common.train_concise_ch11(trainer, {'lr': 0.005, 'momentum': 0.9}, data_iter)

      6.3. 理论分析

      f(x) = 0.1 x_1^2 + 2 x_2^2 的2D示例似乎相当牵强。 下面将看到,它在实际生活中非常具有代表性,至少最小化凸二次目标函数的情况下是如此。

      【未完全消化】6.3.1. 二次凸函数

      • 神经网络的损失函数:并非标准碗可让球顺利滚到中心底部,而是歪斜碗使球来回震荡,路径曲折,下降缓慢。
        • 对角化将歪斜碗摆正,使优化(球滚到中心底部)效率变高。
      • 动量法:
        • 本质上为优化问题的每个特征方向(曲率方向)自动分配合适的有效学习率,让
          • 平缓方向加速,
          • 陡峭方向稳定。
      • 对角化:把复杂的多维优化问题,分解成多个独立的单变量优化问题,从而让理论分析和算法设计变得简单明了。
        • 把歪斜的碗转正,让主轴对齐坐标轴​​,让优化问题变得更简单直观。
        • 使每个方向可以独立优化。

      对角化过程详解

      特征分解 (关键步骤)​​


      https://yuanbao.tencent.com/bot/app/share/chat/hC6uEDgj9L5l

      从 (11.6.6) 配方到 (11.6.7) 的过程:

      ———— 顶点式的核心优势

      开口方向与大小(参数 a 的作用:决定开口方向 和 抛物线的“胖瘦”)

      • 开口方向:
        • a>0:抛物线开口向上
        • a<0:抛物线开口向下
      • 开口大小:
        • ∣a∣越大,抛物线开口越窄(越瘦)
        • ∣a∣越小,抛物线开口越宽(越胖)

      ———— 如何将一般式化为顶点式?

      ———— 实际例子:

      ———— 使用顶点式的场景:

      • 直接求抛物线的顶点或最值
      • 画二次函数图像(因为知道了顶点、对称轴和开口,就能快速画出草图)
      • 解决与实际最值相关的问题,如“何时面积最大”、“何时利润最高”等

      ———— 总结

      形式

      公式

      优点

      适用场景

      顶点式

      y=a(x−h)^2+k

      直接读出顶点(h,k)和最值k

      求最值、画图像、解决优化问题

      一般式

      y=ax^2+bx+c

      直接看出y轴截距(c)

      代入求值、判断交点

      交点式

      y=a(x−x1​)(x−x2​)

      直接读出与x轴的交点(x1,0),(x2,0)

      当已知抛物线与x轴交点时


      ​​1. 出发点:原函数 (公式 11.6.6)​

      2. 目标:配方成顶点式​​

      希望把函数写成这种形式:

      3. 配方过程(关键步骤)​​

      要验证:

      代入得:

      4. 为什么这样变换?(几何意义)​​

      变换后的形式:

      考虑这个目标函数:简单的“碗形”曲面

      ​ ​比喻​​:底部在最低点的 椭圆碗(歪斜碗)​ ​
      Q矩阵​
      ​:决定碗的"形状"(哪个方向陡,哪个方向平缓)​ ​
      现实对应​
      ​:神经网络的损失函数在局部看都近似这种二次曲面
      • x​​:参数向量(要优化的变量),即 碗中点的坐标
      • ​Q​​:正定矩阵(形状矩阵),决定碗的"歪斜程度" 即形状
      • ​​c​​:向量,线性项系数,让碗底不在原点 (决定碗底位置)
      • ​​b​​:标量,常数项,碗底的高度

      (11.6.6)为普通的二次函数。 对于正定矩阵 \mathbf{Q} \succ 0 ,即对于具有正特征值的矩阵,有最小化器为 \mathbf{x}^* = -\mathbf{Q}^{-1} \mathbf{c} ,最小值 h(\mathbf{x}^*) 为 b - \frac{1}{2} \mathbf{c}^\top \mathbf{Q}^{-1} \mathbf{c} 。

      • 公式变形为:h(x) = 1/2 * (x - Q⁻¹c)ᵀ Q (x - Q⁻¹c) + b‘  把碗底移到中心
      • ​​(x - Q⁻¹c)​​:这一步相当于重新定义坐标原点,把坐标原点0 移到碗底 Q⁻¹c。现在,新目标就是从这个新的原点出发,找到最小值(也就是它自己,高度为 b')

      因此可以将 h 重写为

      重写后的函数
      关键变换​​:通过配方,把碗底移到原点
      ​ ​Q⁻¹c​​ = 碗底的最低点坐标
      ​ ​b'​​ = 碗底的高度值

      梯度由 \partial_{\mathbf{x}} f(\mathbf{x}) = \mathbf{Q} (\mathbf{x} - \mathbf{Q}^{-1} \mathbf{c}) 给出。 也就是说,它是由 \mathbf{x} 和最小化器之间的距离乘以 \mathbf{Q} 所得出的。 因此,动量法还是 \mathbf{Q} (\mathbf{x}_t - \mathbf{Q}^{-1} \mathbf{c}) 的线性组合。

      • ∵  \mathbf{Q} 是正定的
      • ∴ 可以通过 ①对角化 \mathbf{Q} = \mathbf{O}^\top \boldsymbol{\Lambda} \mathbf{O} 分解为
        • \mathbf{O} :正交(旋转)矩阵 (将碗旋转)
        • \boldsymbol{\Lambda} :正特征值的对角矩阵
          • 描述旋转摆正后的新碗,在各个方向上的“陡峭程度”。
          • 对角线上的值 λ₁, λ₂, ...就是曲率,值越大表示那个方向越陡。
      • ② 坐标变换 z := O(x - Q⁻¹c)​​
        • 即 将变量从 \mathbf{x} 更改为 \mathbf{z} := \mathbf{O} (\mathbf{x} - \mathbf{Q}^{-1} \mathbf{c}) :
        • 先平移(把碗底放中心),再旋转(把碗摆正)
        • z​​:在​​新坐标系下​​的位置坐标
      • 由此,获得一个非常简化的表达式:
      坐标变换
      该变换做了两件事:
      ​​1、 平移​​:x - Q⁻¹c → 把坐标原点移到碗底          ​ ​
      2、 旋转​​:O(...) → 把碗转正,让主轴对齐坐标轴
      该新函数没有交叉项了 ​​(比如 z₁ * z₂)
      这意味着在 z坐标系下,沿着 z₁方向移动不会影响 z₂方向的梯度,反之亦然。优化问题被​ ​解耦​​了。
      • 这里 b' = b - \frac{1}{2} \mathbf{c}^\top \mathbf{Q}^{-1} \mathbf{c} 。
      • 由于 \mathbf{O} 只是一个正交矩阵,因此不会真正意义上扰动梯度。

       λ 是曲率,控制损失函数的陡峭程度。

      以 \mathbf{z} 表示的梯度下降变成:(在这个摆正了的碗里进行梯度下降)

      因为碗是正的,每个方向可以独立更新:
      z₁的更新只和 λ₁(z₁方向的陡峭度)有关
      z₂的更新只和 λ₂(z₂方向的陡峭度)有关

      该表达式中表明:梯度下降在不同的特征空间之间不会混合。

      • 也就是说,若用 \mathbf{Q} 的特征系统来表示,优化问题是以逐坐标顺序的方式进行的。这在动量法中也适用。

      在这样做的过程中,我们只是证明了以下定理:带有和带有不凸二次函数动量的梯度下降,可以分解为朝二次矩阵特征向量方向坐标顺序的优化。

      6.3.2. 标量函数

      现在尝试最小化函数 f(x) = \frac{\lambda}{2} x^2 :

      • 该目标函数为 ​​碗形​​曲面,底部在 x=0
        • λ控制山的"陡峭程度":
          • λ大 → 山很陡
          • λ小 → 山很平缓
      • 该目标函数的梯度:f'(x) = λx
        • 更新公式:x_{新} = x_旧 - η × 梯度 = x_旧 - ηλx_旧
      • 则对于梯度下降我们有
      从 x₀开始:
      第1步:x₁ = (1-ηλ)x₀                  
      第2步:x₂ = (1-ηλ)x₁ = (1-ηλ)²x₀
      ...
      第 t 步:x_t = (1-ηλ)^t x₀           
      • 情况1:收敛( |1 - \eta \lambda| < 1 )
        • 当 t增大时,(1-ηλ)^t会趋近于0
          条件​​:|1-ηλ| < 1
          ​​设​​:η=0.5, λ=1 ——→ 1-ηλ=0.5
          
          步数t | 位置x_t | 计算过程
          0     | 10.0   | 起点
          1     | 5.0    | 10 × 0.5 = 5.0
          2     | 2.5    | 5 × 0.5 = 2.5  
          3     | 1.25   | 2.5 × 0.5 = 1.25
          4     | 0.625  | 1.25 × 0.5 = 0.625
          10    | 0.0098 | 10 × (0.5)^10 ≈ 0.0098
          
          每步走剩余距离的一半,指数级接近目标。
        • 结果​​:x_t → 0(成功到达山底!)
        • 收敛速度​​:指数级收敛(很快!)
      • 情况2:发散(|1-ηλ| > 1)来回摆,越来越离谱,彻底失控​
        • 当 t增大时,|1-ηλ|^t会无限增大
          条件​​:|1-ηλ| > 1
          ​​设​​:η=2.5, λ=1 ——→ 1-ηλ=-1.5
          
          步数t | 位置x_t | 计算过程
          0     | 10.0   | 起点
          1     | -15.0  | 10 × (-1.5) = -15.0(冲过山底到对面)
          2     | 22.5   | -15 × (-1.5) = 22.5(又冲回来更远)
          3     | -33.75 | 22.5 × (-1.5) = -33.75
          4     | 50.625 | -33.75 × (-1.5) = 50.625
          
          像荡秋千一样,每次摆动幅度增大50%,离目标越来越远。
        • ​​结果​​:x_t爆炸到无穷大(失败!)
      • 情况3:震荡(1-ηλ = -1附近)来回横跳,永不停止。​
        • 1-ηλ为负数 → x_t正负交替
          条件​​:1-ηλ ≈ -1
          ​​设​​:η=2.0, λ=1 ——→ 1-ηλ=-1
          
          步数t | 位置x_t | 计算过程  
          0     | 10.0   | 起点
          1     | -10.0  | 10 × (-1) = -10.0(冲到山底对面)
          2     | 10.0   | -10 × (-1) = 10.0(又冲回起点)
          3     | -10.0  | 10 × (-1) = -10.0
          4     | 10.0   | -10 × (-1) = 10.0
          
          像钟摆一样,永远在10和-10之间振荡,无法到达山底。
        • 结果​​:在山底两边来回跳,永不收敛

      导致出现这种数学规律的 关键公式​​:x_t = (1 - \eta \lambda)^t x_0

      这个公式就像​​复利计算​​:

      • 当 |1-ηλ| < 1​​:(小数)^t会趋近于0 → 收敛
      • ​​当 |1-ηλ| > 1​​:(大数)^t会爆炸到无穷 → 发散
      • 当 1-ηλ = -1​ ​:(-1)^t在1和-1之间振荡 → 震荡
      • 因为在 t 步之后可以得到 x_t = (1 - \eta \lambda)^t x_0 。
      • 这显示了在将学习率 \eta 提高到 \eta \lambda = 1 之前,收敛率最初是如何提高的。
        • 当 η从0逐渐增大时:
          • η=0.1 → 1-ηλ= 0.9  → 每步剩余90%(慢)
          • η=0.5 → 1-ηλ= 0.5  → 每步剩余50%(快!)
          • η=1.0 → 1-ηλ= 0     → ​​一步到位​​(最快!)
        • 所以提高学习率会加速收敛​​,但只能到某个点。
      • 超过该数值之后,梯度开始发散,
        • 当 η继续增大:
          • η=1.5→ 1-ηλ=-0.5→ 开始振荡但还收敛
          • η=2.0→ 1-ηλ=-1→ 临界点(震荡不收敛)
          • η=2.5→ 1-ηλ=-1.5→ 彻底发散
      • 对于 \eta \lambda > 2 而言,优化问题将会发散。
        • 因为当 ηλ>2时,|1-ηλ|>1,属于上面的情况2。
      # 可视化不同曲率(λ)下,梯度下降的收敛行为
      # 定义不同的曲率值(控制损失函数的陡峭程度)
      lambdas = [0.1, 1, 10, 19]  # λ值:从平缓到陡峭
      eta = 0.1 # 固定学习率为0.1
      plt.figure(figsize=(6, 4))  # 创建图形
      for lam in lambdas:         # 对每个λ值绘制收敛曲线
          t = torch.arange(20).detach().numpy() # 创建时间序列:0到19(20个时间步)
          # 计算每个时间步的收敛因子:(1 - ηλ)^t
          plt.plot(t, (1 - eta * lam) ** t, label=f'lambda = {lam:.2f}')
      plt.xlabel('time')  # x轴:时间(迭代次数)
      plt.legend()        # 显示图例
      λ = 0.1(非常平缓)​:每步只减少1%,收敛极慢          
      λ = 1   (中等陡峭)​​:每步减少10%  ,稳定收敛         
          λ = 10        (陡峭):​ ​一步到位​​!立即收敛到最小值        
      λ = 19 (非常陡峭)​:震荡收敛,正负交替但幅度减小 

      所以:学习率与曲率需匹配​。

      为分析动量的收敛情况,首先用两个标量重写更新方程: x 和 动量 v 。于是有:

      状态转移方程

      部分

      物理意义

      解释

      ​​v_{t+1}​​

      下一时刻的“速度”

      由历史速度(βv_t)和当前坡度(λx_t)共同决定

      ​​x_{t+1}​​

      下一时刻的“位置”

      由当前位置和速度共同决定

      ​​矩阵R​​

      ​​“运动规则”​​

      决定了速度和位置的相互作用规律

      简而言之,动量法更强大

      • ① 动量法收敛范围更大:动量法允许使用​​更大的学习率​​而不发散。
        • ​​即 允许更大的学习率​
        • 普通GD稳定条件​​:0 < \eta \lambda < 2
        • 动量法稳定条件  ​​:0 < \eta \lambda < 2 + 2 \beta
        • # 假设 β=0.9(常用动量值)
          普通GD最大安全学习率:η < 2/λ
          动量法最大安全学习率:η < (2 + 2×0.9)/λ = 3.8/λ  # 几乎翻倍!
      • ② 特征值决定收敛速度(收敛速度由 \mathbf{R} 的特征值决定)
        • 在 t 步之后,最初的值 [v_0, x_0] 变为 \mathbf{R}(\beta, \eta, \lambda)^t [v_0, x_0] 。
        • 因此,收敛速度是由 \mathbf{R} 的特征值决定的。
        • 通俗理解​​:
          • 矩阵R的特征值就像系统的“固有频率”
          • 特征值<1:系统稳定收敛(越小收敛越快)
          • 动量法通过精心设计,让特征值​​更小更稳定​

      实际意义:学习率选择​​

      关键公式​​:x_t = (1 - \eta \lambda)^t x_0

      学习率区间

      效果

      建议

      ​​ηλ < 1​​

      稳定收敛

      安全但可能慢

      ​​ηλ = 1​​

      ​​最优收敛​​

      理想情况(一步到位)

      ​​1 < ηλ < 2​​

      振荡但收敛

      可接受,有波动

      ​​ηλ = 2​​

      永恒振荡

      避免!

      ​​ηλ > 2​​

      发散失败

      绝对避免!

      小结

      • 动量法:

        • 用过去梯度的平均值来替换梯度 → 加快了收敛速度

        • 可用场景:无噪声梯度下降 / 嘈杂随机梯度下降

        • 可防止随机梯度下降的优化过程 中的停滞。

      • 由于对过去的数据进行了指数降权,有效梯度数为 \frac{1}{1-\beta} 。

      • 在凸二次问题中,可对动量法进行明确而详细的分析。

      • 动量法的实现非常简单,但它需要存储额外的状态向量(动量v)。

      7. AdaGrad算法 (记得所有陡坡,累积历史梯度平方和)

      Adagrad​​:根据历史梯度平方和为每个参数自适应调整学习率

      7.1. 稀疏特征和学习率

      假设正在训练一个语言模型。为获得良好的准确性,我们大多希望在训练的过程中降低学习率,速度通常为 \mathcal{O}(t^{-\frac{1}{2}}) 或更低。现在讨论关于自然语言中常见的 稀疏特征(即只在偶尔出现的特征)的模型训练。

      核心问题:稀疏特征难优化(稀疏特征的挑战)

      稀疏特征:指在数据集中​​很少出现​​的特征。比如在自然语言处理中:

      • 常见词:"学习"、"模型"(频繁出现)
      • 稀疏词:"预先条件"、"超参数"(很少出现)

      稀疏特征难优化的原因:

      • 传统学习率衰减的问题:\eta = \frac{\eta_0}{\sqrt{t + c}}​
        # 传统学习率衰减
        learning_rate = initial_lr / sqrt(t + 1)
      • 问题分析:学习率要么对于常见特征而言降低太慢,要么对于不常见特征而言降低太快​
        • 常见特征​​:频繁更新,参数很快收敛到最优值
        • 稀疏特征​​(不常见的特征):很少更新,当学习率衰减时,​​还没学到足够信息​​就停止更新了

      解决方案:​记录看到特定特征的次数,然后将其用作调整学习率。

      # 按特征出现次数调整学习率
      learning_rate_i = initial_lr / sqrt(s(i, t) + 1)

      即 使用大小为 \eta_i = \frac{\eta_0}{\sqrt{s(i, t) + c}} 的学习率,而非 \eta = \frac{\eta_0}{\sqrt{t + c}} (传统学习率衰减) 。

      • s(i, t) 即 s(i, t) :特征 i 到时间 t 为止的出现次数
        • s(i, t)  计下:截至 t 时,观察到功能 i 的次数 (容易实施,且不产生额外损耗)
        • ​​常见特征​​:s(i, t)大 → 学习率小(已充分学习)
        • ​​稀疏特征​​:s(i, t)小 → 学习率大(需要更多学习)

      AdaGrad 算法 (Duchi et al., 2011):

      • 将粗略的计数器 s(i, t) 替换为 先前观察所得梯度的 平方之和。
      • 它使用 s(i, t+1) = s(i, t) + \left(\partial_i f(\mathbf{x})\right)^2 来调整学习率。
      • 这有两个好处:
        • ① 不再需要决定梯度何时算足够大。
        • ② 它会随梯度的大小自动变化。
          • 通常对应于较大梯度的坐标会显著缩小,
          • 而其他梯度较小的坐标则会得到更平滑的处理。
      • 在实际应用中,它促成了计算广告学及其相关问题中非常有效的优化程序。
      • 但是,它遮盖了AdaGrad 固有的一些额外优势,这些优势在预处理环境中很容易被理解。

      7.2. 预处理 (提前解耦,以便每个方向独立优化)

      三个具体步骤:

      • ① 识别耦合关系
        • 原始问题:f(x) = ½xᵀQx + cᵀx + b
        • 耦合表现:Q的非对角线元素不为零
        • 物理意义:x₁方向的移动会影响x₂方向的梯度
      • ② 对角化(解耦)
        • 通过特征分解:Q = OᵀΛO
        • 旋转:用正交矩阵O旋转坐标系
        • 结果:在新坐标系z中,Q变为对角矩阵Λ
      • ③ 独立优化
        • 变换后的问题:f(z) = ½(λ₁z₁² + λ₂z₂² + ...) + b'
        • 每个方向独立:z₁的优化不影响z₂
        • 并行优化:可以同时优化所有方向

      解耦后,就可以为每个方向设置最优学习率。

      预处理的数学原理​

      二次函数的最小化问题:​f(\mathbf{x}) = \frac{1}{2} \mathbf{x}^\top \mathbf{Q} \mathbf{x} + \mathbf{c}^\top \mathbf{x} + b

      通过配方找到最小值点:​

      • 其中 \mathbf{x}^* = -\mathbf{Q}^{-1} \mathbf{c} 是最小值点

      ​​对角化(关键步骤)​

      特征分解:\mathbf{Q} = \mathbf{O}^\top \boldsymbol{\Lambda} \mathbf{O}

      • ​​O​​:正交矩阵(旋转)。用正交矩阵O旋转坐标系
      • Λ​​:对角矩阵(特征值)。在新坐标系z中,Q变为对角矩阵Λ

      坐标变换:​

      变换后的神奇效果:​

      • 解耦​​:每个方向独立优化
      • ​​曲率​​:λᵢ 表示第i个方向的陡峭程度

      凸优化问题有助于分析算法的特点。 毕竟对大多数非凸问题来说,获得有意义的理论保证很难,但是直觉和洞察往往会延续。来看看最小化 f(\mathbf{x}) = \frac{1}{2} \mathbf{x}^\top \mathbf{Q} \mathbf{x} + \mathbf{c}^\top \mathbf{x} + b 这一问题。

      • 耦合表现:Q的非对角线元素不为零
      • 物理意义:x₁方向的移动会影响x₂方向的梯度

      正如在(6. 动量法)中那样,可根据其特征分解 \mathbf{Q} = \mathbf{U}^\top \boldsymbol{\Lambda} \mathbf{U} 重写该问题 (用对角化解耦),来得到一个简化得多的问题,使每个坐标都可以单独解出:

      • 这里使用了 \mathbf{x} = \mathbf{U} \mathbf{x} ,且因此 \mathbf{c} = \mathbf{U} \mathbf{c} 。
      • 修改后优化器为 \bar{\mathbf{x}} = -\boldsymbol{\Lambda}^{-1} \bar{\mathbf{c}} 且最小值为 -\frac{1}{2} \bar{\mathbf{c}}^\top \boldsymbol{\Lambda}^{-1} \bar{\mathbf{c}} + b 。
      • 这样更易计算,因为 \boldsymbol{\Lambda} 是一个包含 \mathbf{Q} 特征值的对角矩阵。

      若稍微扰动 \mathbf{c} ,我们会期望在的 f 最小化器中只产生微小的变化。但并没有:

      • 虽然 \mathbf{c} 的微小变化导致了 \bar{\mathbf{c}} 同样的微小变化,但 f 的(以及 \bar{f} 的)最小化器并非如此。
      • 每当特征值 \boldsymbol{\Lambda}_i 很大时,我们只会看到 \bar{x}_i 和 \bar{f} 的最小值发声微小变化。
      • 相反,对小的 \boldsymbol{\Lambda}_i 来说, \bar{x}_i 的变化可能是剧烈的。
      • 最大和最小的特征值之比称为优化问题的条件数(condition number)。

      若条件编号 \kappa 很大,准确解决优化问题就会很难。我们需要确保在获取大量动态的特征值范围时足够谨慎:难道我们不能简单地通过扭曲空间来“修复”这个问题,从而使所有特征值都是 1 ? 理论上这很容易:我们只需要 \mathbf{Q} 的特征值和特征向量即可将问题从 \mathbf{x} 整理到 \mathbf{z} := \boldsymbol{\Lambda}^{\frac{1}{2}} \mathbf{U} \mathbf{x} 中的一个。 在新的坐标系中,\mathbf{x}^\top \mathbf{Q} \mathbf{x} 可以被简化为 \|\mathbf{z}\|^2。但这并不实际。 一般而言,计算特征值和特征向量要比解决实际问题“贵”得多。

      虽然准确计算特征值可能会很昂贵,但即便只是大致猜测并计算它们,也可能已经比不做任何事情好得多。 特别是,我们可以使用 \mathbf{Q} 的对角线条目并相应地重新缩放它。 这比计算特征值开销小的多。

      在这种情况下,我们得到了 \tilde{\mathbf{Q}}_{ij} = \mathbf{Q}_{ij} / \sqrt{\mathbf{Q}_{ii} \mathbf{Q}_{jj}} ,特别注意对于所有 i ,\tilde{\mathbf{Q}}_{ii} = 1 。 在大多数情况下,这大大简化了条件数。 例如我们之前讨论的案例,它将完全消除眼下的问题,因为问题是轴对齐的。

      遗憾的是,我们还面临另一个问题:在深度学习中,通常无法计算目标函数的二阶导数:对于 \mathbf{x} \in \mathbb{R}^d ,即使只在小批量上,二阶导数可能也需要 \mathcal{O}(d^2) 空间来计算,导致几乎不可行。 AdaGrad算法巧妙的思路是,使用一个代理来表示黑塞矩阵(Hessian)的对角线,既相对易于计算又高效。

      为了了解它是如何生效的,让我们来看看 \bar{f}(\bar{\mathbf{x}}) 。 我们有

      • 其中 \bar{\mathbf{x}}_0 是 \bar{f} 的优化器。 因此,梯度的大小取决于 \boldsymbol{\Lambda} 和与最佳值的差值。
      • 若 \bar{\mathbf{x}} - \bar{\mathbf{x}}_0 没有改变,那这就是我们所求的。毕竟在这种情况下,梯度 \partial_{\bar{\mathbf{x}}} \bar{f}(\bar{\mathbf{x}}) 的大小就足够了。
      • 由于AdaGrad算法是一种随机梯度下降算法,所以即使是在最佳值中,我们也会看到具有非零方差的梯度。
      • 因此,可放心地使用梯度的方差作为黑塞矩阵比例的廉价替代。
        • 详尽的分析(要花几页解释)超出了本节的范围,请读者参考 (Duchi et al., 2011)。

      7.3. 算法

      AdaGrad算法原理​​

      • ​​核心思想​​:用​​历史梯度平方和​​来调整学习率
      • 算法步骤:​
      • 算法优势​

        • 自适应学习率​​:
          • 梯度大的方向:s_t大 → 学习率小(防止震荡)
          • 梯度小的方向:s_t小 → 学习率大(加速收敛)
        • 无需手动调参​​:自动根据梯度历史调整
        • 处理稀疏特征​​:对不频繁出现的特征保持较大学习率
      • ​​数学性质分析​

        • 收敛性分析:​
          • 对于凸函数,AdaGrad的收敛速度为 O(1/√T)
          • 对于非凸函数,同样有良好的理论保证
        • 数值稳定性:​
          • 分母加小常数ε防止除零
          • 梯度平方累积确保非负性

      现在接着上面正式开始讨论。使用变量 \mathbf{s}_t 来累加过去的梯度方差,如下所示:

      在这里,操作是按照坐标顺序应用。也就是说,

      • \mathbf{v}^2 有条目 v_i^2 。
      • 同样,\frac{1}{\sqrt{v}} 有条目 \frac{1}{\sqrt{v_i}}, 并且 \mathbf{u} \cdot \mathbf{v} 有条目 u_i v_i 。
      • 与之前一样,
        • \eta :学习率,
        • \epsilon :一个为维持数值稳定性而添加的常数,以确保不会除以 0 。
      • 最后,初始化 \mathbf{s}_0 = \mathbf{0} 。(s为累计过去的梯度方差)

      就像在动量法中需要跟踪一个辅助变量一样:

      • 在AdaGrad算法中,允许每个坐标有单独的学习率。
      • 与SGD算法相比,这并没有明显增加AdaGrad的计算代价,因为主要计算用在 l(y_t, f(\mathbf{x}_t, \mathbf{w})) 及其导数。

      注意:在 \mathbf{s}_t 中累加平方梯度意味着 \mathbf{s}_t 基本上以线性速率增长(实际上梯度会随着优化而衰减,所以增长比线性慢一些)。

      • 这产生了一个学习率 \mathcal{O}(t^{-\frac{1}{2}}) ,但是在单个坐标的层面上进行了调整 (即 每个参数有自己的私人学习率)。 对于凸问题,这完全足够了。
      • 许多AdaGrad算法的变体 的产生皆是为了 在深度学习中更慢地降低学习率,这将在后续章节中讨论它们。
      • 眼下先看它在二次凸问题中的表现如何。仍以同一函数为例:

      下面使用与之前相同的学习率来实现AdaGrad算法,即 \eta = 0.4 。

      • 能看到,自变量的迭代轨迹较平滑。
      • 但由于 \mathbf{s}_t 的累加效果使学习率不断衰减,自变量在迭代后期的移动幅度较小。
      # %matplotlib inline
      import math
      import torch
      import common
      
      def adagrad_2d(x1, x2, s1, s2):
          '''AdaGrad优化器实现(二维版本)
          x1, x2: 当前参数值
          s1, s2: 梯度平方累积状态(历史记忆)
          '''
          eps = 1e-6  # 小常数,防止除以零
      
          # 1. 计算当前梯度(目标函数f(x1,x2)=0.1*x1²+2*x2²的梯度)
          g1 = 0.2 * x1  # ∂f/∂x1 = 0.2*x1
          g2 = 4 * x2    # ∂f/∂x2 = 4*x2
      
          # 2. 累积梯度平方(AdaGrad核心:记忆历史梯度大小)
          s1 += g1 ** 2  # 累积x1方向的梯度平方
          s2 += g2 ** 2  # 累积x2方向的梯度平方
      
          # 3. AdaGrad更新:参数 -= 学习率 / √(历史梯度平方和) * 当前梯度
          x1 -= eta / math.sqrt(s1 + eps) * g1  # 自适应调整x1的学习率
          x2 -= eta / math.sqrt(s2 + eps) * g2  # 自适应调整x2的学习率
          return x1, x2, s1, s2  # 返回更新后的参数和状态
      
      def f_2d(x1, x2):
          '''目标函数:椭圆抛物面'''
          return 0.1 * x1 ** 2 + 2 * x2 ** 2  # 最小值在(0,0)
      
      eta = 0.4 # 中等学习率
      common.show_trace_2d(f_2d, common.train_2d(adagrad_2d))

      • η=0.4 时:收敛稳定但可能较慢
      • η=2.0 时:收敛更快,路径更直接
      • 数学原因:
        有效学习率 = η / √(s_t + ε) # AdaGrad更新公式
        
        # 初期:s_t很小,有效学习率 ≈ η(几乎完全利用)
        # 后期:s_t增大,有效学习率自动减小(防止震荡)
      • 所以:增大η主要影响前期收敛速度,后期由自适应机制保证稳定性。

      将学习率提高到 2 ,可以看到更好的表现:

      • 这已表明,即使在无噪声的情况下,学习率的降低可能相当剧烈,我们需要确保参数能够适当地收敛。
        • 无噪声情况:
          • 噪声:通常指随机梯度中的随机波动(小批量采样导致)
          • 这里使用的是真实梯度(非随机),所以不存在噪声
          • 但依然:学习率会快速下降
          • 这说明:AdaGrad的学习率下降是算法固有特性,不是噪声导致的。
        • 学习率降低剧烈:
          • AdaGrad的机制:
            s_t = s_{t-1} + g_t²  # 梯度平方累积(只增不减!)
            学习率 = η / √s_t      # 单调递减函数
          • "剧烈下降"的数学表现:

            优化阶段

            s_t 增长

            学习率变化

            效果

            初期

            s_t 线性增长

            快速下降(~1/√t)

            快速收敛

            后期

            s_t 很大

            极低(≈0)

            可能过早停止

        • 需确保参数能适当收敛:(因为有潜在风险)
          • ① 学习率过早衰减:
            # 可能发生在到达最优点之前
            while 距离最优解还很远:
                学习率已经降到接近零  # 优化停滞!
          • ② 无法逃离平坦区:
            # 若陷入局部平坦区域
            梯度很小 → s_t增长慢 → 学习率保持很低
            # 但没有足够"动力"爬出平坦区
          • ③ 非凸问题中的困境:
            • 需要增大学习率跳出局部最优
            • 但AdaGrad只减不增,可能被困住
      eta = 2 # 大学习率(测试AdaGrad的鲁棒性)
      common.show_trace_2d(f_2d, common.train_2d(adagrad_2d))

      与之前算法的对比

      算法

      η=0.4效果

      η=2.0效果

      特点

      普通SGD

      收敛

      发散

      对学习率敏感

      动量法

      收敛

      可能震荡

      有一定鲁棒性

      AdaGrad

      收敛

      依然收敛

      高度自适应

      7.4. 从零开始实现

      与动量法一样,AdaGrad算法需要对每个自变量维护同它一样形状的状态变量。

      def init_adagrad_states(feature_dim):
          '''初始化AdaGrad的状态变量(梯度平方累积器)
          为每个可训练参数创建对应的状态变量,用于记录历史梯度平方和
          feature_dim: 特征维度(输入特征数量)
          返回:(s_w, s_b): 权重和偏置的梯度平方累积状态
          '''
          s_w = torch.zeros((feature_dim, 1))  # 权重的梯度平方累积器(与w同形状)
          s_b = torch.zeros(1)                 # 偏置的梯度平方累积器(与b同形状)
          return (s_w, s_b)
      
      def adagrad(params, states, hyperparams):
          '''AdaGrad优化器实现
          params: 待优化参数列表 [w, b]
          states: 状态变量列表 [s_w, s_b]
          hyperparams: 超参数字典 {'lr': 学习率}
          '''
          eps = 1e-6  # 小常数,防止除以零
          for p, s in zip(params, states):  # 同时遍历参数和对应的状态
              with torch.no_grad():         # 禁用梯度跟踪(纯数值计算)
                  # 累积梯度平方:s = s + (梯度)^2
                  s[:] += torch.square(p.grad)  # 原地更新,保持内存引用
      
                  # AdaGrad更新:参数 -= 学习率 * 梯度 / √(s + ε)
                  p[:] -= hyperparams['lr'] * p.grad / torch.sqrt(s + eps)
              p.grad.data.zero_() # 清零当前梯度,准备下一轮计算

      与(5. 小批量随机梯度下降)中的实验相比,这里使用更大的学习率来训练模型。

      # 获取数据迭代器和特征维度(批量大小=10)
      data_iter, feature_dim = common.get_data_ch11(downloader, batch_size=10)
      common.train_ch11(adagrad, init_adagrad_states(feature_dim),
                     {'lr': 0.1}, data_iter, feature_dim)

      7.5. 简洁实现

      可直接使用深度学习框架中提供的AdaGrad算法来训练模型:

      # 使用PyTorch官方实现的AdaGrad(对比验证)
      trainer = torch.optim.Adagrad  # PyTorch内置AdaGrad优化器类
      common.train_concise_ch11(trainer, {'lr': 0.1}, data_iter)

      7.6. 小结

      • AdaGrad算法:

        • 会在单个坐标层面动态降低学习率。(各个方向有自己的学习率)

        • 利用梯度的大小作为调整进度速率的手段:用较小的学习率来补偿带有较大梯度的坐标。

      • 在深度学习问题中,由于内存和计算限制,计算准确的二阶导数通常是不可行的。梯度可以作为一个有效的代理。

      • 如果优化问题的结构相当不均匀,AdaGrad算法可帮助缓解扭曲。

      • AdaGrad算法对于稀疏特征特别有效,在此情况下由于不常出现的问题,学习率需要更慢地降低。

      • 在深度学习问题上,AdaGrad算法有时在降低学习率方面可能过于剧烈。在(10. Adam算法)会讨论缓解这种情况的策略。

      8. RMSProp算法 (有限记忆,重点关注近期,久远的遗忘)

      • RMSprop​​:使用指数加权平均代替累积和
        • 有限记忆:忘记久远历史,关注近期表现
        • 指数衰减:越近的梯度权重越大
        • 自适应:为每个参数单独调整学习率
        • 抗噪声:平滑梯度幅度,减少随机波动影响
      • 既保证了稳定性(平滑噪声),又保持了适应性(能应对变化)。

      (7. AdaGrad算法)中的关键问题之一,是学习率按预定时间表 \mathcal{O}(t^{-\frac{1}{2}}) 显著降低。 虽然这通常适用于凸问题,但对于深度学习中遇到的非凸问题,可能并不理想。 但是,作为一个预处理器,Adagrad算法按坐标顺序的适应性是非常可取的。

      (Tieleman and Hinton, 2012)建议以RMSProp算法作为将速率调度与坐标自适应学习率分离的简单修复方法。

      • Adagrad算法的问题:将梯度 \mathbf{g}_t 的平方累加成状态矢量 \mathbf{s}_t = \mathbf{s}_{t-1} + \mathbf{g}_t^2 。因此,由于缺乏规范化,没有约束力,\mathbf{s}_t 持续增长,几乎上是在算法收敛时呈线性递增。
        # AdaGrad 的累积方式(有问题!)
        s_t = s_{t-1} + g_t²  # 一直累加,永不减少
        
        结果:s_t会无限增长
        导致:学习率 η/√s_t会无限减小 → 最终停止学习

      解决此问题的方法:

      • 一种是使用 \mathbf{s}_t / t 。对 \mathbf{g}_t 的合理分布来说,它将收敛。但限制行为生效的所需时间长,因为该流程记住了值的完整轨迹。
      • 另一种方法是按动量法中的方式使用泄漏平均值,
        • 即 \mathbf{s}_t \leftarrow \gamma \mathbf{s}_{t-1} + (1-\gamma) \mathbf{g}_t^2 ,其中参数 \gamma > 0 。
        • 保持所有其它部分不变就产生了RMSProp算法 ↓

      RMSProp 的解决方案:

      • 指数加权平均(核心创新)
        # RMSProp 的改进方式
        s_t = γ * s_{t-1} + (1-γ) * g_t²  # 不是简单累加
        
        参数 γ(gamma)的作用:
        γ ≈ 1:记忆很强,忘记慢(看重历史)
        γ ≈ 0:记忆很弱,忘记快(看重当前)

      不同 γ 值的记忆效果

      γ 控制记忆强度(通常 0.9-0.99)

      γ 值

      物理意义

      有效记忆步数

      适用场景

      γ=0.9

      中记忆

      约10步

      一般任务

      γ=0.95

      强记忆

      约20步

      稳定环境

      γ=0.99

      超强记忆

      约100步

      噪声大的数据

      • γ=0.9     → 记忆10步      :主要关注最近10步的表现
      • γ=0.99   → 记忆100步    :关注最近100步的表现
      • γ=0.999 → 记忆1000步

      RMSProp vs AdaGrad 对比

      • AdaGrad(无限记忆)
        # 学习率持续下降
        学习率 = η / √(所有历史梯度平方和)
        # 问题:后期几乎不更新
      • RMSProp(有限记忆)
        # 学习率动态调整  
        学习率 = η / √(近期梯度平方的加权平均)
        # 优势:能适应数据分布变化
        
        即:
        # 波动大的梯度 → 学习率自动减小(防震荡)
        # 平稳的梯度 → 学习率保持较大(加速收敛)
        有效学习率 = η / √(平滑后的梯度幅度)
        • 三大优势:
          • ① 解决学习率衰减问题
            # 传统 AdaGrad
            s_t = g₁² + g₂² + ... + g_t²  # 单调递增
            
            # RMSProp  
            s_t ≈ 近期梯度的加权平均  # 有上有下
          • ② 适应非平稳目标:
            • 在深度学习中,损失函数曲面是动态变化的:
              • 不同区域曲率不同
              • 需要动态调整学习率,而非一味减小
          • ③ 对噪声更鲁棒
            • 单个噪声梯度不会永久影响学习率
            • 只有持续的趋势才会显著改变 s_t

      8.1. 算法

      下面详细写出这些方程式。

      新记忆 = γ * 旧记忆 + (1-γ) * 当前梯度²
      γ:记忆保留比例(通常0.9)
      1-γ:新信息权重(通常0.1)
      具体例子(γ=0.9)
      # 第1步:s₁ = 0.1*g₁² + 0.9 * 0
      # 第2步:s₂ = 0.1*g₂² + 0.9*s₁  
      # 第3步:s₃ = 0.1*g₃² + 0.9*s₂
      # 这样,近期梯度权重更大!
      • 常数 \epsilon > 0 通常设为 10^{-6} ,以确保不会因除以零或步长过大而受到影响。
      • 鉴于这种扩展,我们现在可以自由控制学习率 \eta,而不考虑基于每个坐标应用的缩放。
      • 就泄漏平均值而言,可采用与之前在动量法中适用的相同推理。 扩展 \mathbf{s}_t 定义可获得

      • 同之前在(6. 动量法)一样,使用 1 + \gamma + \gamma^2 + \ldots, = \frac{1}{1-\gamma} 。
      • 因此,权重总和标准化为 1 :权重和= (1-γ) + (1-γ)γ + (1-γ)γ² + ... = 1
        • 这意味着:所有历史梯度的总权重为1,近期梯度权重更大
      • 且观测值的半衰期为 \gamma^{-1} 。
        • 半衰期 = 权重减半所需步数 :γ^步数 = 0.5
        • γ=0.9  :半衰期 ≈ 7步(7步后权重减半)
        • γ=0.95:半衰期 ≈ 14步
        • γ=0.5  :半衰期 ≈ 1步(忘得快)

      下面图像化各种数值的 \gamma 在过去40个时间步长的权重:

      import math
      import torch
      import common
      
      '''绘制gamma衰减曲线(理论分析)'''
      plt.figure(figsize=(8, 6))  # 创建第一个独立图表
      gammas = [0.95, 0.9, 0.8, 0.7] # 不同的衰减系数
      for gamma in gammas:
          x = torch.arange(40).detach().numpy() # 时间序列:0到39
          # 计算指数衰减权重:(1-γ) * γ^x
          plt.plot(x, (1-gamma) * gamma ** x, label=f'gamma = {gamma:.2f}')
      plt.xlabel('time')
      plt.ylabel('权重')
      plt.title('RMSProp的记忆衰减效果 - 不同Gamma值的影响\n(越大记忆越长)')
      plt.legend()
      plt.grid(True, alpha=0.3)
      plt.show()  # 显示第一个图

      8.2. 从零开始实现

      • 和之前一样,使用二次函数 f(\mathbf{x})=0.1x_1^2+2x_2^2 来观察RMSProp算法的轨迹。
      • 回想在(7. AdaGrad算法)中,当使用学习率为0.4的Adagrad算法时,
        • 变量在算法的后期阶段移动非常缓慢,因为学习率衰减太快。
        • RMSProp算法中不会发生这种情况,因为 \eta 是单独控制的。
      '''RMSProp在2D问题上的可视化(算法演示)'''
      def rmsprop_2d(x1, x2, s1, s2):
          '''2D RMSProp实现'''
          g1, g2, eps = 0.2 * x1, 4 * x2, 1e-6  # 计算梯度
          s1 = gamma * s1 + (1 - gamma) * g1 ** 2  # 更新梯度平方平均
          s2 = gamma * s2 + (1 - gamma) * g2 ** 2
          x1 -= eta / math.sqrt(s1 + eps) * g1     # RMSProp更新
          x2 -= eta / math.sqrt(s2 + eps) * g2
          return x1, x2, s1, s2
      
      def f_2d(x1, x2):
          '''目标函数:椭圆碗'''
          return 0.1 * x1 ** 2 + 2 * x2 ** 2
      
      eta, gamma = 0.4, 0.9 # 学习率0.4,衰减系数0.9
      common.show_trace_2d(f_2d, common.train_2d(rmsprop_2d)) # 显示RMSProp优化轨迹(椭圆碗形函数)

      接下来,在深度网络中实现RMSProp算法:

      '''RMSProp在真实数据上的训练(实际应用)'''
      def init_rmsprop_states(feature_dim):
          '''初始化RMSProp状态'''
          s_w = torch.zeros((feature_dim, 1)) # 权重的梯度平方平均
          s_b = torch.zeros(1)                # 偏置的梯度平方平均
          return (s_w, s_b)
      
      def rmsprop(params, states, hyperparams):
          '''通用RMSProp实现'''
          gamma, eps = hyperparams['gamma'], 1e-6
          for p, s in zip(params, states):
              with torch.no_grad():
                  # 指数加权平均更新
                  s[:] = gamma * s + (1 - gamma) * torch.square(p.grad)
                  # RMSProp参数更新
                  p[:] -= hyperparams['lr'] * p.grad / torch.sqrt(s + eps)
              p.grad.data.zero_()

      将初始学习率设置为0.01,加权项 \gamma 设置为0.9。 也就是说,\mathbf{s} 累加了过去的 1/(1-\gamma) = 10 次平方梯度观测值的平均值。

      # 获取数据迭代器和特征维度
      data_iter, feature_dim = common.get_data_ch11(downloader, batch_size=10)
      common.train_ch11(rmsprop, init_rmsprop_states(feature_dim),
                     {'lr': 0.01, 'gamma': 0.9}, data_iter, feature_dim)

      8.3. 简洁实现

      可直接使用深度学习框架中提供的RMSProp算法来训练模型:

      trainer = torch.optim.RMSprop # 使用PyTorch内置RMSProp对比
      common.train_concise_ch11(trainer, {'lr': 0.01, 'alpha': 0.9},
                             data_iter)

      小结

      • RMSProp 与 Adagrad 非常相似:皆使用梯度的平方来缩放系数。

      • RMSProp算法与动量法都使用泄漏平均值。但是,RMSProp算法使用该技术来调整按系数顺序的预处理器。

      • 在实验中,学习率需由实验者调度。

      • 系数决定了在调整每坐标比例时历史记录的时长。

      9. Adadelta (AdaDelta = RMSProp + 自动学习率) (AdaGrad的另一种变体)

      • AdaDelta​​:完全自适应,无需手动设置学习率

      之前算法的缺陷:

      • AdaGrad:学习率一直下降,最终停止学习
      • RMSProp:需要手动设置学习率(η)

      传统优化器的问题:都需要手动设置学习率 η

      # 都需要手动设置学习率 η
      SGD: Δx = -η * g
      AdaGrad: Δx = -η/√s * g  
      RMSProp: Δx = -η/√s * g  # 都要调 η!
                  = - (η / sqrt(s + ε)) * gradient

      AdaDelta 的突破:用"参数变化量"来自动确定学习率 ↓

      # 自动学习率,无需调参  
      Δx = - (sqrt(Δx_历史) / sqrt(s + ε)) * gradient  # 无η!
      创新点:用 √Δx_历史 替代了需要手动设置的 η
      • 学习率 ≈ 近期参数变化幅度 / 近期梯度幅度
      • 物理意义:根据过去的"步长经验"来调整现在的步长
        # 自适应学习率 = 历史步长幅度 / 历史梯度幅度
        effective_learning_rate
             = sqrt(historical_step_size) / sqrt(historical_gradient_size)

      Adadelta:

      • 是AdaGrad的另一种变体(7. AdaGrad算法)
        • 主要区别:Adadelta减少了学习率适应坐标的数量。
      • 此外,广义上Adadelta被称为没有学习率:使用变化量本身作为未来变化的校准。
      • 在 (Zeiler, 2012)中被提出。

      9.1. Adadelta算法

      Adadelta使用两个状态变量:(皆属于泄漏平均值)

      •    \mathbf{s}_t  :用于存储梯度二阶导数的泄露平均值
      • \Delta\mathbf{x}_t :用于存储模型本身中参数变化二阶导数的泄露平均值
      • # 状态1:梯度平方的指数平均(衡量梯度大小)
        s_t = ρ * s_{t-1} + (1-ρ) * g_t²
        
        # 状态2:参数更新量的平方平均(衡量步长大小)
        Δx_t = ρ * Δx_{t-1} + (1-ρ) * (Δx_实际)²

      注意,为与其他出版物和实现的兼容性,我们使用作者的原始符号和命名(没有其它真正理由让大家使用不同的希腊变量来表示在动量法、AdaGrad、RMSProp和Adadelta中用于相同用途的参数)。

      以下是Adadelta的技术细节。鉴于参数du jour是 \rho ,我们获得了与(8. RMSProp算法)类似的以下泄漏更新:

      ① 更新梯度平方的指数移动平均(类似RMSProp)
      s[:] = rho * s + (1 - rho) * torch.square(p.grad)

      与(8. RMSProp算法)的区别:Adadelta执行更新使用的梯度 是 重新缩放后的梯度 \mathbf{g}_t' :

      ③ 更新参数
      p[:] -= g # 应用计算出的更新量
      • \mathbf{g}_t' :调整后的梯度,方式计算如下:
      参数变化幅度 / 梯度变化幅度
      ② 计算自适应更新量(AdaDelta核心)
      用历史参数变化幅度来自动确定学习率
      g = (torch.sqrt(delta + eps) / torch.sqrt(s + eps)) * p.grad

      分子:√(Δx_{t-1} + ε) 即 \sqrt{\Delta\mathbf{x}_{t-1} + \epsilon}

      # 表示"近期有效的步长大小"
      # 基于历史经验:上几步用了多大步长
      近期步长 = 最近参数变化的幅度

      分母:√(s_t + ε) 即 {\sqrt{​{\mathbf{s}_t + \epsilon}}}

      # 表示"近期的梯度大小"  
      近期梯度 = 最近损失函数的陡峭程度

      数学原理

      • 分子 √Δx_{t-1}:近期有效的参数变化量
      • 分母 √s_t:近期的梯度幅度
      • 比值:自动平衡"变化幅度"与"梯度幅度"
      • \Delta \mathbf{x}_{t-1} :重新缩放梯度的平方 \mathbf{g}_t' 的泄漏平均值
      • 将 \Delta \mathbf{x}_{0} 初始化为 0 ,然后在每个步骤中使用 \mathbf{g}_t' 更新它,即
      ④ 更新参数变化量的平方平均(为下一步准备)
      记录本次参数变化的幅度,用于下一步的自适应
      delta[:] = rho * delta + (1 - rho) * g * g
      •  \epsilon :为保持数字稳定性而加入的常数(例如 10^{-5} 这样的小值)
      • ρ = 0.9(默认值)
        • 记忆长度:约10步(1/(1-0.9) = 10)
        • 效果:平衡近期与历史信息
        • 适用:大多数情况
        • ρ 的影响
          if ρ → 1.0:  # 长记忆
              更平滑,但适应慢
          if ρ → 0.0:  # 短记忆  
              适应快,但可能不稳定
          
          # 通常用默认值 0.9 即可
          # 如需调整,范围一般在 0.8-0.999

      9.2. 代码实现

      Adadelta需为每个变量维护两个状态变量,即 \mathbf{s}_t 和 \Delta\mathbf{x}_t 。由此产生以下实现:

      # %matplotlib inline
      import torch
      import common
      
      def init_adadelta_states(feature_dim):
          ''' 初始化AdaDelta的状态变量
          s:历史梯度平方平均(控制分母)
          delta:历史参数变化平方平均(控制分子)
          - s_w, s_b: 梯度平方的指数移动平均(类似RMSProp)
          - delta_w, delta_b: 参数更新量的平方平均(AdaDelta特有)
          返回:((s_w, delta_w), (s_b, delta_b))
          '''
          s_w = torch.zeros((feature_dim, 1))      # 权重梯度平方平均
          s_b = torch.zeros(1)                     # 偏置梯度平方平均
          delta_w = torch.zeros((feature_dim, 1))  # 权重更新量平方平均
          delta_b = torch.zeros(1)                 # 偏置更新量平方平均
          return ((s_w, delta_w), (s_b, delta_b))
      
      def adadelta(params, states, hyperparams):
          rho, eps = hyperparams['rho'], 1e-5 # ρ 衰减系数,ϵ 防除零小量
          for p, (s, delta) in zip(params, states):
              with torch.no_grad(): # 禁用梯度跟踪(纯数值计算)
                  # In-place updates via[:]
                  # 1. 更新梯度平方的指数移动平均(类似RMSProp)
                  # s = ρ·s_旧 + (1-ρ)·g²(梯度平方的加权平均)
                  s[:] = rho * s + (1 - rho) * torch.square(p.grad)
      
                  # 2. 计算自适应更新量(AdaDelta核心)
                  # 等效学习率 = √(Δx_历史² + ϵ) / √(g_历史² + ϵ)
                  # 用历史参数变化幅度来自动确定学习率
                  g = (torch.sqrt(delta + eps) / torch.sqrt(s + eps)) * p.grad
      
                  # 3. 更新参数
                  p[:] -= g  # 应用计算出的更新量
      
                  # 4. 更新参数变化量的平方平均(为下一步准备)
                  # 记录本次参数变化的幅度,用于下一步的自适应
                  delta[:] = rho * delta + (1 - rho) * g * g
              p.grad.data.zero_() # 清零梯度,准备下一轮

      对于每次参数更新,选择 \rho = 0.9 相当于10个半衰期。由此得到:

      # 获取数据迭代器和特征维度
      data_iter, feature_dim = common.get_data_ch11(downloader, batch_size=10)
      common.train_ch11(adadelta, init_adadelta_states(feature_dim), # 优化器函数,初始化状态
                     {'rho': 0.9}, data_iter, feature_dim)

      简洁实现只需使用高级API中的Adadelta算法:

      trainer = torch.optim.Adadelta # 使用PyTorch内置AdaDelta
      common.train_concise_ch11(trainer, {'rho': 0.9}, data_iter)

      小结

      • Adadelta没有学习率参数:它使用参数本身的变化率来调整学习率。

      • Adadelta需要两个状态变量,分别用于存储:

        • 梯度的二阶导数 和

        • 参数的变化。

      • Adadelta使用泄漏的平均值来保持对适当统计数据的运行估计。

      10. Adam算法(动量+RMSProp两者结合的最佳效果)

      Adam优化器​​:为每个参数维护不同的学习率(一阶矩和二阶矩)

      • 结合动量和自适应学习率
        • Adam = 动量法(管理方向)+ RMSProp(管理步长)+ 偏差校正(保稳定)
        • 动量法:记住"前进方向"(惯性)→ 动量加速 → 快速收敛
        • RMSProp:根据"路况调整速度"(自适应学习率)→ 稳定优化
        • Adam:两者结合,既聪明又稳定
        • 偏差校正:原始值除以 (1 - β^t) → 补偿初始偏差
      • Adam的完整更新公式

        • 公式11.10.2 即 偏差校正
      • Adam 的三大组件

        • ① 动量部分(一阶矩-动量):梯度方向的平均值(管方向)\mathbf{v}_t =\beta_1 \mathbf{v}_{t-1} + (1 - \beta_1) \mathbf{g}_t 
          # 类似动量法:记录梯度方向趋势
          v_t = β1 * v_{t-1} + (1-β1) * g_t
          
          作用:平滑梯度方向,减少震荡
          β1 = 0.9:记住最近10步的方向趋势
          物理意义:像"速度",保持运动惯性
        • ② RMSProp 部分(二阶矩-幅度):梯度大小的平均值(管步长)\mathbf{s}_t = \beta_2 \mathbf{s}_{t-1} + (1 - \beta_2) \mathbf{g}_t^2 
          # 类似RMSProp:记录梯度大小趋势  
          s_t = β2 * s_{t-1} + (1-β2) * g_t²
          
          作用:自适应调整学习率
          β2 = 0.999:记住最近1000步的梯度幅度
          物理意义:像"路况感知",陡坡慢行,平路加速
        • ③ 偏差校正(创新点)
          # 修正初始阶段的偏差
          v_hat = v_t / (1 - β1^t)  # 校正动量
          s_hat = s_t / (1 - β2^t)  # 校正幅度
          
          校正后 = 原始值 / (1 - β^t)
          t = 1 时:1-β¹ 很小,放大校正
          t → ∞ 时:1-β^t→1,校正减弱
          
          校正过程:
          初期(t小):1-βᵗ很小  → 分母小   → 校正幅度大 → 快速接近真实梯度平均值
          后期(t大):1-βᵗ → 1 → 分母接近1 → 校正幅度小 → 自动减弱,不影响收敛
          极限(t→∞):1-βᵗ = 1 → 不需要校正
          
          需要原因:开始时 v₀=s₀=0,需要几轮"预热"

      本章已经学习了许多有效优化的技术。在本节讨论前,先详细回顾一下这些技术:

      • (4. 随机梯度下降):在解决优化问题时,随机梯度下降效果优于梯度下降。
      • (5. 小批量随机梯度下降)中学习了:
        • 在一个小批量中使用更大的观测值集,可通过向量化提供额外效率。
        • 这是高效的多机、多GPU和整体并行处理的关键。
      • (6. 动量法):添加动量机制,用于汇总过去梯度的历史以加速收敛。
      • (7. AdaGrad算法 ):通过对每个坐标缩放来实现高效计算的预处理器。
        • 每个维度有自己的缩放因子:η / √(G_ii + ε)
        • G_ii = Σ g_i²是第 i 个坐标的历史梯度平方和
          AdaGrad如何实现"坐标缩放":
          # 普通梯度下降(所有坐标同样缩放)
          x_i = x_i - η * g_i  # 所有维度用相同学习率
          
          # AdaGrad(每个坐标独立缩放)
          x_i = x_i - η / √(G_ii + ε) * g_i  # 每个维度有自己的缩放因子
          
          "高效计算"体现在哪里
          # 预处理视角:AdaGrad相当于自动找到了一个对角预处理矩阵D
          D = diag(1/√(G_ii + ε))  # 对角预处理矩阵
          更新量 = -η * D * g      # 预处理后的梯度下降
          物理意义:为每个参数方向自动计算了合适的缩放比例
      • (8. RMSProp算法):通过学习率的调整来分离每个坐标的缩放。
        • 用指数加权平均代码累计和,使学习率不再单调递减,而是动态调整。
        • 且让每个坐标的学习率调整过程相互独立。

      Adam算法 (Kingma and Ba, 2014):

      • 将所有这些技术汇总 → 成为深度学习中使用的更强大和有效的优化算法之一,它非常受欢迎。
      • 但仍存在问题:可能由于方差控制不良而发散(如 (Reddi et al., 2019)表明)
      • 解决方案:一个称为Yogi的热补丁: (Zaheer et al., 2018)提供。

      10.1. 算法

      Adam算法的关键组成部分之一:

      • 使用指数加权移动平均值来估算梯度的动量和二次矩
      • 即它使用状态变量
      v: 动量(物理意义:梯度 方向的指数移动平均)
      s: 幅度(物理意义:梯度 大小的指数移动平均)
      • v_t :一阶矩估计(动量项)
      • s_t :二阶矩估计(幅度项)(梯度平方平均)
      • \beta_1 和 \beta_2 :非负加权参数
        • β₁ :动量衰减系数(通常0.9)
        • β₂ :幅度衰减系数(通常0.999)
        • 常设为 \beta_1 = 0.9 和 \beta_2 = 0.999 。即,方差估计的移动远远慢于动量估计的移动
      • g_t  :当前梯度
      • g_t² :当前梯度平方

      注意:

      • 冷启动问题:若初始化 \mathbf{v}_0 = \mathbf{s}_0 = 0 ,则在开始时的初始偏差会相当大。
        • 偏差相当大,即 实际值远小于本该的真实值
        • 第一步更新后的偏差:假设第1步梯度 g₁ = 5.0,β = 0.9
          v₁ = 0.9×0 + 0.1×5.0 = 0.5  # 但真实平均值应该是5.0
          s₁ = 0.999×0 + 0.001×25.0 = 0.025  # 真实应该是25.0
          
          问题:初始的0值拉低了估计值,导致严重低估
      • 解决方案:使用 \sum_{i=0}^t \beta^i = \frac{1 - \beta^t}{1 - \beta} 。
        总权重 = 0.1 × (0.9^(t-1) + 0.9^(t-2) + ... + 0.9^1 + 0.9^0)
                = 0.1 × (1 + 0.9 + 0.9² + ... + 0.9^(t-1))

        • 以s为例,有:\mathbf{s}_t = (1 - \beta)\mathbf{g}_t^2 + \beta \mathbf{s}_{t-1} \\ = (1 - \beta) (\mathbf{g}_t^2 + \beta \mathbf{s}_{t-1}^2+ \beta^2 \mathbf{s}_{t-2}+\cdots )
          •  展开式的系数和为 \sum_{i=0}^t (1 - \beta)\beta^i = ({1 - \beta})\frac{1 - \beta^t}{1 - \beta}=1 - \beta^{t} 。
          • 当 t 足够大时,系数和趋向于1
          • 当 t 足够小时,却远不足1
          • 因此,
            • 当 t 足够大时,我们的值基本不会受到初始值的影响。
            • 但当t =1时,由于v0= s0=0,我们只能继承到(1 - β)部分的梯度动量和二次矩。
            • 因此,可以通过除以 1 - β^t 进行标准化来解决这个问题。
        • 相应地,标准化状态变量由下式获得
      更新公式:偏差矫正
      (解决冷启动问题:开始时v=0,s=0的偏差)
      v_corrected = v / (1 - 0.9^t) # 随时间衰减校正
      校正后 = 原始值 / (1 - β^t)
       t = 1时:1-β¹ 很小,放大校正
      t→∞时:1-β^t→1,校正减弱
      物理意义:把 非标准化的加权和除以 总权重,得到 真正的加权平均。
      • 本质:通过除以(1-βᵗ)来补偿因指数加权平均在初期权重和不等于1造成的偏差,使得估计值更接近真实的移动平均。
        • 例子:第1步时 1-0.9^1=0.1,将v放大10倍补偿初始偏差。
      • 校正过程:
        • 初期(t小)  :1-βᵗ很小 → 分母小       → 校正幅度大
        • 后期(t大)  :1-βᵗ→1   → 分母接近1 → 校正幅度小
        • 极限(t→∞):1-βᵗ=1    → 不需要校正
      • 校正最终效果:让Adam优化器从第一轮迭代就开始有效工作,加速收敛。

      校正后的值即为估计值。有了正确的估计,现在可以写出更新方程。首先,以非常类似于RMSProp算法的方式重新缩放梯度以获得

      有效学习率 = 初始学习率 × (一阶矩) / sqrt(二阶矩)
      • 与RMSProp不同,Adam的更新使用动量 \hat{\mathbf{v}}_t 而非梯度本身。
      • 此外,由于使用 \frac{1}{\sqrt{\hat{\mathbf{s}}_t} + \epsilon} 而不是 \frac{1}{\sqrt{\hat{\mathbf{s}}_t + \epsilon}} 进行缩放,两者会略有差异。
        • 前者在实践中效果略好一些,因此与RMSProp算法有所区分。
      • 通常设 \epsilon = 10^{-6} ,以便在数值稳定性和逼真度之间取得良好的平衡。
      # 最终更新量 = 学习率 × (校正后的方向) / (校正后的幅度)
      update = - learning_rate * (smoothed_direction) / (smoothed_magnitude)
      
      实际效果:
      # 平坦方向:梯度小 → 二阶矩小 → 有效学习率大 → 加速
      # 陡峭方向:梯度大 → 二阶矩大 → 有效学习率小 → 防震荡

      最后,简单更新:

      更新量 = - 有效学习率 × 当前梯度

      Adam算法的设计灵感很清楚:

      • ① 统一管理了两种不同类型的信息:梯度方向(动量管"往哪走") & 管理梯度大小(规模管"走多快")
        • 不同时间尺度:β₁=0.9(短期记忆),β₂=0.999(长期记忆)
        • 统一框架:都用指数加权平均
        • 偏差校正:通过 1-β^t除法消除初始偏差
      • ② 智能组合已有算法的优点:
        • Adam = Momentum(方向平滑) + RMSProp(自适应学习率) + 偏差校正(冷启动修复)
        • 架构创新:不是简单拼接,而是有机融合
        • 参数共享:β₁和β₂控制不同的记忆长度
        • 算法融合:动量提供方向稳定性,RMSProp提供幅度适应性
      • ③ 保留并强化了学习率 \eta 的作用:
        # 比较三种算法的学习率角色
        SGD: Δw = -η·g_t                    # 固定学习率
        RMSProp: Δw = -η/√s_t · g_t         # 自适应但η仍固定
        Adam: Δw = -η·(v̂_t)/(√ŝ_t + ε)      # 自适应 + 动量导向 + 可调η
        • η保持可调:不是完全依赖"自适应",保留人为调控。η作为最终的安全阀和调节钮。
        • 双重调节:η控制整体步长,s_t调节局部步长
        • 收敛保证:理论上有更好的收敛性质(保证收敛性的数学必要条件)

      具体例子说明

      假设优化过程

      # 连续5步的梯度值
      gradients = [5.0, 3.0, -2.0, 4.0, 1.0]  # 有正有负,大小不同

      一阶矩计算(β₁=0.9)

      物理意义:动量(惯性)

      • 像开车时的速度,保持前进趋势
      v₀ = 0
      v₁ = 0.9*0 + 0.1*5.0   = 0.5      # 主要记住5.0
      v₂ = 0.9*0.5 + 0.1*3.0 = 0.75     # 0.45 + 0.3
      v₃ = 0.9*0.75 + 0.1*(-2.0) = 0.475  # 正负抵消
      v₄ = 0.9*0.475 + 0.1*4.0 = 0.8275
      v₅ = 0.9*0.8275 + 0.1*1.0 = 0.84475

      结果:v₅ ≈ 0.84(平滑后的梯度方向,震荡减弱)

      作用:

      • 减少震荡:正负梯度会相互抵消
      • 加速收敛:在稳定方向积累动量

      二阶矩计算(β₂=0.999)

      物理意义:路况感知

      • 像感知路面的陡峭程度,决定车速
      s₀ = 0
      s₁ = 0.999*0 + 0.001*25.0   = 0.025      # 5²=25
      s₂ = 0.999*0.025 + 0.001*9.0 = 0.033975  # 3²=9
      s₃ = 0.999*0.033975 + 0.001*4.0 = 0.03794   # (-2)²=4
      s₄ = 0.999*0.03794 + 0.001*16.0 = 0.05390   # 4²=16
      s₅ = 0.999*0.05390 + 0.001*1.0 = 0.05485    # 1²=1

      结果:s₅ ≈ 0.055(平滑后的梯度幅度,噪声减弱)

      作用:

      • 自适应学习率:梯度大 → 学习率小(小心下坡)
      • 稳定训练:平滑梯度噪声

      需要两个矩的原因

      场景1:平稳下降

      梯度序列:[2.1, 2.3, 1.9, 2.0, 2.2]  # 方向一致,大小稳定
      一阶矩:≈2.1(正方向,加速前进)
      二阶矩:≈4.5(幅度稳定,学习率适中)
      结果:快速稳定收敛

      场景2:剧烈震荡

      梯度序列:[10.0, -8.0, 12.0, -9.0, 11.0]  # 方向交替,大小波动
      一阶矩:≈1.0(正负抵消,方向平滑)
      二阶矩:≈100.0(幅度很大,学习率自动减小)
      结果:减少震荡,稳定收敛

      场景3:稀疏梯度

      梯度序列:[0, 0, 0, 50.0, 0, 0]  # 大部分为零,偶尔很大
      一阶矩:≈5.0(记住重要信号)
      二阶矩:≈2500.0(大幅梯度,学习率减小)
      结果:对稀疏数据友好

      一阶矩和二阶矩的协同作用:

      优化问题

      一阶矩(动量)解决

      二阶矩(幅度)解决

      联合效果

      梯度震荡

      方向平均,减少摇摆

      大幅梯度,自动减速

      平滑收敛

      稀疏梯度

      积累稀疏信号

      调整稀疏更新步长

      高效学习

      病态曲率

      保持前进动量

      不同方向自适应步长

      快速收敛

      噪声数据

      平滑随机波动

      抑制异常梯度影响

      稳定训练

      计算什么​

      梯度方向的平均值

      梯度大小的平均值

      -

      物理意义​

      速度/惯性

      路况陡峭程度

      -

      作用​

      减少震荡,加速收敛

      自适应调整学习率

      -

      比喻​

      老司机的驾驶经验

      汽车的定速巡航

      -
      最终效果:Adam 通过一阶矩管方向、二阶矩管步长,实现了 既快速又稳定的优化效果。

      偏差校正的重要性

      问题:冷启动偏差

      开始时 t=0,v₀=0,s₀=0:

      # 第1步:v₁ = 0.9 * 0 + 0.1*g₁ = 0.1*g₁ (偏小!)
      # 真实应该:v₁ ≈ g₁

      解决方案:除以 (1-β^t)

      校正后 = 原始值 / (1 - β^t)
      # t=1时:1-β¹ 很小,放大校正
      # t→∞时:1-β^t→1,校正减弱

      10.2. 实现

      超参数如下:

      # 固定参数(通常不变)
      beta1 = 0.9    # 一阶矩衰减率(动量)
      beta2 = 0.999  # 二阶矩衰减率(幅度)
      eps = 1e-6     # 防除零小量(Adam用更小的值)
      
      # 可调参数
      lr = 0.01      # 学习率(主要调这个)

      从头开始实现Adam算法并不难。 为方便起见,将时间步存储在hyperparams字典中。

      def init_adam_states(feature_dim):
          '''初始化Adam的状态变量
          返回:((v_w, s_w), (v_b, s_b))
          - v_w, v_b: 一阶矩估计(梯度动量)
          - s_w, s_b: 二阶矩估计(梯度平方动量)
          '''
          # 权重的一阶矩(动量),偏置的一阶矩
          # 权重的二阶矩(幅度),偏置的二阶矩
          v_w, v_b = torch.zeros((feature_dim, 1)), torch.zeros(1)
          s_w, s_b = torch.zeros((feature_dim, 1)), torch.zeros(1)
          return ((v_w, s_w), (v_b, s_b))
      
      def adam(params, states, hyperparams):
          '''Adam优化器实现
          params: 待优化参数 [w, b]
          states: 状态变量 [(v_w, s_w), (v_b, s_b)]
          hyperparams: 超参数字典 {'lr': 学习率, 't': 时间步}
          '''
          beta1, beta2, eps = 0.9, 0.999, 1e-6 # 固定参数
          for p, (v, s) in zip(params, states):  # 遍历每个参数及其状态
              with torch.no_grad():  # 禁用梯度跟踪(纯数值计算)
                  # 1. 更新一阶矩(动量项):v = β1*v + (1-β1)*grad
                  v[:] = beta1 * v + (1 - beta1) * p.grad
      
                  # 2. 更新二阶矩(幅度项):s = β2*s + (1-β2)*grad²
                  s[:] = beta2 * s + (1 - beta2) * torch.square(p.grad)
      
                  # 3. 偏差校正(解决初始偏差问题)
                  v_bias_corr = v / (1 - beta1 ** hyperparams['t'])  # 一阶矩校正
                  s_bias_corr = s / (1 - beta2 ** hyperparams['t'])  # 二阶矩校正
      
                  # 4. Adam更新:param -= lr * (校正后动量) / sqrt(校正后幅度)
                  p[:] -= hyperparams['lr'] * v_bias_corr / (torch.sqrt(s_bias_corr) + eps)
      
              p.grad.data.zero_()  # 清零梯度
          hyperparams['t'] += 1 # 时间步增加(用于偏差校正)

      现在,使用以上Adam算法来训练模型,设置学习率 \eta = 0.01 :

      # 获取数据迭代器和特征维度
      data_iter, feature_dim = common.get_data_ch11(downloader, batch_size=10)
      common.train_ch11(adam, init_adam_states(feature_dim), # 优化器函数,初始化状态
                     {'lr': 0.01, 't': 1}, data_iter, feature_dim)

      此外,可以用深度学习框架自带算法应用Adam算法,这里只需传递配置参数:

      trainer = torch.optim.Adam # 使用PyTorch内置Adam
      common.train_concise_ch11(trainer, {'lr': 0.01}, data_iter)

      Adam的表现

      • 快速收敛:动量加速
      • 自适应学习率:不同参数不同步长
      • 对超参数鲁棒:默认值效果就好

      Yogi的改进

      • 更好的稀疏数据适应性
      • 对异常梯度更稳定
      • 在某些任务上略优于Adam

      10.3. Yogi(热补丁)(Adam + 改进的二阶矩更新)

      • 特别适合稀疏数据或噪声较大的场景
      改进效果:
      当 g_t² > s(梯度突然增大):sign() = +1,正常增加      
      当 g_t² < s(梯度突然减小):sign() = -1,适当减小记忆
      更适应梯度分布的变化,对异常值更鲁棒

      Yogi的创新

      • 通过sign(g²-s)​ 这个智能开关,让二阶矩能主动减小而不仅仅是增加,从而更灵活地适应梯度分布的变化。

      一句话理解:

      • Yogi让优化器有了"遗忘"能力,不过度受历史异常值的影响,从而在复杂环境中表现更鲁棒。

      Yogi的更新规则满足:

      • 有界性:s不会无限增长
      • 适应性:能快速响应分布变化
      • 收敛性:理论保证更好

      实际效果

      • 在稀疏数据、异常值、分布变化大的任务中:Yogi通常比Adam更稳定、收敛更快。
      • Adam也存在些问题:即使在凸环境下,当 \mathbf{s}_t 的二次矩估计值爆炸时,它可能无法收敛。
        • Adam在异常梯度下的表现:若突然出现一个异常大梯度,则 s会突然增大,需要很多步才能恢复。

          # 二阶矩更新:s = 0.999×s_旧 + 0.001×g²
          s会突然增大,需要很多步才能恢复
          # 结果:学习率被过度压制,收敛变慢
      • (Zaheer et al., 2018)为 \mathbf{s}_t 提出了的改进更新和参数初始化。
        •  重写步骤:(重写到11.10.5 \mathbf{s}_t \leftarrow \mathbf{s}_{t-1} + (1 - \beta_2) \left(\mathbf{g}_t^2 - \mathbf{s}_{t-1}\right) 的步骤)

          原始形式:
          s = βs_旧 + (1-β)g²
          s_t = 0.999s_{t-1} + 0.001g_t^2         # 加权平均,意义隐晦
          重写后:
          s = s_旧 + (1-β)(g² - s_旧)
          s_t = s_{t-1} + 0.001(g_t^2 - s_{t-1})   # 增量更新,意义清晰
        • 论文中建议重写Adam算法更新如下:
      直观理解:
      新估计 = 旧估计 + 学习率 × (新信息 - 旧估计)
      若 g² > s_旧:增加估计值
      若 g² < s_旧:减小估计值
      问题:当出现异常大梯度时,(g² - s_旧)可能很大,导致s剧烈变化
      • 当 \mathbf{g}_t^2 具有值很大的变量或更新很稀疏时,\mathbf{s}_t 过快地“忘记”旧值。
        • 场景1:值很大的变量(异常大梯度)
          # 比如梯度爆炸或异常样本
          正常梯度 g² ≈ 0.1 ~ 10.0
          异常梯度 g² = 1000.0  # 突然出现100倍大的梯度
          # 影响:s被"拉偏",需要很久才能"忘记"这个异常值
          即 "不对称性":一步跳变很容易,但恢复原状需要很多步
          
          # 当出现巨大差异时:
          差异值 = 1000.0(异常大)
          单步改变量 = 0.001 × 1000.0 = 1.0  # 一步就改变很多!
          
          # 但恢复时:
          差异值 = 1.0(正常差异)  
          单步改变量 = 0.001 × 1.0 = 0.001  # 需要1000步才能恢复1.0
        • 场景2:更新很稀疏(长期不更新后突然更新)
          # 比如稀疏特征或低频词向量
          长时间:g² = 0, 0, 0, 0, 0, ...  # s逐渐衰减到接近0
          突然:g² = 20.0  # 长期不更新后突然大更新
          
          # 影响:s从接近0突然跳到很大,学习率被过度压制
      • 解决方法:将 \mathbf{g}_t^2 - \mathbf{s}_{t-1} 替换为 \mathbf{g}_t^2 \odot \mathop{\mathrm{sgn}}(\mathbf{g}_t^2 - \mathbf{s}_{t-1}) 。
        • 即 将 (g² - s_旧)替换为 g² × sign(g² - s_旧)
          • 原公式    :η × (g² - s_旧)       → 差异值可能无限大
          • Yogi公式:η × g² × sign(...)→ 变化量受g²本身限制
          • 变化如下:
            • \mathbf{s}_t \leftarrow \mathbf{s}_{t-1} + (1 - \beta_2) \left(\mathbf{g}_t^2 - \mathbf{s}_{t-1}\right) 变成 ↓
            • \mathbf{s}_t \leftarrow \mathbf{s}_{t-1} + (1 - \beta_2) \mathbf{g}_t^2 \odot \mathop{\mathrm{sgn}}(\mathbf{g}_t^2 - \mathbf{s}_{t-1})
        • 即 Yogi更新,现在更新的规模不再取决于偏差的量。
      Yogi的智能改进:
      Yogi的二阶矩更新公式:
      s_新 = s_旧 + (1-β₂) × sign(g² - s_旧) × g²
      保留方向:sign(g² - s_旧)保持增减方向                     
      控制幅度:用 g²代替 (g² - s_旧),幅度受 g²本身限制
      关键创新:sign(g² - s_旧)这个 符号函数

      sign()函数的作用

      • 数学定义:sign(x) = 符号函数,返回x的符号
        • 当 x > 0:返回 +1
        • 当 x = 0:返回 0
        • 当 x < 0:返回 -1
      • 物理意义:像一个智能开关,根据梯度变化方向决定如何调整记忆强度。
        • 在Yogi中用于智能判断梯度变化方向
      • 具体例子:
        sign(5.2) = +1    # 正数 → +1
        sign(-3.7) = -1   # 负数 → -1  
        sign(0) = 0       # 零 → 0
        sign(g² - s_旧) = 比较当前梯度平方与历史平均
      • 在Yogi中的作用

        # 判断当前梯度与历史水平的关系
        if g² > s_旧:  sign = +1  # 梯度异常增大 → 谨慎增加
        if g² < s_旧:  sign = -1  # 梯度异常减小 → 主动减小记忆

      三种情况分析:

      情况1:正常波动(梯度与历史相当)
      g² = 5.0, s_旧 = 4.0 → sign(5-4) = +1
      s_新 = s_旧 + 正数×g²  # 正常增加
      情况2:异常突增(梯度远大于历史)
      g² = 100.0, s_旧 = 5.0 → sign(100-5) = +1  
      s_新 = s_旧 + 正数×g²  # 增加,但幅度受(1-β₂)控制
      情况3:异常突降(梯度远小于历史)
      g² = 0.1, s_旧 = 10.0 → sign(0.1-10) = -1
      s_新 = s_旧 + 负数×g²  # 主动减小!这是关键创新!
      Yogi vs Adam 对比示例

      面对异常大梯度:

      梯度序列 = [0.1, 0.2, 0.3, 15.0, 0.2, 0.1]  # 第4步异常
      
      # Adam的二阶矩变化:
      s = [0.001, 0.002, 0.003, 0.018, 0.018, 0.018]  
      # 第4步后s从0.003跳到0.018,需要很久才能恢复
      
      # Yogi的二阶矩变化:  
      s = [0.001, 0.002, 0.003, 0.006, 0.005, 0.004]
      # 增加适度,且之后能快速恢复

      物理比喻:

      • Adam:像记性太好的司机,遇到一次陡坡就永远开得很慢
      • Yogi  :像灵活应变的司机,陡坡时减速,平路时恢复正常速度

      论文中,作者还进一步建议用更大的初始批量来初始化动量,而不仅仅是初始的逐点估计。

      def yogi(params, states, hyperparams):
          '''Yogi优化器(Adam的改进版本)
          改进点:只修改二阶矩(s)的更新规则,增强对稀疏梯度的适应性
          '''
          beta1, beta2, eps = 0.9, 0.999, 1e-3 # eps稍大(1e-3 vs 1e-6)
          for p, (v, s) in zip(params, states):
              with torch.no_grad():
                  # 一阶矩更新(与Adam相同)
                  v[:] = beta1 * v + (1 - beta1) * p.grad
      
                  # ========== YOGI的核心改进 ==========
                  # 原Adam:s = s + (1-β2)(grad²-s)(简单加权平均)
                  # Yogi改进:自适应调整更新幅度,对异常梯度更鲁棒
                  #        s = s + (1-β2)*sign(grad²-s)*grad²
                  s[:] = s + (1 - beta2) * torch.sign(
                      torch.square(p.grad) - s) * torch.square(p.grad)
                  # ===================================
      
                  # 偏差校正(与Adam相同)
                  v_bias_corr = v / (1 - beta1 ** hyperparams['t'])
                  s_bias_corr = s / (1 - beta2 ** hyperparams['t'])
                  # 参数更新(与Adam相同)
                  p[:] -= hyperparams['lr'] * v_bias_corr / (torch.sqrt(s_bias_corr)
                                                             + eps)
              p.grad.data.zero_()
          hyperparams['t'] += 1
      
      # 获取数据迭代器和特征维度
      data_iter, feature_dim = common.get_data_ch11(downloader, batch_size=10)
      common.train_ch11(yogi, init_adam_states(feature_dim),
                     {'lr': 0.01, 't': 1}, data_iter, feature_dim)

      Adam vs Yogi 对比

      更新规则对比

      组件

      Adam

      Yogi

      改进点

      一阶矩(v)​

      β1*v + (1-β1)*g

      相同​

      -

      二阶矩(s)​

      β2*s + (1-β2)*g²

      即 s + (1-β2)(g²-s)

      s + (1-β2)*sign(g²-s)*g²

      自适应调整​

      偏差校正​

      v/(1-β1^t)

      相同​

      -

      参数更新​

      -lr*v̂/√(ŝ+ε)

      相同​

      -

      Yogi的优势
      # 对于稀疏梯度或异常值:
      梯度序列 = [0.1, 0.2, 15.0, 0.3, 0.2]  # 突然出现大梯度
      
      # Adam:s会被15.0显著影响,需要多步才能恢复
      # Yogi:通过sign()自适应调整,更快恢复平衡

      小结

      • Adam算法:

        • 将许多优化算法的功能结合到了相当强大的更新规则中。

        • 是在RMSProp算法基础上创建的,还在小批量的随机梯度上使用EWMA

        • 在估计动量和二次矩时,使用偏差校正来调整缓慢的启动速度。

      • 对于具有显著差异的梯度,遇到收敛性问题时,可以通过使用更大的小批量或者切换到改进的估计值来修正它们。Yogi提供了这样的替代方案。


      EWMA(Exponentially Weighted Moving Average,指数加权移动平均)

      • 通过对历史数据赋予指数递减的权重来计算加权平均值。

      • 与简单移动平均不同,EWMA对最近的数据赋予更高的权重,而对较旧的数据赋予逐渐减小的权重。

      11. 学习率调度器

      • 核心思想:优化算法决定更新方向,学习率调度器决定更新步长

      到目前为止,我们主要关注如何更新权重向量的优化算法,而非其更新速率。 然而,调整学习率通常与实际算法同样重要,学习率调度的四大维度:

      • ① 学习率的大小:
        • 问题:学习率需要与问题条件数匹配
        • 若太大 → 优化发散(震荡)
        • 若太小 → 收敛过慢 / 陷入局部最优
        • 问题的条件数很重要:(详情见 (6. 动量法))
          • 直观地说,这是最不敏感与最敏感方向的变化量的比率。
            • 条件数 = 最敏感方向变化率 / 最不敏感方向变化率
            • 即 解决方案:学习率应与Hessian矩阵的条件数相适应
          • 高条件数:不同方向曲率差异大 → 需要自适应学习率
          • 低条件数:相对均匀 → 固定学习率可能足够
      • ② 衰减速率:
        • 若学习率持续过高,可能最终会在最小值附近弹跳,从而无法达到最优解。
          • (5. 小批量随机梯度下降) 比较详细地讨论了该点
            • 衰减的必要性
              # 训练过程类比
              初期:需要大步探索(大学习率)
              中期:需要精细调优(中等学习率)  
              后期:需要稳定收敛(小学习率)
          • (4. 随机梯度下降) 中则分析了性能保证。
        • 衰减速率原则:衰减应比 \mathcal{O}(t^{-\frac{1}{2}}) (即 O(1/√t)) 慢,但要保证凸问题收敛。
          • 理论保证:凸问题下O(1/√t)衰减可保证收敛
          • 实践策略:分段衰减、指数衰减、余弦退火等
      • ③ 初始化:即 预热(Warmup)机制
        • 即 最初开始向着解决方案迈进的速度有多快。这
        • 需要预热的原因:(问题:初始参数随机,大步长更新可能走向错误方向)
          # 问题:初始参数随机,大步更新可能有害
          初始大步长 → 可能走向错误方向 → 需要后期纠正
        • 解决方案:训练初期从小学习率开始,逐步增加。
          # 解决方案:从小学习率开始,逐步增加
          学习率 = [0.001, 0.01, 0.1, 0.05, 0.01, ...]  # 先增后减
          
          # 线性预热示例
          lr = initial_lr * min(1.0, current_step / warmup_steps)
        • 预热的好处:
          • 稳定训练初期    :避免梯度爆炸
          • 更好收敛性        :从温和更新开始,逐步加速
          • 对超参数更鲁棒 :减少初始学习率的敏感性
      • ④ 周期性调整(Cyclical Scheduling):许多优化变体皆可执行。
        • 创新方法:周期性变化学习率跳出局部最优
          • 余弦退火:按余弦函数周期性调整
          • SGDR:带重启的余弦退火
          • 权重平均:对多个周期的解求平均获得更平坦最小值
          • 这超出了本章的范围,建议读者阅读 (Izmailov et al., 2018)来了解个中细节。
            • 例如,如何通过对整个路径参数求平均值来获得更好的解。

      鉴于管理学习率需要很多细节,因此大多数深度学习框架都有自动应对这个问题的工具。本章中将梳理不同的调度策略对准确性的影响,并展示如何通过学习率调度器(learning rate scheduler)来有效管理。

      11.1. 一个简单的问题

      • 从一个简单的问题开始,该问题可轻松计算,但足以说明要义。
      • 为此,这里选择一个稍微现代化的LeNet版本:
        • 激活函数:使用relu,而非sigmoid
        • 汇聚层:使用最大汇聚层,而非平均汇聚层
        • 并应用于Fashion-MNIST数据集
      • 此外,我们混合网络以提高性能。由于大多数代码都是标准的,因此只介绍基础知识,而不做进一步的详细讨论。如果需要,请参阅(现代卷积神经网络——动手学深度学习7_现代卷积神经网络学习-CSDN博客)进行复习。
      import math
      import torch
      from torch import nn
      from torch.optim import lr_scheduler
      import common
      def net_fn():
          '''定义LeNet-5模型(现代变种)
          输入输出流:1x28x28→ 卷积 → 池化 → 卷积 → 池化 → 全连接 → 10分类
          '''
          model = nn.Sequential(
              # 第一卷积层:1通道→6通道,5x5卷积,填充2保持尺寸((28-5+2*2)/1+1=28→尺寸不变)
              nn.Conv2d(1, 6, kernel_size=5, padding=2), nn.ReLU(),
              nn.MaxPool2d(kernel_size=2, stride=2),  # 下采样:28x28→14x14,((28-2)/2+1=14)
      
              # 第二卷积层:6通道→16通道,5x5卷积((14-5)/1+1=10)
              nn.Conv2d(6, 16, kernel_size=5), nn.ReLU(),  # 14x14→10x10
              nn.MaxPool2d(kernel_size=2, stride=2),  # 下采样:10x10→5x5,((10-2)/2+1=5)
      
              # 全连接层
              nn.Flatten(),                           # 展平:16x5x5=400维
              nn.Linear(16 * 5 * 5, 120), nn.ReLU(),    # 400→120→120
              nn.Linear(120, 84), nn.ReLU(),  # 120→84→84
              nn.Linear(84, 10)  # 84→10。输出10个类别(Fashion-MNIST)
          )
          return model
      
      loss = nn.CrossEntropyLoss() # 交叉熵损失,分类任务标准损失函数
      device = common.try_gpu()
      
      batch_size = 256 # 大批量训练,稳定收敛
      train_iter, test_iter = common.load_data_fashion_mnist(batch_size=batch_size) # 训练集/数据集迭代器
      
      # 代码几乎与common.train_ch6定义在卷积神经网络一章LeNet一节中的相同
      def train(net, train_iter, test_iter, num_epochs, loss, trainer, device,
                scheduler=None): # 关键:可选的学习率调度器
          """ 通用训练函数(支持学习率调度)
          net         : 神经网络模型
          train_iter/test_iter: 训练/测试数据迭代器
          num_epochs  : 训练轮数
          loss        : 损失函数
          trainer     : 优化器(SGD/Adam等)
          device      : 计算设备(CPU/GPU)
          scheduler   : 学习率调度器(可选)
          """
          net.to(device) # 模型移至指定设备(如GPU)
          # 初始化动画绘图器,用于动态绘制损失和准确率曲线
          animator = common.Animator(xlabel='epoch', xlim=[0, num_epochs],
                                  legend=['train loss', 'train acc', 'test acc'])
      
          for epoch in range(num_epochs):
              # Accumulator(3):[train_loss训练损失总和、train_acc训练准确度总和、样本总数]
              metric = common.Accumulator(3)  # train_loss,train_acc,num_examples
              for i, (X, y) in enumerate(train_iter):
                  net.train() # 切换到训练模式(启用Dropout和BatchNorm的训练行为)
                  trainer.zero_grad() # 清空上一轮梯度
      
                  # 数据准备
                  X, y = X.to(device), y.to(device) # 将数据移动到设备
                  y_hat = net(X)      # 前向传播:模型预测
                  l = loss(y_hat, y)  # 计算损失(向量形式,每个样本一个损失值)
                  l.backward()        # 反向传播:计算梯度
                  trainer.step()      # 优化器:更新参数
      
                  # 计算本轮指标(无梯度计算,节省内存)
                  with torch.no_grad(): # 禁用梯度计算后累计指标
                      metric.add(l * X.shape[0],      # 损失×批量大小(累加)
                                 common.accuracy(y_hat, y), # 准确率
                                 X.shape[0])                # 样本数
      
                  # 计算平均指标
                  train_loss = metric[0] / metric[2]  # 平均训练损失 = 总损失 / 总样本数
                  train_acc  = metric[1] / metric[2]  # 平均训练准确率 = 总正确数 / 总样本数
                  if (i + 1) % 50 == 0: # 每50轮更新一次训练曲线
                      animator.add(epoch + i / len(train_iter),
                                   (train_loss, train_acc, None))
      
              # 每个epoch结束后评估测试集
              test_acc = common.evaluate_accuracy_gpu(net, test_iter) # 测试集准确率
              animator.add(epoch+1, (None, None, test_acc)) # 更新测试集准确率曲线
      
              # ========== 关键代码:学习率调度 ==========
              if scheduler:
                  if scheduler.__module__ == lr_scheduler.__name__:
                      # Using PyTorchIn-Builtscheduler PyTorch内置调度器:标准调用方式
                      scheduler.step()
                  else:
                      # Using custom defined scheduler 自定义调度器:手动设置学习率
                      for param_group in trainer.param_groups:
                          param_group['lr'] = scheduler(epoch)
              # ========================================
      
          print(f'train loss {train_loss:.3f}, train acc {train_acc:.3f}, '
                f'test acc {test_acc:.3f}')

      下面看看若使用默认设置,调用此算法会发生什么:

      • 例如,设学习率为0.03,并训练30次迭代。
      • 留意在超过了某点、测试准确度方面的进展停滞时,训练准确度将如何继续提高。
        • 测试精度开始停止不前,训练精度却仍在逐渐提高 → 开始过拟合
      • 两条曲线之间的间隙表示过拟合。
        • 训练精度 0.943,测试精度却只有 0.886
        • 训练精度明显高于测试精度 → 过拟合
      lr, num_epochs = 0.3, 30 # 学习率,训练周期数
      
      # 模型和优化器初始化
      net = net_fn() # 创建LeNet实例
      trainer = torch.optim.SGD(net.parameters(), lr=lr) # 优化器用 随机梯度下降
      
      # 开始训练(不使用学习率调度器)
      train(net, train_iter, test_iter, num_epochs, loss, trainer, device)
      训练精度 0.943,测试精度却只有 0.886
      训练精度明显高于测试精度 → 过拟合

      11.2. 学习率调度器(平方根衰减调度器)

      • 平方根衰减调度器:
        •  \eta_{t} = \frac{\eta_{0}}{\sqrt{t+1}} 即 \eta = \eta_0 (t + 1)^{-\frac{1}{2}}  
        • η₀   :初始学习率(0.1)
        • t      :训练步数(从0开始)
        • η_t :第t步的学习率
        • 前期衰减快,后期衰减慢
      • 特点:
        • 理论最优:凸优化问题的理论收敛保证
        • 衰减较快:适合理论分析,实践中可能衰减过快
        • 单调递减:学习率持续下降

      可以在每个迭代轮数(甚至在每个小批量)之后向下调整学习率。

      • 例如,以动态的方式来响应优化的进展情况。
      ''' 平方根衰减调度器
      特点:
      理论最优:凸优化问题的理论收敛保证
      衰减较快:适合理论分析,实践中可能衰减过快
      单调递减:学习率持续下降
      '''
      lr = 0.1  # 设置基础学习率为0.1
      # 参数组:优化器可管理多个参数组,每个组可以有独立的学习率
      # 这里设置第0组参数的学习率为0.1
      trainer.param_groups[0]["lr"] = lr # 手动设置优化器的学习率(优化器参数组中的学习率)
      print(f'learning rate is now {trainer.param_groups[0]["lr"]:.2f}')

      更通常而言,应该定义一个调度器:在调用更新次数时,返回学习率的适当值。

      • 下面定义一个简单的方法,将学习率设置为 \eta = \eta_0 (t + 1)^{-\frac{1}{2}} 。
      class SquareRootScheduler:
          '''平方根衰减调度器类'''
          def __init__(self, lr=0.1):
              self.lr = lr # 初始学习率
      
          def __call__(self, num_update):
              # 平方根衰减:lr_t = lr_0 / sqrt(t+1)
              return self.lr * pow(num_update + 1.0, -0.5)

      在一系列值上绘制它的行为:

      scheduler = SquareRootScheduler(lr=0.1) # 创建调度器实例
      common.plot(torch.arange(num_epochs),
                  [scheduler(t) for t in range(num_epochs)]) # 绘制衰减曲线

      在Fashion-MNIST数据集上训练,这里只是提供调度器作为训练算法的额外参数:

      # 使用该调度器训练
      net = net_fn()
      trainer = torch.optim.SGD(net.parameters(), lr)
      train(net, train_iter, test_iter, num_epochs, loss, trainer, device,
            scheduler)

      这比以前好一些:

      • 曲线比以前更加平滑,且过拟合更小了(train 0.898, test 0.874, 差距: 0.024)
      • 遗憾的是,关于为什么在理论上某些策略会导致较轻的过拟合,有一些观点认为,较小的步长将导致参数更接近零,因此更简单。
      • 但是,这并不能完全解释这种现象,因为
        • 并没有真正地提前停止(完全停止训练,防止过度拟合训练集),
        • 而只是轻柔地降低了学习率(学习率衰减:继续训练,只是步长变小)。

      11.3. 策略

      • 虽然我们不可能涵盖所有类型的学习率调度器,但会尝试在下面简要概述常用的策略:多项式衰减和分段常数表。
      • 此外,余弦学习率调度在实践中的一些问题上运行效果很好。
      • 在某些问题上,最好在使用较高的学习率之前预热优化器。

      11.3.1. 单因子调度器(因子衰减调度器)

      因子调度器(FactorScheduler)

      lr_new = lr * factor  # 每一定步数按固定比例衰减

      指数衰减公式:\eta_{t} = \eta_{0} \cdot \mathrm{factor}^{t} 

      衰减曲线特征:(指数衰减:学习率按几何级数递减)

      • 前期衰减快:绝对下降量大
      • 后期衰减慢:相对比例恒定
      • 永不归零:渐近趋于0(但有停止阈值)

      适用:简单任务,稳定衰减

      多项式衰减的一种替代方案是乘法衰减,

      • 即 \eta_{t+1} \leftarrow \eta_t \cdot \alpha 其中 \alpha \in (0, 1) 。(学习率 = 上一轮学习率 × 衰减因子)
      • 为防止学习率衰减到一个合理的下界之下,更新方程常修改为 \eta_{t+1} \leftarrow \mathop{\mathrm{max}}(\eta_{\mathrm{min}}, \eta_t \cdot \alpha) 
      ''' 因子衰减调度器
      衰减曲线:
      初始: 2.0 → 1.8 → 1.62 → 1.458 → ... → 0.01(停止)
      应用场景:简单任务的稳定衰减
      '''
      class FactorScheduler:
          '''因子衰减调度器
          核心:学习率乘以固定系数(单因子)
          "单因子"强调只有一个衰减系数,每步:new_lr = old_lr × 0.9
          '''
          def __init__(self, factor=1, stop_factor_lr=1e-7, base_lr=0.1):
              self.factor = factor          # 衰减因子(如0.9表示每次×0.9)
              self.stop_factor_lr = stop_factor_lr  # 停止衰减的阈值
              self.base_lr = base_lr        # 当前学习率
      
          def __call__(self, num_update):
              # 每次调用:学习率乘以衰减因子,但不低于阈值
              self.base_lr = max(self.stop_factor_lr, self.base_lr * self.factor)
              return self.base_lr
      
      # 示例:从2.0开始,每步×0.9,最低到0.01
      scheduler = FactorScheduler(factor=0.9, stop_factor_lr=1e-2, base_lr=2.0)
      common.plot(torch.arange(50), [scheduler(t) for t in range(50)])

      参数选择经验:

      # 常用因子值
      factor = 0.9    # 缓慢衰减(长期训练)
      factor = 0.5    # 快速衰减(短期训练)  
      factor = 0.1    # 急剧衰减(阶段式训练)
      
      # 停止阈值(防止学习率过小)
      stop_factor_lr = 1e-4  # 一般任务
      stop_factor_lr = 1e-5  # 需要精细调优的任务

      接下来将使用内置的调度器,但在这里仅解释它们的功能。

      11.3.2. 多因子调度器(多步衰减调度器)

      多因子调度器(MultiFactorScheduler)

      # 在指定epoch处衰减
      milestones = [30, 60, 90]  # 在第30、60、90轮衰减
      factors = [0.1, 0.01, 0.001]  # 衰减比例

      适用:图像分类等标准任务

      优势:在训练关键阶段手动控制衰减时机

      多步衰减的核心价值:

      • 可控性强:手动选择关键衰减点
      • 实用简单:超参数少,调参容易
      • 效果明确:阶梯式变化,行为可预测
      • 广泛适用:适合大多数深度学习任务

      多步衰减是实践中最常用、最可靠的调度策略之一,特别适合需要明确控制训练过程的应用场景。

      • 训练深度网络的常见策略之一是保持学习率为一组分段的常量,且不时地按给定的参数对学习率做乘法衰减。
      • 具体地说,
        • 给定一组降低学习率的时间点,例如 s = \{5, 10, 20\} ,
        • 每当 t \in s 时,降低 \eta_{t+1} \leftarrow \eta_t \cdot \alpha 。
        • 假设每步中的值减半,可以按如下方式实现该点:
      ''' 多步衰减调度器
      衰减模式:
      0-14轮   : 0.5
      15-29轮  : 0.5 × 0.5 = 0.25  
      30轮后   : 0.25 × 0.5 = 0.125
      优势:在训练关键阶段手动控制衰减时机
      '''
      net = net_fn()  # 创建LeNet模型实例
      # 初始学习率:0.5(相对较大,适合初期快速收敛)
      trainer = torch.optim.SGD(net.parameters(), lr=0.5)  # 使用SGD优化器,初始学习率0.5
      
      # PyTorch内置 多步衰减调度器:在指定epoch处衰减
      # milestones=[15, 30] 衰减里程碑:在第15和30个epoch进行学习率衰减
      # gamma=0.5           衰减因子:每次衰减为当前学习率的50%
      scheduler = lr_scheduler.MultiStepLR(trainer, milestones=[15, 30], gamma=0.5)
      
      def get_lr(trainer, scheduler):
          '''学习率获取函数(用于可视化)
          为了绘制学习率曲线而设计,在实际训练中无需手动调用
          '''
          lr = scheduler.get_last_lr()[0]  # 获取当前参数组的学习率
          trainer.step()                   # 优化器更新(实际训练中)模拟优化器更新(为正确推进调度器)
          scheduler.step()                 # 调度器更新(调整学习率)推进调度器到下一状态
          return lr
      
      # 可视化学习率变化:生成阶梯状下降曲线,清晰显示在15、30epoch处的学习率跳变
      common.plot(torch.arange(num_epochs), [get_lr(trainer, scheduler)
                                        for t in range(num_epochs)])

      这种分段恒定学习率调度背后的直觉:让优化持续进行,直到权重向量的分布达到一个驻点。 此时,才将学习率降低,以获得更高质量的代理来达到一个良好的局部最小值。下面的例子展示了如何使用这种方法产生更好的解决方案。

      # 使用多步衰减训练:将调度器传入train函数,在每轮训练后自动调整学习率
      train(net, train_iter, test_iter, num_epochs, loss, trainer, device,
            scheduler)

      milestones选择
      # 方法1:基于训练周期比例
      milestones = [总轮数//3, 总轮数//3 * 2]  # 如30轮训练: [10, 20]
      
      # 方法2:基于验证集性能
      if 验证集准确率连续5轮不提升:
          执行学习率衰减  # 动态调整milestones
      
      # 方法3:经验值(常用)
      milestones = [30, 60, 90]  # 图像分类典型设置
      gamma参数选择
      gamma = 0.1    # 激进衰减:10倍下降,适合大数据集
      gamma = 0.5    # 适中衰减:2倍下降,最常用
      gamma = 0.8    # 温和衰减:1.25倍下降,小数据集

      11.3.3. 余弦调度器(余弦退火调度器)

      余弦调度器(CosineScheduler)

      • 余弦退火公式:
          • 振幅:(base_lr - final_lr)/2 — 变化范围的一半
          • 余弦项:(1 + cos(...))/2 — 从1平滑降到0
          • 偏移:final_lr — 保证最终收敛到目标值
        • 学习率 = 训练结束终点值 + 变化幅度 × 平滑因子
          • 终点值 = 0.01(训练结束时的最终学习率)
          • 变化幅度 = (训练阶段起始lr-训练阶段最终lr)/2 (变化范围的一半)
          • 平滑因子 = (1 + cos(进度×π)) / 2 (从1平滑降到0)
          • 进度 = (当前轮次 - 预热步数) / 总衰减步数
      • 线性预热公式:
        • lr = 起始值 + (目标值-起始值) × (当前步/总预热步)
        • lr = 最开始的原始值 + 预热过程中要变化的幅度 × 当前不在预热总时间里的比值

        优势:平滑衰减,理论性质好

        理论最优性:余弦退火 ≈ 模拟退火算法中的温度调度

        物理类比:像金属冷却过程,开始慢冷(粗调),然后快冷(精调),最后慢冷(定型)

        余弦退火是目前深度学习实践中效果最好、理论最优雅的学习率调度策略,特别适合追求最佳性能的应用场景。

        余弦调度器:

        • 是 (Loshchilov and Hutter, 2016)提出的一种启发式算法。
        • 所依据的观点:不想在一开始就太大地降低学习率,且希望最终能用非常小的学习率来“改进”解决方案。
        • 这产生了一个类似于余弦的调度,函数形式如下所示,学习率的值在 t \in [0, T] 之间。

        • \eta_0 :初始学习率
        • \eta_T :当 T 时的目标学习率
        • 当 t > T 时,只需将值固定到 \eta_T 使其不再增长

        在下面的示例中,设置最大更新步数 T = 20 。

        # 余弦退火调度器
        class CosineScheduler:
            '''余弦退火调度器'''
            def __init__(self, max_update, base_lr=0.01, final_lr=0.0,
                       warmup_steps=0, warmup_begin_lr=0.0):
                # 学习率参数
                self.base_lr_orig = base_lr    # 初始学习率(预热后的目标值)(预热结束后的起始学习率(峰值))
                self.final_lr     = final_lr   # 最终学习率(衰减终点)(训练结束时的最终学习率)
        
                # 训练进度参数
                self.max_update      = max_update      # 总训练步数/轮数(包含预热)
                self.warmup_steps    = warmup_steps    # 预热步数,即慢慢加速阶段(预热阶段步数(0表示无预热))
                self.warmup_begin_lr = warmup_begin_lr # 预热起始学习率
        
                self.max_steps = self.max_update - self.warmup_steps # 实际衰减步数(纯衰减阶段轮数)
        
            def get_warmup_lr(self, epoch):
                '''预热阶段方法,获取预热阶段中,当前步所对应的学习率
                '''
                # 线性预热:从warmup_begin_lr线性增加到base_lr_orig
                # 线性预热公式:lr = 起始值 + (目标值-起始值) × (当前步/总预热步)
                # 即:lr = 最开始的原始值 + 预热过程中要变化的幅度 × 当前不在预热总时间里的比值
                increase = (self.base_lr_orig - self.warmup_begin_lr) \
                               * float(epoch) / float(self.warmup_steps)
                return self.warmup_begin_lr + increase
        
            def __call__(self, epoch):
                '''核心:余弦退火方法'''
                if epoch < self.warmup_steps: # 还在预热期,则线性增加(慢慢加速)
                    return self.get_warmup_lr(epoch) # 预热阶段
                if epoch <= self.max_update: # 预热期结束,在正式训练阶段
                    # 核心:余弦退火公式
                    # 进度 = (当前轮次 - 预热步数) / 总衰减步数
                    # 平滑因子 = (1 + cos(π × 进度)) / 2  # 从1→0平滑变化
                    # 学习率 = 最终学习率 + (初始学习率 - 最终学习率) × 平滑因子
                    self.base_lr = self.final_lr + (
                        self.base_lr_orig - self.final_lr) * (1 + math.cos(
                        math.pi * (epoch - self.warmup_steps) / self.max_steps)) / 2
                return self.base_lr
        
        # 使用示例:20轮内从0.3余弦衰减到0.01
        # 这里预热步数为默认值0,即无预热阶段
        scheduler = CosineScheduler(max_update=20, # 训练20轮
                                    base_lr=0.3,   # 训练阶段的起始学习率为0.5
                                    final_lr=0.01) # 训练结束时的最终学习率,即衰减终点为0.01
        common.plot(torch.arange(num_epochs), [scheduler(t) for t in range(num_epochs)])

        在计算机视觉的背景下,这个调度方式可能产生改进的结果。但请注意,如下所示,这种改进并不一定成立。

        net = net_fn()
        trainer = torch.optim.SGD(net.parameters(), lr=0.3)
        train(net, train_iter, test_iter, num_epochs, loss, trainer, device,
              scheduler)

        11.3.4. 预热(带预热的余弦退火)

        预热调度器(WarmupScheduler)

        # 预热期线性增加,然后按预定策略衰减
        if step < warmup_steps: 
            # 还在预热期,则线性增加(慢慢加速),从起始值线性增加到目标值
            lr = warmup_begin_lr + (lr_max - warmup_begin_lr) * (step / warmup_steps)
        else: 
            # 预热期结束,在正式训练阶段
            lr = cosine_decay(lr_max, step - warmup_steps)

        线性预热公式:

        • lr = 起始值 + (目标值-起始值) × (当前步/总预热步)
        • lr = 最开始的原始值 + 预热过程中要变化的幅度 × 当前不在预热总时间里的比值
        预热策略选择

        策略

        公式

        适用场景

        线性预热​

        lr = start + (target-start)×(step/steps)

        最常用

        常数预热​

        lr = constant(较小值)

        简单任务

        指数预热​

        lr = start × (target/start)^(step/steps)

        需要快速预热

        预热参数设置
        # 典型配置值
        # 大多数任务的推荐配置
        warmup_begin_lr = base_lr * 0.01    # 起始为目标的1%
        warmup_steps = total_steps * 0.1    # 预热10%的总步数
        
        # 示例:base_lr=0.1, total_steps=1000
        warmup_begin_lr = 0.001    # 从0.001开始
        warmup_steps = 100         # 预热100步
        
        # 特殊场景调整
        # 大数据集:延长预热
        warmup_steps = total_steps * 0.2  # 20%步数预热
        
        # 小数据集:缩短预热  
        warmup_steps = total_steps * 0.05  # 5%步数预热
        
        # 敏感模型:更温和的起始
        warmup_begin_lr = base_lr * 0.001  # 从0.1%开始
        • 问题:在某些情况下,初始化参数不足以得到良好的解。 这对某些高级网络设计来说尤其棘手,可能导致不稳定的优化结果。 对此,
          • 一方面,可选择一个足够小的学习率, 从而防止一开始发散,但会进展过慢。
          • 另一方面,较高的学习率最初就会导致发散。
        • 解决这种困境的一个相当简单的解决方法:使用预热期。
          • 在此期间学习率将增加至初始最大值,然后冷却直到优化过程结束。
          • 为简单起见,通常使用线性递增。 这引出了如下表所示的时间表。
        # 带预热的余弦退火
        # 前5轮预热,然后15轮余弦衰减
        scheduler = CosineScheduler(20,  # 总共训练20轮(包含预热)
                                    warmup_steps=5, # 前5轮预热
                                    base_lr=0.3,    # 预热后的目标学习率,即峰值为0.3
                                    final_lr=0.01)  # 训练结束的最终学习率,即衰减终点为0.01
        common.plot(torch.arange(num_epochs), [scheduler(t) for t in range(num_epochs)])

        注意,观察前5个迭代轮数的性能,网络最初收敛得更好。

        net = net_fn()
        trainer = torch.optim.SGD(net.parameters(), lr=0.3)
        train(net, train_iter, test_iter, num_epochs, loss, trainer, device,
              scheduler)

        • 预热可应用于任何调度器,而不仅仅是余弦。
        • 有关学习率调度的更多实验和更详细讨论,请参阅 (Gotmare et al., 2018)。其中,这篇论文的点睛之笔的发现:
          • 预热阶段限制了非常深的网络中参数的发散程度 。
          • 这在直觉上是有道理的:在网络中那些一开始花费最多时间取得进展的部分,随机初始化会产生巨大的发散。

        四种调度策略对比

        调度策略

        数学公式

        特点

        适用场景

        平方根衰减​

        \eta_{t} = \frac{\eta_{0}}{\sqrt{t+1}}

        理论最优,衰减快

        理论分析,简单任务

        问题是强凸的(平方根筛检是最优选择)

        模型简单&数据量小(平方根衰减效果不错)

        因子衰减​

        \eta_{t+1} \leftarrow \eta_t \cdot \alpha

        学习率=

        上轮学习率×衰减因子

        简单可控,指数衰减

        资源有限,稳定训练

        任务简单&无需精细控制

        需要对比算法效果(单因子作为基线

        ,作为基准对比)

        多步衰减​

        分段常数

        手动控制,灵活

        经验调参,阶段式训练

        余弦退火​

        余弦函数

        平滑衰减,带预热

        高级任务,最优性能

        现代深度学习的首选调度策略

        实践中的调度策略选择

        1. 按任务类型选择

        任务类型

        推荐调度策略

        理由

        图像分类​

        余弦退火 + 预热

        平滑收敛,跳出局部最优

        目标检测​

        多因子衰减

        训练稳定,调参简单

        语义分割​

        多项式衰减

        适应多尺度训练

        NLP任务​

        线性预热 + 平方根衰减

        对Transformer类模型有效

        2. 按数据特性选择

        if 数据量小、噪声大:
            使用较小的初始学习率 + 慢速衰减
        elif 数据量大、质量高:  
            使用较大的初始学习率 + 快速衰减
        elif 稀疏数据:
            使用自适应优化器(Adam) + 温和调度
        
        # 经验证明:余弦退火在大多数任务上优于传统策略
        if 需要最佳性能:
            选择余弦退火 + 预热
        elif 需要简单实现:
            选择多步衰减
        else:
            选择线性衰减

        PyTorch中的实现示例

        1. 标准调度器使用

        学习率调度器选择:

        # 学习率调度器选择
        # 简单任务:步进衰减
        scheduler = lr_scheduler.StepLR(optimizer, step_size=30, gamma=0.1)
        
        # 单因子调度器(恒定衰减)
        scheduler = lr_scheduler.FactorScheduler(factor=0.9)  # 每步都×0.9
        
        # PyTorch内置 多步衰减调度器:在指定epoch处衰减
        # milestones=[15, 30] 衰减里程碑:在第15和30个epoch进行学习率衰减
        # gamma=0.5           衰减因子:每次衰减为当前学习率的50%
        scheduler = lr_scheduler.MultiStepLR(trainer, milestones=[15, 30], gamma=0.5)
        
        # 多因子调度器(分段衰减)  
        scheduler = lr_scheduler.MultiStepLR(milestones=[30, 60], gamma=[0.1, 0.01])
        # 第30步×0.1,第60步×0.01
        
        # 复杂任务:余弦退火  
        scheduler = lr_scheduler.CosineAnnealingLR(optimizer, T_max=50)
        
        # 自适应:ReduceLROnPlateau
        scheduler = lr_scheduler.ReduceLROnPlateau(optimizer, 'min', patience=5)

        标准使用流程:

        import torch.optim.lr_scheduler as lr_scheduler
        # from torch.optim import lr_scheduler
        
        # 1、创建优化器
        optimizer = torch.optim.SGD(model.parameters(), lr=0.1)
        
        # 2、学习率调度器选择 —————————— (添加调度器) 
        scheduler = lr_scheduler.CosineAnnealingLR(optimizer, T_max=50) # 复杂任务:余弦退火
        
        # 3、监控与调试(训练循环)
        # 记录学习率变化
        for epoch in range(epochs):
            train(...)
            current_lr = scheduler.get_last_lr()
            print(f"Epoch {epoch}, LR: {current_lr}")
            
            if loss_not_improving:
                scheduler.step()  # 调整学习率

        2. 组合调度策略(预热 + 余弦退火)

        详情:11.3.4. 预热(带预热的余弦退火)

        实用建议

        # 经验法则
        初始学习率 = 0.1 * batch_size/256  # 线性缩放规则
        预热步数 = 2-5个epoch
        衰减策略 = 根据验证集性能动态调整

        高级技巧与最新进展

        1. 自适应批大小学习率

        2. 分层学习率

        # 不同网络层使用不同学习率
        optimizer = torch.optim.SGD([
            {'params': model.backbone.parameters(), 'lr': 0.1},    # 底层:小学习率
            {'params': model.head.parameters(), 'lr': 0.1},        # 顶层:大学习率
        ])

        3. 最新进展

        • OneCycle策略:先增后减的单周期学习率
        • Super-Convergence:超大学习率训练获得超级收敛
        • AutoLR:自动学习率调度算法

        小结

        • 在训练期间逐步降低学习率可提高准确性,且减少模型的过拟合。

        • 在实验中,每当进展趋于稳定时就降低学习率,这是很有效的。

          • 从本质上说,这可确保有效地收敛到一个适当的解,也只有这样才能通过降低学习率来减小参数的固有方差。

        • 余弦调度器在某些计算机视觉问题中很受欢迎。

        • 优化之前的预热期可以防止发散。

        • 优化在深度学习中有多种用途。对于同样的训练误差而言,选择不同的优化算法和学习率调度,除了最大限度地减少训练时间,可以导致测试集上不同的泛化和过拟合量。

        更多推荐