PyTorch学习阶段三-优化器与训练循环

9 阅读23分钟

PyTorch 架构级学习系列 - 第 4 篇

本文将深入优化器的核心原理。你将理解不同优化算法的工作机制,学习率调度的策略,以及如何构建一个完整的训练循环。


📚 目录

  1. 梯度下降的本质:从数学到代码
  2. 优化器家族:SGD、Momentum、Adam 的原理
  3. 学习率调度:如何动态调整学习率
  4. 损失函数:训练的目标
  5. 完整训练循环:从数据到模型
  6. 常见训练问题与解决方案

🎯 Part 1: 梯度下降的本质

核心问题:有了梯度,如何用它来更新参数,让损失函数下降?

1.1 梯度下降的核心思想

问题: 最小化函数 f(x) = (x - 3)²

import torch

# 梯度下降:迭代更新
x = torch.tensor(0.0, requires_grad=True)
learning_rate = 0.1

for step in range(20):
    y = (x - 3) ** 2        # 1. 计算损失
    y.backward()            # 2. 计算梯度

    with torch.no_grad():
        x -= learning_rate * x.grad  # 3. 更新参数:x = x - lr * grad
        x.grad.zero_()      # 4. 清零梯度

print(f"结果: x = {x.item():.4f} (理论值: 3.0)")

核心公式:

θ_{t+1} = θ_t - η * ∇L(θ_t)

其中:
- θ: 参数
- η (eta): 学习率(步长)
- ∇L: 损失函数的梯度

为什么是负梯度方向? 梯度指向函数增长最快的方向,负梯度指向下降最快的方向。

1.2 学习率的影响

# 学习率太小 → 收敛慢
lr = 0.01: 需要 100+ 步

# 学习率合适 → 快速收敛
lr = 0.1: 需要 20# 学习率太大 → 震荡/发散
lr = 1.5: 在最优点附近来回跳,甚至越走越远

1.3 神经网络中的梯度下降

import torch.nn as nn

# 模型:y = w*x + b
model = nn.Linear(10, 1)

# 训练循环(手动梯度下降)
learning_rate = 0.01
for epoch in range(100):
    # 前向传播
    y_pred = model(x_train)
    loss = ((y_pred - y_train) ** 2).mean()

    # 反向传播
    loss.backward()

    # 手动更新所有参数
    with torch.no_grad():
        for param in model.parameters():
            param -= learning_rate * param.grad
            param.grad.zero_()

问题: 每次都要手动写这些代码太麻烦!

解决: PyTorch 提供了优化器来自动处理参数更新。


小结:

  • 梯度下降公式:θ = θ - lr * ∇L(θ)
  • 学习率:太小慢,太大震荡
  • 手动实现:loss.backward() → 更新参数 → grad.zero_()

接下来,我们学习PyTorch的优化器家族...


🚀 Part 2: 优化器家族

核心洞察

不同的优化算法用不同的策略来更新参数,可以更快、更稳定地收敛。

2.1 使用 PyTorch 的优化器

import torch
import torch.nn as nn
import torch.optim as optim

# 模型
model = LinearModel()

# 优化器(代替手动更新)
optimizer = optim.SGD(model.parameters(), lr=0.01)

# 训练循环
for epoch in range(100):
    # 前向传播
    y_pred = model(x_train)
    loss = ((y_pred - y_train) ** 2).mean()

    # 反向传播
    optimizer.zero_grad()  # 清零梯度
    loss.backward()        # 计算梯度
    optimizer.step()       # 更新参数(代替手动更新)

    if (epoch + 1) % 20 == 0:
        print(f"Epoch {epoch+1}: Loss = {loss.item():.4f}")

优化器的核心方法:

# 1. optimizer.zero_grad()
#    清零所有参数的梯度
#    等价于:
#    for param in model.parameters():
#        param.grad.zero_()

# 2. loss.backward()
#    计算梯度(这是 autograd 的工作)

# 3. optimizer.step()
#    根据梯度更新参数
#    等价于:
#    for param in model.parameters():
#        param.data = param.data - lr * param.grad

2.2 SGD:最基础的优化器

SGD(Stochastic Gradient Descent):随机梯度下降

# 基本形式
optimizer = optim.SGD(model.parameters(), lr=0.01)

# 更新规则:
# θ_t+1 = θ_t - η * ∇L(θ_t)

Vanilla SGD 的实现:

class MySGD:
    def __init__(self, params, lr=0.01):
        self.params = list(params)
        self.lr = lr

    def zero_grad(self):
        for param in self.params:
            if param.grad is not None:
                param.grad.zero_()

    def step(self):
        for param in self.params:
            if param.grad is not None:
                param.data = param.data - self.lr * param.grad

# 使用
model = LinearModel()
optimizer = MySGD(model.parameters(), lr=0.01)

for epoch in range(100):
    y_pred = model(x_train)
    loss = ((y_pred - y_train) ** 2).mean()

    optimizer.zero_grad()
    loss.backward()
    optimizer.step()

SGD 的问题:

# 问题 1:震荡
# 在峡谷(narrow valley)中会来回震荡

# 示例:f(x, y) = 0.1*x^2 + 10*y^2
# y 方向的梯度大,x 方向的梯度小
# SGD 会在 y 方向大幅震荡

# 问题 2:慢
# 对所有参数使用相同的学习率
# 某些参数需要大步长,某些需要小步长

2.3 SGD with Momentum:带动量的 SGD

动量(Momentum): 累积历史梯度,加速收敛

# SGD with Momentum
optimizer = optim.SGD(model.parameters(), lr=0.01, momentum=0.9)

# 更新规则:
# v_t = β * v_{t-1} + ∇L(θ_t)      ← 累积动量
# θ_t+1 = θ_t - η * v_t            ← 用动量更新
#
# 其中 β (beta) 是动量系数,通常 0.9 或 0.99

物理类比:小球滚下山坡

