识别手写数字
第一个真正的分类任务:让网络认出 8×8 像素的手写数字。讲清楚分类和回归的区别,softmax 和交叉熵在做什么,为什么要分小批训练,最后用混淆矩阵看它错在哪里。
- 约 45 分钟
- 难度:进阶
- 实测:2026-09-14 torch 2.14,scikit-learn 1.9,CPU,固定随机种子
前面几课的任务都很小:50 个点的房价,4 个点的异或。这一课处理一份真实的数据:1797 张手写数字的图片,让网络认出每张图写的是 0 到 9 里的哪一个。
这是一个分类任务。房价预测输出的是一个连续的数,叫回归;认数字要从 10 个类别里选一个,输出的形式、损失函数都要换。这一课就讲换成了什么、为什么。
数据来自 scikit-learn 自带的手写数字数据集,装好就有,不用下载:
uv add torch scikit-learn
python digit_classifier.py
数据长什么样
digits = load_digits()
数据:1797 张图,每张 8×8 像素,像素值 0~16
第一张图,标签是 0:
::**==
**##++##::
..##.. ++==
::** ====
::== ====
::++ **--
..##::++**
--**++
每张图 8×8 共 64 个像素,每个像素是 0 到 16 的整数,数越大越黑。脚本里用几个字符把深浅画了出来,能看出是一个 0。
网络的输入就是这 64 个数,拉成一行。除以 16,缩放到 0 到 1 之间,道理和第 2 课的标准化一样:
X = torch.tensor(digits.data / 16.0, dtype=torch.float32)
y = torch.tensor(digits.target, dtype=torch.long)
X_train, X_test, y_train, y_test = train_test_split(X, y, test_size=0.25, random_state=0)
训练集 1347 张,测试集 450 张
四分之一的图留出来做测试集,训练时完全不给网络看。最后用它来检查网络是真的学会了认数字,还是只是记住了训练集里的那些图。这和第 06 模块做评估集是一个道理:检查的题目不能出现在练习题里。
网络:输出 10 个分数
model = nn.Sequential(
nn.Linear(64, 64), # 64 个像素 → 64 个隐藏神经元
nn.ReLU(),
nn.Linear(64, 10), # → 10 个输出,分别对应数字 0~9
)
模型共 4810 个参数
nn.Sequential 把几层按顺序串起来,省得自己写一个类。算一下参数:第一层 64×64 个权重加 64 个偏置,4160 个;第二层 64×10 加 10,650 个;一共 4810 个。
这里有两处和前面不同。
激活函数换成了 ReLU。它比 tanh 还简单:输入大于 0 就原样输出,否则输出 0。它算得快,而且在层数多的时候比 tanh 更好训练,现在的网络里最常见的就是它和它的变种。
输出是 10 个数,每个数字一个。它们叫分数(logits),可以是任意的正数负数,哪个最大,网络就认为是哪个数字。
softmax:把分数变成概率
10 个分数不好直接解释。softmax 把它们变成 10 个概率:先对每个分数取指数(保证是正数),再除以所有的和(保证加起来是 1)。原来分数越大的,概率就越大。
probs = torch.softmax(model(X_test[:1]), dim=1)[0]
训练好之后,测试集第一张图的概率:
测试集第一张图(标签 2)的预测概率:
0:0.00 1:0.00 2:1.00 3:0.00 4:0.00 5:0.00 6:0.00 7:0.00 8:0.00 9:0.00
网络几乎百分之百确定这是 2,它确实是 2。
你在第 01 模块第 2 课见过完全一样的东西:大模型每一步输出的,就是词表里每个词元的分数,经过 softmax 变成概率,再按概率选下一个词元。区别只在于大模型的"类别"有十几万个。
交叉熵:分类的损失
回归用均方误差,分类用交叉熵(cross entropy)。它的算法很简单:看网络给正确答案的概率是多少,取对数,再取负号。
正确答案的概率 损失 = -log(概率)
1.00 0.00
0.90 0.11
0.50 0.69
0.10 2.30
0.01 4.61
正确答案的概率越接近 1,损失越接近 0;概率越小,损失越大,而且涨得很快。一个"很自信地答错"的网络会被重重地罚。
还有一个数值值得记住:刚开始训练时,网络什么都不懂,10 个类别的概率差不多都是 0.1,损失大约是 -log(0.1) = 2.30。如果你训练一个 10 分类的网络,第一步的损失离 2.3 很远,多半是哪里写错了。
PyTorch 的 nn.CrossEntropyLoss 把 softmax 和交叉熵合在一起算,所以模型的最后一层直接输出分数,不要自己再加 softmax。
loss_fn = nn.CrossEntropyLoss() # 分类问题用交叉熵
第 09 模块训练 GPT 用的也是它,那时的类别就是词表里的每一个词元。
小批训练
前几课每一步都用全部的数据算一次梯度。这一课改成每次取 64 张图:
optimizer = torch.optim.Adam(model.parameters(), lr=0.01)
for epoch in range(1, 31):
# 每一轮把训练集打乱,每次取 64 张图更新一次参数
order = torch.randperm(len(X_train))
for i in range(0, len(X_train), 64):
idx = order[i:i + 64]
loss = loss_fn(model(X_train[idx]), y_train[idx])
optimizer.zero_grad()
loss.backward()
optimizer.step()
这叫小批(mini-batch)训练。1347 张图,每批 64 张,一轮更新 22 次参数。把全部训练数据过一遍叫一轮(epoch)。
为什么不一次用全部数据?数据集大了,一次根本放不进内存,大模型的训练数据有上万亿个词元。而且小批的梯度虽然有些噪声,但更新得勤快,往往比每轮只更新一次学得更快。每轮开始前把顺序打乱,是为了让每一批都是随机的一小撮,不让网络依赖数据的排列顺序。
优化器也换成了 Adam。它在 SGD 的基础上,给每个参数单独调整步长:一直往同一个方向走的参数步子放大,来回摆动的参数步子缩小。第 2 课那种"一个学习率照顾不了两个方向"的问题,它能自动缓解很多。实际训练里,Adam 和它的变种 AdamW 是最常用的默认选择。
训练结果
训练:
第 1 轮 最后一批的损失 0.1804 训练集准确率 87.8% 测试集准确率 87.6%
第 2 轮 最后一批的损失 0.0335 训练集准确率 92.7% 测试集准确率 92.4%
第 5 轮 最后一批的损失 0.3066 训练集准确率 97.6% 测试集准确率 96.7%
第 10 轮 最后一批的损失 0.0285 训练集准确率 98.7% 测试集准确率 96.0%
第 20 轮 最后一批的损失 0.0035 训练集准确率 99.7% 测试集准确率 97.3%
第 30 轮 最后一批的损失 0.0001 训练集准确率 100.0% 测试集准确率 97.8%
在我的电脑上,整个脚本不到 5 秒就跑完了。
有两点值得看。
第一,"最后一批的损失"跳来跳去:第 2 轮 0.03,第 5 轮反而是 0.31。这就是小批的噪声:每一批只有 64 张图(最后一批只有 1347 - 21×64 = 3 张),碰巧抽到几张难认的,损失就高。判断训练得好不好,要看整个数据集上的指标,而不是某一批的损失。
第二,训练集准确率到了 100%,测试集是 97.8%。网络在见过的图上全对,在没见过的图上错了一些。这个差距下一课专门讲。
它错在哪里
准确率只是一个数。想知道网络错在哪里,看混淆矩阵:行是真实的数字,列是网络预测的数字,对角线上是答对的,其他位置是答错的。
测试集 450 张里错了 10 张。混淆矩阵(行是真实数字,列是预测数字):
0 1 2 3 4 5 6 7 8 9
0 37 . . . . . . . . .
1 . 42 . . . . . . 1 .
2 . . 44 . . . . . . .
3 . . 1 44 . . . . . .
4 . . . . 38 . . . . .
5 . . . . . 47 . . . 1
6 . 1 . . . . 51 . . .
7 . 1 . . . . . 47 . .
8 . 2 1 . . . . . 45 .
9 . . . . . 1 . 1 . 45
10 个错误里,有 4 个是被错认成 1 的(6 和 7 各一个,8 有两个)。把其中一张画出来:
一张判错的图:真实是 7,模型认为是 1
..####::
::++==
**--..
==######++
++####--
##==
##..
..##
这个 7 中间多了一横,下半部分就是一条竖线。8×8 的分辨率下,说它是 1 也不算离谱。看错误的样本,是了解模型的最好办法:有时候是模型的问题,有时候是数据本身就模糊,有时候甚至是标签标错了。这一点和第 06 模块看评估失败的案例是一样的。
练习
- 把隐藏层从 64 个神经元改成 16 个、256 个,训练集和测试集的准确率分别怎么变?
- 把
Adam换成SGD,学习率同样用 0.01,30 轮后测试集准确率是多少?把学习率调到多少,SGD 才能追上 Adam? - 在训练开始之前,先算一次整个训练集上的损失。它离 2.30 近吗?
自测
1. 分类任务的输出和回归任务有什么不同?softmax 在做什么?
回归输出一个连续的数。分类输出每个类别一个分数,softmax 对这些分数取指数再除以总和,把它们变成加起来为 1 的概率,分数越大的类别概率越大。
2. 交叉熵损失是怎么算的?10 分类的网络刚开始训练时,损失大约是多少?
取网络给正确答案的概率,算 -log(概率)。正确答案的概率越接近 1,损失越接近 0。刚开始训练时,10 个类别的概率都在 0.1 附近,损失大约是 -log(0.1) ≈ 2.30。
3. 为什么"最后一批的损失"忽高忽低?怎么判断训练的效果?
每一批只是一小部分数据,碰巧抽到难的样本损失就高,而且最后一批只有几张图。判断效果要看整个训练集和测试集上的指标,比如准确率或全部数据的平均损失,而不是某一批的损失。
提问与讨论
这一课没看懂的地方,在这里问。看到别人的问题,也欢迎你来回答。
提问 +3 积分,回答别人 +6 积分。内容经审核后公开。
正在加载讨论…