☰
PyTorch实现CNN手写数字识别:从数据预处理到模型调优全指南
2026/10/22 9:11:22 网站建设 项目流程

MNIST手写数字识别大概是每一个入门深度学习的人都会遇到的第一道正经坎。我这边也带过不少新人,前前后后少说也陪跑了几十遍这个项目。今天这篇不打算端着教科书腔调讲,就当一个做过、改过、也踩过坑的人跟你聊聊,用Pytorch搭一个CNN跑通MNIST,从数据准备到网络训练,再到效果提升和常见问题排查,一次说透。

先把这个项目到底是什么说清楚。MNIST是机器学习领域最经典的手写数字数据集,里面是6万张训练图片和1万张测试图片,每张都是28x28的灰度图,对应0到9十个数字。所谓用CNN实现手写数字识别,就是搭建一个卷积神经网络,让它学会把这些像素点归类到正确的数字类别上。CNN在这个场景里的表现远超传统全连接网络,原因后面会详细讲。如果你是刚接触深度学习,或者想在Pytorch上把整个训练流程完整走通,这篇文章可以直接拿来做参考。我没有打算把所有东西都铺开讲,重点是那些最关键、最容易出错、也最占用时间的环节。

1. MNIST这个数据集本身,藏着不少值得你先想清楚的问题

先说一个可能被很多人忽略的点:MNIST表面上是"入门用的小数据集",但它并不是"随便玩玩就能全对"的玩具。在普通CNN结构下,测试准确率做到99%以上是很正常的事情,但如果你从零开始写网络、调训练流程,会发现中间能出问题的地方远比想象中多。新手最常见的想法是"这个数据集太简单,我把网络搭出来跑一下就行",结果真跑起来要么损失一直不降、要么出现过拟合、要么GPU利用率感人。所以我在带新人的时候,第一件事不是给代码,而是让他们先把数据集本身看清楚。

1.1 数据格式与预处理,比你想的更值得较真

MNIST的每一张图是28x28的灰度图像,像素值范围在0到255之间。很多人直接从库里面把数据读出来就往网络里塞,这样不是不能跑,但效果和收敛速度都会受影响。标准的做法是把像素值归一化到[0,1]区间,或者更进一步做标准化处理。我习惯用均值0.1307、标准差0.3081这两个MNIST常用的统计量做标准化,这是别人已经在大量实验里验证过的数值。归一化为什么重要?因为神经网络的激活函数和权重更新都依赖输入数据的尺度,输入范围太大会让梯度爆炸或者让某些神经元长期饱和,收敛速度会肉眼可见地变慢。

数据变换直接用Pytorch的transforms模块就能搞定。我的习惯是先ToTensor()把PIL图像或者numpy数组转成张量,然后Normalize((0.1307,), (0.3081,))做标准化。这里有一个细节:Normalize的均值方差参数必须匹配输入数据的通道数和数据分布,MNIST是单通道灰度图,所以只需要一个值。如果你把RGB三通道的经验直接搬过来写三个数,就会出问题。预处理这块看起来不起眼,但后面所有训练效果都建立在这个基础之上。

还有一个容易忽略的问题:数据集的加载方式。Pytorch的torchvision提供了MNIST数据集的直接下载接口,但你最好在代码里设置好参数,让它自动处理不存在的情况,同时考虑网络不好的场景。我见过有人卡在这一步卡了半天的,因为国内访问某些数据源不稳定。稳妥的办法是先手动下载好数据集文件,然后放到指定目录,代码里设置download=False。

1.2 一个容易被忽视的基线问题

很多人在做分类任务时,一上来就选CNN,但是这个决策的底气其实来自于"你必须先知道问题难在哪"。MNIST的像素值本身具有空间局部性,也就是说数字的笔画、边缘、交点是集中在局部区域里的。全连接网络会把每个像素都当成独立的特征输入,完全丢掉了像素之间的空间位置关系,所以它需要大量参数才能勉强记住训练集的特征。CNN通过卷积核在图像上滑动,天然保留了局部空间信息,参数又少,效果又好。如果你想更直观地理解这个问题,可以先拿一个简单的两层全连接网络跑一下,看看准确率能到多少——通常只能到97%上下,而一个简单的CNN轻松就能超过99%。这种对比能让你真正理解"卷积为什么有效",而不是人云亦云。

