☰
PyTorch实战:MNIST手写数字识别全流程详解与踩坑记录
2026/10/1 22:30:21 网站建设 项目流程

MNIST手写数字识别,是PyTorch学习之路上绕不开的“第一口蛋糕”。甚至可以说,如果你能把MNIST的完整流程跑通,后续的CIFAR、ImageNet项目骨架也就八九不离十了。这篇博文记录我带着“第P1周”计划,用PyTorch从零实现手写数字识别的全过程,包括环境搭建时踩过的坑、数据集的下载问题、模型结构怎么选、训练和评估的细节,以及我反复调参后总结出的一些经验。不管你是刚装好环境的小白,还是想快速回顾一遍PyTorch主流程的老手,这篇文章都应该能帮你省下不少时间。

1. 项目拆解:MNIST到底是什么,为什么说它是深度学习“Hello World”

1.1 MNIST数据集的组成与背景

MNIST数据集来自NIST(美国国家标准与技术研究院),由LeCun等人改造成目前大家看到的样子。它包含0到9共十个类别的手写数字灰度图像,每张图片尺寸是28×28像素,像素值范围0到255。其中训练集有60000张,测试集有10000张。很多人以为MNIST很“简单”,但仔细想想,它其实已经包含了图像分类任务的所有核心元素:多分类、灰度图像、有监督学习、数据预处理、模型评估。正因为量级小、背景干净,特别适合用来快速验证一个框架或一个算法的可行性。

我记得第一次拿到MNIST数据时,心里想:这也能算“深度学习”?后来才意识到,项目的难易不在于数据本身,而在于你是否能完整地走通一条流水线。MNIST就是一个打磨流水线的绝佳素材。对于刚接触PyTorch的人来说,不需要处理复杂的JSON标注、不需要理解目标检测的边界框,只需要关注“图片进,数字出”这个直观目标。这就是它能成为入门首选的根本原因。

1.2 第P1周的目标拆解

既然题目叫“第P1周”,我习惯把任务拆成四个阶段:第一,搭建可运行的环境并成功加载MNIST数据集;第二,用PyTorch定义一个可训练的神经网络模型;第三,完成训练循环并让loss持续下降;第四,在测试集上评估并输出准确率。四个阶段缺一不可,任何一个环节卡住都会影响后续进度。同时,这一周的重点不是刷高准确率,而是理解框架的核心机制——张量、自动求导、模块化网络、优化器更新。

这里我想强调一个观点:很多人喜欢上来就复制一个高精度的模型,然后跑通就觉得自己会了。其实这样反而会漏掉很多关键理解。我建议第一周一定要手动敲代码,哪怕是用最简单的两层全连接网络,也要自己实现一次前向传播和反向传播。只有亲手写过,才能体会loss为什么能下降、参数为什么在更新。

1.3 这个项目适合谁

这个项目可以说适合所有准备踏入深度学习的人。如果你是零编程基础,那可能需要先补一点Python语法;如果你有Python基础但没接触过PyTorch,那我强烈推荐你从MNIST开始。它不像一些工业级项目那样需要在数据清洗上花大量时间,而是把重点放到框架使用上。我也见过一些有多年开发经验的人,第一次写PyTorch时照样会在DataLoader的num_workers上栽跟头。所以不管基础如何,只要你想熟悉PyTorch的核心流程,这个项目都值得一做。

从收获来看,跑完这个项目后你至少能掌握:如何创建Dataset、如何构建模型、如何编写训练循环、如何用模型做预测。这些东西是后续所有深度学习工作的基础,而且它们之间是环环相扣的。比如说,不理解DataLoader的机制,你可能不知道为什么训练时内存暴涨;不理解模型的定义,你可能不知道如何修改网络层数。所以别小看这“第P1周”。

2. 环境搭建:从Anaconda到PyTorch的完整踩坑记录

2.1 为什么不直接pip install torch?

