☰
从零手写CNN:PyTorch实战MNIST手写数字识别与调参指南
2026/10/16 7:07:58 网站建设 项目流程

1. 从零手写CNN之前,先想清楚MNIST到底在练什么

很多人第一次接触深度学习,都是从MNIST手写数字识别开始的。这个数据集小到可以塞进一张软盘,6万张训练图加1万张测试图,每张都是28×28的灰度图,像素值0到255。看起来简单得不像话,但恰恰是它,把卷积神经网络里几乎所有核心概念都串了一遍:卷积、池化、激活、全连接、反向传播、梯度下降、过拟合、批归一化、学习率调度。你要是能把MNIST上的CNN从零搭起来、调明白、跑出99%以上的测试准确率,那后面换到CIFAR-10、换到更复杂的图像任务,无非是加深网络、加宽通道、换数据增强策略的事。

我见过太多人一上来就抄一段nn.Sequential的代码,跑通了,准确率98%左右,然后就觉得自己会CNN了。但一问为什么卷积核用3×3而不是5×5,为什么池化用最大池化而不是平均池化,为什么全连接层的输入维度是那个数,就答不上来了。这篇内容就是写给这些"跑通过但没吃透"的朋友,以及刚入门想系统理解CNN搭建流程的新手。我会从数据加载、网络结构设计、训练循环、调参技巧、常见报错排查这几个维度,把PyTorch搭建CNN做MNIST这件事彻底讲透,代码可以直接抄,但更重要的是背后的"为什么"。

需要说明的是,下面涉及的具体超参数(学习率、批大小、通道数等)都是我在实际项目中反复试出来的经验值,不是唯一正确答案,但能帮你少走弯路。所有代码基于PyTorch 2.x版本,CPU和GPU都能跑,GPU大概几十秒一个epoch,CPU也就几分钟,对硬件几乎没有门槛。

2. 数据准备阶段最容易埋下的三个隐患

2.1 MNIST加载的标准化参数为什么是0.1307和0.3081

先看数据加载的标准写法:

import torch from torchvision import datasets, transforms from torch.utils.data import DataLoader 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=1000, shuffle=False)

这里有两个关键点。第一,transforms.ToTensor()不仅把PIL图像转成Tensor,还自动把像素值从0-255缩放到0-1之间,这个操作很多人以为是可选的,其实它是后续Normalize的前提。第二,Normalize((0.1307,), (0.3081,))这两个数字不是随便写的,它们是整个MNIST训练集的像素均值和标准差。计算方式是把所有训练图片的像素值归一化到0-1后,求全局均值和标准差。用这两个值做标准化之后,数据分布接近标准正态分布,梯度下降的收敛会明显更稳。

如果你偷懒不做Normalize,网络也能训练,但收敛速度会慢不少,而且对学习率更敏感。我实测过,同样的网络结构,不做标准化大概需要15个epoch才能到99%,做了标准化8个epoch左右就能到。所以这一步别省。

2.2 batch_size的选择不是越大越好

批大小64是我在MNIST上比较推荐的默认值。原因有几个:MNIST总共6万张训练图,batch_size=64的话一个epoch有938次参数更新,梯度估计的噪声适中,既能跳出局部极小值,又不会震荡太厉害。如果你用batch_size=1000,一个epoch只有60次更新,收敛会变慢,而且容易卡在不太好的解上。反过来用batch_size=8,更新次数太多,训练时间拉长,而且BatchNorm在小批量下统计量不稳定。

当然这不是铁律。如果你GPU显存够大,想加速训练,可以适当增大batch_size,但记得同步调大学习率,一般遵循线性缩放规则:batch_size翻倍,学习率也翻倍。不过MNIST这个任务本身很轻,没必要折腾这些,64就挺好。

2.3 训练集和测试集的transform必须一致

这是一个新手经常踩的坑:训练集用了Normalize,测试集忘了用,或者用了不同的参数。结果就是训练时准确率99%,测试时掉到90%以下,然后怀疑模型过拟合,其实是数据分布不一致导致的。测试集的transform必须和训练集完全一样,包括Normalize的均值和标准差。因为网络是在标准化后的数据上训练的,你喂给它未标准化的数据,它当然懵。

另外,测试集的DataLoader不需要shuffle,因为测试阶段不更新参数,顺序无所谓,而且shuffle反而让你没法对应预测结果和原始标签。训练集必须shuffle,否则每个batch的样本分布高度相关(比如前64张全是数字0),梯度方向会偏,训练不稳定。

