优化方法

优化器是深度学习训练的引擎——给定损失函数的梯度,优化器决定如何更新参数来最小化损失。SGD 是最朴素的 w←w-η·g,但在鞍点和狭长山谷中收敛极慢;Momentum 引入动量 v=βv+g,像小球滚下山坡一样积累速度,加速收敛并平滑震荡;AdaGrad 为每个参数维护历史梯度平方和,让高频更新的参数自动减速、低频参数加速——适合 NLP 中稀疏特征(如罕见词的 embedding);Adam 结合了 Momentum 的一阶矩估计和 RMSProp 的二阶矩估计,是 GPT、BERT、Stable Diffusion 等模型训练的默认选择。学习率调度同样关键:Warmup 在前几百步线性增大学习率避免初期参数剧变,Cosine Decay 使后期学习率平滑衰减到接近零以精细调优——这是 LLM 训练的标准策略。本节逐个解释每种优化器的数学原理,并用 f(w)=w² 对比它们的实际收敛行为。

一、优化问题的本质

目标:找到参数 θ 使得损失函数 L(θ) 最小
θ* = argmin_θ L(θ)

为什么不能直接解方程 ∇L = 0?
① 深度网络的 L(θ) 极其复杂(千万个参数的非凸函数),没有解析解
② 数据量太大(如 1400 万张 ImageNet 图片),求精确梯度不现实
→ 所以用迭代的近似方法:每次看一小批数据,算近似梯度,走一小步

二、批量梯度下降 vs 随机梯度下降 vs 小批量

方法每次用多少数据梯度质量速度适用场景
批量梯度下降(BGD)全部 N 个样本精确极慢小数据集、凸问题
随机梯度下降(SGD)1 个样本噪声大极快在线学习
小批量 SGD(Mini-batch)B 个样本(32~512)适中适中深度学习标配
为什么小批量是最佳选择——数值分析:

假设数据集有 60000 个样本,每个 epoch(历遍所有数据一次):

BGD:1 次参数更新(精确但太慢)
SGD(B=1):60000 次更新(更快但方向太随机)
Mini-batch(B=64):60000/64 = 937 次更新(折中)
Mini-batch(B=256):60000/256 = 234 次更新

内存与 GPU 效率:
B=1:GPU 利用率极低(矩阵乘法太小,大量计算核空闲)
B=32:GPU 开始并行化(利用率约 30%)
B=256:GPU 满载(利用率 90%+,大矩阵乘法效率高)
B=8192:可能超出 GPU 内存;大 batch 泛化可能变差

经验建议:计算机视觉常用 B=32~256,NLP/Transformer 常用 B=16~64。

三、梯度下降(SGD)

θₜ₊₁ = θₜ - α × gₜ

其中 gₜ = ∂L/∂θₜ(当前梯度),α = 学习率
就这么简单——沿着梯度反方向走一步。
数值演示——最小化 f(w) = w⁴ - 3w² + 2(非凸函数)
f'(w) = 4w³ - 6w,学习率 α = 0.05,初始 w₀ = 2.0

步数wf(w)f'(w)更新量
02.0006.00020.000-1.000
11.0000.000-2.000+0.100
21.1000.048-1.274+0.064
31.164-0.012-0.696+0.035
41.199-0.036-0.297+0.015
51.213-0.045-0.075+0.004
101.225-0.050-0.001≈0
收敛到 w ≈ 1.225(即 √(3/2)),f(w) = -0.05,这是一个局部最小值。
另一个局部最小值在 w ≈ -1.225。全局最小值两个等价。
SGD 的三大问题:
学习率选择困难:太大→震荡不收敛,太小→收敛太慢
所有参数用同一个学习率:稀疏特征(如罕见词"磺胺")需要大学习率,高频特征(如"的")需要小学习率
脆弱的鞍点和局部最小值:在鞍点处梯度为 0,卡住不动

四、SGD + 动量(Momentum)

vₜ = β × vₜ₋₁ + gₜ    (累积历史梯度方向)
θₜ₊₁ = θₜ - α × vₜ

β 通常取 0.9(保留上一步 90% 的速度)

