ARTICLE DETAIL

资讯详情

深耕郑州网站建设与运营推广的一线实战洞察。

基于Flickr30k的Python跨模态检索课程设计:双塔模型与CLIP实战

基于Flickr30k的Python跨模态检索课程设计:双塔模型与CLIP实战 简介这份资源面向计算机、人工智能、通信工程等专业的在校学生与教师提供一套基于Flickr30k数据集的图像—文本跨模态检索课程设计完整方案可用于媒体计算实践作业、毕业设计或项目初期立项演示。压缩包共35个文件约1.13MB以22个Python源码为核心覆盖数据划分、图像缩放、模型训练与评估等流程另含6个pyc编译文件、3个Markdown说明文档、1个Shell脚本及少量图片与许可证文件目录中Preprocessing、Models、DataSet等模块划分清晰便于按阶段阅读与调试。资源内附测试界面与项目说明代码均经运行验证答辩评审平均分达96分已有586人学习。读者可据此掌握跨模态检索的完整实现思路包括数据预处理、训练集与验证集划分、模型分阶段训练及检索效果评估并能在现有代码基础上修改扩展完成自己的课设或毕设任务。1. 从一次课程设计答辩翻车说起Flickr30k 跨模态搜索到底在做什么很多同学做课程设计时第一反应是找个现成 demo 改改界面结果答辩时被老师一句「你这检索是怎么算相似度的」问得哑口无言。图像文本跨模态搜索这件事核心不是界面好不好看而是能不能把一张图和一句自然语言描述映射到同一个语义空间里让「一只狗在草地上跑」这句话能准确命中对应的那张图。Flickr30k 数据集恰好提供了这种配对关系三万多张真实场景图片每张配五句英文描述是练跨模态检索最经典的入门数据集之一。这个方向适合两类人一是正在做 python 课程设计、需要完整可运行方案的学生二是想入门多模态检索、但不想一上来就啃 CLIP 论文的工程师。它解决的是「给定一句话找图」和「给定一张图找描述」这两个双向检索问题。整套方案用 python 实现包含数据集处理、模型训练、特征索引和测试界面属于能跑通、能讲清、能写进报告的完整闭环。下面我按实际做过的顺序把选型、代码、参数和踩过的坑一条条拆开讲。2. 数据集与模型选型为什么 Flickr30k 配双塔结构最稳2.1 Flickr30k 的目录结构和标注格式Flickr30k 下载下来通常是两个部分一个是flickr30k-images图片文件夹里面是三万多张.jpg另一个是标注文件常见格式是results_20130124.token或整理好的dataset_flickr30k.json。token 文件每行格式是「图片名#序号\t描述」同一张图会出现五行。json 版本更友好结构是images数组每个元素带filename、split、sentences其中sentences里每项有raw字段存原始描述。我一般先把 token 转成 json 或 csv方便后续按图分组。转换时要注意编码Flickr30k 的描述里有不少非 ASCII 字符用utf-8读、utf-8-sig写避免 Excel 打开乱码。划分训练/验证/测试时官方 split 是 29000/1014/1000直接沿用即可不要自己随机切否则和论文结果没法对比。2.2 双塔结构为什么比单塔更适合课程设计跨模态检索主流做法有两类单塔把图文拼一起送进 Transformer 做交互和双塔图走图像编码器、文走文本编码器各自出向量后算余弦相似度。单塔精度高但推理慢检索时要对每张图重新算一遍三万多张图根本扛不住。双塔可以离线把所有图片特征算好存成矩阵检索时只算一次文本向量做一次矩阵乘法就能出 Top-K工程上更合理。课程设计场景下双塔还有一个隐性优势结构清晰答辩时能画成两个分支加一个相似度层老师一听就懂。图像编码器我一般用 ResNet50 去掉最后的分类层输出 2048 维再投影到 256 维文本编码器用一层 BiLSTM 或直接上预训练 BERT 的[CLS]向量再投影。如果算力有限文本侧用词袋加全连接也能跑但效果会差一截。2.3 用 python 搭一个最小可跑的双塔模型下面这段是模型定义的核心部分图像侧和文本侧各自编码后投影到同一维度训练时用对比损失拉近正样本对、推远负样本对。import torch import torch.nn as nn import torchvision.models as models class ImageEncoder(nn.Module): def __init__(self, embed_dim256): super().__init__() # 用预训练 ResNet50去掉最后的全连接分类层 backbone models.resnet50(pretrainedTrue) self.backbone nn.Sequential(*list(backbone.children())[:-1]) # 输出 (B,2048,1,1) self.proj nn.Linear(2048, embed_dim) # 投影到统一语义空间 def forward(self, x): feat self.backbone(x).flatten(1) # (B,2048) return nn.functional.normalize(self.proj(feat), dim-1) class TextEncoder(nn.Module): def __init__(self, vocab_size, embed_dim256, hidden512): super().__init__() self.embed nn.Embedding(vocab_size, 300, padding_idx0) self.lstm nn.LSTM(300, hidden, batch_firstTrue, bidirectionalTrue) self.proj nn.Linear(hidden * 2, embed_dim) def forward(self, x): # x: (B, L) 已经 padding 的词 id emb self.embed(x) out, _ self.lstm(emb) # 取最后一个非 padding 位置的输出这里简化用最大池化 pooled out.max(dim1).values return nn.functional.normalize(self.proj(pooled), dim-1)图像侧pretrainedTrue会下载 ResNet50 权重第一次跑需要联网如果环境不允许改成False也能跑只是收敛慢。embed_dim设 256 是精度和显存的折中设 128 省显存但检索指标会掉两三个点。文本侧max(dim1)是简化写法更严谨的做法是用 mask 把 padding 位置置为负无穷再取 max否则短句会被 padding 干扰。normalize之后余弦相似度就等价于点积后面建索引直接算矩阵乘法即可。2.4 对比损失里温度系数怎么定训练目标用 InfoNCE 或带温度的对比损失关键是温度系数temperature。这个参数控制 softmax 的尖锐程度太小如 0.01会让模型只关注最难负样本训练不稳定太大如 1.0则区分度不够。我一般从 0.07 起步这是 CLIP 用的值在 Flickr30k 这种规模上表现稳定。如果 loss 震荡厉害先检查 batch 里正样本对有没有对齐再考虑把温度调到 0.1。class ContrastiveLoss(nn.Module): def __init__(self, temperature0.07): super().__init__() self.t temperature def forward(self, img_emb, txt_emb): # 相似度矩阵对角线是正样本对 logits img_emb txt_emb.t() / self.t labels torch.arange(img_emb.size(0), deviceimg_emb.device) loss_i nn.functional.cross_entropy(logits, labels) loss_t nn.functional.cross_entropy(logits.t(), labels) return (loss_i loss_t) / 2logits是 batch 内图对文的相似度矩阵对角线是配对的。两个方向的交叉熵分别保证「图找文」和「文找图」都正确取平均是常见做法。batch size 建议 64 起步太小负样本不够对比学习效果会打折显存不够就梯度累积别硬降 batch。3. 训练流程与特征索引把三万多张图变成可检索的矩阵3.1 数据加载和预处理的关键参数图像统一 resize 到 224×224这是 ResNet 的标准输入。归一化用 ImageNet 的均值和方差mean[0.485,0.456,0.406]、std[0.229,0.224,0.225]。文本侧要建词表Flickr30k 描述总共约一万多个词取出现频率前 10000 建 vocab其余映射到unk。句子截断到最大长度 30超过的截掉不足的补 0。from torch.utils.data import Dataset, DataLoader from PIL import Image import torchvision.transforms as T class FlickrDataset(Dataset): def __init__(self, df, vocab, transformNone, max_len30): self.df df # 每行: filename, caption self.vocab vocab self.transform transform self.max_len max_len def __len__(self): return len(self.df) def __getitem__(self, idx): row self.df.iloc[idx] img Image.open(row[filename]).convert(RGB) if self.transform: img self.transform(img) tokens [self.vocab.get(w, self.vocab[unk]) for w in row[caption].lower().split()][:self.max_len] tokens [0] * (self.max_len - len(tokens)) return img, torch.tensor(tokens) 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]) ])convert(RGB)不能省Flickr30k 里有少量灰度图不转会在 ToTensor 时报错。max_len30覆盖了绝大多数描述再长收益很小。DataLoader 的num_workers在 Windows 上设 0 更稳Linux 上设 4 到 8pin_memoryTrue能加速 GPU 传输。3.2 训练循环和验证指标训练时每个 epoch 结束后在验证集上算 Recall1、Recall5、Recall10。做法是把验证集所有图片特征和文本特征算出来做相似度矩阵看每句话的 Top-K 里有没有正确图片。这个指标比 loss 更直观也是论文里通用的报告方式。def evaluate(model_img, model_txt, loader, device): model_img.eval(); model_txt.eval() all_img, all_txt [], [] with torch.no_grad(): for imgs, caps in loader: imgs, caps imgs.to(device), caps.to(device) all_img.append(model_img(imgs)) all_txt.append(model_txt(caps)) all_img torch.cat(all_img) all_txt torch.cat(all_txt) sims all_img all_txt.t() # (N, N) ranks torch.argsort(sims, dim1, descendingTrue) r1 (ranks[:, :1] torch.arange(len(ranks)).unsqueeze(1).to(device)).any(1).float().mean() r5 (ranks[:, :5] torch.arange(len(ranks)).unsqueeze(1).to(device)).any(1).float().mean() return r1.item(), r5.item()注意验证集里每张图对应五句话算 Recall 时要么按图去重要么把五句话都当独立查询两种口径结果不同报告里要写清楚。我一般按句子算更严格。训练 20 到 30 个 epoch学习率 1e-4用 Adam每 10 个 epoch 降一半。ResNet 部分可以冻结前几层只训后面的能省不少时间。3.3 离线建索引和在线检索训练完把训练集和测试集所有图片特征算出来存成.npy检索时加载进内存。三万多张图、256 维float32 也就三十多兆完全放得下。在线检索就是文本编码器出向量和图片特征矩阵做点积取 Top-K。import numpy as np # 离线把所有图片特征存下来 img_feats [] with torch.no_grad(): for imgs, _ in image_loader: img_feats.append(model_img(imgs.to(device)).cpu().numpy()) img_feats np.concatenate(img_feats) # (N, 256) np.save(img_feats.npy, img_feats) # 在线一句话检索 def search(query, model_txt, vocab, img_feats, filenames, topk5): tokens [vocab.get(w, vocab[unk]) for w in query.lower().split()][:30] tokens [0] * (30 - len(tokens)) with torch.no_grad(): q model_txt(torch.tensor([tokens]).to(device)).cpu().numpy() scores img_feats q.T # (N,1) idx np.argsort(-scores[:, 0])[:topk] return [(filenames[i], float(scores[i, 0])) for i in idx]img_feats存之前已经做过 L2 归一化所以点积就是余弦相似度。如果换了模型或重新训练记得重新生成索引否则检索结果对不上。Top-K 的 K 在界面上一般给 5 或 10太多会稀释展示效果。3.4 测试界面怎么搭最省事课程设计要交界面用 Gradio 或 Streamlit 最快几十行就能出一个能输入文本、展示图片和相似度的页面。Gradio 的gr.Interface或gr.Blocks都行输入框收 query输出用gr.Gallery展示 Top-K 图片路径。注意图片路径要转成绝对路径或可访问的 URL否则界面显示空白。如果老师要求桌面程序PyQt 也能做但开发成本高不少课程设计用 Web 界面足够。4. 避坑与排查那些让指标掉一半的细节4.1 现象训练 loss 正常下降但 Recall1 一直上不去原因通常是负样本不够或特征没归一化。对比学习依赖 batch 内负样本batch 太小如 16时模型学不到区分性。另外如果忘了对投影后的向量做 L2 归一化点积大小受向量模长影响相似度排序会乱。解决把 batch 提到 64 以上显存不够就梯度累积检查两个编码器输出是否都过了normalize。4.2 现象检索结果全是同一张图或几张图这是特征坍缩的典型表现模型把所有输入映射到几乎相同的向量。原因可能是学习率太大、温度系数太小或者训练集里某些图出现次数异常多。解决先把学习率降到 1e-5 试一个 epoch看特征方差有没有恢复温度从 0.07 调到 0.1检查数据里有没有重复图片导致采样偏斜。4.3 现象验证集指标比训练集高很多听起来是好事实际往往是验证集太小Flickr30k 验证集只有 1014 张图随机波动大。另一个可能是训练时用了数据增强而验证没用导致分布不一致。解决报告指标时同时给验证集和测试集结果测试集 1000 张图更稳定数据增强只在训练阶段开验证和测试用同一套预处理。4.4 现象界面能出图但顺序明显不对先确认检索用的图片特征和界面展示的图片文件名是不是同一套顺序。常见错误是建索引时用了os.listdir的顺序展示时又按 DataFrame 顺序读两边对不上。解决建索引时把文件名一起存下来检索返回文件名而不是下标展示时按文件名找图。这个坑我踩过排查了一下午才发现是顺序错位。4.5 现象换一台机器跑指标差很多检查 PyTorch 和 torchvision 版本是否一致ResNet50 预训练权重的加载在不同版本间可能有细微差异。另外随机种子要固定torch.manual_seed、np.random.seed、random.seed都设上DataLoader 的worker_init_fn也设种子否则每次跑结果都有波动。课程设计报告里最好写明环境和种子方便复现。5. 进阶技巧用预训练 CLIP 把 Recall1 再拉高十几个点如果课程设计想拿高分或者想让检索效果明显好于同学最划算的升级是把文本编码器换成预训练 BERT、图像编码器换成 CLIP 的视觉分支或者直接用 CLIP 做零样本检索再在自己的数据上微调。CLIP 在四亿图文对上预训练过迁移到 Flickr30k 上 Recall1 通常能到 60% 以上而从头训的双塔往往只有 30% 到 40%。具体做法有两种。第一种是冻结 CLIP 两个编码器只训练后面的投影层或直接零样本推理适合算力紧张的情况。第二种是解冻最后几层做微调学习率设 1e-5 到 1e-6避免把预训练知识冲掉。下面是用 HuggingFace 的transformers加载 CLIP 做检索的骨架from transformers import CLIPProcessor, CLIPModel import torch model CLIPModel.from_pretrained(openai/clip-vit-base-patch32).to(device) processor CLIPProcessor.from_pretrained(openai/clip-vit-base-patch32) # 离线算图片特征 def encode_images(paths, batch_size32): feats [] for i in range(0, len(paths), batch_size): batch [Image.open(p).convert(RGB) for p in paths[i:ibatch_size]] inputs processor(imagesbatch, return_tensorspt).to(device) with torch.no_grad(): feats.append(model.get_image_features(**inputs).cpu()) return torch.cat(feats) # 在线检索 def clip_search(query, img_feats, filenames, topk5): inputs processor(text[query], return_tensorspt, paddingTrue).to(device) with torch.no_grad(): q model.get_text_features(**inputs).cpu() q q / q.norm(dim-1, keepdimTrue) img_feats img_feats / img_feats.norm(dim-1, keepdimTrue) scores (img_feats q.t()).squeeze(1) idx torch.argsort(scores, descendingTrue)[:topk] return [(filenames[i], float(scores[i])) for i in idx]get_image_features和get_text_features出来的向量默认没归一化必须手动除模长否则点积不是余弦相似度。paddingTrue处理不同长度文本return_tensorspt直接出张量。如果显存不够把 batch_size 降到 8 或 16或者用torch.cuda.amp混合精度。微调时有个细节CLIP 的对比损失温度是可学习的HuggingFace 的CLIPModel里已经包含logit_scale直接用它的forward算 loss 就行不要自己再写一个。另外 Flickr30k 的描述是英文CLIP 原生支持英文不用翻译。如果要做中文检索得换中文 CLIP 或多语言模型那是另一个话题了。最后说个我自己的习惯每次改完模型或参数先在小批量数据上跑通全流程确认索引、检索、界面三处对得上再上全量训练。这个习惯帮我省过至少三次「训了一晚上发现索引没更新」的后悔药。跨模态检索这个方向工程细节比模型结构更决定成败把数据管道和索引一致性守住效果就不会差。希望帮到你。本文还有配套的精品资源点击获取
返回列表