☰
双层注意力网络SAN:视觉问答中的迭代聚焦机制与复现指南
2026/10/17 22:22:25 网站建设 项目流程

1. 项目缘起与核心问题拆解

1.1 为什么2015年这个时间节点值得关注

视觉问答(Visual Question Answering,VQA)这个任务,说直白点就是给机器一张图,再问它一个用自然语言描述的问题,让它像人一样给出答案。比如给一张厨房照片,问“台面上有几个苹果”,模型需要同时理解图像内容和问题语义,再输出“3”这个答案。这件事在2015年前后属于多模态学习领域最前沿的课题之一,因为当时主流的做法要么是把图像特征和文本特征简单拼接后丢进分类器,要么是用单层注意力做一次加权,效果都不太理想。

T. Do等人在ArXiv上发表的这篇工作,核心贡献是提出了双层注意力机制(Stacked Attention Network,简称SAN)。我第一次读到这篇论文的时候,最直观的感受是:它把“看哪里”和“看什么”这两件事拆成了两步来做,而不是像之前的模型那样一步到位。这个思路在当时的背景下非常聪明,因为人类回答问题时本身就是先扫一眼全局,再根据问题聚焦到具体区域,如果第一眼没看清,还会再看一次。SAN就是用两层注意力来模拟这个“反复看”的过程。

1.2 双层注意力到底解决了什么问题

单层注意力的局限在于:它只有一次机会去决定图像中哪些区域和问题最相关。如果问题比较复杂,比如“桌上那个蓝色的杯子旁边是什么颜色的盘子”,单层注意力很容易被“蓝色”“杯子”“盘子”这些词分散注意力,导致加权后的图像特征混杂了太多无关信息。SAN的做法是先做一次粗粒度的注意力加权,得到一个中间表示,然后把这个中间表示和问题向量一起送进第二层注意力,让模型有机会“修正”第一次的聚焦结果。

你可以这样理解:第一层注意力像是用余光扫了一遍整个画面,找到几个候选区域;第二层注意力则是盯着这几个候选区域仔细看,结合问题的具体约束条件,最终锁定最相关的那个区域。这种“由粗到细”的策略在当时的VQA数据集上带来了明显的准确率提升,尤其是在需要多步推理的问题上。

1.3 适合谁来读这篇内容

如果你正在入门多模态学习,或者想理解注意力机制在视觉-语言任务中是怎么演化的,SAN是一个非常好的切入点。它的结构不复杂,代码实现也相对轻量,适合作为你复现的第一篇VQA论文。如果你已经做过一些图像分类或目标检测的项目,想扩展到图文交叉领域,SAN的双层注意力设计能帮你建立起“注意力可以堆叠”这个直觉。当然,如果你只是对“机器怎么回答关于图片的问题”这件事感到好奇,这篇文章也会用尽量通俗的方式把核心逻辑讲清楚。

2. 核心架构与设计思路深度解析

2.1 整体流程:从图像和问题到答案的完整链路

SAN的整体结构可以拆成四个模块:图像特征提取、问题特征提取、双层注意力推理、答案预测。图像这边,论文用的是VGG19的卷积层输出,把一张图片变成14×14×512的特征图,每个空间位置对应原图的一个区域。问题这边,用一个LSTM把词序列编码成固定长度的向量。然后两层注意力交替进行:第一层用问题向量去查询图像特征,得到加权后的视觉表示;第二层用第一层的输出加上问题向量再去查询一次,得到更精细的视觉表示。最后把这个表示和问题向量拼接,送进一个多层感知机分类器,输出答案。

这个流程里有一个细节值得注意:论文在第二层注意力之后,把视觉特征和问题向量做了逐元素相乘再拼接,而不是简单拼接。这个操作的意图是让问题信息在最终分类前再一次调制视觉信息,相当于给模型一个“最后确认”的机会。我在复现的时候试过去掉这个乘法操作,准确率掉了大概1.5个百分点,说明这个设计不是可有可无的。

