如果今天你去翻任意一篇卷积神经网络的综述,或者打开PyTorch官方教程里那个手写数字识别的LeNet示例,会发现几乎所有现代视觉模型的基因,都能回溯到1998年Yann LeCun那篇名为《Gradient-Based Learning Applied to Document Recognition》的论文。我入行深度学习那会儿,带我的前辈第一句话就是:别急着上ResNet,先把LeNet吃透。当时不理解,觉得一个六万参数的小网络有什么好看的。后来自己真的从头复现、调参、跑实验,才明白那句话的分量。
这篇东西不打算给你复述论文原文,而是想把它拆开揉碎,讲讲LeNet到底解决了什么问题、每一层设计背后的逻辑是什么、以及我们从这篇论文里能继承哪些直到今天仍然有效的思考方式。适合三类人看:刚入门CNN、想弄清楚卷积池化全连接为什么长这样的新手;做模型设计但没认真读过原始论文的从业者;以及在复现LeNet时被各种细节卡住的实践派。
1. 一篇写在MNIST出现之前的网络,为什么今天还在读
1.1 LeNet到底解决了什么问题
很多人误以为LeNet是“为了MNIST而设计”的。实际上,LeNet最早期的工作起步于1989年,而MNIST数据集是Yann LeCun等人后来为了验证这套方法才整理发布的。这个时间线很重要,它说明LeNet不是针对某一个现成数据集调出来的模型,而是为了解决一个更本质的问题:如何让机器从像素级别学会“看到”数字,并且对位移、缩放、轻微形变保持稳定。
在LeNet之前,主流做法是手工设计特征加分类器。先提取图像的边缘、交点、闭环等几何特征,再喂给线性分类器或浅层网络。这套路的问题很明显:手写数字的笔迹千变万化,同一个数字在不同人笔下差异极大,靠人肉设计特征根本覆盖不了所有情况。
LeNet的思想是:让网络自己从数据中学习特征。卷积层负责在局部区域提取模式,池化层负责把模式的位置信息变得模糊一些,全连接层负责把这些模式组合成高级别判断。这个“特征提取+分类”的端到端结构,成为后来几乎所有CNN的标准范式。即便是今天的ResNet、EfficientNet,本质上仍然在沿用这条设计主线。
1.2 这篇论文里最容易被忽略的一个身份
除了模型结构,这篇论文还有一个更宏观的身份:它系统阐述了“基于梯度的学习”如何应用于文档识别。论文的题目就叫“Gradient-Based Learning Applied to Document Recognition”,LeNet-5只是其中用来验证方法的一个载体。
这意味着什么?意味着LeNet背后附带了一整套东西:损失函数怎么设计、梯度怎么回传、权重怎么初始化、训练怎么收敛。比如论文里用到的output layer是RBF(径向基函数),而不是今天的Softmax交叉熵;比如它明确讨论了tanh比sigmoid收敛更快。这些细节加在一起,才是LeNet真正值得研究的对象。
我见过不少初学者,把LeNet当成一个“长得比较简单的CNN”来背结构,然后直接跳到ResNet。这样做其实错过了最宝贵的东西——LeNet的每个设计选择都能讲出道理,而这些道理在今天的模型里依然生效。
2. 理解LeNet的设计骨架:卷积、池化与全连接的老祖宗玩法
2.1 局部连接和权值共享:CNN的第一性原理
LeNet里最核心的创新,是引入了“局部感受野”和“权值共享”这两个概念。要理解它们,先看一个反例:如果把一张32×32的灰度图直接展平成一个1024维向量,然后做全连接层,第一层就要1024×1024个权重,计算量勉强能忍;但如果是128×128的图片,第一层就需要16K×16K级别的参数,直接爆炸。
全连接之所以“贵”,是因为它对每个输出神经元都连接了所有输入像素,并且在图片的不同位置重复使用完全不同的权重。而LeNet的洞察是:图像具有局部相关性,一个像素最可能和它附近的像素有关,和远处的像素几乎无关;同时,图像中的模式(比如一条竖线、一个拐角)在任意位置出现的语义是相近的。于是卷积核天然就应该是“局部连接+跨位置共享”的。
这就好比你看一张照片,先看局部再看整体。局部的边缘和纹理不需要在全图每个位置各学一套新的检测器,同一个边缘检测器平移一下就能覆盖其他区域。这个设计直接把参数量压缩了几个数量级,而且让模型具备了一定的平移鲁棒性——正因为权重是共享的,无论数字出现在图像左上角还是右下角,同一个卷积核都能响应。
2.2 池化层:用“下采样”换稳定性的最早实践
LeNet中另一个在后来被视为标配的设计是池化层。论文里用的是平均池化,窗口是2×2,步长为2。也就是说,一个2×2区域里的四个像素值取平均,然后除以一个可学习的系数,再加上一个可学习的偏置,最后经过非线性激活。
池化的目的,表面上看是缩小特征图尺寸、减少计算量,但更本质的作用是提供“局部平移不变性”。比如数字的笔画在某个2×2区域内稍微偏移一个像素,池化后输出的值变化很小;这恰好对应了手写数字识别里最常见的干扰——笔画不会规矩地落在像素网格上。
不过今天再看LeNet的池化设计,有一个细节很有意思:S2层每个feature map只有两个可训练参数(一个缩放系数、一个偏置),而现代网络里的池化基本都不带可学习参数了。这说明在LeNet那个年代,作者还在尝试让每一层都能通过训练来调整,而今天的工程实践更倾向于“能用固定的就用固定的,把可学习的容量留给卷积层”。这个变化本身就是深度学习设计哲学演进的一个缩影。
2.3 用生活化的例子理解一个样本经过LeNet的完整流动
用一个比喻来串一遍LeNet-5的流程。假设你手里有一张32×32的手写数字“6”的灰度图。
第一步,把这张图看成一个32×32的像素矩阵,每个像素是一个0到1之间的灰度值。C1层有6个卷积核,每个卷积核像一张5×5的“放大镜”,在整张图上从左到右、从上到下扫描,每次覆盖一个5×5的局部区域,计算区域内像素和放大镜的加权和。6个放大镜代表6种不同的“偏好”:有的对横线敏感,有的对竖线敏感,有的对弧线敏感。扫描结果得到6张28×28的“响应图”——哪个位置的局部图像和对应模式越像,响应值越高。
第二步,S2层对每张响应图做2×2区域的平均池化,把28×28压缩成14×14。这样做一方面让位置偏差的影响减弱,另一方面让后面的层能够看到更大范围的上下文。
第三步,C3层再次卷积,这回有16个卷积核,但并不是每个核都和S2里全部6张图相连——LeNet在这里设计了一个连接表,部分核只连其中几张特征图。这一步相当于在上一次提取的低级特征(边缘、方向)基础上,组合出更高级的模式(比如一个半圆弧、一个交叉点)。
第四步,S4继续池化,从16张14×14变成16张5×5。至此,网络已经把这些特征图中的空间信息压缩得非常紧凑了。
第五步,C5层用5×5的卷积核对S4的16张图做卷积,因为尺寸刚好是5×5,卷积后就得到120个1×1的特征值。可以把它理解成把前面所有信息“拍扁”成一个120维的特征向量。
第六步,F6层把这120维映射到84维,最后输出层计算这84维向量与10个预定义类别的“距离”,距离最近的那个就是网络的预测。
整个流程下来,图像的原始像素被逐步抽象成“有没有横线、有没有弧线、弧线在什么位置”之类的特征,最终组合成“这是数字6”的判断。这也是CNN最迷人的地方:特征不需要人来定义,完全是数据驱动的。
3. LeNet-5逐层拆解:每个参数都不是随便填的
3.1 输入层与C1:为什么输入是32×32而不是28×28
LeNet-5的输入是32×32,这个数字看起来有点强迫症的味道,但其实是刻意选的。因为C1的卷积核是5×5,不padding的话,卷积输出尺寸是(32-5+1)=28。作者希望第一层卷积后能得到28×28的偶数尺寸,这样后面两次2×2池化正好能整除:28→14→7。
但这里有个很多人会忽略的细节:如果走到C5层之前是7×7,那C5用6×6卷积核就能得到1×1;而LeNet选择在S4之后得到的是16张5×5的特征图,C5再用5×5卷积直接得到1×1。也就是说,输入尺寸的选择会连锁影响后面每一层特征图的尺寸和卷积核大小。LeNet-5整个结构是一个自洽的体系,牵一发而动全身。
另外,32×32比MNIST原始图片的28×28略大,这是因为作者希望数字的笔画不要贴边,给卷积核留出足够的上下文空间。这个思路到今天依然成立——很多检测模型在预处理时会在目标周围留白,让卷积核能看到更完整的背景信息。
C1层有6个5×5的卷积核,输出6张28×28的特征图。参数量:每个卷积核25个权重加1个偏置,6个核一共6×26=156个参数。跟后面的层比起来,这是非常小的开销。
3.2 S2:带可学习权重的平均池化,这个细节后来几乎没人再用
S2层输出6张14×14的特征图,池化窗口是2×2。论文里的计算公式是:对每个2×2区域求和,乘以一个可训练系数w,再加上可训练偏置b,最后过tanh激活。
这正是我前面提过的那个特例。现代实现里,池化层通常不带参数,原因很简单:卷积层已经够强了,池化再加参数不仅收益有限,还容易在训练早期引入额外的梯度不稳定因素。但LeNet这样设计也有它的道理:1998年那会儿网络普遍比较浅,每一层都承担着特征变换的职责,给池化层一点可训练参数,相当于让模型自己决定“压缩信息时要不要做尺度调整”。
S2的参数和连接数需要算一下:6个feature map,每个map有2个参数,共12个参数;每个2×2窗口的输入来自前一层4个像素,6张14×14的输出图共6×14×14×4个连接,也就是18816个连接。
3.3 C3:局部连接表,LeNet里最值得琢磨的一层
C3层是16张10×10的特征图,卷积核仍然是5×5。但这里有一个LeNet经典的、也是很多复现代码里最容易搞错的地方:C3的每个输出图并不是和S2的全部6张图相连,而是按照一张预定义的连接表,只连接其中一部分。
具体分配方式是:C3的前6个feature map各连接S2中连续的3个map,接下来6个各连接连续的4个map,再接下来3个各连接不连续的4个map,最后1个连接全部6个map。这样一来,C3的可训练参数数量就不是16×6×(5×5+1),而是6×(3×25+1)+6×(4×25+1)+3×(4×25+1)+1×(6×25+1)=1516个,差不多只有全连接方案的三分之一。
为什么要这么干?论文里给的理由有两层,一层是打破对称性。如果每个C3的map都连S2的全部map,那么同一层的多个卷积核会在训练中学到雷同的模式,浪费参数。另一层是控制连接数量。在1998年的硬件条件下,减少连接意味着减少计算量和内存占用,这是很现实的工程考量。
今天做模型设计时,很多人不会再用这种手工连接表,因为注意力机制和分组卷积已经提供了更通用的“选择性连接”手段,而且GPU算力也完全不在乎这点连接数差距。但C3给我的启发是:当资源受限时,连接方式本身就是一种可以设计的结构,而不是只能全连或全不连。通道选择、分组卷积、稀疏连接,这些现代概念在LeNet里都能找到影子。
3.4 从C5到输出层:把特征图“打散”成向量的标志性设计
C5层有120个卷积核,卷积核尺寸5×5,因为输入S4是16张5×5的特征图,卷积后每个核正好输出1×1。所以严格来说,C5虽然是卷积层,行为却和全连接层完全等价:把16×5×5=400个输入值加权组合成120个输出值。
F6层是84维的全连接层,输出层的设计也很有讲究。论文里的输出层不是Softmax,而是10个RBF单元,每个RBF对应一个数字类别(0到9)。每个RBF单元计算F6的84维向量和该类别预设“原型向量”之间的欧氏距离,距离最近的就是预测类别。
为什么用84维?因为作者希望把每个数字映射到一个7×12的位图模板上,84正好是7×12。也就是说,F6层的输出在语义上被引导为“数字在某个模板空间中的表示”,RBF则负责做模板匹配。今天几乎没人再这样设计了,交叉熵+Softmax的效果更直接,也更好优化。但RBF这个设计提醒我们:损失函数和输出层的选择,本质上是在往网络里注入先验知识。你注入的先验越强,网络需要从数据中学的东西就越少。
整个LeNet-5的参数量大约6万个,其中绝大部分集中在C5和F6这两层全连接性质的结构里。与现代网络动辄几千万上亿参数相比,它是一个非常精巧的小模型。放在今天,即便用CPU训练,几分钟也能收敛到很好的效果。
4. 从LeNet到CSPNet这些新backbone:血缘关系到底在哪里
4.1 残差、密集连接、跨阶段部分连接,改的到底是什么
近两年像CSPNet这类新backbone在圈子里讨论度很高,名字听起来和LeNet八竿子打不着,但本质上它们在做的事情,仍然是围绕着LeNet定义的几个核心部件打转:卷积负责局部特征提取、池化或stride负责空间下采样、全连接或全局池化负责聚合全局信息。
ResNet引入残差连接,解决的是深层网络梯度消失和退化问题;DenseNet用密集连接让每层都能直接拿到前面所有层的特征,加强了特征复用;CSPNet把特征图分成两部分,一部分走密集模块,一部分直接走捷径拼接,减少了重复梯度计算。这些改进的核心都是“特征如何在层与层之间流动”,而LeNet给出的初始方案是“逐层串行流动”。今天的新结构,基本都是在打破这种严格串行,让信息可以跳跃、分流、融合。
我曾经在项目里把ResNet50换成CSPDarknet,推理速度提升了快一倍,mAP还稳住了。这个收益不是来自什么魔法,而是CSP把Backbone中大量冗余的梯度计算砍掉了。但如果你没搞懂LeNet那套“特征提取→压缩→组合”的主干逻辑,面对CSP的分流设计就会一头雾水。
4.2 为什么今天的backbone仍然逃不出LeNet定义的“几个部件”
你去看任何一个现代backbone,都逃不出下面这几个阶段的组合:
第一阶段,stem层,通常是stride为2的卷积或patch embedding,负责快速降低分辨率并把RGB输入映射到高维空间,这相当于LeNet里C1+S2的职责。
第二阶段,若干个stage,每个stage内做多次卷积特征变换,stage之间通过stride卷积或池化降低分辨率,这相当于LeNet里C3→S4→C5的层次递进。
第三阶段,在stage之后用一个全局池化或全连接层把空间信息聚合起来,作为分类或检测头的输入,这相当于LeNet里F6层的角色。
也就是说,LeNet-5已经为CNN画出了一条完整的流水线:低层提取局部低阶特征、中层组合成结构性模式、高层做全局判断。后面二十几年的演进,更多是在“每一层内部怎么做”上做文章,而不是推翻这条流水线。理解了这一点,再去看任何一篇新backbone论文,你都能快速抓住它的位置——它是在替换流水线的哪个环节。
4.3 重读LeNet对今天做模型设计的真正启发
我在实际做模型选型和设计时,重读LeNet得到的一个很重要的认知是:模型的深度和宽度是次要的,关键是信息流的设计。
LeNet-5整体的信息流动非常清晰:图像进入网络后,空间分辨率逐步降低,通道数逐步增加,语义信息逐步增强。这个“降低分辨率、增加通道数”的模式,后来被VGG、ResNet、EfficientNet等无数模型继承,几乎成了一个不证自明的公理。但为什么这个模式有效?因为随着网络加深,特征从局部边缘纹理逐步变成全局语义,信息越来越抽象,需要更多的通道来编码不同的高层概念,而不需要太高的空间分辨率。
另一个启发是:LeNet的作者在C3层做了手工连接表,今天的我们有了更多自动化的选择,比如NAS(神经架构搜索)可以自动搜索连接模式。但NAS搜出来的结构往往星罗棋布、难以解释,LeNet的手工设计虽然不灵活,却提供了一个思考范式:连接结构是可设计的变量,而不是固定的。那些在模型里做稀疏连接、做分组卷积的实践,本质上都是这个思路的延续。
5. 自己动手复现LeNet-5:一个可以抄作业的PyTorch实现
5.1 把论文结构翻译成现代代码
PyTorch的实现路径很清晰,我直接贴一份我验证过的代码,各项参数严格对应论文。
import torch import torch.nn as nn class LeNet5(nn.Module): def __init__(self, num_classes=10): super().__init__() # 特征提取部分 self.features = nn.Sequential( # C1: 6个5x5卷积核,输入1通道灰度图,输出6张28x28特征图 nn.Conv2d(1, 6, kernel_size=5, stride=1, padding=0), nn.Tanh(), # S2: 2x2平均池化,输出6张14x14特征图 nn.AvgPool2d(kernel_size=2, stride=2), nn.Tanh(), # C3: 16个5x5卷积核,输出16张10x10特征图 nn.Conv2d(6, 16, kernel_size=5, stride=1, padding=0), nn.Tanh(), # S4: 2x2平均池化,输出16张5x5特征图 nn.AvgPool2d(kernel_size=2, stride=2), nn.Tanh(), # C5: 120个5x5卷积核,输入16x5x5,输出120x1x1 nn.Conv2d(16, 120, kernel_size=5, stride=1, padding=0), nn.Tanh(), ) # 分类部分 self.classifier = nn.Sequential( nn.Flatten(), # F6: 84维全连接 nn.Linear(120, 84), nn.Tanh(), # 输出层:10类 nn.Linear(84, num_classes), ) def forward(self, x): x = self.features(x) x = self.classifier(x) return x这里我做了一个简化:把论文里的RBF输出层换成了常规的Linear输出,配合交叉熵损失。原因后面会说。
我给你算一下这套结构的参数量。C1是156个参数,S2没有参数,C3是1516个参数,S4没有参数,C5是120×(16×5×5+1)=48120个参数,F6是84×(120+1)=10164个参数,最后的输出层是10×(84+1)=850个参数。加起来大约6.1万。你打印一下模型结构,就能验证这个数字。
需要特别提醒的是,C3层我直接用了全连接版,也就是每个输出map与输入的全部6个map相连,这样C3的实际参数是16×(6×5×5+1)=2416个,比论文的1516个参数多了一些。如果做严格复现,得用分组卷积或者自定义连接表。我自己的经验是:在MNIST这种任务上,全连接版的C3性能和论文版几乎没差别,但代码简洁很多,新手更容易理解。如果追求论文级别的实验一致性,再去实现连接表。
5.2 训练配置的取舍:激活函数、初始化、优化器怎么选
论文里用的激活函数是tanh,不是我们现在习惯的ReLU。实测下来,在LeNet这种浅层网络上,tanh的表现其实相当好,收敛稳定,而且输出有界,不会像ReLU那样出现神经元死亡的问题。我建议复现时不要一上来就换成ReLU,先按原始设定用tanh跑一遍,感受一下“饱和激活”是什么体验,再对比ReLU的差异。
初始化方面,论文当时的做法在今天看来比较粗糙,就是均匀分布随机初始化。现在的经验是,如果网络里全是tanh,用Xavier初始化会非常稳,梯度不会在反向传播中过早消失。推荐直接写成这样:
def init_weights(m): if isinstance(m, nn.Conv2d) or isinstance(m, nn.Linear): nn.init.xavier_uniform_(m.weight) nn.init.zeros_(m.bias) model.apply(init_weights)优化器用SGD加动量就够了,学习率我习惯从0.01起步,配合StepLR每5个epoch乘0.1。Adam这类自适应优化器在这个小模型上也行,但我总觉得复现老论文时用SGD更有“味道”,而且能观察到更经典的学习动态。
训练MNIST时,输入尺寸要Resize成32×32,而不是直接用28×28的原始图。这一点很关键,因为LeNet-5整个结构的尺寸设计都建立在32×32输入之上。用28×28的话,C1输出变成24×24,S2变成12×12,C3输出8×8,S4变成4×4,C5的5×5卷积就对齐不上了。
5.3 复现过程中最容易踩的三个坑
第一个坑,输入通道数。MNIST是单通道灰度图,但很多人习惯性地把输入写成3通道,结果模型照样能跑,但训练效果明显变差。说白了就是白浪费了参数去学一个不存在的RGB映射。看数据用单通道,做RGB数据集再改回来。
第二个坑,池化后要不要跟激活函数。论文里S2和S4在池化之前有tanh,但今标准化实现经常是在池化之后直接进下一层。我测试下来,两种做法在MNIST上差距不大,但如果严格按论文复现,应该在池化之后加一个tanh。不要图省事直接抄现代ResNet的“Conv-BN-ReLU”的套子,系统性差了很多。
第三个坑是评价指标。LeNet-5在MNIST上的原始报告错误率大约0.95%,但那是用RBF输出层和复杂的失真数据增强得到的。你用简化版Softmax输出、不加增强,能到99%左右正确率就算正常。如果发现跑不到,先检查预处理:像素归一化到[0,1]或[-1,1]了吗?数据shuffle了吗?学习率衰减设置了吗?这三个小问题是最常见的“看着代码没问题但精度就是上不去”的原因。
6. 我重读LeNet时的一些经验沉淀
6.1 读老论文的正确姿势
不要只盯着网络结构图看,要连带看“实验和分析”部分。LeNet那篇论文里,作者们讨论了一大堆在今天的论文里已经很少见的内容:为什么用tanh而不是sigmoid、为什么输出层用RBF而不是Softmax、特征图尺寸为什么要控制在某个范围、误差分析中哪一类数字最容易混淆。这些讨论才是论文真正值钱的地方。
我的习惯是,读老论文时带三个问题:这个设计解决了什么问题?如果去掉它会不会有影响?如果今天我来做,有没有更现代的替代品?带着这三个问题读LeNet,你会在每个层、每个参数里都读出东西来。
6.2 一些值得去尝试的小扩展
如果你想基于LeNet练手,我建议你做几个小实验,都不难但很开窍。
第一,把C3的“全连接版”改成论文里的部分连接版,对比参数量和准确率。这个实验能让你直观感受“连接结构影响参数效率”这件事。
第二,把最后几层的全连接换成全局平均池化(GAP),看看参数量降多少、精度变多少。这个改动可以说是ResNet和GoogLeNet分类头设计的雏形。
第三,在C5之后插入一个Dropout层,看看在小数据集上会不会改善过拟合。LeNet时代没有Dropout,但现代复现中加上它效果往往更稳,这也是“老结构+新技术”的经典组合。
第四个思路更进阶一点,把LeNet的特征提取部分拿出来当backbone,接一个简单的检测头或分割头,在小型数据集上跑跑看。你会发现这个六万参数的小东西,作为特征提取器并没有想象中那么弱,尤其在数据量不大时,它的简洁反而是一种优势。
我自己做了一遍这些实验之后,再看CSPNet那些现代backbone时,能明显感觉到它们和LeNet之间的血缘关系——一个负责理解“把图像变特征”,一个负责思考“特征怎么流动更高效”。只有把前者吃透了,后者才能看懂门道,而不是看个热闹。
这篇就写到这儿吧,接下来我还会继续整理CNN经典论文系列,下一篇打算聊聊AlexNet那条更“暴力”的路子。如果你在复现LeNet时卡在哪一步,欢迎在评论区把你的报错和日志贴出来,我们一起看看是哪里的问题。