第 23 章 用神经网络识别图片
文章目录
- [第 23 章 用神经网络识别图片](#第 23 章 用神经网络识别图片)
-
- [🌈 关于《AI 零基础 36 讲》](#🌈 关于《AI 零基础 36 讲》)
- 先认清图片和答案
- 一批和一轮怎样数
- [让 64 个像素产生 10 个分数](#让 64 个像素产生 10 个分数)
- 把训练循环完整走一遍
- 损失降下来以后,看看错在什么地方
- 训练脚本关掉以后,模型怎样继续用
- 两个很容易遇到的报错
上一章的模型只接收一个数字。本章把输入换成一张手写数字图,输出换成 0 至 9 中的一个类别。训练循环依然包含计算损失、求梯度和更新参数,但图片怎样装进张量、怎样分批,以及训练完怎样单独使用模型,都需要交代清楚。
我们选一组很小的公开图片。普通电脑的 CPU 就能完成训练,读者可以把时间花在看清每一步。本章随附训练脚本、固定数据快照、模型参数和单图预测入口。执行代码见 23-image-classification。

🌈 关于《AI 零基础 36 讲》
🧠 零基础也能开始学 AI
《AI 零基础 36 讲》是一套面向初学者的 AI 入门教程。课程从认识和使用 AI 开始,逐步学习
Python、数据处理、机器学习与深度学习,再进入大模型应用开发,最后完成个人知识库助手的综合实践。
📖 本篇是正式讲解文章
通过具体例子、原创图示和操作结果讲清概念。涉及编程的章节提供可运行代码,方便你边读边试。
🎯 读完以后,动手检验
建议按专栏顺序学习,并完成对应的课后训练,检查自己能否独立运用本章知识。
博客专栏:《AI 零基础 36 讲》
先认清图片和答案
本章使用 scikit-learn 1.7 的 load_digits 数据。它包含 1797 张手写数字图,每张只有 8×8 个像素,原像素值是 0 至 16 的整数。标签是书写者所写的数字,取值为 0 至 9。它是 UCI 原数据测试部分的一个副本,数据来源见数据说明及 官方数据文档。
我们已把图片、标签和划分编号保存到 data/digits.npz,日常运行不用下载。随机种子固定为 42,按类别分层划分,训练集 1437 张,测试集 360 张。每一张图的原始编号都写进划分清单。本章单独示范预测的图片来自这 360 张测试图,真实标签是 5。
先看数组,再做转换。
python
from examples.image_core import load_snapshot, pixels_to_tensor
images, labels, train, test = load_snapshot()
x = pixels_to_tensor(images)
print(images.shape, labels.shape)
print(len(train), len(test))
print(tuple(x.shape), x.dtype)
print(float(x.min()), float(x.max()))
输出为 (1797, 8, 8)、(1797,),训练与测试数量为 1437 360。转换后的张量形状是 (1797, 1, 8, 8),类型为 torch.float32,像素范围是 0 至 1。
这四个维度依次表示图片数量、通道、高和宽。灰度图只有一个通道,彩色图常见的红、绿、蓝三个通道在这里没有出现。原像素除以 16 后,最亮的像素变成 1。这条规则要贯穿训练和预测。如果训练时除以 16,预测时却直接输入 255,模型看到的数值范围就变了。
一批和一轮怎样数
DataLoader 负责按批取数据。本章每批最多 64 张,训练时打乱顺序,最后不足一批的图片也保留。1437 除以 64 等于 22 批完整的图片,再剩 29 张,因此一轮共有 23 批。
python
import torch
from torch.utils.data import DataLoader, TensorDataset
from examples.image_core import load_snapshot, pixels_to_tensor
images, labels, train, _ = load_snapshot()
dataset = TensorDataset(pixels_to_tensor(images)[train], torch.tensor(labels[train], dtype=torch.long))
loader = DataLoader(dataset, batch_size=64, shuffle=False, drop_last=False)
sizes = [len(batch_y) for _, batch_y in loader]
print(len(loader), sizes[-1], sum(sizes))
结果是 23 29 1437。这里为了便于核对,用固定顺序查看批次。正式训练使用 shuffle=True 和固定随机生成器,每一轮重新排列训练图片。
一批是一次参与计算和更新的图片。一轮是完整走过训练集一次。本章训练 30 轮,每批更新一次,所以共更新 690 次。把轮次写成 30,不代表只更新 30 次;把批量改成 128,更新次数也会随之变化。
让 64 个像素产生 10 个分数
第一版图片分类器使用全连接网络。Flatten 把每张 8×8 图片展平为 64 个数,第一层把它们映射到 32 个隐藏节点,ReLU 加入非线性,最后一层给出 10 个类别分数。
python
import torch
from examples.image_core import make_model
model = make_model()
scores = model(torch.zeros(64, 1, 8, 8))
print(tuple(scores.shape))
print(sum(p.numel() for p in model.parameters()))
输出形状为 (64, 10),参数总数为 2410。每行对应一张图,每列按 0 至 9 的顺序对应一个数字。分数最高的列给出预测类别。这些原始分数常叫 logits,可以为负,也不需要加起来等于 1。
训练使用交叉熵损失。真实标签为 5 时,损失会根据这一行所有类别分数,衡量模型给正确类别的支持程度。它比只问"猜对没有"提供更细的训练信息。对于本章的类别编号标签,CrossEntropyLoss 接收原始分数和 long 整数标签,函数内部会处理所需的归一化计算。不要先对分数做 Softmax 再交给它。
下面用一张虚构教学图片的三个类别分数缩小计算规模。真实类别为第 0 类,损失约为 0.407606。
python
import torch
scores = torch.tensor([[1.0, 0.0, -1.0]])
answer = torch.tensor([0], dtype=torch.long)
loss = torch.nn.CrossEntropyLoss()(scores, answer)
print(f"{loss.item():.6f}")
把训练循环完整走一遍
在第 22 章的顺序上,加上"每轮取多批图片"即可。下面用一小批真实训练图片运行一次更新,便于逐行观察。完整 30 轮训练在主程序中。
python
import torch
from examples.image_core import load_snapshot, pixels_to_tensor, make_model
torch.manual_seed(42)
images, labels, train, _ = load_snapshot()
batch_x = pixels_to_tensor(images[train[:64]])
batch_y = torch.tensor(labels[train[:64]], dtype=torch.long)
model = make_model()
optimizer = torch.optim.Adam(model.parameters(), lr=0.01)
model.train()
optimizer.zero_grad(set_to_none=True)
loss = torch.nn.CrossEntropyLoss()(model(batch_x), batch_y)
loss.backward()
optimizer.step()
print(tuple(batch_x.shape), len(batch_y), bool(torch.isfinite(loss)))
输出是 (64, 1, 8, 8) 64 True。清空梯度、前向计算、计算损失、反向传播、更新参数,都在这一批内完成。下一批开始前再次清空梯度,避免意外把上批梯度叠加进来。
本章使用 Adam,学习率 0.01。轮次、学习率和网络规模在运行前固定,没有看完测试分数再挑一组更好看的设置。每轮结束后,用当前模型重新计算整个训练集的损失和准确率。这条曲线记录的是更新后的整批评估值,和逐小批损失的简单平均需要区分。
评估时切换到 eval(),并在 torch.no_grad() 内计算。当前网络没有 Dropout 或 BatchNorm,切换模式不会改变这两类层的行为,因为它们根本没有出现在网络里。但保持完整的训练和评估流程,后续换模型时更容易正确使用。测试集仅在固定 30 轮结束后评估一次。
损失降下来以后,看看错在什么地方

图来自本章 CPU 实际运行。初始训练损失约 2.297286,末轮约 0.017907,训练准确率由约 8.21% 升到约 99.86%。测试集答对 347/360 张,准确率约 96.39%,损失约 0.095715。完整精度保存在结果 JSON。
训练准确率比测试准确率高。这意味着模型对见过的数据拟合得更好,对未参与更新的图片仍会出错。测试类别数量为每类 35 至 37 张,没有出现某一个类别占据绝大多数的情况。不过一个总百分比仍然藏住了各类差异。

纵轴是真实数字,横轴是预测数字,主对角线表示答对的数量。真实数字 8 的 35 张图中,31 张预测正确,4 张被当成 1。于是本次数字 8 的召回率是 31/35,约 88.57%。数字 2 和 5 在这份测试划分里全部答对,也不能推出以后所有写法都能答对。

错误图版取 13 个错误中的前 8 个,按固定测试顺序排列,没有按视觉效果挑选。每个小图都写出真实标签、预测标签和数据编号。图像来自公开数据,组合布局和结果图为本章原创。比如编号 1553 的真实数字为 8,模型输出 1。8×8 的图已经丢失了很多细节,人眼也可能觉得模糊,但数据标签不会因此自动变成模型的判断。保留这些失败图片,才能看清结果的适用范围。
训练脚本关掉以后,模型怎样继续用
训练程序将参数写到 results/digits-state.pt。这个文件同时保存结构标记、类别顺序、输入形状和像素除数。预测程序先建立同样的网络,再装入参数,随后按同一条预处理规则读取图片。
python
from examples.image_core import load_model, load_single_image, predict_image
model = load_model("results/digits-state.pt")
image = load_single_image("data/sample-digit.png")
result = predict_image(model, image)
print(result["label"])
print(f"{result['probabilities'][5]:.4f}")
结果为 5 和 0.9973。这里的十个输出经过 Softmax,按数字 0 至 9 排列。它们是模型在这些类别之间分配的数值,0.9973 也不能当作"现实中有 99.73% 的保证"。输入与训练图片差异很大时,模型依然可能给一个很高的分数。
PNG 显示时使用 0 至 255 的灰度值,加载后先恢复到原来的 0 至 16,再除以 16。配套 NPY 保存原始像素矩阵,两个入口产生完全相同的张量。这一点已有测试。程序还在 360 张测试图上比较保存前后的原始分数,结果逐值一致。单图脚本独立启动,没有重新训练,也没有读取真实标签来生成答案。
两个很容易遇到的报错
把标签写成 0.0 会生成浮点类型。若本意是类别编号,就应生成 torch.long 标签。随附错误程序会出现要求 Long 类型的报错,修复后输出损失 0.407606。只有在明确使用概率分布标签的另一种接口时,浮点目标才有对应含义。
把一张 28×28 图交给本章入口也会失败。修复示例使用配套 8×8 灰度图,得到 (1, 1, 8, 8)。不要只改一个 reshape 把 784 个像素塞进 64 个位置。尺寸、背景明暗、数字位置和笔画分布都可能影响预测。
你现在可以自己运行训练,再关掉训练程序,用单图入口加载文件。记下这一轮的损失、准确率和分错图片。完成【零基础学AI】第 23 章课后练习与答案后,下一章会保留图片的二维位置,用卷积观察局部图案,并比较从头训练和迁移学习。