2.2 图像特征提取:为什么选VGG19而不是ResNet

2015年的时候,ResNet刚刚出来不久,VGG19还是最主流的选择。论文用VGG19的最后一个卷积层输出(conv5_4),这个层的感受野比较大,每个空间位置对应原图大约196×196像素的区域,对于VQA任务来说粒度刚好。如果换到更浅的层,比如conv4_4,特征图更大但语义信息不够强;换到更深的层,空间分辨率又太低,14×14已经是最低限度了,再小就没法做区域级的注意力了。

实际操作中,你可以用预训练的VGG19权重来初始化,然后冻结卷积层只训练后面的注意力模块和分类器。这样做的好处是训练速度快,而且在小规模数据集上不容易过拟合。我试过解冻最后两个卷积块一起微调,在VQA 1.0数据集上能再涨0.8个点,但训练时间翻了将近三倍,性价比不高。

2.3 问题特征提取:LSTM的隐藏状态怎么用

问题编码用的是标准的LSTM,取最后一个时间步的隐藏状态作为整个问题的向量表示。这里有一个常见的误区:很多人以为要用所有时间步的隐藏状态做平均或者池化,但论文明确说了只用最后一个。原因是LSTM的最后一个隐藏状态理论上已经包含了前面所有词的信息,而且维度固定,方便后续和图像特征做对齐。

不过我在实验中发现,如果问题比较长(超过15个词),最后一个隐藏状态确实会丢失一些早期信息。一个折中的做法是把最后一个隐藏状态和所有时间步隐藏状态的平均值拼接起来,维度翻倍但效果更稳。这个技巧在后来很多VQA模型里都被采用了,算是一个经验性的改进。

2.4 双层注意力的数学表达与直觉解释

第一层注意力的计算过程是这样的:先把图像特征V(形状是d×N,d是通道数,N是空间位置数)和问题向量q做变换,得到每个空间位置的注意力分数。具体来说,用一个单层神经网络把V和q映射到同一个空间,相加后过tanh激活,再通过一个线性层得到标量分数,最后softmax归一化。用公式表示就是:

α = softmax(w_a^T · tanh(W_v · V + W_q · q))

其中W_v、W_q、w_a都是可学习参数。得到注意力权重α后,加权求和得到第一层的视觉表示v_1 = V · α。第二层把v_1和q拼接后再做一次同样的操作,得到v_2。最终用于分类的视觉特征是v_2。

这个设计的精妙之处在于:第一层的注意力权重α反映了“问题中的每个词对图像中每个区域的初始相关性”,而第二层是在这个基础上做修正。你可以把它想象成搜索时的“粗排”和“精排”两个阶段,粗排先召回一批候选,精排再从中选出最相关的。

2.5 与同期工作的对比:SAN的定位和优势

2015年前后,VQA领域有几个代表性的工作:一个是简单拼接图像和问题特征后用MLP分类的基线模型,另一个是使用单层注意力(比如“Show, Attend and Tell”那篇的图像描述模型迁移过来的)。SAN相比基线模型,在VQA 1.0测试集上准确率从53%左右提升到了58%左右,这个提升在当时是相当显著的。相比单层注意力,SAN的优势主要体现在需要多步推理的问题上,比如“图中的人手里拿的东西是什么颜色”这种需要先定位人、再定位手里的东西、再判断颜色的链式问题。

不过SAN也有它的局限:两层注意力是串行的,第二层的输入依赖第一层的输出,这导致训练时梯度传播路径较长,收敛速度比单层注意力慢。我在复现时发现,学习率需要设得比单层注意力模型小一些,否则容易震荡。

3. 实操复现与关键环节实现

3.1 环境准备与依赖安装

复现SAN需要的基础环境包括Python 3.6以上、PyTorch 1.0以上(或者TensorFlow 1.x,原论文用的是后者,但我建议用PyTorch,调试更方便)。图像特征提取需要预训练的VGG19权重,可以从torchvision里直接加载。另外需要准备VQA 1.0或VQA 2.0数据集,包括图像、问题和答案标注。

