ARTICLE · INTELLIGENCE

战地情报 · 详情页

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

Vision Transformer原理详解与PyTorch实现:从Patch Embedding到训练踩坑

Vision Transformer原理详解与PyTorch实现:从Patch Embedding到训练踩坑

如果谈到近几年视觉领域最让人睡不着觉的技术,Vision Transformer绝对排得上号。一个把自然语言处理里的Transformer结构硬搬到图像上的想法,居然在分类、检测、分割任务上跟CNN打得有来有回,甚至在大规模数据上直接反超。这个标题本身就像一记闷拳——原来图像不止能用卷积来理解,还能切成小块当序列喂给注意力机制。这篇文章不打算复述论文,而是从一个实际使用者的视角,把ViT的来龙去脉、原理细节、代码实现、训练踩坑一次讲透。无论你是刚接触Transformer的新手,还是想在项目里评估是否换用ViT的从业者,这份笔记都能给你一套可以直接落地参考的思路。

先说结论:ViT不是简单地把Transformer搬到图像上,而是重新定义了“像素之间怎么交互”。传统CNN假设邻近像素之间关系最紧密,离得远的就得靠层层堆叠慢慢扩大感受野;ViT则一开始就让每个图像块(Patch)都能跟全图其他块直接计算相关度,这种长程依赖建模能力是它最大的本钱。

1. Vision Transformer是什么:一次范式迁移

1.1 从卷积到序列:图像不再是网格,而是词

CNN的起点是局部感受野和权值共享,它天生觉得图像是二维网格,特征应该有空间层次。这种归纳偏置让CNN在小数据集上很占便宜——不需要太多数据就能学会边缘、纹理、形状。但它也有天花板:想要让两个距离很远的像素发生关系,必须靠深层网络的逐步堆叠,信息一路传递过去,路径越长,损失越多。

ViT的做法非常激进:把一张图切成固定大小的Patch,比如16x16,然后把每个Patch展平成一个向量,加上位置编码,组成一串“图像词”,送进标准Transformer Encoder。这里不再有卷积的局部性假设,每个Patch从第一层起就能全局聚合信息。换句话说,ViT把“看一张图”变成了“读一句话”,每个词都是图像的一小块。

这种转变最直接的影响是:当训练数据足够大(比如上亿张图),ViT学到特征的表现力往往优于同规模的CNN。原因在于,CNN先验“邻近相关”在部分场景下会限制模型发现更远、更抽象的关系,而Transformer只依赖数据自己说话。

1.2 记忆中的关键节点:那张里程碑式的图

我第一次看到那张部分块被随机遮住的图时很受冲击:模型对遮挡区块的注意力散布在全图多个不相邻的位置,比如遮住一只狗的头,它会很大程度去关注狗的腿和背景草地,这在卷积模型里很难出现——因为卷积每层感受野有限,深层也很难同时覆盖那么多不相邻的语义区域。

这篇开山之作的贡献不在于模型结构有多复杂,恰恰在于它足够简单:没有用任何卷积,只有Patch Embedding + 标准Transformer Encoder + 分类头。这个结构在那之前被认为需要大量数据和强正则才能训练稳定,但论文作者通过在大规模数据上预训练,把ViT做到了在多个视觉任务上媲美甚至超过当时的SOTA CNN。

从那之后,ViT家族不断壮大。有在训练中引入蒸馏来保留CNN优势的变体,有通过金字塔结构让Transformer适配多尺度任务的,也有把卷积和注意力融合到一起的混合结构。但所有的后续工作都得回到这个基本思想:图像是序列,注意力是核心。

2. 核心原理拆解:ViT的四个关键零件

2.1 Patch Embedding:如何把图片切成“词”

输入图像是三通道的张量,shape为(H, W, C)。ViT第一步是把它切成N个小块,每个Patch的尺寸为P×P,那么块数量N=HW/P²。每个Patch经过一个可学习的线性投影,映射到维度D的向量,这就是Patch Embedding。实际实现里,线性投影通常用二维卷积来完成:卷积核大小等于P,步长等于P,输出通道数为D。这样一整张图经过一次卷积,直接得到N个D维向量,比循环切Patch再Linear要高效得多。