类比:小球从山坡滚下来,不仅受当前坡度影响,还保留了之前的速度。
在一致的下坡方向上越滚越快(加速收敛),
在来回震荡的方向上正负梯度相消(减少震荡)。
数值对比——SGD vs Momentum(二维椭圆函数):
f(x,y) = 0.5×100x² + 0.5×y²(等高线是扁椭圆)
∇f = (100x, y),初始点 (3.0, 3.0),α=0.01,β=0.9

SGD(无动量)5步轨迹:
(3.0, 3.0) → (0, 2.97) → (-2.97, 2.94) → (0.03, 2.91) → ...
x 方向来回震荡(梯度 100x 很大但学习率太大)!y 方向缓慢下降。

SGD+Momentum 5步轨迹:
步1: g=(300,3), v=(300,3), 新位置(-0.0,2.97)
步2: g=(-0.3,2.97), v=0.9×(300,3)+(-0.3,2.97)=(269.7,5.67), 新位置...
x 方向:正负梯度在动量中相消 → 震荡被抑制
y 方向:梯度一直为正 → 动量不断累积 → 加速前进

结果:Momentum 在 50 步内收敛到 (0.01, 0.01),SGD 还在剧烈震荡。

五、AdaGrad——自适应学习率

sₜ = sₜ₋₁ + gₜ²   (累积梯度平方)
θₜ₊₁ = θₜ - α × gₜ / (√sₜ + ε)

ε = 10⁻⁸(防止除零)

核心思想:对每个参数,根据历史梯度大小自动调整学习率。
梯度一直很大的参数 → sₜ 很大 → 有效学习率变小(已经学够了)
梯度一直很小的参数 → sₜ 很小 → 有效学习率变大(还需要多学)
数值示例——NLP 词嵌入场景:
两个参数:w₁(对应高频词"的"),w₂(对应低频词"磺胺")
初始学习率 α=0.1

5轮训练中的梯度和 AdaGrad 更新:
轮次g₁(高频)g₂(低频)s₁s₂实际lr₁实际lr₂
12.00.14.00.010.1/√4=0.050.1/√0.01=1.0
21.50.06.250.010.041.0
31.80.29.490.050.0320.447
42.10.013.900.050.0270.447
51.30.315.590.140.0250.267
高频词 "的" → 梯度大 → s₁ 快速增大 → 学习率从 0.05 降到 0.025
低频词 "磺胺" → 梯度小 → s₂ 增长很慢 → 学习率维持在 0.27~1.0
自动实现了"频繁参数小更新,罕见参数大更新"!
AdaGrad 的致命问题:s 只增不减,到训练后期所有参数的学习率都趋于 0,训练提前"冻结"。

六、RMSProp——修复 AdaGrad

sₜ = ρ × sₜ₋₁ + (1-ρ) × gₜ²   (指数加权滑动平均)
θₜ₊₁ = θₜ - α × gₜ / (√sₜ + ε)

ρ = 0.99(衰减率),只保留最近的梯度信息,旧的自动遗忘
解决了 AdaGrad 的 s 单调递增问题。

七、Adam——工业标配优化器

结合了 Momentum(一阶矩)和 RMSProp(二阶矩):

mₜ = β₁ × mₜ₋₁ + (1-β₁) × gₜ   (一阶矩=动量)
vₜ = β₂ × vₜ₋₁ + (1-β₂) × gₜ²  (二阶矩=自适应学习率)

偏差修正(训练初期 mₜ 和 vₜ 偏小,因为初始化为 0):
m̂ₜ = mₜ / (1 - β₁ᵗ)
v̂ₜ = vₜ / (1 - β₂ᵗ)

θₜ₊₁ = θₜ - α × m̂ₜ / (√v̂ₜ + ε)

推荐超参数:α=0.001, β₁=0.9, β₂=0.999, ε=10⁻⁸
Adam 完整计算过程——3步迭代:
参数 w=5.0(初始远离最优值),损失 L=w²,∂L/∂w=2w
α=0.001, β₁=0.9, β₂=0.999, m₀=0, v₀=0

═══ 第1步 ═══
g₁ = 2×5.0 = 10.0
m₁ = 0.9×0 + 0.1×10.0 = 1.0
v₁ = 0.999×0 + 0.001×100 = 0.1
m̂₁ = 1.0/(1-0.9¹) = 1.0/0.1 = 10.0
v̂₁ = 0.1/(1-0.999¹) = 0.1/0.001 = 100.0
w₁ = 5.0 - 0.001 × 10.0/√100.0 = 5.0 - 0.001 × 1.0 = 4.999