# 想象一个小球从山上滚下来:
# - 梯度 = 坡度(重力)
# - 动量 = 速度(惯性)
# - 小球会累积速度,越滚越快
# - 即使到达平坦区域(梯度小),也会因为惯性继续前进

具体例子:理解动量累积

# Momentum 累积的是"速度"(方向 + 大小),不是学习率

# 想象小球滚下山:
# Step 1: grad = -5 → velocity = 0.9*0 + (-5) = -5
# Step 2: grad = -4 → velocity = 0.9*(-5) + (-4) = -8.5  ← 速度累积!
# Step 3: grad = -3 → velocity = 0.9*(-8.5) + (-3) = -10.65 ← 越来越快!

# 如果梯度方向一致 → velocity 累积 → 走得更快
# 如果梯度方向震荡 → velocity 相互抵消 → 走得慢

# 注意:Momentum 不改变学习率,它改变的是"步长的方向和大小"

手动实现 Momentum:

class SGDWithMomentum:
    def __init__(self, params, lr=0.01, momentum=0.9):
        self.params = list(params)
        self.lr = lr
        self.momentum = momentum
        # 为每个参数初始化速度
        self.velocities = [torch.zeros_like(p) for p in self.params]

    def zero_grad(self):
        for param in self.params:
            if param.grad is not None:
                param.grad.zero_()

    def step(self):
        for param, velocity in zip(self.params, self.velocities):
            if param.grad is not None:
                # v_t = β * v_{t-1} + grad
                velocity.mul_(self.momentum).add_(param.grad)
                # θ_t+1 = θ_t - η * v_t
                param.data.add_(velocity, alpha=-self.lr)

# 测试
model = LinearModel()
optimizer = SGDWithMomentum(model.parameters(), lr=0.01, momentum=0.9)

Momentum 的优势:

# 1. 加速收敛
#    在一致的方向上累积速度,越来越快

# 2. 减少震荡
#    震荡方向的梯度正负抵消,动量接近 0

# 3. 逃离局部最小值
#    靠惯性冲出局部最小值(有限)

2.4 RMSProp:自适应学习率

RMSProp(Root Mean Square Propagation): 对每个参数使用不同的学习率

optimizer = optim.RMSprop(model.parameters(), lr=0.01, alpha=0.99)

# 更新规则:
# s_t = α * s_{t-1} + (1-α) * (∇L)^2    ← 累积梯度平方(二阶矩)
# θ_t+1 = θ_t - η / √(s_t + ε) * ∇L    ← 自适应学习率
#
# 其中:
# - s_t: 梯度平方的移动平均
# - α (alpha): 衰减率,通常 0.9 或 0.99
# - ε (epsilon): 防止除零,通常 1e-8

核心思想:

# 梯度一直很大的参数 → 降低学习率(小心走,避免震荡)
# 梯度一直很小的参数 → 保持/增大学习率(大胆走,加速收敛)

# 例子:
param1: grad = 10  → s ≈ 100 → effective_lr = 0.01 / √100 = 0.001  ← 学习率变小
param2: grad = 0.1 → s ≈ 0.01 → effective_lr = 0.01 / √0.01 = 0.1 ← 学习率变大

# RMSProp 自动调整每个参数的学习率!

直观理解:峡谷优化问题

# 问题场景:想象一个峡谷(山谷两侧陡峭,底部平缓)
#
#     陡峭方向(y轴)
#         ↑
#         |  /\    /\    /\
#         | /  \  /  \  /  \    ← 两侧很陡,梯度大
# 平缓  → |___________________  ← 底部平缓,梯度小
#         |     (谷底)

# SGD的问题:所有参数用相同学习率
# - x方向(平缓):grad = 0.1  → 更新 = lr * 0.1 = 0.001(太小!走得慢)
# - y方向(陡峭):grad = 10   → 更新 = lr * 10 = 0.1(太大!来回震荡)

# RMSProp的解决:给每个方向不同的学习率
# - x方向(梯度小):s_x ≈ 0.01  → effective_lr ≈ 0.1 / √0.01 = 1.0(学习率变大!)
# - y方向(梯度大):s_y ≈ 100   → effective_lr ≈ 0.1 / √100 = 0.01(学习率变小!)

# 结果:
# - x方向加速前进
# - y方向小心走,减少震荡
# - 快速到达最优点

完整示例:峡谷函数优化

import torch
import matplotlib.pyplot as plt

# 峡谷函数:x方向平缓,y方向陡峭
def valley_function(x, y):
    return 0.1 * x**2 + 10 * y**2  # x系数小(平缓),y系数大(陡峭)

# ============ SGD 优化 ============
x_sgd, y_sgd = 5.0, 5.0
lr_sgd = 0.01
history_sgd = [(x_sgd, y_sgd)]

for step in range(100):
    grad_x = 0.2 * x_sgd  # ∂f/∂x = 0.2x
    grad_y = 20 * y_sgd   # ∂f/∂y = 20y

    # SGD:所有参数用相同学习率
    x_sgd -= lr_sgd * grad_x
    y_sgd -= lr_sgd * grad_y
    history_sgd.append((x_sgd, y_sgd))

# ============ RMSProp 优化 ============
x_rms, y_rms = 5.0, 5.0
lr_rms = 0.1  # 可以设置大一点
s_x, s_y = 0.0, 0.0
alpha = 0.9
eps = 1e-8
history_rms = [(x_rms, y_rms)]

for step in range(100):
    grad_x = 0.2 * x_rms
    grad_y = 20 * y_rms

    # RMSProp:自适应学习率
    # 1. 累积梯度平方的移动平均
    s_x = alpha * s_x + (1 - alpha) * grad_x**2
    s_y = alpha * s_y + (1 - alpha) * grad_y**2

    # 2. 用自适应学习率更新参数
    x_rms -= lr_rms / (s_x**0.5 + eps) * grad_x
    y_rms -= lr_rms / (s_y**0.5 + eps) * grad_y
    history_rms.append((x_rms, y_rms))

