ARTICLE · INTELLIGENCE

战地情报 · 详情页

来自尧图项目组的一线实战观察与深度解析

多模态ViT原理与实战:从图文检索到跨模态对齐

多模态ViT原理与实战:从图文检索到跨模态对齐

多模态 ViT 这几年把计算机视觉折腾得够呛,也带来了不少实用玩法。从最初大家以为 Transformer 只是 NLP 的专属玩具,到现在 ViT 在分类、检测、检索、图像描述甚至多模态问答里遍地开花,变化快得让人有点跟不上。很多刚接触多模态 ViT 的同学,第一反应通常是:这不就是把图片切块丢进 Transformer 吗?真上手才发现,光是一个特征对齐就能把人绕晕。这篇文章不打算堆公式,而是从原理到实战,把多模态 ViT 的骨架、关键机制、训练细节和踩坑经验完整过一遍。

适合谁看?已经跑过基础分类网络(CNN 系或 ViT 系)、想往多模态方向深入的同学,或者正在做图文检索、图像描述、视觉问答这类任务、想搞清楚模型内部在做什么的开发者。全文不依赖特定框架,代码示例以 PyTorch 风格为主,配套思路可以迁移到其他框架。

1. 多模态 ViT 到底解决了什么问题

1.1 单模态 ViT 的边界在哪

先把基础捋清楚。ViT(Vision Transformer)的思想很简单:把图片拆成 16x16 或 14x14 的 patch,每个 patch 展平后经过线性投影变成 token embedding,再拼上一个 CLS token,加上位置编码,丢进标准 Transformer Encoder。分类时取出 CLS token 对应的输出特征,过一个 MLP Head 就完事。

这套结构在 ImageNet 级别分类任务里已经验证过了,预训练规模足够大时,ViT 的迁移能力甚至超过同量级的 CNN。但单模态 ViT 有个天然边界:它只在视觉模态内部做信息建模,拿到的是一个“图像整体语义”的 embedding,可它不理解这张图和一段文字提到的东西是不是同一件事,也不知道“一只戴帽子的柯基”和一张“没戴帽子的柯基”图片之间差在哪。说白了,单模态模型的特征空间是“闭门造车”的,只见过图片,没见过文字、音频其他模态长什么样。

多模态 ViT 想解决的问题,就是把这个封闭空间打开,让视觉 token 和文本 token 能在同一个语义空间里对齐、交互。对齐得好不好,直接决定下游任务的天花板。

1.2 多模态融合的三种姿势

跨模态融合不是 ViT 才有的需求。在它之前,CNN + RNN、CNN + Attention 已经做了很多尝试。但当时的融合方式比较“粗放”,要么把图像全局特征 concat 到文本特征前面,要么在最后几层简单做点注意力加权,模态之间缺乏细粒度的信息交换。

多模态 Transformer 家族的融合策略大致分三类,理解这三类对后续实验选型非常关键:

  • 单流融合(Single-Stream):图文 token 拼接成一条序列,共用同一个 Transformer Encoder。典型代表是早期的 VisualBERT、UnifiedQA 这类结构。实现简单,参数共享充分,但模态间相互干扰比较严重,文本 token 和图像 token 的长短比例失衡时,容易导致一方特征被稀释。
  • 双流融合(Dual-Stream):文本和图像各走一路独立 Encoder,只在特定层做跨模态交互(典型是 cross-attention)。CLIP、ALIGN 属于这个路线,双塔输出的特征各自独立、又通过对齐 loss 拉近,推理时可以预先抽特征做检索,效率很高。
  • 混合式/分阶段融合(Hybrid):前几层模态独立编码,中间某几层插入跨模态融合模块,兼顾独立语义提取和交互对齐。BLIP、FLAVA 这类模型基本是这个思路的变体。

选择哪种方案,取决于任务对“细粒度交互”的需求程度。做大规模图文检索,双流最划算,因为可以对图像库做离线特征索引;做视觉问答、图像描述这种需要 token 级细粒度推理的任务,单流或者混合式效果更稳。

