简介这份源码包面向参加数据挖掘竞赛的高校学生与跨模态学习研究者聚焦2024年“泰迪杯”B题的跨模态图文互检任务采用共享特征空间对比学习思路构建模型帮助读者理解图文语义对齐与互检检索的完整实现路径。压缩包共9个文件约10KB以4个Python脚本为核心覆盖数据处理、模型定义与训练评估流程另含2个YAML配置、1份README说明及依赖清单等辅助文件结构紧凑、便于快速复现与二次修改。目前已有68人学习下载。读者可从中获得一份完整的赛题方案参考包括训练与评估脚本的调用方式、模型结构的组织逻辑、配置文件的参数设定以及依赖环境的搭建线索适合作为跨模态检索入门实践与竞赛复盘的对照材料但仅供学习研究使用。1. 泰迪杯 B 题拆解跨模态图文互检到底在检什么跨模态图文互检说白了就是给一张图找出最匹配的文本或者给一段文本找出最匹配的图。2024 年泰迪杯数据挖掘挑战赛 B 题把这个问题放在共享特征空间里做要求用对比学习把图像和文本映射到同一个向量空间再在这个空间里做检索。听起来像多模态入门题但真正动手会发现难点不在模型结构而在数据构造、负样本采样和特征对齐的稳定性。我带过几届数据挖掘竞赛这类题最怕的不是模型跑不起来而是跑起来后 recall 卡在 0.3 上不去调参调到怀疑人生。这篇笔记按我复现这类方案的顺序展开先讲共享特征空间和对比学习的选型理由再落到数据加载、模型搭建、训练循环和检索评估最后给几个能直接抄的排错技巧。适合正在做泰迪杯 B 题、或者想入门跨模态检索的工程师新手能跟着代码跑通 baseline熟手能看到温度系数、batch 构造和难负样本的边界。2. 共享特征空间与对比学习为什么这样搭2.1 共享特征空间不是把两个塔拼一起就完事跨模态检索的核心诉求是图像和文本的向量在同一个空间里可比。常见做法是双塔结构图像走 CNN 或 ViT文本走 Transformer 或 LSTM各自输出一个固定维度向量然后通过投影头映射到同一维度。但“同一维度”不等于“同一空间”。如果两个塔各自初始化、各自优化早期梯度方向差异很大图像向量和文本向量会各自聚团跨模态相似度计算基本是噪声。我一般会在投影头之后加一个共享的线性层或者让两个塔共享部分底层参数强制早期特征就有交互。泰迪杯 B 题的数据量通常不大共享底层比完全独立双塔更稳收敛也快。另一个关键是归一化。图像和文本向量必须做 L2 归一化后再算相似度否则图像向量的模长普遍偏大文本向量偏小点积结果会被图像主导。归一化之后相似度就是余弦相似度温度系数才有意义。很多新手翻车就翻在这里模型输出没归一化训练 loss 看着降检索指标不动。2.2 对比学习选 InfoNCE 还是 Triplet看 batch 怎么组对比学习在跨模态里的标准做法是 InfoNCE一个 batch 里图像和文本配对为正样本其余为负样本算交叉熵。它的好处是负样本数量等于 batch size 减一不需要额外挖负样本训练稳定。Triplet Loss 需要显式构造正负对难负样本挖掘稍微麻烦但胜在可以控制负样本难度。泰迪杯 B 题如果数据量在几万对以内我建议先用 InfoNCE 跑通再考虑加难负样本。InfoNCE 的温度系数 τ 是关键参数一般从 0.07 开始试太小会让模型对难负样本过度敏感太大则退化成均匀分布学不到区分性。import torch import torch.nn as nn import torch.nn.functional as F class InfoNCE(nn.Module): def __init__(self, temperature0.07): super().__init__() self.temperature temperature def forward(self, img_emb, txt_emb): # img_emb, txt_emb: [batch, dim], 已做 L2 归一化 logits img_emb txt_emb.t() / self.temperature # [batch, batch] labels torch.arange(logits.size(0), devicelogits.device) loss_i F.cross_entropy(logits, labels) # 图找文 loss_t F.cross_entropy(logits.t(), labels) # 文找图 return (loss_i loss_t) / 2这段代码里logits的每一行是某张图对所有文本的相似度对角线是正样本。loss_i和loss_t分别对应图检索文和文检索图取平均让两个方向同步优化。温度系数temperature直接缩放 logits控制 softmax 的尖锐程度。如果训练 loss 震荡大先把 τ 调到 0.1 试试如果 recall 上不去再往 0.05 降。2.3 数据构造泰迪杯 B 题最容易忽略的预处理泰迪杯 B 题通常给的是图文配对数据但原始数据里会有空文本、重复图像、文本长度差异大等问题。我一般会做三件事第一过滤掉文本长度小于 2 或大于 128 的样本太短没信息太长截断后语义丢失第二对图像做统一 resize 到 224x224并做归一化均值方差用 ImageNet 的第三构建一个Dataset类返回图像张量、文本 token id 和 attention mask。文本 tokenizer 可以用 BERT 的也可以自己训一个小的但泰迪杯场景下直接用预训练 BERT 的 tokenizer 最省事。from torch.utils.data import Dataset from PIL import Image import torchvision.transforms as T class ImageTextDataset(Dataset): def __init__(self, pairs, tokenizer, max_len64): self.pairs pairs # list of (img_path, text) self.tokenizer tokenizer self.max_len max_len self.transform T.Compose([ T.Resize((224, 224)), T.ToTensor(), T.Normalize(mean[0.485, 0.456, 0.406], std[0.229, 0.224, 0.225]) ]) def __len__(self): return len(self.pairs) def __getitem__(self, idx): img_path, text self.pairs[idx] image Image.open(img_path).convert(RGB) image self.transform(image) enc self.tokenizer(text, paddingmax_length, truncationTrue, max_lengthself.max_len, return_tensorspt) return { image: image, input_ids: enc[input_ids].squeeze(0), attention_mask: enc[attention_mask].squeeze(0) }这里max_len设 64 是权衡泰迪杯文本描述通常不长64 够用再大显存吃紧。attention_mask必须传给文本编码器否则 padding 位置会参与注意力计算引入噪声。图像归一化用 ImageNet 统计量是因为预训练 CNN 或 ViT 都是在这个分布上训的不换的话特征提取会偏。3. 从零搭一个可训练的跨模态检索 baseline3.1 图像塔和文本塔的选型与投影头设计图像塔我一般用 ResNet50 或 ViT-B/16 的预训练权重去掉最后的分类层取池化后的特征。文本塔用 BERT-base 的前 768 维 CLS 向量。两个塔的输出维度可能不同比如 ResNet50 是 2048BERT 是 768所以需要各自的投影头映射到统一维度比如 256 或 512。投影头用两层 MLP 加 ReLU最后再做 L2 归一化。共享特征空间的关键在于投影头之后不再有各自独立的非线性变换直接算相似度。import torch.nn as nn import torchvision.models as models from transformers import BertModel class DualEncoder(nn.Module): def __init__(self, embed_dim256): super().__init__() # 图像塔 resnet models.resnet50(pretrainedTrue) self.img_backbone nn.Sequential(*list(resnet.children())[:-1]) self.img_proj nn.Sequential( nn.Linear(2048, 512), nn.ReLU(), nn.Linear(512, embed_dim) ) # 文本塔 self.txt_backbone BertModel.from_pretrained(bert-base-uncased) self.txt_proj nn.Sequential( nn.Linear(768, 512), nn.ReLU(), nn.Linear(512, embed_dim) ) def forward(self, image, input_ids, attention_mask): img_feat self.img_backbone(image).flatten(1) # [B, 2048] img_emb self.img_proj(img_feat) txt_out self.txt_backbone(input_idsinput_ids, attention_maskattention_mask) txt_feat txt_out.last_hidden_state[:, 0, :] # CLS txt_emb self.txt_proj(txt_feat) # L2 归一化 img_emb nn.functional.normalize(img_emb, dim-1) txt_emb nn.functional.normalize(txt_emb, dim-1) return img_emb, txt_embembed_dim设 256 是常见起点太小会欠拟合太大容易过拟合且检索慢。图像塔用Sequential(*list(resnet.children())[:-1])去掉全连接输出是 [B, 2048, 1, 1]flatten 后变 [B, 2048]。文本塔取 CLS 向量因为 BERT 的 CLS 被训练来聚合句子信息。两个投影头结构对称但参数不共享共享的话反而限制各自模态的表达能力。3.2 训练循环优化器、学习率和 warmup 怎么设训练循环里优化器用 AdamW学习率图像塔和文本塔分开设。预训练 backbone 的学习率要小比如 1e-5投影头可以大一点1e-3。如果一起用 1e-3预训练权重会被破坏loss 先降后升。warmup 用 10% 的步数从 0 线性升到设定值再余弦衰减。batch size 在显存允许下尽量大因为 InfoNCE 的负样本数等于 batch size 减一batch 太小对比学习效果差。我一般用 64 或 128。from torch.optim import AdamW from transformers import get_cosine_schedule_with_warmup model DualEncoder(embed_dim256).cuda() criterion InfoNCE(temperature0.07) # 分组学习率 backbone_params list(model.img_backbone.parameters()) \ list(model.txt_backbone.parameters()) head_params list(model.img_proj.parameters()) \ list(model.txt_proj.parameters()) optimizer AdamW([ {params: backbone_params, lr: 1e-5}, {params: head_params, lr: 1e-3} ], weight_decay1e-4) total_steps len(dataloader) * epochs scheduler get_cosine_schedule_with_warmup( optimizer, num_warmup_stepsint(0.1 * total_steps), num_training_stepstotal_steps ) for epoch in range(epochs): model.train() for batch in dataloader: image batch[image].cuda() input_ids batch[input_ids].cuda() attention_mask batch[attention_mask].cuda() img_emb, txt_emb model(image, input_ids, attention_mask) loss criterion(img_emb, txt_emb) optimizer.zero_grad() loss.backward() torch.nn.utils.clip_grad_norm_(model.parameters(), max_norm1.0) optimizer.step() scheduler.step()分组学习率是血泪经验如果 backbone 和投影头用同一个 lr要么 backbone 被毁要么投影头学不动。clip_grad_norm_设 1.0 防止梯度爆炸对比学习早期梯度容易大。warmup 比例 0.1 是常用值太小会震荡太大浪费步数。3.3 检索评估RecallK 和 MedR 怎么算才不骗自己评估时模型切 eval 模式算所有图像和文本的相似度矩阵然后对每个样本排掉自身看正样本是否在前 K 个。Recall1、Recall5、Recall10 是标准指标MedR 是中位数排名。注意评估必须用和训练相同的归一化和温度系数否则相似度尺度不一致。另外评估集不能有训练集里出现过的样本否则指标虚高。def evaluate(model, dataloader): model.eval() all_img, all_txt [], [] with torch.no_grad(): for batch in dataloader: image batch[image].cuda() input_ids batch[input_ids].cuda() attention_mask batch[attention_mask].cuda() img_emb, txt_emb model(image, input_ids, attention_mask) all_img.append(img_emb.cpu()) all_txt.append(txt_emb.cpu()) all_img torch.cat(all_img, dim0) all_txt torch.cat(all_txt, dim0) sims all_img all_txt.t() # [N, N] ranks [] for i in range(sims.size(0)): sim sims[i] # 正样本是第 i 个文本 rank (sim sim[i]).sum().item() 1 ranks.append(rank) ranks torch.tensor(ranks) r1 (ranks 1).float().mean().item() r5 (ranks 5).float().mean().item() r10 (ranks 10).float().mean().item() medr ranks.median().item() return r1, r5, r10, medrsim sim[i]统计比正样本相似度高的数量加 1 就是排名。如果正样本相似度并列排名会偏乐观但实际影响不大。MedR 对异常值不敏感比平均排名更稳。评估时 batch size 可以大一点因为不需要反向传播。4. 避坑与排查泰迪杯 B 题复现时最容易翻车的 5 个点4.1 现象loss 降到 0.1 以下但 Recall1 只有 0.2原因通常是模型学到了“模态内聚类”而不是“跨模态对齐”。图像向量和文本向量各自聚得很紧但跨模态相似度没拉开。解决方法是检查是否做了 L2 归一化以及温度系数是否太小。如果 τ 小于 0.03模型会对难负样本过度惩罚导致特征坍缩。把 τ 调到 0.07 到 0.1 之间再加一个模态内对比损失作为辅助让同模态不同样本也保持距离。4.2 现象训练时 loss 正常评估时指标随机波动原因一般是评估集和训练集有重叠或者评估时没切 eval 模式BatchNorm 和 Dropout 还在更新。检查model.eval()和torch.no_grad()是否加上。另外如果 DataLoader 的 shuffle 在评估时开了每次顺序不同但相似度矩阵是对称的顺序不影响排名所以不是这个原因。最可能的是评估集太小几百个样本的 Recall 波动本来就大建议至少 2000 对以上。4.3 现象文本塔 loss 不降图像塔正常原因可能是 tokenizer 的 padding 方式不对或者 attention_mask 没传。如果 padding 的 token id 是 0而 BERT 的 0 是 [PAD]不传 mask 的话注意力会算到 padding 上。检查attention_mask是否从 tokenizer 输出里取了并且传给了BertModel。另一个可能是文本长度截断太狠比如 max_len 设 16长文本信息丢失模型学不到区分性。4.4 现象显存爆了batch size 只能设 8InfoNCE 的 logits 是 [B, B]B128 时也就 16K 个浮点数不是显存瓶颈。真正吃显存的是图像塔和文本塔的中间激活。解决办法图像塔用混合精度torch.cuda.amp文本塔用 gradient checkpointing。另外把图像 resize 到 224 是标准但如果原始图像很大可以在 Dataset 里先 resize 到 256 再 center crop 224减少预处理显存。batch size 太小的话可以用梯度累积累积 4 步相当于 batch 32。4.5 现象换用 ViT 后指标反而降了ViT 需要更多数据才能超过 ResNet泰迪杯数据量通常不够。如果非要用 ViT建议用 ViT-B/16 的预训练权重并且冻结前几层只训后几层和投影头。另外ViT 的输入是 224patch 16输出 196 个 token取 CLS 或者平均池化都行但平均池化对检索更稳。如果指标降了先检查学习率是不是太大ViT 对 lr 更敏感backbone 用 5e-6 试试。5. 进阶技巧用难负样本和温度系数把 Recall1 再推 5 个点baseline 跑通后想再提点最有效的是难负样本挖掘。InfoNCE 的负样本是 batch 内随机采的大部分很容易区分模型学不到细粒度。做法是每个 epoch 结束后用当前模型算一遍训练集的相似度矩阵对每张图找相似度最高但不是正样本的文本作为难负样本。下一轮训练时把这些难负样本和正样本一起算 loss但要注意不能把难负样本当正样本只是增加它们的权重。def mine_hard_negatives(model, dataloader, top_k5): model.eval() all_img, all_txt, all_idx [], [], [] with torch.no_grad(): for batch in dataloader: image batch[image].cuda() input_ids batch[input_ids].cuda() attention_mask batch[attention_mask].cuda() img_emb, txt_emb model(image, input_ids, attention_mask) all_img.append(img_emb.cpu()) all_txt.append(txt_emb.cpu()) all_img torch.cat(all_img, dim0) all_txt torch.cat(all_txt, dim0) sims all_img all_txt.t() hard_negs [] for i in range(sims.size(0)): sim sims[i].clone() sim[i] -1 # 排除正样本 _, idx sim.topk(top_k) hard_negs.append(idx.tolist()) return hard_negs挖出来的hard_negs存下来训练时在 loss 里对这批负样本加一个权重比如 2.0。但权重不能太大否则模型会过拟合到这批难样本。我一般每 2 个 epoch 重新挖一次保持难样本的时效性。温度系数也可以动态调早期用 0.1 让训练稳后期用 0.05 让模型关注难样本。这个策略在泰迪杯 B 题上通常能把 Recall1 从 0.45 推到 0.50 以上。另一个技巧是投影头之后加一个 batch normalization但不要加在归一化之前。BN 能稳定特征分布但会引入 batch 内统计量评估时如果 batch 太小会不一致。我一般用 LayerNorm 替代对 batch size 不敏感。最后检索时可以用 query expansion对每张图取 top-3 文本的向量平均再重新检索能小幅提升 Recall5。这些技巧不需要改模型结构只在训练和推理阶段加几行代码性价比很高。我自己的习惯是每次改一个点跑完评估再改下一个不要一次加三个技巧否则指标涨了不知道是哪个起作用跌了也不知道该回退哪个。希望帮到你。本文还有配套的精品资源点击获取