☰
从零开始用PyTorch实现CNN识别MNIST:完整实战指南
2026/10/14 12:34:18 网站建设 项目流程

1. 从零开始:为什么用CNN识别MNIST值得认真做一遍

很多人在入门深度学习时会跳过MNIST直接上手真实场景,但我个人觉得MNIST仍然是学习CNN最合适的第一站。原因很简单:数据集小、标注准确、尺寸统一,一张图就28x28的灰度图,训练一次只要几分钟,跑在普通笔记本上也不会让你等得怀疑人生。更关键的是,通过MNIST可以把卷积、池化、全连接、损失函数、训练循环这些概念完整串起来,搞懂之后你再看其他图像分类任务,基本上就是换数据集、调网络结构的问题了。

我曾经带过一个项目组,组里三位同学都是第一次接触深度学习框架,我让他们先各自独立实现一个MNIST分类器,不要急着上现成模型库。一周之后,几个人对CNN的理解深度完全不一样:照着教程抄代码的人,遇到输入维度报错就发懵;而从卷积层参数一步步推导过来的人,能自己debug出问题在哪。差别就在于是否真正理解了数据从原始像素到最终分类分数的流动过程。

这篇文章我会按照我自己的学习路径来写:先说明CNN的核心设计思路,再用PyTorch从头搭建一个可运行的卷积网络,然后处理数据集加载、训练循环、批量训练细节,最后给出我踩过的一些坑和现成的排查方法。写的所有代码都是可以直接复制跑的,如果你有一张支持CUDA的显卡,速度会更快;即便只有CPU,整个流程也可以在几分钟内跑完一轮。

2. CNN核心思路:为什么卷积层能比全连接层更高效

2.1 全连接层的困境

假设我们直接用一个普通的全连接网络来做MNIST分类,输入一张28x28的灰度图片,最朴素的做法是把784个像素值全部展开成一维向量,然后接几个全连接层。理论上这个网络也能拟合训练集,但问题在于参数量大,而且没有利用图像的二维空间结构。图片里相邻像素之间往往有临接关系,一个数字“7”的横线和斜线在局部范围内才有意义,如果强行拉平,这些空间信息就丢了一部分。

更麻烦的是,把784维输入映射到隐藏层,比如隐藏层256个神经元,这个权重矩阵就已经是784x256,约20万个参数。再加几层,参数量会上百万。MNIST训练集本身就6万张图,勉强能撑住;但如果是250x250的彩色图,输入维度变成20多万,全连接层根本没法直接处理,训练速度和内存占用都会爆炸。

2.2 卷积层到底在做什么

卷积神经网络的设计思路很直接:用一个小尺寸的卷积核在图像上滑动,每次只看一个小局部窗口。比如3x3的卷积核,它一次覆盖9个像素,然后计算出该位置的一个新值。窗口从左到右、从上到下移动,最后得到一张新的特征图。每个卷积核相当于一个特征检测器,比如有的核专门检测横线,有的检测竖线,有的检测角点。

直觉上可以这样理解:图像上的特征是有局部性的,一个数字“1”的竖直笔画只影响图像中一小块区域,卷积核通过局部感受野捕捉这些模式,而不是像全连接层那样把整张图的所有像素做加权组合。多层卷积堆叠后,低层学习到边缘纹理,高层把边缘组合成部件甚至完整的数字形状。这种层次化特征提取是CNN的核心优势。

2.3 池化层的实际作用

池化层我一开始也觉得很玄,其实它就是降采样。常见的是2x2的最大池化,即在2x2的小块里取最大值,宽高各减半,参数量不增加。池化的意义在于:一来减少特征图尺寸,让后续计算量下降;二来提供一定的平移不变性,稍微移动几个像素,最大池化后的结果往往不会变化太大。对于MNIST这种明显居中的数字,池化不会带来质变,但它是CNN里非常常用的一环,学习时必须理解。

2.4 为什么CNN在MNIST上表现这么好

MNIST的识别本质是一个10类分类问题,数字内部的区分度较高,即使是简单的两层卷积加全连接,测试准确率也能轻松到99%以上。这个任务的难点早已不是“能不能识别”,而是“如何高效优雅地搭建一个完整流程”。我见过不少初学者把网络结构抄得很花哨,却忽略了数据的标准化、批处理、优化器选择这些细节,最终精度反而不如一个简单但配置正确的模型。真正决定模型能到多少精度的,往往不是网络深度,而是整个训练流程的工程化程度。

3. 环境准备与数据集加载:动手前的必修课

3.1 依赖安装和版本选择