print(f"SGD 100步后位置: ({history_sgd[-1][0]:.4f}, {history_sgd[-1][1]:.4f})")
print(f"RMSProp 100步后位置: ({history_rms[-1][0]:.4f}, {history_rms[-1][1]:.4f})")
# 输出:
# SGD: (4.xxxx, 0.0xxx) ← y方向快速收敛,但x方向慢
# RMSProp: (0.0xxx, 0.0xxx) ← 两个方向都快速收敛

手动实现 RMSProp:

class MyRMSProp:
    def __init__(self, params, lr=0.01, alpha=0.99, eps=1e-8):
        self.params = list(params)
        self.lr = lr
        self.alpha = alpha
        self.eps = eps
        # 为每个参数初始化平方梯度累积
        self.square_avg = [torch.zeros_like(p) for p in self.params]

    def zero_grad(self):
        for param in self.params:
            if param.grad is not None:
                param.grad.zero_()

    def step(self):
        for param, sq_avg in zip(self.params, self.square_avg):
            if param.grad is not None:
                # s_t = α * s_{t-1} + (1-α) * grad^2
                sq_avg.mul_(self.alpha).addcmul_(
                    param.grad, param.grad, value=1 - self.alpha
                )
                # θ = θ - lr / √(s_t + ε) * grad
                param.data.addcdiv_(
                    param.grad,
                    sq_avg.sqrt().add_(self.eps),
                    value=-self.lr
                )

2.5 Adam:最流行的优化器

Adam(Adaptive Moment Estimation): 结合 Momentum 和 RMSProp

optimizer = optim.Adam(model.parameters(), lr=0.001, betas=(0.9, 0.999))

# 更新规则:
# m_t = β1 * m_{t-1} + (1-β1) * ∇L        ← 一阶矩(动量)
# v_t = β2 * v_{t-1} + (1-β2) * (∇L)^2   ← 二阶矩(RMSProp)
#
# m̂_t = m_t / (1 - β1^t)                 ← 偏差修正
# v̂_t = v_t / (1 - β2^t)
#
# θ_t+1 = θ_t - η * m̂_t / (√v̂_t + ε)   ← 自适应 + 动量

为什么需要偏差修正?

# 初始时 m_0 = 0, v_0 = 0
# 第 1 步:
# m_1 = 0.9 * 0 + 0.1 * grad = 0.1 * grad  ← 太小了!
# v_1 = 0.999 * 0 + 0.001 * grad^2 = 0.001 * grad^2

# 偏差修正:
# m̂_1 = m_1 / (1 - 0.9^1) = 0.1*grad / 0.1 = grad ✓
# v̂_1 = v_1 / (1 - 0.999^1) = 0.001*grad^2 / 0.001 = grad^2 ✓

# 后期(t 很大):
# β1^t → 0, β2^t → 0
# 偏差修正项 → 1,几乎没有影响

手动实现 Adam:

class MyAdam:
    def __init__(self, params, lr=0.001, betas=(0.9, 0.999), eps=1e-8):
        self.params = list(params)
        self.lr = lr
        self.beta1, self.beta2 = betas
        self.eps = eps
        self.t = 0  # 时间步

        # 初始化动量和二阶矩
        self.m = [torch.zeros_like(p) for p in self.params]
        self.v = [torch.zeros_like(p) for p in self.params]

    def zero_grad(self):
        for param in self.params:
            if param.grad is not None:
                param.grad.zero_()

    def step(self):
        self.t += 1

        for param, m, v in zip(self.params, self.m, self.v):
            if param.grad is not None:
                grad = param.grad

                # 更新一阶矩(动量)
                m.mul_(self.beta1).add_(grad, alpha=1 - self.beta1)

                # 更新二阶矩(RMSProp)
                v.mul_(self.beta2).addcmul_(grad, grad, value=1 - self.beta2)

                # 偏差修正
                m_hat = m / (1 - self.beta1 ** self.t)
                v_hat = v / (1 - self.beta2 ** self.t)

                # 更新参数
                param.data.addcdiv_(m_hat, v_hat.sqrt().add_(self.eps), value=-self.lr)

2.6 优化器对比

import torch
import torch.optim as optim
import matplotlib.pyplot as plt

# 测试函数:Rosenbrock function (banana function)
# f(x, y) = (1 - x)^2 + 100 * (y - x^2)^2
# 最小值在 (1, 1)

def rosenbrock(x, y):
    return (1 - x)**2 + 100 * (y - x**2)**2

def test_optimizer(optimizer_class, **kwargs):
    """测试优化器在 Rosenbrock 函数上的表现"""
    x = torch.tensor([0.0, 0.0], requires_grad=True)
    optimizer = optimizer_class([x], **kwargs)

    history = [x.detach().clone().numpy()]

    for step in range(200):
        optimizer.zero_grad()
        loss = rosenbrock(x[0], x[1])
        loss.backward()
        optimizer.step()

        history.append(x.detach().clone().numpy())

        if loss.item() < 1e-6:
            break

    return np.array(history)

# 测试不同优化器
histories = {
    'SGD': test_optimizer(optim.SGD, lr=0.001),
    'SGD+Momentum': test_optimizer(optim.SGD, lr=0.001, momentum=0.9),
    'RMSProp': test_optimizer(optim.RMSprop, lr=0.01),
    'Adam': test_optimizer(optim.Adam, lr=0.01),
}

# 可视化
import numpy as np

x_plot = np.linspace(-0.5, 1.5, 100)
y_plot = np.linspace(-0.5, 1.5, 100)
X, Y = np.meshgrid(x_plot, y_plot)
Z = (1 - X)**2 + 100 * (Y - X**2)**2

plt.figure(figsize=(12, 10))
plt.contour(X, Y, Z, levels=np.logspace(-1, 3, 20), cmap='gray', alpha=0.3)

for name, history in histories.items():
    plt.plot(history[:, 0], history[:, 1], '-o', label=name, markersize=2)

plt.plot(1, 1, 'r*', markersize=15, label='最优点 (1, 1)')
plt.xlabel('x')
plt.ylabel('y')
plt.legend()
plt.title('不同优化器在 Rosenbrock 函数上的表现')
plt.show()

