上个月一个做电商搜索的朋友又来找我诉苦用户输入“黑色皮质单肩包带金色链条”前几页总出现漆皮亮面款气得用户直接划走。他问要不要换更牛的视觉 Transformer 模型我说先别急你现在的图像特征和文本特征根本不是同一个语义空间的东西单模态 ViT 提的特征跟文本向量直接算相似度注定翻车。这篇多模态 ViT 的文章我想把这些年做视觉 Transformer 相关项目攒下的理解原原本本写出来从单模态为什么不够用到多模态的几种主流结构再到一份能跑通图文检索任务的完整代码最后附上我踩过的几个大坑。目标是让正在做图文检索、视觉问答、多模态理解项目的你少走弯路。1. 为什么单模态 ViT 只是多模态的起点1.1 五句话讲清楚单模态 ViT 怎么工作先花五分钟把单模态 ViT 的原理对齐一下不然多模态的部分容易卡壳。一张图片输入进来ViT 做的第一件事不是像卷积网络那样直接滑窗而是把图片切成一个个固定大小的 patch常见的是 16x16 像素一块比如一张 224x224 的图就能切成 14x14196 块。每一块 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_size224): 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], paddingmax_length, truncationTrue, max_length32, return_tensorspt ) 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_dim256): 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_idsinput_ids, attention_maskattention_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, temperature0.07): # img_emb, txt_emb shape: [batch, proj_dim] logits img_emb txt_emb.t() / temperature # [batch, batch] labels torch.arange(logits.shape[0], devicelogits.device) loss_img F.cross_entropy(logits, labels) # 图像视角 loss_txt F.cross_entropy(logits.t(), labels) # 文本视角 return (loss_img loss_txt) / 2训练配置上我用的 AdamW初始学习率 1e-4warmup 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 评估指标与效果验证图文检索的标准评估指标是 RecallK含义是给定一个文本查询在所有图像里检索正确的图像是否出现在排序结果前 K 名。我通常同时报 Recall1 和 Recall5。实现思路很简单把验证集所有图像特征一次性算好然后对每个文本特征去遍历所有图像特征算相似度排序后看正确图像的位置。我的训练结果在子集上跑 20 个 epoch等效 batch 128Recall1 从零散跑的 15% 提升到 54%Recall5 到了 79%。这个数字不算顶尖但对于一个没有精细调优的 baseline demo 已经很能说明问题双塔对比结构能快速吃到多模态对齐的红利。如果换成交叉注意力结构还能再提几个点代价是训练时间差不多翻倍。5. 训练多模态 ViT 的避坑实录5.1 Batch Size 太小对比学习原地踏步第一次跑这个模型时我图省事用了单卡 batch size 16。训练了 3 个 epochloss 几乎不动偶尔还往上跳。后来反应过来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_maskpadding 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 项目至少能少走一个月的弯路。
阅读完成 · 觉得有帮助?