我用的环境是Python 3.10 + PyTorch 2.x,这个搭配在目前的主流生态里非常稳定。安装PyTorch的时候要注意版本选择,直接打开PyTorch官网选择对应的操作系统和CUDA版本即可。如果你还没有安装CUDA版本的PyTorch,先确认自己的显卡驱动是否支持CUDA,不确定的话可以直接装CPU版本,先把流程跑通,后续再换GPU版本也不会影响代码结构。

环境准备的几个核心依赖如下:

pip install torch torchvision matplotlib

torch是主框架,torchvision里自带MNIST数据集和常用的数据变换工具,matplotlib用于画训练曲线和查看样本图片。网上有些教程还会让你装numpy,但PyTorch本身带了numpy相关的底层依赖,你直接import numpy一般也没问题,不过为了保险起见,还是可以显式安装。

3.2 数据集下载与transform配置

torchvision.datasets.MNIST会自动下载数据到指定目录。我第一次使用时非常担心网络问题,结果发现这个下载源一般都比较稳定。下载过程中最需要注意的是数据类型和transform。MNIST原本的数据范围是0到255的整数,如果直接喂给网络,数值幅度过大会让梯度更新变得很不稳定。标准做法是归一化到0到1,更推荐的是标准化到均值为0.1307、标准差为0.3081。这两个数值是MNIST整个数据集的像素统计值,直接拿来用就行。

数据预处理配置如下:

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图像或者numpy数组转换为Tensor,并且把值从0到255缩放到0到1。Normalize再进一步做标准化,让像素值分布接近零均值、单位方差。很多新手漏掉这一层,直接跑出来的模型往往收敛慢,甚至最终精度低一截。

3.3 DataLoader:批量训练的关键角色

PyTorch的DataLoader负责把数据集分成一批一批,同时在训练时进行打乱。shuffle=True表示每个epoch重新洗牌,避免模型记住每个批次的固定顺序,带来更好的泛化。batch_size的选择直接影响训练速度和显存占用。MNIST这种小图,设64或128都很常见。我用64,因为CPU训练时更稳定。

from torch.utils.data import DataLoader batch_size = 64 train_loader = DataLoader(dataset=train_dataset, batch_size=batch_size, shuffle=True) test_loader = DataLoader(dataset=test_dataset, batch_size=batch_size, shuffle=False)

有一个细节:测试集不需要shuffle,因为验证准确率不依赖数据顺序。如果shuffle也不会错,但会让每次跑验证集合的结果顺序不同,不利于调试时做对比,所以固定设置False。

4. 搭建CNN模型:网络结构设计与逐层详解

4.1 一个经典的小型CNN结构

网上有非常多的MNIST CNN结构,我建议从下面这个结构入手,因为它简单、好记,而且性能足够好:

import torch.nn as nn class SimpleCNN(nn.Module): def __init__(self): super(SimpleCNN, self).__init__() self.conv1 = nn.Conv2d(in_channels=1, out_channels=32, kernel_size=3, padding=1) self.relu1 = nn.ReLU() self.pool1 = nn.MaxPool2d(kernel_size=2, stride=2) self.conv2 = nn.Conv2d(in_channels=32, out_channels=64, kernel_size=3, padding=1) self.relu2 = nn.ReLU() self.pool2 = nn.MaxPool2d(kernel_size=2, stride=2) self.fc1 = nn.Linear(in_features=64*7*7, out_features=128) self.relu3 = nn.ReLU() self.fc2 = nn.Linear(in_features=128, out_features=10) def forward(self, x): x = self.pool1(self.relu1(self.conv1(x))) x = self.pool2(self.relu2(self.conv2(x))) x = x.view(x.size(0), -1) x = self.relu3(self.fc1(x)) x = self.fc2(x) return x

先分析一下输入输出维度。输入是(batch_size, 1, 28, 28),经过conv1保持尺寸不变(padding=1),得到(batch, 32, 28, 28),ReLU激活后MaxPool2d变为(batch, 32, 14, 14)。再经过conv2,通道从32变成64,尺寸保持14x14,池化后变成(batch, 64, 7, 7)。到这里,特征图已经很小了,接下来必须把多维张量展开成一维才能送入全连接层。

x.view(x.size(0), -1)这一行的意思是:保留batch维度,把后面所有维度展平成长向量。因为6477=3136,每个样本变成了3136维的一维向量。fc1把它映射到128维,最后fc2输出10维,对应0到9十个类别的分数。

4.2 为什么用padding=1

