ARTICLE DETAIL

资讯详情

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

自下而上注意力:VQA区域特征与视觉token工程实践

自下而上注意力:VQA区域特征与视觉token工程实践

简介:这份代码包面向图像字幕、视觉问答(VQA)及显著区域特征提取的研究者与工程实践者,提供基于Faster R-CNN、ResNet-101和Visual Genome对象及属性标注的多GPU训练实现,可生成对应显著图像区域的输出特征,并作为注意力字幕与VQA模型中CNN特征的直接替代。压缩包共981个文件、约14.27MB,内含较完整的Caffe源码与配置,包括cpp、hpp、prototxt、cu等训练和网络定义文件,也含Python脚本、Jupyter Notebook、预训练pt模型、Shell脚本及Markdown说明,便于从数据接口、模型配置到实验示例逐层查阅。已有753人学习或下载,在相关研究中具备一定参考价值。读者可据此复现多GPU训练流程,理解自下而上注意力特征提取与Faster R-CNN配置方法,并借助预训练权重开展图像字幕和VQA实验,结合说明文档梳理目录结构与关键模块,为二次开发和对照实验提供基础。

1. 先说结论:VQA 的瓶颈不在语言模型,而在“看哪里”

图像问答(VQA)准确率卡住的时候,很多人的第一反应是换更大的语言模型、加厚 LSTM、多训几十轮。但在公开榜单上,性价比最高的改动往往发生在输入端:把整张图切成的 7×7 网格特征,换成几十个“物体级”区域向量,每个区域一个 2048 维向量。语言模型一行不改,准确率就能抬一截。这就是自下而上注意力(bottom-up attention)解决的问题:让模型看到的不再是均分的格子,而是“这张图里到底有哪些东西”。这条路线最早被系统性验证的载体,是一套基于 Faster R-CNN 与 Visual Genome 预训练的区域特征抽取器,外加配套的 image-captioning 与 VQA 下游代码。搞懂它,你就搞懂了今天多模态模型里“视觉 token”这个概念是从哪来的。

2. 自下而上与自上而下:两种注意力到底差在哪

2.1 网格特征的问题:空间对齐不等于语义对齐

卷积骨干网最后一层特征图,是固定尺寸的网格。输入 600×800 的图,stride 16 之后大概得到 38×50 的响应图,常见的做法是再池化成 7×7 或者 14×14,每个格子对应一个 2048 维向量。这套表示的优点是规整:所有图片出来都是同样的张量形状,接任何下游网络都不用改接口。缺点是它和图像内容没有关系——网格线是人为划的,物体边界永远不会刚好落在格线上。

后果有三个。第一,一个格子常常横跨物体和背景,池化之后语义被稀释;第二,小目标可能整个落在半个格子里,激活强度拼不过旁边大片背景;第三,同一个格子里塞了两个物体,后续注意力再聪明也拆不开。语言模型只能在一堆已经混了杂质的向量里做加权,上限被输入卡死了。

自上而下(top-down)注意力解决的是“解码时看哪”,它作用在已有的特征上;自下而上(bottom-up)注意力解决的是“特征本身长什么样”。前者是后者的消费者,不是替代品。很多实现把两者叠在一起用,效果才好。

2.2 Faster R-CNN 在这里的角色:不是检测器,是候选区域生成器

这里最容易误解的一点:这套流程里的 Faster R-CNN 并不需要输出“这是猫、那是狗”。下游的 image-captioning 和 VQA 只关心“这里有一块值得看的东西”,不关心它属于哪一类。所以抽取特征时通常走 class-agnostic 模式,把所有类别的框混在一起做非极大值抑制,只保留位置和分数。

这个设计动机很实在。如果按类别筛选,你就得为下游任务预设一个类别清单;而 Visual Genome 里的问题涉及颜色、材质、动作、关系,很多根本不在 80 类检测清单里。放弃类别,等于把检测头“降级”成一个区域打分器,换来的是召回率的提升——宁可多给几个语义模糊的框,也不要漏掉关键区域。

结构上它还是标准的两阶段:RPN 在特征图上滑窗产生候选,RoI head 对每个候选做分类和回归精修。区别只在于,最后保留下来的东西不是类别标签,而是一组归一化坐标加一个置信度分数。

