ARTICLE · INTELLIGENCE

战地情报 · 详情页

来自尧图项目组的一线实战观察与深度解析

Swin Transformer全解析:从Patch Merging到移位窗口的视觉主干设计

Swin Transformer全解析:从Patch Merging到移位窗口的视觉主干设计

之前有一阵子我一直在琢磨一件事:ViT出来以后,图像分类确实被打通了,可一旦把它接到检测、分割这类密集预测任务上,总觉得哪里别扭。同样是主干网络,ResNet的脖子和头用得很顺,ViT反而有种“有力气使不出来”的感觉。

后来我把Swin Transformer这篇2021年初的工作从头到尾过了一遍,才意识到问题不在Transformer本身,而在于ViT那种“一路保持单分辨率、不做金字塔”的设计,跟CV下游任务的需求压根不对齐。Swin Transformer用移位窗口和分层结构,把Transformer做成了CNN一样具备多尺度特征图的主干,正好补齐了这块拼图。

这篇博文我会从动机、架构、核心机制到代码实现细节,把Swin Transformer拆开讲清楚,尤其会重点说patch merging是怎么一步步把分辨率降下来、通道数提上去,最终输出一组多尺度特征图的。适合正在读论文、准备复现实验、或者想把Transformer主干接进检测分割项目的朋友参考。

1. ViT的天花板:为什么整图注意力在CV任务上“使不出劲”

1.1 ViT把CV问题简化成了“大块头分类问题”

ViT的思路很直接:把一张224×224的图像切成16×16的patch,每个patch线性投影成一个token,然后像处理句子一样,让所有token在全序列长度上做全局自注意力。

这个做法在ImageNet分类上表现很好,但它隐含了一个前提:你只关心“这张图是什么”,不关心“东西在哪、边界在哪、每个像素属于谁”。换句话说,ViT设计的时候就没把空间分辨率当回事,它把空间信息压缩进了patch embedding,然后在token层面做全局关系建模。

问题恰恰出在这。检测、分割这类任务需要像素级的定位能力,需要在多个尺度上观察目标。小目标可能只有十几个像素,大目标能占满半个画面。ViT从头到尾都是14×14或者16×16的token网格,相当于只有一层尺度,没有“由细到粗再回到细”的过程,下游任务拿到这张特征图,既缺细节,也缺语义层次。

1.2 全局注意力的计算复杂度是一堵墙

再往深一层说,即便ViT想做多尺度,全局自注意力本身就扛不住高分辨率输入。

计算一下标准多头自注意力的复杂度就知道了。输入token数L,特征维C,复杂度大概是:

O(L²C + LC²)

如果输入是224×224,patch是4×4,那L就是56×56=3136。平方一下,约千万级别的计算量,已经有点吃力了。如果输入换成1024×1024,L变成256×256=65536,L²直接到43亿,这谁顶得住?

所以Swin的思路很务实:既然全局注意力算不动,那就别硬算。把注意力限制在局部窗口里,窗口之间再通过移位机制交换信息。这样既保留了Transformer建模长程依赖的能力,又把复杂度从O(L²)降到了跟token数线性相关的水平。

1.3 CNN的“分层”其实是CV任务最朴素的先验

还有一个点很关键:CNN的多尺度结构不是偶然的,它是被验证了几十年、极其有效的视觉先验。浅层抓边缘纹理,中层抓部件,深层抓整个物体,这本身就是人类视觉系统的工作方式。FPN、UNet、各类检测分割框架,全都建立在“多尺度特征图”这个基础上。

Swin Transformer的高明之处,就是没有跟这个先验对着干。它没有试图用Transformer替代整个CNN,而是把Transformer塞进了CNN的分层骨架里。每个stage内部是局部窗口注意力,stage之间用patch merging降采样,输出的特征图天然就是FPN需要的多尺度金字塔。

这个设计取舍,决定了后来Swin能在检测分割任务上全面压制同体量的CNN主干,也成为它跟ViT系列最大的分水岭。

2. 数据流全解:patch merging如何一步步打造多尺度金字塔

2.1 第一步:4×4 patch切分,先把分辨率降四倍

Swin的输入处理跟ViT就不一样。ViT一般用16×16的patch,Swin用4×4。这个选择不是拍脑袋,而是为了保留更多空间细节,给后续逐级降采样留出余地。

具体流程是:输入图像H×W×3,切分成(H/4)×(W/4)个patch,每个patch是4×4×3=48维,经过一个线性embedding层投影到C维。论文里Swin-T的C=96,所以经过Patch Partition + Linear Embedding之后,得到的是(H/4)×(W/4)×96的特征图。