这里有两个细节值得注意:

  • Patch的尺寸决定了序列长度。P越小,序列越长,计算量越大;P越大,每个Patch信息越粗,序列越短。16是经典的默认值。
  • 线性投影之后的特征向量,本质上是这个Patch内容和语义的编码。事实证明,Patch Embedding学到的特征分布非常清晰,同一物体不同位置的Patch往往会聚类。

2.2 位置编码与分类标记:给序列加上顺序和入口

Transformer本身没有顺序概念,对所有Patch一视同仁。为了让模型知道每个Patch在图像中的位置,必须加入位置编码。ViT使用可学习的绝对位置编码,初始化成可训练参数,随着模型一起更新。它跟语言Transformer的三角编码不同,因为图像没有时间方向,位置关系是二维的,但直接展平成一维序列也能学得很好,这让实现变得非常简单。

另一个重要零件是 [CLS] Token。每次在Patch序列最前面额外拼接一个可学习向量,它会经过所有Transformer层,最后取出对应位置的输出,接一个全连接层做分类。为什么用这个标记?因为注意力机制里,[CLS] Token可以看成全局信息汇聚的“池子”,它通过各层注意力均匀地从所有Patch那里收集信息,最终表示整个图像。在训练时,我们也只用这个向量计算损失,省去对所有Patch输出做全局池化的步骤。

2.3 Multi-Head Self-Attention:信息交互的核心

每个图像词会生成Query、Key、Value三个向量,分别表达“我想找什么”、“我就是什么”、“我实际上有什么”。注意力得分通过Query和Key的点积计算再Softmax归一化,表示每个词应该关注其他词的程度。多头注意力则把D维空间分成h个子空间,每个子空间独立计算注意力,最后拼接在一起。这样多头可以分别关注不同的关系——可能一个头关注颜色纹理,另一个头关注空间结构,还有一个头关注跨区域的物体部件。

这个机制带来的复杂度是O(N²D),N是Patch数量。对224x224输入、16x16 Patch来说,N=196,还能接受;但如果输入是1024x1024、Patch还是16x16,N=4096,二次复杂度会爆炸。这也是为什么后续出现了一系列降低复杂度的工作,比如窗口注意力、稀疏注意力、缩放注意力等。

2.4 MLP和残差连接:稳住训练深度必备

每个Transformer Block里,注意力之后接一个两层MLP,中间用GELU激活,最后用残差连接和LayerNorm把各个操作串起来。结构几乎是标准Transformer Encoder,没有特别复杂的改动。真正影响训练体验的是初始化方式和Learning Rate。

我实测下来,ViT对初始化非常敏感。用较小的标准差初始化最后一层线性层,比如标准差为1e-6,能让训练初期更稳定。残差连接使得梯度能够贯穿深层,但注意力层的梯度幅值波动很大,需要配合Warmup + 高学习率 + AdamW才能跑得起来。这些细节在后面训练技巧里再展开。

3. ViT凭什么能赢:优势与局限的真实对比

3.1 全局感受野与适应性注意力

CNN用固定大小的卷积核,感受野通过深度逐层扩大,但这种扩大是有代价的:当网络足够深,反向传播梯度经过很多层,很容易衰减。ViT从第一层起就拥有全局感受野,每个Patch可以关注任意距离的其他块,而且这种关注是基于数据自适应学出来的,不是结构强制的。这个能力在需要大范围上下文的任务上特别明显,比如图片分类中的物体遮挡、分割实例的边界、检测时的目标关系建模。

我用ViT做图像分类时,对比过它跟ResNet的特征可视化。ViT的注意力图往往能贯穿整个物体,即使物体被部分遮蔽,注意力也会跳到背景中的常见特征区域。这在医学影像、遥感图像这类全局上下文重要的场景下效果显著。而在纹理复杂的自然图像里,CNN的局部归纳偏置反而更有优势——近邻像素确实高度相关,卷积高效且不易过拟合。

3.2 数据胃口:为什么小数据集上容易翻车