结果分析:

SGD:
  - 最慢,很难收敛
  - 在峡谷中震荡严重
  - 200 步还没到最优点

SGD + Momentum:
  - 比 SGD 快,但还是慢
  - 震荡减少
  - 约 150 步收敛

RMSProp:
  - 快,自适应学习率帮助很大
  - 约 100 步收敛

Adam:
  - 最快!
  - 结合了动量和自适应学习率
  - 约 50 步收敛

2.7 优化器核心对比

关键区别总结:

# SGD: 最基础
θ = θ - lr * grad
# - 学习率:固定,所有参数相同
# - 问题:慢,容易震荡

# Momentum: 累积历史梯度(改变步长)
velocity = β * velocity + grad
θ = θ - lr * velocity
# - 学习率:固定
# - 步长:因动量累积而变化
# - 优势:加速收敛,减少震荡

# RMSProp: 自适应学习率(改变学习率)
s = α * s + (1-α) * grad^2
θ = θ - lr / √s * grad
# - 学习率:自适应变化(梯度大→lr小,梯度小→lr大)
# - 步长:lr/√s 自动调整
# - 优势:给"活跃"参数小心走,给"不活跃"参数大胆走

# Adam: Momentum + RMSProp
m = β1 * m + (11) * grad           ← 动量
v = β2 * v + (12) * grad^2         ← RMSProp
θ = θ - lr * m / √v                  ← 结合
# - 优势:结合两者,最鲁棒

选择指南:

优化器核心机制优点缺点适用场景
SGD基础梯度下降简单,理论保证好慢,需要调学习率传统 CV 任务
SGD+Momentum累积历史(动量)加速,减少震荡仍需调学习率CV fine-tuning
RMSProp自适应学习率不同参数不同lr可能不稳定RNN 训练,峡谷问题
AdamMomentum+RMSProp快,鲁棒,少调参可能泛化稍差大多数任务(首选)
AdamWAdam + 权重衰减更好的正则化-Transformer 训练

推荐:

# 默认选择:Adam
optimizer = optim.Adam(model.parameters(), lr=0.001)

# 计算机视觉(需要更好泛化):SGD + Momentum
optimizer = optim.SGD(model.parameters(), lr=0.1, momentum=0.9, weight_decay=1e-4)

# Transformer 模型:AdamW
optimizer = optim.AdamW(model.parameters(), lr=1e-4, weight_decay=0.01)

小结:

  • SGD:最基础,慢但理论好
  • Momentum:累积历史,加速收敛
  • RMSProp:自适应学习率
  • Adam:结合 Momentum + RMSProp,最流行
  • 选择取决于任务和模型

但固定的学习率可能不是最优的。如何动态调整学习率?

这就是 Part 3 要解答的...


📉 Part 3: 学习率调度

核心思想

训练初期用大学习率快速下降,后期用小学习率精细调整。

3.1 为什么需要学习率调度?

问题:固定学习率的局限

# 固定学习率的问题:
# - 太大:初期快,但后期无法精细调整,在最优点附近震荡
# - 太小:初期慢,训练时间长
# - 理想:初期大(快速下降),后期小(精细调整)

可视化:

import torch
import matplotlib.pyplot as plt

def train_with_lr(lr, epochs=100):
    """用固定学习率训练"""
    x = torch.tensor(0.0, requires_grad=True)
    losses = []

    for epoch in range(epochs):
        loss = (x - 3) ** 2
        loss.backward()

        with torch.no_grad():
            x -= lr * x.grad
            x.grad.zero_()

        losses.append(loss.item())

    return losses

# 测试不同学习率
plt.figure(figsize=(12, 4))

plt.subplot(1, 3, 1)
losses = train_with_lr(0.05)
plt.plot(losses)
plt.title('lr=0.05 (太小)\n收敛慢')
plt.xlabel('Epoch')
plt.ylabel('Loss')
plt.yscale('log')

plt.subplot(1, 3, 2)
losses = train_with_lr(0.3)
plt.plot(losses)
plt.title('lr=0.3 (合适)\n但后期可以更小')
plt.xlabel('Epoch')
plt.ylabel('Loss')
plt.yscale('log')

plt.subplot(1, 3, 3)
losses = train_with_lr(1.2)
plt.plot(losses)
plt.title('lr=1.2 (太大)\n震荡')
plt.xlabel('Epoch')
plt.ylabel('Loss')
plt.yscale('log')

plt.tight_layout()
plt.show()

3.2 StepLR:阶梯式衰减

每隔 N 个 epoch,学习率乘以 gamma

import torch.optim as optim
from torch.optim.lr_scheduler import StepLR

# 模型和优化器
model = LinearModel()
optimizer = optim.SGD(model.parameters(), lr=0.1)

# 学习率调度器
scheduler = StepLR(optimizer, step_size=30, gamma=0.1)
# step_size=30: 每 30 个 epoch
# gamma=0.1: 学习率乘以 0.1

for epoch in range(100):
    # 训练一个 epoch
    train_one_epoch(model, optimizer)

    # 更新学习率
    scheduler.step()

    # 查看当前学习率
    current_lr = optimizer.param_groups[0]['lr']
    print(f"Epoch {epoch}: lr = {current_lr}")

# 输出:
# Epoch 0-29: lr = 0.1
# Epoch 30-59: lr = 0.01
# Epoch 60-89: lr = 0.001
# Epoch 90-99: lr = 0.0001

可视化学习率变化:

def visualize_scheduler(scheduler_fn, epochs=100):
    """可视化学习率调度"""
    model = LinearModel()
    optimizer = optim.SGD(model.parameters(), lr=0.1)
    scheduler = scheduler_fn(optimizer)

    lrs = []
    for epoch in range(epochs):
        lrs.append(optimizer.param_groups[0]['lr'])
        optimizer.step()  # 假装训练
        scheduler.step()

    return lrs

