ARTICLE DETAIL

资讯详情

深耕网站建设、视觉设计与SEO优化的一线实战洞察。

FLAVA统一视觉语言模型:三塔融合与多目标预训练工程实践

FLAVA统一视觉语言模型:三塔融合与多目标预训练工程实践

做过多模态项目的人大概都经历过这种分裂感:手上同时挂着三套代码,一套双塔模型跑图文召回,一套跨模态融合模型做精排,还有一套单独的分类头处理下游任务;三套代码三套数据管线,改一个特征维度要同步改三个仓库,调参的时候更是互相打架——召回模型把 batch 开大效果才好,融合模型显存又吃不下这么大的 batch。多模态、Transformer架构、跨模态预训练这几个词被说烂了,但真正落到工程上,"统一"这两个字值多少钱,只有被三套代码折磨过的人心里清楚。FLAVA 这类统一视觉语言模型,核心诉求就是把这条被拆碎的产线重新缝成一条:一个骨干、一套权重、一份数据管线,同一次前向过程里同时吐出单模态特征、跨模态对齐特征和融合特征,下游按需取用。这篇文章主要面向已经能独立跑通 CLIP 类对比学习训练、想进一步理解"三塔融合 + 多目标预训练"的读者,也照顾刚接触多模态、想把整体地图先画出来的同学。我会把架构分工、四个预训练目标的来龙去脉、数据构造、损失权重、显存账本、踩坑清单全部摊开讲,包含可以直接抄走改的代码骨架和参数计算过程。

1. 先把"统一"拆开看:FLAVA 到底统一了什么

1.1 一条被拆碎的产线:多模态任务的真实分布

要理解 FLAVA 的设计动机,得先看清楚多模态任务在真实业务里长什么样。粗略分一下,无非三类:第一类是检索类,给文本找图、给图找文,本质是算两个模态之间的相似度,要求的是各自模态的独立表征足够好,不要求深度融合;第二类是判别类,图像分类、图文匹配打分,需要模型判断"这一对配不配",往往需要模态间的细粒度交互;第三类是生成/问答类,视觉问答、图像描述,输入是多模态的混合序列,输出是文本,必须做真正的跨模态注意力。

问题就在于,这三类任务对模型结构的要求是互相拉扯的。检索任务喜欢双塔,因为图像和文本可以离线分别编码成向量,建索引,十万级、百万级的库也能毫秒级召回;一旦你为了做融合把两个模态塞进同一个编码器,图像侧就没法预计算了,线上必须实时跑一次完整前向,延迟直接翻十几倍。反过来,VQA 这类任务如果只用双塔,两个模态在编码阶段从未见过对方,交互只能发生在最后一步的点积上,细粒度信息全丢了。

主流的做法是"分而治之":双塔负责召回,融合模型负责精排。这套方案能跑通,但代价是两套模型、两份训练成本,而且两套表征空间是没有对齐的——召回模型认为相似的一对,精排模型可能给出完全相反的判断,工程师就得在中间加一层阈值校准,越调越玄学。FLAVA 这类统一模型的思路是:既然两套模型都要学,为什么不在预训练阶段就把单模态表征、跨模态对齐、深度融合这三件事一起学掉?学完之后,检索用哪部分特征、融合任务用哪部分特征,按需取用,不再需要维护两套参数。这个思路听起来顺理成章,但实现上要解决一个硬问题:三类目标对表征的要求本身就是矛盾的,怎么让它们在一个模型里共存而不互相破坏。

1.2 三条技术路线的取舍:双塔、融合、单一编码器

在动手看 FLAVA 的架构之前,我把当时可选的三条路线摆在一起对比过,这个对比表后来我在内部评审里反复用,因为它能很清楚地解释"为什么最后选了看起来最笨的那条路"。

