多模态原理--ClipCap
1.概述
图生文模型(image captioning model)是一种将图片作为输入并生成图片描述的模型。

2. 模型工作原理
ClipCap 是一种结合了 CLIP 和 GPT-2 的图生文模型。CLIP 使用图像编码器生成图像嵌入,GPT-2 用于生成文本。ClipCap 的基本工作原理如下:CLIP 模型将图像转换为嵌入,嵌入作为 GPT-2 的提示词,生成图像的描述文本。总的工作流程就是使用图像嵌入来微调GPT-2。
3. CLIP 模型中图像编码器的特征输出
CLIP 模型地址是 https://hf-mirror.com/OFA-Sys/chinese-clip-vit-base-patch16 ,研究官方API就会发现CLIP提供两种图像特征 last_hidden_state 和 pooler_output(注意:在 windows 系统只有 pooler_output),它们的形状是(1,197,768),(1,512)。因为 GPT-2 的Embedding 维度是768,所以我选择 last_hidden_state 作为图像特征。如果选择 pooler_output 就需要 MLP 进行升维度,然后reshape适应GPT-2 的 Embedding 维度。
4. 利用CLIP提取图像特征和准备图像描述信息
4.1 准备两张图片,并且编辑其图片描述信息

'特朗普凝视远方', '特朗普一本正经的看向前方', '特朗普表情严肃看向远方', '特朗普目光凝重,不知所以', '特朗普呆呆的', '特朗普一本正经的看向前方', '特朗普在装深沉', '特朗普一本正经的看向前方', '特朗普一本正经的看向前方', '特朗普表情严肃看向远方', '特朗普怀着对美国前途的担心,看向对面', '特朗普目光凝重,不知所以', '特朗普在哭笑不得', '特朗普在哭笑不得', '特朗普呆呆的', '特朗普呆呆的', '特朗普眉头紧锁,看向对面', '特朗普眉头紧锁,看向远方', '特朗普眉头紧锁,注视前方', '特朗普怀着对美国前途的担心,看向远方', '特朗普怀着对自己前途的担心,看向对面', '特朗普怀着对自己前途的担心,看向对面', '特朗普怀着对自己前途的担心,看向对面'

