做图像模型做了这么久,上采样、下采样、通道融合这三个词,基本是每天都要碰面的老熟人了。很多刚入行的同学对着网络结构图发懵,看了半天看不出门道,其实大多数图像模型的结构骨架,拆到底就是这三件事的组合:先下采样把空间尺寸压小,把语义信息提纯;再上采样把分辨率恢复回来;中间用通道融合把不同来源、不同尺度的特征粘在一起。标题里那句 channel confusion,说得直白一点,就是通道跟通道之间怎么样混合、怎么样对齐、怎么样不搞乱的问题。
这篇东西,适合两类人看。一类是你正准备复现分割、检测、生成这类带编码-解码结构的网络,想看明白每一层张量到底怎么变的;另一类是你已经在跑模型了,但遇到特征图尺寸对不上、棋盘格伪影、小目标丢失这类怪问题,想找个排查思路。我会从原理讲到实操,把每个操作背后的尺寸变化、参数选择、常见坑都给捋一遍,最后放一段可以直接跑的小网络示例。
1. 为什么总把这三个操作放在一起理解
1.1 一次前向推理,信息经历了什么
一张图片输入网络,计算机看到的不是一张图,而是一个形状为 [N, C, H, W] 的数组:N 是批大小,C 是通道数,H 和 W 是高和宽。图像处理网络的绝大多数参数,都在变换这个数组。从宏观上看,变换只有三类:把 H、W 变小并把 C 变大,这是下采样;把 H、W 变大并把 C 变小,这是上采样;让不同来源的通道重新组合排序、加权、混合,这是通道融合。
自编码器就是一个非常典型的组合。编码器一路走下来,图片从 224×224×3 变成 7×7×512,空间信息被大幅压缩,每个通道对应的"语义"却越来越丰富;解码器再做镜像操作,一路把 7×7×512 恢复成 224×224×3。中间那些跳跃连接、注意力机制,本质都是在做通道融合。把这三件事看成同一个信息流转流程里的三个齿轮,比孤立地去背某个函数更有用。
1.2 三个操作各自的角色分工
下采样解决的是"看得广"的问题。图片变小之后,每个卷积核覆盖的范围在原始图片上对应更大的区域,感受野变大,网络能够从局部纹理逐步抽象出全局结构。代价是空间细节被压缩,小目标可能直接被"压没"。
上采样解决的是"看得细"的问题。分割任务要把每个像素分类,检测任务要把框画在原图尺度上,所以特征图必须放大回原始分辨率。但放大的过程不会凭空创造信息,它只是把已经压缩的语义特征映射回较大空间,细节的恢复程度取决于之前下采样时留了多少底子。
通道融合解决的是"怎么把信息用好"的问题。不同尺度的特征各怀绝技:浅层的特征空间精度高、语义弱,深层的特征语义强、空间精度差。把它们简单拼在一起还是直接相加,或者按权重自适应组合,直接影响了信息能不能真正协同。很多模型精度差,不是卷积不够多,而是融合这一步没有做好。
2. 下采样:把图片缩小没那么简单
2.1 主流下采样手段和它们的性格差异
下采样最常见的手段有三种:最大池化、平均池化、带步长卷积。它们都做同一件事,行为习惯完全不同。
最大池化的做法是取窗口内的最大值,公式非常简单:out = floor((in - kernel) / stride) + 1,窗口为 2×2、步长为 2 时,特征图尺寸直接减半。它的脾气是"只对最强响应敏感",一个窗口里只要有一个突出的边缘或纹理,它就把那个值保留下来,所以对局部突变比较友好,具备一定的平移不变性。代价是窗口内其他三个位置的信息全部丢弃,细粒度细节流失比较严重。
平均池化的做法是取窗口内的均值,把整个区域的信息都揉在一起,平滑效果更好,适合从全局特征中提取背景信息,但对突出目标不够敏感。在分类网络末期,经常能看到一个全局平均池化,把每个通道的 H×W 压成一个数,再送入全连接层,这就是平均池化在顶层的经典用法。
带步长卷积是现在用得越来越多的方案,卷积核照常滑动,步长设为 2,输出尺寸自然减半。它和池化的最大区别在于:池化的规则是写死的,卷积的下采样规则是学出来的。网络可以自动决定在哪些位置保留信息、丢掉哪些信息,灵活性高很多。所以生成网络、分割网络里,stride=2 的卷积基本属于标配。
2.2 实际网络里典型的下采样节奏
把一个常见分类骨干网络的前几层摊开看,下采样的节奏差不多是这样:
输入 224×224×3,第一层卷积后变成 112×112×64,这就是第一次下采样,通常发生在 stride=2 的卷积或者紧随其后的池化层;第二次下采样到 56×56×128;第三次到 28×28×256;第四次到 14×14×512。每次空间尺寸减半,通道数就翻倍。
这个"空间减半、通道翻倍"不是随便定的。一方面,通道数增加意味着网络有更多参数去表达当前尺度的信息;另一方面,空间变小后计算量会下降到原来的四分之一,即使通道数翻倍,整体计算量也只是小幅增加。用信息论的语言说,就是通道数的增长弥补了空间分辨率的损失,尽量不让信息容量出现断崖。
我自己的习惯是,在动手写网络之前先把整条下采样路径的张量尺寸算一遍,算到最小分辨率为止。很多 bug 都出在尺寸推算上:某个层忘记设置步长、padding 算错,到了后面全连接层维度对不上,报错信息只提示 mismatch,根本不知道是哪里漏了。
2.3 下采样踩过的坑
第一个坑是下采样步子迈得太大。某些模型为了追求快速扩大感受野,连续使用 stride=2 的卷积,特征图从 224 直接压到 28 甚至 14。对于分类任务影响不大,对分割和检测任务就是灾难,因为小目标在深层的特征图里可能只剩下一个像素,上采样根本还原不出来。之前我做一个目标检测的小项目,把骨干网络的第二个 stage 从 stride=2 改成 stride=1,小目标的检测效果明显改善,代价是计算量和显存涨了差不多 20%。
第二个坑是过度依赖最大池化。最大池化虽然好写,但它对位置信息不敏感,而分割任务对位置极其敏感。同样一个 2×2 最大池化,窗口内最有辨识度的特征被保留,但它的精确坐标信息丢了。所以分割网络里,要么用带步长卷积做下采样,要么把池化步长设为 1、配合空洞卷积来维持分辨率。
第三个坑是下采样时忘记考虑特征图尺寸是否能被整除。stride=2 的卷积在输入尺寸是奇数时,输出尺寸会带上取整行为,连续下采样之后尺寸推算容易出偏差。所以写代码的时候,尺寸公式要手算一遍,不要只在心里过。
3. 上采样:找回分辨率的手段与门道
3.1 插值类上采样:最稳的选择
上采样最简单的实现是插值:把相邻像素的值按某种规则填充到放大后的位置上。最邻近插值直接把原像素复制到周围,速度快,但容易出现锯齿;双线性插值根据周围四个像素做加权平均,过渡更平滑,是现在最常用的插值方式。
在 PyTorch 里,一行代码就能完成:
import torch.nn.functional as F # scale_factor 指定放大倍数 x = F.interpolate(x, scale_factor=2, mode='bilinear', align_corners=False)align_corners这个参数值得多说一句。align_corners=True时,原始图像的四个角像素和新图像的四个角像素完全对齐,插值坐标按照像素中心距离计算;align_corners=False时,像素坐标按照宽度和高度等比映射,并且把像素当成一个矩形面积来处理,更符合图像的连续性。做图像分割、检测时,我一般统一用align_corners=False,因为它在放大后不会在边缘产生偏移感。
插值上采样最大的优点是没有参数,不会引入额外的训练负担,也不会产生伪影。缺点是它只是一个几何操作,没有可学习的部分,不会根据语义信息来猜测高频细节。所以在实际网络中,插值之后通常还会接一个 3×3 或 1×1 卷积,让网络在放大后的特征图上继续加工。
3.2 转置卷积:需要小心伺候的放大工具
转置卷积是另外一种常见上采样方式,很多人把它叫反卷积,其实这个叫法不准确。它并不逆转卷积过程,而是把输入特征图的每个像素,按照卷积核的权重扩散到输出图的一个区域,扩散产生的区域之间会有重叠,重叠部分做累加。
转置卷积的输出尺寸公式是:
out = (in - 1) * stride - 2 * padding + kernel_size
如果你想用转置卷积做两倍上采样,最常用的参数组合是kernel_size=4, stride=2, padding=1。代入公式算一下:out = (in - 1) * 2 - 2 + 4 = 2 * in,正好翻倍。这个组合几乎是设计网络时的惯性选择,很多论文里的上采样层用的就是这套参数。
但转置卷积有一个臭名昭著的问题:棋盘格伪影。当kernel_size不能被stride整除时,输出图上每个位置的累加次数不一样,有的位置叠加了四次,有的位置只叠加了一次,结果就是放大后的特征图上出现明暗相间的棋盘格子。解决思路一般有三个方向:一是改用kernel_size=4, stride=2, padding=1这样的倍率友好组合,二是先插值放大再卷积,三是用像素重排 PixelShuffle。
像素重排是超分辨率任务里比较常见的上采样方式,它的思路是把通道拆开重排。输入形状是[N, C*r^2, H, W],重排后变成[N, C, r*H, r*W]。也就是说,与其用一个转置卷积把值"扩散"出去,不如让网络自己先在通道维度上生成 r² 组特征图,再把它们按照位置交错排成大图。这种方式能有效避免棋盘格,因为它每个位置的信息来源是确定且均匀的。
3.3 上采样之后的通道调整
无论用哪种方式上采样,放大后的特征图通道数通常不能直接满足下一层要求。解码器里常见的流程是:先做 1×1 卷积调整通道数,再上采样到目标空间尺寸,再将上采样结果和跳跃连接的浅层特征做融合。
顺序上的细节值得注意。如果先上采样再 1×1 卷积,那么 1×1 卷积会在更大的特征图上计算,计算量增加,但每个位置都能独立学习权重;如果先 1×1 再上采样,计算量小,但最终融合的时候,特征图的空间位置没有经过卷积调整,容易出现拼接痕迹。我个人的习惯是:上采样之前先把通道降下来,上采样之后再接一个轻量卷积去平滑特征。这个模式在语义分割里很常见,先减少通道避免后层计算量爆炸,再用少量卷积恢复表达能力。
4. 通道融合:让信息真正协同工作
4.1 相加与拼接的区别
通道融合最常见的两种基础操作,是逐元素相加(add)和通道拼接(concat)。这两个操作表面上看只是一个运算符的区别,其实决定了信息融合的方式完全不同。
相加操作要求两个输入形状完全相同,输出通道数不变,结果就是两个特征图的同一个位置直接累加。这种方式计算成本低,而且强制两个分支在同一空间位置上共享信息,相当于让网络在两者之间做隐式投票。残差网络里的"残差相加"就是这种思路:输入和经过卷积的特征逐位相加,缓解了梯度消失。
拼接操作是把两个特征图沿通道维度叠起来,输出通道数是两者之和。它不像相加那样强制混合,而是把两个分支的通道原封不动地保留,让后续卷积自己去学怎么组合。这种方式信息损失最小,尤其适合高低层特征融合的场景,因为浅层和深层的语义差异太大,贸然相加等于把两个不同口径的数据强行求和,很容易互相干扰。
我在实际项目中体会到一个经验法则:两个分支语义距离很近,比如同一层经过两个不同卷积分支的输出,用相加;两个分支语义差距很大,比如编码器的浅层特征和解码器的深层特征,用拼接。如果拿不定主意,先看你想让网络"自己决定权重"还是"你自己设定一个简单的权重",前者用 concat,后者用 add。
4.2 注意力式加权融合
相加和拼接都是固定规则,还有一种更高级的通道融合方式:自适应加权。最有代表性的就是通道注意力,比如 SE Block。它的流程是:对每个通道的特征图做全局平均池化,得到一个通道描述向量;然后经过两个全连接层做信息压缩再恢复,最后通过 sigmoid 生成 0 到 1 之间的权重,把这个权重乘以原来的特征图。
写成一个最小模块大概是这样:
import torch import torch.nn as nn class SEBlock(nn.Module): def __init__(self, channels, reduction=16): super().__init__() self.pool = nn.AdaptiveAvgPool2d(1) self.fc = nn.Sequential( nn.Linear(channels, channels // reduction), nn.ReLU(inplace=True), nn.Linear(channels // reduction, channels), nn.Sigmoid() ) def forward(self, x): b, c, _, _ = x.size() w = self.pool(x).view(b, c) w = self.fc(w).view(b, c, 1, 1) return x * w这里面的关键思路是,"哪些通道重要"这个问题不应该由人拍脑袋决定,应该让网络自己根据当前输入动态调整。SE 这类机制虽然增加了一点参数量,但换来的是通道之间的竞争性选择,性能提升往往很明显。在做多尺度特征融合的时候,也可以把类似机制用在不同尺度的分支上,给每个分支学一个融合权重,效果比直接相加稳定得多。
4.3 跨尺度融合的经典模式
说到通道融合,不能不提 FPN 和 U-Net 这两种经典结构,它们是把不同尺度特征拼在一起的标准样板。
FPN 采用自顶向下路径:高层特征先上采样到上一层尺寸,然后与低层特征相加。因为高层特征语义强但分辨率低,低层特征空间信息丰富但语义弱,相加之后低层特征获得了高层语义的"指导",高层特征获得了低层空间细节的"补充"。相加之后通常再接一个 3×3 卷积,用于消除上采样带来的混叠效应。
U-Net 的思路不太一样,它的跳跃连接直接把编码器的浅层特征和上采样后的深层特征拼接在一起。拼接之后通道数翻倍,所以要在拼接后接卷积把通道压回去,再继续后面的上采样。U-Net 用拼接而不是相加,我自己的理解是,编码器和解码器两个路径之间的信息差异比 FPN 的层间差异更大,拼接比相加更尊重两份特征的独立性,等后续卷积再统一融合。
有很多人把 channel confusion 理解为"通道搞混了",其实译作通道融合更准确。我在实际调模型时,发现通道数对不上是拼接操作最常见的报错原因:高层特征 256 通道,低层特征 512 通道,直接 concat 必然报错。解法统一是先做 1×1 卷积把其中一个降到相同通道数,再拼接。这个操作几乎成了跨尺度融合的前置标配。
5. 把三者串起来:一个可直接实验的编码-解码小网络
5.1 最小可运行结构
理论说了一大堆,不如直接给一个能跑的玩具网络。这段代码是一个典型的编码-解码结构,包含了本节要讲的所有核心操作:下采样用带步长卷积、上采样用双线性插值加卷积、融合用拼接。
import torch import torch.nn as nn import torch.nn.functional as F class ToyEncoderDecoder(nn.Module): def __init__(self): super().__init__() # 编码器:三次下采样,通道翻倍 self.enc1 = nn.Sequential( nn.Conv2d(3, 16, 3, padding=1), nn.BatchNorm2d(16), nn.ReLU(inplace=True) ) self.down1 = nn.Conv2d(16, 32, 3, stride=2, padding=1) # H,W 减半 self.down2 = nn.Conv2d(32, 64, 3, stride=2, padding=1) # H,W 再减半 self.down3 = nn.Conv2d(64, 128, 3, stride=2, padding=1) # H,W 三减半 # 解码器:先 1x1 降通道,再上采样,再拼接 self.up1 = nn.Conv2d(128, 64, 1) self.up2 = nn.Conv2d(64, 32, 1) self.up3 = nn.Conv2d(32, 16, 1) self.fuse1 = nn.Conv2d(64 + 128, 64, 3, padding=1) self.fuse2 = nn.Conv2d(32 + 64, 32, 3, padding=1) self.fuse3 = nn.Conv2d(16 + 32, 16, 3, padding=1) self.head = nn.Conv2d(16, 1, 1) def forward(self, x): e1 = self.enc1(x) # 224x224x16 e2 = self.down1(e1) # 112x112x32 e3 = self.down2(e2) # 56x56x64 e4 = self.down3(e3) # 28x28x128 d3 = self.up1(e4) # 28x28x64(通道调整,未上采样) d3 = F.interpolate(d3, scale_factor=2, mode='bilinear', align_corners=False) d3 = torch.cat([d3, e3], dim=1) # 56x56x(64+64)=56x56x128 d3 = self.fuse1(d3) # 56x56x64 d2 = self.up2(d3) d2 = F.interpolate(d2, scale_factor=2, mode='bilinear', align_corners=False) d2 = torch.cat([d2, e2], dim=1) # 112x112x(64+32)=112x112x96 d2 = self.fuse2(d2) # 112x112x32 d1 = self.up3(d2) d1 = F.interpolate(d1, scale_factor=2, mode='bilinear', align_corners=False) d1 = torch.cat([d1, e1], dim=1) # 224x224x(32+16)=224x224x48 d1 = self.fuse3(d1) # 224x224x16 out = self.head(d1) return out # 测试一下尺寸变化 if __name__ == '__main__': model = ToyEncoderDecoder() y = model(torch.randn(1, 3, 224, 224)) print(y.shape) # torch.Size([1, 1, 224, 224])这段代码故意写得非常朴素,没有花哨操作,但包含了完整的下采样、上采样、通道融合链路。你可以在自己的机器上直接跑,把每一层的 shape 打印出来,观察张量是怎么一路变化的。把这段代码读懂,再去看 U-Net、FPN 这些正经网络,阻力会小很多。
5.2 通道数的设计逻辑
上面代码里的通道数 16 -> 32 -> 64 -> 128,对应的是一次次的翻倍。为什么下采样通道要翻倍而不是保持不变?核心原因是信息容量。当特征图空间尺寸减半时,可以承载位置信息的"格子"变少了,如果通道数不增加,网络就得把大量信息硬塞进更小的空间里,这会产生严重的信息瓶颈。通道翻倍恰恰补上了这个缺口。
解码器侧的通道数设计则讲究对称递降。上采样之后特征图变大,如果仍然保持大通道数,计算量和显存会被迅速吃满。常见的做法就是,先用 1×1 卷积把通道降到合适的大小,再上采样。这样,上采样后拼接成的通道数虽然会暂时增加,但经过 fuse 卷积后又会降回来,维持在可控范围。
5.3 计算量粗估与显存注意事项
假设输入是 1 张 224×224 的 RGB 图,经过编码器三次下采样后,特征图变成 28×28×128,相比原始输入,空间点数减少了 64 倍。虽然通道数从 3 增长到 128,总体数据量仍然比输入低。真正的显存大头,往往出现在解码器上采样之后、拼接那一刻:特征图恢复到 224×224,叠加浅层特征后通道数瞬间上涨。
这给工程实践提了个醒:不要在上采样阶段堆太多通道数,否则一个 batch 下去,显存直接告急。我在调网络时一般遵循两条铁律:第一,每个上采样之前先降通道;第二,拼接之后立刻卷积压通道,绝不让大分辨率高通道的特征图在网络里停留太久。这两条规则能挡住大部分显存溢出问题。
5.4 跳跃连接的边界问题
拼接操作还有一个容易忽略的细节:编码器特征和上采样特征的边界是否对齐。当一个特征图经过 2 倍上采样后,如果原尺寸是奇数,放大后的尺寸和浅层特征可能相差一个像素,直接拼接会报错或者出现错位。
解决办法通常是在上采样时手动指定输出尺寸,而不是用 scale_factor。比如F.interpolate(d3, size=(e3.shape[2], e3.shape[3]), mode='bilinear', align_corners=False),直接用浅层特征的尺寸作为目标,保证两个特征图严格对齐。这种做法在输入尺寸不固定的场景下几乎是刚需。
6. 常见问题速查与排查经验
6.1 问题、原因、处理方向速查表
下面这张表是我自己排查问题时经常对照的清单,覆盖了大多数和上下采样、通道融合相关的经典问题:
| 问题现象 | 可能原因 | 处理方向 |
|---|---|---|
| 特征图尺寸 mismatch 报错 | 上采样后的尺寸和拼接目标尺寸差了一个像素 | 改用size=参数而不是scale_factor=,对齐目标特征图尺寸 |
| 输出图像有棋盘格状伪影 | 转置卷积的核大小与步长不匹配,累加次数不均匀 | 改用kernel=4, stride=2, padding=1,或换插值+卷积方案 |
| 分割结果中细小目标大面积丢失 | 下采样过度,小目标在深层特征图中只占几个像素 | 减少下采样次数,或在高分辨率分支停留更久 |
| 拼接后通道数太多,显存暴涨 | 浅层特征和上采样特征都保持了大通道数 | 上采样前用 1×1 卷积先降通道,拼接后立刻卷积压通道 |
| 上采样后的特征看起来很模糊 | 插值上采样本身没有学习能力,缺少细节重建层 | 插值之后接卷积,或使用 PixelShuffle 这类可学习上采样 |
| 不同分支相加时训练不稳定 | 两个分支的数值量级差异大,直接相加导致梯度受干扰 | 相加前分别做归一化,或改用拼接让网络自己学权重 |
| 通道注意力加在拼接前没有效果 | 注意力机制作用的通道维度,和拼接后通道域语义不匹配 | 注意力尽量放在融合后的特征上,或分别对各分支做注意力再融合 |
6.2 一个典型排查流程实录
拿"上采样后特征模糊"这个问题为例,我之前的排查流程大概是这样:先确认是插值的问题还是后续卷积的问题。最简单的方法是画特征图,把上采样前后的特征图可视化出来,观察是整体模糊还是局部出现异常重影。整体模糊多半是上采样后的卷积不够深,信息加工不够;局部重影多半是 align_corners 设置问题,导致坐标偏移。
排查完我还发现一个经常被忽略的点:batch normalization 的统计量。解码器在训练早期,特征分布波动很大,如果上采样之后直接接带 BN 的卷积,BN 的均值方差估计不稳定,会放大模糊感。这种情况下可以先冻结 BN,或者换成 GroupNorm 试一遍。这个技巧在训练小 batch 的时候尤其有效。
另一个值得分享的经验是:做上采样之前先检查一下特征图的数值范围。如果不同层特征取值范围差异很大,比如一个层经过 ReLU 后是 0 到正无穷,另一个层经过 sigmoid 后是 0 到 1,直接相加或拼接都容易出问题。我习惯在每个分支最后做一次归一化,保证进入融合阶段前数值范围大致统一,这一步对训练稳定性的帮助比想象中大很多。
6.3 一些可以长期使用的习惯
动笔写网络之前,先把整体尺寸变化表画出来。标注清楚每一层:输入尺寸、经过什么操作、输出尺寸、通道数。这一步能过滤掉至少一半的形状不匹配报错。
改网络时,一次只改一个变量。比如你想验证转置卷积换成插值对效果的影响,那就只改上采样方式,其他全部保持不动。否则模型效果变了,你根本分不清是哪个改动起了作用。
复现别人的网络结构时,一定要自己手算一遍下采样和上采样的尺寸变化,不要完全信任网上搬运的结构图。结构图经常省掉 padding 和 stride 的细节,而 pyTorch 的torchsummary、model.summary()这类工具打印出来的各层输出尺寸,是核对结构的最好工具。
这三四个习惯,能让很多看似玄学的训练问题在前面就被拦下来,根本走不到调参那一步。
我个人实际用下来,最深的体会就是:上采样、下采样和通道融合这三件事,永远要放在一起设计,不能拆开单独调。下采样如果把空间信息耗得太狠,再好的上采样也没办法无中生有;通道融合如果只是机械拼接,不思考两路特征在语义上是否匹配,再深的网络也是在各自为政。先想清楚信息该往哪走、哪层该保留多少分辨率、融合时哪种策略更匹配当前任务,再动手写代码,比堆一堆花哨模块管用得多。