路线代表结构优势致命短板
纯双塔图像编码器 + 文本编码器,末端点积图像可离线编码,检索吞吐极高模态间无细粒度交互,VQA 类任务天花板低
纯融合图像块与文本 token 拼成一个序列,单编码器交互最充分,生成类任务表现好图像侧无法预计算,检索场景成本不可接受;单模态特征质量差
双塔 + 融合编码器两个单模态编码器 + 一个跨模态编码器三种特征都能拿到,任务各取所需参数多、训练目标多、训练不稳定,调参难度陡增

第三条路就是 FLAVA 走的路。它看起来是"既要又要",但仔细想,它其实是在工程上做了一次成本置换:用更多的参数和更复杂的训练目标,换掉了"维护多套模型 + 表征空间不对齐"这两个长期的隐性成本。我认为这个置换在数据量足够大的时候是划算的,数据量小的时候则完全不划算——后面讲数据配比的时候我会具体说这个盈亏平衡点大概在哪。

关键的设计洞察在于:单模态编码器和跨模态编码器承担的角色是不一样的。单模态编码器只需要回答"这张图是什么"、"这段话说了什么",它的输出可以离线缓存,因此适合检索;跨模态编码器只需要回答"这图和这段话放在一起是什么意思",它天然需要看到两个模态的完整序列,因此适合融合任务。把这两件事塞进同一个编码器,就会出现"为了融合牺牲检索效率"或者"为了检索牺牲融合深度"的二选一;分开之后,两个目标各得其所,互不干扰。

1.3 三个编码器的分工与参数账

具体到结构,FLAVA 由三块组成。图像编码器是一个标准的 ViT,输入 224×224 的图,切 16×16 的块,得到 196 个 patch token,加上一个 [CLS],走 12 层 Transformer,隐藏维度 768。文本编码器是 12 层的文本 Transformer,隐藏维度同样 768,结构上接近常见的 base 级编码器。多模态编码器只有 6 层,输入是图像编码器和文本编码器输出的 token 序列拼接而成的长序列,两个模态在这里做完整的自注意力交互。

这个层数配置不是随便定的。单模态编码器要深,因为它承担了绝大部分表征学习的活儿,特征质量直接决定检索效果;多模态编码器可以浅,因为它的输入已经是经过充分编码的语义 token,它只需要做"对齐"和"整合",不需要从像素和字符级别重新学一遍。6 层是个经验值,我试过把它压到 4 层,VQA 类任务的准确率掉了将近两个点,加到 8 层收益就很小了,参数量却涨了三成。如果资源紧张,把多模态层砍到 4 层、把省下的预算加到单模态编码器上,是更划算的做法。

参数量粗略算一下:ViT-B/16 大约 8600 万参数(含 patch embedding),文本编码器 12 层 768 维,主体约 8500 万,多模态编码器 6 层 768 维约 4300 万,加上三个模态各自的投影头、两个掩码预测头(文本词表头、视觉码本头),总量在 2.2 亿到 2.5 亿这个区间,取决于词表大小和视觉码本规模。这个量级在今天看不算大,但训练的复杂性远高于参数量给人的直觉——四个训练目标同时开火,任何一个权重设错,整个模型就废了。

1.4 为什么不用一个编码器硬扛

这里我想多说一句,因为这是最容易走偏的地方。很多人第一反应是"既然 Transformer 能吃任意序列,那我把图像块和文本 token 拼起来喂给一个编码器不就行了,何必搞三个"。这条路不是没人走过,早期的纯融合模型就是这么干的,效果在某些生成任务上还不错,但有两个绕不过去的坎。

第一个坎是单模态特征退化。当图像 token 和文本 token 始终在同一个注意力空间里竞争时,模型会倾向于学"跨模态的联合表征",而不是"纯粹的视觉表征"。结果是图像侧的 [CLS] 向量在做纯视觉检索时表现明显不如独立训练的 ViT。第二个坎是成本。图像一旦不能离线编码,每次文本查询都要重新跑一遍图像编码,检索库上到百万级,这个开销就没法接受了。

所以三编码器不是为了炫技,是被这两个约束逼出来的。理解了这一点,后面看那四个训练目标的设计就不会觉得是在堆料。

2. 四个预训练目标的设计逻辑与数学表达