'一只皮卡丘开心地坐着', '一只皮卡丘开心地在玩耍', '一只皮卡丘嘴巴张开,开心地在玩耍', '一只皮卡丘开心地坐着', '一只皮卡丘开心地坐着', '一只皮卡丘开心地坐着', '一只皮卡丘开心地坐着', '一只皮卡丘开心地坐着', '一只皮卡丘开心地坐着', '一只皮卡丘嘴巴张开,手舞足蹈', '一只皮卡丘嘴巴张开,手舞足蹈', '一只皮卡丘嘴巴张开,手舞足蹈', '一只皮卡丘嘴巴张开,手舞足蹈', '一只皮卡丘嘴巴张开,手舞足蹈', '一只皮卡丘嘴巴张开,手舞足蹈'
4.2 提取图片嵌入特征
import pickle
from PIL import Image
from transformers import ChineseCLIPProcessor, ChineseCLIPModel
from code_4_clipcap_config import CLIP_MODEL_PATH
# 参考官方提供的 API 代码,开始提取图像 Embedding
clip_model = ChineseCLIPModel.from_pretrained(CLIP_MODEL_PATH)
processor = ChineseCLIPProcessor.from_pretrained(CLIP_MODEL_PATH)
inputs_1 = processor(images=Image.open("trump.jpeg"), return_tensors="pt")
inputs_2 = processor(images=Image.open("pokemon.jpeg"), return_tensors="pt")
image_1_features = clip_model.get_image_features(**inputs_1)
image_2_features = clip_model.get_image_features(**inputs_2)
print(image_1_features.last_hidden_state.shape)
print(image_1_features.pooler_output.shape)
image_1_features = image_1_features.last_hidden_state.squeeze(0)
image_2_features = image_2_features.last_hidden_state.squeeze(0)
image_1_features = image_1_features / image_1_features.norm(p=2, dim=-1, keepdim=True) # normalize
image_2_features = image_2_features / image_2_features.norm(p=2, dim=-1, keepdim=True) # normalize
# 官方提供的 API 代码,结束提取图像 Embedding
4.3 保存图片嵌入特征和描述信息
# key:图片id
# value: 图片的嵌入
# 下面的字典也可以放在chroma这样的向量数据库
image_id2embed = {
1: image_1_features,
2: image_2_features,
}
# 图片的id和图片标题对
caption_list = [
(1, "特朗普凝视远方"),
(1, "特朗普一本正经的看向前方"),
(1, "特朗普表情严肃看向远方"),
(1, "特朗普目光凝重,不知所以"),
(1, "特朗普呆呆的"),
(1, "特朗普一本正经的看向前方"),
(1, "特朗普在装深沉"),
(1, "特朗普一本正经的看向前方"),
(1, "特朗普一本正经的看向前方"),
(1, "特朗普表情严肃看向远方"),
(1, "特朗普怀着对美国前途的担心,看向对面"),
(1, "特朗普目光凝重,不知所以"),
(1, "特朗普在哭笑不得"),
(1, "特朗普在哭笑不得"),
(1, "特朗普呆呆的"),
(1, "特朗普呆呆的"),
(1, "特朗普眉头紧锁,看向对面"),
(1, "特朗普眉头紧锁,看向远方"),
(1, "特朗普眉头紧锁,注视前方"),
(1, "特朗普怀着对美国前途的担心,看向远方"),
(1, "特朗普怀着对自己前途的担心,看向对面"),
(1, "特朗普怀着对自己前途的担心,看向对面"),
(1, "特朗普怀着对自己前途的担心,看向对面"),
(2, "一只皮卡丘开心地坐着"),
(2, "一只皮卡丘开心地在玩耍"),
(2, "一只皮卡丘嘴巴张开,开心地在玩耍"),
(2, "一只皮卡丘开心地坐着"),
(2, "一只皮卡丘开心地坐着"),
(2, "一只皮卡丘开心地坐着"),
(2, "一只皮卡丘开心地坐着"),
(2, "一只皮卡丘开心地坐着"),
(2, "一只皮卡丘开心地坐着"),
(2, "一只皮卡丘嘴巴张开,手舞足蹈"),
(2, "一只皮卡丘嘴巴张开,手舞足蹈"),
(2, "一只皮卡丘嘴巴张开,手舞足蹈"),
(2, "一只皮卡丘嘴巴张开,手舞足蹈"),
(2, "一只皮卡丘嘴巴张开,手舞足蹈"),
(2, "一只皮卡丘嘴巴张开,手舞足蹈"),
]
with open("caption_image.pkl", 'wb') as f:
pickle.dump([caption_list, image_id2embed], f)
print(f'图像嵌入的数量:{len(image_id2embed)}')
print(f'图像文本的数量:{len(caption_list)}')

