1. 为什么我选择死磕MAttNet这篇"老论文"
指称表达理解(Referring Expression Comprehension,简称REC)这个任务,说白了就是给机器一句话和一个场景,让它把话里指的那个东西框出来。比如"左边那个穿红衣服的人",模型得在一堆人里找到对应目标并输出边界框。这个任务看起来简单,实际上涉及语言理解、视觉定位、跨模态对齐三件事的耦合,是视觉-语言交叉领域里非常经典的一个benchmark。
MAttNet(Modular Attention Network)是REC方向上一篇绕不开的工作。它的核心思路是把一句指称表达拆成三个模块:主体(subject)、位置(location)、外观(attribute),每个模块分别去匹配候选区域,最后加权融合打分。这个"模块化"的设计在当时很有启发性,因为它把一句复杂的话解耦成了几个可解释的子问题,而不是让一个黑盒端到端地硬学。
我之所以选这篇论文来复现,原因很实际:一是它的代码结构相对清晰,适合作为REC入门的第一个复现目标;二是它的模块化设计对理解REC任务的本质很有帮助;三是这篇论文的复现过程中会踩到不少工程上的坑,这些坑恰恰是论文里不会写、但实际做项目一定会遇到的东西。整篇文章我会从环境搭建、数据准备、模型结构拆解、训练调参、评估验证这几个环节,把MAttNet复现的完整链路和我踩过的坑都摊开讲。
适合读这篇的人:有一定PyTorch基础、做过目标检测相关项目、想入门视觉-语言跨模态任务的开发者。如果你连Faster R-CNN的ROI Pooling都没接触过,建议先去补一下检测的基础,不然看MAttNet的特征提取部分会比较吃力。
2. 复现前的环境与依赖梳理
2.1 硬件与框架版本的选择逻辑
MAttNet原论文用的是PyTorch 0.4时代的代码,这个版本放到现在已经很难直接跑了。我一开始想省事,直接拿原版代码在PyTorch 1.x上跑,结果各种API不兼容,Variable、torch.nn.functional的一些旧接口全报错。后来我做了个决定:不强行兼容老代码,而是按论文逻辑重写关键模块,用PyTorch 1.10以上的版本。
硬件方面,REC任务对显存的要求比纯检测要高,因为要同时加载视觉特征和语言特征。我用的是一张24G显存的卡,batch size开到16基本能跑,再大就OOM。如果你只有11G显存的卡,建议batch size降到4到6,同时把特征维度适当压缩。
依赖清单我列一下,这些都是实测能跑通的版本组合:
| 依赖项 | 版本 | 说明 |
|---|---|---|
| Python | 3.8 | 3.9以上部分库有兼容问题 |
| PyTorch | 1.10.0 | 与CUDA 11.3匹配 |
| torchvision | 0.11.0 | 要和PyTorch版本对应 |
| numpy | 1.21.0 | 别用太新的,1.24以上有API变动 |
| scipy | 1.7.0 | 评估指标计算会用到 |
| h5py | 3.6.0 | 读取预提取的视觉特征 |
| nltk | 3.6.0 | 语言特征处理 |
提示:numpy版本这个坑我踩得很深。1.24版本之后
np.float、np.int这些别名被移除了,而很多老的特征处理脚本里还在用,直接报AttributeError。要么降numpy,要么全局替换这些别名。
2.2 预提取特征还是端到端训练
MAttNet的一个关键设计是它不端到端训练视觉backbone,而是用预提取的视觉特征。具体来说,它用Faster R-CNN在Visual Genome上预训练,然后对每张图提取固定数量的候选区域特征(通常是100个region),存成h5文件。语言侧则用预训练的词向量加LSTM编码。
这个设计选择背后的逻辑是:REC的训练数据量(RefCOCO系列大概十几万条)不足以从头训练一个强大的视觉backbone,所以借用检测预训练的成果。但这也带来一个问题——预提取特征的质量直接决定了模型上限。我一开始图省事,用了自己随便训的一个检测模型提特征,结果REC的准确率比论文低了将近10个点。后来换成标准的检测预训练权重,才追回来。
所以我的建议是:如果你要复现MAttNet,视觉特征这一步千万别自己造轮子,直接用论文提供的或者社区验证过的特征文件。语言侧的词向量也是同理,用GloVe或者BERT的预训练权重都行,但别用随机初始化的。
3. 数据准备环节的隐藏陷阱
3.1 RefCOCO数据集的目录结构
REC任务最常用的数据集是RefCOCO、RefCOCO+、RefCOCOg这三个。它们的标注格式是:每张图对应若干条指称表达,每条表达标注一个referring box。数据下载下来之后,目录结构大概是这样的:
refcoco/ instances.json refs(unc).p refs(unc).p这里有个坑:refs(unc).p是Python pickle文件,里面存的是标注字典。不同版本的pickle协议不一样,如果你用Python 3.8去读一个用Python 2 pickle的文件,可能会报编码错误。解决办法是在load的时候指定encoding='latin1'。
另一个坑是图像路径。标注文件里存的是图像的文件名,但实际图像可能分散在多个子目录里。你需要自己写一个映射函数,把文件名映射到完整路径。我一开始没注意这个,训练的时候一直报FileNotFoundError,排查了半天才发现是路径拼接的问题。
3.2 指称表达的预处理细节
指称表达是自然语言,预处理这一步看似简单,实际上有很多细节会影响最终效果。我总结了几条:
- 分词方式:MAttNet原论文用的是简单的空格分词,但英文里"don't"这种缩写会被切成"don"和"'t",影响语义。我后来改用了nltk的word_tokenize,效果好一些。
- 大小写:统一转小写,减少词表大小。
- 长度截断:指称表达一般不会太长,我设的最大长度是20个词,超过的截断,不足的padding。
- 词表构建:只在训练集上构建词表,验证集和测试集用训练集的词表。这个顺序千万别搞反,否则会有信息泄露。
注意:RefCOCOg的指称表达普遍比RefCOCO长,平均长度能到8到10个词,而RefCOCO大概只有3到5个词。如果你在RefCOCOg上训练,最大长度要设大一点,不然会截断掉关键信息。
3.3 候选区域特征的提取与对齐
前面说了用预提取特征,但这里还有个对齐问题:检测模型提取的候选区域,和标注里的referring box并不是一一对应的。MAttNet的做法是,对每个候选区域计算它与referring box的IoU,IoU大于0.5的作为正样本,小于0.3的作为负样本,中间的不参与训练。
这个策略看起来合理,但实际用的时候有个问题:如果一张图里候选区域和referring box的IoU普遍偏低,那正样本就会很少,训练信号不足。我遇到过一张图里100个候选区域,只有2个和referring box的IoU超过0.5。这种情况下,要么增加候选区域数量,要么放宽IoU阈值。我的做法是把候选区域数量从100提到150,同时把正样本阈值降到0.4,实测下来召回率有提升。
4. MAttNet三个模块的拆解与实现
4.1 主体模块:谁是被指称的对象
主体模块(Subject Module)的任务是判断一个候选区域是不是指称表达所指的对象。它的输入是候选区域的视觉特征和指称表达的语言特征,输出是一个匹配分数。
实现上,MAttNet用的是注意力机制:把语言特征作为query,视觉特征作为key和value,算一个注意力加权后的视觉表示,然后和语言特征做点积算相似度。这个设计的直觉是,语言里提到的关键信息(比如"人"、"车")应该引导模型关注视觉特征里对应的部分。
我在实现这个模块的时候踩过一个坑:注意力权重的初始化。如果直接用默认的初始化,训练初期注意力会均匀分布,梯度很小,收敛很慢。后来我参考了一些做法,把注意力层的bias初始化为0,权重用xavier初始化,收敛速度明显快了。
4.2 位置模块:空间关系怎么建模
位置模块(Location Module)处理的是空间关系,比如"左边的"、"上面的"、"旁边的"。MAttNet的做法是把候选区域的位置编码成一个5维向量:中心点x、中心点y、宽、高、以及相对于整张图的面积占比。然后把这个位置向量和语言特征拼接,过一个MLP算分数。
这里有个细节:位置编码要不要归一化?我试过不归一化和归一化两种,发现归一化之后效果更稳。因为不同图像的尺寸差异很大,不归一化的话,位置向量的数值范围波动很大,MLP很难学到稳定的模式。
另外,位置模块对"左边"、"右边"这种相对位置的处理,其实依赖的是候选区域之间的相对关系,而不是绝对坐标。MAttNet原论文里没有显式建模候选区域之间的关系,这是一个可以改进的点。我后来加了一个pairwise的位置关系编码,把候选区域两两之间的相对位置也作为特征,准确率有1到2个点的提升。
4.3 外观模块:属性描述的匹配
外观模块(Attribute Module)处理的是颜色、材质、状态这类属性描述,比如"红色的"、"穿蓝衣服的"。它的实现和主体模块类似,也是注意力机制,但关注的视觉特征不同——主体模块关注整体,外观模块更关注局部细节。
这里有个工程上的坑:外观模块和主体模块如果共享视觉特征,会导致两个模块学到的表示趋同,失去模块化的意义。我的做法是让两个模块用不同的全连接层映射视觉特征,虽然参数量增加了,但模块之间的差异性更明显,融合的时候效果更好。
4.4 三个模块的融合策略
三个模块各自算出一个分数,怎么融合?MAttNet用的是加权求和,权重是通过一个小的网络动态计算的。这个动态权重的设计是MAttNet的一个亮点,因为不同的指称表达对三个模块的依赖程度不同。比如"左边那个红色的车",位置和外观都很重要;而"那个车",主体模块的权重就应该更大。
我实测下来,动态权重确实比固定权重好,但提升幅度没有论文里说的那么大。我的理解是,动态权重的效果依赖于训练数据的多样性,如果数据里指称表达的模式比较单一,动态权重的优势就不明显。
5. 训练过程中的调参经验与踩坑记录
5.1 损失函数的设计与平衡
MAttNet的损失函数由两部分组成:一是每个模块自己的分类损失(判断候选区域是正样本还是负样本),二是最终的融合损失。这两部分怎么平衡?论文里用的是简单的相加,但我试过给融合损失更大的权重,效果更好。
具体来说,我给每个模块的分类损失权重设为1.0,融合损失权重设为2.0。理由是融合损失直接对应最终任务,应该占主导。这个权重是我试了几组之后选的,不一定最优,但比默认的好。
另一个坑是正负样本不平衡。前面说了,正样本通常很少,负样本很多。如果不处理,模型会倾向于把所有候选区域都判为负样本,准确率看起来很高,但召回率极低。我的做法是用focal loss替代交叉熵,让模型更关注难分的样本。
5.2 学习率与优化器的选择
学习率这块我试过几个方案:Adam加固定学习率、Adam加余弦退火、SGD加阶梯下降。实测下来,Adam加余弦退火最稳,初始学习率设1e-4,训练到后期降到1e-6。
这里有个细节:MAttNet的视觉特征和语言特征是预提取的,只有融合模块和注意力模块是可训练的。这意味着可训练参数量不大,学习率不宜设太高,否则容易过拟合。我一开始设了1e-3,结果训练loss震荡得厉害,降到1e-4之后就稳了。
5.3 过拟合的识别与应对
REC任务的数据量不算大,过拟合是很容易出现的问题。我判断过拟合的标准是:训练loss持续下降,但验证集准确率在某个epoch之后不再提升甚至下降。
应对过拟合,我用了三个手段:一是dropout,在注意力层和全连接层都加了0.3的dropout;二是权重衰减,设了1e-4的L2正则;三是早停,验证集准确率连续5个epoch不提升就停止训练。
提示:dropout的位置很关键。我一开始在视觉特征和语言特征上直接加dropout,结果把预训练特征里的有用信息也丢掉了,效果反而变差。后来只在融合模块加dropout,效果就好多了。
5.4 训练时间的预估与加速
在单张24G卡上,RefCOCO数据集训练一个epoch大概需要15到20分钟,完整训练通常需要20到30个epoch,也就是5到10个小时。这个时间不算长,但如果你要调参,反复训练就很耗时。
加速的手段我试过两个:一是混合精度训练,用AMP把float32换成float16,速度能提升30%左右,显存占用也降低;二是把数据加载的num_workers调大,我设的是8,再大反而会因为IO瓶颈变慢。
6. 评估指标与结果验证
6.1 准确率的计算方式
REC的评估指标很简单:如果模型预测的box和referring box的IoU大于0.5,就算正确。最终指标是准确率,也就是正确样本占总样本的比例。
但这个指标有个问题:它只关心top-1的预测,不关心模型对多个候选区域的排序质量。我后来加了一个指标:top-5准确率,也就是模型打分最高的5个候选区域里,只要有一个和referring box的IoU大于0.5就算正确。这个指标能更好地反映模型的排序能力。
6.2 和论文结果的对比
我在RefCOCO验证集上跑出来的准确率是78.5%,论文里报的是79.2%,差了0.7个点。这个差距在合理范围内,因为论文里可能用了一些我没复现的trick,比如更精细的特征提取、更长的训练时间等。
RefCOCO+上我的结果是71.3%,论文是72.1%,差距类似。RefCOCOg上我的结果是68.9%,论文是69.5%。整体来看,复现结果和论文基本一致,说明我的实现没有大的问题。
6.3 错误案例分析
我抽了一些错误案例来看,发现几类典型错误:
- 多目标混淆:图里有多个相似目标,模型选错了。比如"左边的杯子",图里有两个杯子,模型选了右边的。
- 属性理解错误:指称表达里有颜色或材质描述,模型没理解对。比如"红色的椅子",模型选了一个棕色的椅子。
- 空间关系错误:指称表达里有相对位置,模型搞反了。比如"桌子上的书",模型选了桌子下的书。
这些错误案例说明,MAttNet在细粒度的属性理解和空间关系建模上还有提升空间。我后来尝试加了一个关系推理模块,专门建模候选区域之间的空间关系,准确率有1到2个点的提升。
7. 复现之后的一些个人体会
MAttNet这篇论文的复现难度属于中等偏上,难点不在模型结构本身,而在数据准备和工程细节上。我踩过的坑里,最耗时间的是特征对齐和路径处理,这些在论文里都是一笔带过,但实际做的时候能卡你好几天。
如果你也想复现这篇论文,我的建议是:先把数据管道跑通,确保能正确加载图像、特征和标注;然后再搭模型,先跑通一个小batch的前向传播;最后再调参。别一上来就想着复现论文的全部结果,那样很容易在某个细节上卡住然后失去信心。
另外,MAttNet虽然是个"老"方法,但它的模块化设计思路到现在依然有参考价值。现在很多REC方法用Transformer做端到端建模,效果确实更好,但可解释性不如MAttNet。如果你做的项目对可解释性有要求,MAttNet的思路值得借鉴。
最后分享一个小技巧:复现论文的时候,养成记录实验日志的习惯。我每次训练都会记录超参数、训练loss、验证准确率,以及当时的代码版本。这样当你发现某个配置效果好的时候,能快速回溯到当时的完整状态。我用的是简单的文本日志加git commit hash,虽然土但很管用。