2.1 多模态掩码建模:让模型学会"补全"

掩码建模是这套预训练的主干。思路很朴素:把输入的一部分遮住,让模型根据剩下的部分猜出被遮住的内容。文本侧沿用经典的 15% 掩码、其中 80% 换成掩码符号、10% 随机替换、10% 保持原样的策略;图像侧则激进得多,遮掉 70% 的 patch,并且采用块状掩码而不是随机单点掩码——因为图像相邻 patch 高度冗余,随机遮单点的话,模型只看邻居就能猜出来,学不到东西。

三个编码器各自都要做掩码预测,但预测的任务不一样。文本编码器做的是文本掩码预测,词表分类;多模态编码器做的是双模态的掩码预测——它同时要预测被遮住的图像 patch 属于视觉码本里的哪一项,以及被遮住的文本 token 是哪个词。图像编码器本身通常不做掩码预测(它是被冻结或者只做轻量微调的),它的职责是提供干净的视觉特征,这一点很关键,因为如果图像编码器也参与掩码重建,它学到的表征会偏向"重建友好"而不是"语义友好",检索性能会掉。

视觉码本这块需要说明一下。图像 patch 不能像文本那样直接分类到连续像素,必须先离散化。常见做法是用一个离线训练好的视觉 tokenizer 把每张图编码成一串离散索引,码本大小通常取 8192 到 16384。这个 tokenizer 的质量直接决定了图像掩码预测这个目标的上限——码本太粗,重建任务太简单,学不到细粒度;码本太细,分类头太大,训练不稳定。我的经验是 8192 是个不错的起点,如果下游任务明显依赖细粒度纹理(比如工业质检),可以考虑上到 16384。

掩码建模的损失就是标准的交叉熵:

L_MMM_text = CE(text_logits[被遮位置], text_labels) L_MMM_image = CE(image_logits[被遮位置], image_code_labels)

注意只在被遮住的位置算损失,没遮的位置不参与,这一点和语言模型的因果预测不一样,别搞混。

2.2 图像-文本匹配:给全局一个二分类判据

光有掩码建模是不够的,因为它学的是"局部片段的补全能力",模型完全可以把一张图和一段毫不相干的文本各自补全得很好,两者的联合表征依然是错的。图文匹配这个目标就是用来堵这个漏洞的:把图像和文本一起喂进多模态编码器,取 [CLS] 位置的输出过一个二分类头,判断这一对到底配不配。

负样本怎么采是关键。最省事的做法是从当前 batch 里随机挑另一条文本配给这张图,但随机采出来的负样本往往"太简单"——模型只要看个大概就能区分,学不到细粒度。更有效的做法是难负样本挖掘:先用当前的模型算一遍相似度,把那些"模型觉得很像但其实不配"的样本挑出来当负样本。这套做法在小 batch 下效果有限,因为候选池子太小;batch 上千之后,难负样本的质量会明显提升。

实际实现上有个细节容易踩坑:难负样本挖掘需要额外跑一遍前向,训练吞吐会掉 20% 到 30%。我的做法是每隔若干步才做一次挖掘,把挖到的负样本缓存起来复用,这样吞吐损失能压到 10% 以内,效果几乎无损。另外负正样本比例不要设得太极端,1:1 到 1:2 之间比较稳,比例再高模型会偏向"判负",正样本的得分也被压低。

2.3 对比学习:把整个 batch 当负样本池

对比学习这一路是让两个单模态编码器的输出空间对齐。具体做法是把图像 [CLS] 和文本 [CLS] 各自过一个投影头,映射到同一个低维空间(通常是 256 或 512 维),然后在这个空间里做 InfoNCE:一张图和它配对的文本算正样本,和 batch 里其他所有文本算负样本。

损失长这样,我用 PyTorch 风格写出来,方便直接对照:

def clip_loss(img_emb, txt_emb, temperature): # img_emb, txt_emb: [B, D],已经做过 L2 归一化 logits = img_emb @ txt_emb.t() / temperature # [B, B] labels = torch.arange(logits.size(0), device=logits.device) loss_i2t = F.cross_entropy(logits, labels) loss_t2i = F.cross_entropy(logits.t(), labels) return (loss_i2t + loss_t2i) / 2