═══ 第2步 ═══
g₂ = 2×4.999 = 9.998
m₂ = 0.9×1.0 + 0.1×9.998 = 1.900
v₂ = 0.999×0.1 + 0.001×99.96 = 0.200
m̂₂ = 1.900/(1-0.81) = 1.900/0.19 = 10.0
v̂₂ = 0.200/(1-0.998) = 0.200/0.002 = 100.0
w₂ = 4.999 - 0.001 × 10.0/10.0 = 4.998

═══ 第3步 ═══
g₃ = 9.996
m₃ = 0.9×1.900 + 0.1×9.996 = 2.710
v₃ = 0.999×0.200 + 0.001×99.92 = 0.300
m̂₃ = 2.710/(1-0.729) = 2.710/0.271 = 10.0
v̂₃ = 0.300/(1-0.997) = 0.300/0.003 = 100.0
w₃ = 4.998 - 0.001 × 10.0/10.0 = 4.997

观察:偏差修正后每步几乎走 0.001。
没有偏差修正时:第1步 m̂₁ 会是 1.0 而不是 10.0,步长太小。

八、学习率调度

为什么要调整学习率:
训练初期:远离最优值 → 大学习率快速接近
训练后期:接近最优值 → 小学习率精细调整

常用调度策略:

StepLR(阶梯衰减):每 N 个 epoch 乘以 γ
例:初始 lr=0.1,每 30 epoch ×0.1
epoch 0-29: lr=0.1, epoch 30-59: lr=0.01, epoch 60-89: lr=0.001
用于 ResNet 等 CNN 训练。

CosineAnnealing(余弦退火):
lr(t) = lr_min + 0.5(lr_max - lr_min)(1 + cos(πt/T))
从 lr_max 平滑下降到 lr_min,末期下降加速。
例:lr_max=0.1, lr_min=0, T=100
t=0: lr=0.1, t=25: lr=0.085, t=50: lr=0.05, t=75: lr=0.015, t=100: lr=0

Warmup + Decay(GPT/BERT 标配):
前 N 步线性增大学习率(warmup),之后逐步衰减。
例:warmup=4000步
步1: lr=0.00001, 步2000: lr=0.0005, 步4000: lr=0.001(峰值)
之后余弦衰减到接近 0。
为什么需要 warmup:训练初期参数随机、梯度方差大,大学习率容易发散。

九、优化器选择指南

场景推荐优化器典型配置说明
通用深度学习Adam / AdamWlr=1e-3~3e-4开箱即用,几乎不需要调参
Transformer/LLMAdamW + Warmuplr=3e-4, warmup=4000权重衰减用 AdamW 而非 L2
图像分类(CNN)SGD + Momentumlr=0.1, β=0.9, StepLR精调时 SGD 常比 Adam 好
GAN 训练Adamlr=2e-4, β₁=0.5β₁ 通常设成 0.5 而非 0.9
微调预训练大模型AdamWlr=2e-5~5e-5学习率比从头训练小 10~100 倍

