1. 从“看图识字”到“看图理解”:ViT如何让计算机像人一样看世界?

如果你用过像ChatGPT这样的AI聊天机器人,可能会惊叹于它理解文字、组织语言的能力。这背后的核心功臣,就是Transformer模型。但你想过吗,这种擅长处理“一长串文字”的模型,怎么用来处理“一整张图片”呢?毕竟,图片是二维的像素网格,而Transformer只认一维的序列。这就像让一个只会读文章的人,突然去欣赏一幅画,他该怎么入手?

Vision Transformer (ViT) 就是解决这个问题的“天才翻译官”。它的核心思想,用一个形象的比喻来说,就是把一幅画“撕”成一小块一小块的拼图碎片,然后告诉Transformer:“嘿,别管它们原来是画,你就把这些碎片当成一篇文章里的单词,按顺序去读吧!”这个把图像“翻译”成Transformer能懂的语言的过程,就是ViT最精妙的地方,而其中的两个关键“零件”——图像块(Patches)令牌(Tokens)——正是我们今天要拆解的核心。

我刚开始接触ViT时,也觉得这个概念有点绕。但后来在项目里实际用它做图像分类,才发现这套流程设计得非常巧妙。它绕开了传统卷积神经网络(CNN)那种层层递进、局部感知的方式,直接用一种“全局扫描”的注意力机制来理解图像,在很多任务上效果出奇的好。接下来,我就带你一步步走通ViT的完整处理流水线,从最开始的图像分割,到最后的自注意力计算,保证让你彻底搞懂。

2. 第一步:化整为零——图像如何被分割成Patches?

想象一下,你面前有一张标准的224x224像素的彩色照片。对计算机来说,这就是一个巨大的数字矩阵,形状是 [224, 224, 3](高、宽、RGB三个颜色通道)。Transformer模型可吃不消这么一大块“生肉”,它习惯的输入是一串向量,就像一句话是一串单词。

ViT的做法非常直接:切格子。 它设定一个固定大小的窗口,比如16x16像素,然后像切豆腐一样,把整张图均匀地切成一个个小方块。具体怎么算呢?很简单:(224 / 16) * (224 / 16) = 14 * 14 = 196。也就是说,一张图被切成了196个图像块(Patches)

每个Patch的原始数据形状是 [16, 16, 3]。为什么是3?因为是RGB三通道。把这三个维度的所有数值拉平(Flatten),就会得到一个长度为 16*16*3=768 的一维向量。这个向量,就是最原始的Patch数据。

在实际的代码实现里,这个“切割”动作通常用一个巧妙的卷积层一步完成:

# 使用一个卷积核大小和步长都等于patch_size的卷积层
self.proj = nn.Conv2d(in_channels=3, out_channels=768, kernel_size=16, stride=16)

这行代码是什么意思?它用一个16x16的大卷积核,以16为步长,不重叠地在图像上滑动。因为卷积核大小和步长一样,所以每次操作刚好取出一个16x16的区域,并且通过768个输出通道,直接把这个区域映射成一个768维的向量。输入一个 [1, 3, 224, 224] 的张量(1张图),经过这个操作,就变成了 [1, 768, 14, 14],再经过简单的变形和转置,最终得到 [1, 196, 768] 的张量。看,196个“单词”,每个“单词”有768个特征,序列这不就出来了吗?

这一步是物理上的分割,是ViT处理图像的起点。但光有这一堆数字向量还不够,它们对模型来说还是“天书”,我们需要把它们转换成模型能理解的“语言符号”。

3. 第二步:从像素到语义——Patches如何转化为Tokens?

现在我们有了一堆768维的向量,每个向量代表图像的一个局部区域。但直接把这些向量扔给Transformer行吗?还不行。这些向量只是原始像素值的简单堆砌,缺乏高级的、可学习的语义信息。这就好比给你一堆字母,你还需要把它们组合成有意义的单词。

