CLIP 入门 + 图文检索

pip install git+https://github.com/openai/CLIP.git

CLIP(Contrastive Language-Image Pretraining)使用了**对比学习(Contrastive Learning)**的方式来训练图像和文本的匹配关系,其核心思想是让正确的图像-文本对在特征空间中靠近,而错误的对则远离。

1. CLIP整体框架

CLIP 的目标是学习一个共享语义空间,使得匹配的图像和文本更接近,不匹配的更远。

给定一个 batch:

(Ii,Ti)i=1N {(I_i, T_i)}_{i=1}^N (Ii,Ti)i=1N


1.1 编码与归一化

  • 图像编码:

vi=fimage(Ii) v_i = f_{\text{image}}(I_i) vi=fimage(Ii)

  • 文本编码:

ti=ftext(Ti) t_i = f_{\text{text}}(T_i) ti=ftext(Ti)

  • L2 归一化:

vi←vi∣vi∣,ti←ti∣ti∣ v_i \leftarrow \frac{v_i}{|v_i|}, \quad t_i \leftarrow \frac{t_i}{|t_i|} vivivi,tititi

归一化后:

vi⊤tj=cos⁡(θi,j) v_i^\top t_j = \cos(\theta_{i,j}) vitj=cos(θi,j)

L2 normalize 后:

∥x∥=1∥y∥=1 ∥x∥=1∥y∥=1 x=1∥y=1

所有向量都被投到 单位球面

原本:

dot(x,y)=∥x∥∥y∥cos⁡θ dot(x,y)=∥x∥∥y∥cos⁡θ dot(x,y)=x∥∥ycosθ

归一化后:

dot(x,y)=cos⁡θ dot(x,y)=cos⁡θ dot(x,y)=cosθ

只剩“方向”,没有“长度”

如果不 normalize,会发生什么?

模型会“作弊”

把向量变得很大 → dot product 变大 → loss 变小

不需要学语义,只需要:

增大模长(norm explosion)

举个极端例子

x = [1, 0]
y = [1, 0]
dot = 1

x = [100, 0]
y = [100, 0]
dot = 10000   更大!

但语义完全一样

normalize 后的好处

✔ 防止“作弊”

→ 不能靠放大数值取胜

强制学“方向”(语义)

→ 谁和谁“更接近”

✔ 数值稳定

→ logits 范围可控(配合 temperature)


2. 相似度矩阵(logits)

构造相似度矩阵:

Si,j=vi⊤tj S_{i,j} = v_i^\top t_j Si,j=vitj

加入 temperature(缩放):

logits∗i,j=S∗i,jτ \text{logits}*{i,j} = \frac{S*{i,j}}{\tau} logitsi,j=τSi,j


3. 损失函数(InfoNCE / Cross Entropy)

CLIP 使用 双向对比损失(bidirectional contrastive loss)


3.1 Image → Text(I→T)

对每一行做 softmax(图像作为 query):

L∗i→t=−1N∑∗i=1Nlog⁡exp⁡(Si,i/τ)∑j=1Nexp⁡(Si,j/τ) \mathcal{L}*{i \to t} = - \frac{1}{N} \sum*{i=1}^{N} \log \frac{\exp(S_{i,i}/\tau)}{\sum_{j=1}^{N} \exp(S_{i,j}/\tau)} Lit=N1i=1Nlogj=1Nexp(Si,j/τ)exp(Si,i/τ)

含义:

  • 固定图像 ( I_i )
  • 在所有文本中找 ( T_i )

3.2 Text → Image(T→I)

对每一列做 softmax(文本作为 query):

L∗t→i=−1N∑∗j=1Nlog⁡exp⁡(Sj,j/τ)∑i=1Nexp⁡(Si,j/τ) \mathcal{L}*{t \to i} = - \frac{1}{N} \sum*{j=1}^{N} \log \frac{\exp(S_{j,j}/\tau)}{\sum_{i=1}^{N} \exp(S_{i,j}/\tau)} Lti=N1j=1Nlogi=1Nexp(Si,j/τ)exp(Sj,j/τ)

含义:

  • 固定文本 ( T_j )
  • 在所有图像中找 ( I_j )

3.3 总损失

