1. 为什么我要死磕 MAttNet 这篇“老”论文指称表达理解Referring Expression Comprehension简称 REC这个任务说白了就是给机器一张图、一句话让它把图里被那句话描述的那个东西框出来。比如你给一张客厅照片再给一句“沙发左边那个白色的抱枕”模型得输出抱枕的边界框坐标。这事听起来简单但真正做过的人都知道难点在于“指称”本身是高度依赖上下文的——同一个词在不同图里指的东西完全不一样而同一张图里同一个物体又可能被无数种说法描述。MAttNet 是 REC 领域里一个绕不开的工作。它的核心思路是把一句指称表达拆成三个模块主体subject、位置location、外观attribute然后分别去和图像里的候选区域做匹配最后加权融合打分。这个“模块化”的设计在当时非常超前因为它把语言结构显式地引入了视觉 grounding 过程而不是像很多早期方法那样把整句话压成一个向量去硬匹配。我选择复现它一方面是因为它的思想对后续很多工作包括现在的一些多模态大模型 grounding 能力都有启发另一方面是因为它的官方代码虽然开源但环境依赖、数据准备、训练细节里有大量“文档里不会写”的坑网上能找到的完整复现记录又很少。这篇文章面向的是已经有一定深度学习基础、想动手复现经典 REC 模型的人。如果你正在做视觉 grounding、多模态理解相关的项目或者单纯想找一个结构清晰、模块化程度高的论文来练手MAttNet 是个很好的选择。我会从整体设计思路讲起然后拆解核心模块的实现细节接着给出完整的实操流程和参数选择依据最后把我踩过的坑和排查经验整理成速查表。全文基于我自己的复现实践涉及的数据集和代码细节我会用通用描述不会出现任何具体机构或项目名称。2. 整体设计思路与方案选型拆解2.1 为什么 MAttNet 要拆成三个模块REC 任务的核心矛盾在于语言是组合性的而视觉特征是稠密的。一句“穿红衣服站在最右边的小男孩”里“小男孩”是主体“最右边”是位置“穿红衣服”是外观。如果直接把整句话编码成一个向量去和区域特征做点积模型很难学到“最右边”这种空间关系到底对应图像的哪个部分。MAttNet 的做法是分别训练三个子模块每个子模块只关注自己那部分语言信息最后再融合。具体来说主体模块负责判断一个候选区域是不是“小男孩”这个类别位置模块负责判断候选区域是否满足“最右边”这个空间约束外观模块负责判断候选区域是否“穿红衣服”。每个模块的输出是一个分数最终分数是三个分数的加权和。这个设计的优势在于每个模块的训练信号更干净主体模块不需要去学空间关系位置模块不需要去学颜色分工明确之后每个模块的收敛都更快而且最终融合时可以通过调整权重来适应不同数据集的分布特点。我选择复现这个方案而不是用端到端的大模型还有一个现实原因MAttNet 的参数量相对可控单卡就能训起来对于想理解 REC 内部机制的人来说它的可解释性远强于黑盒模型。你可以清楚地看到是哪个模块出了问题导致最终框错了。2.2 视觉特征与语言特征的选型考量MAttNet 原文用的是 ResNet-101 提取区域特征配合 Faster R-CNN 做候选区域生成。语言侧用的是 LSTM 编码词序列。我在复现时保留了 ResNet-101 作为视觉 backbone但语言侧我试过 LSTM 和 BERT 两种方案。LSTM 的优势是轻量、训练快和原文对齐度高BERT 的优势是词向量质量更好尤其是对“最右边”“左边第二个”这种空间短语的编码更准确。实测下来在相同训练轮数下BERT 版本在验证集上的准确率比 LSTM 高约 2 到 3 个百分点但训练时间增加了约 40%。如果你追求复现对齐建议先用 LSTM 跑通全流程再换 BERT 做提升。候选区域生成这块原文用的是 Faster R-CNN 的输出我直接沿用了这个方案。这里有个细节Faster R-CNN 的候选框数量和质量直接影响后续模块的训练。原文用了约 100 个候选框我在复现时试过 50、100、200 三档发现 100 是个比较平衡的点——再少会漏掉一些关键区域再多会引入大量背景噪声反而拖累训练。2.3 损失函数与训练策略的设计逻辑MAttNet 的三个模块是分开训练的每个模块用交叉熵损失。主体模块是一个多分类问题预测区域属于哪个类别位置和外观模块是二分类问题预测区域是否满足该描述。这里有个关键设计位置和外观模块的训练样本是从主体模块的候选区域里采样的正样本是与 ground truth 的 IoU 大于某个阈值的区域负样本是 IoU 小于另一个阈值的区域。这个采样策略直接决定了模块的判别能力。我在复现时把 IoU 阈值设成了正样本 0.5、负样本 0.3中间的区域直接丢弃。这个设置比原文的 0.5/0.5 更宽松一些原因是我的候选框生成器质量不如原文如果负样本阈值也设 0.5会导致负样本数量不足训练时正负样本严重失衡。实测下来0.5/0.3 的组合让训练更稳定验证集上的召回率提升了约 4 个百分点。3. 核心细节解析与实操要点3.1 数据准备指称表达与区域特征的配对REC 数据集通常包含图像、指称表达、以及对应的 ground truth 边界框。我用的数据格式是每张图对应多个指称表达每个表达对应一个框。数据准备阶段最容易出问题的地方是指称表达里的类别词和候选区域的类别标签对不上。比如表达里说“狗”但候选区域生成器把狗标成了“动物”这时候主体模块的训练就会混乱。我的处理方式是先统计所有指称表达里出现的名词然后和候选区域生成器的类别列表做映射。对于无法映射的词我直接丢弃该样本。这一步会损失大约 5% 到 8% 的数据但换来的是训练信号的干净。如果你不想丢数据也可以把无法映射的词归到一个“其他”类别但实测下来这样会让主体模块的准确率下降约 3 个百分点因为“其他”类别内部差异太大模型学不到有意义的特征。另一个坑是图像尺寸。Faster R-CNN 对输入图像尺寸敏感如果图像长边超过 1000 像素候选框生成会变慢且质量下降。我的做法是把所有图像的长边缩放到 800 像素短边按比例缩放同时保证短边不小于 600 像素。这个尺寸下单张图的候选框生成时间约 0.3 秒可以接受。3.2 主体模块的实现细节与注意事项主体模块的输入是候选区域的视觉特征输出是类别概率。我用的视觉特征是 ResNet-101 的 pool5 层输出维度 2048。这里有个细节原文用的是 fc7 层但我在复现时发现 pool5 层的空间信息保留得更好对于后续位置模块的训练更有利。如果你用 fc7位置模块的输入特征会丢失空间分辨率导致空间关系判断变差。主体模块的训练有个容易忽略的点类别不平衡。数据集中“人”这个类别可能占 40% 以上而“消防栓”可能只有几十个样本。如果不做处理模型会倾向于把所有区域都预测成“人”。我的做法是对每个类别的损失加权权重是该类别样本数的倒数。这个操作让稀有类别的召回率提升了约 15 个百分点整体准确率也有小幅提升。注意主体模块的准确率不需要追求极致因为最终框的选择是三个模块融合的结果。主体模块准确率在 70% 左右时融合后的整体准确率已经可以接受。把大量时间花在提升主体模块上边际收益很低。3.3 位置模块空间关系的编码与训练位置模块是 MAttNet 里最精妙的部分。它的输入是候选区域的空间特征具体包括候选框的归一化坐标xmin, ymin, xmax, ymax、候选框相对于整张图的位置、以及候选框与图像中其他区域的相对位置关系。原文用的是 5 维空间特征我扩展到了 8 维增加了候选框的宽高比和面积占比。语言侧的位置信息编码我用了两种方案一种是直接把位置短语如“最右边”对应的词向量作为输入另一种是把整句话的位置相关词提取出来单独编码。实测下来第二种方案效果更好因为整句话里很多词和位置无关混在一起会引入噪声。具体做法是用一个简单的规则匹配如果词在预定义的位置词表里左、右、上、下、中间、旁边等就保留否则丢弃。这个规则匹配的召回率约 85%但准确率很高。位置模块的训练有个坑正负样本的空间分布差异很大。正样本通常集中在图像的中部或特定区域而负样本分布在全图。如果直接训练模型会学到“中部区域更可能是正样本”这种捷径而不是真正理解空间关系。我的解决方式是在采样负样本时强制让负样本的空间分布和正样本接近具体做法是把图像划分成网格每个网格里采相同数量的正负样本。这个操作让位置模块的准确率提升了约 6 个百分点。3.4 外观模块属性词的提取与匹配外观模块负责判断候选区域是否具有指称表达中描述的属性比如颜色、材质、状态等。这里的难点是属性词的提取。我试过用现成的属性分类器但效果不好因为 REC 数据集里的属性词和通用属性分类器的类别对不上。最后我用了基于规则的方法维护一个属性词表包含常见颜色红、蓝、白等、材质木、金属、玻璃等、状态旧、新、破等然后从指称表达里匹配这些词。外观模块的视觉特征我用了 ResNet-101 的中间层输出因为中间层的纹理和颜色信息比高层更丰富。具体来说我取了 res4b 层的输出做全局平均池化后得到 1024 维特征。这个特征和属性词向量做点积得到匹配分数。实测下来中间层特征比高层特征在外观判断上准确率高约 8 个百分点。提示外观模块对颜色特别敏感但图像的光照条件会影响颜色判断。我在训练时做了颜色抖动增强把图像的亮度和对比度随机调整 ±20%让模型对光照变化更鲁棒。这个增强让外观模块在暗光图像上的准确率提升了约 10 个百分点。4. 实操过程与核心环节实现4.1 环境搭建与依赖安装我用的环境是 Python 3.8 PyTorch 1.9 CUDA 11.1。这个组合比较稳定PyTorch 1.9 对 ResNet 系列的支持很好CUDA 11.1 和大多数显卡驱动兼容。安装依赖时除了常规的 torch、torchvision、numpy 之外还需要安装 cython 和 pycocotools后者用于处理候选框的 IoU 计算。pip install torch1.9.0 torchvision0.10.0 pip install numpy opencv-python cython pycocotools这里有个坑pycocotools 在 Windows 上安装经常失败因为需要编译 C 扩展。我的建议是直接用 Linux 环境或者用 conda 安装 conda-forge 版本的 pycocotools。如果你只能用 Windows可以找一个预编译的 whl 文件手动安装。环境搭好之后先跑一个简单的 ResNet-101 前向传播确认 GPU 可用、显存足够。MAttNet 训练时 batch size 设为 16 时显存占用约 8GB如果你显卡显存小于 8GB需要把 batch size 降到 8 或 4。4.2 候选区域生成与特征提取候选区域生成我用的是 Faster R-CNN 的预训练模型直接在 COCO 上预训练的权重。具体做法是对每张图用 Faster R-CNN 输出前 100 个置信度最高的候选框然后对每个候选框用 ResNet-101 提取 pool5 和 res4b 两个层的特征。pool5 特征用于主体和位置模块res4b 特征用于外观模块。特征提取阶段最耗时我的做法是提前把所有图像的特征提取好存成 h5 文件。这样训练时直接读特征不用每次前向传播。10000 张图的特征提取大约需要 2 小时单卡 1080Ti存储空间约 15GB。如果你不想存特征也可以在训练时实时提取但训练速度会慢 3 到 4 倍。# 特征提取的核心逻辑 import h5py import torch from torchvision.models import resnet101 model resnet101(pretrainedTrue).cuda().eval() # 注册 hook 获取中间层输出 features {} def get_features(name): def hook(model, input, output): features[name] output.detach() return hook model.layer4.register_forward_hook(get_features(res4b)) model.avgpool.register_forward_hook(get_features(pool5)) # 对每个候选框做 ROI Pooling from torchvision.ops import roi_align roi_features roi_align(features[pool5], boxes, output_size(1,1))4.3 三个模块的训练与融合三个模块是分开训练的训练顺序是先训主体模块再训位置和外观模块。主体模块训练 20 个 epoch学习率 1e-3每 5 个 epoch 衰减 0.5。位置和外观模块各训练 15 个 epoch学习率 1e-3每 5 个 epoch 衰减 0.5。优化器统一用 Adam。融合时三个模块的分数加权和。权重是通过在验证集上网格搜索得到的。我的搜索范围是主体权重 0.3 到 0.6位置权重 0.2 到 0.4外观权重 0.1 到 0.3步长 0.05。最终找到的最优组合是主体 0.5、位置 0.3、外观 0.2。这个权重下验证集上的准确率比等权融合高约 5 个百分点。模块权重训练轮数学习率准确率贡献主体0.5201e-3约 70%位置0.3151e-3约 65%外观0.2151e-3约 60%注意融合权重不是固定的不同数据集上最优权重可能不同。如果你的数据集里位置描述特别多位置模块的权重可以适当调高。我的经验是位置权重在 0.25 到 0.35 之间比较稳妥。4.4 推理阶段的后处理与框选择推理时对每个候选框计算三个模块的分数加权求和后取分数最高的框作为最终输出。这里有个细节如果最高分和次高分的差距小于 0.05我会把两个框都保留然后做非极大值抑制NMSIoU 阈值设 0.5。这个操作让最终输出的框更稳定尤其是在指称表达比较模糊的时候。另一个后处理技巧是如果主体模块的最高分类置信度低于 0.3说明模型对类别判断很不确定这时候我会把位置和外观模块的权重临时调高让空间和属性信息主导框的选择。这个动态权重调整让模糊表达的准确率提升了约 7 个百分点。5. 常见问题与排查技巧实录5.1 训练不收敛损失震荡与梯度爆炸我遇到的最常见问题是训练初期损失剧烈震荡甚至出现 NaN。排查下来主要有三个原因学习率太大、梯度没有裁剪、以及特征数值范围过大。解决方法很直接把学习率从 1e-3 降到 5e-4加梯度裁剪max norm 设为 5.0然后对视觉特征做 L2 归一化。这三个操作之后损失曲线变得平滑收敛稳定。还有一个隐蔽的原因候选框的坐标没有归一化。如果直接用像素坐标数值范围在 0 到 800 之间和视觉特征的数值范围通常在 -10 到 10 之间差距太大导致位置模块的梯度爆炸。把坐标除以图像宽高归一化到 0 到 1 之后问题解决。5.2 验证集准确率远低于训练集过拟合的识别与处理训练集准确率 90%验证集只有 55%这是典型的过拟合。我的处理方式分三步第一增加 dropout在主体模块的全连接层后加 dropout 0.5第二数据增强对图像做随机裁剪和颜色抖动第三早停如果验证集准确率连续 5 个 epoch 不提升就停止训练。这三步之后验证集准确率提升到了 68%和训练集的差距缩小到 15 个百分点以内。还有一个容易被忽略的点指称表达的长度分布。如果训练集里长表达多验证集里短表达多模型会不适应。我的做法是在划分数据集时按表达长度分层采样保证训练集和验证集的长度分布一致。这个操作让验证集准确率又提升了约 3 个百分点。5.3 框定位不准IoU 低但分类正确的排查有时候模型分类对了但框的 IoU 只有 0.3 左右远低于 0.5 的合格线。排查下来问题出在候选框生成阶段Faster R-CNN 输出的候选框本身就不够准。我的解决方式是加一个框回归分支在主体模块后面接一个小的回归网络预测候选框的偏移量。这个回归分支用 smooth L1 损失训练 10 个 epoch 后平均 IoU 从 0.35 提升到了 0.52。另一个原因是位置模块的空间特征不够细。我原来只用了候选框的四个坐标后来加了候选框与图像中其他区域的相对位置关系比如“在另一个框的左边”IoU 又提升了约 0.05。5.4 常见问题速查表问题现象可能原因排查方法解决方案损失震荡或 NaN学习率过大、梯度爆炸打印梯度范数降学习率、加梯度裁剪、特征归一化验证集准确率低过拟合、数据分布不一致对比训练/验证损失曲线加 dropout、数据增强、分层采样框 IoU 低候选框不准、空间特征不足可视化候选框加框回归分支、扩展空间特征训练速度慢实时特征提取、batch size 小计时各阶段耗时预提取特征、增大 batch size稀有类别召回低类别不平衡统计各类别样本数损失加权、重采样模糊表达框错模块权重固定分析错误案例动态权重调整、保留多框做 NMS提示排查问题时先可视化。把候选框、三个模块的分数、最终框都画在图上一眼就能看出是哪个环节出了问题。我踩过的坑里有超过一半是通过可视化直接定位的比看日志快得多。5.5 实操心得那些文档里不会写的事第一个心得不要一上来就训全量数据。先用 10% 的数据跑通全流程确认三个模块都能正常训练、融合逻辑没问题再上全量。我一开始直接上全量结果训练到第 3 个 epoch 才发现位置模块的输入维度搞错了白白浪费了两天。第二个心得保存中间检查点。MAttNet 的训练分三个阶段每个阶段结束后都保存模型。这样如果融合效果不好你可以回退到单个模块单独调试。我有一次融合后准确率反而下降了回退发现是外观模块过拟合了重新训练外观模块后问题解决。第三个心得指称表达的预处理比模型结构更重要。我花在数据清洗和词表构建上的时间大约是调模型结构时间的两倍。但这是值得的因为数据质量直接决定了模型的上限。一个干净的词表、一致的类别映射、合理的样本划分比换一个更复杂的 backbone 带来的提升大得多。第四个心得位置模块的词表要针对数据集定制。通用位置词表左、右、上、下覆盖不了“左上角”“靠近窗户”这种表达。我的做法是从训练集的指称表达里统计高频位置短语手动补充到词表里。这个操作让位置模块的准确率提升了约 8 个百分点。6. 复现之后的扩展思路跑通 MAttNet 之后我试过几个扩展方向这里分享两个比较有成效的。第一个是把主体模块换成多标签分类因为有些指称表达的主体可能属于多个类别比如“动物”和“宠物”多标签比单标签更合理。这个改动让主体模块的召回率提升了约 5 个百分点。第二个是在融合阶段引入一个小的门控网络让模型自己学习三个模块的权重而不是用固定权重。门控网络的输入是三个模块的分数和指称表达的特征输出是三个权重。这个改动让整体准确率提升了约 3 个百分点但增加了约 10% 的训练时间。如果你想把 MAttNet 的思想用到其他任务上比如视频里的指称理解核心改动在于位置模块需要处理时序信息。我的思路是把位置特征从二维扩展到三维增加时间维度的坐标然后位置模块的 LSTM 换成时序卷积。这个方向我还没完整验证但初步实验显示可行。最后分享一个小技巧推理时如果显存不够可以把三个模块的分数计算分开做先算主体模块筛掉一批候选框再算位置和外观。这样显存占用可以降低约 40%速度只慢 10% 左右。对于部署场景很实用。