1.3 为什么偏偏是 ViT 而不是纯 CNN

一个很现实的问题:多模态模型一定要用 ViT 吗?用 ResNet 抽取图像特征,再和 BERT 的文本特征做 cross-attention 不行吗?

可行,但效果和灵活性差一截。原因有三:第一,CNN 的特征图是网格状的,要转换成 token 序列还得做 flatten 和线性映射,中间多了一层结构磨损;ViT 天然以序列形式处理视觉信息,和 Transformer 文本分支共用一套位置编码和注意力机制,跨模态交互实现起来干净很多。第二,CNN 的感受野靠堆叠层数慢慢扩大,而 ViT 第一层就能做全局注意力,找图文对应关系时,能直接建立“远端 patch”和“远端单词”的联系,这对对齐任务很重要。第三,ViT 的 patch 粒度可以灵活调整,多模态任务里经常需要高分辨率 patch 保留局部细节(比如 OCR 类任务),ViT 只需改 patch size 或输入分辨率,CNN 要动整个骨干结构。

当然,我不是说 CNN 完全不行,轻量移动端场景里 CNN 分支依然有价值。但从主流 roadmap 看,ViT 系已经成了多模态骨干的默认起点。

2. 多模态 ViT 的核心机制拆解

2.1 Patch Embedding 与视觉 Token 化

ViT 的第一步是把图片变成 token 序列。假设输入是 224x224x3 的图,patch size 为 16,则图片被切成 (224/16)^2 = 196 个 patch,每个 patch 尺寸 16x16x3。把这 196 个 patch 各自展平成 768 维向量(通过一个可学习的线性投影层实现),就得到了 196 个视觉 token。

这一步有几个容易被忽略的细节。第一,patch size 的选择会影响性能和精度的平衡:patch size 越大,token 数越少,计算量越小,但局部细节信息越容易丢失;patch size 越小,token 数越多,计算量指数增长,但细节保留好。第二,线性投影层的初始化方式很关键,一般直接用截断正态分布初始化就行,但要注意和后续 LayerNorm 的配合。第三,位置编码建议用可学习的 1D 位置编码,直接用 2D 位置编码在某些细粒度任务上稍好,但差异不大,不必纠结。

项目里经常还会做一件事:在视觉 token 的最前面加一个 CLS token,用来聚合整张图的语义信息。分类任务用 CLS 特征,检索任务也可以用;但多模态任务里,CLS token 不是必须的,很多双流模型干脆不用 CLS,直接对 patch token 做 mean pooling,效果也很稳。

2.2 CLS Token 与全局特征聚合的隐晦细节

CLS token 的运作方式很容易被误解。它不是特殊魔法,就是一个随机初始化的 token,和所有 patch token 一起进入 Transformer,在自注意力过程中通过 attention 权重“吸收”整个序列的信息。最后一层出来后,这个位置上的输出向量就被当作全局特征。

实操中有个常见坑:CLS token 的语义偏向性和池化特征不一样。CLS token 更关注“区分性信息”,适合判别类任务;mean pooling 特征更关注“整体统计信息”,适合检索类任务里的召回。同一个模型,用 CLS 还是用均值池化,在检索评测里可能差 2-3 个点。我的经验是:不确定时两个都试,交叉验证选结果更好的那个,成本很低。

还有个细节,预训练模型一般会在最后一层丢掉 CLS 之前的中间特征,但如果你做的是细粒度检索(比如找同款衣服),试试把倒数第二层的 CLS 特征抽出来用,经常有惊喜。

2.3 Cross-Attention 如何完成模态间信息交换

跨模态交互是多模态 ViT 的灵魂。以双流模型为例,文本 Encoder 的输出作为 Query 的来源,图像 Encoder 的输出作为 Key 和 Value 的来源,做一次注意力计算,让每个文本 token 都能“看到”所有图像 patch,再决定重点关注哪里。反过来,图像分支也可以用类似方式去 attend 文本 token。