温度系数是个可学习参数,初始化 0.07,训练过程中会自己往下走——通常会收敛到 0.01 到 0.03 之间。我一开始把温度写死成 0.07 不动,结果训练后期损失就卡住不降了,改成可学习之后曲线立刻通畅。这是个很小但很实用的细节。

对比学习对 batch size 极其敏感,因为 batch 里的其他样本就是负样本池。batch 从 256 加到 1024,检索召回率通常能涨三到五个点;但从 1024 再加到 2048,收益就很小了,边际递减非常明显。所以我的建议是:显存允许的话,优先把 batch 堆到 1024 左右,再往上加不如把同样的算力花在数据质量上。

2.4 四项损失的加权与合并方式

把四个目标合起来,总损失是这样:

L_total = λ_M · L_MMM + λ_I · L_IMG_MLM + λ_T · L_TXT_MLM + λ_C · L_CLIP + λ_ITM · L_ITM

权重怎么定,是这套方法里最玄学的部分。我见过不少复现失败案例,八成栽在这里。几个原则我踩过坑之后总结出来了:

第一,对比学习的权重不要一开始就给满。训练早期单模态编码器的表征还很粗糙,对比损失梯度又大,权重给高了会把模型往"两个模态各自坍缩到一个点"的方向推。稳妥的做法是前若干步用较小的权重(比如 0.3 到 0.5),等掩码建模的损失降到一定程度再逐步提到 1.0。

第二,掩码建模的权重整体上要比对比学习高一些。因为掩码建模的损失绝对值本身就比对比损失大(词表几十类,交叉熵量级不同),如果不做归一化直接用同样的权重,掩码项会完全主导梯度。实践中我会先各跑几百步看一下四项损失的量级,再按"让四项损失大致落在同一数量级"的原则去配权重,这比死记一组数字有用得多。

第三,图文匹配的权重可以稍低。它的作用是修偏,不是主力,权重给到 0.5 左右通常就够。给太高会让模型过早地在融合空间里"锁死"判断,反而抑制了细粒度的表征学习。

下面这张表是我自己的起始配置,仅作参考,实际一定要根据损失曲线动态调:

损失项起始权重观察指标调整方向
图像掩码建模1.0重建准确率长期不涨则考虑码本太粗
文本掩码建模1.0掩码位置准确率曲线抖动则降学习率或加 warmup
多模态掩码建模1.0双模态重建准确率明显低于单模态时检查模态类型嵌入
对比学习0.3 → 1.0正样本相似度与负样本差值差值不拉开说明 batch 太小或温度不对
图文匹配0.5二分类准确率过快到 95% 以上说明负样本太简单

3. 数据工程:两类数据源与掩码策略

3.1 有标注图文对与单模态无标注数据的混配

FLAVA 的训练数据由两部分组成,这个设计很有意思,也最容易被简化掉。一部分是图文对数据,用来支撑对比学习和图文匹配这两个需要成对输入的目标;另一部分是无标注的单模态数据——纯图像、纯文本,它们不参与对比学习,只参与各自的掩码建模。

为什么要掺单模态数据?因为高质量的图文对太贵了。几千万对已经是公开数据的量级,但文本语料和图像语料各自都是论亿计的。如果只用图文对训练,文本编码器的词表和语言能力会被图文对里的短文本严重限制——图注大多很短,二三十个词,模型学到的语言分布非常窄。掺入纯文本数据能让文本编码器真正学到语言结构,掺入纯图像数据也一样,能让图像编码器见到更丰富的视觉分布。

配比上,我的经验是图文对、纯图像、纯文本大致按 1:1:1 的量级混合比较稳,但要保证图文对在每个 batch 里都占足够比例,否则对比学习那一项就没法算了。实现上我是这样做的:每个 batch 里固定划出一部分样本作为图文对,剩下的位置从纯图像池和纯文本池里轮流填。这样既保证了对比学习有足够的负样本池,又不浪费单模态数据。

