1. 跨越三十年的经典我为什么又一次认真学习LeNet-5LeNet-5可能是深度学习早期最值得拿出来逐行研究的卷积神经网络没有之一。最近整理手头一个老项目时我又把它翻出来完整读了一遍然后用PyTorch从零实现、训练、再改造成带注意力机制的现代版本整个过程花了一个周末收获比预想中大得多。这篇东西就是这次完整学习的记录包含每一层的参数计算、完整可跑的代码、训练时真实踩过的坑以及我给这个老网络做“现代化手术”的对比结果。先说清楚适合谁看。如果你刚开始学卷积神经网络对“卷积层到底在干什么”“池化为什么不是必须的”“全连接层和卷积层之间是什么关系”这些问题还一知半解那LeNet-5是最好的教材——它小到可以一眼看穿每一层但又完整包含了CNN的所有核心部件。如果你已经有基础想快速找一个结构清晰、便于改装的基线网络做实验LeNet-5也是一个非常趁手的起点。它只有六万多个参数训练一轮在CPU上也就一两分钟调试成本极低。我这次学习的路线是先把原始设计逻辑逐层拆开搞清楚每个数字背后的动机再用PyTorch把它原样复现出来拿到真实训练曲线最后在关键位置插入通道注意力、空间注意力模块验证“老骨架新部件”的效果。这条路线走下来比单纯背一遍网络结构图有用得多因为你真正理解了“为什么这个网络长成这样”。2. 逐层拆解LeNet-5的完整结构每个数字背后都有设计逻辑2.1 先看全貌一张表记住整体结构LeNet-5的输入是32x32的灰度图像典型场景是手写数字识别。整个网络由两个“卷积池化”块、一个卷积层和两个全连接层组成最后输出10个类别。下面这张表是我整理的完整结构参数按我在PyTorch里的实现计算后面每个小节逐个拆解。层名称类型输出尺寸核心参数说明输入灰度图像1x32x32-原版设计输入32x32C1卷积层6x28x28卷积核5x5步长16个卷积核提取边缘、笔锋等低层特征S2平均池化6x14x142x2步长2降低分辨率增强平移鲁棒性C3卷积层16x10x10卷积核5x5步长1原版采用部分连接这里给出两种参数口径S4平均池化16x5x52x2步长2继续压缩特征图C5卷积层120x1x1卷积核5x5步长1相当于全连接把特征图映射到120维向量F6全连接层84120x84输出84维特征对应字符编码设计输出全连接层1084x10原版用RBF现代实现用Softmax这个结构最妙的地方在于它把“特征提取”和“分类决策”彻底分开了。前五层都在做特征变换把原始像素逐步变成高层次的抽象表示后两层则是标准的分类器。这个思想一直沿用到现在几乎所有图像分类网络都是“特征提取部分分类头”的组合。2.2 C1和S2浅层特征从无到有C1层是网络真正“看到”图像的地方。输入是1x32x32的单通道灰度图经过6个5x5卷积核后得到6张28x28的特征图。为什么用5x5因为手写数字的笔画宽度、转折点这些关键结构在几个像素的局部范围内就能体现太大的卷积核容易把细节抹掉太小又不足以捕捉有意义的模式。C1的参数计算很简单每个卷积核有5x525个权重加上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的特征图。这里卷积核尺寸和输入特征图尺寸完全一样所以卷积运算在数学上就等价于全连接把16x5x5400个数值线性变换到120维。这个层的参数量是120 x 16 x 5 x 5 120 48120是整个网络参数的大头。F6层把120维映射到84维。84这个数字在原始设计里不是随便定的它和字符的笔画编码有关——原设计希望每个输出神经元能对应某种笔画模式从而在最后一层组合出字符结构。现在用PyTorch复现时这个设计细节已经不重要了但维度不变仍然有好处84维向量作为一个低维嵌入表示后续分类头可以用更少的参数完成决策。再往后就是输出层。原版用的是径向基函数RBF每个输出神经元计算输入向量和某个模板向量之间的距离距离越小说明越接近某个类别。现代实现普遍换成全连接层加Softmax配合交叉熵损失效果更直接反向传播也更简单。我在复现时用的是全连接加Softmax参数量是84x1010850原版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_classes10): super(LeNet5, self).__init__() self.features nn.Sequential( nn.Conv2d(1, 6, kernel_size5, stride1, padding0), # 32x32 - 28x28 nn.Tanh(), nn.AvgPool2d(kernel_size2, stride2), # 28x28 - 14x14 nn.Conv2d(6, 16, kernel_size5, stride1, padding0), # 14x14 - 10x10 nn.Tanh(), nn.AvgPool2d(kernel_size2, stride2), # 10x10 - 5x5 nn.Conv2d(16, 120, kernel_size5, stride1, padding0), # 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, trainTrue, downloadTrue, transformtransform ) train_loader torch.utils.data.DataLoader( train_dataset, batch_size64, shuffleTrue )训练循环也极其简单但有几个细节要养成习惯每个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(), lr0.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(fEpoch {epoch1}: loss{total_loss/len(train_dataset):.4f}, facc{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加padding2这样28x28输入输出就是28x28282*2-5128后续结构原样保留。两种做法都行但一定要想清楚自己选了哪条路。第二个坑是学习率设置过大导致的震荡。我一开始想试试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, reduction8): super(ChannelAttention, self).__init__() self.avg_pool nn.AdaptiveAvgPool2d(1) self.fc nn.Sequential( nn.Linear(in_channels, in_channels // reduction), nn.ReLU(inplaceTrue), 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_size7): super(SpatialAttention, self).__init__() self.conv nn.Conv2d(2, 1, kernel_sizekernel_size, paddingkernel_size // 2) self.sigmoid nn.Sigmoid() def forward(self, x): avg_out torch.mean(x, dim1, keepdimTrue) max_out, _ torch.max(x, dim1, keepdimTrue) scale torch.cat([avg_out, max_out], dim1) 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从零写出来看它的特征图改它的结构观察每一次改动带来的变化。走完这一遍之后再看那些现代网络你会觉得它们不再是一个个陌生的黑盒而是一层层你能看懂的积木搭起来的。
阅读完成 · 觉得有帮助?