简介这套资源围绕MNIST手写数字识别任务提供基于SVM、决策树、KNN、朴素贝叶斯四种机器学习方法的完整Python实现面向计算机相关专业学生、算法入门者及毕设/课设开发人员。压缩包共19个文件含4个Python源代码、MNIST数据集及标签文件、决策树可视化dot文件、训练好的模型pkl、8张结果对比图和说明文档总大小约11MB目录按Code、Dataset、res划分便于阅读与复现。已有539人学习下载。代码基于Python 3.6编写分别训练四种分类器并输出准确率对比附带结果图和模型文件可快速验证效果同时资源内附项目说明代码均测试运行成功适合作为课程设计、毕业设计或机器学习入门实践的参考基础也支持在此基础上扩展调优。1. 这个标题在讲什么MNIST 识别不是玩具是你的第一条完整链路MNIST 手写数字识别几乎每个学机器学习的人都跑过但大多数人只是把别人写好的 LeNet 粘下来跑通一次看到 98% 的准确率就关掉了。这个标题真正值钱的地方不在于“识别”本身而在于它把机器学习项目的完整链路——数据加载、预处理、模型设计、训练、评估、调参、导出——全部压缩在了一个几天内能完成的小项目里。它适合三类人刚入门想把手写公式变成真实代码的人想系统走一遍训练流程但不想从 CV 比赛入手的转行者以及需要一份能讲清楚、能演示、能扩展的课设项目的人。这篇笔记不贴“官方最优解”而是按我实际调试过的路径把每一步的代码、参数和坑都摊开讲照着做你能得到一个可复现的项目更重要的是知道每一步为什么要这样写。2. 动手之前把任务和模型选型想清楚比写代码重要很多人在 MNIST 上翻车不是因为代码写错而是因为没想清楚“这个任务到底在解决什么问题”以及“为什么大家都选 CNN 而不是全连接网络”。这一章先把地基打牢后面写代码才不至于黑匣子式地调参。2.1 MNIST 数据集的真实构成以及为什么说它“干净”MNIST 是 28×28 像素的灰度图训练集 60000 张测试集 10000 张总共 10 个类别数字 0-9。每张图只有一个数字并且做了尺寸归一化和居中处理。这个“干净”的意义非常大它意味着你不需要做目标检测、不需要做文字分割、不需要处理复杂背景模型的任务从“找到数字在哪”简化成了“判断这个数字是什么”。这也是为什么 MNIST 适合入门——你全部的精力都可以放在分类模型本身上。但“干净”也有代价。因为图片已经预处理好你学不到数据预处理和增强的经验这在后面迁移到真实 OCR 场景时会有落差。所以在做这个项目时我建议你不要只用 torchvision 自带的 Dataset而是自己写一个最小版的数据读取类至少理解一下图片是怎么从磁盘变成 tensor 的。源码包里一般会直接调用datasets.MNIST这没问题但你得知道它内部已经把 0-255 的像素值存成了 numpy 数组并且 label 是整数而不是 one-hot。数据集的下载方式也值得提一句。torchvision.datasets.MNIST(root./data, trainTrue, downloadTrue)这行代码在国内经常卡在下载阶段因为默认源在国外。我的处理方式是一般不用默认源要么提前手动下载好四个 gzip 文件丢到./data/MNIST/raw目录下要么在代码里临时切换镜像源。后面第 3 章会专门把这个坑填掉因为数据加载不成功后面全是空谈。2.2 为什么是 CNN 而不是全连接网络参数量的账算给你看如果把 28×28 的图片拉平每个像素当作一个特征输入维度是 784。一个两层的全连接网络第一层 256 个神经元那这一层光权重就有 784×256 ≈ 20 万个参数第二层 256×10 ≈ 2560 个加起来不算多跑也能跑。但全连接网络的问题是它完全忽略了像素之间的空间结构——把第 3 行第 5 列挪到第 5 行第 3 列对于全连接层来说是两组完全不同的输入对于卷积层来说只是特征的平移。CNN 通过卷积核在空间上滑动天然具备平移等变性并且参数是共享的。一个 3×3 的卷积核只有 9 个权重加上 bias 是 10 个在整张图上共享。Conv1 层输入 1 个通道、输出 32 个通道参数是 1×32×3×3 ≈ 288 个比全连接网络少了三个数量级而表达能力反而更强。这就是为什么 MNIST 这个任务大家默认用 CNN——不是说全连接不能做而是同样精度下 CNN 更省参数、泛化更好。模型结构方面我建议你从 LeNet-5 的简化版开始。一个经典的简化结构是这样的Conv(1→32, 3×3) ReLU MaxPool(2×2) → Conv(32→64, 3×3) ReLU MaxPool(2×2) → Flatten → FC(64×7×7 → 128) → FC(128 → 10)。这里有两个容易踩的坑第一个是卷积层不改变 H、Wpadding1 时但 MaxPool 每次把尺寸减半28→14→7所以全连接层的输入维度是 64×7×7不是 64×28×28第二个是 Flatten 后维度写错非常常见建议你在定义模型时假设输入是(batch, 1, 28, 28)然后手动推一遍每一层的输出形状不要靠猜。2.3 损失函数和评价指标为什么用交叉熵而不用 MSEMNIST 是 10 分类问题输出层的常见做法是接一个 10 维向量然后过 softmax 得到每个类别的概率分布。训练时用的损失函数几乎都是交叉熵而不是均方误差MSE。原因有两个层面。第一交叉熵配合 softmax梯度形式更干净不会像 MSE 那样在输出层饱和时梯度消失第二交叉熵衡量的是两个概率分布的差异语义上更贴合分类任务——“预测分布”和“真实分布”有多远而不是“预测值”和“标签值”的数值差距有多大。在 PyTorch 里nn.CrossEntropyLoss()已经帮你在内部做了 softmax所以网络最后一层不要额外加 softmax直接输出 10 维 logits 就行。很多人在这里犯迷糊测试时手动给输出加了softmax再取 argmax结果一样但没必要。评估指标方面除了整体准确率 accuracy我建议你同时算一下每类别的精确率和召回率。MNIST 看起来很简单但数字 4 和 9、3 和 8 在部分手写样本上人眼都容易看混只看整体准确率会掩盖这些类别上的差异。torchmetrics 库里有现成的ConfusionMatrix、F1Score如果不想引依赖手动用 sklearn 的classification_report也能出一份完整的报表。import torch import torch.nn as nn # 模型输出层和损失函数的搭配方式 model SimpleCNN() criterion nn.CrossEntropyLoss() # 内部已包含 softmax模型输出 logits 即可 optimizer torch.optim.Adam(model.parameters(), lr0.001) # 一个典型的训练步 for images, labels in train_loader: optimizer.zero_grad() outputs model(images) # 形状: (batch_size, 10) loss criterion(outputs, labels) # labels 是整数索引不是 one-hot loss.backward() optimizer.step()这段代码里最关键的是labels的数据类型和形状。CrossEntropyLoss期望的 target 是(batch_size,)的整数张量每个值是 0-9 的类别索引而不是 one-hot 编码。如果你在别处看到用 MSE 训练的 MNIST 代码output 层会多一个 one-hot 转换步骤那种写法在这个任务上基本都会被交叉熵版本吊打。参数方面optimizer.zero_grad()必须放在前向之前否则梯度会累加——这个细节等到第 4 章完整训练脚本里再展开。3. 先把数据加载这关过了torchvision 下载 MNIST 报 404 的替代方案这一章单独拿出来写是因为我见过太多人在第一步就卡住。torchvision.datasets.MNIST(downloadTrue)在国内网络环境下经常报超时或 404报错信息看起来像是代码写错了其实是资源没下载下来。这里给你一套我实际用的方案所有代码都能直接跑通。3.1 从下载源到 Dataset 子类一条完整的加载链路torchvision的 MNIST 接口内部依赖一个 URL默认从 yann.lecun.com 下载这个源在国外且响应不稳定。遇到这种情况我一般直接在代码里指定一个可用的下载源或者干脆手动下载后放本地目录让downloadFalse跳过网络请求。下面是可复现的做法import torch from torch.utils.data import Dataset, DataLoader from torchvision import transforms # 方案一指定本地已下载好的 MNIST 原始文件路径 # 手动下载四个文件train-images-idx3-ubyte.gz、train-labels-idx1-ubyte.gz # t10k-images-idx3-ubyte.gz、t10k-labels-idx1-ubyte.gz # 放到 ./data/MNIST/raw/ 目录下 train_dataset torchvision.datasets.MNIST( root./data, trainTrue, downloadFalse, # 已经从别处下载不再走网络 transformtransforms.ToTensor() )注意downloadFalse时如果./data/MNIST/raw/目录下文件不全会抛RuntimeError: Dataset not found。如果你不确定文件是否齐全可以先让downloadTrue跑一次看到报错的具体文件路径再补不要盲猜。如果你的网络能访问到国内镜像源也可以不走手动下载。常见的做法是修改环境变量或直接改写下载地址。PyTorch 的MNIST类里mirror参数在旧版本可用新版本里被移除了所以我现在的习惯是优先手动下载虽然多一步但最稳定。下载之后通过transforms.Compose把归一化一起做掉transform transforms.Compose([ transforms.ToTensor(), # 把 PIL Image / numpy 数组转成 (C, H, W) 的 tensor值域 0-1 transforms.Normalize((0.1307,), (0.3081,)) # MNIST 全量数据的均值和标准差 ])这段代码里的两个数值(0.1307, 0.3081)是 MNIST 官方统计出的全局均值和标准差直接拿来用就好。Normalize的目的是把像素分布拉到标准正态分布附近加速收敛。如果你不归一化模型也能训练但 loss 会出现明显的震荡学习率稍大就容易发散。这里有个容易被忽略的点ToTensor()已经把像素值从 0-255 缩放到了 0-1然后再做 Normalize 才是符合预期的顺序如果你先 Normalize 再 ToTensor数值范围就乱了。3.2 DataLoader 的参数设置batch_size、shuffle 和 num_workers数据加载到 Dataset 之后还需要 DataLoader 来按 batch 取数据。这里参数不是随便填的直接影响训练速度和模型收敛行为。from torch.utils.data import DataLoader train_loader DataLoader( datasettrain_dataset, batch_size64, # 每批 64 张图常用选择显存不够就降到 32 shuffleTrue, # 训练集必须打乱否则模型会学到样本顺序的偏差 num_workers2, # Windows 上建议设 0Linux/Mac 可以开 2-4 pin_memoryTrue # 如果使用 GPU 训练能减少数据从 CPU 到 GPU 的拷贝时间 ) test_loader DataLoader( datasettest_dataset, batch_size1000, # 测试集大一点无所谓不需要反传梯度也省一次迭代 shuffleFalse, # 测试集不需要打乱 num_workers2, pin_memoryTrue )batch_size64是一个比较中庸的起点不大不小。太大比如 512会导致每个 epoch 梯度更新次数太少收敛变慢太小比如 8会引入较多噪声需要更多 epoch 才能稳定。shuffleTrue在训练集上必须打开理由也很简单——如果没有打乱DataLoader 会按顺序返回 0、1、2…… 类别的样本模型会先见过全部 0 再见过全部 1梯度更新方向会剧烈摆动。num_workers是一个容易被忽略的坑在 Windows 上如果设为大于 0 的值并且脚本不是放在if __name__ __main__:保护块里可能会抛RuntimeError: DataLoader worker (pid(s) X) exited unexpectedly。这不是数据问题是 Windows 多进程机制的坑。如果你在 Windows 下遇到这个报错直接把num_workers0训练速度会慢一些但不耽误跑通。我在工程上的一般做法是调试阶段num_workers0跑完整训练再根据系统调整。到这里数据链路已经通了。你可以做一次快速验证从train_loader里取一个 batch打印images.shape和labels.shape应该是torch.Size([64, 1, 28, 28])和torch.Size([64])。如果 shape 对不上大概率是 transform 的设置出了问题优先检查是不是漏了ToTensor()。这一关过了后面的模型训练才有意义。4. 训练一个能到 99% 的 CNN完整代码与关键参数解读数据加载解决了接下来就是模型训练的正题。这一章给出一个能直接运行的完整脚本然后逐段解释关键逻辑和参数选择的原因。你不需要自己拼接代码直接把下面的内容保存成train.py就能跑。4.1 模型定义比 LeNet-5 更轻量的 MiniCNN我一般建议在 MNIST 上用比 LeNet-5 更轻的结构。LeNet-5 是 1998 年的设计当时输入是 32×32用在 28×28 上需要额外处理另外它的两个卷积层输出通道数6 和 16偏少在现代框架下很难充分发挥。下面这个 MiniCNN 是我在多个机器上调过参的结构在 MNIST 上大约 10 个 epoch 能到 99% 以上import torch.nn as nn class MiniCNN(nn.Module): def __init__(self): super().__init__() self.features nn.Sequential( nn.Conv2d(1, 32, kernel_size3, padding1), # 输入 1 通道输出 32 通道3x3 卷积 nn.ReLU(inplaceTrue), nn.MaxPool2d(2, 2), # 28x28 - 14x14 nn.Conv2d(32, 64, kernel_size3, padding1), # 14x14 - 14x14 nn.ReLU(inplaceTrue), nn.MaxPool2d(2, 2), # 14x14 - 7x7 ) self.classifier nn.Sequential( nn.Flatten(), nn.Linear(64 * 7 * 7, 128), # 展平后维度是 64*7*7 nn.ReLU(inplaceTrue), nn.Dropout(0.3), # 防止过拟合训练时随机丢弃 30% 神经元 nn.Linear(128, 10) # 输出 10 类 logits ) def forward(self, x): x self.features(x) x self.classifier(x) return x这段定义里有三个细节值得说明。第一个是padding1它保证卷积后尺寸不变整个尺寸变化只有池化层在起作用方便推算全连接输入维度。如果你去掉 padding第二次卷积后 14×14 会变成 12×12全连接层的输入就要改成 64×12×12这个数字算错是模型跑不起来的常见原因。第二个是inplaceTrue它在原地修改 tensor节省一点显存对推理结果没有影响。第三个是Dropout(0.3)只加在全连接层之前卷积层不加因为卷积层参数共享本身就带有一定的正则化效果。4.2 训练循环一个 epoch 内发生了什么写清楚import torch import torch.nn as nn from torch.utils.data import DataLoader from torchvision import datasets, transforms device torch.device(cuda if torch.cuda.is_available() else cpu) model MiniCNN().to(device) criterion nn.CrossEntropyLoss() optimizer torch.optim.Adam(model.parameters(), lr1e-3, weight_decay1e-4) # Adam 默认学习率是 1e-3weight_decay 是 L2 正则对抗过拟合 transform transforms.Compose([ transforms.ToTensor(), transforms.Normalize((0.1307,), (0.3081,)) ]) train_dataset datasets.MNIST(root./data, trainTrue, downloadFalse, transformtransform) test_dataset datasets.MNIST(root./data, trainFalse, downloadFalse, transformtransform) train_loader DataLoader(train_dataset, batch_size64, shuffleTrue, num_workers0) test_loader DataLoader(test_dataset, batch_size1000, shuffleFalse, num_workers0) num_epochs 10 best_acc 0.0 for epoch in range(num_epochs): # 训练阶段 model.train() # 启用 BatchNorm、Dropout 等训练行为 total_loss 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() # 更新权重 total_loss loss.item() # 评估阶段 model.eval() # 关闭 DropoutBatchNorm 改用统计量 correct 0 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.data, dim1) total labels.size(0) correct (predicted labels).sum().item() acc 100.0 * correct / total print(fEpoch {epoch1}/{num_epochs}, Loss: {total_loss/len(train_loader):.4f}, fTest Acc: {acc:.2f}%) if acc best_acc: best_acc acc torch.save(model.state_dict(), best_mnist.pt) print(Saved best model.)这段脚本是完整可运行的几个操作需要展开说明。model.train()和model.eval()不是可调可不调的——如果你的模型里有 Dropout 或 BatchNorm忘记切换模式会导致评估结果不可复现。比如Dropout(0.3)在训练时会随机丢神经元如果不切到eval()测试阶段也会丢输出就会有随机性准确率忽高忽低。torch.no_grad()是评估阶段的标配它告诉 PyTorch 不需要记录梯度不仅省内存还会加速推理因为省去了反向传播图的构建。learning rate 1e-3配合 Adam 是当前任务下比较省心的组合。1e-3 这个值是经验性的太大1e-2loss 容易震荡不收敛太小1e-5收敛太慢10 个 epoch 可能到不了 98%。weight_decay在这里起到了 L2 正则的作用MNIST 模型很小不加也能跑到 99%但加了之后测试和训练的准确率差距会更小。学习率是训练过程里最值得调的参数预算有限时优先调它。4.3 从 98% 到 99% 的几个实用操作不只是调学习率在 MNIST 上从零开始训练一个 CNN98% 左右是很容易达到的。但从 98% 到 99% 甚至 99.2% 以上需要的不只是调大 epoch。下面三个操作是我实际验证过有效的第一是学习率调度。前期学习率 1e-3 快速下降后期如果一直不降会在最优值附近来回震荡。最简单的做法是用torch.optim.lr_scheduler.StepLR每 5 个 epoch 把学习率乘 0.5。更平滑的做法是ReduceLROnPlateau它在验证集指标不再提升时自动降学习率很适合这种小数据集上的精调。第二是数据增强MNIST 虽然已经是预处理过的但小幅度的随机旋转和位移仍然能提升泛化能力。torchvision.transforms.RandomAffine(degrees10, translate(0.1, 0.1))在这里很常用注意角度不能太大手写数字旋转 10 度以上就会变得难以辨认。第三是集成预测如果你想让测试准确率再涨 0.2-0.5 个百分点可以训练 3 个不同随机种子初始化的模型把它们的输出概率取平均再取 argmax。这个做法叫模型集成在 Kaggle 比赛里很常见用来压榨最后一点性能。这些操作不需要一次全用上。我的建议是先跑通上面 4.2 节的基础版本确认流程没问题再逐步叠加。每加一个操作记录一次测试准确率你会清楚地看到哪个操作带来了真实提升哪个操作在你的环境里反而劣化。这种对比实验的习惯比 MNIST 本身更重要后面迁移到任何新任务都是同一套方法论。5. 避坑记录MNIST 项目里最常见的 5 个翻车点这一章把我在训练和帮别人调试过程中实际遇到的高频问题汇总一下按“现象 → 原因 → 解决”的结构写每条都能直接对号入座。这些问题有的会导致程序崩溃有的是隐性的精度劣化——后者更危险因为不报错你甚至会误以为模型写对了。5.1 损失函数不下降卡在 2.3 附近不动现象训练时 loss 始终在 2.3 左右徘徊准确率只有 10% 左右和随机猜测差不多。原因2.3 这个值对 10 分类问题来说等于模型在输出均匀分布——每个类别的概率都是 0.1交叉熵的期望值-ln(0.1) ≈ 2.3026。最常见的原因是标签和输入没对齐比如DataLoader返回的顺序是(images, labels)你在循环里写成了for labels, images in train_loader导致模型一直在用标签当输入。第二个常见原因是模型输出层没有正确初始化或者卷积层后接全连接层的维度写错导致全连接层的输入是随机噪声。解决先打印一个 batch 的images.shape、labels.shape和labels.min()/max()确认 label 范围在 0-9 之间再检查模型输出的形状是不是(batch_size, 10)。如果这两步都没问题就把学习率调大到 1e-2 试试如果 loss 能下降说明原来的学习率或者优化器设置有问题。5.2 训练集准确率 99%测试集只有 91%过拟合还是欠拟合现象训练集准确率很快逼近 100%但测试集准确率徘徊在 90% 出头并且差距随着 epoch 增大而拉大。原因这是标准的过拟合。MNIST 有 60000 张训练图片对一个小 CNN 来说完全记住这些图片是绰绰有余的。如果模型容量过大比如全连接层设了 1024 个神经元且没有正则化它就会在训练集上“背答案”。解决优先检查两点。第一模型里有没有 Dropout如果没有在全连接层前加一个Dropout(0.5)第二训练数据是否做了归一化如果像素值没有 Normalize 到标准分布模型的优化路径会变得扭曲加大 train-test gap。如果加了 Dropout 后差距还是很大把全连接层的神经元数量从 128 减到 64或者把卷积输出通道从 64 减到 32。MNIST 本身信息量不大大模型在这里反而吃亏要把“够用”放在“大而全”前面。5.3 第一次训练 / 测试时准确率突变但后面恢复正常现象第一个 epoch 的测试准确率特别低比如 40%然后第二个 epoch 突然跳到 95%之后再缓慢上升。原因这不是 bug是正常的训练过程。模型初始权重是随机的相当于一个什么都不懂的孩子第一个 epoch 结束时它的“知识”还很混乱。如果你在第一个 epoch 之后就立刻用测试集评估得到 40% 并不奇怪。但有一种情况要警惕如果测试集的归一化参数和训练集不一致比如测试集用了不同的均值和标准差准确率会一直偏低且表现不稳定。解决不用做特殊处理。评估时注意一定要加载训练阶段的归一化参数换成测试集自己的统计量是一个隐蔽的错误因为测试集的均值和标准差和训练集是有细微差别的。除非你的模型在 5 个 epoch 后还没有明显上升才需要回头检查数据 pipeline 是否正确。5.4 GPU 利用率上不去训练速度和新手用 CPU 跑差不多现象明明代码里设置了.to(cuda)但训练速度没有明显提升GPU 利用率在 10% 以下。原因MNIST 单张图片只有 28×28模型又小单次前向传播的耗时极短数据从 CPU 拷贝到 GPU 的耗时反而成了瓶颈。如果batch_size又取 32 这种偏小的值模型大部分时间都在等待数据GPU 只能空闲。这在真实场景里是性能分析中常见的问题。解决优先调大batch_size到 256 或 512让一次前向传播的耗时远大于数据拷贝耗时。其次把num_workers从 0 调到 2 或 4让数据加载和模型计算并行起来。最后检查pin_memoryTrue是否开启它对 CPU→GPU 的数据传输有加速作用。如果这些做完 GPU 利用率还是上不去请看实际瓶颈——数据预处理、IO 读取等环节可以用torch.profiler做个简单 profiling比猜根因更高效。5.5 保存的模型在别人电脑上加载报错现象在本地训练好模型把.pt文件发给别人对方加载时报KeyError或者size mismatch。原因最常见的原因有两个。第一模型结构不一致比如你用MiniCNN()训练的但对方加载模型时定义的 class 名相同、内部层定义不同第二torch.save时保存了model.state_dict()推荐但对方用了torch.load直接加载保存和加载方式不匹配。解决统一保存和加载的约定代码层面注意保持模型定义一致。保存时不推荐使用torch.save(model.state_dict(), model.pt)之外的用法加载时用model MiniCNN(); model.load_state_dict(torch.load(model.pt, map_locationcpu))而不是model torch.load(model.pt)。map_locationcpu是一个很实用的参数它让对方在没有 GPU 的机器上也能加载你的模型不会被 CUDA 相关的报错卡住。另外.pt文件建议同时保存模型结构描述信息和训练时的超参哪怕写在注释里也比别人猜要强得多。6. 从“跑通”到“能讲清楚”可以用这三个技巧把项目做扎实这一章不继续堆准确率了。MNIST 项目做到 99% 以后再往上刷几个百分点对你的能力提升已经很小真正有价值的是把这个小项目做扎实——让你不仅能跑还能讲清楚模型的弱点、能扩展、能被别人验证。第一个技巧是错误样本可视化这是我在跑通基础模型后第一件会做的事。方法很简单遍历测试集把所有predicted ! label的图片收集起来用 matplotlib 画成一个网格图每张图标题标注“真实值 → 预测值”。这个操作在项目报告和答辩时极其加分因为它直接展示了模型的失败模式——比如你会发现 9 被误判成 4、3 被误判成 5这些数字的形态本身就接近模型的困境在视觉上一目了然。下面是代码片段import matplotlib.pyplot as plt def plot_errors(model, test_loader, device, n16): model.eval() errors_saved [] with torch.no_grad(): for images, labels in test_loader: images, labels images.to(device), labels.to(device) outputs model(images) preds torch.argmax(outputs, dim1) mis_idx (preds ! labels).nonzero(as_tupleTrue)[0] for idx in mis_idx: if len(errors_saved) n: break errors_saved.append((images[idx].cpu().squeeze(), labels[idx].item(), preds[idx].item())) if len(errors_saved) n: break fig, axes plt.subplots(4, 4, figsize(8, 8)) for ax, (img, true_label, pred_label) in zip(axes.flatten(), errors_saved): ax.imshow(img, cmapgray) ax.set_title(f{true_label} - {pred_label}) ax.axis(off) plt.tight_layout() plt.savefig(mnist_errors.png, dpi150)第二个技巧是用置信度分布来验证模型是否“知道自己不知道”。对于每个测试样本模型输出的 softmax 概率向量里最大值就是置信度。正常模型的置信度分布应该是两极化的大部分正确样本置信度接近 1.0错误样本的置信度明显偏低。你可以把所有样本按置信度排序取最低的 100 个样本统计其中错误比例——如果这个比例显著高于全局错误率说明模型的概率输出是有校准价值的反之则说明模型在“乱给信心”。这个分析用outputs.softmax(dim1).max(dim1)[0]就能拿到置信度向量画一个直方图就完成了。第三个技巧是为后续项目铺路——把训练好的模型导出为 ONNX 格式。这样做的好处是模型可以脱离 PyTorch 环境运行还能用 ONNX Runtime 在 CPU 上做推理加速。导出代码很简单dummy_input torch.randn(1, 1, 28, 28) torch.onnx.export(model, dummy_input, mnist_cnn.onnx, input_names[images], output_names[logits], dynamic_axes{images: {0: batch_size}, logits: {0: batch_size}})导出后可以用onnxruntime做一次推理验证输入同样形状的 tensor输出 should 匹配 PyTorch 的结果。这一步的意义在于MNIST 模型是你在真实项目中第一个能落地的模型ONNX 导出能力在之后的 YOLO、BERT 等几乎所有部署场景里都复用得上。最后说一个我个人踩过的坑训练时为了图省事没有记录每次实验的超参组合结果模型精度达到 99.1% 之后过了一周想复现发现已经记不清当时用的学习率是 1e-3 还是 8e-4 了。后来我养成了一个习惯——每个实验在代码里写一个固定的超参字典和模型权重一起作为文件保存调参时只改字典不动代码。这种“实验留痕”的习惯在 MNIST 上看似多余但项目稍微复杂一点就会成为救命稻草。把一个看起来简单的小项目做成有记录、有分析、有扩展的样子比盲目刷十遍训练循环要值得多。希望这些内容能帮你在 MNIST 这个入口上少走一点弯路。本文还有配套的精品资源点击获取
