查看全课大纲(第 34 / 40 节)
  1. 1.从规则到学习:深度学习解决什么问题
  2. 2.表示学习与端到端训练
  3. 3.张量、计算图与自动微分
  4. 4.训练三要素:数据、模型、目标函数
  5. 5.从感知机到多层感知机
  6. 6.反向传播:手推一个两层网络
  7. 7.激活函数与初始化
  8. 8.优化器:SGD → Momentum → Adam
  9. 9.正则化:权重衰减、Dropout、BN/LN
  10. 10.卷积的直觉与参数共享
  11. 11.池化、感受野与下采样
  12. 12.经典网络演进:LeNet → AlexNet → VGG → ResNet
  13. 13.残差连接与归一化为什么有效
  14. 14.迁移学习与特征可视化
  15. 15.RNN 与 BPTT
  16. 16.LSTM/GRU:门控如何缓解梯度问题
  17. 17.Seq2Seq 与编码器-解码器
  18. 18.注意力前史:Bahdanau 加性注意力
  19. 19.Self-Attention:QKV 与缩放点积
  20. 20.多头注意力:并行的多种关系
  21. 21.位置编码:正弦与可学习
  22. 22.残差、LayerNorm 与前馈层
  23. 23.从零实现一个最小 Transformer block
  24. 24.训练稳定性、复杂度与常见误区
  25. 25.自监督与语言建模目标
  26. 26.BERT:掩码语言模型与双向编码
  27. 27.GPT:自回归生成与缩放定律
  28. 28.指令微调与对齐:SFT → RLHF/DPO
  29. 29.参数高效微调:LoRA 与适配器
  30. 30.自编码器与 VAE
  31. 31.GAN:对抗训练与模式崩塌
  32. 32.扩散模型:前向加噪与反向去噪
  33. 33.扩散模型的应用与采样加速
  34. 34.对比学习与 CLIP
  35. 35.ViT:把图像当序列
  36. 36.多模态架构:对齐、融合与生成
  37. 37.能力边界、幻觉与评测
  38. 38.PyTorch 2.x 训练循环与混合精度
  39. 39.实验管理、评测与复现
  40. 40.推理部署与成本:量化/蒸馏概览

对比学习与 CLIP

小象实战讲义 · 深度学习基础:从神经网络到 Transformer

学完本节,你将能回答:为什么仅通过判断“图片和文字是否匹配”就能训练出强大的图像分类器?CLIP 如何实现“零样本”迁移,不用任何下游标注就能识别新类别?本节是第 8 章《多模态与前沿》的起点,会拆开三件事:双编码器结构、InfoNCE 对比损失、以及把分类改写成图文匹配的零样本流程。先修:第 5 章 Transformer 编码器、第 3 章 ResNet、交叉熵与余弦相似度。后续:下一节讲 ViT,把图像编码器本身也换成 Transformer。

💡 核心导读

  1. 从匹配任务到视觉理解:CLIP 的核心训练目标极其简单——判断一个批次中(图像,文本)对的正确匹配关系。我们将看到,这个看似简单的任务如何迫使模型学习到高质量的、可对齐的视觉和语言表示。
  2. 对比损失函数:我们将深入剖析 InfoNCE 损失函数,它是如何通过计算图像和文本特征之间的相似度矩阵,并鼓励正确配对相似度高于错误配对的。
  3. 零样本分类的“魔法”:训练完成后,CLIP 无需微调即可执行新任务。关键在于将分类任务转化为图文匹配任务:为每个类别生成描述性文本(如“一张狗的照片”),然后找出与待分类图像最匹配的文本。
  4. 规模化是关键:CLIP 论文明确指出,其成功依赖于在超大规模(4 亿对)数据上进行训练。我们将通过代码模拟,理解数据规模和模型容量对对比学习效果的根本性影响。

🧭 前置知识

  1. Transformer 编码器:了解其基本结构,特别是自注意力机制如何用于处理序列数据(第 5 章内容)。
  2. 残差网络(ResNet):了解其作为图像特征提取器的基本工作原理(第 3 章;ViT 见下一节)。
  3. 交叉熵损失与 softmax:理解多分类任务中损失函数的计算方式。
  4. 余弦相似度:理解如何用向量点积衡量两个向量的相似程度(不展开,见站内数学基础课)。