# StepLR
lrs = visualize_scheduler(lambda opt: StepLR(opt, step_size=30, gamma=0.1))
plt.plot(lrs, label='StepLR')
plt.xlabel('Epoch')
plt.ylabel('Learning Rate')
plt.legend()
plt.title('StepLR: 阶梯式衰减')
plt.show()

3.3 ExponentialLR:指数衰减

每个 epoch,学习率乘以 gamma

from torch.optim.lr_scheduler import ExponentialLR

optimizer = optim.SGD(model.parameters(), lr=0.1)
scheduler = ExponentialLR(optimizer, gamma=0.95)
# 每个 epoch: lr = lr * 0.95

# 公式:lr_t = lr_0 * gamma^t
# Epoch 0: lr = 0.1
# Epoch 1: lr = 0.1 * 0.95 = 0.095
# Epoch 2: lr = 0.1 * 0.95^2 = 0.09025
# ...

对比 StepLR 和 ExponentialLR:

plt.figure(figsize=(12, 4))

# StepLR
plt.subplot(1, 2, 1)
lrs = visualize_scheduler(lambda opt: StepLR(opt, step_size=20, gamma=0.5))
plt.plot(lrs)
plt.title('StepLR: 突然下降')
plt.xlabel('Epoch')
plt.ylabel('Learning Rate')

# ExponentialLR
plt.subplot(1, 2, 2)
lrs = visualize_scheduler(lambda opt: ExponentialLR(opt, gamma=0.96))
plt.plot(lrs)
plt.title('ExponentialLR: 平滑下降')
plt.xlabel('Epoch')
plt.ylabel('Learning Rate')

plt.tight_layout()
plt.show()

3.4 CosineAnnealingLR:余弦退火

学习率按余弦曲线下降

from torch.optim.lr_scheduler import CosineAnnealingLR

optimizer = optim.SGD(model.parameters(), lr=0.1)
scheduler = CosineAnnealingLR(optimizer, T_max=100, eta_min=0)
# T_max: 周期长度
# eta_min: 最小学习率

# 公式:
# lr_t = eta_min + (lr_0 - eta_min) * (1 + cos(π * t / T_max)) / 2

特点:

# 优点:
# 1. 开始下降慢(给模型充分时间探索)
# 2. 中期下降快
# 3. 后期下降慢(精细调整)
# 4. 平滑,没有突然的跳变

# 非常适合训练 Transformer 模型

可视化:

lrs = visualize_scheduler(lambda opt: CosineAnnealingLR(opt, T_max=100))
plt.plot(lrs)
plt.title('CosineAnnealingLR: 余弦退火')
plt.xlabel('Epoch')
plt.ylabel('Learning Rate')
plt.grid(True)
plt.show()

3.5 ReduceLROnPlateau:基于指标的自适应调整

当验证集指标不再改善时,降低学习率

from torch.optim.lr_scheduler import ReduceLROnPlateau

optimizer = optim.SGD(model.parameters(), lr=0.1)
scheduler = ReduceLROnPlateau(
    optimizer,
    mode='min',        # 'min': 指标越小越好(loss);'max': 越大越好(accuracy)
    factor=0.1,        # 学习率乘以 factor
    patience=10,       # 等待 10 个 epoch 没有改善
    verbose=True       # 打印信息
)

for epoch in range(100):
    # 训练
    train_loss = train_one_epoch(model, optimizer)

    # 验证
    val_loss = validate(model)

    # 根据验证集损失调整学习率
    scheduler.step(val_loss)  # ← 注意:需要传入指标

    print(f"Epoch {epoch}: train_loss={train_loss:.4f}, val_loss={val_loss:.4f}")

工作原理:

# 记录最佳指标:best = 0.5
# Epoch 10: val_loss = 0.4 → 更新 best = 0.4,重置计数
# Epoch 11: val_loss = 0.42 → 没有改善,计数 = 1
# Epoch 12: val_loss = 0.41 → 没有改善,计数 = 2
# ...
# Epoch 20: val_loss = 0.43 → 计数 = 10 → 降低学习率!lr = lr * 0.1

3.6 Warmup:预热策略

训练初期逐渐增加学习率

# 为什么需要 Warmup?
# 1. 初始参数随机,梯度可能很大
# 2. 大学习率 + 大梯度 → 可能导致梯度爆炸
# 3. Warmup:初期用小学习率,逐渐增加到目标学习率

class WarmupScheduler:
    def __init__(self, optimizer, warmup_epochs, base_lr, target_lr):
        self.optimizer = optimizer
        self.warmup_epochs = warmup_epochs
        self.base_lr = base_lr
        self.target_lr = target_lr
        self.current_epoch = 0

    def step(self):
        if self.current_epoch < self.warmup_epochs:
            # Warmup 阶段:线性增加
            lr = self.base_lr + (self.target_lr - self.base_lr) * \
                 self.current_epoch / self.warmup_epochs
        else:
            # Warmup 后:保持目标学习率
            lr = self.target_lr

        for param_group in self.optimizer.param_groups:
            param_group['lr'] = lr

        self.current_epoch += 1

# 使用
optimizer = optim.Adam(model.parameters(), lr=0.0001)
scheduler = WarmupScheduler(optimizer, warmup_epochs=10, base_lr=1e-6, target_lr=1e-3)

for epoch in range(100):
    scheduler.step()
    train_one_epoch(model, optimizer)

    print(f"Epoch {epoch}: lr = {optimizer.param_groups[0]['lr']:.6f}")

可视化 Warmup:

def get_warmup_cosine_schedule(warmup_epochs, total_epochs, base_lr, max_lr, min_lr):
    """Warmup + Cosine Annealing"""
    lrs = []

    for epoch in range(total_epochs):
        if epoch < warmup_epochs:
            # Warmup: 线性增加
            lr = base_lr + (max_lr - base_lr) * epoch / warmup_epochs
        else:
            # Cosine Annealing
            progress = (epoch - warmup_epochs) / (total_epochs - warmup_epochs)
            lr = min_lr + (max_lr - min_lr) * (1 + np.cos(np.pi * progress)) / 2

        lrs.append(lr)

    return lrs