L=12(L∗i→t+L∗t→i) \mathcal{L} = \frac{1}{2} \left( \mathcal{L}*{i \to t} + \mathcal{L}*{t \to i} \right) L=21(Lit+Lti)


4. 与 Cross Entropy 的等价性

令:

logits=S/τ \text{logits} = S / \tau logits=S/τ

标签为:

yi=i y_i = i yi=i

那么:

loss_i2t = F.cross_entropy(logits, labels)
loss_t2i = F.cross_entropy(logits.T, labels)

等价于上面的两个公式。


本质:

InfoNCE≡CrossEntropy \text{InfoNCE} \equiv \text{CrossEntropy} InfoNCECrossEntropy

只是视角不同:

视角含义
Cross Entropy多分类问题
InfoNCE正样本 vs 多个负样本

5. 为什么需要双向损失?

5.1 logits 是方阵,但语义不同

相似度矩阵:

S∈RN×N S \in \mathbb{R}^{N \times N} SRN×N

  • 第 i 行:图像 ( I_i ) vs 所有文本
  • 第 j 列:文本 ( T_j ) vs 所有图像

5.2 Softmax 归一化不同

I→T(行 softmax):

∑jexp⁡(Si,j/τ) \sum_{j} \exp(S_{i,j}/\tau) jexp(Si,j/τ)

T→I(列 softmax):

∑iexp⁡(Si,j/τ) \sum_{i} \exp(S_{i,j}/\tau) iexp(Si,j/τ)

分母不同 ⇒ loss 不等价


6. 单向 loss 的问题

如果只用 I→T:

L=Li→t \mathcal{L} = \mathcal{L}_{i \to t} L=Lit

可能出现:

  • 多个图像匹配同一个文本
  • embedding 空间塌缩(collapse)

7. 双向 loss 的作用

  1. 强制一一匹配

目标变为:

Ii↔Ti I_i \leftrightarrow T_i IiTi

而不是:

I1,I2→T3 I_1, I_2 \rightarrow T_3 I1,I2T3


  1. 提供双重约束
  • 行约束(image query)
  • 列约束(text query)

  1. 更稳定的梯度

每个样本被优化两次:

  • 一次作为 query
  • 一次作为 target

  1. 提升对称性

学习到:

image↔text \text{image} \leftrightarrow \text{text} imagetext

而不是单向检索能力


8. 直觉总结

CLIP 的训练可以理解为:

在一个 batch 内做一个 N-way matching problem


单向:

每个图找一个正确文本


双向:

图和文本必须互相选择对方

(类似“双向匹配 / stable matching”)


9. 总结

CLIP 的损失函数可以概括为:

  • 使用 cosine similarity + temperature scaling
  • 构造 N×N 相似度矩阵
  • 使用 InfoNCE(= CrossEntropy)
  • 采用 双向对比损失(I→T + T→I)

核心优势:

  1. 利用 batch 内样本作为负样本(高效)
  2. 双向约束,防止 collapse
  3. 学习统一的跨模态语义空间

CLIP loss实验

import torch
import torch.nn.functional as F


class CLIPLoss(torch.nn.Module):
    def __init__(self, temperature=0.07):
        super(CLIPLoss, self).__init__()
        if temperature <= 0:
            raise ValueError("temperature must be positive")
        self.temperature = temperature

    def forward(self, image_features, text_features):
        """
        计算 CLIP 的对比损失

        参数:
        - image_features: 形状为 (N, D) 的图像特征
        - text_features: 形状为 (N, D) 的文本特征

        返回:
        - CLIP 损失
        """

        if image_features.dim() != 2 or text_features.dim() != 2:
            raise ValueError("image_features 和 text_features 必须是二维张量 (N, D)")
        if image_features.shape != text_features.shape:
            raise ValueError("image_features 和 text_features 必须具有相同的形状")

        # 归一化特征(L2 归一化到单位球面)
        image_features = F.normalize(image_features, p=2, dim=-1)  # (N, D)
        text_features = F.normalize(text_features, p=2, dim=-1)  # (N, D)

        # 计算相似度矩阵(余弦相似度)
        logits = (image_features @ text_features.T) / self.temperature  # (N, N)

        # 目标标签:对角线上的才是匹配的
        labels = torch.arange(logits.shape[0], device=logits.device)

        # 计算交叉熵损失(分别计算 I->T 和 T->I)
        loss_i2t = F.cross_entropy(logits, labels)
        loss_t2i = F.cross_entropy(logits.T, labels)
        
        # 这里 logits.shape = [16, 16] labels.shape = [16]
        # 可以这样理解:
        # logits[i]:第i个样本,对16个类别的“打分”
        # labels[i]:第i个样本的正确类别(0~15)
        # 也就是说:这是一个16分类问题,每个batch有16个样本
        # pytorch中的F.cross_entropy会先对logits[i]中的16个值做softmax,得到每个类别的概率分布
        # 再根据labels[i]指定的正确类别,计算交叉熵损失
        # 其实就是计算-log(p(correct_class_probability)),也就是正确类别的概率越大,损失越小

        return (loss_i2t + loss_t2i) / 2


