大数跨境

面试官皱眉:“神经网络训练震荡,你只会调小学习率?!”我:“不然呢?”面试官:“加上Momentum,既能提速,还能把来回摆动压下去!”

面试官皱眉:“神经网络训练震荡,你只会调小学习率?!”我:“不然呢?”面试官:“加上Momentum,既能提速,还能把来回摆动压下去!” 机器学习和人工智能AI
2026-07-10
3

哈喽,大家好~

我们在训练神经网络或做优化时,很多人会遇到“训练很慢”“在某些方向上来回震荡”“学习率要怎么调”的问题。

那么,动量法(Momentum)是一个非常常用且直观的方法,用来加速收敛并抑制震荡。

动量法的核心逻辑是什么?

比如,你在一个山谷里沿坡下山,要尽快到谷底(找到最小值)。普通的梯度下降(GD)就像每一步都看当前的坡度往下走,坡度告诉你方向和大小。

但如果地形“崎岖”,某些方向坡很陡,另一些方向很缓,GD 在陡峭方向会走得快,但在较平缓、长方向上很慢,还可能来回摆动。

动量法的直觉:给优化过程“加上惯性”。像推动一个小球在地形上滚动一样,当前的移动方向会受到过去移动方向的累积影响。这样可以在稳定方向上积累速度(加速),在来回摇摆的方向上被惯性抑制(减小振幅)。因此,动量法可以:

  • 加速沿“长期一致方向”的下降(克服鞍点、缓慢方向)。
  • 抑制在陡峭方向的震荡(尤其当不同方向条件数差别大时)。
  • 在随机噪声梯度下起到平滑作用(类似滤波)。

数学形式上,最常见的动量更新可以写成:

  • 速度更新:
  • 参数更新:

其中  (通常 0.8-0.99)是动量系数,  是学习率,  是当前的梯度。注意有些实现把   放在外面,使得

或者使用负梯度等等,具体写法等价于不同形式的缩放与符号约定,但核心思想是“v 是过去梯度的指数加权平均/累积”,并用 v 来更新参数。

为什么动量能加速?

考虑一个椭圆形的二次函数(条件数很大):在一个方向陡峭,在另一个方向平缓。

普通 GD 在陡峭方向大步进,导致在平缓方向上来回摆动,整体向最小值慢。动量能积累平缓方向的“推进”,抑制在陡峭方向的剧烈反向,从而沿平缓方向持续推进,整体更快到谷底。

另外,在小批量随机梯度(SGD)下,梯度本身有噪声,动量会对梯度做指数加权平均,从而起到“降噪/滤波”效果,使优化更稳定。

完整案例

我们可以生成一个 2 维特征的线性回归数据集,然后分别用 SGD(无动量)和 SGD + Momentum(有动量)来训练一个线性模型(权重 2 维),记录两者在参数空间的轨迹、loss 曲线、速度大小、以及在 noisy gradient 下的平滑效果。

下面,我们通过几幅图来直观对比~

  1. 参数空间的损失等高线 + 两种方法的参数轨迹,包括 Contour + Trajectory。
  2. Loss vs Iteration(训练曲线),对比收敛速度和抖动。
  3. 速度(momentum 向量模)随迭代的变化。
  4. 在带噪声的梯度时间序列中,显示原始梯度和 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(21, 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.00.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(-44200)
w1_range = np.linspace(-64200)
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,配合合适的学习率和学习率调度,就能获得更稳健、更快的训练效果。

【声明】内容源于网络
0
0
机器学习和人工智能AI
让我们一起期待 AI 带给我们的每一场变革!推送最新行业内最新最前沿人工智能技术!
内容 355
粉丝 0
机器学习和人工智能AI 让我们一起期待 AI 带给我们的每一场变革!推送最新行业内最新最前沿人工智能技术!
总阅读3.1k
粉丝0
内容355