lrs = get_warmup_cosine_schedule(
    warmup_epochs=10,
    total_epochs=100,
    base_lr=1e-6,
    max_lr=1e-3,
    min_lr=1e-5
)

plt.plot(lrs)
plt.axvline(x=10, color='r', linestyle='--', label='Warmup 结束')
plt.xlabel('Epoch')
plt.ylabel('Learning Rate')
plt.legend()
plt.title('Warmup + Cosine Annealing')
plt.grid(True)
plt.show()

3.7 学习率调度器对比

import numpy as np
import matplotlib.pyplot as plt

epochs = 100
schedulers = {
    'StepLR': lambda opt: StepLR(opt, step_size=30, gamma=0.1),
    'ExponentialLR': lambda opt: ExponentialLR(opt, gamma=0.96),
    'CosineAnnealingLR': lambda opt: CosineAnnealingLR(opt, T_max=100),
}

plt.figure(figsize=(15, 4))

for i, (name, scheduler_fn) in enumerate(schedulers.items(), 1):
    lrs = visualize_scheduler(scheduler_fn, epochs)

    plt.subplot(1, 3, i)
    plt.plot(lrs, linewidth=2)
    plt.title(name)
    plt.xlabel('Epoch')
    plt.ylabel('Learning Rate')
    plt.grid(True)

plt.tight_layout()
plt.show()

3.8 实际建议

选择学习率调度器:

# 1. 图像分类(ResNet, VGG):
#    StepLR 或多步 MultiStepLR
optimizer = optim.SGD(model.parameters(), lr=0.1, momentum=0.9)
scheduler = optim.lr_scheduler.MultiStepLR(optimizer, milestones=[30, 60, 90], gamma=0.1)

# 2. Transformer 模型(BERT, GPT):
#    Warmup + Cosine Annealing
optimizer = optim.AdamW(model.parameters(), lr=1e-4)
scheduler = get_warmup_cosine_schedule(...)  # 自定义

# 3. 不确定 / 实验阶段:
#    ReduceLROnPlateau(自适应)
optimizer = optim.Adam(model.parameters(), lr=0.001)
scheduler = ReduceLROnPlateau(optimizer, patience=10)

# 4. 小数据集 / Fine-tuning:
#    小的固定学习率 + ExponentialLR
optimizer = optim.Adam(model.parameters(), lr=1e-5)
scheduler = ExponentialLR(optimizer, gamma=0.98)

小结:

  • StepLR:阶梯式,简单粗暴
  • ExponentialLR:指数衰减,平滑
  • CosineAnnealingLR:余弦曲线,平滑且后期精细
  • ReduceLROnPlateau:自适应,基于验证集
  • Warmup:初期小学习率,避免梯度爆炸

有了优化器和学习率调度,还需要一个目标函数来优化!

这就是 Part 4 要介绍的...


🎯 Part 4: 损失函数

核心作用

损失函数定义了"什么是好的模型",是训练的优化目标。

4.1 回归任务:MSE Loss

均方误差(Mean Squared Error):

import torch
import torch.nn as nn

# 手动计算
y_pred = torch.tensor([1.0, 2.0, 3.0])
y_true = torch.tensor([1.5, 2.5, 2.5])

mse = ((y_pred - y_true) ** 2).mean()
print(f"MSE = {mse}")  # 0.1667

# 使用 PyTorch
criterion = nn.MSELoss()
loss = criterion(y_pred, y_true)
print(f"MSE = {loss}")  # 0.1667

公式:

MSE = (1/N) * Σ(y_pred - y_true)^2

特点:
- 对离群点敏感(平方放大误差)
- 可微分
- 适合回归任务

例子:房价预测

# 模型
model = nn.Linear(10, 1)  # 10 个特征 → 1 个输出(价格)

# 损失函数
criterion = nn.MSELoss()

# 训练
for epoch in range(100):
    # 前向传播
    y_pred = model(x_train)  # 预测价格
    loss = criterion(y_pred, y_train)  # 与真实价格的 MSE

    # 反向传播
    optimizer.zero_grad()
    loss.backward()
    optimizer.step()

4.2 分类任务:CrossEntropyLoss

交叉熵损失:

# 多分类问题(如 MNIST 0-9 数字识别)
# 输出:logits(未归一化的分数)
# 标签:类别索引

# 例子:3 个样本,4 个类别
logits = torch.tensor([
    [2.0, 1.0, 0.1, 0.2],  # 样本 0:预测类别 0
    [0.5, 2.5, 0.2, 0.3],  # 样本 1:预测类别 1
    [0.1, 0.2, 0.3, 3.0],  # 样本 2:预测类别 3
])
targets = torch.tensor([0, 1, 3])  # 真实标签

criterion = nn.CrossEntropyLoss()
loss = criterion(logits, targets)
print(f"Loss = {loss}")

公式:

# CrossEntropyLoss = Softmax + NLLLoss

# 步骤 1:Softmax(归一化)
probs = torch.softmax(logits, dim=1)
# [[0.73, 0.27, 0.00, 0.00],
#  [0.12, 0.88, 0.00, 0.00],
#  [0.04, 0.04, 0.05, 0.87]]

# 步骤 2:取对数
log_probs = torch.log(probs)

# 步骤 3:选择正确类别的负对数概率
nll_loss = -log_probs[range(len(targets)), targets].mean()

# 等价于:
criterion = nn.CrossEntropyLoss()
loss = criterion(logits, targets)

为什么用交叉熵?

# 1. 概率解释
# 模型输出概率分布 P,真实分布 Q(one-hot)
# 交叉熵衡量两个分布的差异

# 2. 梯度性质好
# Softmax + Cross Entropy 的梯度形式简洁:
# dL/dz = prob - target
# 例如:prob=[0.7, 0.2, 0.1], target=[1, 0, 0]
#       grad=[0.7-1, 0.2-0, 0.1-0] = [-0.3, 0.2, 0.1]