2. CNN在数字识别里到底做了什么——把卷积、池化、全连接讲透

既然要写CNN实现MNIST,就必须把网络架构里的每个部件说明白。很多人背了很多概念,但一问"为什么这里要用池化""卷积核大小怎么选""Padding到底是干嘛的"就卡住了。这里我用数字识别的场景一个个解释。

2.1 卷积层:从原始像素中提取局部特征

卷积操作的核心思想就是用一个小窗口(卷积核)在图像上从左到右、从上到下地滑动,每次都计算窗口内像素和卷积核的加权和,得到一个新的特征值。这个窗口大小就是kernel size,常见的是3x3或者5x5。以3x3为例,它每次只看周围3x3区域内的像素,能提取到像"横线""竖线""拐角""端点"这类极小的局部模式。多个卷积核叠加在一起,就能提取出多组不同的特征图。第一层卷积通常只能提取边缘、颜色块这种低级特征,但随着网络加深,后面的层会把前面的特征组合成更复杂的模式,比如一个完整的数字圆弧或笔画结构。

这里有一个非常关键的参数:Padding。卷积核滑动时,边缘像素会比中心像素少参与计算,导致特征图尺寸缩小和边缘信息被丢弃。Padding做的就是给图像边缘补上一圈0,让卷积后的尺寸保持不变。MNIST的输入是28x28,如果你用3x3卷积核且padding=1,输出还是28x28;padding=0的话输出会变成26x26。尺寸变化本身不一定致命,但会影响你后面全连接层输入维度的计算。我建议新手一开始就用padding=1保持尺寸,尽量减少变量。

卷积层还有一个超参数叫stride,指的是卷积核每次滑动的步长。stride=1就是逐个像素滑,stride=2就是隔一个像素滑一次。stride增大本质上是在降低特征图分辨率,但代价是会丢失一部分信息。在MNIST这种小图上,我一般用stride=1,把降低分辨率的工作交给池化层去处理。

2.2 激活函数和池化层:非线性与降采样

卷积操作本身是线性的,如果只靠卷积层的堆叠,整个网络无论多深,等价于一个线性模型,完全没有能力学习复杂的决策边界。所以每层卷积之后必须接一个非线性激活函数。现在最常用的是ReLU,公式就是max(0, x),优点是计算简单、梯度消失问题比sigmoid轻得多。在MNIST这种任务上,ReLU几乎是最合适的默认选择。

池化层的作用是降采样。最常见的是最大池化,也就是在一个固定大小的窗口内取最大值。池化有两个好处:一是减小特征图尺寸,从而减少后续计算量;二是引入一定的平移不变性,也就是数字稍微偏移一点,池化得到的主要特征仍然接近。对MNIST来说,图像本身就很小,池化窗口我一般用2x2,stride=2,这样每过一次池化,尺寸就减半。28x28经过一次池化变成14x14,再经过一次变成7x7。后续全连接层的输入维度就靠这个来算。

2.3 全连接层:把特征映射到类别

卷积和池化负责提取特征,但最终要做的是10分类,所以需要把高维特征映射到类别得分上。常见的做法是先把最后一个卷积层输出的特征图展平成一维向量,然后接一个或多个全连接层,最后一层的输出维度是10。比如最后一层卷积输出的形状是(batch_size, 64, 7, 7),展平后就变成(batch_size, 64*7*7),也就是3136维。再接一个Linear(3136, 128),一个Linear(128, 10),在最后一层前也要加ReLU。这个结构是经典的LeNet变体,在MNIST上稳定又好用,非常适合作为第一个自定义网络。

这里有一个值得强调的点:全连接层的输入维度必须和展平后的特征图维度对得上。你改过卷积层参数之后,如果没有重新计算特征图尺寸,这里十有八九会报维度不匹配的错误。我自己的习惯是写个小函数,或者干脆一步一步手算一遍特征图尺寸,然后再去定义全连接层。用动态维度计算也行,比如把展平操作写成一个nn.Flatten(),让Pytorch自动把剩余维度展平,这样哪怕前面尺寸有调整,全连接层也不用改,但这个的前提是你全连接层的输入维度写的是对的。

3. Pytorch实现步骤全拆解——从数据装载到训练循环

