刚开始学手写数字识别 CNN 模型的时候,我以为最麻烦的部分在卷积层怎么设计、梯度怎么回传。结果真正动手第一天就被数据集卡住了——torchvision 下载 MNIST 一直报 404,进度条走到一半直接失败,重试三次都一样。后来把问题彻底查清楚,我发现这个 404 本身就是特别好的入门教材:它逼着你去看 MNIST 数据集的真实结构、torchvision 的下载逻辑、数据在本地到底长成什么样。
这篇文章我不想只贴一份能跑通的代码。我想按实际踩坑的顺序,把 MNIST 手写数字识别这个 CNN 项目的完整流程重新走一遍:从数据集下载失败开始,到 CNN 结构为什么这样设计、PyTorch 代码怎么实现、最后再讲清楚训练时的参数经验和从 MNIST 走向真实视觉任务的扩展思路。适合刚开始学深度学习的读者,也适合那些想回归经典项目验证自己工具链的人。
1. torchvision 下载 MNIST 报 404:第一个真正需要自行解决的坑
1.1 404 到底是谁返回的
torchvision.datasets.MNIST 在download=True时的行为大致是这样的:先检查root/MNIST/processed/下有没有training.pt和test.pt,如果没有,就去检查root/MNIST/raw/下有没有四个gz文件;再没有的话,才开始从内置地址下载。很多人以为 404 是自己代码写错了,其实不是,问题出在它内置的那几个下载链接已经失效了。
而且这个报错很有迷惑性,它不一定是第一个文件就失败。下载过程会按顺序请求图片和标签文件,如果前面的文件访问正常、后面的文件路径变了,你会看到前一半下载成功,后一半突然抛HTTPError: 404 Not Found。网上能看到大量这个问题,集中在 2020 年到 2024 年之间,基本原因都一样:老版本 torchvision 指向的 MNIST 托管地址发生了变化,旧链接没有做重定向。你本地实际上没有拿到完整数据,程序自然跑不下去。
1.2 我的排查链路
遇到这个问题,我建议不要直接搜答案,先自己走一遍排查流程,以后遇到类似数据源失效的问题就都会处理了。
第一步,完整看报错堆栈。找到最底层的异常类型,确认是urllib.error.HTTPError: HTTP Error 404: Not Found,说明服务器能访问,但资源不存在,不是网络不通、也不是被墙。
第二步,打印 torchvision 内置的下载地址。在 Python 里看一下datasets.MNIST类的源码,找到urls或者mirrors属性,你会看到几个 URL 模板,把模板和文件名拼出来就是实际请求地址。
第三步,手动用 curl 或浏览器访问这个地址。直接在终端里执行curl -I "<完整地址>",返回 404 就实锤了:这个固定外链已经失效。
第四步,去 torchvision 的更新记录里查下载源有没有变更。我当时看到新版源码里已经把下载地址切到了对象存储服务镜像,理解了老版本不维护之后,解决办法就很清楚了:要么升级 torchvision,要么手动把文件放到raw/目录下,让下载逻辑认为数据已经存在。
这个链路不是 MNIST 专属,任何数据集加载失败都可以按这个思路排查:先看异常类型,再确认 URL,再验证本地目录结构。
1.3 绕过 404 的三种落地方案
方案 A:手动下载 gz 文件到本地。需要四个文件,文件名的标准格式是:
train-images-idx3-ubyte.gztrain-labels-idx1-ubyte.gzt10k-images-idx3-ubyte.gzt10k-labels-idx1-ubyte.gz
把没解压的gz文件放到项目目录下的data/MNIST/raw/中,然后照常调用:
from torchvision import datasets datasets.MNIST(root='./data', train=True, download=True)torchvision 看到raw/目录下已经有对应的gz文件,就不会再发起网络请求,而是直接解压并生成processed/下的缓存文件。如果你的 torchvision 版本较旧,可能在解压后还会尝试下载一次,这时也可以直接把别人环境里已经生成好的training.pt和test.pt复制到自己的processed/目录,绕过得更彻底。
方案 B:升级 torchvision 到较新版本。新版源码已经把下载源切到新的镜像地址,pip install -U torchvision大概率能解决。不过要注意 torchvision 和 PyTorch 的版本配套关系,升级前先确认你当前的 PyTorch 版本,避免把整个环境搞崩。
方案 C:完全不依赖 torchvision,直接用原生 Python 读取 idx 格式。下面这段代码可以读取 gz 压缩的原始文件:
import gzip import numpy as np def read_images(path): with gzip.open(path, 'rb') as f: data = f.read() return np.frombuffer(data, dtype=np.uint8, offset=16).reshape(-1, 28, 28) def read_labels(path): with gzip.open(path, 'rb') as f: data = f.read() return np.frombuffer(data, dtype=np.uint8, offset=8) train_images = read_images('data/MNIST/raw/train-images-idx3-ubyte.gz') train_labels = read_labels('data/MNIST/raw/train-labels-idx1-ubyte.gz') print(train_images.shape, train_labels.shape) # (60000, 28, 28) (60000,)把数据读成 NumPy 数组后,无论是自己转成 Tensor 还是直接喂给别的框架,都很自由。
提示:记住一个关键区别——
raw/目录需要的是压缩状态下的gz文件,processed/目录需要的是解压后的pt文件。如果你把gz手动解压成二进制文件再放到raw/里,torchvision 反而不认。
1.4 顺带把 MNIST 文件格式看清楚
MNIST 数据文件的结构非常朴素。图片文件train-images-idx3-ubyte.gz解压后,前 16 个字节是文件头,包含魔数、样本数、行数、列数,后面跟着60000 * 28 * 28个字节的像素值。标签文件train-labels-idx1-ubyte.gz的前 8 个字节是文件头,后面是 60000 个标签值。
这套格式是深度学习里最常见的入门格式之一,理解了它,你就知道为什么数据增强、归一化都发生在把像素读成 Tensor 之后。MNIST 本身是 28x28 的灰度图,像素范围 0 到 255,训练集 60000 张,测试集 10000 张,覆盖数字 0 到 9。在真正开始设计网络之前,先把这些基础信息装进脑子,后面看代码会轻松很多。
2. CNN 结构设计:为什么这是全连接网络做不到的事
2.1 展平成向量的问题在哪
最早做手写数字识别时,很多人会想:图像不就是一堆像素吗,把它展平成 784 维向量,喂给一个全连接网络不就行了?MNIST 因为图像小,确实有人用全连接网络达到 98% 左右的准确率,但再往上走就很吃力了。核心原因是全连接层把二维结构拍扁了,像素之间的空间关系没有先验支撑。
全连接网络和 CNN 的差异,我从参数数量这个角度给你拆开看:
| 对比维度 | 全连接网络 | CNN |
|---|---|---|
| 输入形态 | 28x28 展平成 784 | 保留 28x28 二维结构 |
| 第一层参数 | 784x512 ≈ 40 万 | 16 个 3x3 卷积核约 160 个参数 |
| 局部特征建模 | 不显式,靠连接权重硬学 | 滑窗天然关注相邻像素 |
| 平移容忍度 | 基本没有 | 池化操作带来一定平移不变性 |
注意,我说的是特征提取部分的参数对比,不是整个网络的参数。CNN 后面依然有全连接层,这部分参数不会凭空消失。但视觉特征提取如果靠 40 万个参数去硬学一个 28x28 的图,扩展到大图时会直接崩掉。CNN 通过局部连接和权值共享,把特征提取的参数量压低了几个数量级,同时保留了空间结构。
2.2 卷积、池化、BN 与 Dropout 各解决什么问题
卷积层做的事情简单说就是滑动窗口。一个 3x3 的卷积核在图像上从左到右、从上到下扫过去,每个位置算一次点乘,输出一个特征值。这个卷积核就好比一个特征探测器,同一套参数在整张图上复用。第一层卷积核很容易学到边缘、亮度突变这些低级特征,第二层再把低级特征组合成曲线、角点,网络更深以后就能组合出与具体类别相关的语义特征。
池化层解决的是分辨率和位置敏感问题。2x2 的最大池化把一个区域里最大的值保留下来,丢弃掉其他三个值,输出尺寸缩小一半。这样做的好处是让网络对轻微的位移和形变不那么敏感,坏处是丢失了一些细节。所以在简单任务里,通常不会连续堆太多池化层。
BatchNorm 解决的是训练稳定性问题。它把每个 batch 的特征归一化到均值为 0、方差为 1 的分布,再通过可学习的 scale 和 shift 恢复表达能力。加了 BN 之后,你可以放心使用更大的学习率,收敛速度肉眼可见地提升。
Dropout 解决的是过拟合问题。它只在训练时随机让一部分神经元失活,迫使网络不要过度依赖某些节点。MNIST 任务相对简单,但全连接层参数量大,加一点 Dropout 能稳一稳测试集表现。
你可以把 CNN 理解成“先看局部,再拼整体”。看一张人脸判断是谁,你不会一个像素一个像素地看,而是先看眼睛、鼻子、嘴这些局部区域,再组合成整体判断。卷积核就是在做这个事,只不过参数是靠梯度下降学出来的。
2.3 我用的网络结构及各层尺寸推算
我用的网络是一个非常经典的简约 CNN,结构如下:
| 层名 | 输入尺寸 | 输出尺寸 | 说明 |
|---|---|---|---|
| Conv1 3x3, pad=1 | [N, 1, 28, 28] | [N, 16, 28, 28] | 提取低级特征 |
| BatchNorm + ReLU | [N, 16, 28, 28] | [N, 16, 28, 28] | 稳定分布,加速收敛 |
| MaxPool 2x2 | [N, 16, 28, 28] | [N, 16, 14, 14] | 降采样 |
| Conv2 3x3, pad=1 | [N, 16, 14, 14] | [N, 32, 14, 14] | 组合特征 |
| BatchNorm + ReLU | [N, 32, 14, 14] | [N, 32, 14, 14] | 稳定分布 |
| MaxPool 2x2 | [N, 32, 14, 14] | [N, 32, 7, 7] | 降采样 |
| Flatten | [N, 32, 7, 7] | [N, 1568] | 展平 |
| FC1 + ReLU + Dropout | [N, 1568] | [N, 128] | 分类头 |
| FC2 | [N, 128] | [N, 10] | 输出类别 logits |
尺寸推算用到一条公式:
output = (input + 2 * padding - kernel_size) / stride + 1
第一层卷积,输入 28,3x3 卷积核,padding=1,stride=1,所以输出是(28 + 2 - 3) / 1 + 1 = 28,尺寸不变。然后池化层把 28 变成 14。第二次卷积同样保持 14,再池化成 7。最后7 * 7 * 32 = 1568个特征值进入全连接层。
为什么不直接 28 变成 14 再继续而要多加一层卷积?因为连续两层卷积可以在池化之前更快扩大感受野,让网络学会更复杂的组合特征。在我的实测里,单层卷积加两层池化的结构,准确率能到 98.5% 左右,但加完第二层卷积后稳定突破 99%,代价只是多了一点点计算量,非常划算。
3. 单机可跑的完整实现:从 DataLoader 到测试集
3.1 数据加载:为什么是 ToTensor + Normalize
数据加载这一步,最容易忽略的就是归一化。MNIST 原始像素是 0 到 255 的整数,直接喂给网络不是不行,但梯度变化会更大,训练会不稳定。transforms.ToTensor()会把 PIL 图像转成 CHW 格式的 Tensor,同时把像素值缩放到 0 到 1。transforms.Normalize()再按通道做标准化,让数据分布接近标准正态分布。
MNIST 灰度图的常用均值是 0.1307,标准差是 0.3081,这组数值是训练集统计出来的。代码里固定写成这样即可:
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=256, shuffle=False)有一点要注意:train=True拿到的是 60000 张训练图,train=False拿到的是 10000 张测试图。测试集不要设shuffle=True,因为评估时不需要打乱顺序,只要遍历完整即可。
3.2 模型定义
模型定义直接往下写,注意 PyTorch 的写法习惯:
import torch.nn as nn class MNISTCNN(nn.Module): def __init__(self): super().__init__() self.features = nn.Sequential( nn.Conv2d(1, 16, kernel_size=3, padding=1), nn.BatchNorm2d(16), nn.ReLU(inplace=True), nn.MaxPool2d(2), nn.Conv2d(16, 32, kernel_size=3, padding=1), nn.BatchNorm2d(32), nn.ReLU(inplace=True), nn.MaxPool2d(2), ) self.classifier = nn.Sequential( nn.Flatten(), nn.Linear(32 * 7 * 7, 128), nn.ReLU(inplace=True), nn.Dropout(0.2), nn.Linear(128, 10), ) def forward(self, x): return self.classifier(self.features(x))第一个Conv2d的输入通道是 1,因为 MNIST 是灰度图。如果换成 RGB 三通道图像,这里就要改成 3。inplace=True是在原地更新激活值,省一点显存,对结果没有影响。最后一个 Linear 输出 10 个 logits,不接 Softmax,因为 PyTorch 的CrossEntropyLoss内部已经做了LogSoftmax,你不需要在模型里额外加一层 Softmax,加了反而可能影响数值稳定性。
3.3 训练和评估循环
训练循环里最容易记混的是model.train()和model.eval()的切换。model.train()会开启 Dropout 和 BN 的训练行为,model.eval()会关闭 Dropout 并让 BN 使用全局统计量。如果忘记切换,评估结果可能时好时坏,或者训练集准确率和测试集准确率差距巨大。
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=1e-3) def train_one_epoch(loader): model.train() total_loss = 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) return total_loss / total, correct / total def evaluate(loader): model.eval() correct = 0 total = 0 with torch.no_grad(): for images, labels in loader: images, labels = images.to(device), labels.to(device) outputs = model(images) pred = outputs.argmax(dim=1) correct += (pred == labels).sum().item() total += labels.size(0) return correct / total epochs = 10 for epoch in range(epochs): train_loss, train_acc = train_one_epoch(train_loader) test_acc = evaluate(test_loader) print(f"Epoch {epoch + 1:02d} | train loss {train_loss:.4f} | train acc {train_acc:.4f} | test acc {test_acc:.4f}")with torch.no_grad()告诉 PyTorch 不需要保存中间变量、不需要计算梯度,评估阶段的显存占用和计算量都会大幅下降,这是评估循环的固定写法。如果你的显存不大,这句别省。
4. 训练实测:参数怎么定、准确率怎么到 99%
4.1 一组真实的对比数字
下面是我在相同模型结构下跑的一组对比,具体数值会随机器和随机种子小幅浮动,但趋势是稳定的:
| 优化器 | 学习率 | batch_size | epoch | 测试集准确率 |
|---|---|---|---|---|
| Adam | 1e-3 | 64 | 5 | 99.12% |
| Adam | 1e-3 | 64 | 10 | 99.25% |
| Adam | 1e-3 | 256 | 10 | 99.08% |
| SGD + Momentum 0.9 | 0.01 | 128 | 15 | 99.0% |
| SGD + Momentum 0.9 | 0.1 | 128 | 10 | 98.6% |
从结果能明显看出两件事:第一,MNIST 这个任务,简单 CNN 基本就是 99% 上下的水平,再往上推需要数据增强或者模型集成;第二,Adam 在默认学习率下前 5 个 epoch 就能快速爬到高准确率,SGD 需要更长训练时间,但最后也能追上来。
4.2 调参策略:从快跑到微调
我的建议是,第一次跑通时无脑用 Adam + lr=1e-3 + batch_size=64,这套组合在 MNIST 上非常稳,几乎不会出现发散。跑通之后再根据 loss 曲线做调整。
如果训练 loss 不下降,优先怀疑学习率。学习率太大,loss 会震荡;学习率太小,loss 下降得极其缓慢。Adam 的默认学习率已经足够好,不必像 SGD 那样手动做学习率衰减,但如果你看到后期 loss 在某个点反复横跳,可以考虑在最后几个 epoch 把学习率降到 1e-4。
如果训练集准确率很高、测试集准确率上不去,这是过拟合的典型信号。解决方案按优先级排序:加大 Dropout 比率、减少全连接层的神经元数量、做数据增强。MNIST 上最简单的数据增强是随机平移 2 个像素或随机旋转 10 度,实测可以再提升 0.2 到 0.3 个百分点。
4.3 新手最容易踩的坑
第一个坑是忘记切换model.train()和model.eval()。Dropout 在训练和评估时的行为不同,如果你用训练模式跑测试集,Dropout 还在随机失活神经元,测试准确率会忽高忽低,非常容易被误导。
第二个坑是不做归一化直接训练。虽然 BN 能缓解一部分问题,但ToTensor()和Normalize()这几行代码几乎是所有图像任务的标配,别省。
第三个坑是CrossEntropyLoss的 target 类型。PyTorch 要求标签必须是torch.long类型,也就是整数索引,如果你从 NumPy 读的数据不小心转成 float,丢进 loss 里会直接报类型错误。
第四个坑是 Windows 环境下DataLoader的num_workers。老版本 PyTorch 在 Windows 上如果num_workers设成大于 0 的值,可能卡死或者报错。训练 MNIST 这种小数据集,直接设num_workers=0最省心,速度影响也不大。
第五个坑是完全不固定随机种子。PyTorch 的权重初始化、数据加载顺序都有随机性,如果你希望实验可复现,需要固定种子:
import random import numpy as np random.seed(0) np.random.seed(0) torch.manual_seed(0) torch.cuda.manual_seed_all(0) torch.backends.cudnn.deterministic = True固定种子之后,每次结果基本一致。不固定的话,测试集准确率可能会在 0.1% 左右浮动,这是正常现象,不用怀疑模型写错了。
5. 从 MNIST 走出去:真实图像、无框架和硬件侧的扩展
5.1 迁移到花卉图像分类时要改哪些
MNIST 是一个 28x28 单通道、类别均衡、背景干净的数据集,真实视觉任务没有那么友好。拿花卉图像分类来举例,输入会从 28x28 变成 224x224 的 RGB 三通道图像,模型的第一个卷积层输入通道要从 1 改成 3,网络也需要更多下采样层才能处理更高分辨率。
如果你完全从零训练一个 CNN 做花卉分类,效果通常不会太好,因为真实数据集往往只有几千到几万张图,模型容量一旦上去就容易过拟合。正确做法是用预训练模型做迁移学习,比如 ResNet、EfficientNet。像 CSPNet 这类改进 backbone 的设计思路,核心是让 CNN 每个阶段的特征组合更高效,从而在同等算力下提升准确率。这类技术在 MNIST 上完全体现不出优势,问题的瓶颈早就不在表达能力上。
5.2 不依赖框架:用 C# 手搓 BP 神经网络能学到什么
如果你不想依赖 PyTorch 或 TensorFlow,用 C# 从零写一个 BP(反向传播)神经网络做手写数字识别,是一个很硬核但很值得做的学习项目。核心工作包括数据读取、标签 One-Hot 编码、矩阵乘法实现前向传播、链式法则求梯度、梯度下降更新权重。
手写 BP 和用框架最大的区别在于:框架里的loss.backward()一行代码搞定的事,你需要自己一步步推导dW和db。我第一次手搓的时候,最痛苦的是矩阵维度对不齐,但正是这个痛苦的过程,让我真正理解了反向传播是在做什么。课程项目里用 C# 手搓 BP 识别手写数字,本质上就是用最朴素的方式验证“梯度下降能不能真的学出规律”。如果你还想再进一步,手搓 CNN,那还要处理卷积的反向传播、im2col矩阵化等复杂细节,工程量会指数级上升。
5.3 边缘侧可行吗:Verilog 在 FPGA 上实现 CNN 的关键点
另一个很有意思的扩展方向是用 Verilog 在 FPGA 上实现 CNN。MNIST 因为图像小、网络结构简单,非常适合作为硬件加速器的验证载体,很多课程设计和研究都会选这个题目。
FPGA 上做 CNN 和 GPU 上训练 CNN 是完全不同的侧重点。硬件侧首先要考虑量化,也就是把浮点权重和激活值转成 INT8 或更低精度,因为 FPGA 上的浮点乘累加资源非常昂贵。其次要考虑数据流,一个 3x3 卷积窗口要滑动整张图,你可以用行缓存(line buffer)来缓存当前窗口需要的三行像素,再用乘累加单元做点乘。然后是并行设计,把多个卷积核放在不同的计算单元上并行计算,才能体现出 FPGA 的优势。
在 MNIST 上可以先验证整个流程能不能跑通,再迁移到更大的网络和数据集。你需要权衡时序、面积和功耗,这又是另一套知识体系了,但起点往往就是手头这个 28x28 的小数据集。
最后再说点个人体会。我把这个项目的源码和数据处理脚本整理好之后,每次换了新电脑、新环境,第一件事就是跑一遍 MNIST。它当然不能代表真实工业项目,但它能非常快速地验证一件事:你的数据链路能不能通、训练流程有没有被环境问题卡住。模型准确率到 99% 之后,我的主要收获反而不是指标本身,而是学会了怎么从报错信息反推问题、怎么处理数据集、怎么稳步调参。这套思路迁移到真实任务里,比 MNIST 本身有用得多。