ARTICLE DETAIL

资讯详情

深耕郑州网站建设与运营推广的一线实战洞察。

PyTorch实战:MNIST手写数字识别全流程详解与踩坑记录

PyTorch实战:MNIST手写数字识别全流程详解与踩坑记录 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 python3.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.92.2 安装命令与GPU支持装环境需要根据你的硬件选择CPU版还是GPU版。如果电脑有NVIDIA独立显卡并且装好了驱动那大概率可以装CUDA版的PyTorch。以我的环境为例我用的是CUDA 11.7安装命令是conda install pytorch torchvision torchaudio cudatoolkit11.7 -c pytorch -c conda-forge如果不用conda也可以用pippip 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, downloadTrue)运行后却报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文件。然后设置downloadFalse就好了。方法二修改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(rootdata, trainTrue, downloadFalse, 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.14torch 2.0对应0.152.1对应0.162.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(rootdata, trainTrue, downloadFalse, transformtransform) test_dataset datasets.MNIST(rootdata, trainFalse, downloadFalse, transformtransform)这里每一行都有讲究。root是数据存放根目录trainTrue表示加载训练集False表示测试集transform定义了数据预处理方式download我们前面说过如果已经手动放置数据就设成False否则第一次用True。datasets.MNIST返回的对象是PIL图像和整数标签的组合经过transform后图像会变成形状为[1, 28, 28]的PyTorch张量channel1表示灰度标签还是整数。注意此时像素值已经从0~255变到了0~1左右再经过Normalize变成近似正态分布。为什么要归一化因为神经网络在训练时依赖梯度下降如果输入尺度差别太大会导致梯度更新震荡收敛变慢。归一化到均值为0、方差为1是让梯度更稳定的常用手段。3.2 DataLoader的参数你真的了解吗有了Dataset还需要DataLoader来按批次加载数据from torch.utils.data import DataLoader train_loader DataLoader(datasettrain_dataset, batch_size64, shuffleTrue, num_workers0) test_loader DataLoader(datasettest_dataset, batch_size64, shuffleFalse, num_workers0)batch_size是每批样本数量64是常用值也有用32的。shuffleTrue表示每个epoch都打乱数据顺序防止模型记住样本排列顺序测试集不需要shuffle。num_workers是加载数据的子进程数在Linux/macOS上可以设为大于0来加速但在Windows上如果代码不是在if __name__ __main__:保护下运行设置num_workers0会报错。所以我建议新手先用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(), cmapgray) plt.title(fLabel: {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_size3, stride1, padding0) self.conv2 nn.Conv2d(32, 64, kernel_size3, stride1, padding0) 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, dim1)逐层解释。输入图像通道数是1卷积核数量32经过conv1后尺寸变为(28-3)/11 26所以输出特征图是32×26×26。conv2没有改变宽高实际上conv2输入是32通道输出64通道卷积核仍是3×3因此尺寸从26×26缩减到24×24。然后池化2×2变成12×12。所以全连接层的输入是64×12×129216。这里需要手动计算如果后续修改了图片尺寸或卷积层参数这个数字也要同步调整。很多人报错说尺寸不匹配就是因为全连接层输入维度没有跟着改。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_softmaxNLLLoss是因为和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(), lr0.001) epochs 10lr0.001是Adam优化器最常用的默认值对于MNIST这种小数据集基本能保证稳定收敛epochs10足够让模型在测试集上达到98%以上再多可能就开始过拟合了。你也可以试试SGDmomentum收敛速度稍慢但容易找到更平坦的极小值。第一周我不建议过度调超参先跑通默认配置再说。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_dicttorch.save(model.state_dict(), mnist_cnn.pt)state_dict里是模型的参数和缓冲区体积小、加载方便。加载时这样做model Net().to(device) model.load_state_dict(torch.load(mnist_cnn.pt, map_locationdevice))如果训练时用的GPU加载到CPU机器时map_locationdevice会自动映射避免设备不匹配的报错。还要注意如果你改变了模型结构直接加载旧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(dim1) 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(dim1)取每个样本概率最大的类别索引然后与真实标签比较。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(dim1) 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(), cmapgray) 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/rawdownloadFalseDataLoader使用num_workers0在Windows报错Windows下多进程启动方式不同把代码放在ifname main中或num_workers0训练完测试准确率特别低没调用model.eval()Dropout仍在运行评估前加model.eval()并包裹torch.no_grad()遇到“Expected input batch_size”之类的尺寸错误模型全连接层输入尺寸和卷积输出不匹配根据实际特征图尺寸调整fc1的输入维度loss是负数使用了CrossEntropyLoss但模型输出log_softmax统一选择log_softmaxNLLLoss或直接CrossEntropyLoss原始logits这些坑我基本都踩过而且每个都浪费过不少时间。最大的感受是出问题时一定先检查你写的那几行关键小动作。多数问题不是模型设计层面的问题而是使用习惯层面的。比如忘记.zero_grad()导致的梯度累加会在训练中表现为loss忽高忽低这时候如果你去调网络结构完全是浪费时间。7.2 我为了提升准确率做过哪些调整第P1周你可能没有太多调参压力但如果你想往前多走一步可以试试这些方向。第一在模型里加BatchNorm层通常能让收敛更稳定。第二使用学习率调度器比如torch.optim.lr_scheduler.StepLR每5个epoch把学习率乘0.1。第三尝试简单的数据增强比如对图像做随机旋转、平移虽然MNIST是灰度且居中但少量增强可以抑制过拟合。第四把优化器换成SGDmomentum(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(seed42): torch.manual_seed(seed) np.random.seed(seed) torch.backends.cudnn.deterministic True这样每次都得到同样的初始化权重和数据顺序实验对比才公平。我一开始没养成这个习惯导致调整参数后总认为是改动生效了其实是随机性在作祟。从第P1周就养成这个习惯会让你后续研究少走很多弯路。
返回列表