第 8 章 损失函数与优化器
学习目标
- 理解「损失函数」衡量预测与真实的差距
- 掌握回归用
MSELoss、分类用CrossEntropyLoss - 掌握优化器
SGD、Adam与学习率lr - 掌握训练循环五步:前向、算损失、清零梯度、反向、更新
- 能独立写出并运行一个完整的最小训练循环
8.1 损失函数:模型错得有多离谱
损失函数(loss function)接收「预测值」和「真实值」,输出一个标量,衡量两者差距。损失越小,预测越接近真实。
回归任务(预测连续数值,如温度、房价)用 MSELoss(均方误差):
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),目标是整数类别:
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。
创建优化器时把参数列表传进去:
import torch
import torch.nn as nn
model = nn.Linear(1, 1)
optimizer = torch.optim.SGD(model.parameters(), lr=0.01)Adam 是更先进的优化器,自动调整每个参数的学习步长,新手用它通常收敛更稳:
optimizer = torch.optim.Adam(model.parameters(), lr=0.01)两者调用方式完全一样,区别只在内部更新规则。
8.3 训练循环五步
一轮训练(一个 epoch)固定五步,顺序不能乱:
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 学出来:
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.0006、bias ≈ 0.9997,与真实y = 2x + 1吻合; - 损失最后停在约 0.01 而不是 0,是因为数据带了噪声,模型已经「学到了规律、剩下的误差就是噪声」。
把 SGD 换成 Adam 重跑一遍,观察损失下降速度——感受优化器差异。
8.5 学习率的影响
把上例的 lr 改成 0.5,损失会在 191 附近震荡不下降;改成 0.00001,下降极慢。改完再改回来,记住经验:学习率不对,损失曲线会说话——震荡是太大,龟速是太小。
动手实践
- 手算
nn.MSELoss()(torch.tensor([1., 2., 3.]), torch.tensor([2., 2., 2.]))的结果,再运行验证(答案:(1+0+1)/3≈0.6667)。 - 把 8.4 的完整示例复制到文件运行,记录 loss 从 epoch 0 到 1000 的变化;把
lr改成 0.5 再跑,观察损失是否收敛。 - 把 8.4 的优化器换成
Adam(lr=0.01),对比前 200 轮损失的下降速度。 - 故意删掉
optimizer.zero_grad(),运行并观察损失变化,用自己的话解释发生了什么。 - 把损失函数换成
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 数字 |
章末练习
基础
- 写一个函数
mse(pred, true),手动实现均方误差(逐元素相减、平方、求平均),并与nn.MSELoss()的结果对比。 - 搭一个
Linear(1, 1),用SGD(lr=0.01)和MSELoss训练 200 轮,拟合x = torch.linspace(0, 1, 50),目标y = 3x - 1,输出最终 loss 与参数。 - 列出训练循环五步,并按顺序写出代码。
提高
- 解释为什么
optimizer.zero_grad()必须在backward()之前调用;用第 6 章「梯度累积」的知识说明。 - 用同一个模型分别跑
SGD(lr=0.01)和Adam(lr=0.01)各 100 轮,打印两者最终 loss,解释差异(不要求原理细节)。
挑战
- 固定种子后,用不同学习率
[1.0, 0.1, 0.01, 0.0001]各训练 200 轮,记录每组的最终 loss,分析学习率与收敛的关系。 - 对分类 logits
torch.tensor([[1.0, 3.0], [3.0, 1.0]])和标签[1, 0],手算CrossEntropyLoss的两个样本损失并验证(提示:softmax 后取正确类的负对数)。
章末自测
每题选择一个最佳答案。本书不附答案:完成后交由老师或 AI 老师批改讲解。
- 回归任务通常使用哪个损失函数?
- A.
CrossEntropyLoss - B.
MSELoss - C.
BCELoss - D.
L1Loss(也常见,但默认首选是 B)
- A.
- 多分类任务通常使用哪个损失函数?
- A.
MSELoss - B.
CrossEntropyLoss - C.
L1Loss - D.
HingeLoss
- A.
CrossEntropyLoss的输入要求是?- A. Softmax 后的概率
- B. 原始 logits + 整数类别标签
- C. 归一化后的特征
- D. 0~1 之间的浮点标签
- 训练循环中,
optimizer.zero_grad()应该在什么时候调用?- A.
backward()之后 - B.
step()之后 - C.
backward()之前 - D. 任意位置
- A.
- 学习率过大可能导致?
- A. 收敛变慢
- B. 损失震荡甚至发散
- C. 梯度消失
- D. 没有任何影响
- 学习率过小可能导致?
- A. 收敛变快
- B. 收敛极慢甚至看起来不动
- C. 损失震荡
- D. 直接报错
optimizer.step()的作用是?- A. 计算损失
- B. 按梯度更新参数
- C. 清零梯度
- D. 创建模型
- 忘记
zero_grad()的后果是?- A. 梯度丢失
- B. 梯度跨轮累积,更新方向错误
- C. 程序崩溃
- D. 没有后果
- 训练结束后打印 loss 的正确写法是?
- A.
print(loss) - B.
print(loss.item()) - C.
print(loss.value) - D.
print(float(loss.data))也可以,但最规范的是 B
- A.
Adam与SGD的关系是?- A. 完全相同的算法
- B. 都是优化器,
Adam自动调整学习步长 - C.
Adam是损失函数 - D.
SGD不能用于 PyTorch