这里有个容易忽略的细节:Patch Partition在代码里通常直接用卷积实现。一个kernel_size=4, stride=4的卷积,输出通道设为C,一步就能完成“切patch + 线性投影”两件事。这也是PyTorch官方实现的标准做法。很多人初读论文时以为要先切patch再做embedding,实际上一个卷积全搞定了。

2.2 Stage 1:保持分辨率,做局部Transformer建模

拿到(H/4)×(W/4)×96的特征图,进入Stage 1。Stage 1由若干个Swin Transformer Block组成,Swin-T在这里放了2个,Swin-B放了2个。

这个阶段的关键是:分辨率不再下降,通道数也保持不变,纯粹做特征建模。每个block内部是窗口注意力 + MLP,窗口大小M默认是7。以56×56的特征图为例,窗口数就是(56/7)²=64个,每个窗口里7×7=49个token。

到此为止,Swin还只是在“第一层尺度”上做文章。真正让它具备金字塔能力的,是后面的patch merging。

2.3 Patch Merging:间隔采样 + 通道拼接 + 线性映射

Stage 2、3、4的入口,都是Patch Merging。它是Swin的分层关键,作用是把空间分辨率降一半、通道数翻一倍,完全对标CNN里的stride-2下采样。

具体做法分三步:

  1. 对输入特征图做间隔采样。取位置(x, y),如果x和y都是偶数,就单独抽出来一组;x偶y奇抽一组;x奇y偶抽一组;x和y都奇数再抽一组。这四组分别对应“左上、右上、左下、右下”的像素,每组形状都是(H/2)×(W/2)×C。
  2. 把四组特征在通道维度上拼接,得到(H/2)×(W/2)×4C。
  3. 通过一个线性层映射回2C维,再经过LayerNorm。

经过这三步,输入是56×56×96,输出就变成28×28×192。分辨率减半、通道翻倍,信息没有丢,只是重新组织了一下。

这里我提醒一句:这个“间隔采样”在代码里不是用循环做的,而是先用一个reshape把特征图拆成四个方向上的offset,再直接concatenate。如果你自己复现,别写成四层for循环,效率差太远。

2.4 Stage 2/3/4:分辨率逐级减半,通道逐级翻倍

每个Stage的结构都是“Patch Merging + 若干Swin Block”:

  • Stage 2:先合并到14×14×384(以Swin-T为例),再经过2个block
  • Stage 3:先合并到7×7×768,再经过18个block——这是Swin-T最深的stage,计算主力都在这
  • Stage 4:这里有个细节:Stage 4之前还会再做一次Patch Merging,把特征图压到7×7? 等一下,我重新算一下。

以224×224输入、C=96的Swin-T为例:

  • Patch Partition后:56×56×96
  • Stage 1(不改分辨率):56×56×96
  • 第一个Patch Merging:28×28×192
  • Stage 2:28×28×192
  • 第二个Patch Merging:14×14×384
  • Stage 3:14×14×384
  • 第三个Patch Merging:7×7×768
  • Stage 4:7×7×768

所以Stage 4的输出是7×7×768,分辨率从224一路降到7,刚好是32倍下采样,跟ResNet最后一级输出的下采样率完全一致。而中间每一级都会留一份特征图出来,这就是现成的多尺度特征金字塔:

阶段分辨率(224输入)通道数(Swin-T)下采样倍数
Stage 156×56964×
Stage 228×281928×
Stage 314×1438416×
Stage 47×776832×

这组特征往FPN里一塞,检测分割直接拿来用。这也是Swin能无缝对接检测框架的根本原因——它的输出格式本来就是多尺度的,不需要像ViT那样先把14×14的token网格拉直,再强行reshape回一层特征图。

3. 移位窗口注意力:把全局注意力关进窗口,再让窗口自己串门

3.1 窗口注意力的复杂度优势是怎么来的

标准多头自注意力里,每个token都要跟全图所有token做attention。Swin把特征图切成M×M的窗口,窗口内部做自注意力。

以Swin-T的Stage 1为例:56×56的特征图,切成64个7×7窗口。每个窗口内部49个token做自注意力,计算量是64×49²=153K量级。如果做全局注意力,是3136²≈9830K,差了64倍。