if __name__ == '__main__':
    # 调试代码:使用随机图像/文本向量验证 CLIP 损失计算流程
    torch.manual_seed(123)

    batch_size = 16
    dim = 512
    image_features = torch.randn(batch_size, dim)
    text_features = torch.randn(batch_size, dim)

    clip_loss = CLIPLoss(temperature=0.07)
    loss_value = clip_loss(image_features, text_features)

    print(f"CLIP loss: {loss_value.item():.6f}")
 

动手写一个CLIP的前向推理

import torch
from PIL import Image
from transformers import CLIPModel, CLIPProcessor

# 1. 加载模型
model = CLIPModel.from_pretrained("models/clip-vit-base-patch16")
processor = CLIPProcessor.from_pretrained("models/clip-vit-base-patch16")

# 2. 准备数据
url = "week04_clip_retrieval/code/000000039769.jpg"
image = Image.open(url)

labels = ["a photo of a cat", "a photo of a dog", "a photo of a car"]

# 3. 处理输入
inputs = processor(text=labels, images=image, return_tensors="pt", padding=True)

# 4. 手动前向推理
with torch.no_grad():
    # 图像特征[1,512]
    image_features = model.get_image_features(pixel_values=inputs["pixel_values"])
    
    # 文本特征[3,512]
    text_features = model.get_text_features(
        input_ids=inputs["input_ids"],
        attention_mask=inputs["attention_mask"]
    )

# 5. 归一化(CLIP 的关键步骤)
image_features = image_features / image_features.norm(dim=-1, keepdim=True)
text_features = text_features / text_features.norm(dim=-1, keepdim=True)

# 6. 手动计算相似度(cosine similarity)[1,3]
logits = image_features @ text_features.T   # (1, num_labels)

# CLIP 默认有一个 temperature scaling
logit_scale = model.logit_scale.exp()
logits = logits * logit_scale

# 7. softmax 得到概率
probs = logits.softmax(dim=1)

# 8. 选最大
most_likely_idx = probs.argmax(dim=1).item()
most_likely_label = labels[most_likely_idx]

print(f"Most likely label: {most_likely_label} with probability: {probs[0][most_likely_idx].item():.3f}")

CLIP做检索和零样本分类

week04_clip_retrieval/code/clip_simple_image_retrieval.py
week04_clip_retrieval/code/clip_zero_shot_classification.py

代码示例如上,clip做图像检索和零样本分类时,最大的区别是:

  • 在做图像检索时是用text和image_features矩阵的转至计算相似度,用文字检索图像。
  • 而在做零样本分类时,实际上是在用图片检索文本,计算的是image_feature和texts转至的相似度。

传统分类模型使用固定类别的分类头,对图像进行特征提取后,由分类头进行输出,并经过softmax转换成对应类别的概率。与clip的检索方式相比类别固定不变,若要增加类别需重新训练分类头。

如果你对多模态大模型感兴趣,或者也在准备往大模型方向转,可以:

  • 先收藏 / Fork 我的仓库: https://github.com/wz940216/From0to1-MLLM-StudyLog.git
  • 在评论区留个「一起学」, 后面我会继续把每周的学习记录、踩坑经验(包括机械狗视角下的弯路)陆续更新到仓库和这里。

希望半年、一年之后,我们都能回头看到一条清晰的成长曲线,而不是“当时也想学来着”的遗憾。

更多推荐