1. 从"固定标签"到"用一句话定义类别",CLIP改的到底是什么
做图像分类的人都熟悉那种憋屈感:模型训好了,上线了,业务方第二天说"再加一个新品类"。你回去重新标注、重新训练、重新调参、重新评测,一套流程走下来两周没了。如果这个品类还特别长尾,样本只有几十张,那基本就是白干。CLIP这类视觉语言大模型最颠覆的地方,不是它精度比ResNet高多少,而是它把"分类"这件事从"训练一个固定输出的分类头"变成了"算图像向量和一段文字向量的相似度"。类别不再写死在网络结构里,而是变成了你可以随时改的一行字符串。这就是"万物分类"这个说法的来源——你不需要为每一个新类别准备训练数据,你需要的是能准确描述这个类别的一句话。
我第一次在项目里用CLIP,是为了处理一个电商素材库的粗分类任务:主图、细节图、场景图、模特图、尺寸表截图、包装图,六个类别,历史数据混杂,标注质量参差不齐。当时团队已经在ResNet上折腾了快三周,准确率卡在七十几上不去,主要原因是"场景图"和"模特图"的边界在标注规范里本身就模糊。换成CLIP做零样本之后,一小时跑完全量数据,准确率直接到八十四,虽然没有达到可以完全替代人工的程度,但足够把人工复核量砍掉七成。从那天起,我对这类模型的定位就很清楚了:它不是用来刷榜的,是用来快速把一条业务链路跑通、把人力成本降下来的。
1.1 传统分类器的天花板其实不在模型容量
很多人把传统分类效果不好归因于"模型不够大",这是误判。以那个素材库任务为例,用更大的骨干网络、加更多数据增强,收益非常有限,因为瓶颈根本不在表征能力,而在标签定义本身。当"场景图"和"模特图"这两个类在人类标注员之间的一致性只有八成的时候,你让模型去拟合一个本身就有噪声的决策边界,它学到的其实是标注员的偏好,而不是真实的视觉规律。
传统分类头的另一个硬约束是输出维度固定。你想加一个类,就得改最后一层,改完就得重新训练,哪怕只加一类。工程上更麻烦的是版本管理:线上跑的是v1模型,v2加了新类,你得同时维护两套预处理、两套推理服务、两套灰度策略。做过这类迭代的人都知道,真正耗时间的不是训练,是这些配套工作。
CLIP绕开了这两件事。它的视觉编码器输出一个固定维度(ViT-B/32是512维,ViT-L/14是768维)的向量,文本编码器也输出同样维度的向量,两者在同一个空间里可以直接做余弦相似度。分类的过程就是:把你要分的K个类别写成K句话,编码成K个向量,然后看图像向量跟哪一个最接近。加一个类别,就是往列表里多加一句话,模型一秒钟都不用重训。
1.2 双塔对比学习:图像和文本被强行拉到同一个坐标系
理解CLIP的关键在于理解它的训练目标。假设一个批次里有N对图文数据,图像塔和文本塔分别把它们编码成N个图像向量和N个文本向量,归一化之后算一个N×N的相似度矩阵。对角线上的N个元素是真实的配对,应该相似度高;其余N²-N个元素是错配的,应该相似度低。损失函数就是让这个矩阵尽量接近单位矩阵——这个叫对称的InfoNCE损失,图像到文本、文本到图像两个方向各算一次。
这里有个容易被忽略的工程细节:负样本完全来自同一个批次内的其他样本。也就是说,批次越大,负样本越充分,学到的表征越好。这也是为什么这类模型训练时动辄要用几千的批大小配合梯度累积,本质是在构造足够难的负样本池。你在下游做微调时如果批大小开得很小,会明显感觉到对比学习的收益不如预期,原因就在这里。
另一个细节是温度系数。相似度矩阵除以一个可学习的温度τ(初值约0.07),再送进softmax。温度越小,softmax越尖锐,模型越"自信"。这个参数是在训练中一起学的,推理时官方实现里用model.logit_scale.exp()把它取出来乘到logits上。我在代码审查里见过不止一次有人直接softmax(相似度矩阵)而不乘这个系数,结果概率分布被压得极平,最高分常常只有0.2出头,完全没法用来做阈值判断。
1.3 零样本推理的完整链路,以及每一行为什么这么写
光说原理没用,直接上能跑的代码。下面这段是我自己在项目里用的最小可运行版本,用的是公开的CLIP权重:
import torch import clip from PIL import Image device = "cuda" if torch.cuda.is_available() else "cpu" model, preprocess = clip.load("ViT-B/32", device=device) model.eval() # 类别描述,注意这里是自然语言,不是标签名 labels = [ "a photo of a main product image on white background", "a close-up detail shot of a product", "a photo of a model wearing the product", "a screenshot of a size chart", "a photo of product packaging", ] image = preprocess(Image.open("sample.jpg")).unsqueeze(0).to(device) text = clip.tokenize(labels, truncate=True).to(device) with torch.no_grad(): image_features = model.encode_image(image) text_features = model.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 = model.logit_scale.exp() logits = logit_scale * image_features @ text_features.t() probs = logits.softmax(dim=-1) print(probs)这段代码里有四处是新手最容易写错的:preprocess必须用clip.load返回的那个,不能自己拿torchvision拼一个;encode_image和encode_text的输出必须手动归一化,官方API不会帮你做;logit_scale.exp()必须乘上;clip.tokenize的上下文长度是77个token,超过会被截断,写长句的时候要留意。
preprocess这个东西尤其值得展开说。CLIP用的归一化参数是mean=[0.48145466, 0.4578275, 0.40821073]、std=[0.26862954, 0.26130258, 0.27577711],不是ImageNet那套[0.485, 0.456, 0.406]。这两个值差得不多,所以错了也不会报错,只会让你的准确率莫名其妙掉几个点,而且掉得很隐蔽,你很难第一时间怀疑到预处理上。我见过一个项目组在这个问题上排查了整整两天,最后是对比官方issue才发现的。
2. 提示词不是玄学:让零样本结果从及格线往上走
零样本分类的准确率对"你怎么写那句话"极其敏感。同一批数据,类别写成cat和写成a photo of a cat,结果可能差三到五个点;写a photo of a cat, a type of pet和写a blurry photo of a cat,方向又不一样。很多人第一次用CLIP的时候拿裸标签词去试,效果不理想就下结论说"这模型不行",其实是没有把文本侧当成一个需要调的东西来对待。
我的经验是:把文本提示词当成超参数来管理,跟学习率、批大小一个待遇。给每个类别准备一组模板,跑一遍验证集,选最好的那个。这件事的投入产出比高得离谱,通常半小时的工作能换来三到八个点的提升,而且完全不需要训练。
2.1 那个"a photo of a {}"到底为什么有用
官方论文里提到,用a photo of a {label}这种模板比直接用{label}平均能提升一点几个点。原因不难理解:CLIP的训练数据是从互联网上爬的图文对,图下面的说明文字绝大多数是完整的自然语言句子,不是孤立的标签词。你喂给它一个裸词,它在训练时见到的这种分布很少,编码出来的向量位置就不是最优的。加上"a photo of"之后,文本的语法结构和训练分布对齐了,编码质量自然更好。
这个逻辑可以继续往下推:如果你的目标图像有很明确的风格特征,把这个特征写进模板里会有额外收益。比如做卫星图像分类,用a satellite photo of a {label}明显好于通用的a photo of a {label};做医学影像,用a radiograph showing {label}这类模板。本质上你是在用文字告诉模型"我这类图长什么样",帮它把文本向量往对应子空间里挪一挪。
还有一个更狠的做法是模板集成:为每个类别准备多个模板,分别编码,然后把向量平均(或者先把概率平均)。官方实现里给ImageNet准备的模板有八十个,集成之后比单模板提升明显。实操中不需要那么多,挑五到十个语义上有差异的模板就够,比如:
| 模板类型 | 示例 | 适用场景 |
|---|---|---|
| 基础句式 | a photo of a {label} | 通用兜底 |
| 带冠词变体 | a picture of the {label} | 类别名是集合名词时 |
| 风格限定 | a close-up photo of a {label} | 目标多为特写 |
| 场景限定 | a photo of a {label} in the wild | 自然场景数据 |
| 模糊限定 | a low quality photo of a {label} | 数据质量差时 |
| 复数形式 | a photo of many {label}s | 目标常成群出现 |
做集成的时候有个取舍:模板越多,单张图要编码的文本越多,但因为文本编码很便宜(几十个token而已),实际开销可以忽略。真正的问题是会稀释掉真正有效的那个模板。我的做法是先单独评测每个模板,只保留top 3到5个,再集成,而不是一股脑全上。
2.2 类别名本身比模板更值得琢磨
模板是锦上添花,类别名才是决定性的。我踩过最典型的一个坑:给一个工业质检场景做缺陷分类,类别之一是"划痕",我直接写了scratch。结果模型把大量"文字印刷"和"细线条纹理"的图也判成了划痕,因为scratch这个词在英文语料里跟线条、纹理的关联太强了。后来改成a deep linear scratch mark on a metal surface,误判率直接降了一半多。
这里的经验是:类别名要往"视觉上能看见的东西"靠,而不是往"业务概念"靠。业务上的"不良品"在视觉上可能表现为"边缘有毛刺"、"表面有凹陷"、"颜色不均匀",你得把这些视觉表现写出来,模型才认。同理,把"电子产品"这种上位概念直接丢给CLIP,效果一定差,因为它在视觉上太发散了;拆成"a smartphone"、"a laptop"、"a pair of headphones"就准得多。
同义词的处理也值得说一句。有些类别在不同地区、不同人群里叫法不一样,比如"沙发"可以叫sofa也可以叫couch,"卡车"可以叫truck也可以叫lorry。稳妥的做法是给一个类别准备多个同义表述,把它们视为同一个类的多个文本向量,取其中的最大相似度,或者把它们的向量平均后再算。
# 同义词合并的写法 synonyms = { "sofa": ["a photo of a sofa", "a photo of a couch", "a photo of a settee"], "chair": ["a photo of a chair", "a photo of an armchair"], } class_features = {} with torch.no_grad(): for cls_name, prompts in synonyms.items(): feats = model.encode_text(clip.tokenize(prompts, truncate=True).to(device)) feats = feats / feats.norm(dim=-1, keepdim=True) class_features[cls_name] = (feats.mean(dim=0, keepdim=True))注意这里是先各自归一化再平均,平均完最好再归一化一次。顺序不能反,先平均再归一化会让长度不一致的向量互相干扰。
2.3 归一化和温度系数:两个必须显式处理的量
前面提过一次,这里展开讲清楚为什么必须做。CLIP输出的原始向量长度不是1,不同图像、不同文本的输出向量的模长差异还不小。如果你直接做点积而不归一化,得到的结果里就混进了模长信息,相似度的物理意义就没了。归一化之后,点积严格等于余弦相似度,取值范围锁在[-1, 1],你好设阈值,也好做跨模态检索。
温度系数的问题更隐蔽。model.logit_scale存的是log值,约2.659,exp之后约14.3,也就是温度约0.07。乘上这个系数之后,相似度差异被放大14倍,softmax出来的分布才有区分度。不乘的话,假设两个类别的余弦相似度是0.28和0.26,直接softmax几乎是0.505对0.495,你根本没法判断模型到底有没有把握。
顺带说一个实用的点:因为有了这个放大,余弦相似度的绝对值本身没太大参考意义,重要的是相对差距。两个类的分数如果是0.31对0.12,那基本可以放心;如果是0.28对0.27,那就是模型在犹豫,这种样本应该进人工复核队列。我在实际项目里就是靠这个"Top1与Top2的分差"来做置信度筛选的,比用softmax概率还稳,因为它不受类别数量的影响。
3. 三条落地路线:纯零样本、线性探针、微调,怎么选
CLIP能用在项目里,但用在哪个层次上是有讲究的。我把它归纳成三条路线:完全不动模型参数的纯零样本、冻结图像塔只训一个轻量分类头的线性探针、以及在图像塔上挂低秩适配器的微调。这三条路线的成本、效果、维护难度差了一个数量级,选错了要么浪费算力,要么效果上不去。
判断标准其实很简单,看两个量:每个类别的可用标注样本数,以及类别之间的视觉区分度。区分度高(比如猫和汽车)、样本数少(几十张以内),纯零样本就是最优解,别折腾。区分度低但有一定样本(每类几百到几千),线性探针的性价比最高。区分度极低且样本充足,才值得上微调。
3.1 纯零样本的适用边界在哪里
零样本真正能打的场景有几个共同特征:类别语义清晰、类别之间差异是"概念级"而不是"细节级"、图像内容相对干净。内容审核里的违规类型粗筛、素材库的素材类型分类、相册的自动归类、电商主图的场景判断,这些都属于零样本的舒适区。
反过来,零样本最容易翻车的场景也很集中。第一是细粒度分类,比如区分某种花的两个亚种,或者区分两个外观极其相似的工业零件型号,这类任务需要的判别信息在CLIP的表征里根本没被强调,零样本准确率经常掉到随机猜测附近。第二是需要计数的任务,问"图里有三只鸟还是四只鸟",CLIP基本无能为力,它的训练目标里没有数量这个维度。第三是空间关系判断,比如"杯子在桌子左边"和"杯子在桌子右边",这类任务上CLIP的表现接近瞎猜,因为对比学习学的是整体的语义对齐,不是结构化的空间推理。
还有一个容易被忽视的限制:文本编码器的输入长度是77个token。你想用一段长描述来精确定义一个复杂的类别,很可能被截断,后半段根本没进模型。这种情况下不如拆成多个短模板分别编码,效果反而更好。
3.2 线性探针:小样本场景下最被低估的方案
线性探针的做法是:把图像塔完全冻结,用训练集过一遍,把每张图的特征向量存下来,然后在这个特征矩阵上训一个逻辑回归或者单层全连接。因为特征已经提好了,训练这件事在CPU上几秒就能完成,成本几乎为零。
这个方案的收益经常被低估。在一个每类只有两三百张图的任务上,我实测零样本大概七十出头,线性探针能到八十八左右,差距非常大。原因很直接:零样本依赖的是文本侧对类别的"通用理解",而线性探针直接在特征空间里找最优的分割超平面,它是针对你的具体数据分布做优化的。只要特征本身够好(CLIP的特征确实好),一个线性层就能榨出大部分价值。
import numpy as np from sklearn.linear_model import LogisticRegression from sklearn.preprocessing import normalize def extract_features(model, preprocess, image_paths, device, batch_size=64): feats = [] for i in range(0, len(image_paths), batch_size): batch = torch.stack([ preprocess(Image.open(p).convert("RGB")) for p in image_paths[i:i+batch_size] ]).to(device) with torch.no_grad(): f = model.encode_image(batch) feats.append(f.cpu().numpy()) feats = np.concatenate(feats, axis=0) return normalize(feats) # L2 归一化后再送进线性分类器 X_train = extract_features(model, preprocess, train_paths, device) X_val = extract_features(model, preprocess, val_paths, device) clf = LogisticRegression(max_iter=2000, C=1.0, class_weight="balanced") clf.fit(X_train, train_labels) print("val acc:", clf.score(X_val, val_labels))这里有几个实操要点。C这个正则化强度在小样本下影响很大,样本少的时候往小调(0.01到0.1),样本多的时候可以放大到1以上,建议做个网格搜索。class_weight="balanced"在类别不均衡时几乎是必须的,不然少数类会被完全压掉。归一化那一步不能省,逻辑回归对特征尺度敏感,不归一化会让某些维度主导决策。
还有个进阶技巧:把零样本的文本特征也塞进训练数据里。具体做法是把每个类别的文本向量当作该类的一个"虚拟样本",跟真实图像特征一起训线性层。这在样本极少(每类不到二十张)的时候能显著稳定训练,相当于给模型一个先验锚点。
3.3 微调与图文检索:什么时候值得动参数
如果你要做的是图文检索(以文搜图、以图搜图),那路线就不一样了,因为你需要的不是分类,而是一个高质量的共享向量空间。这种情况下通常不需要微调,直接拿CLIP的特征建向量索引就行——图像特征入库,查询时把文本编码成向量去检索,因为两者本来就在同一个空间里。
工程上要注意的是索引规模和召回率。特征维度512或768,用精确检索在几十万量级还能接受,上到百万级就必须上近似最近邻索引了。我一般用内积索引配合归一化特征,等价于余弦相似度,索引参数里把构建时的搜索深度调高一点,召回率能上到95%以上,查询延迟在毫秒级。
真正需要微调的场景,是零样本和线性探针都顶不住的时候,比如细粒度分类、专业领域图像(工业缺陷、医疗影像)。这时候我的建议是不要全量微调,用低秩适配的方式在注意力层上加小矩阵,可训练参数只有原来的百分之一不到,显存占用小,过拟合风险也低。全量微调在这个量级的数据上几乎必然过拟合,而且会把CLIP原来学到的通用语义能力破坏掉,得不偿失。
4. 我在实际项目里踩过的坑,以及怎么绕开
这一节讲的东西,官方文档里基本不会写,但每一个都能让你的项目多花两三天。
4.1 预处理不一致,是最难查的一类问题
前面说过归一化参数的问题,这里补充一个更隐蔽的:缩放和裁剪的顺序。CLIP官方的预处理是先按短边缩放到224(用的是bicubic插值),再中心裁剪224。如果你图省事用了Resize((224, 224))直接拉伸,宽高比就变了,模型的输入分布跟训练时不一致,准确率会掉。
还有一个坑是图片模式。有些PNG带alpha通道,有些是灰度图,有些是CMYK。CLIP的预处理期望三通道RGB,遇到四通道图会直接报错,遇到灰度图会静默产生错误结果。统一在读取时加一句.convert("RGB"),能省掉大量莫名其妙的bug。
我曾经遇到过一个特别诡异的情况:同一个模型、同一批数据,两次跑出来的结果差了六个点。排查了一整天,最后发现是两次用的图片读取库不同,一个用PIL一个用OpenCV,两个库在JPEG解码时的色度上采样实现有细微差异,导致像素值在小数点后有偏差,经过归一化放大之后影响了最终结果。这件事之后我定了个规矩:特征提取管线和线上推理管线必须共用同一份预处理代码,且必须有单元测试保证同一张图两次处理结果完全一致。
4.2 细粒度、计数、空间关系,这三个是硬短板
不要试图用提示词工程去弥补模型能力上的结构性缺陷。我见过有人为了让CLIP数清楚图里有几个人,写了一堆a photo of exactly two people、a photo of exactly three people的模板,结果该错的还是错。原因在于CLIP的训练目标里根本没有数量这个监督信号,你怎么写提示词都变不出来。
正确的做法是换工具,不是换提示词。计数任务上目标检测模型加后处理;空间关系任务上要么上用视觉问答模型,要么用检测框的坐标关系做规则判断;细粒度分类上老老实实标注数据做微调。把CLIP放在它擅长的位置上——做概念级的粗分类、做召回的第一道筛子、做跨模态检索的底座——而不是指望它什么都能干。
4.3 中文场景下,文本编码器的选择很关键
原版CLIP的文本塔主要在英文语料上训练,直接拿中文类别名去编码,效果会有明显衰减。我在一个中文场景的项目里做过对比:同一批图,用英文类别名零样本能到八十二,用中文类别名只有六十多,差距相当大。
解决办法有三条:一是直接用英文写类别名,虽然别扭但效果最好;二是换用支持中文的双语模型权重,社区里有不少在多语言数据上训练的变体,中文效果明显改善;三是用翻译模型把中文类别名译成英文再编码,工程上最简单,但要小心翻译歧义,比如"苹果"到底是水果还是品牌,这种情况下得靠上下文提示来消歧。
我的默认选择是第二条,如果找不到合适的中文权重,就退回到第一条。第三条我一般不用,因为多了一个翻译环节就多了一个不确定因素,出了问题很难定位是翻译错了还是模型判错了。
4.4 显存和吞吐:别只看单张延迟
CLIP的推理成本跟输入分辨率强相关。ViT-B/32是224输入、patch大小32,序列长度只有49,跑起来非常轻;ViT-L/14-336是336输入、patch大小14,序列长度576,显存占用和计算量差了一个数量级。做选型的时候一定要按实际吞吐需求算,不要看着大模型精度高就上。
| 模型规格 | 输入分辨率 | 向量维度 | 相对计算量 | 适用场景 |
|---|---|---|---|---|
| ViT-B/32 | 224 | 512 | 1x | 高吞吐粗筛、边缘设备 |
| ViT-B/16 | 224 | 512 | 约4x | 通用场景,性价比最高 |
| ViT-L/14 | 224 | 768 | 约12x | 精度要求高的离线任务 |
| ViT-L/14-336 | 336 | 768 | 约35x | 细粒度、离线批处理 |
实践中我发现一个反直觉的点:批大小不是越大越好。在单卡上把批大小从32加到128,吞吐确实上去了,但如果你的数据里长宽比差异很大,大批次统一resize再裁剪会造成更多的信息损失。我的做法是分批处理时先按宽高比排序,把比例接近的图放在同一批,减少裁剪带来的信息损失,这个改动通常能带来一两个点的提升,而且不花钱。
5. 怎么证明你的方案真的有效:评测环节的实操细节
模型跑通了不等于方案有效。我见过太多项目在demo阶段看着漂亮,一上真实数据就崩,原因基本都是评测环节做得太草率——验证集是从训练分布里随机抽的,跟线上真实分布差了十万八千里。
5.1 验证集必须模拟真实分布
如果你要做的是零样本分类,验证集的构造尤其关键。不要用那种精心挑选的、每类数量完全均衡的样本,因为线上数据永远是不均衡的。我通常会把验证集分成三块:一块是随机采样的、反映真实分布的;一块是困难样本,也就是人工标注时都犹豫的那些;一块是长尾类别的补充样本。三块分别看指标,才能知道方案在什么条件下会失效。
指标上,除了整体的Top-1准确率,一定要看每类的召回率和混淆矩阵。整体准确率在类别不均衡时极具欺骗性,一个把所有样本都判成多数类的模型,在九比一的数据上也能有九十的准确率,但完全没用。看混淆矩阵能立刻发现问题,比如我之前那个素材库任务,混淆矩阵上"场景图"和"模特图"之间有一大块互相错判,这就是提示词需要针对性调整的信号——后来我把模板改成a photo of a product placed in a real-life scene without a person,这块错判明显收敛。
5.2 从混淆矩阵里读出语义冲突
混淆矩阵不只是看指标的工具,它其实能告诉你模型的文本表征里哪些概念被混在一起了。如果A类和B类大量互错,大概率是这两类的文本向量在空间里离得太近,而原因往往是你的类别描述里有共同的词或者共同的语义倾向。
处理方式有两种。一种是改描述,把区分性的视觉特征显式写进去,让两个向量拉开距离。另一种是引入负向描述,也就是在算相似度的时候,不仅看跟目标类的相似度,还要减去跟易混类的相似度。这个技巧在类别少的时候特别有效:
# 简单版负向提示:拉大易混类之间的距离 target_logits = image_features @ text_features[target_idx].t() confuser_logits = image_features @ text_features[confuser_idx].t() adjusted = target_logits - 0.5 * confuser_logits系数0.5是我试出来的经验值,太大会把真正属于目标类的样本也压下去,太小没效果,建议在验证集上扫一遍。
还有一点值得强调:评测要固定随机种子并重复多次。零样本分类本身是确定性的,但如果你在验证集上做模板选择,就是在做超参数搜索,这时候必须留一个独立的测试集,否则你选出来的模板只是在这批数据上过拟合了。这个道理很简单,但实际项目里因为赶进度而忽略的人不在少数,最后上线效果跟验证效果对不上,又得回头重来一遍。
我在几个项目里反复验证过一件事:CLIP这类模型最大的价值不在于替代精调模型,而在于把"验证一个想法是否可行"的周期从两周压缩到两小时。你可以在一个下午之内试完十种不同的分类体系,看看哪种在视觉上最可分,然后再决定往哪个方向投入标注资源。这个能力本身就值回票价,至于最终上线用零样本还是微调,那是后面才需要纠结的事。