在开始写代码之前,必须把环境整理好。我见过很多新手直接在全局Python环境里执行pip install torch,装完发现和其他库冲突,或者Python版本不对,最后只能重装系统。所以我强烈建议用Anaconda创建独立环境。我的做法是:打开终端,执行conda create -n pytorch python=3.8,然后conda activate pytorch。这个虚拟环境就像给你的项目开了一个单独的“房间”,房间里装什么都不会影响外面的系统Python。

为什么选择Python 3.8而不是最新的3.12?因为PyTorch对Python版本的支持是有延迟的。用太新的Python版本,一些依赖库可能还没有编译好的轮子(wheel),会面临装不上的问题。下面我列一个当前比较稳定的版本对应关系,大家在安装时可以对照参考。

PyTorch版本推荐Python版本说明
1.10/1.113.7-3.9老项目常用,很多教程基于此
1.12/1.133.7-3.10过渡期
2.0/2.13.8-3.11当前主流,支持Windows原生编译
2.2及以上3.8-3.12新版,建议至少Python3.9

2.2 安装命令与GPU支持

装环境需要根据你的硬件选择CPU版还是GPU版。如果电脑有NVIDIA独立显卡,并且装好了驱动,那大概率可以装CUDA版的PyTorch。以我的环境为例,我用的是CUDA 11.7,安装命令是:

conda install pytorch torchvision torchaudio cudatoolkit=11.7 -c pytorch -c conda-forge

如果不用conda,也可以用pip:

pip install torch torchvision torchaudio --index-url https://download.pytorch.org/whl/cu117

注意这里--index-url指定了CUDA 11.7的wheel源。如果不知道自己的CUDA版本,可以运行nvidia-smi查看,上面显示的“CUDA Version”就是驱动支持的最高版本,不一定是已经安装的。不过PyTorch的CUDA运行时是自包含的,只要驱动版本大于等于对应要求就行。

很多新手被“是不是必须要有GPU”这个问题劝退。其实不是。对于MNIST这种小数据集,用CPU训练也就一两分钟一个epoch。所以如果你的电脑没有NVIDIA显卡,或者显卡是A卡,完全不用慌,直接装CPU版本的PyTorch即可:

conda install pytorch torchvision torchaudio cpuonly -c pytorch

我在课程计划里特意说明,第一周不依赖GPU也没关系,等后续做大项目再考虑云服务或换机器都来得及。

2.3 torchvision下载MNIST 404问题的终极解法

这个坑我可以说百分之八十的人都会遇到:在代码里写torchvision.datasets.MNIST(root='./data', download=True),运行后却报HTTP 404错误,或者进度条卡住不动。其实原因很简单:MNIST数据集托管在某些国外服务器上,受网络环境或SSL证书影响,PyTorch内置的下载逻辑经常连不上。但别慌,解决办法有好几种。

方法一:手动下载并放置文件。我们需要的是四个文件:train-images-idx3-ubyte.gz、train-labels-idx1-ubyte.gz、t10k-images-idx3-ubyte.gz、t10k-labels-idx1-ubyte.gz。你可以从一些开源镜像站点下载,也可以让朋友帮忙传一份,然后把它们放到项目根目录的data/MNIST/raw/路径下。注意不要解压,代码会自动识别gzip文件。然后设置download=False就好了。

方法二:修改torchvision源码里的url,替换为国内镜像。这个做法稍微有点侵入性,但很有效。还有方法三:设置环境变量TORCH_HOME指向你已经放好数据集的目录。我个人比较推荐手动下载,因为一劳永逸,而且不受网络变化影响。下面是我的目录结构示例:

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(root='data', train=True, download=False, transform=...)就能顺利加载。这个坑看起来简单,但第一次遇到时真的能卡一晚上,所以一定要记下来。

2.4 为什么torchvision的版本也要对齐

MNIST加载是通过torchvision实现的,而torchvision和torch的版本必须严格对应。比如你装了torch 2.1.0,结果torchvision是0.16.0(对应2.1),但如果你pip时随意混装,可能遇到“module has no attribute 'dataset'”之类的错误。这里的经验是:最好使用pip install torch torchvision同时安装,或者从官网查表。如果你的项目里本来就有一个torch 1.13,但你把torchvision升到0.17,那基本必炸。