有个坑要提醒:单模态数据的掩码建模损失和图文对里的掩码建模损失,量级是不一样的。纯文本的句子长度分布和图文对里的短图注差异很大,如果混在一起不做长度归一化,你会看到损失曲线呈现出诡异的周期性波动——每次采样到长文本 batch 就跳一下。解决办法是损失按有效 token 数取平均,而不是按 batch 取平均。

3.2 图像块掩码与文本掩码的具体做法

图像侧的掩码策略值得单独说。前面提到遮 70%,但"怎么遮"比"遮多少"更影响效果。有两种常见方案:随机遮单个 patch,或者按块遮(把图像切成若干大块,随机选一部分块整体遮掉)。随机单点遮的问题在于,相邻 patch 之间相关性极强,模型只看四周就能推断出中心,任务太简单。块状遮法逼着模型做更长程的推理,学到的表征质量明显更好。

实测对比下,同样是 70% 的掩码率,块状遮法在下游分类任务上能比随机单点遮高出两到三个点。代价是实现复杂一点,需要先生成一个块级的随机掩码再上采样到 patch 级别。

文本侧的掩码相对标准化,15% 的比例,按 BERT 那套 80/10/10 的策略。但有个细节:图文对里的文本往往很短,如果按固定比例遮,很短的句子可能一个词都遮不到,这个样本就白跑了。我的做法是设一个保底数量,比如至少遮一个 token,避免浪费。

3.3 数据管线的并发与预处理开销

数据管线这块说起来枯燥,但它决定了你的训练能不能跑满卡。图像解码和增强是 CPU 密集型操作,一张 224×224 的图从 JPEG 解码到张量,单核大概要几毫秒;一个 batch 一千张,就是几秒钟,完全可能拖垮 GPU 利用率。

几个实际有效的优化:一是把图像预先解码成低分辨率的紧凑格式缓存起来,训练时只做轻量增强,这一步能把 CPU 压力降一大半;二是增强操作尽量用 GPU 做,现在主流的视觉库都支持,把解码后的张量直接搬到卡上再做裁剪翻转,CPU 只负责 I/O;三是数据加载的 worker 数不要凭感觉设,按"CPU 核数除以单卡数再减二"来配通常是个合理的起点。

文本侧反而简单,提前把分词结果缓存成定长数组,训练时直接索引,不要在线分词。

4. 动手搭一个缩小版:代码与配置

4.1 输入构造与分词处理

下面这套代码是我在单机多卡上验证过的骨架,规模做了缩小,但结构关系和原版一致,可以直接拿去改。先看输入构造:

import torch from transformers import AutoTokenizer tokenizer = AutoTokenizer.from_pretrained("your-text-encoder") def build_batch(images, texts, max_len=40): # images: list of PIL Image,已完成增强 pixel_values = torch.stack([img_augment(im) for im in images]) # [B,3,224,224] enc = tokenizer( texts, padding="max_length", truncation=True, max_length=max_len, return_tensors="pt", ) return { "pixel_values": pixel_values, "input_ids": enc["input_ids"], # [B, L] "attention_mask": enc["attention_mask"], # [B, L] }

这里max_len设 40 是个经验值,图注类数据 95% 以上都在这个范围内,再长就是长尾。如果你的业务文本偏长(比如商品详情描述),得单独评估,因为文本长度直接影响到后面多模态编码器的序列长度,进而影响显存。

4.2 前向传播与跨模态序列拼接

这一部分是整个实现的核心,也是容易写错的地方。三个编码器的调用顺序和拼接方式如下:

def forward(self, pixel_values, input_ids, attention_mask, img_mask=None, txt_mask=None): # 1) 单模态编码 img_tokens = self.image_encoder(pixel_values) # [B, 197, 768] txt_tokens = self.text_encoder(input_ids, attention_mask) # [B, L, 768] img_cls = img_tokens[:, 0] txt_cls = txt_tokens[:, 0] # 2) 掩码替换:被遮住的位置换成可学习的占位向量 if img_mask is not None: img_tokens = torch.where( img_mask.unsqueeze(-1), self.mim_embedding.expand_as(img_tokens), img_tokens, ) if txt_mask is not None: txt_tokens = torch.where( txt_mask.unsqueeze(-1), self.mask_embedding.expand_as(txt_tokens), txt_tokens, ) # 3) 拼接:图像序列在前,文本序列在后 fused = torch.cat([img_tokens, txt_tokens], dim=1) # [B, 197+L, 768] # 模态类型嵌入:区分这个位置原本属于哪个模态 fused = fused + self.modality_type_embed(fused) fused_out = self.multimodal_encoder(fused, attention_mask=build_fused_mask(...)) return img_cls, txt_cls, fused_out

有两点必须强调。模态类型嵌入不能省。拼接之后,图像 token 和文本 token 在同一个注意力空间里,模型必须知道每个位置的身份。如果不加这个嵌入,模型只能靠位置推断——图像永远在前 197 个,文本在后 L 个,看似可以推,但一旦序列长度变化(比如图像分辨率改了),这个隐式规则就崩了。加上显式的模态嵌入之后,模型的泛化性会明显好很多。

融合后的注意力掩码要重新构造。文本有 padding,拼到后面之后,图像部分全是有效 token,文本部分有 padding。掩码矩阵要保证图像 token 能看见所有图像 token 和有效文本 token,文本 token 之间按原掩码来,padding 位置对任何 token 都不可见。这块写错的话,训练能跑但效果会很差,而且表面上查不出问题。

4.3 四路损失的合并与反向传播

拿到三组输出之后,损失的组装是这样的:

# 掩码预测 img_logits = self.img_mlm_head(fused_out[:, 1:1+196]) # 视觉码本分类 txt_logits = self.txt_mlm_head(fused_out[:, 197:]) # 词表分类 loss_mmm_img = masked_ce(img_logits, img_labels, img_mask) loss_mmm_txt = masked_ce(txt_logits, txt_labels, txt_mask) # 对比损失 z_img = F.normalize(self.img_proj(img_cls), dim=-1) z_txt = F.normalize(self.txt_proj(txt_cls), dim=-1) loss_clip = clip_loss(z_img, z_txt, self.temperature.clamp(min=0.01)) # 图文匹配 match_logits = self.itm_head(fused_out[:, 0]) loss_itm = F.cross_entropy(match_logits, itm_labels) loss = (w_mmm * (loss_mmm_img + loss_mmm_txt) + w_clip * loss_clip + w_itm * loss_itm) loss.backward()

masked_ce这个函数要自己写,核心是只在掩码位置计算交叉熵,其余位置置零。用F.cross_entropy(..., reduction="none")拿到逐位置损失,再乘掩码求和除以掩码总数,是最直观的写法。记得处理"整个 batch 一个掩码都没有"的边界情况,否则会出现除以零,得到一个 NaN,然后整个训练静默崩溃——这种 bug 极难定位,因为报错信息指向的是几百步之后的某次反向传播。

4.4 显存账本与超参配置

显存这一块我算一笔账,方便你决定 hardware 配置。假设 batch size 为 1024,图像序列 197 个 token,文本序列 40 个 token,隐藏维度 768,混合精度训练:

单层激活值大头是注意力的中间结果,按 1024 × 237 × 768 × 2 字节算,单个张量约 370MB,一层里这类张量有十几个,就是四五个 GB。12 层单模态编码器加 6 层融合编码器,不做优化的话,几十 GB 起步,单卡放不下。

实际可用的做法是梯度检查点:前向只保留每层的输入,反向时重算中间激活。代价是计算量增加 30% 左右,收益是激活显存降到原来的十几分之一。开了检查点之后,batch 1024 大概需要 4 到 8 张 80GB 级别的卡,具体取决于序列长度和是否冻结图像编码器。

