做深度学习做到今天我见过太多人把 1×1 卷积当“降维工具”把 3×3 卷积当“特征提取工具”各自用得很溜却从没想过它们之间其实藏着一套组合哲学。我最初也是这样——直到有一次要把一个基准模型压缩到能跑在端侧设备上才把“1×1 3×3”的搭配从“碰巧好用”变成了“刻意设计”。这篇文章我想把这两步的关系彻底讲透它们各自在干什么、为什么组合起来这么强、以及最常见的三种“两步走”套路分别省在哪、又该怎么在代码里落地。不管你是刚接触卷积神经网络的新手还是已经在调模型但始终对“瓶颈结构”“深度可分离卷积”半懂不懂的从业者这套思路都值得花十分钟重新捋一遍。看懂之后你会发现很多号称精巧的网络结构本质上都是在反复使用同一个哲学把空间维度的处理和通道维度的处理解耦开分两步走。这个认知会直接影响你设计网络时的判断力。1. 两种卷积各自的底层逻辑先别混着用1.1 1×1卷积不是“小卷积”它是通道维上的全连接很多初学者第一眼看到 1×1 卷积会觉得它“什么都没做”——毕竟卷积核在空间上只有一个像素对单个位置来说它没有聚合任何邻域信息。但问题恰恰出在这里1×1 卷积根本不需要空间信息它专管通道之间的线性组合。拿具体计算来说输入形状是 H×W×C_in经过一个 1×1 卷积输出 H×W×C_out。在空间上每个点 (i, j)它做的事情是把一个长度为 C_in 的向量通过一个 C_in×C_out 的权重矩阵映射成另一个长度为 C_out 的向量。这不就是作用在每个空间位置上的全连接吗所以 1×1 卷积有三个核心作用通道信息混合把不同通道在同一空间位置上的特征重新线性组合让信息在通道维度上“流动”起来。改变通道数C_in 和 C_out 可以自由设置想压缩就压缩想扩张就扩张。这是控制模型计算量的最关键开关。廉价的计算代价它完全不涉及空间邻域FLOPs 只有标准卷积的平方反比级别。后面我会写具体算式。如果你把网络想象成一条流水线1×1 卷积就像流水线上给每件产品“贴标签、重新装箱”的工位——不改变物品本身的空间位置但彻底重排了它的属性组合。1.2 3×3卷积才是真正的空间特征提取器3×3 卷积就完全不同了。它的每个输出位置会看输入特征图上周围 3×3 邻域共 9 个像素在空间上产生局部感受野。图像中的边缘、角点、纹理等最基本的结构都是靠这种局部邻域聚合才能被捕捉到。那为什么偏偏是 3×3 这么流行而不是 5×5 或者 7×7最经典的解释来自 VGG 系列的经验两个串联的 3×3 卷积感受野等效于一个 5×5 卷积三个串联的 3×3 卷积感受野等效于一个 7×7 卷积。但参数量和计算量却少得多。推一下单层 7×7 卷积的参数量是 49×C×C而三个 3×3 卷积是 3×9×C×C27×C×C直接省了接近一半。更重要的是两层 3×3 之间夹着非线性激活比单层 7×7 多引入两次非线性变换特征表达能力反而更强。关于“为什么主流网络都在用 3×3 堆叠而不是大卷积核”这是核心答案。1.3 它们不在同一个维度上干活这才是能组合的前提这句话听起来像废话但它是“两步走”哲学的全部基础1×1 卷积只动通道维3×3 卷积主要动空间维同时也在混合通道。一个管“有哪些信息”一个管“周围发生了什么”。如果你只用 1×1 卷积堆网络永远无法在空间上感知邻域结构如果你只用 3×3 卷积堆网络通道之间的信息融合效率会很低而且计算量爆炸。两种卷积各自都“缺一半”。把它们按顺序组合就是在两个维度上分别完成自己的职责——先整理通道再提取空间或者先提取空间再融合通道。这两步的顺序、次数、通道比例演化出了过去十年里大量经典结构设计。2. “两步走”的三种经典走法本质是同一种哲学2.1 先降、再提、后升瓶颈结构BottleneckResNet 系列里最经典的瓶颈模块走法是这样的先用一个 1×1 卷积把通道从 256 压到 64再用一个 3×3 卷积在低通道数下做空间特征提取最后用一个 1×1 卷积把通道恢复到 256。整个过程是典型的“降维-卷积-升维”。这一步每一步都有明确动机。直接拿 256 通道做 3×3 卷积计算量非常大先降维到 64让 3×3 卷积作用在窄通道上计算量一下子降到一个零头空间特征的提取并没有损失太多——因为信息在降维后被压缩到了更紧凑的表示里。最后再升维是为了让输出通道数和输入对齐便于做残差连接。这一个模块内部的“两步走”还不止一次1×1 降维是第一步调整3×3 提取是第二步空间操作最后的 1×1 升维算是第三步收尾。在 ResNet-50 及更深版本里这种瓶颈结构基本取代了原来的两层 3×3 结构原因只有一个同样表达能力下FLOPs 和参数量都大幅下降。2.2 先空间、后通道深度可分离卷积Depthwise Separable ConvolutionMobileNet 系列大量使用的深度可分离卷积是另一种两步走。它把标准卷积拆成两个独立操作第一步是深度卷积每个输入通道单独用一个 3×3 卷积核去卷通道之间互不干扰第二步是逐点卷积用一个 1×1 卷积把上一步各通道的结果按位置重新混合。对比一下标准卷积标准 3×3 卷积同时完成“空间聚合”和“通道混合”两步搅在一起计算量是毫不留情的 C_in×C_out×9。深度可分离卷积把两者拆开先做空间聚合每个通道独立再做通道混合1×1 卷积计算量变成 C_in×9 C_in×C_out。这一步拆解能省多少我下一节给具体数字。这里先说哲学层面标准卷积逼着网络在同一个操作里同时考虑“周围是什么”和“各通道怎么组合”这两个任务其实是可以被解耦的。深度可分离卷积的假设就是——先用 3×3 只提取空间结构再用 1×1 自由融合通道信息效果不会比同步做差多少但计算量小了一个数量级。2.3 两步走也能只走通道SE 模块的启发SENet 提出的 SESqueeze-and-Excitation模块表面上看和卷积无关实际上也在用两步走哲学第一步是全局平均池化把每个通道的空间信息压缩成一个标量第二步是两个 1×1 卷积实现上通常用全连接先降维再升维对每个通道算出一个权重。最后把权重乘回原特征图。这一步“压缩-激励”的过程本质上就是对通道维度做一次“注意力重标定”——先全局看一眼每个通道整体有多重要再用 1×1 做非线性变换重新分配通道权重。它和瓶颈结构有个共同点中间都故意插入了一个维度瓶颈降维再升维目的是让网络在低维空间学会更高效的通道关系顺带减少计算量。SE 模块的流行告诉我一件事1×1 卷积不只是配合 3×3 用的“计算量调节器”它本身就是一种通道维度的建模工具。只要你能把问题拆成“空间上怎么办”和“通道上怎么办”两步这两步走的思想就可以在不同层级上反复套用。3. 算一笔账“两步走”到底省在哪数字说了算3.1 谁在消耗算力公式先摆出来标准卷积的 FLOPs乘加次数通常约等于实际计算量的一半可以写成输出尺寸 H×W×C_out 再接上遍历整个特征图的所有位置FLOPs H × W × C_out × C_in × k × k这个公式里最值得注意的变量是 C_in × k×k输出通道越多、卷积核越大计算量呈平方级上涨。这也是为什么 7×7 卷积极度昂贵、深网络里普遍用 3×3 的原因。为了对比我取一个非常常见的中间层配置输入特征图 28×28×256输出也是 28×28×256。在这个配置上分别算四种做法的 FLOPs单层 7×7 卷积784 × 65536 × 49 ≈ 25.2 亿三个串联 3×3 卷积每层通道不变3 × 784 × 65536 × 9 ≈ 13.9 亿瓶颈结构1×1 降到 640.13 亿 3×3 64 通道0.29 亿 1×1 升回 2560.13 亿≈ 0.55 亿深度可分离卷积3×3 深度卷积0.018 亿 1×1 逐点卷积0.51 亿≈ 0.53 亿3.2 对比表格感受一下数量级差异结构方案FLOPs28×28×256→28×28×256参数量核心特点单层 7×7约 25.2 亿3,211,264感受野大但计算量惊人三个 3×3约 13.9 亿1,767,168等效感受野 7×7省 45%瓶颈结构降到64约 0.55 亿69,632计算量降到单层 7×7 的约 2%深度可分离约 0.53 亿67,840空间与通道解耦极轻量看到瓶颈结构和深度可分离卷积的数字你可能和我第一次算出来时一样震惊同样做一次 28×28×256 的特征变换两步走的方案消耗的计算量只有直接大卷积核的百分之二左右。3.3 省的是什么不省的是什么这里有一个容易混淆的点。FLOPs 降下去了参数量也降下去了但“省”的方式不同。瓶颈结构省的大头是 3×3 卷积作用在高通道数上的开销深度可分离卷积省的大头是 3×3 卷积同时对通道做混合的冗余计算。但要注意FLOPs 低不代表实际推理一定快。在 GPU 上小卷积核的并行度可能受内存带宽限制在 CPU 或移动端则友好得多。所以动手选方案时一定要结合自己的硬件环境做实测不能只看 FLOPs 纸面数字。这一点我在第五节再展开。4. 用代码把“两步走”写出来直接能落地的模块4.1 标准瓶颈模块的实现先说最常用的瓶颈结构。以下 PyTorch 代码我做过多次微调后固定下来的一版带残差连接和可选下采样import torch import torch.nn as nn class Bottleneck(nn.Module): def __init__(self, in_channels, out_channels, stride1, reduction4): super().__init__() hidden out_channels // reduction self.conv1 nn.Conv2d(in_channels, hidden, 1, biasFalse) self.bn1 nn.BatchNorm2d(hidden) self.conv2 nn.Conv2d(hidden, hidden, 3, stridestride, padding1, biasFalse) self.bn2 nn.BatchNorm2d(hidden) self.conv3 nn.Conv2d(hidden, out_channels, 1, biasFalse) self.bn3 nn.BatchNorm2d(out_channels) self.relu nn.ReLU(inplaceTrue) self.downsample None if stride ! 1 or in_channels ! out_channels: self.downsample nn.Sequential( nn.Conv2d(in_channels, out_channels, 1, stridestride, biasFalse), nn.BatchNorm2d(out_channels), ) def forward(self, x): identity x out self.relu(self.bn1(self.conv1(x))) out self.relu(self.bn2(self.conv2(out))) out self.bn3(self.conv3(out)) if self.downsample is not None: identity self.downsample(identity) out identity return self.relu(out)几个值得注意的细节biasFalse卷积后面紧跟 BatchNormbias 会被 BN 抵消掉留着反而浪费参数。reduction4隐藏通道数 输出通道数 / 4这是最常用的压缩比例。后面我会讲什么时候该调它。downsample输入输出通道不一致或 stride2 时需要对残差路径做投影这里用 1×1 卷积完成其实就是一次“1×1 步走”来对齐维度。4.2 深度可分离卷积模块的实现下面这段代码实现的是深度卷积 逐点卷积的完整结构class DepthwiseSeparableConv(nn.Module): def __init__(self, in_channels, out_channels, kernel_size3, stride1): super().__init__() self.depthwise nn.Conv2d( in_channels, in_channels, kernel_size, stride, paddingkernel_size // 2, groupsin_channels, biasFalse ) self.bn1 nn.BatchNorm2d(in_channels) self.pointwise nn.Conv2d(in_channels, out_channels, 1, biasFalse) self.bn2 nn.BatchNorm2d(out_channels) self.relu nn.ReLU(inplaceTrue) def forward(self, x): x self.relu(self.bn1(self.depthwise(x))) x self.relu(self.bn2(self.pointwise(x))) return x这里最关键的是 depthwise 卷积里的 groupsin_channels。这个参数让每个输入通道只被自己的卷积核卷积完全不会跨通道混合。很多新手在这里习惯性不写 groups以为自己在做深度卷积实际上 channels 之间悄悄混在一起了和标准卷积区别只在参数少了点计算量的节省就完全没有了。我习惯把深度可分离卷积层写成“一个深度卷积 BN ReLU 一个逐点卷积 BN ReLU”激活函数放在每个步骤之后而不是只在最后放一个。实测这样训练更容易收敛精度也略高。4.3 选择瓶颈还是深度可分离我的经验如果是用于 CPU/移动端推理深度可分离卷积通常是更稳的选择因为它每个操作都很简单对低并行度的硬件更友好。如果在 GPU 上做大规模训练瓶颈结构往往效果更稳定精度更好调。很多轻量级网络比如 MobileNet 系列的变体在 GPU 上实测并没有想象中那么快核心原因就是深度卷积在 GPU 上很难把算力吃满。如果你刚起步我建议先在同一个数据集上同时实现两种模块对比参数量和精度。直接在现有网络里把标准卷积替换成其中任意一种观察变化这比看十篇论文都有用。4.4 顺带附一个 SE 模块实现SE 模块的实现也很短几乎必背class SqueezeExcitation(nn.Module): def __init__(self, channels, reduction16): super().__init__() self.squeeze nn.AdaptiveAvgPool2d(1) self.fc nn.Sequential( nn.Linear(channels, channels // reduction, biasFalse), nn.ReLU(inplaceTrue), nn.Linear(channels // reduction, channels, biasFalse), nn.Sigmoid(), ) def forward(self, x): b, c, _, _ x.size() y self.squeeze(x).view(b, c) y self.fc(y).view(b, c, 1, 1) return x * y它和卷积模块配套使用时一般放在残差分支的最后、相加之前对通道做加权。如果你把 SE 想象成一个只走“通道维”的两步走模块就很容易理解它和卷积模块为什么能无缝配合。5. 常见误区与调参心得踩过的坑都在这5.1 1×1卷积不只是“降维工具”这是我见过最多的误解。1×1 卷积确实常被用来降维但它的本质是通道混合。如果你只把它当前往计算量削减器你会忽略它在特征融合中的巨大作用。比如在深浅层特征融合的很多结构里1×1 卷积就是用来把不同来源的通道对齐并混合而未必降维。带着这个认知去看网络结构你会少很多困惑。5.2 深度可分离卷积不是万能灵药FLOPs 降了 80% 不意味着推理时间降 80%。至少有两个隐藏成本深度卷积内部必须逐通道串行/并行分通道做GPU 利用率偏低逐点卷积虽然计算量小但要读取整张中间特征图内存访问压力大。所以如果你是 GPU 服务器部署深度可分离卷积的优势会被削弱很多。我自己踩过的坑是把一个标准卷积网改成深度可分离后GPU 训练速度反而慢了 15%而参数量确实降了很多。最终方案是部分层保持标准卷积只在计算热点层使用深度可分离。5.3 BN 和 ReLU 的位置不能乱放瓶颈模块里我强调过一个顺序conv1 → BN → ReLU → conv2 → BN → ReLU → conv3 → BN → 与恒等分支相加 → ReLU。最后一个 ReLU 放在相加之后是因为残差分支的恒等映射需要“干净”地传递到下一层不能在相加前就被激活截断。很多人图省事把 ReLU 全堆在卷积后面结果残差梯度流被严重阻碍训练变慢甚至不收敛。如果你在训练时发现 loss 下不去优先查一下激活函数相对残差连接的位置。5.4 降维比例reduction不是固定 1/4瓶颈结构里 reduction4 是最常见选择但绝不是唯一选择。我曾经在某个分类任务上把 reduction 从 4 调到 8参数量继续下降精度掉了 0.3 个百分点调到 2精度涨了一点参数量却翻了一倍。这个比例本质上是“信息瓶颈”的宽度调得越小中间层能表达的信息越少但计算量也越小。我的经验是如果任务比较简单或者精度压力大用 2 或 4如果就是要极轻量模型可以直接上 8 甚至 16然后配合更宽的网络整体调参。5.5 瓶颈结构的瓶颈到底在哪一层设计瓶颈模块时最容易忽略的是中间 3×3 卷积的输入通道已经被压缩到 hidden因此它的感受野操作只能建立在压缩后的特征上。如果压缩得太狠空间信息的丰富度会被严重削弱。这也是为什么 MobileNet 那类结构宁可逐步降低通道也不做一次性极窄瓶颈的原因。你在设计网络时每一层的压缩比例要跟“这个位置到底需要多丰富的空间特征”匹配而不是统一套一个参数。5.6 初始化对两步走结构的影响不可忽略BN 后接 1×1 卷积时输出方差很大程度上由后续权重尺度决定。我遇到过深度可分离网络在训练初期 loss 掉不下去的情况排查后发现是深度卷积那层初始化方差过大导致经过 BN 后输出震荡。后来在所有深度卷积层把 weight 初始化标准差调小到原来的 0.5 倍问题就消失了。如果你复现轻量网络发现收敛异常先检查初始化分布再检查学习率不要一上来就怪数据集。小结与个人体会把 1×1 和 3×3 放在一起看最深的体会是现代卷积网络在设计上一直在做“拆解”和“解耦”——把空间特征提取和通道信息融合拆成两步在各自维度上做更高效的操作。瓶颈结构是先降再提后升深度可分离卷积是先空间后通道SE 模块则是纯通道维的两步走。套路不同哲学相通。我自己在一次轻量模型改造里用瓶颈替换了三个标准卷积层参数量从 9.6M 掉到 2.1M精度只损失了 0.4 个百分点推理速度提升了接近一倍。踩过的坑也不少ReLU 位置、groups 设置、初始化尺度都是靠打印中间张量和逐层对比才定位出来的。如果你也想动手改一版自己的轻量网络我建议从一个小模型开始把标准卷积替换成瓶颈或深度可分离用同样的训练配置跑一个对比实验。不用追求一步到位先跑通一个最小的“两步走”再逐步铺开这比啃任何结构分析的论文都更实在。