简介面向深度学习与机器学习初学者的MNIST手写数字识别项目基于前馈神经网络实现从模型搭建、训练到保存与预测的完整流程解决新手入门时环境配置难、代码结构乱、训练权重不知如何复用等常见痛点。资源以ZIP压缩包形式提供包含5个文件分别是3个Python脚本与2个H5模型文件压缩包整体大小仅1.39MB非常轻量。Python脚本覆盖数据集加载、模型定义、训练和保存等环节按功能拆分便于逐步学习两个H5文件分别保存模型整体结构和训练好的权重参数加载后即可直接对输入图像进行手写数字识别不需要重新训练。目前已有6116人学习、下载适合深度学习课程设计、神经网络入门实践以及小型项目原型搭建也方便研究者快速验证模型效果。压缩包内同时提供完整代码与训练好的模型文件读者可对照代码理解前馈神经网络在MNIST数据集上的完整建模思路有效降低初学者的上手门槛。1. 手写数字识别为什么从 MNIST 开始:一个 10 分类任务里的完整工程链路手写数字识别长期霸占深度学习入门第一课的位置,不是因为简单,而是因为 MNIST 把深度学习项目的完整链路压缩到了几十秒一轮训练:图像加载、归一化、模型定义、反向传播、评估、保存与推理,每一步都是真实工程里要重复做的事。这篇博文用 MNIST 训练手写数字识别模型,给出可直接运行的完整代码,并演示如何加载训练好的模型文件做单张图片推理。适合想跑通第一个训练脚本的新手,也适合需要一份干净模板验证 PyTorch 环境的工程师。照抄能跑,跑完能懂每段代码在干什么。2. 先把数据拿到手:MNIST 数据集下载、手动补档与预处理MNIST 的 28×28 灰度图看起来简单,但数据获取环节是新手翻车的第一站。torchvision 在downloadTrue时自动从源站拉取四个 gz 文件,这个动作在真实网络环境下并不总顺利。与其在报错里浪费时间,不如先把数据获取逻辑讲透。2.1 torchvision 下载 MNIST 报 404:手动下载四个 gz 文件的正确姿势第一次运行datasets.MNIST(root./data, trainTrue, downloadTrue)时,常见报错是 HTTP Error 404、连接超时或 SSL 证书校验失败。这不是你代码写错,而是目标站点的可用性波动。解决办法很简单:手动把四个原始文件下载好,放进 torchvision 约定的目录,再让代码直接从本地加载。from torchvision import datasets, transforms transform transforms.Compose([ transforms.ToTensor(), transforms.Normalize((0.1307,), (0.3081,)) ]) # downloadFalse 表示不再尝试联网, 直接从 raw 目录读取 train_ds datasets.MNIST(root./data, trainTrue, downloadFalse, transformtransform) test_ds datasets.MNIST(root./data, trainFalse, downloadFalse, transformtransform)需要手动放置的四个文件分别是train-images-idx3-ubyte.gz、train-labels-idx1-ubyte.gz、t10k-images-idx3-ubyte.gz、t10k-labels-idx1-ubyte.gz,统一放在./data/MNIST/raw/目录下。注意文件名要和 torchvision 源码里写死的完全一致,多一个空格都会报Dataset not found。注意:不要手动解压这些 gz 文件,不要改文件名,更不要自己重打包成 zip。torchvision 会按固定文件名识别并自行构建 processed 缓存;你只需要把原文件放对位置。如果你之前下载到一半失败,先把./data/MNIST/raw里残留的临时文件删掉再放新文件,否则会报Corrupt download。这个问题的根源与你的模型代码无关,所以排查顺序应该是:文件是否齐全、文件名是否一致、路径是否拼对,最后才轮到代码本身。2.2 归一化与数据加载代码:28×28 灰度图是怎么进模型的MNIST 每张图是 28×28 像素,灰度范围 0-255。transforms.ToTensor()会把它转成形状为[1, 28, 28]、数值范围 0-1 的浮点张量。紧接着的Normalize((0.1307,), (0.3081,))执行(x - 0.1307) / 0.3081,把像素分布拉到均值接近 0、方差接近 1 的区间。这两个统计量不是随机拍的。MNIST 全数据集的像素均值和标准差就是 0.1307 和 0.3081,几十年来固定不变,直接使用即可,不需要用代码重新统计。归一化做与不做的差别,在训练 loss 曲线上表现得最直接:不归一化时梯度量级不稳定,Adam 可能要调更小的学习率才能收敛;归一化之后,0.001 的学习率在这个任务上基本是稳的。from torch.utils.data import DataLoader train_loader DataLoader(train_ds, batch_size64, shuffleTrue, num_workers2) test_loader DataLoader(test_ds, batch_size256, shuffleFalse, num_workers2)batch_size64是 CNN 在这个任务上的稳妥值,显存占用极小,梯度更新也比较平滑;shuffleTrue只对训练集开,让每个 batch 的样本分布尽量随机,避免模型学到样本顺序;num_workers2表示用两个子进程预取数据,能在一定程度上压过数据读取耗时。测试集不需要打乱,所以shuffleFalse。2.3 选 CNN 还是全连接:MNIST 上的两种方案与参数量对比很多教程拿全连接网络跑 MNIST,也能到 97% 左右,但如果你想把这个流程当作模板复用,建议直接上 CNN。下面这组对比是我在同样训练配置下得到的典型结果:方案关键结构参数量验证集准确率(约)全连接784 → 128 → 1010.2 万97% ~ 98%轻量 CNN2 层卷积 128 全连接42 万99.2% ~ 99.4%全连接网络的每个输出节点都看整张图,参数全部浪费在全局关联上;CNN 的 3×3 卷积核只关注局部笔画和拐角,池化层又天然带来平移不变性,所以参数效率更高。MNIST 上还有个特殊点:图片是黑底白字、数字基本居中,不需要像 ImageNet 那样做随机裁剪、旋转等数据增强,加了反而可能让验证集掉零点几个百分点。这个结论只在 MNIST 上成立,换到真实手写图片场景就是另一回事了,后面避坑章节会展开。3. 完整训练代码:从数据加载到保存最佳模型,一份 PyTorch 脚本直接跑这章给出整套可运行脚本。我的习惯是分成数据、模型、训练三段写,每个函数只做一件事,这样以后换数据集时不用重写整个文件。3.1 训练入口与数据拆分:train/val/test 三段式划分训练集 60000 张,测试集 10000 张。很多人直接把测试集当验证集用,每调一次超参数就看一次测试集结果,这是典型的对测试集过拟合。正确做法是从训练集里切 10000 张出来当验证集,测试集只在最后评估时看一眼。import torch from torch.utils.data import DataLoader, random_split from torchvision import datasets, transforms def load_data(batch_size64, data_dir./data): transform transforms.Compose([ transforms.ToTensor(), transforms.Normalize((0.1307,), (0.3081,)) ]) full_train_ds datasets.MNIST(data_dir, trainTrue, downloadTrue, transformtransform) test_ds datasets.MNIST(data_dir, trainFalse, downloadTrue, transformtransform) train_ds, val_ds random_split(full_train_ds, [50000, 10000]) train_loader DataLoader(train_ds, batch_sizebatch_size, shuffleTrue, num_workers2) val_loader DataLoader(val_ds, batch_size256, shuffleFalse, num_workers2) test_loader DataLoader(test_ds, batch_size256, shuffleFalse, num_workers2) return train_loader, val_loader, test_loaderrandom_split按 50000/10000 切分,不改动原始数据顺序,验证集和训练集来自同一分布。这里有一个小细节:downloadTrue在数据集已经存在时不会重复下载,所以前一次手动放好的文件不受影响。返回三个 loader 的顺序写成 train、val、test,调用时不要接错位置,否则后面的训练评估就全乱套了。3.2 模型定义:一个 10 万参数级 CNN 的逐层拆解我的默认配置是一个两层卷积加两层全连接的小网络,参数量约 42 万,在 CPU 上训练 10 轮也就几分钟,在 GPU 上几十秒。import torch.nn as nn class SimpleCNN(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) self.dropout nn.Dropout(0.25) self.fc1 nn.Linear(64 * 7 * 7, 128) self.fc2 nn.Linear(128, 10) def forward(self, x): x torch.relu(self.conv1(x)) x self.pool(x) x torch.relu(self.conv2(x)) x self.pool(x) x x.view(x.size(0), -1) x torch.relu(self.fc1(x)) x self.dropout(x) return self.fc2(x)逐层看尺寸变化:conv1把 1 通道变成 32 通道,kernel_size3, padding1让 28×28 保持原尺寸;MaxPool2d(2)下采样到 14×14;conv2把 32 通道升到 64 通道,尺寸仍不变;第二次池化后变成 7×7,64 个通道,展平后就是64*7*73136维。fc1输出 128 维,接Dropout(0.25)防止过拟合,最后fc2输出 10 类。注意最后一层没有接 Softmax。PyTorch 的CrossEntropyLoss内部已经包含 Softmax,训练时输出原始 logits 即可,推理时再用argmax(dim1)取最大下标。如果把 Softmax 提前加到模型里,数值上不是错,但会让交叉熵计算更慢,还可能引入数值稳定性问题。3.3 训练循环与 checkpoint:学习率、epoch 和 best-acc 保存机制训练循环的骨架是固定的:清梯度、前向、算 loss、反向、更新参数,每轮结束在验证集上测一次准确率。import torch.optim as optim def evaluate(model, loader, devicecpu): model.eval() correct 0 total 0 with torch.no_grad(): for images, labels in loader: images, labels images.to(device), labels.to(device) outputs model(images) correct (outputs.argmax(dim1) labels).sum().item() total labels.size(0) return correct / total def train_model(model, train_loader, val_loader, epochs10, lr0.001, devicecpu): model.to(device) criterion nn.CrossEntropyLoss() optimizer optim.Adam(model.parameters(), lrlr) best_acc 0.0 for epoch in range(1, epochs 1): model.train() running_loss 0.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() running_loss loss.item() val_acc evaluate(model, val_loader, device) avg_loss running_loss / len(train_loader) print(fepoch {epoch:2d}/{epochs} | loss {avg_loss:.4f} | val_acc {val_acc:.4f}) if val_acc best_acc: best_acc val_acc torch.save(model.state_dict(), mnist_cnn.pth)三个参数值得记牢:epochs10对这个任务足够,再往后验证集准确率基本不动,只会浪费时间;lr0.001是 Adam 在小模型上的默认安全区间,调成 0.01 很容易让 loss 飞掉;optimizer.zero_grad()必须每个 batch 调一次,否则梯度会跨 batch 累加,更新方向直接坏掉。保存逻辑只在验证集准确率创新高时写入mnist_cnn.pth,这就是所谓的 best-acc checkpoint,也是训练中断之后的后悔药:哪怕第 9 轮才发现第 5 轮的模型最好,文件里始终保留的是最优版本。if __name__ __main__: torch.manual_seed(42) device cuda if torch.cuda.is_available() else cpu print(device:, device) train_loader, val_loader, test_loader load_data() model SimpleCNN() train_model(model, train_loader, val_loader, epochs10, lr0.001, devicedevice)torch.manual_seed(42)固定随机种子,保证多次运行的结果可复现。Windows 下这段必须放在if __name__ __main__:里,否则多进程数据加载会重复启动主脚本,直接卡在第一个 batch。4. 模型评估与模型文件直接用:准确率、混淆矩阵与推理脚本训练结束时屏幕上会打出一行行准确率,但真正判断模型能不能用,不能只看一个数字。混淆矩阵能告诉你模型在哪些数字对之间犹豫,而加载模型文件的姿势不对,前面训练的成果可能直接变成废文件。4.1 混淆矩阵:模型到底把哪两个数字搞混准确率 99.2% 意味着 10000 张测试图里约 80 张判断错误。想知道这 80 张错在哪,混淆矩阵是最直接的工具。import numpy as np from sklearn.metrics import confusion_matrix def get_confusion_matrix(model, loader, devicecpu): model.eval() y_true, y_pred [], [] with torch.no_grad(): for images, labels in loader: images, labels images.to(device), labels.to(device) preds model(images).argmax(dim1).cpu().numpy() y_pred.extend(preds) y_true.extend(labels.cpu().numpy()) return confusion_matrix(y_true, y_pred) cm get_confusion_matrix(model, test_loader, device) print(cm)输出是一个 10×10 矩阵,第 i 行第 j 列表示真实数字是 i、预测成 j 的样本数。对角线越大越好,非对角线格子里最大的通常是 4↔9、3↔5、7↔9。这几对数字在人类手写里本身就存在歧义,比如 7 写得带横杠就和 9 的上半部分很像。如果混淆集中在这些搭配上,属于正常现象,不必为了消除它们盲目加深网络;如果出现 0 和 6 大规模混淆,那就要回头检查预处理了。4.2 加载训练好的模型文件:state_dict 的保存与加载训练完生成的mnist_cnn.pth就是标题里说的训练好的模型文件。加载它只需要先重新实例化一个结构完全相同的模型,再把参数填进去。model SimpleCNN() state torch.load(mnist_cnn.pth, map_locationcpu) model.load_state_dict(state) model.eval() print(loaded from mnist_cnn.pth)map_locationcpu是关键参数。如果模型是在 GPU 上训练的,state_dict 里的参数带有 CUDA 设备信息,直接加载到 CPU 会报Attempting to deserialize object on a CUDA device。加了这个参数,保存时的设备就不影响推理环境了,这也是训练服务器和推理机器分离时的常规做法。保存时我只存model.state_dict(),不存整个 model 对象。state_dict 本质上是层名 - 参数张量的映射,体积小、结构清晰,加载时可以灵活决定放 CPU 还是 GPU。如果当初写的是torch.save(model, path),加载后虽然也能用,但后续只要改动模型类名或目录结构,文件就废了。所有项目我都统一走 state_dict 路线。4.3 单张图片推理:预处理不一致是准确率掉下来的主因模型文件加载成功后,最直观的验证方式是把一张图片丢进去看输出。这里埋着 MNIST 推理最常见的坑:训练和推理的预处理必须完全一致,差一步结果都会崩。from PIL import Image def predict_single_image(img_path, model, devicecpu): img Image.open(img_path).convert(L).resize((28, 28)) arr np.array(img, dtypenp.float32) / 255.0 tensor torch.from_numpy(arr).unsqueeze(0).unsqueeze(0) tensor (tensor - 0.1307) / 0.3081 model.eval() with torch.no_grad(): output model(tensor.to(device)) return output.argmax(dim1).item()分析这 6 行:convert(L)把彩色图压成灰度,resize((28, 28))对齐输入尺寸;除以 255 做的是ToTensor()的工作,把 0-255 压到 0-1;unsqueeze(0).unsqueeze(0)分别补上 batch 维和通道维,形状变成[1, 1, 28, 28];最后用训练时的 0.1307 和 0.3081 做标准化。任何一步漏掉,模型看到的输入分布都和训练时不一样,准确率会明显下滑。这里还有一个反色问题:MNIST 是黑底白字,你自己的图片大概率是白底黑字,这将在避坑章节专门解决。5. 手写数字识别训练避坑指南:从数据集到模型文件的 6 个坑这一章写的是我在 MNIST 上真实踩过的坑,按现象、原因、解决的顺序一条条列清楚。新手建议通读一遍再回头跑代码,熟手可以直接对照索引。5.1 torchvision 下载 MNIST 卡死或 404:先手动落地方案现象是代码停在Downloading...进度条长时间不动,或者直接抛出HTTP Error 404。原因不是网络配置,而是源站的不稳定以及部分网络环境对境外地址的访问限制。解决:按照 2.1 节的方法手动下载四个 gz 文件,放入./data/MNIST/raw/,把download设为False。如果之前下载过一半,先删掉 raw 目录下的临时文件再放新文件,避免Corrupt download。手动下载这个动作看起来土,实际是排查成本最低的方案,跳过这一步而在网络层面反复折腾,大概率浪费时间。5.2 训练 loss 不降或变 NaN:先查这三个参数现象是 loss 一开始就是nan,或者前几个 epoch 下降到某个值后就不再动。先查三处,基本都是这三个原因之一:学习率太高,lr0.01搭配 Adam 在这个任务上就可能让梯度爆炸;输入没有归一化,像素值 0-255 直接进模型会放大损失的数值范围;标签类型错误,CrossEntropyLoss要求 target 是整数索引,如果你把标签转成 float 独热向量,loss 的计算会异常。解决:learning rate 拉回0.001,确认 transform 里有Normalize,确认标签是torch.long而不是torch.float。如果已经 NaN,最简单的干预是更换优化器或减小 batch_size,但根因多半还是学习率。5.3 验证集准确率远低于训练集:过拟合的边界与对策现象是训练集准确率很快到 99.5%,验证集卡在 97% 附近,测试集也不见好。原因就是模型开始记忆训练样本的细节而不是数字的共性,层数越深、参数越多,这个现象越明显。对策分两步:先给全连接层加Dropout(0.25),再把 epoch 控制在 10 轮以内,配合 best-acc checkpoint 自动保留最优版本。MNIST 本身是干净数据,不需要随机旋转、平移这类数据增强,很多新手一上来就加增强,验证集反而掉 0.2~0.5 个百分点,这是过度设计。过拟合的边界不是看准确率绝对值,而是看训练集和验证集的差距是否在持续拉大。5.4 Windows 下 DataLoader 直接卡死:num_workers 的坑现象是程序运行到第一个 batch 就卡住,CPU 占用飙高但 GPU 利用率是 0。原因在 Windows 的数据加载机制:当num_workers0时,DataLoader 会启动多进程,而 Windows 下的进程创建方式和 Linux 不同,经常在 spawn 阶段互相等待。解决:Windows 上直接设num_workers0,Linux 和 macOS 再考虑num_workers2或 4。另外如果你坚持要用多进程,训练脚本的入口必须放在if __name__ __main__:里,否则每个子进程都会重新执行整个模块,这也是卡死的一个隐蔽来源。5.5 加载模型报错 state_dict 不匹配:统一用 state_dict 保存现象是load_state_dict抛出size mismatch for fc1.bias: copying a param with shape torch.Size([128]) ...。原因几乎都是模型结构对不上:训练时 fc1 输出 128 维,推理时改成了 256,或者网络里少了一层。解决:保存时统一torch.save(model.state_dict(), path),推理时保证模型类定义一致即可。如果不确定对方训练时用的结构,加载后先打印一层层的 shape 反推网络:print(state.keys()),看每个张量的维度就能还原出结构。这个报错不是文件坏了,是结构不一致,千万别重训模型,先对齐网络定义。5.6 模型在自己的图片上翻车:反色、尺寸和归一化的对齐现象很典型:模型在 MNIST 测试集上 99%,拿手机拍一个数字丢进去,预测结果惨不忍睹。原因有两个层面:第一是颜色方向反了,MNIST 是黑底白字,而拍照和手绘大多是白底黑字;第二是真实图片的笔画粗细、位置偏移和 MNIST 差异很大。解决的第一步是把预处理做成自适应反色:arr np.array(img.resize((28, 28)).convert(L), dtypenp.float32) / 255.0 if arr.mean() 0.5: # 白底黑字时反色, 黑底白字不变 arr 1.0 - arr tensor torch.from_numpy(arr).unsqueeze(0).unsqueeze(0) tensor (tensor - 0.1307) / 0.3081用整张图的像素均值判断底色:白底图片均值明显超过 0.5,反色后变成黑底白字,和 MNIST 一致。这个判断比写死一个布尔变量更稳,因为不同照片的光线亮度不同,固定阈值反而不可靠。做了反色之后,再配合二值化或对比度增强,准确率能拉回一大截。剩下那些仍然认错的,往往是笔画形状本身就接近另一个数字,人眼都容易看错。6. 进阶:让 MNIST 模型认识你自己的手写数字把上一章的反色逻辑整合进一个统一的批量推理入口,顺便聊聊随机种子和 checkpoint 的工程习惯。def predict_batch(img_paths, model, devicecpu): model.eval() results [] with torch.no_grad(): for path in img_paths: img Image.open(path).convert(L).resize((28, 28)) arr np.array(img, dtypenp.float32) / 255.0 if arr.mean() 0.5: arr 1.0 - arr tensor torch.from_numpy(arr).unsqueeze(0).unsqueeze(0) tensor (tensor - 0.1307) / 0.3081 pred model(tensor.to(device)).argmax(dim1).item() results.append((path, pred)) return results这个函数把尺寸变换、反色、归一化、推理全部收在同一个入口,传入一批图片路径就返回(路径, 预测值)列表。单张预测时它够用,但实际项目里更推荐把图片拼成一个 batch 再 forward,对几百张图片的推理能省掉多次 Python 循环开销。MNIST 单张图片只有 784 个像素,瓶颈不在 GPU 而在图像解码和预处理,所以predict_batch用循环体验也很好。最后说一个我的个人习惯:每次训练脚本跑之前,先固定随机种子跑一次不加任何增强的 baseline,记录下验证集准确率,再逐个加增强或调参。这个习惯避免了很多自我感觉良好的改进其实在负优化。MNIST 上增强经常是负优化,换到真实手写图片或工业缺陷检测数据集上,同一套增强可能又真香。别把单个数据集的结论当通用法则。验证模型是否真的可用,我会拿 5 张自己写的数字拍照测试,确认反色和预处理都生效后再交付。这个流程跑顺了,之后换 CIFAR-10 或自定义数据集,你只需要改数据加载和模型最后几层,其余的骨架完全复用。希望帮到你。本文还有配套的精品资源点击获取