5. 整理数据
整理数据主要是生成 DataLoader 可以直接使用的数据。其中关键点有:①文本数据需要 padding 填充。②需要表示文本结束的标识。③注意力权重计算的mask。
import torch
from torch.utils.data import Dataset
import pickle
from typing import Tuple
from code_4_clipcap_config import IMAGE_TOKEN_LENGTH, MAX_LENGTH
class ClipCapDataset(Dataset):
def __init__(self, tokenizer):
# 填充符
pad_id = tokenizer.pad_token_id
# 取出图片的文本和图片的嵌入
with open("caption_image.pkl", 'rb') as f:
caption_list, image_id2embed = pickle.load(f)
print('图片嵌入的总数:{}'.format(len(image_id2embed)))
print('图片描述的总数:{}'.format(len(caption_list)))
image_embed_list = []
caption_ids_list = []
mask_list = []
for image_id, caption in caption_list:
# 使用图像id获取图像的特征(clip.image_encoder输出的)
image_embed = image_id2embed[image_id]
# 只对文本进行分词,不添加任何特殊token。
caption_ids = tokenizer.encode(caption,add_special_tokens=False)
# 在文本的token列表后面添加一个分隔符token
caption_ids.append(tokenizer.sep_token_id)
# 截断
# 训练限制了token输入长度为300
# 只能留下前103个token,因为图像对应的token需要占用197个token的位置
# 最终的数据是:图像的token列表 + 文本的token列表
caption_ids = caption_ids[:MAX_LENGTH - IMAGE_TOKEN_LENGTH]
# 图像部分和文本部分的token都要掩码
mask = [1] * (IMAGE_TOKEN_LENGTH + len(caption_ids))
# 填充pad
padding_len = MAX_LENGTH - IMAGE_TOKEN_LENGTH - len(caption_ids)
caption_ids += [pad_id] * padding_len
# 将填充符掩码为0
mask += [0] * padding_len
caption_ids = torch.tensor(caption_ids).long()
mask = torch.tensor(mask).long()
image_embed_list.append(image_embed)
caption_ids_list.append(caption_ids)
mask_list.append(mask)
# 保存训练数据
with open("train_data.pkl", 'wb') as f:
pickle.dump([
image_embed_list, # clip输出的图片特征的列表
caption_ids_list, # 图片文本的input_ids的列表
mask_list # 掩码的列表
], f)
self.image_embed_list = image_embed_list
self.caption_ids_list = caption_ids_list
self.mask_list = mask_list
print(f'训练数据总数:{len(self.image_embed_list)}')
def __len__(self) -> int:
return len(self.caption_ids_list)
def __getitem__(self, index: int) -> Tuple[torch.Tensor, ...]:
image_embed = self.image_embed_list[index]
caption_ids = self.caption_ids_list[index]
mask = self.mask_list[index]
return image_embed, caption_ids, mask
6. CLIPCaption模型
import torch
import torch.nn as nn
from transformers import AutoModelForCausalLM
from code_4_clipcap_config import LLM_PATH
class ClipCaptionModel(nn.Module):
def __init__(self):
super(ClipCaptionModel, self).__init__()
# 大语言模型:用来生成图片的文本描述
self.gpt2 = AutoModelForCausalLM.from_pretrained(LLM_PATH)
def forward(self, image_embeds, caption_ids, mask):
# 张量形状:[Batch_size, 文本长度, gpt2的词嵌入的维度]
# 标题caption的每个token的词嵌入
caption_embeds = self.gpt2.transformer.wte(caption_ids)
# image_embeds = image_embeds.squeeze(dim=1)
# 197个图片的token + 文本的token
# 张量形状:[Batch_size, 197+文本长度, 词嵌入维度]
embedding_cat = torch.cat((
image_embeds, # 图像的token
caption_embeds # 文本的token
), dim=1)
out = self.gpt2(inputs_embeds=embedding_cat, attention_mask=mask)
# 张量形状:[Batch_size, 197+文本长度,词嵌入维度]
logits = out.logits
return logits
7. 配置信息
import torch
CLIP_MODEL_PATH = "OFA-Sys/chinese-clip-vit-base-patch16"
# 一张图片的嵌入经过投影转换成197个token的embedding,每个embedding的dim是768
IMAGE_TOKEN_LENGTH = 197 # 图片的token的数量
MAX_LENGTH = 300 # 训练阶段最大token数量
# clip对接的大语言模型
LLM_PATH = "uer/gpt2-distil-chinese-cluecorpussmall"
LLM_WORD_EMBD_DIM = 768 # gpt2的词嵌入维度
device = torch.device("cuda")
epochs = 20
8. 模型训练
import torch
from torch.utils.data import DataLoader
from tqdm import tqdm
from transformers import AutoTokenizer
from code_2_clipcap_dataset import ClipCapDataset
from code_3_clipcap_model import ClipCaptionModel
from code_4_clipcap_config import LLM_PATH, IMAGE_TOKEN_LENGTH, device,epochs
# GPT-2 的分词器
tokenizer = AutoTokenizer.from_pretrained(LLM_PATH)
# 加载模型
model = ClipCaptionModel().to(device)
# 加载训练数据集
dataset = ClipCapDataset(tokenizer)
# DataLoader 分批数据
train_dataloader = DataLoader(dataset, batch_size=4, shuffle=True)
# 优化器
optimizer = torch.optim.AdamW(model.parameters(), lr=2e-5)
# 损失函数
loss = torch.nn.CrossEntropyLoss()
model.train()
for epoch in range(epochs):
training_loss = 0.0
for image_embed, caption_ids, mask in tqdm(train_dataloader):
image_embed, caption_ids, mask = image_embed.to(device), caption_ids.to(device), mask.to(device)
# 输出的logits
logits = model(image_embed, caption_ids, mask)
# 计算loss
# [图片的最后一个token],[两],[只],[狗]
# ↓ ↓ ↓
# [两] [只] [狗]
# 将三维数据转化为二维数据,易于计算交叉熵损失
shift_logits = logits[:, IMAGE_TOKEN_LENGTH - 1: -1, :].contiguous().view(-1, logits.size(-1))
# 预测目标 将二维数据转化为一维数据,,易于计算交叉熵损失
shift_labels = caption_ids.view(-1)
loss_v = loss(shift_logits, shift_labels)
training_loss +=loss_v.item()
optimizer.zero_grad()
loss_v.backward()
optimizer.step()
print(f"Epoch [{epoch + 1}/{epochs}], Batch Loss: {training_loss:.3f}")
torch.save(model.state_dict(), f'model.pt')


