☰
手写数字识别系统实战:PyTorch CNN模型训练与部署全流程解析
2026/9/28 14:11:07 网站建设 项目流程

简介:面向高校课程设计与期末大作业的手写数字识别系统,基于Python实现,已获导师指导并取得97分高分,项目完整、下载即用。压缩包共3个文件,包含Python程序源码、数据集压缩包及说明文档,整体约11.06MB,结构精简清晰,便于直接运行和学习。目前已有259人学习浏览,适合需要快速搭建数字识别模型、完成图像分类实验的课程设计场景,也可用于期末冲刺或毕业设计参考。代码无需修改即可运行,省去环境配置与调试时间;配套数据集与说明文档有助于理解项目结构、复现实验结果,并辅助梳理从数据处理到识别的全流程,对希望高效完成高质量作业的本科生尤为实用。

1. 手写数字识别系统到底在做什么:从 MNIST 到可交付的源码包

如果你下载过“基于Python实现的手写数字识别系统源码+数据集.zip”这类压缩包,大概率会看到两层内容:一个存放图片样本的数据集目录,和一堆 .py 源码文件。手写数字识别在计算机视觉里的形象很标准:输入 28×28 的单通道灰度图,输出 0~9 的类别。它看起来是教学玩具,却是很多人第一次摸清「数据集、模型、训练、评估、推理」完整链路的地方。这个压缩包解决的问题很直接:用 Python 提供的开源库,把一张手写字从图像变成数字,并把模型文件和数据集打成一个可以复现、可以跑通、可以改的工程。它适合刚接触 Python 深度学习的人,也适合要给课程设计或小工具加“数字识别”能力的工程同学。接下来,我按自己平时带项目的方式,从模型选型一路拆到部署前的自测。

2. 选模型先想清楚:MLP、CNN 还是传统机器学习

既然是源码加数据集,很多人拿到包就想直接跑。但我不建议这么干。手写数字识别是 28×28 的小图,几乎任何模型都能在这套数据上刷出高准确率。你看到的源码可能是一层全连接网络,可能是 LeNet-5 变体,也可能只是 sklearn 里的 KNN。模型选型决定的是你之后是花两天调数据,还是花两周改网络。这一步先想清楚,后面复制别人的训练脚本时才不会被细节绊住。

2.1 为什么手写数字识别首选 MNIST 标准数据集而不是自定义图片

这套源码包里最常见的底子是 MNIST 数据集:60000 张训练图、10000 张测试图。目录里如果出现 train-images-idx3-ubyte.gz、train-labels-idx1-ubyte.gz 这类 IDX 二进制文件,说明它沿用了 LeCun 时代的标准格式。使用 MNIST 的最大好处是样本被统一到了 28×28、单通道、黑底白字,你不需要做对齐、裁切、去背景,直接喂给网络就行。但坏处也很明显:模型只见过标准数字,碰到真实场景里的歪斜、加粗、反色,精度会肉眼可见地往下掉。所以读懂一个 MNIST 项目的边界,比跑出 99% 准确率更值钱。如果源码里写的是读文件夹里的 .jpg,那多半只是把 MNIST 重新导出成了图片格式,网络要解决的任务本质并没有变。

这类项目在课程设计和入门比赛里很常见,正是因为数据量小、标签完整、评测标准明确,适合用来理解分类模型。这也是为什么很多人第一次训练深度学习模型就是手写数字识别。真正投入工程之前你要清楚一件事:MNIST 准确率再高,也代表不了真实 OCR 场景。它更适合当“环境验证”和“基准测试”,而不是直接当生产模型用。

2.2 用 PyTorch 搭一个可训练的最小 CNN:网络结构、每层参数和代码

最常见的实现是 PyTorch 里的两层卷积网络。我不推荐一上来就上 ResNet,MNIST 用不到那么深的网络,而且源码包里的代码大多是为 CPU 设计的简易结构。你拿到一个项目时,先看它的模型定义,如果结构明显超过两层卷积加两层全连接,多半是作者在炫技,不加分。