公式化一点,标准注意力复杂度是O(L²C),窗口注意力是O(WM²C)=O((L/M²)·M²·C)=O(LC)。注意L在指数上变成了1次方,这是从“二次复杂度”到“线性复杂度”的本质转变。分辨率越大,这种省下来的算力越可观。

3.2 固定窗口的缺陷:信息被框死在局部

但代价也很明显:窗口之间没有交互。左上角的窗口永远看不到右上角窗口里的内容,整个特征图被切成了互不通信的小块。

直白点说,纯窗口注意力只能建模“局部关系”,跟卷积的感受野拉不开差距,这不就失去了用Transformer的意义吗?

Swin的解法是:奇数层用规则窗口(W-MSA),偶数层把窗口整体往右下方向偏移半个窗口大小(SW-MSA)。两次交替之后,原本处于不同窗口区域的token,在新的划分方式下会被分到同一个窗口,彼此之间就能直接做注意力了。

打个比方:第1层里,a和b隔了一个窗口,谁也看不见谁。第2层窗口往右下方挪了半格,a和b可能就被分进同一个新窗口。它们的特征第一次产生了直接交互。逐层堆叠下去,信息就能跨窗口一步步传递,最终覆盖全局。

3.3 循环移位和掩码:高效实现的关键

移位窗口听上去简单,实现起来有个麻烦:如果窗口整体偏移了半个M,特征图边缘会多出一圈,窗口数量会从规则的4个变成9个,而且窗口尺寸不一致,没法直接批量计算。

Swin用的是循环移位(cyclic shift)来处理这个问题。把特征图沿左上方向把多出来的部分裁剪下来,拼到另一侧去,让特征图重新变成一个可以被规则窗口切分的形状。这样窗口数量保持不变,尺寸也一致,可以并行计算。

但这么一挪,有些窗口内部混入了原本不相邻的区域。比如一个窗口里,左上角那块是原图的右下角,右下角那块是原图的左上角,它们物理距离很远,本来不应该做注意力。

Swin为每个窗口生成一个掩码(attention mask),把不属于同一物理区域的token对在计算attention score时直接加一个很大的负数,softmax之后贡献归零。

很多人读论文时觉得“移位窗口”好理解,一到掩码就头疼。其实核心只有一句话:窗口位置变了,但每个token知道自己原本属于哪个物理区域;注意力只允许在同一个物理区域内发生。

3.4 为什么说“交替使用”是灵魂

我刚才讲的W-MSA和SW-MSA交替,不是可有可无的锦上添花,它是整个Swin架构的骨架逻辑。

如果只有W-MSA,等于用一堆互不通信的局部Transformer块做特征提取,效果大概率不如同规模的CNN,因为卷积至少还有3×3邻域的重叠感受野。

如果只有SW-MSA,那每次都要循环移位和掩码计算,效率低,而且失去了局部归纳偏置的稳定性。

交替使用的好处是:第奇数次是规则窗口,计算高效;第偶数次是移位窗口,促成跨窗口信息流动。两个block各司其职,合在一起才是一个完整的“局部建模 + 全局通信”单元。这也是Swin Block数量在Swin-T里是2的倍数(2、2、18、2)的原因——为了保持这种成对的结构。

4. 相对位置偏置:Swin精度上涨的隐形功臣

4.1 绝对位置编码在窗口注意力里的水土不服

ViT用的是一个可学习的绝对位置编码向量,加到patch embedding上。Swin不能直接照搬,原因在于窗口注意力:窗口在图中不断移动,同一个窗口内token的绝对位置是变化的,共享同一套注意力参数时,绝对位置带来的偏差会干扰原本应该纯粹由内容决定的attention_score。

Swin选择的是相对位置偏置(relative position bias)。每个注意力头在计算attention时,会在query和key的点积结果上,加一个只取决于两者相对位置的偏置项。

Attention(Q, K, V) = SoftMax(QKᵀ / √d + B)V

其中B就是相对位置偏置。

4.2 偏置表的形状为什么是(2M−1)×(2M−1)

窗口大小M=7,窗口内任意两个token的坐标差范围是−6到+6。两个方向的差值各自有13种可能(−6到+6),因此所有可能的相对位置组合是13×13=169。

这就是论文里那个偏置表B的维度(2M−1)×(2M−1)的来历。Swin-T在Stage 1用M=7,所以每层每个注意力头对应一个13×13的可学习矩阵。

实现时有个细节:偏置表的索引不是两两坐标差的绝对值,而是带方向的坐标差。一个token在另一个的右上角和它在另一个的左下角,相对位置偏置是不一样的。这让模型能感知方向性,对物体朝向、几何结构的建模有明显帮助。