自然语言作为监督信号:范式的转变

在 CLIP 之前,主流的计算机视觉模型依赖于固定类别的监督学习,例如在 ImageNet 上训练模型区分 1000 种物体。这种范式存在局限:要识别新概念,必须收集并标注新的数据。Learning Transferable Visual Models From Natural Language Supervision(Radford et al., 2021,arXiv:2103.00020)提出了一种替代方案:直接从互联网上大量存在的(图像,文本)对中学习。

论文摘要开宗明义:直接从关于图像的原始文本中学习,是一种有前途的替代方案,它利用了更广泛的监督来源。这里的“文本”可以是图像的标题、描述、标签等。CLIP 证明,预测哪个标题与哪张图片对应这个简单的预训练任务,能从 4 亿对数据中学习到很强的图像表示。

这种监督被称为“自然语言监督”,它比传统的分类标签更丰富、更灵活,因为语言可以描述几乎无限的视觉概念。

CLIP 模型架构与训练机制

CLIP 采用双编码器架构,分别处理图像和文本,并通过对比学习目标对齐两个模态的特征空间。

图像编码器与文本编码器

图像编码器可以是 ResNet 或 Vision Transformer (ViT),文本编码器通常是一个 Transformer。它们的任务是将原始输入映射到一个共享的 d 维特征空间。

import torch
import torch.nn as nn
import torch.nn.functional as F

torch.manual_seed(0)

# 模拟 CLIP 的双编码器架构(极简版)
class SimpleCLIP(nn.Module):
    def __init__(self, embed_dim=8):
        super().__init__()
        # 模拟图像编码器:一个线性层(代表 ResNet/ViT 的最终投影头)
        self.image_encoder = nn.Linear(16, embed_dim)  # 假设图像特征维度为16
        # 模拟文本编码器:一个线性层(代表 Transformer 的 [EOS]  token 投影)
        self.text_encoder = nn.Linear(20, embed_dim)   # 假设文本特征维度为20
        # 共享的特征空间维度
        self.embed_dim = embed_dim

    def encode_image(self, image_features):
        # image_features: (batch, image_feat_dim)
        # 输出: (batch, embed_dim)
        return F.normalize(self.image_encoder(image_features), dim=-1)

    def encode_text(self, text_features):
        # text_features: (batch, text_feat_dim)
        # 输出: (batch, embed_dim)
        return F.normalize(self.text_encoder(text_features), dim=-1)

# 构造模拟数据
batch_size = 2
# 模拟经过主干网络提取的原始特征
image_feats = torch.randn(batch_size, 16)  # (2, 16)
text_feats = torch.randn(batch_size, 20)   # (2, 20)

model = SimpleCLIP(embed_dim=8)
image_embeddings = model.encode_image(image_feats)  # (2, 8)
text_embeddings = model.encode_text(text_feats)     # (2, 8)

print("图像嵌入 shape:", tuple(image_embeddings.shape))
print("文本嵌入 shape:", tuple(text_embeddings.shape))
print("图像嵌入范数(近似):", [round(x, 4) for x in image_embeddings.norm(dim=-1).tolist()])
图像嵌入 shape: (2, 8)
文本嵌入 shape: (2, 8)
图像嵌入范数(近似): [1.0, 1.0]

注意,编码器输出经过了 L2 归一化(F.normalize(..., dim=-1))。这意味着每个特征向量都被缩放到单位长度,此时向量间的点积就等于它们的余弦相似度,值域为 [-1, 1]。

对比损失函数:InfoNCE

CLIP 训练的核心是对比损失,具体来说是 InfoNCE 损失的一个变体。对于一个包含 N 个(图像,文本)对的批次,模型会计算所有图像嵌入和文本嵌入两两之间的相似度,得到一个 N x N 的相似度矩阵。对角线上的元素是正确配对的相似度,其余都是错误配对的相似度。损失函数的目标是最大化对角线上的相似度,同时最小化非对角线上的相似度。

import torch
import torch.nn.functional as F

torch.manual_seed(0)

