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

发布时间:2026/9/15 3:40:20
PyTorch手写数字识别实战:从MNIST数据加载到CNN模型训练 简介基于PyTorch框架的手写数字识别深度学习作业面向计算机视觉初学者与期末大作业场景采用CNN卷积神经网络实现高识别率并配有完整中文注释和项目文档可直接运行或二次开发。资源压缩包共38个文件、大小44.56MB包含9个Python源码文件覆盖数据加载、模型定义、训练验证、GUI界面等模块、pt/pth模型权重、8个gz格式的数据集压缩文件、7张训练准确率及界面效果截图以及docx需求分析、测试用例等文档整体目录结构清晰从图像预处理到模型评估均有代码支撑。已有544人学习使用。通过这套工程读者能具体掌握RMSprop优化器与交叉熵损失函数在PyTorch中的用法理解CNN卷积层、池化层设计及训练流程、模型保存加载等关键环节。搭配需求分析与测试用例文档可帮助参照完成自己的课程作业或项目设计避免从零搭建的重复劳动尤其适合需要完整工程示例的深度学习入门者。1. PyTorch手写数字识别作业最该被认真对待的入门题交这份作业之前请先想清楚一个问题当老师在代码里看到nn.Conv2d(1, 32, kernel_size3, padding1)时会不会紧接着问一句——“为什么第一层是 32 个通道不是 16 也不是 64”手写数字识别是最容易在 5 分钟内跑出 90% 以上准确率的深度学习入门题也正是因为太容易跑通大部分提交上来的 PyTorch 作业只能算“能运行”不能算“会设计”。这篇会把一套基于 PyTorch 和 Python 的完整实现拆开讲从 MNIST 数据管道、两层 CNN 模型、训练循环到模型评估、文件保存和代码注释、文档说明都按一个可以直接交上去的标准来写。适合正在赶深度学习课程作业、或者第一次想用手写数字识别把 PyTorch 基础框架摸清楚的人。2. 基于 PyTorch 的 MNIST 数据管道Dataset、归一化与 DataLoader2.1 为什么手写数字识别作业都用 MNISTMNIST 是计算机视觉里最老也最“干净”的入门数据集60000 张训练图、10000 张测试图每张是 28×28 的灰度图数字是 0 到 9 十个类别类别完全均衡。这道题能被广泛用作深度学习作业不是因为它简单而是它的数据规模刚好能让 CPU 在几分钟内跑完几个 epoch——学生不需要一开始就面对显存、多卡分布式这些问题。PyTorch 里torchvision.datasets.MNIST内建了这个数据集不需要自己写数据爬取和标注脚本。另外如果检索过“mnist 手写数字识别 matlab”会发现网上很多旧代码是用 Matlab 写的而题设要求的是 Python PyTorch环境上不可直接复用。不要混着抄数据虽然可以共用但模型定义和训练逻辑必须按 PyTorch 的 tensor 和 autograd 机制来写。对于第一次接触深度学习的人来说MNIST 还有个好处它小到可以放进内存出错时你盯着tensor的形状就能定位不需要上复杂的日志系统。2.2 用 torchvision 搭建 MNIST 数据加载管道一份能交作业的代码第一步是把数据加载写干净。我一般会把数据管道单独放在data_loader.py里而不是堆在训练脚本里。下面这段是完整可用的最小实现import torch from torch.utils.data import DataLoader from torchvision import datasets, transforms # 组合变换先转张量再按 MNIST 官方统计值做标准化 transform transforms.Compose([ transforms.ToTensor(), # PIL图片/HW矩阵 - CHW浮点张量像素值归一化到[0,1] transforms.Normalize((0.1307,), (0.3081,)) # 减均值除标准差加速收敛 ]) train_set datasets.MNIST( root./data, trainTrue, transformtransform, downloadTrue, ) test_set datasets.MNIST( root./data, trainFalse, transformtransform, downloadTrue, ) train_loader DataLoader(train_set, batch_size64, shuffleTrue, num_workers0) test_loader DataLoader(test_set, batch_size256, shuffleFalse, num_workers0)这段代码的逻辑是一条流水线ToTensor()把原本取值范围 0~255 的 PIL 灰度图转成 0~1 的浮点张量同时把维度从H×W调整为1×H×W也就是 PyTorch 约定的通道在前格式Normalize((0.1307,), (0.3081,))里两个常数是 MNIST 全量像素的均值和标准差做完标准化后数据近似服从标准正态分布。DataLoader负责按batch_size切分数据shuffleTrue让每个 epoch 里样本顺序不同避免模型记住批次顺序。参数里有几个值得注意的地方。num_workers0在 Windows 上最稳妥因为多进程数据加载在 Windows 下经常触发BrokenPipeError课程作业演示用不上加速直接设 0。test_loader的shuffleFalse是故意的——评估时不需要打乱而且保留原始顺序便于后面画混淆矩阵时对齐标签。batch_size64是手写数字识别场景下的常见默认值太小会让 loss 曲线抖动太大在 CPU 上单步耗时明显增加。2.3 首次运行下载慢把数据提前放进 raw 目录第一次执行上面代码时downloadTrue会从官方源下载四个.gz文件。课程机房的常见情况是联网受限或者下载速度很慢。常见做法是提前在能联网的机器上下载好这四个文件然后按照下面的目录结构放好放好之后downloadTrue会检测到文件已存在直接跳过下载data/MNIST/raw/ ├── train-images-idx3-ubyte.gz ├── train-labels-idx1-ubyte.gz ├── t10k-images-idx3-ubyte.gz └── t10k-labels-idx1-ubyte.gz还需要确认一点.gz文件不要手动解压。torchvision内部会按文件名判断原始压缩包是否存在解压后的文件它也认但保留原始.gz是最不容易出错的形态。如果你的root参数改过比如root./mnist_data上面的data/MNIST/raw前缀也要相应变成mnist_data/MNIST/raw。这个细节卡住过不少人文档说明里值得写一句。2.4 加载之后先验证张量形状不要急着建模型先花十秒钟确认数据长什么样。在data_loader.py末尾加几行if __name__ __main__: images, labels next(iter(train_loader)) print(images.shape) # torch.Size([64, 1, 28, 28]) print(labels.shape) # torch.Size([64]) print(images.min().item(), images.max().item())[64, 1, 28, 28]四个维度分别是 batch、通道、高、宽这个顺序贯穿整个 PyTorch 视觉代码。如果打印出来的最小值接近 -0.4、最大值接近 2.2说明归一化生效了。这一步做完数据管道就算跑通了后面所有报错都可以排除“数据没读对”这个嫌疑。3. 手写数字识别的 CNN 网络结构用 PyTorch 搭一个两层卷积模型3.1 为什么全连接网络在 28×28 图片上不够用一些同学的第一个版本是用nn.Linear(784, 100)堆出来的全连接网络。把 28×28 的图片拉平成 784 维向量等于彻底丢掉像素之间的空间位置关系——数字“1”从上到下的连续笔画、数字“8”的上下两个闭环这些结构信息在展平瞬间就消失了。全连接网络不是不能收敛而是需要更多参数来硬记位置模式泛化能力差稍微换个字体或倾斜角度就容易错。CNN 解决的是这个问题它用三个归纳偏置代替人工特征局部连接每个卷积核只看一个小邻域、权值共享同一个卷积核滑过整张图、下采样逐步缩小特征图尺寸。卷积核在 28×28 的图上滑动时不管数字出现在左上角还是右下角提取到的边缘特征是一致的这就是平移不变性。对 MNIST 这种只包含简单笔画的数据集两层卷积已经完全够用这也是经典 LeNet 的思路。3.2 用 nn.Sequential 定义两层卷积模型下面这个模型是我给这个作业的标准答案结构清晰、容易讲、跑得快import torch.nn as nn class CNN(nn.Module): def __init__(self, num_classes10): super().__init__() self.features nn.Sequential( nn.Conv2d(1, 32, kernel_size3, padding1), # 28x28 - 28x28 nn.ReLU(inplaceTrue), nn.MaxPool2d(kernel_size2), # 28x28 - 14x14 nn.Conv2d(32, 64, kernel_size3, padding1), # 14x14 - 14x14 nn.ReLU(inplaceTrue), nn.MaxPool2d(kernel_size2), # 14x14 - 7x7 ) self.classifier nn.Sequential( nn.Flatten(), nn.Linear(64 * 7 * 7, 128), nn.ReLU(inplaceTrue), nn.Dropout(p0.25), nn.Linear(128, num_classes), ) def forward(self, x): x self.features(x) x self.classifier(x) return x前向传播里没有手动写reshape因为nn.Flatten()会自动把[64, 64, 7, 7]拉成[64, 64*7*7]。结构上的关键点是两处padding1配合两处MaxPool2d(kernel_size2)卷积不改变特征图尺寸池化每次减半于是 28→14→7最终得到 64 个 7×7 的特征图。文档说明里建议写一句这个特征图上的每个点都对应原图上一个 7×7 左右的感受野。维度计算公式是out (W 2*P - F) / S 1。第一层卷积W28, P1, F3, S1代入得到(282-3)/1128池化层W28, F2, S2得到(28-2)/2114。写作业时把这个公式和推导过程放在注释里是一个很容易被认可的细节。3.3 通道数和 padding 策略怎么定第一层卷积选 32 个通道是一个性价比很高的经验值。16 个通道参数少一半但对边缘、角点这类低频特征的表达能力偏弱64 个通道准确率略高训练时间也接近翻倍。作业场景下 32→64 的递增模式既展示了特征逐层抽象的思想又控制住了参数量——整个模型大约 42 万参数CPU 上训练 10 个 epoch 只要几分钟。另一个常见的变体是把padding1去掉。去掉之后第一层卷积输出变成(28-3)/1126池化后是 13第二层卷积变成 11池化后是 5最后的特征图是64*5*5。注意这时nn.Linear(64 * 7 * 7, 128)必须同步改成64 * 5 * 5否则形状不匹配直接报错。两种写法都能得到 97% 以上的准确率但padding1的版本让尺寸变化全是整齐的除 2 关系心智负担小得多我一般建议作业用这个版本。3.4 初始化与参数量文档说明里怎么写PyTorch 的nn.Conv2d和nn.Linear默认使用 Kaiming 均匀初始化适配 ReLU 激活函数所以这份代码里不需要手动调用nn.init。作业文档里如果写了“采用 Kaiming 初始化”记得说明这是框架自带行为不是自己实现的。参数量可以用下面这行代码打印total sum(p.numel() for p in model.parameters()) print(fTotal parameters: {total:,})p.numel()统计每个权重张量的元素个数卷积层要算上偏置项。把打印结果贴在文档说明里比空泛地写“模型参数量适中”有说服力得多。4. 手写数字识别训练循环损失函数、优化器与 epoch 的配合4.1 训练一个 epoch 时代码到底在做什么训练循环是这份作业里最难用语言讲清楚的部分因为它涉及 PyTorch 的自动求导机制。用一个 epoch 的视角看代码要遍历全部 60000 张训练图每 64 张一组共 938 个 batch。每个 batch 里执行四步前向传播计算预测值和损失、zero_grad()清空上一轮梯度、backward()反向传播计算梯度、optimizer.step()更新权重。四步顺序不能乱漏掉zero_grad()会导致梯度累加loss 曲线会异常震荡。作业的代码结构我建议按下面的目录组织这样“完整源码”和“文档说明”两个要求都直观可见mnist_pytorch/ ├── data/ │ └── MNIST/ ├── data_loader.py # 数据管道 ├── model.py # 网络结构定义 ├── train.py # 训练循环 模型保存 ├── evaluate.py # 测试集评估 混淆矩阵 ├── README.md # 运行说明、结果、环境 └── requirements.txt4.2 可复制的训练循环代码import torch import torch.nn as nn from tqdm import tqdm from model import CNN from data_loader import train_loader, test_loader device torch.device(cuda if torch.cuda.is_available() else cpu) model CNN().to(device) criterion nn.CrossEntropyLoss() optimizer torch.optim.Adam(model.parameters(), lr1e-3) epochs 10 for epoch in range(1, epochs 1): model.train() running_loss 0.0 loop tqdm(train_loader, descfEpoch {epoch}/{epochs}) for images, labels in loop: images, labels images.to(device), labels.to(device) outputs model(images) # 前向传播输出10个类别的logits loss criterion(outputs, labels) # 交叉熵损失内部含softmax optimizer.zero_grad() # 清空上一次的梯度防止累加 loss.backward() # 反向传播计算每个参数的梯度 optimizer.step() # 沿梯度反方向更新一次参数 running_loss loss.item() loop.set_postfix(lossf{loss.item():.4f}) avg_loss running_loss / len(train_loader) print(fEpoch {epoch}: average loss {avg_loss:.4f}) torch.save(model.state_dict(), mnist_cnn.pth)每个 batch 的核心逻辑就是forward - loss - zero_grad - backward - step这五行。model.train()的作用是让 Dropout 层生效训练时随机丢弃一部分神经元防止过拟合到了测试阶段要调用model.eval()把它关掉否则每次推理结果都会有随机性。tqdm只是用来显示进度条的辅助库如果环境里没装直接把tqdm(train_loader)换成train_loader也不影响训练。损失函数选CrossEntropyLoss有一个隐藏细节它把 Softmax 和负对数似然合并成了一个算子所以模型的最后一层输出的是原始 logits不需要在forward里额外加nn.Softmax()。文档说明里如果画了网络结构图务必不要画一个独立的 Softmax 层这是导师最喜欢抓的细节。优化器选了 Adam因为它对学习率不敏感默认lr1e-3在这类任务上基本不会出问题。4.3 手写数字识别作业的四组必调参数参数推荐值作用调大/调小的影响epochs10完整遍历训练集的次数太小欠拟合loss 还没降到底太大会过拟合测试准确率不升反降batch_size64每次前向/反向传播的样本数调大梯度更平滑但吃内存调小噪声更大收敛不稳定lr1e-3参数更新的步长调大 loss 容易震荡甚至变 NaN调小收敛过慢devicecuda / cpu计算设备无 GPU 时自动回退 CPUMNIST 在 CPU 上完全可跑epoch 不是越大越好。手写数字识别这个任务10 个 epoch 在 Adam 下已经能到 98% 附近继续跑到 20 个 epoch准确率可能只涨零点几个百分点而训练时间翻倍。把“为什么选 10 个 epoch”写进文档说明时可以附一张 loss 曲线图指出 8 个 epoch 之后曲线已经趋于平缓这是更工程师化的表达方式。4.4 训练时最常见的三个崩溃现场第一个崩溃是 loss 变成nan。常见原因不是模型问题而是学习率太大Adam 默认 1e-3 基本安全但如果手动改成 1e-2 以上梯度一步就迈过头数值发散。第二个崩溃是 Windows 下num_workers报错解决方案就是前面说的设成 0。第三个是显存不足作业场景下batch_size64、图像分辨率只有 28×28几乎不可能爆显存如果真爆了把batch_size降到 32 即可。训练日志里如果发现 loss 不降先检查transform有没有加Normalize没做归一化的数据会让梯度更新路径非常曲折。5. 手写数字识别模型评估与保存验证集、混淆矩阵与 torch.save5.1 为什么作业必须报告测试集准确率训练集准确率没有任何意义因为模型“见过”这些图片它可能只是记住了训练样本。MNIST 的标准做法是拿 10000 张训练时从未见过的测试图做评估这个数字才能体现模型的泛化能力。PyTorch 的DataLoader已经把训练集和测试集分开了评估时只需要切换数据集和model.eval()状态。评估和训练有一个关键区别评估阶段不需要计算梯度。用with torch.no_grad():包住推理代码PyTorch 会跳过整个计算图的构建内存占用显著下降推理速度也更快。任何把model.eval()和no_grad漏掉的代码都可能在答辩时被问“为什么这里要切换模式”。5.2 测试集评估代码from sklearn.metrics import confusion_matrix import numpy as np model.load_state_dict(torch.load(mnist_cnn.pth, weights_onlyTrue)) model.to(device) model.eval() correct, total 0, 0 all_preds, all_labels [], [] with torch.no_grad(): for images, labels in test_loader: images, labels images.to(device), labels.to(device) outputs model(images) _, predicted torch.max(outputs, dim1) # 取logits最大的索引作为预测类别 total labels.size(0) correct (predicted labels).sum().item() all_preds.extend(predicted.cpu().numpy()) all_labels.extend(labels.cpu().numpy()) accuracy 100.0 * correct / total print(fTest Accuracy: {accuracy:.2f}%) cm confusion_matrix(all_labels, all_preds) print(Confusion Matrix:) print(cm)torch.max(outputs, dim1)返回两个结果最大值和对应的索引索引就是模型预测的数字。predicted labels得到布尔张量sum().item()统计正确的个数。模型在测试集上的准确率一般稳定在 98.5% 到 99% 之间如果低于 97%优先怀疑训练没收敛而不是模型结构有问题。torch.save(model.state_dict(), mnist_cnn.pth)保存的是权重字典而不是整个模型对象加载时先实例化一个结构完全相同的CNN()再用load_state_dict把权重填进去。新版 PyTorch 里torch.load建议带weights_onlyTrue这是针对反序列化安全性的官方推荐如果遇到加载报错检查是不是torch.save和torch.load的 PyTorch 版本跨度过大。5.3 从混淆矩阵看懂模型错在哪里混淆矩阵是 10×10 的矩阵第i行第j列表示真实数字i被预测成数字j的次数。对角线越接近该行总数说明这个数字的识别率越高。MNIST 上最容易混淆的是4和9、3和8、7和2因为这些数字在笔画走向和整体轮廓上确实有相似之处。评估代码里打印出的confusion matrix我建议直接存成.npy文件或截图放进文档说明它会成为整份作业里最有视觉说服力的材料。5.4 提交前检查清单检查项具体要求源码可复现从干净环境运行train.py能跑通不依赖本地绝对路径模型文件mnist_cnn.pth存在且能用evaluate.py加载文档说明包含环境配置、训练超参数、最终准确率、CPU/GPU 训练耗时注释质量关键计算步骤必须有注释重复代码不必注释数据文件data/目录可以不放压缩包但要在 README 写清来源“文档说明”是作业标题里明确要求的部分常见做法是写一个三五百字的 README先用三行描述项目做什么、网络结构是什么、结果如何再列运行步骤最后贴测试集准确率和一次训练耗时。不要写成论文式的大段文字导师看作业的速度比他想象中快得多。6. 手写数字识别作业加分项四项修改把准确率逼近 99.5%到了这个阶段基础版本已经能交作业了。如果想在报告里多写一页“改进实验”下面四个修改按投入产出比排序改动量小、效果可预期。第一项是给卷积层后加nn.BatchNorm2d。具体插法在每一组Conv2d - ReLU之间插入nn.BatchNorm2d(32)和nn.BatchNorm2d(64)。BatchNorm 把每个通道的激活值重新归一化让网络对初始化不再敏感同时轻微正则化。10 个 epoch 下准确率通常能稳定在 99.2% 以上。第二项是调大 Dropout 强度。基础版本里p0.25只在全连接层生效把第一层全连接的 Dropout 改成p0.5并额外给第二个卷积块后面加一个nn.Dropout2d(p0.2)对防止过拟合有明显帮助。注意Dropout2d丢弃的是整个通道而不是单个像素语义和Dropout不同。第三项是换优化器。Adam 换成torch.optim.SGD(model.parameters(), lr0.01, momentum0.9)训练 15 个 epoch准确率能接近 99.3%。SGD 收敛更慢但泛化更好这也是文档说明里最值得讨论的一组对比实验。第四项是数据增强。加入transforms.RandomRotation(degrees10)和transforms.RandomAffine(translate(0.1, 0.1))训练集不再是一成不变的原始图片。注意增强只应用在训练集测试集仍然只做ToTensor和Normalize否则评估标准就变了。数据增强后 epoch 建议加到 15给模型更多时间看遍增强后的样本。画训练曲线用matplotlib每 50 个 batch 记录一次 loss画出横轴为 batch、纵轴为 loss 的折线图再把测试集准确率画成第二个子图。两张图并排放在文档说明里配合上面四项改进的表格这份作业就比单纯的“能跑通”高出两个档次。如果答辩被问到“能不能进一步优化”答案不是加更深的网络而是先把4/9、3/8这两组混淆样本挑出来用可视化看模型在哪些笔画上犯了错——这个问题的答案通常在混淆矩阵里已经很清楚了。本文还有配套的精品资源点击获取