☰
从全连接到卷积神经网络:LeNet实战与CNN核心原理详解
2026/10/28 8:35:46 网站建设 项目流程

1. 从全连接层到卷积:为什么图像任务需要换一套思路

如果你之前跟着《动手学深度学习》一路学过来,前面几章大概率都在跟全连接层打交道——把输入拉平成一维向量,然后堆几层线性变换加激活函数。这套打法在表格数据上确实好使,但一碰到图像就立刻露怯。我自己第一次拿全连接网络跑CIFAR-10的时候,光是第一层权重矩阵就大到让我怀疑人生:一张32×32×3的图片拉平是3072维,如果隐藏层只给1024个神经元,这一层就是3072×1024≈314万个参数。这还只是一层,要是换成224×224×3的ImageNet图片,参数量直接飙到1.5亿级别,训练不动不说,显存也扛不住。

更关键的问题不是参数量,而是全连接层把图像的空间结构彻底打碎了。对全连接层来说,像素(0,0)和像素(0,1)之间的关系,跟像素(0,0)和像素(31,31)之间的关系没有任何区别——它根本不知道谁挨着谁。可图像的核心信息恰恰藏在局部空间关系里:边缘、角点、纹理,这些都是相邻像素之间的模式。你把图片拉平,等于把一张拼图倒进搅拌机,然后指望网络从一堆碎片里还原出图案,这不是不行,是效率极低。

卷积神经网络(CNN)解决的就是这个问题。它用三个核心思想把图像任务的先验知识直接编码进网络结构里:局部连接(每个神经元只看一小块区域)、权重共享(同一个卷积核在整张图上滑动)、平移等变性(物体挪个位置,特征图也跟着挪)。这三个思想带来的直接好处是参数量的断崖式下降。还是32×32×3的输入,如果我用一个5×5的卷积核、输出6个通道,参数量只有5×5×3×6+6=456个,跟全连接层的314万比起来,差了将近四个数量级。

这一章的核心就是把这套思路从直觉变成可运行的代码。关键词里提到的CNN、卷积、池化、LeNet,基本覆盖了从理论到实战的最小闭环。下面我会按照“卷积运算到底在算什么→池化层为什么能压缩信息→LeNet怎么把这些零件拼成完整网络→训练时踩过的坑”这条线,把每个环节拆开讲透。

1.1 卷积运算的数学本质:互相关而不是严格卷积

这里有个容易被忽略的细节:深度学习里说的“卷积”,严格来讲是互相关运算(cross-correlation),不是数学意义上的卷积。真正的卷积需要先把卷积核翻转180度再滑动,而深度学习框架里实现的都是不翻转的版本。为什么?因为卷积核的参数是学出来的,翻不翻转对模型的表达能力没有本质影响——反正学出来的核会自动适应。但理解这一点很重要,否则你在推导梯度或者手写反向传播的时候会对不上号。

具体计算过程是这样的:假设输入是一个4×4的矩阵,卷积核是3×3,步幅为1、无填充,那么输出就是2×2。输出位置(i,j)的值等于输入中以(i,j)为左上角的3×3子矩阵与卷积核逐元素相乘再求和。用代码表示就是:

import torch from torch import nn def corr2d(X, K): h, w = K.shape Y = torch.zeros((X.shape[0] - h + 1, X.shape[1] - w + 1)) for i in range(Y.shape[0]): for j in range(Y.shape[1]): Y[i, j] = (X[i:i+h, j:j+w] * K).sum() return Y

这段代码虽然效率低,但它是理解卷积层前向传播的最好方式。实际框架里用的是im2col加矩阵乘法或者FFT加速,但底层逻辑就是这个双重循环。

1.2 填充与步幅:控制输出尺寸的两个旋钮

输出尺寸的计算公式是:(H - Kh + 2Ph) / Sh + 1,其中H是输入高度,Kh是卷积核高度,Ph是填充行数,Sh是步幅。这个公式看着简单,但实际调参的时候很容易搞混。我自己的经验是记住一个特例:当Kh=3、Ph=1、Sh=1时,输出尺寸等于输入尺寸,这就是所谓的“same padding”。很多经典网络(比如VGG)就是靠这个规律来堆叠卷积层的。

