资讯动态

MNIST手写体识别从97%到99%:完整机器学习链路与调优实战

发布时间:2026/10/27 21:16:45 来源:尧图企业网站定制
MNIST 这个数据集我前后带过不少人上手也见过太多人把它当成跑通就行的练手项目。实际情况是绝大多数人第一次做手写体识别代码能跑出 98% 的准确率但你问他为什么用交叉熵不用均方误差、为什么卷积核是 3×3、为什么训练集要打乱、为什么验证集准确率卡在 97% 上不去基本答不上来。这就是典型的跑通了但没学会。MNIST 手写体识别看起来简单——10 个类别、28×28 的灰度图、几万条样本随便一个两层网络都能到 95% 以上——但它恰恰是把机器学习完整链路走一遍的最佳载体数据加载与预处理、模型结构设计、损失函数选择、优化器调参、过拟合判断、评估指标解读每一个环节都能挖出真东西。这篇内容我打算按实际动手的顺序来讲从拿到数据开始到最终模型评估结束中间穿插我自己踩过的坑和带人时发现的高频误区。适合刚入门想认真搞懂一个完整项目的人也适合已经跑通过但想补底层逻辑的人。读完你至少能明白为什么你的模型停在 97% 上不去以及怎么把它推到 99% 以上。1. 先把 MNIST 这堆数据摸清楚再动手1.1 数据集的真实构成与常见误解MNIST 全称是 Modified National Institute of Standards and Technology 数据库包含 0 到 9 共十个数字的手写灰度图。训练集 60000 张测试集 10000 张每张图固定 28×28 像素像素值范围 0 到 2550 代表纯黑背景255 代表纯白笔画。很多人拿到数据直接load_data()就开跑连数据长什么样都没看过这是第一个大坑。我建议你第一步先把数据可视化出来。随手抽 16 张图拼成 4×4 的网格看一眼你会发现几个有意思的现象不同人的书写风格差异极大有的7带横杠有的不带有的4封口有的开口有的1就是一根竖线有的带底座。这些类内差异正是模型需要学习的核心难点。另外你还会注意到图像四周有大量纯黑边距真正有笔画的区域集中在中间大约 20×20 的范围内。这个观察直接影响到后面要不要做裁剪和归一化。还有一个常被忽略的点MNIST 的标签是 one-hot 编码还是整数编码原始数据集给的是整数标签0-9但如果你用交叉熵损失框架通常要求标签是整数形式内部自动做 one-hot而如果你自己手写损失函数就得手动转 one-hot。我见过有人把整数标签直接喂给要求 one-hot 的自定义损失结果 loss 一直不降排查了半天以为是学习率问题。这种基础认知不清导致的调试成本远比多花十分钟看数据要高。1.2 像素归一化为什么除以 255 不是可选项像素值原始范围是 0-255直接喂进网络会出问题。原因很直接神经网络每一层的输出是输入加权求和如果输入量级在几百第一层的激活值就会非常大经过几层累积后梯度要么爆炸要么消失。把像素除以 255 缩放到 [0,1] 区间本质上是让输入分布落在网络初始化时预期的量级附近。这里有个细节值得说清楚除以 255 之后数据均值大约在 0.13 左右因为大部分像素是黑色背景的 0并不是零均值。更严谨的做法是做标准化即减去均值再除以标准差让输入分布接近标准正态。实测下来对于 MNIST 这种简单任务单纯除以 255 就够用了标准化带来的提升在 0.1% 量级不明显。但如果你后面要迁移到更复杂的数据集养成标准化的习惯没坏处。我通常的做法是这样import numpy as np def preprocess(x_train, x_test): x_train x_train.astype(float32) / 255.0 x_test x_test.astype(float32) / 255.0 # 如果需要标准化取消下面注释 # mean x_train.mean() # std x_train.std() # x_train (x_train - mean) / std # x_test (x_test - mean) / std return x_train, x_test注意标准化时均值和标准差只能用训练集算再应用到测试集绝不能把测试集数据混进来算统计量。这是数据泄漏的经典场景很多人不在意但在严肃项目里这是硬伤。1.3 训练集、验证集、测试集的正确切分逻辑MNIST 官方给了训练集和测试集但没给验证集。验证集的作用是在训练过程中监控模型泛化能力、做超参选择测试集只在最后评估一次。如果你直接拿测试集当验证集用反复根据测试集准确率调参那测试集就泄漏了最终报告的准确率会虚高。正确做法是从 60000 张训练集里再切出 5000 到 10000 张做验证集。我一般切 10000 张剩 50000 张训练。切分时务必打乱顺序再切因为原始数据是按标签大致有序排列的虽然不像某些数据集那么严格直接取前 10000 张可能导致验证集类别分布不均。from sklearn.model_selection import train_test_split x_train, x_val, y_train, y_val train_test_split( x_train, y_train, test_size10000, random_state42, stratifyy_train )stratifyy_train这个参数很关键它保证切分后训练集和验证集的类别比例一致。MNIST 本身各类别比较均衡每类约 6000 张不加 stratify 问题也不大但养成习惯遇到不均衡数据集时这就是救命参数。2. 模型结构从全连接到卷积的取舍逻辑2.1 为什么全连接网络只能到 97% 左右最朴素的方案是把 28×28 拉平成 784 维向量接一两个全连接层输出 10 类。这个结构跑 MNIST 能到 97%-98%看起来不错但它有个根本缺陷拉平操作丢掉了图像的空间结构。对全连接层来说第 5 个像素和第 6 个像素相邻这件事它不知道它只看到 784 个独立的数值。而手写数字的识别高度依赖局部空间模式——一个8由上下两个环组成一个9是上环加下竖这些结构信息在拉平后就散了。更具体地说全连接层对每个像素位置都分配独立权重参数量巨大784×512 就有 40 万参数容易过拟合而且对图像的平移、轻微形变不鲁棒。你把一个3整体向右平移两个像素对人眼还是3但对全连接网络来说输入向量完全变了预测可能就错了。这就是为什么全连接方案的天花板大概在 98% 附近再往上很难。2.2 卷积层到底在学什么卷积的核心思想是局部感受野加权重共享。一个 3×3 的卷积核在整张图上滑动每个位置用同一组 9 个权重做加权求和。这意味着第一它只关注局部 3×3 区域符合图像局部相关的先验第二同一组权重扫过全图所以不管笔画出现在左上还是右下同一个卷积核都能检测到这就是平移等变性的来源。一个训练好的 MNIST 卷积网络第一层卷积核可视化出来通常是各种边缘检测器——有的检测横线有的检测竖线有的检测斜向笔画。第二层组合这些边缘成简单形状更深的层再组合成数字部件。这个层级特征提取的过程正是卷积网络比全连接强的地方。我常用的一个基础结构是这样的import torch import torch.nn as nn class ConvNet(nn.Module): def __init__(self): super().__init__() self.features nn.Sequential( nn.Conv2d(1, 32, 3, padding1), # 28x28 - 28x28 nn.BatchNorm2d(32), nn.ReLU(), nn.Conv2d(32, 32, 3, padding1), # 28x28 - 28x28 nn.BatchNorm2d(32), nn.ReLU(), nn.MaxPool2d(2), # 28x28 - 14x14 nn.Conv2d(32, 64, 3, padding1), # 14x14 - 14x14 nn.BatchNorm2d(64), nn.ReLU(), nn.Conv2d(64, 64, 3, padding1), nn.BatchNorm2d(64), nn.ReLU(), nn.MaxPool2d(2), # 14x14 - 7x7 ) self.classifier nn.Sequential( nn.Flatten(), nn.Linear(64 * 7 * 7, 128), nn.ReLU(), nn.Dropout(0.5), nn.Linear(128, 10) ) def forward(self, x): x self.features(x) return self.classifier(x)这个结构参数量不大在 MNIST 上能稳定跑到 99.3% 以上。几个设计点解释一下padding1是为了让 3×3 卷积后尺寸不变避免特征图过快缩小每个卷积后接 BatchNorm 加速收敛并稳定训练两次池化把 28×28 降到 7×7最后接全连接分类。Dropout 放在全连接前防止过拟合。2.3 卷积核数量、层数与感受野的平衡有人会问卷积核是不是越多越好、层数是不是越深越好在 MNIST 上答案是否定的。这个任务本身不复杂输入只有 28×28你堆到十几层卷积感受野早就覆盖全图了多出来的层只会增加过拟合风险和训练时间。我实测过把上面的结构加深到 6 个卷积块准确率反而从 99.3% 掉到 99.1%因为小数据集撑不起那么大的模型容量。卷积核数量从 32 起步比较合理第二层翻倍到 64这是常见的金字塔设计——空间尺寸减半、通道数翻倍保持每层的信息容量大致平衡。如果你追求极致轻量把通道数砍到 16 和 32准确率大概掉 0.2%但参数量能少一半适合部署到资源受限的场景。感受野的计算值得单独提一句。一个 3×3 卷积的感受野是 3×3两个 3×3 堆叠的感受野是 5×5三个是 7×7。MNIST 的笔画特征尺度大概在 5×5 到 15×15 之间所以感受野覆盖到 15×15 左右就足够捕捉一个完整数字部件了。上面那个结构经过两次池化加四个卷积感受野完全够用再深就是浪费。3. 损失函数与优化器那些默认值背后的门道3.1 交叉熵为什么是分类任务的标准答案多分类任务用交叉熵损失几乎是默认操作但很多人说不清它和均方误差的区别。直观理解交叉熵衡量的是预测概率分布和真实分布之间的差异它有个好性质——当预测错得离谱时比如真实是3但模型给3的概率只有 0.01损失会非常大梯度也大模型会被狠狠纠正而均方误差在这种情况下的梯度反而会变小导致学得慢。数学上交叉熵配合 softmax 输出层其梯度形式特别干净预测概率减去真实 one-hot 标签。这个梯度不会因为 softmax 的饱和而消失训练非常稳定。而均方误差配合 softmax 时梯度里会多乘一个 softmax 的导数项当预测概率接近 0 或 1 时这个导数趋近于 0梯度消失训练卡住。这就是分类任务几乎不用均方误差的根本原因。在 PyTorch 里nn.CrossEntropyLoss内部已经包含了 softmax所以模型最后一层直接输出 10 维 logits不要再手动加 softmax否则等于做了两次损失计算就错了。这个坑我见过不止一个人踩表现为 loss 下降极慢、准确率上不去。3.2 优化器选择SGD 加动量还是 AdamMNIST 上 SGD 和 Adam 都能用但行为差异明显。Adam 自适应调整每个参数的学习率前期收敛快默认学习率 1e-3 就能跑得不错适合快速出结果。SGD 加动量前期慢一些但最终往往能收敛到略好的解而且泛化性能通常更稳。我的经验是如果你只是想快速验证流程通不通用 Adam学习率 1e-3几十个 epoch 就能到 99%。如果你想认真调一个最优模型用 SGD 加动量 0.9学习率从 0.01 起步配合学习率衰减比如每 10 个 epoch 乘 0.1最终准确率通常比 Adam 高 0.1% 到 0.2%。这个差距在 MNIST 上不明显但在更复杂的任务上会放大。学习率衰减的策略也值得说。固定学习率训练到后期loss 会在最优点附近震荡下不去。用余弦退火或者阶梯衰减让学习率随训练推进逐渐变小模型能更精细地逼近最优解。我一般用CosineAnnealingLR从 0.01 退到 1e-5配合 30 个 epoch效果很稳。optimizer torch.optim.SGD(model.parameters(), lr0.01, momentum0.9, weight_decay5e-4) scheduler torch.optim.lr_scheduler.CosineAnnealingLR(optimizer, T_max30)weight_decay5e-4是 L2 正则抑制过拟合在 MNIST 上这个值比较温和不会明显拖慢收敛。3.3 batch size 对训练动态的实际影响batch size 这个参数很多人随手设 32 或 64 就不管了但它实际影响训练动态。小 batch如 16、32带来的梯度噪声大有一定正则效果但训练不稳定、耗时大 batch如 256、512梯度估计准、训练快但容易陷入尖锐极小值泛化略差。MNIST 上我一般用 64 或 128兼顾速度和稳定性。有个细节batch size 变了学习率最好跟着调。经验法则是学习率和 batch size 大致成正比batch 翻倍学习率也翻倍。因为大 batch 的梯度方差小可以用更大的步子走。如果你从 64 换到 256 却不调学习率会发现收敛变慢就是这个原因。另外训练集每个 epoch 前必须打乱shuffle。PyTorch 的 DataLoader 默认shuffleFalse一定要手动设成 True。不打乱的话模型会先连续看到大量同类样本梯度方向偏斜训练震荡严重。这个细节看似小但不设的话准确率可能差好几个点。4. 训练过程中的监控与过拟合判断4.1 训练 loss 和验证 loss 的背离意味着什么训练时每个 epoch 记录训练集 loss、验证集 loss、验证集准确率这三条曲线是判断模型状态的核心依据。正常情况训练 loss 持续下降验证 loss 先降后升拐点就是过拟合开始的信号验证准确率先升后平。如果你看到训练 loss 一直降但验证 loss 从第 5 个 epoch 就开始升说明模型在死记训练样本泛化能力在退化。这时候要么加正则Dropout、weight decay要么减模型容量要么早停在验证 loss 最低点停止训练。我通常会在验证准确率连续 5 个 epoch 不提升时触发早停保存验证准确率最高的那个模型权重而不是最后一个 epoch 的权重。反过来如果训练 loss 和验证 loss 都很高且降不下去那是欠拟合模型容量不够或者学习率太小需要加层、加通道或者调大学习率。这两种情况的处理方向完全相反所以监控曲线是必须的不能只看最终准确率一个数字。4.2 数据增强在 MNIST 上的分寸数据增强是提升泛化能力的常用手段但 MNIST 上手写数字的增强要克制。随机水平翻转不能用——翻转后的2可能像56翻转后像9会制造错误标签。随机旋转幅度也不能大超过 15 度数字就变形严重了。安全且有效的增强是小幅平移±2 像素、小幅旋转±10 度、轻微缩放0.9 到 1.1 倍。我实测过加适度的随机仿射变换准确率能提升 0.1% 到 0.2%但增强过猛反而掉点。所以增强参数要保守宁可少增强也不要引入错误标签。如果你用的是现成的增强库务必确认它没有默认开启水平翻转这类对数字有害的操作。4.3 学习率找最优值的实操方法学习率是最重要的超参没有之一。除了凭经验设有个很实用的方法叫学习率扫描从一个极小值如 1e-6开始每个 batch 把学习率乘一个系数逐步增大记录每个学习率对应的 loss画出 loss 随学习率变化的曲线。曲线最低点附近就是较优学习率通常取最低点左侧一个数量级的位置作为初始学习率。这个方法在 MNIST 上几分钟就能跑完比盲目试快得多。我一般扫出来的最优区间在 0.01 到 0.05 之间SGD取 0.01 起步配合衰减基本不会错。Adam 的话最优区间在 1e-3 附近。5. 评估阶段准确率之外的真相5.1 混淆矩阵告诉你模型错在哪最终测试集准确率到 99% 以上很多人就收工了。但准确率是个宏观指标它不告诉你模型具体在哪些类别上犯错。画个混淆矩阵你会看到错误集中在特定类别对之间比如4和9、3和5、7和1。这些混淆是有原因的——书写时这些数字的形态确实接近。知道混淆模式后你可以针对性优化如果4和9混淆严重可以检查这类样本的预测置信度对低置信度的样本做重点分析看是数据标注问题还是模型特征提取不足。这种细粒度分析比单纯刷准确率有价值得多也是从跑通项目到理解项目的分水岭。5.2 置信度校准与错误样本分析模型输出的 softmax 概率不一定是校准好的置信度。一个预测7概率 0.99 的样本实际正确率可能只有 0.95。做置信度校准如温度缩放能让概率更有参考意义这在需要根据置信度做决策的场景很重要。更实用的是错误样本分析把测试集里预测错误的样本挑出来可视化逐个人工看。你会发现有些是模型确实没学好形态特殊的数字有些是标注本身有争议写得像4又像9的样本。MNIST 整体标注质量很高但仍有极少数模糊样本。分析这些错误能帮你判断模型的能力边界到底在哪。5.3 从 99% 到 99.5% 还有没有必要MNIST 上 99% 是个常见门槛99.5% 需要更精细的调参和集成。我的看法是作为学习项目跑到 99.3% 左右就足够理解整个流程了再往上刷是边际收益递减。但如果你的目标是把这个模型部署到实际场景比如票据数字识别那就要考虑真实数据和 MNIST 的分布差异——真实票据的数字字体、扫描质量、背景噪声都和 MNIST 不同直接套用准确率会大打折扣。这种情况下与其在 MNIST 上刷到 99.8%不如花时间做领域适配收集目标场景的样本、做针对性的预处理、在真实数据上微调。MNIST 的价值在于帮你建立完整的机器学习工作流认知而不是那个最终数字本身。6. 几个我踩过或见人踩过的坑6.1 张量形状和设备不匹配PyTorch 里 MNIST 图像加载出来是[batch, 1, 28, 28]四维张量第一个 1 是通道数。如果你用全连接网络需要先flatten成[batch, 784]如果用卷积网络保持四维。我见过有人卷积网络输入没加通道维报错Expected 4D input折腾半天。还有把模型放到 GPU 上但数据忘了.to(device)报设备不匹配。这类错误不涉及算法但新手很容易卡住建议写代码时把输入形状打印出来确认一遍。6.2 训练模式和评估模式的切换Dropout 和 BatchNorm 在训练和评估时行为不同。训练时 Dropout 随机置零、BatchNorm 用当前 batch 统计量评估时 Dropout 关闭、BatchNorm 用滑动平均统计量。如果你评估时忘了model.eval()准确率会明显偏低且不稳定。同理评估完要继续训练记得model.train()切回来。这个坑极其常见表现为训练准确率 99% 但测试只有 90%其实模型没问题是模式没切对。6.3 随机种子的可复现性调参时如果每次运行结果都不一样你根本没法判断是参数改动起了作用还是随机波动。训练开始前固定随机种子Python、NumPy、框架各自的种子都要设保证同一组配置结果可复现。但要注意固定种子后不同配置间的对比才公平而且 GPU 上某些操作本身有非确定性完全复现需要额外设置这个在严格实验里要留意。import random, numpy as np, torch def set_seed(seed42): random.seed(seed) np.random.seed(seed) torch.manual_seed(seed) torch.cuda.manual_seed_all(seed)6.4 保存和加载模型的完整状态保存模型时只存state_dict()是不够的如果你用了优化器和学习率调度器恢复训练时它们的状

读完文章,也想定制专属网站?

尧图设计师 24 小时内与您沟通定制方案

免费获取报价 →
↑