☰
PyTorch实现MNIST手写数字识别:从CNN训练到Tkinter画板部署
2026/10/12 13:05:55 网站建设 项目流程

简介:基于卷积神经网络(CNN)的MNIST手写数字识别Python源码项目,附带可直接运行的GUI界面,适合计算机相关专业学生用于毕业设计、课程大作业,以及希望进行深度学习项目实战的Python学习者。项目源自评审分为98分的高分设计,代码经过严格调试,可确保稳定运行。整个压缩包约3.53MB,内含23个文件,主要包括三个核心Python脚本(CNN模型构建、数字识别预测、GUI主程序)、十张数字测试图片(PNG格式)、模型权重文件、项目配置文件以及README说明文档,另含图标与Git忽略文件等辅助内容,整体目录清晰,便于快速上手。目前已有129人学习/下载。通过该项目,用户可以系统了解MNIST数据集上CNN模型的搭建、训练、保存与加载流程,并掌握将模型嵌入图形界面实现交互式识别的完整思路,是深度学习入门和项目实践的合适参考。

1. 这个项目真正要解决的事:从跑通模型到把它装进一个能画数字的界面

第一次跑通基于卷积神经网络的 MNIST 手写数字识别,很多人会误以为难点在模型:数据下载、预处理、训练、评估,这些都有人反复写过。真正动手做才发现,从 torchvision 下载 MNIST 报 404,到保存的模型加载时报错,再到 GUI 画板上写一个数字却识别错,每一步都能让新手卡上半天。这个项目标题看着完整,实际拆开是三件事:CNN 网络怎么设计、MNIST 训练流程怎么跑通、以及最后怎么把模型装进一个用鼠标写数字的界面。适合刚入门 Python、想交付一个能演示的识别程序的人——而不是只想看理论推导的人。下面按我自己的落地顺序讲,参数给到可以直接抄,坑尽量标清楚。

2. 设计一个能识别手写数字的最小卷积网络:结构、参数与 PyTorch 实现

2.1 为什么是 CNN 而不是全连接:局部性与参数量的账

MNIST 每张图只有 28×28 像素,一共 784 个输入特征。如果用全连接网络,第一层若设 128 个神经元,那一层就有 784×128+128 约 10 万个参数;再加一层就奔着几十万去了。对小数据集来说,这种模型很容易在训练集上记住噪声,验证集准确率却上不去。

卷积神经网络的关键在于“局部连接”。一个 3×3 卷积核每次只看一个小邻域,相当于在扫描整张图时捕捉边缘、拐点、短笔画这些局部纹理,再靠后续卷积把局部特征组合成更高层的形状语义。更重要的是共享权重:同一个卷积核在图上所有位置共用一组参数,参数量几乎跟输入尺寸无关,因此同样表达能力的网络,CNN 的参数量比全连接小一个量级。数字识别还天然容忍轻微位移和形变,池化层把特征图缩小,让轻微的笔画位置偏移不至于影响最终分类。

MNIST 的识别本质上是“从局部笔画推断整体数字”,这正好是 CNN 最擅长的任务。全连接网络也能跑到 90% 以上,但要跑到 99%,就得靠数据增强和更大的网络来硬补结构上的不足。所以这个项目首选 CNN 不是跟风,是结构选择和任务匹配的结果。

2.2 最小可用结构:Conv-ReLU-Pool 的搭配逻辑

我常用的基础结构是两层卷积加两层池化,再接两个全连接层,和 LeNet-5 的骨架类似,但通道数更宽:

  • Conv1:1 个输入通道,输出 32 个通道,3×3 卷积核,padding=1
  • ReLU 激活后接 2×2 MaxPool,特征图从 28×28 缩到 14×14
  • Conv2:输入 32 通道,输出 64 通道,3×3 卷积核,padding=1
  • ReLU 激活后接 2×2 MaxPool,特征图缩到 7×7
  • 展平后接全连接:64×7×7=3136 → 128 → 10

