PyTorch MNIST识别实战:从数据加载到CNN模型训练

发布时间:2026/10/9 23:37:19
PyTorch MNIST识别实战:从数据加载到CNN模型训练
简介基于PyTorch构建的MNIST手写数字识别项目覆盖完整源码、原始数据集与训练产物面向计算机、电子信息工程、数学等专业学生在课程设计、期末大作业或毕业设计阶段作为参考资料使用也适合入门深度学习读者对照练习。压缩包共24个文件整体约25.24MB其中2个Python脚本承担数据加载、模型搭建与训练流程2个pth权重文件保存已训练模型结果MNIST原始图像与标签数据以idx和gz格式存放另有说明文档、PyCharm工程配置等辅助内容结构清晰。目前已有2378人浏览学习。借助这套资料读者可快速复现手写数字识别全流程理解PyTorch张量处理、网络前向传播、损失计算与权重保存等关键环节并能在现有代码上自行调整网络结构、超参数或扩展训练逻辑为后续计算机视觉项目打基础。1. 基于Pytorch的MNIST识别一道绕不开的入门题MNIST手写数字识别是接触Pytorch时几乎绕不开的一个小项目。它解决的是一个很具体的问题把28×28像素的灰度图片正确分类到0到9这十个数字。拿到这个标题里的压缩包你其实在找一个能直接跑通的完整方案——既要有能用的源码也要有随包附带的数据免得自己下载时被网络问题折腾半天。很多人在这一步栽跟头不是因为模型复杂而是卡在数据读取、维度转换和CPU训练速度上。下面把我做过的一套MNIST识别流程从头拆到尾包含数据预处理、模型设计、训练脚本、常见坑以及把准确率从0.9推到0.99的进阶路径。无论你是第一次用Pytorch写分类任务还是想对比一下自己的代码哪里有问题都能照着复现。2. 数据加载与预处理把原始MNIST文件变成Pytorch能吃的张量2.1 解压资源包后的数据目录idx二进制格式与读取约定拿到“源码数据.rar”这种压缩包第一个动作不是看模型代码而是先看数据目录。MNIST的官方形态是四个压缩的idx文件训练图像6万张、训练标签6万个、测试图像1万张、测试标签1万个。文件名分别以train-images、train-labels、t10k-images、t10k-labels开头。Pytorch生态里有torchvision这个工具包它内置了MNIST数据集的自动下载和解析逻辑所以我们完全不用自己写二进制解析。idx格式非常直白文件开头是4字节魔数图像是0x00000803标签是0x00000801接着是4个字节的维度数量然后是各维度尺寸和纯像素数据。图像像素按行排列每个字节是一个灰度值28×28也就是784个字节一张图。了解这个格式的用处在于排错当你看到压缩包里不是标准的gz文件而是一堆已经解压出来的idx文件时你会知道它们应该放在data/MNIST/raw目录下文件名一个都不能改torchvision才能识别。我曾经接过一个数据路径错乱的工程就是因为有人把idx文件直接扔在data根目录导致datasets.MNIST找不到标签文件。下面这个片段是我处理数据目录最常用的开头import torch from torchvision import datasets, transforms # 按压缩包解压后的实际位置填写代码和data在同级目录时用相对路径 data_path ./data transform transforms.Compose([ transforms.ToTensor(), ])这里我为什么先不写Normalize第一步只做ToTensor把像素从0到255缩放到0到1。先跑通再谈标准化能减少调试变量。如果包内数据是官方结构datasets.MNIST会直接识别data_path下的MNIST/raw子文件夹。如果结构不对会报FileNotFoundError这时候先检查路径不要急着改代码。torchvision的download参数值得注意第一次运行可以用downloadTrue让它把缺少的文件补全但如果压缩包里已经带了完整数据记得改成False省得每次运行都去检查网络连接。2.2 DataLoader与Normalize归一化为什么重要等数据读进来之后归一化就该上场了。ToTensor只是把数据变成Pytorch的FloatTensor每个像素在[0,1]之间但不同样本的像素均值方差并不一致。如果不做标准化网络前几层容易学得慢做了标准化后输入接近正态分布梯度更新更均匀收敛速度明显提升。MNIST有一个计算好的标准化参数均值0.1307标准差0.3081。这两个值是统计6万张训练图像得到的已经是社区公认的固定值直接抄没有问题。需要注意MNIST是灰度图所以mean和std要写成只有一个分量写成(0.5,0.5,0.5)这种RGB三通道数值虽然不报错但会拖慢收敛。下面是完整的数据集加载代码# 完整transform先转Tensor再标准化 transform transforms.Compose([ transforms.ToTensor(), transforms.Normalize((0.1307,), (0.3081,)) ]) train_dataset datasets.MNIST(data_path, trainTrue, downloadFalse, transformtransform) test_dataset datasets.MNIST(data_path, trainFalse, downloadFalse, transformtransform) # num_workers在Windows下设为0Linux下可设为2 train_loader torch.utils.data.DataLoader( train_dataset, batch_size128, shuffleTrue, num_workers0 ) test_loader torch.utils.data.DataLoader( test_dataset, batch_size128, shuffleFalse, num_workers0 )参数说明batch_size128是个比较稳的默认值。调小到32或64梯度更“随机”收敛路径可能会抖但MNIST这种小数据集影响不大调大到512单epoch速度快但对显存或内存有要求。shuffleTrue只用于训练集测试集保持原始顺序即可因为评估不需要随机性。num_workers在Windows上建议设0这是我踩过的坑后面会专门展开。DataLoader是一个惰性迭代对象每次for循环取一个batch内部自动把Dataset做索引、把PIL图像转成张量所以我们不需要关心单样本的形状。提示Normalize的参数要按单通道写(0.5,0.5,0.5)是RGB三通道的写法直接用到MNIST上会影响收敛。2.3 可视化检查用一张网格图确认数据没有错位数据管道跑通之后我习惯先画一批图看看。这一步能同时确认三件事图片内容确实是手写数字、标签没有和图片错位、归一化后的像素分布没有异常。如果跳过这一步后面模型训练acc不管高低你都分不清是模型问题还是数据问题。以下代码从train_loader里取一个batch并画前6张import matplotlib.pyplot as plt images, labels next(iter(train_loader)) fig, axes plt.subplots(2, 3, figsize(6, 4)) for i, ax in enumerate(axes.flat): img images[i].squeeze().numpy() ax.imshow(img, cmapgray) ax.set_title(str(labels[i].item())) ax.axis(off) plt.tight_layout() plt.show()这段逻辑很直接next(iter(train_loader))取一个batchimages[i]形状是(1,28,28)squeeze()去掉那个值为1的通道维变成(28,28)numpy()转成可被matplotlib识别的ndarray。cmapgray让灰度图正常显示。一个视觉上的细节经过Normalize之后像素值已经包含负数imshow默认会做min-max映射所以显示结果正常。如果你中途把数据用astype(uint8)转回0到255再显示一定要先把像素加回去否则图会灰蒙蒙数字边缘发虚。还有一个习惯是打印批量形状print(images.shape, labels.shape) # torch.Size([128, 1, 28, 28]) torch.Size([128])这行输出能避免很多低级错误模型第一层写法取决于通道数1如果你自己load单张图最外层要补一个batch维。看到这个形状后面模型设计就不会再用错维度。2.4 非标准数据兜底当压缩包里的MNIST不是官方idx文件大多数数据压缩包里的MNIST是官方原始文件但偶尔会有人把数据转成单个PNG文件夹或者CSV。这种情况torchvision的MNIST类就不适用了。遇到过这种情况最简单的办法是改用torch.utils.data.Dataset自己写一个加载器。以图片目录为例import os from PIL import Image from torch.utils.data import Dataset class ImageMNIST(Dataset): def __init__(self, img_dir, transformNone): self.img_dir img_dir self.paths sorted( [os.path.join(img_dir, f) for f in os.listdir(img_dir) if f.endswith(.png)] ) self.transform transform def __len__(self): return len(self.paths) def __getitem__(self, idx): img Image.open(self.paths[idx]).convert(L) label int(os.path.basename(self.paths[idx]).split(_)[0]) if self.transform: img self.transform(img) return img, label这个类仍然读取28×28灰度图然后走同样的transform。注意label从文件名里提取要求文件名是类似“5_001.png”的格式。如果你的压缩包数据文件名不是这个规律先写一段脚本把标签抽出来。写自定义Dataset的核心是实现__len__和__getitem__两个方法并不复杂。3. 模型设计先过一遍全连接再换成CNN3.1 全连接基线把28×28压平成784先验证数据管道第一次做MNIST我建议先写一个全连接网络。原因很简单代码少CPU上跑得快且不用关注通道数和特征图尺寸。全连接网络不考虑图片的空间结构把28×28的像素直接拉直成784个特征然后丢进若干层线性变换。MLP结构如下import torch.nn as nn class MLP(nn.Module): def __init__(self): super().__init__() self.net nn.Sequential( nn.Flatten(), # (B,1,28,28) - (B,784) nn.Linear(784, 512), # 线性层1 nn.ReLU(), # 激活 nn.Linear(512, 256), # 线性层2 nn.ReLU(), # 激活 nn.Linear(256, 10) # 输出层10个类别 ) def forward(self, x): return self.net(x)这个模型包含三层线性层。第一层把784维投影到512维中间加ReLU激活第二层再把512压缩到256最后第三层输出10维。10个输出对应0到9十个类别。注意forward里没有softmax因为后面用的CrossEntropyLoss自带softmax计算如果你在最后一层手动接softmax训练时反而会让数值稳定性变差推理时可以用softmax看概率。参数量784×512512401920512×256256131328256×10102570合计约53.6万。这是很小的模型纯CPU训练一个epoch只要几十秒。为什么不用更少的隐藏层因为一层线性变换表达能力不足不加隐藏层时准确率通常只有80%到90%加一层后能到95%以上再加第二层可以逼近97%。3.2 从MLP到CNN卷积核怎么保留2D结构全连接网络有个明显缺点它把相邻像素的空间关系丢掉了。手写数字的笔画是局部的卷积神经网络通过小尺寸卷积核在图片上滑动保留了局部纹理信息所以准确率能从97%提到99%以上。经典结构如下class CNN(nn.Module): def __init__(self): super().__init__() self.features nn.Sequential( nn.Conv2d(1, 32, kernel_size3, padding1), # 1-32, 28x28 nn.ReLU(), nn.MaxPool2d(2), # 28x28 - 14x14 nn.Conv2d(32, 64, kernel_size3, padding1), # 32-64, 14x14 nn.ReLU(), nn.MaxPool2d(2) # 14x14 - 7x7 ) self.classifier nn.Sequential( nn.Flatten(), # (B,64,7,7) - (B,3136) nn.Linear(64 * 7 * 7, 128), nn.ReLU(), nn.Linear(128, 10) ) def forward(self, x): return self.classifier(self.features(x))第一层Conv2d输入通道是1因为MNIST是灰度图输出32个通道kernel_size3表示每个卷积核是3×3padding1保证输出尺寸不变。每经过一次MaxPool2d特征图尺寸减半。输入28×28第一次池化后14×14第二次池化后7×7。第二层卷积后通道数从32变成64所以进入全连接前有64×7×73136个特征。假如你想调整kernel_size或加一层池化就按这个公式计算特征图边长每经过一次stride为2的池化尺寸除2。3×3卷积加padding不会改变尺寸。参数量第一层卷积3×3×1×3232320第二层3×3×32×646418496全连接3136×128128401536128×10101290总计约42万。跟全连接差不多的规模但分类能力明显更强。如果你想在CPU上更快一些可以把第二个隐藏层128改成64准确率只会掉一点点。3.3 选哪个模型跑这份源码准确率、速度与调试成本对比我通常建议先把全连接跑通再切换到CNN。原因很实际MLP报错时堆栈短CNN里卷积核尺寸、padding、pooling顺序任何一项写错打印出来的特征图尺寸就对不上新手往往难以定位。下面给一个简单对比模型参数量训练速度期望准确率MLP(512-256)约53万快CPU可跑约97%CNN(32-64卷积)约42万稍慢CPU也可接受约99%期望准确率是基于MNIST常见结果的参考值。不同版本和随机种子会带来0.2%左右波动MLP跑到96.5%CNN跑到98.7%都正常。关键是如果MLP只有90%而CNN还是90%问题大概率不在模型结构而在数据预处理或训练超参。3.4 用假数据验证输出形状3分钟排查维度错误无论选哪个模型训练之前都建议做一次前向验证。用随机张量代替真实图片跑一次model看输出形状是否匹配model CNN() fake torch.randn(2, 1, 28, 28) # 模拟2张28x28灰度图 output model(fake) print(output.shape) # 期望 torch.Size([2, 10])如果线性层的输入维度写错这一步会立刻报错你可以在不改训练代码的情况下快速修正。等输出是[2,10]了再让model去读真实数据剩下的问题基本就只在loss和优化器侧。这也是我把这步放在训练循环前面的原因。4. 训练循环与超参数从loss到准确率一步步跑通4.1 超参数选型batch_size、epoch和优化器之间的平衡模型搭好后进入训练。MNIST常用的超参数有一个合理区间。我推荐batch_size128或者64不是越大越好太大会让每个epoch变慢太小会让loss曲线抖动。epoch一般5到10就够MNIST很小10个epoch在CPU上通常也就5到10分钟。优化器我一般首选Adamlr0.001。Adam对学习率不敏感不像SGD那样需要手动调动量适合快速跑通。如果想更高准确率可以换成SGD加momentum0.9配合lr0.01但新手不建议一开始就调这些。损失函数用CrossEntropyLoss它同时完成softmax和交叉熵计算输出10维logits不用手动过softmax。为什么CrossEntropyLoss内部要做softmax因为交叉熵损失要求输入是概率分布但模型输出是未归一化的logits。如果只做argmaxlogits和softmax结果一致所以推理时不需要softmax。训练时如果手贱在模型输出后又加了一轮softmax等价于对输出求了两次softmax梯度会变形准确率反而下降。参数推荐值说明batch_size128均衡速度与梯度稳定性lr0.001 (Adam)太大会nan太小收敛慢epochs5~10MNIST易过拟合不是越久越好optimizerAdam默认beta10.9, beta20.999lossCrossEntropyLoss分类任务标配纯CPU且内存紧张时batch_size降到64有GPU可以开256但MNIST太小GPU优势不明显。如果你有GPU最简单的加速改动是device torch.device(cuda if torch.cuda.is_available() else cpu) model CNN().to(device) # 训练循环里每个batchimages, labels images.to(device), labels.to(device)注意第一次迁移时最容易报错的地方是忘了把labels也搬到GPU。不搬labelsloss会报device mismatch。MNIST规模小GPU提升不明显但没有GPU的代码也能跑不要为了这一步卡住。4.2 训练循环的五个固定动作Pytorch的训练循环无论模型多复杂都逃不过这五步梯度清零、前向计算、算loss、反向传播、更新参数。顺序不能乱。给出完整循环import torch.optim as optim model CNN() optimizer optim.Adam(model.parameters(), lr0.001) criterion nn.CrossEntropyLoss() for epoch in range(5): running_loss 0.0 model.train() # 切到训练模式 for images, labels in train_loader: optimizer.zero_grad() # 第1步清空上一次梯度 outputs model(images) # 第2步前向 loss criterion(outputs, labels) # 第3步算loss loss.backward() # 第4步反向传播 optimizer.step() # 第5步更新权重 running_loss loss.item() * images.size(0) epoch_loss running_loss / len(train_loader.dataset) print(fepoch {epoch1} loss {epoch_loss:.4f})这里最容易被忽略的是optimizer.zero_grad()。Pytorch的梯度是自动累加的如果不清零上一个batch的梯度会加到当前batch上梯度乱掉loss忽上忽下。所以这行必须在每次计算loss之前执行。loss.item()取出标量乘以batch大小再除以6万得到整个训练集上的平均loss不要直接打印每个batch的loss因为最后一个batch特别小视觉上会突然跳一下。如果你在纯CPU上跑模型可以不用model.train()但写上没坏处。有BatchNorm或Dropout时这一句是必须的。4.3 验证循环用测试集算准确率并打印曲线训练归训练真正关心的是测试集准确率。测试阶段需要关闭梯度计算以节省开销correct 0 total 0 model.eval() # 切换到评估模式关掉Dropout/BatchNorm的batch统计 with torch.no_grad(): for images, labels in test_loader: outputs model(images) _, predicted torch.max(outputs, 1) total labels.size(0) correct (predicted labels).sum().item() print(ftest accuracy {100 * correct / total:.2f}%)model.eval()影响Dropout和BatchNorm。在5.5的CNN里没有这两层但养成习惯是对的。torch.max(outputs, 1)返回每个样本最大分数对应的索引也就是预测类别。比较predictedlabels然后求和得到正确样本数。注意不需要softmaxargmax对softmax和logits结果一致。我一般把训练和验证放在同一个epoch里每训练完一个epoch打印一次训练loss和测试准确率。这样能看到准确率是上升还是下降。如果第2个epoch准确率已经接近97%说明模型和数据管道都没问题如果还停在40%优先检查第5章里提到的坑。5. MNIST训练避坑指南5个让新手崩溃的常见问题5.1 Windows上num_workers0报BrokenPipeError现象Windows下训练第一个epoch结束控制台抛BrokenPipeError程序崩溃。代码看起来没毛病。原因Windows的DataLoader多进程工作机制与Linux不同子进程在epoch切换时会和主进程发生管道断连。尤其在脚本里直接写train_loader时几乎必现。解决设置num_workers0问题立刻消失。MNIST只有6万张灰度图单进程读起来慢不了多少。实在要用多进程就把训练入口包在ifname main: 中但Windows上仍可能不稳定。我的建议是别折腾0最省心。5.2 loss不降准确率稳如随机现象训练几个epochloss始终在2.3左右准确率10%。原因最常见是标签错位或者输入图片是空白/噪声。还有可能是CrossEntropyLoss的输入和标签类型不对。解决先画图检查第2.3节。确认图片里有数字、标题和图片对应。再打印outputs.shape和labels.shape确认是[128,10]和[128]。如果labels是FloatTensor用labels.long()转成int64。还有一个小检查看一个batch图片是否有负数标准化后的图像本来就有负数但不应出现所有像素完全相同的样本。5.3 测试集准确率比训练集还高现象训练99.0%测试99.3%怀疑数据泄漏。原因实际上正常。带Dropout的网络训练时随机丢弃神经元测试时使用完整网络所以测试有时更高另外训练集在最后一个epoch可能还没完全收敛也是常见原因。解决只要测试准确率在合理范围内不用管。如果测试比训练高超过5%检查训练/测试是否有交集。torchvision自带的MNIST已经划分好测试集不会混入训练图像不需要额外处理。5.4 第二次运行报FileNotFoundError现象昨天还能跑今天运行报No such file or directory看data目录发现文件不完整。原因downloadTrue和网络不稳定会触发torchvision重新下载初始化。如果下载中断torchvision可能把不完整文件清掉再运行就找不到数据。或者你把压缩包里的数据文件移到了别处。解决数据完整后把download改成False。压缩包里的数据确认路径是data/MNIST/raw且文件名是官方四个名字。如果文件损坏删掉data/MNIST/raw整个目录重新放一份完整数据再设置downloadFalse。不要一边训练一边往数据目录里写文件。5.5 训练99%推理自己画的图却错现象测试集准确率很高但对一张手写数字图片预测时频频出错。原因预处理不一致。测试集里的图片已经被resize成28×28、转成灰度图、归一化到0.1307/0.3081你自己加载的图片可能是彩色、带黑边、大小不同、没有标准化模型看到的是另一分布。解决推理前做同一套预处理。确保图片转成28×28单通道灰度再经过训练时的transform最后unsqueeze(0)增加batch维。后面第6章会给出完整的predict函数直接照抄即可。6. 从0.9到0.99三个提高准确率的落地方案6.1 平移数据增强MNIST测试集在构建时做了中心裁剪数字基本居中但自己拍的图经常有偏移。用RandomAffine给训练集加少量平移增强模型对位置的鲁棒性# 只在训练集上使用平移增强测试集保持原始transform train_transform transforms.Compose([ transforms.RandomAffine(degrees0, translate(0.1, 0.1)), transforms.ToTensor(), transforms.Normalize((0.1307,), (0.3081,)) ])这样模型见过偏移后的数字推理时对位置更不敏感。注意degrees0表示不做旋转只做水平和垂直平移。translate(0.1,0.1)表示平移比例最大到10%。别调太大否则会扫到边界外的黑色区域。6.2 学习率衰减用Adam在0.001下跑到第5个epochloss容易进入平台期。加一个StepLR每3个epoch把学习率缩小10倍scheduler torch.optim.lr_scheduler.StepLR(optimizer, step_size3, gamma0.1) # 每个epoch结束后调用 scheduler.step()这样后期会以更小的步长在loss曲面上精细移动。一个小细节scheduler.step()要放在一个epoch结束后而不是放在每个batch后否则学习率衰减过快。如果你用ReduceLROnPlateau还要把当前准确率传进去新手先用StepLR简单。6.3 保存模型并写一个predict函数把训练好的模型权重保存下来之后直接加载torch.save(model.state_dict(), mnist_cnn.pth) def predict(img_path): from PIL import Image transform_single transforms.Compose([ transforms.ToTensor(), transforms.Normalize((0.1307,), (0.3081,)) ]) img Image.open(img_path).convert(L).resize((28, 28)) x transform_single(img).unsqueeze(0) # 变成(1,1,28,28) model.eval() with torch.no_grad(): pred model(x).argmax(dim1).item() return predpredict里最关键的是模型加载训练和推理的transform必须一模一样。transform_single里没有RandomAffine是因为推理不该改变图片位置。另外加载模型权重后要调用model.eval()把Dropout和BatchNorm关闭否则同一张图每次预测可能不同。我第一次做MNIST时全连接网络跑到97%就上不去了后来加了卷积和随机平移直接跳到99%。这个入门题背后其实是一条完整的Pytorch链路数据加载、模型定义、训练循环、评估、导出。每一步都踩一点坑但都值得调顺。希望帮到你。本文还有配套的精品资源点击获取