LeNet-5全解析:从结构到PyTorch实现与调参实战

发布时间:2026/9/24 20:00:30
LeNet-5全解析:从结构到PyTorch实现与调参实战 第一次让一个朋友跑通图像分类模型我选的就是 LeNet 加 MNIST而不是上来就上 Transformer 或者大语言模型。原因很简单LeNet 足够小小到能在普通 CPU 上几分钟跑完一轮训练又足够完整完整到卷积神经网络的核心组件——卷积、池化、全连接、感受野、参数共享——它全都覆盖了。哪怕站在今天这个“万物皆可 Transformer”的时间点回头看LeNet 依然是理解卷积神经网络CNN最好的起点。这篇博文我会用“拆零件”的方式把 LeNet-5 彻底讲透先讲它为什么这么设计再逐层拆解输入输出和参数计算然后给出完整可复现的 PyTorch 代码最后把我复现时踩过的坑和排查经验全部写出来。无论你是刚看完深度学习理论课、准备动手敲第一个模型还是已经被各种现代网络结构绕晕了想回炉基础这篇内容都适合你。看完之后你可以做到两件事第一闭着眼睛画出 LeNet-5 的结构图和每一层的参数数量第二不依赖教程提示独立用 PyTorch 把它写出来并训练到 99% 以上的准确率。1. 内容整体设计与思路拆解1.1 手写数字识别图像分类的“入门题”LeNet 最初的任务是手写数字识别对应的是 MNIST 数据集。MNIST 里是 28×28 的灰度图片每张图上有 0 到 9 中的一个手写数字训练集 6 万张、测试集 1 万张。这个任务放在今天的深度学习标准里看属于“简单模式”背景干净、数字居中、类别只有 10 个而且类间差异足够明显。但恰恰是这种“简单”让它成为验证 CNN 原理的最佳实验场。我见过太多人一上来就在 ImageNet 上跑 ResNet结果训练一轮要几小时根本没法通过快速实验去理解每一层在做什么。而 LeNet 加上 MNIST你可以随意改结构、调参数、观察中间特征图一次迭代的代价极小反馈却非常直接。还有一个容易被忽略的点MNIST 的手写数字识别和我们人类识字的逻辑很相似。我们看一个数字不会先逐个像素看而是先看轮廓、看笔画方向、看局部形状再组合成整体判断。卷积神经网络的层级结构恰好模拟了这个过程——低层提取边缘和笔画中层组合出数字的局部部件高层综合成完整的数字语义。这个“从局部到全局、从具体到抽象”的递进思路就是 LeNet 想教给我们的核心思维。1.2 LeNet 的诞生背景与设计哲学LeNet-5 是 Yann LeCun 等人在 1998 年发表的经典工作当时整篇论文的标题叫《Gradient-based learning applied to document recognition》LeNet 只是其中一个核心章节。那个年代没有 GPU 大规模训练也没有深度学习框架训练一个网络需要在专用硬件上跑很久。在这种资源极度受限的环境下LeNet 的设计哲学非常明确用尽可能少的参数完成图像分类任务。这套哲学落到具体设计上就是三条原则。第一局部连接每个神经元只连接输入的一个小区域而不是整张图。第二权值共享同一个卷积核扫过整张图每个位置都复用同一组权重。第三下采样通过池化逐步缩小特征图尺寸把空间信息压缩成更高层的语义信息。这三条原则并不是凭空想出来的而是基于一个重要的生物学观察视觉皮层的神经元只对视野中的局部区域产生反应大脑正是通过层层抽象来理解整幅画面的。LeNet 的设计还体现了一个非常务实的考量特征提取不靠手工设计而是靠网络自己学出来。在它之前主流做法是人工设计 HOG、SIFT 这类特征再交给 SVM 分类。LeNet 直接把原始像素喂进网络让卷积层自动学习边缘、纹理、形状等特征。这个思想上的转变比网络结构本身更具革命性后来几乎所有深度学习模型都沿袭了这条路线。2. LeNet-5 结构逐层拆解从输入到输出2.1 数据入口为什么输入必须是 32×32 灰度图LeNet-5 的输入不是 MNIST 原始图片的 28×28而是被放大到 32×32 的灰度图。很多初学者第一次看到这个设定会很困惑为什么好好的 28×28 不用非要 pad 一下原因要从网络的深度说起。看完整条网络你会发现LeNet 一共做了两次 2×2、步长为 2 的池化每次池化会把特征图尺寸缩小一半。如果输入是 28×28经历一次池化变成 14×14再经历一次池化变成 7×7紧接的 5×5 卷积算完后只剩 3×3整个特征图的信息量明显偏小后面的全连接层能拿到的“浓缩特征”就不够充分。而把输入放大到 32×32 之后两次池化后变成 8×8再过 5×5 卷积得到 4×4这个尺寸就健康得多。这里藏着深度学习里一个特别重要的设计原则**输入尺寸、网络深度、卷积核大小三者是绑定在一起设计的不是随意拼凑的。**只要你动了其中一个另外两个往往也要跟着调整。很多初学者在改网络时只改输入尺寸不改后续结构结果模型直接报错或者性能崩坏就是因为没理解这层耦合关系。2.2 C1 卷积层第一层到底在“看”什么C1 是第一个卷积层也是最容易理解的一层。输入是 1 通道的 32×32 灰度图C1 用了 6 个 5×5 的卷积核步长为 1没有填充所以输出是 6 通道的 28×28 特征图。这里的参数量是很多人第一次手算卷积网络时容易出错的地方。单个卷积核的尺寸是 5×5输入通道是 1所以一个卷积核有 25 个权重再加上 1 个偏置就是 26 个参数。C1 有 6 个这样的卷积核总参数量就是 6×26156。你注意到没有整个 C1 层只有 156 个参数却要处理 32×32 的输入图像。原因就在于权值共享——每个卷积核的 25 个权重会在整张图上滑动复用不管输入有多大卷积核的参数量是固定的。那这 6 个卷积核到底在“看”什么训练完成之后可以把卷积核可视化出来你会发现它们分别对不同方向的边缘敏感有的响应横线有的响应竖线有的响应斜向笔画。这正好对应了人类视觉系统对初级特征的提取方式。更妙的是这些卷积核不是任何人设计的而是网络自己从数据中学出来的。这就是“特征自动学习”的直观体现。2.3 S2 池化层平均汇聚背后的信息压缩逻辑S2 是一个池化层输入是 6 通道的 28×28 特征图使用 2×2 窗口、步长为 2输出 6 通道的 14×14 特征图。这里有一个细节经常被现代教程忽略LeNet 原版用的池化不是现在最常见的最大池化而是带可训练参数的平均池化。具体做法是对 2×2 区域内取平均值然后乘上一个可训练的标量权重再加一个可训练的偏置最后经过 tanh 激活函数。每个特征图对应一组这样的权重和偏置所以 S2 层的可训练参数是 6 个权重加 6 个偏置共 12 个。池化的作用我用一个生活类比来解释。想象你看一张远处的照片如果照片分辨率降低一半你还是能认出里面的人是谁因为你关注的是轮廓和整体结构而不是每一根头发的细节。池化就是在干这件事把空间分辨率降下来但把重要的结构信息留下来。这样做有三个直接好处一是参数总量大幅减少计算压力下降二是让网络对轻微的平移和形变更鲁棒——数字稍微歪了一点池化后的特征依然稳定三是每个神经元的感受野变大了后面的卷积层能看到更大范围的图像区域。现代实现中很多人直接把 S2 简化成普通的平均池化省略掉那两个可训练参数效果差别其实不大。但我建议初学阶段还是按原版结构实现一遍因为“对可训练参数的直觉理解”这件事是靠这种小细节积累出来的。2.4 C3 卷积层经典的 partial connection 设计C3 是 LeNet-5 里结构最特殊、也是现代实现里被简化最多的一层。输入是 S2 输出的 6 通道 14×14 特征图C3 用了 16 个 5×5 卷积核输出 16 通道的 10×10 特征图。如果按照“每个输出特征图都连接全部 6 个输入通道”的全连接方式实现参数总量是 16×(5×5×61)2416。但原论文没有这么做而是设计了一张连接表让不同的输出特征图只连接一部分输入通道。具体来说前 6 个输出特征图各连接 3 个输入通道中间的 6 个输出特征图各连接 4 个输入通道接下来的 3 个输出特征图各连接 4 个输入通道最后一个输出特征图连接全部 6 个输入通道。这样算下来的参数量是 6×(3×251)6×(4×251)3×(4×251)1×(6×251)1516比全连接版本少了接近 900 个参数。原论文这样设计的理由是打破对称性、减少连接数量。所谓“打破对称性”是因为如果每个输出图都连接完全相同的输入组合那么多个卷积核可能学到完全一样的特征白白浪费参数。让不同输出图看到不同的输入组合它们就更有可能分化出不同的特征检测器。在 1998 年那个算力紧张的年代这种精打细算非常必要。放到今天的框架下C3 的“部分连接”几乎不会有人再手动实现了PyTorch 里我建议初学者直接用全连接版代码就是普通的 nn.Conv2d(6, 16, 5)先把网络跑通、理解整体流程再回头研究这张连接表的精妙之处。全连接版本的参数量虽然多了近 900 个但对 MNIST 这种小任务的影响完全可以忽略。2.5 S4 池化层到 C5从“局部视野”走向“全局视野”S4 的结构和 S2 完全一致16 通道的 10×10 输入经过 2×2 平均池化变成 16 通道的 5×5 输出同时带 16×232 个可训练参数。到这里整张 32×32 的输入图片已经被压缩成了一个 16 通道、5×5 的“语义地图”。每个通道代表一种特征模式5×5 的空间位置代表这种特征出现的位置。这个“压缩”过程非常关键它把原始像素空间的信息逐步转化成了任务相关的语义信息。C5 是紧接着的一层用了 120 个 5×5 卷积核。因为输入特征图恰好是 5×5卷积核滑不动了卷积结果就是 1×1所以这一层的输出是 120 个 1×1 的值。从数学上看这等价于把输入展平成 16×5×5400 维向量然后做一个 400→120 的全连接映射。但 LeNet 的作者特意保留了卷积的形式主要考虑是强调“CNN 内部的结构一致性”在当时的实现框架里也便于统一计算。这一层的参数量是 120×(5×5×161)48120是整个 LeNet 中参数最多的层数量占了全网络的大约八成。C5 的意义在于完成了从“空间特征图”到“特征向量”的转变。前面的卷积和池化都在做空间信息提取而从这里开始网络开始做分类决策了。2.6 F6 与输出层分类决策是怎么做的F6 是一个全连接层输入是 C5 展平后的 120 维向量输出 84 个神经元经过 tanh 激活。参数量是 84×1208410164。很多人好奇为什么偏偏是 84 这个数字看起来毫无规律。原论文的解释是这 84 个神经元对应一个 7×12 的位图模板也就是设计者把每个字符按 7×12 的像素网格切分让 F6 的每个神经元对应模板里的一个像素位置。这种设计的初衷是让每个神经元承担“字符某个部位”的识别责任。说实话这个设计带有比较强的启发式味道放到今天没有太多理论上的必然性所以现代实现里你改成 128、256 都完全没问题。最后的输出层有 10 个神经元对应 0 到 9 十个数字。原版用的是欧氏径向基函数每个输出神经元持有一个 84 维的模板向量计算输入向量和各个模板之间的欧氏距离距离越小说明越像对应的数字。现代实现几乎都改成了“线性层 Softmax 交叉熵损失”原因很简单Softmax 输出天然可以解释成类别概率交叉熵损失的训练稳定性和收敛速度也更好。LeNet 从 RBF 输出改为 Softmax 输出是这个网络现代化改造中一个非常典型的改动也是我在后文代码里采用的方式。3. 从零实现 LeNetPyTorch 完整代码与训练要点3.1 模型定义把纸面结构翻译成代码理论基础打好了现在开始写代码。我用 PyTorch 实现一个“现代化改良版 LeNet 5”。说它是改良版主要有三点差异输出层用 Softmax 替代 RBFC3 层用全连接替代部分连接表为了训练稳定性我在两个卷积层后各加了一个 ReLU 激活原版用的是 tanh如果你想严格还原把 ReLU 改成 nn.Tanh() 即可。完整模型代码如下import torch import torch.nn as nn class LeNet5(nn.Module): def __init__(self, num_classes10): super(LeNet5, self).__init__() # C1: 卷积层1通道输入6个5x5卷积核 - 6x28x28 self.conv1 nn.Conv2d(1, 6, kernel_size5, stride1, padding0) # S2: 平均池化层2x2窗口步长2 - 6x14x14 self.pool1 nn.AvgPool2d(kernel_size2, stride2) # C3: 卷积层6通道输入16个5x5卷积核 - 16x10x10 self.conv2 nn.Conv2d(6, 16, kernel_size5, stride1, padding0) # S4: 平均池化层 - 16x5x5 self.pool2 nn.AvgPool2d(kernel_size2, stride2) # C5: 卷积层16通道输入120个5x5卷积核 - 120x1x1 self.conv3 nn.Conv2d(16, 120, kernel_size5, stride1, padding0) # F6: 全连接层120 - 84 self.fc1 nn.Linear(120, 84) # 输出层84 - 10 self.fc2 nn.Linear(84, num_classes) def forward(self, x): x torch.relu(self.conv1(x)) x self.pool1(x) x torch.relu(self.conv2(x)) x self.pool2(x) x torch.relu(self.conv3(x)) # 把 Nx120x1x1 压缩成 Nx120 x x.view(x.size(0), -1) x torch.relu(self.fc1(x)) x self.fc2(x) return x# 实例化并检查输出形状 model LeNet5() dummy torch.randn(1, 1, 32, 32) out model(dummy) print(out.shape) # 期望输出 torch.Size([1, 10])我在代码里特意把 C5 保留成了卷积形式而不是直接展平后接全连接层。这样做的原因是保持和原论文一致C5 的输入是 16×5×5卷积核大小也是 5×5所以输出是 120×1×1。实际使用中完全可以把 C5 和 F6 合并成一个 nn.Linear(400, 84)效果几乎一样但用卷积形式能帮你更清晰地理解每一层的数据流形态。3.2 数据加载与预处理简单但别轻视数据部分使用 torchvision 提供的 MNIST。有一个关键细节原始图片是 28×28而 LeNet 的输入是 32×32所以 transform 里必须做 resize。同时要把像素值从 0~255 归一化到 0~1否则梯度更新的行为会很差。import torch 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)关于归一化我多说两句。MNIST 的像素均值约 0.1307、标准差约 0.3081这是公开的统计值直接用就行。归一化之后输入数据的分布被拉到均值接近 0、方差接近 1 的范围这能显著提升梯度下降的稳定性。我曾经见过有人跳过 Normalize 直接训练结果 loss 在 2.3 附近纹丝不动——2.3 这个数值很有标志性它大约等于 ln(10)也就是说模型输出的概率接近“10 个类别完全均匀分布”完全没有学到任何东西。遇到这种情况先检查数据预处理多半就是没归一化或者归一化写错了。3.3 训练参数的选择SGD 与实验配置LeNet 原论文用的是标准的随机梯度下降没有 momentum学习率也是手动调整。现代复现时我建议使用带 momentum 的 SGD这是训练 CNN 时一个非常经典且稳定的选择。常见配置如下优化器SGD学习率 0.01momentum 0.9损失函数交叉熵损失nn.CrossEntropyLossBatch size64Epochs10 到 15我没选择一上来就用 Adam原因是复现 LeNet 的意义在于感受经典方法的行为而 SGD 的行为更“原始”、更接近把网络的骨架逻辑暴露出来的状态。Adam 自适应学习率的特性会掩盖掉某些调参问题不利于初学者建立对学习率、梯度方向这些核心概念的直觉。当然我在实际实验里也会跑一组 Adam 做对比结果往往很有意思——在 MNIST 这种简单任务上SGD 跑到后期甚至能和 Adam 打成平手。3.4 一步步把训练流程跑起来训练循环本身并不复杂核心就是“前向计算、算损失、反向传播、更新参数”这四步。我写了一个包含训练和验证的完整脚本import torch import torch.nn as nn import torch.optim as optim def train_one_epoch(model, train_loader, criterion, optimizer, device): model.train() total_loss 0.0 correct 0 total 0 for images, labels in train_loader: images, labels images.to(device), labels.to(device) outputs model(images) loss criterion(outputs, labels) optimizer.zero_grad() 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 return avg_loss, acc def evaluate(model, test_loader, criterion, device): model.eval() total_loss 0.0 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) loss criterion(outputs, labels) 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 return avg_loss, accdevice torch.device(cuda if torch.cuda.is_available() else cpu) model LeNet5().to(device) criterion nn.CrossEntropyLoss() optimizer optim.SGD(model.parameters(), lr0.01, momentum0.9) epochs 15 for epoch in range(1, epochs 1): train_loss, train_acc train_one_epoch(model, train_loader, criterion, optimizer, device) test_loss, test_acc evaluate(model, test_loader, criterion, device) print(fEpoch {epoch:02d} | Train Loss: {train_loss:.4f} | Train Acc: {train_acc:.4f} | Test Loss: {test_loss:.4f} | Test Acc: {test_acc:.4f})运行起来之后你大概率会看到类似这样的输出Epoch 01 | Train Loss: 0.5218 | Train Acc: 0.8302 | Test Loss: 0.2684 | Test Acc: 0.9196 Epoch 02 | Train Loss: 0.1789 | Train Acc: 0.9441 | Test Loss: 0.1296 | Test Acc: 0.9587 ... Epoch 15 | Train Loss: 0.0143 | Train Acc: 0.9952 | Test Loss: 0.0310 | Test Acc: 0.9897到第 15 轮左右测试集准确率稳定在 98%~99% 之间。我再强调一个关键细节model.train()和model.eval()之间的切换。虽然原版 LeNet 没有 BatchNorm 或 Dropouttrain和eval状态差异不明显但只要你给网络加上这些现代组件忘记切eval()就会导致 BatchNorm 层的 batch 统计量干扰验证结果。这是一个所有用 PyTorch 的人都会踩一次的坑写代码时从一开始就养成切换的习惯能省下很多排查时间。4. 复现 LeNet 时遇到的坑与排查思路4.1 训练震荡学习率太高导致的不收敛我第一次复现 LeNet 时把学习率设成 0.1结果训练 loss 一路暴涨从 0.5 直接飙到 5 以上完全没有收敛的迹象。原因是学习率太大参数更新步长跨过了损失函数的波谷区域梯度下降变成了“布朗运动”。排查方法很简单把学习率缩小 10 倍从 0.1 降到 0.01再跑一轮观察 loss 曲线。如果曲线从下降变成平稳基本就能确认是学习率的问题。对 LeNet 这种小规模网络经验上 0.01 到 0.03 是 SGD 的舒适区间0.1 容易炸0.001 则收敛太慢。如果你想系统排查学习率可以按 0.1、0.03、0.01、0.003 这样指数递减去试每次只改一个参数。还要注意一个容易误判的点如果 loss 一直在 2.3 附近徘徊不降这个数值是个标志性的“均匀分布熵”。它说明模型输出对 10 个类别完全没有区分度也就是什么都没学到。这种情况先查数据归一化再看梯度是否计算正确最后才考虑学习率问题。4.2 训练准确率到了 99% 就不动了正常吗非常正常。LeNet 在 MNIST 上不经过模型结构改进和超参精细调优准确率天花板大概就是 98%~99% 这个区间。MNIST 本身有几张图是连人都很难分辨的比如潦草的 7 和 1、写变形的 9 和 4测试集里存在少量标注噪声。追求 100% 是没有意义的99.5% 左右对 LeNet 来说已经是相当优秀的成绩。如果你想再往上突破方向不在于堆 epoch而在于模型层面的改进把 tanh 换成 ReLU 加速收敛在卷积层之间加 BatchNorm 稳定训练或者把平均池化换成最大池化保留更锐利的特征响应。我做过一组对比实验什么都不改的原始 LeNet 测试准确率约 98.5%给它加上 ReLU 和 BatchNorm 后能达到 99.2%再配合适当的数据增强甚至能逼近 99.5%。这组对比很好地说明了现代组件到底在哪些环节发挥了作用。4.3 怎么判断过拟合从 MNIST 到 CIFAR-10 的落差LeNet 在 MNIST 上几乎不过拟合因为 MNIST 数据量大且干净模型参数量只有 6 万左右刚好匹配这个任务的复杂度。但如果你把同样的 LeNet 原封不动拿去跑 CIFAR-10——一个 3 通道彩色图片、类别更多、背景更复杂的数据集——过拟合会立刻显现训练准确率一路冲到 95% 以上测试准确率却卡在 60% 左右两者之间出现巨大的鸿沟。排查思路是同时观察训练准确率和验证准确率的差值。差值小于 1% 说明模型训练不足继续加 epoch差值持续拉大且训练准确率接近 100%说明模型在“死记硬背”此时需要考虑数据增强、Dropout、减小模型容量或引入正则化项。判断过拟合不能只盯训练集要看训练和验证之间的“剪刀差”这句话值得刻在每一个入门者的显示器上。4.4 张量形状不匹配新手最容易卡住的报错复现 LeNet 时最常见的报错就是“shape mismatch”集中在 C5 之后进入全连接层的地方。报错信息通常类似于“mat1 and mat2 shapes cannot be multiplied (Nx400 and Nx120)”。这个问题的根源在于 C5 的卷积输出形态是 N×120×1×1而nn.Linear期望输入是 N×120。我在前面的代码里用了x x.view(x.size(0), -1)解决这个问题。更通用的做法是使用torch.flatten(x, start_dim1)效果一样。如果输入尺寸不是预想的 32×32比如你忘了把 MNIST 从 28×28 resize 到 32×32那么在 conv2 的位置就会提前爆炸特征图尺寸对不上。排查时我一般先给模型传一个假数据检查输出形状也就是前文代码里的dummy张量这一步 10 秒钟就能定位大部分结构层层的错误。另外有个经验之谈PyTorch 默认张量是 NCHW 格式也就是“批量数×通道数×高×宽”。很多形状问题不是算错卷积公式而是把维度顺序搞混了。当报错信息里出现维度对不上的时候先把每个中间张量的 shape 打印出来逐层核对远比盯着代码空想要高效。5. 从 LeNet 到现代 CNN它留下的设计遗产5.1 卷积层堆叠 池化下采样所有骨干网络的原型现代 CNN 结构五花八门ResNet 有残差连接DenseNet 有密集连接EfficientNet 有复合缩放但你剥开这些外壳骨架依然是 LeNet 最初定义的 Pattern卷积层提取特征池化层压缩尺寸全连接层做分类。以 VGG 为例它的核心就是连续堆叠 3×3 卷积加 2×2 最大池化最后接全连接分类头。ResNet 只是在“卷积 → 卷积”之间加了一条残差跳跃连接让梯度流更顺畅。EfficientNet 做的事情更接近“工程调优”在不同阶段的通道数和深度上做精细化缩放。它们都在 LeNet 的框架内演进只是把每一块积木做得更深、更宽、更精细。这种“万变不离其宗”的特性让 LeNet 成为理解一切现代 CNN 的万能钥匙。你不需要把每一篇新网络论文都从头细读而是可以先想清楚它改动的是 LeNet 框架里的哪一块——是卷积组件的内部结构如深度可分离卷积是下采样的方式如步长卷积替代池化还是分类头与损失函数的设计如全局平均池化。这个分析思维一旦建立读任何网络结构论文都会轻松很多。5.2 感受野、参数共享与局部连接依然有效的底层逻辑LeNet 设计的三个底层逻辑至今没有过时它们是理解 CNN 为什么有效的基石。局部连接意味着每个卷积核只关注输入的一个局部窗口这在计算上是高效的在语义上也是合理的——图像中的目标往往具有局部性一个数字的笔画只影响周围的几十个像素远处的像素对判断这个笔画是什么没有直接帮助。参数共享意味着同一个卷积核在整张图上复用这极大地减少了参数量同时赋予了网络“平移等变性”一个特征不管出现在图片的左上角还是右下角都能被同一个卷积核捕获。这也是为什么用手写数字识别这类任务时LeNet 不需要对数字的位置做精确对齐。感受野的概念则贯穿了从 LeNet 到现代网络的所有设计。第一层卷积的感受野只有 5×5它只能看到局部边缘经过池化后第二层卷积能看到原始图像上更大的范围网络越深每层神经元“看到”的原图区域越大。LeNet 通过区区 7 层结构就完成了从 5×5 局部感知到全图感知的扩展而这个“逐层扩大”的规律正是 CNN 处理图像数据最核心的机制。5.3 池化的进化从平均池化到最大池化再到全局池化LeNet 原版用的是平均池化但现代 CNN 里最大池化更常见。两者行为差异很直观平均池化把窗口内所有值取平均突出的是“整体响应强度”对背景噪声更平滑最大池化取窗口内最大值保留的是“最强的特征响应”对边缘和纹理这类尖锐特征更敏感但也更容易被噪声点干扰。在很长一段时间里最大池化是 CNN 的主流选择因为它在梯度传播时只对最大值的位置反传信息计算简单且特征选择性更强。不过后来研究者发现用步长为 2 的卷积也可以实现下采样而且“可学习”的下采样比固定规则的池化更灵活。现在很多现代网络干脆把池化层完全去掉用带 stride 的卷积层来承担尺度压缩。LeNet 的池化还有一个衍生版本值得了解全局平均池化。它不再用固定大小的窗口而是把整张特征图聚合成一个单值常用于分类头之前替代全连接层也就是把 C5→F6 这整段替换成一个全局池化。好处是极大减少参数量并天然赋予网络“输入尺寸无关”的能力。顺带一提torch.flatten也是这一思路的简化版不过在空间维度上不会做聚合。从 LeNet 的平均池化走到全局平均池化你可以清晰地看到下采样这个动作的本质没有变变的只是“如何聚合局部信息”的粒度和策略。5.4 改进 LeNet 的小实验思路把经典变成自己的东西学完 LeNet 之后我强烈建议你做几组“改良实验”这些实验能帮你把理论基础巩固成直觉。第一个实验把 tanh 激活函数换成 ReLU。你会发现训练收敛速度明显变快因为 ReLU 在正区间的导数是常数 1不存在饱和区梯度流更顺畅。这个改动直观展示了激活函数对深度网络训练的影响。第二个实验把平均池化换成最大池化。观察训练曲线最大池化版本通常收敛更快、峰值准确率也略高一点。这个实验能帮你体会不同池化算子之间的行为差异。第三个实验在网络里插入 BatchNorm 层。你会看到训练过程显著变稳即使把学习率调高到 0.05 也不会爆炸。这个实验能解释为什么 BatchNorm 被誉为“现代深度学习训练里最重要的技巧之一”。第四个实验把输出层换成更宽的隐藏层比如 84 改成 256观察准确率变化。你会发现参数量翻了好几倍但 MNIST 上的准确率几乎不变。这个实验是理解“模型容量和任务复杂度匹配”的最佳方式——LeNet 的参数规模已经足够表达手写数字的分布了盲目的“大”并不会带来收益。我个人在实际操作中的体会是真正让人理解深度学习的不是背会某个网络结构的参数表而是亲手改一个参数、观察它带来的连锁反应。LeNet 是一个绝佳的“实验台”它足够小小到每次改动的影响都清晰可见又足够经典经典到所有现代 CNN 的底层逻辑都能在它身上找到影子。把这篇博文里的代码跑通再把这四组实验做完你对卷积神经网络的掌握程度会超过很多只刷过理论课的人。