要说深度学习入门的第一个项目,MNIST手写数字识别排第二,没人敢排第一。这个数据集从深度学习还是叫“神经网络”的年代就存在了,如今几乎所有框架的官方教程里都有它的身影。我当年第一次把准确率跑到98%的时候,那种兴奋感到现在还记得。但这篇想聊的不只是“跑通一个教程”,而是把全连接神经网络和卷积神经网络两套方案从头到尾拆开,讲清楚它们的结构差异、参数计算、训练细节和踩坑记录。不管你是刚装好Pytorch准备动手的新手,还是想回头补一遍基础概念的开发者,这篇应该都能给你点实在的东西。
1. 项目整体设计与思路拆解
1.1 为什么选MNIST作为入门项目
MNIST数据集本身非常“干净”:每张图片是28×28像素的灰度图,像素值范围0到255,内容是0到9这十个手写数字,训练集60000张,测试集10000张。对于一个刚接触深度学习的人来说,这些特性意味着你可以把绝大多数精力放在“模型怎么搭建、训练怎么跑起来”上,而不用像处理真实业务图像那样先花一周时间做清洗、去噪、标注、增强。
另一个重要原因是MNIST的难度层级非常友好。全连接网络在这里能达到约98%的准确率,卷积网络稍作优化就能突破99%。这样的数据分布让你能快速验证“网络结构对效果的影响到底有多大”——同样的训练轮数、同样的优化器,仅仅把网络从全连接换成卷积,准确率就会明显提升。这种正反馈对初学者建立信心非常关键,也是我推荐所有人把它作为第一个项目的根本原因。
但要注意,MNIST简单不代表可以随便糊弄。很多人把教程代码跑完,准确率看着不错,但对“为什么这个网络有效”“为什么这个参数这么设”完全没有概念,换一个数据集就完全不会了。所以下面的内容,我不光给代码,还会把每一步的决策逻辑讲明白。
1.2 全连接与卷积的核心差异:像素堆叠与局部感知
全连接神经网络处理图像的方式,本质上是把图片“拉平”。一个28×28的图像会变成一个784维的向量,每个像素都作为独立的特征输入到网络里。这种方式的问题在于——它丢失了像素之间的空间关系。数字“7”的横线和斜线在空间上是相邻的,但如果所有像素被拉平成一维,网络需要自己从784个特征里“悟出”这种邻接关系,这对参数量和训练数据量都是巨大的浪费。
卷积神经网络则完全不同。它的核心思路是局部感知:用一个小的卷积核(比如3×3)在图像上滑动,每次只看一个局部区域,提取边缘、拐角、线条这类基础特征,然后在后续层中把这些局部特征组合成更高层的语义特征(比如“圆圈”“竖线”“交叉点”)。这就像人看一张照片时,会先注意到轮廓,再注意到细节,而不是挨个像素去记忆。
两种网络在MNIST上的对比非常典型。全连接网络光输入层就有784个参数节点,逐层放大后参数量很快上万;而卷积网络因为“权值共享”机制——同一个卷积核在整个图像上重复使用——参数量反而更小,效果却更好。这也是为什么从MNIST开始就值得把两种结构都写一遍:你会亲手感受到结构设计的力量,而不只是跑个数字游戏。
1.3 项目技术栈与版本选择
这个项目我用的是Pytorch,版本以2.x为主(1.x的核心用法基本兼容)。选择Pytorch的理由很直接:动态计算图让调试非常方便,你可以在forward函数里随意打印每一层的输出形状;API设计也足够简单,nn.Module、optim.Adam、DataLoader这几个核心组件一学就会。另外,Pytorch在学术界和工业界的覆盖率已经非常高,学会这套接口之后,转向其他框架的成本也会低很多。
版本选择上,Pytorch 2.x对CPU和GPU都做了大量优化,尤其是torch.compile功能,虽然我在这个项目里没有强制使用,但如果你机器配置还行,可以试着在训练前加一行model = torch.compile(model),训练速度会有可感知的提升。另外建议把torchvision一起装上,MNIST数据集的下载和预处理都靠它。环境直接用一个全新的conda环境或者venv,避免和别的项目互相污染依赖版本。
2. 数据加载与预处理:训练开始前最有讲究的一步
2.1 使用torchvision加载MNIST数据集
torchvision帮我们把MNIST数据集的下载和读取封装好了,直接调用datasets.MNIST就行。第一次运行时会自动下载数据到本地,但我建议先手动确认一下网络情况,因为数据源在国外服务器,国内网络环境下偶尔会卡住或者很慢。如果下载超时,可以找镜像源,或者在本地先把数据集下载好,然后通过datasets.MNIST(root=..., download=False)离线加载。
加载的核心代码如下:
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)这里有两个容易出错的点。第一,ToTensor()会把PIL图像从[H, W]或[H, W, C]的格式转成[C, H, W]的Tensor,并且像素值从0-255缩放到0-1。这一步很多新手会忘,直接拿原始像素值丢给网络,轻则收敛变慢,重则不收敛。第二,灰度图只有一个通道,所以Normalize的均值标准差参数要写成单元素的元组(0.1307,)和(0.3081,),而不是三通道的(0.5, 0.5, 0.5)。
2.2 归一化参数为什么是0.1307和0.3081
MNIST官网对训练集的像素值做过一次统计,所有图片像素值的均值大约是0.1307,标准差大约是0.3081(这是缩放到0-1之后的值)。Normalize做的事情是(x - mean) / std,让整个数据分布的均值变为0,方差变为1。这样做的好处是:梯度下降时各个维度的更新幅度更加均匀,损失函数碗状地形不会被“拉长”,收敛速度和稳定性都会明显改善。
我可以举一个不太严谨但很直观的类比。如果你在一张桌子上推一个方块,桌面是倾斜的,方块会顺着最陡的方向快速滚下去,但有可能滚偏;如果先把桌面摆平,摩擦力均匀,那你可以更精确地控制滑动方向。归一化就像是把“桌面”摆平,让模型在参数空间里走得更稳。
有人可能会问:为什么不全用0.5归一化或者不做归一化?我也试过完全不归一化的情况,模型也能收敛,但训练初期的loss波动明显更大,而且需要调更高的学习率才能达到同样的效果。直接用统计得到的0.1307和0.3081是最省心的选择,这两个数在大多数MNIST相关代码里都通用,不需要自己重新算。
2.3 DataLoader与数据划分
数据读进来之后,还需要用DataLoader做批次加载与打乱:
from torch.utils.data import DataLoader train_loader = DataLoader(train_dataset, batch_size=64, shuffle=True) test_loader = DataLoader(test_dataset, batch_size=1000, shuffle=False)batch_size的选择会影响训练的稳定性和显存占用。在这个项目里64是个很平衡的数值,MNIST图片很小,显存几乎不会成为瓶颈;如果改到256甚至512,单次梯度估计会更加平滑,但每个epoch的更新次数变少,收敛步数会增加,我个人的经验是64到128这个区间最顺手。shuffle在训练集上必须为True,否则模型每个epoch看到的样本顺序相同,很容易学到顺序相关的伪模式;测试集则不需要打乱,方便计算整体准确率。
3. 全连接神经网络的实现:从零把基线模型搭起来
3.1 数据维度转换:28×28怎么变成784
全连接网络的第一层神经元接收的输入必须是一个一维向量,所以需要把每张28×28的图片展开成784维。在实现时可以用view(-1, 784)或者flatten(1)。这一步很多人会忽略,直接拿[batch_size, 1, 28, 28]的四维张量往线性层里塞,然后报一个维度不匹配的错。写代码时我喜欢在forward的第一行就做flatten,这样后面的线性层维度一目了然。
要注意的是,只把图片“拉平”成784维,不代表所有空间信息都完全丢失了——像素之间相邻关系的信息从逻辑上讲还在,只是网络结构不再显式地利用它。这为后面对比卷积网络的效果埋下了伏笔。全连接网络在MNIST上能达到98%左右,主要原因不是结构多么匹配图像任务,而是MNIST足够简单:数字大多居中、笔画清晰、噪声少,即使像素弱相关的建模方式也能学好。
3.2 网络结构设计与参数数量计算
这次我搭了一个三层的全连接网络,隐藏层各128个和64个神经元,输出层10个神经元对应0-9的分类:
import torch.nn as nn import torch.nn.functional as F class MLP(nn.Module): def __init__(self, input_dim=784, hidden_dim1=128, hidden_dim2=64, num_classes=10): super(MLP, self).__init__() self.fc1 = nn.Linear(input_dim, hidden_dim1) self.fc2 = nn.Linear(hidden_dim1, hidden_dim2) self.fc3 = nn.Linear(hidden_dim2, num_classes) self.dropout = nn.Dropout(p=0.2) def forward(self, x): x = x.view(x.size(0), -1) x = F.relu(self.fc1(x)) x = self.dropout(x) x = F.relu(self.fc2(x)) x = self.dropout(x) x = self.fc3(x) return x参数数量算一下更清楚。fc1的权重是784×128,加128个偏置,共100480个参数;fc2是128×64加64,共8256个;fc3是64×10加10,共650个。整体约10.9万参数。这个规模对现代机器来讲非常小,即使在CPU上训练,一个epoch也就几秒钟。
隐藏层神经元数量没有绝对的“正确值”。我选128和64是为了先观察基线效果,如果发现欠拟合可以往上加,过拟合就加Dropout或者减小层宽。实际操作时我建议通过实验来定,而不是一开始就追求一个所谓的最优结构。
3.3 训练循环:loss、反向传播与参数更新的完整流程
训练循环是项目里最基础也最核心的部分。Pytorch的标准流程是:前向传播计算预测和损失,反向传播计算梯度,优化器更新参数。每一步都有对应的API,但新手经常忘记的是optimizer.zero_grad()——如果不清空梯度,Pytorch默认会累加梯度,导致参数更新方向完全错乱。
import torch import torch.optim as optim def train_epoch(model, train_loader, criterion, optimizer, device): model.train() total_loss = 0 correct = 0 total = 0 for images, labels in train_loader: images, labels = images.to(device), labels.to(device) outputs = model(images) loss = criterion(outputs, labels) optimizer.zero_grad() loss.backward() optimizer.step() total_loss += loss.item() * images.size(0) _, predicted = torch.max(outputs, 1) total += labels.size(0) correct += (predicted == labels).sum().item() return total_loss / total, correct / total损失函数用的是交叉熵损失。这里有个关键点:nn.CrossEntropyLoss()里面内置了LogSoftmax和NLLLoss,所以模型最后一层输出的是原始的类别得分logits,而不是过softmax之后的概率。新手如果提前在forward里加一层F.softmax,训练时梯度会变得非常不稳定甚至不收敛。测试时如果用softmax输出作为“置信度”来理解结果是没有问题的,但训练时一定不能再softmax。
优化器和整体训练控制如下:
device = torch.device('cuda' if torch.cuda.is_available() else 'cpu') model = MLP().to(device) criterion = nn.CrossEntropyLoss() optimizer = optim.Adam(model.parameters(), lr=0.001) epochs = 10 for epoch in range(1, epochs + 1): train_loss, train_acc = train_epoch(model, train_loader, criterion, optimizer, device) print(f'Epoch {epoch:02d} | Train Loss: {train_loss:.4f} | Train Acc: {train_acc:.4f}')使用Adam优化器配上0.001的学习率,在MNIST这样的任务上基本不需要额外调整就能稳定收敛。从训练日志上可以看到,第一个epoch过后准确率通常就能达到95%左右,后面几个epoch提升速度放缓,最终停在97.8%到98.5%之间。这种“前期猛涨、后期微调”的收敛曲线非常典型,并不是模型坏了,而是数据本身简单、模型容量已经足够的表现。
4. 卷积神经网络的实现:让模型学会看局部特征
4.1 卷积层和池化层到底在做什么
如果说全连接网络是在“记像素”,卷积网络就是在“看结构”。一个卷积层里,若干个可学习的卷积核(比如3×3)会在图像上滑动,每个位置做一次内积运算,输出的特征图对应着某种局部模式的出现强度。第一层卷积核通常能学到边缘、颜色块之类的低级特征,但MNIST是灰度图,更常见的是学到各种方向的笔画边缘。层数加深后,后续卷积核会把低层特征组合成更复杂的模式——圆弧、交叉点、封闭区域,这些都是识别数字的关键结构。
池化层通常跟在卷积层后面,作用是对特征图做下采样。MaxPool2d每次取一个小区域里的最大值,比如2×2的池化会让特征图的宽高各减半。这样做的好处一是降低计算量,二是给模型带来一定的平移不变性——数字稍微偏移几个像素,池化后的主要特征仍然保留。你可以理解为,读完一整段文字之后只记住关键词,这些关键词比每个字的位置更稳定。
4.2 本项目的卷积网络结构设计与尺寸计算
我设计的卷积网络有两个卷积层、两个池化层,以及最后的三个全连接层。结构如下:
import torch.nn as nn import torch.nn.functional as F class CNN(nn.Module): def __init__(self): super(CNN, self).__init__() self.conv1 = nn.Conv2d(in_channels=1, out_channels=32, kernel_size=3, padding=1) self.conv2 = nn.Conv2d(in_channels=32, out_channels=64, kernel_size=3, padding=1) self.pool = nn.MaxPool2d(kernel_size=2, stride=2) self.fc1 = nn.Linear(64 * 7 * 7, 128) self.fc2 = nn.Linear(128, 64) self.fc3 = nn.Linear(64, 10) self.dropout = nn.Dropout(0.25) def forward(self, x): x = F.relu(self.conv1(x)) x = self.pool(x) x = F.relu(self.conv2(x)) x = self.pool(x) x = x.view(x.size(0), -1) x = F.relu(self.fc1(x)) x = self.dropout(x) x = F.relu(self.fc2(x)) x = self.dropout(x) x = self.fc3(x) return x这里有一个必须算清楚的地方:全连接层的输入维度为什么是64 * 7 * 7。我一步步推给你看。输入图像是[batch, 1, 28, 28];第一个卷积层保持尺寸不变(3×3卷积核、padding=1),输出[batch, 32, 28, 28];第一个池化层把高宽各减半,变为[batch, 32, 14, 14];第二个卷积层仍保持尺寸不变,输出[batch, 64, 14, 14];第二个池化层再减半,得到[batch, 64, 7, 7]。把这最后的多维特征图展平,就是64×7×7=3136个特征。
如果你改了卷积核的尺寸、padding或者池化层的步长,这个数字就要重新算。最稳妥的办法是在forward里、展平之前加一行print(x.shape),跑一次前向传播看实际形状,然后再去设置nn.Linear的输入维度。这个习惯能帮你省下大量的试错时间。
4.3 卷积网络参数量的对比分析
用上面的结构,我们来估算下卷积网络的参数量。
conv1:输入1通道,输出32通道,3×3卷积核。参数量是1×32×3×3+32=320。conv2:输入32通道,输出64通道,参数量是32×64×3×3+64=18496。两个卷积层加起来约1.88万参数。后面的全连接层:fc1是3136×128+128=401536,fc2是128×64+64=8256,fc3是64×10+10=650。总参数约42.9万。
你一定注意到了,卷积网络的总参数量比之前那个全连接网络(约10.9万)多了不少。但这里的关键是参数分配方式完全不同:卷积层在提取特征时是权值共享的,参数虽少但作用范围很大;真正占参数大头的反而不是卷积层,而是后面的全连接分类层。这也是很多现代模型会在最后用全局平均池化替代全连接层来压榨参数量的原因。
如果你希望进一步压缩模型规模,可以把全连接层的128降为64,或者干脆只用一层全连接直接映射到10类。准确率会有轻微下降,但模型体积能减少一半以上。在MNIST上怎么折腾都不会太差,这正适合拿来做这类结构实验。
4.4 训练结果与全连接网络的直观对比
我实际跑下来的结果,CNN在同样的训练配置下(10个epoch、batch_size=64、Adam、lr=0.001),最终测试准确率大概能达到99.2%到99.4%,比全连接网络的98%左右高出一个多点。别小看这一个点,在MNIST这个数据集上,从98%到99%难度是递增的。CNN之所以能做到,是因为它在训练过程中真的在“识别”数字的结构特征,而全连接网络更像是在做高维空间的统计匹配。
从收敛速度上看,CNN前期准确率提升甚至比全连接快,第一轮epoch结束通常就能到98%以上。当然,CNN在训练耗时上会比全连接多一点,尤其是CPU上跑,但单epoch也就是十几秒的事,完全可接受。
为了实验严谨,最好两个模型用同一套数据集划分和随机种子来训练。我在项目里添加了torch.manual_seed(42)来固定随机性,这样可以保证对比时,两个模型之间的效果差异主要来自网络结构,而不是数据顺序或初始化带来的随机波动。
5. 训练细节与调参心得:从98%到99%的进阶之路
5.1 学习率、batch size、epoch的配合逻辑
这三个超参数就像一个团队。学习率决定每一步走多大,batch size决定每一步的方向估计有多准,epoch决定总共走多少步。调参时要理解它们的联动关系,而不是孤立地调某一个。
在MNIST上,lr=0.001配合batch_size=64几乎是一个“万能组合”,无论是Adam还是SGD+momentum都能跑出不错的成绩。如果你用SGD,可能需要把学习率提高到0.01并加上动量momentum=0.9,否则收敛速度会明显偏慢。这背后的原因是Adam对每个参数做了自适应缩放,对学习率的敏感度低得多。
epoch的选择要结合验证曲线。很多教程上来就跑20个epoch,我的经验是先设10个,观察验证准确率的变化趋势。如果第10个epoch末尾准确率仍在上升,就说明还没收敛,可以继续加;如果已经开始持平甚至波动,说明再训练也没什么提升。MNIST任务上,CNN一般10-15个epoch收敛完全,全连接网络8-10个epoch就基本到顶了。
5.2 Dropout的作用和设置时机
结构里我加入了Dropout层,它在训练时随机“丢弃”一部分神经元的输出,让网络不能过度依赖某几个神经元,从而提升泛化能力。这相当于考试时不允许带小抄,逼着学生把知识点理解得更全面。
Dropout的比例不宜过大。在这个项目里,全连接层我用p=0.2到p=0.3,卷积层后面的全连接层用p=0.25。比例太大会造成欠拟合,明明训练集上准确率都很低;太小又起不到明显的正则化效果。另外,Dropout只应在训练时生效,Pytorch的nn.Dropout层在调用model.eval()之后会自动关闭,所以在做测试准确率评估之前一定要记得切换到eval模式,否则测试结果会受到随机丢弃的干扰。
5.3 学习率衰减与过拟合的早期判断
训练后期,固定学习率会在最优点附近来回震荡,导致准确率上不去。如果发现训练集的loss在下降,但验证集指标已经停滞,可以考虑降低学习率。简单做法是每5个epoch把学习率乘0.1:
scheduler = optim.lr_scheduler.StepLR(optimizer, step_size=5, gamma=0.1)在每个epoch结束时调用scheduler.step()即可。另一种更加推荐的办法是用ReduceLROnPlateau,它会监测验证loss,连续几个epoch没有改善时自动降学习率。不过对于MNIST这种简单任务,很多时候不需要这么复杂,固定0.001跑完也能得到不错的结果。
过拟合的判断要看训练集准确率和测试集准确率的差。如果训练集已经99.5%以上,测试集却只有98%,说明出现了过拟合。这时候增加数据增强是最直接的手段,MNIST上常用的是随机平移几个像素或者随机旋转小角度。torchvision.transforms.RandomAffine和RandomRotation都可以配合使用。数据增强加上后训练集准确率可能下降一点,但测试集准确率通常会有提升。
5.4 模型的保存、加载与推理演示
训练完的模型当然要保存下来,不然每次演示都要重新训练一遍。Pytorch最常见的方式是保存状态字典:
torch.save(model.state_dict(), 'cnn_mnist.pth')加载时先实例化一个相同结构的模型,然后把保存的权重装进去:
model = CNN().to(device) model.load_state_dict(torch.load('cnn_mnist.pth', map_location=device)) model.eval()这里有个容易踩的坑:保存的权重加载到模型时,模型结构定义必须与训练时完全一致,连全连接层的输出维度都不能差。如果改了结构,加载会报出key不匹配的错误。
推理演示时我最喜欢做一个小交互脚本:从测试集里随机取一张图片,显示出来,然后让模型预测类别。通过torch.unsqueeze(img, 0)把单张图片变成[1, 1, 28, 28]的批次形式,交给模型前向传播,再取argmax得到预测类别。这样你能非常直观地看到模型是怎么“看”这张图的,也可以顺便验证它对形变、噪声的抗干扰能力。
6. 训练过程中的常见问题与排查实录
6.1 维度不匹配报错:十个错误里有八个是它
我刚接触Pytorch时,被维度报错折磨得最多。比如mat1 and mat2 shapes cannot be multiplied (64x1x28x28 and 784x128),这个错的意思就是你把一个四维张量直接喂给了线性层,没有先展平。处理方法是在进入fc1之前加x = x.view(x.size(0), -1)。
还有一种是卷积输出的通道数对不上。比如最后一个池化层输出的特征图展平后是64×7×7,但你在全连接层写成了64×14×14,报错会提示第一维度的特征数量不匹配。解决方法是先在前向传播里打印x.shape,按实际形状去写全连接层的输入维度。我强烈建议所有新手在搭建模型时养成打印形状的习惯,这可以大大减少 Debug 时间。
6.2 训练或者测试前忘记切换模式
这个问题比较隐蔽,不容易报错,而是结果离谱。训练时模型在train模式,Dropout生效,BatchNorm统计量在更新;但测试时如果忘记调用model.eval(),模型仍然处于训练模式,Dropout会随机丢弃信息、BatchNorm会继续用训练时的统计量调整结果,导致测试准确率不稳定甚至偏低。完整的评估流程应该是:
model.eval() with torch.no_grad(): ...torch.no_grad()可以关闭梯度计算,不仅省显存,也避免误操作造成计算图累积。在模型推理和评估时加上它,是每个Pytorch项目的基本素养。
6.3 loss不下降的几个常见原因
如果训练了几个epoch,loss纹丝不动,先不要怀疑模型能力,按这个顺序排查:
- 检查数据预处理有没有做归一化,像素值是不是还停留在0-255。
- 检查损失函数是否选错,比如多分类用了
BCELoss而不是CrossEntropyLoss。 - 检查优化器那里的
param_groups,看模型参数是否真的被优化器管理了。一个常见的错误是把model.parameters()写成了model.parameters(少了括号),优化器里没有任何参数,训练自然没有任何效果。 - 检查学习率是否太小或者太大。
lr=1e-5训练MNIST几乎看不到动静,lr=1.0则会导致loss剧烈震荡。
另外,把训练过程中的loss打印出来观察曲线。如果loss在下降但准确率没变,很多时候是模型已经接近极限,只差最后几个样本的区分,可以继续训练或者调整结构。
6.4 数据下载慢或加载失败的解决思路
第一次download=True跑代码,经常卡在数据集下载这一步。服务在国外,速度不稳定是常事。我自己常用的解决办法是找可靠的国内镜像源,或者手动用浏览器下载MNIST的四个压缩包,放到./data/MNIST/raw/目录下,再以download=False加载。文件放对目录后,torchvision会直接读取本地文件,不再走网络。
如果你在公司内网或者离线环境,这个方法几乎是唯一选择。提前把数据集下载好放在共享目录里,在多台机器上实验时也能省掉重复下载的时间。这个经验在处理其他大型数据集(比如CIFAR、ImageNet子集)时同样适用。
6.5 复现性设置:怎么让实验结果稳定可复现
实验对比的可靠性比单次结果更重要。为了让两次训练得到一致的结果,需要在训练开始前做三件事:
import random import torch import numpy as np random.seed(42) np.random.seed(42) torch.manual_seed(42) if torch.cuda.is_available(): torch.cuda.manual_seed_all(42)在CPU上这样设置后,绝大多数情况能保证复现;在GPU上由于并行计算的浮点运算顺序不完全确定,完全复现要更复杂,但这组设置至少能保证“趋势一致”。固定随机种子还有一个额外的好处:调参时你能判断效果变化是来自超参数还是来自运气。
7. 项目扩展建议与个人的一点体会
跑到这一步,MNIST的两种实现已经完整走通了。如果还想继续往上走,我建议按下面三个方向去扩展。
第一个方向是把网络加深加宽,尝试经典的结构变体。比如加入BatchNorm层、尝试不同的激活函数(GELU、SiLU)、调整卷积核尺寸。MNIST允许你快速反复实验而不心疼时间,是测试结构设计的绝佳试验田。
第二个方向是换数据集。CIFAR-10和Fashion-MNIST是接替MNIST的理想选择,图片尺寸变大、类别更丰富、背景噪声更多,你会真正体会到“在这个数据集上能跑通”和“在那个数据集上也管用”之间的差距。
第三个方向是为模型加上可视化。把第一层卷积核的权重画出来,能看到它学到了什么样的边缘检测器;用torchvision.utils.make_grid展示一批测试图片和对应预测,直观感受模型的三维特征图中间层响应。这些东西会让学习过程更有趣,也能帮助你理解模型内部到底在做什么。
我个人在实际操作中最深的一点体会是:入门阶段不要追求一口气把所有技巧都堆进模型。先把最简单的全连接网络老老实实跑通,理解数据和训练循环的每一个环节,再引入卷积网络体会结构升级带来的变化。这种从简到繁的过程,比直接抄一份优秀的开源代码然后“跑起来就完事”要扎实得多。毕竟MNIST只是一个开始,后面要面对的问题比这复杂得多,基础打牢了,后面才走得快。