AI-深度学习

从零搭建视觉模型 08:让模型开始学习,损失、梯度与训练循环

展开合集目录
  1. 从零搭建视觉模型 01:图片进入模型后发生了什么
  2. 从零搭建视觉模型 02:Python 起步,写给 C/C++ 开发者
  3. 从零搭建视觉模型 03:从像素到张量的基础概念
  4. 从零搭建视觉模型 04:图片如何变成张量
  5. 从零搭建视觉模型 05:MLP 入门,从手算到图片输入
  6. 从零搭建视觉模型 06:亲手搭建一个小型 CNN
  7. 从零搭建视觉模型 07:模型怎样预测类别:分数、概率与决策
  8. 从零搭建视觉模型 08:让模型开始学习,损失、梯度与训练循环
  9. 从零搭建视觉模型 09:数据集、标签与数据划分
  10. 从零搭建视觉模型 10:损失函数与优化器:怎样把错误变成更新
  11. 从零搭建视觉模型 11:批次、训练轮次与学习率
  12. 从零搭建视觉模型 12:训练第一个真实图片分类器
  13. 从零搭建视觉模型 13:过拟合、数据增强与正则化
  14. 从零搭建视觉模型 14:验证、测试与错误分析
  15. 从零搭建视觉模型 15:保存模型、恢复训练与独立推理
  16. 从零搭建视觉模型 · 综合实践:实现 MLP,从损失计算、训练到 ONNX 推理
  17. 从零搭建视觉模型 16:ResNet 与残差连接
  18. 从零搭建视觉模型 17:预训练、迁移学习与微调
  19. 从零搭建视觉模型 18:轻量模型与计算成本
  20. 从零搭建视觉模型 19:目标检测与 YOLO:从类别到目标框
  21. 从零搭建视觉模型 20:图像分割与 U-Net:给每个像素一个判断
  22. 从零搭建视觉模型 21:注意力与视觉 Transformer
  23. 从零搭建视觉模型 22:从训练模型到 ONNX
  24. 从零搭建视觉模型 23:量化与精度对比
  25. 从零搭建视觉模型 24:从摄像头输入到部署验收
  26. 从零搭建视觉模型 25:发展历史、主流分类与当前格局
从零搭建视觉模型 08:让模型开始学习,损失、梯度与训练循环的封面

学习初稿(已开放网页阅读):本篇接在小型 CNN与《模型怎样预测类别》之后。先理解一次更新,再看完整训练循环。配套代码全部使用 CPU;练习参考答案默认折叠,已有实验记录保留。

前几章已经能把输入送进网络并获得输出,但参数还没有根据样本调整。这一章要回答:模型怎样知道哪里需要改变,以及代码中是哪一步真正改变了参数?

第一次阅读先学到实验 01 即可。无需先掌握所有微积分公式,也不要把两个实验一次背下来。

训练比前向计算多了什么

输入和标签 → 前向计算 → 损失 → 反向求梯度 → 更新参数 → 下一批数据

标签是我们提供的正确答案。例如横条纹记为 0,竖条纹记为 1。模型只接收图片来预测;损失函数同时读取预测分数与标签,衡量预测和答案的差距。

名称 本章中表示什么
参数 卷积、全连接层中的权重和偏置
损失 loss 当前预测有多不符合训练目标的一个数
梯度 gradient 参数稍微变化时,损失在当前位置怎样变化
学习率 learning rate 每次沿梯度方向调整多大步
优化器 optimizer 根据梯度执行参数更新的对象

损失低不一定意味着真实场景表现好;先把更新过程弄清,再学习怎样评估。

从一个参数开始

先暂时不使用完整 CNN,只定义一个乘法:

预测值 = w × x
输入 x = 2
目标值 target = 4
初始权重 w = 0

初始预测为 0。希望预测靠近 4,就需要把 w 调整到靠近 2。

本实验用平方误差衡量差距:

loss = (预测值 - 目标值)²
     = (2w - 4)²

当 w=0,损失为 16;当 w=1,损失为 4;当 w=2,损失为 0。平方使正负方向的误差都能得到非负损失。

这只是方便解释的单参数实验。后面的分类任务使用交叉熵,不是直接拿类别编号做平方误差。

