ARTICLE DETAIL

资讯详情

深耕网站建设与运营推广的一线实战洞察。

卷积层多输入多输出通道:卷积核形状、1x1卷积与参数量

卷积层多输入多输出通道:卷积核形状、1x1卷积与参数量 做图像分类那阵子我对卷积层的理解特别朴素——拿一个 3x3 的小窗口在图上滑来滑去窗口里 9 个数和对应位置的 9 个像素相乘再求和得到一个数滑完一圈就得到一张新的特征图。这套理解足够我糊弄过第一个手写数字识别的 demo但一旦把输入换成彩色图或者想给网络加宽一点问题立刻冒出来了代码里那个卷积核为什么要写成(16, 3, 3, 3)四个数字中间那两个 3 分别代表什么为什么我把输入通道数写错一个数字程序就直接报形状不匹配多输入多输出通道是卷积神经网络里最典型的知道但没想明白的知识点。你能背出卷积核的通道数等于输入的通道数卷积核的个数等于输出的通道数这句话但真让你手算一遍张量的形状变化或者对着一个通道配置方案判断它贵不贵很多人就卡壳了。这篇内容就是要把卷积层里通道这个维度彻底拆开从单通道灰度图讲到 RGB 三通道从单个卷积核讲到几十上百个核把参数量、计算量、1x1 卷积、分组卷积这些跟通道强相关的工程问题一次讲透。适合刚学完卷积基础、准备动手搭网络或者调模型时被张量形状折磨过的朋友。1. 从一张灰度图说起卷积核为什么必须长出第三个维度1.1 单通道输入的卷积到底在算什么先回到最简单的场景一张 28x28 的手写数字灰度图。它只有一个通道因为每个像素只有一个亮度值0 到 255 之间。此时你拿一个 3x3 的卷积核去卷它整个过程干净得不像话——卷积核就是 9 个权重加 1 个偏置在图上按步长滑动每个位置做 9 次乘加输出一张 26x26 的二维矩阵假设不加 padding、步长为 1。这个阶段权重张量的形状是(3, 3)输出是一张特征图。信息是扁平的、单层的你不需要考虑通道这个轴。很多人对卷积的直觉就是在这个阶段建立的也正因如此后面一旦多出通道维度脑子里那张窗口滑动的画面就跟不上了。这里有个容易被忽略的细节单通道卷积里卷积核本身没有深度。它不是立体的东西就是一块平面的模板。你可以把它理解为一块 3x3 的印章在哪都能盖出同样的图案。印章的形状决定了它响应什么样的局部模式——横向边缘、纵向边缘、某个角度的斜线。1.2 通道维度的出现彩色图带来的必然结果换成一张 32x32 的彩色图事情变了。每个像素不再是单个数值而是三个数值红、绿、蓝。数据形状从(28, 28)变成了(3, 32, 32)——注意这里的 3 是通道数排在最前面这是 PyTorch 的约定TensorFlow 默认是(32, 32, 3)这个顺序差异坑过无数人后面会专门讲。现在你拿一个 3x3 的平面印章去盖盖不动了。因为图像在每个空间位置上都有 3 个值你的印章只有 9 个权重对应不上 27 个输入。解决方案很自然把印章也变成三层的。卷积核从(3, 3)扩展成(3, 3, 3)最后一维对应输入通道它意味着这个卷积核在红通道上有一组 3x3 权重在绿通道上有一组 3x3 权重在蓝通道上也有一组 3x3 权重。所以卷积核之所以长出第三个维度不是设计者刻意追求的优雅而是输入数据的通道结构逼出来的必然结果。有什么样的输入通道就要求什么样的卷积核深度。这也是为什么后面你写Conv2d(in_channels3, ...)时框架内部构造的权重形状一定是(out_channels, 3, kH, kW)。1.3 一个高频误解把通道当成 batch 维度我见过不少初学者把(3, 32, 32)里的 3 当成3 张图。这是个要命的误解。通道和 batch 是两个完全独立的维度。batch 维度表示一次喂给网络多少张图通道维度表示每张图有多少个特征层。一张 RGB 图是 3 个通道一批 16 张 RGB 图的形状是(16, 3, 32, 32)四维张量分别是 batch、通道、高、宽。这两个维度混淆之后最典型的症状是你把(16, 3, 32, 32)里的一批数据误当成 16 个通道去做卷积核设计然后参数量算出来自己都不敢信。记住一句判据卷积核的通道数永远只跟输入的特征通道对齐跟 batch 没有任何关系。batch 里有多少张图都不影响一个卷积核的形状。2. 多输入通道卷积核的深度由谁拍板2.1 三个 3x5x5 是怎么数出来的拿一个具体配置来走。输入是(3, 32, 32)我们想用 5x5 的窗口输出 6 个通道。PyTorch 里写nn.Conv2d(3, 6, 5)权重张量的形状是(6, 3, 5, 5)。现在逐个数字解释第一个 6 是输出通道数也就是用了 6 个不同的卷积核第二个 3 是输入通道数对应输入的 3 个通道后面两个 5x5 是卷积核的空间尺寸。你可以把这 6 个核想象成 6 个立体印章每个印章的尺寸是 3x5x5也就是 75 个权重。每个印章配 1 个偏置偏置是按输出通道共享的一个输出通道一个偏置不是每个输入通道一个。所以总参数量 6 × (3 × 5 × 5 1) 6 × 76 456。验算一下PyTorch 里这件事很容易验证。import torch import torch.nn as nn conv nn.Conv2d(in_channels3, out_channels6, kernel_size5) print(conv.weight.shape) # torch.Size([6, 3, 5, 5]) print(conv.bias.shape) # torch.Size([6]) total sum(p.numel() for p in conv.parameters()) print(total) # 456conv.weight.shape输出的就是[6, 3, 5, 5]。注意这里的排列顺序是(out_channels, in_channels, kH, kW)不是(in_channels, out_channels, ...)。这个顺序在我刚开始学的时候反复记混因为有些教材的公式写的是反过来。以代码里的实际形状为准别死记公式。2.2 逐通道卷积再求和为什么不是逐通道输出多输入通道的卷积计算过程是理解整个机制的关键一步。它分两步走第一步把三维卷积核拆成三个二维切片每个切片单独跟对应的输入通道做二维卷积。红通道的切片卷红色数据绿通道的切片卷绿色数据蓝通道的切片卷蓝色数据。每个切片卷完都得到一张 28x28 的中间结果5x5 核32-5128。第二步把这三张中间结果逐元素相加再加上偏置得到一张 28x28 的输出。这才是这个卷积核的最终产物。这里最反直觉的地方在于多输入通道只产生一个输出通道不是三个。三个通道的信息在求和这一步被融合成了一张特征图。这个融合是有代价的——中间那三张结果加在一起之后你就分不清某个响应到底来自哪个颜色通道了。但这是刻意的设计卷积核学到的是跨通道的联合模式比如红强蓝弱同时出现某个纹理这种模式单看任何一个通道都捕捉不到。用代码验证一下这个过程import numpy as np # 模拟输入3 通道5x5 x np.random.randn(3, 5, 5) # 模拟卷积核3 个输入通道2x2 空间尺寸 w np.random.randn(3, 2, 2) # 逐通道卷积然后求和 out np.zeros((4, 4)) for c in range(3): for i in range(4): for j in range(4): out[i, j] np.sum(x[c, i:i2, j:j2] * w[c]) print(out.shape) # (4, 4) —— 6 个特征图不只有一个扩展开来如果我们有 6 个这样的卷积核就重复 6 次这个过程每次得到一张 4x4 特征图最后堆叠成(6, 4, 4)。2.3 通道不匹配的报错八成是这三类原因RuntimeError: Given groups1, weight of size [16, 3, 3, 3], expected input[1, 1, 28, 28] to have 3 channels, but got 1 channels instead——这个报错我至少见过几十次新人几乎都会撞上去。它的意思很直白卷积层声明自己接受 3 个输入通道但实际喂进来的数据只有 1 个通道。排查时按下面这个顺序走基本三分钟能定位报错现象常见根因排查动作权重期望 3 通道输入只有 1 通道第一层用了in_channels3但喂的是灰度图检查数据加载时的通道转换灰度图要in_channels1中间层通道对不上前一层输出通道数改过了后一层没同步改从头数一遍每层的out_channels逐层对齐通道数和 batch 都对不上张量维度顺序错了NHWC 和 NCHW 混用打印x.shape确认通道在第几维第三类最容易漏。如果你从别的框架迁移模型或者用了某个把数据转成(H, W, C)的预处理库形状看着差不多卷积算出来却是垃圾。养成在关键位置打印x.shape的习惯比看报错信息快得多。提示卷积层的in_channels必须精确等于上一层输出的通道数。全连接层做衔接的时候通道数被拉平了重新进入卷积前一定要确认形状恢复正确。3. 多输出通道一个卷积核只负责一种特征3.1 从单核到多核特征图的堆叠逻辑单输入单输出通道的卷积本质上是一个模板匹配器它只会响应一种局部模式。你要检测横向边缘就训练一个横向边缘的核你要检测纵向边缘就得再来一个核。一个核管不了两种模式——它的权重被一套数值锁死了。所以想提取多种特征唯一办法就是并行地准备多个卷积核每个核独立学习一种模式各自输出一张特征图。这几张大小相同的特征图堆在一起就构成了多输出通道。这就是输出通道数等于卷积核个数这句话的物理含义。以 2.1 节的配置为例6 个 3x5x5 的核各自卷出 6 张 28x28 的特征图堆成(6, 28, 28)。第 0 张可能是某种纹理响应第 1 张可能是另一种方向的边缘响应具体是什么没人能事先指定——它们是靠反向传播和数据自己学出来的这是深度学习最迷人也是最不透明的地方。3.2 输出通道数决定了下一层的输入宽度输出通道数的选择是一个连锁决策。你把这一层的输出设成 16下一层的in_channels就必须是 16你把这一层改成 64下一层不同步改就会直接报错。通道数是层与层之间的接口契约改一处就得顺藤摸瓜。更重要的是输出通道数直接决定了这一层提取特征的容量上限。通道太少很多模式挤在一张特征图里表达不出来模型欠拟合通道太多参数量和计算量成倍上涨还容易在数据量不足时过拟合。常见的做法是让通道数随网络加深而递增浅层用 32、64深层用到 128、256、512。背后逻辑是——浅层处理的是低级的、通用的、数量少的基础特征边缘、颜色块深层处理的是高级的、抽象的、种类繁多的语义特征眼睛、轮子、文字笔画后者需要更多的槽位来区分。3.3 四维张量布局以及那个总记混的顺序卷积核的完整形状是(out_channels, in_channels, kH, kW)。我自己的记忆方法是这样从外到内先想我要产生几个输出out_channels再想我要匹配几个输入in_channels最后才是空间尺寸。因为写代码的时候你也是先决定这两个通道数再决定窗口大小。有些博客会把公式写成(in_channels, out_channels, kH, kW)那是数学推导里的习惯写法跟 PyTorch 不一样。Keras/TensorFlow 的Conv2D又是另一套参数顺序(filters, kernel_size, ...)内部权重布局也不同。跨框架看代码时先确认它用的是哪种布局否则你会边看边算边算边错。4. 1x1 卷积不改尺寸却改通道数的那个怪东西4.1 1x1 卷积在做的其实是跨通道全连接第一次见到 1x1 卷积的人都会愣一下窗口只有 1 个像素那还卷什么卷积的意义不就在于感受野吗换个角度就通了。当你把输入通道数设为 C_in、输出通道数设为 C_out 时一个 1x1 卷积做的事情是对每个空间位置取该位置上 C_in 个通道的值做一次加权求和输出 C_out 个新值。这本质上就是一个作用在通道维度上的全连接层只不过它在所有空间位置上共享同一组权重。举个最小例子输入(3, H, W)1x1 卷积输出(2, H, W)。权重形状(2, 3, 1, 1)参数量 2 × 3 2 8。对每个像素位置都用这 8 个数字把 3 个值映射成 2 个值。H、W 完全不变。4.2 降维与升维省算力的核心手段1x1 卷积真正的价值在于灵活地改变通道数而几乎不付出空间上的代价。我们算一笔账。假设输入是(256, 28, 28)你想得到(64, 28, 28)。方案 A直接用一个 3x3 卷积从 256 降到 64。参数量 64 × 256 × 3 × 3 147456。方案 B先用 1x1 卷积把 256 降到 64然后再接一个 3x3 卷积保持 64。参数量 64×256×1×1 64×64×3×3 16384 36864 53248。方案 B 的参数量只有方案 A 的三分之一多一点。如果再接一个 1x1 升回 256就是经典的瓶颈结构bottleneck在 ResNet、Inception 这些网络里到处都在用。为什么能省这么多因为 3x3 卷积是按输入通道数×输出通道数计费的通道数相乘是乘法关系降一半就是省四分之三。先用便宜的 1x1 把通道数压下来再让昂贵的 3x3 在低维空间里干活——这是通道维度上最实用的省钱思路。4.3 跨通道信息融合的直观理解除了省钱1x1 卷积还有一个常被提及的作用促进跨通道的信息交互。普通的 3x3 卷积虽然也是逐通道卷积后求和但那个求和发生在空间卷积之后通道间的权重是绑在同一个空间模板上的。1x1 卷积则是纯粹的通道混合器它的每个输出通道都是所有输入通道的线性组合等于给网络增加了一层通道之间的全连接。实际用的时候要注意一点1x1 卷积后面通常紧接非线性激活不然两个线性操作叠在一起还是线性的白加。如果你写的是Conv2d(...1x1) - Conv2d(...3x3)中间不加激活函数数学上可以合并成一个等效卷积那就没有意义了。注意1x1 卷积的步长一般设为 1它不改变空间尺寸。如果看到步长大于 1 的 1x1 卷积那是在做下采样跟改通道数是两回事要分开看。5. 通道数是成本的主开关参数与算力怎么估5.1 参数量公式手算一遍才记得住卷积层的参数量公式是权重数量 out_channels × in_channels × kH × kW偏置数量 out_channels一个输出通道一个偏置总量 out_channels × (in_channels × kH × kW 1)拿几个真实配置练手算多了就有直觉了。配置inout核尺寸参数量第一层常见配置3323x332×(271)896加深后1282563x3256×(11521)2951681x1 降维256641x164×(2561)16448深度可分离的逐点卷积1281281x1128×(1281)16512一眼就能看出参数量随in_channels × out_channels成平方级增长。从 3→32 到 128→256参数量涨了三百多倍而核尺寸没变。这就是为什么深层网络的通道数必须精打细算不能随心所欲地翻倍。5.2 感受野没变算力却可能涨十倍参数量只是静态开销前向计算的算力开销FLOPs还得乘上输出特征图的空间尺寸。粗略估算式是FLOPs ≈ 输出通道数 × 输入通道数 × kH × kW × 输出高度 × 输出宽度 × 2最后一个 2 是因为每个乘加算两次浮点运算。还是拿前面的例子3 → 65x5 核输入 32x32输出 28x28。FLOPs ≈ 6 × 3 × 5 × 5 × 28 × 28 × 2 ≈ 2116800约 2.1 MFLOPs。换成一个典型的中层配置128 → 1283x3 核特征图 28x28。FLOPs ≈ 128 × 128 × 3 × 3 × 28 × 28 × 2 ≈ 231211008约 231 MFLOPs。算力需求涨了一百倍。这两个数字摆在一起很多设计上的取舍就看得清了浅层通道少、特征图大算力主要花在空间维度上深层通道多、特征图小算力主要花在通道维度上。想压算力优先看哪一层的通道数乘积 × 空间尺寸最大它才是真正的瓶颈。5.3 几种通道配置思路的对比通道怎么涨、什么时候涨常见的几种做法各有适用场景策略特点适用场景每次下采样通道数翻倍保证每层算力大致均衡通用分类网络经典 VGG 风格分段递增段内不变便于堆叠相同模块实现简单需要重复模块的深层网络先升后降沙漏型中间捕捉高维特征两端省算力需要空间恢复的分割、检测任务全局统一通道数结构最简参数量可控轻量模型、小数据集入门实验我个人的经验是新手别一上来就追求花哨的通道配置。先用下采样就翻倍这个最朴素的策略跑通看训练曲线和验证精度再考虑针对瓶颈层动手。很多时候模型表现不好不是通道数的问题而是数据增强、学习率、归一化这些环节没调好。6. 把维度变化真正跑一遍代码验证与形状追踪6.1 手写一遍多输入多输出通道的卷积理论讲完最有效的巩固方式是亲手实现一次。不追求性能只为看清楚每一步张量在怎么变。import numpy as np def conv2d_multi(x, w, b, stride1, pad0): x: (C_in, H, W) w: (C_out, C_in, kH, kW) b: (C_out,) 返回: (C_out, H_out, W_out) C_in, H, W x.shape C_out, _, kH, kW w.shape x_pad np.pad(x, ((0, 0), (pad, pad), (pad, pad)), modeconstant) H_out (H 2 * pad - kH) // stride 1 W_out (W 2 * pad - kW) // stride 1 out np.zeros((C_out, H_out, W_out)) for oc in range(C_out): for i in range(H_out): for j in range(W_out): h0, w0 i * stride, j * stride patch x_pad[:, h0:h0kH, w0:w0kW] # (C_in, kH, kW) out[oc, i, j] np.sum(patch * w[oc]) b[oc] return out x np.random.randn(3, 8, 8) w np.random.randn(2, 3, 3, 3) b np.zeros(2) y conv2d_multi(x, w, b) print(y.shape) # (2, 6, 6)这段代码里有三个点值得盯住看patch的形状是(C_in, kH, kW)跟单个卷积核的形状完全一致所以可以直接逐元素相乘再求和内层循环对每个输出通道 oc 独立跑一遍输出通道数 2 完全来自w的第一个维度。通道维度上的所有魔法本质都是这层循环在堆叠。6.2 用 PyTorch 的 Conv2d 对齐同一组参数把同样的权重灌进 PyTorch 的卷积层可以验证自己写的逻辑对不对import torch import torch.nn as nn conv nn.Conv2d(3, 2, 3, biasTrue) with torch.no_grad(): conv.weight.copy_(torch.tensor(w, dtypetorch.float32)) conv.bias.copy_(torch.tensor(b, dtypetorch.float32)) xt torch.tensor(x, dtypetorch.float32).unsqueeze(0) # 加 batch 维 yt conv(xt) print(yt.shape) # torch.Size([1, 2, 6, 6]) print(np.allclose(yt.squeeze(0).detach().numpy(), y, atol1e-5))如果最后一行打印 True说明你对多通道卷积的理解和框架实现是一致的。这个对齐实验我强烈建议每个人都做一次——自己写的实现跑不过框架八成是你对某个维度的理解有偏差趁早发现比在调模型时抓瞎强。注意unsqueeze(0)那一步。卷积层永远期望输入带 batch 维哪怕你只有一张图也得显式补上。形状从(3, 8, 8)变成(1, 3, 8, 8)输出就是(1, 2, 6, 6)。6.3 输出尺寸公式以及最容易算错的地方H_out (H 2×pad - kH) / stride 1向下取整。这个公式本身不难坑在三个地方同时有 padding 和 stride 时先加 padding 再减核尺寸最后才除步长。顺序错了结果就错。无法整除时的取整方向。框架默认向下取整如果你按四舍五入估实际形状会少一个像素。kernel_size 写成元组和整数混用。kernel_size3等价于(3, 3)但如果你写了kernel_size(3, 5)宽高方向的输出尺寸就不一样了得分别算。手动算完再打印x.shape核对写两三次就有肌肉记忆了。7. 真实项目里关于通道的几个坑7.1 通道数设计的经验起点数据量小的时候通道数宁少勿多。我自己的粗略参照是训练样本少于一万张时浅层通道从 16 或 32 起步就够别直接上 64。通道数一多参数量上来小数据集根本喂不饱训练损失一路下降而验证损失掉头往上典型的过拟合。另一个教训是别盲目照抄论文的通道配置。那些网络是给 ImageNet 那个量级的数据设计的你拿几万张图去套往往会发现砍掉一半通道效果反而更好。通道数要跟数据规模匹配这是被反复验证过的经验。7.2 分组卷积与深度可分离都是通道上的拆解理解了通道的常规机制分组卷积就好懂了。普通卷积里每个输出通道都跟所有输入通道相连分组卷积把输入通道切成 G 组每个输出通道只跟自己所属的那一组输入相连。通道之间的连接被切断了参数量和算力都降到原来的 1/G。深度可分离卷积是分组卷积的极端情况分组数等于输入通道数每个通道一个独立的卷积核通道之间完全不混合。它后面通常接一个 1x1 卷积来补回跨通道交互。这是移动端模型的主流做法而它的每一个设计决策都建立在通道维度的信息交换是有成本的这个认知上。回头再看groups这个参数就不会觉得它神秘了——它就是在控制通道之间的连接密度。7.3 一个省事的小习惯最后分享一个我自己的习惯每写一个卷积层顺手在纸上或注释里标出它的输入输出形状。像这样# (B, 3, 32, 32) - Conv2d(3, 32, 3, padding1) - (B, 32, 32, 32) # - MaxPool2d(2) - (B, 32, 16, 16) # - Conv2d(32, 64, 3, padding1) - (B, 64, 16, 16)一开始觉得啰嗦等到网络有十几层、通道数在 32 到 512 之间来回跳的时候这张形状清单能救你半个晚上。通道不对、尺寸不对、批大小不对出了问题顺着清单上下扫一眼通常十秒内就能定位到是哪一层跟哪一层对不上。这个习惯我从写第一个自定义网络坚持到现在没吃过亏。
返回列表