机器学习基础(十二):优化器大全

一、引言

训练神经网络,本质上是在做一件事:找到一组参数 \(\theta\),让损失函数 \(L(\theta)\) 尽可能小。

这个"找"的过程,就是优化器的工作。

你可能遇到过这样的困惑:

  • 用 SGD 训练,loss 抖动剧烈,收敛很慢;
  • 换成 Adam,一开始很快,但最终精度比 SGD 差;
  • 论文里说用 AdamW,到底和 Adam 有什么区别?
  • 学习率调多少合适?

这篇文章从梯度下降的本质出发,逐步推导每种优化器的原理,最后给出实战选择建议


二、梯度下降基础

2.1 参数更新的核心公式

所有优化器的基础都是:

\[\theta \leftarrow \theta - \eta \cdot g \]

其中:

  • \(\theta\):模型参数
  • \(\eta\):学习率(learning rate)
  • \(g = \nabla_\theta L\):损失对参数的梯度

2.2 三种梯度下降变体

变体 每次用多少数据 特点
批量梯度下降(BGD) 全部训练集 稳定但极慢,内存爆炸
随机梯度下降(SGD) 单个样本 快但噪声极大
小批量梯度下降(Mini-batch SGD) 一个 batch(如 32/64/128) 实践中最常用的"SGD"

我们平时说的"SGD"几乎都是指 Mini-batch SGD

2.3 SGD 的问题

朴素 SGD 有几个明显缺陷:

  1. 学习率难调:太大震荡,太小收敛慢;
  2. 各维度梯度尺度差异大:有的参数梯度很大,有的很小,统一学习率顾此失彼;
  3. 容易陷入鞍点/局部最小:梯度为零但不是全局最优。

后续所有优化器,都是在针对这些问题做改进。


三、SGD + 动量(Momentum)

3.1 直觉理解

想象一个球从山坡滚下来:

  • 朴素 SGD:球每次只看当前坡度,遇到小坑就被卡住了;
  • Momentum SGD:球有惯性,积累了速度,能冲过小坑,在平坦方向加速。

3.2 公式推导

引入速度向量 \(v\)(动量项):

\[v_t = \beta \cdot v_{t-1} + g_t \]

\[\theta_t = \theta_{t-1} - \eta \cdot v_t \]

其中 \(\beta\) 通常取 0.9,表示保留 90% 的历史速度。

展开来看:

\[v_t = g_t + \beta g_{t-1} + \beta^2 g_{t-2} + \cdots \]

动量是历史梯度的指数加权移动平均,方向一致时加速,方向震荡时抵消。

3.3 Nesterov 动量(NAG)

动量 SGD 的升级版:先"预判"参数位置,再计算梯度

\[v_t = \beta \cdot v_{t-1} + g(\theta_{t-1} - \beta \cdot v_{t-1}) \]

\[\theta_t = \theta_{t-1} - \eta \cdot v_t \]

Nesterov 相当于"往前看一步再踩刹车",收敛更快、更稳定。

# PyTorch 中使用 SGD + Nesterov Momentum
optimizer = torch.optim.SGD(
    model.parameters(),
    lr=0.01,
    momentum=0.9,
    nesterov=True,
    weight_decay=1e-4  # L2 正则化
)

四、AdaGrad:自适应学习率

4.1 核心思想

不同参数应该有不同的学习率

  • 更新频繁的参数(梯度大)→ 学习率应该小;
  • 更新稀少的参数(梯度小)→ 学习率应该大。

4.2 公式

\[G_t = G_{t-1} + g_t^2 \]

\[\theta_t = \theta_{t-1} - \frac{\eta}{\sqrt{G_t + \epsilon}} \cdot g_t \]

\(G_t\)历史梯度平方的累积和,梯度越大的方向,分母越大,学习率越小。

4.3 缺陷

\(G_t\) 单调递增,学习率会永远递减,训练后期可能"停止学习"。


五、RMSProp:修复 AdaGrad 的衰减问题

5.1 改进点

指数加权移动平均代替累积和,让"遗忘"旧梯度:

\[G_t = \rho \cdot G_{t-1} + (1-\rho) \cdot g_t^2 \]

\[\theta_t = \theta_{t-1} - \frac{\eta}{\sqrt{G_t + \epsilon}} \cdot g_t \]

\(\rho\) 通常取 0.9,\(G_t\) 不再单调递增,学习率可以自动回升。

optimizer = torch.optim.RMSprop(
    model.parameters(),
    lr=0.001,
    alpha=0.9,   # 对应 ρ
    eps=1e-8
)

六、Adam:最流行的优化器

6.1 设计理念

Adam = Momentum(一阶矩)+ RMSProp(二阶矩)

把动量和自适应学习率合二为一:

6.2 完整公式

一阶矩(动量,梯度的指数加权平均):

\[m_t = \beta_1 m_{t-1} + (1-\beta_1) g_t \]

二阶矩(梯度平方的指数加权平均):

\[v_t = \beta_2 v_{t-1} + (1-\beta_2) g_t^2 \]

偏差修正(初始时 \(m_0=0, v_0=0\),修正初始偏差):

\[\hat{m}_t = \frac{m_t}{1-\beta_1^t}, \quad \hat{v}_t = \frac{v_t}{1-\beta_2^t} \]

参数更新:

\[\theta_t = \theta_{t-1} - \frac{\eta}{\sqrt{\hat{v}_t} + \epsilon} \cdot \hat{m}_t \]

默认超参数:

参数 默认值 含义
\(\beta_1\) 0.9 动量衰减系数
\(\beta_2\) 0.999 梯度平方衰减系数
\(\epsilon\) 1e-8 数值稳定项
\(\eta\) 1e-3 初始学习率
optimizer = torch.optim.Adam(
    model.parameters(),
    lr=1e-3,
    betas=(0.9, 0.999),
    eps=1e-8
)

6.3 Adam 的优点

  • 收敛快:前期训练速度远超 SGD;
  • 对学习率不敏感:1e-3 在绝大多数任务上都能用;
  • 适合稀疏梯度:NLP 任务首选。

6.4 Adam 的问题:泛化性差

实验发现,虽然 Adam 收敛快,但最终精度往往不如精调过的 SGD+Momentum

原因分析:Adam 的自适应学习率会让某些方向的梯度"被抑制过度",导致跳过更好的极小值。


七、AdamW:修复 L2 正则化的 Adam

7.1 问题背景

在 Adam 中加 L2 正则化,通常写法是:

optimizer = torch.optim.Adam(model.parameters(), lr=1e-3, weight_decay=1e-4)

看起来没问题,但实际上存在缺陷:weight_decay 在 Adam 中并不等价于 L2 正则化!

7.2 原因详解

标准 L2 正则化在梯度下降中等价于:

\[\theta_t = \theta_{t-1} - \eta(g_t + \lambda\theta_{t-1}) \]

但 Adam 的更新公式会对梯度(包含正则化项 \(\lambda\theta\))做自适应缩放,导致正则化强度随梯度大小变化,不同参数受到的正则化程度不一致。

7.3 AdamW 的解法

将权重衰减从梯度中分离出来,直接作用在参数上:

\[\theta_t = \theta_{t-1} - \frac{\eta}{\sqrt{\hat{v}_t} + \epsilon} \cdot \hat{m}_t - \eta \lambda \theta_{t-1} \]

权重衰减项不经过自适应缩放,正则化效果更干净。

# AdamW:Transformer、BERT、ViT 的标配优化器
optimizer = torch.optim.AdamW(
    model.parameters(),
    lr=1e-4,
    betas=(0.9, 0.999),
    weight_decay=0.01  # 这里的 weight_decay 才是真正的 L2 正则化
)

结论:只要你用 Adam + weight_decay,就应该用 AdamW 而不是 Adam。


八、其他值得了解的优化器

8.1 AMSGrad

Adam 的变种,保证二阶矩单调不减,理论上更稳定:

\[\hat{v}_t = \max(\hat{v}_{t-1}, v_t) \]

实践中提升有限,了解即可。

8.2 RAdam(Rectified Adam)

