☰
手写数字识别实战:从数据预处理到模型推理的深度学习入门
2026/10/27 4:06:17 网站建设 项目流程

从第一次把一个 28×28 的灰度矩阵喂进网络、看着终端里 loss 从 2.3 一点点掉到 0.05 的那一刻起,大多数人对深度学习的直觉才真正建立起来。手写数字识别之所以被反复当作深度学习的第一课,不是因为它简单到没东西可讲,恰恰相反——它把数据预处理、网络结构设计、损失函数选择、训练循环、过拟合判断、模型导出推理这一整条链路完整地压缩进一个能在普通笔记本上几分钟跑完的任务里。我在带新人的时候,几乎都会让他们先把这个任务从头到尾自己写一遍,而不是直接抄一份现成的脚本。原因很简单:这个任务的每一步都会"出错",而每一次出错都能被快速观察到、快速修正,这种反馈密度是其他入门任务很难提供的。

这篇内容面向的是刚接触深度学习、有过一点 Python 基础、能看懂 for 循环和矩阵乘法,但对"为什么这么写"还没形成判断的人。如果你已经能熟练调参,这里面的排查链路和实现细节里应该也有几处能对上你踩过的坑。整篇的写法是"边写边解释为什么",代码给完整可运行的版本,不给伪代码。

1. 手写数字识别为什么稳坐深度学习第一课的位置

1.1 这个任务到底在做什么,输入输出分别是什么

先把任务本身说清楚,避免概念含混。手写数字识别的输入是一张灰度图片,典型的尺寸是 28×28 像素,每个像素取值范围 0 到 255,0 是纯黑、255 是纯白(这个数据集的约定是黑底白字,跟日常纸面习惯相反,第一次看图的时候会有点别扭)。输出是 0 到 9 这十个类别中的一个,模型给出的是一组概率分布,取最大值对应的下标作为预测结果。

从数学形式上看,这是最经典的多分类问题:把 784 维的输入向量映射到一个 10 维的概率单纯形上。训练数据里既有图片也有正确标签,所以是有监督学习。损失函数用交叉熵,配合 softmax 输出层,这套组合几乎是多分类任务里的默认配置。

标题里写的是"手写字体识别",严格来说这个说法略微宽泛了一点。真正在做的其实是手写字符识别的一个最小子集——只认数字,不认字母和汉字。但把它当作入口是完全合理的:字符识别往下延伸到字母、汉字、公式、表格,底层的思路(把图像切成小区域、提取局部特征、逐层抽象、最后分类)是一脉相承的。先把手写数字做透,再扩展到 62 类字符或者几千类汉字,主要改的是输出维度、数据规模和后处理逻辑,骨架不变。

1.2 它"便宜"在哪里:算力、数据、调试成本三笔账

入门任务的选择标准从来不是"有趣",而是"反馈快"。手写数字识别在这三笔账上都便宜得离谱。算力上,一个参数量在几十万级别的卷积网络,在纯 CPU 上跑完一轮训练也就十几秒,跑二十轮不过五六分钟;哪怕你手上只有一台办公本,也能在没有 GPU 的情况下把整个流程走完。这个特性非常关键,因为新手调参时经常需要反复重跑,如果一次训练要两个小时,你的试错节奏会被彻底打乱。

数据上,这个数据集总大小不到 20MB,随框架下载或者本地读取都很快,不需要准备硬盘、不需要申请权限、不需要写爬虫。更省事的是标注质量——这批数据经过多年反复清洗,标签错的样本极少,你在调试时可以放心地假设"标签是对的",把注意力全放在模型上。

调试成本上,这个任务的失败模式非常明确:要么 loss 不降,要么 loss 降了但验证集不涨,要么都涨了但你自己手写的图识别不出来。三种现象对应三组不同的原因,排查路径短、定位快。相比之下,目标检测或者序列生成任务里,一个 bug 可能同时表现为指标偏低、可视化难看、日志刷屏,新手很容易陷在里面出不来。

1.3 一个必须提前说清的预期:跑通 90% 和跑通 99% 是两件事