简单说,torch和torchvision是一对孪生兄弟,版本号的前两位必须一致:torch 1.13对应torchvision 0.14,torch 2.0对应0.15,2.1对应0.16,2.2对应0.17。这个对应关系在torchvision的安装说明里写得很清楚。如果你用conda安装,会自动解析,但用pip就要自己留意。这一条我写进避坑清单里了。

3. 数据加载与预处理:别小看这几行代码

3.1 从datasets.MNIST到Tensor的数据流

在PyTorch中加载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=False, transform=transform) test_dataset = datasets.MNIST(root='data', train=False, download=False, transform=transform)

这里每一行都有讲究。root是数据存放根目录;train=True表示加载训练集,False表示测试集;transform定义了数据预处理方式;download我们前面说过,如果已经手动放置数据就设成False,否则第一次用True。

datasets.MNIST返回的对象是PIL图像和整数标签的组合,经过transform后,图像会变成形状为[1, 28, 28]的PyTorch张量(channel=1表示灰度),标签还是整数。注意此时像素值已经从0~255变到了0~1左右,再经过Normalize变成近似正态分布。为什么要归一化?因为神经网络在训练时依赖梯度下降,如果输入尺度差别太大,会导致梯度更新震荡,收敛变慢。归一化到均值为0、方差为1,是让梯度更稳定的常用手段。

3.2 DataLoader的参数你真的了解吗

有了Dataset,还需要DataLoader来按批次加载数据:

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

batch_size是每批样本数量,64是常用值,也有用32的。shuffle=True表示每个epoch都打乱数据顺序,防止模型记住样本排列顺序;测试集不需要shuffle。num_workers是加载数据的子进程数,在Linux/macOS上可以设为大于0来加速,但在Windows上如果代码不是在if __name__ == '__main__':保护下运行,设置num_workers>0会报错。所以我建议新手先用0,等熟悉了再考虑提升性能。

这里有个容易被忽略的点:num_workers并不是越大越好。如果机器CPU核心数少,或者数据读取很快(MNIST很小),多线程反而会增加开销。还好MNIST数据集只有几十MB,用0也几乎不慢。我之前在Windows上为了追求速度把num_workers设为6,结果每次运行都报“RuntimeError: DataLoader worker (pid(s) 12345) exited unexpectedly”,后来改成放在main函数里并设成2才正常。这个经验也记进避坑清单。

3.3 对图像做可视化验证

数据加载之后,强烈建议先可视化一下,确认数据和标签对应关系正确。用matplotlib画出来就是下面这样:

import matplotlib.pyplot as plt images, labels = next(iter(train_loader)) print(images.shape) # torch.Size([64, 1, 28, 28]) plt.imshow(images[0].squeeze(), cmap='gray') plt.title(f'Label: {labels[0]}') plt.show()

.squeeze()会把[1, 28, 28]变成[28, 28],这样才能正常用imshow显示。这一步看着简单,但能帮你察觉很多问题,比如归一化后图像颜色可能变深变浅,但形状应该还是清晰的数字。另外,你也可以按images[0].max()和images[0].min()检查数据范围,确认预处理是否生效。我每次拿到新数据集基本都会先做一遍这件事,算是一种“数据体检”。

4. 模型设计:用一个简化的LeNet结构搞定分类

4.1 为什么不用全连接网络?

刚开始学的时候,很多人好奇:MNIST图片是28×28,展开成784个像素,直接接几层全连接网络不就行了?确实可以,但效果一般。我之前试过一个三层全连接网络(784→256→128→10),在测试集上大概能到97%左右,但参数量却有大约20万个,比后面要讲的CNN还多。而且全连接网络没有利用图像的空间结构——它把每个像素当成独立的特征,而CNN通过卷积核能提取局部图案(比如边缘、弧线)。

CNN的优势在于“权值共享”和“局部感受野”:一个小小的3×3卷积核在整张图上滑动,用同样的参数检测不同位置的局部特征。这样一来,模型参数量大幅下降,同时泛化能力反而更强。对于MNIST这种简单任务,一个两层卷积的CNN已经可以达到99%以上的准确率。所以我的建议是,虽然全连接可跑通,但为了学得更扎实,第一周就用CNN,至少明白卷积在干什么。

