CLIP 这篇论文,我前后迭代了差不多三周,从最初只跑通预训练模型做零样本分类,到后来把完整的对比学习流程在小规模图文对上复现出来,中间踩的坑比想象中多得多。作为一个一直做单模态模型的人,第一次接触图文联合表征时,“效果竟然能这么直接”是最大的感受:给定一张图片,哪怕没有见过它的类别标签,只要把候选类别描述成一句话,模型就能根据图文相似度给出预测。这也是 CLIP 论文实验最吸引我的地方:用自然语言监督代替固定标签,让视觉模型拥有开放词汇的迁移能力。
这篇内容与其说是论文精读,不如说是一份实验记录。我会从实验动机、原理拆解、数据准备、代码实现、评估方法到避坑经验都过一遍,适合正在复现 CLIP、想动手做多模态表征学习、或者准备做图文检索方向实验的同学参考。如果你只是读过论文但不知道训练细节,这篇应该能帮你省掉不少试错时间。
1. 实验动机与整体方案
1.1 为什么偏偏选 CLIP 来复现
选 CLIP 做实验,不是因为它结构难,而是因为它把“训练目标”和“下游任务”之间的连接方式彻底改掉了。传统分类模型要先定义固定类别,然后在这个闭集上做 softmax;一旦遇到新类别,就得重新标注、重新训练。而 CLIP 把图像和文本映射到同一个向量空间,用自然语言作为监督信号,训练完成后,下游任务只需要把类别转化为文本描述,就能做零样本预测。
我当时的实验目标很具体:在公开的通用图文对数据规模受限的情况下,先把训练闭环跑通,再验证零样本分类和图文检索这两个典型任务。这里没有追求在完整大规模数据上从头训练,因为完整复现 CLIP 需要超大的 batch size 和 GPU 集群,个人实验环境扛不住。把目标降低到“复现方法有效性”,实验范围反而清晰了很多。
1.2 复现目标拆解
把实验拆成三层,方便控制变量。
第一层是理解。搞清楚双塔结构里两个编码器到底学到什么,温度系数如何影响对比损失,为什么 loss 里要除以一个可学习的 scale。
第二层是跑通。用一个小规模图文对数据集完成训练,观察 loss 是否能稳定下降、梯度是否正常、图文相似度是否开始有区分度。
第三层是验证。用训练好的模型做零样本图像分类和图文检索,评估效果,并跟一个简单的有监督 baseline 对比。
拆完目标之后,我对整个实验的预期管理就有了:不是复现论文里的 SOTA 数字,而是复现“方法有效”这个核心结论。只要自己能在一个小型可控环境里看到对比学习对齐图文语义,就算达到目的。
1.3 方案取舍:从零训练还是微调
实验方案上我纠结过:直接加载开源预训练权重做下游任务,还是自己从头训练一个小规模模型。最后选了后者,因为我的目的是理解训练过程本身,而不是调模型。预训练权重可以直接用,但它相当于一个黑盒,损失怎么算、梯度怎么回传、数据怎么组织,不亲手写一遍很难有体感。
不过我也建议,如果你时间有限,先跑通一个最小示例,再用预训练权重验证效果,两条腿走路。用预训练权重评估 prompt 的影响,用自己的小模型验证训练管线,两者互补。我当时就是先花两天写了一个最小 demo,确认 loss 能降,才开始换更大一点的数据集做正式实验。
2. CLIP 原理拆解:从对比学习到零样本迁移
2.1 双塔结构:图像编码器与文本编码器
CLIP 的骨干是两个编码器。图像编码器负责把图片变成向量,文本编码器负责把句子变成向量,两个向量被投影到同一个维度空间,然后做相似度比较。
这里的关键不是“两个编码器本身多复杂”,而是怎么把两个异构模态的信息对齐。图像编码器可以用常见的卷积网络或视觉 Transformer,文本编码器可以用 Transformer;两个编码器后面各接一个投影头,把特征映射到同一个 latent 空间。我实验里用了比较轻量的骨干,避免显存爆炸。如果你的显存很充裕,可以换更大的骨干,但训练难度会同步上升。
需要特别注意:两个分支的输出都要做 L2 归一化。如果不归一化,向量模长会参与到相似度计算中,模型可能通过放大模长来“作弊”降低 loss,而不是真正学习语义对齐。这个细节我在第一次实验里忽略了,结果 loss 看起来很低,但 embedding 分布明显有问题,很多不同类别的图片向量挤在一起,检索效果很差。
2.2 对比学习目标:InfoNCE 与温度系数
训练用的目标函数本质上是 infoNCE 的对称版本。一个 batch 内有 N 个图文对,把 N 个图像向量和 N 个文本向量两两算相似度,得到 N×N 的相似度矩阵。对角线是正样本对,其余 N²−N 个位置是负样本对。损失函数要求对角线相似度尽可能高,非对角线尽可能低。
这个操作类似“找朋友”:给定一张图,你要在 N 句话里把配对的句子挑出来;给定一句话,也要在 N 张图里把配对的图挑出来。所以最终损失是两个方向的交叉熵损失的平均。
温度系数在这里控制分布的锐利程度。温度太低,logits 会被放大,模型会对负样本惩罚更重,训练容易不稳定;温度太高,logits 会被压缩,正负样本差异变模糊,模型学不到区分度。我的经验是把温度设成可学习的标量,初始值取 0.07 附近,训练几个 step 后会自己调整到合适范围。不要手动固定死了,因为不同 batch size 下最优温度差异挺大。
2.3 数据增强与文本模板的作用
对比学习里,数据增强不只是图像空间的裁剪、翻转、颜色抖动;文本侧同样需要“增强”。CLIP 的经典做法是给每个类别写多个文本模板,比如“a photo of a {label}”,或者“a bad photo of a {label}”。训练时这些模板可以帮助模型学到类别描述的变化,而不是死记标签字符串。
实际在我的小实验里,模板数量不需要太多,但至少要保证训练时正样本文本不是单一固定格式。简单加几个前缀,比如“一张{类别}的照片”“这是一张{类别}的图片”,就能明显提高图文检索的召回率。很反直觉,但这就是文本多样性带来的泛化能力。如果你所有正样本文本都完全一样,模型很容易只靠关键词匹配就完成任务,学到的表征会很脆弱。
2.4 零样本迁移为什么能成立
零样本分类的原理非常直接。假设要识别一张狗的图片,分类候选集是猫、狗、鸟。先把每个类别拼成文本描述,比如“a photo of a cat”,用文本编码器得到三个文本向量;再用图像编码器得到图片向量;最后计算图片向量和三个文本向量的余弦相似度,取最大的那个作为预测结果。
整个过程不需要重新训练任何参数,只是把分类标签从整数变成了自然语言句子。这也是为什么 CLIP 可以迁移到训练时没见过的类别,因为它学的是“图像内容与语言描述之间的匹配能力”,而不是“某个类别的统计特征”。模型只要见过“狗”这个词语在各种图像语境里出现,就有可能在新的狗图片上给出正确判断。
3. 环境准备与数据构建
3.1 硬件、框架与基础配置
先交代我的实验环境:单卡 24GB 显存,深度学习框架用的是主流的自动求导框架,代码风格接近 PyTorch,混合精度训练用的是 bfloat16,优化器用 AdamW。单卡 24GB 显存,batch size 大概能开到 64 到 128 之间。
如果你只有 12GB 左右的显存,也不用担心。可以把视觉骨干换小一点,或者把图像分辨率降到 160,文本侧保持 77 长度。CLIP 训练最吃显存的是两个编码器,图片分支占了绝对大头,所以优先减小视觉模型的规模,文本编码器影响相对小一些。
3.2 数据集选择:先能用,再求大
完整复现 CLIP 要用几个亿的图文对,这不是单机能跑的事。我实验时构造了一个小型图文对数据集,包含大约 5 万对数据,覆盖 50 个类别,每张图片通过简单规则生成对应的文本描述。用这种可控数据集的好处是,我能知道模型理论上应该学到什么,出了问题容易定位是数据还是模型的问题。
你也可以用公开的图文对数据集的子集,但要注意清洗:去掉短文本、去重、过滤掉图文不匹配的样本。实际操作里,很多看起来能用的图片文本对,文本里包含大量噪声,比如“图片来自网络”“来源见水印”,这种文本对训练几乎没有帮助。清洗数据的优先级比调模型结构高得多,这一点后面还会再强调。
3.3 图像与文本预处理细节
图像预处理我参考了 CLIP 论文里的做法:先 Resize 让短边到 224,再中心裁剪到 224x224,最后做归一化。训练时加随机裁剪和水平翻转,测试时只做 Resize 和中心裁剪。
文本侧用的是 BPE 分词,序列长度限制在 77。这个 77 不是随便定的,CLIP 原始实验里大部分图文对的文本都比较短,77 足够覆盖。我实验里观察过,超过 77 的部分会被截断,所以写文本描述时要避免太长太绕的句子。如果你的任务涉及长文本,可以自行调大序列长度,但要注意位置编码要跟着改。
4. 核心实现与训练实录
4.1 双塔模型的最小实现
下面是我整理后的核心代码骨架,不是完整工程,但把关键部分都表达了。
class TextEncoder: # 输入 token_ids, attention_mask # 经过 transformer 编码后取 [CLS] 或平均池化 # 经过 linear_projection 得到 d 维向量 ... class ImageEncoder: # 输入 image_tensor # 经过视觉骨干后取特征 # 经过 linear_projection 得到 d 维向量 ... class CLIPModel: def __init__(self): self.image_encoder = ImageEncoder() self.text_encoder = TextEncoder() self.logit_scale = nn.Parameter(torch.log(torch.tensor(1/0.07))) def forward(self, images, tokens, masks): image_features = self.image_encoder(images) text_features = self.text_encoder(tokens, masks) image_features = F.normalize(image_features, dim=-1) text_features = F.normalize(text_features, dim=-1) return image_features, text_features这里有个小坑:logit_scale 通常初始化为 ln(1/0.07),而不是直接用 0.07。因为损失里是 scale * cosine_similarity,模型要学的其实是 scale 的对数形式。直接用 0.07 会让梯度传播不够稳定。另外所有特征在进入 loss 之前必须已经归一化,不然对比损失的计算会出现数值问题。
4.2 对比损失函数实现
对比损失的写法网上有很多版本,我最后用的是带温度系数的对称交叉熵。核心代码如下:
def clip_loss(image_features, text_features, logit_scale): image_features = F.normalize(image_features, dim=-1) text_features = F.normalize(text_features, dim=-1) logits = logit_scale * image_features @ text_features.T batch_size = image_features.shape[0] labels = torch.arange(batch_size, device=image_features.device) loss_i = F.cross_entropy(logits, labels) # 图片->文本 loss_t = F.cross_entropy(logits.T, labels) # 文本->图片 return (loss_i + loss_t) / 2为什么是对称的?因为训练目标是让模型同时做到“给定图片找文本”和“给定文本找图片”,两个方向都学才能让图文表征真正对齐。如果你只用一个方向,模型可能学会把图片映射到文本的偏置上,检索任务的效果会差很多。
4.3 训练超参数设置
我的训练配置大概是这样的:
- 图像输入分辨率:224×224
- 文本最大长度:77
- Batch size:64 到 128
- 优化器:AdamW,学习率 5e-4,权重衰减 0.02
- Scheduler:cosine 或 warmup + linear decay
- 训练轮数:20 个 epoch
- 混合精度:bfloat16
对比学习和自监督训练不太一样,batch size 太小时负样本太少,模型很难学到有区分度的表征。官方原始实验的 batch size 高达 32K,这种规模不是我们用单卡能复刻的。我的解决方法是先用 batch size 64 跑通,再观察 loss 曲线,如果 loss 下降慢,优先考虑增大 batch size 而不是加大学习率。
4.4 训练过程中的观察记录
训练过程里最值得记录的指标不是训练 loss,而是“错误率”或者说“图文匹配 Top-1 准确率”:即从 batch 内随机抽取一个图文对,模型能正确配对的比例。我记录到前几个 epoch 这个准确率从接近 1/batch_size 快速上升到 80% 以上;后面几个 epoch 提升变缓,loss 也进入平台期。
另一个观察是梯度范数。对比学习很容易出现梯度突然变大的情况,尤其在训练初期。如果你发现 loss 出现尖峰,大概率是某个 batch 里有极端相似的负样本,或者温度系数在快速调整。这时候不要立刻降低学习率,先看看是不是文本描述重复导致的。
5. 零样本能力与检索效果验证
5.1 零样本图像分类实验设计
训练完成之后,我在保留的测试集上做了零样本分类。做法是把每个测试类别都转成文本模板,比如“a photo of a cat”,然后用模型计算测试图片与每个类别文本的相似度,选择最高相似度的类别。
这里有个容易出错的地方:相似度到底用“图片特征与文本特征的点积”还是“先对多个模板的文本特征取平均”。我的经验是,单个模板效果不稳定,尤其是一些类别名称比较抽象时,比如“cat”和“dog”可能都能得到高的相似度。用多个模板生成多个文本特征,然后取平均,能有效提升分类准确率,代价是推理时几次文本编码,但文本编码开销很小。
5.2 图文检索:怎么定义和计算指标
图文检索包括两个方向:以文搜图(文本 query → 图片库排序)和以图搜文(图片 query → 文本库排序)。我用了 Recall@K 做评估:对于每个 query,取相似度排序前 K 个结果,看正确配对是否在里面。
实现时,先把所有测试图片编码成向量存下来,把所有 query 文本编码成向量,然后一次矩阵乘就能得到相似度矩阵。注意排序时要不要把 query 自身遮蔽掉,这取决于你的数据组织方式。在标准检索设置下,query 和 candidate 来自不同集合,不需要担心对角线问题。但如果你的测试集本身就是成对的图文,评估时一定要排除同一对样本出现在候选集里的情况,否则指标虚高。
5.3 小模型的结果与 baseline 对比
我训练的小模型在零样本分类上准确率大概到了 60% 左右,看起来不算高,但考虑到分类类别有 50 个、训练图文对只有 5 万,这个结果已经能说明对比学习对齐是有效的。作为对比,我用另一个有监督分类模型做同一个任务,它的闭集准确率能到 85%,但遇到测试集里新增类别会直接失效;而 CLIP 小模型对新增类别也能给出不低于随机的预测,这就是开放词汇能力的价值。
同时我还测了两个方向上的 Recall@5,以文搜图大概在 70% 左右,以图搜文略高一些。这说明模型在图文匹配上学的方向比较均衡,没有严重偏向某一个模态。如果你训练完发现一个方向显著好于另一个方向,大概率是损失函数或数据采样有问题,需要回去检查配对逻辑。
6. 高频踩坑与排查手册
6.1 显存不足:从 batch size 到梯度缓存
单卡训练时 OOM 是最常见的问题。如果你的 batch size 已经开到 128,显存还是不够,不要只想着继续缩小 batch。可以用梯度累积模拟更大的 batch size,或者开启梯度检查点来换显存。但要注意,梯度检查点会增加约 20% 的耗时,训练时可以接受,调参时可以考虑关闭。
还有一个容易被忽略的显存杀手:把图像特征和文本特征同时保留在全量矩阵里,用于计算 logits。batch size 越大,N×N 矩阵的显存占用越大,但相比特征编码器来说这部分通常还能接受。真正占显存的是两个编码器本身,尤其视觉骨干网络,所以优先降低图像分支的骨干规模。
6.2 训练不收敛:loss 始终不降或直接坍塌
对比学习里最典型的失败模式是表征坍塌:所有图片和文本的 embedding 都挤到同一个点,loss 虽然下降但相似度矩阵没有任何判别性。出现这种情况首先要检查两点:一是特征是否做了 L2 归一化,二是温度系数初始值是不是太小。
我初版实验把温度初始值设成了 0.07 的倒数之外的随机数,结果前几个 iteration 负样本梯度爆炸,loss 直接飞到几百。改回 ln(1/0.07) 初始化后才恢复正常。如果你观察到 loss 不降,也可以先打印一下相似度矩阵的对角线与非对角线均值,如果两者差距很小,大概率是模型还在随机初始化状态。
6.3 数据噪声带来的“捷径”学习
小数据集上很容易出现模型走捷径:它可能不是真的理解图文语义,而是靠文本中的某些词汇和图像的某些颜色特征做匹配。我的数据集里有不少文本包含相同前缀,比如“一张猫的照片”,模型可能只学到“有猫这个词的就和猫图片匹配”,而不是学到“猫”这个概念本身。
要缓解这个问题,必须在数据构建时增加文本多样性,用不同的句式、插入修饰词、甚至加入少量不匹配的 hard negative。这一点对最终效果的影响,比调模型结构还大。
6.4 Prompt 模板的敏感性
零样本分类时,同样的模型在不同 prompt 下结果可能差 5 到 10 个点。我试过有模板“a photo of a {label}”和“a picture of {label}”,在部分类别上表现完全不同。建议做法是准备一组模板,推理时对每个类别的多个模板编码结果取平均,或者使用类似“ensemble of prompts”的思路。
如果你在使用官方预训练模型,官方模板集可以直接复用;像我这样自己训练的小模型,模板简单一些即可,但至少不要只用单模板做最终结论。特别是类别名比较抽象时,文本模板的选择甚至比模型结构对结果的影响更大。
6.5 评估结果不稳定:随机种子与重复测
对比学习训练受随机种子影响很大。我跑同一个配置,换随机种子后零样本分类准确率能波动 2 到 3 个点。所以在汇报实验效果时,至少要跑三个种子取均值,不然很容易对自己的方法产生误判。
另外,验证集图片在预处理上要跟训练保持一致。我踩过的一个坑是:训练时用随机裁剪,评估时也忘了关掉数据增强,导致同一张图片每次推理结果都不一样。评估时一定要保证预处理是确定性的。把这些细节控制住,实验的可复现性才会有保障。
| 常见错误现象 | 可能原因 | 解决方案 |
|---|---|---|
| loss 不降 | 温度初始值不对、batch 太小 | 改为 ln(1/0.07) 初始化,增大 batch 或梯度累积 |
| 检索指标单方向偏高 | 损失或数据采样不对称 | 检查双方向交叉熵是否都参与,检查配对逻辑 |
| 零样本分类波动大 | prompt 模板太少、随机种子不稳定 | 多模板平均、多个种子取均值 |
| 显存 OOM | 图像编码器过大、batch 过大 | 减小骨干、梯度检查点、混合精度 |
| 训练后 embedding 分布无区分度 | 缺少 L2 归一化、表征坍塌 | 检查归一化,打印相似度矩阵均值差 |
7. 写在最后:一次实验的真实体感
CLIP 论文实验最让我惊讶的地方,不是模型结构,而是整个训练目标给表征学习带来的范式变化。以前我训练视觉模型,总是围绕一个固定的损失函数和固定的标签空间;CLIP 让我意识到,只要把监督信号换成语言,模型就能学会更开放、更通用的表示。即使我的实验规模很小,这种“以文搜图”“以图搜文”的能力也是传统分类模型很难给到的感受。
最后分享一个我自己的小习惯:每次改完代码,先不急着跑完整实验,而是用 100 对数据快速验证代码是否报错、loss 是否下降。如果 100 对数据上能稳定过拟合,再把数据切回完整规模。这个习惯帮我节省了大量调试时间。CLIP 实验的门槛主要在工程细节和数据质量,模型结构反而是最不费时间的一部分。希望这篇记录能帮你少走一点弯路。