手写字体识别这个任务,几乎成了每个刚接触深度学习的人绕不过去的第一道坎。听起来像是个玩具级的问题:一张 28×28 的灰度小图,上面画着一个 0 到 9 之间的数字,让机器告诉你是几。但真正动手做过的人都知道,从环境装不上、张量维度对不齐,到训练损失卡在 2.3 不动、测试准确率突然跳水的这一段路,踩的坑一个都不少。这篇东西不打算复述任何教科书的推导,只讲我带着几个新手从零跑到能用、再从"能跑"推到"能解释"这一路上真正有用的东西——包括网络结构为什么这么选、每一层输出的形状到底怎么算、参数量和计算量是怎么手算出来的、以及那些教程里通常一句话带过的预处理和调参细节。无论你是完全没写过训练循环的小白,还是已经能跑通模型但说不清为什么准的进阶者,下面这些内容应该都能直接拿去用。
1. 为什么偏偏是手写字体识别当入门第一课
1.1 这个任务真正在解决的问题是什么
把问题说清楚,比急着写代码重要得多。手写字体识别要做的事情,本质上是从一张二维像素矩阵映射到一个离散类别标签。输入是 28×28 的灰度图,展开就是 784 个 0 到 255 之间的整数;输出是 10 个候选类别中的某一个。中间隔着的东西是:同一个数字在不同人笔下,笔画粗细、倾斜角度、连笔方式、书写位置都不一样,甚至连"7"有没有那一横、"4"封不封口都不统一。所以你没法写一条 if-else 规则来判定,规则写不完,写了也覆盖不全。
这正是深度学习方法出场的理由。它不靠人去设计特征,而是让网络自己在大量样本上找出一组可判别的特征表达。浅层学到的往往是边缘、笔画朝向这类低阶模式,深层组合出的是闭合环、交叉点、圆弧开口方向这类结构性信息。分类头最后做的事很朴素:把这些结构特征加权求和,得到 10 个分数,再归一化成概率。
想明白这一点,你就会知道为什么预处理那么关键——归一化改变了输入分布的尺度,直接影响所有层拿到的数值范围;也能理解为什么网络太深反而可能变差——28×28 的信息量有限,堆太深会先把有效信号压没了。这个认知一旦建立,后面所有的参数选择就不再是抄别人的超参数表,而是有自己的判断依据。
1.2 相比其他入门任务,它的性价比高在哪
我试过让新手从图像分类的通用大场景入门,也试过从文本情感分析切入,最后发现手写数字这条路的学习曲线最平滑,原因很实际。第一,数据不稀缺也几乎不需要清洗。经典手写数字基准集有 6 万张训练图、1 万张测试图,标签干净、类别均衡,单张图只有 784 字节,整个数据集压缩后几十兆,一台没有显卡的笔记本就能跑完全程。第二,单次训练的反馈极快。一个四层的小卷积网络,在普通 CPU 上跑一轮也就几十秒到几分钟,这意味着你可以在一小时内做完十几次对照实验,比如把学习率从 1e-3 调到 1e-4 看损失曲线有什么变化——这种快速试错的体验对建立直觉极其重要。
第三,评估标准单一且客观。准确率一个数字就能说明大部分问题,不需要像目标检测那样去纠结 IoU 阈值,也不像生成任务那样靠人眼看图打分。第四,错误样本可解释。模型认错的那几百张图,你可以一张一张翻着看,很容易分辨出是"笔画太细"还是"标签本身就写歪了",这个过程对理解模型能力的边界特别有价值。
反过来说,如果你一上来就做医学影像分割或者工业缺陷检测,数据拿不到、标注成本高、一次训练要跑几小时,出现问题你连"是数据的问题还是模型的问题"都分不清。入门阶段最怕的就是反馈回路太长。
1.3 谁适合照着往下做
具备 Python 基础语法能力、知道什么是函数和类、听说过矩阵乘法但不需要手推,这个门槛就够用了。数学部分我会在必要的地方把公式拆成"这句话在说人话是什么意思",比如梯度下降我会解释成"沿着下坡方向挪一小步",卷积我会解释成"拿一个小窗口在图上滑着看"。如果你已经能跑通别人的开源脚本但对里面每一行在干嘛没底,第四章的实操和第五章的排查对你价值最大。
至于设备,我建议第一次跑就别折腾显卡环境。小网络的瓶颈根本不在算力上,反而是配置文件、路径、版本不对这些琐事更耗时间。等你能稳定复现出 98% 以上的测试准确率了,再去考虑多卡、混合精度这些话题也不迟。
2. 整体方案:从一张灰度图到十个概率的完整链路
2.1 先把业务问题翻译成数学语言
这一步很多人跳过,结果后面调参全靠猜。手写数字识别的数学形式是:给定输入张量 x,形状为 [N, 1, 28, 28],其中 N 是批大小,1 是通道数(灰度图只有一个通道),28×28 是空间尺寸;模型 f 输出 logits,形状为 [N, 10];目标是让正确类别的那个位置上的值尽可能大。
这里有几个容易含糊的点。为什么用 logits 而不是直接输出概率?因为 softmax 和交叉熵在数值上可以合并计算,框架内部做的是 log-sum-exp 的稳定实现,你直接喂 logits 比先算 softmax 再取对数精度更高,也能避免极端值溢出。为什么批大小不设成 1?因为单样本的梯度噪声太大,权重更新方向抖得厉害;批大小增大相当于对多份梯度取平均,方向更稳,同时还能把矩阵运算并行起来。批大小也不是越大越好,太大会让一个 epoch 内的更新次数变少,收敛变慢,而且泛化性能有时候反而下降。
还有一个隐含的假设:训练集和测试集的分布是一致的。这句话听着废话,但真实业务里最容易翻车的就是这里——你在基准集上刷到 99%,换成同事用触屏写的数字立刻掉到 70%,因为笔画粗细和抗锯齿方式完全不同。养成"先想清楚训练分布和上线分布是否匹配"的习惯,比多背两个网络结构有用。
2.2 网络结构的选型逻辑
入门阶段我推荐小型卷积网络,不推荐直接用全连接网络,也不推荐一上来就上残差网络。原因分开说。
全连接网络的问题是参数浪费太严重。784 个输入直接连到 128 个隐藏单元,就是 10 万个参数,而这些参数之间没有任何空间上的共享——图像左上角的像素和右下角的像素在它眼里地位完全平等,它必须从零学出"相邻像素通常相关"这件事。卷积层天然带两个先验:局部连接(一个输出只看输入的一小块区域)和权重共享(同一个卷积核在整张图上滑动)。对图像任务来说,这两个先验几乎总是成立的,相当于免费给了模型一大批正确的先验知识。
那为什么不用残差网络?因为残差结构是为深层网络解决梯度退化问题设计的,而 28×28 的输入经过两三次下采样后就只剩 7×7 甚至 3×3 了,再深下去空间信息被彻底压扁,收益趋近于零,参数量却翻了好几倍。我在实践中试过把这个任务堆到 18 层,测试准确率反而不如四层的结构,训练时间翻了三倍多。结构复杂度要和任务复杂度匹配,这条经验在后来所有项目里都成立。
我最终选的是一个两层卷积加两层全连接的组合,中间夹批归一化和池化。批归一化解决的是内部协变量偏移——每层输入的分布随着前面层参数更新而漂移,导致后面层一直在追一个移动的目标。加了它之后学习率可以设大一些,收敛也稳。池化用的是 2×2 最大池化,作用是降维加一点平移不变性:数字稍微写偏一两个像素,池化后的结果基本不变。
2.3 训练策略与超参数的初始设定
超参数我不建议一上来就网格搜索,先给一组"经过大量实践验证的稳妥值",把流程跑通再说。我的默认起点是这样的:
| 超参数 | 初始取值 | 选择理由 |
|---|---|---|
| 批大小 | 128 | 兼顾梯度稳定性和单轮更新次数,显存/内存占用友好 |
| 初始学习率 | 1e-3(Adam) | Adam 对学习率不敏感,1e-3 是它的经典默认值 |
| 训练轮数 | 10 | 这个任务通常 5 轮内就到 98%,10 轮足够观察过拟合迹象 |
| 权重衰减 | 1e-4 | 轻量正则,防止全连接层参数膨胀 |
| 优化器 | Adam | 自适应步长,前期不需要精细调学习率 |
| 损失函数 | 交叉熵 | 多分类标准配置,配合 logits 输入 |
关于优化器我再补一句。随机梯度下降配合动量在调好学习率的情况下,最终泛化性能有时略优于 Adam,但它对学习率的敏感度高得多,学习率大一点就发散、小一点就爬得慢,入门阶段用它容易把时间浪费在调参上。Adam 的问题是后期可能在最优点附近震荡,这时候加一个学习率衰减策略(比如在第 6 轮和第 9 轮各降为原来的 0.1 倍)就能明显改善。
还有一个我踩过的坑:不要用训练集的准确率来判断模型好坏。训练准确率只反映拟合能力,模型完全可以把训练集背下来。真正的判断依据永远是留出的验证集或测试集。我在早期项目里就因为盯着训练损失一路降到 0.001 而沾沾自喜,结果测试集上错得离谱。
3. 核心细节拆解:教程里通常跳过的部分
3.1 张量形状是万恶之源
十次报错里有六次是形状不对。我把这条链路上每个节点的形状列清楚,你对着看就不会迷路。
输入是 [N, 1, 28, 28]。经过第一个卷积层(输出 32 通道,卷积核 3×3,填充 1),形状变成 [N, 32, 28, 28]。填充 1 的作用是让输出空间尺寸不变——公式是输出尺寸 = (输入尺寸 + 2×填充 - 卷积核) / 步长 + 1,代入(28 + 2 - 3)/1 + 1 = 28。如果不加填充,就变成 26,两次卷积下来尺寸掉得很快,边缘信息也丢得多。
接着 2×2 最大池化,步长默认等于核大小,所以 [N, 32, 28, 28] 变成 [N, 32, 14, 14]。第二个卷积层通道从 32 升到 64,尺寸保持 14,再池化一次变成 [N, 64, 7, 7]。到这里特征提取部分结束。
然后是全连接层,它只认二维矩阵,所以必须先展平:64 × 7 × 7 = 3136,形状变成 [N, 3136]。这个 3136 是硬编码的,一旦你改了输入尺寸或者少加一次池化,这个数字就必须跟着改,否则立刻报维度不匹配。我的建议是写一个辅助函数动态算出来,或者干脆用自适应平均池化把空间尺寸压到 1×1,彻底摆脱这个耦合。
最后输出 [N, 10]。如果你在做推理时忘了加torch.no_grad(),或者忘了model.eval(),或者在训练时忘了model.train(),批归一化和 Dropout 的行为就会错位,导致同一个模型在训练和评估时表现差异巨大。这三个调用看着琐碎,但每一个都是真实事故的来源。
3.2 卷积核到底在学什么
很多人把卷积当成黑盒,其实可以直观理解。一个 3×3 的卷积核就是 9 个可学习的权重,它在图上滑动时做的是"局部加权求和"。如果这 9 个权重长成上边一行是 -1、中间一行是 0、下边一行是 +1 的样子,那它实际上就是一个水平边缘检测器——上下亮度差得越多,响应越强。网络不需要人去设计这些核,反向传播会自动把它们调整到对分类最有用的形态。
第一层通常学到的是各种方向的边缘和简单纹理,这一点你完全可以把 32 个卷积核的可视化图打印出来看,会发现它们确实呈现出条纹、斑点、明暗块的样子。第二层在上一层的基础上组合,学到的是"两条边缘围成一个角"这种更抽象的模式。层数越深,感受野越大,看到的范围越广。
通道数为什么从 1 到 32 再到 64 逐层翻倍?因为随着空间分辨率下降,我们希望用更多的通道来补偿信息损失——空间上被压缩了,就用特征维度上的丰富度找补回来。这也是一种工程上的平衡习惯:下采样一次,通道翻一倍,这条经验在绝大多数卷积网络里都能看到。
卷积核大小选 3×3 而不是 5×5 或 7×7,理由是两个 3×3 堆叠的感受野等于一个 5×5,但参数量只有 2×9=18 对比 25,而且中间多了一次非线性激活,表达力更强。这个结论在实际项目中非常站得住脚。
3.3 损失函数与优化器为什么这么配
交叉熵的表达式是-log(正确类别的预测概率)。这句话的含义是:如果模型给正确类别打了 0.9 的概率,损失是 0.105;如果只打了 0.1,损失是 2.303。也就是说,损失对"自信地答错"惩罚极其严厉,而对"犹豫地答对"惩罚较轻。这正好符合我们想要的行为——宁可模型说"我不太确定",也别让它错得理直气壮。
梯度下降的直观图景是:把损失看作一个曲面,参数是曲面上的一个点,每一轮我们算一下这个点所在位置的坡度,然后朝着下坡方向挪一小步。步子大小就是学习率。步子太大会跨过谷底来回震荡,太小则半天走不到底。
Adam 在这个基础上做了两件事:一是给每个参数单独维护一个步长,更新频繁的参数步长自动变小;二是引入动量,把历史梯度也考虑进来,避免在狭窄方向上反复横跳。它需要两个衰减系数,默认 0.9 和 0.999,我基本没动过。实践里唯一需要注意的是,Adam 配合权重衰减时要用 AdamW,否则衰减的实现方式和理论不一致,正则效果会打折扣,这个细节在很多老代码里是错的。
关于学习率衰减,我用的是阶梯式:第 6 轮和第 9 轮各乘 0.1。你可以把每轮结束时的学习率打印出来确认生效,我就见过因为写错了调度器的步进时机,导致学习率从第一轮就掉到 1e-6,训练完全没动静的情况。
3.4 参数量与计算量的手算过程
会手算参数量,你对模型的"重量"就有数了,这直接影响部署决策。逐层算:
- 第一个卷积层:
1 × 32 × 3 × 3 + 32 = 320(权重加偏置) - 第一个批归一化:
32 × 2 = 64 - 第二个卷积层:
32 × 64 × 3 × 3 + 64 = 18496 - 第二个批归一化:
64 × 2 = 128 - 第一个全连接层:
3136 × 128 + 128 = 401536 - 第二个全连接层:
128 × 10 + 10 = 1290
合计约42 万参数,按单精度浮点算占 1.7MB 左右。这个体积意味着它可以轻松塞进任何嵌入式设备,甚至直接内联到小程序里跑。
计算量方面,卷积层的乘加次数是输出高 × 输出宽 × 输出通道 × 输入通道 × 核高 × 核宽:
- 第一个卷积:
28 × 28 × 32 × 1 × 3 × 3 ≈ 22.6 万 - 第二个卷积:
14 × 14 × 64 × 32 × 3 × 3 ≈ 361 万 - 第一个全连接:
3136 × 128 ≈ 40 万
单张图前向大约424 万次乘加。这个数字很关键,它解释了为什么 CPU 上也能做到毫秒级推理,也解释了为什么瓶颈在第二个卷积层——它的计算量占了八成以上。如果你的目标是极致轻量,优先压缩的就是这一层,比如把通道数从 64 降到 32,或者改用深度可分离卷积,计算量能降一个数量级,精度损失通常在 0.3 个百分点以内。
4. 完整实操:从零跑通一个能用的识别模型
4.1 环境准备与版本坑
我推荐用一个干净的虚拟环境,Python 版本选 3.9 到 3.11 之间都行,太新的版本有时候第三方包还没跟上。核心依赖就三个:深度学习框架本身、torchvision(提供数据集工具和图像变换)、以及 matplotlib 用来做可视化。安装时务必让框架和 torchvision 版本对应,这两个不同步是最高频的报错来源,典型症状是导入时抛出关于 C++ 扩展找不到的异常。
注意:不要在同一环境里混装 CPU 版和 GPU 版,也不要同时存在多个框架的旧版本,这类问题排查起来极耗时间,简单粗暴地重建一个干净环境往往更快。
装完立刻做一次自检,把版本号、是否可用加速设备、随机数种子都打印出来,并且固定种子:
import torch, torchvision, random, numpy as np seed = 42 random.seed(seed) np.random.seed(seed) torch.manual_seed(seed) torch.cuda.manual_seed_all(seed) print("torch:", torch.__version__) print("torchvision:", torchvision.__version__) print("device:", "cuda" if torch.cuda.is_available() else "cpu")固定种子的意义在于让实验可复现。不过要有心理准备,即使固定了种子,因为部分算子在不同硬件上的实现顺序不同,结果仍可能有千分位级别的浮动,这属于正常现象,不用纠结。
4.2 数据加载与第一批样本检查
我不建议直接用框架内置的下载接口,一是网络环境不稳定时容易卡住,二是你迟早要换成自己的数据,早点习惯标准目录结构更好。把数据整理成按类别分文件夹的形式:
data/ train/ 0/ img_0001.png ... 1/ ... ... 9/ ... test/ 0/ ... 9/这样用ImageFolder一行就能构建数据集,标签会自动从文件夹名映射成索引。变换部分训练集和测试集必须分开写,训练集加增强,测试集只做归一化:
from torchvision import datasets, transforms from torch.utils.data import DataLoader train_tf = transforms.Compose([ transforms.RandomAffine(degrees=10, translate=(0.08, 0.08), scale=(0.9, 1.1)), transforms.ToTensor(), transforms.Normalize((0.1307,), (0.3081,)), ]) test_tf = transforms.Compose([ transforms.ToTensor(), transforms.Normalize((0.1307,), (0.3081,)), ]) train_set = datasets.ImageFolder("data/train", transform=train_tf) test_set = datasets.ImageFolder("data/test", transform=test_tf) train_loader = DataLoader(train_set, batch_size=128, shuffle=True, num_workers=2) test_loader = DataLoader(test_set, batch_size=256, shuffle=False, num_workers=2)Normalize里的两个数字是训练集的全局均值和标准差,是统计出来的经验值。如果你换成自己的数据,一定要重新算一遍,否则输入尺度不对,收敛会明显变慢。计算方法就是把所有训练图堆起来求均值和标准差。
数据加载完,第一件事是把第一批样本画出来看。我见过太多因为目录名写错、图片被重命名工具改乱、标签和内容对不上的事故。画 16 张图,每张图上方标出标签,肉眼看一遍。这一步花你两分钟,可能省掉后面两小时。
4.3 模型定义与结构打印
按第 2 章的思路写出来:
import torch.nn as nn class SmallCNN(nn.Module): def __init__(self, num_classes=10): super().__init__() self.features = nn.Sequential( nn.Conv2d(1, 32, 3, padding=1), nn.BatchNorm2d(32), nn.ReLU(inplace=True), nn.MaxPool2d(2), # 28 -> 14 nn.Conv2d(32, 64, 3, padding=1), nn.BatchNorm2d(64), nn.ReLU(inplace=True), nn.MaxPool2d(2), # 14 -> 7 ) self.classifier = nn.Sequential( nn.Flatten(), nn.Linear(64 * 7 * 7, 128), nn.ReLU(inplace=True), nn.Dropout(0.3), nn.Linear(128, num_classes), ) def forward(self, x): return self.classifier(self.features(x))inplace=True的 ReLU 省一点内存,在这个规模下影响不大,但养成习惯没坏处。Dropout 只放在全连接层之间,卷积部分一般不用,因为卷积层本身的参数共享已经提供了较强的正则效果。
写完一定要做的事:用一个假输入跑一遍,打印每层输出形状和总参数量。
model = SmallCNN() dummy = torch.randn(2, 1, 28, 28) print(model(dummy).shape) # 期望 torch.Size([2, 10]) total = sum(p.numel() for p in model.parameters()) trainable = sum(p.numel() for p in model.parameters() if p.requires_grad) print(f"总参数 {total}, 可训练 {trainable}")预期输出torch.Size([2, 10])和大概 42 万参数。如果形状对不上,问题一定出在展平那一步的维度计算上,回去核对 7×7 是怎么来的。
4.4 训练循环的骨架
训练循环的骨架就四步,顺序不能乱:清梯度 → 前向 → 算损失 → 反向 → 更新。写成函数:
def train_one_epoch(model, loader, criterion, optimizer, device): model.train() total_loss, correct, total = 0.0, 0, 0 for images, labels in loader: images, labels = images.to(device), labels.to(device) optimizer.zero_grad(set_to_none=True) logits = model(images) loss = criterion(logits, labels) loss.backward() optimizer.step() total_loss += loss.item() * labels.size(0) correct += (logits.argmax(dim=1) == labels).sum().item() total += labels.size(0) return total_loss / total, correct / total几个细节值得单独说。zero_grad(set_to_none=True)比默认的置零更快也更省内存,因为框架会跳过写零这一步,直接标记为 None。loss.item() * labels.size(0)是为了按样本数加权——最后一批可能不满 128,如果直接对 batch loss 求平均会有偏差。argmax(dim=1)是在 10 个 logits 里取最大值的索引,这就是预测类别。
评估函数要记得三件事:model.eval()、torch.no_grad()、以及不要漏掉转设备:
@torch.no_grad() def evaluate(model, loader, criterion, device): model.eval() total_loss, correct, total = 0.0, 0, 0 for images, labels in loader: images, labels = images.to(device), labels.to(device) logits = model(images) loss = criterion(logits, labels) total_loss += loss.item() * labels.size(0) correct += (logits.argmax(dim=1) == labels).sum().item() total += labels.size(0) return total_loss / total, correct / total主循环里把每轮的学习率、训练损失、训练准确率、测试损失、测试准确率都记下来,打印成一行。我习惯在日志里加一列"距上次测试准确率提升的幅度",一旦连续几轮提升都是零,就该停下来看看到底是收敛了还是卡住了。
按这套配置,实测的典型轨迹是这样的:第 1 轮测试准确率 97% 上下,第 3 轮 98.3%,第 5 轮 98.8%,第 8 轮前后到 99.1%,之后基本走平。如果你的曲线不是这个形态,直接跳到第 5 章对照排查。
4.5 评估、单张推理与错误样本分析
准确率是个聚合数字,它会掩盖问题。真正有价值的是混淆矩阵和错误样本列表。把测试集上所有预测错误的样本收集起来,打印它们的真实标签、预测标签、以及预测概率。你会发现这些错误有明显规律:4 和 9 混得最多,因为某些写法下 9 的上半部分闭合、下半部分竖笔,和 4 非常像;7 和 1 也容易混,取决于有没有加横。还有一部分是"人眼也认不出"的,标签本身就存疑。
这一步的价值在于告诉你模型的短板在哪。如果错误集中在少数几类之间的混淆,说明特征表达能力还不够,加深一层或者加宽通道可能有用;如果错误均匀分布在整个测试集上,概率还都很低,那更可能是训练不充分或者学习率不对。
单张推理的写法要注意三件事,缺一不可:
from PIL import Image def predict_single(model, image_path, device): model.eval() img = Image.open(image_path).convert("L") tf = transforms.Compose([ transforms.Resize((28, 28)), transforms.ToTensor(), transforms.Normalize((0.1307,), (0.3081,)), ]) x = tf(img).unsqueeze(0).to(device) # [1,1,28,28] with torch.no_grad(): prob = torch.softmax(model(x), dim=1)[0] conf, idx = prob.max(dim=0) return idx.item(), conf.item(), prob.cpu().numpy()convert("L")保证是单通道,unsqueeze(0)补上批维度,softmax把 logits 换成概率方便设阈值。务必注意推理时的预处理要和训练时严格一致,包括归一化参数。我遇到过训练用 0.13/0.31、推理忘了归一化的情况,结果模型对什么都预测同一个类别,排查了半天。
置信度低的时候(比如低于 0.6),实际业务里应该走"拒识"逻辑,交给人工或者要求用户重写,而不是硬着头皮给答案。这个设计在真实产品里比提升一个点准确率更有价值。
4.6 导出与轻量部署思路
训练完把权重存下来,同时把结构定义和预处理参数一起存,否则半年后你自己都想不起来当时用的什么配置:
torch.save({ "model_state": model.state_dict(), "arch": "SmallCNN", "input_size": (1, 28, 28), "norm_mean": 0.1307, "norm_std": 0.3081, "classes": list(range(10)), }, "digit_cnn.pth")如果要部署到端侧,下一步就是做推理图导出和量化。对于这个规模,把权重从 32 位浮点量化到 8 位整数,模型体积能压到 0.5MB 以内,在移动端一次推理通常在个位数毫秒。量化的坑在于校准数据要覆盖真实的输入分布,如果校准集全是印刷体而线上是手写体,量化后的精度掉得会比预期多。
还有一条更激进的路线是直接把卷积层手工展开成定点运算,这对嵌入式场景很常见,但工程量不小,入门阶段不建议碰。
5. 常见问题与排查实录
5.1 损失不降或者直接爆成 NaN
损失卡住不动,先看三件事。第一,学习率是不是太大。如果你用的是随机梯度下降且学习率设了 0.1 以上,很容易直接发散,症状是损失从 2.3 跳到几十甚至变成 NaN。改成 0.01 或者换成 Adam 的 1e-3 试试。第二,输入有没有归一化。像素值 0 到 255 直接喂进去,第一层的激活值会非常大,梯度也跟着爆炸。归一化到均值 0 附近是必须的。第三,标签和输出的对应关系对不对。这个错误最隐蔽——如果标签被整体偏移了一位,模型永远学不会,损失会稳定在一个比较高的值附近震荡(大约 2.3,也就是随机猜的水平)。
损失变成 NaN 的话,除了学习率过大,还要检查有没有对 0 取对数、有没有除零。交叉熵函数内部做了数值稳定处理,一般不会出问题,但如果你自己写了 softmax 再取 log,就很容易踩这个坑。直接用框架提供的交叉熵损失传入 logits,不要自己拼。
还有一种情况是损失缓慢下降但准确率不动。这通常意味着模型在学"把所有样本都预测成同一类",因为类别分布均衡时这样也能拿到 10% 的准确率。检查预测结果的类别分布就能确认,如果全挤在一个类上,基本是学习率过大导致模型塌缩,降低学习率重新开始。
5.2 准确率虚高与数据泄漏
测试准确率 99.9% 的时候先别高兴,优先怀疑数据泄漏。最常见的三种:训练集和测试集有重叠图片(同一张图被不同命名保存了两份);做数据增强时在划分数据集之前就做了,导致同一张原图的增强版本同时出现在两边;用了测试集来挑选超参数,然后又在同一批测试集上报告结果。
第二种最隐蔽。正确的顺序永远是:先按原始样本划分训练/验证/测试,再对训练集单独做增强。我见过有人图省事,把整个数据集先做十倍增强再随机切分,结果测试集里混进了训练图的旋转版本,准确率能虚高两三个点。
第三种属于方法论问题。严格来说,调超参数应该用验证集,测试集只在最后跑一次。如果反复用测试集调参,你报告的其实是"在测试集上过拟合后的最优值",上线表现会低于预期。入门阶段至少要做到:从训练集里再切出 10% 当验证集,用它来挑轮数和学习率。
5.3 过拟合与欠拟合怎么分辨
看两条曲线的相对关系最直观。训练损失持续下降、测试损失先降后升,这是典型的过拟合,说明模型开始记忆训练样本的细节噪声了。训练损失和测试损失都很高且贴得很近,这是欠拟合,模型容量不够或者训练不充分。
针对过拟合,我按性价比排序的处理手段是:先加数据增强(旋转 ±10 度、平移 8%、缩放 0.9 到 1.1,这套组合对这个任务几乎不损失精度),再加 Dropout(0.3 到 0.5 之间),再加权重衰减(1e-4 到 1e-3),最后才考虑减小模型。减小模型是最后手段,因为它可能把欠拟合引进来。
针对欠拟合,顺序反过来:先看训练轮数够不够,再看模型容量够不够,最后看学习率是不是太小。我遇到过一个案例,训练了 30 轮测试准确率还只有 92%,排查半天发现是学习率衰减的触发时机写错了,第 2 轮就降到 1e-5,等于后面基本没在学。
提示:判断过拟合不要只看准确率,要看损失。准确率是离散的,一个点就是一个点,损失是连续的,能提前好几轮看出趋势变化。
5.4 高频问题速查表
把上面这些整理成一张表,出问题时按症状对号入座:
| 症状 | 最可能的原因 | 优先处理 |
|---|---|---|
| 损失变 NaN | 学习率过大、未归一化、手写 softmax | 降学习率到 1e-4,检查预处理 |
| 损失稳定在 2.3 附近 | 标签错位、模型塌缩、学习率过大 | 打印预测类别分布,检查标签映射 |
| 损失不降但非 2.3 | 学习率过小、梯度被截断 | 提高学习率,检查是否误加了梯度裁剪 |
| 训练准高、测试准低 | 过拟合、数据泄漏 | 加增强,核查训练测试划分 |
| 训练准低、测试准也低 | 欠拟合、结构不合理 | 加轮数,加容量,检查展平维度 |
| 训练测试都很好,换真实数据就崩 | 分布不匹配 | 采集真实数据微调,改预处理 |
| 同一批数据两次结果差异大 | 未固定种子、Dropout 未切 eval | 固定随机种子,检查 eval 调用 |
| 推理结果全是同一类 | 推理预处理与训练不一致 | 逐项对齐归一化和尺寸 |
6. 从玩具数据集走向真实业务
6.1 基准数据和真实数据之间的鸿沟
这个任务最容易被低估的地方就在这里。基准集里的手写数字是标准化采集的,笔画粗细均匀、位置居中、背景纯黑、抗锯齿方式统一。而真实场景里,你面对的可能是手机拍照的表格、屏幕上的手写输入、扫描件里的潦草字迹。差异体现在:背景不干净(有表格线、有污渍)、笔画断裂或粘连、字符大小不一且位置随机、光照不均导致对比度变化。
直接把这些图缩放到 28×28 喂进模型,准确率腰斩是常态。我在一个类似项目里实测过,同一个模型在基准测试集上是 99.1%,换成真实拍摄的一批数字,只有 71%。这个落差不是模型不行,是分布漂移。
解决办法分两层。预处理层:先做二值化或者自适应阈值,把背景干扰去掉;再检测字符的外接矩形做裁剪,让字符居中且占满画面;最后做尺寸归一化。这一套流程能把准确率拉回 85% 左右。模型层:用少量真实标注数据做微调,哪怕只有几百张,也能再往上提不少。
6.2 什么时候该上数据增强和迁移学习
数据增强的介入时机是:只要你只有几百到几千张真实样本,就该用。除了前面说的几何变换,针对手写场景特别有效的还有:随机素描化(模拟不同笔触)、随机弹性形变(模拟书写时的轻微扭曲)、随机擦除(模拟笔画断裂,强迫模型不要依赖单一局部特征)。弹性形变实现起来稍麻烦,但在小样本场景下收益很显著。
迁移学习的介入时机是:当你的真实数据里除了数字还有别的字符,或者你需要识别连笔的手写串。这时候单字符分类的思路就不够了,通常要换成序列识别模型,用在大规模文本图像上预训练过的骨干网络,只改最后的分类头。这里不必再纠结从零搭建,直接找成熟的识别框架改比较划算。
我在实践中总结出一条判断标准:如果补齐数据所需的成本低于调模型所需的成本,就去补数据。在绝大多数实际项目里,标注 500 张贴合场景的样本,效果胜过三天的结构搜索。
6.3 一条可执行的进阶路线
如果你已经顺畅跑完了第 4 章,接下来可以按这个顺序往下走,每一步都有明确的可验证目标:
- 消融实验:去掉批归一化、去掉 Dropout、去掉数据增强,各跑一次,记录测试准确率和收敛轮数。这一步能让你真正理解每个模块值多少分,比看论文里的消融表印象深刻得多。
- 特征可视化:把第一个卷积层的 32 个卷积核画出来,把全连接层的 128 维输出用降维方法投到二维平面上,看不同数字能不能聚成簇。如果簇分得很开,说明特征学得不错。
- 错误归因:把错分样本按真实标签分组,统计每一类的错分率。找出最差的那一类,针对性补数据或者加增强。
- 轻量化对比:把通道数减半、把第二个卷积换成深度可分离卷积,记录参数量、计算量和准确率的三方变化,画一张取舍表。
- 端到端小应用:写一个能读本地图片、输出识别结果和置信度的脚本,接上一个简单的界面或者命令行工具,把整个链路走通。这一步做完,你对"模型从训练到用起来"的认知才算闭环。
我个人最看重第 1 步和第 3 步。消融让你知道每个零件的作用,错误归因让你知道模型的能力边界在哪。这两件事做扎实了,换到任何其他图像分类任务上,你的迁移成本都很低。
最后分享一个我做这类小任务时一直保留的习惯:每次实验都存一个文件夹,里面放配置文件、训练日志、最终权重和两三张典型的错分样本图。半年后回头看,这几张错分图往往比日志里的数字更能唤起当时的判断依据——为什么那次改了这个参数,为什么那次放弃了某个结构,都在图里。