在卷积操作里,padding是指在图像外围补一圈0。不补的话,3x3卷积会把28x28的图变成26x26。如果后续结构里想精确计算每层尺寸会很麻烦,而且多次卷积后特征图缩得特别快。补一圈0之后,输出尺寸和输入尺寸保持一致,这样在搭建更多层时,你只需要关心通道数变化,不用反复算尺寸,这个习惯在搭建更深网络时非常省心。

4.3 ReLU的必要性

ReLU是现在用得最多的激活函数,公式很简单:输入大于0就原样输出,小于等于0就输出0。它的好处是计算快,并且能缓解梯度消失问题。在全连接层之间如果不加ReLU,多个线性层堆叠还是线性变换,无法拟合复杂函数。CNN的卷积层和全连接层之间都需要非线性激活,这个顺序千万不能搞反。有些框架中Conv2d后面可以不显式写激活层,但PyTorch里我建议显式定义,让网络结构一目了然。

4.4 感受一下模型参数量

训练之前先统计一下模型参数,有助于了解模型规模。在代码里这样看:

model = SimpleCNN() total_params = sum(p.numel() for p in model.parameters()) print(f"Total parameters: {total_params}")

这个模型大概有120万参数。相比全连接网络动辄几千万参数,CNN已经非常轻量了。但120万参数对于MNIST来说依然有些冗余,如果你想验证一下什么是“轻量”,可以试试把卷积通道数减半,比如用16和32,精度几乎不会下降太多,参数量却能减少四倍多。这也是一个很好的小实验。

5. 训练过程详解:从损失函数到参数更新的完整循环

5.1 选择交叉熵损失函数

分类任务最常用的损失函数是交叉熵。PyTorch里直接调用nn.CrossEntropyLoss()即可。这个函数内部其实做了两件事:一是对模型输出的原始分数做softmax归一化成概率,二是计算预测概率与真实标签之间的交叉熵。所以不需要自己在模型最后一层加softmax。

交叉熵公式理解起来不复杂,给定真实类别y和预测概率p,损失是-log(p_y)。也就是说,模型给正确类别的概率越低,损失越大。这样优化器就会推动模型不断提高正确类别上的置信度。MNIST有10个类别,每个样本属于一个类别,这个分类设定非常适合交叉熵。

5.2 优化器选择:Adam还是SGD

我推荐初学者用Adam优化器,因为它自带自适应学习率,在多数情况下不需要频繁调参,收敛速度也快。学习率默认1e-3就能跑得不错。不过如果你是严谨派,想充分体验训练过程,SGD加动量也是很好的选择。SGD需要手动调节学习率,常见起步值是0.01,配合momentum=0.9。很多老手会告诉你SGD最后的泛化能力往往比Adam稍好一点,但差距在MNIST上其实很不明显。我实际测试过Adam和SGD,分别跑10轮,最终准确率都能到99%左右,没必要在这里过度纠结。

5.3 训练循环的标准写法

下面的代码是每个epoch里遍历所有训练批次并更新参数的完整流程:

import torch import torch.optim as optim device = torch.device('cuda' if torch.cuda.is_available() else 'cpu') model = SimpleCNN().to(device) criterion = nn.CrossEntropyLoss() optimizer = optim.Adam(model.parameters(), lr=0.001) epochs = 5 train_loss_history = [] for epoch in range(epochs): model.train() running_loss = 0.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() running_loss += loss.item() * images.size(0) epoch_loss = running_loss / len(train_loader.dataset) train_loss_history.append(epoch_loss) print(f"Epoch {epoch+1}/{epochs}, Loss: {epoch_loss:.4f}")

每一步的逻辑我分别说明一下。optimizer.zero_grad()必须放在前向传播之前,作用是清空上一轮留下的梯度。如果不做这一步,PyTorch默认会累加梯度,导致梯度值越来越大,训练直接发散。这是个非常经典的坑。

loss.backward()根据当前损失计算模型里所有参数的梯度,计算图会自动释放。optimizer.step()用优化器更新参数。整个过程是标准的三步:清旧梯度、算梯度、更新参数。

5.4 训练过程中的model.train()和model.eval()

这两行代码很容易被忽略,但确实有实际作用。model.train()会启用BatchNorm的统计信息更新,以及Dropout的随机失效机制;model.eval()则关闭这些机制,保证验证时的结果可复现。我们当前这个SimpleCNN里即没有BatchNorm也没有Dropout,所以两者看起来没有差别,但一旦模型复杂起来,漏掉model.eval()会导致验证结果时好时坏,让人误以为是模型随机性大。

6. 测试与准确率评估:模型到底学会了什么

