多模态AI:这次怎么落地的
边界条件才会告诉你方案能不能留。
"
第一次接触多模态AI时,我脑子里的想象是:喂进去一张图,模型就给我读出图里的文字、认出图里的人、告诉你图里发生了什么事。
为什么多模态AI需要公共空间
最早在图像分类任务上,模型输出的是类别概率分布——猫是0.9、狗是0.1。但当你问"这张图里是什么动物"时,你要的是一个能和自然语言对齐的答案,不是一个独热编码。
我第一次尝试用CLIP做图像检索时,跑了这样一段代码:
import torch
from PIL import Image
from transformers import CLIPProcessor, CLIPModel
model = CLIPModel.from_pretrained("openai/clip-vit-base-patch32")
processor = CLIPProcessor.from_pretrained("openai/clip-vit-base-patch32")
# 图像编码
image = Image.open("cat.jpg")
inputs = processor(images=image, return_tensors="pt")
image_features = model.get_image_features(**inputs)
# 文本编码
text_inputs = processor(text=["a photo of a cat", "a photo of a dog"], return_tensors="pt", padding=True)
text_features = model.get_text_features(**text_inputs)
# 计算相似度
similarity = (image_features @ text_features.T).softmax(dim=-1)
print(similarity)
输出结果告诉我,“a photo of a cat"的相似度是0.98,“a photo of a dog"是0.02。但当时我没搞懂的是:为什么图像特征和文本特征可以直接做点积?它们是怎么对齐到一个维度的?
后来才知道,CLIP训练时用了4亿对图像-文本数据,通过对比学习把两种模态拉到了一个公共嵌入空间。图像编码器和文本编码器各自输出512维向量,这两组向量在语义上是可以对齐比较的。这不是什么魔法,只是把两个原本不相关的空间强行"对齐"到一个共享空间里——简单粗暴,但有效。
用CLIP做图像检索的坑
第一次在实际项目里用CLIP做图像检索,我犯了一个低级错误:直接用相似度排序,完全没考虑编码器的容量限制。
项目场景是这样的:有一个图片库,用户输入自然语言描述,要找出最相关的图片。我最初的实现是这样的:
from tqdm import tqdm
import numpy as np
# 预先编码所有图片
image_db = []
for img_path in tqdm(os.listdir("image_db")):
img = Image.open(f"image_db/{img_path}")
inputs = processor(images=img, return_tensors="pt")
features = model.get_image_features(**inputs)
image_db.append(features.cpu().numpy().squeeze())
image_db = np.array(image_db)
def search(query, top_k=5):
query_features = model.get_text_features(**processor(text=[query], return_tensors="pt", padding=True))
similarities = (image_db @ query_features.T).squeeze()
return similarities.argsort()[-top_k:][::-1]
看起来没问题,跑了个小demo也工作正常。但当图片库扩充到上万张时,两个坑都暴露出来了。
第一个坑是显存。我一开始把所有图像特征都放在GPU上,显存直接爆了。后来改成先在CPU上存成numpy数组,推理时再按批次移到GPU:
import torch
from torch.utils.data import DataLoader
def search_batched(query, top_k=5, batch_size=128):
query_features = model.get_text_features(**processor(text=[query], return_tensors="pt", padding=True))
query_features = query_features.cpu().numpy().squeeze()
similarities = []
dataloader = DataLoader(image_db, batch_size=batch_size)
for batch in dataloader:
batch = batch.to(device)
batch_sim = (batch @ query_features).cpu().numpy()
similarities.extend(batch_sim)
return np.array(similarities).argsort()[-top_k:][::-1]
第二个坑更隐蔽:编码器的输入分辨率限制。CLIP默认输入是224x224,但我的图片库里有大量高分辨率照片。直接resize会丢细节,不改又会OOM。后来我换了个思路:先用一个轻量级模型(如ResNet-50)提取候选集,再用CLIP做精排。
# 第一步:用ResNet粗排
def coarse_search(query_image, top_k=50):
# 这里省略ResNet实现
pass
# 第二步:用CLIP精排
def fine_search(query_text, candidate_indices):
candidate_features = image_db[candidate_indices]
query_features = model.get_text_features(**processor(text=[query_text], return_tensors="pt", padding=True))
similarities = (candidate_features @ query_features.T).squeeze()
ranked_indices = candidate_indices[similarities.argsort()[::-1]]
return ranked_indices
这个两级检索方案把内存占用降到了原来的十分之一,但精度几乎没损失。多模态检索工程里经常要做这种取舍——不用追求一步到位,分阶段解决通常更务实。
图像描述生成的实践
图像描述(Image Captioning)是个更直接的需求:给张图,用自然语言描述它。我试过两种方案:传统Transformer架构和基于BLIP的预训练模型。
传统架构用CNN做视觉编码器,Transformer做语言解码器,这是一个典型的编码器-解码器结构:
import torch
import torch.nn as nn
from torchvision import models
class ImageCaptionModel(nn.Module):
def __init__(self, embed_size, hidden_size, vocab_size):
super().__init__()
resnet = models.resnet50(pretrained=True)
modules = list(resnet.children())[:-1]
self.cnn = nn.Sequential(*modules)
self.fc = nn.Linear(resnet.fc.in_features, embed_size)
self.rnn = nn.LSTM(embed_size, hidden_size, batch_first=True)
self.linear = nn.Linear(hidden_size, vocab_size)
def forward(self, images, captions):
features = self.cnn(images)
features = features.view(features.size(0), -1)
features = self.fc(features)
embeddings = self.embedding(captions)
embeddings = torch.cat((features.unsqueeze(1), embeddings), 1)
hiddens, _ = self.rnn(embeddings)
outputs = self.linear(hiddens)
return outputs
这个模型看起来很标准,但训练起来很费劲。我跑了三天,loss一直降不下去,生成的描述总是像"a photo of a photo of a photo"这种重复鬼话。后来才发现几个问题:
第一个是数据质量问题。我用的是COCO数据集,但里面有不少噪声——标注不全、描述重复、甚至有错误标注。我做了简单清洗,去掉长度小于10个词的描述、高频重复词比例过高的样本:
import json
from collections import Counter
def clean_coco_annotations(annotations_path, output_path):
with open(annotations_path) as f:
data = json.load(f)
filtered = []
for ann in data['annotations']:
caption = ann['caption'].lower().strip()
words = caption.split()
if len(words) < 10:
continue
word_counts = Counter(words)
max_repeat = max(word_counts.values())
if max_repeat / len(words) > 0.3:
continue
filtered.append(ann)
data['annotations'] = filtered
with open(output_path, 'w') as f:
json.dump(data, f)
第二个问题是训练策略。我一开始用的是teacher forcing,但这会导致模型只看到正确的上一词,推理时一旦出错就会产生级联错误。后来加了scheduled sampling:
def forward(self, images, captions, teacher_forcing_ratio=0.5):
features = self.cnn(images)
features = features.view(features.size(0), -1)
features = self.fc(features)
batch_size = images.size(0)
max_length = captions.size(1)
outputs = torch.zeros(batch_size, max_length, self.vocab_size).to(device)
hidden = None
input = features.unsqueeze(1)
for t in range(max_length):
output, hidden = self.rnn(input, hidden)
output = self.linear(output.squeeze(1))
outputs[:, t, :] = output
teacher_force = random.random() < teacher_forcing_ratio
top1 = output.argmax(1)
input = captions[:, t] if teacher_force else top1
input = self.embedding(input).unsqueeze(1)
return outputs
第三个问题是评估指标。我一开始只看BLEU分数,但后来发现BLEU对词序过于敏感,而CIDEr对描述质量更敏感。最终的评估是这样:
from nltk.translate.bleu_score import corpus_bleu
from pycocoevalcap.cider.cider import Cider
def evaluate_caption(model, dataloader):
references = []
hypotheses = []
for images, captions in dataloader:
preds = model.sample(images)
references.extend([cap.tolist() for cap in captions])
hypotheses.extend(pred.tolist() for pred in preds)
bleu = corpus_bleu([[ref] for ref in references], hypotheses)
cider_scorer = Cider()
cider_score, _ = cider_scorer.compute_score(references, hypotheses)
return bleu, cider_score
折腾了大半个月,模型终于能生成像样的描述了,但和预训练模型相比还是差一截。后来我直接用了BLIP,效果明显更好,也不用自己从头训练:
from transformers import BlipProcessor, BlipForConditionalGeneration
processor = BlipProcessor.from_pretrained("Salesforce/blip-image-captioning-base")
model = BlipForConditionalGeneration.from_pretrained("Salesforce/blip-image-captioning-base")
image = Image.open("photo.jpg").convert('RGB')
inputs = processor(image, return_tensors="pt")
out = model.generate(**inputs)
caption = processor.decode(out[0], skip_special_tokens=True)
print(caption)
这件事让我明白了一个道理:不要什么都自己从头训练。预训练模型已经学了很多,先用它做基线,再决定要不要在特定任务上fine-tune。
视觉问答(VQA)的尝试
VQA比图像描述更进一步,需要模型能理解问题并从图像里找答案。我试过两种方案:基于CLIP的简单方案和基于LLaVA的端到端方案。
CLIP方案的思路是:把问题当成文本查询,用CLIP检索相关答案。我预先准备了一个答案候选库(从训练集里提取的高频答案),然后用CLIP计算问题-答案相似度:
def vqa_clip(image_path, question, answer_candidates):
# 图像编码
image = Image.open(image_path).convert('RGB')
image_inputs = processor(images=image, return_tensors="pt")
image_features = model.get_image_features(**image_inputs)
# 问题编码
question_inputs = processor(text=[question], return_tensors="pt", padding=True)
question_features = model.get_text_features(**question_inputs)
# 答案候选编码
answer_inputs = processor(text=answer_candidates, return_tensors="pt, padding=True)
answer_features = model.get_text_features(**answer_inputs)
# 组合特征(这里简化为加法)
combined_features = (image_features + question_features).cpu().numpy().squeeze()
answer_features_np = answer_features.cpu().numpy()
# 计算相似度
similarities = (answer_features_np @ combined_features)
best_idx = similarities.argmax()
return answer_candidates[best_idx]
这个方案实现简单,但效果一般。问题在于:CLIP主要是为了图像-文本匹配设计的,它不擅长做推理。当我问"图里左边那个人穿什么颜色"时,CLIP找不到"左边"这种空间关系的显式特征。
后来我试了LLaVA,效果明显好很多:
from transformers import LlavaProcessor, LlavaForConditionalGeneration
from PIL import Image
processor = LlavaProcessor.from_pretrained("llava-hf/llava-1.5-7b-hf")
model = LlavaForConditionalGeneration.from_pretrained("llava-hf/llava-1.5-7b-hf")
prompt = "<image>\nUSER: 图里左边那个人穿什么颜色?\nASSISTANT:"
image = Image.open("group_photo.jpg").convert('RGB')
inputs = processor(text=prompt, images=image, return_tensors="pt")
output = model.generate(**inputs, max_new_tokens=50)
answer = processor.decode(output[0], skip_special_tokens=True)
print(answer)
LLaVA的优势在于它把图像和文本都送到了一个统一的LLM里,模型可以做更复杂的推理。但它也有自己的问题:一是显存占用大,7B模型至少需要16GB显存;二是推理速度慢,一张图一个问答要几秒钟。
在实际项目里,我把两个方案结合起来了:简单问题用CLIP快速过滤,复杂问题再用LLaVA精做:
def vqa_hybrid(image_path, question):
# 简单问题检测(这里用关键词匹配)
simple_keywords = ['是什么', '有多少', '什么颜色', '几只']
if any(kw in question for kw in simple_keywords):
return vqa_clip(image_path, question, simple_answer_candidates)
else:
return vqa_llava(image_path, question)
这种混合方案把平均响应时间从3秒降到了0.8秒,而且复杂问题的准确率还提高了。
几个实践建议
折腾了这么久,有几条经验我想记下来,以后再搞多模态项目时不至于重复踩坑。
第一条是:不要低估数据预处理的工作量。我最初以为只要把图片喂给模型就行,但实际要做很多事情——去重复、标准化分辨率、过滤低质量样本、平衡类别分布。有一次我因为没做去重,训练集里有几千张几乎一样的图,模型过拟合得很严重。
第二条是:显存永远不够用。多模态模型通常比纯文本模型大得多,而图片数据本身也很占空间。从一开始就要考虑量化、模型剪枝、梯度累积这些技术,不要等到项目后期才想办法。
第三条是:评估指标要多选几个。BLEU、CIDEr、SPICE、ROUGE各有侧重,只看一个指标容易产生错觉。我有一段时间只看BLEU,结果模型生成的描述语法正确但语义空洞,后来加了CIDEr才发现问题。
第四条是:工程能力比模型架构更重要。一个调优良好的CLIP基线,往往比一个从头训练的复杂模型更有用。多模态AI的核心价值不是模型有多复杂,而是能不能在实际场景里解决问题。
写在最后
多模态AI是个好东西,但它不是万能的。图像和文本的对齐只是第一步,真正的挑战在于理解场景、推理关系、生成有意义的答案。
我至今还记得第一次用CLIP做图像检索时的那种兴奋——那种"原来可以这样把两种模态联系起来"的感觉。但后来冷静下来才发现,真正落地的产品要考虑的东西远不止模型本身:数据质量、推理速度、显存占用、用户体验、成本控制。
技术方案的选择通常都是trade-off:精度vs速度、通用性vs针对性、资源消耗vs效果。没有银弹,只有在具体场景里权衡之后的最优解。
这篇文章写的都是我自己踩过的坑,有些问题现在看起来很低级,但当时确实困扰了很久。希望这些经验能帮后来者少走一点弯路,也希望以后再做多模态项目时,我能记得今天写下的这些教训。
版权声明: 本文首发于 指尖魔法屋-多模态AI:这次怎么落地的(https://blog.thinkmoon.cn/post/143-multimodal-ai-deep-analysis-vision-to-text/) 转载或引用必须申明原指尖魔法屋来源及源地址!
评论
使用 GitHub 账号登录后即可留言,支持 Markdown。