10. 模型推理

import torch
import torch.nn.functional as F
from PIL import Image
from transformers import AutoTokenizer, ChineseCLIPModel, ChineseCLIPProcessor
from code_3_clipcap_model import ClipCaptionModel
from code_4_clipcap_config import LLM_PATH, CLIP_MODEL_PATH, device, MAX_LENGTH
def generate(model, image_embeds, tokenizer):
b_size = image_embeds.size(0)
pad_id = tokenizer.pad_token_id
sep_id = tokenizer.sep_token_id
unk_id = tokenizer.unk_token_id
temperature = 0.7
cur_len = 0
caption_ids = [] # 存储生成的caption
finish_flag = [False] * b_size # 第i个输入是否完成生成的标志
while True:
out = model.gpt2(inputs_embeds=image_embeds)
logits = out.logits # [B, len, vocab_size]
# 采样下一个token
next_token_logits = logits[:, -1, :] # 取最后一个单词的预测分布
next_token_logits = next_token_logits / temperature
next_token_logits[:, unk_id] = -float('Inf') # 将unk设为无穷小
# 采样下一个token,多项分布
next_token_ids = torch.multinomial(F.softmax(next_token_logits, dim=-1),num_samples=1).squeeze(1).tolist()
for index in range(len(next_token_ids)):
token_id = next_token_ids[index]
# 如果第i个句子已经生成结束。为了下次生成任务,需要进行padding
if finish_flag[index]:
next_token_ids[index] = pad_id
# 当第i个句子预测到了分隔符,说明生成结束
elif token_id == sep_id:
finish_flag[index] = True
# 第一轮生成的时候,需要为每张图片创建列表
elif cur_len == 0:
caption_ids.append([token_id])
else:
caption_ids[index].append(token_id)
next_token_ids = torch.tensor(next_token_ids).to(device)
next_token_embeds = model.gpt2.transformer.wte(next_token_ids).to(device).unsqueeze(1)
# 将生成的next token拼接到上文的后面,继续生成
image_embeds = torch.cat((image_embeds, next_token_embeds), dim=1)
cur_len += 1 # 生成长度+1
# 如果生成长度大于最大长度,或者所有图片的生成文本都结束了,退出生成过程。
if cur_len > MAX_LENGTH or False not in finish_flag:
break
# 解码
captions = []
for caption_id in caption_ids:
caption = tokenizer.convert_ids_to_tokens(caption_id)
caption = ''.join(caption)
captions.append(caption)
return captions
# 加载分词器
tokenizer = AutoTokenizer.from_pretrained(LLM_PATH)
# 初始化模型
model = ClipCaptionModel().to(device)
# 加载权重
model.load_state_dict(torch.load("model.pt",map_location=device), False)
model.eval()
# 加载clip模型
clip_model = ChineseCLIPModel.from_pretrained(CLIP_MODEL_PATH)
processor = ChineseCLIPProcessor.from_pretrained(CLIP_MODEL_PATH)
inputs_1 = processor(images=Image.open("trump.jpeg"), return_tensors="pt")
inputs_2 = processor(images=Image.open("t.jpg"), return_tensors="pt")
image_1_features = clip_model.get_image_features(**inputs_1)
image_2_features = clip_model.get_image_features(**inputs_2)
image_1_features = image_1_features.last_hidden_state.squeeze(0)
image_2_features = image_2_features.last_hidden_state.squeeze(0)
image_1_features = image_1_features / image_1_features.norm(p=2, dim=-1, keepdim=True) # normalize
image_2_features = image_2_features / image_2_features.norm(p=2, dim=-1, keepdim=True) # normalize
# 将两张图片的特征打包成一个批次数据
data = torch.stack([image_1_features,image_2_features], dim=0).to(device)
captions = generate(model, data, tokenizer)
print(captions)
captions = generate(model, data, tokenizer)
print(captions)
captions = generate(model, data, tokenizer)
print(captions)
captions = generate(model, data, tokenizer)
print(captions)
captions = generate(model, data, tokenizer)
print(captions)

10. 总结:因为训练数据和硬件资源有限,所以模型推理效果不是很理想,但是图生文的模型架构已经可以清楚的展示。

- 点赞
- 收藏
- 关注作者
评论(0)