最近在复现各种视觉语言模型(VLM)架构时,我发现很多开源实现要么过于复杂,要么隐藏了核心细节。于是决定写这篇手把手教程,带大家用PyTorch从零构建一个简化版的VLM——我称之为Seemore(致敬Andrej Karpathy的makemore项目)。这个实现包含了现代VLMs的三个核心组件:视觉编码器、跨模态投影模块和解码器语言模型。
提示:完整代码已开源在GitHub(https://github.com/AviSoori1x/seemore),建议配合代码阅读本文。虽然使用T4 GPU就能运行,但CPU训练会非常慢。
传统语言模型只能处理文本,而VLMs可以同时理解图像和文本。想象一下,你不仅能问模型"这幅画是什么风格?",还能上传图片让它直接分析。这种能力使得VLMs在:
经过分析GPT-4、LLaVA等主流模型,我发现它们都遵循类似的架构范式:
code复制图像输入 → 视觉编码器 → 跨模态投影 → 语言模型解码器 → 文本输出
注意:实际应用中,前两个组件往往使用预训练模型(如CLIP的ViT),并保持权重冻结,只训练投影模块。
我选择从头实现ViT而非直接调用预训练模型,这样更能理解底层原理。关键实现步骤:
ViT首先将图像分割为固定大小的块(如16x16像素),然后线性投影每个块:
python复制class PatchEmbeddings(nn.Module):
def __init__(self, img_size=96, patch_size=16, hidden_dim=512):
super().__init__()
self.conv = nn.Conv2d(3, hidden_dim,
kernel_size=patch_size,
stride=patch_size)
def forward(self, X):
X = self.conv(X) # [B, C, H, W]
X = X.flatten(2) # [B, C, num_patches]
return X.transpose(1, 2) # [B, num_patches, C]
对于96x96的输入图像,使用16x16的patch,将得到36个patch((96/16)^2),每个patch投影为512维向量。
与NLP中的Transformer类似,我们需要:
python复制self.cls_token = nn.Parameter(torch.zeros(1, 1, num_hiddens))
self.pos_embedding = nn.Parameter(
torch.randn(1, num_patches + 1, num_hiddens))
ViT的核心是多层Transformer编码器。关键点在于:
python复制class Block(nn.Module):
def __init__(self, n_embd, num_heads, dropout=0.1, is_decoder=False):
super().__init__()
self.attn = MultiHeadAttention(n_embd, num_heads, dropout, is_decoder)
self.ffn = nn.Sequential(
nn.Linear(n_embd, 4 * n_embd),
nn.GELU(),
nn.Linear(4 * n_embd, n_embd)
)
def forward(self, x):
x = x + self.attn(x) # 残差连接
x = x + self.ffn(x)
return x
视觉特征(如512维)和文本嵌入(如768维)通常维度不同。投影模块的作用就是进行维度对齐:
我采用了两层MLP作为投影器:
python复制class MultiModalProjector(nn.Module):
def __init__(self, n_embd, image_embed_dim):
super().__init__()
self.net = nn.Sequential(
nn.Linear(image_embed_dim, 4 * image_embed_dim),
nn.GELU(),
nn.Linear(4 * image_embed_dim, n_embd)
)
实际应用中,这个模块的设计对模型性能影响很大。LLaVA等模型发现,使用更大的投影层(如将视觉特征先扩展到更高维)能提升模型能力。
解码器是基于Transformer的自回归语言模型,关键区别在于:
为防止模型"偷看"未来信息,需要对注意力矩阵应用下三角掩码:
python复制if self.is_decoder:
tril = torch.tril(torch.ones(T, T, device=x.device))
wei = wei.masked_fill(tril == 0, float('-inf'))
将投影后的视觉特征作为前缀(prefix)与文本嵌入拼接:
python复制img_emb = self.image_projection(image_embeds).unsqueeze(1)
tok_emb = torch.cat([img_emb, tok_emb], dim=1)
这样在生成文本时,模型就能同时考虑视觉和文本上下文。
将三个组件组合成完整模型后,训练时需要注意:
使用标准的交叉熵损失,但对图像部分的目标标签设为-100(忽略):
python复制targets = torch.cat([
torch.full((batch_size, 1), -100, device=device),
text_targets
], dim=1)
loss = F.cross_entropy(logits.view(-1, logits.size(-1)),
targets.view(-1),
ignore_index=-100)
实际训练通常分两阶段:
在实现过程中,我遇到了以下几个典型问题:
现象:损失值波动大,生成文本无意义
排查:
解决:
python复制# 添加梯度检查
for name, param in model.named_parameters():
if param.grad is None:
print(f"No gradient for {name}")
原因:投影模块能力不足,视觉信息丢失
优化:
优化方案:
python复制scaler = torch.cuda.amp.GradScaler()
with torch.cuda.amp.autocast():
outputs = model(inputs)
loss = criterion(outputs, targets)
scaler.scale(loss).backward()
scaler.step(optimizer)
scaler.update()
基础实现完成后,可以考虑以下增强:
修改投影模块,使其能处理多个图像特征:
python复制img_embs = [self.image_projection(img) for img in image_embeds]
img_emb = torch.stack(img_embs, dim=1) # [B, num_imgs, D]
当前实现要求固定输入尺寸。可以通过以下方式改进:
对于大语言模型,可以采用:
实现LoRA的一个简单示例:
python复制class LoRALayer(nn.Module):
def __init__(self, original_layer, rank=8):
super().__init__()
self.original = original_layer
self.lora_A = nn.Linear(original_layer.in_features, rank, bias=False)
self.lora_B = nn.Linear(rank, original_layer.out_features, bias=False)
def forward(self, x):
return self.original(x) + self.lora_B(self.lora_A(x))
通过这个项目,我深刻体会到VLMs的核心创新不在于单个组件,而在于如何优雅地桥接视觉与语言模态。虽然我们的实现只有基础功能,但它清晰地展示了现代多模态模型的运作原理。