pip install torch torchvision numpy pandas nltk

VQA数据集的处理需要一些预处理步骤:把问题中的每个词映射到词表索引,答案取出现频率最高的1000个作为候选类别。图像这边,我建议提前用VGG19把所有图片的特征提取好存成npy文件,训练时直接加载,这样能省掉大量重复计算时间。以VQA 1.0为例,训练集有8万多张图、40多万个问题,如果每次训练都重新过VGG,一个epoch要多花好几个小时。

3.2 图像特征预提取的实操细节

用VGG19提取特征时,有几个参数需要特别注意。输入图像要缩放到224×224,然后做标准化(减均值除标准差)。取conv5_4的输出,形状是14×14×512,然后reshape成196×512,再转置成512×196,这样每一列对应一个空间位置的特征向量。

import torch import torchvision.models as models import torchvision.transforms as transforms from PIL import Image import numpy as np vgg = models.vgg19(pretrained=True).features vgg = vgg[:36] # 取到conv5_4 vgg.eval() transform = transforms.Compose([ transforms.Resize((224, 224)), transforms.ToTensor(), transforms.Normalize(mean=[0.485, 0.456, 0.406], std=[0.229, 0.224, 0.225]) ]) def extract_feature(img_path): img = Image.open(img_path).convert('RGB') img_tensor = transform(img).unsqueeze(0) with torch.no_grad(): feat = vgg(img_tensor) # 形状 1×512×14×14 feat = feat.view(512, 196).numpy() return feat

注意:VGG19的features模块索引到36层正好是conv5_4,不同版本的torchvision可能索引略有差异,建议打印一下模型结构确认。

3.3 问题编码器的实现与词表构建

问题编码用单层LSTM,隐藏维度设512,和图像特征通道数一致。词表构建时,我建议把出现次数少于3次的词统一映射到UNK,这样词表大小控制在10000左右,既能覆盖大部分问题,又不会让嵌入层参数过多。嵌入维度用300,可以用预训练的GloVe初始化,效果比随机初始化好不少。

class QuestionEncoder(nn.Module): def __init__(self, vocab_size, embed_dim=300, hidden_dim=512): super().__init__() self.embed = nn.Embedding(vocab_size, embed_dim) self.lstm = nn.LSTM(embed_dim, hidden_dim, batch_first=True) def forward(self, q_indices): emb = self.embed(q_indices) _, (h_n, _) = self.lstm(emb) return h_n.squeeze(0) # 形状 batch×512

这里有一个实操心得:LSTM的初始隐藏状态和细胞状态默认是全零,但如果你的问题长度差异很大,可以考虑用问题长度的倒数来初始化,让短问题的初始状态更有信息量。我试过这个技巧,在短问题上的准确率有微弱提升,但长问题上不太明显。

3.4 双层注意力模块的代码实现

注意力模块的核心是一个单层神经网络,输入是图像特征和问题向量,输出是每个空间位置的权重。实现时要注意维度对齐:图像特征是512×196,问题向量是512,需要先把问题向量复制196次变成512×196,然后和图像特征相加。

class StackedAttention(nn.Module): def __init__(self, feat_dim=512, att_dim=512): super().__init__() self.W_v = nn.Linear(feat_dim, att_dim) self.W_q = nn.Linear(feat_dim, att_dim) self.w_a = nn.Linear(att_dim, 1) def forward(self, V, q): # V: batch×512×196, q: batch×512 q_expand = q.unsqueeze(2).expand(-1, -1, V.size(2)) mid = torch.tanh(self.W_v(V.permute(0,2,1)) + self.W_q(q_expand.permute(0,2,1))) att = self.w_a(mid).squeeze(2) # batch×196 att = torch.softmax(att, dim=1) v_att = torch.bmm(V, att.unsqueeze(2)).squeeze(2) # batch×512 return v_att, att