自动处理 Adam 早期训练不稳定问题,相当于"自适应 warmup":

# 需要安装 torch-optimizer 或使用 PyTorch 内置
from torch.optim import RAdam
optimizer = RAdam(model.parameters(), lr=1e-3)

8.3 Lion(Evolved by Google Brain)

2023 年 Google Brain 通过程序搜索发现的优化器,只用梯度的符号更新参数:

\[\theta_t = \theta_{t-1} - \eta \cdot \text{sign}(\beta_1 m_{t-1} + (1-\beta_1)g_t) - \eta\lambda\theta_{t-1} \]

  • 内存占用比 Adam 少(只需一个动量项);
  • 在大模型训练中表现优异;
  • 学习率通常设为 Adam 的 1/3 到 1/10。

九、学习率调度策略

优化器 × 学习率调度 = 完整的训练策略。固定学习率往往不是最优的。

9.1 常用调度器

StepLR(每 N 轮衰减一次):

scheduler = torch.optim.lr_scheduler.StepLR(optimizer, step_size=30, gamma=0.1)

CosineAnnealingLR(余弦退火,最常用):

scheduler = torch.optim.lr_scheduler.CosineAnnealingLR(optimizer, T_max=100)

OneCycleLR(先升后降,配合 SGD 效果极好):

scheduler = torch.optim.lr_scheduler.OneCycleLR(
    optimizer, max_lr=0.1, steps_per_epoch=len(train_loader), epochs=50
)

Warmup + Cosine Decay(Transformer 标配):

from torch.optim.lr_scheduler import LinearLR, CosineAnnealingLR, SequentialLR

warmup = LinearLR(optimizer, start_factor=0.1, total_iters=10)
cosine = CosineAnnealingLR(optimizer, T_max=90)
scheduler = SequentialLR(optimizer, schedulers=[warmup, cosine], milestones=[10])

9.2 学习率调度的使用方式

for epoch in range(num_epochs):
    train(model, optimizer)
    scheduler.step()  # 每个 epoch 结束后更新学习率

十、综合实战:用不同优化器训练同一个网络

下面用一段代码,在 CIFAR-10 上对比 SGD、Adam、AdamW 的训练曲线

import torch
import torch.nn as nn
import torch.nn.functional as F
import torchvision
import torchvision.transforms as transforms
from torch.utils.data import DataLoader
import matplotlib.pyplot as plt

# ---- 数据 ----
transform = transforms.Compose([
    transforms.ToTensor(),
    transforms.Normalize((0.5, 0.5, 0.5), (0.5, 0.5, 0.5))
])
train_set = torchvision.datasets.CIFAR10(root='./data', train=True, download=True, transform=transform)
train_loader = DataLoader(train_set, batch_size=128, shuffle=True, num_workers=2)

# ---- 模型(简单 CNN)----
class SimpleCNN(nn.Module):
    def __init__(self):
        super().__init__()
        self.conv1 = nn.Conv2d(3, 32, 3, padding=1)
        self.bn1   = nn.BatchNorm2d(32)
        self.conv2 = nn.Conv2d(32, 64, 3, padding=1)
        self.bn2   = nn.BatchNorm2d(64)
        self.pool  = nn.MaxPool2d(2, 2)
        self.fc1   = nn.Linear(64 * 8 * 8, 256)
        self.drop  = nn.Dropout(0.5)
        self.fc2   = nn.Linear(256, 10)

    def forward(self, x):
        x = self.pool(F.relu(self.bn1(self.conv1(x))))
        x = self.pool(F.relu(self.bn2(self.conv2(x))))
        x = x.view(x.size(0), -1)
        x = F.relu(self.fc1(x))
        x = self.drop(x)
        return self.fc2(x)

