☰
LeNet-5手写数字识别:CNN卷积神经网络原理与PyTorch复现
2026/10/22 2:55:25 网站建设 项目流程

不知道你有没有过这样的阶段:深度学习的理论看了不少,卷积、池化、激活函数这些概念都能说得头头是道,但真要自己动手实现一个CNN,面对一堆张量维度变化时还是会卡住。如果有这种情况,我强烈建议你别一上来就追最新的Transformer或者上百层的ResNet,而是把一个1998年提出的、只有6万多参数的小网络彻底搞明白,我说的就是LeNet-5。

LeNet-5是用来识别手写数字的经典卷积神经网络,同时也是整个CNN家族的开山之作之一。它虽然小,但五脏俱全:卷积层、下采样层、全连接层、特征提取、分类决策这些现代视觉模型的核心机制都有,而且结构清晰到几乎是为教学量身定做的。这篇内容就是我完整复现和调优LeNet-5的过程记录,包含每一层的参数计算、PyTorch代码、训练心得和踩过的坑,适合刚入门深度学习、想真正搞懂CNN底层逻辑的开发者参考。

1. 为什么要学LeNet-5:一个迷你但完整的CNN范本

1.1 LeNet-5在深度学习史上的位置

LeNet-5由深度学习领域的先驱团队在1998年正式提出,当时的主要应用场景是银行支票上的手写数字识别。在它之前,图像识别的主流做法是手工设计特征,比如提取边缘、纹理信息,再配合分类器。这类方法最大的问题是特征设计严重依赖人工经验,换个字体、换个背景可能就失效了。

LeNet-5的革命性在于它把“特征提取”这一步也交给了网络自己学习。卷积层自动从原始像素中学习到边缘、弧线、局部纹理等低层特征,再通过多层堆叠组合成更高级的模式,最后用全连接层完成分类。这个“端到端学习”的思路,直到今天仍是几乎所有视觉模型的基本范式。

之所以现在还要回头学这个老古董,是因为它足够简单,简单到你可以把每一层输出的尺寸、参数量、感受野变化都手算清楚。当你能在纸上推导出28x28是怎么变成14x14、再变成5x5的,你对CNN的理解会比看一百篇科普文章都扎实。这种从底层建立起来的尺寸感和参数感,对以后调试更复杂的网络模型至关重要。

1.2 学它真正能迁移到现代网络的能力

很多初学者有个误区,觉得学了LeNet-5这种老网络没什么用,不如直接上ResNet、EfficientNet。但实际你仔细观察会发现,现如今的CNN骨干网络,在宏观结构上并没有跳出LeNet-5框定的范式:一个卷积块提取特征,经过池化或步长卷积降低分辨率,堆叠若干层后把特征图展平,接全连接层输出分类结果。ResNet、DenseNet之类的改进,更多是在“怎么堆叠”这个环节做文章,而不是推翻这个基本框架。

具体到技能迁移,学LeNet-5至少能给你三样东西:

  • 尺寸计算能力:能熟练推算卷积、池化前后特征图的尺寸变化,这是后来读懂任何网络结构图的基础。
  • 参数量的敏感度:知道哪些层是参数量大户,这对控制模型大小、判断过拟合风险很有帮助。
  • 训练调试的感觉:在这类小网络上做实验成本极低,十几秒就能跑完一个epoch,非常适合用来验证学习率、初始化方法、优化器选择对收敛速度的影响。

可以说,LeNet-5就是深度学习领域的“hello world”。你不一定靠它吃饭,但通过它建立起来的基本功,会伴随你学习所有更复杂的模型。

2. 逐层拆解LeNet-5:每一层到底在干什么

2.1 输入层与预处理:32x32从哪来

LeNet-5的原始输入是32x32的灰度图像,也就是单通道。你可能知道MNIST数据集里的手写数字图片是28x28的,那为什么LeNet-5要输入32x32呢?

关键在于感受野和边缘信息。原论文作者发现,直接输入28x28的原图,靠近图像边缘的像素在卷积过程中参与计算的次数太少,很多边缘特征容易丢失。把图像通过平移、缩放等方式扩到32x32,相当于给原始数字周围补了一圈空白边界,这样卷积核在扫描时能更充分地捕捉到接近图像边缘的笔画特征。

