提到卷积神经网络很多人的入门第一课就是 LeNet。哪怕放到今天各种上百层的大模型已经满天飞我依然觉得 LeNet 这套架构值得认真吃透。它结构不复杂却把所有核心组件——卷积、池化、全连接——按一种非常朴实的逻辑串了起来专门解决手写数字识别这件事。这篇文章就围绕 LeNet 展开把它的设计思路、网络结构、从零实现到训练踩坑完整聊一遍。无论你是刚接触深度学习的新手还是想回头把基础概念重新夯实的老手这都是一份可以直接参考的实操记录。很多人会问现在有那么多更强的模型为什么还要去啃一个几十年前的老架构我的看法是LeNet 就像一组很好的乐高积木虽然只有几个基础零件但拼装方式深刻影响了后续几乎所有卷积网络。ResNet、VGG、DenseNet 里的卷积块、池化策略甚至注意力机制里的位置编码骨子里都延续着 LeNet 的某些思考。把它的每个细节弄清楚后面学任何复杂网络都会轻松得多。1. 为什么在深度学习快餐时代还要死磕 LeNet1.1 从现代视角看 LeNet 到底“长什么样”LeNet 的全称通常写作 LeNet-5数字「5」表示它主要由 5 个层级组成两个卷积层、两个池化层再加两个全连接层通常会把最后的输出层也算作全连接层所以严格说核心结构是 222。输入是一张单通道灰度图原始设计里推荐把图像 resize 成 32×32 送入网络。数据经过卷积、池化、再卷积、再池化最后把特征图展开成一维向量通过全连接层映射到 10 个类别。从现在的角度看这个结构简单得像是“玩具”没有 Batch Normalization没有残差连接没有 Dropout激活函数用的还是早期的 tanh 或者 sigmoid。但正是这种简单让它成为一个极佳的教学样本。你不需要被各种技巧干扰只需要盯着数据在每一层的变化就能把特征提取和分类这两件核心任务的关系看明白。LeNet 的输入尺寸为什么是 32×32 而不是原始图片的 28×28因为 28×28 经过两次 5×5 卷积、两次 2×2 池化后边长会缩水到 28-4-420再池化变成 10最后剩下 5×5 的特征图。如果一开始把输入提升到 32×32经过两次池化后正好收成 5×5可以让最后全连接层的输入有一个整齐的尺寸。这个细节不是随便定的而是根据卷积和池化的感受野反复推出来的。1.2 LeNet 的设计哲学局部感受野与参数共享LeNet 最有价值的贡献是把“卷积”这个概念落实成了真正好用的视觉特征提取器。早期的全连接网络处理图片时每个像素都要和下一层的每个神经元连接参数数量爆炸。比如一张 32×32 的灰度图如果第一层全连接有 1000 个神经元那权重就是 1024×1000100万多个参数。这个数量在十几年前基本没法训练。卷积层解决这个问题靠的是两个思路。第一是局部感受野图像里相邻像素之间的相关性远大于相隔很远的像素所以每个神经元只连接输入的一小块区域比如 5×5。第二是参数共享同一个卷积核会在整张图上滑动也就是说检测“横向边缘”的这个模板要在所有位置都生效权重完全相同。这样一来一个 5×5 的卷积核只有 25 个权重参数加上一个偏置也就 26 个参数。哪怕有 6 个卷积核也才 156 个参数。和上百万规模的全连接层相比这简直是从大象变成蚂蚁。此外池化层降采样层的作用是缩小特征图的尺寸让后面的层看到的范围更大同时也在一定范围内增强平移不变性。简单说数字稍微偏移几个像素池化之后特征差异不会特别剧烈。这个设计理念到今天仍然活跃在各种网络里只是池化方式从平均池化慢慢演变成最大池化或者干脆用带步长的卷积替代。2. 网络结构与核心参数逐层拆解2.1 各层结构一览一张表看懂全部细节我这里按最标准的 LeNet-5 配置列一下输入假设为 32×32 单通道。注意实际复现中有些层会做小改动我后文会提到。层名称操作类型卷积核/窗口输出尺寸参数量约输入--1×32×320C1卷积6 个 5×5 卷积核步长 1无 padding6×28×28156S2平均池化2×2 窗口步长 26×14×1412可训练系数偏置C3卷积16 个 5×5 卷积核步长 1无 padding16×10×102416按全连接卷积估计S4平均池化2×2 窗口步长 216×5×532C5卷积120 个 5×5 卷积核120×1×148120F6全连接输入 120输出 848410164输出全连接输入 84输出 1010850注意C5 虽然名义上是卷积层但它的输入正好是 16×5×5 的特征图卷积核也是 5×5所以输出为 1×1。本质上它就是一个全连接层。现在很多人实现的时候直接用FlattenLinear(16*5*5, 120)来实现 C5效果完全一样。参数量的计算也很直观C1 层每个卷积核有 5×525 个权重加上 1 个偏置共 26 个参数6 个核就是 156。C3 层如果把输入看成一个整体每个核需要 5×5×6150 个权重加 1 个偏置16 个核就是 151×162416。C5 层每个核需要 5×5×16400 个权重再加 1 个偏置120 个核就是 401×12048120。整个网络参数加起来不到 6 万这在现代动辄几亿参数的模型面前确实是“轻量级小钢炮”。2.2 卷积尺寸变化与池化的数学直觉每次卷积之后尺寸如何变化有一个非常简单的公式输出尺寸 (输入尺寸 - 卷积核尺寸 2×padding) / 步长 1LeNet 的卷积层都是 5×5 卷积核padding0步长1所以 32 变 2828 变 10。池化层是 2×2 窗口步长 2所以 28 变 1410 变 5。这几个数字之间环环相扣理解后可以自己设计网络。关于池化LeNet 原始版本用的是平均池化Average Pooling也就是在 2×2 的窗口里取平均值。现代实现中很多人会改成最大池化Max Pooling原因在于最大池化可以保留最明显的特征响应有利于提取边缘、纹理等细节并且求导简单。但平均池化也有它的好处它对所有位置的低频特征更敏感输出更平滑。在 LeNet 那个年代平均池化加可训练系数的组合能把噪声对特征的影响压到更低。今天我们不必纠结于必须用哪种理解它们的差异即可。2.3 关于 C3 层连接方式一个容易被忽略但很有意思的细节原始 LeNet-5 的 C3 层并不像我们现在实现的那样把每个卷积核和 S2 的全部 6 个特征图都连接而是采用了一种非对称的连接策略。具体来说16 个卷积核里前 6 个只连接 S2 中相邻的 3 个特征图中间 6 个连接相邻的 4 个特征图再后面 3 个连接不相邻的 4 个特征图最后一个连接全部 6 个特征图。这种设计有两个目的一是减少参数量二是强制不同卷积核学到互补的特征。在现代计算机视觉里非对称连接很少被主动使用了因为全连接的卷积已经足够高效而且 GPU 上全连接卷积的矩阵运算更规整对硬件更友好。但了解这个细节仍然有价值它提醒我们网络结构的设计不只是堆叠层而是在权衡容量、计算成本和特征多样性。读历史模型的意义就在于此能帮你看到设计师当时的思考过程而不是机械地背架构。3. 从零开始实现并训练一个 LeNet3.1 环境与数据准备我用的深度学习框架是 PyTorch版本是 2.xPython 3.10 以上即可。如果你没有 GPU用 CPU 跑 LeNet 也完全足够因为总计算量非常小几分钟就能完成一轮训练。当然有 GPU 会更快但其实都体会不到什么延迟。数据方面自然选择手写数字识别数据集。这个数据集包含 6 万张训练图片和 1 万张测试图片每张都是 28×28 的灰度图。LeNet 要求输入 32×32所以需要在预处理时先用插值把图片 resize 到 32×32。我用 torchvision 来加载和预处理代码如下import torch import torch.nn as nn import torch.nn.functional as F from torch.utils.data import DataLoader from torchvision import datasets, transforms transform transforms.Compose([ transforms.Resize((32, 32)), transforms.ToTensor(), transforms.Normalize((0.1307,), (0.3081,)) ]) train_dataset datasets.MNIST(root./data, trainTrue, downloadTrue, transformtransform) test_dataset datasets.MNIST(root./data, trainFalse, downloadTrue, transformtransform) train_loader DataLoader(train_dataset, batch_size64, shuffleTrue) test_loader DataLoader(test_dataset, batch_size256, shuffleFalse)这里Normalize((0.1307,), (0.3081,))分别表示数据集的均值和方法这两个数值是手写数字集全量像素统计出来的。归一化后像素值大致落在 [-1, 1] 区间可以大幅提升训练的稳定性。如果你不想用预设值也可以自己算但直接用常见值是安全的。3.2 网络定义用现代写法复刻经典结构按标准 LeNet-5 配置写一个 PyTorch 模型激活函数我在这里用 ReLU而不是原始的 tanh。原因是 ReLU 能有效缓解梯度消失而且现代框架对它的优化更好训练收敛速度肉眼可见地快。如果你想忠实还原也可以把F.relu换回torch.tanh即可但要注意收敛速度可能会慢一些。class LeNet(nn.Module): def __init__(self): super(LeNet, self).__init__() self.conv1 nn.Conv2d(1, 6, kernel_size5) # 1x32x32 - 6x28x28 self.pool1 nn.AvgPool2d(kernel_size2) # 6x28x28 - 6x14x14 self.conv2 nn.Conv2d(6, 16, kernel_size5) # 6x14x14 - 16x10x10 self.pool2 nn.AvgPool2d(kernel_size2) # 16x10x10 - 16x5x5 self.fc1 nn.Linear(16 * 5 * 5, 120) # 输入展平后为400 self.fc2 nn.Linear(120, 84) self.fc3 nn.Linear(84, 10) def forward(self, x): x self.pool1(F.relu(self.conv1(x))) x self.pool2(F.relu(self.conv2(x))) x x.view(x.size(0), -1) x F.relu(self.fc1(x)) x F.relu(self.fc2(x)) x self.fc3(x) return x这段代码有一个非常关键的点就是view(x.size(0), -1)它把16×5×5的特征图展平成大小为 400 的向量然后才能喂给全连接层。千万别忘了写这一步否则维度会报错。另外池化层我用的是AvgPool2d对应原始的平均池化。如果你想试试最大池化把AvgPool2d改成MaxPool2d即可其他不用动。二者在这个任务上都能达到不错的效果但最大池化普遍会快一点收敛。我两种都试过最终准确率差异不大。3.3 训练超参数的挑选这些值不是拍脑袋定的训练 LeNet最常用的优化器是随机梯度下降 SGD并加上动量。学习率可以从 0.01 开始这是一个小而稳的经验值。LeNet 参数量少0.01 的更新幅度足够让它快速下降不会出现太大的震荡。如果你用 Adam学习率可以调低到 0.001但 SGD 动量的经典组合通常更符合这种小模型的特性。损失函数使用交叉熵CrossEntropyLoss。有一点要注意PyTorch 的nn.CrossEntropyLoss内部已经包含了 Softmax 操作所以网络的最后一层直接输出 10 个节点的裸 logits 就可以了不要在forward里再手动加softmax。这算是一个很常见的坑尤其是从 TensorFlow 转过来的人容易搞混。训练循环的代码框架如下每轮训练时把图片和标签搬到设备上清零梯度前向传播计算损失反向传播优化器更新。再在测试集上计算准确率。device torch.device(cuda if torch.cuda.is_available() else cpu) model LeNet().to(device) criterion nn.CrossEntropyLoss() optimizer torch.optim.SGD(model.parameters(), lr0.01, momentum0.9) def train_one_epoch(epoch): model.train() total_loss 0.0 correct 0 total 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) _, predicted torch.max(outputs, 1) correct (predicted labels).sum().item() total labels.size(0) avg_loss total_loss / total acc correct / total print(fEpoch {epoch}: loss{avg_loss:.4f}, acc{acc:.4f}) for epoch in range(1, 11): train_one_epoch(epoch)测试集评估代码类似只是把model.train()改成model.eval()并且包在torch.no_grad()里。eval()的作用是关掉训练相关逻辑确保池化、Dropout 等层在推理时行为一致。这里没有 Dropout但为了后续扩展还是养成习惯。3.4 训练过程与结果观察我自己在 CPU 上跑这个配置每轮大约半分钟到一分钟10 轮下来几分钟结束。观察到的趋势一般是第 1 轮结束训练准确率差不多 93%~95%第 3 轮之后准确率能到 97% 以上到第 8~10 轮训练准确率基本稳定在 99% 左右测试准确率也在 98%~99% 之间。LeNet 在手写数字集上达到 98% 以上并不困难毕竟这个数据集本身相对简单。如果你发现准确率卡在 90% 上下通常不是网络结构的问题而是预处理或训练参数的问题。我在下一章会具体聊几种常见故障。4. 训练中常见的问题与排查实录4.1 收敛太慢或者 loss 一直不动这个问题我见过太多次其中一半以上是学习率没设置好。LeNet 属于小网络如果学习率设到 0.001训练会显得很“肉”loss 缓慢下降几轮下来还看不到明显效果。我建议先用 0.01 作为基准如果 loss 出现剧烈震荡再降到 0.005 或 0.003。如果完全不动先检查数据是否归一化其次检查标签是否正确最后再检查模型最后一层是不是没有展开成全连接层。另一个很容易被忽略的点是输出层的激活。如果你在forward的最后手动加了softmax再交给CrossEntropyLoss等于做了两次 softmax 操作虽然通常不会报错但会导致梯度效果变差、收敛变慢。保持最后一层是裸 logits 才是正确姿势。4.2 训练集过拟合或者欠拟合LeNet 参数量只有几万在标准的几万张训练图片上是很不容易过拟合的。但如果你只用了其中几千张子集或者做了过多的数据增强比如旋转、裁剪、加噪声就可能看到训练集准确率接近 100%测试准确率却往下掉。解决办法是降低数据增强强度或者在全连接层之间加一个 Dropout比如p0.5。虽然原始 LeNet 没有 Dropout但现代复现中加上它通常能提升测试集泛化性。欠拟合的情况更少见但如果你的卷积核数量比标准配置少很多比如第一层只用 2 个卷积核或者训练轮数太少训练损失会一直居高不下。这种时候优先考虑增加卷积核数量或训练轮数而不是盲目加大学习率。加大学习率只能加速收敛并不能扩展模型容量。4.3 学习率、权重初始化与激活函数的取舍我发现很多初学者会忽略权重初始化的重要性。PyTorch 的nn.Conv2d和nn.Linear默认使用了一定的随机初始化策略通常是 Kaiming 初始化这已经足够支撑 LeNet 收敛。如果你自己手动把权重都置为零网络将完全无法学习如果初始化值过大反向传播时梯度容易爆炸。所以除非你对初始化的理解非常深否则用框架默认设置就好。激活函数方面ReLU 对比原始 tanh 的最大优势是计算快、梯度不容易饱和。但 ReLU 有一个“死神经元”的问题如果学习率过大很多神经元可能永久输出 0。在小网络里这通常影响不大因为参数共享让每个卷积核依然能学到有效特征。如果你发现使用 ReLU 时训练准确率突然掉到某个值后再也上不去可以把学习率调到 0.005或者换成 LeakyReLU 试一下。我个人在实际操作中的体会是学习率 0.01、动量 0.9、ReLU、平均池化、输入归一化这套组合对 LeNet 在手写数字集上的表现几乎是最稳的。我踩过最大的坑是忘记view展平导致维度报错排查了半天才发现是少了这一步。这个教训让我养成了一个习惯在写完 forward 后先打印每一层的输出形状确认无误再跑训练。如果你还想进一步延伸可以试着对 LeNet 做三件小事把第一层卷积核数量从 6 改成 32观察参数量变化和准确率提升把平均池化换成最大池化比较收敛速度或者把输入图片做随机旋转测试网络的鲁棒性。这些改动都很简单但能帮你真正建立对卷积网络的直觉比单纯看论文有效得多。
阅读完成 · 觉得有帮助?