import torch import torch.nn as nn class SimpleCNN(nn.Module): def __init__(self): super().__init__() self.conv1 = nn.Conv2d(1, 32, kernel_size=3, padding=1) self.conv2 = nn.Conv2d(32, 64, kernel_size=3, padding=1) self.pool = nn.MaxPool2d(2, 2) self.dropout = nn.Dropout(0.25) self.fc1 = nn.Linear(64 * 7 * 7, 128) self.fc2 = nn.Linear(128, 10) def forward(self, x): x = self.pool(torch.relu(self.conv1(x))) x = self.pool(torch.relu(self.conv2(x))) x = x.view(x.size(0), -1) x = torch.relu(self.fc1(x)) x = self.dropout(x) return self.fc2(x)

这个网络非常“够用”:第一个卷积把输入的 1 通道映射成 32 个特征图,kernel_size 取 3、padding 取 1,28×28 的输入经过卷积后尺寸仍然是 28×28。紧接着 MaxPool2d 把空间尺寸减半到 14×14。第二组卷积和池化之后变成 7×7、64 通道,展平以后是 64×7×7=3136 维,经过 128 维全连接层,最后输出 10 个类别的 logits。

为什么不用 5×5 卷积?两层 3×3 堆叠的感受野相当于一层 5×5,但参数量更少,训练更快。Dropout 放在全连接层前面,是为了防止 MNIST 这种小数据上出现训练集 100%、验证集 97% 的典型过拟合。整体参数量约 20 万,纯 CPU 训练一个 epoch 只需要几十秒,对大多数入门场景来说比 GPU 更省心。如果你的数据集来源是“手写数字识别系统源码”,这样的结构基本是底线,往上加宽度或深度收益很有限。

2.3 训练配置里的三个关键项:损失函数、优化器、批次大小

模型定义好之后,源码里真正影响结果的无非三样东西:损失函数、优化器、批次大小。很多人会忽略这个配置,直接拿别人的超参来跑,结果损失曲线发散,就开始怀疑模型写错了。

from torchvision import datasets, transforms from torch.utils.data import DataLoader transform = transforms.Compose([ transforms.ToTensor(), transforms.Normalize((0.1307,), (0.3081,)) ]) train_dataset = datasets.MNIST('./data', train=True, download=True, transform=transform) train_loader = DataLoader(train_dataset, batch_size=128, shuffle=True)

MNIST 的像素取值是 0~255,转成张量后必须归一化,网络才好训练。Normalize 里的 0.1307 和 0.3081 是 MNIST 训练集的灰度均值与标准差。如果只做 ToTensor 不归一化,模型不是不能收敛,但通常要多跑 3~5 个 epoch 才能看到接近 99% 的准确率。batch_size 选 128 是经验和显存之间的平衡点,改成 64 会让梯度更抖动,但有时最终精度反而略高;改成 256 训练更快,但收敛点常常不稳。

损失函数和优化器一般这样写:

criterion = nn.CrossEntropyLoss() optimizer = torch.optim.Adam(model.parameters(), lr=1e-3)

CrossEntropyLoss 在 PyTorch 里已经内部组合了 softmax 和 NLLLoss,所以网络最后一层不要提前接 softmax,直接输出原始 logits 就行。优化器用 Adam 是因为它自带自适应学习率,对新手最友好;如果换成 SGD,需要把 lr 调到 0.01 附近,还要额外加 momentum=0.9。工程里还有一种常见做法是每几个 epoch 手动把学习率乘 0.1,也就是 StepLR 方式,但这个要在 baseline 稳定之后再加,否则一上来就会把“调参的锅”和“网络结构的锅”混在一起。

数据加载还有一个容易看走眼的地方:DataLoader 里的 shuffle。训练集必须开 shuffle=True,验证集和测试集要 shuffle=False。原因很简单,训练时让模型见到随机顺序的样本,能打破类别之间的排列相关性;测试时我们希望每次评估都在同一批数据顺序下进行,否则调参时无法公平比较结果。

3. 把数据集和源码跑起来:训练、评估与推理的完整链路

