如果你刚开始学深度学习,那 CNN + PyTorch + MNIST 这套组合,基本是绕不开的第一课。MNIST 就是深度学习界的“Hello World”,用卷积神经网络在 PyTorch 里训练一个手写数字识别模型,几乎每个做 AI 的从业者都亲手跑过一遍。这篇文章不是我拿现成 Demo 糊弄你,而是我自己踩过无数坑之后的完整复盘,从“为什么图像要用 CNN 而不能用普通神经网络”这类基础问题,到环境安装、数据下载 404、模型定义、训练调参、特征图可视化,一条线全串起来。
我会把代码全部贴出来,并且逐段讲清楚“这一行到底在干嘛、为什么这么写”。这篇文章适合三种人看:第一次接触 CNN 的初学者;已经会调库但不太清楚内部原理的“调包侠”;以及想复现一个干净、易读、能直接跑通的 MNIST 实战项目的同学。
1. 内容整体设计与思路拆解
1.1 为什么图像要用 CNN 而不是普通全连接网络
这个问题几乎每个人初学的时候都会问,也是面试高频题。要理解这件事,你得先回到数据本身。一张 28x28 的灰度 MNIST 图片,本质上是一个 28x28 的矩阵,展开以后是 784 个像素值。如果我们用普通的前馈神经网络来处理,第一件事就是把 784 个像素“拍扁”成一维向量输入。
问题是,图像的空间信息在这个“拍扁”的过程中被破坏了。手写数字“2”和“3”的区别,很大程度体现在笔画的局部形状、边缘方向、相对位置上。普通神经网络默认每个输入特征之间是独立的,它很难学到“这 3 个像素组合起来是一条竖线”这种局部模式,因为它没有“相邻像素”的概念。
更致命的是参数爆炸。假设输入是一张 128x128 的彩色图,展平后就是 128x128x3 = 49152 维,你哪怕设置第一个隐藏层只有 1024 个神经元,这一层的参数就超过 5000 万。在 MNIST 这种小图上还好,一旦到了真实图像任务,全连接网络在计算上基本是不可行的。
CNN 的做法完全不同。它有两个核心机制:局部连接和权值共享。卷积核每次只看图片的一个小区域,比如 3x3,这就叫局部感受野;这个 3x3 的卷积核滑过整张图片时,参数是同一份,这就叫权值共享。用大白话说,全连接网络像派一群人去检查整张照片的每个像素,而 CNN 像分工协作——有人专门看边缘,有人专门看纹理,最后把结果汇总。这个思路非常贴近人类视觉皮层的处理方式。
1.2 卷积层、池化层、全连接层:三层结构各干什么
经典的 CNN 分类网络通常由三类层组成:卷积层负责提取特征,池化层负责降维,全连接层负责分类决策。
卷积层是怎么工作的?你可以把卷积核想象成一个小手电筒,在图片上从左到右、从上到下地扫过。每次照到 3x3 的区域,就把这个区域里的像素值和卷积核的权重做加权求和,得到一个输出值。一个卷积核只能提取一种模式,比如“竖直边缘”或者“左上角到右下角的斜线”。所以我们通常会放很多个卷积核——在下面的代码里,第一层放了 32 个,第二层放了 64 个,让网络自己从数据里学出这些模式。
池化层最常用的是最大池化。它的操作很简单,把 2x2 的区域中最大的那个值取出来,图片尺寸直接减半。为什么要这么做?一方面降低计算量,另一方面也是在告诉你:“这个局部区域里最明显的特征保留下来就够了,具体位置稍微平移一点没关系。”这就给模型带来了平移不变性。
最后是分类层。卷积和池化把原始图片逐步变换成一组更抽象的特征图,但最终我们要输出 0-9 这 10 个数字各自的概率。全连接层把特征图展平成一个长向量,再通过几层线性变换映射到 10 个输出上。在 PyTorch 里这一步就是 Flatten 加 Linear。
这张表格是我自己整理过很多次的最直观流程,输入一张 28x28 的灰度图后每一层的形状变化:
| 层级 | 操作 | 输出形状 |
|---|---|---|
| 输入 | 原始图片 | (1, 28, 28) |
| 卷积层1 | Conv2d(1, 32, kernel=3, padding=1) + ReLU | (32, 28, 28) |
| 池化层1 | MaxPool2d(2) | (32, 14, 14) |
| 卷积层2 | Conv2d(32, 64, kernel=3, padding=1) + ReLU | (64, 14, 14) |
| 池化层2 | MaxPool2d(2) | (64, 7, 7) |
| 展平 | Flatten | (3136) |
| 全连接1 | Linear(3136, 128) + ReLU | (128) |
| 输出层 | Linear(128, 10) | (10) |
2. 动手之前:环境搭建与 MNIST 数据的避坑记录
2.1 版本选择:CPU 够不够用,GPU 怎么选
MNIST 是灰度小图,单张只有 28x28,CPU 训练一个上面这种规模的 CNN,10 个 epoch 大约也就十分钟级别。如果你只是学习,先装 CPU 版本完全没问题,跑通了再考虑 GPU 也不迟。
安装 PyTorch 我用得最顺的是在 Anaconda 里建一个独立环境,避免污染基础的 Python。假设你电脑没有 NVIDIA 显卡,或者暂时不想折腾 CUDA,直接执行:
conda create -n pytorch python=3.10 -y conda activate pytorch pip install torch torchvision --index-url https://download.pytorch.org/whl/cpu如果你确定要用 GPU,先去官网的 Get Started 页面选好系统、包管理方式、CUDA 版本,然后把生成的命令复制执行。我见过太多人卡在“安装完以后导入 torch 报错”这一步,绝大多数情况是 CUDA 版本和显卡驱动不匹配。一个很实用的判断方法:在命令行输入nvidia-smi,看右上角支持的 CUDA 版本,然后安装那个版本号以下的 PyTorch 都没问题,不需要非得装最新。
2.2 Windows 上的经典报错:WinError 1114 怎么解决
如果你在 Windows 上安装 PyTorch 后,第一次import torch就报OSError: [WinError 1114] 动态链接库(DLL)初始化例程失败,别慌,这是个非常经典的坑。这个报错指向的是torch\lib\c10.dll加载失败,几乎和你的代码无关。
我实际排查过几次,最终确认主要诱因有两类:一类是系统缺少 Microsoft Visual C++ Redistributable 运行库,去微软官网下载最新版安装就能解决;另一类是 Python 版本和 PyTorch 版本兼容性问题,比如 Python 3.11 配某些早期 PyTorch 版本就会出问题。解决办法是换到 Python 3.10,或者把 PyTorch 升级到新版。
还有一个细节特别容易忽略:如果你用的是 Anaconda 环境,装完包以后要确认当前环境真的激活了,否则pip install可能装到了 base 环境,代码里 import 的却是 pytorch 环境下的包,版本对不上也会报莫名其妙的错。
2.3 torchvision 下载 MNIST 404 的终极解决方案
这个坑几乎每个用 torchvision 的人都会遇到。你用torchvision.datasets.MNIST(... , download=True)时,控制台突然报 404 错误,或者 gibberish/not a gzipped file 之类的异常。原因是 torchvision 默认下载地址指向了 MNIST 原始官网,但这个站点的资源经常迁移或者不稳定,旧版本 torchvision 的 URL 还停留在老地址,自然就 404 了。
我在项目里最稳妥的做法是手动下载数据文件。去 MNIST 官网找到那四个 gz 压缩包,直接下载后,放到项目目录下的data/MNIST/raw/文件夹里。注意文件名要保持原样:train-images-idx3-ubyte.gz、train-labels-idx1-ubyte.gz、t10k-images-idx3-ubyte.gz、t10k-labels-idx1-ubyte.gz。
只要这四个文件在位,datasets.MNIST加载数据时就不会再去下载,你也可以把download参数设为True也不会反复触发网络请求。这个方法比改代码去换镜像源要可靠得多,尤其适合网络环境不太稳定的场景。
3. 核心代码逐段解析:用 PyTorch 从零写一个 CNN 训练 MNIST
3.1 项目文件怎么组织:结构清晰比什么都重要
写深度学习代码,最忌讳把所有内容堆在一个文件里。训练逻辑、模型定义、数据加载混在一起,前期跑通可能觉得很爽,但你想改个网络结构、换个数据集的时候,改起来非常痛苦。我习惯的项目结构很简单:
mnist_cnn/ ├── data/ # MNIST 数据存放目录 ├── model.py # 模型定义 ├── train.py # 训练和测试主脚本 └── mnist_cnn.pth # 训练完成后保存的权重文件这个划分足够精简,而且以后扩展到其他图像分类任务,只需要改model.py和数据加载部分。下面我把两个文件的代码都贴出来,然后拆开讲。
3.2 模型定义:这一层为什么要这么写
model.py的完整内容如下:
import torch.nn as nn class CNN(nn.Module): def __init__(self): super().__init__() self.features = nn.Sequential( nn.Conv2d(1, 32, kernel_size=3, padding=1), nn.ReLU(inplace=True), nn.MaxPool2d(2), nn.Conv2d(32, 64, kernel_size=3, padding=1), nn.ReLU(inplace=True), nn.MaxPool2d(2), ) self.classifier = nn.Sequential( nn.Flatten(), nn.Linear(64 * 7 * 7, 128), nn.ReLU(inplace=True), nn.Dropout(0.5), nn.Linear(128, 10), ) def forward(self, x): return self.classifier(self.features(x))第一层Conv2d(1, 32, kernel_size=3, padding=1)的 1 表示输入通道数——MNIST 是灰度图,所以是 1;如果是彩色图,这里就要改成 3。32 是输出通道数,也就是说我们用 32 个不同的卷积核去提取特征。padding=1的妙处在于,3x3 卷积核扫过图像后,输出尺寸和输入保持一致,这样特征图的高宽不会因为卷积而缩小,对浅层网络来说能保留更多边界信息。
两个卷积池化块之后,特征图从 28x28 变成 14x14,再变成 7x7。64 * 7 * 7是展平后的向量长度,这张图我在前面表格里推过一遍,所以你可以对照着看代码。最后一个Linear(128, 10)输出的是 10 个类别的 logits。
特别说明一下,为什么最后没有加 Softmax 层?因为 PyTorch 的CrossEntropyLoss已经内部包含了 Softmax 计算,你直接拿模型输出的原始 logits 算损失就行,加了反而容易出精度问题。
3.3 训练主脚本:数据加载、训练循环、评估一气呵成
train.py的代码看起来多一点,但每一块都是必需的:
import torch import torch.nn as nn from torch.utils.data import DataLoader from torchvision import datasets, transforms from model import CNN device = torch.device("cuda" if torch.cuda.is_available() else "cpu") transform = transforms.Compose([ transforms.ToTensor(), transforms.Normalize((0.1307,), (0.3081,)) ]) train_data = datasets.MNIST(root="./data", train=True, download=True, transform=transform) test_data = datasets.MNIST(root="./data", train=False, download=True, transform=transform) train_loader = DataLoader(train_data, batch_size=64, shuffle=True, num_workers=0) test_loader = DataLoader(test_data, batch_size=256, shuffle=False, num_workers=0) model = CNN().to(device) criterion = nn.CrossEntropyLoss() optimizer = torch.optim.Adam(model.parameters(), lr=1e-3) def evaluate(): model.eval() total, correct = 0, 0 with torch.no_grad(): for images, labels in test_loader: images, labels = images.to(device), labels.to(device) outputs = model(images) preds = outputs.argmax(dim=1) total += labels.size(0) correct += (preds == labels).sum().item() return correct / total if __name__ == "__main__": epochs = 10 for epoch in range(1, epochs + 1): 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) avg_loss = running_loss / len(train_data) acc = evaluate() print(f"Epoch {epoch:02d} | Loss {avg_loss:.4f} | Test Acc {acc:.4f}") torch.save(model.state_dict(), "mnist_cnn.pth")数据预处理部分有两个重点。ToTensor()会把 PIL 图片转换成张量,并且把像素值从 0-255 缩放到 0-1 区间。Normalize((0.1307,), (0.3081,))用的是 MNIST 官方统计出来的均值和标准差,它把数据从 0-1 区间转成均值为 0、标准差为 1 的分布,这样梯度传播更稳定,模型收敛也更快。很多初学者会在这里偷懒不写 Normalize,我请你千万不要省,这个细节真的会影响最终效果。
训练循环里的model.train()和evaluate里的model.eval()也要特别留意。Dropout 层在训练和预测时行为不同,train()模式下会随机丢弃一部分神经元,eval()模式下则全部保留。所以评估准确率之前必须切换到 eval 模式,否则结果会不稳定。
3.4 训练一个模型实际看到的结果
我在 CPU 环境下跑这个代码,大概每个 epoch 需要 1-2 分钟,10 个 epoch 总共十几分钟。打印出来的曲线大致是这个趋势:
Epoch 01 | Loss 0.3208 | Test Acc 0.9287 Epoch 02 | Loss 0.1452 | Test Acc 0.9664 Epoch 03 | Loss 0.1023 | Test Acc 0.9751 Epoch 04 | Loss 0.0774 | Test Acc 0.9807 Epoch 05 | Loss 0.0622 | Test Acc 0.9833 Epoch 06 | Loss 0.0516 | Test Acc 0.9851 ... Epoch 10 | Loss 0.0278 | Test Acc 0.9918第一个 epoch 就能到 92% 左右,后面稳步爬升到 99% 以上,这个结果在 MNIST 上是非常正常的。如果你跑出来的准确率明显偏低,大概率不是模型问题,而是数据预处理或者训练参数出了问题。
4. 训练参数的选择与实验观察:调参不是玄学
4.1 batch_size、学习率、epochs 到底该怎么选
我给的这套参数并不是随便拍的,每个都有它的道理。
batch_size=64是在稳定性和训练速度之间比较好的折中点。batch 太小,梯度震荡厉害;batch 太大,一个 epoch 的更新次数少,收敛变慢,而且对内存显存要求高。MNIST 训练集有 60000 张图片,64 张一个 batch,一个 epoch 就是 938 个 batch,这个更新频率对 Adam 优化器来说非常舒服。
学习率我选了1e-3,这是 Adam 优化的默认值,也是经过大量任务验证的“黄金起步点”。如果你发现训练一开始损失就剧烈震荡不下降,多半是学习率太大;如果损失下降得像蜗牛一样慢,可以往3e-4或5e-4调。我之前试过用1e-2去训,结果损失曲线像个电锯来回横跳,最后收敛到 90% 都费劲。
epochs=10对 MNIST 够用了。这个数据集的识别难度本身不大,10 个 epoch 足够让模型在测试集上到 99%。继续多训几个 epoch 不是不行,但收益很小,还可能开始过拟合。
4.2 怎么判断模型有没有过拟合
只盯着测试集准确率看是不够的,你应该同时观察训练集上的损失和准确率。这里有一个我常用的经验判据:如果训练集准确率已经冲到了 99.5%,而测试集准确率只有 97%,两者差距越拉越大,那就说明模型开始“背题”了——它记住了训练集里特有的噪声模式,而不是真正的泛化规律。
想更早发现过拟合,可以在代码里把训练集切一部分出来当验证集,比如用random_split把 60000 张训练图分成 54000 张训练、6000 张验证,每个 epoch 结束都在验证集上测一次。一旦发现验证集准确率连续几个 epoch 不涨、训练集准确率还在往上走,就可以停了。这也解释了我模型里为什么加Dropout(0.5)——它就是专门用来缓解过拟合的,训练时随机砍掉一半全连接层的神经元,让网络不能过度依赖某几个节点。
4.3 用小实验理解数据量和模型能力的关系
学到这里,我可以给你一个更有感觉的实验思路:把训练数据从 60000 张减少到 1000 张,其他参数全不动,再跑一遍训练,你猜测试准确率会掉到多少?我自己试过,大概只能到 85% 左右。这说明数据量是模型表现的天花板,无论你把网络调得多花哨,喂进去的信息不够,模型就学不出完整的规律。
反过来,我们把模型缩小——比如把两个卷积层的通道数从 32/64 改成 16/32——准确率可能只掉零点几个百分点,但训练速度能快小一倍。这就是模型容量和数据规模的权衡。初学者很容易产生“网络越大越深越好”的错觉,但实际上能解决当前问题的模型才是好模型,这个实验越早做,越能帮你建立正确的感觉。
5. 常见问题与排查技巧实录
5.1 数据下载、加载与 Windows 环境相关报错
| 报错/现象 | 可能原因 | 解决办法 |
|---|---|---|
| torchvision 下载 MNIST 时 404 | 旧版 torchvision 下载地址失效 | 手动下载四个压缩包放到data/MNIST/raw/;或升级 torchvision |
OSError: [WinError 1114],c10.dll 加载失败 | 缺少 VC++ 运行库,或 Python/PyTorch 版本冲突 | 安装 Visual C++ Redistributable;换 Python 3.10;升级 PyTorch |
Windows 下num_workers设大后程序卡死或报错 | Windows 的 DataLoader 多进程兼容性问题 | 把num_workers设为 0 |
| 下载很慢,半天不动 | 默认源访问不稳定 | 手动下载到本地再加载,避免反复请求 |
| batch 太大致使内存/显存溢出 | 单个 batch 的中间变量占用过高 | 尝试减小 batch_size 到 32 或 16 |
5.2 训练效果不对时的排查顺序
如果你的模型准确率不正常,别急着加 Dropout、改网络结构,先按这个顺序排查:第一步,打印一两张训练图片,确认图像没有变形、标签没有错位;第二步,尝试用小批量数据,比如 100 张图,跑 5 个 epoch,看训练损失能不能降下去;第三步,检查数据预处理的 Normalize 是否生效,均值和标准差写没写反;第四步,确认模型最后没有多余加 Softmax,避免和CrossEntropyLoss冲突。
我见过不止一个人,折腾了半天发现问题是数据加载时transform忘加ToTensor(),图像以 PIL 格式直接进了网络。你可以打印一个 batch 的images.shape和dtype来快速验证,正常情况应该是torch.FloatTensor的形状(64, 1, 28, 28)。这比盯着代码看半天有效得多。
5.3 进阶技巧:把卷积层“打开”看看模型学到了什么
训练完成后,你可以把第一层卷积的输出可视化一下,这一步对建立直觉非常有帮助。我在模型进入 eval 模式后,取一张测试图片,把它喂进model.features[0],然后画出卷积结果的灰度图:
import matplotlib.pyplot as plt model.eval() with torch.no_grad(): sample, _ = test_data[0] sample = sample.unsqueeze(0).to(device) feat = model.features[0](sample).squeeze(0).cpu() fig, axes = plt.subplots(1, 5, figsize=(12, 3)) for i in range(5): axes[i].imshow(feat[i], cmap="gray") axes[i].axis("off") plt.show()你会看到前几个卷积核的输出很像边缘检测器——有些把数字的竖直轮廓提了出来,有些关注横向笔画,有些对斜线敏感。这就是 CNN 提取特征的直观证据,也是全连接网络很难给出的解释。第二层卷积的输出会更抽象,已经把边缘组合成了类似“数字部件”的特征。看到这些,你才算真正“看到”了 CNN 的内部状态。
关于 MNIST 项目的扩展,我最后再补一句个人经验:当你完整复现过这个项目之后,下一个建议尝试的方向是把这个模型迁移到 Fashion-MNIST 上——它依然是 28x28 的灰度图,数据加载方式几乎一样,但识别难度明显更高,你会遇到一些 MNIST 上遇不到的问题,比如类别之间的视觉相似性,这对你调整模型和超参数的帮助很大。