这里有几个刻意为之的细节。卷积层都用 padding=1,这样 28×28 的输入经过 3×3 卷积后尺寸不变,可以控制特征图在池化前保持足够分辨率;如果不用 padding,28 会直接变 26,两层下来到池化前就只有 24×24,特征信息损失更快。激活函数用 ReLU 而不是 sigmoid,是因为 ReLU 在正区间梯度恒为 1,反向传播时不会层层衰减,训练收敛快得多。

我一开始没有加 BatchNorm 和 Dropout。原因很简单:先让网络能稳定收敛,再根据过拟合信号一个一个加正则手段,否则出了问题都不知道是结构的问题还是正则参数的问题。MNIST 本身数据量足够、任务也简单,基础结构往往就能跑到 99%,不需要在一开始就把模型堆复杂。

2.3 用 PyTorch 定义网络的完整代码与每层参数说明

import torch import torch.nn as nn class CNN(nn.Module): def __init__(self): super(CNN, self).__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) self.fc1 = nn.Linear(64 * 7 * 7, 128) self.fc2 = nn.Linear(128, 10) def forward(self, x): # 输入 x: [batch_size, 1, 28, 28] x = self.pool(torch.relu(self.conv1(x))) # [batch, 32, 14, 14] x = self.pool(torch.relu(self.conv2(x))) # [batch, 64, 7, 7] x = x.view(x.size(0), -1) # 展平为 [batch, 64*7*7] x = torch.relu(self.fc1(x)) return self.fc2(x) # 输出 10 类 logits

forward 里的每一行都对应之前说的结构。x.view(x.size(0), -1)把 4 维特征图展平成 2 维矩阵,第一个维度保持 batch 不变,后面的 64×7×7 全拉平。注意最后一层返回的是 logits,不是 softmax 概率——PyTorch 的CrossEntropyLoss内部会先做 log_softmax 再算交叉熵,如果在这里提前套了 softmax,训练时会因为数值问题导致梯度变差。

模型参数量可以自己算一下:conv1 是 1×32×3×3 加 32 个偏置共 320 个参数,conv2 是 32×64×3×3 加 64 个偏置约 18496 个,两个全连接加起来约 40 万个。整体不到 50 万参数,CPU 上训练完全没压力。

3. MNIST 数据准备:下载、预处理与 DataLoader 的四个关键设置

3.1 数据集获取:在线下载失败后的本地兜底方案

torchvision.datasets.MNIST提供了download=True的方式自动下载数据,但这一步在不少网络环境下会失败,报 URLError 或者 404。torchvision 内置的下载地址指向国外数据源,一旦连不上就是卡死、报错、再重试的循环。我一般一开始就不用download=True,而是手动把四个压缩包放到位。

手动下载方案需要先创建目录结构,把以下四个文件放进data/MNIST/raw/目录:

  • train-images-idx3-ubyte.gz:训练集图像,约 9.9MB
  • train-labels-idx1-ubyte.gz:训练集标签,约 29KB
  • t10k-images-idx3-ubyte.gz:测试集图像,约 1.6MB
  • t10k-labels-idx1-ubyte.gz:测试集标签,约 4.5KB

放好后,代码里这样加载:

from torchvision import datasets, transforms transform = transforms.Compose([ transforms.ToTensor(), transforms.Normalize((0.1307,), (0.3081,)) ]) train_dataset = datasets.MNIST( root='data', train=True, transform=transform, download=False # 改为 False,使用本地文件 ) test_dataset = datasets.MNIST( root='data', train=False, transform=transform, download=False )

注意目录必须精确到MNIST/raw/,数据集类在download=False时会先检查这个路径下是否存在所需文件,找不到就会抛 RuntimeError。下载的 gzip 不要手动解压,datasets.MNIST会在第一次加载时自己完成解压和缓存。如果加载时出现 EOFError 或者 size mismatch,通常是文件没下全,删掉对应 gz 后重新下载一次就好。

3.2 预处理流水线:归一化与 ToTensor 的前后顺序

MNIST 原始数据是 PIL 图像,需要转成 PyTorch 张量并归一化。transforms.Compose的顺序有讲究:先ToTensor(),再Normalize()。

ToTensor()会把 PIL 图像或 H×W 的 numpy 数组转成形状为[1, 28, 28]的 float32 张量,并把像素值从 0~255 缩放到 0~1。这样数值范围才适合后续的梯度计算。Normalize((0.1307,), (0.3081,))使用的是 MNIST 训练集的全局均值和标准差,标准化后每个像素的分布接近均值为 0、方差为 1 的标准正态分布。

这个顺序不能反。如果先 Normalize,ToTensor()前的数据还是 PIL 整数,Normalize 会直接拿整数去减均值除标准差,等于做了一次完全没意义的平移缩放,模型训练时 loss 曲线会上下乱跳,很难收敛。工业界的说法是“预处理管线就是模型的一部分”,训练和推理必须走同一套 transform,否则模型看到的输入分布不一样,准确率会大打折扣。

3.3 DataLoader 参数:batch size、shuffle 与 worker 的取舍

from torch.utils.data import DataLoader train_loader = DataLoader( train_dataset, batch_size=64, shuffle=True, num_workers=0, pin_memory=False ) test_loader = DataLoader( test_dataset, batch_size=256, shuffle=False, num_workers=0 )

batch_size=64 是 MNIST 上的常规选择。太小(比如 8)会让梯度噪声变大,训练曲线抖得厉害;太大(比如 512)单次梯度更新过于平滑,收敛变慢,而且 6 万张图一个 epoch 很快就跑完,实际上等于浪费算力。64 是稳定性和速度的一个良好折中。

shuffle=True 只对训练集开。它的作用是打乱样本顺序,避免连续几十个同类数字带来的梯度周期性偏置,否则模型会在不同 batch 之间来回震荡。测试集不需要 shuffle,评估时打乱顺序没有意义。

num_workers 在 Windows 上建议直接设 0,多进程数据加载在 Windows 上容易触发if __name__ == '__main__'相关的报错。Linux 上设 2~4 可以加速数据读取,但对 MNIST 这种小图收益非常有限。pin_memory 只在用 GPU 训练且有 CUDA 张量需求时才有价值,纯 CPU 训练开着反而多一步无意义的内存拷贝,我通常不设。

3.4 数据验证:看一眼样本,别让脏数据带偏训练

数据管线搭好后,我会先画几张图确认样本长什么样,再开始训练。这一步很多人跳过,直到模型识别不准才回头看数据,结果发现是预处理顺序错了。

import matplotlib.pyplot as plt import torch images, labels = next(iter(train_loader)) # 取一个 batch 的前 4 张 fig, axes = plt.subplots(1, 4, figsize=(8, 2)) for i in range(4): # images[i] 是 [1, 28, 28],需要 squeeze 去掉通道维再显示 axes[i].imshow(images[i].squeeze(), cmap='gray') axes[i].set_title(f'label={labels[i].item()}') axes[i].axis('off') plt.show()

这里值得关注两点。一是图像颜色:MNIST 是黑底白字,背景像素值低,笔画像素值高,显示出来应该是白字深底。如果你看到的是白底黑字,说明某个环节把像素翻转了,后面训练反而是“负样本”的学习,自己手写数字识别时更会对不上。二是标签是否与图像内容一致:偶尔会有损坏文件导致标签错位,肉眼抽查比任何自动化检查都更直接。这一步花 30 秒,能省掉后面几小时的排查时间。

4. 训练与评估:把准确率跑到 99% 的完整训练循环

4.1 训练循环的标准写法:五步走与损失曲线观察

import torch.optim as optim device = torch.device('cuda' if torch.cuda.is_available() else 'cpu') model = CNN().to(device) criterion = nn.CrossEntropyLoss() optimizer = optim.SGD(model.parameters(), lr=0.01, momentum=0.9) for epoch in range(1, 16): model.train() running_loss = 0.0 for images, labels in train_loader: images, labels = images.to(device), labels.to(device) outputs = model(images) loss = criterion(outputs, labels) optimizer.zero_grad() loss.backward() optimizer.step() running_loss += loss.item() * images.size(0) epoch_loss = running_loss / len(train_loader.dataset) print(f'Epoch {epoch:2d}, Loss: {epoch_loss:.4f}')

每个 batch 的训练是固定五步:前向算出 outputs,用交叉熵算出 loss,清空上一步的梯度,反向传播,更新参数。optimizer.zero_grad()不能省,PyTorch 的梯度默认是累加的,不清空会把多个 batch 的梯度混在一起,参数更新方向完全错乱。

我习惯把 loss 乘以images.size(0)再除数据集总长度,算的是整个训练集的平均 loss,而不是最后一个 batch 的 loss。这样每个 epoch 输出一个稳定可比的数值,方便观察收敛趋势。前 3 个 epoch loss 应该快速下降,比如从 0.5 降到 0.1 以下;如果 loss 在 0.5 附近徘徊不动,优先怀疑学习率太低或者数据没归一化。

4.2 超参数搭配:学习率、batch size 与 epoch 的常见组合

SGD 配 lr=0.01、momentum=0.9 是 CNN 在 MNIST 上被验证过无数次的组合。动量相当于给梯度更新加了一个惯性项,能压制来回震荡、加快收敛。Adam 在这个任务上通常用 lr=0.001,前几个 epoch 收敛很快,但最终准确率往往不如 SGD 加动量打磨得干净——SGD 的动量机制在后期能更精细地逼近最优解。

epoch 数量方面,CPU 上一个 15 epoch 的训练大概二三十分钟,GPU 一两分钟。10 epoch 基本能到 99%,第 10 轮之后准确率每轮只涨零点几个百分点,这是正常现象,不是代码出 bug。如果时间紧,训练到 10 epoch 就可以收手,保存模型做 GUI。

常见翻车场景是学习率设太高。lr=0.1 时 loss 曲线会剧烈震荡,甚至发散到 nan;lr=0.0001 时 loss 每个 epoch 只降一点点,10 轮下来还是 0.3。判断方法是看第一个 epoch 的前几个 batch:正常情况第一个 batch 的 loss 应该在 0.3 到 0.5 之间,随后逐 batch 下降。如果第一个 batch 就输出 2.3 以上且不降,学习率或数据预处理一定有问题。

4.3 评估阶段:用验证集判断是否过拟合

训练完每个 epoch 后,我会顺手在测试集上算一下当前准确率,这样才能实时掌握模型状态。

def evaluate(model, test_loader): model.eval() correct, total = 0, 0 with torch.no_grad(): for images, labels in test_loader: images, labels = images.to(device), labels.to(device) outputs = model(images) preds = outputs.argmax(dim=1) correct += (preds == labels).sum().item() total += labels.size(0) return correct / total

model.eval()是切换模型到推理模式。自己这个网络没有 Dropout 和 BatchNorm,影响不大,但养成习惯很重要——如果以后加了这些层,忘记切回 eval 会直接导致推理结果不稳定。torch.no_grad()显式关掉梯度计算,省内存也省时间。

MNIST 的评估预期是:第 1 轮约 95%,第 5 轮约 97%~98%,第 10 轮接近 99%。判断过拟合别只看准确率,准确率在高位变化太迟钝;要同时看验证 loss。如果训练 loss 持续下降而验证 loss 开始回升,就是过拟合的明确信号。MNIST 任务本身简单,这个结构很少过拟合,真要出现先检查 shuffle 和预处理。

4.4 保存模型:只保存权重还是整个模型

训练结束后,保存模型是 GUI 集成前的最后一步。我强烈建议只保存state_dict,也就是模型的全部权重参数,而不是整个模型对象。整个模型用torch.save(model, 'model.pth')保存虽然方便,但加载时依赖当时定义的类名和代码结构,代码改了类名、换了运行环境就报 pickle 反序列化错误。

torch.save(model.state_dict(), 'mnist_cnn.pth') # 在另一个脚本或 GUI 程序里加载 model = CNN() state_dict = torch.load('mnist_cnn.pth', map_location='cpu') model.load_state_dict(state_dict) model.eval()

map_location='cpu'是给训练时用了 GPU 的模型准备的,强制所有张量加载到 CPU,避免目标机器没有 CUDA 时直接报错。load_state_dict会严格检查键名和形状是否匹配,不匹配会报错。如果只想知道模型能不能加载,可以先不接 eval 直接跑一次前向,但正式用之前一定要调model.eval()。保存的文件只有约 1.7MB,适合作为 GUI 项目的资源文件。

5. 避坑与排查:从数据下载到 GUI 识别的五个常见翻车现场

5.1 torchvision 下载 MNIST 报 404 或 URLError

现象是第一次运行datasets.MNIST(download=True)时卡住,最后抛URLError或 404 状态码,换了网络环境也一样。原因很直接:torchvision 内置的下载源指向国外服务器,连接不稳定或直接被拒绝。这是 MNIST 项目里遇到率最高的问题,几乎每个人都有过。

解决方法是走 3.1 节的手动下载方案:把四个 gz 文件放好,设download=False。此外,如果下载位置不对,运行时还会报RuntimeError: Dataset not found之类的错误,意思是在data/MNIST/raw/下没找到文件。此时去这个目录看一眼就明白了。文件损坏的情况偶尔也会有,表现为解压时报 EOFError,把对应 gz 删掉重新放一份,不需要把整个数据集删掉。

5.2 训练 loss 下降但验证准确率卡在 90% 上不去

现象是网络能学,loss 每轮都降,但验证集准确率到 90% 附近就停滞。这一般不是模型结构容量不够,而是数据预处理的锅。最常见的是Normalize没生效,或者 transform 没有真正传给 dataset——有人会在创建 dataset 时忘记写transform=transform,默认就变成原始 PIL 整数图像,模型学到的分布和预期完全不同。

解决分两步查。第一步,打印train_dataset[0][0]看张量的最小值和最大值:正确预处理后是负的(标准化后均值接近 0),如果看到 0~255 或 0~1 浮点,说明 Normalize 没跑。第二步,把通道数和形状打印出来,确认是[1, 28, 28]而不是[28, 28]。另外如果网络只写了单层卷积,确实可能只有 90% 出头的上限,这种情况直接换两层卷积结构就好。

5.3 测试集 99%,自己手写数字却识别不准

这是 GUI 项目最典型的翻车现场:模型在测试集上准确率接近 99%,但鼠标在画板上写一个 3,识别结果五花八门。原因有两个层面。第一是颜色反转,MNIST 是黑底白字,GUI 画板通常是白底黑字,像素语义完全反了;第二是分布不匹配,MNIST 的数字是 28×28 居中、粗细均匀,画板上的数字尺寸、笔画粗细、位置偏移都不同。

解决方法是做三步转换:反转像素、缩放、居中。反转用255 - arr即可;缩放用 PIL 的 resize 到 28×28;居中则需要计算数字的质心,把非零区域移到图片中心。只做前两步能把准确率从“几乎瞎猜”拉回“勉强能用”,三步都做才能接近测试集水准。我在 GUI 集成时会把这三步写进一个preprocess_canvas函数里,每次预测前固定走同一套逻辑。

5.4 预测结果总是偏向某几个类别

现象是无论写什么都预测成 0 或 1,偶尔 3、7 也往这两个类别上靠。这个往往是画布转换时的边界问题:图像里包含了画布边框、附近控件文字或大片背景噪声,这些噪声经过缩放后变成不规则的灰色块,恰好触发了模型对某些数字的误判。

排查时最有效的做法是把预处理后的张量直接保存成图片,亲眼看一眼模型“看到”了什么。这一步能把问题从黑匣子变成可视化的输入检查。如果保存出来的图里有边框线,调整画布坐标;如果有大面积灰底,检查图像截取区域。另外,如果归一化参数写错了(比如均值直接写 0.5),图像会整体偏灰,也一样会诱导模型偏向某一类。

5.5 CPU 训练太慢导致反复试错成本高

现象是没有 GPU,单 epoch 要跑几分钟,调一轮参数等半小时,心态直接崩。原因是网络堆得太大、batch_size 设太大、或者加载数据时 num_workers 设了高数值但 CPU 核数不够,导致数据加载比计算还慢。

我的做法是先用 5000 张子集把全流程跑通,确认 loss 在降、验证准确率在涨,再换全量数据。子集只需要把原始 dataset 包进torch.utils.data.Subset即可,训练代码一行不用改。另外把 batch_size 从 64 降到 32 也能让第一个 epoch 更快出结果,代价只是收敛稍慢,用来调试完全够。MNIST 本身是很轻量的任务,CPU 上 10 个 epoch 通常也就十来分钟,如果这都跑不动,网络结构一定有问题。

6. 把模型装进 GUI:Tkinter 画板识别全流程与精度验证技巧

GUI 部分我选 Tkinter,没有用 PyQt。理由很直接:Tkinter 是 Python 标准库,不需要额外安装,打包和分发都省事;这个项目要的只是一个能画数字、点按钮、显示预测结果的画板,Tkinter 完全满足。用 PyQt 的话,光是把整个界面包成可执行文件就要多踩一轮坑。

画板核心思路是:用一个 Canvas 接收鼠标绘制,预测时把 Canvas 上的图形内容转成 28×28 张量,复用前面训练时的预处理逻辑,最后把模型输出的 logits 转成概率并显示最大概率对应的数字。

canvas = tk.Canvas(root, width=280, height=280, bg='white') canvas.bind('<B1-Motion>', paint) def paint(event): x, y = event.x, event.y canvas.create_oval(x-6, y-6, x+6, y+6, fill='black', width=0)
from PIL import Image def canvas_to_tensor(): # 拿到画布实际内容,转成 28x28 灰度图 canvas.postscript(file='tmp.eps') # Tkinter 自带导出为 eps img = Image.open('tmp.eps') img = img.convert('L').resize((28, 28)) arr = 255 - np.array(img) # 白底黑字反转为黑底白字 arr = (arr / 255.0 - 0.1307) / 0.3081 tensor = torch.tensor(arr, dtype=torch.float32).unsqueeze(0).unsqueeze(0) return tensor

这里用canvas.postscript把画布内容导出成图片,是 Tkinter 跨平台最稳妥的方式,比ImageGrab.grab截屏更可靠,因为截屏还要处理坐标换算和窗口遮挡问题。实际项目里我还会在canvas_to_tensor里加一步质心居中:把非零像素的质心移到图片中心。这一步对 GUI 手写识别提升非常明显,它让画板数字与 MNIST 训练样本的分布对齐,值得仔细调。

预测代码放在按钮回调里,加载模型用第 4 章保存的mnist_cnn.pth,在no_grad下前向一次,取argmax显示到界面。验证技巧是我的习惯:从测试集里取前 5 张图保存成 PNG,在 GUI 里逐个加载并识别,确认预处理逻辑和训练时完全一致。如果测试图识别正确但手写错,问题锁定在画布转换;如果测试图也识别错,问题在模型加载或预处理。这能帮你把排查范围缩到最小。

我最深的体会是:别在 GUI 里追求测试集那样的 99.5%,画板数字和 MNIST 天然有分布差异,能稳定识别你自己手写的规整数字,就证明整个方案是通的。模型刷点是一回事,把训练、推理、界面串起来是另一回事,后者才是工程上真正值钱的部分。每次写完新模型,我会先画一个和 MNIST 风格接近的大号居中数字做冒烟测试,再画潦草版对比差距,这个习惯帮我避开了很多“训练很好、演示翻车”的局面。希望帮到你。

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

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

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

立即咨询