拓冰建站拓冰建站
首页 / 资讯中心 / 正文

PyTorch手写数字识别实战:从数据加载到CNN模型训练全解析

简介面向深度学习初学者与图像分类入门者这套基于PyTorch的MNIST手写数字识别项目提供了一份带详细注释的完整工程覆盖数据预处理、CNN模型定义、损失函数与优化器选择、训练循环、评估验证、模型保存加载及可视化等关键步骤。资源包共17个文件、约33.16MB以3个Python源文件模型、训练、评估为主体另含预训练权重/检查点文件、训练过程图片和MNIST原始数据文件解压后即可对照运行。目前已有2138人学习下载。项目以经典卷积神经网络实现手写数字分类代码结构清晰可让读者直观理解PyTorch框架下从数据流到参数更新的完整链路既能作为深度学习的第一个实战模板也能快速迁移到其他图像分类任务中是一份兼具教学价值与工程参考意义的入门资源。 我第一次跑通手写数字识别那天几乎所有新手该踩的坑都踩了一遍数据没归一化、模型不收敛、GPU压根没派上用场……最后在笔记本CPU上硬是磨出了97%的准确率。后来回头看MNIST这套流程就像深度学习的“眼保健操”招式不多却能把数据加载、模型构建、训练评估整个骨架练得明明白白。这篇文章不搞花活就踏踏实实把手写数字识别从数据到模型再到训练串一遍用的是深度学习领域最主流的PyTorch框架代码全部带详细注释。不管你是刚装好PyTorch的小白还是想复盘基础流程的进阶选手照着敲一遍你也能在几分钟内看到自己的模型把0到9认得像模像样。整个项目最终能跑到99%以上的准确率但真正值钱的是你在这套流程里建立起来的“深度学习肌肉记忆”。1. 整体方案选型为什么是MNIST配上PyTorch1.1 MNIST数据集到底特殊在哪MNIST全称是Modified National Institute of Standards and Technology由6万张训练图片和1万张测试图片组成每张都是28×28像素的灰度图内容是手写的0到9。这个数据集在深度学习圈子里被反复用了十多年原因简单粗暴足够小、足够标准、反馈足够快。小到什么程度一张图只有784个像素点把整个数据集加载进内存也就几十MB。这意味着你在普通笔记本CPU上也能在几分钟内完成一轮完整训练。对一个刚接触深度学习的人来说这种“即时正反馈”极其重要——你调一个学习率改一层网络立刻能看到准确率变化不需要像做CV大模型那样等上几个小时的训练时间。我第一次跑MNIST的时候还把数据集下载地址纠结了半天其实完全没必要。torchvision库里自带MNIST下载器指定参数后它会自动从官方源拉到本地省去了手工下载、解压、转换格式的麻烦。这种“开箱即用”的数据集天然就是入门首选。1.2 为什么选PyTorch作为框架PyTorch这几年几乎成了学术圈和工业界的默认选项核心原因是它的动态计算图机制。什么意思你用TensorFlow老版本写网络时得先把整张计算图定义好再往里面灌数据改结构就得重新构图。而PyTorch的模型结构在每次前向传播时动态生成调试时可以直接用print打断点甚至用pdb一路单步查这在排查维度对不上、梯度为NaN这类问题时效率直接甩开静态图框架一个身位。另外PyTorch的生态配套做得非常完善torchvision管数据集和预训练模型torch.utils.data管数据装载配合NumPy、Matplotlib这些常用Python库写手写数字识别这种项目几乎不需要额外引第三方包。社区里的案例和博客数量也是最多的遇到问题随便一搜就有解决方案对新手极其友好。1.3 模型路线从全连接到卷积神经网络手写数字识别有两种典型路线一种是用多层感知机MLP直接把784个像素拉平成向量送进全连接层另一种是卷积神经网络CNN保留图片的二维结构用卷积核提取局部特征。我的建议是如果你想理解深度学习的核心流程先用MLP跑通一遍再看它和CNN的准确率差距感受会非常深。MLP在MNIST上大概能到97%到98%而CNN轻松就能到99%以上。差距来自一个关键思想图片中的数字边缘、纹理、笔画结构都是局部相关的CNN用卷积核在局部窗口滑动能在保留空间关系的前提下提取特征而全连接层把每个像素独立对待模型要靠大量参数硬“背”像素组合模式效率自然低了。这就好比认人脸MLP是让你把整张脸的每个像素点都单独记下来CNN则是先看眼睛、鼻子、嘴巴各自长什么样再看它们相对位置对不对。后者显然更符合人类认知习惯泛化能力也更强。所以这篇文章我直接采用CNN路线但会把每一层的维度变化讲透确保你即使第一次接触卷积也能清楚每个数字是怎么从28×28一路变成10个分类概率的。2. 环境准备与数据装载先把数据喂进模型2.1 环境版本与依赖安装做这个项目你只需要三个Python包torch、torchvision、matplotlib。torch是核心计算库torchvision提供数据集和图像处理工具matplotlib用来可视化结果。安装时注意一个小坑不要图省事直接pip install torch一把梭最好根据你自己的显卡情况选择对应版本。没有NVIDIA显卡的话直接装CPU版就行MNIST这种小数据集CPU完全够用有显卡但不会看CUDA版本的可以在命令行输入nvidia-smi查看驱动对应的CUDA版本然后去PyTorch官网用生成的命令安装。我第一次装的时候没看版本装了个CPU版但一直以为自己用的GPU训练速度慢得离谱后来才发现压根没调用显卡。验证环境是否装好可以跑这三行命令import torch import torchvision print(torch.__version__) # 比如 2.0.0 print(torch.cuda.is_available()) # 有显卡且装对版本会输出 True2.2 数据预处理与归一化的门道MNIST原始图片是0到255的灰度值直接喂给神经网络也不是不行但效果会差不少。原因在于神经网络里的权重初始化通常默认输入在0到1附近如果输入是0到255的大数值第一层加权求和后很容易让激活函数进入饱和区梯度变得特别小模型很难收敛。所以标准做法是加一个归一化处理把像素值从0到255缩放到0到1再进一步做标准化让数据分布接近标准正态分布。torchvision里的transforms模块封装好了一切from torchvision import datasets, transforms transform transforms.Compose([ transforms.ToTensor(), # 将PIL Image或numpy数组转为Tensor并把[0,255]缩放到[0.0,1.0] transforms.Normalize((0.1307,), (0.3081,)) # 用MNIST数据集的全局均值和标准差做标准化 ])这里的0.1307和0.3081是MNIST整个数据集的像素均值和标准差是官方统计好的固定值。标准化之后每个像素的分布大致落在-1到1之间模型训练会更稳定。2.3 DataLoader的加载机制PyTorch里加载数据不是一次性把6万张图丢进模型而是通过DataLoader按批次迭代取数据。这样做的原因很朴素内存装不下全部数据而且按小批次更新参数比全量更新更稳迭代效率也更高。train_dataset datasets.MNIST(root./data, trainTrue, downloadTrue, transformtransform) test_dataset datasets.MNIST(root./data, trainFalse, downloadTrue, transformtransform) batch_size 64 train_loader torch.utils.data.DataLoader(train_dataset, batch_sizebatch_size, shuffleTrue) test_loader torch.utils.data.DataLoader(test_dataset, batch_sizebatch_size, shuffleFalse)shuffleTrue表示每个epoch都重新打乱训练数据防止模型按固定顺序学到不存在的“规律”测试集不需要打乱所以设成False。batch_size64意味着每次迭代取64张图6万张训练集一个epoch大约是938次迭代。batch_size调的思路后面会专门讲这里先记住小一点模型更新频繁但训练震荡大一点训练平稳但吃显存。3. 模型构建五层小网络背后的设计逻辑3.1 网络结构逐层拆解我用的CNN结构很经典两个卷积块加三个全连接层每层都配有对应的激活函数和池化操作。直接看代码import torch.nn as nn import torch.nn.functional as F class Net(nn.Module): def __init__(self): super(Net, self).__init__() # 第一卷积块输入1通道(灰度图)输出32通道卷积核5x5 self.conv1 nn.Conv2d(1, 32, kernel_size5, stride1, padding2) # 第二卷积块输入32通道输出64通道卷积核5x5 self.conv2 nn.Conv2d(32, 64, kernel_size5, stride1, padding2) # 全连接层经过两次池化后图像从28x28缩小到7x7再乘64个通道 self.fc1 nn.Linear(64 * 7 * 7, 512) self.fc2 nn.Linear(512, 128) self.fc3 nn.Linear(128, 10) # 输出10个类别 def forward(self, x): x F.relu(self.conv1(x)) # 卷积ReLU激活 x F.max_pool2d(x, 2) # 2x2最大池化尺寸减半 x F.relu(self.conv2(x)) # 卷积ReLU激活 x F.max_pool2d(x, 2) # 2x2最大池化尺寸减半 x x.view(x.size(0), -1) # 展平操作将多维特征图拉成一维向量 x F.relu(self.fc1(x)) # 全连接层1 x F.relu(self.fc2(x)) # 全连接层2 x self.fc3(x) # 输出层不接激活函数 return x如果你第一次写网络可能对维度变化有点懵。我带你走一遍输入x的形状是[64, 1, 28, 28]64是batch size1是通道数28×28是高宽。经过conv1padding2且卷积核5×5输出尺寸不变仍是28×28所以变成[64, 32, 28, 28]。接一个2×2最大池化高宽各除以2变成[64, 32, 14, 14]。conv2后得到[64, 64, 14, 14]再池化得到[64, 64, 7, 7]。展平后变成[64, 64×7×7]也就是[64, 3136]正好接上fc1的输入维度。最后输出层的10个值对应0到9十个类别的“得分”。注意最后一层没有接ReLU或Sigmoid因为后面要用CrossEntropyLoss它内部自带了Softmax操作提前激活反而会画蛇添足。3.2 为什么用ReLU而不是Sigmoid老式神经网络里Sigmoid很常见但对深层网络不友好它的梯度在输入绝对值较大时几乎为0反向传播时梯度连乘很快就“消失”了前面的层根本学不到东西。ReLU在正区间梯度恒为1能有效缓解梯度消失问题计算还简单只需一个max(0, x)所以在CNN里成了标配。ReLU唯一的副作用是负输入直接被截断成0可能会导致某些神经元“死亡”。实际工程中如果真的出现大量死亡神经元可以考虑LeakyReLU不过在MNIST这种小模型里ReLU完全够用不需要额外处理。3.3 损失函数与优化器怎么配损失函数我用nn.CrossEntropyLoss()这是分类问题的事实标准。它把全连接层输出的10个原始得分做Softmax归一化成概率再和真实标签计算交叉熵。用PyTorch写起来只需两行criterion nn.CrossEntropyLoss() optimizer torch.optim.Adam(model.parameters(), lr0.001)优化器我选Adam而不是传统的SGD。Adam结合了Momentum和RMSProp的优点对学习率的敏感度低很多调参压力小。新手阶段用Adam能少掉很多头发。它的lr0.001是默认值在MNIST上表现不错。等你对训练过程更熟悉可以再试试SGD加Momentum有时在测试集上的泛化表现更好。4. 训练与评估从损失曲线到99%准确率4.1 一份带详细注释的训练脚本训练循环是整个项目的发动机逻辑就三件套前向传播算损失、反向传播算梯度、优化器更新参数。代码注释我写得很详细def train(model, device, train_loader, optimizer, epoch): model.train() # 切换到训练模式启用Dropout、BatchNorm等训练行为 for batch_idx, (data, target) in enumerate(train_loader): data, target data.to(device), target.to(device) # 数据搬到GPU或CPU optimizer.zero_grad() # 梯度清零否则PyTorch会累加上一个batch的梯度 output model(data) # 前向传播得到预测得分 loss criterion(output, target) # 计算损失 loss.backward() # 反向传播计算每个参数的梯度 optimizer.step() # 根据梯度更新参数 if batch_idx % 200 0: print(fTrain Epoch: {epoch} [{batch_idx * len(data)}/{len(train_loader.dataset)} f({100. * batch_idx / len(train_loader):.0f}%)]\tLoss: {loss.item():.6f})这里有一个新手最常见的问题为什么每次迭代前要optimizer.zero_grad()因为PyTorch的反向传播会把梯度累加到参数的.grad属性上如果不手动清零第二次反向传播的梯度就会和第一次的叠加参数更新方向就被污染了。忘了清零是我见过最多的bug之一。4.2 测试函数与train/eval模式切换训练完成后要在测试集上评估。测试和训练有几个关键区别不需要计算梯度、不需要更新参数、模型要切换到eval模式def test(model, device, test_loader): model.eval() # 切换到评估模式关闭Dropout、固定BatchNorm参数 correct 0 total 0 with torch.no_grad(): # 停止梯度追踪节省内存和计算 for data, target in test_loader: data, target data.to(device), target.to(device) output model(data) pred output.argmax(dim1, keepdimTrue) # 取得分最高的类别作为预测结果 correct pred.eq(target.view_as(pred)).sum().item() total data.size(0) accuracy 100. * correct / total print(fTest set: Accuracy: {correct}/{total} ({accuracy:.2f}%)) return accuracymodel.eval()和model.train()的切换不是形式主义。Dropout层在训练时按概率随机丢弃神经元这是为了制造集成效果、防止过拟合如果测试时不关掉预测结果就会有随机性同一个样本两次预测可能不一样。BatchNorm层在训练时用当前batch统计量做归一化在测试时用训练阶段累计的全局统计量不切换模式也会导致错误。这两个坑在真实项目里都很常见一定要养成习惯。4.3 训练效果评判与调参方向完整训练10个epochCNN在测试集通常能到99%以上。但99%之后每提升0.1%都很难因为MNIST里确实有少量标注错误和极其潦草的数字。这时候不要盲目追求100%学会看训练曲线判断模型状态更重要。关注两个数字训练集准确率和测试集准确率。如果训练集准确率高但测试集明显低这是过拟合说明模型把训练样本的“个性”背下来了没学到共性可以加Dropout、增大数据增强、减小模型容量。如果两个准确率都不高可能是欠拟合说明模型容量不够或者训练不充分可以加深加宽网络、调大学习率、增加训练轮数。我自己的排查顺序是先用两三个epoch观察loss是否持续下降如果loss不动甚至NaN大概率是数据预处理问题或学习率太大如果loss在降但准确率原地踏步就去看是不是标签和预测的维度对不上。维度不匹配的错误提示一般很明确但新手容易在reshape和view上翻车建议多用print(output.shape)检查。5. 新手高频问题排查实录5.1 训练慢到怀疑人生很多人第一步就卡在“CPU训练太慢”。MNIST虽然小但如果你电脑本身性能一般也建议把数据转移到GPUdevice torch.device(cuda if torch.cuda.is_available() else cpu) model Net().to(device)训练循环里把data, target也用.to(device)搬到对应设备。这里的关键是模型和数据必须在同一个设备上否则PyTorch会报错说“Expected all tensors on same device”。5.2 准确率一直卡在90%左右上不去90%这个关口很典型通常不是你模型的问题而是数据或者训练流程的问题。最常见的原因是忘了归一化灰度值0到255直接进模型。解决方法是确认transforms里有ToTensor和Normalize不要一开始为了简化就跳过。另外检查batch_size是否太大或太小。batch_size64是比较均衡的选择太小会导致梯度震荡太大则一个epoch内参数更新次数少训练速度慢。学习率也是关键Adam默认0.001在MNIST上很稳如果手动改过建议先调回默认值。5.3 DataLoader报多进程错误在Windows上用DataLoader时如果num_workers设置大于0有时会报BrokenPipeError。这不是你的代码逻辑错了而是Windows下多进程数据处理和主程序之间的兼容性问题。最简单的方法是把num_workers设为0或者把训练代码包在if __name__ __main__:里。我习惯在Windows上默认不设置num_workers反正在MNIST这种小数据集上数据加载优化的收益远没到影响整体速度的程度稳定性更重要。5.4 模型预测结果可视化不直观训练完干瞪眼不够爽建议把预测结果画出来看看。可以随机取测试集的8张图打印真实标签和预测标签再用matplotlib以2×4网格展示import matplotlib.pyplot as plt # 取一个batch data, target next(iter(test_loader)) output model(data.to(device)) pred output.argmax(dim1).cpu() # 显示前8张 fig, axes plt.subplots(2, 4, figsize(8, 4)) for idx, ax in enumerate(axes.flatten()): ax.imshow(data[idx][0], cmapgray) ax.set_title(fTrue: {target[idx].item()}, Pred: {pred[idx].item()}) ax.axis(off) plt.show()看到模型把哪些数字认错、哪些数字画得歪歪扭扭却被正确识别你会对CNN学到的特征有很直观的感觉。我印象最深的是模型偶尔会把手写的9认成4看了图片后发现确实是那种收笔带弧度的写法人眼都会犹豫何况机器。5.5 后续还能往哪个方向扩展跑通这个项目后可以试着改改网络结构比如把卷积核改成3×3加一层BatchNorm或者把全连接层换成全局平均池化也可以试着换数据集比如Fashion-MNIST或CIFAR-10感受不同任务对模型的要求差异还可以引入数据增强在训练时对图片做随机旋转平移提高模型的鲁棒性。我个人经验是真正把手写数字识别吃透的标准不是跑出99%准确率而是能做到“改任何一处结构都能预测出大概效果”。比如你知道加了Dropout会让训练准确率略降但测试准确率提升你知道学习率从0.001改成0.01会让loss抖动更剧烈。这些手感只有亲手改代码、跑实验、看曲线才能积累起来。最后再分享一个小技巧把训练好的模型保存成文件以后直接加载复用不用每次从头训练。torch.save(model.state_dict(), mnist_cnn.pth) # 之后需要推理时 model.load_state_dict(torch.load(mnist_cnn.pth)) model.eval()我已经拿这套流程带过好几批入门的朋友凡是认真照着敲一遍的后面再学、目标检测、LSTM都要顺畅得多。深度学习没有那么多玄学把MNIST这个地基打牢后面的路会好走一半。本文还有配套的精品资源点击获取
分享:

看完干货,该让你的企业上线了

免费需求沟通 · 48 小时内出具建站方案 · 河南本地可上门