哈喽,大家好~
我们在训练神经网络或做优化时,很多人会遇到“训练很慢”“在某些方向上来回震荡”“学习率要怎么调”的问题。
那么,动量法(Momentum)是一个非常常用且直观的方法,用来加速收敛并抑制震荡。
动量法的核心逻辑是什么?
比如,你在一个山谷里沿坡下山,要尽快到谷底(找到最小值)。普通的梯度下降(GD)就像每一步都看当前的坡度往下走,坡度告诉你方向和大小。
但如果地形“崎岖”,某些方向坡很陡,另一些方向很缓,GD 在陡峭方向会走得快,但在较平缓、长方向上很慢,还可能来回摆动。
动量法的直觉:给优化过程“加上惯性”。像推动一个小球在地形上滚动一样,当前的移动方向会受到过去移动方向的累积影响。这样可以在稳定方向上积累速度(加速),在来回摇摆的方向上被惯性抑制(减小振幅)。因此,动量法可以:
-
加速沿“长期一致方向”的下降(克服鞍点、缓慢方向)。 -
抑制在陡峭方向的震荡(尤其当不同方向条件数差别大时)。 -
在随机噪声梯度下起到平滑作用(类似滤波)。
数学形式上,最常见的动量更新可以写成:
-
速度更新:
-
参数更新:
其中 (通常 0.8-0.99)是动量系数, 是学习率, 是当前的梯度。注意有些实现把 放在外面,使得
或者使用负梯度等等,具体写法等价于不同形式的缩放与符号约定,但核心思想是“v 是过去梯度的指数加权平均/累积”,并用 v 来更新参数。
为什么动量能加速?
考虑一个椭圆形的二次函数(条件数很大):在一个方向陡峭,在另一个方向平缓。
普通 GD 在陡峭方向大步进,导致在平缓方向上来回摆动,整体向最小值慢。动量能积累平缓方向的“推进”,抑制在陡峭方向的剧烈反向,从而沿平缓方向持续推进,整体更快到谷底。
另外,在小批量随机梯度(SGD)下,梯度本身有噪声,动量会对梯度做指数加权平均,从而起到“降噪/滤波”效果,使优化更稳定。
完整案例
我们可以生成一个 2 维特征的线性回归数据集,然后分别用 SGD(无动量)和 SGD + Momentum(有动量)来训练一个线性模型(权重 2 维),记录两者在参数空间的轨迹、loss 曲线、速度大小、以及在 noisy gradient 下的平滑效果。
下面,我们通过几幅图来直观对比~
-
参数空间的损失等高线 + 两种方法的参数轨迹,包括 Contour + Trajectory。 -
Loss vs Iteration(训练曲线),对比收敛速度和抖动。 -
速度(momentum 向量模)随迭代的变化。 -
在带噪声的梯度时间序列中,显示原始梯度和 momentum 平滑后的信号(以某一个维度的梯度为例)。
import torch
import torch.nn as nn
import torch.optim as optim
import numpy as np
import matplotlib.pyplot as plt
from matplotlib import cm
np.random.seed(42)
torch.manual_seed(42)
# 1. 数据集:线性回归,输入维度 2
N = 1200
true_w = np.array([2.0, -3.5]) # 真实权重
true_b = 0.5
X = np.random.randn(N, 2) * 1.5
noise = 0.8 * np.random.randn(N)
y = X.dot(true_w) + true_b + noise
# 标准化,便于观察
X_mean = X.mean(axis=0)
X_std = X.std(axis=0)
Xn = (X - X_mean) / X_std
# 转为 torch tensor
X_t = torch.tensor(Xn, dtype=torch.float32)
y_t = torch.tensor(y, dtype=torch.float32).unsqueeze(1)
# 2. 定义简单线性模型,初始化相同参数以便对比
class LinearModel(nn.Module):
def __init__(self):
super().__init__()
self.linear = nn.Linear(2, 1, bias=True)
def forward(self, x):
return self.linear(x)
# 初始化两个模型,手动设置相同初始参数
def init_model_seed(seed=0):
torch.manual_seed(seed)
m = LinearModel()
# 初始化为固定值,便于两者起点一致
with torch.no_grad():
m.linear.weight.copy_(torch.tensor([[0.0, 0.0]]))
m.linear.bias.copy_(torch.tensor([0.0]))
return m
m_sgd = init_model_seed(123)
m_mom = init_model_seed(123)
# 3. 训练配置
lr = 0.05
momentum_coef = 0.9
epochs = 120
opt_sgd = optim.SGD(m_sgd.parameters(), lr=lr, momentum=0.0)
opt_mom = optim.SGD(m_mom.parameters(), lr=lr, momentum=momentum_coef)
loss_fn = nn.MSELoss()
# 记录轨迹与损失
traj_sgd = []
traj_mom = []
loss_sgd = []
loss_mom = []
vel_mom = [] # 记录 momentum 的速度模(仅能访问 optimizer 的 state)
# 4. 训练循环(批量全部数据,用每天梯度来模拟)
for t in range(epochs):
# SGD
opt_sgd.zero_grad()
pred_s = m_sgd(X_t)
ls = loss_fn(pred_s, y_t)
ls.backward()
opt_sgd.step()
loss_sgd.append(ls.item())
w_s = m_sgd.linear.weight.detach().cpu().numpy().reshape(-1)
b_s = m_sgd.linear.bias.detach().cpu().numpy().item()
traj_sgd.append([w_s[0], w_s[1]])
# Momentum
opt_mom.zero_grad()
pred_m = m_mom(X_t)
lm = loss_fn(pred_m, y_t)
lm.backward()
opt_mom.step()
loss_mom.append(lm.item())
w_m = m_mom.linear.weight.detach().cpu().numpy().reshape(-1)
b_m = m_mom.linear.bias.detach().cpu().numpy().item()
traj_mom.append([w_m[0], w_m[1]])
# 记录动量速度的模(通过 optimizer.state)
# state: 每个 param 的 state['momentum_buffer']
momentum_norm = 0.0
for p in opt_mom.param_groups[0]['params']:
st = opt_mom.state[p]
if 'momentum_buffer' in st:
buf = st['momentum_buffer']
momentum_norm += buf.norm().item()**2
vel_mom.append(np.sqrt(momentum_norm))
traj_sgd = np.array(traj_sgd)
traj_mom = np.array(traj_mom)
# 5. 构建损失面的网格(仅针对权重 w0,w1 两个维度,bias 固定为 0)
w0_range = np.linspace(-4, 4, 200)
w1_range = np.linspace(-6, 4, 200)
W0, W1 = np.meshgrid(w0_range, w1_range)
Z = np.zeros_like(W0)
# 计算 MSE loss 在网格上的值(批量计算)
X_np = Xn # 标准化后的输入
for i in range(W0.shape[0]):
for j in range(W0.shape[1]):
w_tmp = np.array([W0[i,j], W1[i,j]])
preds = X_np.dot(w_tmp) # bias=0
Z[i,j] = ((preds - (y - true_b))**2).mean() # 注意:y 底层含真实 bias,这里简化处理
# 6. 可视化
plt.figure(figsize=(10,8))
cs = plt.contour(W0, W1, Z, levels=40, cmap='viridis')
plt.clabel(cs, inline=True, fontsize=8)
plt.plot(traj_sgd[:,0], traj_sgd[:,1], '-o', color='orange', label='SGD', linewidth=2, markersize=4)
plt.plot(traj_mom[:,0], traj_mom[:,1], '-o', color='magenta', label='SGD+Momentum', linewidth=2, markersize=4)
plt.scatter([true_w[0]/X_std[0]], [true_w[1]/X_std[1]], c='red', s=80, label='True w (scaled)')
plt.title('参数空间等高线与轨迹')
plt.xlabel('w0')
plt.ylabel('w1')
plt.legend()
plt.show()
# Loss 曲线
plt.figure(figsize=(10,5))
plt.plot(loss_sgd, label='SGD', color='orange', linewidth=2)
plt.plot(loss_mom, label=f'SGD+Momentum({momentum_coef})', color='magenta', linewidth=2)
plt.yscale('log')
plt.xlabel('Iteration')
plt.ylabel('MSE Loss (log scale)')
plt.title('Loss vs Iteration')
plt.legend()
plt.show()
# 动量速度模
plt.figure(figsize=(8,4))
plt.plot(vel_mom, color='teal', linewidth=2)
plt.xlabel('Iteration')
plt.ylabel('Momentum norm')
plt.title('Momentum magnitude over iterations')
plt.show()
# 7. 模拟 noisy gradient 时间序列(选择一个维度)
# 计算每次迭代的梯度(人工复现:对同一批数据重算梯度并加入额外噪声来模拟小批量噪声)
grads = []
grads_mom_smoothed = []
buf = 0.0
gamma = momentum_coef
for t in range(epochs):
# 计算当前梯度(在 SGD 模型上)
opt_sgd.zero_grad()
pred = m_sgd(X_t)
l = loss_fn(pred, y_t)
l.backward()
# fetch gradient of weight[0,0]
g = m_sgd.linear.weight.grad.detach().cpu().numpy()[0,0]
# 添加额外随机噪声来模拟小批量梯度波动
noisy_g = g + np.random.randn()*0.8
grads.append(noisy_g)
# momentum smoothing (手工)
buf = gamma * buf + (1 - gamma) * noisy_g
grads_mom_smoothed.append(buf)
plt.figure(figsize=(10,4))
plt.plot(grads, color='crimson', alpha=0.7, label='Noisy gradient (dim0)')
plt.plot(grads_mom_smoothed, color='royalblue', linewidth=2, label='Momentum smoothed')
plt.xlabel('Iteration')
plt.ylabel('Gradient value')
plt.title('Gradient noise vs Momentum smoothing (one dimension)')
plt.legend()
plt.show()
核心步骤
数据生成:创建 2 维特征的线性回归数据,添加噪声。对特征做标准化,方便观察权重缩放。
模型初始化:用一个简单的线性层(2 -> 1)。为了直观对比,让两个模型共享相同初始参数(方便在参数空间绘图时二者起点一致)。
优化器:分别使用 SGD(无动量)和 SGD(带 momentum=0.9)。学习率相同。
训练记录:每次迭代记录权重轨迹(w0, w1)、loss,以及 momentum 的模(通过 optimizer.state 获取 momentum buffer)。
损失面网格:在参数空间绘制 MSE 的等高线,便于观察参数轨迹相对于损失面的移动方向。
额外模拟:生成一个含噪声的梯度时间序列,展示原始噪声梯度与 momentum 平滑之后的曲线对比。
可视化分析
参数空间等高线 + 轨迹:
等高线表示在参数 (w0, w1) 空间中的损失值(MSE)。点和曲线表示随迭代步数优化器如何沿参数空间移动。
SGD 的轨迹通常会表现出较大的锯齿或摆动,尤其在椭圆状等高线的狭窄方向上(即条件数较大时),带动量的轨迹更“流线型”,在一致方向上积累向下的动能,更直接地滑向最低点。
就是说,动量能使路径更平滑,减少在垂直于主要下降方向上的来回震荡,从而更快接近最优解。
Loss vs Iteration:
展示训练过程中损失随迭代的变化(对比 SGD 与 SGD+Momentum)。
-
初期可能两个方法都下降,但带动量的方法往往更快到达较低的损失。 -
SGD 曲线可能有更多上下抖动(尤其在小批量噪声或高学习率时)。 -
我们在图中用对数 y 轴以便观察收敛速度差异。
结论:动量能加速收敛并使损失曲线更稳健。
Momentum magnitude over iterations:
记录并展示动量向量(momentum buffer)的模随迭代的变化。
-
动量刚开始时为 0,随着梯度方向一致,动量模会逐步累积并达到某个稳定范围或峰值。 -
在接近最优点或方向改变时,动量会衰减或震荡。
动量并不是恒定的“推力”,而是动态累积的“惯性”,能结合梯度信息自动调整大小。
Gradient noise vs Momentum smoothing:
展示某一维度的“原始噪声梯度序列”与动量平滑后的曲线。
-
原始梯度含明显高频噪声(尤其模拟小批量或人为添加噪声时)。 -
动量平滑后的曲线更平滑,下降趋势更连续。
动量相当于对梯度做指数加权平均,能显著减少梯度中的随机波动,从而让参数更新更稳定。
实践建议
常用取值:动量系数 通常在 0.8 到 0.99 之间,常见是 0.9。若动量太大(接近 1),可能引起过冲,需要配合适当的学习率衰减或更小学习率。
学习率与动量的搭配:动量能让你用更大的有效学习率推进收敛,但建议在调整时同时观察训练曲线与验证曲线,避免过冲或发散。
与 Nesterov 动量对比:Nesterov 的核心是“先看未来位置再计算梯度”,通常会带来更稳定的加速。PyTorch 的 SGD 支持 nesterov=True。
动量在 Adam 等自适应优化器中的角色:Adam 的一部分(第一矩估计)本质上也是对梯度的一阶动量估计,起到类似的平滑与加速作用。
小批量噪声下的好处:在 SGD 下,动量能抑制噪声带来的抖动,使训练曲线更平滑,尤其在小批量(batch size 小)时效果明显。
总结
总的来说,动量就是“带惯性的梯度下降”:保存一份历史速度(历史梯度的加权累积),用这份速度去更新参数。
它能“加速有方向的一致下降”并“抑制无方向或来回振荡”,同时在含噪梯度下充当低通滤波器,使训练更稳定。
从实践角度,动量是几乎所有深度学习训练中都会用到的技巧:默认开启 momentum=0.9,配合合适的学习率和学习率调度,就能获得更稳健、更快的训练效果。