很多复现代码里会先算出一个“相对位置索引表”,形状是M²×M²,每个元素是二维坐标差。然后把这个索引表转成一维的扁平索引,去查B表。这一步在代码实现里特别容易出错——坐标差做算术运算后要确保映射到非负区间,而且索引顺序要跟attention score矩阵的行列对应。

给你一个简单的验证方法:写代码的时候,把B表设成可学习的并打印出来看看有没有梯度更新。如果B表在训练中一直是随机初始值,八成是索引查表写错了。

4.3 共享与复用的哲学

还有一点值得提:Swin的位置偏置表不是每层各自独立的,而是每层内部所有注意力头共享一个刚查出来的相对位置索引表,但偏置数值B不同。这意味着同一层里,不同窗口可以复用同一套相对位置关系——不管窗口在图中的哪个位置,“左移一格、上移一格”的相对关系都一样。

这个设计非常优雅。它让模型学到了平移不变的相对位置先验,又保留了不同层、不同头各自微调位置语义的自由度。论文里专门做了消融:去掉相对位置偏置,精度掉一大截;换成绝对位置编码,精度也不如带偏置的版本。这个不起眼的B表,实际是Swin精度能追平甚至反超CNN的关键因素之一。

5. 三个关键的工程细节:看起来小,错起来大

这一节不讲概念,专门讲我复现和实践过程中踩过的、以及观察别人踩过的几个细节坑。

5.1 Patch Merging里的reshape与permute顺序

Patch Merging的间隔采样,在代码里是用reshape + permute实现的,不是循环。

第一次写的时候,我按照直觉先reshape成(B, H/2, 2, W/2, 2, C),然后直接取对角线元素,结果输出的空间顺序全乱了。

正确做法是先permute成(B, H/2, W/2, 2, 2, C),让两个2分别代表行方向和列方向的采样偏移,再reshape成(B, H/2, W/2, 4C)。这一步的教训是:先想清楚维度语义,再写permute。resize的索引顺序、采样的空间映射关系,稍微差一点,后面stage的特征就对不齐了。

5.2 掩码计算在窗口不整除时怎么办

标准输入是224×224,patch partition后56×56,窗口7×7刚好整除,所以很多人忽略了窗口切分可能遇到的边缘残缺问题。

但是只要输入分辨率变一下,比如测试时用480×480,patch后变成120×120,120除以7=17.14,切出来的最后一个窗口不够7×7。PyTorch的窗口切分一般会用padding补齐,然后把padding区域用掩码遮挡掉。

我建议不要依赖“刚好整除”这个巧合,把代码里的pad逻辑写完整。至少包括:

  1. 计算H方向需要pad的数量,按M的整数倍向上取整。
  2. W方向同理。
  3. 给attention mask留出pad位置设置为−100,保证padding区域不参与注意力。

很多公开实现的测试推理部分会专门处理这个问题,训练时因为固定224就省略了。到了部署阶段,一旦输入尺寸变化,这个坑必炸。

5.3 相对位置索引表的注册方式

PyTorch里,偏置表通常注册成buffer,因为它不是参与梯度更新?不,它是可学习的参数,应该用Parameter注册。但相对位置索引表不含梯度,应该用register_buffer。

我见过一种错误写法:把整个偏置表都用buffer注册,训练时偏置完全不更新,模型精度怎么都上不去。检查方法很简单,print模型参数的requires_grad列表,看看位置偏置对应的项是不是False。

正确结构是:

  • relative_position_index:register_buffer,M²×M²的长整型索引表,不更新。
  • relative_position_bias_table:nn.Parameter,维度(2M−1)×(2M−1)×num_heads,参与更新。

6. Swin各尺寸配置、计算量与适用场景的参考

6.1 配置表的背后逻辑

Swin论文提供了一组不同规模的主干配置,参数规模从28M到197M不等:

模型通道C每层block数窗口大小参数量
Swin-T962, 2, 18, 2728M
Swin-S962, 18, 18, 2750M
Swin-B1282, 2, 18, 2788M
Swin-L1922, 48, 24, 27197M

需要说明的是block数不对称:Stage 3放了最多block,因为降到14×14之后计算量已经可控,可以放心堆深度。Stage 4只有2个block,因为7×7本身token极少,不需要继续加深。

6.2 计算量的分配也很有意思

