
简介基于 Python 实现的手写数字识别系统源码与配套数据集已打包上传面向高校学生、毕业设计者及 Python 初学者适用于课程设计、期末大作业或机器学习入门实践。项目围绕手写数字识别构建了完整流程涵盖数据预处理、模型搭建、训练与测试评估等关键环节代码结构清晰配合随包数据集可直接运行无需再费心搜集和整理数据。整个压缩包共 3 个文件主要包含 Python 主程序、rar 格式的数据集压缩包以及 Markdown 格式的说明文档整体大小约 11.06MB。该项目曾获导师指导并通过 97 分的期末大作业评分下载后基本无需修改即可使用既可作为课程设计提交方案也可作为二次开发的学习蓝本。目前已有 260 人浏览学习适合希望快速上手手写数字识别项目并完成高质量作业的读者。1. 手写数字识别系统为什么这个看似过时的项目仍是 Python 入门的必修课手写数字识别是深度学习里最经典的图像分类任务把 0-9 的手写数字图片识别成正确标签。这个基于 Python 实现的手写数字识别系统看着不起眼却把数据加载、模型搭建、训练调参、推理验证整条链路一次性打通普通笔记本就能跑完。正因为简单它逼你把每个环节都弄明白数据怎么预处理、网络怎么搭、损失为什么选交叉熵、不收敛先查什么。理清这条链路后面迁移到 CIFAR-10 或实际业务图像分类只是换数据集和调结构的问题。这篇文章用 PyTorch 从零搭一个 LeNet-5把 MNIST 数据集的加载、训练、避坑、推理完整走一遍。适合做课程设计的在校生、缺完整项目的 Python 入门者以及需要快速验证环境的工程师。2. 技术选型与运行环境为什么手写数字识别要用 CNN 而不是传统算法2.1 图像分类的本质从 28×28 像素矩阵到 10 类概率分布MNIST 里每张图是 28×28 的灰度图在程序里就是一个 28 行 28 列的矩阵每个元素是 0-255 的整数0 是全黑、255 是全白。把矩阵按行展开就是一串 784 个像素点。所谓识别就是学一个映射函数 f把 784 维输入映射到 10 个类别的概率分布最后取概率最大的下标作为预测标签。难点在于这个函数怎么构造。传统做法里KNN 最直观把新图和训练集里 6 万张图逐一算距离取最近的 k 个投票。MNIST 上用 KNN 可以到 97% 左右但每预测一张都要遍历训练集推理耗时随训练集线性增长。SVM 加人工特征能到 98.7%代价是你要自己设计特征——统计重心、连通域、笔画密度这些特征的质量直接决定准确率天花板特征工程才是 SVM 方案里真正的工作量。CNN 的思路完全不同。卷积核在图像上滑动每个位置的局部像素和核做点积得到一张特征图。第一层卷积学的是边缘、线条方向深层学的是圆弧、闭合区域这些组合特征。网络自己从数据里把特征学出来这正是 CNN 在图像任务上超过传统方法的核心原因——更好的特征表示比更强的分类器更关键。训练完把中间层特征图画出来看能直观看到网络确实在逐层抽象。2.2 为什么选 LeNet-5卷积共享权重带来的参数量优势LeNet-5 是 1998 年 Yann LeCun 为银行支票数字识别设计的网络结构是卷积→池化→卷积→池化→全连接的经典范式。这个结构有两个设计值得记。第一卷积加池化的组合。5×5 卷积核提取局部特征2×2 最大池化把特征图缩小一半同时带来平移不变性——数字偏移几个像素池化后的特征依然相似。第二权值共享。一个卷积核的参数在整个特征图上复用第一层卷积只有 156 个参数同样的输入换成全连接仅第一层就要上万参数。参数量小、结构规整让 LeNet-5 成为复现经典的第一选择。整个网络约 4.4 万参数一遍训练也就几分钟。做个对比一个 784→128→10 的全连接网络约 10 万参数MNIST 上最高到 98% 出头LeNet-5 用不到一半的参数轻松到 99% 以上。原因就是卷积的归纳偏置更贴合图像结构这是从全连接换到 CNN 最本质的收益。下面按无 padding 的 28×28 输入给出尺寸链Conv1 后 24×24Pool1 后 12×12Conv2 后 8×8Pool2 后 4×4展平成 256 维。这个尺寸链是第四章源码里nn.Linear(16*4*4, 120)的来历。如果你把卷积核改成 3×3 或者加 padding后面全连接层的输入维度都要跟着重算这是手改网络时最容易翻车的一步。2.3 Python 环境安装与依赖选型版本选对少踩一半坑环境问题在 MNIST 项目里占掉一半的报错量所以先说。Python 版本建议选 3.8 到 3.11 之间。3.6 太老新版 PyTorch 不再支持3.12 太新部分依赖还没有对应轮子。用虚拟环境隔离项目依赖避免把系统 Python 弄乱。python -m venv mnist_env source mnist_env/bin/activate # Windows 用 mnist_env\Scripts\activate # CPU 版 PyTorch避免一开始就折腾 CUDA 驱动 pip install torch torchvision --index-url https://download.pytorch.org/whl/cpu pip install matplotlib numpyMNIST 这个规模CPU 上一个 epoch 十几秒没必要上来就搞 GPU。等代码流程确认没问题再按自己的显卡驱动版本选 GPU 版。装完用一行命令验证能输出版本号就说明环境没问题。python -c import torch, torchvision; print(torch.__version__, torchvision.__version__)Windows 用户有两个额外建议项目路径不要带中文否则部分库读数据时会报编码错误pip 安装特别慢时可以在命令里加-i参数指定一个可用的 PyPI 镜像地址。torchvision 里已经内置了 MNIST 数据集类装好它就不用单独找数据了。环境就绪后下一步把数据集本身摸清楚。3. MNIST 数据集处理文件结构、加载方式与归一化参数3.1 MNIST 数据集的来历与文件格式MNIST 是 Modified NIST 的缩写由更早的 NIST 手写数字库整理而来一共 70000 张 0-9 手写数字灰度图每张 28×28。其中 60000 张作训练、10000 张作测试每个数字约 6000 张训练图、1000 张测试图类别分布均匀不存在类别不平衡问题。测试集从不在训练中出现——这是刚入门时容易忽略的点训练集和测试集必须严格隔离否则评估结果会虚高。压缩包解开后是 4 个 gz 文件torchvision 已经能直接处理。如果你想离线使用或者想手动读原始格式需要了解一下 idx 结构图像文件头 4 字节是魔数 0x00000803接着 4 字节样本数再 4 字节行数、4 字节列数之后每 784 字节是一张图标签文件头 8 字节后每 1 字节对应一个标签。文件名内容说明train-images-idx3-ubyte.gz训练图像6 万张train-labels-idx1-ubyte.gz训练标签6 万字节t10k-images-idx3-ubyte.gz测试图像1 万张t10k-labels-idx1-ubyte.gz测试标签1 万字节这个格式在课程里反复出现是因为它很适合练习手写解析器。但实际项目里不建议自己写优先用框架内置加载器出错概率低得多省下的时间拿去看训练曲线更有价值。3.2 三种加载方式从 torchvision 内置接口到离线数据读取推荐方式是用 torchvision 内置数据集接口。第一次运行会自动下载、解压并缓存之后每次直接读本地缓存不会再走网络。from torchvision import datasets, transforms # 预处理顺序不能乱先 ToTensor再 Normalize transform transforms.Compose([ transforms.ToTensor(), transforms.Normalize((0.1307,), (0.3081,)) ]) train_dataset datasets.MNIST(root./data, trainTrue, transformtransform, downloadTrue) test_dataset datasets.MNIST(root./data, trainFalse, transformtransform, downloadTrue) print(len(train_dataset), len(test_dataset)) # 60000 10000 print(train_dataset[0][0].shape) # torch.Size([1, 28, 28])这里root./data是数据缓存目录downloadTrue表示本地没有就自动下载。如果你手里的数据已经是压缩包或者运行环境不方便联网把解压后的 4 个 gz 文件放进./data/MNIST/raw/目录再设downloadFalse加载器同样能识别。这是离线环境下跑通这个项目最常见的方式也是拿到这类源码包时最先要确认的一点。数据加载对不对最直接的办法是打印几张图看一眼确认标签和图像能对得上。这一步花两分钟能避免后面训练时在数据上猜半天。import matplotlib.pyplot as plt fig, axes plt.subplots(2, 5, figsize(10, 4)) for i, ax in enumerate(axes.flat): img, label train_dataset[i] # img 是归一化后的 Tensor ax.imshow(img.squeeze().numpy(), cmapgray) ax.set_title(flabel: {label}) ax.axis(off) plt.tight_layout() plt.show()注意画图前要squeeze()去掉通道维否则 matplotlib 会把它当三通道图处理显示出来是灰色或者直接报形状错误。图像正常、标签对得上数据环节就过关了。3.3 归一化用 0.1307 与 0.3081参数来历与数据增强的克制Normalize 的两个参数 0.1307 和 0.3081 不是随手填的是 MNIST 全体训练图像像素的均值和标准差。ToTensor 先把图像从 0-255 缩放到 0-1然后用 (x-0.1307)/0.3081 做标准化让数据分布接近标准正态。这样梯度更新更平稳收敛更快。全连接网络或者 SVM 可以不做这一步CNN 做了这一步之后loss 曲线明显更平滑学习率容忍度也更高。有人图省事写Normalize((0.5,), (0.5,))甚至不写归一化。不归一化也能训到 99%但训练抖动明显学习率稍大就震荡填错均值影响倒不大但会破坏像素语义。最稳妥的做法就是用 MNIST 官方统计出的这两个值。注意先 ToTensor 再 Normalize顺序不能反。先归一化后转 Tensor会把 0-255 的整数当成 0-1 的浮点数处理整张图变成接近 0 的值模型直接学不动。数据增强在 MNIST 上要克制。随机旋转超过 15 度6 和 9 的朝向信息被破坏2 翻过来很像 7随机平移超过 3 像素数字边缘会被切掉。我的建议是基础版不加增强LeNet-5 十个 epoch 就能到 99% 以上没必要在这里冒险。等你想冲击更高准确率再考虑 ±5 度旋转、±2 像素平移的小幅度增强。4. 模型实现与训练LeNet-5 源码拆解与超参数设定4.1 逐层参数表28×28 输入下每层的尺寸与参数量复现 LeNet-5 之前先把每一层的输入输出算明白。用无 padding 的 28×28 输入版本尺寸链是 28→24→12→8→4具体如下表。层算子输入尺寸输出尺寸参数量Conv15×5 卷积6 个核1×28×286×24×24(1×5×51)×6156Pool12×2 最大池化6×24×246×12×120Conv25×5 卷积16 个核6×12×1216×8×8(6×5×51)×162416Pool22×2 最大池化16×8×816×4×40FC1全连接256120256×12012030840FC2全连接12084120×848410164FC3全连接841084×1010850总参数量约 4.4 万。注意 Conv2 的核数是 16不需要纠结原论文里对称卷积核的组合复现时直接用独立核即可效果几乎无差。卷积层后面接 ReLU最后一层不接激活直接输出 logits交给损失函数处理。这张表在调错时的作用比想象中大。改过网络后全连接层输入维度对不上是高频报错nn.Linear(16*4*4, 120)里的 256 就来自 Pool2 的输出尺寸。任何一个卷积核尺寸或 padding 参数变动这里的 256 都要跟着改很多报错信息指向的size mismatch就是这条尺寸链断了。4.2 可复现的完整训练源码模型定义、训练循环与评估模型定义和训练循环写在一个脚本里是这类源码包最实用的形态。以下代码不做分布式、不用高级特性单卡 CPU/GPU 都能跑全部加起来不到 90 行。import torch import torch.nn as nn import torch.optim as optim from torch.utils.data import DataLoader from torchvision import datasets, transforms device torch.device(cuda if torch.cuda.is_available() else cpu) class LeNet5(nn.Module): def __init__(self): super().__init__() self.features nn.Sequential( nn.Conv2d(1, 6, kernel_size5, padding0), # 28 - 24 nn.ReLU(inplaceTrue), nn.MaxPool2d(kernel_size2, stride2), # 24 - 12 nn.Conv2d(6, 16, kernel_size5, padding0), # 12 - 8 nn.ReLU(inplaceTrue), nn.MaxPool2d(kernel_size2, stride2), # 8 - 4 ) self.classifier nn.Sequential( nn.Flatten(), nn.Linear(16 * 4 * 4, 120), nn.ReLU(inplaceTrue), nn.Linear(120, 84), nn.ReLU(inplaceTrue), nn.Linear(84, 10), ) def forward(self, x): x self.features(x) x self.classifier(x) return x 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() outputs model(images) loss criterion(outputs, labels) loss.backward() optimizer.step() total_loss loss.item() * images.size(0) correct (outputs.argmax(1) labels).sum().item() total labels.size(0) return total_loss / total, 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() * images.size(0) correct (outputs.argmax(1) labels).sum().item() total labels.size(0) return total_loss / total, correct / total transform transforms.Compose([ transforms.ToTensor(), transforms.Normalize((0.1307,), (0.3081,)) ]) train_dataset datasets.MNIST(root./data, trainTrue, transformtransform, downloadTrue) test_dataset datasets.MNIST(root./data, trainFalse, transformtransform, downloadTrue) train_loader DataLoader(train_dataset, batch_size64, shuffleTrue, num_workers0) test_loader DataLoader(test_dataset, batch_size256, shuffleFalse, num_workers0) model LeNet5().to(device) criterion nn.CrossEntropyLoss() optimizer optim.Adam(model.parameters(), lr1e-3) scheduler optim.lr_scheduler.StepLR(optimizer, step_size5, gamma0.5) epochs 10 for epoch in range(1, epochs 1): train_loss, train_acc train_one_epoch(model, train_loader, criterion, optimizer, device) test_loss, test_acc evaluate(model, test_loader, criterion, device) scheduler.step() print(fEpoch {epoch:02d} | train loss {train_loss:.4f} | ftrain acc {train_acc:.4f} | test acc {test_acc:.4f})几个关键点逐一说明。zero_grad()必须放在每次迭代开头否则梯度会跨 batch 累积loss 曲线会出现周期性尖峰。outputs.argmax(1)是在类别维度上取最大概率的索引和标签逐元素比较后求和得到正确样本数。evaluate里用torch.no_grad()包住推理目的是不构建计算图省内存也避免误调 backward。这个脚本直接跑10 个 epoch 后测试集准确率在 99% 上下。第一次跑的时候如果数据下载慢就把数据文件先放好再设downloadFalse不要让网络拖住训练流程。跑通之后再回去调 4.3 的超参数观察它们各自怎么影响曲线。4.3 三个核心超参数学习率、batch size、epoch 怎么搭配训练代码给全了但真正决定效果的是学习率、batch size、epoch 这三个值的搭配。它们不是独立的调参时当成一个整体来动。学习率先说。Adam 优化器在 MNIST 上默认 1e-3 非常稳从我跑过的经验看1e-2 时 loss 会震荡1e-4 时收敛慢到让人失去耐心1e-3 是普遍能一次到 99% 的值。如果换成 SGD学习率要放到 1e-2 左右并且加动量 0.9。这里有个常见的翻车点把 SGD 的 1e-2 直接套到 Adam 上或者反过来几乎必然不收敛。调参这事确实有点玄学但学习率的选择范围是有规律的不是随机试。batch size 在 64 和 128 之间选。MNIST 单张图只有 1×28×28显存不是约束6 万张训练图按 64 一个 batch一个 epoch 约 938 步10 个 epoch 也就几分钟。batch 越小梯度噪声越大但泛化不一定差batch 过大会导致收敛变慢。测试时用 256 主要是为了提高吞吐。epoch 数建议 10 起步。LeNet-5 在 MNIST 上 5 个 epoch 能到 98.5%10 个 epoch 到 99%。继续加到 20训练集准确率逼近 99.9%测试集却停在 99.2% 附近不再动这就是过拟合信号。判断标准不是训练集准确率而是测试集 loss它开始回升就该停。代码里的StepLR(step_size5, gamma0.5)表示每 5 个 epoch 学习率减半不加也能到 99%加上之后最后几个 epoch 的曲线更稳能再挤零点几个百分点属于锦上添花。5. 手写数字识别避坑指南5 个高频翻车现场与排查方法5.1 损失一直不降先查数据预处理而不是调模型现象训练一两个 epoch 后train loss 还在 2.3 附近准确率 10% 上下和随机猜差不多。原因最常见的两个。一是没做归一化0-255 的大数值直接进网络梯度被个别大像素主导优化极不稳定二是 transform 顺序写反先 Normalize 后 ToTensor输入几乎全 0。这两种情况模型都能跑起来但永远不会收敛。解决检查 transform 顺序确保 ToTensor 在前、Normalize 在后然后打印输入验证分布。sample, _ train_dataset[0] print(sample.min().item(), sample.max().item()) # 预期 -1 到 1 之间如果 min 约 0、max 约 1说明归一化没生效。这种问题神经网络永远不会自己纠正查数据比调模型快得多。我的排查习惯是先确认输入分布再动网络结构顺序反了会浪费很多时间。5.2 Windows 下 DataLoader 直接报错多进程与main保护现象脚本在 macOS 或 Linux 上运行正常Windows 一跑 DataLoader 就报An attempt has been made to start a new process before the current process has finished its bootstrapping phase有时干脆卡死不动。原因num_workers大于 0 时DataLoader 会用多进程加载数据。Windows 没有 fork只能 spawn 新进程新进程会重新导入主模块如果训练代码没有放进if __name__ __main__:保护子进程会递归执行主流程直接冲突。解决二选一。把训练主流程包进if __name__ __main__:块里或者把num_workers设为 0。MNIST 数据量很小num_workers0完全够用我为了省心直接这么写。这个问题只在 Windows 下出现是跨平台移植时的高频翻车点。5.3 训练集 99.9% 测试集 98.2%过拟合与尺寸链错误现象训练集准确率逼近 100%测试集却卡在 98% 上下调学习率、加 epoch 都没有改善。原因两种典型。一是模型结构问题Conv2 输出展平后的维度写错全连接层实际吃到的是截断的特征信息二是过拟合训练集只有 6 万张模型把训练数据的噪声也背下来了泛化自然上不去。解决先逐层打印张量形状对照第四章的参数表核对 24→12→8→4 尺寸链。再观察训练 loss 和测试 loss 的差距测试 loss 不再下降甚至回升就是过拟合给全连接层加nn.Dropout(p0.5)或者做小幅数据增强。如果差距不大只是容量不够考虑加一层卷积或加宽全连接层。5.4 自制手写图片预测全乱颜色、尺寸、线宽的三个坑现象测试集上准确率 99%用画图软件手写一个数字存成 PNG 送进模型预测结果完全不准置信度也很低。原因数据分布对不上。MNIST 是黑底白字、28×28、数字居中、线宽约 1-2 像素画图软件默认白底黑字、图片几百像素、线宽十几像素。方向完全不同模型必然翻车。具体坑有三个没反色、resize 方式不对、没归一化。解决写一个预处理函数转灰度→缩放到 28×28→反色→转 Tensor→归一化并且把预处理后的图像打印出来和 MNIST 样本对比。看到图像变成黑底白字、粗细接近再喂模型。第六章给出完整代码。这里最容易忽略的是反色白底黑字的图像不反色模型看到的像素值和训练分布是反的权重完全用错方向。5.5 速度慢或显存爆炸漏掉 eval 与 no_grad 的后果现象GPU 上 batch 明明不大显存却 OOM或者 CPU 上跑一个 epoch 特别久风扇狂转。原因推理时没有用torch.no_grad()PyTorch 会把中间激活全部保留以构建计算图显存占用翻好几倍。另一个问题是训练和评估之间没有切换model.train()和model.eval()dropout 和批归一化在推理阶段行为不对结果时好时坏。解决训练循环开头写model.train()评估和推理开头写model.eval()评估的 for 循环用with torch.no_grad():包住。这两个习惯养成后后续做任何模型都不会再犯。另外 DataLoader 的num_workers在 Windows 下设太高会拖慢启动设 2 就够不需要追求更大的值。6. 把模型用起来保存权重、自定义图片推理与准确率验证6.1 模型保存与加载的推荐写法训练完的参数要落盘。常见做法是只存state_dict不存整个模型对象这样换机器、换 Python 版本都能加载。# 保存 torch.save(model.state_dict(), mnist_lenet5.pt) # 加载 model LeNet5().to(device) model.load_state_dict(torch.load(mnist_lenet5.pt, map_locationdevice)) model.eval()加载后记得调eval()除非你接下来还要继续训练。很多人加载完直接推理模型还停留在训练模式dropout 还在起作用预测结果会带随机性这是推理结果不稳定的一大来源。6.2 单张 PNG 推理的完整预处理针对 5.4 的三个坑这里给出可直接复用的预处理加推理函数。核心是让外部图片尽量贴近 MNIST 的分布灰度、28×28、黑底白字、归一化。from PIL import Image import torchvision.transforms.functional as F def predict_image(model, image_path, device): img Image.open(image_path).convert(L) # 转灰度 img img.resize((28, 28), Image.Resampling.BILINEAR) img F.invert(img) # 反色变成黑底白字 tensor F.to_tensor(img) # [1, 28, 28]值域 0-1 tensor (tensor - 0.1307) / 0.3081 # 手动归一化 tensor tensor.unsqueeze(0).to(device) # [1, 1, 28, 28] with torch.no_grad(): logits model(tensor) prob torch.softmax(logits, dim1) pred prob.argmax(1).item() print(f预测: {pred}置信度: {prob[0][pred].item():.4f}) return pred这段代码里有两个容易踩的点。Image.Resampling.BILINEAR是新版 Pillow 的写法老版本用Image.BILINEAR按自己装的 Pillow 版本来。unsqueeze(0)是补 batch 维度模型输入要求四维[N, C, H, W]少这一维会直接报维度错误。6.3 验证准确率与下一步提升方向模型训完验证不能只看一个数字。我习惯把预测错的测试样本批量打出来看错的是 7 看成 1 还是 9 看成 4。这一步能直接告诉你下一步该调什么如果错误集中在形状相似的数字对就加强数据增强或加宽网络如果错误分布均匀说明模型容量到顶了该考虑换结构。如果想把 99.2% 推到 99.5% 以上方向依次是加一层卷积、加 dropout、做 ±5 度旋转和 ±2 像素平移的小增强、用余弦退火学习率。但这些在 MNIST 上的收益都小于 1%投入产出比很低。真正值得做的是把这条链路迁移到自己的任务上——验证码识别、票据数字提取、表单自动填写模型结构不用大改换数据集和类别数就能跑。说一个我自己的习惯每到一个新环境先跑一遍 MNIST 全流程。既能验证 PyTorch 和 torchvision 装得对不对又能确认机器上有没有历史遗留的 Python 路径问题。这套流程跑通之后换业务数据集基本就是改输入通道、类别数和图像尺寸的事后面做任何图像分类项目都会顺手很多。希望这些源码思路、超参数和踩坑记录能帮到你的第一个深度学习项目。本文还有配套的精品资源点击获取