模型选型清楚了,接下来就是把压缩包里的东西落成能运行的结果。这一步我不会只让你双击 train.py,而是建议你把“数据组织、训练循环、评估、导出、推理”拆成多个环节逐个确认。常见做法是,源码包里至少有 dataset 目录、模型定义、训练脚本,有的还带 requirements.txt。你先把文件结构理清楚,再动手跑,否则报错时你会分不清是代码的问题还是环境的问题。

3.1 解压源码包后先看这四样东西,再决定要不要改代码

拿到一个 zip 源码包,不要直接运行入口脚本,先看四样东西:

  • 入口脚本是哪一份。很多项目同时有 train.py、main.py、predict.py,入口不对,后面全白跑。
  • 数据集是否包含在包内。如果只有源码没有 data,大概率会靠 download=True 现下,这一步受网络影响大。
  • 依赖文件里写了什么版本。PyTorch 1.x 和 2.x 在部分 API 上有差异,Python 3.6 和 3.10 的行为也不一样。
  • 配置项是硬编码在代码里,还是单独抽成了 config.py 或 yaml 文件。

我一般先用 tree 命令看目录结构,再打开入口脚本读前 50 行。真正会坑人的地方通常在数据路径上:有人会把数据路径写成绝对路径,比如 C:\Users\自己的用户名\data,到了别人机器上必然崩。如果你看到这种硬编码路径,别犹豫,改成基于当前文件路径的写法:

import os BASE_DIR = os.path.dirname(os.path.abspath(__file__)) DATA_DIR = os.path.join(BASE_DIR, 'data')

这段代码的意思很简单:让数据目录跟着当前解压出的文件夹走,不再依赖原作者电脑的绝对路径。os.path.dirname 取当前脚本所在目录,abspath 先把路径转成绝对路径,避免你从其他终端里用相对路径执行时找不到文件。这段逻辑值得放进任何你打算分发的源码里,它能帮你少接一半的“大哥,为什么我这里找不到文件”的求助。

3.2 用 DataLoader 组织训练数据:normalize、shuffle、worker 的取舍

数据加载部分,我建议尽量采用 torchvision 的 MNIST 接口,而不是自己在本地把 IDX 二进制逐个解析成图片。原因在于 torchvision 处理好了一整套标签映射和文件锁定,你只需要传 download=True 让它首次自动下载。不过 download=True 的默认行为是下载到当前运行目录下的 data/,一旦中断就容易留下不完整的压缩包。

train_transform = transforms.Compose([ transforms.RandomAffine(degrees=10, translate=(0.1, 0.1)), transforms.ToTensor(), transforms.Normalize((0.1307,), (0.3081,)) ]) test_transform = transforms.Compose([ transforms.ToTensor(), transforms.Normalize((0.1307,), (0.3081,)) ]) train_loader = DataLoader( datasets.MNIST('./data', train=True, transform=train_transform), batch_size=128, shuffle=True, num_workers=2, pin_memory=True ) test_loader = DataLoader( datasets.MNIST('./data', train=False, transform=test_transform), batch_size=256, shuffle=False, num_workers=2 )

训练集这里先加了 RandomAffine,轻度的旋转和平移能在不改变数字语义的前提下提升泛化能力。测试集不可以用数据增强,否则你评估的不是真实场景,而是“增强后”的场景,可复现性会变差。num_workers=2 表示用两个子进程做数据预处理,在 Windows 上如果设置过大会导致内存暴涨,还可能与 PyCharm 的调试模式冲突。pin_memory 在 GPU 训练时能减少主机到显存的搬运时间,纯 CPU 训练时可以去掉。

如果源码包提供的是已经分好类的图片文件夹而不是 IDX,可以用 torchvision.datasets.ImageFolder 读,前提是目录结构是train/0/xx.png、train/1/xx.png这种按类别分层的结构。千万别把所有数字图混在一个目录里然后另配 CSV 标签,那会让加载逻辑变得很脆,还容易因为 CSV 编码问题在 Windows 上崩溃。

3.3 训练主循环与验证评估:只看 loss 会骗自己,按 epoch 同时看准确率

