我最初读CLIP官方代码那会儿,说实话有点懵。整个模型看起来不复杂,但里面到处是细节:为什么要用对比学习而不是直接分类,temperature参数到底在干嘛,为什么load权重的时候不能直接load_state_dict。一个“超级超级细”的要求,恰恰是把这些细节一个个抠明白的过程。这篇内容我按自己的复现和理解顺序来写,尽量做到每一步都有代码、有解释、有为什么。
不管你是做多模态检索、zero-shot分类、图文相似度计算,还是想给生成模型加引导信号,CLIP都是绕不开的基础模块。这篇文章适合三部分人:刚入门多模态的小白、想把手头分类任务改成zero-shot方案的算法工程师,以及准备阅读并魔改CLIP源码的进阶玩家。我会从模型原理、官方仓库结构、核心代码逐行解读,一直讲到实际跑通图文检索和CLIP Score计算,最后附上我踩过的坑。
1. CLIP到底在做什么:核心思路拆解
1.1 为什么是“对比”而不是“预测”
我们先回到CLIP最大的设计决策上。在CLIP出现之前,视觉模型的主流玩法是先在ImageNet这种大规模标注数据集上做分类预训练,然后迁移到下游任务。这种范式的问题很明显:标注成本极高,而且学到的特征被限制在预训练分类类别里。
CLIP的出发点很朴素:网上的图片天然带着大量的文本信息,比如一张猫的照片可能旁边就有一句“a photo of a cat sitting on a windowsill”。如果能够利用这种海量的“图文配对”数据,那就不需要人工标注了。
那怎么利用呢?OpenAI选了一条和之前都不太一样的路:不预测、不重建,而是做“匹配”。模型拿到一张图片和一段文字,只判断它们是匹配的还是不匹配的。
这个思路其实就是对比学习的思想:拉近匹配图文对的特征距离,推远不匹配图文对的特征距离。整个训练过程没有让模型去生成图片描述,也没有让模型去做词汇分类,只是通过对比让两个编码器学会把语义相近的图文特征放到相邻的位置。
这里有一个比较关键的理解:CLIP其实是在构建一个“语义空间的对齐”。图像编码器把图片映射到一个高维向量,文本编码器把文本映射到同一个高维向量空间,训练完成后,这两个向量空间是共享的。所以后来大家都在说CLIP是打通了视觉语言两个模态之间的一扇门。
1.2 对称损失的数学意义
CLIP的loss在官方代码里叫clip_loss,但它的前身就是经典的InfoNCE。我们假设一个batch有N个图文对,会把图像特征和文本特征都做L2归一化,然后计算它们之间的点乘矩阵,得到N乘N的logits矩阵。理想情况下矩阵对角线的元素应该最大,因为对角线上是匹配的图文对,非对角线上则是我们构造出来的负样本。
要注意的是,这个损失是“对称”的。也就是说,不仅要让第i张图片对第i段文本的相似度最高,还要让第i段文本对第i张图片的相似度最高。所以官方代码里对logits矩阵分别按列和按行都做了一次交叉熵损失,然后取平均。
为什么要对称?因为图像和文本在这个空间里是平等的,我们不希望某一个模态主导梯度更新。非对称的做法会导致某一个编码器学到的特征质量下降。实测中,如果只保留一个方向,文本端的特征很容易退化,因为文本编码器的梯度来源变少了。
另外,logits矩阵里的数值还要乘上一个可学习的温度参数。在CLIP中,这个温度参数被包装成logit_scale,它是一个log空间里的标量,初始值通常设为ln(1/0.07)。0.07这个数字是从对比学习早期工作沿用下来的,经验上能让logits的数值范围在一个比较合理的区间,太大梯度容易爆炸,太小模型又不敏感。训练完成后再拿出来看看,会发现它已经变化了不少,所以“可学习”这个设计是很重要的,并不只是摆设。
2. 官方仓库整体结构:动手之前先摸清地图
2.1 仓库文件布局与依赖环境
OpenAI官方仓库的地址是github.com/openai/CLIP,代码量不大,核心实现都在clip包下面。整个仓库只有几个关键模块:
clip/model.py:模型结构,包括图像编码器和文本编码器。clip/clip.py:模型加载、预处理、tokenize等封装逻辑。clip/simple_tokenizer.py:GPT-2风格的BPE分词器。clip/zh_vocab.json、clip/bpe_simple_vocab_16e6.txt.gz:词表文件。
这个代码布局非常简洁,没有复杂的工程结构,核心逻辑就几百行。对于想研究源码的人来说,是很适合精读的。
环境依赖主要就是torch和torchvision。官方仓库里还提到需要timm,如果你用的是ResNet类主干,timm可以不用,但如果你看到源码里有timm.create_model的调用,就说明这个版本对timm有依赖。我一般建议直接装上,避免运行报错。
如果你和我一样,平时是在WSL Ubuntu里开发,那么有一个小建议:终端字体换成JetBrains Mono或者Fira Code,配Windows Terminal的现代终端渲染,观感比较接近macOS的体验,长时间看代码眼睛舒服很多。这不影响跑模型,但对调试代码时的体验提升是实实在在的。
2.2 可用的预训练骨干网络型号
官方提供了非常丰富的骨干网络支持,主要分为两大系列:ResNet系列和ViT系列。
ResNet系列有RN50、RN101、RN50x4、RN50x16和RN50x64,其中后面的数字越大代表引用参数和计算量越大,特征维度也越高。ViT系列有ViT-B/32、ViT-B/16、ViT-L/14等,斜杠后面的数字代表patch大小,数字越小,每个patch越小,序列长度越长,计算量也越大。
选择哪一个,取决于你的实际场景。如果只是验证效果、快速跑通Demo,ViT-B/32是最平衡的选择。如果追求精度,可以上ViT-L/14,但显存占用和推理时间都要考虑。我经常在算力紧张的服务器上先用RN50做流程验证,再切到ViT-B/16出最终结果。
这里有一个容易忽略的点:不同骨干网络对应的输入图像尺寸不一样。ResNet系列默认输入是224x224,但RN50x4这类的输入尺寸变成了288x288。ViT-L/14的输入是224x224(也支持336x336的ViT-L/14@336px)。原始数据进入模型前,会被clip.load返回的预处理函数统一处理,所以你不必手工resize,但如果你要自己写预处理管线,就必须关注这个尺寸差异。
3. model.py逐行精讲:把官方核心代码拆开揉碎
3.1 基础模块:QuickGELU、LayerNorm、Attention
先看最底层的基础模块。CLIP里用到的激活函数不是标准的ReLU,而是QuickGELU。GELU本身是Transformer里常见的激活函数,相比ReLU,它在负数区间不是完全截断,而是有一个平滑过渡。QuickGELU是GELU的一个近似版本,计算速度更快:
class QuickGELU(nn.Module): def forward(self, x: torch.Tensor): return x * torch.sigmoid(1.702 * x)这个1.702是一个非常经典的近似系数。原始GELU是0.5 * x * (1 + erf(x / sqrt(2))),计算的时候带误差函数erf,GPU上效率不算高。用sigmoid去近似,精度损失很小,但速度更快。后面用Transformer做文本编码器时,这个激活函数会出现在FFN的隐藏层里。
然后是LayerNorm。有些同学一开始不理解为什么在视觉模型里也要用LayerNorm而不是BatchNorm。CLIP的文本编码器是Transformer结构,Transformer里做归一化基本都用LayerNorm。图像编码器如果是ViT,那同样用LayerNorm;如果是ResNet结构,则图像端用BatchNorm。这样设计的原因很简单:LayerNorm不依赖batch size,对变长输入更友好,而BatchNorm在小batch下统计量不稳定。文本端序列长度虽然固定,但token级别归一化还是LayerNorm更合理。
核心注意力模块没有用PyTorch自带的nn.MultiheadAttention,而是手写了一个ResidualAttentionBlock,关键代码是:
class ResidualAttentionBlock(nn.Module): def __init__(self, d_model: int, n_head: int, attn_mask: torch.Tensor = None): super().__init__() self.attn = nn.MultiheadAttention(d_model, n_head) self.ln_1 = LayerNorm(d_model) self.mlp = nn.Sequential(OrderedDict([ ("c_fc", nn.Linear(d_model, d_model * 4)), ("gelu", QuickGELU()), ("c_proj", nn.Linear(d_model * 4, d_model)) ])) self.ln_2 = LayerNorm(d_model) self.attn_mask = attn_mask def attention(self, x: torch.Tensor): self.attn_mask = self.attn_mask.to(dtype=x.dtype, device=x.device) if self.attn_mask is not None else None return self.attn(x, x, x, need_weights=False, attn_mask=self.attn_mask)[0] def forward(self, x: torch.Tensor): x = x + self.attention(self.ln_1(x)) x = x + self.mlp(self.ln_2(x)) return x让我逐行拆解。首先attention方法调用self.attn(x, x, x, need_weights=False, attn_mask=self.attn_mask),这是PyTorch的nn.MultiheadAttention在自注意力场景下的标准写法。need_weights=False是为了省去权重矩阵的计算,推理时能省不少内存。attn_mask是给文本端用的因果掩码,保证当前位置只能看到前面的token,不能看到后面的。
forward里的残差连接是这样设计的:先对输入做LayerNorm,再进入注意力,然后和原始输入相加;之后再做LayerNorm,进入MLP,再残差相加。标准Transformer的做法是在注意力前和后都用残差,这里也不例外。
nn.MultiheadAttention在PyTorch中的默认行为是输入形状为(seq_len, batch, embed_dim),这一点和很多人的直觉不同。CLIP源码里也没有特别去改batch_first=True,所以你如果自己写数据流,需要习惯这个维度顺序。
3.2 Transformer与VisionTransformer:位置编码和类型嵌入
在model.py里有一个Transformer类,它其实就是把多个ResidualAttentionBlock串起来,并在开头加了一个LayerNorm:
class Transformer(nn.Module): def __init__(self, width: int, layers: int, heads: int, attn_mask: torch.Tensor = None): super().__init__() self.width = width self.layers = layers self.resblocks = nn.Sequential(*[ResidualAttentionBlock(width, heads, attn_mask) for _ in range(layers)]) def forward(self, x: torch.Tensor): return self.resblocks(x)注意,官方CLIP在文本Transformer的最后并没有额外再叠加一个LayerNorm,这和很多BERT代码不太一样。因为最后的归一化已经在某些文本塔的项目里被单独处理了,CLIP的训练设计里并不依赖这个尾部LayerNorm来做最后的特征规范化,特征在后续使用时会统一做L2 normalization,所以谁在前谁在后不影响大局。
VisionTransformer的起点是把图像切成patch。以ViT-B/32为例,patch大小是32x32,一张224x224的图片会被切成7x7=49个patch。然后通过一个卷积层conv1把每个patch映射成768维的向量:
self.conv1 = nn.Conv2d(in_channels=3, out_channels=width, kernel_size=patch_size, stride=patch_size, bias=False)这一层从效果上看等价于线性投影,因为卷积核大小等于步长,且等于patch大小,每个patch内部不会重叠。这里没有bias,后面会用一个nn.Parameter来补class token的位置,所以卷积层本身不需要偏置。
接下来是class_embedding。这个可学习的向量会被拼到patch embedding前面,shape是[1, 1, width]:
self.class_embedding = nn.Parameter(torch.randn(width))forward里,先过卷积,然后flatten成序列,再把class_embedding广播拼接过去。代码里其实有一段reshape操作:
x = x.reshape(x.shape[0], x.shape[1], -1) # [B, C, H*W] x = x.permute(0, 2, 1) # [B, H*W, C] x = torch.cat([self.class_embedding.to(x.dtype) + torch.zeros(x.shape[0], 1, x.shape[-1], dtype=x.dtype, device=x.device), x], dim=1)注意这里为什么要用“加零”的方式扩展class_embedding到batch维度,而不是直接repeat或expand。因为expand在某些情况下会产生不连续的张量,后面接Transformer时可能触发拷贝,带来额外的显存和耗时;用torch.zeros加过去,虽然也生成了新张量,但它的写法直观,而且语义上很清晰。
紧接着是位置编码:
x = x + self.positional_embedding.to(x.dtype)这个positional_embedding同样是可学习的,不是sinusoidal那种固定正弦余弦。CLIP认为固定的位置编码在足够大的数据量下,没有可学习的好,可学习的更灵活。
最后是ln_post和proj。ln_post对最后一层输出做归一化,proj是一个线性层,把特征维度投射到多模态公共空间。需要特别说明的是,proj的维度不是骨干网络的特征维度,而是embed_dim,官方不同的权重文件里这个值各不相同。例如ViT-B/32的公共空间维度是512,ViT-L/14则是768。
如果proj是None,那就不做投影,直接用归一化后的特征作为多模态向量。但在官方预训练权重里,proj都不是None,我们正常使用时会拿到公共空间向量。
3.3 文本编码器和tokenizer:BPE与特殊token细节
CLIP的文本编码器本质上是一个只包含Transformer Encoder的模型,没有decoder,也没有做masked language model。它的输入是token序列,tokenizer用的是GPT-2的BPE,在simple_tokenizer.py里实现。
tokenize函数是你在实践中最常调用的函数:
def tokenize(texts, context_length: int = 77): if isinstance(texts, str): texts = [texts] sot_token = _tokenizer.encoder["<|startoftext|>"] eot_token = _tokenizer.encoder["<|endoftext|>"] all_tokens = [[sot_token] + _tokenizer.encode(text) + [eot_token] for text in texts] result = torch.zeros(len(all_tokens), context_length, dtype=torch.long) for i, tokens in enumerate(all_tokens): if len(tokens) > context_length: tokens = tokens[:context_length - 1] + [eot_token] result[i, :len(tokens)] = torch.tensor(tokens) return result这个函数的逻辑很直接,但有几个细节要注意。
第一,每个文本的开头强制加<|startoftext|>,结尾强制加<|endoftext|>。CLIP的文本端把这两个特殊token当作序列的边界信号,训练时也是这样处理的,所以你在推理时不要省略它们。
第二,如果token序列超过77,代码先截断到context_length - 1,然后强制末尾补一个eot_token。这保证了每个序列都以结束符收尾。如果你有一批长文本,很多会被截断,这会损失尾部信息。实践中我建议先检查文本长度分布,如果超过77的情况很多,要么训练时减少截断,要么干脆只用关键短语做文本端输入。
第三,返回的result是一个torch.LongTensor,shape是[batch, 77],不足77的部分全部用0填充。0对应的token在BPE词表里是<|endoftext|>,但这类padding token在Transformer中由于有attention mask,并不会参与注意力计算,所以不会污染语义。
这里有一个容易被忽略的问题:CLIP的文本编码器用的还是0作为padding token,而0同时也是一个真实token。好在self-attention中有因果掩码,且文本端不需要对padding做额外的mask处理,这在源码中是靠attn_mask来实现的,而不是常规的padding_mask。这点和很多语言模型不太一样,读代码的时候不要搞混。
3.4 CLIP类的forward流程:特征、相似度和logit_scale
整个模型的核心是CLIP类,它的关键部分是这样的:
class CLIP(nn.Module): def __init__(self, embed_dim, image_resolution, vision_layers, vision_width, vision_patch_size, context_length, vocab_size, transformer_width, transformer_heads, transformer_layers): super().__init__() self.context_length = context_length self.visual = VisionTransformer(...) self.transformer = Transformer(...) self.token_embedding = nn.Embedding(vocab_size, transformer_width) self.ln_final = LayerNorm(transformer_width) self.text_projection = nn.Parameter(torch.empty(transformer_width, embed_dim)) self.logit_scale = nn.Parameter(torch.ones([]) * np.log(1 / 0.07)) self.initialize_parameters() def encode_image(self, image): return self.visual(image) def encode_text(self, text): x = self.token_embedding(text) x = x + self.positional_embedding x = x.permute(1, 0, 2) x = self.transformer(x) x = x.permute(1, 0, 2) x = self.ln_final(x) 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) image_features = image_features / image_features.norm(dim=-1, keepdim=True) text_features = text_features / text_features.norm(dim=-1, keepdim=True) logit_scale = self.logit_scale.exp() logits_per_image = logit_scale * image_features @ text_features.t() logits_per_text = logits_per_image.t() return logits_per_image, logits_per_textforward中最核心的是三行代码:归一化、计算logits、得到对称矩阵。
encode_image输出[batch, embed_dim]的特征,encode_text输出[batch, embed_dim]的特征。然后两者都除以自身的L2范数,也就是把特征向量归一化到单位球面上。为什么要归一化?因为后面内积结果会被当作相似度,而相似度应该只和方向有关,和向量的长度无关。如果不归一化,不同文本的长度差异会直接影响分数高低,模型就没法公正地比较。
logit_scale是一个可学习的参数,它在forward里被取指数,得到实际温度系数。之所以在log空间维护这个参数,是为了保证温度系数恒为正,同时梯度更新也更稳定,不会因为数值穿零导致loss异常。
logits_per_image的shape是[batch_size, batch_size],第i行第j列表示第i张图片和第j段文本的相似度。logits_per_text就是它的转置。这两个矩阵后面的交叉熵损失是等价的,所以如果你只想得到一个loss,取其中一个矩阵算就行,官方取的是两者平均。
关于这个相似度,它其实没有严格的上限和下限,因为温度系数会缩放数值范围,如果你想把相似度转成概率,可以对每个文本端做softmax,但要注意softmax后的数值不能直接解释为“匹配概率”的绝对值,它只是在给定候选集合内的相对比较。
4. 完整实操:从加载权重到跑通图文检索与zero-shot分类
4.1 环境准备和权重加载
实操的第一步是安装依赖。我建议单独建一个虚拟环境,Python版本3.9以上,然后安装PyTorch和torchvision。如果你用的是GPU,安装对应CUDA版本的torch;如果只是CPU跑演示,PyTorch CPU版本也够用,但推理会比较慢。
接着安装CLIP库。官方仓库推荐的方式是:
pip install git+https://github.com/openai/CLIP.git如果你网速不佳,也可以把仓库clone下来后,在目录里执行pip install -e .。安装完成后,你还需要确认一下timm有没有装,有些版本的CLIP会在这个依赖上出问题。
加载模型的代码很简单:
import clip import torch device = "cuda" if torch.cuda.is_available() else "cpu" model, preprocess = clip.load("ViT-B/32", device=device)clip.load返回两个对象,第一个是模型,第二个是预处理函数preprocess。preprocess会做三件事:把图片缩放到指定尺寸、中心裁剪、转成张量并归一化。归一化使用的均值和标准差就是ImageNet的[0.48145466, 0.4578275, 0.40821073]和[0.26862954, 0.26130258, 0.27577711],这个细节很关键,如果你想用其他库加载图片,一定要沿用这套统计量,否则模型特征会失真。
加载权重时会自动下载对应的.pt文件并缓存。官方权重托管在OpenAI的CDN上,如果下载失败,常见原因是网络超时,可以通过手动下载权重文件后放在缓存目录解决。你可以先跑一次clip.load,看报错里打印的路径,然后把手动下载的权重放进去。还有一个办法是使用HF_ENDPOINT之类的镜像加速,但这会因环境不同而变化,最好还是手动处理。
4.2 图片和文本特征抽取
一旦模型加载成功,特征抽取就非常直观了。以一张图片和一段句子为例:
from PIL import Image import requests image = Image.open("cat.jpg") image_input = preprocess(image).unsqueeze(0).to(device) text = "a photo of a cat" text_tokens = clip.tokenize([text]).to(device) with torch.no_grad(): image_features = model.encode_image(image_input) text_features = model.encode_text(text_tokens)注意preprocess返回的是[C, H, W]的张量,所以需要unsqueeze(0)加一个batch维。clip.tokenize返回的也是[1, 77]的张量,已经带了batch维,不需要再额外添加。
在这段代码里,你有几处容易踩坑的地方。第一个是数据类型:clip.load默认情况下,如果device是cuda,模型参数会是torch.float16,也就是半精度。如果你传进去的输入张量是float32,会报类型不匹配。解决办法是在preprocess之后加.half(),或者干脆加载模型时用clip.load("ViT-B/32", device=device, fp16=False)关掉半精度。我个人推荐在调试阶段关掉fp16,等流程稳定后再开启。
第二个坑是预处理函数的输入格式。preprocess期望输入是PIL.Image对象,或者能被转换成PIL的格式。如果你直接传入一个numpy.ndarray,某些版本的CLIP可能直接报错,而且这个报错信息很模糊。最好统一使用PIL格式读取图片。你也可以自己用cv2读图后转成PIL,注意cv2默认是BGR顺序,需要先转RGB再转PIL。
第三点是encode_image接受的输入必须是四维张量[B, C, H, W],少了batch维会直接报维度错误,而且带不带requires_grad在推理时无所谓,但训练时你需要确保输入能够反传。
4.3 零样本分类:把分类问题改造成图文匹配问题
CLIP最亮眼的用法就是零样本分类。以CIFAR-100为例,我把测试集里的若干图片直接丢给模型,让它从100个类名中挑出最相近的一个。
传统的分类模型需要训练一个分类头,但CLIP不需要。做法是:把每个类别名称构造成一个文本,比如“a photo of a plane”“a photo of a bird”,然后计算图片特征和所有类别文本特征的相似度,取最高的那个作为预测结果。
labels = clip.tokenize([f"a photo of a {c}" for c in class_names]).to(device) with torch.no_grad(): image_features = model.encode_image(image_inputs) text_features = model.encode_text(labels) image_features /= image_features.norm(dim=-1, keepdim=True) text_features /= text_features.norm(dim=-1, keepdim=True) similarity = (100.0 * image_features @ text_features.T).softmax(dim=-1)这段代码里有几个经验点。
第一,100.0这个缩放系数的来源是温度参数。官方在zero-shot分类报告里把logits乘以100,本质上是把相似度拉开,让softmax结果更锐利。如果你用自己训练过的模型,这个系数要看温度参数训练后的值,不要照搬100。
第二,类别文本的写法非常重要。直接写"plane"不如写"a photo of a plane"效果好,因为训练数据里的图像描述大多是带上下文的自然句子,不是孤立的单词。你可以理解为,CLIP的文本端对“完整描述”更敏感,对零星词汇的编码不稳定。实践中我还试过加一些额外上下文,比如“a photo of a cat, high quality”,有时能轻微提升效果,但主要还是看训练分布的匹配度。
第三,如果你的类别很多,文本端一次性要编码很多条,要注意显存占用。比如ImageNet有1000类,一次编码1000条文本,特征矩阵是[1000, 512],问题不大;但如果你有10万个候选类,建议分批处理文本,避免OOM。
这个zero-shot分类效果,在常见数据集上,CLIP的ViT-L/14@336px模型基本能媲美简单监督模型。当然,它并不是绝对最强,但它最大的价值是不需要任何训练,秒切新类别。
4.4 CLIP Score到底是什么、怎么算
你在很多AI绘图和检索场景里会听到CLIP Score这个词。它不是一个固定的指标,而是指用CLIP模型来给图文匹配程度打分。最基础的计算方式是:拿一张图和一段文本分别过编码器,得到两个特征向量,然后算余弦相似度。
with torch.no_grad(): image_features = model.encode_image(image_input) text_features = model.encode_text(text_tokens) image_features = image_features / image_features.norm(dim=-1, keepdim=True) text_features = text_features / text_features.norm(dim=-1, keepdim=True) clip_score = (image_features @ text_features.T).item()这里的分数范围通常在-1到1之间,但实际情况下,尤其对于匹配的图文对,分数会在0.2到0.4附近。不同模型、不同文本模板会影响分数绝对值,所以CLIP Score一般不跨模型直接比较。
在图像生成评测里,CLIP Score还有一个常见变体:计算生成图和文本提示之间的相似度,配合softmax或logit缩放使用。在这个场景里,温度系数会被拿来放大差异,所以不同代码库算出来的CLIP Score虽然趋势一致,但绝对值可能不同。
理解这一点很重要:CLIP Score不是一个有绝对物理意义的值,它是一个相对指标。你用它来比较两张图哪张更符合这句提示词,比单看某一个绝对分数靠谱得多。
4.5 用CLIP做简单的图文检索排序
图文检索是CLIP另一个典型应用,其实和zero-shot分类在代码层面几乎一样。假设你有一万张图片,想找到与文本“一条在草地上奔跑的狗”最匹配的图片,做法就是:先把一万张图全部编码成特征矩阵,再把文本编码成特征,做矩阵乘法,按相似度降序排序取top-K。
image_features = model.encode_image(all_images) text_features = model.encode_text([query_text]) scores = image_features @ text_features.T topk_indices = scores.squeeze(-1).topk(10).indices注意这里为了速度,最好先对所有图片特征归一化,文本特征归一化,然后一次性矩阵乘法。如果你有几十万张图,全部存在显存里不现实,建议先抽特征存成npy文件,检索时只加载特征矩阵,GPU内存占用很小。这应该是CLIP工程化落地中最推荐的做法。
另外,检索结果的排序质量受图像预处理影响很大。如果图片本身分辨率差异很大、长宽比不一致,直接resize再中心裁剪会丢失大量信息。我一般会先把图做等比缩放,再填成正方形,或者用CenterCrop保持中心区域不变,效果会稳定很多。
5. 常见问题排查与避坑记录
5.1 权重下载失败或慢
这个是最常遇到的问题。官方权重文件被放在CDN上,加载时如果网络不通会报连接错误。最简单的解决方法是手动下载。先运行一次加载命令,根据报错信息找到缓存路径,然后用浏览器或下载工具把.pt文件放进目录并重命名正确。
也可以先用第三方平台下载权重再放到本地。如果你要离线部署,推荐把权重文件复制到模型加载目录下,并用clip.load的download_root参数指定权重目录,这样避免每次都要重新下载。
5.2 类型不匹配:Tensor dtype不一致
半精度加载模型后,最典型报错是RuntimeError: expected scalar type Float but found Half。这是因为模型的权重是float16,而你传入的输入是float32。
处理方式有三类:
第一,加载时设置fp16=False,这种情况适合CPU或者精度敏感的调试场景。 第二,输入数据做.half(),配合GPU运行,能节省显存并加速推理。 第三,混合精度训练时,输入用float32,模型用float16,需要额外加autocast控制,比较复杂,日常推理不必用到。
我自己在推理流程里基本固定用fp16=False,因为CLIP这个规模在单张GPU上推理本身很快,半精度带来的速度提升并不明显,反而增加排查问题的成本。
5.3 输入图片尺寸不对导致报错或特征质量差
如果图片尺寸和模型预设不一致,preprocess会自动处理。但如果你绕开preprocess自己做了归一化和resize,很容易因为尺寸不对导致模型推理报错或输出特征出现偏差。
ViT系列对输入分辨率是敏感的,因为位置编码是固定大小。你换成不同尺寸图像,位置编码的patch数变了,模型直接没法跑。某些代码里会用插值方式调整位置编码来适配更高分辨率,但CLIP官方没有提供这个接口,所以不要随意改动输入分辨率。
ResNet系列虽然卷积结构上能接受任意尺寸,但注意力池化层和全连接层在设计时也依赖固定分辨率,所以仍然不要随意修改。
5.4 文本端截断导致语义丢失
前面讲到,context_length默认是77。如果你的文本超过77个token,tokenize会直接截断并强制加结束符。对于长文本,这个截断会丢掉重要信息,导致文本特征质量明显下降。
一个可用的解法是:在输入模型之前,先对文本做关键词提取或摘要,只把核心短语送入模型。另一个办法是分段处理,把文本拆成多个片段分别编码,然后再做均值池化。这样虽然不是官方方式,但在很多工程实践中效果尚可。
5.5 显存不足
如果你在跑大批量图像编码时显存不足,可以考虑下面几个方向。
第一,降低batch size,这最直接有效。 第二,用半精度推理,能省接近一半显存。 第三,用torch.no_grad()包裹推理代码,避免保存中间变量。 第四,如果数据集太大,建议分批处理并逐步保存特征,不要一次把所有图都载入显存。
显存不足时先看是不是输入张量维度过大,再看是不是用了过大的backbone。ViT-L/14一张224x224的图在fp16下大概需要2到3GB显存,相比ViT-B/32大不少,如果机器显存有限,可以谨慎选择。
5.6 官方代码里没有padding mask,为什么文本特征不受影响
这个问题经常有人问。传统Transformer在处理padding token时会用attention mask把padding位置遮住,但CLIP的官方文本编码器似乎没有显式做这一点。
关键在于CLIP的文本编码器使用的是因果注意力掩码,它是根据序列长度生成的三角掩码,和文本内容无关。也就是说,padding位置的token虽然也会参与注意力计算,但由于它们在序列末尾,因果掩码已经限制了后续token能看到它们。更重要的是,最后取特征时不是取整个序列的均值池化,而是取eot_token位置的特征,这相当于只关注了模型认为最应该总结整个序列的那个位置。
这个设计在训练时已经内化到模型参数里了,所以推理时不需要额外做padding mask,也不会造成明显的信息泄漏问题。
6. 我的实操体会和注意事项
代码看到这里,CLIP的整体脉络已经比较清晰了。我再分享几个实际工作中会反复用到的细节。
第一,CLIP的文本模板设计比想象中影响更大。同样是zero-shot分类,用“a photo of a {class}”和直接用“{class}”,效果可能相差好几个点。在多轮迭代中,模板工程可能是前期最值得投入的优化点。不需要太复杂的提示词,关键是让文本端的表达风格更贴近预训练数据分布。
第二,如果你想基于CLIP做微调,大部分场景只需要训练最后的projection层,甚至固定图像编码器和文本编码器不动,只调整logit_scale参数,效果就已经能稳定进步。如果从头微调整个模型,很容易在小数据上过拟合,还会破坏预训练学到的通用语义空间。
第三,在使用CLIP时一定记得做特征归一化。很多人直接拿原始特征算内积或欧氏距离,得到的结果会受特征的模长影响。还是那句话,CLIP训练时用的就是归一化后的余弦相似度,你推理时也应该保持一致。
第四,如果你想给图像生成模型加CLIP-based reward,不要忽略了batch内负样本的作用。单张图片单条文本算相似度虽然简单,但CLIP在训练时是通过同batch内大量负样本建立对比关系的,单样本打分不一定稳定。有条件时,尽量构造一个候选集,通过排序或softmax来使用分数,而不是只取绝对相似度。
第五,关于WSL环境的开发体验,我再补一句。因为CLIP在Linux环境兼容性更好,WSL是一个非常顺手的选择,文件系统和显卡驱动都能直通。把终端字体设置好、启用Windows Terminal的GPU加速渲染,长时间写代码的体验确实能接近macOS。
CLIP这个模型的门槛不算高,代码量也不大,但它背后的设计思路和工程细节,足够细品很久。把官方代码逐行读懂之后,再看其他多模态模型会轻松很多。我自己读过VQA、BLIP、ALBEF等模型的源码时,经常会发现它们都带有CLIP的影子。所以如果你真的想做多模态方向,花一整天把CLIP代码吃透,是非常划算的投资。