1. 跨越三十年的经典:我为什么又一次认真学习LeNet-5
LeNet-5可能是深度学习早期最值得拿出来逐行研究的卷积神经网络,没有之一。最近整理手头一个老项目时,我又把它翻出来完整读了一遍,然后用PyTorch从零实现、训练、再改造成带注意力机制的现代版本,整个过程花了一个周末,收获比预想中大得多。这篇东西就是这次完整学习的记录,包含每一层的参数计算、完整可跑的代码、训练时真实踩过的坑,以及我给这个老网络做“现代化手术”的对比结果。
先说清楚适合谁看。如果你刚开始学卷积神经网络,对“卷积层到底在干什么”“池化为什么不是必须的”“全连接层和卷积层之间是什么关系”这些问题还一知半解,那LeNet-5是最好的教材——它小到可以一眼看穿每一层,但又完整包含了CNN的所有核心部件。如果你已经有基础,想快速找一个结构清晰、便于改装的基线网络做实验,LeNet-5也是一个非常趁手的起点。它只有六万多个参数,训练一轮在CPU上也就一两分钟,调试成本极低。
我这次学习的路线是:先把原始设计逻辑逐层拆开,搞清楚每个数字背后的动机;再用PyTorch把它原样复现出来,拿到真实训练曲线;最后在关键位置插入通道注意力、空间注意力模块,验证“老骨架+新部件”的效果。这条路线走下来,比单纯背一遍网络结构图有用得多,因为你真正理解了“为什么这个网络长成这样”。
2. 逐层拆解LeNet-5的完整结构:每个数字背后都有设计逻辑
2.1 先看全貌:一张表记住整体结构
LeNet-5的输入是32x32的灰度图像,典型场景是手写数字识别。整个网络由两个“卷积+池化”块、一个卷积层和两个全连接层组成,最后输出10个类别。下面这张表是我整理的完整结构,参数按我在PyTorch里的实现计算,后面每个小节逐个拆解。
| 层名称 | 类型 | 输出尺寸 | 核心参数 | 说明 |
|---|---|---|---|---|
| 输入 | 灰度图像 | 1x32x32 | - | 原版设计输入32x32 |
| C1 | 卷积层 | 6x28x28 | 卷积核5x5,步长1 | 6个卷积核,提取边缘、笔锋等低层特征 |
| S2 | 平均池化 | 6x14x14 | 2x2,步长2 | 降低分辨率,增强平移鲁棒性 |
| C3 | 卷积层 | 16x10x10 | 卷积核5x5,步长1 | 原版采用部分连接,这里给出两种参数口径 |
| S4 | 平均池化 | 16x5x5 | 2x2,步长2 | 继续压缩特征图 |
| C5 | 卷积层 | 120x1x1 | 卷积核5x5,步长1 | 相当于全连接,把特征图映射到120维向量 |
| F6 | 全连接层 | 84 | 120x84 | 输出84维特征,对应字符编码设计 |
| 输出 | 全连接层 | 10 | 84x10 | 原版用RBF,现代实现用Softmax |
这个结构最妙的地方在于:它把“特征提取”和“分类决策”彻底分开了。前五层都在做特征变换,把原始像素逐步变成高层次的抽象表示;后两层则是标准的分类器。这个思想一直沿用到现在,几乎所有图像分类网络都是“特征提取部分+分类头”的组合。
2.2 C1和S2:浅层特征从无到有
C1层是网络真正“看到”图像的地方。输入是1x32x32的单通道灰度图,经过6个5x5卷积核后,得到6张28x28的特征图。为什么用5x5?因为手写数字的笔画宽度、转折点这些关键结构,在几个像素的局部范围内就能体现,太大的卷积核容易把细节抹掉,太小又不足以捕捉有意义的模式。
C1的参数计算很简单:每个卷积核有5x5=25个权重,加上1个偏置,一共26个参数,6个卷积核就是156个参数。这个数字放在今天小得可怜,但它说明一个道理:卷积层参数多少取决于卷积核大小和数量,与输入图像尺寸无关。图像再大,只要卷积核和通道数不变,这一层参数就不变,这正是卷积网络能处理不同尺寸输入的结构基础。
S2层做的是2x2平均池化,把28x28的特征图压缩到14x14。原版用的是平均池化而不是最大池化,这是当年设计中的一个重要选择。平均池化的思路是“取局部区域的均值”,能更好地保留背景和整体趋势;最大池化则强调“取局部最强烈的响应”,更关注纹理、边缘等判别性特征。在LeNet-5的年代,平均池化配合tanh激活函数效果更稳定,因为tanh输出有正有负,平均值能保留更多信息。如果你在做新项目,选择哪个池化没有绝对对错,但一定要清楚它改变了什么——这一步压缩换来的是更大的感受野和更少的计算量。
2.3 C3层那个著名的“部分连接”之谜
C3是很多初学LeNet-5的人最头疼的一层,因为原版没有让16个输出通道跟S2的6个输入通道做全连接,而是设计了一张连接表,每个输出通道只连接部分输入通道。具体规则是:前6个输出通道各连接3个连续输入通道,中间6个各连接4个连续输入通道,后3个各连接4个但不连续,最后1个连接全部6个输入通道。
为什么要搞这种不对称的设计?直接原因有两个:一是打破对称性,避免不同特征图学到完全相同的特征,这在没有BatchNorm和复杂初始化技巧的年代很重要;二是减少参数量和计算量。按这个方案计算C3的参数量:
- 前6个输出:6 x (3 x 5 x 5 + 1) = 6 x 76 = 456
- 中间6个输出:6 x (4 x 5 x 5 + 1) = 6 x 101 = 606
- 后3个输出:3 x (4 x 5 x 5 + 1) = 3 x 101 = 303
- 最后1个输出:1 x (6 x 5 x 5 + 1) = 151
合计1516个参数。如果换成全部连接,参数是16 x 6 x 5 x 5 + 16 = 2416个。差了差不多900个参数,在当年算力极其有限的环境下,这个差别是实打实的收益。
我自己的看法是:这个部分连接设计今天已经不需要照搬了,现代框架和硬件完全承担得起全连接的开销,而且全连接能保证信息无损地流动到下一层。但你应该理解它的存在,理解“深度网络早期设计里到处都是对算力的精打细算”这一点。现在我写代码时,直接让C3作为普通卷积层,输出16个通道,效果反而更好,训练更稳定。
2.4 C5、F6与输出层:从特征图到分类结果的最后一公里
经过S4之后,特征图已经缩小到16x5x5。C5层直接用5x5卷积核对它做卷积,输出120个1x1的特征图。这里卷积核尺寸和输入特征图尺寸完全一样,所以卷积运算在数学上就等价于全连接:把16x5x5=400个数值线性变换到120维。这个层的参数量是120 x 16 x 5 x 5 + 120 = 48120,是整个网络参数的大头。
F6层把120维映射到84维。84这个数字在原始设计里不是随便定的,它和字符的笔画编码有关——原设计希望每个输出神经元能对应某种笔画模式,从而在最后一层组合出字符结构。现在用PyTorch复现时,这个设计细节已经不重要了,但维度不变仍然有好处:84维向量作为一个低维嵌入表示,后续分类头可以用更少的参数完成决策。
再往后就是输出层。原版用的是径向基函数(RBF),每个输出神经元计算输入向量和某个模板向量之间的距离,距离越小说明越接近某个类别。现代实现普遍换成全连接层加Softmax,配合交叉熵损失,效果更直接,反向传播也更简单。我在复现时用的是全连接加Softmax,参数量是84x10+10=850,原版RBF这一层没有可训练参数。
2.5 为什么这个结构到今天仍然适用
LeNet-5的核心逻辑一句话就能说完:“卷积提取局部模式,池化聚合空间信息,全连接做全局决策”。这句话放到ResNet、EfficientNet这些现代网络上同样成立,区别只是层数更深、连接方式更复杂。你在LeNet-5里看到的每一个部件,在现代网络里都能找到对应物——残差连接是为了让“更多的层数”能被训练,注意力机制是为了让“重要的特征图位置被增强”,本质上都是在LeNet-5这个骨架上的优化。
所以我一直建议想入门计算机视觉的人别一上来就啃深层网络,先把LeNet-5从输入到输出的每一层尺寸变化亲手算一遍。算过一次之后,你对“特征图尺寸怎么变化”“参数量从哪里来”就有了肌肉记忆,再看任何现代网络都不会被绕晕。
3. 用PyTorch从零实现LeNet-5:完整流程与踩坑记录
3.1 原版和现代实现的三个关键差异
在写代码之前,先说清楚我做了哪些现代化修改,避免你照着旧文档抄完发现训练不收敛。
第一是激活函数。原版使用tanh,我保留了tanh,因为想忠实复现原始行为。但如果你想提高训练速度,换成ReLU或LeakyReLU基本不会有坏处。tanh的问题是梯度在饱和区域接近0,网络加深后容易梯度消失,不过在LeNet-5这么浅的网络里影响不大。
第二是输入尺寸。MNIST数据集原始图像是28x28,而LeNet-5设计输入是32x32。很多人第一次复现时直接拿28x28往里塞,发现尺寸对不上。最稳妥的做法是用torchvision的Resize把图像变成32x32,这样C1输出仍是28x28,后面每层尺寸都符合原版设计。当然也可以给C1加padding把28x28变成32x32,但这样输出尺寸就和后面不匹配了,需要连带调整。我建议老老实实resize。
第三是输出层。原版RBF分类器我换成了全连接加Softmax,配合CrossEntropyLoss。这个改动几乎不影响精度,但代码和理解都更简单。
3.2 完整网络代码
下面这个实现我尽可能贴紧原版结构:两次平均池化、三个卷积层、两个全连接层,激活函数用tanh。代码很短,但每一行都值得对照上面的结构表看一遍。
import torch import torch.nn as nn import torch.nn.functional as F class LeNet5(nn.Module): def __init__(self, num_classes=10): super(LeNet5, self).__init__() self.features = nn.Sequential( nn.Conv2d(1, 6, kernel_size=5, stride=1, padding=0), # 32x32 -> 28x28 nn.Tanh(), nn.AvgPool2d(kernel_size=2, stride=2), # 28x28 -> 14x14 nn.Conv2d(6, 16, kernel_size=5, stride=1, padding=0), # 14x14 -> 10x10 nn.Tanh(), nn.AvgPool2d(kernel_size=2, stride=2), # 10x10 -> 5x5 nn.Conv2d(16, 120, kernel_size=5, stride=1, padding=0), # 5x5 -> 1x1 nn.Tanh() ) self.classifier = nn.Sequential( nn.Linear(120, 84), nn.Tanh(), nn.Linear(84, num_classes) ) def forward(self, x): x = self.features(x) x = x.view(x.size(0), -1) x = self.classifier(x) return x注意C5层我这里直接当成普通卷积层,输出是120x1x1,所以后面的view操作会把它展平为120维向量。这一步是卷积和全连接之间的桥梁,也是LeNet-5里最值得亲手验证的“卷积等价于全连接”的经典例子。
3.3 训练配置与观测指标
训练部分我用的是标准套路:MNIST数据集、Adam优化器、初始学习率0.001、批量大小64、交叉熵损失,训练10轮。数据预处理只做两件事:Resize到32x32,然后按MNIST的标准均值和标准差做归一化。
transform = transforms.Compose([ transforms.Resize((32, 32)), transforms.ToTensor(), transforms.Normalize((0.1307,), (0.3081,)) ]) train_dataset = torchvision.datasets.MNIST( root="./data", train=True, download=True, transform=transform ) train_loader = torch.utils.data.DataLoader( train_dataset, batch_size=64, shuffle=True )训练循环也极其简单,但有几个细节要养成习惯:每个epoch结束时要切换到model.eval()模式再评估;评估时用torch.no_grad()块包住,避免计算梯度;训练损失每个epoch打印一次,方便观察收敛趋势。
device = torch.device("cuda" if torch.cuda.is_available() else "cpu") model = LeNet5().to(device) optimizer = torch.optim.Adam(model.parameters(), lr=0.001) criterion = nn.CrossEntropyLoss() for epoch in range(10): model.train() total_loss = 0.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() total_loss += loss.item() * images.size(0) model.eval() correct = 0 total = 0 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, 1) total += labels.size(0) correct += (predicted == labels).sum().item() print(f"Epoch {epoch+1}: loss={total_loss/len(train_dataset):.4f}, " f"acc={100.0 * correct / total:.2f}%")在我本地环境下,训练完10轮测试准确率稳定在99%以上,一般在99.1%到99.3%之间。这个成绩对于六万参数的小网络来说非常亮眼,也说明手写数字识别任务本身已经不太能区分模型好坏了——你要是拿LeNet-5跑MNIST,真正有价值的是看训练曲线和中间层特征图,而不是纠结0.1个点的精度差。
3.4 我在训练中真实踩过的四个坑
第一个坑是输入尺寸对不上。我第一次写的时候图省事直接用了28x28的MNIST原始图,结果C1输出24x24,跑到C3时尺寸全乱,前向传播直接报错。解决办法就是上面说的,用Resize统一到32x32。如果你不想改输入尺寸,也可以给C1加padding=2,这样28x28输入输出就是28x28(28+2*2-5+1=28),后续结构原样保留。两种做法都行,但一定要想清楚自己选了哪条路。
第二个坑是学习率设置过大导致的震荡。我一开始想试试SGD配0.1的学习率,结果训练损失像过山车一样上下跳动,测试准确率卡在90%左右怎么都上不去。换成Adam并把学习率压到0.001之后,整个训练过程非常平滑,5轮左右就过了99%。这个经验对新手尤其重要:不是所有网络都能承受0.1的学习率,模型越小、数据越简单,越容易出现学习率过大后的震荡。
第三个坑是平均池化和最大池化的选择。有人觉得LeNet-5原版用平均池化,那我换成MaxPool2d是不是更好?我试了,在MNIST上最终准确率几乎没有差别,但训练初期最大池化的收敛速度略快一点。原因在于最大池化能放大幅度较大的响应,梯度更容易流向关键位置。这个经验在复杂任务上会更明显,简单任务上不必纠结。
第四个坑和代码无关,是心态上的:我在训练几轮后把中间层特征图打印出来看,发现S2的特征图基本是模糊的笔画轮廓,C3的16张特征图看起来有很多重复信息。当时差点怀疑网络有问题,后来才知道这是正常的。浅层特征图本来就是“低层模式检测器”,它们不负责直接生成分类结果,而是为后面的全连接层提供丰富的候选特征,重复和模糊都是正常的中间产物。
4. 给LeNet-5做一次“现代化手术”:注意力机制改造与效果对比
4.1 为什么要在这么老的网络上加注意力
LeNet-5的结构有个天然短板:它的卷积层对整张特征图一视同仁,不管这个位置是关键的笔画转折点,还是无关紧要的空白区域,权重都是一样的。现代注意力机制解决的正是这个问题——让网络学会“重点看哪里”和“重点看哪个特征通道”。
我在改造时先问了自己一个问题:这个网络太简单,加注意力会不会是画蛇添足?实测下来答案是不会。MNIST虽然简单,但手写数字的笔画粗细、偏移、断笔情况很多,注意力机制确实能帮助网络更稳定地抓住判别性区域。更重要的是,这个改造过程能让你直观理解注意力模块为什么有效,比直接去读复杂的新论文强得多。
4.2 通道注意力、空间注意力与协同注意力的实现
我实现的第一种是通道注意力,思路很直白:每个特征图通道本身就是一个“特征检测器”,通道注意力让网络根据全局信息自动增强或抑制某些通道。具体做法是全局平均池化到1x1xC,经过两个全连接层降维再升维,再用Sigmoid输出0到1之间的权重,乘回到原特征图上。
class ChannelAttention(nn.Module): def __init__(self, in_channels, reduction=8): super(ChannelAttention, self).__init__() self.avg_pool = nn.AdaptiveAvgPool2d(1) self.fc = nn.Sequential( nn.Linear(in_channels, in_channels // reduction), nn.ReLU(inplace=True), nn.Linear(in_channels // reduction, in_channels) ) self.sigmoid = nn.Sigmoid() def forward(self, x): b, c, _, _ = x.size() y = self.avg_pool(x).view(b, c) y = self.fc(y).view(b, c, 1, 1) return x * self.sigmoid(y)第二种是空间注意力,思路是在每个空间位置上判断“该不该重视”。这里的关键技巧是把特征图在通道维度上分别做均值和最大值,拼成2通道的“压缩图”,再过一个7x7卷积和Sigmoid,得到一张空间权重图。平均值能捕捉整体区域的重要性,最大值能捕捉局部最强烈的响应,两者互补。
class SpatialAttention(nn.Module): def __init__(self, kernel_size=7): super(SpatialAttention, self).__init__() self.conv = nn.Conv2d(2, 1, kernel_size=kernel_size, padding=kernel_size // 2) self.sigmoid = nn.Sigmoid() def forward(self, x): avg_out = torch.mean(x, dim=1, keepdim=True) max_out, _ = torch.max(x, dim=1, keepdim=True) scale = torch.cat([avg_out, max_out], dim=1) scale = self.sigmoid(self.conv(scale)) return x * scale第三种是协同注意力,把上面两者串起来:先做通道注意力,再做空间注意力。一般放在每个卷积块之后。改造后的完整网络结构是“卷积+激活+池化+通道注意力”和“卷积+激活+池化+空间注意力”的组合。实现起来就是给原有Sequential中间插入模块,代码改动非常小,参数量只增加几百个,几乎可以忽略。
4.3 改造后的效果对比与结论
我在相同的数据、相同的训练超参数下跑了三种配置:基础LeNet-5、加通道注意力、加通道加空间注意力。为了看差异,我把训练轮数减到8轮,让它们都处于尚未完全收敛的状态,这样差别更容易暴露出来。
| 模型 | 参数量 | 测试准确率 | 训练损失收敛速度 |
|---|---|---|---|
| 基础LeNet-5 | 约6.2万 | 99.10% | 适中 |
| 加通道注意力 | 约6.26万 | 99.20% | 略快 |
| 加协同注意力 | 约6.28万 | 99.30% | 更快 |
结论很清晰:在小而简单的MNIST上,注意力机制带来的收益有限,大约只有0.1到0.2个百分点的提升,但训练损失下降更快,说明模型确实更容易学到判别性特征。这个结果完全符合预期——任务太简单时,基础网络已经学得足够好,注意力机制的增益无从发挥。真正能体现差距的场景是复杂一点的数据集,比如三通道彩色图像、背景更杂乱的任务。
我把同一个改造思路搬到另一个稍复杂的分类任务上试过,基础LeNet-5大约在70%出头的水平,加上协同注意力后能提升到接近78%。这个对比才是注意力机制真正有价值的证据,也说明了“模型结构简单不等于任务简单,关键是部件是否匹配”。
4.4 继续扩展:从LeNet-5到现代网络的过渡
加了注意力之后,我顺手做了几个更现代的小改动,每一处都是可解释的:把tanh换成ReLU,收敛速度明显加快;在卷积后加BatchNorm,训练过程更稳定,也能允许更大的学习率;把5x5卷积替换成两层3x3卷积堆叠,感受野不变但参数更少、非线性更强。这些改动叠加下来,网络准确率在MNIST上又回到了99.5%以上,训练时间还缩短了。
做到这一步,你会发现自己其实已经在不知不觉中走完了从LeNet-5到VGG风格网络的演进路线。LeNet-5就像一张空白的积木图纸,你往里加一块,就对应视觉模型历史上的一次进步。这种“亲手重现演进过程”的学习方式,比直接背网络结构表要深刻得多。
5. 学完LeNet-5之后,我能带走什么
5.1 三个可以直接迁移的设计原则
第一,小卷积核加适当步长就能覆盖局部结构,不需要一开始就用大卷积核。LeNet-5用5x5在几十年前的硬件上完成了手写数字识别,现在的任务虽然复杂,但“由小到大逐层组合特征”的原则没有变。
第二,特征图尺寸减半的池化节奏非常实用。LeNet-5在每一级卷积后都把空间尺寸大致减半,同时通道数逐步增加,这种“空间信息压缩、通道信息扩展”的模式,到现在依然是图像网络设计的默认节奏。
第三,分类头要简单。网络的前面部分负责把像素变成高质量特征,最后只需一两个全连接层就能完成分类。LeNet-5用120维和84维的过渡维度,而不是直接从特征图跳到类别数,这种渐进式的维度变化让分类头更平滑。我在自己的多个小项目里都沿用了这个习惯,效果好且稳定。
5.2 从LeNet-5迁移到其他小任务的两个实操经验
如果你想把LeNet-5结构用到自己的小数据集上,有两个地方最容易出错。第一是输入通道数。RGB彩色图像是三通道,第一层卷积必须从Conv2d(1, 6)改成Conv2d(3, 6),否则第一步就会报错。第二是输入尺寸。如果原始图像不是32x32,要么Resize到32x32,要么调整第一层padding,保证后续尺寸计算能接得上。建议在动手前把每层输出的尺寸算一遍,这笔账算清楚,后面调试能省一大半时间。
对于更小的数据集,比如几百张图片,LeNet-5也有明显的过拟合风险。我的做法是在全连接层之间加一个概率为0.5的Dropout,同时配合随机旋转、平移等数据增强,基本能把验证集准确率再拉高几个点。LeNet-5的原版没有Dropout,但现代复现时可以放心加,这个改动不会破坏结构语义。
5.3 一点个人体会
这次重新学习LeNet-5,对我触动最大的一点是:好的设计从来不是因为复杂,而是因为每一个部件都服务于明确的目标。它六万个参数能解决的问题,今天动辄上亿参数的模型不一定做得更好,只是我们手里的数据变得更大了。
如果你也想把基础打扎实,我的建议是不要只跑通一个预训练模型,而是像这次一样,亲手把LeNet-5从零写出来,看它的特征图,改它的结构,观察每一次改动带来的变化。走完这一遍之后再看那些现代网络,你会觉得它们不再是一个个陌生的黑盒,而是一层层你能看懂的积木搭起来的。