接下来是硬核部分。我会把整个实现过程分成几块,每一块给出代码和解释。你可以照着敲一遍,也可以直接拷下来改一改。代码风格比较简练,但关键的细节我都做了注释。环境方面,Pytorch版本1.x或者2.x都可以,我这边用的是稳定版本,CUDA有更好,没有也能在CPU上跑就是慢一点。

3.1 数据装载:DataLoader的正确使用方式

数据装载这部分最容易被忽略的就是DataLoader的参数设置。batch_size我一般设成64或者128,太小的batch会造成梯度更新不稳定,太大会导致显存不足而且收敛速度也可能变慢。在MNIST这种小图上,64是一个很稳的起点。

训练集加载之后需要shuffle=True,这样每个epoch里面样本顺序都会被打乱,防止模型学习到样本间的顺序依赖。测试集则不需要shuffle,因为我们只是拿它做评估,不打乱顺序可以保证评估结果稳定。num_workers这个参数在Windows上要特别小心,设置为0最稳妥,设为大于0有时候会和ifname== 'main'保护机制冲突,报出奇怪的多线程错误。我自己的经验是,如果数据集不大,num_workers=0也能接受,如果数据量大再考虑调高,同时把代码放进main函数里执行。

代码如下:

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 ) train_loader = DataLoader(train_dataset, batch_size=64, shuffle=True) test_loader = DataLoader(test_dataset, batch_size=64, shuffle=False)

这里有个小细节值得说:Normalize的参数是元组,单通道也要写成(0.1307,)而不是0.1307,否则某些Pytorch版本会报维度不匹配的错误。初学者经常在这个地方卡一下,看到报错也不知道问题在哪。

3.2 模型定义:写一个能跑出99%的CNN结构

我用了一个比较经典的小型CNN结构,总共四层卷积加两层全连接。第一层卷积从1个通道变成32个通道,第二层从32变成64,每组卷积后面跟一个ReLU和一个最大池化。第三种组合在MNIST上已经有很多人验证过了,稳定性和效果都很好。为了增强一点正则化效果,我还在全连接层之间加了Dropout,比例设在0.25左右。Dropout比例太低效果不明显,太高又会抑制学习能力,0.25在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) self.dropout = nn.Dropout(0.25) def forward(self, x): x = self.pool(F.relu(self.conv1(x))) x = self.pool(F.relu(self.conv2(x))) x = x.view(x.size(0), -1) x = F.relu(self.fc1(x)) x = self.dropout(x) x = self.fc2(x) return x

这里要特别说一下x.view(x.size(0), -1)这个展平操作。x.size(0)是batch size,-1表示让Pytorch自动计算剩下所有维度的乘积,也就是把(batch, 64, 7, 7)展平成(batch, 3136)。这个写法比硬编码64 * 7 * 7灵活一些,因为只要前面的卷积池化结构不变,这里就不用改。但实际上你需要知道展平之后的维度确实是3136,否则全连接层的输入写错了一样会报错。

3.3 训练循环:损失、优化器、梯度清零与参数更新

训练循环是整个流程里最核心的部分。损失函数用交叉熵,这里有个很多人没搞明白的点:Pytorch的nn.CrossEntropyLoss内部已经包含了softmax操作,所以你模型最后一层输出的是原始logit,不需要再手动加softmax。如果你在自己的评估代码里加了softmax,然后又把输出传给交叉熵损失函数,就等于重复做了两次softmax,虽然不一定报错,但梯度计算会变得很奇怪。

优化器我选Adam,学习率设成0.001,这是Adam最常用的默认配置。也许有人会问为什么不用SGD,这个后面讲调参的时候再细说。训练循环的标准写法是:

device = torch.device('cuda' if torch.cuda.is_available() else 'cpu') model = MNISTCNN().to(device) criterion = nn.CrossEntropyLoss() optimizer = torch.optim.Adam(model.parameters(), lr=0.001) def train_one_epoch(model, loader, optimizer, criterion, device): model.train() total_loss = 0.0 correct = 0 total = 0 for images, labels in 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() * images.size(0) pred = outputs.argmax(dim=1) correct += (pred == labels).sum().item() total += labels.size(0) avg_loss = total_loss / total acc = correct / total return avg_loss, acc