十、代码验证(C# / Rust)

C#(.NET 10)

// dotnet run 即可执行
double F(double w) => w * w;
double GradF(double w) => 2.0 * w;

// ===== SGD / Momentum / Adam 对比 =====
void Train(string name, double lr, double wInit = 5.0, int steps = 20)
{
    double w = wInit, m = 0, v = 0, vel = 0;
    double beta1 = 0.9, beta2 = 0.999, eps = 1e-8;
    for (int t = 1; t <= steps; t++)
    {
        double g = GradF(w);
        switch (name)
        {
            case "SGD":      w -= lr * g; break;
            case "Momentum": vel = 0.9 * vel + g; w -= lr * vel; break;
            case "Adam":
                m = beta1 * m + (1 - beta1) * g;
                v = beta2 * v + (1 - beta2) * g * g;
                double mH = m / (1 - Math.Pow(beta1, t));
                double vH = v / (1 - Math.Pow(beta2, t));
                w -= lr * mH / (Math.Sqrt(vH) + eps);
                break;
        }
    }
    Console.WriteLine($"{name,-10}: 最终 w={w:F6}, f(w)={F(w):F8}");
}

Train("SGD", 0.1);
Train("Momentum", 0.01);
Train("Adam", 0.5);

// ===== AdaGrad 演示 =====
Console.WriteLine("\nAdaGrad (高频 vs 低频参数):");
double w1 = 5.0, w2 = 5.0, s1 = 0.0, s2 = 0.0;
double lrAdagrad = 0.5;
double[] gW1 = { 2.0, 1.5, 1.8, 2.1, 1.3 };
double[] gW2 = { 0.1, 0.0, 0.2, 0.0, 0.3 };
for (int t = 0; t < 5; t++)
{
    s1 += gW1[t] * gW1[t]; s2 += gW2[t] * gW2[t];
    double eff1 = lrAdagrad / (Math.Sqrt(s1) + 1e-8);
    double eff2 = lrAdagrad / (Math.Sqrt(s2) + 1e-8);
    w1 -= eff1 * gW1[t]; w2 -= eff2 * gW2[t];
    Console.WriteLine($"  t={t + 1}: 高频lr={eff1:F4}, 低频lr={eff2:F4}");
}

// ===== Warmup + Cosine Decay =====
Console.WriteLine("\nWarmup + Cosine Decay 调度:");
int totalSteps = 100, warmup = 10;
double lrMax = 0.001;
foreach (int step in new[] { 1, 5, 10, 25, 50, 75, 100 })
{
    double lrS = step <= warmup
        ? lrMax * step / warmup
        : lrMax * 0.5 * (1 + Math.Cos(Math.PI * (step - warmup) / (double)(totalSteps - warmup)));
    Console.WriteLine($"  Step {step,3}: lr = {lrS:F6}");
}

Rust

fn main() {
    let f = |w: f64| w * w;
    let grad_f = |w: f64| 2.0 * w;

    // ===== SGD / Momentum / Adam 对比 =====
    for &(name, lr) in &[("SGD", 0.1), ("Momentum", 0.01), ("Adam", 0.5)] {
        let (mut w, mut m, mut v, mut vel) = (5.0_f64, 0.0, 0.0, 0.0);
        let (beta1, beta2, eps) = (0.9_f64, 0.999, 1e-8);
        for t in 1..=20_i32 {
            let g = grad_f(w);
            match name {
                "SGD"      => w -= lr * g,
                "Momentum" => { vel = 0.9_f64.mul_add(vel, g); w -= lr * vel; }
                "Adam"     => {
                    m = beta1.mul_add(m, (1.0 - beta1) * g);
                    v = beta2.mul_add(v, (1.0 - beta2) * g * g);
                    let m_hat = m / (1.0 - beta1.powi(t));
                    let v_hat = v / (1.0 - beta2.powi(t));
                    w -= lr * m_hat / (v_hat.sqrt() + eps);
                }
                _ => {}
            }
        }
        println!("{name:<10}: 最终 w={w:.6}, f(w)={:.8}", f(w));
    }

    // ===== AdaGrad 演示 =====
    println!("\nAdaGrad (高频 vs 低频参数):");
    let (mut w1, mut w2, mut s1, mut s2) = (5.0_f64, 5.0, 0.0, 0.0);
    let lr = 0.5_f64;
    let gw1 = [2.0, 1.5, 1.8, 2.1, 1.3];
    let gw2 = [0.1, 0.0, 0.2, 0.0, 0.3];
    for t in 0..5 {
        s1 += gw1[t] * gw1[t]; s2 += gw2[t] * gw2[t];
        let eff1 = lr / (s1.sqrt() + 1e-8);
        let eff2 = lr / (s2.sqrt() + 1e-8);
        w1 -= eff1 * gw1[t]; w2 -= eff2 * gw2[t];
        println!("  t={}: 高频lr={eff1:.4}, 低频lr={eff2:.4}", t + 1);
    }

    // ===== Warmup + Cosine Decay =====
    println!("\nWarmup + Cosine Decay 调度:");
    let (total, warmup, lr_max) = (100_i32, 10_i32, 0.001_f64);
    for step in [1, 5, 10, 25, 50, 75, 100] {
        let lr_s = if step <= warmup {
            lr_max * step as f64 / warmup as f64
        } else {
            let prog = (step - warmup) as f64 / (total - warmup) as f64;
            lr_max * (std::f64::consts::PI * prog).cos().mul_add(0.5, 0.5)
        };
        println!("  Step {step:3}: lr = {lr_s:.6}");
    }
}

已阅读当前小节,可返回首页继续浏览其它主题。