ViT没有CNN的归纳偏置,这意味着它需要在训练中自己学习“邻近像素相关”这个规律。如果训练集只有一两万张图,ViT很容易学到局部噪声或错误的相关性,表现远不如同参数量的ResNet。这个现象在早期备受质疑,后来业界总结出共识:ViT需要在大规模数据(千万级以上)上预训练,再在下游任务上微调,才能发挥潜力。

具体数据量门槛很难一概而论,但我的经验是:当分类训练集小于10万张图时,ViT的收益一般抵不过它对数据的额外需求。此时可以考虑混合模型(比如用卷积做前几层特征提取,后面接Transformer)或者加强数据增强、正则化来硬堆性能。盲目把小模型换成ViT,大概率会得到一个更慢、更差的结果。

3.3 计算成本与扩展性:优点和缺点一体两面

全局注意力带来了表达力,也带来了计算开销。ViT的FLOPs跟Patch数量的平方成正比,这就导致它天然偏“胖”——增大维度D的边际成本比增大分辨率低得多。所以ViT普遍用很大的Embedding维度(比如768、1024),配合较少的层数。反过来说,如果我们想要模型同时处理高分辨率和高维度,计算量会迅速突破单卡极限,必须靠分布式训练或注意力变体。

这也是为什么实际工程项目里很少直接用原始ViT处理高清图。常见做法是:输入先用卷积下采样再进ViT,或者干脆用层次化ViT。如果你只是因为“Transformer=先进”而替换现有CNN,一定要先算清楚你的硬件和时延预算能不能扛得住O(N²)的Attention。

4. 手把手:从零实现一个极简ViT

4.1 搭建Patch Embedding和Transformer Block

我这里用PyTorch写一个精简版ViT,不含预训练的完整版本,但结构完整、可以直接在这基础上扩展。代码里省去了很多细节装饰,用于理解核心流程。先引入需要的库。

import torch import torch.nn as nn class PatchEmbedding(nn.Module): def __init__(self, in_channels=3, patch_size=16, embed_dim=768): super().__init__() self.proj = nn.Conv2d(in_channels, embed_dim, kernel_size=patch_size, stride=patch_size) def forward(self, x): # x: (B, 3, H, W) -> (B, N, D) x = self.proj(x) # (B, D, H/P, W/P) x = x.flatten(2) # (B, D, N) x = x.transpose(1, 2) # (B, N, D) return x

这里用卷积实现投影,一个操作搞定切块和线性映射。输入经过proj之后,形状变为(B, embed_dim, H/P, W/P),然后flatten最后两维得到(B, embed_dim, N),再转置成(B, N, embed_dim),方便后续输入Transformer。

Transformer Block就是标准encoder层,包含LayerNorm、自注意力、MLP和残差。实现如下:

class Attention(nn.Module): def __init__(self, dim, heads=8): super().__init__() self.heads = heads self.scale = dim ** -0.5 self.qkv = nn.Linear(dim, dim * 3, bias=False) self.proj = nn.Linear(dim, dim) def forward(self, x): B, N, C = x.shape qkv = self.qkv(x).reshape(B, N, 3, self.heads, C // self.heads) q, k, v = qkv.permute(2, 0, 3, 1, 4).unbind(0) attn = (q @ k.transpose(-2, -1)) * self.scale attn = attn.softmax(dim=-1) x = (attn @ v).transpose(1, 2).reshape(B, N, C) return self.proj(x) class Block(nn.Module): def __init__(self, dim, heads=8): super().__init__() self.norm1 = nn.LayerNorm(dim) self.attn = Attention(dim, heads=heads) self.norm2 = nn.LayerNorm(dim) self.mlp = nn.Sequential( nn.Linear(dim, dim * 4), nn.GELU(), nn.Linear(dim * 4, dim) ) def forward(self, x): x = x + self.attn(self.norm1(x)) x = x + self.mlp(self.norm2(x)) return x

Attention实现里,把Q、K、V放在一个Linear里一次算出来,再拆成多头。scale取dim的负0.5次方用来稳定点积结果。Softmax前乘scale,这是标准做法。

4.2 组装完整的ViT模型

有了PatchEmbedding和Block,我们只需要拼装整体。先定义分类Token和位置编码,然后堆叠多个Block,最后通过分类头输出类别概率。

class ViT(nn.Module): def __init__(self, image_size=224, patch_size=16, num_classes=1000, dim=768, depth=12, heads=12, mlp_dim=3072): super().__init__() self.patch_embed = PatchEmbedding(3, patch_size, dim) n_patches = (image_size // patch_size) ** 2 self.pos_embed = nn.Parameter(torch.randn(1, n_patches + 1, dim)) self.cls_token = nn.Parameter(torch.randn(1, 1, dim)) self.blocks = nn.ModuleList([Block(dim, heads) for _ in range(depth)]) self.norm = nn.LayerNorm(dim) self.head = nn.Linear(dim, num_classes) def forward(self, x): B = x.shape[0] x = self.patch_embed(x) # (B, N, D) cls = self.cls_token.expand(B, -1, -1) # (B, 1, D) x = torch.cat([cls, x], dim=1) # (B, N+1, D) x = x + self.pos_embed for blk in self.blocks: x = blk(x) x = self.norm(x) cls_out = x[:, 0] return self.head(cls_out)

初始化位置编码使用randn,实际训练时模型会自己调整。如果用随机初始化,建议加上适当的标准差缩放。depths、dim、heads三个参数直接决定模型大小。文中这段代码只做演示,数据增强、dropout都省略了;验证模型能否跑通可以用一个小随机张量试一下:ViT(image_size=64, patch_size=16, dim=192, depth=4, heads=4)(torch.randn(2,3,64,64))。

4.3 如何加载预训练权重而不是从零训

上面的代码训出来的效果通常很差,因为随机初始化ViT对数据要求很高。实际项目里应该从公开的预训练权重开始。用某个深度学习库自带的VisionTransformer接口,可以方便地导入在大规模数据上训练好的模型,然后修改分类头适配自己的类别数。加载时需要注意源码和官方权重结构是否一致,比如Norm位置、是否有额外Dropout层等,不一致的话权重名称对不上,需要自己写转换脚本。

如果找不到现成接口,也可以拿官方仓库分发的权重文件,通过state_dict逐层加载。关键代码就一句话:model.load_state_dict(torch.load("weight.pth"), strict=False),如果结构差异较大,先用torch.load读出键名,再手动建立新旧映射。这一步很容易因为命名差异翻车,我在实际项目中踩过好几次。

5. 把ViT训好:经验、技巧和常见坑

5.1 优化器、学习率与Warmup

ViT在ImageNet上的常用配置是:优化器AdamW,基础学习率0.003,weight decay设0.3,batch size 4096配合线性缩放学习率。我们做小数据集迁移时不可能用这么大的batch,我的经验是:默认Learing Rate从5e-4开始,如果数据量小就降到1e-4,batch size每翻一倍,学习率线性乘以2。

Warmup不能省。直接全量训练会发现损失在开始几百步内有明显波动,尤其是前1-2个epoch,因为Attention容易在训练初期被极端的梯度推入差的局部区域。建议warmup步数占总步数的5%~10%,学习率从接近0线性增到目标值,之后再用余弦退火逐渐衰减。这个组合在ViT上几乎稳定有效。

5.2 数据增强和正则:决定小数据上的成败

ViT比CNN更需要正则。我用过的有效策略包括:

  • RandAugment:随机组合多种颜色、旋转、平移等操作,强度控制在中小幅度。
  • Mixup和CutMix:这两招对ViT特别有用,混合样本能够打乱空间局部性,防止模型依赖“周围像素必然相关”的捷径。
  • Random Erasing / Patch Dropout:模拟遮挡,强迫模型建立全局依赖。特别是Patch Dropout,随机丢弃部分输入Patch,与ViT的序列输入非常契合。
  • Label Smoothing:将软标签引入,防止过度自信,稳定训练。

Dropout也不可少,通常加在Attention的Softmax之后和MLP中,数值在0.1~0.3量级。但要注意预训练模型已经带了一部分正则,微调时Dropout建议调小或不加,否则因为数据量小很容易欠拟合。

5.3 位置编码与分辨率微调:变着花样用

ViT对输入分辨率的变化相对敏感。因为预训练时位置编码是固定长度,如果直接输入更高分辨率图像,Patch数量增加会导致位置编码长度不够。一个常用办法是二维插值:把预训练的位置编码矩阵重排回sqrt(N)×sqrt(N)的形状,然后插值到新尺寸,再展平。这个操作在检测、分割中常用,因为这类任务需要更高的输入分辨率。

但插值后的位置编码会略改变空间语义,最好在目标数据上微调几个epoch让模型重新适应。如果新分辨率跟预训练差异太大,比如从224到896,直接插值效果差,建议先用224微调,再逐步升分辨率逐步微调。我在一个细粒度分类项目里试过从384微调,效果比直接跳到448好得多。

5.4 常见问题速查:训练走了弯路怎么办

现象可能原因处理建议
训练损失不降学习率过大/过小,没有warmup调低到1e-4,增加warmup
验证集精度远低于训练集数据增强不够,过拟合增加Mixup、CutMix、Dropout
注意力图全是背景位置编码没学好,训练不充分延长训练步数,检查初始化
高分辨率微调崩溃位置编码插值不合理分阶段升分辨率,或使用相对位置编码变体
显存不足Patch数量太多,Attention开销二次增长减小Patch尺寸,使用窗口注意力或降采样

我遇到最多的问题是“训练稳定好用但泛化差”。这往往是因为在ImageNet预训练上微调时,学习率没有降到足够低,且数据增强不够激进。对ViT来说,微调时的关键是把预训练的知识保留住,而不是从头学新数据,所以有效学习率通常比CNN微调低不少。

6. 什么时候选ViT:项目里的决策参考

6.1 适合ViT的场景

  • 数据量充足(至少几十万张)或者有强预训练模型可迁移
  • 任务需要长程依赖,比如目标检测中识别相互覆盖的实例、医学影像里的病变区关联
  • 图像分辨率中等但类别语义复杂,比如细粒度识别
  • 团队算力够,能承受训练和推理时的计算开销

在这些场景里,ViT的成绩通常高于同规模CNN,尤其是大规模预训练之后微调,收益稳定明显。

6.2 不适合ViT的场景

  • 只有几万张图甚至几千张图的垂直任务
  • 对推理延迟要求严格,需要在边缘设备上跑
  • 输入图片非常大,且无法下采样,Attention开销会失控
  • 你已经有一套调试好的CNN推理链路,换模型带来的收益未必高

对于这些情况,老老实实选ResNet、EfficientNet或混合结构更划算。我见过有人因为“Transformer高级”硬换成ViT,结果精度低了5个点,训练时间还翻倍。技术选型从来不是选最新的,而是选最合适的。

6.3 一条务实的折中路径:卷积与注意力的融合

纠结选CNN还是ViT,其实还有一种解:用CNN的前几层做降采样和局部特征提取,后面接Transformer做全局建模。这种混合结构同时保留了局部先验和全局交互能力,在中小数据集上比纯ViT更稳,在大数据上也不逊色。实现时可以把PatchEmbedding替换成若干卷积层,分辨率降到合适大小后再进入Transformer块,这样既省显存,又补足了纯ViT在纹理细节上的弱项。

我在一个遥感图像分类项目中就用了类似方案:前四层用卷积,后面三个Transformer Block做全局融合。数据只有8万张,混合结构比纯ViT提升了约3个点,训练速度还快了接近一半。如果项目初期无法确定数据量是否撑得起纯ViT,混合结构是更稳妥的起点。

写在最后的实操体会

ViT没有想象中那么高冷,但也没有营销号吹得那么万能。我自己的实践感受是:如果你已经有了一套表现不错的CNN方案,先用它做baseline,再考虑要不要加ViT;如果你刚开始一个视觉项目,数据量又不小,直接上预训练ViT微调往往能带来惊喜,但一定要重视优化器和数据增强的细节。多试试把注意力图可视化出来,你会在上面看到许多CNN不太会表现出的全局语义交互,这种理解对排错和调参都有帮助。这篇文章写到这里,剩下的就是动手去跑一个最小模型,哪怕先跑通一次穷举也不是白费功夫。

RELATED READING

延伸阅读

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