资讯动态

PyTorch实现LeNet-5识别MNIST手写数字:从原理到实战全解析

发布时间:2026/10/17 11:10:06 来源:尧图企业网站定制
刚把LeNet-5在MNIST上完整跑通的时候我盯着那个99.2%的测试准确率愣了几秒——一个九十年代提出的网络结构用我手头这台不带独显的轻薄本训练几分钟就收敛了。这件事本身就很有意思我们总在追逐越来越大的模型却忘了在手写数字分类这种经典任务上一个结构清晰的小网络依然能干得干净利落。这篇文章不是抄文档式的教程我会把从环境准备到最终评估的整个流程拆开来讲包括我自己踩过的坑和调整过的细节重点放在“每一步为什么这么做”上。这套项目用到的核心组合很经典PyTorch作为深度学习框架LeNet-5作为卷积神经网络结构MNIST作为手写数字图片数据集目标就是让模型学会把28×28的灰度图片正确分类成0到9。适合谁看想认真跑通第一个CNN项目的新手、需要快速验证训练流程的开发者、以及对经典网络结构有好奇心的朋友。不夸张地说把这一条链路吃透后续再去碰ResNet、Transformer甚至目标检测模型你都会发现很多套路是相通的。1. 项目思路与方案选型拆解1.1 为什么是PyTorch、LeNet-5和MNIST选PyTorch不需要纠结太久。它的动态计算图机制让调试变得非常直接我可以用print(model)直接查看网络结构可以在训练循环中间打断点查看某一层输出的形状这种灵活性在动手写网络时是实打实的优势。相比静态图框架PyTorch的写法更接近Python原生逻辑你写forward函数就是在写一个普通的Python方法控制流、循环、条件判断想怎么写就怎么写。LeNet-5是1998年提出的经典卷积神经网络用它做入门再合适不过。结构足够简单——两个卷积层、两个池化层、三个全连接层总共才六万多参数——但它包含了CNN最核心的构件卷积提取局部特征、池化降低分辨率、全连接做分类。把LeNet-5彻底搞明白CNN的基本套路就打通了。MNIST数据集包含六万张训练图片和一万张测试图片每一张都是28×28的灰度手写数字。图片尺寸小训练速度快标注准确不存在类别不平衡问题是验证网络结构是否有效的理想“试验田”。而且它足够干净出了问题时你能确定是自己的代码写错了而不是数据本身有毛病。1.2 整体流程设计与核心路径这个项目的完整链路大概是这样的先加载MNIST数据集并做必要的预处理然后逐层搭建LeNet-5网络结构接着配置损失函数和优化器训练若干个epoch并观察loss变化最后在测试集上评估准确率、绘制混淆矩阵。看起来平平无奇对吧但每个环节都有讲究。数据预处理不只要归一化像素值还涉及Tensor的转换和DataLoader的batch组织方式网络搭建不只要把层的参数写对还要搞清楚每层输入输出的张量形状是怎么变化的训练过程不只要调一个学习率还得理解优化器的行为、loss曲线的含义。很多新手一上来就急着把模型跑起来跑通了却不知道自己写了什么模型效果不好也不知道从哪里排查。我的建议是先花半小时把整个链路在脑子里过一遍在纸上写出每一层输入输出的形状变化再动手写代码。这个习惯能帮你省下大量排查bug的时间。2. 数据准备MNIST加载与预处理2.1 数据集初识不需要额外下载的省心之处用PyTorch加载MNIST非常简单torchvision.datasets.MNIST这个类会处理下载、解压、读取全部流程。第一次运行时会自动下载到指定路径之后直接从本地读取。我在自己机器上跑的时候下载大概花了半分钟数据集大小约十几MB完全不用担心存储问题。有一点值得注意MNIST原始数据里的像素值范围是0到255类型是整数但神经网络训练时希望输入落在小范围内通常的做法是除以255归一化到0到1之间。有个不算冷的知识——MNIST官方数据集里的图片其实是黑底白字背景为0、数字笔迹接近255。有些人会用反转操作把图片变成白底黑字但实践中这两种方式都不影响模型收敛关键是数据处理和模型预期要一致。2.2 预处理与DataLoader配置细节我用transforms.Compose把两个预处理步骤串起来先转成Tensor再做标准化。转Tensor这一步会自动把像素值缩放到0到1区间因为PyTorch的ToTensor默认做了除以255的操作。标准化阶段我用的均值和标准差是(0.1307, 0.3081)这是MNIST全数据集的统计值用了它们能让数据分布更接近标准正态分布有助于训练加速。transform transforms.Compose([ transforms.ToTensor(), transforms.Normalize((0.1307,), (0.3081,)) ]) train_dataset datasets.MNIST(root./data, trainTrue, downloadTrue, transformtransform) test_dataset datasets.MNIST(root./data, trainFalse, downloadTrue, transformtransform) train_loader DataLoader(train_dataset, batch_size64, shuffleTrue) test_loader DataLoader(test_dataset, batch_size64, shuffleFalse)DataLoader里的batch_size和shuffle是训练节奏的关键。我把训练集的shuffle设为True让每个epoch的数据顺序都不一样避免模型学到样本顺序相关的伪规律。测试集则保持shuffleFalse只为了一件事——后续做混淆矩阵可视化时预测结果和真实标签的对应关系不会被打乱。batch_size64是我试过32和128之后的选择。32时每个epoch迭代次数多训练时间明显变长准确率提升却有限128时很快但loss曲线震荡更明显。64是这中间均衡的选择迭代次数适中梯度估计也足够稳定。提示如果你在公司的内网环境或代理受限的机器上跑数据集下载可能失败。解决办法是直接去MNIST官网手动下载四个gz压缩包放到./data/MNIST/raw/目录下再次运行代码时会跳过下载。3. LeNet-5结构逐层拆解与关键参数计算3.1 从输入到卷积层28×28如何变成32×32LeNet-5的原始设计输入是32×32的灰度图但MNIST图片是28×28。这两者之间的差异是理解padding作用的绝佳案例。原始论文没有用padding图像经过两次5×5卷积后从32×32变成28×28再变成14×14如果我们不做padding28×28的输入经过同样两次卷积会变成24×24和20×20既不美观也损失了大量边缘信息。解决办法是给第一层卷积加padding2。这个操作相当于在图像四周各补两圈028×28就变成了32×32然后5×5卷积后再变回28×28模拟出原始LeNet-5的输入条件。说到底padding的意义是控制特征图尺寸变化的节奏让卷积操作可以在不显著缩小分辨率的前提下提取更深层的特征。第一层卷积Conv2d(1, 6, kernel_size5, padding2)的输出是6个通道的28×28特征图。这里6个卷积核理解为6个不同的特征提取器——有的负责检测横线、竖线有的检测弧线、拐角配合出现的特征组合起来就是识别数字所需的“零件”。第二层Conv2d(6, 16, kernel_size5)不做padding输入6个通道的14×14特征图经过池化缩小了一半输出16个通道的10×10特征图。计算特征图尺寸有个通用公式输出尺寸 (输入尺寸 - 卷积核大小 2×padding) / stride 1。第一次卷积(32-52×2)/1128第二次卷积(14-50)/1110。我建议你在纸上算一遍然后对照代码里打印出来的形状检查这个习惯会帮你快速定位维度不匹配的报错。3.2 池化层降采样不只是为了省算力LeNet-5里的池化层用的是平均池化但PyTorch里我用了MaxPool2d(kernel_size2, stride2)。原因很实际PyTorch的AvgPool2d行为与LeNet-5原文中对池化层加入可训练缩放系数的设计不完全一致而最大池化在实践中通常比平均池化表现更好它对特征响应更强的地方给出更大激活值等于在做“选择性通过”。池化的核心作用有两个。第一是降分辨率让后续卷积的感受野变大但计算量变小第二是提供一定的平移不变性像素轻微偏移后池化结果不会剧烈变化。用生活类比你把一张高清照片缩小成缩略图缩略图依然能看出照片里是猫还是狗池化做的就是类似的抽象化处理。第二次池化之后16个通道的10×10特征图变成了16个通道的5×5特征图。此时张量形状是(batch_size, 16, 5, 5)在进入全连接层前需要展平成一维向量长度就是16×5×5400。3.3 全连接层与输出层非线性组合找答案LeNet-5的三个全连接层依次是400→120、120→84、84→10。中间层用ReLU激活函数最后一层不激活直接输出原始得分logits。我在源码里稍微做了调整原始论文的激活函数是tanh我换成了ReLU。原因很直接ReLU计算更快能缓解梯度消失问题在MNIST这种不算深的网络上收敛稳定而且PyTorch新手对ReLU的认知度更高后续迁移到其他网络也顺滑。最终输出10个值每个位置对应一个数字类别的“得分”得分最高的类别就是模型的预测结果。交叉熵损失函数内部会先做softmax把这些得分转成概率分布再和真实标签计算损失。所以不需要在最后一层手动加softmax。整个网络的完整定义如下class LeNet5(nn.Module): def __init__(self): super().__init__() self.conv1 nn.Conv2d(1, 6, kernel_size5, padding2) self.pool1 nn.MaxPool2d(2, 2) self.conv2 nn.Conv2d(6, 16, kernel_size5) self.pool2 nn.MaxPool2d(2, 2) self.fc1 nn.Linear(16 * 5 * 5, 120) self.fc2 nn.Linear(120, 84) self.fc3 nn.Linear(84, 10) def forward(self, x): x torch.relu(self.conv1(x)) x self.pool1(x) x torch.relu(self.conv2(x)) x self.pool2(x) x x.view(-1, 16 * 5 * 5) x torch.relu(self.fc1(x)) x torch.relu(self.fc2(x)) x self.fc3(x) return x这里最容易被忽视的细节是x.view(-1, 16*5*5)这一行的形状变换。-1表示由PyTorch自动推断batch维度。如果前面的参数写错了比如第二层卷积padding多加了一圈展平后特征数量就不是400全连接层输入维度就对应不上报错信息里会明确提示形状不匹配。这类问题非常常见理解了形状推导能省很多事。4. 训练环节超参数选择与训练循环要点4.1 超参数决策loss函数、优化器与学习率我把这次项目的主要超参数选择放在一个表里方便你对照着看超参数我的选择备选方案选择理由损失函数交叉熵损失MSE分类任务的默认选择配合softmax输出效果好优化器Adamlr0.001SGD动量自适应学习率新手友好收敛稳定batch_size6432 / 128收敛速度与稳定性的折中epoch数105 / 1510轮已足够接近最优表现激活函数ReLUtanh / sigmoid缓解梯度消失计算开销小交叉熵损失是分类问题的标配原因是它和softmax天然配合。信息论层面的解释是交叉熵衡量两个概率分布的差异真实标签是正确数字位置为1、其余位置为0的分布模型输出是预测概率分布训练目标就是让这两个分布尽可能接近。优化器选Adam而不是SGD核心考量是省心。Adam会自动调节每个参数的学习率对学习率选择不那么敏感。SGD加上合适的动量也能达到类似效果但对超参数更敏感初学者容易调到发散。如果想进一步精调可以考虑给Adam加余弦退火学习率调度但我实测下来这个项目场景下收益非常有限。学习率0.001是Adam的默认值绝大多数任务都能用这个起点跑通。我曾经试过0.01损失值在初期不降反升很快出现NaN——这就是学习率过大的典型特征。如果你发现loss曲线突然变成NaN第一反应就是把学习率调小一个数量级。4.2 训练循环从零开始的规范写法我平时写训练循环习惯用函数封装因为后续会反复用到。关键是几个要点把模型设为训练模式调用model.train()每个epoch遍历DataLoader清空梯度optimizer.zero_grad()前向传播计算loss反向传播更新参数。def train_one_epoch(model, train_loader, criterion, optimizer, device): model.train() running_loss 0.0 correct 0 total 0 for images, labels in train_loader: images, labels images.to(device), labels.to(device) optimizer.zero_grad() outputs model(images) loss criterion(outputs, labels) loss.backward() optimizer.step() running_loss loss.item() _, predicted torch.max(outputs.data, 1) total labels.size(0) correct (predicted labels).sum().item() avg_loss running_loss / len(train_loader) accuracy 100.0 * correct / total return avg_loss, accuracyoptimizer.zero_grad()这一行不能漏。PyTorch的梯度默认是累积的如果不清空梯度会叠加到上一轮的数值上参数更新方向就会错乱。一个典型的错误是把这个调用放在loss.backward()之后那样等于这轮迭代没清空下轮迭代把两轮的梯度混在一起用。关于设备问题我默认用CPU训练这个模型就够用了一个epoch大约十秒左右。如果你的机器有NVIDIA显卡用model.to(cuda)和images.to(cuda)把数据搬到GPU上速度会有几十倍提升。判断设备是否可用的标准写法是device torch.device(cuda if torch.cuda.is_available() else cpu)这样自动选择省得在不同机器上反复改。我打印了10个epoch的训练日志第一轮结束准确率约96%第三轮达到98%以上后面几轮在98%~99%区间微调。这种快速收敛的现象很有LeNet-5风格网络容量有限而MNIST任务相对简单所以典型的曲线就是前期猛涨后面趋于平缓。4.3 训练过程中的典型陷阱与判断依据第一个陷阱是输出层激活函数画蛇添足。有些人习惯在最后一层加softmax然后配合nn.CrossEntropyLoss使用这会导致训练loss偏高、难以下降因为交叉熵损失内部已经做过一次softmax等于做了两次概率变换梯度信号被污染了。判断方法如果训练loss初始就在2以上且下降很慢多半就是这个问题。第二个陷阱是训练和测试模式混淆。PyTorch的model.train()和model.eval()会改变某些层的行为最典型的是BatchNorm和Dropout。虽然LeNet-5里我没用这两种层但养成习惯有好处train模式开启数据增强和正则化相关的行为eval模式确保推理时统计量稳定输出确定的结果。第三个问题更隐蔽——类别数字化与标签形状。MNIST的标签是0到9的整数交叉熵损失直接接受这种形式不需要做one-hot编码。如果你从某个教程里看到先把标签转成one-hot再喂给模型的旧式写法注意它对应的是nn.MultiLabelSoftMarginLoss之类的损失函数不要混淆。5. 测试评估与结果可视化5.1 评估指标准确率之外还需要什么模型训练完成后评估环节要区分“模型记住了训练数据”和“模型学到了泛化规律”。MNIST测试集是训练时未见过的一万张图片测试准确率才是模型真实水平的标尺。我的测试代码会记录三件事整体准确率、每个类别的准确率、以及预测和真实标签的配对列表。def evaluate(model, test_loader, device): model.eval() correct 0 total 0 all_preds [] all_labels [] with torch.no_grad(): for images, labels in test_loader: images, labels images.to(device), labels.to(device) outputs model(images) _, predicted torch.max(outputs.data, 1) total labels.size(0) correct (predicted labels).sum().item() all_preds.extend(predicted.cpu().numpy()) all_labels.extend(labels.cpu().numpy()) accuracy 100.0 * correct / total return accuracy, all_preds, all_labels测试时用torch.no_grad()包住整个循环很关键。这会告诉PyTorch不需要计算梯度从而关闭自动求导的追踪和显存分配推理速度提升的同时也避免意外修改模型参数。初学者容易忘记后果是测试过程占用大量的显存和计算资源尤其当模型较大或数据较多时明显卡顿。运行结果是测试集准确率约99.1%对一万张图片只有大约九十个错误。这在MNIST上算是合格水平因为接近100%但偶尔的误判都集中在笔迹潦草的数字上。如果你用混淆矩阵查看会发现5和3、4和9之间最容易混淆——这些数字的局部特征相似度高确实难认。5.2 可视化分析从混淆矩阵到特征图我建议在这个项目里做两种可视化第一个是混淆矩阵。confusion matrix直观展示哪些类别被识别成了谁对角线越亮越好非对角线位置的亮点就是模型犯错的集中区域。from sklearn.metrics import confusion_matrix import seaborn as sns import matplotlib.pyplot as plt cm confusion_matrix(all_labels, all_preds) plt.figure(figsize(10, 8)) sns.heatmap(cm, annotTrue, fmtd, cmapBlues) plt.xlabel(Predicted) plt.ylabel(True) plt.show()第二种可视化更有深度——把第一层卷积的6个卷积核画出来。6个5×5的卷积核本质上就是6张小图可以看出模型学到了什么样的边缘检测模式。这能帮你直观理解卷积核在做什么有的核中间亮、两侧暗检测垂直边缘有的横着亮检测水平边缘有的是对角模式检测斜向笔画。再进一步可以把某个测试图片输入到模型中输出第一层卷积后的6张特征图。你会发现每张图高亮的地方各不相同。一张潦草的“7”有的特征图高亮它的右侧竖线有的高亮它的左上角横线。这种“零件分解再组合”的过程就是CNN识别数字的核心机制。注意可视化特征图时需要把特征图的值从负数区间映射到0到255的可显示范围一般用(x - x.min()) / (x.max() - x.min())做归一化。否则直接用原始张量作图会得到一张接近全黑的图。6. 常见问题排查与经验速查6.1 问题定位三板斧我在调试这个项目的过程中总结了一套快速定位问题的思路按顺序排查能省不少时间。第一板斧是看数据形状。几乎所有维度相关报错都能通过输出每层输出的shape来定位。在你自己的代码里临时加几行print(x.shape)跑一次前向传播看哪个位置的形状与期望不符。我的经验是80%的网络搭建错误都是这个手段找到的。第二板斧是看loss数值。训练初期loss是否正常下降、是否出现NaN、是否反复震荡分别对应不同的原因。NaN一般是学习率过大、梯度爆炸或数据里有无效值震荡可能是batch_size过小、学习率略高或者训练数据顺序没有shuffle。第三板斧是看训练集和测试集的表现差距。训练集准确率很高但测试集很低就是过拟合两者都低则是欠拟合或网络结构有问题。写代码前先明确这个诊断逻辑后面调参会非常高效。6.2 常见问题速查表问题现象可能原因解决办法训练loss一直是2.3附近不下降输出层和损失函数重复softmax移除最后一层的softmax操作第一个epoch准确率接近10%数据标签错位或网络输出未对齐检查DataLoader的label字段检查类别数是否等于10loss突然变NaN学习率过大或梯度爆炸学习率调小一个数量级检查输入数据是否存在无穷值测试准确率远低于训练准确率过拟合增加数据增强、添加Dropout或减小模型容量报错“size mismatch”某个层的输入维度与前一层输出不匹配打印各层形状逐层比对CPU训练特别慢未利用GPU或batch过大用model.to(cuda)适当减小batch_size6.3 性能改进的几个方向虽然基础的LeNet-5已经能达到99%准确率但如果你想让数字再往上爬一爬有几个温和的改进方向不用大改网络结构。第一个是加Dropout。在后面的全连接层之间加nn.Dropout(p0.5)训练时随机丢弃一部分神经元减少全连接层的过拟合倾向。我实测的效果是从99.0%提升到约99.2%提升幅度有限但确实有正向作用。第二个是数据增强。对MNIST做随机旋转±10度、平移1-2像素等轻微扰动让模型看到更多样的数字写法。这里有一个重要提醒测试时不要用数据增强否则评估结果不稳定无法判断模型在原始数据上的真正水平。第三个是调整优化器策略。改用SGD momentum如SGD(model.parameters(), lr0.01, momentum0.9)配合学习率衰减有时能比Adam达到更高精度代价是需要多调几组参数。如果时间有限保持Adam默认配置完全够用。回到我开头说的那个瞬间——一个九十年代的网络结构在今天依然能稳妥地解决MNIST分类任务。这种感觉就像用一把做工扎实的老式工具完成一件标准工件却依然顺手。LeNet-5的价值不只是教科书里的一张结构图它是理解现代卷积神经网络的最佳起点。把数据管道、网络定义、训练循环、评估环节这四个模块全部吃透你再用PyTorch去做任何其他图像项目骨架都是相同的一套。后面如果再进阶可以试试把LeNet-5换成ResNet或VGG对比它们在MNIST上的表现差异想深挖的话还可以用这个结构去跑Fashion-MNIST看看不同数据分布下网络的表现会如何迁移。先把这个项目彻底跑顺你踩过的每一个坑都会变成后续项目里的经验储备。

读完文章,也想定制专属网站?

尧图设计师 24 小时内与您沟通定制方案

免费获取报价 →
↑