这个细节当年是手写数字识别精度提升的重要因素之一。放到今天来看,它其实就是在告诉我们:输入尺寸的选择不能随意,要考虑卷积核大小、网络深度和边缘信息保留之间的关系。你现在看很多现代网络会在输入阶段做padding或者resize,本质上也是为了处理同样的问题。

在复现时,如果你直接用MNIST的28x28输入,网络也是能跑的,只是结构上需要微调最后一层全连接前的尺寸。但我建议还是按照原版来,先pad到32x32,这样能完整体验到原设计思路。

2.2 卷积层C1和C3:特征提取的关键环节

LeNet-5一共有两个卷积层,分别是C1和C3。

C1层使用6个5x5的卷积核,步长为1,没有padding,把32x32的输入变成6个28x28的特征图。这个层的参数量很好算:每个5x5卷积核有25个权重参数,加1个偏置,6个卷积核就是6x26=156个参数。

C3层则有点特殊,它使用16个5x5卷积核,把S2层的6个14x14特征图变成16个10x10特征图。这里要注意,原版LeNet-5的C3层并没有采用“每个输出特征图都连接到所有输入特征图”的全连接方式,而是用了一张连接表,让不同的输出特征图只连接部分输入特征图。这样做的目的有两个:一是打破对称性,让不同特征图学习到互补的特征;二是减少参数量。

按原版连接表,C3层的参数是16x(5x5x6+1)=2416吗?并不是。实际上不同输出特征图连接的输入通道数不一样,有的是3个、有的4个、有的6个,具体算下来参数是1516个左右。不过在PyTorch里,直接使用nn.Conv2d(6, 16, kernel_size=5)这种全连接方式效果也差不多,而且代码更简洁,现代复现基本都是这么写的。如果你想追求原汁原味,也可以自定义分组卷积来实现稀疏连接,但实际提升有限,属于锦上添花。

卷积层的核心意义在于局部感知和参数共享。局部感知指的是每个卷积核只关注图像的一个小区域,而不是像全连接层那样看到整张图;参数共享指的是同一个卷积核在整张图上滑来滑去,用的都是同一套权重。这两个特性让CNN的参数量大幅下降,同时天然具备平移等变性,也就是目标在图像里换个位置,照样能被识别出来。

2.3 下采样层S2和S4:池化并不是唯一选择

LeNet-5中的S2和S4层是下采样层,也就是我们现在常说的池化层。原版使用的是平均池化的变体:对2x2区域内的四个像素求和,再乘以一个可训练权重,加上一个可训练偏置,最后通过激活函数输出。换句话说,这种池化的缩放系数和偏移量是学习出来的,而不是固定写死的。

S2层的输入是C1输出的28x28x6,经过2x2池化、步长2,输出14x14x6。每个特征图有2个可训练参数(权重和偏置),所以S2层一共12个参数。S4层同理,把10x10x16变成5x5x16,共32个参数。

为什么这里要强调池化层也有参数?因为这是很多人容易忽略的点。现代实现里,我们通常直接用nn.AvgPool2d或者nn.MaxPool2d,这两种操作本身没有可学习参数。但从理解的角度来看,原版的这种设计说明了一个重要的思想:下采样本身也可以是一个可学习的操作,网络能够自己调整如何对局部特征进行聚合。

从实际操作来看,分类任务中用最大池化通常比平均池化效果更好一些,因为它保留的是最强烈的响应特征。但原版LeNet-5用的是平均池化,兼顾了背景信息的平滑。我在复现时选择了nn.AvgPool2d来贴近原版,实测下来在MNIST上准确率与MaxPool版本几乎没差别。初学者不必太纠结这个选择,两种都可以试试,重点是想明白池化层的作用:降低分辨率、扩大感受野、减少后续计算量,同时增强平移不变性。

2.4 全连接层C5、F6与输出层:从特征到分类

在S4层输出5x5x16的特征图后,接下来就是分类部分了。