4.2 卷积神经网络的每层尺寸计算

这里给出我在第P1周使用的模型定义,结构类似经典的LeNet-5简化版,官方示例也用它:

class Net(nn.Module): def __init__(self): super(Net, self).__init__() self.conv1 = nn.Conv2d(1, 32, kernel_size=3, stride=1, padding=0) self.conv2 = nn.Conv2d(32, 64, kernel_size=3, stride=1, padding=0) self.dropout1 = nn.Dropout2d(0.25) self.dropout2 = nn.Dropout2d(0.5) self.fc1 = nn.Linear(64 * 12 * 12, 128) self.fc2 = nn.Linear(128, 10) def forward(self, x): x = self.conv1(x) # [1, 28, 28] -> [32, 26, 26] x = F.relu(x) x = self.conv2(x) # [32, 26, 26] -> [64, 24, 24] x = F.relu(x) x = F.max_pool2d(x, 2) # [64, 24, 24] -> [64, 12, 12] x = self.dropout1(x) x = torch.flatten(x, 1) # [64, 12, 12] -> [9216] x = self.fc1(x) # [9216] -> [128] x = F.relu(x) x = self.dropout2(x) x = self.fc2(x) # [128] -> [10] return F.log_softmax(x, dim=1)

逐层解释。输入图像通道数是1,卷积核数量32,经过conv1后尺寸变为(28-3)/1+1 = 26,所以输出特征图是32×26×26。conv2没有改变宽高?实际上,conv2输入是32通道,输出64通道,卷积核仍是3×3,因此尺寸从26×26缩减到24×24。然后池化2×2,变成12×12。所以全连接层的输入是64×12×12=9216。这里需要手动计算,如果后续修改了图片尺寸或卷积层参数,这个数字也要同步调整。很多人报错说尺寸不匹配,就是因为全连接层输入维度没有跟着改。

4.3 为什么用log_softmax和NLLLoss

模型最后返回log_softmax的结果,配合torch.nn.NLLLoss使用。简单说,log_softmax先计算softmax(把10个类别的分数变成0到1的概率),再取自然对数。因为对数函数是单调递增的,所以最大的概率仍然是最大的对数。NLLLoss则根据真实标签的索引,取出对应位置负的对数概率作为损失。把这两个操作分开,是为了数值稳定性,避免直接算softmax后再算交叉熵时的精度问题。

当然,你也可以不用log_softmax,把模型最后只返回原始logits,然后用torch.nn.CrossEntropyLoss。这个损失函数内部已经把softmax和负对数合并在一起了。两种方式效果等价,但你要确保你的损失函数和模型输出匹配——很多新手把两者搞混,训练时loss不降甚至变成负数,大概率就是这个原因。我在代码里选择log_softmax+NLLLoss,是因为和PyTorch官方示例保持一致,调试起来比较轻松。

5. 训练循环:让模型自己学会认数字

5.1 超参数设置理由

训练代码是整个项目的核心。我的超参数设置如下:

device = torch.device("cuda" if torch.cuda.is_available() else "cpu") model = Net().to(device) criterion = nn.NLLLoss() optimizer = torch.optim.Adam(model.parameters(), lr=0.001) epochs = 10

lr=0.001是Adam优化器最常用的默认值,对于MNIST这种小数据集基本能保证稳定收敛;epochs=10足够让模型在测试集上达到98%以上,再多可能就开始过拟合了。你也可以试试SGD+momentum,收敛速度稍慢,但容易找到更平坦的极小值。第一周我不建议过度调超参,先跑通默认配置再说。

5.2 训练一步的完整代码

下面这个train函数是标准的训练模板:

def train(model, device, train_loader, optimizer, criterion, epoch): model.train() train_loss = 0 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() train_loss += loss.item() if batch_idx % 200 == 0: print('Train Epoch: {} [{}/{} ({:.0f}%)]\tLoss: {:.6f}'.format( epoch, batch_idx * len(data), len(train_loader.dataset), 100. * batch_idx / len(train_loader), loss.item()))

