Skip to content

第 8 章 损失函数与优化器

学习目标

  • 理解「损失函数」衡量预测与真实的差距
  • 掌握回归用 MSELoss、分类用 CrossEntropyLoss
  • 掌握优化器 SGDAdam 与学习率 lr
  • 掌握训练循环五步:前向、算损失、清零梯度、反向、更新
  • 能独立写出并运行一个完整的最小训练循环

8.1 损失函数:模型错得有多离谱

损失函数(loss function)接收「预测值」和「真实值」,输出一个标量,衡量两者差距。损失越小,预测越接近真实。

回归任务(预测连续数值,如温度、房价)用 MSELoss(均方误差):

python
import torch
import torch.nn as nn

pred = torch.tensor([2.0, 3.0, 4.0])
true = torch.tensor([1.0, 4.0, 5.0])
loss_fn = nn.MSELoss()
print(loss_fn(pred, true).item())

输出:

1.0

均方误差 = ((2-1)² + (3-4)² + (4-5)²) / 3 = 1.0。平方让大误差被放大,也让损失函数可导。

分类任务(预测类别,如图片是猫还是狗)用 CrossEntropyLoss(交叉熵)。它有两个要求:输入是原始 logits(网络最后一层不加 Softmax),目标是整数类别:

python
import torch
import torch.nn as nn

logits = torch.tensor([[2.0, 0.5],   # 样本 1:更偏向类别 0
                       [0.2, 1.8]])  # 样本 2:更偏向类别 1
targets = torch.tensor([0, 1])
loss_fn = nn.CrossEntropyLoss()
print(loss_fn(logits, targets).item())

输出:

0.19265705347061157

交叉熵奖励「正确的类分数高」:样本 1 的类别 0 得分 2.0 高于 0.5,样本 2 的类别 1 得分 1.8 高于 0.2,损失很小。如果预测反过来,损失会大很多。

二分类还有一个常用选择 BCEWithLogitsLoss,它接收一个 logit 和 0/1 标签。本书以回归和多分类为主线,先记住:回归用 MSE,分类用交叉熵,不要混用

8.2 优化器:怎么更新参数

优化器(optimizer)按梯度更新参数。最简单的是 SGD(随机梯度下降):

新参数 = 旧参数 - 学习率 × 梯度

学习率(learning rate, lr)是每次更新的步长。太大一步跨过最低点、震荡不收敛;太小收敛极慢。常见取值在 0.001 ~ 0.1 之间,Adam 常用 0.001

创建优化器时把参数列表传进去:

python
import torch
import torch.nn as nn

model = nn.Linear(1, 1)
optimizer = torch.optim.SGD(model.parameters(), lr=0.01)

Adam 是更先进的优化器,自动调整每个参数的学习步长,新手用它通常收敛更稳:

python
optimizer = torch.optim.Adam(model.parameters(), lr=0.01)

两者调用方式完全一样,区别只在内部更新规则。

8.3 训练循环五步

一轮训练(一个 epoch)固定五步,顺序不能乱:

python
pred = model(x)            # 1. 前向:算出预测
loss = loss_fn(pred, y)    # 2. 算损失
optimizer.zero_grad()      # 3. 清零梯度(重要!)
loss.backward()            # 4. 反向传播:算梯度
optimizer.step()           # 5. 按梯度更新参数

第 3 步最容易忘。第 6 章讲过梯度会累积,不清零的话,这一轮会加上上一轮的梯度,参数更新方向就错了。

8.4 完整示例:拟合 y = 2x + 1

合成数据(带一点噪声),训练一个 Linear(1, 1)y ≈ 2x + 1 学出来:

python
import torch
import torch.nn as nn

torch.manual_seed(0)
x = torch.linspace(0, 10, 100).reshape(-1, 1)
y = 2 * x + 1 + 0.1 * torch.randn_like(x)

model = nn.Linear(1, 1)
loss_fn = nn.MSELoss()
optimizer = torch.optim.SGD(model.parameters(), lr=0.01)

for epoch in range(1001):
    pred = model(x)
    loss = loss_fn(pred, y)
    optimizer.zero_grad()
    loss.backward()
    optimizer.step()
    if epoch % 200 == 0:
        print(f"epoch {epoch}: loss = {loss.item():.6f}")

print("weight:", model.weight.item())
print("bias:", model.bias.item())

输出:

epoch 0: loss = 191.920120
epoch 200: loss = 0.023265
epoch 400: loss = 0.012240
epoch 600: loss = 0.010733
epoch 800: loss = 0.010527
epoch 1000: loss = 0.010499
weight: 2.0005998611450195
bias: 0.9996812343597412

解读:

  • 损失从 191.92 一路降到约 0.0105,收敛;
  • 学到的 weight ≈ 2.0006bias ≈ 0.9997,与真实 y = 2x + 1 吻合;
  • 损失最后停在约 0.01 而不是 0,是因为数据带了噪声,模型已经「学到了规律、剩下的误差就是噪声」。

