看一张路口照片,你会注意到车、人和红绿灯。但模型刚收到的只是像素数值。ViT 的第一步不是先找出这些物体,而是把图片分成小块,让每一块变成一个向量。之后,注意力才让不同位置交换信息。

这里先把“切块”和“理解”分开。读完这一篇,再看 CLIP,就能区分图像编码器(encoder)的结构和训练它的目标。

先算清楚,到底有多少个 token

假设图片是 $224\times224$ 的 RGB 图像,每块是 $16\times16$,不重叠、不补边:

$$N=\frac{224}{16}\frac{224}{16}=196,\qquad P^2C=16^2\times3=768.$$

196 是块数,768 是每块原始数值的个数。它们不是同一个维度。下面沿用 PyTorch 常见的通道在前格式;其他框架也可能把通道放在最后。

步骤 一批图片的形状 这一步做了什么
输入 $B\times3\times224\times224$ 保留高、宽和颜色通道
切块并展平 $B\times196\times768$ 每个位置对应一块像素
共享线性投影 $B\times196\times D$ 所有块使用同一组权重
加 CLS 和位置向量 $B\times197\times D$ 多一个汇总位置,并标记各块在哪里
Transformer 编码器 $B\times197\times D$ 更新每个位置的表示,不在这里缩短序列

投影不一定是压缩。 $D$ 是模型的隐藏维度:选 768 时维度不变,选 1024 时反而增加。它学的是哪些像素组合值得保留,不是把图片查表换成词表(vocabulary) ID。

用一张 4 × 4 的“小图片”走一遍

下面是自拟的单通道数值图,不是训练结果。每个 $2\times2$ 小块按行展平,再从左上到右下排列。

 1  2 |  3  4       左上 → [1, 2, 5, 6]
 5  6 |  7  8       右上 → [3, 4, 7, 8]
------+------       左下 → [9, 10, 13, 14]
 9 10 | 11 12       右下 → [11, 12, 15, 16]
13 14 | 15 16

人为选两个投影:第一个算块内均值,第二个算“右列均值减左列均值”。左上块就变成 [3.5, 1.0]。真实模型会学习权重,不需要我们提前规定“检测左右差异”。

def patchify_gray(image, patch_size):
    if not isinstance(patch_size, int) or patch_size <= 0 or not image or not image[0]:
        raise ValueError("Expected a nonempty image and a positive integer patch size")
    height, width = len(image), len(image[0])
    if any(len(row) != width for row in image):
        raise ValueError("Rows must have equal widths")
    if height % patch_size or width % patch_size:
        raise ValueError("This example does not pad or crop partial patches")
    return [
        [image[top + row][left + column]
         for row in range(patch_size) for column in range(patch_size)]
        for top in range(0, height, patch_size)
        for left in range(0, width, patch_size)
    ]

image = [[1 + 4 * row + column for column in range(4)] for row in range(4)]
patches = patchify_gray(image, 2)
projection_rows = [[0.25, 0.25, 0.25, 0.25], [-0.5, 0.5, -0.5, 0.5]]
embeddings = [[sum(value * weight for value, weight in zip(patch, weights))
               for weights in projection_rows] for patch in patches]
assert embeddings == [[3.5, 1.0], [5.5, 1.0], [11.5, 1.0], [13.5, 1.0]]

这里也能看到一个限制:只有这两个特征时,一些不同的小块会得到相同结果。例如 [1, 2, 5, 6] 和 [2, 3, 4, 5] 都输出 [3.5, 1.0]。后面的注意力无法从这两个数中恢复被丢掉的差异。

动画 ViT:切块、展平、投影

这张图需要启用 JavaScript。

实际实现通常用 kernel size 和 stride 都等于 patch size 的卷积完成切块投影;没有重叠时,它与逐块使用同一个线性映射对应。读 ViT 官方实现 时,可以顺着 embedding、reshape、cls、Transformer 看形状变化。

CLS 不是标签,位置向量也不是装饰

CLS 起初是一个可学习的向量,不携带“这张图是汽车”的答案。经过多层注意力,它可以汇总其他块的信息;分类头再根据它预测类别。也可以设计全局平均池化的读出方式,不能把 CLS 当成所有视觉模型必须有的部件。

位置则解决另一个问题:同样几块内容,上下左右换了位置,图片含义可能不同。原始 ViT 使用可学习的位置向量。它们加在内容向量上,而不是另塞一列坐标。注意力仍然会看所有块,不因此变成只看相邻块的卷积。原论文 §3

一个 block 里发生什么

省略 dropout,把整条序列记为 $X$,Pre-LN block 可以写成:

$$U=X+\operatorname{Attention}(\operatorname{LN}(X)),\qquad Y=U+\operatorname{MLP}(\operatorname{LN}(U)).$$

注意力在位置之间交换信息,MLP 在每个位置上变换特征,残差保留原来的路径。于是左上块的输出不再只依赖左上像素,也能参考远处的路牌。要拆到 Q/K/V,可以接着读多头注意力。

这还是 encoder:各块可以双向交流,不像生成文字时必须遮住未来 token。把 encoder 中的一层叫 block,并不意味着模型“不再使用 encoder”。

分辨率、块大小,各自在改什么

先固定隐藏维度和层数,只算 patch token,不计 CLS:

图片 / patch token 数 单头完整注意力分数的元素数
$224\times224$ / $16\times16$ 196 38,416
$448\times448$ / $16\times16$ 784 614,656
$448\times448$ / $32\times32$ 196 38,416

第二行保留更细的空间采样,但 token 变为 4 倍,完整分数矩阵变为 16 倍。第三行把 token 数降回来,却把更多像素放进同一块;账面成本相同,不代表小字、边缘和物体关系的识别能力相同。

这张表不是整网显存或耗时预测。MLP、投影、batch size、内核是否物化分数矩阵都会改变实际成本。更高分辨率还要求处理位置向量:原始 ViT 微调(fine-tuning)时对 patch 网格做二维插值,CLS 单独处理。插值让形状接得上,不保证新尺寸下效果不变。

微调骨干和只训练分类头,不是一回事

设置 更新什么 主要在测什么
Linear probe 冻结骨干,只训练线性头 已有特征能否线性区分类别
Fine-tuning 更新骨干及任务头,具体冻结范围须说明 模型适应新任务后的能力
特征提取 全部冻结,不训练新头 给检索(retrieval)或其他模型提供表示

原始 ViT 的迁移学习不是一律冻结骨干:论文更换为零初始化的线性分类头后进行微调,另有冻结表示的少样本评测。看结果时先确认协议,不能把这两类数字直接放在一起比较。原论文 §3.2、§4

冻结骨干、只训一个小头,在机器人感知里也很常见;分割、深度这类像素级任务上,DINO 一类自监督(self-supervised)特征和 CLIP 式特征差多少,见冻结骨干与几何基础模型。

ViT 解释了“图片怎么编码”,但没有规定编码后用来干什么。CLIP 用图文配对训练表示;视觉到语言继续把视觉特征接到生成模型。结构相似,目标不同,学出来的能力也不能画等号。

本页术语 5
编码器 encoder
把原始输入压成向量的那一半模型
词表 vocabulary
模型能输出的所有 token 的集合
微调 fine-tuning
在预训练权重上继续训练以适配具体任务
检索 retrieval
从外部语料里找出相关内容
自监督 self-supervised
从数据自身结构造出监督信号,不依赖人工标注