
1. 为什么我选择死磕MAttNet这篇老论文指称表达理解Referring Expression Comprehension简称REC这个任务说白了就是给机器一句话和一个场景让它把话里指的那个东西框出来。比如左边那个穿红衣服的人模型得在一堆人里找到对应目标并输出边界框。这个任务看起来简单实际上涉及语言理解、视觉定位、跨模态对齐三件事的耦合是视觉-语言交叉领域里非常经典的一个benchmark。MAttNetModular 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同时把特征维度适当压缩。依赖清单我列一下这些都是实测能跑通的版本组合依赖项版本说明Python3.83.9以上部分库有兼容问题PyTorch1.10.0与CUDA 11.3匹配torchvision0.11.0要和PyTorch版本对应numpy1.21.0别用太新的1.24以上有API变动scipy1.7.0评估指标计算会用到h5py3.6.0读取预提取的视觉特征nltk3.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的时候指定encodinglatin1。另一个坑是图像路径。标注文件里存的是图像的文件名但实际图像可能分散在多个子目录里。你需要自己写一个映射函数把文件名映射到完整路径。我一开始没注意这个训练的时候一直报FileNotFoundError排查了半天才发现是路径拼接的问题。3.2 指称表达的预处理细节指称表达是自然语言预处理这一步看似简单实际上有很多细节会影响最终效果。我总结了几条分词方式MAttNet原论文用的是简单的空格分词但英文里dont这种缩写会被切成don和t影响语义。我后来改用了nltk的word_tokenize效果好一些。大小写统一转小写减少词表大小。长度截断指称表达一般不会太长我设的最大长度是20个词超过的截断不足的padding。词表构建只在训练集上构建词表验证集和测试集用训练集的词表。这个顺序千万别搞反否则会有信息泄露。注意RefCOCOg的指称表达普遍比RefCOCO长平均长度能到8到10个词而RefCOCO大概只有3到5个词。如果你在RefCOCOg上训练最大长度要设大一点不然会截断掉关键信息。3.3 候选区域特征的提取与对齐前面说了用预提取特征但这里还有个对齐问题检测模型提取的候选区域和标注里的referring box并不是一一对应的。MAttNet的做法是对每个候选区域计算它与referring box的IoUIoU大于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虽然土但很管用。