3. Vision Transformer详解

ViT,全称 Vision Transformer。说白了,就是把 Transformer 那套东西直接搬到图像上。2020 年 Google 那篇 An Image is Worth 16x16 Words 出来的时候,我第一反应是「这也能行?」——结果人家真把 CNN 给干翻了。

我个人习惯把 ViT 拆成四个核心模块来讲:Patch Embedding、位置编码、自注意力机制、分类头。面试时你按这个顺序答,面试官会觉得你思路很清晰。

核心观点:ViT 把图像当作「句子」来处理——每个 patch 就是一个 ,patch 之间的空间关系靠位置编码来建模。

1. Patch Embedding:图像怎么变成序列?

图像是二维的,Transformer 吃的是序列。怎么转?

ViT 的做法很简单:把一张 H×W×C 的图像切成 N 个 P×P×C 的小块(patch)。每个 patch 拉平成一个向量,再经过一个线性投影映射到 D 维空间。这个 D 就是 Transformer 的隐层维度。

举个例子:224×224×3 的图像,patch size 取 16×16,那么一共得到 (224/16)² = 196 个 patch。每个 patch 拉平是 16×16×3 = 768 维,投影到 768 维后,我们就有了 196 个 ,每个 768 维。

import torch
import torch.nn as nn

class PatchEmbed(nn.Module):
    def __init__(self, img_size=224, patch_size=16, in_chans=3, embed_dim=768):
        super().__init__()
        self.num_patches = (img_size // patch_size) ** 2
        # 直接用卷积实现:kernel_size = stride = patch_size
        self.proj = nn.Conv2d(in_chans, embed_dim, 
                              kernel_size=patch_size, stride=patch_size)

    def forward(self, x):
        # x: [B, 3, 224, 224] -> [B, 768, 14, 14] -> [B, 196, 768]
        x = self.proj(x)
        x = x.flatten(2).transpose(1, 2)
        return x
我的一点经验:实际项目中,patch size 的选择很关键。16×16 是默认值,但如果你做细粒度分类(比如识别不同品种的鸟),8×8 或 4×4 效果更好——代价是计算量翻倍。

2. 位置编码:给 patch 排个序

Transformer 本身是置换不变的——你把 顺序打乱,输出结果不变。但图像不是这样,左上角的 patch 和右下角的 patch 显然不同。所以我们需要位置编码。

ViT 用的是可学习的位置编码。初始化一个 [num_patches+1, embed_dim] 的矩阵,和 patch embedding 相加。注意那个 +1,是因为我们额外加了一个 [CLS] 。

为什么不直接用正弦编码?我个人理解是:可学习编码更灵活,能适应不同数据集的空间分布。我在做遥感图像分类时试过两种,可学习的确实比正弦的好 1-2 个点。

# 位置编码初始化
pos_embed = nn.Parameter(torch.zeros(1, num_patches + 1, embed_dim))
# 前向时直接相加
x = x + pos_embed
避坑指南:我曾经在微调时踩过一个坑——预训练模型用的是 224×224 图像,位置编码是 196+1 个。但我的任务需要 384×384 输入,patch 数变成了 576+1。直接插值位置编码会导致性能下降。解决方案是使用 2D 插值,保持空间结构。

3. 自注意力机制:ViT 的核心

自注意力机制,说白了就是让每个 patch 去「看」其他所有 patch,然后决定自己应该关注谁。

公式很简单:

Attention(Q, K, V) = softmax(QK^T / sqrt(d_k)) V

Q、K、V 都是从输入经过线性变换得到的。QK^T 计算的是两两 patch 之间的相似度,softmax 归一化成权重,最后加权求和得到输出。

ViT 用的是多头自注意力(MSA)。把 D 维分成 h 个头,每个头独立做注意力,最后拼接起来。这样做的好处是:每个头可以关注不同的模式——有的头关注局部纹理,有的头关注全局轮廓。

组件 作用 面试常问
Q (Query) 当前 patch 想「问」什么 Q 和 K 维度必须一致
K (Key) 其他 patch 能「提供」什么 为什么除以 sqrt(d_k)?防梯度消失
V (Value) 实际要聚合的信息 V 的维度可以和 Q/K 不同
多头 并行学习多种注意力模式 头数一般取 12 或 16
面试高频题:ViT 的自注意力和 CNN 的卷积有什么区别?
我的回答思路:CNN 是局部连接 + 权重共享,感受野有限;ViT 是全局连接 + 动态权重,每个 patch 都能看到所有 patch。但 ViT 缺少归纳偏置,需要更多数据才能训好。

4. [CLS] 与分类头

ViT 借鉴了 BERT 的做法:在输入序列最前面加一个 [CLS] 。这个 不参与 patch 的物理含义,只负责在最后一层输出分类结果。

为什么用 [CLS] 而不是对所有 patch 做平均池化?我个人的理解是:[CLS] 在训练过程中会「被迫」去聚合全局信息,而平均池化可能会稀释掉关键特征。实验也证明 [CLS] 的效果略好。

class ViT(nn.Module):
    def __init__(self, ...):
        super().__init__()
        self.cls_ = nn.Parameter(torch.zeros(1, 1, embed_dim))
        self.head = nn.Linear(embed_dim, num_classes)
    
    def forward(self, x):
        B = x.shape[0]
        # 拼接 [CLS] 
        cls_tokens = self.cls_.expand(B, -1, -1)
        x = torch.cat([cls_tokens, x], dim=1)
        # 经过 Transformer 编码器...
        x = self.encoder(x)
        # 取 [CLS] 对应的输出
        x = x[:, 0]
        return self.head(x)

5. 整体架构一览

把上面所有模块串起来,ViT 的完整流程就是:

  1. 输入图像 → Patch Embedding → 得到 patch 序列
  2. 拼接 [CLS] + 加上位置编码
  3. 经过 L 层 Transformer Encoder(每层包含 MSA + MLP + LayerNorm + 残差连接)
  4. 取 [CLS] 的输出 → 分类头 → 得到分类结果

下面这张图把整个流程画清楚了:

ViT 整体架构流程图 输入图像 224×224×3 Patch Embedding 16×16, stride=16 拼接 [CLS] + 位置编码 Transformer Encoder ×L 取 [CLS] 输出 分类头 (MLP) 分类结果 关键参数说明 • Patch size: 16×16 → 196 个 patch • Embed dim: 768, 头数: 12, Encoder 层数: 12 (ViT-Base) • [CLS] 位置: 序列最前面, 索引 0 面试常问点 • 为什么 ViT 需要更多数据?——缺少 CNN 的归纳偏置 • 位置编码为什么可学习?——比正弦编码更灵活

6. 面试避坑与实战经验

最后分享几个我在面试和项目中遇到的坑:

  • 计算量问题:ViT 的自注意力复杂度是 O(N²),N 是 patch 数。224×224 输入时还好,但如果是 512×512,patch 数变成 1024,显存直接爆炸。我一般建议用 Swin Transformer 或 PVT 这种层级式结构来处理高分辨率输入。
  • 数据量问题:ViT 在 ImageNet-1K(128 万张图)上从头训练,效果不如 ResNet。但用 JFT-300M(3 亿张图)预训练后,ViT 就反超了。所以面试时如果被问到「ViT 的局限性」,数据效率低是一个很好的切入点。
  • 微调技巧:我曾经在医疗图像上微调 ViT,发现直接全量微调效果不好。后来用了「渐进式解冻」——先冻结 patch embedding 和位置编码,只训练分类头;再逐步解冻后面的层。效果提升了 3 个点。
一个小建议:面试时如果被问到 ViT 的细节,可以主动提一下「ViT 的归纳偏置比 CNN 弱,但灵活性更强」。这能体现你对模型本质的理解,而不是只背公式。