引言 #
2023 年 3 月份,OPENAI 发布 GPT-4 多模态模型。”多模态” 这个词第一次走进大众的视野。不过彼时 AI 应用大多还是以对话类产品为主,而且开源模型在支持“多模态”功能上也相对比较滞后。
等到下半年,一些研究机构和开源模型厂商开始陆陆续续发布了一些开源的多模态模型:
- 2023 年 4 月 17 日微软研究院、威斯康星大学和哥伦比亚大学的研究院基于开源模型 Vicuna (基于 LLaMA 微调) 和 OPENAI 开源的 CLIP 作为视觉编码器训练了 LLaVA
- 2023 年 8 月 24 日阿里发布了基于 Qwen 大语言模型 和 ViT 的 Qwen-VL
至于 LLaMA 和 DeepSeek,直到 2024 年才发布了官方的多模态开源模型
- 2024 年 3 月 14 日 DeepSeek 发布了基于 DeepSeek-VL
- 2024 年 9 月 25 日 Meta 发布了 LLaMA-3.2-Vision(Llama 3.2: Revolutionizing edge AI and vision with open, customizable models)
本人旨在从小白视角,简单理解一下多模态的实现方式。
Vision Transformer (ViT) #
Vision Transformer 最早是由 Google 研究院中的 Google 大脑团队在 2020 年 10 月提出的,论文名叫 An Image is Worth 16x16 Words: Transformers for Image Recognition at Scale 。
具体的代码库在 google-research/vision_transformer。
我们先从论文开始看起
简单地讲,ViT 会把图片分割成固定大小的块,然后对每一个块进行线性嵌入 (embedding),并添加对应的位置嵌入,输入给标准的 Transformer 架构中。
例如一张分辨率为 (\(H,W)\) 的原始图像,将图片切分成分辨率为 (\(P,P)\) 的切片 (patch)。那么切片的数量就是 (\(N = HW/P^2)\),用作 Transformer 的有效输入的序列长度。假设 Transformer 中所有层的潜在向量大小是 (\(D)\) ,那么这些切片会被一个线性投影映射到 (\(D)\) 维度。当然,这个线性投影是可以训练的。
## https://github.com/google-research/vision_transformer/blob/main/vit_jax/models_vit.py
class VisionTransformer(nn.Module):
"""VisionTransformer."""
transformer: Any
encoder: Type[nn.Module] = Encoder
...
@nn.compact
def __call__(self, inputs, *, train):
x = inputs
...
## We can merge s2d+emb into a single conv; it's the same.
x = nn.Conv(
features=self.hidden_size,
kernel_size=self.patches.size,
strides=self.patches.size,
padding='VALID',
name='embedding')(
x)
## Here, x is a grid of embeddings.
## (Possibly partial) Transformer.
if self.transformer is not None:
n, h, w, c = x.shape
x = jnp.reshape(x, [n, h * w, c])
...
x = self.encoder(name='Transformer', **self.transformer)(x, train=train)
...
class Encoder(nn.Module):
"""Transformer Model Encoder for sequence to sequence translation.
Attributes:
num_layers: number of layers
mlp_dim: dimension of the mlp on top of attention block
num_heads: Number of heads in nn.MultiHeadDotProductAttention
dropout_rate: dropout rate.
attention_dropout_rate: dropout rate in self attention.
"""
num_layers: int
mlp_dim: int
num_heads: int
dropout_rate: float = 0.1
attention_dropout_rate: float = 0.1
add_position_embedding: bool = True
@nn.compact
def __call__(self, x, *, train):
assert x.ndim == 3 ## (batch, len, emb)
## Position Encoder
if self.add_position_embedding:
x = AddPositionEmbs(
posemb_init=nn.initializers.normal(stddev=0.02), ## from BERT.
name='posembed_input')(
x)
x = nn.Dropout(rate=self.dropout_rate)(x, deterministic=not train)
## Input Encoder
for lyr in range(self.num_layers):
x = Encoder1DBlock(
mlp_dim=self.mlp_dim,
dropout_rate=self.dropout_rate,
attention_dropout_rate=self.attention_dropout_rate,
name=f'encoderblock_{lyr}',
num_heads=self.num_heads)(
x, deterministic=not train)
encoded = nn.LayerNorm(name='encoder_norm')(x)
return encoded
到目前为止其实还是很难理解 ViT 模型是如何理解图片的,个人认为 ViT 模型的主要目的是 最大限度的将图片信息 encode,以保证将 token 传递给 Transformer 的时候可以更好地理解图片
基于这个目标,我们来看看 ViT 是如何处理图像的,下面以 ViT-L/32 (基于 BERT-Large 搭建的 32 x 32 切分的模型)为例
- 左图是 图片经过 RGB 色块经过 ViT-L/32 的线性投影之后的滤波器
- 中图是对 patch 切片的位置嵌入
- 左图是根据注意力权重计算出的图像空间中整合信息的平均距离,即平均注意力距离 (mean attention distance),可以理解为感受视野
除此之外,团队还将模型的 token 输出和实际的输入做了一些对比:
上面的一些实验可以发现,注意力距离随着网络深度的增加而增加,同时研究者们发现 ViT 模型更关于与分类语义相关的图像区域。
图文对齐 #
前面我们介绍了 ViT 如何把图片转换成 token, 那么下一个问题就是,模型如何理解和学习这些 token ?当用户同时输入图文的时候,模型又如何将图片和文字结合起来理解?
所以这部分介绍多模态模型中如何对其图像和文本对应的 toekn,以及训练相关的,以此基础来实现多模态。
OPENAI CLIP #
CLIP 是 OPENAI 在 21 年提出的一个视觉模型,相关的论文名是 Learning Transferable Visual Models From Natural Language Supervision。OPENAI 官方开源的代码库是 openai/CLIP,后续由社区牵头了一个 mlfoundations/open_clip 的项目,支持了更多数据集、vit 的架构等等。
CLIP 在训练任务中是用来判断 文本 和 图片的匹配度。当训练的数据量足够大之后,在实际生成的过程中,模型会预测与图像更相关的文本,以此来做图片的分类和文本的不全。
CLIP 分为有两个比较重要的点:
- 文本编码器 (text embedding):这里直接使用的有 63M 参数 12 层的 Transformer
- 图像编码器 (image embedding):① 使用境地昂的 ResNet-50 作为图像编码器 ② 使用我们上面提到过的 Vision Transformer 作为图像编码器,在原始的 vit 上做了一个小修改,就是在patch 和 position 位置嵌入添加了层归一化
下面我们展开说说具体的步骤
- 首先根据文本编码器 (text embedding) 和图像编码器 (image embedding) 计算各自的特征嵌入
- 然后计算嵌入的余弦相似性 (cosine),并使用 temperature 参数进行缩放
- 使用 softmax 归一化为概率分布,以此检测文本和图像的匹配度
通俗的讲,图像编码器的工作类似传统 CV 模型,输出图像的特征表示。假设模型学习了 N 分类,文本编码器产生的特征表示会作为”分类器”的权重,以此来计算最相近的分类,用于作为最终的输出。
class CLIP(nn.Module):
def __init__(self,
embed_dim: int,
## vision
image_resolution: int,
vision_layers: Union[Tuple[int, int, int, int], int],
vision_width: int,
vision_patch_size: int,
## text
context_length: int,
vocab_size: int,
transformer_width: int,
transformer_heads: int,
transformer_layers: int
):
super().__init__()
self.context_length = context_length
## image encoder
if isinstance(vision_layers, (tuple, list)):
vision_heads = vision_width * 32 // 64
self.visual = ModifiedResNet(
layers=vision_layers,
output_dim=embed_dim,
heads=vision_heads,
input_resolution=image_resolution,
width=vision_width
)
else:
vision_heads = vision_width // 64
self.visual = VisionTransformer(
input_resolution=image_resolution,
patch_size=vision_patch_size,
width=vision_width,
layers=vision_layers,
heads=vision_heads,
output_dim=embed_dim
)
## text encoder
self.transformer = Transformer(
width=transformer_width,
layers=transformer_layers,
heads=transformer_heads,
attn_mask=self.build_attention_mask()
)
def encode_image(self, image):
return self.visual(image.type(self.dtype))
def encode_text(self, text):
x = self.token_embedding(text).type(self.dtype) ## [batch_size, n_ctx, d_model]
x = x + self.positional_embedding.type(self.dtype)
x = x.permute(1, 0, 2) ## NLD -> LND
x = self.transformer(x)
x = x.permute(1, 0, 2) ## LND -> NLD
x = self.ln_final(x).type(self.dtype)
## x.shape = [batch_size, n_ctx, transformer.width]
## take features from the eot embedding (eot_token is the highest number in each sequence)
x = x[torch.arange(x.shape[0]), text.argmax(dim=-1)] @ self.text_projection
return x
def forward(self, image, text):
image_features = self.encode_image(image)
text_features = self.encode_text(text)
## normalized features
image_features = image_features / image_features.norm(dim=1, keepdim=True)
text_features = text_features / text_features.norm(dim=1, keepdim=True)
## cosine similarity as logits
logit_scale = self.logit_scale.exp()
logits_per_image = logit_scale * image_features @ text_features.t()
logits_per_text = logits_per_image.t()
## shape = [global_batch_size, global_batch_size]
return logits_per_image, logits_per_text
SigLIP & SigLIT #
Google 在 23 年 3 月提出了 Sigmoid Loss for Language Image Pre-Training,主要是针对 CLIP 模型预训练过程中提出的一个优化思路。主要是优化在计算损失函数的时候。
- Sigmoid 加上 OPENAI 提出的 CLIP 被命名为 SigLIP
- Sigmoid 加上 Google 基于 CLIP 优化的 LIT 被命名为 SigLiT (LIT 本文没有做介绍,感兴趣的可以看 LiT: Zero-Shot Transfer with Locked-image text Tuning
这部分相对枯燥且抽象一些,直接贴一些代码来解释。
我们直接看 mlfoundations/open_clip 项目中对损失函数的定义
先看看 CLIP 本身的损失函数,使用的 cross_entropy
函数来计算对比损失,使用 softmax
来计算蒸馏损失
class DistillClipLoss(ClipLoss):
def dist_loss(self, teacher_logits, student_logits):
return -(teacher_logits.softmax(dim=1) * student_logits.log_softmax(dim=1)).sum(dim=1).mean(dim=0)
def forward(
self,
image_features,
text_features,
logit_scale,
dist_image_features,
dist_text_features,
dist_logit_scale,
output_dict=False,
):
logits_per_image, logits_per_text = \
self.get_logits(image_features, text_features, logit_scale)
dist_logits_per_image, dist_logits_per_text = \
self.get_logits(dist_image_features, dist_text_features, dist_logit_scale)
labels = self.get_ground_truth(image_features.device, logits_per_image.shape[0])
contrastive_loss = (
## cross entroy
F.cross_entropy(logits_per_image, labels) +
F.cross_entropy(logits_per_text, labels)
) / 2
distill_loss = (
## softmax
self.dist_loss(dist_logits_per_image, logits_per_image) +
self.dist_loss(dist_logits_per_text, logits_per_text)
) / 2
if output_dict:
return {"contrastive_loss": contrastive_loss, "distill_loss": distill_loss}
return contrastive_loss, distill_loss
下面看看 Sigmoid 损失是如何计算的,比较核心的是计算 loss 的时候使用的 logsigmoid
输出概率对数
class SigLipLoss(nn.Module):
""" Sigmoid Loss for Language Image Pre-Training (SigLIP) - https://arxiv.org/abs/2303.15343
@article{zhai2023sigmoid,
title={Sigmoid loss for language image pre-training},
author={Zhai, Xiaohua and Mustafa, Basil and Kolesnikov, Alexander and Beyer, Lucas},
journal={arXiv preprint arXiv:2303.15343},
year={2023}
}
"""
def __init__(
self,
cache_labels: bool = False,
rank: int = 0,
world_size: int = 1,
dist_impl: Optional[str] = None,
):
super().__init__()
self.cache_labels = cache_labels
self.rank = rank
self.world_size = world_size
self.dist_impl = dist_impl or 'bidir' ## default to bidir exchange for now, this will likely change
assert self.dist_impl in ('bidir', 'shift', 'reduce', 'gather')
## cache state FIXME cache not currently used, worthwhile?
self.prev_num_logits = 0
self.labels = {}
def get_ground_truth(self, device, dtype, num_logits, negative_only=False) -> torch.Tensor:
labels = -torch.ones((num_logits, num_logits), device=device, dtype=dtype)
if not negative_only:
labels = 2 * torch.eye(num_logits, device=device, dtype=dtype) + labels
return labels
def get_logits(self, image_features, text_features, logit_scale, logit_bias=None):
logits = logit_scale * image_features @ text_features.T
if logit_bias is not None:
logits += logit_bias
return logits
def _loss(self, image_features, text_features, logit_scale, logit_bias=None, negative_only=False):
logits = self.get_logits(image_features, text_features, logit_scale, logit_bias)
labels = self.get_ground_truth(
image_features.device,
image_features.dtype,
image_features.shape[0],
negative_only=negative_only,
)
## logsigmoid
loss = -F.logsigmoid(labels * logits).sum() / image_features.shape[0]
return loss
def forward(self, image_features, text_features, logit_scale, logit_bias, output_dict=False):
loss = self._loss(image_features, text_features, logit_scale, logit_bias)
if self.world_size > 1:
if self.dist_impl == 'bidir':
right_rank = (self.rank + 1) % self.world_size
left_rank = (self.rank - 1 + self.world_size) % self.world_size
text_features_to_right = text_features_to_left = text_features
num_bidir, remainder = divmod(self.world_size - 1, 2)
for i in range(num_bidir):
text_features_recv = neighbour_exchange_bidir_with_grad(
left_rank,
right_rank,
text_features_to_left,
text_features_to_right,
)
for f in text_features_recv:
loss += self._loss(
image_features,
f,
logit_scale,
logit_bias,
negative_only=True,
)
text_features_to_left, text_features_to_right = text_features_recv
if remainder:
text_features_recv = neighbour_exchange_with_grad(
left_rank,
right_rank,
text_features_to_right
)
loss += self._loss(
image_features,
text_features_recv,
logit_scale,
logit_bias,
negative_only=True,
)
elif self.dist_impl == "shift":
right_rank = (self.rank + 1) % self.world_size
left_rank = (self.rank - 1 + self.world_size) % self.world_size
text_features_to_right = text_features
for i in range(self.world_size - 1):
text_features_from_left = neighbour_exchange_with_grad(
left_rank,
right_rank,
text_features_to_right,
)
loss += self._loss(
image_features,
text_features_from_left,
logit_scale,
logit_bias,
negative_only=True,
)
text_features_to_right = text_features_from_left
elif self.dist_impl == "reduce":
for i in range(self.world_size):
text_from_other = torch.distributed.nn.all_reduce(
text_features * (self.rank == i),
torch.distributed.ReduceOp.SUM,
)
loss += float(i != self.rank) * self._loss(
image_features,
text_from_other,
logit_scale,
logit_bias,
negative_only=True,
)
elif self.dist_impl == "gather":
all_text = torch.distributed.nn.all_gather(text_features)
for i in range(self.world_size):
loss += float(i != self.rank) * self._loss(
image_features,
all_text[i],
logit_scale,
logit_bias,
negative_only=True,
)
else:
assert False
return {"contrastive_loss": loss} if output_dict else loss
多模态模型 #
Qwen Vision #
Qwen-VL #
第一版的 Qwen 多模态模型是在 23 年 8 月提出的,具体的论文是 Qwen-VL: A Versatile Vision-Language Model for Understanding, Localization, Text Reading, and Beyond,模型权重在 [HuggingFace] Qwen/Qwen-VL,微调和训练相关的官方代码在 [Github] QwenLM/Qwen-VL。
首先需要了解,Qwen-VL 的大体结构是怎么样,这在论文开头就有提及。
Qwen-VL 整体架构由三个组件组成:
- 大语言模型 (Large Language Model):采用 Qwen-7B 作为基础组件,基于 Qwen-7B 预训练的权重初始化。
- 视觉编码器 (Vision Encoder):使用的 Vision Transformer (ViT) 架构,具体使用的是基于 openclip 的 ViT-bigG-14,将图像分割成 14*14 的块,以此生成图像特征。(Qwen-VL 的视觉编码器的输入只支持到了 448 * 448)
- 位置感知视觉语言适配器 (Position-aware Vision-Language Adapter):为缓解长图像特征带来的效率问题,可以使用视觉语言适配器来压缩图像特征。这是一个单层的交叉注意力 (cross-attention) 模块来做的,可以将视觉特征序列压缩到 256 。
下面贴一下这三个组件相关的代码:
## https://huggingface.co/Qwen/Qwen-VL/blob/main/modeling_qwen.py
class QWenModel(QWenPreTrainedModel):
...
def __init__(self, config):
super().__init__(config)
...
## large language model
self.h = nn.ModuleList(
[
QWenBlock(
config
)
for i in range(config.num_hidden_layers)
]
)
self.ln_f = RMSNorm(
self.embed_dim,
eps=config.layer_norm_epsilon,
)
## vision model
self.visual = VisionTransformer(**config.visual)
self.post_init()
## https://huggingface.co/Qwen/Qwen-VL/blob/main/visual.py
class VisionTransformer(nn.Module):
def __init__(
self,
image_size: int,
patch_size: int,
width: int,
layers: int,
heads: int,
mlp_ratio: float,
n_queries: int = 256,
output_dim: int = 512,
**kwargs
):
...
self.transformer = TransformerBlock(
width,
layers,
heads,
mlp_ratio,
act_layer=act_layer,
norm_layer=norm_layer,
)
## **Position-aware Vision-Language Adapter**
self.attn_pool = Resampler(
grid_size=int(math.sqrt(n_queries)),
embed_dim=output_dim,
num_heads=output_dim // 128,
kv_dim=width,
norm_layer=norm_layer,
)
...
class Resampler(nn.Module):
"""
A 2D perceiver-resampler network with one cross attention layers by
(grid_size**2) learnable queries and 2d sincos pos_emb
Outputs:
A tensor with the shape of (grid_size**2, embed_dim)
"""
def __init__(
self,
grid_size,
embed_dim,
num_heads,
kv_dim=None,
norm_layer=nn.LayerNorm
):
...
除此之外,在为了使模型更好的区分图像和文本,Qwen-VL 还在序列 token 中添加了一下特殊 token 标识:
- 针对图像输入,在图像特征序列的开头和结尾分别附加了
<img>
和</img>
标识代表图像内容的开头和结尾 - Qwen-VL 在训练期间涉及区域描述、问题和检测等等,针对这些内容,Qwen-VL 会在边界框的开头和结尾添加
<box>
和</box>
标识,同时这个 box 引用的一些内容添加了<ref>
和</ref>
标识
这部分的代码主要是在 QWenTokenizer
:
class QWenTokenizer(PreTrainedTokenizer):
"""QWen tokenizer."""
vocab_files_names = VOCAB_FILES_NAMES
def __init__(
self,
vocab_file,
errors="replace",
image_start_tag='<img>',
image_end_tag='</img>',
image_pad_tag='<imgpad>',
ref_start_tag='<ref>',
ref_end_tag='</ref>',
box_start_tag='<box>',
box_end_tag='</box>',
quad_start_tag='<quad>',
quad_end_tag='</quad>',
**kwargs,
):
...
self.img_start_id = self.special_tokens[self.image_start_tag]
self.img_end_id = self.special_tokens[self.image_end_tag]
self.img_pad_id = self.special_tokens[self.image_pad_tag]
self.ref_start_id = self.special_tokens[self.ref_start_tag]
self.ref_end_id = self.special_tokens[self.ref_end_tag]
self.box_start_id = self.special_tokens[self.box_start_tag]
self.box_end_id = self.special_tokens[self.box_end_tag]
self.quad_start_id = self.special_tokens[self.quad_start_tag]
self.quad_end_id = self.special_tokens[self.quad_end_tag]
...
def to_list_format(self, text: str):
...
def _encode_vl_info(tokens):
if len(tokens) == 0:
return []
if tokens[0] == self.img_start_id and tokens[-1] == self.img_end_id:
key = 'image'
elif tokens[0] == self.ref_start_id and tokens[-1] == self.ref_end_id:
key = 'ref'
elif tokens[0] == self.box_start_id and tokens[-1] == self.box_end_id:
key = 'box'
elif tokens[0] == self.quad_start_id and tokens[-1] == self.quad_end_id:
key = 'quad'
else:
_tobytes = lambda x: x.encode('utf-8') if isinstance(x, str) else x
return [{'text': b''.join(map(_tobytes, map(self.decoder.get, tokens))).decode('utf-8')}]
_tobytes = lambda x: x.encode('utf-8') if isinstance(x, str) else x
val = b''.join(map(_tobytes, map(self.decoder.get, tokens[1:-1]))).decode('utf-8')
return [{key: val}]
return _replace_closed_tag(
token_ids,
(self.img_start_id, self.ref_start_id, self.box_start_id, self.quad_start_id),
(self.img_end_id, self.ref_end_id, self.box_end_id, self.quad_end_id),
_encode_vl_info,
_encode_vl_info,
)
Qwen2-VL #
时隔一年,阿里在 24 年 9 月推出了 Qwen2-VL,对应的论文是Qwen2-VL: Enhancing Vision-Language Model’s Perception of the World at Any Resolution。模型地址是 [HuggingFace] Qwen2-VL
相比 Qwen-VL,Qwen2-VL 在整体架构上没有太大的场景,沿用了语言模型+视觉编码器的架构。除此之外,Qwen2-VL 支持了视频格式,并且优化了模型的感知,具体主要体现在下面几个点:
其一,Qwen2-VL 中支持了动态分辨率 (Naive Dynamic Resolution) ,基于此 Qwen2-VL 允许处理任何分辨率的图像。这部分实现是基于 2D 旋转位置嵌入的 Transformer (2D-RoPE, 2D Rotary Position Embedding) 来实现的,去掉原始图片的绝对位置,通过相对位置来获取图像的二位位置。
除此之外,在 ViT 之后还是用了一个 MLP 层将相邻的 2 x 2 tokens 压缩成单个 token,添加了 <|vision_start|>
和 <|vision_end|>
放置在压缩 token 的开头和结尾。比如分辨率为 224x224 的图像,使用 patch_size=14 的 ViT 编码,被切分为 16 * 16 也就是 256 个 token,然后压缩之后变成 64 个 token,再加上开始和结束标识共计 66 个 tokens。
其二,Qwen2-VL 引入了多模态旋转位置嵌入 (M-RoPE, Multimodal Rotary Position Embedding)。前面说到的 2D-RoPE 主要是用于图像的,M-RoPE 是作用于文本和图像的。M-RoPE 通过时间、高度、宽度来定义不同模态的旋转位置。
- 对于文本输入,使用相同的位置 id,使得 M-RoPE 的效果等同于 1D-RoPE
- 对于图像输入,用于表示时间的 id 保持不变,根据 patch 在图像中的相对位置分配高度和宽度位置,类似上面讲到的 2D-RoPE
- 对于视频输入,每个帧序列的时间 id 都递增,高度和宽度则是根据当前 patch 在当前帧所在的相对位置来分配
- 针对多个模态的输入,每个模态的位置编号是使用前一个模态最大位置 id 递增 1 来确定
最后就是一些优化点,比如
- 默认是以每秒 2 帧的速度来采样视频
- 每个视频的 token 总数限制在了 16384
Qwen2.5-VL #
Qwen2.5-VL 是阿里在 2025 年 2 月发布的,对应的论文是 Qwen2.5-VL Technical Report,代码库在 QwenLM/Qwen2.5-VL。
不过其实相隔 Qwen2-VL 也就半年左右,整体变化不是很大,主要是在一些细节上的变化。
- 大语言模型切换到 Qwen2.5
- 重新设计了 ViT,但沿用了 2D-RoPE 和窗口注意力机制
- 针对视频内容,采用动态帧率来采集视频。同时将 M-RoPE 中的时间 id,在 Qwen2-VL 中是自增的,改成了和时间戳对齐,以此来学习不同 FPS 采样下视频保持一致的时间对齐。
- Qwen2.5-VL 还支持了文档的解析,但并不是类似传统的分别取提取文档布局、文本、表格和插图,而是统一转换成 HTML 格式,用 HTML 格式表示整体的文档布局、文本、表格、插图等等内容。
其他的内容就主要是在训练相关的了,这里就不提了。
DeepSeek Vision #
DeepSeek-VL #
DeepSeek 发布多模态模型比 Qwen 晚了不少,大概是 2024 年 3 月发布的。
首次发布的 DeepSeek-VL 是基于 DeepSeek v1 版本的稠密模型架构的。具体论文是 DeepSeek-VL: Towards Real-World Vision-Language Understanding,对应的代码库在 deepseek-ai/DeepSeek-VL。
同样的,和 Qwen-VL 类似,也是主要由三个模块组成:大语言模型、视觉语言适配器和混合视觉编码器。
大语言模型就是基于 DeepSeek LLM 构建的,这里不用多说了;视觉语言编码器采用的是双层混合多层感知器 (MLP),用于连接视觉编码器和 LLM,这里用双层也是因为混合视觉编码器的原因,所以采用双层分别处理高分辨率特征和低分辨率特征。
重点需要将的是 混合视觉编码器。
相较于 Qwen 使用 ViT 作为编码器,DeepSeek-VL 使用了 SigLIP (siglip_large_patch16_384) + SAM-B (sam_b_downsample) 的混合视觉编码器。
- 使用 Meta 开源的 SAM-B 编码器接收高分辨率的图像输入,更好地保留图像的细节信息 (参考论文 [2023.04] Segment Anything)
- 保留带有低分辨率图像输入的 SigLIP-L 编码器,保留语义内容
下面简单讲讲这两种编码器的处理过程,首先进入编码器之前,会将图片 resize 调整到 1024 * 1024 分辨率的图像。
SAM-B 编码器会将1024 * 1024 的高分辨率图 生成 64 * 64 * 256 的特征图,这里的 256 可以理解为维度或者通道;VL 适配器会将它插值为 96 * 96 * 256 的特征图;再经过两个 stride
为 2 的卷积层的下采样,分别生成 48 * 48 * 512 和 24 * 24 * 1024 的特征图;最后这个 24 * 24 * 1024 的特征图会被 reshape 为 576 x 1024,即 576 token ,每个 token 1024 维度。
## https://github.com/deepseek-ai/DeepSeek-VL/blob/main/deepseek_vl/models/sam.py
def create_sam_vit(
model_name: str = "sam_b_downsample",
image_size: int = 1024,
ckpt_path: str = "",
**kwargs,
):
...
sam_cfg = SAMViTCfg(**SAM_MODEL_CONFIG[model_name])
image_encoder = ImageEncoderViT(
depth=sam_cfg.layers,
embed_dim=sam_cfg.width,
img_size=image_size,
mlp_ratio=4,
norm_layer=partial(torch.nn.LayerNorm, eps=1e-6),
num_heads=sam_cfg.heads,
patch_size=sam_cfg.patch_size,
qkv_bias=True,
use_rel_pos=True,
global_attn_indexes=sam_cfg.global_attn_indexes,
window_size=14,
out_chans=sam_cfg.prompt_embed_dim,
downsample_channels=sam_cfg.downsample_channels,
)
...
## This class and its supporting functions below lightly adapted from the ViTDet backbone available at: https://github.com/facebookresearch/detectron2/blob/main/detectron2/modeling/backbone/vit.py ## noqa
class ImageEncoderViT(nn.Module):
...
def forward(self, x: torch.Tensor) -> torch.Tensor:
## patch embedding
x = self.patch_embed(x)
if self.pos_embed is not None:
## position embedding
x = x + self.pos_embed
global_features = []
for i, blk in enumerate(self.blocks):
x = blk(x)
if self.sam_hd and blk.window_size == 0:
global_features.append(x)
x = self.neck(x.permute(0, 3, 1, 2))
x_dtype = x.dtype
## -> 96 * 96 * 256
x = F.interpolate(
x.float(), size=(96, 96), mode="bilinear", align_corners=False
).to(x_dtype)
## -> 24 * 24 * 1024
x = self.downsamples(x)
if self.sam_hd:
first_global_feature = self.neck_hd(global_features[0].permute(0, 3, 1, 2))
x_dtype = first_global_feature.dtype
first_global_feature = F.interpolate(
first_global_feature.float(),
size=(96, 96),
mode="bilinear",
align_corners=False,
)
first_global_feature = self.downsamples(first_global_feature.to(x_dtype))
x = x + first_global_feature * self.hd_alpha_downsamples
return x
下面是 SigLIP 编码器,DeepSeek-VL 使用的是 siglip_large_patch16_384,他的输入是 384 * 384,意味着需要将图片分辨率调整到 384 * 384,然后的 patch_size
是 16,即会分割成 576 个 16 * 16 的 patch,每个 patch 都是 1024 维度的即 576 * 1024。
## https://github.com/deepseek-ai/DeepSeek-VL/blob/main/deepseek_vl/models/siglip_vit.py
def create_siglip_vit(
model_name: str = "siglip_so400m_patch14_384",
image_size: int = 384,
select_layer: int = -1,
ckpt_path: str = "",
**kwargs,
):
...
vision_cfg = SigLIPVisionCfg(**SigLIP_MODEL_CONFIG[model_name])
model = VisionTransformer(
img_size=image_size,
patch_size=vision_cfg.patch_size,
embed_dim=vision_cfg.width,
depth=layers,
num_heads=vision_cfg.heads,
mlp_ratio=vision_cfg.mlp_ratio,
class_token=vision_cfg.class_token,
global_pool=vision_cfg.global_pool,
ignore_head=kwargs.get("ignore_head", True),
weight_init=kwargs.get("weight_init", "skip"),
num_classes=0,
)
通过上面的解释,最终图片经过 SAM-B 编码器后变成了 576 个 1024 维的 token;同样的,SigLIP 编码器也将图片变成了 576 个 1024 维的 token。这里我们最后看一下混合编码器相关的代码
## https://github.com/deepseek-ai/DeepSeek-VL/blob/main/deepseek_vl/models/clip_encoder.py
class CLIPVisionTower(nn.Module):
def build_vision_tower(self, vision_tower_params):
if self.model_name.startswith("siglip"):
self.select_feature = "same"
## siglip vit
vision_tower = create_siglip_vit(**vision_tower_params)
forward_kwargs = dict()
elif self.model_name.startswith("sam"):
## sam vit
vision_tower = create_sam_vit(**vision_tower_params)
forward_kwargs = dict()
else: ## huggingface
from transformers import CLIPVisionModel
vision_tower = CLIPVisionModel.from_pretrained(**vision_tower_params)
forward_kwargs = dict(output_hidden_states=True)
return vision_tower, forward_kwargs
class HybridVisionTower(nn.Module):
def __init__(
self,
high_res_cfg: Dict,
low_res_cfg: Dict,
freeze_high: bool = False,
freeze_low: bool = False,
concat_type: Literal["feature", "sequence", "add", "tuple"] = "tuple",
**ignore_kwargs,
):
super().__init__()
self.vision_tower_high = CLIPVisionTower(**high_res_cfg)
self.vision_tower_low = CLIPVisionTower(**low_res_cfg)
...
def forward(self, images: torch.Tensor):
"""
Args:
images (torch.Tensor): [bs, 3, H, W]
Returns:
res (torch.Tensor): [bs, t, c]
"""
## [bs, c, h, w]
high_images = images
## 1024 * 1024 -> 384 * 384
## [bs, c, h_low, w_low]
low_images = self.resize(images)
## separately run two vision towers
## run high_res vision tower
high_res = self.vision_tower_high(high_images)
## [bs, c, h, w] -> [bs, h*w, c]
high_res = rearrange(high_res, "b c h w -> b (h w) c")
## run low_res vision tower
low_res = self.vision_tower_low(low_images)
if self.concat_type == "feature":
images_features = torch.cat([high_res, low_res], dim=-1)
elif self.concat_type == "sequence":
images_features = torch.cat([high_res, low_res], dim=1)
elif self.concat_type == "add":
images_features = high_res + low_res
elif self.concat_type == "tuple":
images_features = (high_res, low_res)
else:
raise ValueError(
"Currently only support `feature`, `sequence`, `add` and `tuple` concat type."
)
return images_features
训练相关的部分本文这里不详细介绍了,感兴趣的可以去看看论文和代码。
DeepSeek-VL2 #
DeepSeek-VL2 采用的 DeepSeek-MoE 作为基础语言模型,所以这就让他有一个最大的变化:一个 MoE (混合专家) 的多模态模型。
除此之外,我们讲过 DeepSeek-VL 采用了混合编码器,即接收 1024 * 1024 的 SAM-B 编码器和接收 384 * 384 的 SigLIP 编码器; DeepSeek-VL2 引入了动态铺块的视觉编码策略,以此来处理不同长宽比的图像。
相比 DeepSeek-VL,其实 VL2 的方法确实简单一些,且只用了一个 SigLIP 编码器。具体使用的是 siglip_so400m_patch14_384
:输入支持 384 * 384,以 14 * 14 的 patch 分割,维度定义为 1152,即最终输出 27 * 27 = 729 个维度是 1152 的 token。
下面重点就是动态平铺策略,因为 SigLIP 是以 384 * 384 作为输入,所以为了使用不同的图片,预先定义了一组候选分辨率 (\(C_R={(m \times 384, n\times 384)| 1 \le m,n,mn \le 9 })\),其中 (\(m:n)\) 表示纵横比。将输入的图像调整到每个候选分辨率 (\(m_i:n_i)\),然后按照 384 * 384 进行分割,这样我们就得到了 (\(m_i \times n_i)\) 个 384 * 384 的区块,这些被称为 local views
。除此之外, DeepSeek-VL2 还会将图片完整的 resize 到 384 * 384 分辨率,以此来得到一个 全局缩略图块。最后我们就会得到 (\(1 + m_i \times n_i)\) 个图块,将这些都交给 siglip_so400m_patch14_384
来处理。
下面我们看看相关的代码
class DeepseekVLV2Processor(ProcessorMixin):
...
def tokenize_with_images(
self,
conversation: str,
images: List[Image.Image],
bos: bool = True,
eos: bool = True,
cropping: bool = True,
):
"""Tokenize text with <image> tags."""
...
images_list, images_seq_mask, images_spatial_crop = [], [], []
num_image_tokens = []
tokenized_str = []
for text_sep, image in zip(text_splits, images):
"""encode text_sep"""
tokenized_sep = self.encode(text_sep, bos=False, eos=False)
tokenized_str += tokenized_sep
images_seq_mask += [False] * len(tokenized_sep)
"""select best resolution for anyres"""
if cropping:
best_width, best_height = select_best_resolution(image.size, self.candidate_resolutions)
else:
best_width, best_height = self.image_size, self.image_size
## print(image.size, (best_width, best_height)) ## check the select_best_resolutions func
"""process the global view"""
global_view = ImageOps.pad(image, (self.image_size, self.image_size),
color=tuple(int(x * 255) for x in self.image_transform.mean))
images_list.append(self.image_transform(global_view))
"""process the local views"""
local_view = ImageOps.pad(image, (best_width, best_height),
color=tuple(int(x * 255) for x in self.image_transform.mean))
for i in range(0, best_height, self.image_size):
for j in range(0, best_width, self.image_size):
images_list.append(
self.image_transform(local_view.crop((j, i, j + self.image_size, i + self.image_size))))
"""record height / width crop num"""
num_width_tiles, num_height_tiles = best_width // self.image_size, best_height // self.image_size
images_spatial_crop.append([num_width_tiles, num_height_tiles])
"""add image tokens"""
h = w = math.ceil((self.image_size // self.patch_size) / self.downsample_ratio)
## global views tokens h * (w + 1), 1 is for line seperator
tokenized_image = [self.image_token_id] * h * (w + 1)
## add a seperator between global and local views
tokenized_image += [self.image_token_id]
## local views tokens, (num_height_tiles * h) * (num_width_tiles * w + 1)
tokenized_image += [self.image_token_id] * (num_height_tiles * h) * (num_width_tiles * w + 1)
tokenized_str += tokenized_image
images_seq_mask += [True] * len(tokenized_image)
num_image_tokens.append(len(tokenized_image))
## print(width_crop_num, height_crop_num, len(tokenized_image)) ## test the correctness of the number of image-related tokens
"""process the last text split"""
tokenized_sep = self.encode(text_splits[-1], bos=False, eos=False)
tokenized_str += tokenized_sep
images_seq_mask += [False] * len(tokenized_sep)
"""add the bos and eos tokens"""
if bos:
tokenized_str = [self.bos_id] + tokenized_str
images_seq_mask = [False] + images_seq_mask
if eos:
tokenized_str = tokenized_str + [self.eos_id]
images_seq_mask = images_seq_mask + [False]
assert len(tokenized_str) == len(
images_seq_mask), f"tokenize_with_images func: tokenized_str's length {len(tokenized_str)} is not equal to imags_seq_mask's length {len(images_seq_mask)}"
return tokenized_str, images_list, images_seq_mask, images_spatial_crop, num_image_tokens
相关 #
An Image is Worth 16x16 Words: Transformers for Image Recognition at Scale
Learning Transferable Visual Models From Natural Language Supervision
https://github.com/openai/CLIP
https://github.com/mlfoundations/open_clip
Sigmoid Loss for Language Image Pre-Training
Qwen-VL: A Versatile Vision-Language Model for Understanding,…
Qwen2-VL: Enhancing Vision-Language Model’s Perception of the…
DeepSeek-VL: Towards Real-World Vision-Language Understanding
https://github.com/deepseek-ai/DeepSeek-VL
DeepSeek-VL2: Mixture-of-Experts Vision-Language Models for…