以Swin-T为例,总FLOPs约4.5G,其中Stage 3占了大部分。这是因为Stage 3分辨率14×14,窗口8×8=64个,每个窗口49个token,虽然token数少,但block数量多。

如果只做分类,一般用Stage 4输出的7×7特征图全局池化后接分类头。如果做检测分割,就把四个stage的输出全部接进neck。这算是Swin主干的通用使用范式。

7. 实际项目里Survive下来的几条经验

7.1 训练超参的几个“默认答案”

我试过几组配置,比较稳定的组合是:

  • 优化器用AdamW,初始学习率1e-3左右,weight decay 0.05。
  • 学习率调度用cosine退火,配合20个epoch左右的warmup。
  • 数据增强跟ViT那套ImageNet配置接近:RandAugment、Mixup、CutMix、Random Erasing都可以上。
  • 分类任务训练300个epoch,ImageNet top-1一般在83%上下(Swin-T),跟论文报告比较接近。

有一点跟CNN明显不同:Swin对学习率很敏感。learning rate稍微大一点,比如直接上3e-3,前面warmup阶段loss就容易飞。建议从小学习率1e-3起步,用warmup过渡到1e-3?严格的顺序是:warmup阶段从0或极小值线性增加到目标学习率,再用cosine退火降下来。CNN常用的“常数学习率+step decay”在这里不太灵,至少我试下来效果差不少。

7.2 DropPath:我自己踩过的正则化陷阱

Swin Block里有个常被忽略的正则化手段——DropPath,即随机丢弃整个残差分支。

Swin-T用的是0.1的默认值。如果做小数据集(比如只有几千张图),0.1可能不够,建议适度上调到0.2~0.3,能缓解过拟合。但如果数据量很大,DropPath开大了反而会导致训练不稳定。

这里有个容易踩的坑:DropPath应该加在block内部残差连接的分支上,即:

x = x + drop_path(attention(x)) x = x + drop_path(mlp(x))

而不是加在整个block的输出上。两者效果差很多,我见过有人把DropPath直接加在block输出端,结果整个网络的恒等映射被频繁打断,特征退化严重。

7.3 下游任务怎么接才顺

做检测时,把Stage 1到Stage 4的输出按顺序送进FPN,注意Stage 1输出的通道数C通常太小(96),如果直接进FPN可能细节有余语义不足,很多框架里会从Stage 2开始接。

做分割时,UNet式的跳连结构跟Swin的多尺度特征天然契合。我用Swin-T做过一个语义分割demo,把Stage 1的高分辨率特征作为skip connection,Stage 4的低分辨率语义特征作为bottleneck,上采样路径一层层融合回来,效果比同参数量的CNN主干好一截。

7.4 推理阶段的性能特征

Swin窗口注意力虽然计算量低,但实现上需要对特征图做窗口重排(window partition/unpartition),这类操作在GPU上会引入额外的访存开销,尤其在小batch、小分辨率下特别明显。

实测下来,Swin-T在单卡上的推理吞吐比同FLOPs的ResNet略低,但在大分辨率输入场景,比如检测任务里的1216×1216输入,Swin的优势会体现出来——因为窗口注意力的复杂度跟分辨率线性相关,而ResNet在深层靠的是stride卷积,特征图小了以后计算量下降,但没Swin省得这么明显。

如果项目对延迟特别敏感,建议优先考虑Swin-T,并把窗口大小从7调成4试试。窗口变小,注意力矩阵从49×49变成16×16,计算量大幅下降,精度损失看任务,有的任务几乎不掉点。

8. 写在复现笔记最后的话

Swin Transformer这个架构从问世到现在,已经成了视觉Transformer分支里一个绕不开的参考点。它是“把Transformer做成分层主干”这件事的标杆,patch merging和移位窗口这两个设计已经被后续很多工作吸收和改造。

如果让我给正准备读这篇论文的朋友一个建议:第一遍读的时候抓住三条主线就好——多尺度是怎么来的、窗口注意力怎么算的、相对位置偏置是干嘛的。第二遍再去看代码细节,尤其是掩码和索引表这两块。第三遍如果做复现,从Swin-T开始,先跑通分类,再接到检测分割里看多尺度特征图的实际效果。

读论文最怕停在“知道概念”这个层面。Swin的价值不只是提出了一堆名词,而是把“多尺度、窗口、偏置”这些零件组装成了一个能直接用的系统。真正想搞懂它,跑一个demo比读十遍摘要都管用。

RELATED READING

延伸阅读

更多一线实战笔记与深度复盘,助您持续精进