# ---- 训练函数 ----
def train_model(optimizer_name, num_epochs=20):
    model = SimpleCNN().cuda()
    criterion = nn.CrossEntropyLoss()

    if optimizer_name == 'SGD':
        optimizer = torch.optim.SGD(model.parameters(), lr=0.01, momentum=0.9, weight_decay=1e-4)
        scheduler = torch.optim.lr_scheduler.CosineAnnealingLR(optimizer, T_max=num_epochs)
    elif optimizer_name == 'Adam':
        optimizer = torch.optim.Adam(model.parameters(), lr=1e-3, weight_decay=1e-4)
        scheduler = None
    elif optimizer_name == 'AdamW':
        optimizer = torch.optim.AdamW(model.parameters(), lr=1e-3, weight_decay=0.01)
        scheduler = None

    loss_history = []
    for epoch in range(num_epochs):
        model.train()
        total_loss = 0
        for images, labels in train_loader:
            images, labels = images.cuda(), labels.cuda()
            optimizer.zero_grad()
            loss = criterion(model(images), labels)
            loss.backward()
            optimizer.step()
            total_loss += loss.item()
        if scheduler:
            scheduler.step()
        avg_loss = total_loss / len(train_loader)
        loss_history.append(avg_loss)
        print(f"[{optimizer_name}] Epoch {epoch+1}/{num_epochs}  Loss: {avg_loss:.4f}")
    return loss_history

# ---- 对比实验 ----
results = {}
for opt in ['SGD', 'Adam', 'AdamW']:
    results[opt] = train_model(opt)

# ---- 绘图 ----
plt.figure(figsize=(10, 5))
for opt, losses in results.items():
    plt.plot(losses, label=opt)
plt.xlabel('Epoch')
plt.ylabel('Training Loss')
plt.title('Optimizer Comparison on CIFAR-10')
plt.legend()
plt.grid(True)
plt.tight_layout()
plt.savefig('optimizer_comparison.png', dpi=150)
plt.show()

十一、优化器选择指南

根据任务类型和场景,一张表帮你决策:

场景 推荐优化器 学习率建议
CV 分类(追求极致精度) SGD + Momentum + Cosine 0.1,配合 warmup
CV 分类(快速原型) AdamW 1e-3
NLP / Transformer AdamW + warmup 1e-4 ~ 5e-4
GAN 训练 Adam(生成器 + 判别器) 2e-4
强化学习 Adam / RMSProp 1e-4 ~ 3e-4
大模型 fine-tuning AdamW 1e-5 ~ 5e-5
资源受限(大模型训练) Lion Adam lr 的 1/5

经验法则

  1. 首选 AdamW,设 lr=1e-3weight_decay=0.01,加上 Cosine 调度,覆盖 80% 场景;
  2. 如果要冲 SOTA 精度,试试 SGD + Momentum + OneCycleLR
  3. 用了 Adam 一定要用 AdamW,weight_decay 才是真正的正则化;
  4. 学习率永远要配合调度器,裸学习率很少是最优的;
  5. warmup 是 Transformer 的标配,前 5~10% 步骤线性升温,避免早期梯度爆炸。

十二、各优化器原理总结

梯度下降演进路线:

SGD                 ── 简单,需仔细调参
  └── + Momentum    ── 加速收敛,减少震荡(β=0.9)
       └── Nesterov ── 预判+动量,更快更稳

AdaGrad             ── 自适应学习率,但会衰减到零
  └── RMSProp       ── 用指数平均替代累积,解决衰减问题

Adam = Momentum + RMSProp  ── 快速收敛,工程首选
  └── AMSGrad       ── 理论更稳,实践差别不大
  └── RAdam         ── 自适应warmup,更鲁棒
  └── AdamW         ── 修复L2正则化,现代标准
       └── Lion     ── 只用符号,更省内存

十三、总结

优化器的选择不是玄学,有规律可循:

  • 理解动量:它让梯度"有惯性",减少震荡;
  • 理解自适应学习率:让每个参数有自己的步长;
  • Adam = 两者结合,收敛快但泛化稍差;
  • AdamW 修复了 Adam 的正则化问题,是现代深度学习的标准配置;
  • 学习率调度和优化器同样重要,余弦退火是万金油。

炼丹口诀:AdamW 起步,Cosine 收敛,SGD 冲精度,Warmup 保稳定。

posted @ 2026-04-06 21:18  YZG5N  阅读(130)  评论(0)    收藏  举报