1. 为什么三十多年前的LeNet至今还是深度学习的第一课
在深度学习社区里,有一个约定俗成的入门动作:复现LeNet。这个网络小到用CPU跑几分钟就能看到效果,又完整到可以装下卷积神经网络(CNN)的全部核心思想——局部连接、权值共享、下采样、层级特征提取。我在一个模拟的字符识别项目(就叫它模拟项目X吧)里把它从零搭了一遍,过程中意识到,与其说LeNet是一个老模型,不如说它是一份没有废话的教科书。
LeNet-5是这一系列网络里最成熟的版本,解决的问题很具体:给一张手写数字的灰度图,判断它是0到9中的哪一个。这类任务有一个广为人知的标准数据集——MNIST手写数字数据集,它后来几乎成了深度学习的"入门考试题"。这篇文章适合两类读者:一类是刚入门深度学习、需要一个能完整跑通"数据加载到训练到评估"全流程的示例;另一类是已经在用ResNet这类现代网络、却想搞清楚"模型最初为什么长这样"的人。我会从网络结构、关键设计、训练代码和避坑笔记几个方面,把我复现LeNet时看到的细节一起说清楚。
1.1 一个只有六万参数的微型网络,为什么"五脏俱全"
如果把LeNet-5拆开看,它的结构可以用一串箭头概括:
输入32×32灰度图 → C1卷积层(6个5×5卷积核) → S2下采样层 → C3卷积层(16个5×5卷积核) → S4下采样层 → C5卷积层(120个5×5卷积核) → F6全连接层 → 输出层(10类)这套流程放到今天依然眼熟。卷积层负责提取局部特征,下采样层负责缩小空间分辨率、增大感受野,全连接层负责把抽象特征映射到类别得分。后面出现的AlexNet、VGG、ResNet,宏观上都没有跳出这个套路:先让空间尺寸逐步减小、通道数逐步增加,最后接一个分类头。区别只是层数更深、设计更精细。
LeNet-5的全部参数量大约只有6万个。这个数字放在今天极其不起眼,一个常规的ResNet都有上千万参数。但正是这种"小而全",让它成为理解CNN内部运作机制的绝佳载体。你不需要等GPU、不需要搭分布式训练,单张CPU上一轮迭代也就几十秒,这对调试和学习来说太友好了。
真正让我觉得它值得反复研究的原因,是它的每个结构都有数学上的"为什么"。为什么卷积核要共享参数?为什么C3层要用不对称的连接表?为什么下采样层里还要放可学习系数?这些"为什么"在今天的框架教程里很少被解释,但它们恰恰是LeNet留给后来者最宝贵的东西。
1.2 你会在哪种场景下需要复现LeNet
这些年我见过不少把LeNet拿来用的场景,总结下来大概是三类。
第一类是学习入门。在MNIST上快速体验模型定义、反向传播、损失下降、准确率评估的完整闭环。对新手来说,用一两百行代码就能写完并跑通整个流程,成就感来得特别快,而且特别适合用来建立"深度学习到底是什么"的直觉。
第二类是当作基线模型。做OCR、验证码识别、简单字符分类任务时,先拿LeNet打底,看准确率起点在哪里,再决定要不要上更复杂的网络。它参数少、训练快、不容易过拟合,用来作为被试对象的参照非常合适。
第三类是对模型设计思路做研究。比如我这次就是想搞清楚C3连接表对特征多样性到底有多大影响,才专门把原始连接方式用代码还原了一遍。这类偏抽象的需求,LeNet也是再合适不过的样本。
无论哪种场景,LeNet都不会让你白花时间。它太经典了,以至于今天主流深度学习框架的官方示例里仍然保留着它的位置。下面我从结构开始,一层一层把它拆开。
2. 逐层拆解LeNet-5:七层网络在每一层都做了什么
要理解LeNet,先记住这张结构表。我按原始设计整理了每一层的输出尺寸和参数量,建议把它当成索引来用,后面的小节会对每个关键层展开说明。
| 层名 | 输出尺寸 | 结构说明 | 参数量 |
|---|---|---|---|
| 输入层 | 32×32×1 | 单通道灰度图,像素值经过归一化 | - |
| C1卷积层 | 28×28×6 | 6个5×5卷积核,步长1,无填充 | 156 |
| S2下采样层 | 14×14×6 | 2×2区域平均,乘以可学习系数再加偏置 | 12 |
| C3卷积层 | 10×10×16 | 16个5×5卷积核,使用稀疏连接表 | 1516 |
| S4下采样层 | 5×5×16 | 2×2区域平均,乘以可学习系数再加偏置 | 32 |
| C5卷积层 | 1×1×120 | 120个5×5卷积核,覆盖全部16张特征图 | 48120 |
| F6全连接层 | 84 | 与C5全连接,tanh激活 | 10164 |
| 输出层 | 10 | 10个RBF单元(现代实现常用线性层) | 840~850 |
把参数加起来,C1、S2、C3、S4、C5、F6六层刚好是6万个左右。有个值得注意的现象:参数大头几乎全集中在C5和F6这两个靠近输出的层上,前面的卷积层反而很轻。这正是局部连接和权值共享带来的收益,后面我会专门算这笔账。
2.1 从输入到C1:卷积核如何在32×32图上滑动
LeNet把输入约定为32×32的灰度图,这个尺寸不是随意定的。原始任务里的手写数字在送入网络之前,会经过归一化处理,让数字主体居中、四周留出一些空白。这样卷积核在滑动时,既能完整看到字符轮廓,又能感知到背景对比,特征提取会更稳定。
C1层使用6个5×5卷积核。对于32×32的输入,步长取1、不填充,输出尺寸的计算方式为:
(32 - 5) / 1 + 1 = 28于是每张输入图会生成一张28×28的特征图,6个核就是6张。一个卷积核本质上就是一个5×5的"小窗口模板",滑动到不同位置时,如果局部区域与模板相似,输出值就会大。这个阶段,6个核意味着网络最多只能学习6种基础特征,最常见的是横线、竖线、斜边、角点之类。
有同学会问:只有6个核会不会不够用?对MNIST这种数字笔画相对规则的任务来说,确实够用了。这也是LeNet能"小而有力量"的前提。如果换成人脸识别这种复杂任务,6个核连边都摸不着,这也是为什么现代网络的第一层动不动就是64个甚至96个卷积核。
2.2 S2到S4:下采样到底在降低什么
S2对C1输出的6张特征图分别做下采样。做法是把每张28×28的特征图划分成互不重叠的2×2小块,对每个小块里的4个像素取平均,然后乘上一个可学习标量、加上可学习偏置,最后过tanh激活,输出变成14×14×6。
这个操作有两个直接目的。
第一,降低计算量。14×14的像素总量只有28×28的四分之一,后续C3卷积层的计算开销直接缩水,这是几十年前算力窘迫时代非常实际的考量。
第二,引入平移和轻微形变的容忍度。数字图像里的笔迹不可能每次都完美对齐,一个特征稍微偏移两个像素,经过2×2下采样后,上层神经元仍然可能给出相近的响应。这种"容忍度"不是卷积自带的,而是池化结构带来的。
S4做的事情和S2完全一致,只不过输入从6张特征图变成16张,尺寸从10×10变成5×5。走到这一步,空间分辨率已经非常小了,但通道数从6涨到了16。网络对图像的描述越来越抽象:先是局部边缘,然后是边缘组合成的部件,最后是整个数字的大致抽象结构。
2.3 C5、F6和输出层:为什么越往后越像全连接
C5虽然名义上是个卷积层,但它很特殊。它的输入是S4输出的5×5×16特征图,卷积核依然是5×5,步长1。因为卷积核尺寸刚好和输入特征图一样大,卷积一次就覆盖了整张图,所以输出变成1×1×120。
换句话说,C5的每一个输出神经元实际上连接了S4的全部16×5×5=400个值。这和标准全连接层在数学上完全等价,只是形式上披了一层卷积的外衣。之所以仍叫卷积,是为了保持"卷积只是局部连接的特例"这一统一视角——C5刚好是感受野和整张图重合时的特殊情况。
F6层把120维特征压缩到84维,使用tanh激活。84这个数字放在今天并不特殊,但它体现了那个时代的设计思路:在最终分类前保留一个中等宽度的隐层,给输出层足够的非线性空间去刻画各类别的原型。
输出层在原始实现里也很有讲究。它不是softmax,而是一组RBF(径向基函数)单元。每个类别对应一个模板向量,RBF单元计算F6输出与该模板向量之间的欧氏距离,距离最小的那个类别就是预测结果。今天复现时,绝大多数人直接换成线性层加softmax,用交叉熵损失训练,效果往往更稳定。原因在于交叉熵梯度对分类边界更友好,而原始RBF的梯度在距离接近零时容易消失。
3. LeNet里容易被忽略的三个关键设计:权值共享、连接表与可学习池化
单纯看懂层结构只是第一步。LeNet真正有意思的地方藏在几个细节设计里。这些设计在今天的框架教程里经常被一笔带过,但它们决定了网络的表达能力和泛化能力。
3.1 权值共享对比全连接:参数量差了三个数量级
权值共享是CNN最核心的发明之一。我经常用这样一组数字来体会它有多重要。
C1层只有6个5×5卷积核,算上偏置一共156个参数。如果不做权值共享,让C1的每个输出位置都独立连接到输入的所有像素,那么从32×32的输入到28×28×6的中间表示,单这一层就要大约480万个连接权重。对比一下,156对480万,参数差了将近三万倍。
共享参数的本质是一种强先验:图像特征不随位置变化。一个在画面左上角有效的边缘模板,在右下角大概率同样有效。所有空间位置共用同一组卷积核,不只是省了参数,更重要的是把"平移等变性"直接编码进了模型结构——同一个数字挪到画面任何位置,都能被同一组核识别出来。这种先验对图像任务太有效了,以至于后世十几年几乎没有动摇过这个基本设计。
3.2 C3连接表:不对称连接到底在解决什么问题
C3层是LeNet里最容易被忽略、也最有故事的一个层。
如果让C3的16张特征图全部连接S2的6张特征图,每个5×5卷积核需要6×25+1=151个参数,16个核一共2416个参数。但原始设计没有这么做,而是用了一张稀疏连接表:前6张C3特征图各连接3张S2图,中间9张各连接4张,最后1张连接全部6张。
直接收益是参数量从2416降到1516。但更深层的目的,是打破对称性。
如果每张C3特征图看到的输入来源完全一样,那么在反向传播里,各特征图收到的梯度会非常相似,学出来的卷积核很容易趋于雷同,特征就会冗余。让不同的特征图连接不同的输入来源,相当于强迫它们"分工",各自关注不同类型的组合特征。这种不对称设计在算力和数据都有限的年代,是一种非常巧妙的正则化手段。
不过说实话,今天多数PyTorch复现并没有实现这张连接表,直接用nn.Conv2d(6, 16, kernel_size=5)做全连接也能拿到很好的结果。连接表的价值更多在于理解设计意图,而不必逐字照搬。如果你好奇某张特征图到底连了哪几张输入图,可以去看原始论文里的连接表,一两行就能看懂。
3.3 池化层里的可学习系数:LeNet不是简单的"平均池化"
很多人以为LeNet的S2、S4就是普通的平均池化,其实不是。原始实现里,每个2×2区域先求平均,然后乘一个随训练更新的标量,再加一个标量偏置,最后过tanh激活。也就是说,池化层本身是有可学习参数的,不能简单当作固定操作。
S2的12个参数,就来自6张特征图各一组(scale, bias)。这个设计的用意是让网络自己决定下采样时是放大还是压缩信号,而不是一律"求平均"。放到今天类比,它有点像BatchNorm里的缩放和平移系数,只是作用在池化输出上。
现代框架里的AvgPool2d丢掉了这部分可学习参数,MaxPool2d更是只取最大值,效果在不同任务上各有优劣。如果想让复现尽量贴近原始LeNet,我建议自定义一个带scale和bias的池化层。但如果只是入门学习,先跑通普通平均池化版本完全没问题,两者的准确率差异在MNIST上通常不超过0.2个百分点。
4. 用PyTorch从零搭建LeNet并训练MNIST:一份可以直接改的代码
讲完理论,来点真正能跑的代码。我用PyTorch从零实现了一个接近原始结构的LeNet-5,并在MNIST上做了完整训练。下面这段代码我按"结构清晰优先"来写,每一层都标了注释,方便查尺寸。
4.1 模型定义:先追求接近原始结构
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__() # 原始LeNet输入为32x32,第一层卷积不做padding self.conv1 = nn.Conv2d(1, 6, kernel_size=5) # 32 -> 28 self.pool1 = nn.AvgPool2d(kernel_size=2, stride=2) self.conv2 = nn.Conv2d(6, 16, kernel_size=5) # 14 -> 10 self.pool2 = nn.AvgPool2d(kernel_size=2, stride=2) self.fc1 = nn.Linear(16 * 5 * 5, 120) self.fc2 = nn.Linear(120, 84) self.fc3 = nn.Linear(84, num_classes) def forward(self, x): x = torch.tanh(self.conv1(x)) x = self.pool1(x) x = torch.tanh(self.conv2(x)) x = self.pool2(x) x = x.view(x.size(0), -1) x = torch.tanh(self.fc1(x)) x = torch.tanh(self.fc2(x)) x = self.fc3(x) return x注意,conv1我故意没有加padding=2。网上很多代码为了直接吃28×28的MNIST图,会给第一层加padding,这样后面每一层的尺寸全变了,fc1的输入就不再是16×5×5。你当然可以那么改,但那就不是严格意义的LeNet-5了。我的建议是先按原始结构写,然后在数据预处理里把图像尺寸对齐到32×32,这样便于逐层验证shape,出了问题也容易定位。
如果你想更贴近原始池化层,可以把AvgPool2d换成一个带可学习系数的版本:
class LeNetPool(nn.Module): def __init__(self, channels): super(LeNetPool, self).__init__() self.weight = nn.Parameter(torch.ones(channels, 1, 1)) self.bias = nn.Parameter(torch.zeros(channels, 1, 1)) def forward(self, x): x = F.avg_pool2d(x, kernel_size=2, stride=2) return torch.tanh(self.weight * x + self.bias)用的时候把pool1 = LeNetPool(6)、pool2 = LeNetPool(16)替换掉原来的AvgPool2d即可。初次复现时,我建议先跑普通AvgPool版本,流程全通了再换也不迟。
4.2 MNIST预处理:28×28为什么要塞进32×32
MNIST原始图像是28×28,LeNet输入要求32×32。最简单的处理方式是直接缩放到32×32,再归一化像素值。
from torchvision import datasets, transforms transform = transforms.Compose([ transforms.Resize((32, 32)), transforms.ToTensor(), transforms.Normalize((0.1307,), (0.3081,)) ]) train_ds = datasets.MNIST(root='./data', train=True, download=True, transform=transform) test_ds = datasets.MNIST(root='./data', train=False, download=True, transform=transform)这里有两个点值得解释。第一,Resize((32, 32))会把28×28插值放大到32×32,对MNIST这种笔画简单的数字来说,信息损失可以忽略。第二,Normalize这步经常被新手省略,但我测试过,不归一化时损失初始值会偏高,收敛也需要更多轮数。
MNIST本身的像素取值是0~255的整数,ToTensor()会先压到0~1之间。Normalize的均值0.1307和标准差0.3081是MNIST数据集的统计量,归一化之后特征分布近似标准正态,梯度更新更平稳。这两行是数据管线的标配,别省。
4.3 训练循环与一版可复现结果
下面是完整的训练代码,优化器选用SGD加momentum,损失函数用交叉熵。批量大小取64,训练10个epoch。
import torch from torch.utils.data import DataLoader train_loader = DataLoader(train_ds, batch_size=64, shuffle=True) test_loader = DataLoader(test_ds, batch_size=256) model = LeNet5() optimizer = torch.optim.SGD(model.parameters(), lr=0.01, momentum=0.9) criterion = nn.CrossEntropyLoss() for epoch in range(10): model.train() total_loss = 0.0 for images, labels in train_loader: optimizer.zero_grad() outputs = model(images) loss = criterion(outputs, labels) loss.backward() optimizer.step() total_loss += loss.item() * images.size(0) avg_loss = total_loss / len(train_ds) model.eval() correct = 0 total = 0 with torch.no_grad(): for images, labels in test_loader: outputs = model(images) preds = outputs.argmax(dim=1) correct += (preds == labels).sum().item() total += labels.size(0) acc = correct / total * 100 print(f"epoch {epoch+1:02d} | loss {avg_loss:.4f} | test acc {acc:.2f}%")这套配置在我的模拟项目X里得到的结果大致是:第1个epoch结束后测试准确率就能到95%左右,第3个epoch越过98%,第8到第10个epoch稳定在99%上下。LeNet的训练速度很快,10个epoch在普通CPU上也就几分钟。
这里有个经验规律值得分享:当测试准确率超过99%之后,损失可能还在缓慢下降,但准确率几乎不再变化。这不是代码有bug,而是这个小模型的表达能力已经接近上限。如果你想继续提升,方向不再是调学习率,而是加数据增强、换更大模型或者做集成,否则只是在原地空转。
5. 复现LeNet时避坑笔记:学习率、初始化与损失曲线的真实反馈
代码能跑通只是第一步。真正让新手崩溃的,往往是那些报错信息看不懂、损失曲线乱飞、结果复现不出来的情况。我把复现LeNet时反复踩过的坑整理在这里,希望你能少走弯路。
5.1 学习率过高时损失曲线会如何"表演"
我用SGD加momentum做过一组对照实验。学习率取0.1时,训练损失在前面几百个batch里明显震荡,测试准确率在90%附近反复横跳,难以稳定上升。学习率取0.01时,损失稳步下降,10个epoch收敛到99%左右。学习率取0.001时,收敛明显变慢,但最终也能到98%附近,只是要多花一倍的时间。
这个现象和LeNet使用的激活函数有直接关系。原始结构用tanh,它的输出在±1处饱和,一旦某个神经元被推到饱和区,梯度就会趋近于零,反向传播几乎更新不动。学习率偏大时,很多神经元容易被快速推向饱和区,损失自然来回震荡。
如果你坚持用tanh,学习率保守一点更稳妥。如果换成ReLU,问题会大幅缓解,学习率甚至可以直接提到0.05甚至0.1。我的建议是:为了忠实复现原始结构,用tanh体会一遍;为了实际应用快速出结果,换成ReLU更省心。
5.2 三处容易让新手卡住的细节
第一处是维度报错。PyTorch的报错信息通常会指向fc1的in_features,这时候不要盲目去改Linear的输入大小,而是应该回头逐层计算每张特征图的尺寸。LeNet的尺寸链路很清爽:32→28→14→10→5,只要有一层加了padding,后面就全乱了。我习惯在模型里加一段临时的打印代码,把每层输出shape打出来,定位问题比看报错快得多。
第二处是忘记数据归一化。直接拿0~255的像素喂给模型,损失初始值会明显偏高,训练过程也会变慢。这个问题不致命,但会严重干扰你对"模型到底学得怎么样"的判断。归一化之后,损失曲线更容易呈现漂亮的下滑,也更容易判断学习率是否合适。
第三处是训练集和测试集的评估混在一起。LeNet很小、训练快,10个epoch内过拟合并不严重,这会让一些人偷懒,只打印训练集的损失,不看测试集准确率。但实际任务里,过拟合一定会在某一天找上门,养成"验证集为准"的习惯越早越好。我每次做完一组实验,都会把测试集准确率当作最终指标,训练损失只作为参考。
5.3 从LeNet延伸出去:现代CNN还在沿用哪些设计
写到这里,有必要把视野拉回到今天,看看LeNet的影响到底有多深。
第一,卷积加池化的交替堆叠依然是主流。不过池化方式变了,现代网络更多用MaxPool,再往后甚至用带步长的卷积直接替代池化,但"缩小分辨率、增大通道数"的路线没变。
第二,激活函数从tanh、sigmoid全面换成了ReLU及其变体。核心原因是解决梯度消失,让网络可以堆得更深。LeNet只有5层深,感觉不到这个问题;一旦到AlexNet的8层、VGG的16到19层,tanh就会迅速失灵,ReLU几乎是必然选择。
第三,深层网络的稳定训练离不开BatchNorm和Dropout。LeNet时代没有这些武器,训练全靠小心的初始化和学习率设置。今天你在LeNet后面再加几层卷积,就会发现不加大招根本训不动,这就是为什么现代网络都会配齐这些组件。
第四,分类头开始变轻。很多现代网络不再用一层大规模的全连接收尾,而是用全局平均池化加1×1卷积替代,参数量更少、过拟合风险更低。LeNet末尾多达6万参数中,一多半都消耗在C5和F6上,这个设计放在今天是相当奢侈的。
LeNet之后的十几年,深度学习经历了爆发式发展,但底层逻辑始终没有离开它当年确立的基本盘:局部感知、参数共享、逐层抽象。搞懂了LeNet,再去看任何一张现代CNN的结构图,你都会觉得似曾相识。
最后分享一个我自己的习惯:每次复现完一个经典模型,我都会把测试集里预测出错的样本按网格打印出来,逐个看。LeNet的典型错误集中在"1被认成7"、笔画过分密集的潦草数字等样本上。这些错误会让你看到模型能力的真实边界,比任何理论分析都有说服力。LeNet虽然小,但它值得你认真对待——毕竟它是那一代深度学习留给今天最好的入门礼物。