跳过正文
  1. 文章/

多模态模型是如何处理和理解图片的?

·6721 字·14 分钟
AI 多模态 机器学习 ViT CLIP 视觉编码
Weaxs
作者
Weaxs

引言
#

2023 年 3 月份,OPENAI 发布 GPT-4 多模态模型。”多模态” 这个词第一次走进大众的视野。不过彼时 AI 应用大多还是以对话类产品为主,而且开源模型在支持“多模态”功能上也相对比较滞后。

等到下半年,一些研究机构和开源模型厂商开始陆陆续续发布了一些开源的多模态模型:

  • 2023 年 4 月 17 日微软研究院、威斯康星大学和哥伦比亚大学的研究院基于开源模型 Vicuna (基于 LLaMA 微调) 和 OPENAI 开源的 CLIP 作为视觉编码器训练了 LLaVA
  • 2023 年 8 月 24 日阿里发布了基于 Qwen 大语言模型 和 ViT 的 Qwen-VL

至于 LLaMA 和 DeepSeek,直到 2024 年才发布了官方的多模态开源模型

本人旨在从小白视角,简单理解一下多模态的实现方式。

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

我们先从论文开始看起

VsionTransformer.png

简单地讲,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),可以理解为感受视野

ViT-L.png

除此之外,团队还将模型的 token 输出和实际的输入做了一些对比:

ViT-In&Out.png

上面的一些实验可以发现,注意力距离随着网络深度的增加而增加,同时研究者们发现 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.png

CLIP 分为有两个比较重要的点:

  1. 文本编码器 (text embedding):这里直接使用的有 63M 参数 12 层的 Transformer
  2. 图像编码器 (image embedding):① 使用境地昂的 ResNet-50 作为图像编码器 ② 使用我们上面提到过的 Vision Transformer 作为图像编码器,在原始的 vit 上做了一个小修改,就是在patch 和 position 位置嵌入添加了层归一化

下面我们展开说说具体的步骤

  1. 首先根据文本编码器 (text embedding) 和图像编码器 (image embedding) 计算各自的特征嵌入
  2. 然后计算嵌入的余弦相似性 (cosine),并使用 temperature 参数进行缩放
  3. 使用 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 模型预训练过程中提出的一个优化思路。主要是优化在计算损失函数的时候。

这部分相对枯燥且抽象一些,直接贴一些代码来解释。

我们直接看 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 。

Qwen-VL.png

下面贴一下这三个组件相关的代码:

## 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.png

其二,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 来确定

M-RoPE.png

最后就是一些优化点,比如

  • 默认是以每秒 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 格式表示整体的文档布局、文本、表格、插图等等内容。

其他的内容就主要是在训练相关的了,这里就不提了。

Qwen2.5-VL.jpeg

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,这里用双层也是因为混合视觉编码器的原因,所以采用双层分别处理高分辨率特征和低分辨率特征。

DeepSeek-VL.png

重点需要将的是 混合视觉编码器

相较于 Qwen 使用 ViT 作为编码器,DeepSeek-VL 使用了 SigLIP (siglip_large_patch16_384) + SAM-B (sam_b_downsample) 的混合视觉编码器。

  • 使用 Meta 开源的 SAM-B 编码器接收高分辨率的图像输入,更好地保留图像的细节信息 (参考论文 [2023.04] Segment Anything)
  • 保留带有低分辨率图像输入的 SigLIP-L 编码器,保留语义内容
这里 DeepSeek 团队在论文里说明 CLIP 系列中的编码器主要用于语义视觉表示,但是存在编码模糊的问题,导致存在盲区 (参考论文 Eyes Wide Shut? Exploring the Visual Shortcomings of Multimodal LLMs);同时,CLIP 模型受限于相对低分辨率的输入。

下面简单讲讲这两种编码器的处理过程,首先进入编码器之前,会将图片 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-VL2.png

相比 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 来处理。

还有一点需要处理,这种动态处理图片的方式会相当消耗 token,所以为了提升效率和上下文长度限制,在处理多个 (> 2) 个图像时会禁用动态平铺策略。

DeepSeek-VL2 Image Encoder.png

下面我们看看相关的代码


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…

Qwen2.5-VL Technical Report

DeepSeek-VL: Towards Real-World Vision-Language Understanding

https://github.com/deepseek-ai/DeepSeek-VL

DeepSeek-VL2: Mixture-of-Experts Vision-Language Models for…

https://github.com/deepseek-ai/DeepSeek-VL2

相关文章

浅谈 DeepSeek-R1 和 Kimi k1.5 论文中的思维链 + 强化学习
·2588 字·6 分钟
AI LLM CoT 强化学习 DeepSeek Kimi 模型蒸馏 思维链
浅谈 DeepSeek-R1 和 Kimi k1.5 两个模型在推理能力上的技术特点:DeepSeek 采用 GRPO 算法和模型蒸馏提升推理表现,Kimi 则探索长文本思维链和强化学习的结合方案。
使用 TiDB Vector 构建 LightRAG 知识库
·2505 字·5 分钟
RAG LLM AI TiDB 工程实践
梳理了 LightRAG 之后,发现 LightRAG 对持久化支持的还不够多,缺少了最重要的 TiDB (不是)。故抽空贡献之,顺便写个软文。
从论文到源码:详解 RAG 算法
·11763 字·24 分钟
RAG LLM AI 论文笔记 算法原理
本文旨在通过论文+源码的解读,探究 RAG 算法的架构设计和具体的代码实现。本文主要讨论了 GraphRAG、LightRAG 和 RAPTOR RAG,除此之外还提及了 Anthropic 提出的 Contextual Retrieval 上下文检索和 RAG 算法的评估方法。最后在实践中,建议还是根据知识库文档的规模来选择不同的方法。