线性投影(Linear Projection) 就是完成这个“组词”工作的关键步骤。我们通过一个可学习的线性变换层(一个全连接层),将每个768维的Patch向量,映射到一个新的D维空间(在ViT-Base模型中,D通常也设为768,但这是一个可以设计的维度)。

你可以把这个线性投影层想象成一个“翻译器”或“编码器”。它学习到的权重矩阵,能够从原始的像素强度中,提取出对后续任务(比如识别猫狗)更有用的抽象特征。经过这个变换,原本的图像块(Patch) 就正式升级为了令牌(Token)。Token是模型内部真正的处理单元,它携带了经过初步抽象后的视觉信息。

这里有一个非常重要的点需要厘清:Patch是物理存在的、图像被分割后的一个个小方块,是数据的原始形态;而Token是这些Patch经过数学变换(线性投影)后得到的向量表示,是模型内部的、承载语义信息的载体。 先有Patch,然后每个Patch被映射成一个Token。在ViT的流程里,我们常说“输入一系列Tokens”,其实指的就是这一批经过映射后的向量。

为了让你更直观地理解这个变换的维度变化,我列个表格对比一下:

处理阶段数据形态形状示例 (以ViT-B/16,单张图为例)说明
原始图像三维像素矩阵[224, 224, 3]高度、宽度、颜色通道
分割后图像块序列[196, 16, 16, 3]196个块,每个块16x16x3
拉平后原始向量序列[196, 768]每个块展平为768维向量
线性投影后令牌序列[196, 768]每个Patch被映射为768维的Token

形状看起来没变?是的,维度数可能没变(都是768),但向量内每个数值的含义已经发生了本质变化,从具体的像素值变成了抽象的、由模型学习到的特征。

4. 第三步:注入灵魂——Class Token与位置编码为何不可或缺?

现在,我们有了196个代表图像局部信息的Token。但这里还有两个问题亟待解决:

  1. 如何做整体分类? Transformer处理完所有Token后,会输出196个新的向量。我们该用哪一个来代表整张图片做分类呢?平均一下?还是选第一个?
  2. 位置信息去哪了? 我们把图像切碎再打平成序列,彻底破坏了像素之间天然的空间相邻关系。模型怎么知道某个Token是来自图片左上角还是右下角?

ViT借鉴了NLP中BERT模型的做法,引入了两个巧妙的设计。

首先是Class Token。 我们在序列的最前面,额外添加一个特殊的、可学习的Token,通常记为 [CLS]。这个Token本身不来自任何图像块,你可以把它想象成一个“问题学生”或者“总结者”。在自注意力机制中,它会与序列里所有其他的图像Token进行交互,“收集”全局信息。训练完成后,这个 [CLS] Token的输出向量,就被认为是整张图像的聚合表征,直接送到分类头(一个MLP)去做最终的类别预测。代码实现很简单:

# 初始化一个可学习的Class Token
self.cls_token = nn.Parameter(torch.zeros(1, 1, embed_dim)) # 形状 [1, 1, D]
# 在前向传播时,将其复制到batch中每个样本,并拼接到序列开头
cls_tokens = self.cls_token.expand(batch_size, -1, -1) # 变成 [B, 1, D]
x = torch.cat((cls_tokens, x), dim=1) # 拼接后 x 形状变为 [B, 197, D]

其次是位置编码(Positional Encoding)。 Transformer本身是“排列不变”的,打乱输入序列的顺序,输出也会相应打乱,但模型并不知道“顺序”本身意味着什么。对于图像,空间位置是极其重要的信息(天空的Token通常在顶部,草地的Token在底部)。因此,我们必须显式地告诉模型每个Token的原始位置。

ViT采用了一种更简单的策略:可学习的位置编码。我们初始化一个形状为 [1, 197, D] 的可学习参数矩阵(197=196个图像Token+1个Class Token),其中每一行对应序列中一个位置的信息。然后,直接把这个位置编码加到对应的Token序列上:

x = x + self.pos_embed  # 简单的逐元素相加

这个过程就像给每个Token发了一个“座位号”。通过训练,模型会学会理解这些“座位号”所代表的空间关系。有研究可视化过学习到的位置编码相似度,发现位置相近的Token,其编码向量的相似度也更高,这证明模型确实学到了空间结构。

至此,图像的预处理全部完成。我们得到了一个形状为 [B, 197, D] 的、富含语义和位置信息的Token序列,它已经准备好被送入Transformer的核心——编码器层进行深度加工了。

5. 核心引擎:自注意力机制如何让Token“相互交流”?

前面所有的步骤,都是在为Transformer Encoder准备“食材”。现在,“食材”下锅了,真正的“烹饪”开始。Transformer Encoder由多个相同的层堆叠而成(ViT-Base是12层),每一层的核心就是多头自注意力机制

自注意力是让模型理解Token之间关系的魔法。它的目标很简单:让序列中的每个Token,都能根据所有其他Token的重要性,来更新自己的表示。我打个比方,你看一幅“猫追老鼠”的图。当模型处理“猫”这个Token时,自注意力机制会帮它发现:“老鼠”这个Token和我高度相关,“天空”这个Token和我关系不大。然后它就会更多地吸收“老鼠”的信息,更新自己对“猫”的表示。

具体是怎么算的呢?我们拆开看:

  1. 生成Q, K, V:对于输入序列中的每个Token(假设维度是D),我们通过三个不同的线性变换层,为它生成三个新的向量:查询向量(Query)键向量(Key)值向量(Value)。你可以理解为:

    • Query (Q):“我(当前Token)想知道什么?”
    • Key (K):“我(其他Token)有什么信息?”
    • Value (V):“我(其他Token)具体的内容是什么?”
  2. 计算注意力分数:用当前Token的Q向量,去和序列中所有Token(包括自己)的K向量做点积。点积的结果,衡量了当前Token与另一个Token的“相关性”或“匹配度”。相关性越高,分数越大。

  3. 缩放与归一化:将这些点积分数除以一个缩放因子(通常是Key向量维度的平方根),防止数值过大。然后通过Softmax函数,将所有分数归一化为一个概率分布,总和为1。这个分布就是注意力权重,它明确指出了在更新当前Token时,应该“关注”其他每个Token的程度。

  4. 加权求和:用上一步得到的注意力权重,对所有Token的V向量进行加权求和。这个加权和的结果,就是当前Token经过“全局信息融合”后的新表示。

“多头”又是什么意思? 与其只做一次上述计算,我们并行地做多次(例如8次或12次),每次使用不同的、可学习的Q、K、V投影矩阵。这相当于让模型从多个不同的“子空间”或“视角”去计算注意力。最后,把所有“头”计算出的结果拼接起来,再经过一个线性投影层融合。这样,模型就能同时捕捉到不同类型的关系(例如,一个头关注颜色相似性,另一个头关注纹理相似性)。

在代码里,这个过程非常紧凑:

# 假设输入x形状为 [B, N, D]
B, N, D = x.shape
# 通过一个线性层同时生成Q, K, V,然后拆分成多头
qkv = self.qkv(x).reshape(B, N, 3, self.num_heads, D // self.num_heads).permute(2, 0, 3, 1, 4)
q, k, v = qkv[0], qkv[1], qkv[2] # 每个形状 [B, num_heads, N, head_dim]

# 计算注意力分数
attn = (q @ k.transpose(-2, -1)) * self.scale # @ 表示矩阵乘法
attn = attn.softmax(dim=-1)
attn = self.attn_drop(attn)

# 加权求和并输出
x = (attn @ v).transpose(1, 2).reshape(B, N, D)
x = self.proj(x)

自注意力机制是ViT强大能力的源泉。它让模型在第一层就能建立任意两个图像块(无论它们相距多远)之间的直接联系,拥有了真正的全局感受野。这是它与传统CNN最本质的区别——CNN需要很多层卷积才能逐步扩大感受野,看到全局。

6. 编码器层的完整拼图:注意力之后还有什么?

一个完整的Transformer编码器层,并不是只有一个自注意力模块。它是一套精心设计的“组合拳”,目的是稳定、高效地训练深度网络。除了我们刚讲完的多头自注意力(MSA),一个标准层还包括以下部分,并且通常按如下顺序组织:

  1. 层归一化(LayerNorm):在输入进入MSA和MLP之前,都会先经过一个层归一化。它作用于每一个单独的样本(Token序列),沿着特征维度进行归一化,使得该层输入的数据分布保持稳定,加速训练。这与CNN中常用的批归一化(BatchNorm)不同,后者是在一个批次内所有样本的同一特征上进行归一化。

  2. 残差连接(Residual Connection):这是训练深度网络的神器。每个子层(MSA或MLP)的输出,都会直接加上该子层的输入。即:输出 = 子层(层归一化(输入)) + 输入。这创建了一条“高速公路”,让梯度可以直接回传,有效缓解了深度网络中的梯度消失问题。

  3. 前馈网络(MLP):在MSA之后,会接一个简单的两层全连接网络,中间通常用GELU激活函数。这个MLP作用于每个Token individually(独立地),它对自注意力层提取的、已经融合了全局信息的特征进行进一步的变换和非线性增强。你可以把它理解为对每个Token的“个人深度加工”。

  4. 再来一次层归一化和残差连接:MLP的输出同样会先经过层归一化,然后再与MLP的输入相加。

所以,一个编码器层的前向传播过程,用伪代码表示就是:

def forward(x):
    # 第一个子层:自注意力 + 残差
    x_norm1 = layer_norm(x)
    attn_output = multi_head_attention(x_norm1)
    x = x + attn_output  # 残差连接

    # 第二个子层:前馈网络 + 残差
    x_norm2 = layer_norm(x)
    mlp_output = mlp(x_norm2)
    x = x + mlp_output  # 残差连接
    return x

这种“归一化-注意力/MLP-残差”的结构被反复堆叠L次(L是模型深度),构成了Transformer编码器的主体。每一层都让Token的表示变得更加抽象和富含语义。最终,我们取序列的第一个Token——也就是我们特意添加的那个 [CLS] Token——经过所有层加工后的输出,作为整张图像的最终特征表示。

7. 实战理解:用代码串联ViT的全流程

光说不练假把式。让我们结合一段简化的PyTorch风格代码,把上面所有的步骤串联起来,看看一个完整的ViT前向传播到底是怎么走的。我会在关键步骤加上注释。

import torch
import torch.nn as nn

class SimpleViT(nn.Module):
    def __init__(self, img_size=224, patch_size=16, in_chans=3, embed_dim=768, depth=12, num_heads=12, num_classes=1000):
        super().__init__()
        # 1. 图像分块与线性投影 (Patch Embedding)
        self.patch_embed = nn.Conv2d(in_chans, embed_dim, kernel_size=patch_size, stride=patch_size)
        num_patches = (img_size // patch_size) ** 2

        # 2. 可学习的Class Token和位置编码
        self.cls_token = nn.Parameter(torch.zeros(1, 1, embed_dim))
        self.pos_embed = nn.Parameter(torch.zeros(1, num_patches + 1, embed_dim)) # +1 for cls_token

        # 3. Transformer编码器堆叠
        self.encoder_layers = nn.ModuleList([
            TransformerEncoderLayer(embed_dim, num_heads) for _ in range(depth)
        ])
        # 最后的层归一化
        self.norm = nn.LayerNorm(embed_dim)

        # 4. 分类头
        self.head = nn.Linear(embed_dim, num_classes)

    def forward(self, x):
        B, C, H, W = x.shape # 输入图像,例如 [B, 3, 224, 224]

        # --- 第一步:创建图像Tokens ---
        x = self.patch_embed(x)  # 输出 [B, embed_dim, num_patches_h, num_patches_w]
        x = x.flatten(2).transpose(1, 2)  # 展平并转置 -> [B, num_patches, embed_dim]

        # --- 第二步:添加Class Token和位置编码 ---
        cls_tokens = self.cls_token.expand(B, -1, -1)  # 扩展至batch大小 -> [B, 1, embed_dim]
        x = torch.cat((cls_tokens, x), dim=1)  # 拼接 -> [B, num_patches+1, embed_dim]
        x = x + self.pos_embed  # 加上位置编码

        # --- 第三步:通过Transformer编码器 ---
        for layer in self.encoder_layers:
            x = layer(x)  # 每层输入输出形状不变 [B, N, D]

        # --- 第四步:提取分类特征并预测 ---
        x = self.norm(x)  # 最终层归一化
        cls_output = x[:, 0]  # 取出第一个Token,即[CLS] token的输出 -> [B, embed_dim]
        out = self.head(cls_output)  # 分类头 -> [B, num_classes]
        return out

# 简化的编码器层定义(省略了Dropout等细节)
class TransformerEncoderLayer(nn.Module):
    def __init__(self, embed_dim, num_heads):
        super().__init__()
        self.norm1 = nn.LayerNorm(embed_dim)
        self.attn = MultiHeadAttention(embed_dim, num_heads)
        self.norm2 = nn.LayerNorm(embed_dim)
        self.mlp = MLP(embed_dim)

    def forward(self, x):
        # 子层1:自注意力 + 残差
        x = x + self.attn(self.norm1(x))
        # 子层2:前馈网络 + 残差
        x = x + self.mlp(self.norm2(x))
        return x

通过这段代码,你可以清晰地看到数据流的演变:从 [B, 3, 224, 224] 的原始图像,到 [B, 197, 768] 的Token序列,再经过编码器层层处理,最后取出 [CLS] token的 [B, 768] 特征进行分类。这就是ViT将图像“翻译”给Transformer理解,并利用自注意力完成图像识别的完整故事。

8. 回顾与思考:ViT的设计哲学与启示

走完这一整套流程,我们再回过头看ViT,它的设计其实体现了一种“大道至简”的思想。它没有像CNN那样精心设计局部连接、权重共享等针对图像的先验知识(归纳偏置),而是选择了一种最“暴力”也最“通用”的方式:把图像当成序列,然后用强大的自注意力机制去学习其中所有元素间的关系。

这种设计带来了几个显著特点:

  • 全局建模能力:从第一层开始就拥有全局感受野,能直接捕捉长距离依赖。
  • 可扩展性:模型性能随着数据量、模型规模的增加而显著提升,这在超大规模预训练中表现惊人。
  • 与NLP的统一:使得视觉和语言任务能使用相似的模型架构,为后来的多模态大模型(如CLIP)铺平了道路。

当然,ViT也有其“胃口”,它通常需要在大规模数据集(如JFT-300M)上预训练,才能在小数据集上发挥出超越CNN的性能。这也催生了一系列改进工作,比如Swin Transformer引入了局部窗口和移位窗口注意力,在保持全局能力的同时提升了效率。

理解ViT中Tokens和Patches的转换流程,是理解一切视觉Transformer变体的基础。下次当你看到DETR(目标检测)、SegFormer(图像分割)这些基于Transformer的视觉模型时,你会发现它们的起点,都是这套将图像“切片”、“编码”、“注入位置信息”再交给Transformer处理的基本范式。希望这篇详细的解析,能帮你彻底打通这个关键的技术节点。在实际项目中尝试调整Patch大小、编码维度或注意力头数,你会对这套机制有更深刻的体会。

Logo

北京人形旗下天工造物具身智能开源社区,聚焦具身天工与慧思开物两大平台

更多推荐