需要注意几点。第一,model.train()必须放在训练前,它会启用Dropout和BatchNorm的训练模式。如果忘记调用,Dropout不生效,模型很容易过拟合。第二,optimizer.zero_grad()必须在loss.backward()之前调用,因为PyTorch的梯度是默认累加的,如果不清零,梯度会累加到旧值上,导致更新方向混乱。第三,data, target = data.to(device), target.to(device)这一行很重要,如果模型在GPU上而数据在CPU上,会报类似“input and target tensors are not on the same device”的错误。第四,loss.item()取出Python浮点数用于记录,且不参与计算图,避免内存累积。

5.3 epoch结束后如何保存最好的模型

训练结束时通常需要保存模型。不要只保存整个模型对象,推荐保存state_dict:

torch.save(model.state_dict(), "mnist_cnn.pt")

state_dict里是模型的参数和缓冲区,体积小、加载方便。加载时这样做:

model = Net().to(device) model.load_state_dict(torch.load("mnist_cnn.pt", map_location=device))

如果训练时用的GPU,加载到CPU机器时,map_location=device会自动映射,避免设备不匹配的报错。还要注意,如果你改变了模型结构,直接加载旧state_dict会报key错误。所以模型定义一旦确定,不要轻易改层。你也可以在训练过程中根据验证集准确率保存最佳模型,而不是只保存最后一个epoch的结果。这是让实验可复现的关键。

6. 模型评估:用测试集检验真实水平

6.1 写一个干净的评估函数

训练完不能光看训练loss,一定要在测试集上评估。评估代码相对简单:

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) correct += pred.eq(target).sum().item() test_loss /= len(test_loader) accuracy = 100. * correct / len(test_loader.dataset) print('Test set: Average loss: {:.4f}, Accuracy: {}/{} ({:.2f}%)\n'.format( test_loss, correct, len(test_loader.dataset), accuracy)) return accuracy

这里有两个关键点。第一,必须调用model.eval(),它会关闭Dropout,并让BatchNorm使用全局统计量。很多人的模型在训练集上98%,一到测试集只有80%,就是忘了这行。第二,with torch.no_grad()告诉PyTorch不要构建计算图,这既省内存又加快推理速度。预测时用output.argmax(dim=1)取每个样本概率最大的类别索引,然后与真实标签比较。correct是所有批次求和的结果,最后除以测试集总样本数10000得到准确率。

6.2 抽几个错误样本来看看

准确率只是数字,有时候看看错在哪更重要。可以这样找出预测错误的样本:

import matplotlib.pyplot as plt model.eval() errors = [] with torch.no_grad(): for data, target in test_loader: output = model(data) pred = output.argmax(dim=1) for i in range(len(target)): if pred[i] != target[i]: errors.append((data[i], target[i], pred[i])) plt.figure(figsize=(10, 4)) for idx, (img, true, pd) in enumerate(errors[:8]): plt.subplot(2, 4, idx + 1) plt.imshow(img.squeeze(), cmap='gray') plt.title(f'真实:{true} 预测:{pd}') plt.axis('off') plt.show()

我跑出来的错误样本往往集中在书写潦草的数字上,比如原本是7但写得像1,或者4写得像9。这些样本即便让人眼判断也可能会误解。所以测试集准确率98%并不是模型蠢,而是任务本身存在一定标注歧义。通过可视化错误样本,你能更理性地看待准确率,而不是一味追求100%。

6.3 训练集和测试集准确率的差距说明了什么

如果训练集准确率很高而测试集明显偏低,典型原因是过拟合。第P1周用的模型不大,加上Dropout和训练数据量较大,不太容易严重过拟合,但依然值得观察。如果出现这种情况,优先检查是否忘了model.eval(),其次再考虑数据预处理是否正确。如果训练集本身准确率就不高,比如只有70%,那说明模型容量不足或者学习率不合适,需要先解决收敛问题。

