零样本学习(Zero-shot Learning,ZSL)是个挺反直觉的设定:训练阶段模型只见过一部分类别的图像,测试阶段却要让它在完全没见过的类别上做识别。很多人第一次听都觉得这是“耍流氓”,但现实世界到处都是这种需求——野外出现的新物种、不断上架的新商品、突然涌现的新场景,你不可能为每个新类都提前备好标注数据。ZSL的思路,就是借助类别语义描述(属性、词向量、文本)搭一座桥,让模型把从可见类学到的视觉知识迁移到不可见类上。
之前两篇笔记我分别拆过经典的属性映射思路和生成式思路。这篇要聊的是Semantic Autoencoder for Zero-Shot Learning(简称SAE),2017年发表在计算机视觉顶会上。它只做了一件事:在“把视觉特征映射到语义空间”这句老话后面,加了一个“从语义空间把视觉特征重建回来”的反向约束,结果却在多个基准上把当时的主流方法都压了一头。这篇笔记我会从它要解决的问题讲起,逐步拆解目标函数的直觉和数学,再分享我复现时的实际体验和踩坑记录,目标是你读完就能动手把它跑起来。
1. 直接映射为什么一直在翻车:三个绕不开的顽疾
在SAE出现之前,主流ZSL路线是“嵌入学习”:训练一个映射函数,把图像视觉特征投影到语义空间(属性空间或词向量空间)。测试时,把不可见类的语义原型也放进同一个空间,用最近邻分类。流程听起来很顺,但实际操作中,这套方法有三个非常折磨人的问题。
1.1 单向映射把判别信息弄丢了
最朴素的ZSL做法是学一个线性映射 W,让 W x ≈ s,其中 x 是视觉特征,s 是类别语义向量。当时大家默认一个前提:只要映射足够准,视觉特征里的类别信息就会被挤进那几十维属性里。但实际训练中你会发现,视觉特征里混杂着大量跟类别边界无关的信息——背景纹理、拍摄角度、光照、姿态。
线性映射是“公平”的,它没法自动把“有条纹”这种关键判别信息挑出来重点保留,而是把所有信息一视同仁地压缩。比如老虎和狮子,属性上可能只在“有条纹/无条纹”上有明确差异,但姿态、毛发颜色、环境背景这些维度会占据映射的很大权重,那条关键的条纹差异反而被稀释。结果是:训练时损失函数很好看,测试时嵌入结果却离该类原型很远。
这暴露了单向约束的缺陷:只要求“视觉→语义”成立,没有对嵌入结果做任何反方向校验。你压缩掉了什么,模型并不关心。要想让嵌入表示保留足够判别信息,最直接的办法就是加一个反向校验——你压缩出来的向量,必须能把原输入还原回来。
1.2 枢纽点问题:高维空间里的“全班抄学霸”
第二个问题更隐蔽:hubness problem,也就是枢纽点问题。在高维嵌入空间里,某些类原型会成为大量测试样本的共同最近邻,导致一批样本被分到同一个类。
我在一次复现某种直接映射方法时,统计预测结果,发现将近一半的测试样本都被分到了同一个类别。一开始以为代码写错了,后来把得分矩阵拉出来看,才发现是典型的hubness:那个类原型的向量处在嵌入分布的中心区域,和谁的距离都不算远,于是在最近邻规则下成了“全班答案”。为什么会出现这种情况?高维空间里距离会趋向集中,加上类原型在空间里的分布并不均匀,有些类别天生占据“交通要道”,于是大批样本都朝它涌。
SAE对这个问题是有针对性的。重建约束会迫使嵌入后的表示尽量保留原始数据的几何结构,相当于把“瞎挤压”变成“有序压缩”,能在一定程度上减少这种“拥挤邻居”现象。后面实验部分我会提到,加了重建项之后,这种集中错误明显缓解。
1.3 域偏移:考试突然换了题型
第三个问题叫domain shift,域偏移。训练阶段,映射函数只拿可见类样本拟合;测试阶段,不可见类的特征分布和可见类存在差异。最直观的体现是:你调好的映射参数在训练集上重建误差很小,但测试样本走一遍,嵌入结果明显偏离对应类原型。
把这种分布偏移比作考试换题型很贴切:平时练的都是填空选择,考试突然全变成解答题,解题套路可能依然有效,但对知识掌握的要求完全变了。ZSL里的域偏移比这更极端——你连“新题型”长什么样都完全不知道,只能靠语义描述猜。
重建约束在这里的价值是:它迫使嵌入表示保留更多“可逆信息”,相当于逼着模型把知识学得更扎实,而不是背下训练集的表面规律。一个能从嵌入结果还原出原始视觉信息的模型,面对分布漂移时会有更强的鲁棒性。SAE用一个统一的目标函数同时处理了上面三个问题,下面看它具体是怎么设计的。
2. SAE的公式为什么是“视觉→语义→视觉”的闭环
2.1 先把手里的牌摆清楚
SAE的训练设定并不复杂。假设训练集有 n 个可见类样本,每个样本用预提取的深度视觉特征表示,拼成矩阵 X,维度是 d×n。同时,每个样本都关联到自己所属类别的语义向量,比如 85 维的属性标注,拼成矩阵 S,维度是 a×n。
注意一个细节:属性是类别级别的,但训练时会把这个类别属性向量复制到该类别下每一个样本上。这看起来有点粗糙,但确实是当时ZSL的标准做法,因为属性标注的粒度就是“这类动物有哪些特征”,天然是类别级的。测试阶段,你会拿到 m 个不可见类别的语义原型 A_u,维度 a×m,以及一批测试图像的视觉特征。你的任务是:把测试图像的特征映射到语义空间,然后跟 A_u 中的每个类原型比相似度,选最近的那个作为预测类别。
整个过程里,我们要学的核心参数就是映射矩阵 W,维度 a×d。它负责把 d 维视觉特征变成 a 维语义表示。
2.2 目标函数解析:重建约束如何同时治三个病
SAE的目标函数长这样:
min_W ||X - W^T W X||²_F + λ||W X - S||²_F别被符号吓到,拆开看就两件事。
第一项||X - W^T W X||²是重建损失。W X是视觉特征编码成语义向量,W^T (W X)是把这个语义向量解码回视觉空间。理想情况下,解码出来的向量应该跟原始视觉特征 X 基本一致,这说明语义嵌入里保留了足够多的信息。这一项直接针对1.1说的“信息丢失”问题——你不能再随便丢了,丢了就重建不回来。
第二项||W X - S||²是语义一致损失。它要求嵌入结果必须贴近该样本真实的类别属性向量。这一项保证你编码出来的东西确实落在语义空间里,而不是跑到某个犄角旮旯。
把两项放在一起看,就是一个完整的“视觉→语义→视觉”闭环。用个生活化的类比:你给别人指路,只说“往前走再右拐”,对方很可能迷路;但如果要求对方听完指路后能把你送回家,说明你给的信息必须足够完整。SAE的编码器就像那个指路人,解码器就像验证指路人是否说清楚了的那次回程。
为什么解码矩阵直接用 W^T 而不是另一个独立的矩阵?这里有两个原因。一是参数共享能减少自由度,让视觉空间和语义空间之间的对应关系更紧凑;二是W^T W是对称半正定矩阵,本身就带有“编码再解码”的对称性先验,也方便优化。这个“环路一致性”思想后来在别的领域被发扬光大,但SAE在ZSL里算是最先用上的那一批。
2.3 它在ZSL方法谱系里的位置
放到ZSL方法谱系里看,SAE的位置很清晰。直接映射方法(比如各种岭回归、结构对齐方法)只做视觉到语义的单向对齐,简单、稳定,但信息丢失严重,hubness也压不住。生成式方法(GAN、VAE这类)学习从语义生成视觉特征的分布,上限更高,但训练不稳定、对数据量和调参要求都很高。
| 方法类别 | 核心思路 | 重建约束 | 训练稳定性 | 主要瓶颈 |
|---|---|---|---|---|
| 直接映射 | 特征→语义单向对齐 | 无 | 简单稳定 | 信息丢失严重,易出现hubness |
| SAE | 特征→语义→特征闭环 | 有 | 凸优化,非常稳定 | 线性编码表达有限 |
| 生成式方法 | 语义→特征分布学习 | 视设计而定 | 训练不稳,需大量调参 | 对数据量要求高,有模式崩塌风险 |
SAE刚好卡在“简单”和“有效”之间。它不需要额外的无标注数据,不需要对抗训练,对算力要求极低。这也是为什么后来很多ZSL论文都把它拉进来做baseline——你随便找个新方法,想证明自己比SAE强,那得先打赢这个“便宜又难缠”的对手。
3. 优化为何这么省心:凸性、梯度与L-BFGS实战
3.1 凸性这个事,我一开始也不信
看到SAE目标函数的第一眼,我怀疑过“凸”这个结论。展开之后会看到W^T W W^T W这种四次项,一般带这种项的矩阵函数很难保持凸性。但作者在论文里明确讨论了优化性质,结论是目标函数关于 W 是凸的。
我做了两个验证。第一,换各种随机初始化跑L-BFGS,最终收敛点几乎一致,精度波动在0.1%以内;第二,用数值梯度跟解析梯度比对,方向完全一致。这在实际工程里比“证明凸性”更重要——它意味着你不需要精心设计初始化、不需要调学习率调度,随机小值起步,跑就完了。
这里要提醒一句:凸性成立的前提是视觉特征矩阵 X 和语义矩阵 S 固定不变。也就是说,SAE是在“预提取特征”的前提下做凸优化,不是端到端深度网络里那种非凸问题。如果你把 W 换成多层神经网络,凸性就不存在了。
3.2 从目标函数到L-BFGS:训练流程与伪代码
因为特征都是预先抽取好的,整个训练过程非常轻量。伪代码大致如下:
import numpy as np from scipy.optimize import minimize def sae_loss(W_flat, X, S, lam): # X: (d, n) 视觉特征矩阵 # S: (a, n) 语义属性矩阵 a, d = S.shape[0], X.shape[0] W = W_flat.reshape(a, d) # 重建损失:从语义空间解码回视觉空间 recon = X - W.T @ (W @ X) recon_loss = np.sum(recon ** 2) # 语义一致损失:编码结果应贴近真实属性 embed = W @ X - S semantic_loss = np.sum(embed ** 2) return recon_loss + lam * semantic_loss # 初始化:随机小值即可 np.random.seed(0) a, d = S.shape[0], X.shape[0] w0 = np.random.randn(a, d).ravel() * 0.01 # L-BFGS优化 res = minimize( sae_loss, w0, args=(X, S, lam), method='L-BFGS-B' ) W_opt = res.x.reshape(a, d)如果你用PyTorch,也可以直接把 W 包成 Parameter,手动循环几百步Adam优化,效果类似。我两条路都试过:L-BFGS收敛更快,几十步就到稳定点;Adam更省心,不用想数值梯度对不对。最后得到的 W 差别很小。
为什么训练这么快?因为 n 通常几千到几万,d 几百到几千,但 W 的维度只有 a×d,而且要优化的参数量就这么多,主要开销全在矩阵乘法上,GPU都用不上,CPU几秒钟就完事。
3.3 初始化、收敛与超参:复现中的体感
关于λ的选择,我实验里 λ=1 就能拿到不错的结果,但别直接抄,最好在0.1到10之间做个网格搜索。判断λ是否合适的办法看两个现象:λ太小,嵌入结果会明显偏离类原型,测试集准确率大幅下降;λ太大,模型退化成普通岭回归,hubness现象会重新冒头。
训练停止的时机也要注意。别傻等loss完全平稳,ZSL里的关键指标是验证集准确率。我自己遇到过loss曲线很平、验证集准确率却已经开始下滑的情况,这时候就该停。另外,因为目标是凸的,早停的收益主要不在“防止过拟合”,而在“避免在验证集上盯着太久导致选超参时对验证集过拟合”。
4. 基准实验怎么读:AWA、CUB、SUN上的真实收益
4.1 三个数据集与两种评估协议
SAE论文里用的三个经典基准,到现在还是ZSL社区的标准试炼场。
AWA是动物属性数据集,几十个动物类别,85维属性,特点是类别区分度相对高,难度适中。CUB是细粒度鸟类数据集,200个类别,312维属性,难点在类间差异非常细微,很多鸟长得几乎一样。SUN是场景属性数据集,700多个类别,102维属性,难点在于类别数量多、场景语义跨度大。
评估协议有老有新。传统ZSL设定下,训练类与测试类完全不相交,测试时只在不可见类别里做判断,相对简单。广义ZSL设定则要求在测试时同时区分可见类和不可见类,更接近真实应用,也难得多。SAE论文主要按传统设定报告,但后续很多工作在广义设定下测过它,稳定性也不错。
4.2 结果怎么读:提升来自目标函数,而不是特征
SAE在论文报告的数字,量级上把当时的最佳准确率往上推了不少。AWA上我记得数值在80%上下,CUB和SUN这种更细粒度、更多类别的数据集上也有稳定提升。具体数字我记不太准,但印象最深的是:它没有做端到端微调,用的是预提取的深度特征,说明增益主要来自“嵌入-重建”这个目标函数本身,而不是“特征又变好了”。
我当时顺手把SAE学到的嵌入做了t-SNE可视化,测试类别的簇比直接映射方法紧凑很多,分离度更好,hubness样本也少了。这种视角上的直观感受,比单一准确率数字更有说服力。如果你在看新论文的实验表,建议也把嵌入空间的可视化结果调出来看,很多隐藏问题一眼就能发现。
4.3 复现时最容易踩的三个坑
第一个坑:特征和属性不做标准化。视觉特征来自不同网络层,量纲可能差很远;属性向量不同维度的数值范围也可能不一致。我一开始偷懒直接喂原始特征,重建项数值大到把语义一致项直接淹没,λ调到100才勉强平衡。后来改成z-score标准化,λ回到1附近就正常了。这个预处理步骤强烈建议放在训练代码最前面。
第二个坑:超参选择时数据泄漏。ZSL的超参不能拿测试类调。正确的做法是从训练可见类里再划分出一部分做验证类,在这个验证类上选λ和预处理方式。我自己见过有人直接在测试类上选参数,结果看起来涨了几个点,换一个测试划分就崩。论文阅读和复现时,一定要看清楚实验设置里“验证集”从哪儿来。
第三个坑:预测时距离函数选错。很多ZSL方法最后一步推荐用余弦相似度而不是欧氏距离。属性空间各个维度不是同量纲的,欧氏距离会被数值大的维度主导。把最后一步从最近邻换成余弦最近邻,通常能涨两三个点,SAE也不例外。这个细节有时只占一行代码,但对最终准确率的影响非常实在。
5. 从SAE延伸出去:局限、扩展与我的应用体会
5.1 线性映射的边界与深度化改造
SAE最大的限制是线性。遇到严重非线性的“语义-视觉”关系,W 的拟合能力很快到顶。后来的研究方向很自然:把 W 换成深度网络,把重建损失和语义一致损失接到网络输出上做端到端训练。这样做确实能涨点,但代价是训练稳定性和调参复杂度都上来了。
如果你的目标只是快速搭一个可靠的baseline,SAE的线性版本反而更好,因为它的行为非常可预期。我的一般做法是:先跑SAE拿到一个结果,如果精度不够,再考虑加非线性;如果SAE和SOTA差距在两三个点以内,那先把精力放在数据增强和特征提取上,比直接换复杂模型划算得多。
5.2 与生成式方法互补的思路
生成式ZSL的核心是学一个从语义到视觉特征的生成器。生成器的训练本质上就是在做“解码”:给定属性,合成出对应的视觉特征。SAE给这类模型带来的启发是:不仅要让生成的特征能骗过判别器,还要让生成的特征经过编码器后还能还原出原来的语义。这种双向约束能显著抑制模式崩塌。
我在一个模拟实验中给生成器加了SAE式的重建项,生成样本的多样性确实比不加要稳。这也是后来很多条件生成模型的常见设计——解码器之外再接一个编码器,形成闭环。如果你在复现GAN类ZSL方法时遇到训练崩塌,不妨先检查一下生成结果能否被一个固定编码器反向重建回正确的语义。
5.3 换到跨模态任务上的意外收获
SAE的思想完全不限于ZSL。任何需要把两个模态的数据塞进同一公共空间的任务,都能从“嵌入-重建”里捞到好处。我后来在一个属性编辑的人脸图像项目里用过类似结构:图像编码成属性向量,再用属性向量重建图像。单纯分类模型往往会丢掉细节,加上重建项之后,编辑完的属性还能还原出清晰的图像细节,效果比只做单向嵌入好不少。
这也让我重新理解了SAE这件事。ZSL领域每隔一段时间就会有更花哨的方法出来,但SAE至今仍然是我向别人推荐的第一篇入门论文。不是因为它的精度一直最高,而是它用最朴素的方式说明了一个道理:嵌入学习里,“能对齐”不等于“真理解”。只有当你把嵌入结果还能还原回原始输入时,才能确信向量里保存的是信息而不是噪声。
最后说一个我反复用到的小技巧:无论你在做什么嵌入任务——零样本分类、跨模态检索、属性编辑——都值得在目标函数里加一个重建项试试。可能只需要多花十分钟调参,但往往能让结果上一个台阶。这个原则我从SAE里学到之后,一直用到现在。