泰迪杯B题跨模态图文互检:共享特征空间对比学习实战 简介这份资源是2024年“泰迪杯”数据挖掘挑战赛B题的完整参赛源码面向数据挖掘、人工智能与计算机视觉方向的高校学生及研究者聚焦跨模态图文互检这一典型任务。方案以共享特征空间对比学习为核心思路通过对图文特征进行对齐与对比约束建模两种模态之间的语义关联从而支撑高效的图文互检。压缩包共9个文件约10KB以4个Python脚本为主体覆盖数据处理、模型定义与训练、评估等环节另含2个YAML配置、1份README说明及依赖清单等辅助文件结构紧凑、便于快速复现与二次修改。目前已有68人学习下载。读者可从中获得一套完整的赛题解题框架理解对比学习在跨模态检索中的落地方式并参考其训练与评估流程、参数配置组织方式用于课程设计、竞赛复现或相关课题的入门实践但仅供学习使用。1. 泰迪杯 B 题拆解跨模态图文互检到底在检什么跨模态图文互检说白了就是给一张图从一堆文本里把描述它的那句捞出来反过来给一段文本从一堆图里把匹配的那张挑出来。2024 年泰迪杯数据挖掘挑战赛 B 题把它包装成基于共享特征空间对比学习的赛题核心诉求没变让图像和文本在同一个向量空间里可比。做过检索的人都知道难点不在模型多大而在共享特征空间这五个字——图像特征和文本特征天生分布不同硬拼在一起就是各说各话。这个方案适合两类人一是打数据挖掘类竞赛想拿可复现 baseline 的选手二是做商品图文匹配、以图搜文这类业务、想搞懂对比学习怎么落地的工程师。下面按空间怎么共享 → 数据怎么喂 → 模型怎么搭 → 坑在哪 → 怎么验证的顺序讲透。2. 共享特征空间对比学习为什么能把图和文拉到一起2.1 从双塔到共享空间选型理由先讲清跨模态检索的经典结构是双塔一个图像编码器一个文本编码器各自出向量最后算相似度。问题在于两个塔各训各的图像向量和文本向量根本不在一个坐标系里余弦相似度算出来没有物理意义。共享特征空间要解决的就是这个——让匹配的图文对在空间里靠近不匹配的推远。对比学习Contrastive Learning正是干这个的一个 batch 里第 i 张图和第 i 段文本是正样本对其余 2(N-1) 个组合都是负样本用 InfoNCE 损失把正样本相似度拉高、负样本压低。为什么选对比学习而不是分类因为检索任务没有固定类别你不可能给每张图打一个正确文本的标签然后做 softmax。对比学习的妙处是把匹配这件事转化成在 batch 内排序负样本从 batch 里现取不需要额外标注。这也是 CLIP 那套范式的核心泰迪杯 B 题要求共享特征空间对比学习本质就是让你复现这个思路。选型上还有几个关键决定。第一图像编码器用 CNN 还是 ViT数据量小的时候 ResNet 更稳ViT 吃数据赛题数据通常几千到几万对ResNet50 起步够用。第二文本编码器用 LSTM 还是 Transformer中文赛题建议直接用预训练 BERT 的中文版做 backbone比从头训 LSTM 收敛快得多。第三相似度用点积还是余弦对比学习里两者等价向量归一化后但归一化能防止模型靠放大向量模长来作弊所以务必 L2 normalize。2.2 温度系数与损失函数三个必调参数InfoNCE 损失里有个温度系数 τtau它控制 softmax 的锐度。τ 太小模型只盯着最难的负样本训练不稳定τ 太大所有负样本权重接近学不到区分性。经验值在 0.05 到 0.1 之间CLIP 用的是可学习的 logit scale初始 1/0.07≈14.3。下面是对比损失的最小实现import torch import torch.nn.functional as F def contrastive_loss(img_emb, txt_emb, temperature0.07): # img_emb, txt_emb: [batch, dim]已做 L2 归一化 img_emb F.normalize(img_emb, dim-1) txt_emb F.normalize(txt_emb, dim-1) # 相似度矩阵 [batch, batch]对角线是正样本 logits img_emb txt_emb.t() / temperature # 行方向每张图对应哪个文本列方向每段文本对应哪张图 labels torch.arange(img_emb.size(0), deviceimg_emb.device) loss_i2t F.cross_entropy(logits, labels) loss_t2i F.cross_entropy(logits.t(), labels) return (loss_i2t loss_t2i) / 2逻辑说明logits是 batch 内所有图文对的相似度矩阵对角线是正样本。loss_i2t是以图找文方向loss_t2i是以文找图方向两个方向都算是因为检索是双向的只优化一个方向会导致另一个方向塌掉。参数说明temperature建议从 0.07 起调训练 loss 震荡就调到 0.1收敛太慢就降到 0.05dim一般取 256 或 512太大显存吃紧太小表达不够。第三个必调参数是 batch size。对比学习的负样本数量等于 batch size 减一batch 越大负样本越多学到的区分性越强。但显存有限常见做法是用梯度累积模拟大 batch或者用动量编码器MoCo 那套维护一个负样本队列。赛题环境如果只有单卡 12Gbatch 开到 64 就差不多再大要上混合精度。2.3 特征对齐的两种做法投影头与共享编码器共享特征空间有两种实现路径。一种是双塔各自编码后各接一个投影头projection head通常是两层 MLP把维度映射到同一空间对比损失加在投影后的向量上。这是 CLIP 的做法好处是编码器可以保留各自领域的特征投影头负责对齐。另一种是共享编码器图像和文本过同一个 Transformer靠模态类型 embedding 区分这种对数据量要求极高赛题场景不推荐。投影头的维度选择有讲究。如果图像编码器输出 2048 维、文本编码器输出 768 维投影头统一映射到 256 维对比损失在这个 256 维空间算。注意投影头只在训练时用推理检索时可以用投影前的特征也可以继续用投影后的实测投影后更稳因为对齐是在这个空间完成的。这里有个容易翻车的点投影头后面千万别加 BatchNorm 之后再接对比损失BN 在 batch 内做归一化会泄露负样本信息导致 loss 虚低但检索效果差血泪经验。3. 数据管道图文对的构造与增强策略3.1 赛题数据的清洗与配对泰迪杯这类赛题给的数据通常是图像文件夹 文本描述文件可能是一图一文也可能一图多文。第一步是解析配对关系把 (image_path, text) 组成样本对。中文文本要过一遍清洗去 HTML 标签、去多余空白、统一全半角。图像要检查损坏文件用 PIL 打开验证损坏的直接剔除否则训练到一半报错。import os from PIL import Image import pandas as pd def build_pairs(img_dir, caption_file): df pd.read_csv(caption_file, sep\t, headerNone, names[img, text]) pairs [] for _, row in df.iterrows(): path os.path.join(img_dir, row[img]) try: with Image.open(path) as im: im.verify() # 验证图像完整性 except Exception: continue # 损坏图像跳过 text str(row[text]).strip().replace(\u3000, ) if len(text) 2: continue # 过短文本丢弃 pairs.append((path, text)) return pairs逻辑说明im.verify()只验证不加载速度快但验证后图像对象不能再用需要重新打开。参数说明sep\t按实际分隔符改赛题常见是 tab 或逗号文本长度阈值 2 是经验值太短的描述没有区分性留着反而干扰对比学习。配对完成后要做去重和负样本检查。如果数据里存在完全相同的文本对应不同图或者相同图对应语义相近的文本对比学习会把这些当成负样本互相推远实际它们是正样本这叫假负样本是检索任务里最隐蔽的坑。处理办法是对文本做一次相似度聚类相似度高于阈值的文本对不参与互相推远或者用 soft label 软化。3.2 图像与文本的增强别把语义增强没了图像增强用常规的 RandomResizedCrop、ColorJitter、水平翻转。注意跨模态检索里图像增强不能改变语义比如把红色的车增强成绿色的车就废了所以 ColorJitter 的强度和饱和度范围要收窄。文本增强更微妙同义词替换、随机删除词都可能改变语义中文赛题建议只做轻微的随机 mask或者干脆不做文本增强靠 batch 内负样本就够了。from torchvision import transforms train_transform transforms.Compose([ transforms.RandomResizedCrop(224, scale(0.7, 1.0)), # 裁剪别太狠 transforms.RandomHorizontalFlip(), transforms.ColorJitter(brightness0.2, contrast0.2, saturation0.1, hue0.02), # 色相几乎不动 transforms.ToTensor(), transforms.Normalize(mean[0.485, 0.456, 0.406], std[0.229, 0.224, 0.225]), ])逻辑说明scale(0.7, 1.0)保证裁剪后至少保留 70% 区域避免把主体裁掉。hue0.02几乎不改色相防止颜色语义被破坏。参数说明Normalize 的均值方差是 ImageNet 统计值如果图像编码器用 ImageNet 预训练权重就必须用这套如果从头训可以换成自己数据集的统计值。文本侧的处理流程是分词 → 转 id → padding/truncation 到固定长度。中文用 BERT 的 tokenizer最大长度 64 通常够赛题描述一般不长。padding 用 attention mask 屏蔽掉别让模型学到 padding 的位置信息。3.3 DataLoader 的 batch 构造负样本从哪来对比学习的负样本就是同 batch 内的其他样本所以 DataLoader 的 shuffle 必须开否则同一 batch 全是相似样本负样本质量差。另外要注意如果数据集里同一张图有多条描述shuffle 后它们可能落进同一 batch互相成为负样本这就是前面说的假负样本。解决办法是自定义 Sampler保证同一图的多条描述不在同一 batch或者用去重后的数据。from torch.utils.data import DataLoader loader DataLoader( dataset, batch_size64, shuffleTrue, # 必须开保证负样本多样性 num_workers4, pin_memoryTrue, # GPU 训练加速 drop_lastTrue, # 丢掉最后不足一个 batch 的样本 )逻辑说明drop_lastTrue很重要对比学习依赖 batch 内负样本最后一个 batch 如果只有几个样本负样本太少loss 会异常波动直接丢掉更稳。参数说明num_workers按 CPU 核数设IO 瓶颈时调大pin_memoryTrue在 GPU 训练时能加速数据传输。4. 模型搭建与训练从编码器到检索评估4.1 双塔编码器的组装图像塔用 torchvision 的 ResNet50去掉最后的分类层输出 2048 维文本塔用 transformers 的中文 BERT取 [CLS] 向量 768 维。两个塔各接一个投影头映射到 256 维共享空间。import torch.nn as nn from torchvision.models import resnet50 from transformers import BertModel class DualEncoder(nn.Module): def __init__(self, embed_dim256): super().__init__() self.img_backbone resnet50(pretrainedTrue) self.img_backbone.fc nn.Identity() # 去掉分类头输出 2048 self.txt_backbone BertModel.from_pretrained(bert-base-chinese) self.img_proj nn.Sequential( nn.Linear(2048, 512), nn.ReLU(), nn.Linear(512, embed_dim)) self.txt_proj nn.Sequential( nn.Linear(768, 512), nn.ReLU(), nn.Linear(512, embed_dim)) def forward(self, images, input_ids, attention_mask): img_feat self.img_backbone(images) # [B, 2048] txt_out self.txt_backbone(input_ids, attention_maskattention_mask) txt_feat txt_out.last_hidden_state[:, 0] # [CLS] [B, 768] return self.img_proj(img_feat), self.txt_proj(txt_feat)逻辑说明nn.Identity()替换 ResNet 的 fc 层保留 2048 维特征。BERT 取last_hidden_state[:, 0]即 [CLS] 位置的输出作为句向量。参数说明embed_dim256是共享空间维度可调 128~512投影头中间层 512 是经验值两层 MLP 比单层效果好但别超过三层容易过拟合。4.2 训练循环与学习率策略双塔训练有个关键技巧图像塔和文本塔的学习率要分开设。文本塔是预训练 BERT学习率要小2e-5 量级图像塔可以大一点1e-4。如果统一用一个学习率要么 BERT 被训崩要么图像塔学不动。optimizer torch.optim.AdamW([ {params: model.img_backbone.parameters(), lr: 1e-4}, {params: model.txt_backbone.parameters(), lr: 2e-5}, {params: model.img_proj.parameters(), lr: 1e-4}, {params: model.txt_proj.parameters(), lr: 1e-4}, ], weight_decay0.01) scheduler torch.optim.lr_scheduler.CosineAnnealingLR( optimizer, T_maxepochs, eta_min1e-6) for epoch in range(epochs): model.train() for images, input_ids, mask in loader: images, input_ids, mask images.cuda(), input_ids.cuda(), mask.cuda() img_emb, txt_emb model(images, input_ids, mask) loss contrastive_loss(img_emb, txt_emb, temperature0.07) optimizer.zero_grad() loss.backward() torch.nn.utils.clip_grad_norm_(model.parameters(), max_norm1.0) optimizer.step() scheduler.step()逻辑说明分组学习率让预训练 BERT 微调更温和。clip_grad_norm_防止梯度爆炸对比学习初期 loss 可能很大梯度裁剪是后悔药。参数说明weight_decay0.01是 AdamW 常规值T_maxepochs让余弦退火在一个完整训练周期内降到最低max_norm1.0是梯度裁剪阈值训练不稳就降到 0.5。4.3 检索评估RecallK 怎么算才对训练完要评估跨模态检索的标准指标是 RecallK对每个查询检索结果里前 K 个是否包含正确匹配。图文互检要算两个方向——图找文i2t和文找图t2i两个都报。torch.no_grad() def evaluate(model, loader, k_list[1, 5, 10]): model.eval() all_img, all_txt [], [] for images, input_ids, mask in loader: img_emb, txt_emb model(images.cuda(), input_ids.cuda(), mask.cuda()) all_img.append(img_emb.cpu()) all_txt.append(txt_emb.cpu()) img_emb torch.cat(all_img) # [N, D] txt_emb torch.cat(all_txt) # [N, D] sim img_emb txt_emb.t() # [N, N] 相似度矩阵 ranks sim.argsort(dim1, descendingTrue) labels torch.arange(len(img_emb)).unsqueeze(1) results {} for k in k_list: hit (ranks[:, :k] labels).any(dim1).float().mean() results[fi2t_R{k}] hit.item() return results逻辑说明把所有样本的向量拼成矩阵一次算完相似度矩阵比逐条算快得多。argsort降序排列后看正确匹配的索引是否落在前 K。参数说明k_list一般报 1、5、10如果数据量大相似度矩阵 N×N 可能爆显存要分块计算。5. 避坑与排查跨模态对比学习最容易翻车的五件事5.1 现象loss 一路降到接近 0但 Recall1 只有随机水平原因这是对比学习最经典的翻车——模型找到了捷径。常见于投影头后接了 BatchNorm或者温度系数设得极小模型把所有向量映射到几乎相同的方向相似度矩阵全接近 1loss 看着低但没有任何区分性。另一个可能是数据里存在大量重复样本正负样本无法区分。解决去掉投影头后的 BN改用 LayerNorm 或不归一化温度系数不要低于 0.05检查数据去重统计唯一文本数和唯一图像数如果远小于样本总数说明重复严重必须先清洗。5.2 现象训练 loss 剧烈震荡几个 step 就 NaN原因对比学习初期相似度矩阵数值范围大除以小温度系数后 softmax 容易溢出。混合精度训练下 fp16 更容易 NaN。另外学习率设太大BERT 部分直接训崩。解决梯度裁剪必开max_norm设 1.0 甚至 0.5温度系数初期用 0.1稳定后再降混合精度用torch.cuda.amp时给 loss 加autocast保护或者干脆用 fp32 训前几个 epoch 再切混合精度。5.3 现象图找文效果好文找图一塌糊涂原因两个方向的损失权重不平衡或者文本编码器太弱。如果只优化 i2t 方向文本向量会退化。另外文本侧如果 padding 处理不当attention mask 没传对BERT 把 padding 也编码进去了。解决损失函数必须双向对称(loss_i2t loss_t2i) / 2检查 attention mask 是否正确传入 BERTpadding token 的 mask 必须是 0文本最大长度别设太长64 够用太长会稀释 [CLS] 的信息。5.4 现象验证集指标比训练集高很多原因验证集的负样本是全体样本训练集的负样本只有 batch 内那些验证任务更难但指标反而高说明训练时 batch 太小负样本太少模型没学到真正的区分性。也可能是验证集和训练集有重叠数据泄露。解决增大 batch size 或用梯度累积检查数据划分确保验证集的图文对没在训练集出现过如果数据量允许用动量队列扩大负样本池。5.5 现象推理时单条检索慢得没法用原因每次查询都把所有候选重新编码一遍。图像编码器前向一次几十毫秒几万条候选就是几分钟。解决离线把所有候选的向量算好存下来查询时只编码查询侧然后算相似度。向量存成 numpy 或 faiss 索引几万条用 numpy 矩阵乘法毫秒级上百万条再考虑 faiss。这是工程落地的基本功别在推理时重复造轮子。6. 进阶技巧用难负样本挖掘把 Recall1 再抬几个点baseline 跑通后想提分最有效的手段是难负样本挖掘Hard Negative Mining。普通对比学习里负样本是随机取的大部分负样本和正样本差异明显模型学不到细粒度区分。难负样本是那些看起来像但不是的样本——比如一只黑猫坐在沙发上和一只黑猫坐在椅子上模型必须抓住沙发 vs 椅子才能区分。做法是先用当前模型对所有样本算一遍相似度对每个正样本对找出相似度最高但不是正样本的那些作为难负样本下一轮训练时重点优化它们。实现上可以维护一个难负样本队列或者用两阶段训练——第一阶段普通对比学习第二阶段只用难负样本对微调。def mine_hard_negatives(model, loader, top_k5): model.eval() img_emb, txt_emb [], [] with torch.no_grad(): for images, input_ids, mask in loader: i, t model(images.cuda(), input_ids.cuda(), mask.cuda()) img_emb.append(i.cpu()); txt_emb.append(t.cpu()) img_emb torch.cat(img_emb); txt_emb torch.cat(txt_emb) sim img_emb txt_emb.t() sim.fill_diagonal_(-1e4) # 排除正样本 hard_idx sim.topk(top_k, dim1).indices # 每张图最像的 K 个错误文本 return hard_idx逻辑说明fill_diagonal_把对角线正样本置为极小值topk取相似度最高的 K 个这些就是难负样本的索引。参数说明top_k5表示每张图取 5 个最难负样本太多会引入噪声有些难负样本其实是标注错误的正样本太少挖掘不充分。难负样本挖掘有个陷阱如果数据标注有噪声挖出来的难负样本可能是被错标的正样本强行推远会损害模型。所以挖掘后要人工抽查一批或者用软标签降低这些样本的权重。我一般会先挖 top-3 看看如果里面超过三成是语义相近的合理负样本再加大 top_k。另一个进阶方向是跨模态对齐的验证。训练完后除了 RecallK我习惯再做一个 t-SNE 可视化把图像和文本向量降到二维看分布。如果匹配的图文对在图上聚成一簇说明共享空间学到位了如果图像和文本分成两团各聚各的说明对齐失败得回头查投影头和损失函数。这个可视化不写进报告但自己排查问题时比看 loss 曲线直观得多。最后说个习惯每次改完超参或结构固定跑三次取平均对比学习对随机种子敏感单次结果波动一两个点很正常别被一次偶然的高分骗了。希望帮到你。本文还有配套的精品资源点击获取