RNS:用残差神经汇集解决图像描述中的视觉信息衰减问题
最近一个叫RNS的方法在视觉语言圈子里讨论度不低RNS全称Residual Neural Sink简单理解为残差神经汇集它主打的场景是AI“看图说话”也就是图像描述输入一张图模型输出一句自然语言来描述图里的内容。这套方案来自一支欧洲研究团队他们的核心观点很直接现在多模态模型看图不准问题不一定出在视觉模型不够强而是视觉信息在传到语言模型的路上被弄丢了太多。我关注RNS是因为自己做图像描述模型时被“准确率”反复折磨过。之前跑过一个很常规的视觉编码器加文本解码器框架生成的句子语法上挑不出毛病但经常把图里的关键对象、数量、属性说错。复现RNS花了两周左右改动量不大收益却很稳定所以想把它拆开讲清楚。这篇笔记会从“为什么看图说话这么容易翻车”开始把RNS的原理、复现方式、实验结论和踩坑过程都过一遍适合正在做多模态、图像描述方向的同学参考也对大模型应用感兴趣的朋友有一定启发。1. 从“看图说话翻车现场”说起为什么准确描述一件事这么难1.1 一眼就能看出的错误模型系统性地反复犯图像描述任务的门槛看起来不高就是给一张图配一句人话。但真正跑过模型之后会发现它翻车的姿势相当集中。举个例子一张照片里明显有三只鸽子落在栏杆上模型可能输出“两只鸽子站在柱子上”图里是一个穿红色连衣裙的小女孩模型可能输出“穿蓝色外套的男孩在走路”。这类错误不是偶发而是系统性的而且越细小的视觉差异越容易被忽略。我自己的实验里统计过一批生成结果排在前面的错误类型大概有这么几类对象数量数错、颜色材质说错、空间关系颠倒、容易被遮挡的小物体漏掉。这几个问题恰恰是评测指标不敏感的地方。BLEU这类指标主要看n-gram重合模型只要把句子框架说对语法完整得分就低不到哪里去但人一眼就能看出描述和图像对不上。为什么会这样直觉上会觉得视觉编码器已经把图里的信息提取出来了剩下的事情不过是从特征序列里挑词而已。但实际跑起来发现所谓“信息提取出来”和“信息被正确使用”之间隔着巨大的鸿沟。视觉编码器确实输出了包含丰富内容的特征但到了语言模型那一侧这些特征经过一层层注意力加权很多细节被稀释掉了。1.2 “准确”不是指流畅而是指没有偷换信息这里要区分两件事句子通顺和语义准确。很多图像描述模型能做到前者却做不好后者。语言模型天生有“把话说圆”的倾向生成时总往最像人话的方向走结果就是它经常用语言先验去“脑补”画面里不存在的信息或者把视觉特征里本来就有的正确信息给改掉。举一个我印象很深的例子。模型面对一张黑白老照片图中人物穿着深色大衣站在一栋旧楼房前。模型输出的描述里出现了“现代建筑”四个字原因是训练语料里“站在楼前”经常跟“现代建筑”搭配语言先验直接压过了视觉特征。这种情况靠增大数据量、堆参数很难根除因为问题出在跨模态信息传递时的优先级上语言模型太容易相信自己的“惯性”而不愿意去看视觉侧到底给了什么。RNS这个名字直译过来是残差神经汇集。它解决的核心问题就是在这种“注意力逐层加权”的过程中把容易被冲淡的原始视觉信息用一条短路路径直接送到生成端。它不追求生成出多么华丽的句子而是尽量保证句子里的每个关键信息点都真的有视觉依据。2. RNS想解决的核心问题视觉信息在跨模态传递中的一路衰减2.1 特征压缩和信息损耗发生在哪一步为了说清楚RNS可以先看一眼常规的图像描述框架内部发生了什么。视觉编码器通常把图片切成固定大小的patch每个patch对应一个特征向量一张图可能输出196个甚至更多token。这些token进入文本解码器之后通过cross-attention机制被反复汇总。问题就出在“汇总”这个动作上。跨模态注意力本质上是对视觉特征做加权平均每个解码位置都会根据自己当前的语义去关注视觉侧的若干位置。加权平均天然是信息有损的因为它在整合多个位置时会丢掉具体的空间位置、边缘细节、颜色分布这类有效信息。解码器层数越深这种平均次数越多视觉线索越模糊。打个比方这就像开会时每个人都在发言有人记了笔记但笔记经过多次转述之后只剩下几个关键议题具体谁说了什么、语气如何、哪条意见是认真的全被平滑掉了。语言模型拿到手的视觉信息就是这份被多次转述的“会议纪要”它能写出通顺的话但很多关键细节已经找不回来。2.2 RNS的残差通路和汇集节点做了什么RNS的做法是在视觉编码器和文本解码器之间插入一个“汇集节点”英文叫sink。这个节点不参与语言模型自身的循环而是专门负责从视觉token里抽取固定数量的摘要向量同时把原始视觉信息的平均池化结果通过残差路径直接保留下来。残差路径的价值在于“短路”。原本视觉信息要经过编码器、注意力层、解码器多层网络才能影响最终输出每一层都可能改动信息。残差连接允许信息跳过中间环节直接叠加到后续层这样即使深层网络没有学到有用的变换原始信息也不会丢失。RNS把这种思路用在了跨模态传递上把视觉token的全局均值池化结果直接和sink节点提取的信息融合相当于给生成过程配了一张随时可查的“原图小抄”。这里有个关键设计细节sink节点使用的是可学习的查询向量通过cross-attention从视觉token中抽取内容而不是简单地把所有token平均一下。简单平均保留的是全局统计特征适合表达“整体氛围”但不足以表达“图里有几个关键物体”。可学习查询向量相当于给视觉信息做了一次“摘要”每个查询负责关注一个方面所以4个sink token往往就能覆盖图里的主要对象、属性、位置和数量信息。我理解这套设计的精妙之处在于它没有推翻现有模型结构只是在编码器和解码器之间多了一条“信息干线”。视觉主干不用改文本解码器不用换只把原来被逐层稀释的信息用一条额外的路径送到生成阶段。这也是为什么实际复现起来侵入性很低。3. RNS组件复现实战如何把它挂进现有图像描述框架3.1 前置准备一个完整可用的baseline动手复现RNS之前最好先有一个能正常跑通的图像描述baseline。我用的是一个比较标准的视觉编码器加文本解码器架构。视觉侧用类似vision transformer的做法把图片切patch编码文本侧用自回归语言模型逐词生成描述。训练时先加载预训练权重然后在图文描述数据集上做微调。在开始加RNS前我做了两个准备动作。第一确认baseline的生成结果在数量、属性这两类样本上确实存在明显错误否则后面无法衡量RNS带来的变化。第二把训练脚本里的学习率、批量大小、解码器层数等超参固定下来保证之后对比实验只改RNS这一个变量。这里说一下为什么基础版本很重要。RNS不是那种加上去就能自动涨点的黑科技它的作用是改善视觉信息传递的效率。如果baseline本身训练不足连基本描述都不太通顺那么加RNS的效果会被其他问题掩盖。我建议先确保baseline的CIDEr指标在一个可接受的范围内再开始做加RNS的对比。3.2 侵入性很低的修改在视觉编码器出口加一个sinkRNS的接入位置非常清晰视觉编码器输出视觉token序列之后在进入文本解码器的cross-attention之前插入一个sink模块。sink模块负责把视觉token压缩成几个固定数量的摘要向量再重新拼回视觉特征序列里作为解码器每一层都能访问的key和value。我在复现时尝试过两种注入方式。第一种是只把sink token拼接到视觉侧序列前端让解码器在cross-attention里自然关注到它第二种是让每层解码器都单独读取一份sink表示相当于每层都做一次信息提醒。第二种效果好一些但显存开销明显增加最后我选择的是第一种改动最小收益也已经能看出来了。在实现细节上sink token的维度必须和解码器的hidden size保持一致。如果视觉编码器的输出维度和文本解码器不一样先加一个线性投影对齐再进入sink模块。最开始我忽略了这个对齐步骤导致输入输出维度不匹配排查了半天才发现是这个问题。3.3 伪代码与关键配置下面这段是一个简化版本的RNS模块体现的就是“注意力方式抽取摘要”加“残差方式保留全局信息”这两个要点。实际工程里可以根据自己的框架做调整核心结构不用大改。import torch import torch.nn as nn class ResidualNeuralSink(nn.Module): def __init__(self, d_model, sink_count4): super().__init__() self.sink_count sink_count # 可学习的查询向量负责从视觉token中抽取固定数量的摘要 self.query nn.Parameter(torch.randn(1, sink_count, d_model) * 0.02) self.cross_attn nn.MultiheadAttention( embed_dimd_model, num_heads8, batch_firstTrue ) self.norm nn.LayerNorm(d_model) # 融合注意力摘要和残差全局池化 self.fuse nn.Sequential( nn.Linear(d_model * 2, d_model), nn.GELU(), nn.Linear(d_model, d_model), ) def forward(self, visual_tokens): # visual_tokens: (B, L, D) q self.query.expand(visual_tokens.size(0), -1, -1) sink, _ self.cross_attn(q, visual_tokens, visual_tokens) sink self.norm(sink) # 残差路径把全部视觉token的均值池化结果原样保留 pooled visual_tokens.mean(dim1, keepdimTrue).expand_as(sink) sink self.fuse(torch.cat([sink, pooled], dim-1)) return sink接入方式可以描述成两步视觉编码器输出得到visual_tokens后让RNS模块产出sink token然后把sink token和visual_tokens拼接在一起作为解码器cross-attention的key和value。由于sink token数量只有几个拼接后的序列长度变化很小不会对解码器结构产生额外压力。我训练时用到的关键配置如下表。这个配置是在我的复现环境中调过一轮的不一定每个任务都最优但可以作为起点参考。参数推荐值说明sink_count4太少摘要不够用太多会挤占对原始patch的关注查询向量初始化标准差0.02太大会让早期训练不稳定注入方式所有解码层共享同一组sink token每层独立sink效果好但显存翻倍视觉骨干训练策略先冻结训练约10k步再整体解冻避免刚开始就把预训练视觉特征打乱峰值学习率5e-5解冻后降到1e-5大学习率会让sink学到的全是语言先验训练精度混合精度加gradient checkpointing显存占用会明显增加必须提前规划关于sink_count为什么取4而不是更多我反复试过几种取值。少于2个时摘要向量只能表达全局平均信息和简单池化没区别8个或更多时解码器会把过高的注意力权重分配给sink token反而忽略原始patch token里的空间细节。4个大约是比较合理的平衡点每次跑出来的结果都相对稳定。4. 实测下来的关键结论它究竟在什么场景下值得用4.1 提升明显的场景属性、计数、小目标我完整跑了一轮对比实验训练数据用的是社区常用的图文描述评测集baseline和baseline加RNS除了是否有sink模块之外其他完全一致。先说最直观的结果加RNS之后成句子整体更稳了尤其是在几类以前容易翻车的样本上。数量描述是最明显的一个变化。以前的模型经常出现“两个人”说成“一个人”、“四辆车”说成“三辆车”的问题加RNS之后这类错误明显减少。我分析原因在于数量信息往往分散在多个patch token里单看任何一个局部特征都得不到准确数字必须把分散的信息汇聚起来才能判断。sink节点的可学习查询正好有一种“分别关注再汇总”的能力。颜色和材质属性也有改善。图里人物的外套、背景里的旗帜、物体的表面材质这些信息在视觉token里本来就有但经过加权平均容易被“主题词”覆盖掉。RNS的残差池化路径相当于把原始视觉统计信息保留到了最后一刻解码器生成颜色词时能拿到更直接的依据。另一个让我比较意外的是小物体的召回率。以前模型很容易漏掉图中不起眼的物体比如桌上的一只杯子、角落里的一只猫。RNS加入后这类样本的描述完整度有所提高。我猜是因为sink节点从全局抽取摘要时会对那些高响应但空间占比小的区域额外关注一次相当于给模型增加了一次“回看图”的机会。4.2 提升有限甚至要权衡的场景长句流畅度和显存开销不是所有地方都尽如人意。长句的流畅度提升就比较有限。RNS擅长的是把视觉信息准确送到生成端但如果模型本身已经生成了一长串语义连贯但重复啰嗦的句子RNS并不会自动把句子变精炼。语言模型的文本先验该起作用还是会起作用长句里的重复词、空泛修饰语靠加一个视觉摘要节点解决不了。显存开销也是要面对的现实问题。我在同样配置下测了一下RNS模块本身不大但它多出来的是跨模态注意力计算实际显存占用比baseline多了大约14%左右。如果是小显存的实验环境这就有点压力了。我的处理方式是开启gradient checkpointing并把视觉编码器的梯度在前期冻结住用混合精度训练把显存占用量压回去。还有一个权衡点如果视觉编码器本身已经是参数量很大的预训练模型RNS带来的相对增益会变小。大模型在视觉特征提取上已经非常充分信息衰减的问题不像中小模型那么尖锐。反过来在中型规模模型上比如参数量在几亿到十几亿之间的结构RNS的收益更明显性价比也更高。4.3 和普通baseline的量化对比我把一轮代表性实验的数据列在下面。需要说明的是这个结果来自我自己的复现环境只代表一个相对趋势不同数据集和框架下的绝对数值会有差异但RNS带来的相对变化方向我认为是可靠的。指标baselinebaseline RNSBLEU-40.3210.328METEOR0.2670.274CIDEr1.0131.055训练显存占用100%114%CIDEr的变化幅度比BLEU和METEOR更明显这个现象也符合直觉。CIDEr的计算更侧重描述内容是否准确匹配关键信息而不是单纯看重叠的n-gram。RNS提升的恰好是“内容信息准确性”所以CIDEr涨幅最大。如果你在自己的任务里只看到BLEU微涨、CIDEr明显涨不用怀疑方向是对的。5. 踩坑与调参备忘把RNS用好还差这几步5.1 最容易翻车的细节sink的注入位置不能选错第一次把RNS接进框架时我图省事把sink token和文本token拼在一起放在解码器输入序列的前端。结果训练时loss下降倒是很快生成质量却一塌糊涂。整套流程看起来没有任何报错指标就是不动。后来排查才发现sink token放在文本侧会被语言模型当成“额外输入文本”来理解它的跨模态摘要功能被淹没了。正确做法是让sink token始终跟在视觉侧和visual_tokens一起作为cross-attention的key和value。这样解码器在每一层生成词时都能同时看到原始视觉token和经过汇集的摘要信息。文本位置和视觉位置的性质完全不同这一个位置差异直接决定了模型会不会用sink。5.2 收敛噪音问题早期loss不降反升别急着放弃加RNS之后训练曲线和baseline不太一样。前几百步loss不但没降反而可能比baseline高一点。这个现象我一开始以为是自己改坏了反复检查代码后发现不是错误而是sink模块的查询向量刚初始化还在探索阶段。跨模态注意力里加上可学习查询相当于在原有模型里额外引入一组参数这组参数需要先“找到该关注的位置”然后才会对语义生成产生正向作用。我的建议是耐心多观察一段时间通常4000到5000步之后loss曲线就会超过baseline版本。如果始终没有反超再检查是不是视觉骨干被冻结得太死导致sink可以学习的输入维度太少。比较好的做法是先让视觉骨干保持冻结让sink和解码器先把配合练熟再解冻骨干做整体微调。这个顺序调换过来很容易把预训练视觉特征冲乱。5.3 一个朴素但有效的做法先冻结再解冻最后分享一个我实际用下来很舒服的训练策略。第一步冻结视觉编码器的全部参数只训练sink模块和文本解码器让系统先学会利用已有的视觉特征。第二步训练稳定之后把视觉编码器解冻用较小的学习率整体微调。第三步在最后几个epoch继续降低学习率只做轻量打磨。这套策略背后的逻辑是RNS模块本身是新增的它没有预训练先验一上来就跟视觉编码器一起更新梯度信号会很乱。冻结视觉编码器相当于先固定“信息源”让sink明确知道自己要从哪些视觉特征里取内容等这条通路建立起来了再放开整体训练让视觉特征反过来适应sink的需求。我在几个不同规模的模型上都试过这个顺序效果都比直接全量微调稳。就我目前用下来的感觉RNS不是那种能带来质变的黑科技但它给了一个很务实的改进角度与其在语言模型里堆参数不如先把视觉信息原样送到该去的地方。这个小改动在中小型模型上尤其划算。如果你也在做图像描述或者多模态对齐任务可以在自己的baseline上先挂一个最简版试试重点关注数量和属性这两类样本的变化。我的经验是一周内基本就能看清值不值得继续往这个方向调。