做视觉项目的人大概都遇到过这种场面:需求方临时加了三个新类别,你的分类模型得重新标数据、重新训练、重新评估,一轮下来两周没了;更难受的是有些类别天生样本稀少,标个几十张训出来还不如人眼扫一遍。CLIP(Contrastive Language-Image Pre-training,视觉语言对比预训练模型)就是在这种反复折腾里被越来越多人捡起来的工具——它不教模型"这是什么类",而是让模型学会"这张图和哪句话更配"。类别不再是写死在网络最后一层的固定输出,而是一句话、一个短语,随时能改。这也是很多人把这类视觉语言大模型叫作"万物分类器"的原因:只要有合适的描述词,它就能给没见过的类别打分。这篇东西我打算从它到底解决了什么问题讲起,把双塔结构、对比损失、温度系数这些内部机制拆开说清楚,再给一套能直接跑起来的零样本分类实操流程,中间穿插我自己踩过的预处理不一致、prompt 写歪、阈值乱设这些坑。适合已经会用分类模型但被类别迭代拖累的工程同学,也适合刚接触多模态、想搞清楚"为什么图文对齐能拿来做分类"的入门读者。
1. 分类任务的旧账:为什么"固定类别"这条路越走越窄
1.1 传统监督分类的三处硬伤
监督分类的范式非常干净:定一个类别集合,收集每个类别的标注样本,训一个 Softmax 分类头,输出维度等于类别数。干净带来的代价是僵化,僵化体现在三个地方。
第一个是类别集合一旦定死,改动成本极高。你训了个 20 类的商品识别模型,业务方想加"宠物用品"和"户外装备",理论上可以只训新类的头再接旧模型,但实际上一旦类别间存在视觉相似性(比如"户外装备"里的登山杖和"运动器材"里的球拍),旧数据必须重采样重训,否则新头会把老类别的样本抢走。这种"加了两个类,回归了五个类"的情况我经历过不止一次。
第二个是长尾问题无解。真实业务里总有一批类别只有几十张样本,甚至只有个位数。十几张图训出来的分类头基本等于随机初始化加一点偏移,mAP 掉十几个点是常态。而人力上你很难说服业务方为一个月出单不到十次的品类去标两千张图。
第三个是语义信息被浪费了。传统分类器的标签是一个 one-hot 向量,"猫"和"狗"在标签空间里的距离与"猫"和"汽车"的距离完全一样,模型学不到"猫和狗都是动物"这种先验。你要做层级分类、开放集识别、跨域迁移,都得靠额外的技巧去补。
CLIP 的思路是绕开这三条:既然标签本身是人为定义的文字,那为什么不直接拿文字当监督信号?这一句话就是整篇内容的起点。
1.2 把分类翻译成"图文检索"的核心思路
CLIP 最漂亮的地方不是网络多大、数据多少,而是它把分类问题换了一种问法。传统分类问的是"这张图属于哪一类",CLIP 问的是"这张图和哪句描述最搭"。
具体做法是:把每个类别名塞进一个句子模板,比如类别是"猫",就生成"一张猫的照片"这样的文本;用文本编码器把这句描述编码成一个向量,用图像编码器把待测图片编码成另一个向量;然后算两者的余弦相似度。哪个类别的描述向量和图片向量最接近,就判成哪一类。整个流程里没有任何一步依赖"事先训好的分类头",类别集合完全由你手上那几十句话决定,改类别=改句子。
这个转换带来的直接好处是零样本。你训练时模型见过的是四亿级别的"图片-原始网页文本"对,从来没人为标注过"这是第 3 类",但在推理时你给一句它没见过的描述,它依然能算相似度。这就把"新类别上线"的成本从"标数据+训练+评估"压缩到了"写一句话+跑推理",对业务迭代速度的提升是数量级的。
代价当然也有:它的精度上限不如针对性训练的监督模型,尤其在细粒度任务上差距明显;它对文本描述的质量极其敏感,句子写歪了结果就歪;它算不了计数、空间关系这类需要结构化理解的东西。这些坑后面会单独讲。
1.3 适用边界:什么活儿别硬塞给 CLIP
用 CLIP 之前先想清楚它擅长什么、不擅长什么,能省下大量调试时间。
适合的场景通常具备这几个特征:类别数量中等(几十到几千)、类别之间视觉差异明显、类别会频繁变动、标注预算紧张、需要一个能快速验证的 baseline。比如内容平台的图片打标、电商的粗粒度品类识别、相册的自动聚类、素材库的以图搜图,这些都是 CLIP 的主场。
不太适合的场景同样明显:需要区分同一物种下十几个亚种的细粒度识别(鸟类、车型、花卉品种)、需要数"图里有几个人"的计数任务、需要判断"A 在 B 左边"的空间关系任务、以及要求极高精度(比如工业质检里的缺陷分级,差一个百分点就是几十万损失)。这些任务里 CLIP 可以当特征提取器或辅助信号,但别指望它端到端解决。
注意:判断一个任务能不能上 CLIP,有个很粗暴但很准的自测方法——把候选类别名给一个没受过训练的人看图片,如果他能一眼分对,CLIP 大概率也能;如果他需要盯着细节反复比对,CLIP 大概率不行。
2. 双塔与对比学习:CLIP 内部到底在算什么
2.1 图像塔与文本塔的分工
CLIP 的结构可以概括成两个编码器加一个投影层。图像侧通常是一个 Vision Transformer 或者 ResNet,文本侧是一个 Transformer 编码器,两侧输出经过各自的线性投影,被映射到同一个维度空间里。这个"同一维度空间"是整个设计的核心:只有图像向量和文本向量落在同一个坐标系里,余弦相似度才有意义。
图像侧的常见规格里,ViT-B/32 把 224×224 的输入切成 32×32 的块,得到 7×7=49 个 patch token,加一个 class token 共 50 个;ViT-B/16 是 14×14=196+1=197 个 token;ViT-L/14 是 16×16=256+1=257 个 token,宽度从 768 提到 1024,投影维度从 512 提到 768。patch 越小、token 越多,细节保留得越好,代价是计算量按平方级往上走。表里这几个规格是我实际选型时最常对比的:
| 规格 | patch 大小 | token 数(含 cls) | 编码宽度 | 投影维度 | 相对吞吐 | 典型用途 |
|---|---|---|---|---|---|---|
| ViT-B/32 | 32 | 50 | 768 | 512 | 最快 | 大规模粗筛、CPU 边缘 |
| ViT-B/16 | 16 | 197 | 768 | 512 | 中等 | 通用零样本分类 |
| ViT-L/14 | 14 | 257 | 1024 | 768 | 慢 | 精度优先、检索 |
| ViT-L/14-336 | 14 | 577 | 1024 | 768 | 最慢 | 小目标、细粒度 |
文本侧要注意一个细节:文本编码器有长度上限,通常是 77 个 token,超出的部分直接截断。这意味着你写类别描述时不能用长句子,太长会被切掉后半段,剩下的语义可能完全变味。我在早期用长句描述业务规则时就吃过这个亏,句子后半段"不含赠品"被截掉了,结果把带赠品的图也判进了同一类。
2.2 一个 batch 就是一张 N×N 的判别矩阵
训练阶段是 CLIP 最值得细看的部分。假设一个 batch 里有 N 张图和 N 条对应的文本(这里的"对应"指真实配对,来自同一网页),图像编码器输出 N×D 的矩阵,文本编码器输出 N×D 的矩阵,两者各自做 L2 归一化后做矩阵乘法,得到一个 N×N 的相似度矩阵。
这个矩阵里,对角线上的元素是"正确的图文对",其余 N²-N 个元素是"错误的搭配"。训练目标就是让对角线上的值尽量大、非对角线上的值尽量小。这个目标同时对两个方向做 Softmax:按行做一次,让每张图在 N 条文本里挑出自己的那条;按列做一次,让每条文本在 N 张图里挑出自己的那张。两个方向的交叉熵取平均,就是常说的对称对比损失。
这里有个容易被忽视的经验点:batch size 直接决定了任务难度。N=32 时,模型只需要在 32 个候选里选对 1 个;N=32768 时,它要在三万多条文本里挑出正确的那条。后者显然难得多,学到的表征也更 discriminative。这也是为什么这类模型训练动辄需要上千张卡的显存——负样本越多,学到的特征越细。你自己做小规模微调时如果发现效果上不去,先看看 batch size 是不是太小了,这是最常见的原因之一。
2.3 温度系数 logit_scale:最容易被忽略的那个旋钮
相似度算完之后不能直接进 Softmax,中间要先乘一个温度系数的倒数。在实现里通常存成一个可学习参数logit_scale,初始化为 log(1/0.07) ≈ 2.6592,也就是初始温度 0.07。乘完再取指数,得到的就是缩放后的 logits。
为什么要这一步?因为余弦相似度的取值范围是 [-1, 1],N 很大时这些值挤在一个很窄的区间里,Softmax 之后几乎均匀分布,梯度信号极弱,模型学不动。除以一个很小的温度(等于乘以一个很大的数,初始约 14.3 倍)能把分布拉开,让正确的对子获得足够强的概率优势。训练过程中这个参数会自己调整,上限一般卡在 log(100) ≈ 4.6052,防止无限放大导致数值不稳定。
这个参数在做推理时必须用模型自带的那个值,不能随手设成 1。我刚上手时图省事直接对相似度做了 argmax,看起来结果也还行,但一旦需要输出概率或者做阈值判断就全乱了——因为没有缩放,所有类别的概率都挤在 0.02 到 0.05 之间(假设 50 个类别),完全没法设拒识阈值。后来改成读取模型的logit_scale,概率分布立刻变得可用,正确类别的置信度通常在 0.7 以上,误判样本的置信度则明显低一截。
2.4 四亿图文对换来的能力与代价
CLIP 的能力底座是四亿量级的"图片-网页文本"配对数据。这些文本没有经过清洗,是网页上图片旁边的 alt 文本、标题、说明文字,噪声极大——很多配对的文本和图片关系很弱,甚至是广告词。但胜在规模够大、覆盖面够广、语言足够自然。
自然语言监督带来的好处是语义泛化能力强。因为它见过的是人类真实描述世界的方式,而不是人工定义的标签体系,所以它对"同义词""上下位词""描述性短语"都有一定的鲁棒性。你用"一只小狗"和"一只幼犬"去描述同一个类别,得到的向量通常很接近,这在纯标签训练的模型里是做不到的。
代价主要有三块。一是数据噪声导致的能力不均衡:常见物体、常见场景表现很好,专业领域(医学影像、工业零件、天文图像)明显偏弱,因为这些内容在网页文本里的描述密度低。二是对文本措辞敏感:同一个类别换个说法,准确率可能有几个点的波动,这是后面要专门讲 prompt 工程的原因。三是无法处理否定和复杂逻辑:"没有人的房间"这种描述它基本理解不了,因为它学到的是词和图的粗粒度共现,不是逻辑推理。搞清楚这些边界,比盲目调参有用得多。
3. 零样本分类实操:从加载模型到输出标签
3.1 环境准备与模型规格选择
先装依赖,常规的深度学习环境加模型加载库就够:
pip install torch torchvision transformers pillow numpy选模型规格有个实用原则:先用 ViT-B/32 跑通全流程看基线,再决定要不要上大模型。B/32 在单张消费级显卡上处理几千张图毫无压力,跑出来的准确率能让你判断任务本身是否可行。如果 B/32 的准确率已经在 70% 以上,换 B/16 通常能涨 3 到 5 个点,换 L/14 再涨 2 到 3 个点,但速度掉一半以上。如果 B/32 只有 30%,别急着换大模型,先检查预处理和 prompt,八成是这两处的问题。
加载模型的标准写法:
import torch from transformers import CLIPModel, CLIPProcessor device = "cuda" if torch.cuda.is_available() else "cpu" model_name = "openai/clip-vit-base-patch32" # 换成对应规格的权重标识 model = CLIPModel.from_pretrained(model_name).to(device).eval() processor = CLIPProcessor.from_pretrained(model_name)几个必须做的收尾动作:.eval()关掉 dropout 和 BN 的滑动统计更新;推理包在torch.no_grad()里;能用 fp16 就用 fp16,显存直接减半,速度提升 30% 到 60%,精度损失在分类任务上几乎看不出来。
注意:
processor里封装了图像预处理和文本分词两套逻辑,必须成对使用。我见过有人图像处理用了 A 版本的 processor、文本用了 B 版本的 tokenizer,两边词表和归一化参数不一致,结果怎么调都不对。
3.2 预处理必须对齐:最容易翻车的一步
CLIP 的图像预处理是固定的四步:短边缩放到指定尺寸(通常是 224)、中心裁剪到 224×224、用 BICUBIC 插值、按 ImageNet 的均值方差归一化(mean 约 [0.4815, 0.4578, 0.4082],std 约 [0.2686, 0.2613, 0.2758],精确值以 processor 给出的为准)。
这四步里最容易出问题的是归一化和裁剪。归一化错了(比如用了 0-255 的原始像素没除 255,或者漏了减均值),模型输出会完全失去意义,但因为不报错,你会以为是模型能力问题。中心裁剪的问题是:如果你的业务图片里主体经常不在画面中央(比如商品图主体偏左),中心裁剪会把主体切掉一部分。这种情况可以考虑改成缩放整图不裁剪,但要注意这会引入训练和推理的分布差异,实测下来通常还是中心裁剪更稳,除非你的数据确实特殊。
还有一点是颜色通道顺序。OpenCV 读图默认是 BGR,PIL 是 RGB,混用会导致红蓝通道互换,模型依然能输出结果,但准确率会莫名其妙地掉十几个点。这个坑非常隐蔽,我排查过一次,最后发现就是读图库没转 RGB。
预处理这块可以完全交给 processor,别自己写:
from PIL import Image img = Image.open("test.jpg").convert("RGB") # 强制转 RGB,别省这一步 inputs = processor(images=img, return_tensors="pt").to(device) with torch.no_grad(): image_features = model.get_image_features(**inputs) image_features = image_features / image_features.norm(dim=-1, keepdim=True)最后那行归一化不能忘。模型输出的原始向量模长不是 1,不归一化直接点乘得到的是内积而不是余弦相似度,结果会被向量模长带偏。文本侧同理。
3.3 prompt 构造:类别词和模板的写法
文本侧的处理流程是:给每个类别构造一句描述,批量编码,归一化,缓存起来。
class_names = ["猫", "狗", "汽车", "飞机", "花"] templates = ["一张{}的照片"] texts = [templates[0].format(c) for c in class_names] text_inputs = processor(text=texts, return_tensors="pt", padding=True).to(device) with torch.no_grad(): text_features = model.get_text_features(**text_inputs) text_features = text_features / text_features.norm(dim=-1, keepdim=True)这里有几个实操要点值得展开。
第一,模板别只用一个。原论文里用了 80 个模板做集成,包括"一张{}的照片""一张{}的特写""{}的素描""一张模糊的{}"等等。做法是把同一个类别的 80 个文本向量取平均后再归一化,相当于把类别的语义中心估计得更稳。实测下来单模板和 80 模板集成的准确率差 2 到 4 个点,这个提升是免费的,没有额外计算成本(文本编码只做一次,缓存在内存里即可)。我通常用 10 到 20 个精选模板就够,不用上 80 个。
第二,类别词要具体、可视觉化。"交通工具"这种抽象上位词效果很差,"一辆银色的轿车"就具体得多。而且要注意多义词,比如"crane"既是起重机也是鹤,模型会同时被两个语义拉扯,向量变成一个奇怪的中间态。解决办法是在描述里加限定词把语义钉死,比如"一台施工用的起重机"。
第三,类别之间不能互相包含。如果类别集合是["狗", "动物", "宠物"],这三个描述在语义空间里高度重叠,模型很难分辨,因为"狗"的图片确实也符合"动物"和"宠物"的描述。这种情况要么把层级理清只保留一层,要么在描述里加上互斥的限定词。
第四,中文任务要选对模型。英文为主的权重对中文文本的编码能力很有限,直接输入中文类名效果通常不理想。两个办法:一是把中文类名翻译成英文再喂给英文模型(实测这个办法在多数场景下比直接用中文权重还好),二是换成用中文图文对训练过的对齐版本。我一般先试翻译,成本最低,效果不够再考虑换模型。
3.4 相似度、温度缩放与 softmax 的完整代码
把上面几步串起来,完整推理流程大概是这样:
import torch def zero_shot_classify(images, class_names, model, processor, device, topk=3): templates = [ "一张{}的照片", "一张{}的特写照片", "一张清晰的{}图片", "一张模糊的{}图片", "{},拍摄于白天", ] # 1. 文本侧:多模板平均 + 归一化,只算一次并复用 text_feats = [] for c in class_names: batch_texts = [t.format(c) for t in templates] ti = processor(text=batch_texts, return_tensors="pt", padding=True).to(device) with torch.no_grad(): tf = model.get_text_features(**ti) tf = tf / tf.norm(dim=-1, keepdim=True) tf = tf.mean(dim=0) # 模板集成 tf = tf / tf.norm(dim=-1, keepdim=True) # 平均后要重新归一化 text_feats.append(tf) text_feats = torch.stack(text_feats) # [C, D] # 2. 图像侧 ii = processor(images=images, return_tensors="pt").to(device) with torch.no_grad(): imf = model.get_image_features(**ii) imf = imf / imf.norm(dim=-1, keepdim=True) # [N, D] # 3. 相似度 + 温度缩放 logit_scale = model.logit_scale.exp() # 模型自带的可学习温度倒数 logits = logit_scale * imf @ text_feats.T # [N, C] probs = logits.softmax(dim=-1) top = probs.topk(min(topk, len(class_names)), dim=-1) return [ [(class_names[i], round(float(p), 4)) for p, i in zip(ps, idxs)] for ps, idxs in zip(top.values, top.indices) ]这段代码里有三处细节决定成败。text_feats的循环在类别多的时候很慢,但文本特征是固定的,完全可以离线算好存成 .npy 文件,线上只做图像编码,几千个类别也就是几毫秒的矩阵乘法。logit_scale一定要从模型里取,前面说过原因。模板平均之后一定要重新归一化,因为多个单位向量的平均向量模长小于 1,不归一化会让这类别的分数被系统性压低。
关于批量处理,还有一个提速技巧:图像编码是瓶颈,把 batch 开大能显著提升吞吐,但要注意显存。ViT-L/14 在 fp16 下,单张 24G 卡跑 batch 64 大概占 12 到 14G,跑 batch 128 就开始危险了。我一般按显存的 70% 反推 batch 大小,留出余量给输入尺寸波动。
3.5 拒识阈值:处理"哪个都不像"的样本
零样本分类最大的工程陷阱是Softmax 会强行分配。哪怕输入是一张纯噪声图,Softmax 之后各类概率加起来也是 1,照样会有一个"最大概率"的类别被选出来。如果你直接取 argmax,模型会以 100% 的自信把一张毫无意义的图判成"猫"。
解决办法是加一层拒识。常用两种判据:一是 Softmax 最大概率低于阈值就判为"未知",二是最大余弦相似度低于阈值就拒识。前者受类别数影响(类别越多,最大概率越低),后者不受,我一般用后者,阈值通过验证集标定。
标定的方法很朴素:准备一批已知类别的正样本和一批明显不属于任何类别的负样本,统计两者的最大相似度分布,找一个能把绝大部分正样本留下、同时滤掉大部分负样本的分位点。实践中不同模型的相似度分布差异很大,同一个模型在不同领域数据上差异也大,所以阈值必须用你自己的数据标,不能抄别人的经验值。
另外一个小技巧:logit_scale乘上相似度之后做 Softmax,最大概率其实是一个不错的综合置信度指标,因为它同时考虑了"这个类别有多像"和"比其他类别像多少"。我通常两个指标都算,取交集拒识,召回损失可控。
4. 影响范围:CLIP 撑起来的那些场景
4.1 图文检索与以图搜图
分类之外,CLIP 最直接的应用是检索。因为图像和文本在同一个向量空间里,你可以把图片库全部编码成向量存进向量索引,然后用一句话去查图,也可以用一张图去查相似的图。这就是内容平台"以文搜图"、素材库"相似图推荐"、相册"按描述找照片"的底层实现。
工程上要注意两点。第一是向量存储的规模。几万张图暴力算内积没问题,几百万张就必须上近似最近邻索引,用 HNSW 或者 IVF 系列,召回率设 0.95 以上时查询延迟通常在毫秒级。第二是索引刷新策略。图片是持续入库的,索引要支持增量添加,别每次全量重建,几百万条重建一次的时间成本不可接受。
以图搜图还有一个常见的精度问题:用同一张图查自己,相似度应该是 1.0;但如果查询图和库里的图经过了不同的预处理(比如查询图是用户上传的原图,库里的图是缩略图),相似度会掉到 0.85 左右,可能排不到第一位。解决思路是入库和查询用完全一致的预处理管线,不要一个走 processor、一个走自定义 resize。
4.2 当通用特征提取器用
把最后的分类头扔掉,只取图像编码器的输出,你得到的就是一个 512 维或 768 维的通用视觉特征。这个特征在很多下游任务上表现好得超出预期,尤其是标注数据很少的时候。
最典型的用法是线性探针:冻结 CLIP 图像编码器,只在特征上训一个线性分类器。我做过一次对比,在只有几百张标注图的小任务上,线性探针的准确率比从头训一个小 CNN 高出 15 个点以上,因为 CLIP 的特征已经把大部分视觉语义编码进去了。数据量越大,两者的差距越小,到几万张时基本持平。
另一个用法是聚类和去重。图像向量归一化之后算余弦距离,做层次聚类或者 DBSCAN,可以直接把图库自动分组。做数据清洗时用它找重复图、找近似重复图(加了水印、改了尺寸的同一张图,向量相似度通常在 0.95 以上),比感知哈希靠谱得多。我在整理一批十几万张的素材时用过这招,把重复率从 30% 压到 3% 以下,人工复核成本降了一个数量级。
注意:CLIP 特征对文字和排版特别敏感。两张内容完全不同但排版相似的图(比如都是白底黑字的卡片式设计),向量相似度会异常高。做去重时这类误判要单独处理,我的办法是在向量里剔除掉高对比度区域的特征响应,或者直接用 OCR 结果做二次校验。
4.3 作为多模态系统的积木
这两年 CLIP 更大的价值其实不是自己当模型用,而是当别人的组件。文生图系统里,文本提示要先被编码成条件向量,很多方案直接用 CLIP 的文本编码器;多模态对话系统里,图像要先被编码成若干视觉 token 塞进语言模型,很多方案直接用 CLIP 的视觉塔。这种"语言模型 + 视觉编码器"的拼接范式,本质上就是靠 CLIP 已经把图文对齐好了,中间的连接层只需要学一个很薄的映射。
这也解释了为什么 CLIP 的变体这么多:有人把对比损失换成 sigmoid 形式去掉 batch 内的全局归一化依赖,让训练更省显存;有人把文本塔换成更大更强的语言模型,视觉塔不动;有人专门做中文、日文、多语言的对齐版本。这些变体的改进方向各不相同,但核心的"双塔 + 对齐"结构没变。
对于做应用的团队,这意味着一个很实际的判断:如果你只需要分类和检索,用现成的对齐模型就行,不用追新;如果你要把它接进自己的多模态系统当组件,那视觉塔的规格、输出 token 数、是否支持变分辨率,这些才是选型的关键指标,而不是分类准确率高半个点。
5. 踩坑实录与排查清单
5.1 准确率不及预期的六种原因
我整理了一份按"排查优先级"排序的清单,前面几条出现频率最高,建议按顺序过。
预处理不一致,排在第一位。表现是准确率稳定偏低但不算离谱(比如 50% 到 60%),模型输出不像完全乱掉。检查点:是否正确转 RGB、是否漏了归一化、Resize 和 Crop 的顺序是否和 processor 一致、插值方式是否一致。
类别词歧义或过于抽象。表现是某些特定类别准确率明显低于其他类别。检查方法很直接:把类别名单独拿出来看,问自己"一个没背景知识的人能凭这句话判断吗"。不行就换具体说法。
语言不匹配。中文类名喂英文模型是最常见的组合错误。表现是所有类别的置信度都偏低、分布很平。先做翻译再测一遍,如果准确率跳涨十个点以上,就是这个原因。
类别重叠。表现是混淆矩阵里有两个类别互相大量错分。检查方法看混淆矩阵,如果 A 类有一半被判成 B,而 B 类也有一半被判成 A,基本可以确定描述语义重叠,需要加互斥限定词或者合并类别。
缺少模板集成。单模板的方差很大,某些类别可能恰好被某个模板"坑"了。加模板集成是成本最低的改动。
模型规格不够。如果任务本身确实需要细粒度分辨,前面几条都排查过了还是不行,那就换大模型或者考虑微调。这是最后的手段,不是第一选择。
把这六条做成排查顺序而不是随机乱试,能少走很多弯路。我见过有团队花两周调数据增强,最后发现是 OpenCV 读图没转 RGB。
5.2 显存、速度与工程化细节
工程化的坑和精度坑是两回事,但同样能拖垮项目。
显存方面,除了用 fp16、减小 batch,还有个容易被忽略的点是文本编码的显存占用。类别多的时候(比如 5000 个类别乘 20 个模板 = 10 万条文本),一次性编码会把显存打爆。做法是分批编码,每批 1000 条,编码完立刻转成 numpy 存起来,释放显存。文本特征全程只在 CPU 内存里,不占 GPU。
速度方面,图像编码是绝对瓶颈。几个实测有效的优化:把torch.backends.cudnn.benchmark设为 True(输入尺寸固定时有效);用torch.inference_mode()替代torch.no_grad(),前者开销更小;把 DataLoader 的num_workers开到 CPU 核数的 1.5 倍左右,IO 密集时能减少等待;如果只是过一遍全量数据做离线打标,用 TensorRT 或者 ONNX Runtime 加速,实测能有 2 到 3 倍提升。
数值稳定性方面,Softmax 之前记得做减最大值的处理(PyTorch 的softmax内部已经做了,自己用 numpy 实现时要手动处理)。余弦相似度做除法时,模长为 0 的向量会导致 nan,加个 eps 兜底。
服务化方面,模型加载慢是一次性成本,别在每个请求里重新from_pretrained。用常驻进程加请求队列,模型只加载一次。文本特征预计算并存成文件,服务启动时加载到内存。这样单次推理的成本就只剩图像编码加一个矩阵乘法,在单张中端显卡上做到几十 QPS 不难。
5.3 问题速查表
把上面散落的问题汇总成一张表,出问题的时候可以直接对着查:
| 现象 | 最可能的原因 | 快速验证方法 | 处理方式 |
|---|---|---|---|
| 准确率 50% 上下,各分类均匀错 | 预处理不一致或通道顺序错 | 换用 processor 全流程重测 | 统一预处理管线 |
| 所有类别置信度都很平(0.02 到 0.05) | 没用 logit_scale 缩放 | 打印相似度最大最小值 | 乘以model.logit_scale.exp() |
| 某几个类别总是互相混淆 | 类别词语义重叠 | 看混淆矩阵对角线外分布 | 加互斥限定词或合并 |
| 中文类名效果差 | 语言不匹配 | 翻成英文重测对比 | 翻译或换中文对齐版本 |
| 输入噪声图也有高置信度 | 缺少拒识机制 | 构造负样本测相似度分布 | 加相似度阈值拒识 |
| 长描述效果诡异 | 文本被截断到 77 token | 打印 token 数 | 描述控制在 20 词以内 |
| 文本编码阶段 OOM | 类别数×模板数过大 | 看显存峰值 | 分批编码,特征落盘 |
| 同一张图查不到自己 | 入库与查询预处理不同 | 对比两条链路的预处理参数 | 统一成同一套 |
| 文字排版图相似度虚高 | 特征对排版敏感 | 找两张同版式异内容的图测 | 叠加 OCR 或文本检测做二次校验 |
| 大批量打标速度慢 | IO 阻塞或 batch 太小 | 看 GPU 利用率 | 加 worker、调大 batch、fp16 |
这张表里的每一行我基本都亲身踩过,尤其前三条,属于新手必踩、老手偶尔也会忘的级别。
6. 几条实测下来比较实在的经验
6.1 冻结微调与 prompt 调优怎么选
摆在面前的路通常有三条:纯零样本、只调 prompt、做微调。选择依据其实就一个——你手上有多少标注数据,以及类别会不会一直变。
零标注、类别频繁变,纯零样本加模板集成,两小时内能上线一个能用的版本,这是 CLIP 最大的价值。有每类几十张标注、类别基本固定,那就调 prompt:把 T 个模板的向量改成可学习参数,冻结两侧编码器只训这几十个向量,显存需求极小(单卡就能跑),在少样本设置下通常能把准确率再拉 5 到 10 个点,而且训练只要几分钟。有每类几百上千张、要追求极致精度,才考虑做部分微调——解冻视觉塔的最后几层或者加个适配层,全量微调基本没必要,容易过拟合还破坏原有的泛化能力。
有个经验细节:微调会显著削弱零样本能力。你在 20 个类上微调完之后,模型对这 20 类很准,但换一批新类别可能还不如微调前。如果业务上类别会持续新增,微调要谨慎,或者保留一份原始权重做双路兜底。
6.2 还能往哪儿扩
如果这套流程跑顺了,有几个方向可以继续往下做。一是把单标签分类扩成多标签,做法是对每个类别独立算相似度并与该类别自己的阈值比较,而不是做 Softmax 竞争,这样一张图可以同时命中"户外""人""树"多个标签。二是把 CLIP 特征接进向量数据库做混合检索,文本查图和图查图共用一个索引,实现"以文搜图 + 相似推荐"一套打通。三是把它当成数据清洗工具,先用零样本给海量无标注数据打粗标,人工只复核低置信度的那部分,标注效率能提升好几倍,这是我目前用得最顺手的一个方向。
最后分享一个我自己的笨办法:每次上新任务,我都会先挑 50 张图手工跑一遍,把 CLIP 的 top-3 结果和我的判断逐条对照,把错的地方归类——是类别词的问题、是预处理的问题、还是任务本身超出模型能力。这 50 张图的功夫,比后面盲目调三天参都值。