2.3 Visual Genome 为什么必须出现:属性监督把“物体”变成“概念”

只在 80 类检测数据集上训练的检测器,见过的概念太少。问到“那个人穿的红衣服是什么材质”,模型可能连“红衣服”这个区域都没提出来。Visual Genome 的价值在这里体现:它的标注不只是框,还有对象、属性、关系三层。训练时把属性也当成可检测的目标,模型学到的就不只是“人”这个类,还有“穿红衣服的人”“木制的桌子”这类组合概念。

常见的实现思路是:在 Visual Genome 上微调检测器,把对象类和属性类一起放进分类头,让每个 proposal 的语义覆盖更宽。图片规模在十万量级,关系标注在百万量级,这个体量足够把骨干网的语义偏置调整过来。抽特征阶段再把检测器冻结,只做前向,因为下游任务的数据量和它不匹配——微调容易过拟合,而且训练成本高一个数量级。

这三个环节串起来,才是完整的“自下而上”:Visual Genome 提供概念广度,Faster R-CNN 提供区域定位,class-agnostic 的输出提供任务无关性。

3. 一个区域怎么变成 2048 维向量:从 ROI 池化到均值池化

3.1 骨干网 C5 层与 ROI 池化的分辨率选择

特征从哪一层取,是一个容易被忽略但影响很大的决定。ResNet-101 的 conv5_3 输出 stride 16、2048 通道,语义最强但空间分辨率低;conv4 阶段 stride 8、1024 通道,位置准但语义弱。区域特征抽取一般选前者——因为下游要的是“这块区域是什么”,语义比像素级位置更重要。

选定层之后,对每个 proposal 做 ROI 池化,把任意大小的框映射成固定尺寸的响应块,常见是 7×7。这一步的关键参数是池化后的空间尺度:7×7 保留了一点内部布局信息,14×14 更细但特征维度会翻四倍。实际工程里 7×7 是主流,因为再往上对下游几乎没有增益,只是白白占用显存。

抽取脚本的典型调用长这样,参数名对着仓库里的 argparse 核一遍即可:

python tools/generate_tsv.py \ --cfg experiments/cfgs/faster_rcnn_end2end_resnet.yml \ --def models/vg/ResNet-101/faster_rcnn_end2end_final/test.prototxt \ --net data/faster_rcnn_models/resnet101_faster_rcnn_final.caffemodel \ --out data/genome/features_resnet101_genome \ --split train \ --max_boxes 100 \ # 每张图最多保留多少个区域,决定下游 K 的上限 --min_boxes 10 \ # 兜底:分数再低也要凑够这么多个,避免整图无框 --conf_thresh 0.2 \ # 初始置信度门槛,低于它的 proposal 直接丢 --nms_thresh 0.7 \ # 非极大值抑制的 IoU 阈值,class-agnostic 时跨类合并 --batch_size 1 # prototxt 里 batch 维度通常写死为 1,别乱改

--max_boxes和--min_boxes是一对,不是随便填的。上限决定下游注意力的候选池大小,也决定特征文件体积;下限是防御性的,遇到纯色背景、模糊图这类检测器信心普遍很低的场景,兜底补几个框总比返回空数组好——空数组会让下游的 batch 组织直接崩掉。

3.2 框的筛选:置信度、NMS 与数量上限的联动

筛选顺序是有讲究的:先按置信度卡阈值,再按分数排序做 NMS,最后截断到上限。顺序反过来,NMS 的复杂度会随着低分框数量暴涨,抽取速度直接掉一个档次。

NMS 阈值取 0.7 偏宽松,这是刻意的。检测任务里 0.5 左右更常见,目的是去掉重复框;但这里的目标是给下游提供多样化的候选区域,两个 IoU 0.6 的框可能分别覆盖“人”和“人手里的杯子”,压掉一个就少了一个语义。宽松阈值的代价是框之间有冗余,不过下游的注意力机制本来就擅长在冗余里挑重点。

