PyTorch 架构级学习系列 - 第 4 篇
本文将深入优化器的核心原理。你将理解不同优化算法的工作机制,学习率调度的策略,以及如何构建一个完整的训练循环。
📚 目录
- 梯度下降的本质:从数学到代码
- 优化器家族:SGD、Momentum、Adam 的原理
- 学习率调度:如何动态调整学习率
- 损失函数:训练的目标
- 完整训练循环:从数据到模型
- 常见训练问题与解决方案
🎯 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 + (1-β1) * grad ← 动量
v = β2 * v + (1-β2) * grad^2 ← RMSProp
θ = θ - lr * m / √v ← 结合
# - 优势:结合两者,最鲁棒
选择指南:
| 优化器 | 核心机制 | 优点 | 缺点 | 适用场景 |
|---|---|---|---|---|
| SGD | 基础梯度下降 | 简单,理论保证好 | 慢,需要调学习率 | 传统 CV 任务 |
| SGD+Momentum | 累积历史(动量) | 加速,减少震荡 | 仍需调学习率 | CV fine-tuning |
| RMSProp | 自适应学习率 | 不同参数不同lr | 可能不稳定 | RNN 训练,峡谷问题 |
| Adam | Momentum+RMSProp | 快,鲁棒,少调参 | 可能泛化稍差 | 大多数任务(首选) |
| AdamW | Adam + 权重衰减 | 更好的正则化 | - | 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) | 图像标签 |
| 语义分割 | CrossEntropyLoss | Conv(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.0 或 5.0
2. 混合精度训练(Mixed Precision)
概念: 部分计算用FP16(半精度),部分用FP32(全精度)
优势:
- 速度快2倍:FP16计算更快
- 显存省一半:FP16占用空间小
- 精度不降:关键部分仍用FP32
原理:
前向传播 → FP16(快)
损失计算 → FP32(精确)
反向传播 → FP16(快)+ 梯度缩放(防止underflow)
参数更新 → FP32(精确)
适用: GPU训练(需要Tensor Core支持)
3. 学习率查找(LR Finder)
问题: 不知道设置多大的学习率
方法:
- 从很小的学习率开始(如1e-8)
- 每个batch指数增长学习率
- 记录每个学习率下的损失
- 绘制学习率-损失曲线
如何选择:
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 损失不下降
症状: 训练损失一直很高,不下降或下降极慢
可能原因与解决方案:
- 学习率太小 → 增大学习率(试试0.001 → 0.01)
- 学习率太大 → 损失震荡或NaN → 减小学习率(试试0.01 → 0.001)
- 梯度消失 → 使用ReLU激活函数,添加BatchNorm,检查梯度范数
- 数据未归一化 → 使用
transforms.Normalize标准化输入 - 初始化不好 → 使用Xavier或He初始化
- 标签错误 → 检查标签分布是否均匀
6.2 过拟合
症状: 训练准确率高,测试准确率低(差距>5%)
解决方案:
- 增加Dropout → 试试0.3-0.7的dropout率
- 添加L2正则化 →
optimizer = Adam(params, weight_decay=1e-4) - 数据增强 → RandomRotation、RandomCrop、RandomFlip等
- Early Stopping → 验证集不改善时停止训练
- 减少模型复杂度 → 减少层数或神经元数量
- 增加训练数据 → 收集更多数据或使用数据增强
6.3 欠拟合
症状: 训练和测试准确率都低
解决方案:
- 增加模型复杂度 → 增加层数、增加神经元数量
- 增加训练时间 → 训练更多epoch
- 增大学习率 → 加快收敛速度
- 减少正则化 → 减少Dropout、减少weight_decay
- 检查数据质量 → 数据是否有噪声?特征是否足够?
6.4 梯度爆炸
症状: 损失突然变成NaN或Inf,梯度范数突然很大
解决方案:
- 梯度裁剪 →
torch.nn.utils.clip_grad_norm_(model.parameters(), max_norm=1.0) - 降低学习率 → 从0.01降到0.001或更小
- 使用BatchNorm → 稳定梯度传播
- 检查初始化 → 使用Kaiming/Xavier初始化
6.5 训练太慢
解决方案:
- 使用GPU →
model.to('cuda') - 增大batch size → 128 → 256(充分利用GPU)
- 混合精度训练 → 使用FP16,加速2倍
- 多线程数据加载 →
DataLoader(num_workers=4, pin_memory=True) - 减小模型 → 减少不必要的层和参数
6.6 显存不足(Out of Memory)
解决方案:
- 减小batch size → 256 → 128 → 64
- 梯度累积 → 多个小batch累积后再更新
- 梯度检查点 → 牺牲计算换显存
- 释放中间变量 →
del output; torch.cuda.empty_cache() - 使用混合精度 → FP16占用显存更少