C5层在原始论文里也叫卷积层,用120个5x5的卷积核对5x5x16的输入做卷积,因为卷积核尺寸和输入特征图尺寸一样大,所以输出是1x1x120。这实际上就是把一个5x5x16的立体特征块展平后,做了一个线性变换。现代实现里直接用nn.Linear(16*5*5, 120)等价替换,效果完全一样。

F6层是84个神经元的全连接层,输入是C5输出的120维向量,所以参数量是84x120+84=10164个。为什么选84这个数字?原论文的解释是它对应一个7x12的位图,可以绘制出形状类似字母的图案,方便可视化中间层的响应。这个设计放到今天看没什么实际意义,属于历史遗留细节,但了解它有助于你理解神经网络设计早期有很多关于“人机交互”的考虑。

输出层在原始设计中是10个欧氏径向基函数单元,每个类别对应一个,计算输入向量与该类别的原型向量之间的欧氏距离,距离越小说明越像这一类。现代实现通常直接换成softmax加交叉熵损失,形式更简洁,数值上也更稳定。

整个网络的总参数量大约在6万左右,具体分布有这样一个趋势:卷积层占了小头,全连接层占了绝对大头。这个现象在后来的网络中也很常见,很多现代CNN的分类头部分往往占据了相当比例的参数量。理解了这一点,你就能明白为什么很多网络在部署到移动端时,会对全连接层做剪枝或者用全局平均池化来替代。

3. 代码实现:用PyTorch从零搭建LeNet-5

3.1 为什么选PyTorch而不是其他框架

复现这类经典网络,框架选择上我比较推荐PyTorch。原因很直接:它的nn.Module和nn.Sequential机制能把网络结构映射得非常清晰,你写的代码和论文里的结构图几乎可以一一对应。对于学习为目的的项目,这种可读性比性能更重要。

当然TensorFlow和Keras也能做,但PyTorch在动态图和print调试方面的体验对新手更友好。比如你想看看某一层的输出尺寸对不对,直接打印tensor的shape就行,不需要构建静态图。另外LeNet-5本身计算量太小,CPU上跑都毫无压力,所以对硬件条件也没有要求。

3.2 模型定义:对照结构写代码

直接上代码,这是我复现时使用的PyTorch版本,结构与原版做了少量合理化的现代适配,主体完全保持一致:

import torch import torch.nn as nn class LeNet5(nn.Module): def __init__(self, num_classes=10): super(LeNet5, self).__init__() # 特征提取部分:对应于C1、S2、C3、S4 self.features = nn.Sequential( # C1: 6个5x5卷积核,输入1通道,输出6通道,输出尺寸28x28 nn.Conv2d(1, 6, kernel_size=5, stride=1, padding=0), nn.ReLU(), # S2: 2x2平均池化,输出14x14 nn.AvgPool2d(kernel_size=2, stride=2), # C3: 16个5x5卷积核,输出10x10 nn.Conv2d(6, 16, kernel_size=5), nn.ReLU(), # S4: 2x2平均池化,输出5x5 nn.AvgPool2d(kernel_size=2, stride=2), ) # 分类部分:对应于C5、F6、输出层 self.classifier = nn.Sequential( # C5: 120个5x5卷积核,等价于全连接 nn.Linear(16 * 5 * 5, 120), nn.ReLU(), # F6: 84个神经元 nn.Linear(120, 84), nn.ReLU(), # 输出层: 10个类别 nn.Linear(84, num_classes), ) def forward(self, x): x = self.features(x) # 将特征图展平成向量 x = torch.flatten(x, start_dim=1) x = self.classifier(x) return x

这段代码有几个地方要专门解释一下:

一是激活函数的选择。原版LeNet-5用的是sigmoid或tanh,因为那个年代ReLU还没有成为主流。但现代复现普遍换成ReLU,原因是sigmoid在反向传播时容易饱和,梯度消失的问题在小网络上虽然不算严重,但ReLU的训练速度明显更快,收敛也更稳定。这个替换不影响你对网络结构的学习,反而能让你少踩梯度消失的坑。

