☰
PyTorch实现CNN识别MNIST:从数据加载到99%准确率实战
2026/10/13 1:36:16 网站建设 项目流程

简介:MNIST手写数字数据集被视为机器学习领域的经典实验样本,卷积神经网络则是图像识别中非常有效的架构,两者结合非常适合作为深度学习入门的完整案例。项目基于Python与PyTorch框架,实现了CNN对MNIST数据集的识别,覆盖数据读取、模型设计、训练与测试全过程,适合初学者或课程设计参考。压缩包共包含11个文件,包括可运行的CNN训练代码、Word版设计报告、多张PNG格式的训练与测试效果图,以及说明文档、配置文件等辅助内容,整体大小仅176KB,结构精简便于研读。借助该案例,读者可以系统学习数据预处理、卷积层与池化层搭建、损失函数选择、多轮迭代训练及精度评估,并通过报告中的图表对比不同训练轮次下的表现,独立搭建出自己的手写数字识别模型。目前已有2027人学习/浏览,项目虽小但配套完整,适合反复实践与扩展。

1. 为什么每个学深度学习的都绕不开MNIST和CNN

MNIST手写数字识别是深度学习里最经典的入门项目:28×28 的灰度图片,要判断是 0 到 9 哪个数字。而卷积神经网络(CNN)正是为了处理这类图像任务设计的——用卷积核提取局部特征,再逐层抽象,最后输出分类概率。这个“基于Python的CNN识别MNIST数据集”的项目包,解压后就是一套完整的“训练、验证、预测”闭环代码,你在上面跑通一次,就等于把深度学习的主干流程过了一遍。

这套东西适合两类人:刚学完Python基础、想看看神经网络到底怎么工作的新手;以及已经跑过全连接网络、想理解卷积为什么能碾压传统方法的初级开发者。它能解决的实际问题很简单:给你一批手写数字图片,构建一个能够自动分类的模型,并且在测试集上把准确率做到 99% 以上。别小看这个数字,它背后的数据加载、模型设计、损失函数、反向传播、超参数调优,每一环都会在你后续做图像分类、目标检测时再次遇到。

2. 数据准备:MNIST下载与目录组织,还有torchvision 404的坑

2.1 别急着写模型,先把数据落盘

我见过太多人一上来就写nn.Conv2d,结果数据还没下载下来。MNIST 数据虽然是公开的,但下载过程反而最容易劝退新手。PyTorch 生态里最常用的加载方式是torchvision.datasets.MNIST,第一次运行会自动下载到本地。代码只有几行:

from torchvision import datasets, transforms # 定义数据预处理:转成Tensor并归一化 transform = transforms.Compose([ transforms.ToTensor(), # PIL图片 -> [0,1] 的Tensor,形状变成 [1, 28, 28] transforms.Normalize((0.1307,), (0.3081,)) # MNIST全量数据的均值和标准差 ]) # 训练集:download=True 表示第一次运行自动下载 train_dataset = datasets.MNIST( root='./data', train=True, transform=transform, download=True ) # 测试集:同样下载,train=False test_dataset = datasets.MNIST( root='./data', train=False, transform=transform, download=True )

这段代码的逻辑很清楚:root指定数据存放目录,transform会在每次取样本时执行。ToTensor()有两个作用,一是把 PIL 图像或 numpy 数组从[H, W]变成[C, H, W],二是把像素值从 0~255 缩放到 0~1。Normalize再按通道做标准化,让输入分布接近均值为 0、方差为 1,这能显著加速收敛。MNIST 的均值和标准差是固定的0.1307和0.3081,不用你自己算。

真正让人头疼的是download=True这一句。torchvision默认从 Yann LeCun 的 MNIST 主页下载.gz压缩包,那个地址属于老古董级别,经常因为网络问题或域名变动返回 404。你看到的现象是控制台打印了一堆下载进度,最后抛出一个URLError: 404 Not Found。这不是你的代码问题,也不代表 torchvision 坏了,纯粹是默认下载源不稳。

遇到这种情况,常见做法是手动下载四个.gz文件,放到torchvision期望的目录里,再把download改成False。期望的目录结构是固定的:

./data/ └── MNIST/ └── raw/ ├── train-images-idx3-ubyte.gz ├── train-labels-idx1-ubyte.gz ├── t10k-images-idx3-ubyte.gz └── t10k-labels-idx1-ubyte.gz