def contrastive_loss(logits_per_image, logits_per_text):
    """
    对称的对比损失。
    logits_per_image: (N, N) 矩阵,logits_per_image[i, j] 是第 i 张图像与第 j 个文本的相似度。
    logits_per_text:  (N, N) 矩阵,logits_per_text[i, j] 是第 i 个文本与第 j 张图像的相似度。
    """
    labels = torch.arange(logits_per_image.size(0), device=logits_per_image.device)  # [0, 1, ..., N-1]
    # 图像分类损失:用图像特征去预测匹配的文本索引
    loss_i = F.cross_entropy(logits_per_image, labels)
    # 文本分类损失:用文本特征去预测匹配的图像索引
    loss_t = F.cross_entropy(logits_per_text, labels)
    return (loss_i + loss_t) / 2

# 模拟一个批次的图像和文本嵌入(已归一化)
N = 3  # 批次大小
d = 4  # 嵌入维度
image_emb = F.normalize(torch.randn(N, d), dim=-1)
text_emb = F.normalize(torch.randn(N, d), dim=-1)

# 计算相似度矩阵:余弦相似度 = 点积(因为向量已归一化)
logits_per_image = image_emb @ text_emb.T  # (N, N), 图像 i 与文本 j 的相似度
logits_per_text = logits_per_image.T       # (N, N), 文本 i 与图像 j 的相似度
labels = torch.arange(N)                   # 对角配对的下标 [0, 1, ..., N-1]

print("图像嵌入(前三行):")
for i in range(N):
    print(f"  图像{i}: {[round(v,4) for v in image_emb[i].tolist()]}")
print("\n文本嵌入(前三行):")
for i in range(N):
    print(f"  文本{i}: {[round(v,4) for v in text_emb[i].tolist()]}")
print(f"\n相似度矩阵 (logits_per_image):")
for row in logits_per_image:
    print(f"  {[round(v,4) for v in row.tolist()]}")
print(f"\n正确标签 (labels): {labels.tolist()}")
print(f"对比损失值: {contrastive_loss(logits_per_image, logits_per_text):.4f}")
图像嵌入(前三行):
  图像0: [0.5615, -0.1069, -0.7939, 0.2071]
  图像1: [-0.5424, -0.6995, 0.2017, 0.4192]
  图像2: [-0.6956, -0.3901, -0.577, 0.1761]

文本嵌入(前三行):
  文本0: [-0.486, 0.6243, -0.6076, 0.0696]
  文本1: [-0.3013, 0.1985, -0.4746, -0.8029]
  文本2: [0.1915, 0.7529, 0.4858, 0.4005]

相似度矩阵 (logits_per_image):
  [0.1572, 0.0201, -0.2757]
  [-0.2665, -0.4077, -0.3647]
  [0.4574, 0.2646, -0.6367]

正确标签 (labels): [0, 1, 2]
对比损失值: 1.3123

在这个例子中,对角线元素 [0.1572, -0.4077, -0.6367] 并非各行、各列的最大值——比如第一行最大的是 -0.2757(不在对角线上),所以损失值较高(1.3123,高于随机猜测水平 -log(1/3)≈1.0986)。训练过程就是通过调整编码器参数,使得对角线元素成为其所在行和列的最大值。

为什么这个目标能学到好表示? 一个 batch 里的 N 个样本对,对每张图来说另外 N-1 段文本就是天然的负样本。模型要把正确那段从 N-1 个干扰项里挑出来,就必须抓住“这张图和这段文字独有”的判别性特征,而不能只靠颜色、纹理这类捷径。这种“用 batch 内其他样本当负样本”的做法与对比预测编码(CPC)提出的 InfoNCE 一脉相承,InfoNCE 与互信息最大化有直接联系。batch 越大,负样本越充分,学到的表示越好——这也是 CLIP 要用超大 batch 的原因之一。

零样本预测:如何用文本描述进行分类

训练完成后,CLIP 如何识别它从未见过的类别?关键在于将分类任务重新表述为图文匹配任务。

  1. 构建文本提示:对于目标数据集的每个类别,将其名称放入一个模板中,例如“一张 {类别} 的照片”。论文发现,使用多个提示并集成(提示工程)能提升性能。
  2. 编码文本提示:用训练好的文本编码器将所有类别的提示文本编码成特征向量。
  3. 编码待分类图像:用图像编码器编码图像。
  4. 计算相似度并预测:计算图像特征与所有类别文本特征的余弦相似度,选择相似度最高的类别作为预测结果。