二是为什么C5层直接用nn.Linear代替。原版C5是卷积层,但因为卷积核和输入特征图尺寸恰好相等,卷积操作就退化成了矩阵乘法,和全连接层等价。用nn.Linear写起来更简洁,forward里也少了一次卷积运算。如果你的输入尺寸严格按照32x32来,那1655这个维度就固定不变,不需要动态计算。

三是关于初始化。PyTorch的nn.Linear和nn.Conv2d默认会做Kaiming初始化,这对ReLU网络来说是合适的。但如果你选择了sigmoid类的激活函数,我一定会推荐你换成Xavier初始化,否则前几层输出很容易全部挤到饱和区,导致梯度接近0,网络完全学不动。

3.3 训练脚本:数据加载与训练循环

模型定义好了之后,还需要一份完整的训练脚本。这里给出我调试过程中使用的版本,它结构清晰,方便你在这个基础上做各种实验:

import torch from torch import nn, optim from torch.utils.data import DataLoader from torchvision import datasets, transforms # 1. 数据加载:将MNIST先pad到32x32 transform = transforms.Compose([ transforms.Resize((32, 32)), # 简单起见用Resize替代pad transforms.ToTensor(), transforms.Normalize((0.1307,), (0.3081,)) ]) train_dataset = datasets.MNIST( root='./data', train=True, download=True, transform=transform ) test_dataset = datasets.MNIST( root='./data', train=False, download=True, transform=transform ) train_loader = DataLoader(train_dataset, batch_size=64, shuffle=True) test_loader = DataLoader(test_dataset, batch_size=256, shuffle=False) # 2. 模型、损失函数、优化器 model = LeNet5(num_classes=10) criterion = nn.CrossEntropyLoss() optimizer = optim.SGD(model.parameters(), lr=0.01, momentum=0.9) # 3. 训练循环 def train_one_epoch(model, loader, criterion, optimizer): model.train() total_loss = 0 correct = 0 total = 0 for images, labels in loader: optimizer.zero_grad() outputs = model(images) loss = criterion(outputs, labels) loss.backward() optimizer.step() total_loss += loss.item() * images.size(0) _, predicted = torch.max(outputs, 1) total += labels.size(0) correct += (predicted == labels).sum().item() return total_loss / total, correct / total # 4. 评估函数 def evaluate(model, loader): model.eval() correct = 0 total = 0 with torch.no_grad(): for images, labels in loader: outputs = model(images) _, predicted = torch.max(outputs, 1) total += labels.size(0) correct += (predicted == labels).sum().item() return correct / total # 5. 训练过程 epochs = 10 for epoch in range(1, epochs + 1): train_loss, train_acc = train_one_epoch( model, train_loader, criterion, optimizer ) test_acc = evaluate(model, test_loader) print(f"Epoch {epoch:02d} | Loss: {train_loss:.4f} | " f"Train Acc: {train_acc:.4f} | Test Acc: {test_acc:.4f}")

这段脚本里有几个细节值得注意:

Resize((32, 32))严格来说和原版的pad方式不同,Resize会改变数字的原始笔画比例,而pad是在四周补零。但实测下来,对于MNIST这种高度规范化的数据集,两种方式的最终精度差距在0.1%以内。如果你强迫症发作想完全复现原版,可以改用transforms.Pad(2),因为MNIST是28x28,pad 2个像素就是32x32。我后续实验就换成了Pad方式,效果与Resize基本一致。

归一化参数用的是MNIST数据集的全局均值和标准差,分别是0.1307和0.3081。这个归一化非常重要,它能让输入数据落在0附近,避免像素值0-255的尺度差异导致梯度更新不稳定。很多初学者省略这一步,模型也有可能在MNIST上勉强收敛,但换到更复杂的数据集就彻底不行了,所以这个习惯最好从入门就养成。

优化器用SGD加momentum而不是Adam,是刻意为之。LeNet-5的原始论文时代用的是朴素SGD,我加上momentum是为了加速收敛。对于这种小规模网络,SGD+momentum的泛化能力通常比Adam略好,而且不依赖那么多超参数。如果你想换Adam,也可以把学习率降到0.001,效果同样不差。