填充的作用不只是控制尺寸,它还影响边界信息的利用效率。不加填充的话,边缘像素被卷积核扫过的次数远少于中心像素,导致边缘特征提取不充分。加一圈零填充之后,边缘像素的“待遇”就跟中心像素接近了。步幅则是用来降采样的,步幅为2时输出尺寸大约减半,这在后面会跟池化层形成功能上的竞争关系。

2. 池化层:为什么最大池化比平均池化更常用

池化层的操作比卷积层简单得多——没有可学习的参数,就是一个固定的聚合函数在滑动窗口上作用。最大池化取窗口内的最大值,平均池化取平均值。关键词里出现了“平均池化层计算公式”,这里把两个公式都列一下:

  • 最大池化:output[i,j] = max(input[i*s:i*s+kh, j*s:j*s+kw])
  • 平均池化:output[i,j] = mean(input[i*s:i*s+kh, j*s:j*s+kw])

那为什么实际网络里最大池化用得更多?我自己的理解是:最大池化相当于在问“这个区域里有没有出现某个特征”,它保留了最强烈的激活信号,对微小位移更鲁棒。平均池化则是在问“这个区域里某个特征的平均强度是多少”,它会把弱信号也平均进来,反而模糊了特征的边界。在分类任务里,我们更关心“有没有”而不是“平均多少”,所以最大池化更对路。

不过平均池化也不是没用。在网络的最后阶段,比如全局平均池化(Global Average Pooling),它经常被用来替代全连接层做降维。ResNet和MobileNet都用了这个技巧,好处是参数量大幅减少,而且对输入尺寸的变化更鲁棒。关键词里提到的“金字塔池化模块”(SPP)则是另一种思路:用不同尺度的池化窗口并行操作,然后把结果拼接起来,这样网络就能同时捕捉粗粒度和细粒度的上下文信息。

2.1 池化层的反向传播:最大池化的梯度只回传给最大值位置

池化层虽然没有参数,但反向传播还是要做的。最大池化的梯度回传规则是:梯度只传给前向传播时取到最大值的位置,其他位置梯度为零。这意味着如果某个窗口内的最大值来自一个噪声像素,那这个噪声就会在反向传播时被放大。这也是为什么有些人会在池化前加一点dropout或者用随机池化来缓解过拟合。

平均池化的梯度则是均匀分配的:每个输入位置的梯度等于输出梯度除以窗口大小。这个操作更平滑,但也更容易把梯度稀释掉。实际用的时候,最大池化在浅层更常见,平均池化在深层或者全局池化时更常见。

2.2 池化层和步幅卷积的取舍

步幅为2的卷积层其实也能起到降采样的作用,而且它还有可学习的参数。那为什么还要用池化?一个实际的原因是:池化没有参数,计算量小,而且不会引入额外的过拟合风险。另一个原因是池化提供了固定的降采样比例,而步幅卷积的降采样效果依赖于学到的卷积核,不够稳定。不过最近几年有些网络(比如All-Conv Net)干脆去掉了池化层,全部用步幅卷积替代,效果也不差。所以这更多是一个工程上的权衡,没有绝对的对错。

3. LeNet-5:把卷积、池化、全连接串起来的第一个经典

LeNet-5是Yann LeCun在1998年提出的,用来识别手写数字。虽然它现在的性能已经被各种深网碾压,但它的结构设计思路至今仍然值得学习。整个网络只有7层(不包括输入),参数量大约6万,放在今天看小得可怜,但麻雀虽小五脏俱全。

结构是这样的:输入32×32的灰度图→C1卷积层(6个5×5核,输出28×28×6)→S2平均池化层(2×2窗口,步幅2,输出14×14×6)→C3卷积层(16个5×5核,输出10×10×16)→S4平均池化层(输出5×5×16)→C5卷积层(120个5×5核,输出1×1×120)→F6全连接层(84个神经元)→输出层(10个类别)。