这段代码里有一个细节值得说道说道:optimizer.zero_grad()每次迭代都必须调用,它的作用是清空上一次迭代留下来的梯度。如果你忘了调用,Pytorch默认会把梯度累积起来,这会导致更新方向完全错误。新手最常见的报错恰恰是这里,但令人意外的是,这个错误不会让你的程序崩溃,只会让损失函数曲线非常诡异,忽高忽低最后根本不收敛。

评估循环和训练循环非常相似,但有三个关键区别:模型要切换成model.eval()模式、不需要计算梯度(用with torch.no_grad():包住)、也不需要更新参数。model.eval()会关闭Dropout,让模型的行为变成确定性推理,这一点特别重要。如果你在评估时忘了切回eval()模式,Dropout还在随机丢弃神经元,你每次评估的准确率都会不一样,而且往往偏低,这会让结果分析失去意义。

3.4 完整训练脚本与超参数配置

为了让你直接上手,我把训练脚本的完整骨架贴出来。epoch我一般跑10到15个就够了,MNIST上的准确率提升在这个区间内会趋于饱和。

def evaluate(model, loader, criterion, device): model.eval() total_loss = 0.0 correct = 0 total = 0 with torch.no_grad(): for images, labels in loader: images, labels = images.to(device), labels.to(device) outputs = model(images) loss = criterion(outputs, labels) total_loss += loss.item() * images.size(0) pred = outputs.argmax(dim=1) correct += (pred == labels).sum().item() total += labels.size(0) return total_loss / total, correct / total num_epochs = 12 for epoch in range(1, num_epochs + 1): train_loss, train_acc = train_one_epoch( model, train_loader, optimizer, criterion, device ) test_loss, test_acc = evaluate(model, test_loader, criterion, device) print( f'Epoch {epoch:02d} | Train Loss: {train_loss:.4f} | Train Acc: {train_acc:.4f} | ' f'Test Loss: {test_loss:.4f} | Test Acc: {test_acc:.4f}' )

跑完这个脚本,正常的输出趋势应该是:训练损失稳步下降,训练准确率一路走高,测试准确率也跟着上升,最终稳定在99%左右。如果测试准确率和训练准确率之间的差距越拉越大,就说明过拟合了。在MNIST上因为数据集本身比较规范,加上Dropout的存在,轻微的过拟合不用太担心,但如果你发现测试准确率只有97%而训练接近100%,就得重新审视一下网络结构和正则化策略了。

4. 训练完成后如何评估模型——不只是看一个准确率数字

训练完模型之后,很多人就急着收工,只看一个"最终测试准确率",比如99.1%,然后就觉得大功告成了。实际在做项目或者学习复盘的时候,这种做法会漏掉很多信息。我一般会从三个方面做评估。

4.1 混淆矩阵:看清错误模式

准确率是一个整体指标,它会把0到9这十个类别的表现混在一起。但有些数字容易互相混淆,比如4和9、3和8、7和1,这些在MNIST里是出了名的难分对。混淆矩阵能直观告诉你模型到底把哪些类别搞混了、错误集中在哪。用Pytorch收集所有预测结果和真实标签,然后调用sklearn.metrics.confusion_matrix画出来就行了。这一步对理解模型非常有帮助:比如你发现7经常被当成1,那就可以去检查是不是训练数据里7和1的分布有问题,或者网络对细长形笔画的区分能力还不够。

4.2 随机抽看预测结果:眼见为实

逻辑上的"准确率99%"不如亲眼看到几组预测结果更有实感。我会写一个简单的函数,随机从测试集里抽若干张图,把原始图像、真实标签、预测标签一起输出。这样你不仅能确认模型确实学到了有用的特征,还能直观地看到它犯错的样本长什么样。有些错误样本连人类都不一定能一眼判断对,比如某些手写潦草到极致的数字,这时候你就知道模型已经做到了它能力范围内的极限。对错误的直接观察,往往比任何抽象指标都更能给你调参灵感。

4.3 误差分析的基本思路

如果你发现某个特定类别的准确率显著低于其他类别,我的排查思路大致是这样:先看训练集中这个类别的样本数量是否偏少,MNIST本身各类别数量均衡,但也有细微差异;再看它容易和哪些类别混淆,考虑是不是网络的特征提取能力不够;最后考虑数据增强,比如对数字做轻微的旋转或平移,让模型对形态变化更鲁棒。注意,在MNIST上数据增强要格外克制,因为数字是有语义的,过度旋转会把6变成9、9变成6,反而制造错误标签。