4. 训练实战:让LeNet-5真正跑起来

4.1 数据准备与增强:MNIST就够了

LeNet-5最经典的训练数据集就是MNIST,也就是包含0到9手写数字的灰度图片数据集,训练集6万张,测试集1万张。它的图像虽然只有28x28,但足够把一个CNN从头到尾训练得明明白白。

对于LeNet-5这个体量的网络来说,MNIST的数据量其实是偏多的,所以你会看到一个有意思的现象:训练几轮之后准确率非常高,但很难过拟合。这是因为模型容量太小,而数据量足够大,两者之间达到了一个很轻松的平衡。在真实项目中遇到数据不足的情况时,就要靠数据增强来补,常见的操作有随机旋转、随机平移、随机缩放、添加噪声等。

我在训练LeNet-5时试过加入transforms.RandomRotation(5)这种轻度增强,训练集准确率反而从99.3%降到了99.0%,但测试集准确率基本持平。原因在于MNIST本身已经足够规范,刻意增加变化对模型泛化能力的提升很有限,反而增加了训练难度。这也提醒我们:数据增强不是无脑加,要根据数据集的特点来判断。

如果你想让训练过程更有挑战性,可以试试Fashion-MNIST数据集,它是10类服装图片,同样28x28灰度,但纹理和形状模式比数字复杂得多,LeNet-5在上面只能达到90%左右的准确率。这个过程会让你体会到模型容量和任务复杂度之间的关系。

4.2 超参数选择与训练结果

我最终采用的超参数是批量大小64、学习率0.01、动量0.9、训练10个epoch,使用SGD优化器和交叉熵损失函数。在这个配置下,模型到第3个epoch时测试准确率已经超过98%,第6个epoch左右超过99%,最终稳定在99.2%左右。

这个结果很好复现,哪怕你的代码和训练过程稍有不同,最终精度也不会差太多。如果你看到自己的准确率明显低于99%,大概率是以下某个环节出了问题:数据没有归一化、学习率设置不对、模型结构里卷积核的输出通道数写错了、或者训练循环里忘了调用optimizer.zero_grad()导致梯度累加。

一个有意思的实验是试试把学习率从0.01改成0.1,你会发现模型在训练初期损失下降很快,但到后面开始震荡,验证集准确率出现上下跳动,很难收敛到99%以上。这是因为学习率太大,参数在最优解附近来回横跳,无法稳定落位。反过来把学习率改成0.0001,你会发现训练速度肉眼可见地变慢,10个epoch可能只能到97%左右。这就是学习率对训练过程最直观的影响,我强烈建议你亲手做一次这个对比实验,比看十篇讲学习率的文章印象都深。

4.3 训练过程的观察点:损失曲线与准确率

训练过程中,我会在每轮epoch结束后同时打印训练集损失、训练集准确率和测试集准确率三项指标。这三项分别代表了不同的含义:训练集损失衡量模型在当前数据上的拟合程度,训练集准确率是拟合程度的直观体现,测试集准确率则代表泛化能力。

一个健康的训练曲线应该是:训练损失稳步下降,训练准确率逐步上升,测试准确率跟着上升,三者之间的差距不大。如果训练集准确率很高但测试集准确率上不去,那就是典型的过拟合信号,说明网络“背”住了训练数据却无法泛化。如果两者都低,那就先考虑是欠拟合、特征提取能力不足,还是数据预处理有问题。

在实际训练中我还发现,MNIST上LeNet-5在第1个epoch结束就能到98%的测试准确率,看似非常惊人,但你不要因此高估网络的实力。MNIST作为入门数据集,本身噪声小、类别均衡、背景干净,对CNN来说属于低难度任务。同样的LeNet-5拿到CIFAR-10上,测试准确率只有70%左右,远远达不到主流水平。所以看到好成绩先别激动,要看数据集本身是什么难度级别。

5. 常见问题与排查技巧实录

5.1 模型不收敛或者收敛太慢怎么办

这是所有初学者都会遇到的头号问题,我在带某同学调试模拟项目X的时候就碰到过这种情况:代码完全照着教程写的,但训练损失就是不降,或者降得极其缓慢。

