ARTICLE · INTELLIGENCE

战地情报 · 详情页

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

负样本引导重排序:让图像描述模型不再“胡说八道”

负样本引导重排序:让图像描述模型不再“胡说八道”

做图像描述(看图说话)这几年,我一直有个很深的体会:模型能“说”并不难,难的是让它“说到点子上”。前两天看到一篇来自某欧洲高校计算机视觉实验室的新工作,团队把方法命名为RNS,全称是 Reranking with Negative Sampling,翻译过来就是“负样本引导的重排序”。如果你也在做图像理解、多模态生成,或者正在被模型“一本正经胡说八道”的问题折磨,这篇内容值得认真读完。

RNS解决的问题非常具体:在模型已经能生成多个候选描述的情况下,怎么从这些候选中挑出那个真正贴合图片内容的句子,而不是只挑一个“读起来顺口但其实看图不靠谱”的结果。它把一个常被忽略的环节——句子选择,从“按生成概率直接取最高”升级成了“用专门的排序器综合判断”。概念不复杂,工程上也容易落地,但效果提升却非常明显。下面我把它的来龙去脉、核心原理和复现要点完整拆一遍。

1. 看图说话的问题到底卡在哪

1.1 一个看似简单却一直被低估的任务

图像描述(image captioning)的任务一句话就能说清楚:给模型一张图,让它输出一句通顺自然、且内容与图像高度一致的自然语言描述。但真做起来的难度远超预期。它不像图像分类那样只输出一个标签,也不像机器翻译那样输入输出都是文本。图像描述要求模型同时具备三件事:看懂图上有哪些物体和关系、把这些视觉信息组织成符合语法的句子、保证句子里的每个关键语义都能在图像里找到对应点。

这三件事任何一环掉链子,输出就会出问题。我在实际项目中见过太多“看起来还行”的模型:语法完全正确,句子也流畅,但描述的对象和图像内容对不上号。比如一张“白色轿车停在超市门口”的图,模型写成了“一辆公交车行驶在公路上”。这种错误不是模型不会说英文,不是词汇量不够,而是它在“选择该说什么”这个环节出了问题。

1.2 三个典型错误:幻觉对象、重复片段、语义漂移

如果长期看模型生成的描述文本,会发现错误类型其实高度集中。最常见的是幻觉对象,图片里根本没有出现的东西,被模型大言不惭地写进了句子。模型在训练时看到“猫”和“沙发”经常一起出现,于是只要视觉特征里有类似沙发纹理的信号,它就可能凭空补出一个“猫”来。

第二个高频问题是重复片段。自回归解码器逐字生成时,如果某个 token 的概率分布不够尖锐,很容易陷入局部循环,输出“a dog and a dog and a dog…”这种毫无信息量的重复,句子长度越长越明显。很多入门项目里都能看到这类输出,根源在于解码策略和训练目标都没有对“全局连贯性”做足够的约束。

第三类问题叫语义漂移。句子前半段还在描述正确的对象,生成到后半段话题突然跳走。比如“一个穿红色衣服的女孩在公园里跑步,手里拿着一个手机”,后半截的“手机”就是凭空跳出来的。这类问题最隐蔽,因为每个局部片段单独看都正常,合在一起就是错。

1.3 为什么数据更多、模型更大也不一定能解决

很多人第一反应是:模型不行,那就加数据、加参数。可现实是,即便模型规模翻倍、训练数据增加,上述三类错误依然会存在,只是频率降低,不会根除。原因在于,这些错误的本质是“解码和选择机制”不够好,而不是视觉表征不够强。

你可以把生成描述理解成两步:第一步是从图像里提取足够好的信息,第二步是把这些信息组织成一句最优的句子。现有的大规模预训练模型,第一步通常已经做得相当好;但第二步里的“最优句子选择”,传统做法往往只是按生成概率做个贪心解码或 beam search,本质上是局部最优,不是全局最优。也就是说,模型可能早就“想”出了正确描述,但在最终选择路径时把它丢掉了。

这正是 RNS 选择切入的地方:不动表征,不改主干模型,专门去优化“从多个候选里挑出最好那个”的选择环节。

2. RNS的整体设计与核心思路

2.1 一句话理解RNS:先多生成,再按可靠性挑

RNS 的思路用一个比方能讲明白:现在公司要招人,你不再让候选人只交一份简历就定结果,而是先让所有人做一轮开放式笔试,产出一批候选方案,再由资历更深的评审团从“实际做事能力、专业匹配度、表达是否靠谱”等多个维度综合打分,选出最终录用的人。