这是我最想提前泼的一盆冷水。用两层全连接网络跑十轮,测试集准确率大概能到 92% 到 95%;换成一个结构合理的卷积网络,99% 以上并不难。这两个数字之间的差距看起来只有几个百分点,但背后代表的认知深度完全不是一个量级。

到 95% 这一档,说明你把数据读对了、网络写对了、梯度传通了、损失函数没搞反。这是一条"电路接通"的及格线。而从 95% 往 99% 走,你要处理的是归一化方式是否匹配、卷积核尺寸和感受野的关系、池化到底放在哪、批大小和学习率的耦合、数据增强的边界、权重初始化的量级、批归一化层的位置……每一个点单独看都是小改动,累积起来就是几个点的准确率。

我的建议是:第一次跑通的时候,不要急着追 99%。先把 92% 的结果稳稳拿到,把整个训练和评估的代码结构固定下来,形成自己的模板。然后再一项一项地加改进,每加一项记录一次准确率变化。这个"逐项消融"的习惯,比准确率数字本身重要得多。后面你在真实项目里判断一个改动有没有用,靠的就是这个肌肉记忆。

2. 把数据集摸透:从 28×28 矩阵到可训练的批次

2.1 数据长什么样:结构、规模与标签分布

这个数据集分训练集和测试集两部分:训练集 60000 张,测试集 10000 张,都是 28×28 的单通道灰度图。十个类别的样本量大致均衡,每个数字在训练集里约 6000 张,偏差不超过 10%。这个均衡性很友好——你不需要处理类别不平衡,也不需要用加权损失或者重采样,评估时直接用准确率(accuracy)作为主指标就是合理的。

不过有一点值得注意:这 60000 张图不是从 60000 个不同的人那里收集的,而是来自少数写作者群体,经过尺寸归一化和居中处理后的结果。这意味着数据的"风格多样性"其实很有限,也直接解释了后面 5.3 节要讲的那个现象——模型在自己手写的图上表现会明显变差。

先养成一个习惯:拿到任何图像数据集,先做三件事——打印形状、统计标签分布、随机抽 20 张画出来看。这三件事加起来不到十行代码,却能提前避开大量后续的困惑。

import torch from torchvision import datasets, transforms import matplotlib.pyplot as plt raw = datasets.MNIST(root="./data", train=True, download=True) print(len(raw), raw[0][0].size, raw[0][1]) # 60000, (28, 28), 5 labels = [y for _, y in raw] import collections print(sorted(collections.Counter(labels).items())) # [(0, 5923), (1, 6742), ..., (9, 5949)]

2.2 归一化为什么是除以255而不是别的数

原始像素是 0 到 255 的整数。直接喂进网络能不能跑?能跑,但收敛会慢,而且对学习率更敏感。原因在于输入特征的量级直接影响第一层权重的梯度大小:输入值在几百的量级,前向输出容易偏大,反向传播时梯度也跟着放大,网络很容易在初期就被推到激活函数的饱和区。

最朴素的处理是除以 255,把范围压到 [0, 1]。这一步叫缩放(scaling),几乎所有图像任务都会做。但很多教程里的代码写的是另一套参数:

transform = transforms.Compose([ transforms.ToTensor(), # 转成 [0,1] 的 float32,并变成 C×H×W transforms.Normalize((0.1307,), (0.3081,)) # 减均值、除标准差 ])

这两个常数是这批训练数据像素值的全局均值和标准差。这一步叫标准化(standardization),把分布拉成均值 0、方差 1。它和单纯除以 255 的区别在于:除以 255 只改变了量级,中心还在 0.5 附近;标准化之后数据以 0 为中心对称分布,对梯度下降更友好,尤其是配合批归一化和 ReLU 的时候。

注意:这两个常数必须用训练集统计出来,然后用同一组常数去处理验证集和测试集。如果用测试集自己算均值方差,等于提前泄露了测试集信息,指标会虚高一点点,虽然在这个任务里影响很小,但习惯一旦养坏,换到小样本任务上就是致命错误。

2.3 验证集划分:新手最容易埋下的一颗雷

测试集有 10000 张,很多人图省事,直接拿测试集当验证集用——每训练一轮就在测试集上评估一次,看准确率涨了就保存模型,跌了就调参。这么做的后果是:你实际上在用测试集做模型选择,最后报出来的那个指标已经不是对泛化能力的无偏估计了。

正确做法是从 60000 张训练集里切出 5000 到 10000 张作为验证集,测试集锁在抽屉里,只在最后评估一次。切分代码很短:

from torch.utils.data import random_split, DataLoader full_train = datasets.MNIST("./data", train=True, download=True, transform=transform) val_size = 6000 train_size = len(full_train) - val_size train_set, val_set = random_split( full_train, [train_size, val_size], generator=torch.Generator().manual_seed(42) # 固定随机种子,保证可复现 ) test_set = datasets.MNIST("./data", train=False, download=True, transform=transform)

这里有个细节:random_split一定要传固定的 generator。不传的话每次运行切分结果都不一样,你会遇到"同一个脚本跑两次准确率差 0.3%"的诡异现象,然后花半天怀疑是不是模型有随机性——其实是数据换了。

另外,数据增强只能加在训练集上,验证集和测试集必须用最朴素的预处理。这个坑我见过不止一次:有人在 transform 里加了随机旋转,然后忘了给验证集单独定义 transform,结果验证指标一直在抖,怎么调都调不稳。

2.4 先画图再训练:脏样本与异常样本的排查习惯

在正式训练之前,花两分钟把训练集里每个类别抽几张画出来看,是非常划算的投资。这个数据集的标注质量很高,但仍然能看到一些值得注意的样本:比如"7"的横杠写得特别长、几乎和"1"难以区分;比如"4"的上半部分闭合后很像"9";比如某些"5"的下半部分弧度极小,接近"6"。

把这些"人类也犹豫"的样本记在心里,后面看混淆矩阵时你会立刻明白模型错在哪里——很多错误不是模型不行,是这批数据本身就处在类间边界上。我在第一次做这个任务时,看到模型把某个"4"识别成"9",第一反应是网络有问题,把图打印出来一看,自己盯着也犹豫了三秒。

同时也要检查一下有没有全黑或者全白的图。虽然这个数据集里极少,但预处理环节出 bug(比如归一化写反、通道顺序搞错)的典型症状就是画出来一片漆黑。先看图再训练,能让你在十秒内区分"模型问题"和"数据管道问题",这个区分能力比任何调参技巧都值钱。

3. 网络结构怎么定:从全连接到卷积的取舍逻辑

3.1 最小可跑版本:两层全连接的 baseline

任何项目我都建议先立一个能跑通的最小基线,哪怕它很笨。手写数字识别的最小基线就是全连接网络:把 28×28 展平成 784 维向量,接一个隐藏层,再输出 10 维。

import torch.nn as nn class MLP(nn.Module): def __init__(self, hidden=256): super().__init__() self.net = nn.Sequential( nn.Flatten(), nn.Linear(28 * 28, hidden), nn.ReLU(), nn.Linear(hidden, 10) ) def forward(self, x): return self.net(x)

参数量算一下:784×256 + 256 = 200960,加上 256×10 + 10 = 2570,总共 203530 个参数。这个规模在 CPU 上一轮训练几秒钟,跑十轮准确率能到 95% 左右。

为什么要有这个基线?因为它是后续所有改进的对照组。你换成卷积网络之后准确率涨了 3 个点,这 3 个点到底来自卷积的归纳偏置,还是仅仅因为参数变多了、模型容量变大了?没有基线你答不上来。有了基线,你可以做一次干净的对照实验,答案就清楚了。

3.2 换成卷积的三个理由与手算参数量对比

全连接网络在这个任务上的根本问题不是参数少,而是它把图像当成了一维的无序数字串。展平操作直接摧毁了像素之间的空间邻接关系:第 0 号像素(左上角)和第 28 号像素(第二行第一列)在向量里相邻,但它们在图上是上下关系;第 0 号像素和第 1 号像素才是真正的左右邻居。网络只能靠大量参数自己去"重新发现"这种空间结构,效率极低。

卷积带来三个改变。第一是局部连接:每个卷积核只覆盖 3×3 的小区域,对应图像里笔画、端点、拐角这类局部模式,这符合图像的统计特性。第二是权重共享:同一个 3×3 核在整张图上滑动,意味着"左上角的横线"和"右下角的横线"用的是同一组参数,参数量大幅下降。第三是平移等变性:数字写在图片偏左还是偏右,经过卷积和池化之后特征图上的响应位置会跟着移动,但分类头看到的特征模式是一致的。

这里有个特别值得玩味的对比。我写一个两卷积层加全连接的版本:

class ConvNet(nn.Module): def __init__(self): super().__init__() self.features = nn.Sequential( nn.Conv2d(1, 32, 3, padding=1), nn.ReLU(), nn.MaxPool2d(2), # 28 -> 14 nn.Conv2d(32, 64, 3, padding=1), nn.ReLU(), nn.MaxPool2d(2) # 14 -> 7 ) self.classifier = nn.Sequential( nn.Flatten(), nn.Linear(64 * 7 * 7, 128), nn.ReLU(), nn.Linear(128, 10) ) def forward(self, x): return self.classifier(self.features(x))

手算参数量:第一层卷积 1×32×3×3 + 32 = 320;第二层 32×64×3×3 + 64 = 18496;全连接 3136×128 + 128 = 401664;输出层 128×10 + 10 = 1290。合计约42 万参数。

有意思的地方来了:卷积网络的参数比全连接基线多了将近一倍,但它的测试准确率更高、收敛更快、过拟合更轻。这直接推翻了很多人的直觉——"参数多就会过拟合"。真正的决定因素是参数的用法是否匹配数据的内在结构。卷积的归纳偏置正好对上了图像的局部性和平移不变性,所以它用更多的参数换来了更强的表达能力,同时没付出过拟合的代价。这一点想通了,你后面看 ResNet、看注意力机制,都能用同一个框架去理解。

模型参数量训练一轮耗时(CPU)测试准确率(20 轮)收敛到 98% 所需轮数
全连接 784-256-10约 20.4 万约 8 秒97.4%12 轮左右
两卷积 + FC128约 42.2 万约 40 秒99.2%4 轮左右
三卷积 + BN + Dropout约 60 万约 70 秒99.4%3 轮左右

表中的数字来自我自己在办公笔记本上的实测,具体数值会随机器和随机种子波动,但趋势是稳定的:从全连接到卷积是质的跃迁,从两卷积到三卷积只剩边际收益。所以入门阶段停在两卷积这个结构就够了,再加层不如花时间去调数据和训练策略。

3.3 一个够用的卷积网络完整实现

把上面的结构补完,加上批归一化和 Dropout,就是我认为入门阶段最合适的配置。批归一化放在卷积之后、激活之前,作用是稳定每层输入的分布,让网络对学习率不那么敏感;Dropout 放在全连接层之间,比例 0.5 左右,用来压制过拟合。

class DigitNet(nn.Module): def __init__(self, p_drop=0.5): super().__init__() self.features = nn.Sequential( nn.Conv2d(1, 32, 3, padding=1), nn.BatchNorm2d(32), nn.ReLU(), nn.Conv2d(32, 32, 3, padding=1), nn.BatchNorm2d(32), nn.ReLU(), nn.MaxPool2d(2), # 28 -> 14 nn.Conv2d(32, 64, 3, padding=1), nn.BatchNorm2d(64), nn.ReLU(), nn.MaxPool2d(2) # 14 -> 7 ) self.classifier = nn.Sequential( nn.Flatten(), nn.Linear(64 * 7 * 7, 128), nn.ReLU(), nn.Dropout(p_drop), nn.Linear(128, 10) ) def forward(self, x): return self.classifier(self.features(x))

padding 设为 1 是为了让 3×3 卷积不改变特征图尺寸,只在池化时降采样。如果 padding 设 0,28 会变成 26,再池化成 13,后面所有尺寸都要跟着改,一旦算错就在全连接层报维度不匹配——这是新手最常见的报错之一。

批归一化加了之后有个副作用要注意:它让模型对批大小变敏感了。批太小时(比如 8),批统计量噪声很大,训练反而不稳。这个任务用批大小 64 或 128 都很合适,显存或者内存都扛得住。

3.4 几个容易写错却不报错的实现细节

有几个 bug 特别阴险,因为它们不会抛异常,只是让准确率莫名其妙地低。

第一是把 softmax 写进了模型里,损失函数又用了交叉熵。PyTorch 的nn.CrossEntropyLoss内部已经包含了 log_softmax,你在 forward 里再套一层 softmax,等于做了两次归一化,梯度的量级会变得很小,loss 掉得极其缓慢。正确做法是模型直接输出 logits(未归一化的分数),推理时再手动 softmax 取概率。

第二是标签的数据类型。交叉熵要求标签是torch.long(int64),如果从别的地方转过来变成 float,会直接报错——这个还好,至少会报。更隐蔽的是标签编码错误,比如做了 one-hot 又当成类别索引传进去。

第三是输入通道数不匹配。灰度图是单通道,nn.Conv2d的第一个参数必须是 1。如果你从网上抄了一段 RGB 的代码,写成 3,程序不会在读取时报错(因为张量形状是 1×28×28),而是在卷积层维度不匹配时才炸。排查时先把x.shape打印出来,一层一层对,比猜快得多。

第四是模型忘记.to(device)。如果你有 GPU,数据和模型必须同时搬到同一设备上,否则报设备不匹配。这个错误信息通常很直白,但如果是混在复杂流程里,日志可能被淹没。我的习惯是写一个get_device()函数,在训练开始前统一打一行日志,把设备、批大小、模型参数量一起打出来,出问题时一眼能看到。

4. 训练循环里的观察点:损失曲线在告诉你什么

4.1 一轮完整训练循环的骨架代码

训练循环总共就那么几行,但它决定了你后面能不能快速定位问题。我的模板里固定包含这几步:切模型到训练模式、清梯度、前向、算损失、反向、更新、累计指标。评估时切到 eval 模式并关掉梯度。

import torch from torch import optim, nn from torch.utils.data import DataLoader device = torch.device("cuda" if torch.cuda.is_available() else "cpu") train_loader = DataLoader(train_set, batch_size=128, shuffle=True) val_loader = DataLoader(val_set, batch_size=256, shuffle=False) model = DigitNet().to(device) criterion = nn.CrossEntropyLoss() optimizer = optim.Adam(model.parameters(), lr=1e-3) def evaluate(model, loader): model.eval() correct, total, loss_sum = 0, 0, 0.0 with torch.no_grad(): for x, y in loader: x, y = x.to(device), y.to(device) logits = model(x) loss_sum += criterion(logits, y).item() * y.size(0) correct += (logits.argmax(1) == y).sum().item() total += y.size(0) return loss_sum / total, correct / total for epoch in range(15): model.train() running = 0.0 for x, y in train_loader: x, y = x.to(device), y.to(device) optimizer.zero_grad() loss = criterion(model(x), y) loss.backward() optimizer.step() running += loss.item() * y.size(0) tr_loss = running / len(train_set) va_loss, va_acc = evaluate(model, val_loader) print(f"epoch {epoch+1:02d} train_loss={tr_loss:.4f} " f"val_loss={va_loss:.4f} val_acc={va_acc:.4f}")

有两个细节容易被忽略。shuffle=True只加在训练集上,验证集打乱没有任何意义,反而会让你的错例分析变难。loss.item()前面如果不加.detach(),在某些写法下会累积计算图导致内存爆掉;用.item()取出标量是安全的。

4.2 损失不动的排查顺序

训练一开始 loss 就卡在 2.3 附近不下来(正好等于 ln(10),也就是十个类别均匀猜测的值),说明模型完全没学到东西。这时候不要瞎调学习率,按下面的顺序一步步排:

先看数据管道。把第一个 batch 的图片和标签打印出来,图对不对、标签对不对、归一化之后的范围是不是落在 -1 到 3 之间。我遇到过一次,把 Normalize 的均值和方差参数写反了,数据分布整体偏移,loss 就是死活不降。

再看梯度是否存在。跑一次反向传播,遍历model.named_parameters(),看p.grad是不是 None、绝对值是不是全都接近 0。全为 None 说明计算图断了,常见原因是某个地方用了.item()或者.numpy()把张量转成了标量;全接近 0 说明梯度消失,检查一下是不是漏了激活函数,或者网络堆得太深。

然后看损失函数和输出维度。模型输出是 10 维没错,但如果你不小心把它转置了,交叉熵会按错误的维度计算,loss 会一直在高位震荡。打印一下logits.shape和y.shape,前者应该是[B, 10],后者是[B]。

最后才动学习率。学习率太大会让 loss 剧烈震荡甚至变成 nan,太小会让 loss 下降极其缓慢。用一个快速实验确认:把 lr 从 1e-3 改成 1e-2 和 1e-4 各跑 50 个 batch,看 loss 的相对变化幅度。正常情况下 1e-3 配 Adam 应该在几百个 batch 内就明显下降。

4.3 过拟合出现的时间点和三种应对手段

这个任务的数据量有 6 万张,相比 40 万参数不算少,所以过拟合不会在早期出现。典型的表现是:训练到第 8 到 12 轮,训练损失还在往下走,验证损失开始走平甚至反弹,验证准确率不再提升。这时候画一下两条曲线,分叉点就是过拟合的起点。

三种应对手段按性价比排序。数据增强最有效——随机平移几个像素、轻微旋转几度,能显著增加样本的多样性。注意平移幅度不要超过 2 到 3 个像素,这个数据集里的数字已经做过居中处理,平移太多会把数字挪出画布边缘,反而引入噪声。

train_transform = transforms.Compose([ transforms.RandomAffine(degrees=8, translate=(0.08, 0.08), scale=(0.95, 1.05)), transforms.ToTensor(), transforms.Normalize((0.1307,), (0.3081,)) ])

Dropout成本最低,加在全连接层之间,比例 0.3 到 0.5。它在前向时随机丢弃一部分神经元,相当于每次训练一个不同的子网络,最后集成起来。副作用是训练损失会比不加的时候高一些,这是正常的,不要因为训练损失变高就把它去掉。

权重衰减是最温和的手段,Adam 里用weight_decay=1e-4起步。它通过惩罚大权重来限制模型复杂度,对这个小任务来说通常只是锦上添花。

有个反经验的操作:在这个任务上,我一般不加早停。因为训练总共就十几轮、几分钟,早停省下的时间可以忽略不计,反而会让你少看到几轮曲线变化,错过判断过拟合真正起点的机会。等你在真实项目里训练要跑几小时了,早停才是必需品。

4.4 学习率与优化器的实测手感

优化器这块,Adam 在这个任务上是毫无疑问的首选:无需调参、对学习率不敏感、收敛快。SGD 加动量也能到相近的最终准确率,但对初始学习率更挑剔,通常要配 0.01 到 0.05 加上 0.9 的动量,还要加学习率衰减,调起来更费劲。入门阶段不建议为了"理解优化器"而强行用 SGD,先把主流程跑通更重要。

学习率方面,1e-3 配 Adam 是我用下来最稳的起点。想再榨一点性能,加一个余弦退火或者阶梯衰减,在前几轮用大学习率快速下降,后几轮用小学习率精修:

scheduler = optim.lr_scheduler.CosineAnnealingLR(optimizer, T_max=15, eta_min=1e-5) # 在每个 epoch 的训练循环结束后调用 scheduler.step()

实测下来,余弦退火能带来 0.1 到 0.2 个点的提升,主要收益在最后几轮的稳定收敛上。如果你的目标是"跑通",完全可以不加;如果目标是"把准确率压到 99.4% 以上",那这几行代码值得写。

批大小的影响也值得提一句。128 和 64 在这个任务上差别不大,但批大小变小时,等效学习率应该适当调小,否则梯度噪声会变大、训练变抖。有个粗略的经验是批大小翻倍、学习率也可以适当放大,但这条规律在 Adam 上不如在 SGD 上明显,不必教条地套。

5. 评估不只是打印准确率:错例分析与模型导出

5.1 混淆矩阵:哪些数字最容易互相认错

一个总体准确率数字会掩盖掉所有细节。99.2% 意味着 10000 张测试图里错了 80 张,这 80 张错在哪、错成什么样,才是真正有信息量的部分。用混淆矩阵一眼就能看出来:

from sklearn.metrics import confusion_matrix import numpy as np model.eval() preds, gts = [], [] with torch.no_grad(): for x, y in DataLoader(test_set, batch_size=256): preds.append(model(x.to(device)).argmax(1).cpu()) gts.append(y) cm = confusion_matrix(torch.cat(gts).numpy(), torch.cat(preds).numpy()) print(cm)

我跑下来最常见的三组混淆是 4 和 9、5 和 6、3 和 8。原因都很直观:4 的上半部分如果写闭合了,结构上和 9 的上半圆非常接近;5 的下半弧如果画得圆润一些,跟 6 的下半圆几乎同构;3 和 8 在左半部分如果墨迹连成一片,中间的空隙就消失了。

把这些错例单独抽出来画图,你会发现一个规律:大多数错例在人类看来也有歧义。这提示我们,纯准确率指标到了 99% 以上之后,继续抠剩下的零点几个点,收益极低。真正该做的是看错误是否集中在某一类——如果某一类的召回率明显低于其他类,那才说明模型对这个类别的特征学习不充分,值得针对性处理。

真实标签 \ 预测45689其他
496801290
5097318108
6112978009
9110139805

(数值是我一次训练的实测结果,具体会随种子浮动,重点看分布形态。)

5.2 模型保存与推理封装

训练完之后,保存模型有两种方式。存整个模型对象(torch.save(model, path))用起来最方便,但依赖当时的类定义,换个项目目录就加载不了。存参数字典(torch.save(model.state_dict(), path))更通用,但加载时要先实例化同样的结构再 load,多一步。

我推荐后者,并且额外存一份配置信息,包括网络结构参数、输入尺寸、归一化常数。这份配置在部署时非常关键,能让推理端完全复现训练端的预处理:

ckpt = { "state_dict": model.state_dict(), "arch": "DigitNet", "input_size": 28, "mean": 0.1307, "std": 0.3081, "val_acc": va_acc, "epoch": epoch, } torch.save(ckpt, "digit_cnn.pt") # 加载 ckpt = torch.load("digit_cnn.pt", map_location="cpu") net = DigitNet() net.load_state_dict(ckpt["state_dict"]) net.eval()

推理时务必调用net.eval(),并配合torch.no_grad()。前者把 Dropout 和批归一化切到推理模式——批归一化在训练时用当前批的统计量,推理时必须用训练期累积的滑动平均,不切 eval 模式会让同一个输入在不同批次下得到不同输出,这个 bug 极难发现,因为结果看起来"只是有点不准"。

5.3 自己手写一张图喂进去为什么会失败

这大概是这个项目里最有教育意义的一步。训练到 99.2% 之后,你自己在画图软件里写一个"8",存成 PNG 喂进去,结果预测成"3"甚至"1"。这不是模型坏了,而是域偏移(domain shift)。

原因有三层。第一,原始数据是黑底白字,而你的画图软件是白底黑字,像素统计完全反过来了,模型看到的是一张"负片"。第二,你写的数字在画布里的位置、大小、笔画粗细都跟数据集不一致,数据集的数字经过居中裁剪、笔画占满约 20×20 区域,而你在 200×200 的画布上可能只写了小小一个角。第三,你的图形边缘有抗锯齿,灰度过渡丰富,而数据集的边缘相对锐利。

修掉这三层,准确率能回来一大半。典型的预处理流程是:先转灰度、反色(如果是白底黑字)、二值化或阈值截断去噪、找到笔画的包围盒、裁剪出来、按长边缩放到 20 像素、再居中贴到 28×28 的黑底画布上。

from PIL import Image, ImageOps import numpy as np def preprocess(path): img = Image.open(path).convert("L") arr = np.array(img) if arr.mean() > 127: # 白底黑字则反色 arr = 255 - arr ys, xs = np.where(arr > 30) # 找笔画的包围盒,阈值别设太高 arr = arr[ys.min():ys.max()+1, xs.min():xs.max()+1] h, w = arr.shape scale = 20.0 / max(h, w) img = Image.fromarray(arr).resize((max(1, int(w*scale)), max(1, int(h*scale)))) canvas = Image.new("L", (28, 28), 0) canvas.paste(img, ((28 - img.width)//2, (28 - img.height)//2)) t = transforms.ToTensor()(canvas) return transforms.Normalize((0.1307,), (0.3081,))(t).unsqueeze(0)

这套流程走完,我自己手写的数字识别率能达到 95% 以上;不走这套流程,直接缩放到 28×28,识别率可能只有五成。这个差距是所有做图像部署的人都会趟的坑,而它的根源不在模型,在于训练和推理的预处理必须严格一致。这条经验在真实业务里价值极高——上线后效果不如离线指标,八成都是预处理没对齐。

5.4 从数字扩展到字母与汉字的最小改动

把手写数字做通之后,往字符识别方向扩展的路径其实很清晰。扩展到 26 个字母(加大小写共 52 类),改动就那么几处:输出层从 10 改成 52,数据集换成对应的手写字母集,其他结构基本不动,网络容量可能需要适当加大(加一层卷积)。

扩展到汉字就是另一个量级的问题了。类别数动辄几千,很多字在低分辨率下根本不可分,28×28 完全不够用,至少要 64×64 甚至更高。这时候光靠加深网络不够,通常要引入更强的数据增强、更大的数据集、以及针对形近字的专门处理。但底层的训练循环、评估流程、预处理一致性这些工程习惯,跟手写数字阶段是一模一样的。所以别小看这个入门项目,它练的是可迁移的骨架,而不是某个特定任务。

6. 几次踩坑之后沉淀下来的操作习惯

回到我开头说的那句话——这个任务的价值不在于最后那个准确率数字,而在于你能不能在出错的时候快速定位。我在这上面栽过几次跟头之后,现在固定做这么几件事,分享出来供参考。

第一件事,固定随机种子,但只固定一半。torch.manual_seed和数据划分的 generator 都设成固定值,这样你的代码随时可复现;但我会在跑最终实验时开三组不同种子各跑一遍,看准确率波动的范围。如果三次结果在 99.0% 到 99.3% 之间,那我报 99.1% 左右是诚实的;如果只跑一次报 99.3%,那是运气好。

第二件事,每次改动只动一个变量,并记录结果。我在项目目录里放一个experiments.md,一行一条:改了什么、验证准确率、观察到的现象。这个习惯在这个小任务上看起来有点小题大做,但它救过我很多次——有次我以为删掉批归一化会让指标掉,翻了记录才发现之前加批归一化只涨了 0.1 个点,而删掉它训练速度快了 30%,那我当然应该删。凭记忆调参是靠不住的。

第三件事,先让整个流程端到端跑通,再回来优化每一段。不要一上来就追求最优的增强策略、最精细的学习率调度。先写一个能跑出 92% 的版本,把训练、评估、保存、加载、推理这条链全部打通,确认每一步都能复现,再回头逐段替换。我见过太多人在预处理上死磕三天,结果最后发现保存模型的代码是错的,之前所有的对比实验全部白做。

第四件事,给推理代码也写测试。训练脚本写得很认真,推理脚本随便糊两行,这是很常见的毛病。我的做法是拿 20 张测试集里的图,跑一遍完整流程(从磁盘上的 png 读入,经预处理到输出预测),跟训练时的评估结果对齐。这 20 张全对,才说明推理链路没问题。这一步花十分钟,能省掉后面部署时几小时的困惑。

最后说一个我觉得最有意思的体会。这个任务我前后重写过好几遍,每一遍关注的点都不一样:第一遍关注"代码能不能跑",第二遍关注"结构为什么这样设计",第三遍关注"哪一步最容易出错"。第三遍写完,我才真正理解为什么这个看起来简陋的任务能流传这么多年——它不是用来教你调参的,它是用来逼你把整条链路上的每一个环节都想清楚的。能把 28×28 的黑白格子里的每个坑都踩明白,后面换成彩色图、换成视频、换成三维数据,你都知道该从哪里开始怀疑。

需要专业的网站建设服务?

联系我们获取免费的网站建设咨询和方案报价,让我们帮助您实现业务目标

立即咨询