这一章的目标
理解损失函数(Loss Function) 是什么 —— 怎么给模型的预测 "打分"
理解优化器(Optimizer) 是什么 —— 模型怎么根据 "分数" 修正自己
跑一个最简单的 "手动训练",体会损失下降的过程
第 5 章我们搭好了网络,给它一张图,它输出 10 个数字(叫做 logits,即未归一化的得分)。
但问题来了:这 10 个数字和正确答案(label)有什么关系?怎么告诉模型 "你猜错了,要改"?
损失函数就是干这件事的:它接收模型的预测和正确答案,算出一个数值表示"差了多少"。这个值叫损失(Loss)。
Loss 很大 → 模型错得很离谱 → 需要大幅修正
Loss 很小 → 模型差不多对了 → 微调即可
Loss ≈ 0 → 模型预测完美(实际几乎不会发生)
📌 术语:交叉熵损失(CrossEntropyLoss)
分类问题最常用的损失函数。这个名字听起来很数学,但你不用懂公式。
它的行为很简单:模型对正确类别的预测概率越高,Loss 越低;预测概率越低,Loss 越高。
import torch
import torch.nn as nn
# 定义交叉熵损失函数,用于多分类任务(MNIST数字识别)
loss_fn = nn.CrossEntropyLoss()
# 模拟输入:模型输出原始得分logits,shape[batch_size, 类别数]
# 代表一张图片,对应 0~9 一共10个数字的预测分值
logits = torch.tensor([[0.1, 0.2, 0.3, 5.0, 0.1, 0.2, 0.1, 0.1, 0.1, 0.1]])
label = torch.tensor([3]) # 图片真实标签:数字3
# 计算损失
loss = loss_fn(logits, label)
# 预测正确:目标类别分值远高于其他类别,损失数值很小
print(f"预测得好时的 Loss: {loss.item():.4f}")
# 预测错误案例
bad_logits = torch.tensor([[0.1, 0.2, 0.3, 0.1, 0.1, 5.0, 0.1, 0.1, 0.1, 0.1]])
# 真实类别3得分很低,错误类别5得分最高
loss2 = loss_fn(bad_logits, label)
# 预测偏差大,损失数值显著变大
print(f"预测得差时的 Loss: {loss2.item():.4f}")运行代码,输出如下:
预测得好时的 Loss: 0.0679
预测得差时的 Loss: 4.9671看到了吗?Loss 自动反映了预测的好坏。这就是为什么我们可以在训练中用它作为"优化信号"。
| 任务类型 | 损失函数 | PyTorch 类 |
| 多分类(如 MNIST 10类) | 交叉熵 | nn.CrossEntropyLoss() |
| 二分类 | 二元交叉熵 | nn.BCELoss() / nn.BCEWithLogitsLoss() |
| 回归(预测数值) | 均方误差 | nn.MSELoss() |
对 MNIST,用 nn.CrossEntropyLoss()。注意:它内部已经包含了 Softmax,所以不用在模型最后一层加 Softmax(加了反而会出错)。
有了 Loss,我们就知道模型错得多离谱。接下来需要根据 Loss 调整模型参数(W 和 b),让下一次预测更好一点。
但问题来了:模型可能有几万甚至上百万个参数,每个参数该往哪个方向调?调多少?
这就是优化器的工作。它的核心思想是梯度下降(Gradient Descent)。
📌 术语:梯度下降(Gradient Descent)
想象你在浓雾中站在一座山的半山腰,目标是走到山谷最低点。你看不到全貌,但能感受到脚下的坡度——坡往哪边斜,你就往哪边走,每走一步都是下坡,最终总能到达谷底。
"坡度"就是梯度。优化器做的事就是:
算出每个参数对 Loss 的影响有多大(梯度)
把参数往 Loss 减小的方向挪一点
重复
📌 术语:学习率(Learning Rate,简称 lr)
"每步走多大"。lr 太大 → 可能跨过最低点,来回震荡;lr 太小 → 走得太慢,训练很久。
学习率是最重要的超参数之一。对于初学者,lr=0.001(即 1e-3) 是一个安全的起点。
常见优化器如下:
SGD(带动量):最简单的优化器,带着惯性下山。收敛慢,但泛化效果经常很好,比赛常用。
AdaGrad:每个参数单独调步伐。适合稀疏数据;缺点训练后期步子越来越小,走不动。
RMSprop:AdaGrad 改良版,限制梯度无限累积,梯度震荡时走得更平稳,适合循环网络。
Adam(最常用):融合动量 + 自适应步长,收敛快,上手简单。
AdamW:修正 Adam 的权重衰减漏洞。当下通用首选,Transformer、图像任务主流。
AdaDelta / Adamax:Adam 衍生变体,日常很少使用。
LBFGS:二阶优化方法,需要全部数据一起计算,只适合小型网络,大模型不用。
上代码:
import torch.optim as optim
# 见第5章定义的 MNIST_CNN 网络
model = MNIST_CNN()
# Adam:目前最常用的优化器,自带"自适应学习率",初学者首选
optimizer = optim.Adam(model.parameters(), lr=0.001)
# SGD:最经典的随机梯度下降,简单但可能需要手动调学习率
# optimizer = optim.SGD(model.parameters(), lr=0.01, momentum=0.9)对于本教程的 MNIST 项目,用 Adam、lr=0.001 就行了。
在写正式的完整训练循环之前,我们先手动做一次前向+反向+参数更新,体会这个过程:
import torch
import torch.nn as nn
import torch.optim as optim
# 第5章定义的网络
class MNIST_CNN(nn.Module):
"""MNIST 手写数字识别 CNN
输入:batch张 1通道 28×28灰度手写数字图片
输出:batch×10 的logits得分,对应数字0~9,未做softmax
"""
def __init__(self):
# 调用父类nn.Module构造函数,注册网络层,是pytorch模型必须写的
super().__init__()
# ── 第1组:检测低级特征(边缘、角落、线条) ──
# Conv2d 参数:(输入通道, 输出通道, 卷积核大小, padding填充)
# in_channels=1:MNIST灰度图,单通道
# out_channels=32:使用32个3×3卷积核,输出32张特征图
# kernel_size=3:3×3卷积核,捕捉局部相邻像素
# padding=1:图片四周补1圈0,卷积前后宽高保持不变 28→28
self.conv1 = nn.Conv2d(1, 32, kernel_size=3, padding=1)
# MaxPool2d 最大池化,kernel_size=2:取2×2窗口内最大值作为输出
# 步长默认等于kernel_size,宽高减半:28×28 →14×14
# 作用:压缩特征图尺寸、降低计算量,让特征对微小位移更鲁棒
self.pool1 = nn.MaxPool2d(kernel_size=2)
# ── 第2组:检测高级特征(曲线、数字部件、局部形状) ──
# 输入通道32:接收上一层32张特征图;输出通道提升到64,提取更多高阶组合特征
# padding=1保证卷积后尺寸不变 14→14(因为前面已经池化过)
self.conv2 = nn.Conv2d(32, 64, kernel_size=3, padding=1)
# 第二次池化,尺寸再次减半:14×14 →7×7
self.pool2 = nn.MaxPool2d(kernel_size=2)
# ── 全连接层:把卷积提取的特征映射为类别得分,完成最终分类 ──
# 64*7*7:conv2+pool2之后特征图维度(64,7,7),展平后向量长度
# 3136维特征压缩映射到128维中间特征向量
self.fc1 = nn.Linear(64 * 7 * 7, 128)
# 最后一层:128维特征映射到10个输出,对应0‑9十个数字的原始得分logits
self.fc2 = nn.Linear(128, 10)
def forward(self, x):
"""
前向传播逻辑:数据流过网络的计算流程
:param x: 输入张量 x 形状: (batch_size, 1, 28, 28)
:return: logits原始分类得分,shape=(batch_size,10)
"""
# 第一层卷积+ReLU激活;ReLU引入非线性,网络才能学习复杂特征
# 输出shape:(batch, 32, 28, 28)
x = torch.relu(self.conv1(x))
# 最大池化下采样,宽高减半;输出shape:(batch, 32, 14, 14)
x = self.pool1(x)
# 第二层卷积提取高级特征 + ReLU非线性激活
# 输出shape:(batch, 64, 14, 14)
x = torch.relu(self.conv2(x))
# 第二次池化下采样;输出shape:(batch, 64, 7, 7)
x = self.pool2(x)
# 展平操作view:保留第0维batch,后面所有维度合并成一维
# x.size(0) 获取batch大小;-1自动计算剩余维度总大小64*7*7=3136
# 输出shape:(batch, 3136)
x = x.view(x.size(0), -1)
# 第一层全连接+ReLU,做特征融合变换;输出shape:(batch, 128)
x = torch.relu(self.fc1(x))
# 最后全连接输出10类原始得分logits,CrossEntropyLoss会内部做softmax,这里不用手动softmax
# 输出shape:(batch, 10)
x = self.fc2(x)
return x
if __name__ == "__main__":
# 准备数据:取一个很小的 batch(4张图,模拟)
images = torch.randn(4, 1, 28, 28) # 4张"假图片"
labels = torch.tensor([3, 7, 1, 5]) # 正确答案
# 创建模型、损失函数、优化器
model = MNIST_CNN()
loss_fn = nn.CrossEntropyLoss()
optimizer = optim.Adam(model.parameters(), lr=0.001)
# 一次训练迭代的 5 个步骤
# 1. 前向传播:模型根据当前参数做出预测
outputs = model(images) # → (4, 10)
# 2. 计算损失:对比预测和正确答案,算出差了多少
loss = loss_fn(outputs, labels)
print(f"训练前 Loss: {loss.item():.4f}")
# 3. 清零梯度:PyTorch 默认累积梯度,每次更新前必须清零
optimizer.zero_grad()
# 4. 反向传播:自动算出每个参数对 Loss 的梯度
loss.backward()
# 5. 更新参数:优化器根据梯度调整参数
optimizer.step()
# 再看一次 Loss(参数已经更新了,Loss 应该变小)
outputs2 = model(images)
loss2 = loss_fn(outputs2, labels)
print(f"训练后 Loss: {loss2.item():.4f}")运行代码,输出如下:
训练前 Loss: 2.2931
训练后 Loss: 1.8582⚠️ 五个步骤缺一不可。
这是深度学习最核心的代码模式。我当初学的时候就死记硬背这五步,背熟了后面什么都好理解:
① outputs = model(inputs) 前向传播
② loss = loss_fn(outputs, target) 计算损失
③ optimizer.zero_grad() 梯度清零
④ loss.backward() 反向传播(算梯度)
⑤ optimizer.step() 更新参数
你可能好奇 loss.backward() 这行代码到底干了什么。
简单说:PyTorch 在每次前向传播时会偷偷记录一个 计算图(computation graph),记下数据从哪里来、经过了哪些运算。当你调用 loss.backward() 时,PyTorch 沿着这个计算图从后往前,用链式法则算出每个参数对 Loss 的偏导数(也就是梯度)。这个过程叫自动微分(Autograd)。
你不需要手动算导数 —— 这就是 PyTorch 最大的便利之一。
损失函数衡量模型预测和正确答案之间的差距
MNIST 分类用 nn.CrossEntropyLoss()(自带 Softmax,模型输出不要加 Softmax)
优化器根据梯度调整模型参数,让 Loss 逐步下降
Adam 优化器 + lr=0.001 是初学者最安全的选择
训练核心五步:前向 → 算损失 → 清零梯度 → 反向传播 → 更新参数
点击查看完整的 手动跑一次训练迭代 示例代码。