3. CNN网络结构设计:每一层都在解决什么问题

3.1 从LeNet到现代CNN的演进逻辑

MNIST上最经典的网络是LeNet-5,1998年提出的,结构是:卷积→池化→卷积→池化→全连接→全连接→输出。这个结构放到今天依然能跑到99%左右,但我们可以做得更好。我下面给出的结构是在LeNet基础上做了现代化改造:加入BatchNorm、用ReLU替代Sigmoid、用最大池化、加入Dropout。

先看完整代码:

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(1, 32, kernel_size=3, padding=1) self.bn1 = nn.BatchNorm2d(32) self.conv2 = nn.Conv2d(32, 64, kernel_size=3, padding=1) self.bn2 = nn.BatchNorm2d(64) self.pool = nn.MaxPool2d(2, 2) self.dropout1 = nn.Dropout(0.25) self.fc1 = nn.Linear(64 * 7 * 7, 128) self.dropout2 = nn.Dropout(0.5) self.fc2 = nn.Linear(128, 10) def forward(self, x): x = self.pool(F.relu(self.bn1(self.conv1(x)))) x = self.pool(F.relu(self.bn2(self.conv2(x)))) x = x.view(-1, 64 * 7 * 7) x = self.dropout1(x) x = F.relu(self.fc1(x)) x = self.dropout2(x) x = self.fc2(x) return x

逐层拆解一下。第一层卷积:输入通道1(灰度图),输出通道32,卷积核3×3,padding=1。padding=1的作用是保持输出特征图的空间尺寸和输入一致,28×28进,28×28出。如果不加padding,3×3卷积会让尺寸变成26×26,两层之后变成22×22,信息损失会更快。输出通道32的意思是用了32个不同的卷积核,每个核学习一种局部特征,比如边缘、角点、纹理。

第二层卷积:输入32通道,输出64通道,同样3×3加padding。通道数翻倍是CNN设计的常见做法,因为随着空间尺寸减小(池化导致),我们需要增加通道数来保持信息容量。这就像你把一张大图切成小块,每块虽然小了,但你要用更多的"描述维度"来刻画它。

3.2 为什么用3×3卷积核而不是5×5或7×7

两个3×3卷积堆叠的感受野等于一个5×5卷积,但参数更少:两个3×3卷积的参数是2×3×3=18(忽略通道),一个5×5是25。而且两个3×3之间多了一次非线性激活,表达能力更强。这是VGG网络的核心洞察,放到MNIST上同样适用。7×7就更不划算了,参数多,而且MNIST的字符笔画很细,太大的卷积核会过度平滑局部细节。

3.3 池化层的位置和尺寸选择

我用了两次2×2最大池化,每次把空间尺寸减半。28→14→7。最大池化比平均池化更适合图像任务,因为它保留了每个区域的最强响应,相当于"这个区域有没有出现某种特征",而平均池化会把这个响应稀释掉。对于手写数字这种笔画特征明显的任务,最大池化效果更好。

池化层放在ReLU之后,这是标准顺序:卷积→BN→ReLU→池化。BN放在卷积之后、激活之前,是因为BN要标准化的是卷积输出的分布,激活函数是非线性的,放在BN后面才能保证进入激活的值是标准化的。

3.4 全连接层的维度计算和Dropout的放置

经过两次池化,特征图尺寸是7×7,通道数是64,所以展平后是64×7×7=3136维。这个数字必须算对,否则view操作会报错。很多人在这里卡住,其实就是没搞清楚每次池化对尺寸的影响。

全连接层我用了128个神经元,然后输出10类。中间加了两个Dropout:第一个在展平之后,丢弃率0.25;第二个在fc1之后,丢弃率0.5。Dropout的作用是随机丢弃一部分神经元,强迫网络不依赖某些特定路径,从而减少过拟合。MNIST数据量不算大,不加Dropout也能跑到99%,但加了之后测试集准确率更稳定,尤其是训练epoch较多的时候。

注意:Dropout只在训练时生效,测试时要关闭。PyTorch里用model.train()和model.eval()切换,eval()会自动关闭Dropout和BatchNorm的统计更新。

4. 训练循环里的门道:损失函数、优化器和学习率

4.1 交叉熵损失和Adam优化器的搭配