5. 提升模型效果的几个实操方向——把99%再往上推一小步

很多人做到99%就停下来了,但如果你想把模型效果推到99.3%、99.5%甚至更高,有几种常见的优化方向。这里我按可操作性和收益来排序。

5.1 调整网络结构:稍微加深一点

MNIST图很小,网络太深意义不大,但适当地在卷积层上做一点文章绝对有收益。比如把第一层卷积换成两个连续的小卷积核堆叠(3x3 + 3x3),这样在感受野上相当于一个5x5卷积,但参数更少、非线性更强。或者增加卷积输出通道数,从32/64改成64/128,这个简单粗暴但有效。当然代价是训练时间变长,在CPU上可能有点难受。网络结构的改动不会让准确率瞬间飞升,但往往能稳定地提升零点几个百分点。

5.2 学习率调度:后期用小学习率精细调优

训练初期用相对大的学习率可以让损失快速下降,但到了后期,如果学习率不变,损失会在一个平台附近来回震荡,无法进一步压低。我常用的做法是StepLR,每训练几个epoch就把学习率乘以一个0.1的衰减因子。在MNIST上我会在训练到第8个epoch左右把学习率从0.001调成0.0001,这样最后两个epoch的损失还能再往下走一点,准确率也会有一个小幅的跳升。用Pytorch写起来非常简单:

from torch.optim.lr_scheduler import StepLR scheduler = StepLR(optimizer, step_size=8, gamma=0.1) # 每个epoch训练完之后调用 scheduler.step()

注意,scheduler.step()要放在训练完之后,而且不要和optimizer.step()搞混。有些版本的Pytorch里,如果你使用的是ReduceLROnPlateau这类需要传入监控指标的调度器,还要注意传入的指标是训练损失还是验证损失,这个选择本身也会影响最终结果。

5.3 数据增强:有限度地增加扰动

MNIST虽然已经是一个比较干净的数据集,但适当的数据增强能让模型泛化能力更好。常见的增强包括随机旋转、随机平移、随机缩放。在torchvision中实现很简单,比如给训练集的transform加上RandomAffine(degrees=5, translate=(0.1, 0.1))。这里的关键词是"适度"。MNIST数字的方向性很强,旋转超过15度就会改变语义,所以我把角度限制在正负5度左右。平移的幅度也别太大,0.1倍图像尺寸已经是相对安全的上限。加数据增强之后,模型训练初期的收敛速度可能会变慢,但最终的测试准确率和鲁棒性会更好。

5.4 从Adam切换到SGD的小实验

Adam的优势是收敛快、对学习率不敏感,适合快速验证想法。但不少研究实验表明,在图像分类任务上,精心调过学习率的SGD(带动量)最终能达到的准确率往往不低于Adam,甚至略高。我在MNIST上也做过对比:同样的网络结构,用Adam跑到约99.1%,然后换成带动量的SGD、学习率0.01、动量0.9,再把学习率调度配上,最终能到99.3%左右。但这个差距不是固定不变的,而且这个实验本身的意义不是说明SGD更好,而是让你体会优化器的选择对最终效果有什么影响。如果你只想快速出结果,Adam完全够用;如果你想做实验打磨,SGD值得一试。

6. 过程中最常见的几个坑——如果你也遇到了,可以少走弯路

这一节我想专门写点"没人会在教程里提但你一定会遇到"的东西。

6.1 损失不下降,可能不是网络问题而是数据问题

很多人看到损失曲线平坦就急着改网络结构。我的经验是,先检查数据预处理。最常见的问题是用错了标准化参数,或者数据加载时图像的形状不对。MNIST在Pytorch中的数据形状是(batch, 1, 28, 28),如果你的代码里多了一步squeeze把通道维挤掉了,模型就会报错或产生非常奇怪的行为。另一个常见问题是标签和数据错位,DataLoader返回的顺序应该是(images, labels),但如果你手动从dataset里取数据时下标写反了,损失不下降也是很正常的事情。