为什么提示模板这么重要? 直接把类别名丢进去会有歧义:boxer 既可能是拳击手也可能是拳师犬,单靠一个词,文本编码器抓不准你想表达的概念。套进“a photo of a {label}”这样的模板后,语义被锚定到“照片里的一类物体”,匹配质量明显提升。论文在 36 个数据集上统计,提示工程加多模板集成平均提升近 5 个点——相当于基线方法多用 4 倍算力换来的收益,而这一步在推理时是“免费”的。集成时通常把 80 个左右模板的文本嵌入取平均,再当作该类的代表向量。

import torch
import torch.nn.functional as F

torch.manual_seed(0)

# 模拟零样本分类流程
class_names = ["狗", "猫", "汽车", "鸟"]
# 1. 构建提示(这里简化,实际可使用多个模板)
prompts = [f"一张 {name} 的照片" for name in class_names]
# 假设 prompts 已被文本编码器处理,我们直接模拟其嵌入
num_classes = len(class_names)
d = 8
# 模拟训练好的文本编码器对各类别提示的编码结果
text_embeddings_for_classes = F.normalize(torch.randn(num_classes, d), dim=-1)  # (4, 8)

# 2. 待分类的图像(假设 batch_size=2)
image_embeddings_input = F.normalize(torch.randn(2, d), dim=-1)  # (2, 8)

# 3. 计算相似度:图像 (2,8) @ 文本.T (8,4) -> (2,4)
similarities = image_embeddings_input @ text_embeddings_for_classes.T  # (2, 4)

# 4. 预测:取相似度最高的类别索引
predictions = similarities.argmax(dim=-1)

print("类别文本嵌入(每行一个类):")
for i, name in enumerate(class_names):
    print(f"  {name}: {[round(v,4) for v in text_embeddings_for_classes[i].tolist()]}")
print(f"\n图像嵌入:")
for i in range(2):
    print(f"  图像{i}: {[round(v,4) for v in image_embeddings_input[i].tolist()]}")
print(f"\n相似度矩阵 (图像 x 类别):")
for i in range(2):
    print(f"  图像{i}: {[round(v,4) for v in similarities[i].tolist()]}")
print(f"\n预测类别索引: {predictions.tolist()}")
print(f"预测类别名称: {[class_names[idx] for idx in predictions.tolist()]}")
类别文本嵌入(每行一个类):
  狗: [-0.3835, -0.3925, -0.0853, -0.1478, 0.2891, 0.2357, -0.1076, -0.7205]
  猫: [0.1475, -0.5783, 0.1602, 0.1411, 0.0549, 0.5666, 0.5112, -0.1132]
  汽车: [-0.3907, -0.4899, 0.1637, 0.2292, 0.173, -0.4492, -0.0986, 0.5353]
  鸟: [0.2831, -0.2209, -0.0654, 0.0692, 0.5243, 0.5986, 0.3571, -0.3183]

图像嵌入:
  图像0: [-0.5021, 0.0259, -0.4032, 0.2033, 0.3598, 0.092, 0.5244, 0.361]
  图像1: [-0.0309, 0.2392, -0.0874, 0.0159, 0.1578, 0.695, -0.4434, -0.479]

相似度矩阵 (图像 x 类别):
  图像0: [-0.0041, 0.1741, 0.3266, 0.2086]
  图像1: [0.5253, 0.0753, -0.6134, 0.4381]

预测类别索引: [2, 0]
预测类别名称: ['汽车', '狗']

规模化与效率:CLIP 成功的关键

论文花了大量篇幅讨论规模化的重要性。早期类似方法(如 VirTex, ICMLM)性能不佳,一个关键原因是它们只在十几万张图像上训练。CLIP 则在 4 亿(image, text)对数据上训练,并证明了性能随着计算量、模型容量和数据规模的增加而平滑提升。

论文的图 2 给了效率对比:预测词袋(BoW)编码的基线已经比自回归 Transformer 语言模型快 3 倍,而把目标换成 CLIP 的对比目标后,效率再提升 4 倍。换句话说,对比目标比“用 Transformer 预测完整标题”快约 12 倍(3×4)。原因是对比学习只要求图像与文本在全局语义上对齐,不必逐词建模标题的细粒度序列细节。

训练细节:温度、投影头与数据增强

