《动手学深度学习》随机梯度下降(SGD)精讲:无偏梯度、学习率调度与收敛性分析
《动手学深度学习》随机梯度下降(SGD)精讲:无偏梯度、学习率调度与收敛性分析
随机梯度下降(Stochastic Gradient Descent,SGD)是《动手学深度学习》(d2l-zh)全书中训练模型时反复使用的默认优化器,也是理解 Adam、小批量随机梯度下降等更高级算法的基础。本文以 chapter_optimization/sgd.md 为核心,结合仓库内 d2l 工具库的源码实现,系统讲解 SGD 为何能大幅降低单次迭代的计算代价、为何随机梯度是完整梯度的无偏估计、动态学习率的三种经典调度策略,以及凸目标下 SGD 收敛率的严格推导。读完本文,你将能够独立复现 SGD 的二维轨迹实验,并理解"学习率必须随时间衰减"这一深度学习实践背后的数学动因。
从梯度下降到随机梯度下降
在前面的章节(梯度下降)中,我们学习了梯度下降的基本原理:目标函数沿负梯度方向迭代,即可逐步逼近极小值。然而,深度学习的实际目标函数通常是训练数据集中每个样本损失的平均值。
给定 个样本的训练数据集,设 是关于索引 的训练样本的损失函数( 为参数向量),则目标函数为:
其梯度为:
如果使用梯度下降法,每个自变量迭代的计算代价为 ,随样本量 线性增长。当训练数据集较大时,每次迭代的梯度下降计算代价将相当高。这正是 SGD 登场的直接动机。
随机梯度更新:从 到
SGD 的核心思想是:在每次迭代中,随机均匀采样一个索引 ,只计算该样本的梯度 来更新参数:
其中 是学习率。这样一来,每次迭代的计算代价从梯度下降的 骤降至常数 。
为什么可以这样"偷工减料"?关键在于随机梯度 是完整梯度 的无偏估计:
这意味着,虽然单次更新使用了"噪声较大"的单个样本梯度,但平均而言,随机梯度是对真实梯度的良好估计,算法在期望意义上仍然沿着正确的下降方向前进。
仓库源码中的真实 SGD 实现
在 d2l-zh 仓库中,d2l/torch.py 给出了带批量归一化因子的 SGD 实际实现(用于线性回归手写实现章节):
def sgd(params, lr, batch_size):
"""小批量随机梯度下降
Defined in :numref:`sec_linear_scratch`"""
with torch.no_grad():
for param in params:
param -= lr * param.grad / batch_size
param.grad.zero_()
值得注意的是,仓库中的 sgd() 已经按 batch_size 对梯度做了平均,这正是小批量随机梯度下降的标准形态(参见 小批量随机梯度下降)。而本节的 sgd 实验函数在 d2l/mxnet.py、d2l/tensorflow.py、d2l/paddle.py 中也有对应的等价实现,四个框架(MXNet、PyTorch、TensorFlow、PaddlePaddle)的接口设计保持一致,便于读者跨框架对照学习。
用噪声模拟随机梯度:二维轨迹实验
为了直观比较梯度下降与 SGD 的行为差异,本节向"干净的"梯度中添加均值为 0、方差为 1 的随机噪声来模拟随机梯度。目标函数选择经典的椭球函数:
def f(x1, x2): # 目标函数
return x1 ** 2 + 2 * x2 ** 2
def f_grad(x1, x2): # 目标函数的梯度
return 2 * x1, 4 * x2
SGD 步长函数(PyTorch/MXNet/Paddle 版本)如下:
def sgd(x1, x2, s1, s2, f_grad):
g1, g2 = f_grad(x1, x2)
# 模拟有噪声的梯度
g1 += d2l.normal(0.0, 1, (1,)).item()
g2 += d2l.normal(0.0, 1, (1,)).item()
eta_t = eta * lr()
return (x1 - eta_t * g1, x2 - eta_t * g2, 0, 0)
TensorFlow 版本的噪声采样写法略有不同(使用 d2l.normal([1], 0.0, 1)),核心逻辑一致。函数签名中的 s1, s2 是预留给后续优化算法(如动量法、AdaGrad)的内部状态变量,在本节中始终返回 0;lr 则是一个可插拔的学习率函数,这正是后面动态学习率实验的挂钩点。
使用常数学习率运行 50 步:
def constant_lr():
return 1
eta = 0.1
lr = constant_lr # 常数学习速度
d2l.show_trace_2d(f, d2l.train_2d(sgd, steps=50, f_grad=f_grad))
实验观察到的关键现象是:SGD 中变量的轨迹比梯度下降(见 梯度下降)嘈杂得多。即使已经接近最小值,参数仍受瞬间梯度 注入的不确定性影响;即使经过 50 次迭代,解的质量依然不佳,且继续增加步数也不会改善。
轨迹绘制的底层机制:train_2d 与 show_trace_2d
上述实验由 d2l 工具库的两个函数驱动,它们的实现位于 d2l/torch.py(四个框架版本位置对应相同):
def train_2d(trainer, steps=20, f_grad=None):
"""用定制的训练机优化2D目标函数"""
# 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):
"""显示优化过程中2D变量的轨迹"""
d2l.set_figsize()
d2l.plt.plot(*zip(*results), '-o', color='#ff7f0e')
x1, x2 = d2l.meshgrid(d2l.arange(-5.5, 1.0, 0.1),
d2l.arange(-3.0, 1.0, 0.1), indexing='ij')
d2l.plt.contour(x1, x2, f(x1, x2), colors='#1f77b4')
d2l.plt.xlabel('x1')
d2l.plt.ylabel('x2')
从源码可以确认:train_2d 从 出发,将每一步的 记录到 results 列表中;show_trace_2d 则在目标函数的等高线(contour)上叠加橙色折线轨迹,从而直观呈现优化路径。这两个函数在全书多个优化章节(梯度下降、SGD、动量法、AdaGrad 等)中被复用,是理解所有基于 2D 目标函数优化实验的统一入口。
学习率困境:太小没进展,太大不收敛
噪声实验揭示了 SGD 与梯度下降的本质差异:即使接近最小值,参数仍被瞬时梯度的随机性"扰动"。而仅靠固定学习率 无法调和一对矛盾——
- 学习率太小:初始阶段几乎没有任何有意义的进展;
- 学习率太大:无法获得好的解(如上文所示,轨迹剧烈震荡)。
解决这两个相互冲突目标的唯一方法是在优化过程中动态降低学习率。这也是 sgd 步长函数中引入学习率函数 lr 的原因:在上面常数学习率的例子中,学习率调度的功能处于"休眠"状态,因为我们把 lr 设成了常量。
动态学习率:三种经典调度策略
用与时间相关的学习率 取代固定 ,增加了控制收敛的复杂性。核心问题在于确定 的衰减速度:太快会过早停止优化,太慢则浪费过多优化时间。以下三种基本策略是后续更高级学习率调度方法的基础:
- 分段常数(piecewise constant):每当优化进度停顿时降低学习率,这是训练深度网络的常见策略;
- 指数衰减(exponential decay):更激进地降低学习率,但往往导致算法在收敛之前就过早停止;
- 多项式衰减(polynomial decay):一个受欢迎的选择是 ,在凸优化情形下有充分的证据表明该速率表现良好。
指数衰减实验
def exponential_lr():
# 在函数外部定义,而在内部更新的全局变量
global t
t += 1
return math.exp(-0.1 * t)
t = 1
lr = exponential_lr
d2l.show_trace_2d(f, d2l.train_2d(sgd, steps=1000, f_grad=f_grad))
实现要点:t 是定义在函数外部、在函数内部通过 global 关键字更新的全局变量,每次调用 lr() 都会使 自增 1,返回 。运行结果与预期一致:参数的方差大大减少,但代价是未能收敛到最优解 ——即使经过 1000 个迭代步骤,仍然离最优解很远,算法根本无法收敛。这是因为学习率衰减过快,导致后期步长趋近于零。
多项式衰减实验
def polynomial_lr():
# 在函数外部定义,而在内部更新的全局变量
global t
t += 1
return (1 + 0.1 * t) ** (-0.5)
t = 1
lr = polynomial_lr
d2l.show_trace_2d(f, d2l.train_2d(sgd, steps=50, f_grad=f_grad))
多项式衰减中学习率随迭代次数的平方根倒数衰减()。与指数衰减对比鲜明:仅仅 50 次迭代之后,收敛就明显更好——既保留了足够的下降动力,又在接近最优解时自动缩小步长以抑制噪声。
更多调度选择与理论边界
除了上述三种策略,还存在更多选择:例如从较小的学习率开始、迅速升高后再缓慢降低(warmup 思想的雏形);甚至可以在较小和较大学习率之间交替切换。本书在此将注意力集中在可以进行全面理论分析的调度上——即凸环境下的学习率。对于一般非凸问题,很难获得有意义的收敛保证,因为总体而言,最小化非线性非凸问题是 NP 困难的(该判断源自原始文档对 Tibshirani 2015 讲义笔记的引用)。
凸目标下的收敛性分析
以下对凸目标函数 SGD 的收敛性分析是可选内容,主要用于传达更多直觉。这里只给出最简单的证明之一(引用自 Nesterov 与 Vial 2000 年的工作),目标函数表现特别好时还存在更先进的证明技术。
假设:对任意 ,目标函数 关于 是凸的。考虑 SGD 更新:
其中 是第 步从某分布中抽取的训练样本对应的目标函数。记期望风险为
为关于 的最低风险, 为风险最小化器(假设存在于 的定义域内)。追踪当前参数与风险最小化器之间的距离:
假设随机梯度的 范数受常数 限制:
由于对任意凸函数 都有 (对所有 ),由凸性可得:
将上述两式代入距离展开式,得到 时刻参数距离的边界:
这个不等式揭示了学习率必须衰减的根本原因:只要当前损失与最优损失的差异超过 ,算法就取得进展;而该差异必然收敛到零,因此学习率 也必须消失(趋向零)。
对不等式取期望:
对 求和(中间项相消,舍去低阶项):
定义优化路径的平滑版本(加权平均参数):
利用詹森不等式(取 ,可参考 凸性 中关于詹森不等式的论述)以及 的凸性,有 ,代入后得到最终边界:
其中 是初始参数与最优结果距离的边界。
结论:收敛速度取决于随机梯度范数的限制方式()以及初始参数与最优解的距离()。边界用 而非 表示,因为 是优化路径的平滑版本。只要已知 和 ,就可以选择学习率 ,此时上界为 ,即以 的速度收敛到最优解。
随机梯度与有限样本:有放回还是无放回?
此前讨论 SGD 时存在一个隐含假设:从分布 中采样样本 (通常带标签 )并据此更新模型。对于有限样本,离散分布可写为 。
但需要澄清两点事实:
- 本节实验并非真正采样:玩具示例只是向非随机梯度添加噪声,"假装"有成对的 。这种做法在此处是合理的(详细讨论见练习 2)。
- 全书实践采用无放回遍历:更关键的是,此前所有讨论中我们并非从分布采样,而是恰好遍历所有实例一次(每个 epoch 打乱顺序后完整过一遍)。
为什么无放回更可取?考虑相反的极端——有放回地从离散分布中采样 个观测值。随机选择元素 的概率是 ,因此至少选中它一次的概率为:
而某个样本(训练示例)被选中恰好一次的概率为:
也就是说,有放回采样下约有 37% 的样本出现一次、约 26% 的样本一次都没被选中(,剩余部分被多次选中),这导致方差增加、数据效率降低。因此实践中采用无放回采样(这也是本书各章节的默认选择);重复遍历训练数据集时,会以不同的随机顺序遍历它。
小结
- 对于凸问题,可以证明在广泛的学习率选择下,随机梯度下降都将收敛到最优解;
- 对深度学习而言情况通常并非如此,但凸问题的分析提供了有价值的优化洞见:逐步降低学习率,但不要降得太快;
- 学习率过小或过大都会引发问题,实践中往往需要多次实验才能找到合适的学习率;
- 当训练数据集中样本更多时,梯度下降每次迭代的计算代价更高,因此这些场景下首选随机梯度下降;
- 非凸情形下 SGD 的最优性保证通常不可用,因为需要检查的局部最小值数量可能是指数级的。
练习
- 尝试不同的随机梯度下降学习率计划和不同的迭代次数进行实验。特别是,根据迭代次数的函数绘制与最优解 的距离。
- 证明对于函数 ,向梯度添加正态噪声等同于最小化损失函数 ,其中 从正态分布中提取。
- 从 分别使用有放回与无放回方法采样时,比较随机梯度下降的收敛性。
- 如果某些梯度(或其关联的某些坐标)始终比所有其他梯度都大,应如何修改随机梯度下降求解器?
- 假设 。 有多少个局部最小值?请尝试修改 ,使最小化它时必须评估所有局部最小值。
延伸阅读路径
本文的完整代码与公式均来自 chapter_optimization/sgd.md,可结合以下仓库资源深入研读:SGD 的前置知识见 梯度下降,SGD 与批量训练的折中方案见 小批量随机梯度下降,动量和自适应学习率算法见 动量法、AdaGrad 等章节;d2l 工具库的完整实现位于 d2l/torch.py、d2l/mxnet.py、d2l/tensorflow.py、d2l/paddle.py,可按对应框架查阅 train_2d、show_trace_2d 与 sgd 等函数。