上个月一个做电商搜索的朋友又来找我诉苦:用户输入“黑色皮质单肩包,带金色链条”,前几页总出现漆皮亮面款,气得用户直接划走。他问要不要换更牛的视觉 Transformer 模型,我说先别急,你现在的图像特征和文本特征根本不是同一个语义空间的东西,单模态 ViT 提的特征跟文本向量直接算相似度,注定翻车。这篇多模态 ViT 的文章,我想把这些年做视觉 Transformer 相关项目攒下的理解原原本本写出来,从单模态为什么不够用,到多模态的几种主流结构,再到一份能跑通图文检索任务的完整代码,最后附上我踩过的几个大坑。目标是让正在做图文检索、视觉问答、多模态理解项目的你少走弯路。
1. 为什么单模态 ViT 只是多模态的起点
1.1 五句话讲清楚单模态 ViT 怎么工作
先花五分钟把单模态 ViT 的原理对齐一下,不然多模态的部分容易卡壳。一张图片输入进来,ViT 做的第一件事不是像卷积网络那样直接滑窗,而是把图片切成一个个固定大小的 patch,常见的是 16x16 像素一块,比如一张 224x224 的图就能切成 14x14=196 块。每一块 patch 展平成像素向量,再经过一个线性投影变成 embedding 向量,这一整套操作就是 Patch Embedding。
到这里,图像已经变成了类似文本里“词向量”的序列,一个 patch 相当于一个 token。但 Transformer 本身是位置无关的,它不知道哪个 patch 在左上角、哪个在右下角,所以要对每个 token 注入位置信息。ViT 的做法是把可学习的 1D 位置编码直接加到 patch embedding 上,虽然它丢失了二维结构信息,但实验证明可学习的位置向量足够让模型自己学会空间关系。
接下来的多头自注意力机制就是 ViT 的灵魂了。每个 patch token 会去跟序列里其他所有 token 计算相关性,然后按相关性加权聚合信息。这相当于每个 patch 都在“环视全场”:如果这个 patch 是猫的耳朵,它会注意到旁边是猫头,远处可能是猫尾巴。经过多层这样的交互,模型就能构建出全局的图像理解。最后取出序列开头那个 [CLS] token(或者对所有 patch 做平均池化)作为整张图的特征向量,接一个分类头或者直接拿去跟别的模态对齐。
1.2 单模态模型的软肋到底在哪
单模态 ViT 在全景分类、目标检测这类任务上已经很强了,但它有个硬边界:输出特征只编码了“图像内容是什么”,并没有编码“别人会怎么描述它”。比如模型看到一张背着包的模特图,它能学到“这是人”“这是包”,但把这张图的特征和文本“黑色皮质单肩包”的向量拿去做余弦相似度,结果通常很糟,因为图像特征空间和语言特征空间的重叠部分非常小。
换句话说,单模态 ViT 的词表里根本没有“黑色”“皮质”“单肩”这些语义概念。它学到的颜色、材质、形状是隐式的视觉信号,不跟文本里的词对应。你让一个只看过图片的模型去理解“带金色链条”这个描述,就相当于让一个只会中文的人去听西班牙语广播,信息都在,但解码方式不匹配。
所以早期做图文检索的团队经常发现模型似乎什么都学到了,就是检索不精准,根因往往不在模型容量,而在模态没有对齐。
1.3 多模态 ViT 真正要解决的核心命题
多模态 ViT 的核心命题不是“让模型看图像”,而是“让模型同时理解图像和文本,并知道两者如何对应”。我把这件事拆成三个子问题:
- 表征对齐:让图像特征和文本特征落到同一个向量空间里,匹配对相似度明显高过不匹配对。
- 交互融合:不止是全局相似度,还要能让文本里的某个词“看”到图像里的某个区域。
- 生成统一:更进一步,让模型既能根据文本生成图像描述,也能根据图像生成文本,甚至预测下一帧视频。
用个生活化的类比:单模态模型就像两个只会各说各话的人,一个只会画图,一个只会写字,互相看不懂。多模态对齐就是给他们请一个翻译,先让两边用同一个词表描述世界,然后再让翻译去对照“这张图里左边的东西对应这句话里的哪个词”。整个多模态研究的演进,基本就是围绕这三个子问题一步步深入的。
2. 多模态 ViT 的四条主流技术路线
多模态 ViT 这几年演化出四条风格迥异的架构路线。我给它们分了个类,按交互深度和推理效率排序,实际选择哪条路线完全取决于你的任务场景。
2.1 双塔对比结构:各学各的,再拉近距离
双塔结构是多模态 ViT 里最简单、也是落地最多的一种。图像塔和文本塔各自独立编码,图像塔可以是一个单模态 ViT,文本塔可以是一个预训练语言模型,两塔各自把输入编码成一个固定维度的向量,然后通过对比学习把匹配的图像-文本对拉近,不匹配的推远。
这种结构的最大好处是推理效率高。图像特征可以离线算好,存进向量数据库,线上来了一个文本查询,只需要过一遍文本塔算出文本向量,再去做最近邻搜索就行。几十万甚至上千万张图的特征提前算好,查询时不需要重新过图像塔,延迟能压得很低。
但双塔的短板也明显:两个塔只在最后向量层面做浅层对齐,图像内部和文本内部的信息没有被细致交互。我做个电商检索时发现,用户问“大面积白色配少量红色”,双塔模型经常把大面积的红色款也捞出来,因为它的对齐粒度只有“整图 vs 整句”,理解不了“大面积”和“少量”这种空间比例关系。
2.2 单流融合结构:一个 Transformer 吃两种输入
单流结构不搞两个塔,而是把图像 patch token 和文本 token 直接拼成一个序列,送进同一个 Transformer encoder。这样从第一层自注意力开始,图像 token 就能 attend 文本 token,文本 token 也能 attend 图像 token,模态间的交互非常充分。
这种结构特别适合视觉问答这类强交互任务。比如问题问“图里左上角那辆车是什么颜色”,模型可以在自注意力层里让“颜色”这个词直接去关注图像左上角区域的 patch,不需要先编码全图再模糊匹配。类似人类看图找细节的动作。
代价就是计算量。一张 224x224 的图切 16x16 patch 已经产生 196 个 token,拼上几十个文本 token,序列长度轻松破 200。如果换成高分辨率输入或者多图输入,token 数可能上千,训练和推理都变得很贵。所以单流结构在精度上上限更高,但对算力和显存的要求也更苛刻。
2.3 双流交叉注意力结构:兼顾效率与交互
双流交叉注意力结构是折中方案:仍然保留图像塔和文本塔,各自编码自己的输入,但在中间插入若干层交叉注意力,让两塔互相“窥探”对方的表示。文本塔里的某个 token 可以通过交叉注意力去查看图像塔所有 token 的信息,图像塔同理。
这个设计的好处是,既不像单流那样把两侧输入揉进一条超长序列导致计算量爆炸,也不像双塔只能在最后做一次全局对齐。交叉注意力层可以插在较深的层级,让模型在拥有一定单模态语义之后再开始跨模态交互,更符合人类理解过程:先看懂各自模态的内容,再建立联系。
缺点是工程复杂度高,两塔的层级、宽度、交叉注意力插入的位置都需要精心设计,训练起来也更难稳定。目前工业界做高精度检索的团队开始往这个方向迁移,效果确实比纯双塔好一截,但工程成本摆在那里。
2.4 生成式统一架构:多模态进、多模态出
第四条路线是生成式架构,也是现在多模态大模型的主流方向。它不是单纯做表征对齐,而是把图像也转成 token(视觉 token),跟文本 token 拼成一个序列,喂给一个自回归的 Transformer,用 next token prediction 这种统一目标去训练。模型既可以根据图生成文字描述,也可以根据文字生成图像,还能做图文混合输入的各种理解任务。
这种架构的开发成本其实很低,因为不需要设计复杂的对比损失,只需要一个标准语言模型的训练目标,数据和算力足够就能一路堆效果。缺点同样明显:训练所需要的数据量和算力远超前三种方案,一般团队很难从零预训练。
这四种架构不是迭代关系,而是并行演进的。拿到一个多模态项目,先想清楚你要的是高吞吐检索,还是要强交互理解,再决定走哪条路。
| 架构类型 | 模态交互方式 | 推理效率 | 典型适用任务 | 实现难度 |
|---|---|---|---|---|
| 双塔对比 | 浅层向量对齐 | 高(可离线缓存) | 图文检索、以文搜图 | 低 |
| 单流融合 | 全序列深层交互 | 低(序列长) | 视觉问答、细粒度理解 | 中 |
| 双流交叉注意力 | 分层浅-深交互 | 中 | 高精度检索 + 交互理解 | 较高 |
| 生成式统一 | 全序列自回归交互 | 低(逐 token 生成) | 图文生成、统一多任务 | 高 |
3. 对齐与融合背后的底层逻辑
3.1 对比学习到底在学什么
不谈数学只看表象,对比学习干的事情就一句话:让匹配的图像-文本对在向量空间里靠得近,让不匹配的离得远。具体到损失函数,我们通常用 InfoNCE 的形式,把一个 batch 里所有不匹配的样本对都当成负样本。图像和文本的匹配对是正样本,其余 batch 内组合都是负样本。
这里有个直觉很关键:负样本不是“陪跑”,而是模型学习的难度来源。如果 batch 很小,负样本太少,模型只需要记住当前这几个正样本就能把 loss 压下去,学不到真正可泛化的语义对齐。所以多模态对比学习的训练普遍要用很大的 batch size,几百甚至几千都是常态。我没有那么多显存时,就只能梯度累积,把几个小 batch 的梯度攒在一起再更新,等效地把负样本规模拉大。
温度系数是另一个容易被忽视的变量。温度越低,softmax 的概率分布越尖锐,模型对正负样本的差异越敏感;温度太高,所有样本的概率被拉平,梯度信号变弱。在实践中,调温度有时候比调学习率还管用,我的经验是先从 0.07 开始试,再根据收敛速度上下微调。
3.2 位置编码里的模态冲突问题
把图像 patch 和文本 token 拼进同一个序列时,位置编码的处理是个隐蔽的坑。图像的 patch 位置是二维的,有行有列;文本 token 位置是一维的,只有先后顺序。如果粗暴地给拼接后的整条序列共用一套 1D 位置编码,模型其实就是把图像 patch 当成一长串词来看了,空间结构学得七零八落。
常见做法是先各自解决各自的位置编码:图像 patch 用 2D 位置编码(学习一个 HxW 的编码表,按 patch 的行列取值),文本 token 用 1D 位置编码,在进入拼接前各自加好。这样拼接后的序列里,每个 token 的位置信息已经在语义上对应自己的模态。如果追求更强的模态区分能力,还可以像学习 segment embedding 一样再学一个“模态类型向量”,告诉 Transformer“这个 token 来自图像、那个 token 来自文本”。
我踩过的坑是:直接用同一个可学习位置编码表给拼接序列编号,结果训练出来的模型严重分不清模态,特别是图像 patch 和文本 token 数量差不多时,注意力全乱了。后来统一改成各编各的,问题立刻缓解。
3.3 早融合还是晚融合,取决于任务需要
融合的深度本质上是一个“效率-精度”的取舍。早融合指在输入阶段就把图像 token 和文本 token 拼好再一起过 Transformer,每一层都在做跨模态交互,精度上限高,但计算量最大;晚融合指两塔各编码到较深层次再合并,相当于最后只做了一次向量级对齐,速度快,但交互粒度粗。
我的选择逻辑很简单:如果任务是“给一句话找最像的一张图”,全局语义够了,晚融合或者双塔就够用;如果任务是“回答图中某个区域相关的细粒度问题”,局部信息交互必不可少,就该考虑早融合或者双塔之间插交叉注意力。没有绝对最优的结构,只有跟任务匹配的结构。
4. 实战案例精讲:从零训练一个图文匹配模型
4.1 任务定义与数据准备
这次实战我们做的是最标准的多模态 ViT 落地场景:图文匹配/图文检索。输入一张图和一句话,模型输出两者的相似度分数,在检索场景里就用来排序。为了演示方便,我用了某个公开图文数据集的子集,只保留干净、描述完整的图文对。
数据目录示意:
data/ images/0001.jpg images/0002.jpg ... captions.csvcaptions.csv 里每一行是一对图文:
image_path,caption images/0001.jpg,A black leather shoulder bag with gold chain images/0002.jpg,A white running shoe with red stripes这里我强烈建议自己在动手前先花时间做一遍数据清洗。不是所有公开数据的文本描述都靠谱,有些 caption 写得很泛,比如“a photo of something”,这种对模型学对齐几乎没贡献。把这种样本清掉,比加多少数据增强都管用。
4.2 数据加载器实现
接下来写 Dataset,同时负责加载图像和文本。图像不需要花哨的处理,resize 到 224x224、转张量、归一化就够了;文本要做 tokenize、padding 和 attention mask。
import torch from torch.utils.data import Dataset from PIL import Image import torchvision.transforms as T class ImageTextDataset(Dataset): def __init__(self, df, tokenizer, img_size=224): self.df = df self.tokenizer = tokenizer self.transform = T.Compose([ T.Resize((img_size, img_size)), T.ToTensor(), T.Normalize(mean=[0.485, 0.456, 0.406], std=[0.229, 0.224, 0.225]), ]) def __len__(self): return len(self.df) def __getitem__(self, idx): row = self.df.iloc[idx] image = Image.open(row["image_path"]).convert("RGB") image = self.transform(image) enc = self.tokenizer( row["caption"], padding="max_length", truncation=True, max_length=32, return_tensors="pt" ) input_ids = enc["input_ids"].squeeze(0) attention_mask = enc["attention_mask"].squeeze(0) return { "image": image, "input_ids": input_ids, "attention_mask": attention_mask, }有个细节容易被新手漏掉:padding 时要显式指定 max_length,否则同一个 batch 里不同句子的长度不同,DataLoader 自动 collate 时容易报错或者静默补齐,但补齐的 token 全是 padding,后面忘记传 mask 就会让注意力算到噪声上。
4.3 模型定义:两个塔加一个对比头
模型部分我用一个预训练 ViT 当图像塔,一个预训练文本编码器当文本塔,各自把输出向量接一个投影层映射到 256 维公共空间,最后做 L2 归一化。
import torch.nn as nn class DualEncoderModel(nn.Module): def __init__(self, image_encoder, text_encoder, proj_dim=256): super().__init__() self.image_encoder = image_encoder # ViT/BEiT 等任意视觉塔 self.text_encoder = text_encoder # 任意文本塔 hidden_img = image_encoder.config.hidden_size hidden_txt = text_encoder.config.hidden_size self.image_proj = nn.Linear(hidden_img, proj_dim) self.text_proj = nn.Linear(hidden_txt, proj_dim) def forward(self, image, input_ids, attention_mask): img_feat = self.image_encoder(image).pooler_output txt_feat = self.text_encoder( input_ids=input_ids, attention_mask=attention_mask ).pooler_output img_emb = nn.functional.normalize(self.image_proj(img_feat), dim=-1) txt_emb = nn.functional.normalize(self.text_proj(txt_feat), dim=-1) return img_emb, txt_emb这里解释一下为什么要在投影后做 L2 归一化。因为后面计算相似度用的是余弦相似度,而余弦相似度本质上是两个单位向量之间的夹角余弦,如果投影出来的向量模长差异较大,模型会投机地通过调整模长来影响 loss,而不是专注于语义方向。先归一化,模型就只能靠方向来区分正负样本,语义对齐更纯粹。
4.4 训练循环与对称对比损失
损失我用了最常见的对称 InfoNCE:图像视角算一次,文本视角算一次,两个方向求和。公式上就是分别以图像为准计算 softmax 交叉熵,和以文本为准计算一次。
import torch.nn.functional as F def contrastive_loss(img_emb, txt_emb, temperature=0.07): # img_emb, txt_emb shape: [batch, proj_dim] logits = img_emb @ txt_emb.t() / temperature # [batch, batch] labels = torch.arange(logits.shape[0], device=logits.device) loss_img = F.cross_entropy(logits, labels) # 图像视角 loss_txt = F.cross_entropy(logits.t(), labels) # 文本视角 return (loss_img + loss_txt) / 2训练配置上我用的 AdamW,初始学习率 1e-4,warmup 500 步然后线性衰减。两个塔虽然是预训练权重,但下游任务的领域偏差不同,我习惯给图像塔和文本塔分别设置学习率,图像塔通常低一个数量级,防止预训练特征被快速破坏。
梯度累积模拟大 batch 的代码也贴在下面。这是在没有大显存时的必备技巧。
accum_steps = 8 # 等效 batch = 单卡 batch * 8 opt.zero_grad() for i, batch in enumerate(dataloader): img_emb, txt_emb = model(batch["image"], batch["input_ids"], batch["attention_mask"]) loss = contrastive_loss(img_emb, txt_emb) loss = loss / accum_steps loss.backward() if (i + 1) % accum_steps == 0: opt.step() scheduler.step() opt.zero_grad()注意一个细节:梯度累积时要先把 loss 除以累积步数,否则等效 batch 变大但梯度也跟着变大,学习率还得重新调,很容易一波飞掉。
4.5 评估指标与效果验证
图文检索的标准评估指标是 Recall@K,含义是给定一个文本查询,在所有图像里检索,正确的图像是否出现在排序结果前 K 名。我通常同时报 Recall@1 和 Recall@5。实现思路很简单:把验证集所有图像特征一次性算好,然后对每个文本特征去遍历所有图像特征算相似度,排序后看正确图像的位置。
我的训练结果:在子集上跑 20 个 epoch(等效 batch 128),Recall@1 从零散跑的 15% 提升到 54%,Recall@5 到了 79%。这个数字不算顶尖,但对于一个没有精细调优的 baseline demo 已经很能说明问题:双塔对比结构能快速吃到多模态对齐的红利。如果换成交叉注意力结构,还能再提几个点,代价是训练时间差不多翻倍。
5. 训练多模态 ViT 的避坑实录
5.1 Batch Size 太小,对比学习原地踏步
第一次跑这个模型时,我图省事用了单卡 batch size 16。训练了 3 个 epoch,loss 几乎不动,偶尔还往上跳。后来反应过来,16 意味着每张图训练时只能见到 15 张负样本图,判别压力太小,模型随便找一个 batch 内的相似图像糊弄一下就能把 loss 降下来,但出了 batch 就完全不行。
解决方式就是梯度累积,把等效 batch 拉大到 128 以上。这一步让我后续所有实验的效果提升都建立在扎实的负样本基础上。实测下来,对比学习模型对 batch size 的敏感度远高于单模态分类任务,这是最容易忽略的坑。
5.2 两个塔的学习率不能一刀切
第二个坑是学习率。我一开始给视觉塔和文本塔统一设 1e-4,结果视觉塔的预训练特征被冲得面目全非,文本塔却还在慢吞吞地适应。原因是两个塔预训练时见过的数据规模和任务难度完全不一样,微调时需要不同程度地“放开”。
我的做法是:视觉塔设置在 2e-5 到 5e-5 之间,文本塔可以到 1e-4,投影层因为是随机初始化的,单独放一个 1e-3 的学习率也无妨。分组学习率实现不难,PyTorch 的 param_groups 就能办到。如果你发现某个塔 loss 震荡,优先把学习率调低,而不是加正则。
5.3 文本 padding 的 attention mask 没传
这是一个非常隐蔽但后果严重的 bug。我在调试时发现 loss 表现正常,但检索结果总把一些空泛的描述排在前面。翻了半天代码,发现文本塔的前向调用里没有传 attention_mask,padding token 跟真实 token 被一视同仁地参与了注意力计算。
padding 是 0 向量,它 attend 其他 token 时产生的梯度全部是噪声;而其他 token attend padding token 时也会学到一堆无意义信息。更关键的是,padding 的位置在不同句子中不同,模型学到的是跟位置绑定的伪模式。修复方式就是老老实实把 DataLoader 里的 attention_mask 传给文本塔,一个参数都不能漏。
5.4 训练和推理的图像预处理不一致
最后这个坑是在部署时踩到的。训练阶段我对图像做了 resize 到 224、随机裁剪、随机水平翻转,但推理服务里只做了 resize 和归一化,没有随机裁剪,也没有翻转。结果线上指标跟离线验证差了一截。
原因很简单:随机裁剪和翻转是数据增强,它们让模型学会对轻微位移和镜像变换鲁棒,但推理时直接喂原图,图像的内容分布跟训练时“看到”的分布有差异,特征空间就漂移了。解决方式不是把增强完全去掉,而是统一预处理链路:训练和推理都用“先 resize 到 224x224、再中心裁剪到 224x224”这种确定性流程,只在训练时额外叠加随机增强。上线前一定要拿训练阶段同一张图、同一条文本在离线和服务里各跑一遍,确认特征输出一致,再谈效果。
6. 从图文匹配到更远的多模态方向
6.1 从图文对扩展到视频-文本理解
图文匹配的框架稍作改动就能迁移到视频-文本任务。对视频按一定帧率采样,比如每秒采 1 帧,每帧过同一个图像塔得到帧级向量,然后做时序平均池化或者更高级的时序注意力,得到视频级向量,再拿去跟文本向量做对齐。这样能直接复用已经训练好的图文对齐能力,避免从头训练一个视频塔。
我自己在某短视频标签项目中试过这个思路:用预训练好的图文双塔模型,加上一层简单的帧平均,就把视频-文本检索的 baseline 跑通了,效果比找现成视频预训练模型再微调还要稳,主要是图文塔已经学到了更丰富的文本语义,视频侧只是时空分布的延伸。
6.2 从对比学习走向生成式预训练
对比学习路线擅长检索,但模型学到的是判别式表征,对“为什么这张图配这句话”没有细粒度解释能力。如果想要模型真正理解多模态内容,下一步是生成式预训练。
具体做法可以是在双塔模型之上加一层生成头:给定图像特征,去预测被 mask 掉的文本 token;反过来给定文本特征,去预测被 mask 的图像 patch。这种跨模态 mask 重建目标,能在对比学习之外给模型补充更强的细粒度对齐能力。训练成本会增加,但换来的是下游任务上更丰富的语义理解。
6.3 工程落地阶段的小建议
模型只是一部分,真正工程落地时我还有几条实操建议。图像特征一定要离线批量算好,用近似最近邻检索库做索引,线上只算文本向量,延迟能压到几十毫秒级别。模型蒸馏不要放到最后才做,训练时就考虑小模型的目标蒸馏会让迁移更平滑。数据清洗优先级永远高于模型换皮,多模态模型对数据噪声的敏感度超出大多数人的预期,几小时的清洗往往比换个 loss 函数更有效。
最后聊一点我自己的整体体会。做多模态 ViT 项目最大的收获是明白了“任务决定架构”:只想做检索,就别一上来上单流大模型,双塔配合大规模负样本才是性价比之王;要做细粒度理解和生成,再考虑更强交互的单流或交叉注意力。另一个深刻的体会是,多模态训练里数据清洗带来的收益几乎被所有人低估。我见过太多团队花两周调模型结构,却不愿意花一天洗数据,效果一直卡着上不去。先把图文对数据洗干净,再把本文里这几个基础坑排掉,你的多模态 ViT 项目至少能少走一个月的弯路。