# 3. 惩罚错误预测
# 预测越错,损失越大(对数放大)

注意事项:

# ⚠️ CrossEntropyLoss 期望:
# - 输入:logits(原始分数),不需要 softmax
# - 标签:类别索引(0, 1, 2, ...),不是 one-hot

# ❌ 错误
logits = torch.randn(10, 5)
probs = torch.softmax(logits, dim=1)  # ← 不要 softmax
loss = nn.CrossEntropyLoss()(probs, targets)  # 错误!

# ✅ 正确
logits = torch.randn(10, 5)
loss = nn.CrossEntropyLoss()(logits, targets)  # 直接用 logits

4.3 二分类:BCELoss

Binary Cross Entropy:二元交叉熵

# 二分类:0 或 1
# 例如:猫狗分类、垃圾邮件检测

# 输出:经过 sigmoid 的概率
y_pred = torch.tensor([0.9, 0.2, 0.8, 0.1])  # 预测概率
y_true = torch.tensor([1.0, 0.0, 1.0, 0.0])  # 真实标签

criterion = nn.BCELoss()
loss = criterion(y_pred, y_true)
print(f"BCE Loss = {loss}")

公式:

# BCE = -[y*log(p) + (1-y)*log(1-p)]

# 手动计算
bce = -(y_true * torch.log(y_pred) + (1 - y_true) * torch.log(1 - y_pred)).mean()

BCEWithLogitsLoss:更稳定的版本

# 问题:sigmoid 可能输出接近 0 或 1
# log(0) = -inf → 数值不稳定

# 解决:将 sigmoid 和 BCE 结合
# BCEWithLogitsLoss = Sigmoid + BCELoss

logits = torch.tensor([2.0, -1.5, 1.8, -2.0])  # 未归一化的分数
y_true = torch.tensor([1.0, 0.0, 1.0, 0.0])

criterion = nn.BCEWithLogitsLoss()
loss = criterion(logits, y_true)

# 等价于(但更稳定):
# probs = torch.sigmoid(logits)
# loss = nn.BCELoss()(probs, y_true)

4.4 自定义损失函数

# 场景:需要特殊的损失函数

class CustomLoss(nn.Module):
    def __init__(self, alpha=0.5):
        super().__init__()
        self.alpha = alpha

    def forward(self, y_pred, y_true):
        """自定义损失:MSE + L1"""
        mse = ((y_pred - y_true) ** 2).mean()
        l1 = torch.abs(y_pred - y_true).mean()
        return self.alpha * mse + (1 - self.alpha) * l1

# 使用
criterion = CustomLoss(alpha=0.7)
loss = criterion(y_pred, y_true)
loss.backward()

实际例子:Focal Loss(用于不平衡分类)

class FocalLoss(nn.Module):
    """
    Focal Loss:解决类别不平衡问题
    论文:Focal Loss for Dense Object Detection
    """
    def __init__(self, alpha=0.25, gamma=2.0):
        super().__init__()
        self.alpha = alpha
        self.gamma = gamma

    def forward(self, logits, targets):
        # 计算概率
        probs = torch.softmax(logits, dim=1)

        # 选择正确类别的概率
        pt = probs[range(len(targets)), targets]

        # Focal Loss
        focal_weight = (1 - pt) ** self.gamma
        ce_loss = -torch.log(pt)
        loss = self.alpha * focal_weight * ce_loss

        return loss.mean()

# 使用
criterion = FocalLoss(alpha=0.25, gamma=2.0)
loss = criterion(logits, targets)

4.5 损失函数选择指南

任务类型损失函数输出层示例
回归MSELoss线性层房价预测
多分类CrossEntropyLoss线性层(logits)MNIST
二分类BCEWithLogitsLoss线性层(logits)猫狗分类
多标签BCEWithLogitsLoss线性层(logits)图像标签
语义分割CrossEntropyLossConv(pixel-wise)像素分类

常见错误:

# ❌ 错误 1:CrossEntropyLoss 用了 softmax
model = nn.Sequential(
    nn.Linear(10, 5),
    nn.Softmax(dim=1)  # ← 不要!
)
loss = nn.CrossEntropyLoss()(model(x), y)  # CrossEntropyLoss 内部会做 softmax

# ✅ 正确
model = nn.Linear(10, 5)  # 输出 logits
loss = nn.CrossEntropyLoss()(model(x), y)

# ❌ 错误 2:BCE 忘记 sigmoid
model = nn.Linear(10, 1)
loss = nn.BCELoss()(model(x), y)  # model(x) 不在 [0, 1]!

# ✅ 正确
model = nn.Sequential(nn.Linear(10, 1), nn.Sigmoid())
loss = nn.BCELoss()(model(x), y)
# 或者用 BCEWithLogitsLoss
model = nn.Linear(10, 1)
loss = nn.BCEWithLogitsLoss()(model(x), y)

小结:

  • MSELoss:回归任务
  • CrossEntropyLoss:多分类(内含 Softmax)
  • BCEWithLogitsLoss:二分类(内含 Sigmoid)
  • 可以自定义损失函数
  • 注意输出层和损失函数的匹配

现在我们有了所有组件:模型、优化器、学习率调度、损失函数。如何组合成完整的训练循环?

这就是 Part 5 要完成的...


🔄 Part 5: 完整训练循环

核心流程

数据加载 → 前向传播 → 计算损失 → 反向传播 → 更新参数 → 验证 → 保存模型

5.1 保存和加载模型

# 方法 1:保存整个模型
torch.save(model, 'model.pth')
model = torch.load('model.pth')

# 方法 2:只保存参数(推荐)
torch.save(model.state_dict(), 'model_weights.pth')
model.load_state_dict(torch.load('model_weights.pth'))

# 方法 3:保存训练状态(可恢复训练)
checkpoint = {
    'epoch': epoch,
    'model_state_dict': model.state_dict(),
    'optimizer_state_dict': optimizer.state_dict(),
    'loss': loss,
    'scheduler_state_dict': scheduler.state_dict(),
}
torch.save(checkpoint, 'checkpoint.pth')