训练循环是每个人都会抄但常常抄错的环节。最常见的错误是 model.train() 和 model.eval() 用反,或者验证时忘了写 torch.no_grad(),结果显存被梯度占满。

import torch device = torch.device('cuda' if torch.cuda.is_available() else 'cpu') model.to(device) def train_one_epoch(model, loader, optimizer, criterion, 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() outputs = model(images) loss = criterion(outputs, labels) loss.backward() optimizer.step() total_loss += loss.item() pred = outputs.argmax(dim=1) correct += (pred == labels).sum().item() total += labels.size(0) return total_loss / len(loader), correct / total def evaluate(model, loader, criterion, device): model.eval() total_loss, correct, total = 0.0, 0, 0 with torch.no_grad(): for images, labels in loader: images, labels = images.to(device), labels.to(device) outputs = model(images) loss = criterion(outputs, labels) total_loss += loss.item() pred = outputs.argmax(dim=1) correct += (pred == labels).sum().item() total += labels.size(0) return total_loss / len(loader), correct / total

这段代码里几个关键动作要理解:optimizer.zero_grad() 必须放在前向传播之前,否则梯度会跨 batch 累加,loss 曲线会像锯齿一样乱跳。outputs.argmax(dim=1) 是取每个样本最大 logit 对应的下标,也就是预测数字。loss.item() 负责把标量张量转成 Python float,否则打印出来的是带梯度信息的张量文本,很难直接用于日志统计。

训练时我建议每轮都同时记录 train_loss、train_acc、val_loss、val_acc,而不是只盯 loss。因为 loss 下降有时候只是过拟合的表现,准确率才是最终要交付的指标。常见做法是做个 early stopping:连续 3 个 epoch 验证集准确率没有刷新最高值,就保存当前模型并终止训练,这比傻跑 20 个 epoch 省时间,也能避免最后保存的模型不是最优的那个。

3.4 导出模型并写预测函数:单张 28×28 灰度图返回数字和置信度

训练完之后,源码包里通常会有保存模型的分支。PyTorch 最稳妥的保存方式是只存 state_dict,不存整模型,这样加载时不会因为你改了网络结构里的某个属性而报错。

torch.save(model.state_dict(), 'mnist_cnn.pth') def predict_single_image(model, image_tensor): model.eval() with torch.no_grad(): logits = model(image_tensor.unsqueeze(0)) prob = torch.softmax(logits, dim=1) pred = prob.argmax(dim=1).item() confidence = prob[0, pred].item() return pred, confidence

image_tensor 必须是一个 1×1×28×28 的张量,所以用 unsqueeze(0) 在 batch 维度插入一维。softmax 把 logits 变成 0~1 的概率分布,再取 argmax 得到预测类别。confidence 是模型对这个答案的把握程度,不能直接当成真实概率,但可以帮你判断一张图是不是超出训练分布。如果 confidence 长期低于 0.6,说明输入预处理和训练集差异太大,别急着怪网络。

加载已保存的参数时,先实例化一个同结构的网络,再 load_state_dict:

model = SimpleCNN() model.load_state_dict(torch.load('mnist_cnn.pth', map_location='cpu'))

map_location='cpu' 是为了让原先在 GPU 上训练的参数能落到 CPU 上运行,部署到没有 CUDA 的环境时必加。如果没有这一行,你在纯 CPU 机器上加载时会遇到Attempting to deserialize object on CUDA device这类报错,处理起来比想象中更绕。

4. 手写数字识别常见的坑与排查:训练集 99% 但实测翻车

这是我看过最多人卡住的一章。模型在 MNIST 测试集上能跑到 99.2%,放到真实场景里识别率可能连一半都不到。产生这种差距的原因几乎都在数据处理而不是网络结构。下面四类坑,是我在跑各种“Python 手写数字识别”源码时反复踩过的,每条都按现象、原因、解决来写,方便你对照排查。

4.1 现象:训练集准确率逼近 99%,用手机拍的数字乱识别

原因在样本分布上一点不玄学:MNIST 是黑底白字,而手机拍摄通常是白底黑字,灰度值正好反了。网络学到的特征是“亮色是笔画”,你给它一张黑字白底的图,它自然认为背景才是笔画,输出自然乱掉。

解决方法是做自适应反转。读入图像后看整张图的平均灰度,如果平均灰度低于 128,说明背景偏黑、前景偏亮,就执行 cv2.bitwise_not 翻转;如果平均灰度高于 128,说明是白底黑字,直接进入下一步。

import cv2 import numpy as np def preprocess_photo(image_path): img = cv2.imread(image_path, cv2.IMREAD_GRAYSCALE) img = cv2.resize(img, (28, 28), interpolation=cv2.INTER_AREA) if img.mean() < 128: img = cv2.bitwise_not(img) img = img.astype(np.float32) / 255.0 img = (img - 0.1307) / 0.3081 return torch.from_numpy(img).unsqueeze(0).float()

resize 用 INTER_AREA 缩小时更平滑,不会像 INTER_LINEAR 那样出现大量细碎噪声。判断平均灰度并加阈值,比让用户手动设置颜色模式省事得多。最后再走和训练时完全相同的 Normalize,这一步漏了,模型输出的概率分布会整体偏移,confidence 会变得极低。

4.2 现象:跑源码报 ModuleNotFoundError: No module named 'torch'

原因通常不是源码缺包,而是你在错误的 Python 环境里执行了 python train.py。很多人的机器上装过 Anaconda,又单独装过 Python,终端默认激活的 base 环境里根本没有 torch。

我建议用python -m pip list查看当前环境里的包,不要直接 pip list,因为后者可能查的是别的安装前缀下的包。然后安装对应环境的版本:

python -m pip install torch torchvision

如果是 GPU 机器,先到 PyTorch 官网的安装向导里生成匹配你 CUDA 版本的命令,不要随便抄一条历史命令。装完做一次验证:

python -c "import torch; print(torch.__version__, torch.cuda.is_available())"

看到 True 才说明 GPU 可用。还有一个很低级的坑:项目里写着from model import SimpleCNN,但 model.py 文件名和系统库撞了名,或者入口脚本恰好也叫 model.py。如果报module 'model' has no attribute 'SimpleCNN',去检查当前目录是不是有一个叫 model.py 的旧文件挡在前面。

4.3 现象:MNIST 数据集下载到一半中断,训练读入空样本甚至崩掉

download=True 看起来方便,实际上经常因为网络波动导致 .gz 文件只有几百字节。训练时可能不报错,但准确率一直徘徊在 10% 左右,这就是典型的数据没下载完整,torchvision 的缓存机制没有检测出来。

解决方案是把下载环节和训练环节分开。第一次运行时显式下载,并确认文件大小接近标准值。MNIST 官方文件大小稳定,train-images-idx3-ubyte.gz 通常在 9.5MB 左右,如果看到只有 13KB,删掉 data/ 下对应文件重新下载。

ls -la data/

如果反复下载失败,更稳的办法是用浏览器先把四个 .gz 文件下载到本地,再放到data/MNIST/raw/目录下。torchvision 下载时如果检测到 raw 目录里已有同名文件,会自动跳过,不会对内容做二次校验。这也是为什么很多离线部署场景会直接把 MNIST 原始文件打包成离线数据集,跟随源码分发,省得每次安装时都卡在下载步骤。

4.4 现象:CPU 训练一个 epoch 能跑到天亮,GPU 显存又爆了

原因很单纯:batch_size 设置过大,或者模型输入没有正确放到 device 上。代码里经常会出现images.to(device)后忘了重新赋值给 images,导致模型前向传播时输入还在 CPU 上,不仅慢,还会在 GPU 训练时造成隐性显存泄漏。

排查顺序从简单到复杂:

  • 把 batch_size 从 256 降到 128,观察显存占用曲线。
  • 去掉 pin_memory=True,在 Windows 加 CUDA 的组合下,这个参数偶尔会制造隐形显存碎片。
  • 确认 model.to(device) 发生在训练循环开始之前,而不是在每次迭代里重复执行。
  • 如果 CPU 跑单 epoch 超过 5 分钟,检查 num_workers、 shuffle、图片解码逻辑,有时瓶颈在数据读取而不是计算。

我遇到过一次特别闹心的情况:验证集 DataLoader 也设成了 shuffle=True,导致每个 epoch 评估顺序不同,验证准确率一直在 96%~98% 之间晃。他以为是模型没收敛,反复调学习率,实际上只是评估顺序的波动,换成 shuffle=False 后结果立刻稳定在 99% 附近。这类坑不会让程序崩溃,但会严重误导调参方向。

5. 让手写数字识别系统更抗造:数据增强、模型轻量化与可复用接口

模型跑通只是起点,真正要交给同事或用户用,还得考虑三件事:能不能容忍真实写字变形,能不能被别的程序调用,以及怎么证明它真的可靠。

5.1 数据增强三件套:随机旋转、平移、加噪

MNIST 是干净得不能再干净的数据,真实手写会歪、会偏移、会有杂点。我一般会在训练 Transform 里加随机仿射变换和亮度扰动,这能在不动网络结构的前提下,把模型的实测表现往上拉好几个点。

train_transform = transforms.Compose([ transforms.RandomAffine(degrees=15, translate=(0.15, 0.15)), transforms.ColorJitter(brightness=0.2), transforms.ToTensor(), transforms.Normalize((0.1307,), (0.3081,)) ])

degrees=15 用来容忍写字偏斜,translate 让数字在框里稍微漂移,ColorJitter 模拟不同笔迹深浅。测试集仍然只做 ToTensor 和 Normalize,不需要随机增强,否则测试结果会虚高且不可复现。数据增强不是越多越好,平移比例超过 0.2 会造成数字截断,模型可能把 8 看成 3,这个度需要你自己对着验证集调。

5.2 导出 TorchScript 或 ONNX:把识别逻辑变成不依赖训练脚本的模块

如果这个手写数字识别系统要给别人用,就不应该要求对方也安装和你完全一致的 PyTorch 训练环境。一个很成熟的思路是把模型导出成 ONNX 或者 TorchScript,再写一层极薄的推理脚本。

model.to('cpu') model.eval() dummy_input = torch.randn(1, 1, 28, 28) torch.onnx.export( model, dummy_input, "mnist.onnx", input_names=["input"], output_names=["output"], dynamic_axes={"input": {0: "batch"}, "output": {0: "batch"}} )

ONNX 导出后,你可以用 onnxruntime 在 CPU 上快速推理,免去每次调用都要 import torch 的负担。dynamic_axes 指定 batch 维可变,这样既能一次识别一张,也能批量识别多张而不改变模型结构。导出完成后拿 onnxruntime 跑一次输出,和 PyTorch 原模型的结果对一下误差,通常两者差值应该小于 1e-4,如果差别很大,说明模型里有不可导的自定义操作,需要换导出方式。

5.3 最终验证方式:亲手画 10 个数字,看预测结果和置信度

这是我最喜欢做的最后一道关。不要只依赖 MNIST 官方测试集,因为它和你将来面对的使用场景差距太大。我会用画图工具写 0~9,存成白底黑字的 28×28 图,再让 predict_single_image 逐个识别,把预测结果和置信度打印出来。你甚至可以故意写歪一点、粗一点,去试探模型的鲁棒性边界。这一步能暴露出你前面所有预处理步骤是否真的跟训练时对齐了顺序:先做反色判断、再缩放、再归一化,一个都不能少。

写进交付说明里的内容要有两条硬规则:输入图片必须是单通道灰度,或者能被自动转成灰度;图片尺寸不能直接拉伸成 28×28 而不做等比缩放,否则长宽比被扭曲,数字会失去结构。我在跑这类项目时踩过最多的坑就是 resize 后数字变形,0 被拉成括号,1 被拉成竖线。后来不管谁拿一个新样本过来,我都会先把他那张图保存下来,跟前一步的预处理输出放在一起对比,看看到底是哪一步把字弄残了。希望这个习惯和这套流程能帮到你。

本文还有配套的精品资源,点击获取

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

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

立即咨询