SGD 换成 Adam 重跑一遍,观察损失下降速度——感受优化器差异。

8.5 学习率的影响

把上例的 lr 改成 0.5,损失会在 191 附近震荡不下降;改成 0.00001,下降极慢。改完再改回来,记住经验:学习率不对,损失曲线会说话——震荡是太大,龟速是太小。

动手实践

  1. 手算 nn.MSELoss()(torch.tensor([1., 2., 3.]), torch.tensor([2., 2., 2.])) 的结果,再运行验证(答案:(1+0+1)/3≈0.6667)。
  2. 把 8.4 的完整示例复制到文件运行,记录 loss 从 epoch 0 到 1000 的变化;把 lr 改成 0.5 再跑,观察损失是否收敛。
  3. 把 8.4 的优化器换成 Adam(lr=0.01),对比前 200 轮损失的下降速度。
  4. 故意删掉 optimizer.zero_grad(),运行并观察损失变化,用自己的话解释发生了什么。
  5. 把损失函数换成 nn.L1Loss() 重跑 8.4,观察收敛后的损失量级,并说出 MSE 与 L1 的差异(提示:对离群点的惩罚)。

常见错误

错误写法现象原因
忘记 optimizer.zero_grad()参数更新乱跳、损失不降梯度累积到多轮,更新方向错误
optimizer.step() 写在 loss.backward() 前面参数永远不更新先算梯度才能用梯度更新
backward() 后不 step()梯度算了但不更新五步缺一不可
分类任务用 MSELoss收敛差、语义错分类用 CrossEntropyLoss(输入 logits + 整数标签)
CrossEntropyLoss 输入已经过 Softmax损失偏大、梯度异常交叉熵内部自带 Softmax,传原始 logits
打印 loss 直接拼字符串TypeError 或出现 tensor(...)loss.item() 转 Python 数字

章末练习

基础

  1. 写一个函数 mse(pred, true),手动实现均方误差(逐元素相减、平方、求平均),并与 nn.MSELoss() 的结果对比。
  2. 搭一个 Linear(1, 1),用 SGD(lr=0.01)MSELoss 训练 200 轮,拟合 x = torch.linspace(0, 1, 50),目标 y = 3x - 1,输出最终 loss 与参数。
  3. 列出训练循环五步,并按顺序写出代码。

提高

  1. 解释为什么 optimizer.zero_grad() 必须在 backward() 之前调用;用第 6 章「梯度累积」的知识说明。
  2. 用同一个模型分别跑 SGD(lr=0.01)Adam(lr=0.01) 各 100 轮,打印两者最终 loss,解释差异(不要求原理细节)。

挑战

  1. 固定种子后,用不同学习率 [1.0, 0.1, 0.01, 0.0001] 各训练 200 轮,记录每组的最终 loss,分析学习率与收敛的关系。
  2. 对分类 logits torch.tensor([[1.0, 3.0], [3.0, 1.0]]) 和标签 [1, 0],手算 CrossEntropyLoss 的两个样本损失并验证(提示:softmax 后取正确类的负对数)。

章末自测

每题选择一个最佳答案。本书不附答案:完成后交由老师或 AI 老师批改讲解。

  1. 回归任务通常使用哪个损失函数?
    • A. CrossEntropyLoss
    • B. MSELoss
    • C. BCELoss
    • D. L1Loss(也常见,但默认首选是 B)
  2. 多分类任务通常使用哪个损失函数?
    • A. MSELoss
    • B. CrossEntropyLoss
    • C. L1Loss
    • D. HingeLoss
  3. CrossEntropyLoss 的输入要求是?
    • A. Softmax 后的概率
    • B. 原始 logits + 整数类别标签
    • C. 归一化后的特征
    • D. 0~1 之间的浮点标签
  4. 训练循环中,optimizer.zero_grad() 应该在什么时候调用?
    • A. backward() 之后
    • B. step() 之后
    • C. backward() 之前
    • D. 任意位置
  5. 学习率过大可能导致?
    • A. 收敛变慢
    • B. 损失震荡甚至发散
    • C. 梯度消失
    • D. 没有任何影响
  6. 学习率过小可能导致?
    • A. 收敛变快
    • B. 收敛极慢甚至看起来不动
    • C. 损失震荡
    • D. 直接报错
  7. optimizer.step() 的作用是?
    • A. 计算损失
    • B. 按梯度更新参数
    • C. 清零梯度
    • D. 创建模型
  8. 忘记 zero_grad() 的后果是?
    • A. 梯度丢失
    • B. 梯度跨轮累积,更新方向错误
    • C. 程序崩溃
    • D. 没有后果
  9. 训练结束后打印 loss 的正确写法是?
    • A. print(loss)
    • B. print(loss.item())
    • C. print(loss.value)
    • D. print(float(loss.data)) 也可以,但最规范的是 B
  10. AdamSGD 的关系是?
    • A. 完全相同的算法
    • B. 都是优化器,Adam 自动调整学习步长
    • C. Adam 是损失函数
    • D. SGD 不能用于 PyTorch