放到图像描述场景里,笔试环节就是让训练好的自回归解码器生成 N 个候选句子,评审环节就是用一个专门的重排序器,给每个候选句子打一个“可靠性分数”,最后选分数最高的句子作为最终输出。

这个两阶段框架并不算新思想,信息检索领域的 re-ranking 早就在用。RNS 的独特之处在于:它对重排序器的训练方式做了针对性设计,核心是引入了高质量的负样本。传统的重排序器只会见过“候选句子与参考句子像不像”这样的正样本比较信号,RNS 会额外构造大量“与图片描述部分相关、但关键信息出错”的负样本,逼着重排序器学会辨认那些看起来顺眼、实际上与图像不一致的句子。

2.2 负样本:不是随便抄几句坏话

负样本的质量直接决定排序器的上限。如果只在海里随便抽几句完全无关的话当负样本,模型很快就能学会——那也太简单了,句子和图像完全不相关,一眼就能看出来。这种负样本训练出来的排序器,拿到真实场景里没有任何实战能力,因为真实候选句都是“有点相关、但细节出错”的类型。

RNS 构造负样本的核心原则是“难负样本”,也就是和正样本高度相似、但存在关键语义错误的句子。常用做法有三种。第一种是对象级替换:把参考描述中的核心名词替换成同类别或视觉特征相近的另一个词。比如“一只猫趴在窗台上”改成“一只狗趴在窗台上”。第二种是局部语序打乱:把正常的句法结构破坏,让模型看到“语序正确性”也是一种重要的判断信号。第三种是跨图错配:把另一张图片的、语法完全正确的参考描述拿过来,当作当前图片的负样本。这类负样本在训练中非常有效,能迫使排序器学会真正去理解“图像内容”与“句子语义”之间的对应关系,而不只是依赖句子本身的质量。

2.3 可靠性分数如何计算

重排序器给候选句子打分,不能只看一个信号,至少要综合三路信息。

第一路是生成似然分数。解码器给这个候选句子分配的概率能反映出模型自己觉得它顺不顺,但这项分数单独用会偏向高频套话,所以只能作为基础分。第二路是视觉-语言匹配分数。用一个跨模态编码器,把图像特征和候选句子的文本特征做交叉注意力,判断句子描述的内容在图像里有没有对应区域。第三路是负样本辨别分数。排序器在训练时见过大量难负样本,会学会一套“哪些细节最容易出错”的敏感度,这部分信号能让排序器主动惩罚那些“看起来合理、实际是幻觉”的候选句。

最终分数是这三路信号的加权融合。融合权重不需要太复杂,固定比例或者加一个简单的门控网络都可以。我在复现时先用固定比例(比如 0.3、0.4、0.3)跑通流程,再根据验证集效果做小幅调整,效果就足够好。

2.4 为什么比端到端微调更稳

看图说话领域有一个很常见的优化路线,是用强化学习直接优化 CIDEr 之类的指标,例如 self-critical 方法。它的问题在于训练方差大、超参数敏感,经常出现指标在训练集上涨、验证集开始震荡,或者生成的句子为了押中 n-gram 导致严重重复。这种“直接拿指标当奖励”的方式,相当于让一个新手去考满分卷,过程很容易走偏。

RNS 把生成和选择解耦,生成器继续用稳定成熟的交叉熵训练,排序器单独用排序损失训练。每个模块的任务都更简单,训练也就更稳定。工程上这还有个额外好处:排序器和生成器可以分别迭代。今天换一个更强的生成器,排序器不用重头训练;排序器想加一个新打分信号,生成器也不用动。模块化带来的是实打实的迭代效率提升。

当然,RNS 也不是没有代价。它需要额外维护一个重排序模型,推理时也要先把 N 个候选句子都生成出来再打分,计算量比单次贪婪解码要高。但在精度优先的业务场景里,这一点点成本完全划算。

3. 关键细节与实操要点

3.1 候选生成阶段的参数控制

RNS 的效果上限很大程度取决于候选句子质量。如果 beam search 出来的 16 个候选本身就全是烂句子,那排序器再强也挑不出金子。所以第一步不是调排序器,而是先把生成阶段调好。

beam size 我建议设成 8 到 16。太小,候选多样性不够;太大,排序器的计算压力会明显增加。长度惩罚(length penalty)也值得专门调,我常用 alpha 在 0.8 到 1.2 之间,重点观察候选句里有没有因为长度惩罚过低导致的长句崩坏。