这里分享一个我常用的经验:每个epoch都记录训练集和测试集的准确率,画一条曲线。在MNIST上,训练集准确率通常会略高于测试集,但差距应该控制在1%左右。如果差距达到3%以上,就说明模型有点记样本了。改进方法包括增加Dropout比例、减小模型规模、加入数据增强,或者提前结束训练。但这个阶段只要保证模型能正常收敛,不追求极致,就可以了。

7. 避坑清单与后续拓展

7.1 新手最容易犯的五个错误(表格速查)

我用一个表格把前面提到的坑汇总一下,方便你排查:

错误现象根本原因解决办法
下载MNIST报404或超时网络问题或证书问题手动下载四个文件到data/MNIST/raw,download=False
DataLoader使用num_workers>0在Windows报错Windows下多进程启动方式不同把代码放在ifname== 'main'中,或num_workers=0
训练完测试准确率特别低没调用model.eval(),Dropout仍在运行评估前加model.eval(),并包裹torch.no_grad()
遇到“Expected input batch_size”之类的尺寸错误模型全连接层输入尺寸和卷积输出不匹配根据实际特征图尺寸调整fc1的输入维度
loss是负数使用了CrossEntropyLoss但模型输出log_softmax统一选择log_softmax+NLLLoss,或直接CrossEntropyLoss+原始logits

这些坑我基本都踩过,而且每个都浪费过不少时间。最大的感受是:出问题时,一定先检查你写的那几行关键小动作。多数问题不是模型设计层面的问题,而是使用习惯层面的。比如忘记.zero_grad()导致的梯度累加,会在训练中表现为loss忽高忽低,这时候如果你去调网络结构,完全是浪费时间。

7.2 我为了提升准确率做过哪些调整

第P1周你可能没有太多调参压力,但如果你想往前多走一步,可以试试这些方向。第一,在模型里加BatchNorm层,通常能让收敛更稳定。第二,使用学习率调度器,比如torch.optim.lr_scheduler.StepLR,每5个epoch把学习率乘0.1。第三,尝试简单的数据增强,比如对图像做随机旋转、平移,虽然MNIST是灰度且居中,但少量增强可以抑制过拟合。第四,把优化器换成SGD+momentum(0.9),并微调学习率。我不建议一上来就用一堆trick,先把基础跑通,再记录不同改动下的准确率,这样你才知道哪一步起了作用。

从我自己的实验来看,基础CNN不加任何增强,用Adam跑10个epoch就能到99%左右。加上BatchNorm和StepLR后,可以达到99.3%以上。但再往上,每提升0.1%都需要更多复杂的改动,比如做宽度学习、模型集成、测试时增强等,投入产出比开始下降。如果你只是学习,98%就已经是“完全搞定”的水平了。

7.3 接下来怎么做:从第P1周到第P2周

MNIST跑通之后,我建议往三个方向延伸。第一,换数据集,把MNIST换成Fashion-MNIST或CIFAR-10,你会发现同样的模型在不同数据上表现完全不同,这会让你对“数据分布”有更深刻的感受。第二,改造模型,试着把LeNet改成ResNet的一个小残差块,理解跳跃连接为什么能解决深层网络退化。第三,深入框架,研究一下torch.nn.Module的注册机制,以及autograd是怎么记录反向传播的,这会让你在调试bug时更游刃有余。

每一步都值得单独花一周,就像“第P2周”“第P3周”那样。如果你按照这个节奏认真走下来,两个月后自然会拥有比较扎实的PyTorch功底。现在回想第P1周遇到的各种问题,大多都是因为对底层机制不够熟悉。这些坑踩得越早,后面就越顺。

最后我再多分享一个小技巧:训练时在代码里固定随机种子。

import torch import numpy as np def set_seed(seed=42): torch.manual_seed(seed) np.random.seed(seed) torch.backends.cudnn.deterministic = True

这样每次都得到同样的初始化权重和数据顺序,实验对比才公平。我一开始没养成这个习惯,导致调整参数后总认为是改动生效了,其实是随机性在作祟。从第P1周就养成这个习惯,会让你后续研究少走很多弯路。

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

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

立即咨询