这种设计直觉上是合理的:每个单词和每个图像 patch 之间的相似度得分,就是它们语义对齐程度的度量。比如文本里有“酒杯”,图像某个 patch 里刚好有酒杯形状,这个 patch 的 Key 和“酒杯”的 Query 算出的 attention score 就会偏高,后续融合特征里酒杯相关的信息占比就更大。

实现时有个细节值得注意:Cross-Attention 里的 QKV 投影是否需要共享权重?共享权重可以显著减少参数量,但前提是两个模态的特征空间已经经过预训练对齐,否则共享投影会拖慢收敛甚至不收敛。我实践下来的建议是:除非你用的是已经对齐过的预训练主干,否则先用独立投影,等对齐效果稳定后再尝试共享权重,别一上来就为了省参数给自己埋坑。

2.4 模态对齐损失函数:InfoNCE 和它的变体

跨模态对齐的核心训练目标,通常是 InfoNCE 对比损失。以图文对为例,一个 batch 里有 N 个图文对,把每个图像的 embedding 和每个文本的 embedding 两两计算相似度,得到 NxN 的相似度矩阵。对角线上的 N 个位置是正样本对(图像和对应的文本),其余位置是负样本对。InfoNCE 要做的事,就是让对角线相似度尽量大、非对角线尽量小。

代码实现上有一个极易写错的细节:相似度矩阵计算之前,两个模态的 embedding 必须做 L2 归一化。不归一化的话,向量模长差距会干扰相似度的可比性,训练时 loss 降得很慢,甚至出现梯度振荡。另一处是温度系数 tau 的选择,常见初始值在 0.07 左右,tau 太小会让 loss 对相似度分布太敏感,容易出现训练不稳;tau 太大会让正负样本区分不明显,学到的特征区分度不够。

InfoNCE 的变体也很多,比如有监督对比学习(结合 label 构造正负样本)、跨模态难负样本挖掘(hard negative mining)、解耦对比学习(DCL,去掉正样本对之间的互信息约束)。变体选型不是越复杂越好,我的经验是:数据干净、量大的时候,InfoNCE 原版就够了;数据噪声大、正样本本身不可靠时,加难负样本挖掘收益更明显。

3. 实战案例:一个多模态图文检索 Demo 的完整实现

3.1 任务定义与数据集准备

这个案例做的是标准图文双向检索:给定一张图,从文本库中找到最匹配的描述;给定一句话,从图像库中找到最对应的图。模型采用双流结构,视觉分支和文本分支各自独立编码,最后用 InfoNCE 对齐图文 embedding。

数据集选一个中等规模的中文图文对数据集。实际项目里数据清洗比模型结构重要得多,简单说三条硬规则:去重(完全相同的文本对和几乎重复的图像都去掉)、过滤低质量文本(长度小于 3 个字、全是数字、乱码文本直接丢弃)、图文镜像(确认图像方向和文本语义是否匹配,比如“左手指向”这类方向敏感描述需要过滤)。

训练集/验证集/测试集划分比例按 8:1:1,评估指标用 Recall@1/5/10,不做额外蒸馏或精调,先跑通 baseline。

数据量不大时(比如 5 万对左右),对 ViT-Base 来说小跑一轮也就几个小时的事,关键观察点在于 loss 的变化趋势和检索指标的增长曲线是否符合预期。

3.2 模型代码骨架与关键超参数

完整代码不贴了,这里给出核心结构,PyTorch 风格,方便照着改:

import torch import torch.nn as nn import torch.nn.functional as F class ViTEncoder(nn.Module): def __init__(self, img_size=224, patch_size=16, in_chans=3, embed_dim=768, depth=12, num_heads=12): super().__init__() self.patch_embed = nn.Conv2d(in_chans, embed_dim, kernel_size=patch_size, stride=patch_size) self.cls_token = nn.Parameter(torch.zeros(1, 1, embed_dim)) self.pos_embed = nn.Parameter(torch.zeros(1, (img_size // patch_size) ** 2 + 1, embed_dim)) self.blocks = nn.ModuleList([ TransformerBlock(embed_dim, num_heads) for _ in range(depth) ]) self.norm = nn.LayerNorm(embed_dim) def forward(self, x): B = x.shape[0] x = self.patch_embed(x).flatten(2).transpose(1, 2) cls_tokens = self.cls_token.expand(B, -1, -1) x = torch.cat([cls_tokens, x], dim=1) x = x + self.pos_embed for blk in self.blocks: x = blk(x) return self.norm(x)[:, 0] class TextEncoder(nn.Module): # 文本分支可直接复用预训练 BERT encoder,取 [CLS] 输出 def forward(self, input_ids, attention_mask): outputs = self.bert(input_ids, attention_mask=attention_mask) return outputs.pooler_output class MultiModalViT(nn.Module): def __init__(self): super().__init__() self.vision_encoder = ViTEncoder() self.text_encoder = TextEncoder() self.tau = nn.Parameter(torch.log(torch.tensor(1/0.07))) def forward(self, images, input_ids, attention_mask): img_feat = self.vision_encoder(images) txt_feat = self.text_encoder(input_ids, attention_mask) return F.normalize(img_feat, dim=-1), F.normalize(txt_feat, dim=-1)

关键配置:视觉分支 embed_dim 768、12 层 Transformer、patch 16、输入分辨率 224;文本分支用 12 层 BERT-base。优化器 AdamW,学习率视觉分支 1e-4、文本分支 2e-5(如果加载预训练则更小),weight decay 0.05,warmup 比例 5%,batch size 128。单卡显存不够时先减小 batch size,别轻易动学习率。

训练日志里要重点盯两个东西:loss 曲线和 R@1。如果 loss 降到 4.0 附近却怎么也降不下去,先检查 L2 归一化或者温度系数 tau 是否被误设成固定常量。如果 loss 正常降、R@1 不涨,大概率数据质量有问题(图文对错配)。

3.3 训练技巧与 trick 实践记录

训练过程中有几个体感很明显的 trick,分享实测结论:

  • 冻结 vs 微调:数据量小于 10 万时,视觉分支建议冻结前 6 层,只微调后 6 层和 patch embedding;文本分支如果用了预训练模型,建议整体冻结,只微调最后一层和投影头。原因很简单,双流模型图文数据不足时,两个分支同时大幅微调会让特征空间快速漂移,对齐信号还没建立起来就乱套了。
  • 数据增强:图像侧常规增强(随机裁剪、翻转、颜色抖动)就够,别用太强的几何增强(比如大角度旋转),因为文本描述可能包含方向词,增强后文本语义和图像内容就对不上了;文本侧加一点 random mask 增强鲁棒性。
  • 梯度累计:小显存场景下梯度累计能缓解 batch size 过小的问题,但注意 BN 类结构(如果有)会受影响,ViT 里基本没有 BN,体验比 CNN 平滑很多。

另一个容易忽视的点:每轮验证时,检索指标要用整库计算,而不是只在验证集子集上算。特征先全部抽出来存到内存,然后用矩阵乘法批量算相似度,这样得到的 R@K 才接近线上效果。

3.4 评估方法与检索效果优化

检索评估不需要走 PyTorch 的 eval loop,直接抽特征、算相似度、排 Top-K 即可。框架代码如下:

def compute_recall(image_loader, text_loader, model, k=10): model.eval() img_feats, txt_feats = [], [] with torch.no_grad(): for images, _ in image_loader: img_feat = model.vision_encoder(images.cuda()) img_feats.append(F.normalize(img_feat, dim=-1).cpu()) for input_ids, attn_mask, _ in text_loader: txt_feat = model.text_encoder(input_ids.cuda(), attn_mask.cuda()) txt_feats.append(F.normalize(txt_feat, dim=-1).cpu()) img_feats = torch.cat(img_feats) txt_feats = torch.cat(txt_feats) sim = txt_feats @ img_feats.T recall = 0 for i in range(sim.shape[0]): topk = sim[i].topk(k).indices.tolist() if i in topk: recall += 1 return recall / sim.shape[0]

优化效果最明显的一步是难负样本挖掘:用当前模型从全库召回 top-K 个相似但不匹配的样本,加入训练 batch 作为额外负样本。这个操作能把 R@1 从大约 52% 拉到 58% 左右(不同数据集差异大,但趋势稳定)。代价是训练时间大概增加 30%-50%,且每过几个 epoch 需要重新挖掘一次,否则负样本会过时。

4. 数据、优化与效率:决定多模态 ViT 上限的隐性因素

4.1 数据质量比模型结构更致命

这句话值得重复三遍:多模态模型对数据质量的敏感度,远超单模态模型。单模态模型遇到脏数据,顶多是学到的特征不够干净,在分类任务里只是掉几个点;多模态模型图文对错配,会让对齐目标根本不存在,模型只能自己硬找“相关性”,最后常常学到一些虚假捷径,比如只看图的颜色分布就做匹配。

实际项目里,建议清洗流程按以下顺序走:完全重复的图文对去重、短文本过滤(少于 3 个字)、图文错配抽样检测(随机抽几千条人工看一遍)、困难样本增强(把某些正样本对的文本换一部分词,变成“语义相似但不匹配”的困难负样本)。最后一步特别有效,能让模型学到细粒度区分,而不是靠模糊匹配蒙混过关。

4.2 学习率策略与优化器选择

多模态 ViT 的优化器选择,AdamW 是默认答案,几乎没有例外。SGD 跑 ViT 也不是不行,但收敛慢,且对学习率的敏感度极高,工程上不推荐。

学习率设置要分模块拆:视觉分支如果随机初始化,初始学习率 1e-4 左右,训练中途用 cosine 调度降到底;文本分支如果加载预训练 BERT,学习率至少要低一个数量级(2e-5 起),否则预训练知识会被快速破坏。我的经验是,文本分支的破坏是不可逆的,一旦没保住,后面恢复很难,所以宁可一开始调低一点,也不要为了冲初始指标而开高。

还有个小 trick:训练前 5% 的 step 做 warmup,但 warmup 的终点学习率不要直接跳到目标值,做一个线性插值到目标学习率的 1/10,再通过 cosine 降下去,全程会更稳定。这个细节某些开源实现里有,但很多人没注意到。

4.3 训练效率与显存优化

ViT 本身是显存大户。图像侧 196 个 token,每层注意力矩阵是 196x196,看着不多,但 12 层叠起来,再乘以 batch size,显存压力马上就来了。几个参考方案:

  • 梯度检查点(gradient checkpointing):用计算换显存,backward 时重算 forward 的中间激活,显存占用大概下降 30%-50%,训练时间增加 15%-20%。多模态双流模型非常适合这个方法,因为两个分支的中间激活都可以重算。
  • 混合精度训练(AMP):FP16 训练几乎是标配了,显存直接减半。注意 loss scale 策略要开动态的,否则 loss 容易下溢。
  • FlashAttention:如果你的框架支持,FlashAttention 能把注意力计算从 O(N^2) 显存降到 O(N),长序列场景(比如高分辨率图或长文本)收益极大。

做图像侧高分辨率输入时(比如 384 或 512),可以只对视觉分支开梯度检查点,文本分支保持全精度,这样显存开销增长可控。

4.4 模型训练后的压缩与部署思路

多模态 ViT 的推理部署,常见瓶颈在视觉分支的 backbone。文本分支一般用 CPU 也能跑得动,图像分支的 patch embedding 和 12 层 Transformer 在 CPU 上会拖慢响应时间。

推荐几个部署期做法:视觉分支蒸馏到小模型(比如把 12 层的 ViT-Base 蒸馏到 8 层 ViT-Small),精度损失通常可以控制在 1-2 个点,速度提升明显。蒸馏时注意只蒸馏最后一层 CLS 特征是不够的,建议中间层的特征也做一层 L2 匹配,不然小模型学到的是“形似神不似”。量化方面,动态量化对文本分支效果尚可,对视觉分支建议做 QAT(量化感知训练),直接 PTQ 掉点会比较大。如果部署场景对延迟要求特别高,还可以把视觉分支的 patch embedding 融合进预处理管线,减少一遍全图卷积的开销。

5. 常见问题与排查技巧实录

5.1 图文不匹配数据导致 Loss 假性收敛

现象:InfoNCE loss 降得很顺,前几个 epoch 唰唰往下掉,但 R@1 只有个位数,或者几乎倒数。

排查思路:先验数据。抽 100 条训练样本人工看,是不是有大量图文无关对。这类数据会让模型学会“图像和文本都在说猫,但猫的细节对不上”这种粗略匹配,检索时只要语义大方向一致就给高分。解决办法是清洗数据、增加硬负样本,把模型逼回真正的细粒度匹配上来。

另一个隐蔽原因是数据 pipeline 里 image 和 text 的配对被 shuffle 错位了,尤其是多进程 DataLoader 配合缓存时,两个分支加载的索引不一致。先检查数据管道,再怀疑模型,顺序别搞反。

5.2 梯度爆炸与梯度消失的典型场景

ViT 结构本身梯度传播相对稳定,但跨模态训练里时有发生。最常见的原因是文本分支学习率太高,尤其是用了预训练 BERT 但没冻结也没降学习率时,预训练分布瞬间被冲垮,梯度开始剧烈振荡。

诊断方式很简单:打印每个分支的梯度 L2 范数,观察哪个分支先出问题。文本分支出问题就降文本学习率或冻结;视觉分支出问题就考虑是否 patch embedding 初始值过大,可以改小它的初始化标准差。注意别看到 loss 变 NaN 就盲目调全局学习率,一定要定位到具体分支模块。

5.3 输出特征各向异性退化问题

多模态训练里有个现象很恼火:模型的 embedding 分布越学越“扁”,所有特征差不多挤在一个很小的锥形区域里,相似度矩阵数值普遍偏高,正负样本几乎不可分。

这个问题的本质是对比损失把所有特征都往单位球面上拉,但缺少“均匀性”约束,导致特征坍缩到球面上一个很窄的区域。对策有几个:温度系数 tau 调大一点(比如从 0.07 调到 0.15)降低压缩强度;在 loss 里加一项均匀性正则(比如让特征在球面上尽量分散);或者直接用 DCL 这类解耦对比损失。

5.4 长尾分布与零样本检索效果不佳的分析

多模态数据天然有长尾问题,热门的类别样本多,冷门类别样本少。模型在频繁出现的图文对上能学到很好的匹配,遇到冷门实体就完全抓瞎。

改进方向分三个层次:数据层,对冷门类别的图文对做采样加权;模型层,加一个 learnable 的类别 embedding 先验;训练层面,用 focal contrastive loss 之类难例聚焦的变体。实际操作里最有用的是第一层,简单粗暴但见效快。

6. 个人实践总结与扩展建议

我跑过多轮多模态 ViT 项目之后,最大的体会是,模型结构虽然重要,但决定最终效果上限的往往不是结构本身,而是数据质量、对齐目标和训练节奏的配合。同一个 ViT-Base 结构,数据清洗前后检索指标能差 10 个点以上,这个数字比更换任何主流结构带来的涨点都大。

对刚入门的朋友,建议从双流结构起步,先跑通一个中等规模的图文检索任务,把 InfoNCE、温度系数、特征归一化这些核心机制吃透,再逐步尝试 BLIP 的分阶段融合或者单流结构的细粒度推理任务。每一步改动都盯着 ablation 曲线看,不要一口气换三四个模块,不然出了问题根本定位不到原因。

最后分享一个小技巧:多模态项目的实验记录,除了记录模型和指标,务必同时记录数据 pipeline 的版本和数据清洗规则。我掉过好几次坑,刷新一遍数据后指标涨了 3 个点,后来有人在群里问“为什么我复现不出这个结果”,查了半天才发现是清洗规则不一致。数据版本管理做得越细致,实验复现和后续迭代就越省心。多模态 ViT 这条路还没走到头,很多结构细节和训练策略仍然在快速演进,保持随时验证的心态,比追着新模型跑更实在。

RELATED READING

延伸阅读

更多一线实战笔记与深度复盘,助您持续精进