排查思路是这样的:先看训练集的前几个样本是否正常工作,把图像打印出来看有没有失真;再看模型输出的形状是不是(batch, 10);最后用一个很小的batch,比如8张图,手动跑一遍前向传播,看看损失值是不是一个合理的初始值(对于10分类,交叉熵的初始值通常在2.3附近,因为-ln(1/10)≈2.3026)。如果初始损失不在这个量级,那不用继续训练了,先去修数据。

6.2 显存不够怎么办:从batch size开始剪

虽然MNIST图很小,但如果你batch size设得特别大、网络又加得比较深,中低端显卡也可能显存不足。最简单的办法是减小batch size,从128降到64甚至32。另一个办法是减少卷积通道数。还有一个容易忽略的问题:每次迭代对输入调用了.cuda(),但是模型本身忘了.to(device),这种情况会直接报类型不匹配或device不匹配错误。我见过不少这类问题,其实只要在模型定义后加上model = model.to(device)就解决了。

6.3 复现性问题:随机种子到底影响多大

深度学习的训练过程带有很多随机性,数据加载顺序、权重初始化和Dropout都涉及随机数。如果你在某次运行得到99.1%,下一次变成98.9%,这是正常现象,不用惊讶。但如果波动太大,或者你希望实验结果可复现,可以设置随机种子:

import random import numpy as np import torch def set_seed(seed): random.seed(seed) np.random.seed(seed) torch.manual_seed(seed) torch.cuda.manual_seed_all(seed) torch.backends.cudnn.deterministic = True torch.backends.cudnn.benchmark = False

这里有个细节:torch.backends.cudnn.deterministic = True会让卷积算法变成确定性版本,但代价是运行速度可能变慢。如果你只是在验证想法,不需要开这个,关掉反而更快。复现性和性能之间往往是需要平衡的。

6.4 CPU训练不要太着急

如果你没有GPU,MNIST在CPU上训练其实也可以接受。我试过上面这个简单CNN,在普通CPU上跑一个epoch大约需要几十秒到一两分钟,具体看机器性能,12个epoch下来大约十几分钟。虽然比GPU慢不少,但作为学习完全够用。有一些加速技巧,比如把num_workers调高让数据加载并行化,但实际瓶颈往往在卷积计算本身,这种优化收效有限。更值得做的可能是精简网络结构,把通道数减半,换来更快的实验迭代速度。

7. 一点更超前的思路:按需组织好你的训练代码

最后聊聊工程层面的经验。很多人学完MNIST之后就想快速复制这个流程到其他数据集上,但发现代码到处都要改,改完还会出错。出现这种问题的根源在于没有从一开始就把代码按逻辑模块化。

我的做法是把整个项目拆成几个独立模块:数据加载放一个文件,模型定义放一个文件,训练和评估函数放一个文件,最后用主脚本把它们组装起来。这样换数据集时只需要改数据加载和模型定义的输出维度;换模型时只需要改模型文件;调试时也可以在交互环境里单独测试某一环。听起来很简单,但真的能帮你省很多时间。项目大起来之后,这种结构化组织的收益会成倍放大。

8. 完成MNIST之后,下一步你可以做什么

这个项目做完之后,我觉得有两条比较自然的进阶路径。第一条是把网络换到稍微复杂一点的数据集上,比如Fashion-MNIST。它和MNIST有相同的尺寸和通道数,数据维度几乎不用改,但分类难度明显更高,因为衣服、鞋子、包这些类别之间的区分不像数字那么直观,模型的准确率天花板也低一些。你会在这上面重新感受到"从98%到90%"的落差,这对理解模型能力边界很有帮助。

第二条路径是回看你的CNN结构,尝试一下现代架构里的经典组件,比如BatchNormalization。在MNIST这种小数据集上,BatchNorm的收益不一定非常明显,但如果你把它加到卷积层后面,观察训练曲线的变化,你就理解了这个组件的作用机制,为之后接触更复杂网络打下基础。你还可以尝试自己手写一次反向传播过程,用简单的两层网络把梯度算一遍,这会让你对优化过程的理解上一个台阶。

我个人觉得,MNIST这个项目最适合用来练就一种"看穿表面现象"的能力:看到准确率99%时,知道它背后是什么在起作用;看到损失不降时,知道该从哪里排查。这种能力比任何一个模型在这个数据集上的表现都更值钱。

需要专业的网站建设服务?

联系我们获取免费的网站建设咨询和方案报价,让我们帮助您实现业务目标

立即咨询