YAOTU INSIGHTS

PyTorch手写数字识别实战:从MNIST下载404到CNN准确率99%全流程解析

PyTorch手写数字识别实战:从MNIST下载404到CNN准确率99%全流程解析
MNIST手写数字识别几乎是每个深度学习入门者的第一站。但有意思的是很多人第一站就翻车了运行那行downloadTrue结果网络报404好不容易下载好数据模型训练完了面对一堆训练日志又不知道该怎么判断效果。这篇文章我会用PyTorch把从环境搭建到模型评估的完整流程走一遍全连接网络和卷积网络都给出可直接运行的代码并且把torchvision下载MNIST报404这个高频问题单独拎出来讲透。无论你是刚在Anaconda里装好Python的新手还是已经能跑通但想弄清楚每个细节的半熟手照着这篇文章走应该不会再卡壳。1. 先搞清楚MNIST到底是什么数据长什么样、任务怎么定义1.1 28×28像素和10个类别MNIST的全部家底MNIST的全称是Modified National Institute of Standards and Technology database最早整理自美国国家标准与技术研究院收集的手写数字样本后来经过预处理成了现在这个经典版本。整个数据集包含70000张灰度图片其中60000张用于训练10000张用于测试。每张图是28×28像素一共784个像素点每个像素取值0到2550代表黑色背景255代表白色笔迹中间是不同深浅的灰色过渡。图片上写的是一个阿拉伯数字从0到9共10个类别任务就是让模型识别出这张图是哪个数字。这里有几个初学者容易忽略的细节。第一MNIST的原始数据不是常见的jpg或者png格式而是一种叫IDX的二进制格式专门为机器学习设计所以直接用看图软件打不开必须靠torchvision这类工具库来解析。第二torchvision在加载MNIST时会自动把数据转换成PyTorch的Tensor你不需要关心底层格式的转换过程。第三训练集和测试集的划分是固定的两者千万不能混用否则你报出来的99%准确率在别人眼里没有任何说服力因为测试集被你泄漏到训练过程中了。1.2 分类任务在数学上到底是什么所谓分类识别本质上就是建立一个从784维向量到10类标签的映射。输入一张图展平之后就是一个784维的向量网络要做的事情是输出一个10维的向量向量的每个分量代表这张图属于对应数字的概率哪个分量最大就预测成哪个数字。比如输出向量是[0.01, 0.02, 0.88, 0.004, ...]第三个分量最大模型就认为这张图是数字2。把这句话转换成神经网络的语言就是网络是一个函数784是自变量维度10是因变量维度中间那些隐藏层神经元的作用是把原始像素空间这种线性不可分的表示逐层变换到一个线性可分的特征空间。这也是为什么网络层数越多、每层神经元越多模型的表达能力就越强。但表达能力越强不代表越好后面讲过拟合的时候你会看到模型的容量必须和任务的复杂度匹配不然就是在背答案而不是在学习规律。1.3 为什么MNIST适合当入门题MNIST几乎是给入门量身定做的。首先单张图片只有784个输入全连接网络轻松就能跑到97%以上的准确率CPU环境下几分钟就能完成训练不需要昂贵显卡。其次数据量适中60000张训练图对内存非常友好一个几十MB的压缩包就能装完。第三任务结果直观写出来的数字一眼就能看出对错你不需要任何领域知识就能判断模型有没有学明白。我这些年带过不少初学朋友发现一个规律凡是能完整走通MNIST这套链路的人后面学CIFAR、ImageNet、目标检测都会顺很多。原因很简单Dataset加载数据、DataLoader供给数据、model前向传播、loss反向传播、optimizer更新参数——这套流程的逻辑骨架在任何深度学习项目里都不变。MNIST的意义不在于任务本身有多难而在于让你用一个最小的成本把这条流水线彻底跑通。2. 环境准备Python、PyTorch与CUDA的版本匹配逻辑2.1 为什么建议用Anaconda建独立环境环境安装的第一步是选一个干净的Python运行环境。我强烈建议用Anaconda而不是直接在系统Python里到处pip install。原因不是Anaconda有多高大上而是conda可以给每个项目创建独立的虚拟环境环境之间互不干扰。你在这个项目里用PyTorch 2.8在那个项目里用PyTorch 1.13切换只需要几秒钟的激活命令不会出现升级了torchvision之后另一个项目莫名其妙跑不起来这种连锁灾难。创建环境的命令非常简单conda create -n pytorch python3.10 conda activate pytorchPython版本我建议选3.10或者3.11这两个版本和PyTorch 2.x的兼容性最稳。我的主力环境用的就是Python 3.10.11配PyTorch 2.8.0跑MNIST这种小任务绰绰有余。如果你用的是更高版本的Python比如3.12也不是完全不行但个别老版本的第三方库可能还没跟进没必要给自己添堵。2.2 CPU版还是GPU版别一上来就追求显卡新手最常见的执念是我显卡不行是不是就不能学深度学习。以MNIST为例真的不用担心。用CPU训练一个两层全连接网络跑10个epoch也就两三分钟用GPU反而要多花时间在数据搬运和CUDA初始化上体验差距并不明显。所以我的建议是先装CPU版把流程跑通确认自己的代码逻辑没有硬伤再根据实际项目需求决定要不要配GPU环境。如果你确实有NVIDIA显卡安装GPU版也很简单。先查看自己显卡驱动的支持情况在命令行运行nvidia-smi右上角会显示驱动支持的CUDA版本。比如显示12.1那就可以安装对应CUDA 12.1的PyTorchpip install torch torchvision torchaudio --index-url https://download.pytorch.org/whl/cu121如果是CPU版命令更简单pip install torch torchvision torchaudio这里最核心的概念是PyTorch的CUDA版本号指的是它运行时依赖的CUDA库版本你的显卡驱动只需要大于等于这个版本即可。比如cu121版的PyTorch要求驱动支持CUDA 12.1及以上而不是说驱动里的版本必须恰好等于12.1。很多人在这一步反复折腾把驱动升了又降纯粹是被这个匹配关系绕晕了。2.3 安装后必须做的三步健康检查装完之后不要急着写模型代码先做一个环境健康检查。打开Python交互式终端依次运行import torch print(torch.__version__) print(torch.cuda.is_available()) print(torch.cuda.get_device_name(0) if torch.cuda.is_available() else CPU only)第一行输出类似2.8.0说明PyTorch主体安装成功第二行输出True说明GPU可用第三行会显示你的显卡型号。如果cuda.is_available()返回False而你确认自己装的是GPU版那90%是显卡驱动版本过旧或者安装的PyTorch版本对应的CUDA库和驱动不匹配。这个时候先别急着重装用nvidia-smi看清楚驱动支持的CUDA版本再去PyTorch官网选择对应版本的安装命令。顺便提醒一句NVIDIA官网的驱动下载页面有自动检测驱动功能懒人可以直接用。3. torchvision下载MNIST报404一次讲透离线加载方案3.1 报错的真实面目MNIST下载404的问题几乎每个用torchvision的人都踩过一次。报错信息通常长这样Downloading http://yann.lecun.com/exdb/mnist/train-images-idx3-ubyte.gz Failed to download (trying next): HTTP Error 404: Not Found如果是在国内网络环境下还可能是超时、连接被重置这类错误。我遇到过不少初学者在这里直接崩溃以为自己环境装错了或者代码写错了然后卸载重装PyTorch问题依然存在白白折腾一下午。其实问题和你的代码、你的环境都没关系。torchvision的MNIST加载逻辑默认会去国外官方站点下载数据官方站点服务器在海外直连经常不稳定加上站点偶尔调整文件路径404就成了高频问题。这一步卡住的人连模型代码都还没写自然觉得自己什么都不会。解决办法其实很直接不依赖torchvision的自动下载改为手动把数据文件下载好放到本地指定目录再让torchvision加载本地文件整个过程完全不碰网络。3.2 手动下载四个文件并放到正确位置MNIST数据里真正有用的其实是四个gzip压缩文件torchvision一切的后续处理都基于这四个文件对应关系如下文件用途大小约train-images-idx3-ubyte.gz60000张训练图片9.9 MBtrain-labels-idx1-ubyte.gz60000个训练标签28 KBt10k-images-idx3-ubyte.gz10000张测试图片1.6 MBt10k-labels-idx1-ubyte.gz10000个测试标签4.5 KB你可以先从官方站点尝试下载如果打不开就去GitHub或者Gitee上搜索mnist dataset离线包很多开发者会把这四个文件整理成压缩包供下载。下载之后要注意目录结构以root参数为./data为例最终的文件树应该是./data/ └── MNIST/ └── raw/ ├── train-images-idx3-ubyte.gz ├── train-labels-idx1-ubyte.gz ├── t10k-images-idx3-ubyte.gz └── t10k-labels-idx1-ubyte.gz这一步最容易出错的是路径。torchvision的MNIST类会在root下自动创建一个MNIST/raw子目录你手动放文件的时候必须严格按这个结构来文件名一个字都不能改。我有次帮朋友排错发现他把文件放到了./data/raw而不是./data/MNIST/raw结果torchvision怎么都找不到。3.3 用downloadFalse加载本地文件文件放好之后加载代码只需要把download参数从True改成Falsefrom torchvision import datasets, transforms transform transforms.Compose([ transforms.ToTensor(), transforms.Normalize((0.1307,), (0.3081,)) ]) train_dataset datasets.MNIST(root./data, trainTrue, transformtransform, downloadFalse) test_dataset datasets.MNIST(root./data, trainFalse, transformtransform, downloadFalse)torchvision发现raw目录下已经有文件就会自动解析并生成后续训练要用的Tensor数据整个过程不需要网络参与。如果你发现downloadFalse仍然报Dataset not found那基本可以断定raw目录的文件没放对位置或者文件名和上面表格里的不一致。逐个核对一下目录树问题基本都能解决。3.4 文件完整性检查手动下载有个隐患下载过程可能被截断文件不完整加载时会出现乱码或者维度错误。最朴素的检查方式就是看文件大小上面表格里列的是我实际下载的准确数据可以对照一下。如果你下载的压缩包解压不出正确的魔数或者加载时报zipfile.BadZipFile那就是文件坏了重新下载一次即可。一个更省心的小技巧把下载好的gzip文件保留在固定的本地目录里比如~/datasets/mnist以后其它项目要再用MNIST直接把root参数指过去就再也不用经历404的折腾了。数据集这种基础设施一次性备好长期受益。4. 数据加载核心transforms与DataLoader的每一行都别跳过4.1 ToTensor到底做了什么很多人看完MNIST入门教程照抄代码能跑但从不关心transforms里的ToTensor和Normalize是干什么的。这两行恰恰是整个数据预处理的核心理解它们能帮你省掉后面一大堆莫名其妙的调试时间。ToTensor做的事可以拆成三件把PIL图片或者numpy数组转成PyTorch的Tensor把数据维度从H×W×C高×宽×通道转成C×H×W通道×高×宽把像素值从0到255的整数缩放到0.0到1.0的浮点数。MNIST是单通道灰度图所以转换后每张图变成1×28×28的张量。为什么一定要缩放神经网络训练时对输入数值范围很敏感。如果直接拿0到255的数值喂给网络一方面数值量级过大会让权重更新不平衡另一方面大多数激活函数在0附近梯度最大、在两端饱和输入数值太大容易让神经元一开始就处于饱和区梯度几乎为零模型根本学不动。把数据压到0到1区间本质上是告诉网络所有特征的尺度是一致的。4.2 Normalize的0.1307和0.3081是怎么来的Normalize的公式是(x - mean) / std。对MNIST而言0.1307是整个训练集的像素均值0.3081是标准差。这两个值是别人提前算好写死在教程里的你不需要每次重新计算。归一化之后数据分布会尽量接近标准正态分布也就是均值0、方差1。这样做的道理和ToTensor类似但更进一步神经网络的权重通常按0附近的小随机数初始化如果输入也落在0附近模型收敛会更快更稳定。很多人偷懒用0.5、0.5当mean和std效果也不是不行但MNIST这两个专用值是最准的既然能拿到就别用凑合的。4.3 DataLoader的batch_size与shuffle怎么选数据集对象只是把数据存好了真正干活时把数据喂给网络的是DataLoader。它做的事情是自动切batch、按需打乱顺序、并行加载数据。两个参数最关键参数训练集测试集理由batch_size64或128256或512训练时太大容易内存爆测试时不更新参数可以大一点shuffleTrueFalse训练时打乱避免网络学到样本顺序测试时不需要batch_size的选择会影响训练效果。太小如16每个batch的梯度噪声大loss曲线会抖得厉害太大如1024梯度方向过于确定容易陷进梯度为零的平缓区域而且占内存。MNIST这种小数据集64到128是最稳的区间我习惯用128。加载代码from torch.utils.data import DataLoader train_loader DataLoader(train_dataset, batch_size128, shuffleTrue, num_workers0) test_loader DataLoader(test_dataset, batch_size256, shuffleFalse, num_workers0)特别注意num_workers这个参数。Windows系统上建议设0否则多进程数据加载在某些情况下会报错或者卡住这是我多次实测得到的结论。Linux和Mac可以根据CPU核数适当调大比如设成4数据加载速度会有明显提升。5. 搭建全连接神经网络从784维输入到10类输出的完整推导5.1 输入784、输出10中间放多少隐藏层网络结构的第一层和最后一层是确定的。输入层接展平后的图片784个神经元输出层必须有10个神经元对应0到9十个数字。中间隐藏层的层数和神经元数没有标准答案但对MNIST这个量级两到三层、每层128到512个神经元已经完全够用。我用一个典型的配置演示第一隐藏层256个神经元第二隐藏层128个神经元。为什么不把每层都堆到1024因为MNIST本身难度不高模型参数越多越容易过拟合——训练集上表现完美测试集上反而掉链子。先小后大、逐步增加容量是调参的铁律不管什么任务都适用。5.2 模型代码继承nn.Module的正确姿势import torch.nn as nn class SimpleNN(nn.Module): def __init__(self): super().__init__() self.fc1 nn.Linear(784, 256) self.fc2 nn.Linear(256, 128) self.fc3 nn.Linear(128, 10) self.relu nn.ReLU() def forward(self, x): x x.view(x.size(0), -1) x self.relu(self.fc1(x)) x self.relu(self.fc2(x)) x self.fc3(x) return x这里有两个关键点。第一x.view(x.size(0), -1)把形状为[batch_size, 1, 28, 28]的输入展平成[batch_size, 784]这是全连接层要求的输入格式。写-1的意思是让PyTorch自动推算这一维的大小也就是784。第二最后一层不要接ReLU因为后面接的是交叉熵损失它内部会自动做softmax操作你如果提前把负的logits截断成0概率分布就完全错了。这个错误很隐蔽跑起来不一定报错但准确率会诡异地上不去。5.3 损失函数与优化器的选择逻辑import torch.optim as optim model SimpleNN() criterion nn.CrossEntropyLoss() optimizer optim.Adam(model.parameters(), lr0.001)为什么用CrossEntropyLoss多分类任务的标准选择就是它。它把softmax和负对数似然合并在一起计算内部先算出每个类别的softmax概率再取正确类别概率的负对数。这样做的优势是数值稳定梯度形式更简洁。用MSE做分类损失不是不行但收敛慢而且对概率解释不友好没必要和自己过不去。为什么用Adam而不是SGDAdam自带自适应学习率对不同参数按需调整更新步长对学习率不敏感默认0.001基本就能跑出不错的结果。SGD需要手动调学习率和动量调参成本高很多。MNIST这种入门任务Adam是最高性价比的选择。等你以后做大规模训练、需要精细控制优化过程时再回头研究SGD和各类变体的区别也不迟。6. 训练循环与测试评估完整代码逐行解读6.1 训练循环的四个必备动作PyTorch的训练循环看起来就那么几行但每一行的顺序都不能变。核心是四个动作前向传播算出预测结果、根据结果和真实标签计算损失、反向传播求梯度、更新参数。epochs 10 for epoch in range(epochs): model.train() running_loss 0.0 for images, labels in train_loader: optimizer.zero_grad() outputs model(images) loss criterion(outputs, labels) loss.backward() optimizer.step() running_loss loss.item() avg_loss running_loss / len(train_loader) print(fEpoch {epoch 1}/{epochs}, Average Loss: {avg_loss:.4f})新手最容易漏的是optimizer.zero_grad()。PyTorch的梯度是累积的每次loss.backward()算出的梯度会累加到参数的.grad属性上如果不清零下一轮的梯度会和上一轮的叠加导致参数更新方向和幅度完全错乱。这个设计初衷是为了支持梯度累积这种高级技巧但对初学者来说老老实实每轮清零。model.train()的作用是把模型切到训练模式。对普通的全连接网络来说这个操作似乎没啥影响但它对于带Dropout和BatchNorm的网络非常重要——训练模式下Dropout会随机丢弃神经元BatchNorm会使用当前batch的统计量而测试模式下两者行为完全不同。养成写model.train()和model.eval()的习惯以后换复杂模型才不会踩坑。还有一个容易被忽略的点如果你想在GPU上跑需要在循环前加两行设备设置device torch.device(cuda if torch.cuda.is_available() else cpu) model model.to(device)然后在循环里把images和labels也搬到设备上images, labels images.to(device), labels.to(device)不搬的话模型在GPU而数据在CPU会直接报device mismatch的错误。6.2 测试评估的正确姿势eval和no_grad训练完成后评估模型代码套路和训练循环相似但有三个关键区别model.eval() correct 0 total 0 with torch.no_grad(): for images, labels in test_loader: outputs model(images) _, predicted torch.max(outputs, dim1) total labels.size(0) correct (predicted labels).sum().item() print(fTest Accuracy: {100 * correct / total:.2f}%)model.eval()让模型切到评估模式torch.no_grad()告诉PyTorch这一段代码不需要计算梯度。测试时确实不需要梯度关掉之后能省大量内存和计算时间代码速度会有明显提升。torch.max(outputs, dim1)返回两个值第一个是每个样本的最大值第二个是最大值的索引——索引正好就是预测的类别编号。这一行是典型的一行顶两步写法初学者最好把它理解透。6.3 跑完10轮之后你应该看到什么用上面的默认参数跑10个epoch正常的loss曲线是从0.5左右开始前三轮快速降到0.1以下然后缓慢走低最后稳定在0.02到0.05之间。测试准确率通常在97%到98%。如果你的结果明显低于这个水平先别急着改模型回去看训练集上的loss降不降得下去——如果降不下去问题大概率在学习率或数据预处理上如果训练集loss已经很低但测试集上不去那才轮到讨论过拟合。7. 进阶用两层卷积把准确率刷到99%7.1 卷积网络为什么在图像上更占优势全连接网络有一个先天缺陷它把每个像素当成完全独立的输入特征完全忽略了像素之间的空间位置关系。一个数字8上半部分和下半部分之间是有结构的但全连接网络只能靠大量参数硬生生记住这种结构效率极低。卷积神经网络天生就是为图像设计的。卷积核在图像上滑动时每次只看一个小邻域这叫做局部感受野同一个卷积核在整个图像上共享权重参数量大幅减少。这两个特性让CNN能用更聪明的结构学到更强的空间特征。对MNIST这种有明显笔画结构的任务CNN就是降维打击。7.2 CNN完整代码与维度推导class CNN(nn.Module): def __init__(self): super().__init__() self.conv1 nn.Conv2d(1, 32, kernel_size3, padding1) self.conv2 nn.Conv2d(32, 64, kernel_size3, padding1) self.pool nn.MaxPool2d(2, 2) self.fc1 nn.Linear(64 * 7 * 7, 128) self.fc2 nn.Linear(128, 10) self.relu nn.ReLU() def forward(self, x): x self.pool(self.relu(self.conv1(x))) x self.pool(self.relu(self.conv2(x))) x x.view(x.size(0), -1) x self.relu(self.fc1(x)) x self.fc2(x) return x我们来推一下维度是怎么从28变成7的。输入是[batch, 1, 28, 28]第一层卷积用padding1、kernel_size3输出宽高保持不变仍是28×28通道数从1变到32然后MaxPool2d(2,2)把宽高各减半变成14×14。第二层卷积同样保持尺寸通道数从32变到64再次池化变成7×7。所以展平之后是64×7×73136再接入全连接层到128再到10。训练代码和全连接网络完全一致连训练循环都不用改。这也是PyTorch设计的高明之处——模型可以任意替换训练和评估的流程是通用的。7.3 两种模型的最终对比模型参数总量训练10轮耗时CPU测试准确率全连接网络256-128约23.5万约2分钟97%-98%两层CNN约42万约4分钟99%以上这里有个反直觉的事实我给出的这个CNN参数总量反而比全连接网络高主要高在最后一层全连接上因为7×7×643136维的特征接128个神经元这个矩阵本身就占了很大比例。但这不改变CNN的优势本质——它的卷积部分只用了不到2万个参数就完成了图像特征提取而特征提取恰恰是图像识别最核心的能力。如果你在卷积部分之后加一个全局平均池化把3136维压成64维再接全连接参数量会立刻降到几万准确率也不会有明显损失。这个对比恰好说明CNN强在结构设计而不是参数规模。更大的参数量也能取得更高的准确率靠的是特征学习方式的改进。8. 我踩过的坑和调参心得从报错到效果优化的实战笔记8.1 六个高频报错与其解法跑MNIST这一路我见过的报错基本可以归结为几类整理成表格供你对照查询报错信息出现原因解决方案HTTP Error 404 / URLErrortorchvision自动下载MNIST失败手动下载离线包见第3节RuntimeError: size mismatch全连接层输入维度和展平后不一致重新计算展平维度保证Linear输入匹配Expected 4-dimensional input全连接网络忘了view展平forward里加x.view(x.size(0), -1)RuntimeError: Found dtype Long输入或标签类型不对确认images是float类型labels保持LongCUDA out of memorybatch_size太大或显存不足减小batch_size或者换CPU跑CUDNN_STATUS_NOT_INITIALIZED驱动和CUDA版本不匹配重新安装匹配的驱动或直接用CPU版其中size mismatch是最常见的几乎每个自己动手改网络结构的人都会遇到。核心思路是先手动推一遍各层输出的维度特别是经过卷积和池化之后展平维度变了下一层Linear的输入维度也要跟着变这两处必须对得上。8.2 过拟合的判断方法与对策训练到5轮以后你可能会发现训练集准确率已经接近100%但测试集只有96%左右——这就是过拟合的典型信号。判断方法很简单当训练集和测试集的准确率差距超过3个百分点就要考虑抑制过拟合了。常用手段有三个加Dropout让部分神经元随机失活迫使网络学到更鲁棒的特征做数据增强比如随机旋转、平移几个像素减小模型容量少放一层或者减少神经元数量。对MNIST来说全连接网络比CNN更容易过拟合因为它参数多、容量大容易把训练集的细节背下来。CNN因为卷积层的权重共享机制天然对过拟合有更强的抵抗力这也是它在小数据集上也能表现优秀的原因之一。8.3 学习率、batch_size与训练轮数的经验值最后聊几个我多次实践总结下来的数字。学习率用Adam时0.001是标准起点loss不降就试0.0003loss剧烈震荡就试0.003但超过0.01基本就会不收敛。训练轮数方面MNIST到10轮足够全连接网络在10轮之后测试准确率的提升非常有限再继续跑只是浪费电。batch_size保持128是最省心的想追求更高的训练稳定性可以降到64但没必要再小。提示训练初期的loss如果出现NaN几乎都是学习率过大导致的先把lr降到0.0001再试。确认lr没问题还报NaN再检查输入数据里有没有异常值。还有一个容易忽略的小坑Windows系统上如果设置num_workers大于0训练脚本被中断后有时会在后台残留僵尸进程导致下次运行时数据加载变得非常慢。我现在的习惯是Windows一律用num_workers0Linux和Mac才根据CPU核数调大。这种事看起来是小事但在实际项目中确实耽误过不少时间提前规避能省很多事。如果你打算把MNIST这一套流程迁移到其他数据集上先确认三个输入要素数据的目录结构、每个样本的尺寸、类别数。把这三个参数一换模型结构和训练循环基本都能复用。这也是为什么我建议你从MNIST开始认认真真把每一行代码吃透——它不是一道只做一遍的作业题而是一把可以反复使用的钥匙。