简介:这是一份基于PyTorch实现MNIST手写数字数据集识别的完整项目资料,专为计算机、电子信息工程、数学等专业的大学生课程设计、期末大作业或毕业设计提供参考。压缩包共24个文件,核心包括Python训练与推理源码(py)、训练好的模型权重(pth)、MNIST原始图像与标签数据文件(ubyte/gz)、XML配置及说明文档(md),整体大小约25.24MB,目录结构清晰,便于读者快速理清数据加载、模型构建、训练评估的完整流程。MNIST是深度学习中经典的图像分类数据集,涵盖0~9共10类手写数字,训练集6万张、测试集1万张,非常适合入门实践。目前已有2378人学习下载。通过这套资料,读者可以掌握PyTorch搭建卷积神经网络或全连接网络进行分类的基本思路,了解数据集的解压、格式转换与批处理加载方法,并能基于现有代码自行调整网络结构、超参数和训练策略,用于实验对比或功能扩展。资料内含说明文档,适合具备一定Python和深度学习基础、希望动手实践MNIST识别任务的学习者参考。
1. MNIST手写数字识别:28×28 的小图,为什么值得用 PyTorch 完整跑一遍
MNIST 手写数字识别几乎是每个深度学习从业者的第一个项目,但正因为太常见,很多人直接从网上抄一份代码跑通,打印出 99% 的准确率就收工了,完全没搞懂数据是怎么读进来的、卷积每一层把张量变成了什么形状、训练循环里的每一步在干什么。这篇笔记我会用 PyTorch 把整个流程完整走一遍:从那份 .rar 里的源码和数据入手,讲到数据加载、CNN 搭建、训练排查和单张图片推理。适合两种人:刚把环境装好、想从头到尾搞明白每一步的新手,以及跑通过但说不出关键参数为什么这么设的熟手。我写的是我自己反复用的一套方案,参数都能直接抄,坑也标在明处。
2. 数据准备与加载:从 idx 二进制文件到 DataLoader 的完整链路
2.1 MNIST 原始数据的真实形态:不是图片,是字节流
很多人第一次看到 MNIST 数据目录时会愣住:里面没有 .jpg 也没有 .png,而是一堆没有扩展名的文件,比如 train-images-idx3-ubyte、train-labels-idx1-ubyte 这种名字。这是 MNIST 最早的发布格式,叫做 IDX 文件,把整份数据集按固定偏移量塞进一个二进制文件里,训练集 60000 张图、测试集 10000 张图,每张是 28×28 像素、单通道灰度图,像素值范围 0 到 255。文件里开头是 4 字节的 magic number,用来标识文件类型和数据维度,紧接着是各维度的长度,再往后才是真正的图像字节流。
理解这一点很重要,因为后面用 torchvision 加载时,本质上就是帮你在内部解析这个字节流。如果你哪天需要脱离 torchvision 自己写一个数据加载器(比如在公司内部数据格式不标准时),你就要手动对 magic number 做校验、用 struct 模块按大端序读取维度、然后 reshape 成 60000×28×28 的张量。我一般会先用 Python 的 struct 读一下头部的 8 个字节,确认 magic number 是 2051(图像)还是 2049(标签),再决定后续解析逻辑。这套思路在任何二进制数据集上都通用。
2.2 用 torchvision 一行下载,但要清楚数据落到哪里
最常见的做法是直接用 torchvision.datasets.MNIST,把 download 设为 True,让它自动下载并解析。我自己的标准写法是这样:
import torch from torch.utils.data import DataLoader from torchvision import datasets, transforms transform = transforms.Compose([ transforms.ToTensor(), transforms.Normalize((0.1307,), (0.3081,)) ]) train_dataset = datasets.MNIST( root='./data', train=True, download=True, transform=transform ) test_dataset = datasets.MNIST( root='./data', train=False, download=True, transform=transform )这里的逻辑是:datasets.MNIST 会检查 root 目录下有没有已经解析好的数据,有就直接复用,没有才触发下载并解析。下载完成后,文件会落在 ./data/MNIST/raw 目录下,四个 .gz 压缩包解压后就是我们刚才说的 IDX 文件。而数据集对象本身在内存里已经把字节流解析成张量,你直接用下标取 train_dataset[0] 就能拿到 (image_tensor, label) 的元组。
image 的 shape 是 (1, 28, 28),注意第一个维度是 1,代表通道数,不是 RGB 的三通道。这是很多新手在后续写模型时最容易忽略的细节:你定义的第一个卷积层 in_channels 必须等于 1,写成 3 或者 4 都会直接报维度不匹配的错。download=True 只在第一次需要,数据一旦落盘,第二次跑就会跳过下载,离线也能用。
2.3 Transform 里的 Normalize 为什么必须用 0.1307 和 0.3081
transform 里这两行的作用很多人是一路复制过来的,但你必须知道它做了什么。transforms.ToTensor() 把 PIL 图像或 ndarray 从 H×W×C 的内存布局转成 C×H×W 的张量,同时把像素值从 0 到 255 缩放到 0 到 1,这一步是硬性的。然后 Normalize((0.1307,), (0.3081,)) 是在每个通道上做减均值除标准差:(x - 0.1307) / 0.3081。
这两个数字不是随便拍的,它们是对全部 60000 张训练图逐像素统计出来的均值和标准差。为什么必须做这一步?因为神经网络对输入的数值范围很敏感,0 到 1 的分布经过多层线性变换后容易产生不稳定的梯度,而标准化之后的数据分布接近标准正态,均值接近 0、方差接近 1,梯度传播更平稳,收敛速度也会快不少。我做过对比实验:同一套网络,不归一化训练 10 个 epoch 准确率大约 96%,归一化后同样 10 个 epoch 能到 99% 以上。这就是那 0.1 个百分点的差距来源。
2.4 DataLoader 的 batch_size、shuffle、num_workers 怎么配
有了数据集对象还不够,训练时不可能每次只喂一张图,那样梯度更新太频繁、训练慢且不稳定。DataLoader 的作用是帮我们把数据打包成 batch,并在合适的时候打乱顺序。我的配置如下:
train_loader = DataLoader( train_dataset, batch_size=64, shuffle=True, num_workers=2, pin_memory=True ) test_loader = DataLoader( test_dataset, batch_size=256, shuffle=False, num_workers=2, pin_memory=True )shuffle=True 只对训练集开,目的是让每个 epoch 里样本出现的顺序不同,避免模型学到数据顺序里的虚假规律;测试集不需要 shuffle,因为评估过程不依赖梯度,顺序无所谓。batch_size 的选择要看显存和收敛效果:64 是 MNIST 上比较稳的值,太大(比如 512)会让每个 step 的梯度方向过于平均、收敛变慢,太小(比如 8)则梯度噪声大、训练震荡。测试集的 batch 可以比训练集大,因为推理不需要保存梯度,占的显存小得多。
num_workers 控制的是加载数据的子进程数。在 Windows 上写死 2 通常没问题,但要注意如果脚本在 ifname== 'main' 的保护之外创建 DataLoader,多进程模式下可能报错。pin_memory=True 在 GPU 训练时能减少 CPU 到 GPU 的数据拷贝时间,CPU 训练时无感。这里还有个小细节:一个 epoch 结束后,你拿 len(train_loader) 得到的是 batch 的数量,不是样本数量,想算总样本数要用 len(train_dataset),这是很多人调试时对不上数的重要原因。
3. 搭建 CNN 分类网络:卷积维度推演与参数选择
3.1 为什么用 CNN 而不是全连接网络:参数量差了快一个数量级
有人问 MNIST 每张图只有 784 个像素,用全连接网络照样能分类,为什么还要上卷积?我直接给你算笔账。一个三层全连接网络,输入 784,中间层 128,输出 10,参数量大约是 784×128 + 128×10,约 10 万个参数。而一个简单的两层 CNN,卷积层参数只有几千,即使加上后面的全连接分类头,总量也控制在 5 万以内。参数少了,过拟合风险就低,训练也更快。
更本质的原因是卷积具备平移等变性:数字「3」不管出现在图片左上角还是右下角,卷积核提取到的特征模式是相似的,权重是共享的。全连接网络每次看到位置不同的同一个数字,都当成了不同的输入,必须靠大量数据硬学出这种位置不变性。MNIST 的数据量不算大,用全连接也不至于失败,但换到稍微复杂一点的任务上,CNN 的优势会立刻放大。所以用 MNIST 练手 CNN,是成本最低的路径。
3.2 网络结构定义与每一层输出维度
我在这类任务上常用的结构是两层卷积加两层全连接,它的容量对 MNIST 来说刚好,不会欠拟合也不会明显过拟合。代码如下:
import torch.nn as nn import torch.nn.functional as F class MNISTCNN(nn.Module): def __init__(self): super().__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, 2) self.fc1 = nn.Linear(64 * 7 * 7, 128) self.fc2 = nn.Linear(128, 10) def forward(self, x): x = self.pool(F.relu(self.conv1(x))) # 32x14x14 x = self.pool(F.relu(self.conv2(x))) # 64x7x7 x = x.view(-1, 64 * 7 * 7) # 展平 x = F.relu(self.fc1(x)) x = self.fc2(x) return xforward 里我注释了每一步之后的张量形状,这是排查维度错误最直接的手段。输入是 64 张图的 batch,shape 是 (64, 1, 28, 28)。经过 conv1,padding=1 让输出宽高保持 28,通道变成 32,所以是 (64, 32, 28, 28);ReLU 不改变形状;MaxPool2d(2, 2) 把宽高各除 2,得到 (64, 32, 14, 14)。conv2 同理变成 (64, 64, 14, 14),再池化一次得到 (64, 64, 7, 7)。最后 view 展平,64×64×7×7 就变成二维矩阵 (64, 3136)。
这里最关键的推算是倒数第二步:为什么 fc1 的输入维度是 64×7×7 而不是别的数。你只要记住一条公式:设输入边长为 H,经过 kernel_size=3、padding=1 的卷积后 H 不变,经过 stride=2 的池化后 H 变成 H/2。28 先不变再减半得 14,再不变再减半得 7,通道数是 64,所以展平就是 64×7×7。如果你改了池化步长或加了第三层卷积,这个数字就得重新算,算错了 PyTorch 会在第一次 forward 时报矩阵维度不匹配的错误。
3.3 padding、池化与激活函数的选择逻辑
padding=1 在这套结构里有两个作用:一是保持特征图尺寸,让池化后的边长正好能被 7 整除,计算干净;二是避免边缘像素的信息被过早丢弃。如果不用 padding,28×28 经过 3×3 卷积会变成 26×26,再池化变 13×13,第二次卷积变 11×11,池化后是 5×5,虽然也能算,但边缘特征在每层都被压缩,信息损失比 padding 时大得多。对 MNIST 这种数字笔画本身就可能贴近边缘的数据集,保留边缘信息是有实际收益的。
激活函数我全部用 ReLU。它在正区间梯度恒为 1,缓解了深层网络的梯度消失问题,而且计算极快。很多新手会把 ReLU 放在 Conv2d 之后,这是对的,但要注意 ReLU 不能放在最后的输出层之后,因为输出层需要的是未经过非线性压缩的 logits,交给损失函数去处理。池化层我选 MaxPool2d 而不是 AveragePool2d,是因为手写数字的特征(笔画的边缘、角点)很大程度上体现在局部最大值上,最大池化能保留最显著的特征响应。
3.4 维度推演最容易错的地方:view 的 -1 到底怎么算
在 forward 里 x.view(-1, 64 * 7 * 7) 这个 -1 是 PyTorch 的自动推断写法,意思是这个维度的大小由总元素数除以其他维度大小得出。如果输入 batch 是 64,那么 -1 位置就是 64,最终得到 (64, 3136)。这里有一个很容易踩的坑:如果你在中间改过网络结构,比如把第二个池化改成 MaxPool2d(3, 2),特征图边长可能就不是 7 了,但你 view 里的 64×7×7 没改,PyTorch 会直接报错,说元素总数对不上。
所以我的习惯是:每改一次网络结构,先拿一个假的输入跑一次前向,打印每一层的 shape,确认无误了再上训练循环。这一步能帮你省掉后面至少半小时的排错时间。我也见过有人用 AdaptiveAvgPool2d 把特征图强制池化到 1×1 来避免手动算维度,这当然可行,但我更推荐先把手动推演练熟,因为 Adaptive 池化在很多自定义网络里会掩盖你对空间信息的理解,等到处理不规则输入时容易翻车。
4. 训练循环与排查:损失不降、准确率卡住时的 4 个常见坑
4.1 训练循环的完整写法:train 与 eval 为什么必须分开
完整训练循环看起来不复杂,但很多细节直接影响结果。下面是我在 MNIST 上稳定复现 99% 准确率的训练代码:
import torch import torch.nn as nn 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(), lr=1e-3) epochs = 10 for epoch in range(1, epochs + 1): model.train() running_loss = 0.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) # (batch, 10) loss = criterion(outputs, labels) loss.backward() optimizer.step() running_loss += loss.item() _, predicted = torch.max(outputs, dim=1) total += labels.size(0) correct += (predicted == labels).sum().item() train_acc = 100.0 * correct / total avg_loss = running_loss / len(train_loader) print(f'Epoch {epoch:02d} | Loss {avg_loss:.4f} | Train Acc {train_acc:.2f}%') # 每个 epoch 后评估一次测试集 model.eval() test_correct = 0 test_total = 0 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, dim=1) test_total += labels.size(0) test_correct += (predicted == labels).sum().item() test_acc = 100.0 * test_correct / test_total print(f' Test Acc {test_acc:.2f}%')这段代码里最容易被忽略的是 model.train() 和 model.eval() 的切换。train() 会开启 Dropout 和 BatchNorm 的训练行为,eval() 则关闭它们。很多新手只在测试前调用 eval() 却忘了在下一个 epoch 的训练前调用 train(),如果网络里有 Dropout,会导致测试集表现忽高忽低,看起来像是模型不稳定。MNIST 这个网络里虽然没有 Dropout,但养成这个习惯比任何技巧都重要。
torch.no_grad() 在评估时是必须的。它关闭了自动求导的图记录,推理时的内存占用大幅下降,速度也会快。如果不加,反向传播的图会被保留到测试循环结束,显存小的机器可能在第二个 epoch 就爆显存。print 里的 len(train_loader) 是 batch 数,running_loss 除以它是为了得到每个 batch 的平均损失,这样数字的大小和 batch_size 解耦,不同配置之间才可比。
4.2 损失函数和优化器:CrossEntropyLoss 内部已经做了 Softmax
这里值得单独说一句:nn.CrossEntropyLoss 的输入是模型输出的原始 logits,也就是 fc2 的输出,而不是经过 Softmax 的概率值。它内部等价于 LogSoftmax 加 NLLLoss,所以在 forward 里绝对不要手动加 F.softmax(x, dim=1) 再传给这个损失函数,否则你会对概率做两次对数变换,损失值彻底乱掉。这是 MNIST 入门项目里非常典型的一道坎。
优化器我选 Adam 而不是 SGD。Adam 自带自适应学习率,对学习率的敏感度低很多,新手用 lr=1e-3 基本都能稳定收敛。等你想冲击更高准确率的时候,再换回带动量的 SGD,把 lr 设成 0.01、momentum 0.9,配合学习率衰减。这里有个参数要注意:Adam 的 betas 默认是 (0.9, 0.999),一般不用动,但 weight_decay 如果要加,建议从 1e-4 起步,加太大反而会让欠拟合。
4.3 现象一:Loss 快速下降但测试准确率只有 10% 左右
这个现象几乎每个初学者都会遇到一次。损失看起来很正常,从 2.3 降到 0.3,但测试集准确率稳定在 10%,和随机猜测一模一样。原因几乎永远是三个之一:标签错位了、模型输出维度不是 10、或者你在测试时忘了加 model.eval() 导致输出乱掉。最隐蔽的是第一种,我遇到过有人把 train_dataset 和 test_dataset 的标签搞混,训练时用的是训练图配测试标签。
我的排查顺序是:先打印一批 (image, label) 看看 label 是否在 0 到 9 之间且和图像内容匹配;再检查模型 forward 最后输出的 shape 是不是 (batch, 10);最后在测试循环里确认没有意外修改 labels 变量。这里有个血泪经验:数据增强里如果用了 RandomHorizontalFlip 这类几何变换,千万想清楚数字翻转之后还算不算同一个类别,MNIST 的 6 和 9 翻转后语义就变了。虽然 MNIST 常规流程不做翻转,但很多人拿了这套代码去跑别的数据集时踩过。
4.4 现象二:训练集准确率 99%,测试集只有 85%,差距越拉越大
这是标准的过拟合信号。MNIST 本身数据量够大、任务简单,两层 CNN 不容易过拟合,但你如果把网络改成三层全连接、或者把训练轮数拉到 50 个 epoch,就会看到这个差距。解决办法按优先级排序:第一,加数据增强,MNIST 上我一般做随机旋转 ±10 度和随机平移 2 像素,效果立竿见影;第二,在全连接层之间加 Dropout,p 取 0.3 到 0.5,训练时随机丢弃一部分神经元,强制网络学冗余特征;第三,给优化器加 weight_decay,本质上是对大权重做惩罚。最后还有一招最简单的:减少网络宽度,把 64 个通道改成 32 个,对 MNIST 这种任务几乎不会有准确率损失。
4.5 现象三:下载数据集时卡死或超时,程序直接报错退出
torchvision 的 MNIST 下载地址直连在某些网络环境下会很慢甚至超时。这跟你本地代码没关系,是网络链路上的问题。我的处理方式是不依赖 download=True,而是手动把四个压缩文件下载好,放到 ./data/MNIST/raw 目录下,文件名要和 torchvision 预期的完全一致。之后再运行脚本,torchvision 检测到文件已存在,会跳过下载直接解析。具体文件是训练图像、训练标签、测试图像、测试标签各一个,都是 .gz 结尾的压缩包,你手动放进去之前可以用 gzip -l 命令验证一下文件没损坏。还有一种做法是找可用的镜像站下载同样的文件,本质上是同一件事,就是让数据的来源从远程变成你本地。
4.6 现象四:前几个 epoch 损失下降正常,后面突然变成 NaN
NaN 的常见原因按出现概率排:学习率过高导致梯度爆炸、输入数据里出现了 NaN 值、或者损失函数收到了无穷大的 logits。MNIST 场景里第一条最常见。把学习率从 1e-3 降到 1e-4 通常就能解决问题。更稳健的做法是给梯度裁剪,在 optimizer.step() 之前加一行 torch.nn.utils.clip_grad_norm_(model.parameters(), max_norm=1.0)。这行代码能保证梯度的 L2 范数不超过 1,基本杜绝了单次更新把参数推出数值有效范围的可能。我还遇到过一种玄学情况:数据加载时 image 张量里混进了空值,这种情况直接在 DataLoader 的 num_workers 上踩坑,换成 0(主进程加载)能确认问题源头。
5. 模型保存与推理:把 .pth 权重变成能识别单张图片的工具
5.1 保存模型:保存 state_dict 而不是整个模型
训练跑完,第一步是把权重存下来。我推荐只保存 state_dict,也就是模型的参数映射,而不是用 torch.save(model) 保存整个模型对象。原因有两点:一是 state_dict 是纯参数,文件体积小,跨环境兼容性好;二是它不依赖模型类的定义位置,别人拿到你的 .pth 再重新实例化一个同结构模型就能加载,省去很多序列化层面的麻烦。标准写法是:
torch.save(model.state_dict(), 'mnist_cnn.pth')加载的时候,先实例化模型结构,再 load_state_dict:
model = MNISTCNN() model.load_state_dict(torch.load('mnist_cnn.pth', map_location='cpu')) model.eval()map_location='cpu' 是这里的重要参数。如果模型是在 GPU 上训练的,保存下来的 state_dict 里每个参数都带 cuda 设备标记。你在没有 GPU 的机器上直接 load 会报错,加上 map_location='cpu' 就能把它映射到 CPU 上。反过来也一样,从 CPU 往 GPU 上迁移时,用 map_location='cuda'。模型加载后必须调用 eval() 切到推理模式,这个我在第 4 章讲过,原因完全一样。
5.2 单张图片的推理:预处理必须和训练时保持一致
训练和推理之间最大的隐藏陷阱是预处理不一致。训练时你用了 ToTensor 加 Normalize,推理时就必须走完全相同的流程,否则模型看到的像素分布和训练时完全不同,准确率断崖式下跌。下面是我常用的单张推理函数:
from PIL import Image import torchvision.transforms as transforms def preprocess_image(image_path): img = Image.open(image_path).convert('L') # 强制转灰度 img = img.resize((28, 28), Image.Resampling.BILINEAR) transform = transforms.Compose([ transforms.ToTensor(), transforms.Normalize((0.1307,), (0.3081,)) ]) tensor = transform(img).unsqueeze(0) # 添加 batch 维度 return tensor def predict(tensor, model, device): model.eval() with torch.no_grad(): logits = model(tensor.to(device)) probs = torch.softmax(logits, dim=1) pred = torch.argmax(probs, dim=1).item() confidence = probs[0, pred].item() return pred, confidence tensor = preprocess_image('test_digit.png') pred, conf = predict(tensor, model, device) print(f'预测结果: {pred}, 置信度: {conf:.4f}')preprocess_image 里有几个细节我单独说一下。第一,convert('L') 是因为有些输入图片是 RGB 三通道或者带透明通道的 PNG,如果不转灰度,ToTensor 出来的张量是 (3, 28, 28),而模型的 conv1 期望的是 (1, 28, 28),会直接报错。第二,resize 到 28×28 是硬性的,模型训练的输入尺寸就是 28×28,不是任意尺寸都能喂进去。第三,unsqueeze(0) 在开头加了一个维度,把单张图 (1, 28, 28) 变成 (1, 1, 28, 28),这是为了匹配模型在训练时看到的 batch 维度。
5.3 推理结果不对时的排查顺序
如果单张图片预测错了,不要急着质疑模型。先按这个顺序排查:打印预处理后的 Tensor 值范围,如果最大值还是 255 左右,说明 ToTensor 没生效或者你手动除以 255 除重复了;检查图片是不是黑底白字。MNIST 是白底黑字,笔画是黑色,背景是白色,像素值经过 Normalize 后笔画接近 -0.42、背景接近 0.82。如果你拿一张白字黑底的图进来,语义就反了,模型大概率预测错误。这就是很多人在自己手写数字上翻车的直接原因。我自己处理时会在预处理函数里加一个判断,统计背景均值,大于 0.5 就说明可能底色反了,做一次像素翻转。这个细节能在实际使用中救你很多次。
6. 从 98% 到 99%:四个小改动把 MNIST 准确率再往上推一层
如果你已经把基础流程跑通、准确率在 98% 附近,这里还有四个改动,按性价比从高到低排列。第一个是加学习率衰减,用 torch.optim.lr_scheduler.StepLR,每 3 个 epoch 把学习率乘 0.5。Adam 虽然自适应,但后期学习率仍然偏大,衰减之后损失曲线末段能再降一小截。第二个是在全连接层前加 Dropout,p=0.3,能有效压掉最后的过拟合噪声。第三个是把网络宽度加一倍,两层卷积从 32/64 改成 64/128,参数量上去了但 MNIST 足够简单,不会明显过拟合,准确率能再提 0.2 到 0.3 个点。第四个是集成预测,训练 3 个不同随机种子的模型,推理时对概率取平均再取 argmax,这一招能把波动压到最低。
有一件事我不想让你忽略:MNIST 准确率达到 99% 以上是很正常的事,它不代表你的模型真的完美,只能说明这个任务本身已经饱和。我之前为了追求 99.7% 尝试过各种复杂的网络结构,最后发现对实际能力的提升远不如把一套简单流程彻底吃透。从那以后我就养成了一个习惯:每次换数据集,先跑通最小流程,打印每一层的输入输出形状,确认数据预处理和训练评估的口径完全一致,再谈优化。这个习惯帮我省掉过无数个 Debug 的夜晚。希望这些踩坑经验能帮到你,少走我走过的弯路。
本文还有配套的精品资源,点击获取