1. 从“词”到“块”:理解向量化统一的核心思想
朋友们,今天咱们接着聊Vision Transformer。上一回我们大概知道了ViT是怎么把图片切成块(Patch)然后喂给Transformer的。但说实话,我当时学到这里,心里一直有个疙瘩:NLP里处理的是“词”(Word),CV里处理的是“图块”(Patch),这俩八竿子打不着的东西,凭啥能用同一套Transformer架构来处理?这背后的设计哲学,才是ViT真正精妙和革命性的地方。它不是简单粗暴的“拿来主义”,而是完成了一次漂亮的“跨模态统一”。理解了这一点,你再看ViT,甚至看现在大火的“多模态”模型,感觉会完全不一样。
咱们先打个比方。想象一下,你是一个图书馆管理员,你的任务是把全世界不同语言、不同载体的信息(比如中文小说、英文论文、画册、乐谱)都整理成统一的格式,方便一个超级智能的检索系统来学习。你该怎么办?你肯定不会要求系统去分别理解中文语法、英文时态、绘画笔触和五线谱。最聪明的办法是,你把所有信息都先“打碎”成最基本的信息单元——对于文本,可能是词或字;对于画册,可能是一小块局部图案;对于乐谱,可能是一个小节。然后,你把所有这些“碎片”都转换成一种通用的、机器能理解的“密码”——也就是一个固定长度的数字向量。这样一来,无论原始信息是文字还是图像,到了机器眼里,都变成了一串串长度相同、含义丰富的数字。Transformer这个“超级大脑”要学习的,就是这些数字序列之间的关系。这就是向量化统一的核心:为不同模态的数据,找到一种通用的“分词”和“向量表示”方法。
在NLP领域,这个方法叫 Word Embedding(词嵌入),它已经非常成熟了。而在CV领域,ViT提出的 Patch Embedding(图像块嵌入)就是它的“镜像”操作。ViT的作者们敏锐地发现,Transformer在NLP上的巨大成功,关键在于它处理的是离散的、序列化的语义单元。那么,只要能让图像也变成这样的单元序列,Transformer就能在图像上大显身手。所以,整个ViT的预处理部分,可以看作是对NLP中Word Embedding流程的一次精心“复刻”和“适配”。接下来,我们就掰开揉碎,看看这两个“Embedding”兄弟,在“分词”、“映射”和“记住位置”这三个关键步骤上,到底是怎么做到“神似”又“形不同”的。
2. Word Embedding:NLP如何教会机器“阅读”
在深入对比之前,我们得先彻底搞懂老大哥Word Embedding在做什么。这可不是为了凑字数,而是因为只有明白了NLP的“标准操作”,你才能欣赏ViT的“移植手术”有多巧妙。
2.1 从独热编码的困境到词向量的曙光
最早让机器理解文本的方法非常“机械”。比如“我爱AI”这句话,我们先分词得到[“我”, “爱”, “AI”]。然后构建一个词汇表,假设有5000个词。那么“我”就用一个长度为5000的向量表示,只有第1024位是1,其他全是0,这就是独热编码(One-hot Encoding)。“爱”和“AI”同理,各自占据一个不同的位置为1。
这种方法问题太大了。第一是维度灾难。词汇表稍微大点,比如10万词,每个词就是一个10万维的稀疏向量,存储和计算都是噩梦。第二是语义鸿沟。在独热编码里,“猫”和“猫咪”这两个向量是正交的,点积为零,机器完全看不出它们有关系。这就像图书馆里把内容相近的书放在完全不同的楼层和区域,检索效率极低。
Word Embedding就是为了解决这些问题而生的。它的核心思想是:不再用稀疏的、高维的、孤立的独热向量来表示一个词,而是学习一个低维的、稠密的、连续的向量。这个向量通常只有几十、几百维(比如128维、768维),我们称之为词向量。神奇的是,在这个低维空间里,语义相似的词,它们的向量在空间中的位置也会很接近。比如,“国王”的向量减去“男人”的向量,再加上“女人”的向量,结果会非常接近“女王”的向量。这意味着词向量捕捉到了词汇之间的语义和语法关系。
2.2 Word Embedding的实现:一个可学习的查找表
那么,这个神奇的词向量是怎么来的呢?在经典的Word2Vec或Glove模型中,是通过在大规模语料上训练得到的。但在Transformer架构(比如BERT、GPT)里,它变得更直接:Word Embedding是一个可以随模型一起训练的可学习参数矩阵。
具体来说,假设我们的词汇表大小是 vocab_size(比如50000),我们想要的词向量维度是 d_model(比如768)。那么,我们就初始化一个形状为 (vocab_size, d_model) 的矩阵,称之为 Embedding 层。这个矩阵的每一行,就对应词汇表里一个词的 d_model 维向量。
当模型处理句子“我爱AI”时:
- 分词/Token化:将句子转换成词汇表ID序列,比如
[1024, 2048, 3072]。 - 查表映射:通过Embedding层,根据ID
1024取出第1024行的向量,作为“我”的词向量。这个过程,本质上就是一个“查表”操作。 - 输出序列:最终,一个长度为3的句子,就被转换成了一个形状为
(3, 768)的矩阵。这个矩阵就是Transformer Encoder的输入。
你可以把Embedding层想象成一个智能的“词向量字典”。训练开始时,里面的向量是随机初始化的,没什么意义。但随着模型在大量文本上训练(比如完形填空、预测下一个词),通过反向传播,这个“字典”里的词向量会被不断调整。最终,语义、语法、语境相似的词,它们的向量表示就会在空间中被“推”到相近的位置。这个过程,就是让离散的符号(词)“嵌入”(Embed)到一个连续的语义空间。
3. Patch Embedding:CV向NLP的“对齐”手术
理解了Word Embedding,现在我们来看ViT的Patch Embedding。你会发现,它的每一步都在向NLP看齐,但同时又充分考虑了图像数据的特性。
3.1 “分词”操作:将图像切割成视觉“词汇”
NLP的分词相对直观,有空格、标点等自然分隔符。图像是一片连续的像素海洋,它的“词”在哪里?ViT给出的方案简单而有力:规则网格划分。
给定一张 H x W x C 的图像(例如 224x224x3),我们设定一个Patch大小 P(例如 16)。然后,就像用一把尺子一样,把图像均匀地划分成 (H/P) x (W/P) 个网格。每个网格就是一个 P x P x C 的图像块,这就是图像的“视觉词汇”或“Token”。对于 224x16 的输入,我们会得到 (224/16) * (224/16) = 14 * 14 = 196 个Patch。
这步操作解决了两个关键问题:
- 序列化:将二维的、结构化的图像,转换成了一个一维的序列(196个Patch)。Transformer天生就是处理序列的专家。
- 降维:直接处理
224x224=50176个像素点作为序列太长,计算复杂度是序列长度的平方,无法承受。切成16x16的块后,序列长度骤降到196,变得可处理。
这就像我们要分析一幅世界地图,直接数每一个像素点毫无意义。更聪明的办法是把地图划分成各个国家的区块,然后研究这些区块之间的关系。Patch,就是图像的“国家区块”。
3.2 “映射”操作:从像素块到特征向量
现在我们有了一堆 16x16x3=768 维的像素块(假设是RGB三通道)。这相当于NLP中分词后得到的“原始词”。接下来,我们需要像Word Embedding一样,把这些“原始视觉词”映射到一个高维的、富有语义的特征空间(d_model 维,例如768维)。
在原始ViT论文中,这个操作是通过一个线性投影(Linear Projection) 完成的:将每个拉平后的Patch(1x768 的向量)乘以一个可学习的投影矩阵 E(形状为 (768, d_model)),得到 1 x d_model 的Patch Embedding。所有196个Patch都做同样的操作,就得到了 196 x d_model 的矩阵。
然而,在实际代码实现中,我们有一个更高效、更符合CV工程师直觉的做法:使用一个卷积核大小和步长都等于Patch Size的卷积层。
import torch
import torch.nn as nn
class PatchEmbedding(nn.Module):
def __init__(self, img_size=224, patch_size=16, in_chans=3, embed_dim=768):
super().__init__()
# 关键在这里:一个卷积层同时完成“切分”和“投影”
self.proj = nn.Conv2d(in_chans, embed_dim, kernel_size=patch_size, stride=patch_size)
def forward(self, x):
# x 形状: [B, 3, 224, 224]
x = self.proj(x) # 卷积操作,输出: [B, 768, 14, 14]
x = x.flatten(2) # 展平空间维度: [B, 768, 196]
x = x.transpose(1, 2) # 调整维度,得到序列: [B, 196, 768]
return x
看这段代码,是不是恍然大悟?nn.Conv2d 的 kernel_size 和 stride 都设为 patch_size,意味着卷积核不重叠地滑过整张图像,每一个卷积操作正好处理一个 16x16 的Patch。卷积核的通道数 embed_dim 就是我们要投影到的目标维度。这一个卷积层,一气呵成地完成了“图像分块”和“线性投影”两步,输出直接就是我们需要的一序列Patch Embedding。这种实现方式不仅高效,而且因为卷积本身具有的局部特性,它甚至能在投影过程中隐式地融合一点局部信息,比单纯的线性投影更有优势。
3.3 位置编码:为视觉序列注入空间秩序
在NLP中,“我打你”和“你打我”意思完全不同,词序至关重要。在图像中,Patch之间的相对位置同样包含重要信息:天空的Patch通常在顶部,草地的Patch在底部,猫耳朵的Patch紧挨着猫头的Patch。
Transformer的自注意力机制本身是置换不变的,它不在乎输入序列的顺序。为了让它感知位置,我们必须显式地加入位置信息。这就是 Position Embedding(位置编码)。
ViT完全借鉴了原始Transformer的做法:使用一组可学习的一维位置编码。我们生成一个形状为 (num_patches+1, embed_dim) 的可学习参数矩阵(加1是因为还有一个分类Token,后面会讲)。这个矩阵的第 i 行,就代表了序列中第 i 个位置的位置编码向量。在Forward过程中,我们简单地将这个位置编码向量加到对应的Patch Embedding向量上:
# 假设 patches_emb 形状为 [B, 196, 768], pos_emb 形状为 [1, 196, 768]
patches_with_pos = patches_emb + pos_emb
通过加法,位置信息就被融合进了特征表示。模型在训练过程中会学会如何利用这些加上的位置向量来理解图像的空间结构。我试过在简单的分类任务中移除位置编码,模型性能确实会有可观测的下降,这证明了空间信息对于视觉任务是不可或缺的。
4. 统一的输入范式:CLS Token与跨模态的桥梁
如果你仔细观察ViT的输入,会发现它比单纯的Patch序列多了一个东西:一个额外的、可学习的 [CLS] Token(分类令牌)。这个设计也是直接从NLP的BERT模型“抄”过来的,但它在这里起到了画龙点睛的作用,完成了跨模态统一的最后一环。
4.1 CLS Token的由来与作用
在BERT中,[CLS] Token被附加在输入序列的开头,用于汇聚整个句子的语义信息,最终用于下游任务如句子分类。ViT完美复刻了这个设计:在将图像Patch序列输入Transformer Encoder之前,我们在序列的最前面拼接一个可学习的向量,这个向量就是 [CLS] Token。
它的作用非常明确:作为整个图像信息的“聚合器”。在Transformer Encoder中,通过多层自注意力机制,序列中所有Token(包括所有Patch Token和 [CLS] Token)之间都会进行充分的信息交互。[CLS] Token通过与所有图像块Token进行注意力计算,能够“看到”并“吸收”整张图像的全局信息。
训练完成后,我们不取任何图像块Token的输出,而是只取 [CLS] Token 对应的输出向量,将它送入一个轻量的分类头(通常是一个MLP),就能得到图像的分类结果。这相当于让这个特殊的Token去学习“这张图里有什么”。
4.2 为什么这是统一范式的关键?
这个设计巧妙地规避了CV和NLP的一个本质差异。在NLP中,序列的每个词本身就有明确的语义,任务可以是基于每个词进行的(如命名实体识别)。但在图像分类中,我们的目标是对整张图给出一个标签,而不是对每个Patch分类。[CLS] Token的设计,为Transformer这种序列模型处理“整体性”任务提供了一个优雅的接口。
它建立了一种跨模态的、任务驱动的统一接口:
- 对于NLP句子分类:输入 =
[CLS]+ 词序列,输出用[CLS]的特征。 - 对于图像分类:输入 =
[CLS]+ 图像块序列,输出用[CLS]的特征。
你看,输入格式完全同构了。这使得同一个Transformer Encoder骨干,几乎无需修改就能处理两种截然不同的数据。这为后来的多模态模型(如CLIP)奠定了坚实的基础——文本和图像经过各自的Embedding层后,都被变成了 [序列长度, 特征维度] 的矩阵,前面都带一个用于汇聚信息的 [CLS] Token,然后就可以用同一套Transformer进行融合和理解。
5. 动手实现:从零构建完整的Patch Embedding模块
理论说了这么多,不写代码总觉得不踏实。下面我带大家手敲一个完整的、包含所有细节的Patch Embedding模块。我会把每一步的维度变化都标清楚,确保你能真正复现。
import torch
import torch.nn as nn
import numpy as np
from scipy import stats
class VisionTransformerPatchEmbed(nn.Module):
"""
完整的ViT Patch Embedding模块。
包含:图像分块投影、CLS Token、位置编码。
"""
def __init__(self, img_size=224, patch_size=16, in_chans=3, embed_dim=768, dropout_rate=0.1):
super().__init__()
self.img_size = img_size
self.patch_size = patch_size
self.num_patches = (img_size // patch_size) ** 2 # 计算Patch数量
# 1. 使用卷积层实现分块与投影(核心操作)
self.proj = nn.Conv2d(in_chans, embed_dim, kernel_size=patch_size, stride=patch_size)
# 2. 创建可学习的CLS Token (形状: [1, 1, embed_dim])
# 这是一个需要被训练的模型参数,初始化为随机值。
self.cls_token = nn.Parameter(torch.zeros(1, 1, embed_dim))
# 通常会用一个小标准差的正态分布初始化,这里简单用零初始化后,训练时会自动更新。
# 3. 创建可学习的位置编码 (形状: [1, num_patches + 1, embed_dim])
# 为所有图像Patch + 1个CLS Token分配位置编码。
self.pos_embed = nn.Parameter(torch.zeros(1, self.num_patches + 1, embed_dim))
# 更精细的初始化:使用截断正态分布,避免初始值过大
nn.init.trunc_normal_(self.pos_embed, std=0.02)
# 4. Dropout层,用于防止过拟合
self.pos_drop = nn.Dropout(p=dropout_rate)
def forward(self, x):
"""
前向传播。
输入 x: [B, C, H, W]
输出: [B, num_patches + 1, embed_dim]
"""
B, C, H, W = x.shape
# 确保输入图像尺寸符合预期
assert H == self.img_size and W == self.img_size, \
f"Input image size ({H}*{W}) doesn't match model ({self.img_size}*{self.img_size})."
# 步骤1: 投影,得到图像块嵌入 [B, embed_dim, num_patches_h, num_patches_w]
x = self.proj(x) # 输出形状: [B, 768, 14, 14]
# 步骤2: 展平空间维度 [B, embed_dim, num_patches]
# flatten(2) 表示从第2个维度开始展平(从0开始计数),即把高和宽展平。
x = x.flatten(2) # 输出形状: [B, 768, 196]
# 步骤3: 调整维度,得到序列形式 [B, num_patches, embed_dim]
x = x.transpose(1, 2) # 输出形状: [B, 196, 768]
# 步骤4: 添加CLS Token
# 将CLS Token扩展到当前批大小
cls_tokens = self.cls_token.expand(B, -1, -1) # 形状: [B, 1, 768]
# 在序列维度(dim=1)进行拼接,CLS Token在前
x = torch.cat((cls_tokens, x), dim=1) # 输出形状: [B, 197, 768]
# 步骤5: 添加位置编码
x = x + self.pos_embed # 广播相加,pos_embed形状为[1, 197, 768]
# 步骤6: 应用Dropout
x = self.pos_drop(x)
return x # 最终输出: [B, 197, 768],可以直接送入Transformer Encoder
# 实例化并测试模块
if __name__ == "__main__":
# 模拟一个批量为4的RGB图像输入
dummy_images = torch.randn(4, 3, 224, 224)
patch_embed = VisionTransformerPatchEmbed(img_size=224, patch_size=16, embed_dim=768)
output = patch_embed(dummy_images)
print(f"输入图像形状: {dummy_images.shape}")
print(f"输出序列形状: {output.shape}")
print(f"序列长度(含CLS Token): {output.shape[1]}")
print(f"每个Token的特征维度: {output.shape[2]}")
运行这段代码,你会看到输出类似:
输入图像形状: torch.Size([4, 3, 224, 224])
输出序列形状: torch.Size([4, 197, 768])
序列长度(含CLS Token): 197
每个Token的特征维度: 768
维度变化全流程解析:
[4, 3, 224, 224]: 输入4张224x224的RGB图。[4, 768, 14, 14]: 经过kernel=16, stride=16的卷积,同时完成分块和投影。通道数变为embed_dim=768,高宽各变为224/16=14。[4, 768, 196]: 将14x14的空间网格展平为196。[4, 196, 768]: 转置,得到196个序列Token,每个Token是768维向量。至此,图像已完全转化为NLP风格的序列。[4, 197, 768]: 拼接上[CLS]Token。[4, 197, 768]: 加上位置编码。[4, 197, 768]: 经过Dropout后输出。这个张量,在格式上已经和BERT处理完的文本序列输入([B, seq_len, hidden_size])一模一样了。
6. 对比、思考与实战经验
最后,我们来系统地对比一下Word Embedding和Patch Embedding,并分享一些我在实际使用中踩过的坑和心得。
6.1 核心思想对比表
| 特性维度 | NLP - Word Embedding | CV - Patch Embedding (ViT) | 统一的设计哲学 |
|---|---|---|---|
| 输入对象 | 离散的文本符号(词/子词) | 连续的图像像素块 | 将原始数据离散化为基本处理单元 |
| “分词”方式 | 基于规则/词表的分词器(如BPE) | 规则的、非重叠的网格划分 | 定义一种可重复的、将数据切片的方法 |
| 单元表示 | 词汇表ID(整数) | 拉平后的像素值向量(浮点数) | 每个单元有一个初始的数值表示 |
| 核心映射 | 可学习的Embedding查表矩阵 (vocab_size, d_model) | 可学习的线性投影/卷积 (P*P*C, d_model) | 通过一个可学习的线性变换,将初始表示映射到高维语义空间 |
| 输出格式 | [batch_size, seq_len, d_model] | [batch_size, num_patches, d_model] | 完全统一的序列格式 |
| 位置信息 | 正弦位置编码 / 可学习位置编码 | 可学习的一维位置编码 | 显式添加位置编码,弥补Transformer的置换不变性 |
| 特殊Token | [CLS], [SEP], [MASK] 等 | [CLS] Token | 使用 [CLS] Token作为全局信息聚合器,用于分类等任务 |
从这个对比可以清晰看到,Patch Embedding不是简单的模仿,而是一次深刻的范式迁移。它抓住了Transformer成功的本质——处理序列化的语义单元,并为此设计了一套将图像“序列化”和“语义化”的流程。
6.2 实战中的注意事项与调参经验
-
Patch Size的选择:这是最重要的超参数之一。较小的Patch Size(如4、8)能保留更多细节,但会极大增加序列长度(
224/4=56 -> 序列长3136),导致计算量和内存开销呈平方级增长。较大的Patch Size(如32)序列短、效率高,但可能丢失细粒度信息,影响模型性能,尤其在小物体识别上。经验之谈:对于ImageNet级别的分类,16x16是一个很好的平衡点。如果你的计算资源有限,或者处理更高分辨率的图像(如384、512),可以尝试增大Patch Size。 -
位置编码的初始化与处理:ViT默认使用可学习的位置编码。我发现在一些任务中,用预训练模型时,如果输入图像分辨率与预训练时不同(比如预训练用224,微调用384),直接插值位置编码可能会引入噪声。有些工作(如DeiT)尝试了相对位置编码或二维位置编码,在小数据集上表现更好。对于自定义任务,如果图像的空间结构非常规(比如医学图像的某个局部区域特别重要),可以尝试设计更灵活的位置编码方式。
-
CLS Token vs. 全局平均池化 (GAP):ViT使用CLS Token。另一种常见做法是像CNN那样,对所有图像块Token的输出做全局平均池化(GAP),用这个池化后的向量做分类。我两种都试过,在大多数情况下CLS Token表现略好或相当,因为它是一个需要主动“学习总结”的向量。但GAP的优势是更简单,没有额外参数。个人建议:除非有特殊理由,否则跟随ViT的标准做法使用CLS Token。
-
通道数的匹配:我们的示例代码默认输入是3通道RGB图。如果你处理的是灰度图(1通道)或医学图像(如多通道CT),需要修改
in_chans参数,并确保预训练权重能适配(通常需要随机初始化投影层)。 -
Dropout的使用:在Patch Embedding后直接加Dropout(如代码中的
pos_drop)是一个有效的正则化手段,尤其是在数据集较小的时候,能防止过拟合。Dropout率一般设置在0.0到0.2之间。
理解从Word Embedding到Patch Embedding的这条“统一之路”,不仅仅是为了弄懂ViT。它更像是一把钥匙,帮你打开了理解多模态AI的大门。今天,无论是CLIP(图文匹配)、DALL-E(文生图),还是各种视频理解模型,其底层核心思想都是一致的:将不同来源、不同形态的数据,通过特定的Embedding方式,映射到同一个高维向量空间,然后用统一的Transformer架构进行处理和学习。Patch Embedding正是这个伟大构想中,为视觉世界打开的那扇门。下次当你看到任何基于Transformer的视觉或多模态模型时,不妨先去找找它的“Patch Embedding”在哪里,你会发现,万变不离其宗。
—— 从Word Embedding到Patch Embedding:跨模态的向量化统一之路&spm=1001.2101.3001.5002&articleId=150687268&d=1&t=3&u=61b1ed6eec984401a4b5f97faf118e3d)
396

被折叠的 条评论
为什么被折叠?