有几个细节值得注意:第一,LeNet用的是平均池化而不是最大池化,这在当时是主流,后来才被最大池化取代。第二,C3层到S4层的连接不是全连接,而是按照一张表来选择性地连接,目的是打破对称性、减少参数量。第三,激活函数用的是tanh或者sigmoid,不是ReLU——ReLU要到2011年之后才流行起来。

3.1 用PyTorch复现LeNet-5的完整代码

下面是我自己写的一版LeNet-5,跟原版有一些微调(比如用ReLU替代sigmoid,因为训练更快),但整体结构保持一致:

import torch from torch import nn class LeNet5(nn.Module): def __init__(self, num_classes=10): super().__init__() self.features = nn.Sequential( nn.Conv2d(1, 6, kernel_size=5, padding=2), nn.ReLU(), nn.AvgPool2d(kernel_size=2, stride=2), nn.Conv2d(6, 16, kernel_size=5), nn.ReLU(), nn.AvgPool2d(kernel_size=2, stride=2), nn.Conv2d(16, 120, kernel_size=5), nn.ReLU(), ) self.classifier = nn.Sequential( nn.Linear(120, 84), nn.ReLU(), nn.Linear(84, num_classes), ) def forward(self, x): x = self.features(x) x = torch.flatten(x, 1) x = self.classifier(x) return x

注意第一层卷积我加了padding=2,这样输出尺寸保持32×32,跟原版一致。如果不加padding,输出会变成28×28,后面所有层的尺寸都会跟着变。这个细节在复现经典网络时特别容易搞错,建议每次改结构之后都打印一下每层的输出形状。

3.2 训练LeNet时最容易踩的三个坑

第一个坑是输入尺寸不匹配。LeNet-5原版要求输入是32×32,但MNIST数据集是28×28。如果你直接把28×28的图片喂进去,第一层卷积输出会变成24×24,后面一路缩下去,最后全连接层的输入维度就对不上了。解决办法要么是把图片resize到32×32,要么是调整网络结构。我一般选择resize,因为这样更贴近原版。

第二个坑是学习率设得太大。LeNet参数量少,对学习率比较敏感。我用SGD的时候,学习率设0.01比较稳,设0.1就会震荡甚至发散。如果用Adam,学习率可以设1e-3左右。这个没有绝对标准,但从小学习率开始试错总是更安全的。

第三个坑是忘记做数据归一化。MNIST的像素值是0到255,直接喂进去会导致梯度爆炸。标准做法是除以255归一化到0到1,再减去0.1307、除以0.3081(MNIST的全局均值和标准差)。这两个数字是统计出来的,直接用就行。

4. 卷积核到底学到了什么:可视化与直觉建立

很多人学完卷积之后还是觉得抽象,因为卷积核的参数是学出来的,看不见摸不着。其实可以通过可视化第一层的卷积核来建立直觉。在LeNet训练好之后,把第一层6个5×5的卷积核打印出来,你会发现它们学到的模式非常像边缘检测器——有的对水平边缘敏感,有的对垂直边缘敏感,有的对对角线敏感。这跟传统图像处理里的Sobel算子、Gabor滤波器非常相似,但它们是网络自己学出来的,不需要人工设计。

再往深层的卷积核看,它们学到的就是更抽象的组合特征了。第二层可能学到的是“拐角”“交叉点”,第三层可能学到的是“数字的局部笔画”。这种从低级特征到高级特征的层次化表示,正是CNN强大的核心原因。

4.1 用钩子函数提取中间层特征图

PyTorch里可以用register_forward_hook来提取中间层的输出,这样就能看到一张图片在网络里被逐层处理的过程:

activations = {} def get_activation(name): def hook(model, input, output): activations[name] = output.detach() return hook model.features[0].register_forward_hook(get_activation('conv1')) model.features[3].register_forward_hook(get_activation('conv2')) with torch.no_grad(): model(test_img.unsqueeze(0)) print(activations['conv1'].shape) # torch.Size([1, 6, 28, 28]) print(activations['conv2'].shape) # torch.Size([1, 16, 10, 10])

拿到特征图之后可以用matplotlib画出来,你会看到不同通道关注的是图片的不同部分。有些通道对背景敏感,有些对笔画敏感,这种分工是网络自动学出来的,不需要人工干预。

