3.4 一阶方法的进展
3.3 节的梯度下降有两个理想化:每步都能计算全体数据上的精确梯度,且收敛速度由条件数直接决定。实际训练中这两条都不成立:数据量使全梯度计算昂贵,条件数在深度网络中动辄成千上万。本节介绍针对这两个问题的四类改进,以及它们在分布式场景下的形态。
改进分两条线。确定性问题的线:动量法与重球法平滑迭代轨迹,Nesterov 加速把条件数依赖从
本节的分析工具来自 3.3 节:下降引理、光滑性与强凸性给出的二次界、以及条件数。区别只在梯度不再是精确值:随机梯度是真实梯度的无偏估计,噪声的方差成为速率公式中的新参数。
3.4.1 动量法
病态问题中的震荡
梯度下降在条件数大的问题上表现出一对典型症状:沿低曲率方向前进缓慢,在高曲率方向来回震荡。二次函数上可以精确看出原因:第
动量法在更新中保留历史方向,让低频方向的分量逐步累积,高频方向的分量在振荡中相互抵消。带权重的历史累积称为动量:
重球法与收敛条件
动量法的一个经典分析形式是重球法(heavy ball method),把动量写成相邻两步的差:
两种写法相差一个
时,距离到最优点的收缩因子为
达到与共轭梯度法同阶的速率。这一结论有两个限制:参数依赖已知的
动量还提供一项与收敛速率无关的实用好处:梯度噪声的方差被平均抑制。随机梯度带噪时,动量对历史梯度做指数平均,等效于把有效批量放大到
3.4.2 Nesterov 加速与一阶方法的最优性
加速梯度法
Nesterov 加速梯度法(accelerated gradient descent,AGD)在动量之外引入外推:梯度不在当前点计算,而在沿动量方向外推得到的点上计算。
初值取
速率结论分两档。
凸且光滑:
强凸且光滑:加入强凸系数后(或在加速法上周期性重启),线性收敛速率中的条件数依赖变为
迭代次数从
一阶方法的最优性
加速是否已经到顶,由 Nesterov 下界回答。在一阶 oracle 模型下(算法只能访问函数值与梯度,不能访问 Hessian,也不能利用问题结构),存在一个凸光滑函数使任何一阶算法在第
强凸情形下也有对应的下界,形式为
oracle 复杂度:优化的抽象计算模型
一阶 oracle 模型把算法能获得的信息限制为每个点的函数值与梯度。复杂度分析在 model 内进行:上界给出算法,下界给出模型内所有算法的共同限制。这一框架把优化算法与问题结构分开:访问 Hessian(二阶 oracle)或利用问题结构(如稀疏、低秩)可以得到更快的算法,而一般的一阶方法已经触及该模型的天花板。这也解释了 3.5 节与 3.7 节的必要性:牛顿法、拟牛顿法与近端方法都在使用一阶 oracle 之外的额外结构。
下列代码在条件数
import numpy as np
m, L = 1.0, 100.0
Q = np.diag([m, L])
f = lambda x: 0.5 * x @ (Q @ x)
def gd(kmax=3000):
x = np.array([1.0, 1.0]); hist = []
for _ in range(kmax):
hist.append(f(x))
x = x - (1 / L) * (Q @ x)
return np.array(hist)
def heavy_ball(kmax=3000):
eta = 4 / (np.sqrt(L) + np.sqrt(m)) ** 2
beta = ((np.sqrt(L) - np.sqrt(m)) / (np.sqrt(L) + np.sqrt(m))) ** 2
x, x_prev = np.array([1.0, 1.0]), np.array([1.0, 1.0])
hist = []
for _ in range(kmax):
hist.append(f(x))
x, x_prev = x - eta * (Q @ x) + beta * (x - x_prev), x
return np.array(hist), eta, beta
def nesterov(kmax=3000):
x = x_prev = y = np.array([1.0, 1.0])
t = 1.0
hist = []
for _ in range(kmax):
hist.append(f(x))
x_new = y - (1 / L) * (Q @ y)
t_new = (1 + np.sqrt(1 + 4 * t ** 2)) / 2
y = x_new + ((t - 1) / t_new) * (x_new - x)
x_prev, x, t = x, x_new, t_new
return np.array(hist)
h_gd = gd()
h_hb, eta_hb, beta_hb = heavy_ball()
h_nt = nesterov()
print(f"重球法最优参数:eta = {eta_hb:.6f},beta = {beta_hb:.6f}")
print(f" 距离收缩因子 (sqrt(kappa)-1)/(sqrt(kappa)+1) = {(10 - 1) / (10 + 1):.4f},函数值收缩 = {((10 - 1) / (10 + 1)) ** 2:.4f}")
for name, h in [("梯度下降", h_gd), ("重球法", h_hb), ("Nesterov", h_nt)]:
k_star = int(np.argmax(h <= 1e-8))
print(f" {name:8s}: f 降到 1e-8 的步数 = {k_star},k = 200 时 f = {h[200]:.3e}")
# 梯度下降受条件数限制需要近千步;重球法(参数针对该问题调优)与 Nesterov 都在两百步内达到输出显示:梯度下降需要
3.4.3 随机梯度下降
目标与梯度估计
机器学习中的目标函数通常是样本平均:
三档速率
噪声改变了收敛分析的结论,速率公式中多出噪声项。
强凸且光滑,递减步长:取
比全梯度的线性收敛慢,但对每次迭代只花
凸且光滑,递减步长:取
非凸且光滑,固定步长:取
右端第一项随
小批量的作用与噪声的意义
小批量
噪声也有正面作用。梯度噪声使迭代轨迹偏离精确的下降路径,从而有机会离开尖锐的极小点与鞍点,这一效应与泛化性能的关联是深度学习中讨论较多的话题(3.8 节)。无论动机如何,噪声在速率公式中始终以
3.4.4 方差缩减
控制变量
固定步长 SGD 的噪声地板促使人们寻找方差更小的梯度估计。方差缩减(variance reduction)的基本工具是控制变量(control variate):为随机变量
SVRG(stochastic variance reduced gradient)取
三项的含义:前两项的差在全梯度下为零(同一批样本,两个点),因此
SAGA 用梯度表实现同一思想:为每个样本保存最近一次计算的梯度,更新时用新旧梯度之差修正。两者的速率都可写成
单样本梯度计算次数。与全梯度下降的
数值对比
下列代码在条件数约
import numpy as np
rng = np.random.default_rng(0)
n, d = 4000, 40
scales = 0.85 ** np.arange(d) # 让特征量纲不同,制造条件数
X = rng.normal(size=(n, d)) * scales
w_true = rng.normal(size=d)
y = X @ w_true + 0.5 * rng.normal(size=n)
lam = 0.02
H = X.T @ X / n + lam * np.eye(d)
Lg, mg = np.linalg.eigvalsh(H).max(), np.linalg.eigvalsh(H).min()
w_star = np.linalg.solve(H, X.T @ y / n)
f_star = 0.5 * np.mean((X @ w_star - y) ** 2) + 0.5 * lam * np.sum(w_star ** 2)
print(f"L = {Lg:.4f},m = {mg:.4f},kappa = {Lg / mg:.1f}")
f_all = lambda w: 0.5 * np.mean((X @ w - y) ** 2) + 0.5 * lam * np.sum(w ** 2)
g_all = lambda w: X.T @ (X @ w - y) / n + lam * w
g_i = lambda w, i: X[i] * (X[i] @ w - y[i]) + lam * w
Lmax = float(np.max(np.sum(X ** 2, axis=1)) + lam)
budget = 10 ** 6
# 全梯度下降:每步 n 次梯度计算
w, evals_gd, gaps_gd = np.zeros(d), [], []
ev = 0
while ev < budget:
evals_gd.append(ev); gaps_gd.append(f_all(w) - f_star)
w = w - (1 / Lg) * g_all(w); ev += n
# SGD:递减步长
w, evals_sgd, gaps_sgd = np.zeros(d), [], []
for k in range(1, budget + 1):
i = rng.integers(n)
w = w - (0.05 / np.sqrt(k)) * g_i(w, i)
if k % 5000 == 0:
evals_sgd.append(k); gaps_sgd.append(f_all(w) - f_star)
# SGD:固定步长(观察噪声地板)
w, evals_fix, gaps_fix = np.zeros(d), [], []
for k in range(1, budget + 1):
i = rng.integers(n)
w = w - 0.01 * g_i(w, i)
if k % 5000 == 0:
evals_fix.append(k); gaps_fix.append(f_all(w) - f_star)
# SVRG:外层全梯度 + 内层 n 步方差缩减
w, evals_sv, gaps_sv = np.zeros(d), [], []
eta_s, ev = 1 / (4 * Lmax), 0
while ev < budget:
w_tilde = w.copy(); full = g_all(w_tilde); ev += n
evals_sv.append(ev); gaps_sv.append(f_all(w) - f_star)
for _ in range(n):
i = rng.integers(n)
w = w - eta_s * (g_i(w, i) - g_i(w_tilde, i) + full)
ev += 1
print("在相同梯度计算次数下的 f - f*:")
print("方法 10^4 10^5 10^6")
for name, evs, gs in [("全梯度", evals_gd, gaps_gd), ("SGD-递减", evals_sgd, gaps_sgd),
("SGD-固定", evals_fix, gaps_fix), ("SVRG", evals_sv, gaps_sv)]:
row = []
for M in [10 ** 4, 10 ** 5, 10 ** 6]:
idx = int(np.searchsorted(evs, M, side="right")) - 1
row.append(f"{gs[idx]:.2e}" if idx >= 0 else "----")
print(f"{name:10s} " + " ".join(f"{v:>8s}" for v in row))输出:
L = 1.0146,m = 0.0200,kappa = 50.7
在相同梯度计算次数下的 f - f*:
方法 10^4 10^5 10^6
全梯度 2.07e-01 1.01e-02 1.29e-08
SGD-递减 3.73e-02 6.44e-03 8.27e-05
SGD-固定 3.28e-03 1.87e-03 4.36e-03
SVRG 2.11e+00 1.67e-15 5.55e-17表格中的四行对应四种典型行为。全梯度下降前期昂贵(一万次梯度计算只完成
3.4.5 自适应步长
对角预条件
3.3 节的数值实验说明:条件数由坐标选择决定,缩放坐标可以让收敛速度改善几个数量级。自适应方法把这个缩放内置到算法里:为每个坐标维护一个尺度估计,用梯度的历史信息逐步学习它。
AdaGrad 累积梯度平方并按其平方根缩放:
累积量只增不减,因此有效步长单调下降。这一行为对稀疏特征有利(出现频率低的坐标保留较大步长),但在稠密问题上步长衰减过快,训练后期几乎停滞。
RMSProp 把累积改为指数滑动平均,去掉单调衰减:
Adam 在二阶矩之外再加一阶矩(动量)与偏差校正:
偏差校正是为了消除
与条件数的关系
自适应更新可以写成对角预条件形式:
下列代码在条件数
import numpy as np
Q = np.diag([1.0, 1000.0])
f = lambda x: 0.5 * x @ (Q @ x)
# 梯度下降:步长 1/L = 1e-3
x, hist = np.array([1.0, 1.0]), []
for k in range(200001):
hist.append(f(x))
x = x - 1e-3 * (Q @ x)
print(f"梯度下降(步长 1/L):f 降到 1e-8 的步数 = {int(np.argmax(np.array(hist) <= 1e-8))}")
# Adam:lr = 0.05,默认 beta
x = np.array([1.0, 1.0])
m_k = np.zeros(2); v_k = np.zeros(2)
b1, b2, eps, lr = 0.9, 0.999, 1e-8, 0.05
hist = []
for k in range(1, 20001):
hist.append(f(x))
g = Q @ x
m_k = b1 * m_k + (1 - b1) * g
v_k = b2 * v_k + (1 - b2) * g ** 2
m_hat = m_k / (1 - b1 ** k)
v_hat = v_k / (1 - b2 ** k)
x = x - lr * m_hat / (np.sqrt(v_hat) + eps)
hist = np.array(hist)
print(f"Adam(lr = 0.05,beta = 0.9/0.999):f 降到 1e-8 的步数 = {int(np.argmax(hist <= 1e-8))}")
# 梯度下降受最小曲率限制:慢方向每步只收缩千分之一,需要近万步
# Adam 不需要知道 L 与 m,用两个坐标的梯度量级自动完成尺度校正输出:梯度下降需要
Adam 的代价是失去部分理论保证:原始版本存在不收敛的反例(AMSGrad 等修正版本针对这一点),最优解附近的震荡使最终精度通常低于精调的 SGD 加动量。工程上的常见做法是训练中用 Adam,收尾切换 SGD,或者在 Adam 中使用解耦的权重衰减(AdamW)以避免惩罚项被自适应缩放干扰。
3.4.6 大批量训练、本地 SGD 与联邦学习
大批量与线性缩放
随机梯度的方差随批量按
线性缩放在训练初期不成立:初始参数下梯度方向不稳定,放大后的步长会使训练发散。常用对策是预热(warmup):前若干步把学习率从很小的值线性升到目标值,之后再按余弦或其他调度衰减。学习率调度(阶梯衰减、余弦退火)的作用是让后期步长足够小以适应噪声地板的下降。
并行方式与通信
数据并行把批量分配到多个设备,每个设备计算局部梯度后求和。同步数据并行每步需要一次全归约(AllReduce)通信,通信量与模型参数量成正比;Ring AllReduce 通过环形拓扑把单点带宽需求降到
异步 SGD 允许设备不等其他设备,用略旧的参数计算梯度。它消除了同步等待,但引入了延迟梯度带来的偏差:更新的信息越旧,有效步长越难控制,实践中需要更小的步长与稀疏化更新(只传输被修改的坐标)。
通信压缩在网络带宽受限时使用:把梯度量化到低精度(如 1-bit、4-bit)或只传输最大的若干分量(稀疏化)。压缩引入的误差用误差反馈补偿:把本次丢弃或量化的残差保留,累加到下一次传输中。误差反馈使压缩后的序列在期望意义上仍然无偏,避免误差累积改变收敛点。
本地 SGD 与联邦学习
本地 SGD(local SGD)把通信频率降低:每个设备在本地做
联邦学习是本地 SGD 的极端形态:数据分布在用户设备上,任何一条数据都不能离开本地,通信由设备主动发起且可能不可用(设备掉线)。FedAvg 就是每轮选取一部分设备做本地训练、上传参数、服务器加权平均的流程,
3.4.7 本节小结
从精确梯度到实际训练
本节把 3.3 节的梯度法推向实际训练。动量法用梯度历史平滑迭代,重球法在已知谱信息时把条件数依赖降到
一阶方法处理的目标都是光滑的:梯度处处存在且 Lipschitz 连续。下一组方法(近端与分裂算法)处理非光滑目标,它们的最优性条件与收敛分析需要 3.2 节的次微分工具。在进入那里之前,3.5 节先补齐二阶方法:牛顿法、拟牛顿法与信赖域,它们把方向的选择从梯度扩展到曲率估计。
练习题
第 1 题 概念推导
参考答案
迭代次数对比:强凸光滑下,梯度下降的速率为
Nesterov 加速(配强凸系数或重启)的速率为
条件数
下界与最优性:Nesterov 下界说明存在凸光滑函数使任何一阶算法满足
加速的收益边界:下界是存在性结论(有一个坏函数),不是普适的悲观结论。对已经良态的问题(
第 2 题 计算推理
某训练任务使用小批量 SGD,固定步长
参考答案
噪声地板:
即
三种方案:
其一,减小步长。地板与
其二,增大批量。批量梯度方差按批量的反比下降,把批量增大
其三,方差缩减。SVRG 一类的估计器把方差从
选择建议:训练后期需要高精度时优先方差缩减;批量与步长的组合主要用于控制训练速度而非最终精度;对无法使用方差缩减的大规模场景,用递减步长与学习率调度让后期步长自然变小,效果等价于第一种方案但按调度而非手动。
第 3 题 代码验证
用 3.4.1 与 3.4.2 节的代码框架验证三个结论:其一,重球法在条件数
参考答案
import numpy as np
m, L = 1.0, 10000.0
Q = np.diag([m, L])
f = lambda x: 0.5 * x @ (Q @ x)
# 重球法:最优参数
eta = 4 / (np.sqrt(L) + np.sqrt(m)) ** 2
beta = ((np.sqrt(L) - np.sqrt(m)) / (np.sqrt(L) + np.sqrt(m))) ** 2
x, x_prev = np.array([1.0, 1.0]), np.array([1.0, 1.0])
dists = []
for k in range(8000):
dists.append(np.linalg.norm(x))
x, x_prev = x - eta * (Q @ x) + beta * (x - x_prev), x
dists = np.array(dists)
r_theory = (np.sqrt(L) - np.sqrt(m)) / (np.sqrt(L) + np.sqrt(m))
print(f"重球法:eta = {eta:.8f},beta = {beta:.6f}")
print(f" 第 200 步局部比值 = {dists[200] / dists[199]:.6f}")
print(f" 窗口平均收缩因子(2000 到 8000 步)= {(dists[7999] / dists[1999]) ** (1 / 6000):.6f}")
print(f" 理论收缩因子 = {r_theory:.6f},降到 1e-8 的步数 = {int(np.argmax(dists <= 1e-8))}")
# Nesterov:只用 L(梯度在 y 处求值)
def run_nesterov(fun, grad, kmax=200000):
x, y = np.array([1.0, 1.0]), np.array([1.0, 1.0])
t = 1.0
hist = []
for _ in range(kmax):
hist.append(fun(x))
x_new = y - (1 / L) * grad(y)
t_new = (1 + np.sqrt(1 + 4 * t ** 2)) / 2
y = x_new + ((t - 1) / t_new) * (x_new - x)
x, t = x_new, t_new
return np.array(hist)
h1 = run_nesterov(f, lambda y: Q @ y) # 原始坐标:Q = diag(1, 10000)
f2 = lambda x: 0.5 * (10000.0 * x[0] ** 2 + x[1] ** 2)
h2 = run_nesterov(f2, lambda y: np.array([10000.0 * y[0], y[1]])) # 交换后的谱与一致的梯度
print(f"Nesterov:f 降到 1e-8 的步数 = {int(np.argmax(h1 <= 1e-8))}")
print(f"Nesterov(交换坐标):f2 降到 1e-8 的步数 = {int(np.argmax(h2 <= 1e-8))}")输出:
重球法:eta = 0.00039212,beta = 0.960788
第 200 步局部比值 = 0.985111
窗口平均收缩因子(2000 到 8000 步)= 0.980425
理论收缩因子 = 0.980198,降到 1e-8 的步数 = 1315
Nesterov:f 降到 1e-8 的步数 = 699
Nesterov(交换坐标):f2 降到 1e-8 的步数 = 699结论:其一,重球法的窗口平均收缩因子
其二,Nesterov 需要
其三,交换坐标后步数完全不变(同为
第 4 题 综合应用
某团队在一个
参考答案
问题定位:后期损失不降、验证指标震荡对应噪声地板。用 3.4.3 节的公式,地板为
三种改进方案:
其一,学习率调度(余弦退火或阶梯衰减)。原理是随训练进程减小步长,让噪声地板按
其二,增大批量并配合线性缩放与预热。原理是方差按批量下降,批量翻倍配步长翻倍可以保持下降速度并减少迭代次数。代价是计算资源与通信量上升;风险是超过临界批量后收益消失(可以扫描批量-步长曲线找临界点),以及大学习率导致的初期不稳定(用预热缓解)。
其三,切换优化器或改用方差缩减/自适应方法。原理是用 SVRG 类方法消除噪声地板(代价是每轮全梯度),或改用 Adam 类自适应方法使后期震荡减小(代价是最终精度可能低于精调 SGD)。对
扩展到 64 卡、批量 65536 的配套调整:按线性缩放把学习率从
常见错误
错误 1 · 在固定步长的 SGD 上期待损失降到零
原因:固定步长的随机梯度下降存在噪声地板
解决:先用公式估算地板量级,与观察到的波动幅度对照。需要更低损失时减小步长、增大批量(并同步调整步长)或改用方差缩减。训练脚本中记录梯度范数的滑动平均,它比损失更能反映是否到达地板:梯度范数稳定在非零平台时提高精度只能靠上述手段。
错误 2 · 认为动量系数越大越好
原因:动量系数接近
解决:把动量系数作为需要验证的超参数:在固定学习率下扫描
错误 3 · 把加速方法当作无条件更快的选择
原因:Nesterov 加速的收益依赖条件数与噪声水平。在噪声大的随机场景中,外推会放大梯度噪声,实际表现可能不如普通动量;在条件数已经接近
解决:在确定性凸问题上(或近端算法的光滑部分)优先使用加速;随机场景中把加速与方差缩减配合,或在有重启机制时使用。引入前做对照实验:同样的计算预算下比较加速与普通动量,若收益不足
错误 4 · 把学习率与批量的线性缩放当作精确规则
原因:线性缩放(批量翻倍、学习率翻倍)只在噪声主导的区间近似成立:批量很小时随机梯度的方向噪声大,放大步长会发散;批量很大时(超过临界批量)方差已经被平均掉,放大步长只会带来不稳定而不增加下降量。把规则当作精确公式会在两个端点上出错。
解决:把线性缩放作为起始点而不是终点:先按规则设置,再在批量改变后的头几百步监控是否发散,必要时增加预热步数。用批量扫描确定临界批量(固定总计算量,比较不同批量下的最终损失,损失开始变差的点即临界批量附近),超过它之后不再按比例增大学习率。