PyTorch MNIST手写数字识别实战:CNN搭建与训练全流程

📅 发布时间:2026/10/11 2:25:05
PyTorch MNIST手写数字识别实战:CNN搭建与训练全流程
简介基于Python深度学习实现MNIST手写数据集识别的完整工程以rar压缩包形式提供适合计算机、电子信息工程、数学等专业学生作为课程设计、期末大作业或毕业设计的参考资料。压缩包共18个文件大小约19.77MB包含5个Python源文件、5个编译生成的pyc缓存、3个JSON配置文件、2个图像数据文件idx3-ubyte、2个标签数据文件idx1-ubyte及1个pkl数据文件覆盖网络结构定义、层实现、激活函数、训练与评估等关键模块。已有511人学习下载。整体代码和数据集配套完整目录结构清晰便于快速上手深度学习图像识别流程读者可结合源码理解卷积网络处理MNIST数据的细节并自行调试、添加功能以适配不同需求。1. MNIST手写数据集识别拿它起步Python深度学习值不值MNIST手写数字识别在深度学习领域当了二十多年的“入门第一关”。60000张28x28灰度图一台没有独立显卡的笔记本也能在一小时内把准确率做到97%以上这种即时的正反馈比任何理论都管用。很多做图像方向的开发者真正动手写第一个深度学习程序选的就是它因为任务简单到模型不是瓶颈难点全在数据加载、预处理和训练参数上。这篇笔记要讲的是把“基于Python深度学习实现mnist手写数据集识别”这条链路完整跑通需要知道的事——数据从哪来、网络怎么搭、参数怎么调、踩过哪些坑以及最后怎么拿自己写的字去验证模型。适合刚装好Python还没跑通第一个深度学习项目的入门者也适合手里缺一份快速基线源码的CV从业者。2. MNIST数据准备torchvision下载失败怎么救DataLoader参数怎么设2.1 torchvision下载MNIST卡住或404四份gz文件的本地化加载在深度学习项目里数据获取往往是被低估的一步。PyTorch官方提供的torchvision.datasets.MNIST接口设计得很省事第一次运行会在root目录下自动创建MNIST/raw文件夹然后从国外服务器把数据拉下来。这个流程在国内网络环境下经常翻车长时间卡住、连接被重置甚至直接报URLError。很多初学者以为是自己代码写错了其实只是网络不通白白在下载阶段消耗了大把耐心。torchvision对MNIST数据源的设计其实很直接——四份gz压缩文件放到指定目录下downloadTrue就不会真正触发网络请求。我一般这样操作先手动把四份gz文件下载好放进./data/MNIST/raw目录然后代码里照常写downloadTrue。torchvision检查到文件已存在会跳过下载直接解压读取。关键点有两个路径必须是MNIST/raw两级结构文件名必须严格对齐——train-images-idx3-ubyte.gz、train-labels-idx1-ubyte.gz、t10k-images-idx3-ubyte.gz、t10k-labels-idx1-ubyte.gz。文件名错一个字符都会报FileNotFoundError四个文件缺一个也会在解压时报错。数据就位之后的加载代码很简单但transform里面的两步操作直接决定训练效果# data_prepare.py import torchvision.transforms as transforms from torchvision.datasets import MNIST transform transforms.Compose([ transforms.ToTensor(), transforms.Normalize((0.1307,), (0.3081,)) ]) train_data MNIST( root./data, trainTrue, transformtransform, downloadTrue # 本地文件存在时不会真正走网络 ) test_data MNIST( root./data, trainFalse, transformtransform, downloadTrue )这段代码里transform决定样本进入网络之前被怎样处理。ToTensor()把原始像素值从0到255的无符号整数缩放到0到1的浮点数同时把图像形状从28x28变成1x28x28多出的通道维度是卷积层输入的基本要求。Normalize接着用MNIST全量训练图像的均值0.1307和标准差0.3081做标准化把输入分布压到接近零均值、单位方差。别小看这两步直接拿0到255的原始整数进网络也能训练但loss下降会明显变慢最后准确率也到不了同样水平。trainFalse则表示加载测试集这一万张图在训练过程中绝不能混进训练数据否则测试准确率会虚高因为模型已经“见过”这些样本了。还有一个容易踩的细节训练集和测试集的transform必须保持一致不能训练时做Normalize而测试时忘了。一旦测试输入分布偏移精度会莫名掉两三个点不报错也不提示排查起来非常折磨。这类问题用替换法检查最有效——把测试集transform改成和训练集完全一样再跑一次对比精度。下载完成后建议用Python脚本确认四份数据文件完整性。压缩包下载中断时文件大小异常torchvision解压时会报CRC错误这个报错信息经常被忽略大家光看到格式不对就怀疑代码。import os raw_dir ./data/MNIST/raw for fname in [train-images-idx3-ubyte.gz, train-labels-idx1-ubyte.gz, t10k-images-idx3-ubyte.gz, t10k-labels-idx1-ubyte.gz]: path os.path.join(raw_dir, fname) size os.path.getsize(path) print(f{fname}: {size} bytes) if size 10000: print( 文件过小建议删除后重新下载)2.2 验证数据加载结果batch形状、标签范围与matplotlib可视化数据加载完成后在训练之前花三十秒验证一次往往能省掉后面数小时的排查。方法是从DataLoader里取一个batch打印形状和标签范围再用matplotlib画几张图肉眼确认内容对不对得上。import matplotlib.pyplot as plt sample_images, sample_labels next(iter(train_loader)) print(batch形状:, sample_images.shape) print(标签形状:, sample_labels.shape) print(标签范围:, sample_labels.min().item(), -, sample_labels.max().item()) # 画出前八张图确认内容与标签对应 fig, axes plt.subplots(2, 4, figsize(6, 3)) for i, ax in enumerate(axes.flat): ax.imshow(sample_images[i][0], cmapgray) ax.set_title(int(sample_labels[i])) ax.axis(off) plt.show()如果输出batch形状是torch.Size([64, 1, 28, 28])说明batch_size为64、单通道灰度、宽高28的格式都没问题标签范围应落在0到9之间。如果形状里的通道数不是1或者标签范围出现10以上的值多半是transform或加载参数写错了趁早回头检查比后面黑盒排查高效得多。画出图像这一步也不要省MNIST自带数据集的标签基本不会错但如果换过数据源、做过裁剪或拼接操作顺序错位很常见。DataLoader的参数选择有讲究。batch_size64在MNIST上是安全起步值梯度更新稳定且单轮耗时短调小到16会让loss曲线更震荡有时能逃离较差的局部极小点但多数情况只是噪声变大调到256以上每个epoch时间缩短收敛精度通常略低这是小批量梯度下降的固有特性。shuffle在训练集上必须开启否则每个epoch模型都按固定顺序接触数字类别学到的是顺序相关的假规律测试集保持False即可。num_workers在Windows上建议设0Linux上设2或4Windows下非0值经常在运行中途报BrokenPipeError这是教程评论区出现频率最高的报错之一。pin_memoryTrue对GPU训练有加速纯CPU训练不影响常规写上即可。from torch.utils.data import DataLoader train_loader DataLoader( train_data, batch_size64, shuffleTrue, num_workers0 if os.name nt else 2, pin_memoryTrue ) test_loader DataLoader( test_data, batch_size128, shuffleFalse, num_workers0 if os.name nt else 2, pin_memoryTrue )还有一个经常出现的误区训练和测试共用同一个DataLoader实例。这样测试阶段shuffleTrue会随机打乱测试顺序虽然不影响准确率统计但每次排查预测错误样本时顺序都在变很难对照原始图定位问题。更严重的是如果代码里误把测试数据的梯度也回传模型等于间接“看过”测试集测试准确率虚高得毫无意义。训练和测试两个DataLoader分开定义各自循环里保持正确的模式是一个成本极低但能避免多个坑的好习惯。3. 搭建识别网络全连接为何不够CNN如何选型与实现3.1 全连接网络能跑但不够好参数量与局部特征的差距MNIST任务简单到两层全连接网络就能拿到90%以上准确率这容易让人误以为卷积网络是多余的。实际上全连接网络把每个像素都当成独立特征完全没有利用数字图像中相邻像素之间的空间结构。数字“7”就是左上到右下的斜线数字“0”就是一圈连续弧线这种局部结构在全连接网络里必须靠大量冗余参数强行记忆而不是像卷积那样通过权重共享天然提取。这里有个具体的数值对比值得记住。输入784个像素、隐藏层256个神经元的全连接层参数量为784x256256约20万而一个卷积核为3x3、输出32个通道的卷积层参数量只有3x3x3232约320个。即便搭建一个完整的CNN总参数量也就在20万级别和一层全连接隐藏层相当但表达能力高出一截。这也是为什么MNIST教程最终都会导向CNN——同样的参数量更好的特征提取方式精度上不去才奇怪。不使用更复杂网络的原因是MNIST本身太小。ResNet、VGG这类深度网络在这个任务上不仅训练更慢还更容易过拟合最终测试准确率未必比LeNet风格的小网络高。在简单任务上堆模型复杂度是入门阶段常见的错路模型跑得慢不说调参难度也大。先把小网络吃透比直接上大模型更有价值。3.2 用PyTorch实现LeNet风格CNN结构、代码与参数量分析MNIST最经典也最稳的卷积结构从LeNet-5演化而来。LeNet-5是上世纪90年代为银行支票手写数字识别设计的放到今天依然适合28x28小灰度图。常见做法是两层卷积加两层全连接中间穿插池化和Dropout。代码不长但每一层的作用值得拆开讲。import torch.nn as nn class MNISTCNN(nn.Module): def __init__(self): super().__init__() self.conv1 nn.Conv2d(1, 32, kernel_size3, padding1) self.conv2 nn.Conv2d(32, 64, kernel_size3, padding1) self.pool nn.MaxPool2d(2, 2) self.dropout1 nn.Dropout(0.25) self.fc1 nn.Linear(64 * 7 * 7, 128) self.dropout2 nn.Dropout(0.5) self.fc2 nn.Linear(128, 10) def forward(self, x): x self.pool(torch.relu(self.conv1(x))) # 输出 32x14x14 x self.pool(torch.relu(self.conv2(x))) # 输出 64x7x7 x x.view(x.size(0), -1) # 展平成 64x3136 x self.dropout1(x) x torch.relu(self.fc1(x)) x self.dropout2(x) x self.fc2(x) return xforward里每一行都对应一个明确的形状变化。conv1输入1x28x28padding1让卷积不改变空间尺寸输出32通道的28x28特征图池化把空间尺寸减半到14x14。conv2把通道从32变到64再池化成7x7。view把每个样本展平成64x3136的向量之后接两个全连接层最后一层输出10个数值对应0到9十个类别的得分。注意fc2后面没有接softmax——这是新手最容易混淆的地方nn.CrossEntropyLoss在PyTorch内部自带softmax操作手动加softmax反而让数值变换做了两遍影响数值稳定性。所以标准写法就是输出原始得分损失函数负责归一化。Dropout是这套结构里控制过拟合的关键。两个Dropout分别放在展平后和第一个全连接层后训练时随机把25%和50%的神经元输出置零迫使网络不依赖少数神经元效果相当于同时训练了大量共享权重的子网络。MNIST训练集和测试集分布非常接近不加Dropout训练集能到100%而测试集只有98%左右加上Dropout之后测试集可以稳在99%以上。注意Dropout在推理阶段自动关闭前提是代码里调了model.eval()否则Dropout继续随机丢弃推理结果每次都不稳定。激活函数选ReLU而不是sigmoid或tanh原因是ReLU在正区间的梯度恒为1能有效缓解深层网络的梯度消失问题。MNIST只有两层卷积梯度消失并不严重但ReLU计算更快、收敛更快是当代CNN的默认选择。如果换成sigmoid训练曲线下降会明显慢一截最终精度也会略低所以没有特殊理由不要改。另外这套网络没有加BatchNorm。MNIST数据分布本身很简单训练批次64不算小BatchNorm带来的分布稳定化收益不明显反而额外增加参数量和推理阶段的计算。如果换到CIFAR或真实图像数据BatchNorm基本就是标配但在这个入门项目里不是必选项。这个取舍背后的思路是“最简单的能用的模型”先跑通再逐步加组件而不是第一次就把所有组件堆上。4. 训练循环的完整实现损失函数、优化器与准确率提升路线4.1 训练主循环的代码骨架与参数说明数据和模型就位后训练循环是整个项目的发动机。PyTorch的训练循环范式相对固定核心步骤五步前向传播算损失、梯度清零、反向传播、优化器更新、记录指标。MNIST一个epoch有60000/64约938个batchCPU上跑一个epoch大约二三十秒整个训练几十个epoch时间不算长循环代码不需要做复杂优化保持清晰可读更重要。import torch.optim as optim device torch.device(cuda if torch.cuda.is_available() else cpu) model MNISTCNN().to(device) criterion nn.CrossEntropyLoss() optimizer optim.Adam(model.parameters(), lr0.001) 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) outputs model(images) # 前向 loss criterion(outputs, labels) optimizer.zero_grad() # 梯度清零 loss.backward() # 反向传播 optimizer.step() # 参数更新 total_loss loss.item() * images.size(0) _, predicted torch.max(outputs, 1) correct (predicted labels).sum().item() total labels.size(0) return total_loss / total, correct / totaloptimizer.zero_grad()这行单独强调再多次都不为过。漏掉它梯度会在每个batch累积loss曲线剧烈震荡甚至发散到nan很多新手排查半天才发现是三行顺序里的第一行丢了。loss.backward()计算梯度optimizer.step()用梯度更新参数这三行的顺序和调用方式固定下来后几乎所有PyTorch项目都能复用。model.train()这行把模型切到训练模式Dropout开始随机丢弃神经元如果忘了调用模型默认停在eval模式Dropout不生效训练效果和预期差异很大。这里需要提一下设备管理。images、labels、model放到同一个device上CPU训练什么都不用做有GPU时model先.to(cuda)每个batch的tensor也要.to(cuda)否则前向计算报device mismatch。代码里的.to(device)放在循环内部是合理的加载器返回的tensor默认在CPU上每batch搬到GPU开销很小。如果追求极致性能可以配合pin_memoryTrue让搬运更快但MNIST上感知不明显。4.2 评估函数、epoch循环与学习率正则化的实际影响训练主循环之外需要一个评估函数。评估和训练不同不需要计算梯度也不需要更新参数只需要把batch里的图跑一次前向统计预测正确的数量。这里的关键是用torch.no_grad()包住整个循环避免PyTorch为推理构建计算图浪费内存和算力。def evaluate(model, loader, device): model.eval() correct, total 0, 0 with torch.no_grad(): for images, labels in loader: images, labels images.to(device), labels.to(device) outputs model(images) _, predicted torch.max(outputs, 1) correct (predicted labels).sum().item() total labels.size(0) return correct / total for epoch in range(15): loss, train_acc train_one_epoch(model, train_loader, criterion, optimizer, device) test_acc evaluate(model, test_loader, device) print(fepoch {epoch1:02d} loss {loss:.4f} train_acc {train_acc:.4f} test_acc {test_acc:.4f})epoch循环里每轮在测试集上评估一次观察两条曲线的走势。训练准确率稳步上升而测试准确率停滞甚至回落就是过拟合的前兆应该考虑减少epoch数量或增强Dropout。MNIST上跑到第10到15个epoch测试准确率通常会稳定在98%到99%区间。如果第3个epoch还不到90%优先回去检查数据预处理而不是调网络。学习率是MNIST训练中影响最直接的超参数。Adam用lr0.001是经过大量实验验证的默认值不需要改。改成0.01前几个epoch的loss会快速下降随后在平台期震荡最终测试精度到不了99%改成0.0001loss下降很慢15个epoch可能只到95%。直觉是学习率大了不收敛小了收敛慢Adam默认值在这个任务上正好踩中平衡点。下表是经验值参考| 学习率 | 收敛速度 | 典型测试准确率15个epoch | | 0.01 | 快但震荡 | 约97% | | 0.001 | 适中稳定 | 约99% | | 0.0001 | 慢 | 约95%-96% |L2正则化在PyTorch里对应优化器参数weight_decay。网上很多博客写着weight_decay1e-4是标配于是MNIST上也照抄结果准确率反而更低。原因是MNIST数据量够大、任务简单模型不容易真正过拟合weight_decay限制模型容量反而坏事。在MNIST上weight_decay保持默认0靠Dropout和epoch控制就足够。换到Fashion-MNIST或CIFAR这类更难的数据集weight_decay的价值才体现出来。这个反直觉现象值得记一笔简单任务上正则化不一定带来收益参数不是越“正规”越好。epoch数量同样不要贪多。MNIST上跑15到20个epoch测试准确率饱和继续训练训练集准确率往100%冲测试集反而可能出现毫厘级别的回落这就是过拟合的早期信号。最稳的做法是保存测试准确率最高的模型权重训练结束后用最优checkpoint推理而不是默认用最后一个epoch的结果。在训练循环里加一个if test_acc best_acc的判断保存最优权重这个方法在任何数据集上都适用值得养成习惯。5. MNIST训练避坑指南五个常见问题与排查方法MNIST项目代码量不大但运行时出的问题却不少。下面五个问题是我在实际带项目时见过频率最高的每一条都按“现象 → 原因 → 解决”的结构写方便直接对号入座。5.1 loss不降反升或直接变成nan现象训练刚开始还好几轮迭代后loss变成nan或者从第一个epoch起就一直停在1.2附近打转。原因loss变成nan最常见的原因是梯度爆炸触发点通常是学习率过大或者模型输出没有做数值稳定处理。loss一直不降多数是数据预处理问题最常见的是忘了归一化输入像素值0到255全量送进网络。另一个高频因素是优化器的zero_grad漏写导致梯度跨batch累积loss曲线像心电图一样上下乱跳。解决先把学习率降到0.0001试探如果loss恢复下降再逐步调回。检查数据有没有经过ToTensor()和Normalize把训练样本的像素范围打印出来确认在0到1之间。最后检查训练循环里是否调用了optimizer.zero_grad()。三个地方都排查完仍无法解决用单个batch测试——只取一个batch做前向和后向确认loss能正常计算再跑全量。单batch测试是定位这类问题最有效的手段能快速把问题范围缩小到模型还是数据。5.2 训练集99.9%但测试集只有93%现象训练结束时训练集准确率接近100%测试集却只有93%左右差值超过五个点。原因典型过拟合。MNIST训练集和测试集来自同一分布正常差值应控制在1到2个点内。差到5个点以上通常是模型容量偏大、训练时忘记开启Dropout、或者epoch跑太久模型把训练集的噪声当成有效特征学进去了。解决三步依次检查。确认forward里Dropout模块存在且训练时model.train()被调用过把epoch数量减到10观察测试集准确率是否回升把第一层卷积的通道数从32减到16降低模型容量。在MNIST上前两步基本就能把差值拉回2个点以内第三步是最后手段。如果减通道数之后测试准确率明显下降说明之前是正则化不够而非容量过大把通道数加回去并调整Dropout概率。5.3 训练速度极慢一个epoch要跑十几分钟现象代码正常跑但一个epoch耗时极长整个实验完全做不动。原因三个常见方向。一是Windows上num_workers设成了非0子进程数据加载冲突导致效率低下二是CPU训练时没有启用底层数学库加速三是循环里反复做tensor的CPU/GPU搬运造成不必要的开销。解决Windows上把num_workers设0通常立刻改善。确认PyTorch安装的是官方预编译版本官方包默认链接MKL和OpenMPCPU矩阵运算有加速自己从源码编译如果没配好底层库性能会有数量级差距。把设备变量固定下来在循环外写device模型和tensor统一用.to(device)转移不要每轮重复创建device对象或反复搬运。这三个点都改完后MNIST在普通笔记本上一个epoch的时间应该在30秒以内如果还慢检查后台是不是有程序占满CPU。5.4 测试集准确率99%但推理自己写的数字全错现象测试集准确率很高拿自己画的数字去推理经常识别错甚至完全不认识。原因大多数是预处理不一致。训练时图像经过ToTensor和Normalize推理时单张图片直接转numpy数组或PIL图没有做同样的处理模型看到的输入分布和训练时对不上。另一个常见原因是颜色方向反了MNIST是白底黑字自己画的是黑底白字颜色反转后网络看到的内容完全变样。解决推理代码里复用训练时的transform确保图片先转28x28灰度图再走ToTensor和Normalize。检查图片背景和前景的颜色关系黑底白字先做像素反转255减像素值变成白底黑字再送入网络。单张图转成batch时用unsqueeze(0)调整形状从28x28变成1x1x28x28。还有一个技巧把预处理后的图片保存下来看一眼确认形状完整、笔画粗细合适、数字居中——预处理后的图才是模型真正看到的内容这一步很多人跳过导致在错误前提下调模型参数。5.5 同一份代码每次结果不同无法复现实验现象同一份代码跑两次测试准确率一次98.5%一次99.1%结果对不上怀疑代码有问题。原因训练本身是随机过程。参数初始化、数据打乱、Dropout的神经元丢弃都带随机性。不固定随机种子时不同运行之间的差异可能超过一个百分点。这不代表代码有bug但会让人在调参时无法判断效果变化来自参数还是随机波动。解决在脚本开头固定随机种子覆盖Python内置random、NumPy和PyTorch三处。import random import numpy as np import torch def set_seed(seed): random.seed(seed) np.random.seed(seed) torch.manual_seed(seed) torch.cuda.manual_seed_all(seed) set_seed(42)固定种子后同一份代码在同一环境下结果一致适合调参阶段做对比实验。但要注意固定种子看到的单次分数不完全等于真实水平最终评估时应该放开随机种子跑5次取平均值用均值判断模型能力。这里还有一个细节在GPU上如果还想进一步提升复现性可以设置torch.backends.cudnn.deterministic True但这个开关会让程序变慢MNIST这种小模型通常不需要。6. 模型保存、加载与自测用自己的手写数字验证模型6.1 保存/加载的正确姿势与跨设备注意事项训练完成后模型权重存在内存里进程退出就没了。保存的目的是把训练好的权重写到磁盘下次直接加载推理不必重新训练。PyTorch推荐做法是保存state_dict——只存参数字典不包含模型结构格式清晰且兼容性好。加载前先定义同一结构的模型再load参数。# 保存 torch.save(model.state_dict(), mnist_cnn.pt) # 加载 model MNISTCNN() model.load_state_dict(torch.load(mnist_cnn.pt, map_locationcpu)) model.eval()load_state_dict要求当前模型结构和保存时的完全一致层名对不上会报Missing key。map_locationcpu用于把GPU上保存的权重加载到CPU场景不指定会报设备不匹配。加载后必须调用model.eval()切到推理模式否则Dropout仍然随机丢弃神经元同一次推理每次输出都可能不同。6.2 自测手写数字白底黑字、尺寸与归一化的坑用自己的手写图片验证模型是MNIST项目最后也是最有成就感的一步预处理坑也最多。一张手机拍或画图软件写的图和MNIST的28x28灰度格式差得很远。我一般走这条通道转灰度图 → 反转颜色 → 缩放居中到28x28 → 转tensor并复用训练时的transform。from PIL import Image, ImageOps def preprocess_image(path): img Image.open(path).convert(L) # 转灰度 img ImageOps.invert(img) # 黑底白字转白底黑字 img img.resize((28, 28), Image.Resampling.LANCZOS) transform transforms.Compose([ transforms.ToTensor(), transforms.Normalize((0.1307,), (0.3081,)) ]) tensor transform(img).unsqueeze(0) # 加batch维度 return tensorImageOps.invert只在原图是黑底白字时使用白底黑字不需要反转。resize用LANCZOS插值比默认的最近邻插值好很多后者会把笔画缩出明显锯齿。unsqueeze(0)把1x28x28变成1x1x28x28模型输入要求四维BxCxHxW漏掉这行会报维度错误。推理出错时先保存预处理后的图片看一下很多“模型识别不准”其实是预处理把数字弄变形了——数字不居中、笔画太细、或者被缩放到只剩几个像素这些都不是模型能解决的。训练和推理全部跑通后最值得做的验证不是反复看测试集准确率而是拿一两张自己写的数字、打印体、甚至网上找的字体做一组对照。这个习惯能帮你建立对整套流程的直觉知道数据从原始状态到模型输入之间发生了什么也知道模型最终看到的是什么。我自己的体会是MNIST的价值不在于把99%变成99.2%而在于它足够小、足够干净逼着人把数据、模型、训练、推理这条链路上每一个环节都理解到位。希望这篇里写的参数和坑能帮你在同样的路上少花一些冤枉时间。本文还有配套的精品资源点击获取