4.2 感受野的计算:为什么深层神经元能看到更大的区域

感受野是指输出特征图上一个像素对应输入图像上的区域大小。第一层3×3卷积的感受野是3×3,第二层再堆一个3×3卷积,感受野就变成5×5,第三层变成7×7。计算公式是:RF = RF_prev + (K - 1) * stride_product,其中stride_product是前面所有层步幅的乘积。

理解感受野很重要,因为它决定了网络能捕捉多大的上下文。如果感受野太小,网络就只能看到局部纹理,无法理解全局形状。这也是为什么深层网络要不断降采样——降采样增大了后续层的感受野,让神经元能看到更大的区域。LeNet最后C5层的感受野已经覆盖了整张32×32的输入,所以它才能做出全局的判断。

5. 从LeNet到现代CNN:哪些设计被保留,哪些被淘汰

LeNet是1998年的设计,跟现在的ResNet、EfficientNet比起来当然很原始,但它确立的基本范式——卷积+池化+全连接的堆叠——至今没有本质变化。被保留的设计包括:局部连接、权重共享、层次化特征提取、端到端训练。被淘汰或改进的设计包括:sigmoid/tanh激活(被ReLU取代)、平均池化(被最大池化取代)、全连接层结尾(被全局平均池化取代)、浅层结构(被深层残差结构取代)。

关键词里提到的“深度可分离卷积”是另一个重要演进。标准卷积同时做通道混合和空间滤波,深度可分离卷积把这两件事拆开:先对每个通道单独做空间卷积(depthwise),再用1×1卷积做通道混合(pointwise)。这样参数量和计算量都能降到原来的1/8到1/9,MobileNet就是靠这个在移动端跑起来的。

5.1 一维卷积和三维卷积的适用场景

虽然图像是二维的,但卷积操作可以推广到任意维度。一维卷积(Conv1d)常用在时序数据上,比如音频、传感器信号、股票价格。它的卷积核只在一个方向上滑动,输出也是一维的。关键词里提到的“一维卷积神经网络介绍的文献”和“股票价格预测”就是这类应用。

三维卷积(Conv3d)则用在视频或者医学影像上,卷积核在三个方向上滑动(宽、高、时间/深度)。计算量比二维卷积大一个数量级,所以一般只在需要时空信息的任务上用。我试过用3D卷积做视频分类,光是处理一个16帧的短视频片段,显存就吃掉了好几个G,没有好显卡根本跑不动。

5.2 空洞卷积与金字塔池化:扩大感受野的两种思路

空洞卷积(Dilated Convolution)是在卷积核的元素之间插入空洞,这样不增加参数量的前提下就能扩大感受野。比如3×3的卷积核,dilation=2时实际覆盖的区域是5×5,dilation=4时覆盖9×9。DeepLab系列在语义分割里大量用了这个技巧。

金字塔池化(SPP)则是用不同尺度的池化窗口并行操作,然后把结果拼接起来。这样网络就能同时捕捉不同尺度的上下文信息。SPP-Net最早提出这个思路来解决输入尺寸不固定的问题,后来被PSPNet等分割网络发扬光大。

6. 组队学习中的实操建议与常见问题

组队学习最大的好处是有人一起踩坑、一起讨论,但最大的风险是“搭便车”和“进度不同步”。我参加过几次组队学习,也组织过几次,总结下来有几个经验值得分享。

第一,每个人都要动手跑代码。看别人跑通跟自己跑通是两码事。环境配置、版本冲突、路径错误这些问题,只有自己动手才会遇到。建议每个人在自己的机器上把LeNet完整训练一遍,哪怕只跑5个epoch,也能对训练流程有肌肉记忆。

第二,用统一的代码框架。组队学习最怕各写各的,最后没法对比。建议用同一个Git仓库,每个人开自己的分支,定期合并。代码风格可以不一致,但接口要统一——比如都用PyTorch,都用同样的数据加载方式。