另外一定要开启重复惩罚。如果你用的解码器支持 repetition_penalty 之类的参数,建议设到 1.2 以上。否则生成阶段就会把“重复片段”这类错误大量带进候选池,排序器需要花很大力气去纠正,等于白白浪费拟合能力。

3.2 重排序器的结构与输入

重排序器本身不需要很复杂。我用的是一个轻量级的双向上下文编码器,输入由三个部分组成:候选句子的 token 序列、图像的区域特征(或者图像级特征,视任务规模而定)、以及一个表示“候选句子来自第几个 beam”的序号特征。序号特征这个细节容易忽略,但实测对结果有正贡献,因为 beam 里靠前的候选句通常整体质量更稳,排序器可以学习到这个先验。

训练损失用成对排序损失(pairwise ranking loss)。目标很简单:正样本的分数比负样本的分数至少高出 margin。代码里可以写成 max(0, margin - score_pos + score_neg)。margin 我推荐 0.2 到 0.5,太小会让排序器“无所谓”,太大则可能导致训练早期梯度太强、模型不稳定。

训练数据构造环节要特别提醒:正样本和负样本必须来自同一幅图。如果你把 A 图的正样本和 B 图的负样本配对,模型学到的是“哪句话更顺”,而不是“哪句话更贴合这张图”,会严重影响最终效果。

3.3 与其他优化路线的对比

为了让你对 RNS 的定位更清楚,我用一张表横向对比几条常见的优化路线。

方法路线核心思想优点缺点
生成概率重排直接用解码器的生成概率选 top-1实现简单、零额外成本偏向局部高频词,忽视全局语义一致性
强化学习微调用评估指标当奖励,直接优化生成器能让模型对齐指标方差大、容易重复、超参数敏感
两阶段人工筛选生成大量候选后人工挑最好的上限高不可自动化,无法落地
RNS生成候选 + 负样本引导排序器自动挑稳定、可解释、模块化多一个排序器训练和推理成本

从这张表可以看出来,RNS 并不是要在生成模型上和别人硬拼参数,它赢在“选择”这件事做得系统化。这也是我在业务项目里最认可它的地方。

4. 一个可落地的最小复现流程

4.1 准备一套小规模数据

先说数据。图像描述模型需要图文对数据。如果你没有现成的内部数据,建议直接从某个公开的图像描述数据集中切一个子集出来,先跑通流程,再逐步扩大规模。我在这里用一个虚构代称 U 指代那个数据集,具体是谁不重要,关键是它的格式要包含:图像文件路径、每张图对应多条人工描述、以及划分好的训练集/验证集/测试集。

小规模验证项目M 我采用的是 2 万张图、每张图 5 条参考描述。这个体量足够看到一个趋势,又不至于让你卡在算力上。数据准备好后,记得做三件事:把图片统一缩放到相同尺寸,把描述文本全部切成 token,过滤掉出现频率过低的词。词汇表太大会让模型学不扎实,建议控制在 5000 到 10000 个词的规模。

4.2 训练生成模型

第一阶段的生成模型可以用一个轻量结构:视觉编码器负责把图片转成特征序列,解码器负责把特征序列转化为描述文本。训练目标就是标准的交叉熵。

import torch import torch.nn as nn # 示意:image_feats.shape = [batch, num_regions, feat_dim] # target_ids.shape = [batch, seq_len] def train_generator(model, image_feats, target_ids, vocab_size): # 解码器基于图像特征和已生成的前缀 token 预测下一个 token logits = model(image_feats, target_ids[:, :-1]) # [batch, seq_len-1, vocab_size] loss = nn.CrossEntropyLoss(ignore_index=0)( logits.reshape(-1, vocab_size), target_ids[:, 1:].reshape(-1) ) return loss

这个阶段不需要太长,验证集上 CIDEr 曲线开始走平就可以停。之后我建议再用一个轻量级的强化信号做几个 epoch 的微调,但它不是必须的。如果你的硬性评价指标压力不大,跳过这一步完全可行。

4.3 训练RNS排序器

生成模型训练好后固定权重,开始构造排序器训练数据。每一张训练图片走一遍 beam search,得到 N 个候选句子,再从参考描述里随机抽一个作为正样本,用前面提到的三类策略构造负样本。

def train_ranker(ranker, image_feats, pos_ids, neg_ids, margin=0.3): pos_score = ranker(image_feats, pos_ids) # 正样本可靠性分数 neg_score = ranker(image_feats, neg_ids) # 负样本可靠性分数 loss = torch.relu(margin - pos_score + neg_score).mean() return loss