如果只有单卡,那就老老实实把 batch 降到 32 到 64,靠梯度累积凑等效 batch。这里有个细节:对比学习在梯度累积下会被削弱,因为每次前向只看到自己那一个微 batch 的负样本,累积多步之后的等效 batch 并不等于对比学习的有效负样本池。解决办法是维护一个特征队列,把前几步算出来的特征缓存起来当额外负样本,或者干脆接受这个损失,把单步微 batch 设大一点(32 已经能提供不错的负样本池)。我自己的机器上是微 batch 64、累积 16 步,效果接近真实 batch 1024 的八成。

优化器配置大致如下:

参数取值说明
优化器AdamWbeta 用默认的 0.9 / 0.999
峰值学习率3e-4(等效 batch 1024)等效 batch 减半则学习率也大致减半
权重衰减0.02比常规的 0.01 略高,能压住后期过拟合
学习率调度线性 warmup + 余弦衰减warmup 步数取总步数的 2%,太少容易前期崩
梯度裁剪1.0四个损失叠加后梯度范数波动很大,必须裁
精度bf16 混合精度比 fp16 稳,不需要损失缩放

梯度裁剪这一项特别要提。四项损失同时反传,梯度范数的波动比单目标训练大得多,我遇到过梯度范数突然冲到几百的情况,不裁剪的话一步就能把模型带偏。裁剪到 1.0 是稳妥的选择,如果你发现训练前期损失下降极慢,可以试试放宽到 5.0 观察一下。

5. 下游迁移:三种用法与微调路径

5.1 检索任务:只用单模态特征,别用融合特征

检索场景最省事的用法是直接冻结两个单模态编码器,取 [CLS] 特征做向量检索。这里有个很多人会犯的错误:既然模型有融合编码器,那用融合后的特征去检索不是更"准"吗?短期看确实准,但代价是图像侧没法预计算。千万级图库上,每次查询都跑一遍完整的图像编码,延迟和成本都不可接受。

正确姿势是用单模态特征建索引,用融合特征做重排。具体来说,先拿图像编码器的 [CLS] 向量建好向量库,用户查询时用文本编码器的 [CLS] 向量做近似近邻检索,召回前 100 条;然后对这 100 对候选,逐个跑一遍多模态编码器算图文匹配分数,重排取前 10。这个两阶段结构能把召回率在纯双塔的基础上再提几个点,同时延迟只增加一次百级别的融合前向,完全可接受。

建索引的时候,向量的归一化方式要和训练时保持一致。训练时对比学习用的是 L2 归一化后的向量,检索时也必须归一化后算余弦相似度,用未归一化的原向量算内积会得到完全错误的排序。

5.2 判别与问答类任务:加轻量任务头微调

分类、视觉问答这类任务,标准做法是把多模态编码器的输出接一个任务头微调。视觉问答的场景下,输入是图文拼接序列,输出是答案分类或生成,跟预训练时的多模态掩码建模结构很像,迁移很自然。

微调时我建议分阶段:先冻结单模态编码器,只训多模态编码器和任务头若干轮,等损失降下来再解冻全部参数用小学习率(预训练的十分之一左右)继续训。这样做的原因是任务头的随机初始化会产生很大的梯度,如果一开始就全量微调,这股大梯度会顺着多模态编码器一路传回单模态编码器,把预训练学好的表征破坏掉。分阶段训练能把这个破坏降到最低。

微调数据量少的时候(几千条级别),还可以考虑只训多模态编码器的最后两三层加任务头,其余全冻。这样参数量小,过拟合风险低,收敛也快。

5.3 零样本能力:从匹配分数到分类

FLAVA 这类模型天然具备一定的零样本能力,思路和图文检索一样:把类别名转成文本提示(比如"一张{类别}的照片"),用文本编码器编码,和图像特征算相似度,取最高的那类作为预测。这个能力在预训练数据覆盖的类别上表现还不错,但在专业领域(医疗、工业)基本失效,因为预训练没见过这些视觉概念。