第三,记录实验日志。每次调参之后把学习率、batch size、训练轮数、最终准确率记下来。这些日志在讨论的时候非常有用,能避免“我试过不行”这种没有信息量的结论。我一般用CSV或者Markdown表格记录,简单直接。

6.1 环境配置的坑:CUDA版本与PyTorch版本的匹配

这是新手最容易卡住的地方。PyTorch的版本和CUDA的版本必须匹配,否则要么装不上,要么装上了跑不了GPU。我的建议是:先去PyTorch官网查对应关系表,然后按照表上的命令装。如果不想折腾,直接用CPU版本也能跑LeNet,只是慢一点。MNIST数据集很小,CPU跑一个epoch也就一两分钟,完全能接受。

如果要用GPU,装完之后一定要验证:

import torch print(torch.__version__) print(torch.cuda.is_available()) print(torch.cuda.get_device_name(0))

如果is_available()返回False,说明CUDA没配好,需要检查驱动版本和CUDA版本是否匹配。

6.2 过拟合的判断与缓解

LeNet参数量少,在MNIST上一般不会严重过拟合。但如果你换了更复杂的网络或者更小的数据集,过拟合就会出现。判断过拟合的标准很简单:训练集准确率持续上升,验证集准确率停滞甚至下降。这时候可以加Dropout、加L2正则化、或者做数据增强。

关键词里提到了“深度学习l2正则化pytorch代码”,这里给一个简单的实现:

optimizer = torch.optim.SGD(model.parameters(), lr=0.01, weight_decay=1e-4)

weight_decay就是L2正则化的系数,一般设1e-4到1e-5之间。注意它跟学习率是耦合的,学习率大的时候weight_decay也要相应调大。

6.3 模型保存与加载的注意事项

训练完模型一定要保存,否则下次又要重新跑。PyTorch有两种保存方式:保存整个模型(torch.save(model, path))和只保存参数(torch.save(model.state_dict(), path))。推荐后者,因为前者依赖具体的类定义,换环境容易出问题。

加载的时候要先实例化模型,再加载参数:

model = LeNet5() model.load_state_dict(torch.load('lenet.pth')) model.eval()

注意model.eval()很重要,它会关闭Dropout和BatchNorm的训练行为。如果忘了加,推理结果会不稳定。

7. 卷积神经网络的知识点串联:从这一章往外延伸

学完这一章之后,你手里应该有了一个能跑通的CNN。接下来可以往几个方向延伸:一是换数据集,把MNIST换成Fashion-MNIST或者CIFAR-10,感受一下复杂度上升之后网络需要怎么调整;二是换网络,把LeNet换成AlexNet、VGG、ResNet,看看深层网络是怎么解决梯度消失和退化问题的;三是换任务,从分类扩展到检测、分割、生成,每个任务都有对应的经典网络结构。

关键词里提到的“深度学习鱼书”指的是《深度学习入门:基于Python的理论与实现》,那本书用numpy手写了一遍CNN的前向和反向传播,对理解底层原理非常有帮助。如果你觉得PyTorch封装得太好、看不到细节,可以跟着鱼书手推一遍卷积层的反向传播,推完之后对框架的信任度会高很多。

另外,“动手深度学习”这个关键词对应的就是李沐的《动手学深度学习》,它的代码风格非常工程化,每一章都有可运行的notebook。我建议把这一章的notebook完整跑一遍,然后自己改几个参数看看效果变化。比如把池化层从平均池化改成最大池化,把激活函数从ReLU改成LeakyReLU,把优化器从SGD改成Adam,观察训练曲线和最终准确率的变化。这种“改参数-看结果”的循环是建立直觉最快的方式。

最后说一个我自己的体会:CNN看起来简单,但真正调好一个网络需要大量的实验和经验。不要指望看一遍书就能掌握,也不要因为第一次跑出来准确率不高就放弃。我训练LeNet的时候,第一次只跑到92%的准确率,后来调整了学习率、加了归一化、换了优化器,才慢慢爬到98%以上。这个过程本身就是学习的一部分,踩过的坑越多,理解越深。

需要专业的网站建设服务?

联系我们获取免费的网站建设咨询和方案报价,让我们帮助您实现业务目标

立即咨询