去年年底我接了个项目,编号一路挂到“2025_NIPS”,本质上是要把“零样本分类器”和“核方法”这对看起来很不搭的技术组合到一起。目标非常明确:让零样本分类不再只是一堆文本原型向量和图像特征算余弦相似度。这篇文章不打算复述论文原文,我想站在实际调模型、踩坑、反复看结果的角度,把整个方案的来龙去脉、核心原理、可复现细节和排查经验都拆开讲一遍,方便你直接拿去参考。
项目本身可以概括成一句话:利用预训练语言模型为一个类别生成大量语义描述,用文本编码器把这些描述映射成“支持向量”,再用RBF核把图像特征和支持向量的相似度映射成分类得分。这里的“生成”包含两层含义,一是生成描述内容,二是从描述池里生成最佳支持向量子集,两层合起来才是标题里所说的 Support Vector Generation。
如果你正在做视觉-语言模型下游任务、零样本分类,或者对“怎么把经典核方法重新捡起来结合LLM”感兴趣,这篇文章应该能给你一个比较完整的落地视角。我尽量把每一步为什么这么做、参数怎么选、以及我实际踩过的坑都写进去。
1. 这个项目到底在解决什么问题
1.1 零样本分类的“线性天花板”
先说背景。零样本分类的问题设定是:训练阶段模型只见过图像和文本描述的对齐关系,没有见过目标类别的图像,测试时要分类的类别是“新类别”,完全不出现在训练阶段。常见做法是拿预训练视觉-语言模型做零样本推理:把类别名拼成若干个模板(比如“a photo of a [类别]”,或者“[类别] 的图片”),用文本编码器得到文本向量,拿图像编码器得到图像向量,然后算余弦相似度,取最大者作为分类结果。
这个流程在很多开源模型上表现不错,但它本质上是一个线性分类器:决策边界就是图像embedding和类别文本embedding之间的夹角。类别数量少、类别差异明显的时候够用;一旦遇到细粒度场景,比如几十种长得差不多的鸟、农作物病害、商品SKU,单一文本向量没法覆盖类内多样性,线性边界又切不开类间重叠,准确率就会明显下降。这个“线性天花板”不是模型容量不够,而是决策函数的表达形式受限。我试过一些方案,比如对图像特征做线性探测、加MLP头,但这些都需要有标注数据微调,会破坏零样本的初衷。另一个思路是给每个类别多生成几条文本描述,再把这几十条描述向量做平均——平均只是平移了决策边界,并没有改变边界的形状,收益很有限。
1.2 从原型向量到支持向量:一个直觉的转变
后来我换个角度想:既然文本描述可以生成很多条,为什么非要平均,而不是把其中一些作为“支持向量”来用?支持向量这个概念来自经典SVM。在SVM里,最后起作用的不是所有样本,而是离决策边界最近的那一小撮样本,它们决定了分类边界的位置和形状。类比到零样本场景:类别文本描述向量就好比“样本”,我希望能自动挑出一小撮对这个类别最有判别力的描述,作为分类决策的依据。
这个转变的好处是两方面的。一是稀疏,推理时不需要存储和计算几百条描述向量,保留几十条就够了;二是可解释性,模型最终能告诉我“因为这条图像特征和‘翅膀上有白色条纹’这条描述高度匹配,所以我认为它是类别A”。但落到实现上要解决两个问题:第一,怎么从一堆语言模型生成的描述里选出有判别力的支持向量;第二,选了支持向量之后,用什么决策函数把它们组合起来。第二个问题的答案,我最终选择了核方法。
2. 整体方案拆解:生成支持向量后再核化
2.1 一条完整的处理链路
整个项目的处理链路可以分成四个阶段。
描述生成阶段,对每个目标类别,调用预训练语言模型生成K条类别描述,要求覆盖外观、部位、场景、与负类的差异等维度。向量化阶段,把每条描述用VLM的文本编码器映射成文本embedding,图像侧同理,把测试图像用图像编码器映射成图像embedding。支持向量挑选阶段,对每一个类别,从候选描述向量集合中选出一个子集,作为该类别的支持向量,并附带权重。核化分类阶段,测试时计算图像embedding与各支持向量之间的核函数值,按权重求和得到该类的得分,得分最大者为预测类别。
核心是第三和第四阶段。支持向量挑选解决“选哪些”,核函数解决“怎么加权组合”,前面两个阶段属于原料准备和特征提取。为什么把决策放在核化上?因为支持向量集合一旦给定,如果继续用余弦相似度或线性距离来聚合,那么支持向量再多也还是线性聚合,只是相当于做了一次特征加权平均的变体。核函数可以把相似度计算映射到非线性高维空间,让决策边界在embedding空间里变得灵活,但又不像训练一个深度网络那样需要大量标注数据——核方法只需要在样本对之间计算相似度,不需要学习参数,天然适配零样本这种没有训练样本的场景。
2.2 为什么是核方法而不是继续堆参数
有人会问,直接对支持向量集合套一个小型MLP,或者训练一个浅层网络,不是也能拟合非线性边界吗?问题在于零样本设定下没有目标域的标注样本,你没法训练这个MLP。如果有人说可以用源域数据预训练一个Meta-Net,那本质上又从零样本退化成了小样本学习,泛化和收敛都依赖源域分布。
核方法的优势在这里很具体。RBF核(也常被称为Gaussian核)写成 k(x, z)=exp(-γ||x-z||^2),只依赖两个向量之间的距离,没有任何可学习参数。距离近的相似度高,距离远的指数衰减。这种“局部性”正好符合零样本分类的直觉:一个支持向量应该只对离它近的图像特征产生强响应,而不是像余弦相似度那样在全局范围内影响所有图像。换句话说,单个支持向量只负责“守住”embedding空间里它附近的一片区域,多个支持向量各自的局部区域拼接起来,就可以覆盖一个类别内部多个mode的分布,还能在类别重叠区域产生更复杂的边界。
2.3 “生成”这个动作在技术上有两层含义
标题里Support Vector Generation的“生成”值得单独说,它有两层含义。第一层是内容生成:支持向量不是从已标注样本里选出来的,而是由预训练语言模型生成的语义描述编码而来。传统SVM的支持向量来自训练数据,这里没有目标类别的训练数据,所以用LLM“编造”出这些代表类别特征的文本。第二层是集合生成,也就是集合选择:给定候选池,通过某种算法生成“哪个子集作为支持向量集”这个组合决策。两层合起来才是完整的Support Vector Generation。
我最初踩过一个坑:只做了第一层,也就是生成一堆描述向量,然后用全部向量做平均,结果和单模板相比提升很小。后来加入第二层,用稀疏化算法去挑子集,效果才有明显变化。这说明“生成内容”和“生成集合”是两件都要做的事,缺一个都谈不上真正的支持向量生成。
3. 核心细节与实操要点
3.1 文本描述池:决定支持向量质量的原料
支持向量的质量上限在描述池阶段就定死了。描述池太窄,后面再好的挑选算法也巧妇难为无米之炊。我用的prompt设计大致是:先给出类别名和任务背景,要求模型写出“从外观上能区分这个类别与其他相近类别的视觉特征描述”;明确要求描述是纯视觉的、可被图像编码器理解的,比如“嘴巴是弯钩状的”“尾部羽毛有蓝色渐变”,不要出现“这是一种性格温顺的动物”这种非视觉信息;用一个temperature稍高的设置生成多条,覆盖类内多样性;同时让模型生成“与它最容易混淆的类别描述”,作为负类支持向量候选。
描述数量上,我在实验中通常每个类别生成40到80条,挑出20到40条作为最终支持向量。这个数量不是拍脑袋定的。太少,类内多个mode覆盖不住;太多,支持向量池里会出现大量语义重复,稀释了判别信息,而且推理时要算的核值数量线性上涨。语言模型在同一个temperature下多次生成会有随机性,正好利用这种随机性做语义多样性采样。
3.2 支持向量挑选:怎么选出“打得准”的少量向量
候选描述向量有了,接下来要选子集。我实验了三种方式,最后稳定使用的是“贪心边际筛选+去冗余”的思路。
先把每个候选向量的“判别力”量化。我用的是一对多的方式:对类别A的每个候选描述向量,计算它与类别A所有候选向量的平均相似度(类内一致性),再计算它与所有负类描述向量的最大相似度(类间冲突度)。得分 = 类内一致性 - λ * 类间冲突度,λ控制对负类的惩罚力度。然后按得分从高到低排队,逐个加入支持向量集合,但加入前检查它和已在集合中的向量之间的最大余弦相似度,如果超过某个阈值,说明语义重复,就跳过。这样可以保证集合内覆盖广、冗余低。
还有一种尝试是直接用稀疏SVM的思路:以描述向量为训练样本,类别为标签,构造一个小型非线性分类器,然后取出分类器中的支持向量。这个方法理论上有依据,但需要自己构造负类样本,负类采样的质量直接影响选出的向量,不太稳定。我最终没有在线上使用它,但如果你手里有比较干净的正负描述池,这条路径也值得试。
3.3 核函数与得分计算
选好支持向量后,分类器的得分函数是整个方案的核心。
对类别c,设它的支持向量集合为S_c = {s_1,...,s_m},每个支持向量对应权重α_i。对图像特征x,类别c的得分是:
score_c(x) = sum_i α_i * k(x, s_i)
核函数选RBF核:k(x, s) = exp(-γ * ||x - s||^2)。这里的x和s都是单位归一化后的embedding。权重α_i可以取LLM自评的置信度,也可以取挑选阶段的判别力得分,最简单的做法是全部等权。
为什么用RBF而不是余弦相似度?因为余弦相似度本质上是线性点积在归一化空间里的表达,多个支持向量加权求和仍然是线性组合。RBF核引入了指数非线性,让远离支持向量的特征快速衰减,等于在每个支持向量周围划出一个“响应区域”。多个支持向量的响应区域叠加,就能在embedding空间勾出非线性的类别区域。γ这个参数直接控制响应区域的半径。γ越大,区域越窄,决策边界越细碎,容易过拟合某几条描述;γ越小,区域越宽,逐渐退化成全局平均,失去核化的意义。
3.4 超参数速查与设置逻辑
我把经过多轮实验后比较合理的设置列成一张表,方便你直接抄。
| 超参数 | 含义 | 参考范围 | 初始推荐 | 说明 |
|---|---|---|---|---|
| K | 每类生成的描述条数 | 30~100 | 60 | 太少多样性不够,太多语义冗余且计算量上升 |
| M | 每类最终保留的支持向量数 | 10~30 | 20 | 结合冗余阈值自动确定,不必强行卡死 |
| γ | RBF核宽度 | 1~16 | 8 | 与embedding本身的内积标度有关,需归一化后调 |
| λ | 负类惩罚权重 | 0.2~0.8 | 0.5 | 控制“避开混淆类别”的强度 |
| 冗余阈值 | 集合内两向量最大余弦相似度 | 0.8~0.9 | 0.85 | 太高会保留重复语义,太低效果接近全体平均 |
这里强调一个容易被忽略的点:图像特征和文本特征在各自encoder输出的范数、内积尺度可能不同。我统一做了L2归一化,使向量落在单位球面上,这样γ的取值才有跨模态的可比性。如果你不归一化就套标准γ范围,结果会很飘。
4. 参考实现:从零搭一个最小可运行版本
4.1 整体代码结构与数据流
我不会贴一个跑不动的完整工程,而是给出最小可运行的数据流,你拿到之后可以替换成自己手上的VLM和LLM。整个流程用伪Python表达,不依赖任何特定框架,实际实现时用你熟悉的深度学习框架即可。
核心数据结构是三份:desc_pool,字典,类别名映射到描述文本列表;desc_vecs,字典,类别名映射到描述向量矩阵,形状为(K, D);sv_set,字典,类别名映射到支持向量列表,每个元素是(support_vector, weight)。数据流是:类别列表 -> LLM生成描述 -> VLM文本编码器编码 -> 挑选支持向量 -> 保存。推理时:图像 -> VLM图像编码器 -> 与所有类别的支持向量计算核得分 -> 取最大值。
4.2 描述生成与特征提取
描述生成的逻辑可以简化成下面这样:
def generate_descriptions(class_name, n=60): prompt = f"请列出{n}条能帮助在图片中识别'{class_name}'的视觉特征描述," prompt += "要求外观可观察、可区分相近类别,每条不超过15个词。" return llm_batch_generate(prompt, temperature=0.8, N=n)特征提取假设你已经有一个可用的VLM:
def encode_texts(texts): # texts: List[str] # 返回 (N, D) 的归一化文本向量 vecs = vl_model.text_encoder(texts) return normalize(vecs) def encode_image(img): feat = vl_model.image_encoder(img) return normalize(feat)这里有个实操注意点:很多VLM在文本编码时会对输入做模板包装(比如自动加“a photo of”)。如果语言模型生成的描述已经是完整句子,务必关掉模板包装,或者用统一模板重新封装后再编码,否则同一个句子经过两次不同的包装,向量分布会偏移,后续挑选支持向量时类内一致性会失真。我一开始没注意这个,导致挑选出的向量集中在某几个相似模板上,描述池的真实多样性被压缩了。
4.3 支持向量选择算法
我最终使用的贪心选择伪代码如下:
def select_support_vectors(cand_vecs, neg_vecs, lambda_=0.5, max_redundancy=0.85): # cand_vecs: (N, D) 类别A的所有描述向量 # neg_vecs: (M, D) 所有负类描述向量 intra = cand_vecs @ cand_vecs.T # 类内相似度矩阵 inter = cand_vecs @ neg_vecs.T # 类间相似度矩阵 score = intra.mean(axis=1) - lambda_ * inter.max(axis=1) order = np.argsort(-score) sv = [] for idx in order: if not sv: sv.append(idx) continue sims = cand_vecs[idx] @ np.stack(sv).T if sims.max() < max_redundancy: sv.append(idx) if len(sv) >= max_sv: break return sv, score[sv]负类描述向量的来源可以有两种:一是从语言模型专门生成的“易混淆类别描述”中取;二是直接用其他类别被选中的支持向量来充当。第二种有循环依赖,实现麻烦一些,我在实验中用的是第一种,方法更干净。
有一处逻辑容易想当然:我原本以为类内一致性越高越好,后来发现描述太“通用”的向量(比如“这是一种鸟”)类内一致性极高,但鉴别力很差。于是我把类间冲突度放进来做惩罚,才把这类无效描述压下去。这也是为什么score里必须有inter项,不能只看类内。
4.4 零样本推理与评估
推理打分可以这样实现:
def classify(img_vec, sv_dict, gamma=8.0, top_k=20): # sv_dict: 类别名 -> (sv_vecs, weights) scores = {} for cname, (sv_vecs, weights) in sv_dict.items(): dist2 = squared_l2(img_vec, sv_vecs) # (m,) kvals = np.exp(-gamma * dist2) scores[cname] = (kvals * weights).sum() return argmax(scores)如果类别数很多,可以先做一个粗糙的top-k预筛。常见做法:先对每个类别取描述向量的平均原型,计算图像与各原型的余弦相似度,保留top-k类别,再对这k个类别做支持向量核计算。这一步通常能把计算量压到原来的十分之一到二十分之一,对视觉检索类应用很友好。
评估协议上,我按标准的零样本评估方式做:测试类别完全不出现在训练阶段,只报告分类准确率。为了对比,我会同时跑三个基线:单模板余弦、多描述平均、支持向量核方法,同一套图像特征和文本向量,只改聚合层,这样能干净地看出核化带来的收益。我实际观察到的趋势是:在细粒度任务上,核方法相对单模板的提升通常比较明显,相对多描述平均也有几个点的收益;在类别差异很大的通用任务上,相对多描述平均没什么优势,但不掉点。这说明核化支持向量主要解决的是细粒度上的边界问题,它不是万能药。
5. 常见问题与调参排查实录
5.1 支持向量数量怎么定
第一次跑实验的人最容易问:到底选几条支持向量?我的经验是不要拍脑袋定死,而是看两个信号。
第一个信号是“边际收益曲线”。每多加入一条支持向量,记录在验证集上的准确率增量。一般会呈现一个先快速上升、然后平缓甚至下降的趋势,取上升转平缓的拐点位置作为自然数量。第二个信号是集合内的平均冗余度。当候选描述向量之间的相似度普遍超过0.85时,说明描述池已经饱和,再增加支持向量多数是重复信息。
如果你没有验证集,零样本设定里常见这种情况,就用负类冲突度做兜底:当新增向量与负类最大相似度太高,而类内一致性提升很小时,就停。这条经验我是在一次细粒度鸟类任务上踩坑总结出来的,当时我把支持向量数从40加到80,准确率不但没涨,反而因为冗余向量把决策边界带偏了。
5.2 核宽度选多大合适
γ是最容易出错的一个超参。它的物理意义是RBF响应区域的半径。单位归一化后的embedding两两之间的欧氏距离大致在0到2之间,平方距离在0到4之间。γ取1时,即使距离为1的两个向量,核值也有exp(-1)=0.37,响应区域很宽;γ取16时,距离0.5的核值只有exp(-4)=0.018,几乎只有非常近的向量才响应。
我建议先用验证集或随机抽样做一次粗调:在1、2、4、8、16这几个档位跑一遍,看准确率曲线,常见的甜区在4到8之间。如果γ太大,模型会过拟合到某几条描述上,甚至出现“图像特征只匹配一条支持向量、其他支持向量完全失活”的情况,这会导致结果对单条描述质量极其敏感,鲁棒性很差。如果γ太小,核方法的效果趋近于所有支持向量等权线性相加,收益就消失了。
有个实用技巧:用支持向量集合内部的成对距离分布来定γ。统计集合内所有支持向量两两距离的中位数d_mid,把γ设成1/(d_mid^2)附近。这能保证大部分支持向量之间有一定的重叠响应,又不会全体糊成一片。我在项目里把这个初始化方式做成通用函数,省了很多人肉试参。
5.3 语言模型生成的描述带噪怎么办
LLM生成描述不是每次都靠谱。我遇到过三类典型噪声:一是编造了实际不可见的属性,比如把“有红色斑点”写错成“有蓝色斑点”;二是描述过于抽象,比如“非常独特的形状”,在嵌入空间里没有明确的语义锚点;三是把两个相近类别的区分点写成同一句话,导致负类描述和正类描述高度重合。
针对这几种情况,我的处理方式是加一道过滤层。最简单有效的是用LLM自评一致度:让模型对每条描述标注“可见性、区分度、与目标类别的相关性”,各打1到5分,把低于阈值的描述直接排除。还有一种更轻量的做法是统计这条描述在多次重复生成中出现的频率——如果它总是出现,说明是类别稳定的核心特征;如果只出现一次,属于模型随机生成的噪点,优先级降低。
另外,我强烈建议在生成prompt里让LLM同时输出“该类别最常见的混淆对象”,拿这些混淆对象再生成一组负类描述。实验里这个改进比单纯加正类描述数量的收益更明显,因为支持向量的价值在于划定边界,边界需要知道“它不应该是什么”。
5.4 类别多、开销大怎么优化
类别数量上百甚至上千时,逐类别算支持向量核值的开销会超出预期。我给出三个优化手段。
一是top-k预筛,前面在推理部分提过,先用品类原型做粗筛,把候选类别压缩到30个以内,再做核计算,单张图推理时长能压到可接受范围。二是向量量化,支持向量集合的规模不大,但如果你有大量类别,可以对支持向量做聚类,每个类簇保留一个质心向量并乘以簇内权重,把集合规模压缩一半甚至更多,准确率损失通常控制在可接受范围内。三是延迟编码,图像特征可以先用一个相对廉价的搜索索引找出可能命中的类别集,只对这批类别计算精细核得分。
这三种优化可以叠加使用。实测下来,在千类规模下,粗筛加量化组合可以让单图推理时间明显下降,同时保持接近峰值准确率。如果你要上生产环境,这一步基本是必须的。
6. 写在最后的几条实操经验
这个项目做下来,我最大的体会是“生成”不等于“全都要用”。一开始拿到LLM生成的大量描述,我下意识想全量平均,认为信息越多越好,结果却被冗余描述拖累。后来做了支持向量挑选,才把“描述”真正变成了“证据”。
还有一点是关于决策函数的选择。我在做这个项目时曾经在“训练一个浅层MLP聚合支持向量”和“用核方法”之间纠结了很久,最终选择核方法,看中的是它在零样本设定下的优雅:没有可学习参数,就不会因为没有目标域训练数据而过拟合;决策边界由支持向量的位置和γ共同决定,可解释性也更强。
如果你想把这个思路扩展开,我建议试试几个方向:把核函数的中心从文本向量扩展到图文融合向量;用可学习的相似度度量替代RBF核;或者把LLM生成描述时加入图像先验,比如用VLM先对测试图像做一次粗聚类,再针对性生成描述。这些方向我都只在实验中碰过一部分,坑还很多,但确实比固守在余弦相似度上有意思得多。
支持向量这个经典概念,借助预训练语言模型的生成能力重新回到零样本分类场景,我认为后续还有不少文章可以做。以上是我个人的实操经验,不一定对所有模型和数据集都成立,但希望给正在往这个方向尝试的你省一些弯路。