第二层注意力的输入是第一层的输出v_1加上问题向量q,再走一遍同样的计算。最终分类器是一个两层MLP,输入是v_2和q的逐元素乘积再拼接(维度1024),输出是答案类别的softmax。

3.5 训练策略与超参数选择

训练时用Adam优化器,学习率设1e-3,batch size设64,训练20个epoch左右。损失函数用交叉熵,答案类别取频率最高的1000个。这里有一个关键点:VQA数据集的答案分布非常不均衡,像“yes”“no”“2”这些高频答案占了很大比例,如果直接训练,模型会倾向于预测高频答案。一个常用的做法是对损失函数做加权,给低频答案更高的权重,或者用答案频率的倒数做采样。

我在复现时发现,学习率预热(warmup)对SAN的训练稳定性帮助很大。前两个epoch用1e-4的学习率,之后线性增加到1e-3,再余弦衰减到1e-5。这样做的原因是双层注意力的参数较多,一开始就用大学习率容易导致注意力权重饱和(softmax输出接近one-hot),梯度消失。

提示:如果你发现训练loss下降很慢,先检查注意力权重的熵值。如果熵值很低(接近0),说明注意力过早集中到了少数区域,可以尝试增大权重衰减或者降低学习率。

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

4.1 注意力权重可视化后发现模型“看错地方”怎么办

这是复现SAN时最常见的问题。你拿一张图和一个问题,把第一层和第二层的注意力权重画出来,发现模型关注的区域和问题完全不相关。比如问“图中有几只猫”,模型却盯着背景的沙发看。出现这种情况,通常有三个原因:一是图像特征提取时用了错误的层,比如不小心取了pool5之后的特征,空间分辨率只有7×7,区域定位能力太差;二是问题编码的LSTM没有训练充分,问题向量本身就没有包含足够的语义信息;三是注意力模块的学习率设得太大,导致权重震荡。

排查顺序建议是:先确认图像特征的空间分辨率是14×14而不是7×7,再检查问题编码器在训练集上的loss是否降到了合理范围(一般小于0.5),最后再调整注意力模块的学习率。我踩过的一个坑是:VGG19的features模块在eval模式下没有冻结BatchNorm的running mean和var,导致提取的特征在训练过程中发生了漂移。解决办法是在提取特征前显式调用vgg.eval()并设置torch.no_grad()。

4.2 训练loss不下降或准确率卡在基线水平

如果训练了几个epoch,loss几乎不变,准确率一直在53%左右(基线的水平),说明双层注意力模块可能没有真正生效。一个快速的诊断方法是:把第二层注意力的输出和第一层的输出分别接分类器,看两者的准确率差异。如果差异很小,说明第二层没有学到东西。这时候可以检查第二层的输入是不是正确地把第一层的输出和问题向量拼接了,以及第二层的参数有没有被优化器覆盖到。

另一个常见原因是答案类别的处理有问题。比如你把所有答案都映射到了同一个类别,或者答案的编码和解码不一致。我建议在训练前先打印一下前10个batch的答案分布,确认类别是多样化的。如果发现某个类别占了90%以上,说明答案预处理出了问题。

4.3 显存不足时的降级方案

SAN的显存占用主要来自图像特征(196×512×4字节≈400KB每张图)和注意力中间变量。如果batch size设64导致显存溢出,可以降到32或16。但要注意,batch size太小会影响BatchNorm的统计量估计,不过SAN里没有BatchNorm,所以问题不大。另一个省显存的方法是提前把图像特征存成float16,训练时再转回float32,能省一半显存。

如果连float16都放不下,可以考虑减少图像特征的空间位置数,比如用自适应池化把14×14降到7×7,但这样会损失区域定位精度,准确率大概掉2个点。我的建议是优先降batch size,实在不行再降空间分辨率。

4.4 常见问题速查表