class-agnostic 模式下的 NMS 是跨类合并的,这意味着同一个位置不会出现“既是桌子又是书”的两个框。这个取舍换来的是框的数量可控——如果按类做 NMS,一张图很容易冒出几百个框,max_boxes截断时反而会把真正重要的区域挤掉。

3.3 从 flatten 到 mean:为什么要做空间平均

ROI 池化得到的是(2048, 7, 7)的张量。直接 flatten 是一万维,接全连接层参数爆炸;而且 flatten 隐含了“左上角和右下角位置固定”的假设,同一个物体稍微平移,特征就完全变了,这对下游没有好处。

主流做法是做空间维度的均值池化,得到 2048 维。为什么是均值不是最大值:最大池化只保留每个通道的最强响应,假如某个通道对“红色”敏感,那这块区域里只要有一小块红,整个向量就被打满,区域整体的信息被丢掉了。均值保留的是平均激活强度,对区域大小和形状更鲁棒。也有实现用求和,效果接近,区别只在于数值尺度——求和会让大框的向量模长明显更大,如果下游没做归一化,注意力会天然偏向大物体。

提示:不少下游代码会在读入特征后做一次 L2 归一化。这一步不是可选项,尤其是不同来源的特征文件混用时,尺度不一致会让注意力权重完全跑偏。

最终每个区域就是一个 2048 维向量,加上 4 个归一化坐标,外加一个可选的置信度分数。

3.4 特征落盘格式与读取对齐

抽取完的特征通常按 split 打包成一个压缩的 npz,键名固定,下游只认这几个:

import numpy as np d = np.load('train_resnet101_faster_rcnn_genome.npz') image_id = d['image_id'] # (N,) 原始图片 id,不是文件序号 num_boxes = d['num_boxes'] # (N,) 每张图实际有效的框数 boxes = d['boxes'] # (N, K, 4) 归一化 xyxy,K 是上限 features = d['features'] # (N, K, 2048) image_h = d['image_h'] # (N,) image_w = d['image_w'] # (N,) i = 0 k = int(num_boxes[i]) box = boxes[i, :k] # 后面全是零填充,必须按 num_boxes 切 feat = features[i, :k] # 需要像素坐标时再还原,位置编码、画图、裁图都用得上 xyxy_pixel = box * np.array([image_w[i], image_h[i], image_w[i], image_h[i]])

三个细节决定这份文件好不好用。一是boxes归一化到[0,1]并采用 xyxy 顺序,跨数据集混用时不用再关心原图分辨率;二是features按 K 补齐,补齐位是零,读的时候必须靠num_boxes切掉,否则零向量会参与注意力计算;三是image_id必须和下游数据集使用的 id 体系一致,这一步对不上,后面全盘错位,而且错得很隐蔽——loss 会正常下降,只是学到的是噪声。

4. 同一份特征池怎么喂给字幕和 VQA 两个任务

4.1 图像字幕:top-down attention 每一步只看几个区域

image-captioning 的解码器通常是两层 LSTM。第一层叫 attention LSTM,输入是上一个词、上一个隐状态、以及上一时刻的上下文向量;它的输出用来对 K 个区域打分。第二层叫 language LSTM,接收注意力加权后的上下文和第一层的隐状态,输出当前词。

为什么拆两层而不是一层搞定:生成一个词要做好两件不同的事——决定“现在该看图的哪个部分”,和“把看到的东西组织成语法正确的句子”。塞进同一个隐状态里,两件事互相干扰,训练早期尤其明显。拆开之后,注意力层专心做区域选择,语言层专心做词序,收敛更稳。

import torch, torch.nn as nn class TopDownAttention(nn.Module): def __init__(self, feat_dim=2048, hidden=512, att_dim=512): super().__init__() self.feat_proj = nn.Linear(feat_dim, att_dim) # 区域侧降维 self.hidden_proj = nn.Linear(hidden, att_dim) # 解码器隐状态侧降维 self.score = nn.Linear(att_dim, 1) # 每个区域出一个标量分 def forward(self, feats, h_t, mask): # feats: (B, K, 2048) h_t: (B, hidden) mask: (B, K),1 有效 0 填充 a = torch.tanh(self.feat_proj(feats) + self.hidden_proj(h_t).unsqueeze(1)) s = self.score(a).squeeze(-1) # (B, K) s = s.masked_fill(mask == 0, -1e4) # 填充位必须打掉 alpha = torch.softmax(s, dim=-1) # (B, K) 注意力权重 ctx = (feats * alpha.unsqueeze(-1)).sum(1) # (B, 2048) 上下文向量 return ctx, alpha