CLIP 的实现细节比想象中朴素:

  1. 温度 τ\tau 是可学习参数:softmax 前的 logits 要先乘 1/τ1/\tau,τ\tau 控制分布的陡峭程度。CLIP 把 log⁡τ\log \tau 当作可学习的乘性标量,随训练自动调整,不必手工当超参。
  2. 线性投影,不用非线性头:从编码器输出到多模态嵌入空间只有一个线性层。SimCLR 一类方法常用 MLP 投影头,CLIP 试过发现训练效率没有差别,就保持了最简形式。
  3. 数据增强只有随机方形裁剪:没有颜色抖动、翻转那一整套增强——4 亿对数据本身的多样性已经足够。

代价也要说清:双编码器把图和文各自压成一个向量,能算相似度,却做不了细粒度推理(比如“图左边那个杯子是什么颜色”),也不能生成描述。要处理这类任务,就得换成把两路 token 拼在一起做交叉注意力的融合式架构——这正是下一节之后要展开的内容。另一项代价是推理时要为每个类别算一次文本嵌入,类别多时成本线性增长,不过文本侧可以离线缓存,实际影响不大。

零样本到底有多强?与线性探针的比较

“零样本”听起来神奇,但要看清它实际站在哪:

  • 对 few-shot:零样本 CLIP 的平均表现约等于在同一特征空间上训练的 4-shot 线性分类器,接近最好的 16-shot 结果。
  • 对线性探针:多数数据集上,零样本比在 CLIP 特征上训练的线性探针低 10–25 个点,只有 5 个数据集差距在 3 个点以内。
  • 对整体 SOTA:零样本 CLIP 大致相当于 ResNet-50 特征的线性分类器水平,离各数据集的最优还有明显距离。论文估算还要约 1000 倍算力才可能追平,当前硬件不现实。

它不擅长什么

  • 细粒度分类:车型、花种、机型这种要靠细微差异区分的任务,CLIP 明显弱于专用模型。
  • 抽象任务与计数:数图里有几个物体,表现接近随机。
  • 真正的分布外数据:手写数字 MNIST 上只有 88% 准确率,输给“原始像素 + 逻辑回归”这种极简基线——因为预训练数据里几乎没有手写体。
  • 只能从给定类别里选:零样本分类的本质是图文匹配,它不能像图像描述模型那样生成新描述。

📝 动手练一练

问题:F.cross_entropy 内部已经算过一次 log_softmax。如果在把相似度矩阵交给它之前,先对 logits 手动做一次 softmax,会得到什么后果?请用代码比较正确写法与“先 softmax 再传”的损失值,并解释为什么后者的损失会偏小。

👉 点击查看参考答案

思路:F.cross_entropy 的输入应当是未归一化的 logits。先 softmax 会把每个值压到 (0,1),再送进 cross_entropy 等于对已归一化的分布又做一次 log_softmax,概率被二次压缩,损失整体偏小、梯度也变得平缓,模型学得更慢。CLIP 官方实现就是把 logits_per_image 直接传进去。

import torch
import torch.nn.functional as F

torch.manual_seed(0)

N, d = 3, 4
image_emb = F.normalize(torch.randn(N, d), dim=-1)
text_emb = F.normalize(torch.randn(N, d), dim=-1)
logits = image_emb @ text_emb.T      # 相似度矩阵(logits)
labels = torch.arange(N)

# 正确:直接把 logits 交给 cross_entropy(内部含 log_softmax)
loss_ok = F.cross_entropy(logits, labels)

# 错误:先手动 softmax,再交给 cross_entropy(又做了一次 log_softmax)
probs = F.softmax(logits, dim=-1)
loss_bad = F.cross_entropy(probs, labels)

print("相似度矩阵(logits):")
for row in logits:
    print(" ", [round(v.item(), 4) for v in row])
print(f"正确损失(直接传 logits): {loss_ok.item():.4f}")
print(f"错误损失(先 softmax 再传): {loss_bad.item():.4f}")
相似度矩阵(logits):
  [0.1572, 0.0201, -0.2757]
  [-0.2665, -0.4077, -0.3647]
  [0.4574, 0.2646, -0.6367]
正确损失(直接传 logits): 1.3167
错误损失(先 softmax 再传): 1.1471