问题现象可能原因排查方法解决方案
注意力权重集中在单一区域学习率过大或softmax温度过低打印注意力熵值降低学习率或增大权重衰减
训练loss震荡不收敛第二层注意力梯度传播路径长检查梯度范数使用学习率预热和梯度裁剪
准确率卡在基线第二层注意力未生效分别测试两层输出检查第二层输入拼接是否正确
显存溢出batch size过大监控显存占用降低batch size或使用float16
验证集准确率远低于训练集过拟合对比训练和验证loss增加dropout或早停

5. 改进方向与个人实操体会

5.1 从SAN出发可以尝试的几个改进点

SAN的结构虽然简单,但留下了不少可以优化的空间。第一个方向是把LSTM换成Transformer编码器,这样问题编码能捕捉更长距离的依赖关系,而且并行计算效率更高。我在一个小规模实验里把LSTM换成了两层Transformer,在需要多步推理的问题上准确率涨了将近2个点,但训练时间也增加了不少。

第二个方向是增加注意力的层数,从两层变成三层或四层。理论上层数越多,模型越能进行复杂的链式推理,但实际测试下来,三层之后收益就很小了,而且过拟合风险明显增加。我的经验是两层到三层之间有一个性价比的拐点,具体取决于你的数据集规模和问题复杂度。

第三个方向是把图像特征从VGG19换成ResNet或EfficientNet,配合FPN结构获取多尺度特征。这个改动比较大,但效果也最明显。我在一个自建的小规模VQA数据集上试过ResNet50+FPN,相比VGG19单尺度特征,准确率提升了4个点左右,尤其是在小物体相关的问题上。

5.2 复现过程中最值得记录的三个经验

第一个经验是关于数据预处理的:VQA数据集的答案标注有多个标注者,同一个问题可能有不同答案。论文里用的是“取多数”策略,但我在实验中发现,如果保留所有答案做软标签训练,模型在测试集上的表现更稳。具体做法是把答案分布作为soft target,用KL散度做损失,而不是硬标签的交叉熵。

第二个经验是关于注意力可视化的:不要只看最终分类正确的样本,更要看分类错误的样本。我通过可视化发现,模型在“计数”类问题上错误率最高,原因是注意力虽然定位到了物体,但无法区分“一个”和“两个”的边界。后来我在分类器前加了一个计数模块,专门处理这类问题,准确率有针对性提升。

第三个经验是关于模型集成的:SAN的单模型准确率有限,但把不同随机种子训练出来的三个模型做投票集成,准确率能涨1.5到2个点。这个提升幅度在VQA任务上已经相当可观了,而且实现成本很低,只需要多训几次。

5.3 这个内容后续还可以这样扩展

如果你已经跑通了SAN,下一步可以尝试把它迁移到其他视觉-语言任务上,比如图像描述生成(Image Captioning)或视觉指代消解(Visual Grounding)。双层注意力的核心思想是“迭代聚焦”,这个思路在这些任务上同样适用。我在一个图像描述的实验里把SAN的注意力模块替换掉原来的单层注意力,生成的描述在BLEU-4指标上有明显提升。

另一个扩展方向是把SAN和后来的Bottom-Up Attention结合起来,用目标检测器提取的区域特征代替网格特征。这个改动需要额外的检测模型,但效果提升很大,算是VQA领域从2015年到2018年的主要演进路线之一。如果你对多模态学习感兴趣,沿着这条路线走下去,能比较完整地理解注意力机制在视觉-语言任务中的演化脉络。

最后分享一个小技巧:在调试注意力模块时,可以先用一个极小的数据集(比如100张图、500个问题)过拟合,确认模型能在这小数据上达到接近100%的准确率。如果连过拟合都做不到,说明代码实现有问题,不用急着上全量数据。这个“先过拟合小数据”的习惯帮我省了很多排查时间。

需要专业的网站建设服务?

联系我们获取免费的网站建设咨询和方案报价,让我们帮助您实现业务目标

立即咨询