import torch.optim as optim device = torch.device('cuda' if torch.cuda.is_available() else 'cpu') model = CNN().to(device) criterion = nn.CrossEntropyLoss() optimizer = optim.Adam(model.parameters(), lr=0.001)

CrossEntropyLoss内部已经包含了Softmax,所以网络最后一层不要加Softmax,直接输出logits就行。这是PyTorch的一个设计特点,很多人从Keras转过来会不习惯。如果你手动加了Softmax再用CrossEntropyLoss,相当于做了两次Softmax,梯度会变得很小,训练几乎不动。

优化器选Adam,学习率0.001。Adam的自适应学习率让它在MNIST这种任务上几乎不需要调参就能收敛得很好。SGD也能用,但需要手动调学习率和动量,对新手不友好。Adam的缺点是最终收敛的精度可能略低于精心调参的SGD,但在MNIST上差距可以忽略。

4.2 完整的训练和评估函数

def train(model, device, train_loader, optimizer, epoch): model.train() for batch_idx, (data, target) in enumerate(train_loader): data, target = data.to(device), target.to(device) optimizer.zero_grad() output = model(data) loss = criterion(output, target) loss.backward() optimizer.step() if batch_idx % 100 == 0: print(f'Train Epoch: {epoch} [{batch_idx * len(data)}/{len(train_loader.dataset)}]' f'\tLoss: {loss.item():.6f}') def test(model, device, test_loader): model.eval() test_loss = 0 correct = 0 with torch.no_grad(): for data, target in test_loader: data, target = data.to(device), target.to(device) output = model(data) test_loss += criterion(output, target).item() pred = output.argmax(dim=1, keepdim=True) correct += pred.eq(target.view_as(pred)).sum().item() test_loss /= len(test_loader) accuracy = 100. * correct / len(test_loader.dataset) print(f'\nTest set: Average loss: {test_loss:.4f}, ' f'Accuracy: {correct}/{len(test_loader.dataset)} ({accuracy:.2f}%)\n') return accuracy

几个细节。optimizer.zero_grad()必须放在反向传播之前,否则梯度会累加。PyTorch默认会累积梯度,这是为了支持RNN这类需要跨时间步累积梯度的结构,但在普通CNN里每次迭代都要清零。with torch.no_grad()在测试时关闭梯度计算,节省显存和计算时间。output.argmax(dim=1)取每个样本10个输出中最大的那个索引作为预测类别。

4.3 学习率调度:什么时候该降学习率

固定学习率0.001跑10个epoch,MNIST上大概能到99.2%左右。如果你想再往上推一点,可以加一个学习率调度器:

scheduler = optim.lr_scheduler.StepLR(optimizer, step_size=5, gamma=0.5)

每5个epoch把学习率乘以0.5。这样前期学习率大,快速下降;后期学习率小,精细调整。我实测能提升0.1到0.2个百分点。不过MNIST本身太简单,提升空间有限,这个技巧更多是让你熟悉调度器的用法,换到难数据集上效果会明显得多。

5. 实测中遇到的坑和排查思路

5.1 维度不匹配报错:view和Linear的输入对不上

最常见的报错是RuntimeError: shape '[-1, 3136]' is invalid for input of size ...。原因通常是池化层数或卷积padding设置和预期不一致,导致展平后的维度不是3136。排查方法很简单:在forward里加一行print(x.shape),看看池化之后到底是多少。或者用x = x.view(x.size(0), -1)让PyTorch自动推断展平维度,但这样你就不知道全连接层该写多少输入了。我建议还是手动算清楚,这是理解CNN结构的基本功。

5.2 准确率卡在10%不动:标签和输出维度搞反了

如果你发现训练loss不下降,准确率一直在10%左右(10类随机猜的概率),大概率是标签处理有问题。检查一下target是不是0到9的整数,而不是one-hot编码。CrossEntropyLoss要求target是类别索引,不是one-hot。另外检查输出层是不是10个神经元,别写成1个了。

5.3 训练准确率99%但测试准确率90%:过拟合的识别和处理

如果训练集准确率远高于测试集,说明过拟合了。处理手段按优先级排序:第一,加Dropout,我上面已经加了;第二,加数据增强,比如随机旋转、随机平移,但MNIST的数字对旋转比较敏感(6和9旋转后容易混),所以旋转角度要小,一般±10度以内;第三,减小网络容量,比如把64通道降到32;第四,加L2正则化,在优化器里设置weight_decay=1e-4。

