
卷积神经网络里卷积层的多输入多输出通道是我带新人时最容易卡住的一环。很多人能背出“卷积就是在图像上滑动窗口做加权求和”可一看到输入张量后面挂着个 3、卷积核是四维的、输出又变成 64 个通道脑子立刻糊成一团。为什么不能像全连接层那样一进一出同一张图为什么要用几十上百组卷积核去反复算多出来的那几维到底在表达什么信息这些问题不解决后面看卷积神经网络结构图、改模型结构、调超参全都只能凭感觉试。我第一次读 LeNet5 卷积神经网络那篇论文时也犯过同样的错把 6 个 5×5 的卷积核理解成“6 种不同的边缘检测器”却完全没想过这 6 个输出通道之间是怎么协同工作的。后来做图像分类项目输入从灰度单通道换成三通道第一层卷积的 in_channels 从 1 改成 3参数量立刻涨了三倍改完之后模型精度反而掉了一截——那次踩坑让我意识到多输入多输出通道不是“把数字填对”就完事背后有一套关于信息混合、参数预算和特征表达的完整逻辑。这篇内容我打算把这件事彻底讲透多输入通道的卷积到底怎么算、多输出通道为什么是必要的、1×1 卷积在这里扮演什么角色、参数量和计算量怎么估、手写实现怎么和框架结果对齐以及我在真实项目里遇到的通道相关报错怎么排查。适合刚学完单通道卷积想往深处走的同学也适合模型能跑通、但通道维度还是“跟着教程抄”的从业者。读完你应该能做到看到任意一个卷积层配置立刻在脑子里拆开它的输入、卷积核、输出三个维度并且知道每一维改动会带来什么代价。1. 先把单通道卷积算明白才知道多通道补了什么1.1 单通道卷积的完整计算过程与它的天花板单通道卷积的设定非常干净一张 H×W 的二维图配一个 kh×kw 的二维卷积核滑窗做逐元素相乘再求和。写成公式就是输出某个位置的值等于核覆盖区域内所有像素与对应核权重的乘积之和步长为 1、无填充时输出尺寸是 (H-kh1)×(W-kw1)。这个过程在信号处理里叫互相关深度学习框架里默认实现的也是互相关只不过大家习惯性都叫它卷积这个约定不用纠结记住计算方式是逐元素乘加就够了。这套设定能干什么边缘检测、角点响应、简单纹理提取都能做。拿一个经典的 3×3 Sobel 核或者干脆用 [[1,0,-1],[1,0,-1],[1,0,-1]] 这种横向差分核你会发现它在竖直线条位置响应强烈在平坦区域响应接近零。这说明单通道卷积本质上是在问一个问题“这个位置的局部邻域和我的核长得像不像”核就是模板输出就是匹配程度。问题也正在这里。一个核只能问一个问题。想要同时检测横边缘、竖边缘、斜边缘、斑点就得换不同核分别算一遍。而且灰度图只有一个通道如果输入是彩色图三张通道表分别包含亮度和色度信息单通道卷积完全没法把它们联合起来看——它连“同时观察三个通道”这个动作都做不到。这就是单输入通道的天花板表达能力被卷积核的数量和输入的通道数双重锁死。注意单通道并不意味着只能处理灰度图。你完全可以把 RGB 三个通道分别送进三个独立的单通道卷积再把结果拼起来。后面会讲到这种做法和多输入通道卷积的结果几乎一样但参数组织和工程实现上差很多理解这个等价关系对看懂通道机制非常关键。1.2 多输入通道把三张颜色表当成一个立方体来看多输入通道的核心思想其实一句话就能说完**每个输入通道配一个独立的卷积核切片各自做完二维卷积之后把结果逐元素相加得到唯一一个输出通道。**注意这里的关键词是“相加”不是拼接。这是初学者最容易搞混的地方。假设输入是 3×3×3 的小张量前两维是空间尺寸 3×3第三维是通道数 3。卷积核是 2×2×3空间尺寸 2×2输入通道数 3。计算时把核沿着通道维度切成三片每片 2×2分别和输入对应的通道做二维滑动卷积得到三个 2×2 的中间结果然后这三个中间结果在相同位置上直接相加最终输出一个 2×2 的单通道特征图。为什么是加而不是拼想象你要判断一个位置是不是“红色圆形物体的边缘”。红色信息在 R 通道里、边缘信息在梯度里、形状信息可能分散在多个通道。你希望得到一个统一的、综合的意见“这个位置综合来看像不像”。加法提供的正是这种“综合打分”的语义而拼接只是把不同通道的意见并排摆放还需要后续层再处理。数学上多输入通道卷积的公式可以写成输出等于对输入通道求和、在每个通道内部做二维卷积再加偏置。换句话说多输入通道只是把“逐元素乘加”的求和范围从空间维度扩展到了空间加通道两个维度。理解这一点之后你会发现多输入通道根本没有引入新运算只是把求和范围扩大了而已。1.3 多输出通道一组卷积核就是一组并行的特征探测器如果多输入通道解决的是“怎么把多个来源的信息合并成一个判断”那么多输出通道解决的就是“怎么同时做出多种判断”。做法很直接准备 C_out 组卷积核每组核的形状都是 C_in×kh×kw每一组独立完成上面说的多输入通道卷积各自产生一个 H_out×W_out 的二维特征图最后把这 C_out 个特征图沿着通道维度堆叠得到 C_out×H_out×W_out 的输出张量。拿 PyTorch 里的权重张量来对照最清楚nn.Conv2d(3, 64, 3)的 weight 形状是(64, 3, 3, 3)四个维度依次是输出通道数、输入通道数、核高、核宽。看到这个形状你就该条件反射地知道第 0 维是“多少个不同的判断”第 1 维是“每个判断要看多少个输入来源”。这两个维度是整段代码里最容易记反的地方我见过不少人把(64, 3, 3, 3)读成“64 个输入通道、3 个输出通道”然后被报错折磨半天。多输出通道的实际意义在于一个卷积层不再只输出一张响应图而是输出一组特征图每一张都在强调输入的某种局部模式。浅层网络里不同通道往往对应不同方向的边缘、不同颜色的色块深层网络里通道的语义变得抽象可能对应“眼睛”“车轮”“文字笔画”这类组合模式。通道数越多这个层能同时表达的模式种类就越多代价是参数和计算量同步上涨。提示输出通道数不一定要等于输入通道数两者完全独立。nn.Conv2d(3, 64, 3)和nn.Conv2d(64, 3, 3)是合法且常见的前者做升维、后者做降维具体用哪种取决于你在网络里的位置和参数预算。2. 多输入多输出通道的运算细节与手算验证2.1 逐通道卷积再跨通道求和的公式展开把完整流程写清楚。设输入张量 X 的形状为 C_in×H×W卷积核权重 W 的形状为 C_out×C_in×kh×kw偏置 b 的长度为 C_out。对于第 co 个输出通道、输出位置 (i, j)计算公式是先把 W[co] 这个 C_in×kh×kw 的切片拿出来对每个输入通道 c 取窗口 X[c, i:ikh, j:jkw]两两逐元素相乘然后把所有 c 的结果和所有空间位置的结果全部加起来最后加上 b[co]。用双重求和的语言描述就是输出等于对输入通道索引 c 求和、对核内空间位置 (u, v) 求和累加 X[c, iu, jv] 乘以 W[co, c, u, v]再加偏置。整个式子没有任何新东西唯一需要注意的是求和变量有两个c 和 (u,v)。新手写反向传播或者手写前向时最常犯的错就是漏掉对 c 的求和结果输出形状多出一维。这个公式还藏着一个重要推论**输入通道数必须等于卷积核的输入通道数。**如果输入是 3 通道而核只准备了 2 个通道切片那么第三个通道的窗口就没有对应的权重去乘运算无法定义。这就是为什么把灰度模型改成彩色输入时第一层卷积的 in_channels 必须跟着改否则框架会直接抛维度错误。2.2 手算一遍 3×3×3 输入配两个 2×2×3 卷积核光看公式容易飘动手算一遍最实在。设计一个便于手算的小例子输入 3×3×3三个通道分别是通道 0[[1,2,0],[3,1,2],[0,4,1]]通道 1[[0,1,2],[1,0,1],[2,1,0]]通道 2[[2,0,1],[0,2,0],[1,0,2]]准备两个输出通道的核形状都是 2×2×3。第一个输出通道的核通道 0 切片[[1,-1],[0,1]]通道 1 切片[[-1,0],[0,1]]通道 2 切片[[0,1],[1,0]]步长 1、无填充输出空间尺寸是 (3-21)2所以输出是 2×2。先算左上角位置窗口取输入的左上 2×2 区域通道 0 窗口[[1,2],[3,1]]与核切片逐元素相乘再求和1×1 2×(-1) 3×0 1×1 1 - 2 0 1 0。 通道 1 窗口[[0,1],[1,0]]与核切片计算0×(-1) 1×0 1×0 0×1 0。 通道 2 窗口[[2,0],[0,2]]与核切片计算2×0 0×1 0×1 2×0 0。 三通道结果相加得 0加上偏置 1最终输出 1。继续算右上角位置窗口是输入右上 2×2。通道 0 窗口[[2,0],[1,2]]2×1 0×(-1) 1×0 2×1 4。通道 1 窗口[[1,2],[0,1]]1×(-1) 2×0 0×0 1×1 0。通道 2 窗口[[0,1],[2,0]]0×0 1×1 2×1 0×0 3。三项相加得 7加偏置 1 得 8。左下角位置通道 0 窗口[[3,1],[0,4]]贡献 3 - 1 0 4 6通道 1 窗口[[1,0],[2,1]]贡献 -1 0 0 1 0通道 2 窗口[[0,2],[1,0]]贡献 0 2 1 0 3合计 9加偏置得 10。右下角通道 0[[1,2],[4,1]]贡献 1 - 2 0 1 0通道 1[[0,1],[1,0]]贡献 0通道 2[[2,0],[0,2]]贡献 0合计 0加偏置得 1。第一个输出通道的结果是[[1,8],[10,1]]。第二个输出通道零偏置核切片分别是通道 0 用[[0,1],[1,0]]、通道 1 用[[1,0],[0,-1]]、通道 2 用[[-1,0],[0,1]]同样四个位置算下来得到[[5,1],[1,6]]。这个例子是人为构造的所以有些通道贡献正好为零这恰好直观展示了“不同核切片可以选择性地关注不同输入通道”——第一组核主要靠通道 0 和通道 1 说话通道 2 几乎没参与。2.3 用 PyTorch 对拍确认手算没有算错手算完了必须验证不然错了也不知道。把上面的输入、权重、偏置原样喂给nn.Conv2d权重直接赋值覆盖跑一遍看结果是否和手算一致import torch import torch.nn as nn x torch.tensor([ [[1., 2., 0.], [3., 1., 2.], [0., 4., 1.]], [[0., 1., 2.], [1., 0., 1.], [2., 1., 0.]], [[2., 0., 1.], [0., 2., 0.], [1., 0., 2.]], ]).unsqueeze(0) # (1, 3, 3, 3) w torch.tensor([ [[[1., -1.], [0., 1.]], [[-1., 0.], [0., 1.]], [[0., 1.], [1., 0.]]], [[[0., 1.], [1., 0.]], [[1., 0.], [0., -1.]], [[-1., 0.], [0., 1.]]], ]) # (2, 3, 2, 2) b torch.tensor([1., 0.]) conv nn.Conv2d(3, 2, kernel_size2, biasTrue) with torch.no_grad(): conv.weight.copy_(w) conv.bias.copy_(b) print(conv.weight.shape) # torch.Size([2, 3, 2, 2]) print(conv(x))跑出来的结果应该和手算完全吻合。这一步的意义不只是核对数字更是让你把“权重形状 (C_out, C_in, kh, kw)”这个认知固化下来。以后写任何卷积层先检查 weight 的第 0 维是不是你想要的输出通道数、第 1 维是不是输入通道数九成的维度报错都能在这一步提前拦下来。注意copy_必须在torch.no_grad()下做否则会报“leaf variable requires grad”之类的错误。这不是通道的问题但新手在做这类权重替换实验时经常撞上。3. 多输出通道与 1×1 卷积通道维度的信息混合器3.1 输出通道数如何决定参数量与表达能力输出通道数 C_out 的影响是双重的。一方面是表达能力每个输出通道代表一组独立的核权重也就是一种独立的局部模式检测器C_out 越大这个层能同时捕捉的模式种类越多。另一方面是参数与计算开销参数量与 C_out 成正比在输入通道和核尺寸固定的前提下C_out 翻倍参数量和计算量基本也翻倍。这里有一个很容易被忽略的细节**每个输出通道都会看到全部输入通道。**也就是说nn.Conv2d(256, 256, 3)里的每一个输出通道都要和 256 个输入通道的窗口做乘加参数量是 256×256×3×3 589824。这个数字在 VGG 这类网络里非常常见而 VGG 的参数量大头几乎都堆在了后面几个 3×3 卷积层上。理解了这一点你就能明白为什么后续网络设计都在拼命想办法减少 3×3 卷积的通道交互成本。还有一点值得说清楚多输出通道之间并不是完全独立的。它们共享同一份输入但各自有独立的权重最终结果在下一层又会被重新混合。这种“本层独立、跨层混合”的模式是卷积网络的核心特征之一。如果你希望本层内部就做通道间的信息交换那就要用到 1×1 卷积或者干脆用分组卷积配合通道重排。3.2 1×1 卷积到底在干什么1×1 卷积初次接触时非常反直觉核的空间尺寸是 1没有邻域信息那还卷什么答案在于它的计算方式。1×1 卷积在每一个空间位置上把 C_in 个通道的数值拿出来和 C_out 组权重做加权求和输出 C_out 个值。换句话说它本质上是作用在每个像素位置上的一个小型全连接层输入维度 C_in、输出维度 C_out只不过这个全连接层在所有空间位置上共享权重。它的第一个典型用法是跨通道信息融合。3×3 卷积虽然也能混合通道但它同时混入了空间信息而且参数量是 1×1 的 9 倍。当你只想让通道之间交换信息、不想改变空间感受野时1×1 是最省的做法。第二个用法是升降维。把 C_in 从 256 降到 64用 1×1 卷积只要 64×256 16384 个参数如果换成 3×3 卷积做同样的事参数量是 64×256×9 589824差了 36 倍。ResNet 的 bottleneck 结构就是靠这个思路把计算量压下来的先 1×1 降维、再 3×3 卷积、最后 1×1 升维回去。第三个用法是非线性增强。1×1 卷积后面通常接批归一化和激活函数等于给每个空间位置增加了一次非线性变换在不扩大感受野的前提下提升网络的非线性表达能力。这个用法在 NiN 网络里被系统性地提出后来被各种结构继承。3.3 代码实操用 1×1 卷积做通道升降维看代码最直观。下面这段构造一个典型的 bottleneck 模块输入通道 256中间压到 64再恢复到 256import torch import torch.nn as nn class Bottleneck(nn.Module): def __init__(self, in_ch256, mid_ch64, out_ch256): super().__init__() self.reduce nn.Conv2d(in_ch, mid_ch, kernel_size1, biasFalse) self.bn1 nn.BatchNorm2d(mid_ch) self.conv3x3 nn.Conv2d(mid_ch, mid_ch, kernel_size3, padding1, biasFalse) self.bn2 nn.BatchNorm2d(mid_ch) self.expand nn.Conv2d(mid_ch, out_ch, kernel_size1, biasFalse) self.bn3 nn.BatchNorm2d(out_ch) self.relu nn.ReLU(inplaceTrue) def forward(self, x): y self.relu(self.bn1(self.reduce(x))) y self.relu(self.bn2(self.conv3x3(y))) y self.bn3(self.expand(y)) return self.relu(y x) blk Bottleneck() x torch.randn(2, 256, 56, 56) print(blk(x).shape) # torch.Size([2, 256, 56, 56]) # 参数量对比 direct nn.Conv2d(256, 256, 3, padding1, biasFalse) print(bottleneck 参数:, sum(p.numel() for p in blk.parameters() if p.requires_grad)) print(单个 3x3 参数:, sum(p.numel() for p in direct.parameters()))跑下来你会看到 bottleneck 的总参数量远小于一个直接的 3×3 卷积层而这正是通道维度上做文章的价值所在。顺带说一句biasFalse配合 BatchNorm 是标准搭配因为 BN 里有自己的偏置项卷积再加偏置属于重复参数白白增加开销。提示1×1 卷积的权重形状是(C_out, C_in, 1, 1)在内存里看起来像一个普通的二维矩阵。有些推理框架会把它直接映射成矩阵乘法这也是它在移动端推理优化里特别受青睐的原因之一。4. 参数量与计算量通道设计前必须算清楚的两笔账4.1 参数量公式与主流网络的通道配置对照卷积层的参数量公式很简单参数 C_out × C_in × kh × kw C_out如果带偏置。有些实现会写成 C_out × (C_in × kh × kw 1)意思一样。偏置项在通道数很大时相对微不足道但当 C_in 和核尺寸都很小时就不容忽视比如第一层 3 通道输入的 3×3 卷积偏置占比能到 3% 以上。拿几个真实层的数字对照一下更清楚网络层输入通道输出通道核尺寸参数量LeNet-5 C1165×5156LeNet-5 C36165×52416全连接假设VGG16 conv1_13643×31792VGG16 conv3_22562563×3590080ResNet bottleneck 降维256641×116384ResNet bottleneck 升维642561×116384这张表最值得盯住的是 VGG16 conv3_2 那一行590080 个参数单层就接近 60 万而它只是 56×56 空间尺寸上的一个 3×3 卷积。VGG 的参数量之所以能飙到 1.38 亿主要就是靠后面几个这种层堆出来的。再看 ResNet 的两个 1×1 相加才 32768 个参数还不到前者的 6%这就是通道设计带来的量级差距。顺便提一句 LeNet-5 的 C3原始论文里的 S2 到 C3 并不是全通道连接而是用了一张连接表让 C3 的每个输出通道只连接 S2 的部分输入通道。这在今天看就是分组卷积的雏形。如果你按“全连接”去算 LeNet-5 的参数量会比论文里的数字多出不少这也是很多教程对不上的原因。4.2 FLOPs 与显存占用的估算方法计算量的常用指标是乘加次数也就是 MACs总浮点运算数约等于 2 倍 MACs乘一次、加一次。卷积层的 MACs 公式是MACs C_out × C_in × kh × kw × H_out × W_out。注意这个公式里空间输出尺寸 H_out×W_out 是乘在最后的所以空间尺寸大的一层即使通道不多计算量也可能很惊人。拿 VGG16 第一层验算C_in3、C_out64、核 3×3、输入 224×224 输出 224×224。MACs 64×3×9×224×224 86,704,128大约 8670 万次乘加对应约 1.73 亿 FLOPs。再看 conv3_264×256 已经换成 256×256核仍是 3×3空间是 56×56。MACs 256×256×9×3136 1,849,688,064约 18.5 亿次乘加。虽然空间尺寸小了很多但因为通道数暴涨计算量反而比第一层高出一个多数量级。显存占用要分两块看激活值和中间缓存。前向传播时每层的输出特征图都要保留下来供反向传播用占用大小是 batch × C × H × W × 4 字节float32。如果某个层输出是 1×256×56×56就是 3.2MBbatch 增到 32 就是 103MB。再加上反向传播保存的中间量实际显存占用通常是激活值的两三倍。这也是为什么通道数大的层在大 batch 下特别容易爆显存。层C_outH_out×W_out单样本激活batch32conv1_164224×22412.8 MB411 MBconv3_225656×563.2 MB103 MB1×1 降维后6456×560.8 MB26 MB看到这张表你就明白了为什么很多工程会在不影响精度的前提下把浅层通道数砍一砍或者在浅层就早点下采样。4.3 通道数设计的几条经验法则第一条法则是空间减半、通道翻倍。这个模式在 VGG、ResNet 里反复出现背后的逻辑是当池化把空间尺寸缩小一半后单个空间位置承载的信息密度上升需要通过增加通道数来维持整体信息容量大致不变。信息容量可以粗略理解成通道数乘以空间面积减半再翻倍正好抵消。第二条是首层通道数不要贪多。第一层卷积直接面对原始输入输入通道只有 3扩大输出通道数的边际收益不高但计算量随空间尺寸成正比代价很大。常见的做法是首层 32 或 64 通道配合 stride 2 或紧接一个下采样。第三条是深层的 3×3 卷积优先考虑 bottleneck 结构。当某一层的输入输出通道都在 256 以上时直接用 3×3 卷积的参数和计算量都很夸张用 1×1 降维到四分之一、3×3 处理、再 1×1 升回去是已经被反复验证过的高性价比方案。第四条是通道数取 8 的倍数对硬件友好。GPU 的矩阵运算在现代张量核心上按 8 或 16 个通道一组做对齐通道数取 8 的倍数能让利用率更高。这条不是理论要求是实测出来的工程经验32、64、96、128 这样的配置比 37、63 之类的数字跑起来更稳。注意别照着别人的模型直接把通道数搬过来。输入分辨率、batch size、精度要求不同最优通道配置会差很多。我的建议是先按经验值搭骨架再通过消融实验微调关键的几个层而不是一上来就精雕细琢。5. 不依赖框架手写多通道卷积把机制彻底钉死5.1 用 numpy 从零实现多输入多输出卷积想真正吃透通道机制最好的办法是自己写一遍。下面这段 numpy 实现支持任意 C_in、C_out、核尺寸、步长和填充逻辑非常直白import numpy as np def conv2d_multi(x, w, b, stride1, padding0): 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, C_in_w, kh, kw w.shape assert C_in C_in_w, 输入通道数与卷积核的输入通道数不一致 if padding 0: x np.pad(x, ((0, 0), (padding, padding), (padding, padding))) H, W x.shape[1], x.shape[2] H_out (H - kh) // stride 1 W_out (W - kw) // stride 1 out np.zeros((C_out, H_out, W_out), dtypex.dtype) for co in range(C_out): for i in range(H_out): for j in range(W_out): h0, w0 i * stride, j * stride window x[:, h0:h0 kh, w0:w0 kw] # (C_in, kh, kw) out[co, i, j] np.sum(window * w[co]) b[co] return out核心就三行取窗口、逐元素乘、跨通道求和。那个np.sum(window * w[co])之所以能一次性把 C_in、kh、kw 三个维度全加起来是因为 window 和 w[co] 的形状完全相同都是 (C_in, kh, kw)。这就是多输入通道卷积的公式在代码里的自然体现——求和范围自动覆盖了通道维和空间维。用第二节的手算数据跑一下这段代码输入转成 (3,3,3)权重转成 (2,3,2,2)偏置设为 [1,0]应该得到和手算完全一致的结果。这一步做完你对通道的直觉基本就建立起来了。5.2 与 PyTorch 结果对齐以及常见的实现陷阱写完之后一定要和框架对拍。做法很简单把同样的输入、权重、偏置分别喂给 numpy 实现和nn.Conv2d用np.allclose判断结果是否一致容差设 1e-5 就够。如果对不上逐项排查下面这几个点。第一个陷阱是权重维度顺序。numpy 里你可能会习惯性写成 (kh, kw, C_in, C_out)而 PyTorch 是 (C_out, C_in, kh, kw)。这个顺序差异在转换时如果不做transpose数值会完全错乱但形状可能还能跑通特别隐蔽。第二个陷阱是通道方向搞反。手写实现的输入如果是 (H, W, C) 格式比如直接用图像库读出来的数组必须先transpose(2,0,1)转成 (C, H, W)否则卷积会在错误的方向上滑动得到的结果看似合理其实全错。第三个陷阱是填充的处理。np.pad的填充顺序需要按维度对应写错会导致只在行方向填充或者填充了通道维。一个简单的自查方式是打印填充后的形状确认空间维度都增加了 2×padding。第四个陷阱是步长与输出尺寸的计算。公式(H - kh) // stride 1在 H-kh 不能被 stride 整除时会有取整误差和框架的向下取整行为一致但如果你写成了先加 padding 再减顺序搞错就会少一行。建议写完立刻用几组不同的输入尺寸验证一遍。提示对拍时最好构造随机输入和随机权重跑上十组不同尺寸的组合比只测一组固定数据靠谱得多。我在实际项目里就是靠这种随机对拍抓出过一个 padding 顺序写反的 bug肉眼完全看不出来。5.3 分组卷积与通道维度的另一种切法理解了标准的多输入多输出之后再看分组卷积就很容易了。设 groupsg做法是把输入通道平均分成 g 组输出通道也平均分成 g 组第 k 组输出只和第 k 组输入做卷积组与组之间不通信。nn.Conv2d(256, 256, 3, groups2)的参数量只有标准卷积的一半因为每个输出通道只看 128 个输入通道。深度可分离卷积是 g 等于输入通道数的极端情况先做逐通道卷积每个输入通道单独卷groupsC_in输出通道也是 C_in再用 1×1 卷积做逐点卷积混合通道。MobileNet 系列就是靠这个把计算量降下来的。参数量上标准 3×3 卷积是 C_in×C_out×9深度可分离是 C_in×9 C_in×C_out当 C_out 较大时差距接近 9 倍。分组卷积的代价是通道间的信息交流变少了。解决办法之一是在层与层之间加通道重排操作把通道顺序打乱让下一层的分组能拿到跨组的信息。这个思路在移动端网络里被广泛使用。回到 LeNet-5 的 C3 层它当年用的那套部分连接表其实就是手动设计的分组结构只是那时候还没有“分组卷积”这个统一的名字。6. 通道相关的报错排查与实战经验6.1 维度不匹配类报错速查表通道相关的报错几乎占了我日常调试时间的三分之一。整理成表遇到直接对号入座报错信息关键词典型根因排查动作expected input to have 3 channels, but got 1输入是灰度图首层 in_channels 写成了 3检查数据加载后的张量形状或把首层改成 1weight of size [64,3,3,3], expected input to have 3 channels首层输入通道与数据通道不匹配打印第一层输入形状确认是 1 还是 3mat1 and mat2 shapes cannot be multiplied全连接层输入维度与展平后的特征维度不符打印卷积输出的 C×H×W 乘积手动核对size mismatch for weight加载的预训练权重与当前模型通道配置不同对比 state_dict 里每层的形状Given groups2, weight of size ...分组数与通道数不整除确认输入输出通道都能被 groups 整除output size is too small通道没问题但空间尺寸被卷没了检查 padding 和步长的累积效果排查这类问题有一个通用套路在模型里注册前向钩子把每一层的输入输出形状打出来一眼就能看出是哪一层开始不对劲。def shape_hook(module, inp, out): print(f{module.__class__.__name__:12s} fin{tuple(inp[0].shape)} out{tuple(out.shape)}) model nn.Sequential( nn.Conv2d(3, 64, 3, padding1), nn.ReLU(), nn.MaxPool2d(2), nn.Conv2d(64, 128, 3, padding1), nn.ReLU(), nn.AdaptiveAvgPool2d(1), nn.Flatten(), nn.Linear(128, 10), ) for layer in model: layer.register_forward_hook(shape_hook) model(torch.randn(1, 3, 224, 224))跑一遍每一层的输入输出形状都清清楚楚比一行行 print 高效得多。这个习惯我建议从写第一个模型开始就养成能省大量时间。6.2 训练不收敛时先回头查通道配置通道配置错了不一定会报错有时候只是效果变差这种问题更难查。我遇到过几次典型情况。一次是把某个卷积层的输出通道从 256 误改成 25少了一个 6。模型照样能跑loss 也在降但验证集精度就是上不去。原因在于 25 这个数不是 8 的倍数GPU 利用率下降更关键的是后续层的输入通道数也跟着变成 25整条通道链路都偏了。后来我加了一段启动时打印每层通道数的校验代码把这类笔误彻底堵住。另一次是残差连接的两个分支通道数不一致。主分支经过若干卷积后通道变了但捷径分支还是原始通道相加时直接报错。解决办法是在捷径上加一个 1×1 卷积把通道对齐这也是 ResNet 里所谓的投影捷径。写残差结构时只要主分支改变了通道数或空间尺寸捷径就必须跟着调整这个规则记牢能省很多调试时间。还有一种隐蔽情况是通道数太少导致信息瓶颈。如果某个中间层把通道压得太狠比如从 512 直接压到 8后面再怎么加层也救不回来因为信息在那一步就丢掉了。这种瓶颈在做轻量化模型时很容易出现判断方法是看这一层的输出通道数是否显著小于前后两层如果是就要怀疑是不是压过头了。6.3 我在实际项目里踩过的坑与几条实操心得第一个心得改通道之前先算参数量。我养成了一个习惯动任何通道数之前先在纸上或者脚本里把改动前后的参数量算出来确认变化幅度在可接受范围内再动手。很多时候你会发现自己以为的“小改动”其实让参数量翻了三倍。第二个心得首层通道数用 8 的倍数。这条前面提过但值得再强调。40 和 32 在精度上可能差不多但 32 在 GPU 上的吞吐明显更好。这种优化不需要改算法改个数字就有收益。第三个心得做消融时一次只动一个通道。同时改好几个层的通道数最后效果变好了你也不知道是哪一处的功劳。我现在做通道相关的实验都是一层一层地扫虽然慢但结论可靠。第四个心得把通道信息写进日志。训练脚本启动时打印一次完整的通道配置表出问题时对比历史记录能快速定位是哪次改动引入的问题。这个做法看着笨但在多人协作的项目里非常管用。第五个心得也是我认为最重要的一条别把通道数当成玄学参数去乱试。它背后有明确的计算逻辑——输出通道决定表达容量输入通道决定信息来源两者的乘积决定参数规模。想清楚这三个关系通道设计就从“调参”变成了“算账”。我自己从“凭感觉填数字”转向“先算后填”之后模型迭代速度大概快了一倍不止因为无效尝试少了很多。至于 1×1 卷积我的态度是能用就用但别滥用。它在降维和跨通道融合上确实高效但如果每一层都塞一个 1×1网络深度会虚增训练时梯度传播路径变长收敛反而变慢。我的经验是在通道数超过 256 的瓶颈处、以及需要显式做通道混合的位置用它其他位置老老实实用 3×3 就好。最后分享一个我常用的验证小技巧。写完一个带多通道的模块后随手构造一个极端输入——比如 batch1、通道1、空间1×1 的张量喂进去看看能不能跑通。这个测试能一次性暴露出通道整除、padding 越界、空间尺寸算错这三类问题比等训练报错要快得多。这个习惯帮我省下的调试时间远比写测试代码本身花掉的时间多。