# 恢复
checkpoint = torch.load('checkpoint.pth')
model.load_state_dict(checkpoint['model_state_dict'])
optimizer.load_state_dict(checkpoint['optimizer_state_dict'])
scheduler.load_state_dict(checkpoint['scheduler_state_dict'])
start_epoch = checkpoint['epoch'] + 1

5.2 Early Stopping(早停)

概念: 当验证集性能不再提升时,提前停止训练,防止过拟合。

工作原理:

  • 监控验证集指标(如验证损失、准确率)
  • 记录最佳性能和当前等待轮数(patience counter)
  • 如果连续N个epoch没有改善,停止训练

例子:

Epoch 10: val_loss = 0.5 → 记录最佳 = 0.5,计数器 = 0
Epoch 11: val_loss = 0.52 → 没改善,计数器 = 1
Epoch 12: val_loss = 0.51 → 没改善,计数器 = 2
...
Epoch 20: val_loss = 0.53 → 计数器 = 10 → 触发早停!

关键参数:

  • patience: 等待轮数(如10个epoch)
  • min_delta: 认为是改善的最小变化(如0.001)

适用场景:

  • 防止过拟合
  • 节省训练时间
  • 自动找到最佳训练时长

5.3 常用训练技巧

1. 梯度裁剪(Gradient Clipping)

问题: 梯度爆炸导致训练不稳定(损失突然变成NaN或Inf)

解决: 限制梯度的最大范数(norm)

如果 ||grad|| > max_norm:
    grad = grad * (max_norm / ||grad||)

效果:

  • 防止梯度爆炸
  • 稳定深层网络训练
  • 常用于RNN/Transformer

典型值: max_norm = 1.05.0


2. 混合精度训练(Mixed Precision)

概念: 部分计算用FP16(半精度),部分用FP32(全精度)

优势:

  • 速度快2倍:FP16计算更快
  • 显存省一半:FP16占用空间小
  • 精度不降:关键部分仍用FP32

原理:

前向传播 → FP16(快)
损失计算 → FP32(精确)
反向传播 → FP16(快)+ 梯度缩放(防止underflow)
参数更新 → FP32(精确)

适用: GPU训练(需要Tensor Core支持)


3. 学习率查找(LR Finder)

问题: 不知道设置多大的学习率

方法:

  1. 从很小的学习率开始(如1e-8)
  2. 每个batch指数增长学习率
  3. 记录每个学习率下的损失
  4. 绘制学习率-损失曲线

如何选择:

Loss
  |     ╱───────
  |    ╱        (损失不再下降,lr太大)
  |   ╱
  |  │ ← 选这里(损失下降最快的点)
  | ╱
  |╱____________ lr (log scale)
    ↑
    损失开始下降

推荐学习率: 损失下降最快处 ÷ 10


小结:

  • 标准训练循环:train → validate → update scheduler → save best model
  • 保存模型:state_dict() 更灵活
  • Early Stopping:验证集不改善时提前停止
  • 梯度裁剪:限制梯度范数,防止爆炸
  • 混合精度:FP16+FP32,加速训练
  • LR Finder:自动找最佳学习率

🔍 Part 6: 常见训练问题与解决方案

核心目标:快速诊断和解决训练中的常见问题

6.1 损失不下降

症状: 训练损失一直很高,不下降或下降极慢

可能原因与解决方案:

  1. 学习率太小 → 增大学习率(试试0.001 → 0.01)
  2. 学习率太大 → 损失震荡或NaN → 减小学习率(试试0.01 → 0.001)
  3. 梯度消失 → 使用ReLU激活函数,添加BatchNorm,检查梯度范数
  4. 数据未归一化 → 使用transforms.Normalize标准化输入
  5. 初始化不好 → 使用Xavier或He初始化
  6. 标签错误 → 检查标签分布是否均匀

6.2 过拟合

症状: 训练准确率高,测试准确率低(差距>5%)

解决方案:

  1. 增加Dropout → 试试0.3-0.7的dropout率
  2. 添加L2正则化optimizer = Adam(params, weight_decay=1e-4)
  3. 数据增强 → RandomRotation、RandomCrop、RandomFlip等
  4. Early Stopping → 验证集不改善时停止训练
  5. 减少模型复杂度 → 减少层数或神经元数量
  6. 增加训练数据 → 收集更多数据或使用数据增强

6.3 欠拟合

症状: 训练和测试准确率都低

解决方案:

  1. 增加模型复杂度 → 增加层数、增加神经元数量
  2. 增加训练时间 → 训练更多epoch
  3. 增大学习率 → 加快收敛速度
  4. 减少正则化 → 减少Dropout、减少weight_decay
  5. 检查数据质量 → 数据是否有噪声?特征是否足够?

6.4 梯度爆炸

症状: 损失突然变成NaN或Inf,梯度范数突然很大

解决方案:

  1. 梯度裁剪torch.nn.utils.clip_grad_norm_(model.parameters(), max_norm=1.0)
  2. 降低学习率 → 从0.01降到0.001或更小
  3. 使用BatchNorm → 稳定梯度传播
  4. 检查初始化 → 使用Kaiming/Xavier初始化

6.5 训练太慢

解决方案:

  1. 使用GPUmodel.to('cuda')
  2. 增大batch size → 128 → 256(充分利用GPU)
  3. 混合精度训练 → 使用FP16,加速2倍
  4. 多线程数据加载DataLoader(num_workers=4, pin_memory=True)
  5. 减小模型 → 减少不必要的层和参数

6.6 显存不足(Out of Memory)

解决方案:

  1. 减小batch size → 256 → 128 → 64
  2. 梯度累积 → 多个小batch累积后再更新
  3. 梯度检查点 → 牺牲计算换显存
  4. 释放中间变量del output; torch.cuda.empty_cache()
  5. 使用混合精度 → FP16占用显存更少