mask那一行是整段代码里最不能省的。K 在 batch 内是变长的(有的图 12 个框,有的 87 个),补齐到同一个上限之后,如果不把填充位打掉,softmax 会把概率分给一堆零向量,上下文向量被稀释,而且稀释程度随 batch 内最大 K 波动——同一张图换个 batch 组合,生成的字幕就不一样,这种玄学 bug 极难排查。

4.2 VQA:把问题向量当查询,做区域加权

VQA 的融合方式比字幕更自由。常见做法是:问题过一遍 GRU 或 LSTM 得到q,每个区域特征v_i与q做一个联合表示,联合表示过一个线性层得到标量分数,softmax 归一化后加权求和得到v_hat,最后把[v_hat, q]拼接送进分类头输出答案。

联合表示的选择是有讲究的。逐元素相乘(element-wise product)参数少、训练快,但只能捕捉“同维度上的呼应”;双线性池化(bilinear pooling)能建模二阶交互,表达力更强,代价是参数量随维度平方增长,得靠低秩分解压回来。实际项目里,数据量小的时候用逐元素相乘更稳,数据量上来了再换双线性,收益才明显。

另一个容易忽略的点是答案空间。VQA 的答案本质是一个开放词表,但实际训练会截断到出现频率最高的几千个答案,剩下的统统映射成“其他”。这个截断阈值直接决定准确率上限——阈值太低,很多本该答对的被归到“其他”;太高,长尾类别的样本太少学不动。

4.3 跨框架搬运:Caffe 抽特征、PyTorch 训下游

这套资源的一个现实特征:特征抽取用的是 Caffe(模型定义是 prototxt,权重是 caffemodel),下游训练用的是 PyTorch。中间靠 npz 文件解耦。这个设计在当时是无奈之举——Visual Genome 上预训练好的检测器权重是 Caffe 格式的,重训一遍成本太高;但下游研究早就转移到 PyTorch 了。

解耦带来的好处比想象中大:抽特征是一次性的离线任务,跑完就固化;下游可以反复改模型结构、调超参,不用每次重新过一遍检测器。资源里配的 Jupyter Notebook 示例主要就是用来看数据、验证对齐、画区域框,比直接看训练脚本直观得多。

需要注意的对齐点有三个。一是颜色通道,Caffe 侧通常按 BGR 读图并减均值,PyTorch 侧默认 RGB,如果下游还要自己做图像增广,得确认增广后的图不会和特征错位。二是输入尺度,检测器对输入图的短边做了固定缩放,框坐标归一化之后这件事被隐藏了,但如果你想把框映射回原图裁图,就得自己还原。三是数值精度,特征存成 float32 就够,转成 float16 能省一半空间,但注意力打分对精度敏感,容易在小分数差上翻车。

5. 复现时的五个坑:从坐标归一化到框架版本漂移

5.1 现象:训练 loss 正常下降,但指标比预期低一截

原因几乎总是特征和标签的读取错位。features和boxes是按 K 补齐的,直接整体喂进网络,零向量会参与注意力计算;更隐蔽的情况是num_boxes用了 float 存储,转 int 时截断方向不对,少取了一个框。

解决方式是写一个断言:读完之后立刻检查features[i, :k].abs().sum() > 0且features[i, k:].abs().sum() == 0,两条都成立才说明切得对。这个检查成本极低,建议固化到 Dataset 的__init__里。

5.2 现象:少数图片的特征全是零,或者只有三五个框

原因是筛选链条上多个阈值叠加。置信度门槛卡掉大部分 proposal,NMS 又压掉一批,最后剩下的不够min_boxes。如果兜底逻辑写得不对(比如兜底时忘了重置 NMS),就会出现空结果。

排查方法是先统计整个 split 的num_boxes分布,画个直方图。正常情况应该集中在几十的量级,如果出现明显的零值尖峰,就是筛选参数的问题。临时把conf_thresh降到 0.05 再跑一遍对照,能快速定位。