不过MNIST上过拟合其实不严重,因为数据量够大(6万张),网络也不算深。如果你测试准确率只有90%出头,更可能的原因是训练不够(epoch太少)或者学习率太大导致震荡。

5.4 GPU和CPU切换时的设备不一致报错

RuntimeError: Expected all tensors to be on the same device这个报错,通常是因为模型在GPU上,但数据还在CPU上。解决办法就是我在训练函数里写的,每个batch的数据都要.to(device)。另外,如果你保存了GPU上训练的模型,加载到CPU上推理时,要用torch.load(path, map_location='cpu')。

6. 把准确率从99%推到99.5%的几招

MNIST的测试准确率天花板大概在99.7%左右,再往上就是过拟合测试集了。从99%到99.5%这半个百分点,靠的是细节打磨。

第一招,加数据增强。用transforms.RandomAffine(degrees=10, translate=(0.1, 0.1)),让每个epoch看到的图片都有轻微变化,相当于扩充了数据集。注意增强只在训练集上用,测试集不能用。

第二招,用更大的网络。把通道数从32/64提升到64/128,全连接层从128提升到256。但要注意参数量增加后过拟合风险也增加,需要配合更强的Dropout或weight_decay。

第三招,集成多个模型。训练5个结构相同但初始化不同的CNN,测试时把它们的输出平均一下。这个方法稳定提升0.2%左右,代价是训练时间和推理时间翻5倍。实际项目中不划算,但作为学习练习可以试试。

第四招,用学习率预热和余弦退火。前2个epoch学习率从0线性增加到0.001,然后按余弦曲线下降到0。这个策略在难数据集上效果显著,MNIST上也能榨出一点提升。

提示:不要为了刷MNIST的准确率花太多时间。99%和99.5%在实际应用中几乎没有区别,把精力放在理解原理和迁移到其他数据集上更值得。

7. 训练完之后:模型保存、加载和推理

训练完成后,保存模型参数:

torch.save(model.state_dict(), 'mnist_cnn.pth')

加载时先实例化同样的网络结构,再加载参数:

model = CNN().to(device) model.load_state_dict(torch.load('mnist_cnn.pth', map_location=device)) model.eval()

推理单张图片时,要注意图片的预处理必须和训练时一致:

from PIL import Image img = Image.open('digit.png').convert('L') img = transform(img).unsqueeze(0).to(device) with torch.no_grad(): output = model(img) pred = output.argmax(dim=1).item() print(f'Predicted digit: {pred}')

convert('L')转灰度,transform做ToTensor和Normalize,unsqueeze(0)增加batch维度。这三步缺一不可,尤其是Normalize,忘了的话预测结果会完全乱掉。

我在实际项目里还遇到过一个情况:用自己手写的数字拍照识别,准确率远低于测试集。原因是拍照的光照、对比度、笔画粗细和MNIST的分布差异很大。解决办法是在推理前做一次自适应二值化,把图片变成黑白分明的样子,再缩放到28×28。这个预处理流程比网络结构本身更影响实际效果,但很多教程都不提。

8. 从MNIST毕业之后该往哪走

MNIST跑通只是起点。接下来我建议按这个顺序进阶:先换Fashion-MNIST,同样的28×28灰度图,但类别是衣服鞋子包包,难度略高,能检验你的网络是不是真的学到了特征而不是死记硬背。然后换CIFAR-10,32×32彩色图,10类物体,这个数据集上CNN的准确率大概在85%到95%之间,调参空间大,能让你真正体会到网络深度、数据增强、学习率调度的影响。再往后就是迁移学习,用预训练的ResNet在自定义小数据集上微调,这是实际工作中最常用的套路。

回到CNN本身,MNIST上你学到的卷积、池化、BN、Dropout、全连接,在更深的网络里依然是这些组件,只是堆叠方式变了。ResNet加了残差连接,Inception加了多尺度并行卷积,DenseNet加了密集连接,但底层逻辑没变。把MNIST这个最小闭环走通,后面就是不断加组件、调结构的过程。

最后分享一个我自己的习惯:每次搭新网络,先用一个极小的子集(比如100张图)跑几个epoch,确认没有维度错误、loss能下降,再上全量数据。这样调试成本低,改结构也快。直接上全量数据跑半天才发现报错,时间就浪费了。

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

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

立即咨询