6.1 测试循环实现

训练结束后,我们需要在完全没有参与训练的测试集上评估模型。MNIST测试集有1万张图,足够真实反映泛化能力。测试阶段要关闭梯度计算,使用torch.no_grad()上下文管理器,减少内存占用并加速运行。

def evaluate(model, dataloader, device): model.eval() correct = 0 total = 0 with torch.no_grad(): for images, labels in dataloader: images, labels = images.to(device), labels.to(device) outputs = model(images) _, predicted = torch.max(outputs, 1) total += labels.size(0) correct += (predicted == labels).sum().item() accuracy = 100.0 * correct / total return accuracy test_acc = evaluate(model, test_loader, device) print(f"Test Accuracy: {test_acc:.2f}%")

torch.max(outputs, 1)返回每一行最大值和对应的索引。索引就是预测类别。如果某一行是[-1.2, 3.4, -0.5, ...],最大值是3.4且出现在第1列,那么预测结果就是类别1。对比predicted和labels,得到一个布尔张量,sum()数出其中True的数量,就是预测正确的样本个数。

我跑这个网络5个epoch,测试准确率通常到98.8%附近;跑到10个epoch可以到99.4%左右。如果你发现自己的准确率在99%上下波动,那说明流程没问题,再往上提就需要靠数据增强或者调整网络结构了。

6.2 查看分类错误的样本

准确率只是冷冰冰的数字,真正有用的做法是挑出模型预测失败的样本,看看到底是哪些图片让模型犯难。很多时候这些错误样本本身对人也具有一定挑战性,比如手写体过于潦草,或者图像噪声较大。这里给一段查看错误样本核心逻辑的代码:

import matplotlib.pyplot as plt model.eval() errors = [] with torch.no_grad(): for images, labels in test_loader: images, labels = images.to(device), labels.to(device) outputs = model(images) preds = torch.max(outputs, 1).indices for i in range(len(labels)): if preds[i] != labels[i]: errors.append((images[i].cpu(), labels[i].item(), preds[i].item())) if len(errors) >= 5: break if len(errors) >= 5: break fig, axes = plt.subplots(1, 5, figsize=(12, 3)) for i, (img, label, pred) in enumerate(errors): axes[i].imshow(img.squeeze(), cmap='gray') axes[i].set_title(f"label:{label}, pred:{pred}") axes[i].axis('off') plt.show()

通过这个可视化,我遇到过好几次很有意思的情况:“3”被识别成“8”,“7”被识别成“1”,“9”被识别成“4”。仔细看样本会发现有些图写得确实凌乱,人眼都不一定能看清。这并不代表模型出了严重问题,而是说明MNIST里确实存在模糊样本,任何模型都不可能做到百分之百准确。

6.3 绘制损失曲线用以判断收敛状态

训练过程中打印的Loss数值很难直接看出趋势,画个曲线更直观。把每个epoch的损失记录下来,训练结束后画出来:

plt.plot(range(1, epochs+1), train_loss_history, marker='o') plt.xlabel('Epoch') plt.ylabel('Training Loss') plt.title('Training Loss Curve') plt.show()

正常曲线应该快速下降然后逐渐变平。如果你发现Loss在后期出现反弹,说明学习率太高,模型在最优点附近震荡;如果Loss下降得非常慢,可能是学习率太低,或者数据没有标准化。MNIST这个任务一般不会出现太离谱的曲线,一旦出现异常,迅速从学习率和数据预处理两个方向排查即可。

7. 常见问题与调试实录:那些不跑一遍绝对遇不到的坑

7.1 维度不匹配报错

新手经常会遇到类似Expected input batch_size to match target size这样的报错,原因通常是模型最后输出10维,而标签是单个数,看起来匹配不上,实际上是对批维度理解有误。常见原因是输入时忘了把图片从(batch, 1, 28, 28)展平直接送入全连接层。解决方案就是检查forward函数里是否执行了view操作。我在第一次搭建时就把view放到了卷积层之前,导致后续全连接层收到了四维张量,报错非常迷惑。后来习惯在forward开头先打印x.shape,调试起来快很多。

7.2 训练Loss变成NaN

NaN问题一般有三个来源:学习率太大导致梯度爆炸、数据出现异常值、损失函数不匹配。MNIST标准化后数据范围固定,不太会出现异常值。最常见的是学习率设置偏高,特别是Adam虽然自适应,但初始学习率超过1e-2也可能发散。建议从1e-3起步,如果看到NaN,先把学习率降到1e-4,一般就能恢复正常训练。还有一种情况是模型在最后一层使用了softmax之后又接CrossEntropyLoss,导致数值不稳定,CrossEntropyLoss内部已经包含了softmax,不用重复加。