5.3 现象:评估指标断崖式下跌,但训练过程毫无异常

经典的血泪经验:image_id对不上。图像数据集的 id 往往不是文件序号,而是从文件名经过某种映射得到的整数;训练集和验证集的文件排列顺序也不一样。如果按行号对齐,训练时看着正常(因为特征和标签都来自同一份错位数据),一到评估就全乱。

解决办法只有一个:所有地方都以image_id为键建字典,禁止用行号索引。写数据加载时先做一次全量校验,确认特征文件里的 id 集合和标注文件里的 id 集合完全一致,差一个都别往下跑。

5.4 现象:自己前向算出的特征和文件里的数值差一点点,怎么都对不上

这类偏差通常来自预处理。通道顺序(BGR/RGB)、均值减法的方式(逐通道减还是整体减)、输入缩放时短边的目标尺寸、以及 prototxt 里可能存在的输入尺度因子,任何一个不同都会让数值漂移。

排查要逐层来:拿一张图,固定输入,把骨干网某一层的输出打印出来,和 Caffe 侧同层输出做逐元素对比。差在输入层就是预处理问题,差在中间层就是某次归一化或激活的配置不同。别指望整体对比能看出原因,那是黑匣子。

5.5 现象:抽取速度慢到不可接受,或者显存直接爆

两个独立原因。速度慢多半是没做批内共享——同一张图的所有 proposal 共享同一次全图卷积,如果实现里对每个 proposal 都重跑一遍骨干网,耗时会翻几十倍。显存爆多半是batch_size调大了,但 prototxt 里的输入维度是写死的 1,改命令行参数没用,得改网络定义。

工程上的常规做法是:batch_size保持 1,用多进程或多卡按图片分片并行,每个分片写自己的输出文件,最后合并。这样既绕开了 prototxt 的限制,又能把 GPU 吃满。

6. 离线特征的取舍:固定 K 与冻结骨干到底划不划算

把特征抽出来存成文件,本质上是拿灵活性换效率。这笔账算下来划不划算,取决于你改模型的频率和数据的规模。

维度离线固定特征在线联合训练
训练速度快,每轮只跑轻量下游网络慢,每步都要过检测器
显存占用低,特征可放内存或本地盘高,骨干网激活要留着反传
数据增广几乎没法做,图已经变成向量可以自由裁剪、翻转、变色
端到端优化断开,检测器和下游各自最优连通,理论上界更高
复现成本低,特征文件一次生成反复用高,环境和随机种子都要对齐

固定 K 也是同一类取舍。自适应的 10 到 100 个框更贴近内容,但 batch 内变长意味着要么补齐浪费算力,要么按长度分桶增加实现复杂度。很多下游实现干脆统一取前 36 个(按置信度排序),换来的是张量形状固定、代码干净、显存可预测。代价是信息量被截断——一张信息密集的图可能真的需要八十个区域,截到 36 个就丢了细节。这个损失在小分辨率图上不明显,在高分辨率、多物体的场景里会体现出来。

冻结骨干是第三个取舍点。检测器在 Visual Genome 上学到的区域概念是通用的,下游数据量通常在几万到十几万的量级,微调一个上亿参数的检测器几乎必然过拟合。但反过来说,如果你的下游任务领域偏移很大(比如医学影像、卫星图),通用检测器提的框可能根本不落在关键区域上,这时候不微调就是自缚手脚。判断标准很朴素:先抽一批特征可视化看看框落在哪,如果框的位置和人类直觉一致,冻结就行;如果框总是飘在背景上,那问题不在下游模型,在抽取器。

我踩过最疼的一次坑,是拿到一份特征文件就直接开训,训了三天指标不动,最后发现那份文件是用另一套 id 映射生成的,训练集和验证集的特征整体错了位。从那以后我每次接入新的特征文件,都强制走一遍三步校验:id 集合比对、num_boxes分布直方图、随机抽三张图把框画回原图肉眼确认。这三步加起来不到十分钟,能省掉三天。希望帮到你。

本文还有配套的精品资源,点击获取

返回列表