你在任意一个能访问的镜像站把这些文件下载下来,按上面的路径放好,然后重新执行datasets.MNIST(..., download=False),加载就会瞬间完成,而且以后再也不需要联网。注意文件名一个字符都不能错,torchvision是按名字去 raw 目录里找的。文件名对、目录不对,也会报 “Dataset not found” 之类的错误。把download=False作为出厂设置,是我用这个数据集的血泪经验之一,不然每次换机器都要看一次网络脸色。

2.2 DataLoader 参数:batch_size、shuffle、num_workers 怎么设

数据集对象只是把样本准备好,真正喂给模型的是DataLoader。它负责把样本打包成 batch、做随机打乱、用多进程预取数据。看这段代码:

from torch.utils.data import DataLoader train_loader = DataLoader( train_dataset, batch_size=64, # 每次取64张图 shuffle=True, # 每个epoch重新打乱 num_workers=4 # 4个进程预加载数据 ) test_loader = DataLoader( test_dataset, batch_size=128, shuffle=False, # 测试时不打乱,方便统计 num_workers=4 )

batch_size=64是 MNIST 上非常稳妥的选择。显存不够就减到 32,想冲速度可以加到 128,但再大容易让收敛不稳定。shuffle只有在训练集上才开,测试集保持原始顺序,方便你之后逐个对比预测结果。num_workers在 Windows 上建议设为 0,否则经常报BrokenPipeError;Linux 和 macOS 上可以设成 CPU 核数,我一般写 4。这一步跑通后,后面所有 epoch 的数据加载都不是瓶颈,瓶颈全在模型和超参数上。

3. CNN 模型搭建:一个 99% 准确率的结构怎么设计

3.1 卷积层为什么能取代全连接:局部感受野与权值共享

MNIST 图片 28×28,如果用全连接网络,第一层就要有 784 个输入节点,第二层若 256 个神经元,光这一层就有 20 万个参数。而且全连接把每个像素都平等对待,完全忽略“相邻像素才构成特征”这个图像领域的常识。

CNN 的两个核心思想正好解决这个问题。第一个是局部感受野:卷积核只看一个小窗口,比如 3×3,提取的是局部纹理、边缘、角点这些低级特征。第二个是权值共享:同一个卷积核滑过整张图,参数是同一组,所以不管目标出现在左上角还是右下角,检测用的权重都一样。这不仅让模型能捕捉位置无关的特征,还大幅减少了参数量。

3.2 经典结构:两个 Conv+Pool 块,再加全连接头

对于 MNIST 这种小图,一个能跑到 99% 以上的结构非常固定:卷积块提取特征,池化层降采样,最后展平接全连接输出 10 类概率。我用 PyTorch 写一个最经典、最不容易翻车的版本:

import torch.nn as nn import torch.nn.functional as F class MNISTCNN(nn.Module): def __init__(self): super(MNISTCNN, self).__init__() # 第一个卷积块:1 -> 32 个特征图 self.conv1 = nn.Conv2d(1, 32, kernel_size=3, padding=1) self.bn1 = nn.BatchNorm2d(32) # 第二个卷积块:32 -> 64 个特征图 self.conv2 = nn.Conv2d(32, 64, kernel_size=3, padding=1) self.bn2 = nn.BatchNorm2d(64) # 池化:2x2,步长2 self.pool = nn.MaxPool2d(2, 2) # 全连接头 self.fc1 = nn.Linear(64 * 7 * 7, 128) self.dropout = nn.Dropout(0.5) self.fc2 = nn.Linear(128, 10) def forward(self, x): # 输入 x: [batch, 1, 28, 28] x = self.pool(F.relu(self.bn1(self.conv1(x)))) # -> [batch, 32, 14, 14] x = self.pool(F.relu(self.bn2(self.conv2(x)))) # -> [batch, 64, 7, 7] x = x.view(x.size(0), -1) # 展平 -> [batch, 64*7*7] x = F.relu(self.fc1(x)) x = self.dropout(x) # 训练时随机失活50% x = self.fc2(x) # 输出10个logits return x

forward里的运算顺序是经过验证的:先卷积,再批归一化,再 ReLU,最后池化。BatchNorm2d放在卷积和激活之间,能稳定训练、减少对初始学习率的敏感度。这里有两个细节新手常忽略。第一,第二次卷积后特征图是 7×7,这是由 28 -> 14 -> 7 两次池化得到的,所以展平后是64*7*7,不是64*28*28,写错维度会在view时报错。第二,最后一层fc2不要接 ReLU 或 Softmax。交叉熵损失函数nn.CrossEntropyLoss内部会做 Softmax,你再接一个 Softmax 等于把概率又变换一次,训练时梯度会变得不正常。

3.3 参数量怎么看,以及为什么这个结构够用小图

想知道这个模型有多少参数,可以在主程序里加一行:

def count_parameters(model): return sum(p.numel() for p in model.parameters() if p.requires_grad) model = MNISTCNN() print(f"参数量: {count_parameters(model) / 1e3:.2f} K")

输出大约在 42K 左右,也就是四万多个参数。对比之前全连接动辄几十万参数量,CNN 在 MNIST 上的参数优势非常明显。但注意,这个结构是为 28×28 小图设计的。如果直接拿去跑 224×224 的彩色图,第一个全连接层的输入维度就必须改,否则view之后的维度对不上。这是把 MNIST 代码迁移到真实图像任务时最常见的翻车点。

4. 训练循环与超参数:真正拉开收敛速度的细节

4.1 优化器与损失函数:CrossEntropy 就够了,不用花哨

CNN 分类任务的标准配置是Adam优化器加CrossEntropyLoss。很多新手喜欢换更“高级”的优化器,比如 SGD with momentum,其实在 MNIST 上,Adam 就是性价比最高的选择。它自带自适应学习率,对学习率的初始值不敏感,哪怕设置得稍微不合适,也能跑出一个不错的结果。交叉熵损失函数则是对多分类问题的标准度量,它在内部把 logits 转成概率分布,再计算预测分布与真实标签的交叉熵。

import torch.optim as optim model = MNISTCNN() device = torch.device("cuda" if torch.cuda.is_available() else "cpu") model.to(device) criterion = nn.CrossEntropyLoss() optimizer = optim.Adam(model.parameters(), lr=1e-3)

这一段没什么玄学,但要注意criterion和optimizer都要在模型移到 GPU 之后创建,否则优化器持有的参数还是在 CPU 上的副本。lr=1e-3是 Adam 的经典默认值,在 MNIST 上通常 3 个 epoch 就能看到 98% 以上的准确率。

4.2 超参数配置:epoch、batch_size、学习率的一组可复现数值

我直接给出一组能稳定复现 99% 以上测试准确率的配置,你不用调参就能用:

超参数推荐值说明
epochs10第 5 个 epoch 通常已过 99%,10 个 epoch 足够收敛
batch_size64训练集 60000 张,每 epoch 约 938 个 batch
learning rate1e-3Adam 默认值,前 5 个 epoch 不必改
dropout0.5接在第一个全连接后,防止过拟合
weight decay1e-4可选,加一点能让验证曲线更稳定

训练 10 个 epoch 在 CPU 上大约需要 3~5 分钟,在 GPU 上不到一分钟,属于完全等得起的范围。epochs再往上加到 20,测试准确率通常也不会再涨,反而可能在后期过拟合导致验证准确率小幅波动。MNIST 数据量足够大、任务足够简单,是一个“练手可以,加深没收益”的典型代表。

4.3 训练与验证循环:别让模型只在训练集上自嗨

训练循环的骨架不复杂,但很多人把它写成了黑匣子——只看最后的准确率,中间发生了什么完全不管。我习惯在每个 epoch 里同时跑训练和验证,并记录 loss 和准确率:

def train_one_epoch(model, train_loader, criterion, optimizer, device): model.train() # 启用训练模式,dropout生效 total_loss = 0 correct = 0 total = 0 for images, labels in train_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) _, predicted = outputs.max(1) total += labels.size(0) correct += predicted.eq(labels).sum().item() return total_loss / total, correct / total def evaluate(model, test_loader, criterion, device): model.eval() # 关闭dropout和batch norm的统计更新 total_loss = 0 correct = 0 total = 0 with torch.no_grad(): for images, labels in test_loader: images, labels = images.to(device), labels.to(device) outputs = model(images) loss = criterion(outputs, labels) total_loss += loss.item() * images.size(0) _, predicted = outputs.max(1) total += labels.size(0) correct += predicted.eq(labels).sum().item() return total_loss / total, correct / total

关键点在model.train()和model.eval()的切换。dropout层在训练时会随机丢弃一半神经元,验证时必须关闭;BatchNorm层在训练时用当前 batch 的均值和方差,验证时用训练阶段累计的统计量。漏掉eval()是最常见的准确率忽高忽低的元凶之一。

主循环里把这两个函数串起来,并每轮打印训练 loss、训练准确率、验证准确率:

for epoch in range(1, 11): train_loss, train_acc = train_one_epoch( model, train_loader, criterion, optimizer, device ) val_loss, val_acc = evaluate(model, test_loader, criterion, device) print(f"Epoch {epoch:02d} | " f"train loss: {train_loss:.4f} | train acc: {train_acc:.4f} | " f"val acc: {val_acc:.4f}") # 保存验证准确率最高的模型,作为“后悔药” if val_acc > best_acc: best_acc = val_acc torch.save(model.state_dict(), "mnist_cnn_best.pth")

每次验证准确率提升时保存一次模型权重,这样可以避免最后 epoch 过拟合导致模型反而变差。文件名用一个“best”后缀,后续做单张预测时直接加载这个文件。如果不做这个保存,训练结束时的最后一个 epoch 不一定是最好的模型,这是新手最容易忽略的细节。

5. 排查:MNIST 项目最常见的 5 个翻车现场

5.1 现象:训练准确率一直卡在 10% 左右

10% 正好是随机猜的数字分类准确率,这个现象几乎可以确定是代码流程问题,而不是模型能力问题。最常见的原因是DataLoader的标签和图像没有对齐,或是在某个地方打乱了标签。第二个常见原因是你把CrossEntropyLoss的输出直接当成准确率去打印,loss 值当然不会等于准确率。第三个原因,如果你的train_one_epoch里忘了optimizer.zero_grad(),梯度会在多个 batch 上累积,参数更新轨迹完全错乱,准确率也会卡在随机水平。

排查这种问题,我一般先固定一个 batch 看一眼数据:

images, labels = next(iter(train_loader)) print(images.shape) # 应该是 torch.Size([64, 1, 28, 28]) print(labels[:10]) # 应该是 0~9 之间的整数

如果 shape 不对,检查transform里有没有多余的Resize或Grayscale。如果标签不对,检查你是否在某个地方对labels做了额外操作。只要数据通路正确,这个模型在前 10 个 batch 之后准确率一定会脱离 10%。

5.2 现象:Loss 变成 NaN,训练直接崩

Loss 变 NaN 通常发生在训练的前几个 batch。第一个原因是学习率太大,Adam 虽然自适应,但lr=1e-2甚至更高时,梯度更新幅度过大,数值溢出。解决办法是把学习率降到1e-3或1e-4。第二个原因是数据没有归一化,如果你没做transform而是直接把 0~255 的 PIL 图像送进网络,卷积输出的数值会很大,经过多层之后梯度爆炸。检查你的transform里是否包含ToTensor()。第三个原因是BatchNorm使用不当,比如在model.eval()时仍然要求更新统计量,或者 batch_size=1,BatchNorm2d方差为 0,也会产生 NaN。MNIST 训练集足够大,batch_size 设为 64 不会有这个问题。

如果排查了以上三点仍然 NaN,就在每个 batch 的loss.backward()前加一句print(loss.item()),看它是从哪个 batch 开始变成非数值的。这一步能帮你快速锁定是数据问题还是网络结构问题。

5.3 现象:torchvision 下载 MNIST 一直 404

这个现象在第 2 章提过,这里说完整的排查顺序。先用datasets.MNIST(..., download=True)跑一次,如果报HTTPError 404,说明默认下载源不可达。此时有两种解决办法。第一种是手动下载并放置在data/MNIST/raw目录,然后设置download=False。第二种是修改环境变量或使用国内镜像,但镜像地址可能变化,不如第一种方案稳定。

注意一个细节:download=True时,torchvision会先去检查raw目录里是否已有完整的.gz文件,如果有,它不会重新下载。所以你手动放好文件后,即使不改成download=False,程序也会直接用已有文件。但为了跑通之后的自动部署,我仍然建议在最终代码里明确写成download=False,避免别人克隆工程后再次去访问那个不稳定的地址。

5.4 现象:GPU 显存不够,但 MNIST 明明很小

MNIST 单张图只有 28×28 灰度图,显存占用应该非常小。如果这都能 OOM,通常不是数据大小的问题,而是DataLoader的num_workers开太多,或者你同时加载了训练集和测试集两份数据到 GPU。PyTorch 的DataLoader默认在 CPU 内存里加载数据,只有images.to(device)才把 batch 搬去 GPU,所以 OOM 大概率是你把整个测试集也to(device)了。

另一个隐蔽原因是.to(device)用法错误。如果model在 GPU,而输入 tensor 还在 CPU,.to()会触发隐式拷贝,但不会报错,只是显存占用增加。排查时用nvidia-smi看占用率,如果显存居高不下,检查代码里是否对images和labels同时做了to(device)。MNIST 在 2GB 显存上可以毫无压力训练,OOM 基本都是代码问题。

5.5 现象:验证准确率上不去,反复在 85%~90% 波动

90% 以下到 99% 的差距,通常不是模型结构问题,而是训练不充分或数据预处理不一致。首先检查测试集的transform是否和训练集一样,很多人的测试集漏了Normalize,导致输入分布不一致,准确率直接掉几个点。其次检查训练 epoch 是否太少,MNIST 上 3 个 epoch 可能只有 97%,到第 8 个 epoch 才会稳定到 99%。最后看你的验证集是否需要打乱,如果测试集也开了shuffle=True,每次验证的 batch 不同,准确率会有微小波动,虽然不影响最终结果,但会干扰你判断模型是否真的在变好。

如果这些都排除了,可以把dropout从 0.5 降到 0.3 再试一次。对 MNIST 这种小数据集,0.5 的 dropout 有时会欠拟合,尤其是当你的卷积层已经提供了足够的特征抽象能力时,全连接层的随机失活反而限制了特征组合。

6. 从 99% 到真正用起来:单张预测与失败样本分析

训练完的模型如果只能打印准确率,那它还是一个黑匣子。我会把模型参数保存下来,然后写一个独立的预测脚本,加载权重,对任意一张手写数字图片输出预测结果。这个能力才是未来做产品、做 demo 的基础。

from PIL import Image import torch import torchvision.transforms as transforms def predict_single_image(image_path, model, device): # 加载并预处理图片,必须和训练时完全一致 img = Image.open(image_path).convert("L") # 转灰度图 transform = transforms.Compose([ transforms.Resize((28, 28)), # 缩放成28x28 transforms.ToTensor(), transforms.Normalize((0.1307,), (0.3081,)) ]) img = transform(img).unsqueeze(0).to(device) # 增加batch维度 model.eval() with torch.no_grad(): output = model(img) pred = output.argmax(1).item() return pred # 使用示例 model = MNISTCNN() model.load_state_dict(torch.load("mnist_cnn_best.pth", map_location="cpu")) model.eval() result = predict_single_image("./test_image.png", model, torch.device("cpu")) print(f"预测结果: {result}")

这段代码最需要注意的是预处理一致性。你训练时用了Resize吗?用了哪种Normalize?预测时必须原样复制。很多人在 MNIST 上训练 99%,但拿一张自己画的图去预测却一塌糊涂,原因就是图片尺寸和背景不同。MNIST 是黑底白字、数字居中,你画的白底黑字图直接塞进去,即使缩放成 28×28,特征分布也完全不同。常见做法是把图片二值化、翻转颜色、缩放到 20×20 再放到 28×28 的黑底中央。

最后,我建议你把测试集里预测错误的样本打印出来看看。MNIST 的错误样本大多是 4 和 9、3 和 8、7 和 1 这种肉眼都容易混淆的写法。看这些 bad case 会让你明白,99% 的准确率不是模型在“作弊”,而是它真的学到了人类识别数字时也会用的笔画特征。我自己第一次跑完这个项目时,最大的教训就是把“准确率达标”当成了终点,忽略了数据流和模型行为的一致性验证,后来做真实项目才发现,这两件事才是工程经验的核心。这个 zip 项目包只是一个起点,你把它跑通、看懂、改出自己的一版,才算真正入了深度学习的门。希望帮到你。

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

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

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

立即咨询