Vision Transformer 详解:从原理到面试必考点
ViT,全称 Vision Transformer。说白了,就是把 Transformer 那套东西直接搬到图像上。2020 年 Google 那篇 An Image is Worth 16x16 Words 出来的时候,我第一反应是「这也能行?」——结果人家真把 CNN 给干翻了。
我个人习惯把 ViT 拆成四个核心模块来讲:Patch Embedding、位置编码、自注意力机制、分类头。面试时你按这个顺序答,面试官会觉得你思路很清晰。
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 个 token,每个 token 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
2. 位置编码:给 patch 排个序
Transformer 本身是置换不变的——你把 token 顺序打乱,输出结果不变。但图像不是这样,左上角的 patch 和右下角的 patch 显然不同。所以我们需要位置编码。
ViT 用的是可学习的位置编码。初始化一个 [num_patches+1, embed_dim] 的矩阵,和 patch embedding 相加。注意那个 +1,是因为我们额外加了一个 [CLS] token。
为什么不直接用正弦编码?我个人理解是:可学习编码更灵活,能适应不同数据集的空间分布。我在做遥感图像分类时试过两种,可学习的确实比正弦的好 1-2 个点。
# 位置编码初始化
pos_embed = nn.Parameter(torch.zeros(1, num_patches + 1, embed_dim))
# 前向时直接相加
x = x + pos_embed
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 |
我的回答思路:CNN 是局部连接 + 权重共享,感受野有限;ViT 是全局连接 + 动态权重,每个 patch 都能看到所有 patch。但 ViT 缺少归纳偏置,需要更多数据才能训好。
4. [CLS] Token 与分类头
ViT 借鉴了 BERT 的做法:在输入序列最前面加一个 [CLS] token。这个 token 不参与 patch 的物理含义,只负责在最后一层输出分类结果。
为什么用 [CLS] 而不是对所有 patch 做平均池化?我个人的理解是:[CLS] 在训练过程中会「被迫」去聚合全局信息,而平均池化可能会稀释掉关键特征。实验也证明 [CLS] 的效果略好。
class ViT(nn.Module):
def __init__(self, ...):
super().__init__()
self.cls_token = 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] token
cls_tokens = self.cls_token.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 的完整流程就是:
- 输入图像 → Patch Embedding → 得到 patch token 序列
- 拼接 [CLS] token + 加上位置编码
- 经过 L 层 Transformer Encoder(每层包含 MSA + MLP + LayerNorm + 残差连接)
- 取 [CLS] token 的输出 → 分类头 → 得到分类结果
下面这张图把整个流程画清楚了:
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 个点。