零样本学习:语义对齐、广义评测与工程选型指南
简介:面向零样本学习入门、课程实验与论文复现需求整理的一份实践包,围绕零样本人体行为识别等场景,提供基础复现和基于PyTorch的原文复现思路,适合具备一定机器学习基础、希望对照论文理解语义空间、属性表示与兼容性方法的学生和研究者使用。压缩包共11个文件,约38.98MB,包含6个Python脚本、2份PDF论文及说明、1个Excel属性表、1个文本说明和1个Jupyter Notebook,分别用于数据处理、模型实现、属性矩阵查看与实验记录,便于按模块阅读、运行和调试。该资源已有2851人学习下载。包内附复现论文、语义空间矩阵、数据集说明及程序代码,因数据集体积较大需自行下载;读者可据此梳理从数据读取、属性构造到零样本分类验证的完整流程,并结合Notebook和说明文档快速定位关键实现与参数设置。
1. 零样本学习到底在解决什么:一个从没见过也能认出来的问题
先做个小测试。你教一个小孩认猫和狗,给他看了几十张照片,他基本就不会认错了。现在你拿出一张他从未见过的羊驼照片,问他这是什么,他大概会愣一下,然后说「像羊,又像骆驼」。他没有被教过「羊驼」这个类别,但他靠「毛茸茸」「长脖子」「四條腿」这些描述,把一个新类别挂到了已有的认知上。
零样本学习(zero-shot learning)要干的就是这件事:让模型在训练阶段完全没见过某个类别的样本,推理时却能把它认出来。这里的「零」指的是目标类别的标注样本数量为零,而不是模型什么都不用学。它真正依赖的是另一份东西——每个类别的文字描述、属性清单或者词向量,相当于给每个类别配一张「说明书」。模型学的是「图像长什么样」和「说明书上怎么写」之间的对应关系,遇到新类别时,靠读说明书来猜。
这个概念值得搞懂,是因为它戳中了所有做分类任务的人都会撞上的那堵墙:你能标注的类别永远是有限的,而现实世界的新类别是无限的。搞懂零样本学习,你就知道模型的能力边界在哪里,也就不会在业务里对它抱有不切实际的期待。
2. 为什么会有零样本学习:标注成本与类别膨胀的现实
2.1 人工标注的天花板在哪里
常规的图像分类是一条很朴素的流水线:收集图片、人工打标签、训练模型、上线。这套流程在类别少、样本充足的时候非常有效,但它的成本结构是线性的——每增加一个类别,就要重新收集几百上千张图,再雇人一张张标。一条产品线有二十个类别还好说,有兩万个类别时,标注预算会先于算法崩溃。
更麻烦的是长尾。真实的业务数据里,绝大部分类别只出现寥寥几次,剩下的海量类别每个都只有个位数的样本。你不可能为每个稀有类别都配一份像样的训练集。还有一类情况是类别本身在动态增长:今天上线时只有一百种商品,三个月后运营加了两百种新品,模型如果不认识新品,就永远把它们判成某个老类别,错得还很自信。
这就是零样本学习存在的第一层理由:它把「认识新类别」的成本,从「收集并标注样本」转移到了「写一段描述」。写描述这件事,人力成本低一个数量级,而且可以批量做、可以外包、甚至可以由模型自动生成。虽然精度会打折扣,但在很多场景里,「大致认对」比「完全不认识」有价值得多。
提示:零样本不等于免标注。它省掉的是目标类别的样本标注,换成了类别层面的语义标注。语义描述写得糙,后面的精度会一路塌方。
2.2 语义描述:给每个类别留一份「说明书」
要让模型认识一个没见过的类别,你必须提供某种「关于这个类别的知识」。最常见的载体是属性(attribute)。比如鸟类识别里,每个类别会被描述成一组可判断的二元属性或数值属性:有没有红色头顶、翅膀有没有条纹、喙是长是短、体型大概多大。一只没见过的鸟,只要它的属性组合能和已知类别区分开,模型就有机会把它分对。
另一类载体是自然语言描述。与其手动列属性,不如直接写一句话:「一种体型较小的鸟,背部和翅膀呈橄榄绿色,腹部偏黄,有明显的白色眼圈。」大规模图文预训练模型走的就是这条路,它把整段文本编码成一个向量,作为类别的语义表示。
属性描述和自然语言描述的区别,打个比方就是填表格和写作文。填表格精确、规整、便于计算,但需要领域专家设计属性体系,换个领域就得重来;写作文灵活、覆盖广、随手就能写,但信息密度低、噪声大,模型得自己有本事从文字里抽出关键点。早期的零样本研究几乎都在填表格,近几年的趋势明显偏向写作文。
这里有个容易被忽略的细节:语义描述必须是「可判别」的。如果所有类别的描述都写成「有毛、有四条腿、会动」,那这份说明书对分类毫无帮助。好的属性体系要能把类别之间的差异撑开,让不同类别在语义空间里离得足够远。
2.3 从属性到词向量:桥梁是怎么搭的
把语义变成数字,是整件事的技术前提。属性可以直接拼成一个定长向量,每一位对应一个属性,取值零或一,或者一个连续分数。文本则用词向量或句向量模型编码成几百维的稠密向量。不管哪种方式,最后每个类别都会得到一个定长的语义向量,所有类别排在一起,就构成了一个语义空间。
这个空间有意思的地方在于,它自带结构。语义相近的类别在空间里距离近,语义差别大的类别距离远。这种结构不是人为规定的,而是从描述本身长出来的。零样本学习之所以能泛化,靠的就是这份结构:模型在见过的类别上学到的「视觉到语义」的映射关系,可以顺着这份结构延伸到没见过的区域。
但语义空间也有它的问题。它是人写的,带有主观性和不完整性;它是粗糙的,一个类别压成几百个数字,丢掉了大量细节;它和视觉特征之间还存在天然的鸿沟——文字里的「条纹」和图像里的条纹纹理,是两种完全不同的信号。整条技术路线的难点,几乎都集中在怎么把这两个空间对齐上。
3. 零样本学习的核心机制:把图像和文字塞进同一个空间
3.1 视觉空间与语义空间的对齐
最经典的思路叫嵌入映射。模型里有两套东西:一套是图像编码器,把一张图变成视觉向量;另一套是类别语义向量。训练阶段只用见过的类别,目标是学一个映射函数,把视觉向量投到语义空间里,或者反过来把语义向量投到视觉空间里,让「图像投过去的位置」和「它所属类别的语义向量」尽量重合。
推理阶段是这套思路最精妙的地方。来一张没见过的类别的图,编码器先把它变成视觉向量,映射函数把它投到语义空间,得到一个落点。然后拿这个落点和所有候选类别的语义向量算相似度,最近的那个就是预测结果。注意,候选集里可以包含训练时压根没出现过的类别,模型不需要为它们准备任何分类头。输出空间是动态的,这是零样本和普通分类最本质的差别。
不同的做法在「往哪个方向投」上有分歧。投到语义空间的好处是语义空间维度低、结构稳,缺点是视觉信息被压缩得太狠;投到视觉空间则保留了更多图像细节,但视觉空间维度高、噪声大,容易过拟合到见过的类别上。后来的研究又提出了第三种方案:在两个空间之间搭一个中间空间,让两边都往里投。这类方法在论文里经常被表述成一个双线性的相容函数,形式不复杂,但效果好。
3.2 生成式路线:先造样本再分类
上面那条路有个绕不开的毛病:模型从没真正见过目标类别的样本,只能靠相似度去猜,一旦语义描述和真实视觉分布对不上,预测就会翻车。生成式路线换了个思路——既然缺样本,那就造样本。
具体做法是训练一个条件生成模型,输入是类别的语义向量,输出是对应的视觉特征。训练时用见过的类别学「给定语义向量怎么生成特征」,训好之后,把没见过的类别的语义向量喂进去,就能凭空生成一批该类的视觉特征。有了这批合成特征,零样本问题就被转化成了普通的监督分类问题,后面接一个常规分类器就行。
这条路子把零样本的难点从「跨空间对齐」挪到了「生成质量」上。生成的特征够不够真、多样性够不够、有没有塌缩到少数几个模式,直接决定最终精度。好处是它天然支持广义零样本场景,因为合成样本可以和真实样本混在一起训练,缓解后面要说的偏置问题。
注意:生成式方法看着很美,但它对生成器的训练稳定性极其敏感。同一份代码换个随机种子,结果可能差好几个点,这部分调参有不少玄学成分。
3.3 广义零样本与那个叫 H 的指标
早期的零样本评测有个理想化假设:测试时只在没见过的类别里选。这叫传统零样本,设定干净,但和现实脱节——真实系统面对的往往是见过的和没见过的类别混在一起,模型必须先判断「这是不是新类别」,再判断「是哪一类」。这个更难的设定叫广义零样本学习。
广义设定一上来就暴露了一个尖锐的问题:偏置。模型在训练时只见过老类别,天然会偏向老类别,新类别的样本很容易被判成某个语义相近的老类别。极端情况下,模型把所有测试样本都判成老类别,老类别准确率能到九成以上,新类别准确率接近零,但整体准确率看着还挺好看。这就是为什么评测不能只看总体精度。
业界通用的做法是分别报告见过类别的准确率(S)和没见类别的准确率(U),再算它们的调和平均 H = 2SU/(S+U)。调和平均对短板极其敏感,一边接近零,H 就接近零。用 H 做指标,等于逼着模型在两边都不能摆烂。血泪经验是:看论文里的数字时,先看它报的是哪种设定、哪个指标,只报总体精度的结果基本没有参考价值。
下表把几种设定摆在一起,方便对照:
| 设定 | 训练时见过的类别 | 测试时候选集 | 常见指标 |
|---|---|---|---|
| 传统零样本 | 只有老类别 | 只有新类别 | 新类准确率 U |
| 广义零样本 | 只有老类别 | 老类别 + 新类别 | 调和平均 H |
| 少样本 | 老类别 + 新类少量样本 | 老类别 + 新类别 | 总体准确率 |
3.4 一个最小可跑的代码骨架
概念讲完,落到代码上其实骨架很短。下面这段用最小二乘做视觉到语义的线性映射,能跑通传统零样本的基本流程,重点是看清「候选集是变量」这件事。
import numpy as np # semantic: 所有类别的语义向量,形状 [C, D_s],行序即类别编号 # image_feats: 图像特征,形状 [N, D_v] # labels: 训练样本的类别编号,只包含老类别 def train_mapping(image_feats, labels, semantic): # 只取老类别的语义向量,和图像特征做最小二乘拟合 seen_sem = semantic[labels] # [N, D_s] W, *_ = np.linalg.lstsq(image_feats, seen_sem, rcond=None) return W # [D_v, D_s] def predict(image_feats, W, semantic, candidate_ids): # 先把特征和语义向量都做 L2 归一化,避免模长干扰相似度 feats = image_feats / np.linalg.norm(image_feats, axis=1, keepdims=True) cand = semantic[candidate_ids] cand = cand / np.linalg.norm(cand, axis=1, keepdims=True) projected = feats @ W # 投到语义空间 [N, D_s] projected = projected / np.linalg.norm(projected, axis=1, keepdims=True) sim = projected @ cand.T # 余弦相似度 [N, len(candidate_ids)] return candidate_ids[sim.argmax(axis=1)] # 取最相似的候选类别train_mapping里的np.linalg.lstsq求的是让映射误差平方和最小的解,返回的W就是那座桥。predict的第四个参数candidate_ids是关键设计:传统零样本传新类别的编号列表,广义零样本传全部类别编号,同一个函数就能覆盖两种设定。归一化那两行别省,不归一化的话,语义向量模长大的类别会系统性地占便宜,结果会明显偏。
这套骨架的精度当然比不过论文里的方法,但它是理解所有复杂模型的底座。看任何一篇零样本论文的代码,你都能在里面找到这两个函数的变体:一个负责对齐,一个负责在候选集里检索。
4. 常见误区与易混概念:零样本、少样本、开集识别差在哪
4.1 易混点一:零样本学习并不等于零训练
最容易产生的误解,是以为零样本就是「模型不用训练,直接推理」。恰恰相反,零样本模型的训练过程一点都不少,只是训练数据里没有目标类别的样本而已。它照样要在大量老类别样本上迭代,照样要调超参、照样会过拟合。
混淆的原因在于「零」这个字太有迷惑性,它修饰的是样本数量,不是训练过程。区分方法很简单:问一句「模型在训练阶段见过什么」。如果见过老类别的大量标注样本,靠语义描述迁移到新类别,那是零样本;如果一个样本都没见过、纯靠预训练模型的通用能力回答,那属于另一回事,通常叫零训练或者免训练推理。
4.2 易混点二:零样本和少样本不是程度差别
很多人把这两个当成同一个光谱上的两端——零样本是一个样本都没有,少样本是有一点点。这个理解只对了一半。数量上确实如此,但方法上差别很大。
零样本的核心是「靠语义描述建立跨类别的桥」,没有目标类别的样本,你只能依赖外部知识;少样本的核心是「靠少量样本微调」,既然有样本了,就可以直接在这几个样本上做梯度更新,语义描述反而变成辅助。区分方法是看方法文档里有没有「微调」这个动作:需要在目标类别样本上更新参数的,基本都是少样本路线;只用语义向量做检索或生成的,才是零样本路线。这两类方法在工程上的部署成本、迭代速度完全不同,混为一谈会做出错误的选型判断。
4.3 易混点三:零样本和开集识别目标相反
开集识别要解决的是「这条样本是不是我认识的类别」,重点是拒识,遇到不认识的类别应该报「未知」,而不是硬塞进某个已知类别。零样本学习要解决的恰恰相反——遇到不认识的类别,它必须给出一个具体答案,还得答对。
混淆的原因是两者都在处理「训练时没见过的东西」。区分看输出:开集识别的输出是「已知类别之一」或者「未知」,零样本的输出一定是候选类别里的某一个,哪怕候选类别训练时压根没出现。工程上这两个能力经常要组合使用,先用开集识别判断该不该答,再用零样本决定答什么。但如果只做其中一个,别指望它顺带把另一个也解决。
4.4 易混点四:语义描述不等于提示词调优
现在流行用自然语言描述类别,于是一个新误解出现了:写一段类别描述,就叫零样本了。其实关键不在于「写了文字」,而在于这段文字有没有被用来建立跨类别的映射关系。
区分方法看用途:如果文字只是拼进输入模板里,模型本身已经在海量图文对上训练过,这是提示词层面的技巧;如果文字被编码成语义向量,作为类别的代理参与到分类决策中,新旧类别共用同一套对齐逻辑,那才是零样本的机制。前者改几个词就能影响结果,后者需要重新训练映射,工程含义差很远。
4.5 易混点五:精度数字不能跨设定比较
第四个高频误区是拿两篇论文的数字直接对比。传统零样本只在新类别里选,广义零样本在新旧混合里选,两者的难度不是一个量级。前者的准确率天然更高,因为候选集小、干扰少。
区分方法是先看评测设定,再看指标。看到一篇报「新类准确率九成」的,先确认它是不是传统设定、候选集只有几十类;看到报 H 值的,才知道它是在混合设定下比较均衡的结果。跨设定比数字,等于拿百米成绩去比跨栏,结论一定是错的。
5. 这份论文加代码的资源,能帮你省掉哪些摸索
5.1 论文部分该盯住哪几段
一份零样本的论文加代码资料,价值不在于让你从零推导公式,而在于帮你跳过「读了十篇才发现设定不一样」的阶段。拿到论文时,建议先翻三处,而不是从头读到尾。
第一处是实验设置那一节,看清楚它用的是传统设定还是广义设定,候选集是怎么划的,类别划分遵循什么协议。这一节决定了后面所有数字的含义。第二处是消融实验,看作者拆掉了哪个模块、数字掉了多少,这比主结果更能说明方法的关键在哪里。第三处是失败案例分析,好的论文会老实交代模型在哪些类别上错得离谱,这部分信息在代码复现时极其有用,因为你能预判自己会撞上什么。
读的时候留意一个细节:很多方法的性能提升,来自对语义描述的预处理,而不是模型结构本身。资料里如果有这部分说明,别跳过,它往往是最容易复现收益的地方。
5.2 代码部分的阅读顺序
代码不要按文件目录顺序读,按数据流读。先找数据加载部分,确认语义向量是怎么构造的、类别划分文件长什么样、老类别和新类别的编号是怎么分开的。这三件事搞清楚了,后面所有逻辑才有落脚点。
接着找训练入口,看它优化的是什么损失——是对齐损失、生成损失,还是两者都有。然后找推理入口,重点看候选集是怎么传进去的。很多复现代码把候选集的构造藏在评测脚本里,不仔细看会以为模型直接输出全部类别,实际不是。
最后看评测脚本。零样本的评测比普通分类复杂,要分别算新类和旧类的精度,再做调和平均。如果评测脚本写错了,你会得到一组看着正常、实际没意义的结果。建议第一遍跑通后,手动构造几个极端输入验证一下,比如全部判成老类别,看看指标是不是如预期地掉到接近零。
5.3 复现时最容易翻车的地方
第一类是随机种子。生成式方法对种子特别敏感,同一份代码两次运行差三五个点是常事。想判断一个方法稳不稳,至少跑三个种子取平均,单次结果没有说服力。
第二类是语义向量的归一化方式。有的代码在训练前归一化,有的在计算相似度时归一化,有的两处都做。这些细节在论文里通常不写,但会明显影响结果。复现时如果数字对不上,先查这里。
第三类是见过的类别和新类别的划分协议。同一份数据,换一种划分方式,结果就不可比。资料里如果附带了划分文件,照着用,不要自己随手切分,否则你跑出来的数字和论文里的一比,会误以为自己实现错了。
第四类是评测时的候选集范围。这个坑最隐蔽,因为代码可能默认把全部类别都放进候选集,而你以为是传统设定。跑之前打印一下候选集长度,一行代码的事,能省掉半天的困惑。
6. 什么时候该用零样本学习,什么时候别硬上
先说该用的时候。第一类是类别会持续新增、而你没法为每次新增都重训模型的场景,比如商品类目、内容标签、工单分类,语义描述可以直接写成一段业务说明,新类目上线当天就能被识别,虽然精度不如专门训练的模型,但比「全部判成老类目」强太多。第二类是需要冷启动的场景,新产品、新业务线一开始没有历史数据,用语义描述先撑起一个能用的基线,等样本攒够了再切到常规监督训练。第三类是做候选召回,零样本的排序能力一般,但用来把几万个类别筛到几十个候选,性价比很高。
再说别硬上的时候。如果你的类别是固定的、样本也充足,那就老老实实做普通分类,零样本的精度一定不如它,多绕这一圈纯属自找麻烦。如果类别之间的差异极其细微、必须靠专业视觉特征才能分辨,比如同一种零件的外观瑕疵分级,语义描述根本写不清那些差别,零样本的表现会惨不忍睹。还有一类是对错误代价极其敏感的场景,零样本的错法往往是「自信地错到语义相近的类别上」,这种错比直接报未知更难排查,没有兜底机制的话不要上。
下面这张清单可以直接拿来过一遍:
| 判断项 | 倾向用零样本 | 倾向别用 |
|---|---|---|
| 类别是否持续新增 | 高频新增,来不及重训 | 固定不变 |
| 每类样本量 | 长尾稀疏,多数类别样本极少 | 每类都有充足标注 |
| 语义描述能否写清 | 差异可用属性或文字概括 | 差异细微,难以言说 |
| 错误代价 | 可容忍,有兜底 | 一次错判代价很高 |
| 当前基线 | 完全没有新类识别能力 | 已有可用的监督模型 |
我自己的习惯是,在动手之前先做一个小实验:从已有数据里挑出三个类别,把它们从训练集里彻底剔除,只用文字描述让模型去认。如果这三类的召回率连随机猜都赢不了多少,那说明这个领域的语义描述和视觉特征之间缺乏可对齐的结构,后面再怎么调参也是白费力气。这个十分钟的小验证,帮我省下过好几次白干一周的后悔药。
踩过的坑里最深的一个,是早期只看总体精度就上了一个广义零样本的模型,线下指标很漂亮,上线后新类别的召回几乎为零。后来每次做类似的事,我都会强制把新类和旧类的精度分开打印出来,再算一遍调和平均,数字难看就难看,至少知道自己站在哪里。希望这些能帮到你,也祝你在自己的数据上少走几步弯路。
本文还有配套的精品资源,点击获取