导数和梯度先理解到什么程度

导数可以先理解为“在当前位置,参数增加一点,损失大约怎样变”。对本例:

loss = (2w - 4)²
对 w 的导数 = 2 × (2w - 4) × 2

第一个 2×(2w-4) 来自平方,最后的 2 来自里面 2w-4 对 w 的变化率。这是链式法则在简单例子中的体现:沿着计算关系,把各步骤的局部变化率连接起来。

w=0 时导数为 -16。负号说明在当前位置略微增大 w 会降低损失;有很多参数时,把各个参数的这种变化率放在一起,就得到梯度。

梯度描述局部趋势,不是正确答案,也不是应该直接赋给参数的数值。实际网络不需要你手推全部公式,PyTorch 根据记录下来的计算过程自动求导。自动求导说明

为什么更新时要减去梯度

本章先使用最基本的梯度下降更新:

新权重 = 旧权重 - 学习率 × 梯度

取学习率 0.1:

新权重 = 0 - 0.1×(-16) = 1.6
新预测 = 1.6×2 = 3.2
新损失 = (3.2-4)² = 0.64

这一步让损失从 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

重点看四行:

weight = torch.tensor(0.0, requires_grad=True)
prediction = weight * x
loss = (prediction - target) ** 2
loss.backward()

requires_grad=True 表示需要跟踪与这个张量相关的梯度。loss.backward() 根据本次计算求梯度,结果放到 weight.grad;此时权重还没有变。

随后代码在 torch.no_grad() 中执行减法更新。这里不记录“更新动作”的梯度,但训练时的前向计算和损失计算必须保留梯度记录,不能把整个训练循环包进 no_grad()。

预期输出:

更新前权重: 0.0000
更新前预测: 0.0000
更新前损失: 16.0000
权重梯度: -16.0000
更新后权重: 1.6000
更新后预测: 3.2000
更新后损失: 0.6400

本脚本只更新一次,因此没有下一次梯度清理。完整训练中每轮都会产生新梯度,需要明确何时清理上一轮结果。

给 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

本任务每张图片只属于横、竖中的一个类别,采用:

loss_function = nn.CrossEntropyLoss()
loss = loss_function(scores, labels)

在本章的类别编号用法中,输入是原始 logits,标签是从 0 开始的整数。不要先给 scores 加 softmax,也不要把 argmax 得到的类别编号交给这个损失函数。CrossEntropyLoss 官方说明

直觉上,它希望正确类别相对于其他类别得到更高的分数。两个分数相等时,两类对应的概率相等,单份损失约为 0.693;它不等于“错了 69.3%”。本章先学会正确调用,暂不要求推导交叉熵公式。

损失和准确率的作用不同:损失用于提供训练信号;准确率只统计选中的类别是否正确。分数变化时,准确率可能没变,损失却已经变化。

完整训练的五个步骤

optimizer.zero_grad()
scores = model(images)
loss = loss_function(scores, labels)
loss.backward()
optimizer.step()
语句 做了什么
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:

每一轮 epoch:遍历全部 256 张训练图
每个 batch:处理其中 32 张
每轮更新次数:256 / 32 = 8
训练 20 轮:一共更新 160 次

外层 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

在博客根目录运行:

python3 content/posts/AI-深度学习/从零搭建视觉模型/08-让模型开始学习/code/01_one_update.py
python3 content/posts/AI-深度学习/从零搭建视觉模型/08-让模型开始学习/code/02_train_stripes.py

两个脚本只依赖 PyTorch,不下载数据,也不生成模型文件。复用前几章的环境即可直接运行;安装说明见本篇 code/README.md。

2026-10-12 本机 CPU 验证节选:

训练前验证: loss=0.7008, accuracy=42.2%
epoch 01: train_loss=0.6376, val_loss=0.5385, val_accuracy=89.1%
epoch 20: train_loss=0.0045, val_loss=0.0405, val_accuracy=98.4%

数值是这次运行记录,不是所有环境必须达到的精确结果。完整输出见 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 导出和数值对照;遇到问题可按实践中的索引返回对应理论章节。


完整阅读目录 · 上一篇 · 下一篇