学习初稿(已开放网页阅读):本篇接在小型 CNN与《模型怎样预测类别》之后。先理解一次更新,再看完整训练循环。配套代码全部使用 CPU;练习参考答案默认折叠,已有实验记录保留。
前几章已经能把输入送进网络并获得输出,但参数还没有根据样本调整。这一章要回答:模型怎样知道哪里需要改变,以及代码中是哪一步真正改变了参数?
第一次阅读先学到实验 01 即可。无需先掌握所有微积分公式,也不要把两个实验一次背下来。
训练比前向计算多了什么
|
标签是我们提供的正确答案。例如横条纹记为 0,竖条纹记为 1。模型只接收图片来预测;损失函数同时读取预测分数与标签,衡量预测和答案的差距。
| 名称 | 本章中表示什么 |
|---|---|
| 参数 | 卷积、全连接层中的权重和偏置 |
| 损失 loss | 当前预测有多不符合训练目标的一个数 |
| 梯度 gradient | 参数稍微变化时,损失在当前位置怎样变化 |
| 学习率 learning rate | 每次沿梯度方向调整多大步 |
| 优化器 optimizer | 根据梯度执行参数更新的对象 |
损失低不一定意味着真实场景表现好;先把更新过程弄清,再学习怎样评估。
从一个参数开始
先暂时不使用完整 CNN,只定义一个乘法:
|
初始预测为 0。希望预测靠近 4,就需要把 w 调整到靠近 2。
本实验用平方误差衡量差距:
|
当 w=0,损失为 16;当 w=1,损失为 4;当 w=2,损失为 0。平方使正负方向的误差都能得到非负损失。
这只是方便解释的单参数实验。后面的分类任务使用交叉熵,不是直接拿类别编号做平方误差。
导数和梯度先理解到什么程度
导数可以先理解为“在当前位置,参数增加一点,损失大约怎样变”。对本例:
|
第一个 2×(2w-4) 来自平方,最后的 2 来自里面 2w-4 对 w 的变化率。这是链式法则在简单例子中的体现:沿着计算关系,把各步骤的局部变化率连接起来。
w=0 时导数为 -16。负号说明在当前位置略微增大 w 会降低损失;有很多参数时,把各个参数的这种变化率放在一起,就得到梯度。
梯度描述局部趋势,不是正确答案,也不是应该直接赋给参数的数值。实际网络不需要你手推全部公式,PyTorch 根据记录下来的计算过程自动求导。自动求导说明
为什么更新时要减去梯度
本章先使用最基本的梯度下降更新:
|
取学习率 0.1:
|
这一步让损失从 16 降为 0.64,但还没有恰好达到目标。学习率过大可能跨过合适区域,甚至让损失变大;过小则可能进展很慢。它不是准确率,也不是训练样本的比例。
实验 01:看清每个数
实验 01:一个参数怎样更新
查看完整代码:01_one_update.py
"""一个参数、一次梯度更新。只用 CPU。"""
import torch
def main():
x = torch.tensor(2.0)
target = torch.tensor(4.0)
weight = torch.tensor(0.0, requires_grad=True)
learning_rate = 0.1
prediction = weight * x
loss = (prediction - target) ** 2
loss.backward()
print(f"更新前权重: {weight.item():.4f}")
print(f"更新前预测: {prediction.item():.4f}")
print(f"更新前损失: {loss.item():.4f}")
print(f"权重梯度: {weight.grad.item():.4f}")
# 更新不属于此次求导过程,因此放在 no_grad 中。
with torch.no_grad():
weight -= learning_rate * weight.grad
new_prediction = weight * x
new_loss = (new_prediction - target) ** 2
print(f"更新后权重: {weight.item():.4f}")
print(f"更新后预测: {new_prediction.item():.4f}")
print(f"更新后损失: {new_loss.item():.4f}")
assert abs(weight.item() - 1.6) < 1e-6
assert new_loss.item() < loss.item()
if __name__ == "__main__":
main()
在项目根目录执行;下载包解压后,在解压目录执行 python3 01_one_update.py。
cd 'content/posts/AI-深度学习/从零搭建视觉模型/08-让模型开始学习/code' && python3 01_one_update.py
重点看四行:
|
requires_grad=True 表示需要跟踪与这个张量相关的梯度。loss.backward() 根据本次计算求梯度,结果放到 weight.grad;此时权重还没有变。
随后代码在 torch.no_grad() 中执行减法更新。这里不记录“更新动作”的梯度,但训练时的前向计算和损失计算必须保留梯度记录,不能把整个训练循环包进 no_grad()。
预期输出:
|
本脚本只更新一次,因此没有下一次梯度清理。完整训练中每轮都会产生新梯度,需要明确何时清理上一轮结果。
给 CNN 准备一个容易观察的任务
实验 02 使用上一章同样的结构:卷积 → ReLU → 池化 → 展平 → 两个分数。网络代码完整放在本篇目录内。
我们在内存生成 32×32 的三通道图片,暗背景上有一条亮带:
- 标签 0:横向亮带。
- 标签 1:竖向亮带。
亮带位置、宽度和背景噪声会变化。生成函数先创建 [0, 0.15) 的随机背景,再给亮带区域加 0.75,所以数值仍在 [0, 1] 内,不需要再除以 255。
image[:, position:position + width, :] 选中一组行,画出横条纹;image[:, :, position:position + width] 选中一组列,画出竖条纹。label = index % 2 交替分配两类标签。
标签在生成图片时确定,而不是用模型自己的猜测当作答案。训练集 256 张、验证集 64 张,分别用不同随机种子独立生成。这个小数据集只用于理解训练流程,不代表真实图片的复杂度,也不是人脸口鼻遮挡数据。
一批图片和标签长什么样
| 对象 | 形状与类型 | 含义 |
|---|---|---|
images |
(32, 3, 32, 32),float32 |
32 张图片 |
labels |
(32,),torch.long |
每张图片的类别编号 0 或 1 |
scores |
(32, 2),float32 |
每张图片的两个原始分数 |
loss |
标量 | 默认对这一批样本损失取平均 |
TensorDataset(images, labels) 把图片和对应标签配成数据集。DataLoader(..., batch_size=32, shuffle=True) 每次取出一批配对数据,并在遍历时打乱样本顺序;它不会把图片和标签的对应关系打乱。
for images, labels in train_loader 是把每批返回的两个部分分别取名为 images 和 labels。它不是列表推导式,可以理解为“取下一批输入和答案”。
分类损失:先使用 CrossEntropyLoss
本任务每张图片只属于横、竖中的一个类别,采用:
|
在本章的类别编号用法中,输入是原始 logits,标签是从 0 开始的整数。不要先给 scores 加 softmax,也不要把 argmax 得到的类别编号交给这个损失函数。CrossEntropyLoss 官方说明
直觉上,它希望正确类别相对于其他类别得到更高的分数。两个分数相等时,两类对应的概率相等,单份损失约为 0.693;它不等于“错了 69.3%”。本章先学会正确调用,暂不要求推导交叉熵公式。
损失和准确率的作用不同:损失用于提供训练信号;准确率只统计选中的类别是否正确。分数变化时,准确率可能没变,损失却已经变化。
完整训练的五个步骤
|
| 语句 | 做了什么 |
|---|---|
zero_grad() |
清理优化器管理的参数上遗留的梯度 |
model(images) |
使用当前参数前向计算 |
loss_function(...) |
比较预测分数与标签 |
backward() |
求本次损失对参数的梯度 |
step() |
按优化器规则真正更新参数 |
本章用 torch.optim.SGD(model.parameters(), lr=0.05),默认没有动量,便于和实验 01 的更新公式对照。zero_grad() 不清空权重,backward() 不替你更新权重;如果漏掉 step(),网络不会因为打印了损失就自动学习。框架默认会累积梯度,所以这里每批开始前都清理。PyTorch 训练循环说明
损失打印时用 .item() 取出 Python 数值,但调用 backward() 的必须仍是原始损失张量。不要把 loss 改成 loss.item() 后再求导。
epoch 和 batch 不要混淆
本实验有 256 张训练图片,batch_size=32:
|
外层 for epoch in range(epochs) 控制轮数,内层 for images, labels in train_loader 逐批更新。每批算完才执行一次 step(),不是每轮只执行一次。
记录整轮损失时,代码用批次平均损失乘以批次样本数,累加后再除以总样本数,这样即使最后一批较小,也不会给它过大的权重。本章的 train_loss 记录的是本轮各批在更新前的损失,参数在批间不断变化。
验证阶段为什么不能更新参数
验证集用于观察没有参与参数更新的样本。evaluate() 设置 model.eval() 并使用 torch.no_grad(),只计算损失和准确率,不调用 backward() 或 step()。
argmax(dim=1) 为每张图片选择两个分数中较大的那个位置;结果与标签比较,统计正确数量,再除以样本总数。
下一轮训练开始必须重新调用 model.train()。对本章没有 Dropout、BatchNorm 的网络,这两个模式不改变层的计算规则,但仍按训练与验证的职责明确切换。
训练集与验证集不能混着用于更新参数。这里两组样本虽独立生成,却来自相同的简单规则;验证结果只能说明这种条纹任务上的表现。以后反复根据验证集调参,还需要独立测试集来做最终评估。
实验 02:运行并观察训练
实验 02:让 CNN 学习横竖条纹
查看完整代码:02_train_stripes.py
"""用合成横竖条纹学习训练循环;仅 CPU,不下载或写入数据。"""
import torch
from torch import nn
from torch.utils.data import TensorDataset, DataLoader
def make_dataset(count, seed):
generator = torch.Generator().manual_seed(seed)
images = torch.empty((count, 3, 32, 32), dtype=torch.float32)
labels = torch.empty(count, dtype=torch.long)
for index in range(count):
label = index % 2 # 0=横条纹,1=竖条纹;两类数量相同(count 为偶数)。
image = torch.rand((3, 32, 32), generator=generator) * 0.15
position = torch.randint(4, 24, (1,), generator=generator).item()
width = torch.randint(3, 6, (1,), generator=generator).item()
if label == 0:
image[:, position:position + width, :] += 0.75
else:
image[:, :, position:position + width] += 0.75
images[index] = image
labels[index] = label
return TensorDataset(images, labels)
class TinyCNN(nn.Module):
def __init__(self):
super().__init__()
self.conv = nn.Conv2d(3, 8, kernel_size=3, padding=1)
self.relu = nn.ReLU()
self.pool = nn.MaxPool2d(2, 2)
self.flatten = nn.Flatten(start_dim=1)
self.classifier = nn.Linear(8 * 16 * 16, 2)
def forward(self, images):
features = self.conv(images)
features = self.relu(features)
features = self.pool(features)
features = self.flatten(features)
scores = self.classifier(features)
return scores
def evaluate(model, loader, loss_function):
model.eval()
total_loss = 0.0
correct_count = 0
sample_count = 0
with torch.no_grad():
for images, labels in loader:
scores = model(images)
loss = loss_function(scores, labels)
predictions = scores.argmax(dim=1)
batch_count = labels.shape[0]
total_loss += loss.item() * batch_count
correct_count += (predictions == labels).sum().item()
sample_count += batch_count
return total_loss / sample_count, correct_count / sample_count
def main():
torch.set_num_threads(1) # 小实验限制 CPU 线程数,避免线程开销。
torch.manual_seed(7)
train_data = make_dataset(256, seed=100)
validation_data = make_dataset(64, seed=200)
train_loader = DataLoader(train_data, batch_size=32, shuffle=True)
validation_loader = DataLoader(validation_data, batch_size=32, shuffle=False)
model = TinyCNN()
loss_function = nn.CrossEntropyLoss()
optimizer = torch.optim.SGD(model.parameters(), lr=0.05)
epochs = 20
initial_loss, initial_accuracy = evaluate(model, validation_loader, loss_function)
print(f"训练前验证: loss={initial_loss:.4f}, accuracy={initial_accuracy:.1%}")
for epoch in range(epochs):
model.train()
total_loss = 0.0
sample_count = 0
for images, labels in train_loader:
optimizer.zero_grad()
scores = model(images)
loss = loss_function(scores, labels)
loss.backward()
optimizer.step()
batch_count = labels.shape[0]
total_loss += loss.item() * batch_count
sample_count += batch_count
training_loss = total_loss / sample_count
validation_loss, accuracy = evaluate(model, validation_loader, loss_function)
print(f"epoch {epoch + 1:02d}: train_loss={training_loss:.4f}, "
f"val_loss={validation_loss:.4f}, val_accuracy={accuracy:.1%}")
print("这只是合成条纹任务的验证结果,不代表能识别真实场景。")
if __name__ == "__main__":
main()
在项目根目录执行;下载包解压后,在解压目录执行 python3 02_train_stripes.py。
cd 'content/posts/AI-深度学习/从零搭建视觉模型/08-让模型开始学习/code' && python3 02_train_stripes.py
在博客根目录运行:
|
两个脚本只依赖 PyTorch,不下载数据,也不生成模型文件。复用前几章的环境即可直接运行;安装说明见本篇 code/README.md。
2026-10-12 本机 CPU 验证节选:
|
数值是这次运行记录,不是所有环境必须达到的精确结果。完整输出见 code/verification.md。验证损失可能在某些轮次稍微上升,准确率也不一定每轮变化;不能只根据某一轮就判断训练是否有效。
课后练习
先完成第一题,再逐步阅读训练脚本。2026-10-18 补充以下参考答案,供完整草稿通读;这不表示本次练习已完成。
练习 1:改变一次更新的步长
保持实验 01 其他设置不变,分别将学习率改为 0.05 和 0.3。先算出更新后的权重、预测与损失,再运行验证。哪一种让损失变大?说明为什么“学习率越大越好”不成立。
参考答案与运行注意事项
原始 x=2、目标为 4、w=0,损失为 16,梯度为 -16。学习率 0.05 时,w 变成 0.8,预测 1.6,损失 5.76;学习率 0.3 时,w 变成 4.8,预测 9.6,损失 31.36。后者越过最优值 w=2 太远,损失反而增加。
原脚本末尾断言固定检查学习率 0.1 时的权重和损失下降;修改学习率做本题时,需要同步改为对应预期值。学习率 0.3 的实验本来就用于观察损失上升,不要保留“损失必须下降”的断言,以免误认实现出错。
练习 2:参数到底在哪一步变化
在条纹训练脚本的第一个 batch 中,用 model.conv.weight.detach().clone() 保存更新前权重。分别在 backward() 后、step() 后与它比较,打印权重是否变化;同时打印 model.conv.weight.grad 是否存在。detach() 在这里让对照副本不参与求梯度。
参考答案
backward() 计算梯度,通常能看到 grad,但它本身不修改参数。step() 才按优化器规则更新参数;在本实验有非零梯度、非零学习率且优化器包含这些参数时,应看到改变。不能把这个观察泛化成每次 step 必然改变每个参数,零梯度或冻结等情况需要分别解释。
练习 3:改变批次数量
保持 256 张训练图、20 轮,把 batch_size 改为 64。每轮更新几次,总共几次?输出分数和标签形状如何变化?解释为何更新次数变化不等于参数数量变化。
参考答案
每轮 256/64=4 次,20 轮共 80 次,假设每批更新一次且未提前停止。二分类分数为 [64,2],整数标签为 [64]。改变批次改变一次处理的样本数量与更新频率,网络层定义没有变化,因此参数数量不变。
练习 4:检查验证没有偷偷训练
在调用 evaluate() 前复制卷积权重和输出层权重,调用后检查是否完全相同。指出验证函数中哪些操作只是在统计结果,以及为何验证准确率高仍不足以说明真实图片也能识别。
参考答案
验证函数不执行反向传播与优化器更新,因此本实验权重应保持一致。累计损失、正确数和样本数只是统计;eval() 与关闭梯度各自负责推理模式与梯度记录。合成条纹的分布简单,即使验证集独立生成并表现良好,也只能支持该合成任务范围内的结论,真实图像还需代表性数据评估。
后续学习
本章打通输入、损失、梯度与更新。接下来按完整阅读目录,先学习《数据集、标签与数据划分》,再系统整理损失、优化器和训练配置,进入真实图片分类、过拟合、错误分析与模型保存。
综合实践
想把这些知识接成一套可运行的代码,可以进入实现 MLP:从损失计算、训练到 ONNX 推理。实践包含自生成数据、训练与验证、模型保存、独立推理、ONNX 导出和数值对照;遇到问题可按实践中的索引返回对应理论章节。