7.3 CPU训练太慢怎么办

如果设备只有CPU,也不要着急。MNIST图片小,5轮训练最多也就几分钟。可以把batch_size调成128或者256,减少批次数量,训练速度会有明显提升。再一个技巧是torch.backends.mkl.enabled = True,Intel CPU上开启MKL后卷积运算更快,但MKL通常默认就是开启的。真正想加速的话,减少每层的卷积通道数或层数,对MNIST影响不大,速度却可以快不少。

7.4 验证准确率一直不增长

这种情况在使用SGD时比较常见:学习率设成了0.0001,太低了,网络几乎学不到东西。处理方法是先用Adam+1e-3试跑3轮,看Train Loss有没有明显下降。如果Adam下正常,说明代码没问题,只是优化器参数需要调整。另外还要确认数据预处理是否正确,比如标准化参数写反了,像素值变成负的大数,模型也会难以收敛。

7.5 过拟合的几个信号

MNIST本身数据集足够大,用我们这个小网络很难过拟合。但如果你使用更大的模型,比如把卷积通道换成128、256,训练Loss可以降到很低,而测试准确率反而停滞甚至下降,这就是过拟合信号。解决办法是增加Dropout层、减少网络容量或者加入数据增强。MNIST上最常用的数据增强是随机平移几个像素,这个操作简单有效。比如在transform里加入transforms.RandomAffine(degrees=10, translate=(0.1, 0.1)),可以让模型对位置偏差更鲁棒。

8. 从MNIST出发:后面还能继续做什么

8.1 调整网络结构做对比实验

跑通一个CNN之后,最有价值的动作是做对比实验。把卷积核从3x3换成5x5,把池化换成stride=2的卷积,把ReLU换成LeakyReLU,每改动一个变量就记录一次准确率变化。这个过程能让你直观理解每个组件对模型性能的影响。我当时做了三组对比:有无padding、是否使用池化、全连接层中间维度是64还是128,结果发现去掉池化会让模型参数变大但准确率几乎不变,说明在MNIST上池化更多是节省计算量。

8.2 迁移到其他数据集

把代码迁移到Fashion-MNIST只需要替换数据集名称。Fashion-MNIST也是28x28灰度图,但是衣物分类,比数字识别更有挑战性。直接同样网络结构跑一遍,准确率会在92%到94%之间,通过对Fashion-MNIST的调优,你会更清楚什么时候需要增加模型容量。CIFAR-10是32x32彩色图,需要把第一层卷积的in_channels改成3,也基本能跑,但准确率不会太高,因为网络结构确实偏浅了。

8.3 记录实验笔记的习惯

我建议从现在开始就用一个表格记录每次实验的配置和结果,比如网络层数、卷积核大小、学习率、batch_size、最终准确率。别小看这个习惯,等你后续实验做多了,就会发现很多参数之间互相影响,单靠脑子记根本分不清哪个改动带来了提升。表格记录方式可以很简单,用Excel或者Markdown都行,但务必包含“改动点”这一列,方便回溯。

我自己后来做复杂项目时,最感谢的就是早期老老实实记笔记的习惯。像batch_size从64调到128之后,准确率下降了0.2%,如果不记录,我根本不会注意到这其实是学习率没有相应调整的结果,只会觉得模型结构有问题,然后在错误的方向上浪费大量时间。

最后再分享一点个人经验

这篇文章里所有代码,我都是按照自己从零复现时最合适的顺序写出来的。如果你现在正在学习,不要一次性把代码全部看完就直接跑,建议手动重打一遍核心模型类,边打边在注释里写出每个张量经过每层之后的shape。这个过程对理解CNN的实际数据流非常有帮助。

我第一次跑这个模型时,因为对PyTorch的device机制不熟悉,忘了把模型和数据都放到cuda上,结果程序报错提示“input and parameter must be on the same device”,当时还以为是电脑坏了,后来才发现是cpu和gpu不一致的问题。这类小问题其实每个初学者都会遇到,只要你记住一个原则:模型在哪个设备上,数据就必须在哪个设备上,见一次就能彻底避开。

MNIST只是一个起点,但它足够让你把深度学习从纸上谈兵变成手上练兵。跑通之后,再回头看那些复杂的网络结构,你会发现它们不过是卷积、激活、池化、全连接这些基础模块的不同组合方式。基础打牢了,后面迎接更复杂任务的底气就完全不一样了。

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

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

立即咨询