训练排序器时有一个操作细节值得强调:候选句子要从生成模型实际采样出来,而不是从参考描述里随机切断或复制。原因很简单,推理阶段排序器面对的一定是生成模型自己吐出来的句子,如果训练时用的是“干净的人工描述”,分布就错位了。这一点很多人第一次实现时会踩坑。

4.4 推理阶段接入重排序

推理流程比训练更简单。原来你可能直接用 beam search 的 top-1 作为结果,现在改成:先生成 N 个候选句子,逐句过一遍排序器,最后取排序器分数最高的那句。

def infer_with_ranker(generator, ranker, image_feats, beam_size=16): candidates, scores = generator.beam_search(image_feats, beam_size=beam_size) # 对每个候选句子计算 RNS 综合分数 final_scores = [ 0.3 * scores[i] + 0.4 * ranker.visual_match(image_feats, candidates[i]) + 0.3 * ranker.discriminative_score(image_feats, candidates[i]) for i in range(len(candidates)) ] best_idx = torch.tensor(final_scores).argmax() return candidates[best_idx]

这一步整体替换成本很低,不需要改动生成模型内部结构,也不需要重新训练主模型。我在实际项目中,从决定接入到跑通线上效果,只用了不到一周,绝大多数时间都花在调负样本构造策略上。

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

5.1 候选都是烂句子,重排救不回来

这是最先要排除的情况。如果你把 beam search 的结果完整打印出来,发现 16 个句子里有 14 个都是重复或明显偏题的,那说明问题在生成阶段,而不是排序器。先回去加大 repetition_penalty,适当调低温度,或者检查生成模型是不是训练没收敛。一个实用的判断标准是:候选池里至少有三分之一句子,人眼看着是“有潜力但有小错”的。如果达不到,就别先折腾排序器。

5.2 排序器在验证集上反而变差

典型反应是:生成器没动,加了排序器后指标反而掉了。这时候先别怀疑 RNS 的思路,大概率是负样本构造出了问题。负样本太简单,排序器学到的判别边界特别粗糙,真实候选句在它眼里全都差不多;负样本太难,排序器又会被一些极端case带偏,出现分数震荡。

我的经验是先做一个最简单的版本:只用“跨图错配”这一种负样本跑通流程,保证排序器确实有效,然后再逐步加入对象替换和语序打乱。这样一旦效果变差,你立刻知道是哪种负样本引入的问题。

5.3 指标涨了,人眼看着别扭

CIDEr 这类指标本质上是 n-gram 匹配度,它天生偏好高频出现的套话。排序器在训练时如果过度拟合 CIDEr,就会倾向于给那些“常见但平庸”的句子打高分。这会导致一个尴尬局面:客观指标涨了 2 个点,但人眼评测时感觉模型变得更套路化。

我的解决办法是训练排序器时不要把参考句子当唯一天花板,而是在 loss 里加入一个正则项:如果候选句子包含的图像级对象,与视觉检测模块给出的高置信度对象列表完全不匹配,就额外惩罚。这个信号不用很强,但能有效拉住模型,让它别为了指标好看而丢掉真实视觉信息。

5.4 常见问题速查表

现象可能原因排查方向
候选句子反复出现同一词组解码阶段重复惩罚不够调高 repetition_penalty,增大 beam size
排序器分数分布太集中,几乎分不出高低负样本太简单替换成难负样本,提高对象级替换比例
训练排序器时 loss 迟迟不降margin 设置过大或数据配对错误降低 margin,检查是否同图配对
推理速度不符合要求候选数量多,排序器计算量大减小 beam size,给排序器做量化或蒸馏
指标涨但人工评测差过度拟合 n-gram 指标加入视觉对象一致性正则

我在自己业务场景里复现这套思路时,最值钱的不是模型结构,而是把“选出好句子”这一件小事真正当成独立工程问题来对待。RNS 给我的最大启发是:当模型输出不稳定时,先别急着加参数、加数据,试着把生成和选择拆开,看看是不是选择环节太随意了。这个思路不仅能用在图像描述上,机器翻译、文本摘要、对话系统其实都有同样的问题。

最后再分享一个小技巧:负样本的质量直接决定排序器上限,这个环节非常值得你花最多的时间去调试。每调整一次负样本构造策略,都先在验证集上用一个小样本池跑一轮对比,稳定了再去全量训练,效率会高很多。

RELATED READING

延伸阅读

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