优先排查顺序是这样的:先看数据归一化有没有做,再看损失函数和标签是否匹配,然后检查模型输出的维度。MNIST是10类,输出必须是10维向量,如果最后一层写成输出84维再接一个不匹配的损失函数,程序会直接报错或者学到很奇怪的东西。这些基础问题确认无误后,再考虑调学习率。

在LeNet-5这种小网络上,学习率从0.1到0.0001都可以试,但我建议先固定一个中间值0.01跑几个epoch观察趋势。如果损失在前几个batch里有明显下降,说明方向对了;如果完全不动,把学习率调大10倍试试;如果损失直接变成NaN,说明学习率过大,调小10倍再跑。

还有一个容易被忽略的点是初始化种子。PyTorch默认的随机初始化本身是合理的,但有时候你重复跑训练,会发现精度有零点几个百分点的波动。如果你的实验需要对比,建议固定随机种子,确保实验结果可复现。

5.2 过拟合与欠拟合的判断与处理

LeNet-5在MNIST上其实很难过拟合,因为它容量太小。但如果把它拿去做Fashion-MNIST或者CIFAR-10,过拟合的情况就会出现。判断标准很简单:训练集准确率持续上升甚至接近100%,但测试集准确率徘徊不前甚至下降,中间差距越拉越大,就是过拟合。

处理过拟合的思路从数据、模型和训练三个维度入手。数据层面加增强,模型层面减小参数量或加Dropout、BatchNorm,训练层面用早停法,也就是在验证集准确率不再提升时停止训练。我试过在LeNet-5的两个全连接层之间插入nn.Dropout(0.5),在CIFAR-10上能把测试准确率提升大约3个百分点,效果立竿见影。

欠拟合的情况则相反:训练集准确率和测试集准确率都低。这时候说明模型能力不足,可以考虑加宽卷积层通道数、加深网络层数,或者检查学习率是否设置得太小。我在实际项目中遇到过把学习率设置为0.00001的情况,模型训练了20轮训练准确率还不到50%,换大学习率后情况迅速改善。记住:先确认模型没学崩,再怀疑模型容量不够。

5.3 LeNet-5在现代数据集上的局限与改进方向

LeNet-5毕竟是20多年前的网络,在现代任务上有着明显的局限性。最典型的问题有两个:一是感受野有限,到S4层结束,特征图已经缩小到5x5,对尺寸稍大的图像或者需要感知大范围上下文的任务,表达能力就比较吃力;二是通道数太少,只有6和16个卷积核,特征多样性不足。

但正因为有这些局限,它才是很好的改进练习素材。我在完整的LeNet-5学习项目中做了一组对比实验:把C3层的卷积核从16个改成32个,然后全连接层的输入维度也相应调整,结果在CIFAR-10上测试准确率从70%提升到了75%左右。这就很直观地展示了“模型宽度对表达能力的影响”。如果你做模拟项目Y,也可以沿这个方向尝试。

另外一个常见的改进是给每一层卷积后面加BatchNorm,然后接ReLU。BatchNorm的作用是让每层的输入分布保持稳定,从而可以使用更大的学习率、加速收敛。对于一个6万参数的小网络,这属于锦上添花,但理解它的工作原理对之后学习更大规模的模型很有帮助。

最后提一个我个人的经验:学完LeNet-5之后,如果有余力,可以自己动手把相同结构迁移到Fashion-MNIST上跑一遍,再试着把输入改成32x32的RGB三通道图像来适应CIFAR-10。你会发现,当你能够熟练地根据输入尺寸调整网络维度,并且知道为什么某个维度要改、某个参数可以不变的时候,你对CNN的理解就已经不再是“会用框架调包”的层面了,而是真正建立了自己的结构设计直觉。

这种直觉不是看书看来的,是在一次次踩坑和对比实验里攒出来的。LeNet-5就是练这个基本功最好的练功桩,千万别嫌它老,等你哪天回头看,会发现它帮你省下的时间和精力,远超想象。

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

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

立即咨询