实战里我的态度是:零样本结果用来做快速验证和冷启动可以,正式上线前一定要用少量标注数据做一次轻量微调或者至少做一次提示词调优。提示词的措辞对准确率影响能到五到十个点,值得花半天时间枚举几十个模板做集成。

6. 踩坑记录与排查清单

6.1 对比损失塌缩:最难查的一类故障

症状是:训练前几十步损失正常下降,然后突然所有图像的相似度都变得很高,batch 内任意一对的余弦相似度都接近 0.9,对比损失卡在 ln(batch) 附近不再下降。这叫表征塌缩,模型把所有输入映射到了同一个点。

我遇到过三次,原因各不相同。第一次是温度参数设得太小(写死了 0.01),梯度爆炸把表征推到一起;第二次是学习率太大,训练前期直接发散;第三次最隐蔽,是数据管线有 bug,同一个 batch 里重复出现了同一张图的不同增强版本,模型很容易就把它们对齐了,然后顺着这条路把所有东西都对齐到一起。

排查顺序我总结成一张表:

现象优先怀疑验证方式处理
相似度矩阵全是 0.9+温度过小打印温度参数实际值加下界约束,clamp 到 0.01 以上
损失从第一步就异常数据重复统计 batch 内图像哈希去重率修数据管线,加去重逻辑
损失周期性抖动样本长度不均按长度分桶看损失损失按有效 token 数归一化
前期正常后期爆学习率无衰减打印各步学习率加余弦衰减、梯度裁剪

6.2 显存与吞吐:被忽视的隐性瓶颈

显存不够好办,降 batch、开检查点。真正难搞的是"显存够但吞吐上不去"。这种情况八成是 GPU 在等数据,nvidia-smi 上看利用率忽高忽低,像心电图。原因通常是数据加载的 worker 不够,或者图像增强全压在 CPU 上。我一般会先看数据加载耗时占总步时的比例,超过 20% 就要动手优化了。

还有一种吞吐问题来自多模态编码器的序列长度。文本长度从 40 涨到 80,融合序列从 237 涨到 277,注意力计算量涨了 36%,但参数量一点没变,很容易被忽视。如果你的业务文本偏长,一定要单独按最长长度做一次显存和吞吐评估,别拿短文本的配置直接上线。

6.3 常见问题速查表

这张表是我自己攒的,遇到问题先扫一遍,能省不少时间:

问题可能原因排查动作
掩码重建准确率长期停在很低水平视觉码本太粗或图像编码器被不当冻结检查码本大小,确认图像编码器是否在更新
图文匹配准确率一上来就 95%+负样本太简单检查负样本采样逻辑,加入难例挖掘
检索召回率远低于预期特征未归一化,或用了融合特征建索引确认归一化一致性,改用单模态特征
微调后效果不如冻结任务头初始梯度破坏预训练表征改成分阶段解冻,先冻后放
多卡训练损失曲线与单卡差异大对比学习跨卡负样本未打通确认是否做了跨卡 all_gather
训练后期验证集指标下滑过拟合,权重衰减不足提高权重衰减,或加早停

多卡那一项特别值得说。如果你的对比学习没有实现跨卡的特征汇聚,每张卡各自算自己那部分 batch 的对比损失,等效负样本池就缩小到了单卡 batch 大小,八卡并行相当于白瞎了七卡的负样本。实现上就是把各卡的图像特征和文本特征 all_gather 拼成全局矩阵再算损失,注意梯度要正确回传到各自的卡。这一步写错不会报错,只会让你的模型效果比你预期的差一截,非常隐蔽。

我自己在这个方向上投入的时间,大概有一半不是在调模型结构,而是在修数据管线和验证训练目标之间的相互干扰。FLAVA 这套框架的价值不在于某个单点技术有多新,而在于它把"单模态表征、跨模态对齐、深度融合"这三件在工程上长期割裂的事,塞进了一次训练里。如果你手头的业务同时需要检索和融合两类能力,这套思路值得完整复现一遍;如果只做检索,那双塔加上好数据其实更划算,别为了架构优雅给自己加复杂度。

返回列表