首页 / 资讯中心 / 文章详情

卷积神经网络模块全解析:从卷积、池化到残差与注意力

卷积神经网络模块全解析:从卷积、池化到残差与注意力 ★ FEATURED ARTICLE
很多人在第一次接触卷积神经网络时最容易出现的困惑不是某个层的原理看不懂而是把网络拆开之后不知道每个“零件”到底承担什么职责更不知道这些零件之间怎么搭配才合理。我自己带项目时也经常遇到这样的情况模型不收敛改来改去没有方向最后发现是某一两个模块的配置和组合方式出了问题。所以这篇内容我想从一个更实在的角度切入——把卷积神经网络常见模块逐个拆开讲清楚它们各自解决什么问题、为什么必须这么设计、在实际搭建和调参时有哪些坑。无论你是刚看完理论准备动手复现还是已经在跑实验但总被各种维度对不上、显存不足、收敛不稳的问题困扰这篇文章应该都能给你一些能直接落地的参考。1. 从“搭积木”说起CNN模块化的底层逻辑1.1 为什么说深度网络本质上是模块组合深度学习框架流行之后搭建网络变得越来越像搭积木。你定义一个卷积层再加一个池化层堆几个全连接层一个最简单的CNN就出来了。但这个“搭积木”的过程并不只是把层堆起来那么简单它的前提是每个模块的设计都有明确的“职责边界”。拿图像分类任务举例。原始输入是一张图像比如224×224×3这个“3”是RGB三个通道。如果直接用全连接层来处理那么第一层的参数量就是224×224×3乘以后面神经元的数量。假设隐藏层有1024个神经元那么参数量大约是1.5亿这在十几年前的计算条件下几乎不可能训练。卷积神经网络之所以能火起来核心思路就是把“全连接”这种每个输入都必须和每个输出建立连接的方式改成“局部连接权值共享”的方式。局部连接指的是每个卷积核只关注输入的一个小邻域权值共享指的是同一个卷积核在图像的不同位置重复使用。这两条加在一起参数规模一下子就降了好几个数量级。理解这个背景很重要因为后面所有模块的设计包括池化、激活函数、归一化、残差连接等等本质上都是在围绕“怎么在控制参数量和计算量的前提下让网络有更强的表达能力和更好的优化性质”这两件事做文章。1.2 一个卷积层内部到底发生了什么很多人看卷积的公式觉得抽象我用一个最直观的例子来说明。假设你有一张5×5的单通道图像用3×3的卷积核去卷积步长为1不填充那么输出尺寸是(5-3)/113得到3×3的特征图。这个过程可以理解成一个3×3的小窗口在整个图像上从左到右、从上到下地滑动每滑动到一个位置就把窗口内的9个像素值和卷积核的9个权重做逐元素乘法再求和得到输出特征图上的一个值。如果是多通道输入比如前面一层输出了64个通道的特征图那么卷积核的深度也得是64也就是说一个输出通道对应一个64×3×3的卷积核。假设这一层要输出128个通道那么总参数量就是128×64×3×3再加上128个偏置。这个计算量怎么估可以通过输出的特征图尺寸和通道数来算一般用FLOPs衡量也就是每秒浮点运算次数但在深度学习里通常用它指代模型的浮点运算总量。这里有一个实操中特别容易忽略的点很多人把卷积层参数写成了Conv2d(输入通道输出通道kernel_size)就完事了但输出特征图的尺寸还会受到步长stride和填充padding的影响。如果你希望输入输出尺寸不变通常会设置padding为kernel_size/2配合stride1。比如3×3卷积设置padding15×5卷积设置padding2。这个规则在做特征融合或者保证维度对齐时经常用到不记住的话后面维度报错会查得很头疼。1.3 模块化的思想来源CNN的模块化并不是一开始就有的它是随着网络深度增加逐步演化出来的。早期LeNet里就是“卷积池化全连接”的简单堆叠那时候层数很少不需要在层与层之间做太多特殊处理。后来AlexNet把网络加深到8层VGG又把它加深到16到19层这时人们发现单纯把卷积层叠深会遇到两个麻烦一是参数量和计算量爆炸所以VGG开始统一使用3×3小卷积核二是梯度消失问题开始显现层数一深反向传播时梯度一层层传回去前面的层基本学不到东西。于是后面出现了各种专门解决这些问题的模块比如归一化模块、残差模块、注意力模块等。所以我建议初学者在学CNN时不要只停留在“我能背出某个网络的结构图”的层面而是去思考每一层每一种连接方式背后的动机。模块化的真正价值不是让你更快地堆模型而是让你在模型出问题时能够准确地定位到“是哪个模块出了问题”。2. 卷积层与池化层特征提取的“主力军”和“压缩器”2.1 卷积核尺寸与感受野的权衡如果你看过几篇经典网络论文会发现一个规律早期的网络喜欢用大卷积核比如AlexNet用了11×11后来的VGG和ResNet几乎全部用3×3。原因很简单大卷积核能获得更大的感受野但参数量和计算量增速太快。一个7×7卷积的参数量是49C_in×C_out而三个串联的3×3卷积每一层参数是9C_in×C_out三个加起来才27C_in×C_out但感受野同样能达到7×7。也就是说在感受野相同的条件下用多个小卷积核代替一个大卷积核参数可以省下将近一半而且中间还多了两层非线性变换表达能力反而更强。在实际选型时我做项目一般遵守几个习惯第一层可以用稍大的卷积核比如7×7或5×5配合大步长目的是快速降低分辨率、减少后续计算量中间层统一用3×3因为最灵活、最容易和别人设计的模块对齐1×1卷积则专门用来做通道变换和降维它不改变空间尺寸但可以在通道维度上做信息混合。感受野还有一个容易被低估的点它在深层特征图中是持续累积的。比如你做了一个四层3×3卷积每层stride1最后一层的每个神经元实际上看到了原始输入的9×9区域。这个数值可以直接通过公式RF_n RF_{n-1} (K-1)×stride_offset累加算出来也可以用简单的规律“每经过一个k×k卷积感受野增加k-1在stride1时”来近似估算。当你设计一些需要大范围上下文信息的任务时比如语义分割、目标检测中的大目标识别感受野不够就得考虑加深网络或者用空洞卷积。2.2 步长、填充和空洞卷积控制特征图尺寸的三种手段步长这块我用一个特别常见的踩坑经历说明很多人在代码里写了Conv2d(in, out, kernel_size3, stride2, padding1)以为输出尺寸会减半但实际上带padding时输出尺寸计算公式是floor((H 2P - K)/S) 1代入后是floor((2242-3)/2)1 floor(223/2)1 1111 112输出减半这个没问题。但如果输入尺寸是奇数比如17那输出就是floor((172-3)/2)19并没有严格的2倍关系后续要恢复尺寸或者做特征对齐时就会遇到困难。所以很多网络在处理输入尺寸时都会先做自适应池化或者全局平均池化保证最后一个卷积层的输出能对齐到一个固定大小。空洞卷积是另一把“双刃剑”它通过在卷积核的元素之间插入空洞来扩大感受野不增加参数量。比如3×3空洞卷积、扩张率dilation2实际感受野是5×5但参数量还是9。这个模块在处理高分辨率特征图、需要大感受野又不想降低分辨率时非常有用比如语义分割里的DeepLab系列就用它很彻底。但它的问题是容易产生“网格效应”因为空洞位置是稀疏的如果连续多层都设置较大的扩张率可能会漏掉某些区域的连续性信息。我在实践里的做法是只有在需要感受野扩张且算力受限时才使用并且尽量让不同层的扩张率错开比如依次取1、2、5避免信息遮挡。2.3 池化到底在压缩什么池化层最直观的作用是降低特征图的空间分辨率。比如一个2×2、stride2的最大池化会把每个2×2小区域的值取最大值输出图像尺寸直接缩小一半。它带来的好处是参数和计算量减少、感受野扩大、对微小位置偏移有一定容忍性。但池化的“压缩”是有代价的它丢信息。最大池化保留了邻域内的最强响应适合捕捉边缘、纹理等局部特征但如果图像里有大量细微纹理或者需要保留位置信息比如目标检测中需要精确定位最大池化用得太猛就会降低定位精度。平均池化相对温和它保留的是邻域响应的大致水平语义分割任务里经常用它来平滑特征。还有一个经常被忽略的版本是全局平均池化。它在很多现代CNN里已经取代了传统Flatten全连接层直接用整个特征图所有值的平均值作为输出特征。比如最后一层卷积输出是7×7×2048用全局平均池化后直接得到2048维向量然后再接一个类别数的全连接层。这样做的好处是参数量骤减、不容易过拟合、对输入尺寸变化更鲁棒。我在做分类模型时如果一个任务对最终准确率要求不那么极端但希望模型足够轻量全局平均池化几乎是我的默认选择。2.4 池化模块的替代方案池化也不是唯一的降采样手段。stride2的卷积本身就可以达到降采样目的而且它多了一组可学习的参数信息保留能力更强。像ResNet的很多降采样阶段就是用stride2的1×1卷积来完成的。另外还有一个常见的组合是“先卷积降维再池化”。这个做法的考虑是如果直接池化通道数不变计算量没有在池化之前提前减少如果先用1×1卷积降通道再池化计算量可以进一步压缩。什么时候用池化什么时候用stride卷积我个人的习惯是移动端或轻量级网络多用stride卷积因为很多硬件对卷积算子优化得更到位传统网络结构里可以继续用max pooling它可解释性更强调试起来更直白。两者没有绝对优劣看你手头的硬件支持和任务需求。3. 激活函数让网络“活”起来的非线性模块3.1 没有激活函数的CNN为什么退化卷积操作本身是线性运算也就是一个加权求和过程。如果网络里只有卷积和全连接这些线性层那么不管堆多少层整个网络在数学上都可以被压缩成一次线性变换那样模型最多只能学习输入和输出之间的线性关系根本表达不了复杂的图像特征。激活函数就是在这种线性输出上加入非线性让网络有能力拟合任意复杂的函数。Sigmoid和Tanh是最早用的激活函数它们在数学上连续且光滑但存在很明显的饱和区当输入很大或很小时梯度几乎为0反向传播时梯度传到这一层就衰减得差不多了导致深层网络训练困难这在学术界叫“梯度消失”。3.2 ReLU为什么成为事实标准ReLU的公式是max(0, x)通俗理解就是负值全部截断为0。它的好处很直接正半轴导数为1不存在梯度衰减计算量极小。在工程实践里ReLU几乎是所有现代CNN的第一选择。但ReLU有个著名的死亡问题如果一个神经元的输入在训练中长期落在负半轴那么它的梯度恒为0权重再也不会更新这个神经元实际上就“死了”。在极端情况下一个网络里可能出现大量死亡神经元导致模型容量下降。解决办法也很多Leaky ReLU把负半轴改成很小的斜率比如0.01让负值也能传递少量梯度Parametric ReLU则把这个斜率设为可学习参数让网络自己学。实际项目中我从经验上会这么选常规CNN任务直接上ReLU不用太纠结如果网络较深且明显出现某些通道特征一直是0或者训练早期loss就停滞我会把ReLU换成Leaky ReLU或ELU。GELU是近几年的“深度升级版”它结合了ReLU和Dropout的思路在Transformer和很多新CNN中被广泛使用但计算成本略微高一点。它和ReLU的差别主要体现在负值区域的平滑处理上如果你在跑任务时看别人代码里用了GELU直接用一般没问题。3.3 激活函数在模块组合中的位置激活函数通常放在卷积层之后、池化层之前。也有人习惯“卷积BNReLU”的组合把归一化和激活放在卷积输出之后这样卷积的原始输出先被归一化到合理范围再经过非线性激活训练更稳定。需要提醒的是不同模块组合中激活函数的位置会影响BN的统计量计算后面我讲BN的时候会具体展开。在搭建网络时我的代码习惯是把卷积、BN、ReLU封装成一个小的block比如Conv-BN-ReLU这样既符合主流实践也方便复用。4. 归一化与正则化模块训练稳不稳、泛化好不好它们说了算4.1 Batch Normalization的工作方式与使用细节训练深层网络时常见的问题是每层输入分布随着前一层参数更新不断变化这叫内部协变量偏移。Batch Normalization解决这个问题的思路很直接在每一层对一批样本的某个通道在所有空间位置上计算均值和方差然后做标准化最后再通过两个可学习参数缩放和平移恢复表达能力。很多人会问一个问题做了标准化之后不是把之前层学到的东西给抹掉了吗其实BN保留了γ和β两个可学习参数如果网络觉得原始的表达更合适它可以把γ学成标准差、把β学成均值理论上可以恢复成原始分布。所以BN不是简单粗暴地白化特征而是给网络提供了“稳定的分布环境”让优化更容易。BN在工程上有一个特别明显的坑训练时和推理时的行为不一致。训练时用当前batch的均值方差推理时却要用训练阶段累计得到的全局均值方差。如果使用框架的model.eval()模式PyTorch和TensorFlow会自动切换到全局统计量但如果你自己写推理逻辑时忘了切换结果会非常离谱。另外当batch size很小时比如只有2或4BN统计量的噪声会很大训练很不稳定。这种情况下可以考虑用Group Normalization或Layer Normalization替代它们与batch大小无关在小batch场景下更稳定。4.2 Dropout的正确打开方式Dropout的原始做法是在训练时以概率p随机把一部分神经元的输出置0。这样可以防止网络过于依赖某些节点强迫它学习到更鲁棒的特征。在CNN里Dropout通常放在全连接层之间而不是卷积层之后。原因在于卷积层本身有较强的空间相关性相邻位置的值高度冗余随机置0的影响很容易被周围邻居补偿效果不明显而全连接层的特征是高度非结构化的Dropout能起到更好的正则化作用。Dropout还有一个重要参数叫inverted_dropout这也是目前框架默认实现的方式。它会在训练时把保留的神经元的输出除以1-p以保持整体输出的期望值不变这样推理时不需要额外缩放。这个细节如果不清楚自己在numpy里手写Dropout时很容易做错。4.3 让BN和Dropout共存的经验很多初学者喜欢“堆满”先加BN再加Dropout再加各种正则化。我的建议是不要盲目堆叠。BN本身已经有很强的正则化效果它能降低网络对特定权重的敏感度减少过拟合。在这种情况下再加过大的Dropout有时反而会限制模型容量、拖慢收敛。我一般先不加Dropout让BN把训练跑稳观察验证集loss和训练集loss的差距如果出现过拟合再去调Dropout的保留概率。其他辅助正则化手段也很值得一提权重衰减weight decay几乎是必开的一般设置在1e-4到1e-5之间标签平滑label smoothing可以缓和过拟合尤其当类别数量很多时Mixup、CutMix这类数据增强方法对CNN的泛化提升也很明显比单纯调整网络结构有时更有效。我在实际项目里通常会先保证数据增强足够充分再考虑结构调整后者一旦动了往往所有参数都得重新调。5. 从LeNet到ResNet经典模块组合与结构演进5.1 经典CNN的模块组合方式对比为了更清晰地看出模块组合的思路我整理了一个表格对比几个经典网络的模块构成网络卷积模块特点池化方式分类头部关键设计LeNet-55×5卷积2层平均池化全连接最早的CNN范式AlexNet11×5×3卷积多层最大池化全连接Dro引入ReLU、多GPU训练VGG全部3×3卷积堆叠最大池化全连接小卷积核堆叠加深度GoogLeNet/Inception多分支不同尺寸卷积池化全局平均池化Inception模块多尺度特征ResNet3×3卷积残差连接卷积降采样全局平均池化残差结构解决深度退化这个表里的每一个“关键设计”其实都对应了一个模块级别的创新。LeNet只是验证了CNN能做好手写数字识别AlexNet通过ReLU和Dropout把网络做大证明了深度网络的潜力VGG把卷积核统一成3×3让设计变得极其简洁也告诉我们模型深度比单个卷积核尺寸更重要Inception更多考虑的是如何在同一层内融合不同感受野的特征到了ResNet思路彻底转向“为什么网络变深之后反而更难训练”这个问题上。5.2 残差模块解决“深而难练”的核心思路理论上网络越深表达能力越强但实际训练时随着深度增加网络的训练误差反而可能变大这叫退化问题。这不是过拟合而是优化困难。残差模块的核心思路是在学习一个从输入到输出的映射H(x)时不完全学习整个映射只学习一个残差F(x)H(x)-x最终输出是F(x)x。这样做的价值在于在极端情况下如果残差学习不到任何有用信息网络可以退化成恒等映射层数加深至少不会带来明显坏处。对梯度而言反向传播时有一个“短路径”从后面的层直接传到前面的层梯度不容易消失深层网络终于训练得动了。我在实际搭建时经常使用两种残差块。一种用于浅层网络用两个3×3卷积输入输出通道一致另一种用于深层网络降采样阶段用1×1卷积 3×3卷积 1×1卷积的瓶颈结构参数更少、深度更深。还要做一个细节处理当残差分支的输入通道和主分支输出通道不一致时需要在shortcut上用一个1×1卷积匹配通道和分辨率。有人直接用conv1x1(stride2)也有人先用池化降分辨率再接1×1效果上差别不大但前者更普遍。5.3 轻量注意力模块小改动大收益残差模块解决的是“能不能训练得好”而注意力模块解决的是“特征中有哪些信息更重要”。SENet就是一个经典例子它对特征图做全局平均池化得到通道描述向量然后经过两个全连接层计算出每个通道的重要性权重再与原始特征图相乘。这个模块的参数量和计算量都非常小但能在物体分类任务上带来稳定的准确率提升。另一个常用的是CBAM它在通道注意力之后又加了空间注意力让网络不仅知道“哪些通道重要”还知道“哪个空间位置重要”。在目标检测这类对空间位置敏感的任务里CBAM的效果往往更明显。使用注意力模块时比较深的感触是不要贪多。很多人一上来就给每一层都加SE模块结果模型参数涨了不少速度也慢了准确率提升却有限。我的经验是在网络的浅层空间信息还没有充分整合通道注意力的作用通常不明显在深层通道语义已经比较明确加SE/CBAM效果更好。所以在实际项目中我会选择在最后几个阶段加注意力而不是全部层都加。5.4 模块化带来的设计自由度回顾这些经典结构会发现一个趋势CNN从原来强调“网络结构序列设计”逐渐变成“模块组合艺术”。你可以把卷积、BN、激活、Dropout、残差、注意力当作标准零件根据任务自由组合。这也是为什么现代框架里大家都会写BasicBlock、Bottleneck、ConvBNReLU这类可复用组件。做迁移学习时你完全可以在ResNet的每个stage后面插入一个轻量注意力模块或者把原来的最大池化替换成stride卷积整个设计过程如同搭积木一样灵活。6. 模块选型与组合实战从零搭一个CNN并跑通6.1 设计前的尺寸计算与显存规划做网络设计前我一般先在草稿纸上算一遍每一层的特征图尺寸和通道数。以32×32×3的输入为例设计一个6层卷积的小网络层模块输出尺寸输出通道参数量估算输入-32×32×33-卷积块1Conv3x3BNReLU32×3216约16×3×3×3池化1MaxPool2x216×1616-卷积块2Conv3x3BNReLU16×1632约32×16×3×3池化2MaxPool2x28×832-卷积块3Conv3x3BNReLU8×864约64×32×3×3全局平均池化GlobalAvgPool1×164-全连接分类Linear类别数-约64×类别数你可以看到参数量主要集中在卷积层分类头很少。想估算显存时不仅要把参数本身的大小算进去还得考虑到每个特征图在训练时为了反向传播需要保留中间缓存。所以有时候网络看起来参数不多但中间特征图很大照样能把显存挤爆。遇到这种情况可以适当降低batch size、减少通道数或者在下采样时提前用stride卷积压缩空间分辨率。6.2 可复用的模块代码结构以PyTorch风格为例一个足够模块化的CNN通常长这样import torch import torch.nn as nn def conv_bn_relu(in_channels, out_channels, kernel_size3, stride1, padding1): return nn.Sequential( nn.Conv2d(in_channels, out_channels, kernel_size, stride, padding, biasFalse), nn.BatchNorm2d(out_channels), nn.ReLU(inplaceTrue) ) class BasicBlock(nn.Module): def __init__(self, in_channels, out_channels, stride1): super().__init__() self.conv1 conv_bn_relu(in_channels, out_channels, 3, stride, 1) self.conv2 nn.Conv2d(out_channels, out_channels, 3, 1, 1, biasFalse) self.bn2 nn.BatchNorm2d(out_channels) self.relu nn.ReLU(inplaceTrue) self.shortcut nn.Sequential() if stride ! 1 or in_channels ! out_channels: self.shortcut nn.Sequential( nn.Conv2d(in_channels, out_channels, 1, stride, biasFalse), nn.BatchNorm2d(out_channels) ) def forward(self, x): identity x out self.conv1(x) out self.conv2(out) out self.bn2(out) out self.shortcut(identity) out self.relu(out) return out这段代码里有两个细节值得专门说。第一是卷积层里设置了biasFalse因为在它后面紧跟着BNBN在标准化过程中已经包含了偏置的作用如果卷积层再加偏置相当于多了一组冗余参数白白增加计算量。第二是残差分支里使用1×1卷积而不是直接截断或补零目的是让通道和分辨率对齐这一步在适配不同输入输出维度时必须做。6.3 训练调参过程中的常见问题我再列几个自己踩过之后印象特别深的坑。第一个坑是BN和batch size的矛盾。有次我用一个公开数据集做小batch训练设置为4结果loss反复震荡怎么都降不下去。后来我把BN全部换成GroupNorm问题立刻缓解了很多因为小batch下BN统计量太不稳定。第二个坑是ReLU死亡。检查特征图时发现不少通道的输出全是0这种情况一般发生在学习率设置过大时。我当时的解决方法是降低学习率同时把ReLU换成Leaky ReLU让负值区域有梯度可以恢复。第三个坑是维度不匹配。在拼接残差时忘了处理shortcut的分辨率导致维度不一致直接报错。这类问题其实很好避免只要在代码里显式检查每一层输出尺寸并且养成“写一步就打印一下shape”的习惯。第四个坑是关于训练模式和推理模式。用PyTorch训练完模型保存权重再加载做推理时如果忘记调model.eval()BN的统计数据会不一样结果会莫名其妙变差。当时我排查了很久最后发现只是少了这一行。这种问题调试起来很费时间我建议在训练和推理的代码模板里固定写上model.train()和model.eval()两层调用不让模式切换靠潜意识。6.4 用什么标准判断模块组合是否合理判断一个网络设计得好不好不能只看最终准确率。我一般会看几个中间指标第一个是训练loss和验证loss的曲线形态如果训练loss还在降、验证loss已经明显回升说明过拟合了需要加强正则化第二个是每一层的梯度范数分布如果某个模块的梯度始终很小说明它可能没有起到应有作用要么去掉要么调整结构第三个是激活值的分布如果某层激活大量集中在0附近说明激活函数或初始化可能不太合拍。写代码时还有一个很实用的技巧把数据做成一个很小的固定batch比如4张图快速跑几个step然后用torchsummary或自己打印shape确认模型结构没问题再回到完整数据集上训练。宁可前期多花5分钟做验证也不要训练到一半被维度问题打断。7. 我给新手的实操建议和模块调试心得最后说几点非常个人的体会。第一不要一上来就追求结构新颖。把最经典的模块组合比如“卷积BNReLU最大池化”这套基础结构吃透比收集一堆最新模块更重要。很多新模型本质上都是在这个框架上做增量优化。第二做模块改动时一次只改一处。我知道很多人喜欢同时换激活函数、加注意力、改损失函数然后发现效果好了也不知道是哪一步起的作用。我自己的实验习惯是每次只改变一个模块然后对比曲线和指标这样每改动一个零件心里都有数。第三善用已有预训练模型做迁移学习。从零训练一个深度CNN在小数据集上基本等于碰运气直接拿在ImageNet或更大数据集上预训练好的权重做微调通常比自研结构效果更稳定。如果你手头任务的数据量只有几千张图与其花时间设计复杂模块不如先用成熟网络跑一个baseline在baseline之上再逐步分析瓶颈在哪里。第四算力紧张时优先优化数据加载和增强不要只盯着网络结构优化。比如对输入做随机裁剪、翻转、颜色扰动往往比增加一层注意力模块带来更明显的泛化提升。我把这些经验总结成一句话卷积神经网络的常见模块你不需要全部精通才能上手但当你遇到训练不稳定、维度不对、效果不达标这些问题时能快速定位到具体模块并知道怎么替换这种能力才是真正的核心竞争力。希望这篇文章能帮你少走一些弯路。
阅读完成 · 觉得有帮助?
咨询建站