读法:正确写法拿到 1.3167,高于随机水平 log(3)≈1.0986(说明这个随机初始化下模型还没对齐);错误写法被压到 1.1471,看起来“更接近最优”,其实是 softmax 把概率挤到 (0,1) 后再取对数造成的系统性低估。更关键的是梯度:正确写法下 logits 的梯度是 p - y,量级正常;错误写法经过两次归一化后梯度被压平,训练会变慢甚至停滞。

常见错误

错误理解或写法现象或报错原因正确做法
忘记对编码器输出进行 L2 归一化相似度值域不受控,可能非常大或非常小,导致 softmax 饱和,梯度消失。余弦相似度要求向量是单位长度。对比损失在归一化后的特征上工作最佳。在计算相似度前,对图像和文本嵌入使用 F.normalize(..., dim=-1)。
错误构造对比损失的标签模型无法学习正确的对齐关系,性能很差。误以为 logits_per_image 和 logits_per_text 使用相同的标签。实际上,它们分别以图像和文本为查询,标签都是 torch.arange(batch_size),但含义不同:一个是文本索引,一个是图像索引。理解对称性:labels = torch.arange(batch_size)。F.cross_entropy(logits_per_image, labels) 让图像预测匹配的文本索引;F.cross_entropy(logits_per_text, labels) 让文本预测匹配的图像索引。
在零样本预测时使用单一的、不好的提示模板分类准确率低于预期。语言模型对提示措辞敏感。“一张狗的照片”和“一幅狗的图片”可能产生略有不同的嵌入。使用提示工程:为每个类别生成多个提示(如“一张 {类别} 的照片”、“{类别} 的彩色图片”),计算所有提示嵌入的平均值作为该类别的最终文本特征。
在小规模数据上期望 CLIP 式对比学习有很好效果模型过拟合,无法学到泛化特征,零样本迁移能力弱。CLIP 的核心洞见之一是规模化。对比学习需要大量负样本(批次中的其他对)来定义有效的特征空间。小批次或小数据量无法提供足够的对比信号。理解这是方法固有的数据需求。在小规模项目中,考虑使用预训练的 CLIP 模型作为特征提取器,或采用其他更适合小数据的学习范式。

本章小结

  1. CLIP 利用自然语言作为监督信号,通过预测互联网规模的(图像,文本)配对关系,学习可迁移的视觉表示。
  2. 其核心是对比学习,使用双编码器架构和 InfoNCE 损失,在一个共享的特征空间内对齐图像和文本特征。
  3. 零样本预测是 CLIP 的标志性能力,通过将分类任务转化为图文匹配任务来实现,无需下游数据训练。
  4. 规模化(大数据、大模型、大算力)是 CLIP 成功的关键前提,这使得对比学习能学到丰富、鲁棒的特征表示。
  5. 提示工程能显著提升零样本性能,通过集成多个提示文本的嵌入来稳定类别的文本表示。
  6. 零样本有明确上限:平均约等于 ResNet-50 线性探针,细粒度分类、计数与真正的分布外数据仍明显偏弱。

📋 行动清单

  • 运行并修改对比损失代码块,将批次大小 N 从 3 改为 4,观察相似度矩阵和损失值的变化,理解批次大小如何影响对比学习(提供更多负样本)。
  • 在零样本分类代码块中,尝试添加第二个提示模板,例如“一幅 {类别} 的图画”,计算两个模板嵌入的平均值作为类别特征,观察预测结果是否变化。
  • 查阅 torch.nn.functional.cross_entropy 的文档,理解它内部已经包含了 log_softmax,因此在计算对比损失时,不应在相似度矩阵 logits 上额外手动进行 softmax。

延伸阅读

  1. Learning Transferable Visual Models From Natural Language Supervision(Radford et al., 2021,arXiv:2103.00020)—— 本节核心论文,含双编码器、InfoNCE 与零样本迁移的完整实验。
  2. Representation Learning with Contrastive Predictive Coding(van den Oord et al., 2018,arXiv:1807.03748)—— InfoNCE 损失的原始出处,理解“用批次内负样本做对比”的最早形式。
  3. An Image is Worth 16x16 Words(Dosovitskiy et al., 2020,arXiv:2010.11929)—— CLIP 可选用的 ViT 图像编码器,下一节展开。

—— 小象教研组