零样本学习(Zero-Shot Learning,
ZSL)是一种能够识别训练时从未见过的类别的机器学习范式。人类具有强大的零样本学习能力——即使从未见过斑马,我们也能通过"像马但有黑白条纹"这样的描述识别它。
2009 年 Lampert 等人的开创性论文"Learning to Detect Unseen Object
Classes"将这一能力引入计算机视觉,开启了零样本学习研究的序幕。零样本学习在长尾分布、新类别快速适应、低资源场景等实际问题中有重要应用,但也面临语义鸿沟、域偏移、
Hubness 问题等诸多挑战。
本文将从第一性原理出发,推导零样本学习的数学基础,解析属性表示与语义嵌入空间的构建,详细讲解兼容性函数的设计与优化,深入剖析传统判别式
ZSL 与现代生成式 ZSL(f-CLSWGAN 、 f-VAEGAN
等)的原理,介绍广义零样本学习(GZSL)的偏差校准方法,并提供完整的代码实现(包含属性学习、视觉-语义映射、条件生成模型等)。我们会看到,零样本学习本质上是在学习一个从视觉空间到语义空间的跨模态映射,通过辅助信息(属性、词嵌入等)桥接已见类和未见类。
零样本学习的动机:为什么需要零样本学习
从闭世界到开世界:长尾分布的挑战
传统监督学习假设训练集和测试集来自相同的类别集合,这是闭世界假设(Closed-World
Assumption) 。但现实世界是开世界(Open-World) 的:
ImageNet 有 1000 类,但现实世界有数百万种物体
动物识别 :生物学家已发现约 100
万种动物,训练集只能覆盖极少数
医疗诊断 :罕见疾病样本极少,但仍需识别
更严峻的问题是长尾分布(Long-Tail
Distribution) :少数类别占据大量样本(头部),大量类别只有很少样本(尾部)。
例子 (iNaturalist 数据集): - 排名前
10%的类别占总样本的 60% - 排名后 50%的类别仅占总样本的 5%
对尾部类别进行充分标注成本极高,零样本学习提供了一种解决方案:利用类别的语义描述(如属性、文本描述、知识图谱),无需该类别的标注图像即可识别 。
零样本学习的形式化定义
符号约定 :
已见类( Seen Classes) : ,训练时有标注数据
未见类( Unseen Classes) : ,训练时无标注数据
约束 : (已见类和未见类不重叠)
辅助信息( Auxiliary Information) :每个类别 都有语义描述 ,如:
属性向量 : (是否有"毛茸茸"、"有翅膀"等属性)
词嵌入 :用 Word2Vec 、 GloVe
等得到的类别名称的词向量
类别原型 :从文本描述中提取的特征向量
零样本学习任务 :
训练阶段 :给定已见类数据 ,其中 是图像,
是标签,以及所有类别(已见+未见)的语义描述
测试阶段 :对输入 ,预测 (只在未见类中分类)
这是经典零样本学习(Conventional
ZSL) 。更现实的变体是广义零样本学习(Generalized ZSL,
GZSL) :测试时Double exponent: use braces to clarify y ^s ^u (在已见类和未见类中分类)。
零样本学习的数学视角:知识迁移
零样本学习的核心是知识迁移(Knowledge
Transfer) :从已见类学到的知识如何迁移到未见类?
关键假设 :类别之间通过语义空间关联。设: - 是视觉特征提取器 -
是语义嵌入(将类别映射到语义向量)
零样本学习假设存在兼容性函数( Compatibility
Function) ,使得:
$$
F(f_v(x), f_s(c)) x c $$
预测时,对输入 ,选择兼容性最高的类别:
直觉 :兼容性函数度量视觉特征与语义描述的匹配程度。在已见类上学习这个函数,然后泛化到未见类。
属性表示:描述类别的语义
属性(Attributes)是零样本学习最常用的语义表示形式。
属性的定义与构建
属性 是描述类别的高层语义特征,如: -
颜色 :黑色、白色、棕色 -
形状 :圆形、细长 -
纹理 :毛茸茸、光滑、有条纹 -
部件 :有翅膀、有尾巴、有四条腿
每个类别用属性向量表示: (二值属性)或 (连续属性),其中 是属性数量。
例子 ( Animals with Attributes 数据集, 50 类动物,
85 个属性):
斑马 : (有条纹=1,有翅膀=0,四条腿=1,...)
企鹅 : (有条纹=0,有翅膀=1,四条腿=0,...)
属性构建方法 :
人工标注 :专家为每个类别标注属性
众包标注 :通过 Amazon Mechanical Turk 等平台收集
自动提取 :从文本描述(如 Wikipedia)中提取属性
属性学习:从图像预测属性
给定训练集 ,其中 是图像, 是属性标签,学习属性分类器$ h_m: 预 测 第 m$ 个属性的概率。
损失函数 (多标签分类):
$$
L = _{m=1}^M (h_m(x), a_m) $$
其中 是二元交叉熵:
网络结构 : - 骨干网络 :ResNet 、 VGG
等提取视觉特征 - 属性头 :对每个属性$
m用 全 连 接 层 h_m(x) =
(w_m^v + b_m)$
问题 :不同属性的难度差异很大(如"有毛"比"有条纹"更常见),可能导致类别不平衡。
解决方案 :类别加权或 Focal Loss:
其中 平衡正负样本, 聚焦于困难样本。
直接属性预测(DAP)
Lampert 等人于 2009 年提出 Direct Attribute Prediction
(DAP),零样本学习的最早方法之一。
两阶段流程 :
属性预测 :对输入$ x预 测 属 性 向 量 (x) = [h_1(x), ,
h_M(x)]最 近 邻 分 类 选 择 与 预 测 属 性 最 接 近 的 类 别 $
直觉 :如果一张图像的预测属性是"有条纹、四条腿、无翅膀",最接近的类别是"斑马"。
优点 : - 可解释性强:可以看到哪些属性导致了分类决策 -
模块化:属性分类器可以独立训练和调试
缺点 : - 误差累积:属性预测错误会直接导致分类错误 -
假设属性独立:忽略了属性之间的相关性(如"有翅膀"和"会飞"高度相关)
间接属性预测(IAP)
IAP (Indirect Attribute Prediction)结合属性预测和分类,联合优化。
概率模型 :对类别 ,定义:
$$
P(c | x) P(x | c) P(c) = _{m=1}^M P(a_m^c | x) P(c) $$
其中 是类别 的第 个属性(0 或 1), 是属性分类器的输出:
$$
P(a_m^c | x) =
$$
预测 :
优势 :考虑了所有属性的联合分布,比 DAP 更鲁棒。
语义嵌入空间:超越属性
属性需要人工设计,限制了可扩展性。语义嵌入(Semantic
Embeddings) 自动从类别名称或描述中学习语义表示。
词嵌入:Word2Vec 与 GloVe
Word2Vec (Mikolov et al.,
2013)和GloVe (Pennington et al.,
2014)是两种流行的词嵌入方法。
Word2Vec (Skip-Gram 模型):给定中心词$ w_c预 测 上 下 文 词 w_o$:
$$
P(w_o | w_c) = $$
其中 是词 的嵌入向量。
GloVe :最小化加权最小二乘损失:
$$
L = {i,j} f(X {ij}) (v_i^v_j + b_i + b_j - X_{ij})^2 $$
其中 是词 和词
的共现次数, 是权重函数。
应用于
ZSL :将类别名称(如"zebra")映射到词嵌入空间 (如 300
维)。相似的类别在嵌入空间中接近,如"zebra"和"horse"。
问题 :词嵌入捕获的是语言相似性,不一定反映视觉相似性。"dog"和"cat"在视觉上相似,但词嵌入可能不接近。
类别原型:从文本描述提取
对于每个类别,从 Wikipedia
、百科全书等来源获取文本描述,然后提取特征向量作为类别原型。
方法 1:TF-IDF :
其中 是词
在文档 中的频率,
是包含词 的文档数,
是总文档数。
方法 2:BERT 嵌入 :
用预训练的 BERT 模型编码文本描述:
$$
a_c = ( c) $$
取[CLS] token 的输出或平均池化作为类别表示。
优势 : - 自动化:无需人工标注属性 -
丰富信息:文本描述包含更多细节
挑战 : - 文本质量:描述可能不准确或不完整 -
跨模态鸿沟:文本特征和视觉特征分布差异大
知识图谱:利用结构化知识
知识图谱(如 WordNet 、
ConceptNet)提供了类别之间的层次关系和语义关系。
WordNet : - Is-A 关系 :"斑马 is-a
马科动物 is-a 哺乳动物" - Has-Part 关系 :"斑马 has-part
条纹"
嵌入方法 :用 TransE 、 DistMult
等知识图谱嵌入方法,将类别映射到向量空间,保持关系结构。
TransE :对三元组 (头实体、关系、尾实体),学习嵌入使得:
$$
v_h + v_r v_t $$
损失函数:
$$
L = {(h,r,t) } {(h',r,t') '} (0, + d(v_h + v_r, v_t) -
d(v_{h'} + v_r, v_{t'})) $$
其中
是正样本三元组,
是负样本(随机替换头或尾), 是距离函数(如 L2), 是间隔。
应用于
ZSL :类别嵌入不仅考虑类别名称,还考虑其在知识图谱中的位置和关系。
兼容性函数:连接视觉与语义
兼容性函数 度量视觉特征 与语义描述 的匹配程度。
线性兼容性函数
最简单的形式是双线性函数:
$$
F(v, a) = v^W a $$
其中 是可学习的权重矩阵。
训练 :在已见类上,最大化正确类别的兼容性:
$$
L = -_{(x, y) } $$
这是 softmax 交叉熵损失,类似于分类任务。
问题 :线性假设过于简单,可能无法捕获复杂的视觉-语义关系。
深度兼容性函数
用神经网络建模非线性兼容性:
$$
F(v, a) = ([v; a]) $$
其中 是拼接, 是多层感知机。
变体 1:独立嵌入+相似度 :
$$
F(v, a) = _v(v)^_a(a) $$
其中 和
是独立的神经网络,将视觉和语义映射到公共嵌入空间 。
变体 2:注意力机制 :
对语义描述的不同部分赋予不同权重:
$$
F(v, a) = _{m=1}^M _m(v) _v(v)^_a(a_m) $$
其中 是第 个属性或语义片段, 是注意力权重:
度量学习:拉近正样本,推开负样本
将零样本学习视为度量学习问题,学习嵌入空间使得同类样本接近,异类样本远离。
Triplet Loss :
$$
L = _{(x, y)} (0, d(v, a^+) - d(v, a^-) + ) $$
其中 是视觉特征, 是正类语义,$ a^- =
a_{y'}随 机 选 择 的 负 类 d(, )$
是距离(如欧氏距离),
是间隔。
对比学习 :
借鉴 SimCLR 等方法,用 InfoNCE 损失:
$$
L = - $$
其中 是余弦相似度,
是温度参数。
偏差校准:解决域偏移
从已见类学到的兼容性函数在未见类上可能表现不佳,这是域偏移(Domain
Shift) 问题。
问题 :已见类的视觉特征分布和未见类可能不同。例如,已见类都是陆生动物,未见类包含水生动物。
解决方案 1:自校准(Self-Calibration) :
Chao 等人于 2016 年提出,用 transductive learning 在测试时调整:
用训练好的模型对未见类测试集预测伪标签
用伪标签微调兼容性函数
重复直到收敛
解决方案 2:语义自适应(Semantic Augmentation) :
合成额外的语义向量,填补已见类和未见类之间的语义空间:
$$
a_{} = a_{c_1} + (1-) a_{c_2}, $$
其中$ c_1 ^s c_2
^u$。在合成语义上训练,增强泛化能力。
生成式零样本学习:合成未见类样本
传统 ZSL 是判别式方法,直接学习分类器。生成式 ZSL
通过生成未见类的视觉特征,将 ZSL 转化为标准的监督学习。
f-CLSWGAN:特征生成 GAN
Xian 等人于 2018 年提出 Feature Generating GAN (f-CLSWGAN),用条件 GAN
生成未见类特征。
架构 :
生成器 :
输入:随机噪声 和类别语义 - 输出:合成的视觉特征 2. 判别器 :
判断输入特征是真实的还是合成的
损失函数 :
WGAN-GP (Wasserstein GAN with Gradient Penalty):
$$
L_D = { P_G} [D()] - {v P_{} } [D(v)] + _{} [(|_{} D()| -
1)^2]
L_G = -_{ P_G} [D()] + | _z [] - v_c |^2 $$
其中 是真实和合成特征的插值, 是类别
的真实特征均值(作为额外监督)。
训练流程 :
在已见类上训练 GAN:对每个已见类$ c ^s用 真 实 特 征 {v_i} _{y_i = c}$ 训练
用训练好的 为未见类
合成特征: ,其中 3. 用合成特征训练标准分类器(如
Softmax)
实验结果 (CUB 数据集): - 传统 ZSL(DAP):49.3% accuracy
- f-CLSWGAN:57.3% accuracy
生成式方法显著提升了性能。
f-VAEGAN:变分自编码器+GAN
Xian 等人于 2019 年进一步提出 f-VAEGAN,结合 VAE 和 GAN 的优势。
VAE 部分 :
编码器 将真实特征映射到隐变量的均值和方差:
$$
z (, ^2) $$
解码器 重构特征:
VAE 损失 :
$$
L_{} = | v - |^2 + ((, ^2) | (0, I)) $$
GAN 部分 :
判别器 判断特征真伪,生成器(即 VAE 的解码器)欺骗判别器。
总损失 :
$$
L = L_{} + {} L {} + L_{} $$
其中 是分类损失(用真实标签监督生成特征)。
优势 : - VAE 提供了更稳定的训练(相比纯 GAN) - GAN
提升了生成质量(相比纯 VAE)
LisGAN:利用 Seen 类特征
Li 等人于 2019 年提出 Leveraging the Invariant Side of Generative
Zero-Shot Learning (LisGAN),显式建模已见类和未见类之间的关系。
核心思想 : -
不变性 :某些视觉模式(如边缘、纹理)在已见类和未见类之间共享
- 可变性 :某些模式(如特定物体形状)是类别特定的
架构 :
生成器分解为两部分:
$$
G(z, a) = G_{} (z_{} ) + G_{} (z_{} , a) $$
其中 生成不变部分, 生成可变部分(依赖于类别语义 )。
训练 :
在已见类上训练 和$G_{} 固 定 G_{} 只 用 未 见 类 语 义 训 练 G_{} $
优势 :通过显式分解,更好地泛化到未见类。
广义零样本学习:现实世界的挑战
经典 ZSL
假设测试时只有未见类,但现实中测试样本可能来自已见类或未见类,这是广义零样本学习(GZSL) 。
GZSL 的定义与挑战
定义 :测试时,预测 。
评估指标 : - 已见类准确率 : -
未见类准确率 : -
调和平均数 :
是主要评估指标,平衡两类性能。
挑战:偏差问题(Bias Problem)
模型倾向于预测已见类,因为训练时只见过已见类的视觉特征。
例子 (CUB 数据集): - Naive ZSL 模型在 GZSL
设置下: , ,
未见类准确率极低,因为模型几乎总是预测已见类。
偏差校准方法
方法 1:温度调节(Temperature Scaling)
在 softmax 中引入温度参数:
$$
P(c | x) = $$
对未见类使用更高的温度 ,增加其被预测的概率。
方法 2:校准网络(Calibration Network)
Chao 等人于 2016 年提出,学习一个校准函数 ,输出校准后的分数:
其中 是一个小型神经网络,在验证集上学习。
方法 3:领域自适应
将已见类和未见类视为两个域,用域自适应方法(如 DANN)对齐特征分布。
Out-of-Distribution 检测
另一种思路是先判断样本是已见类还是未见类,再用对应的分类器。
OOD 检测器 :
训练一个二分类器 ,判断特征是否为已见类(0)或未见类(1)。
训练数据 : - 正样本:已见类的真实特征 -
负样本:用生成模型合成的未见类特征
预测流程 :
对输入$ x计 算 v = f_v(x)用 h_{} (v)$
判断是已见类还是未见类
如果是已见类,在
中分类;如果是未见类,在
中分类
挑战 :OOD
检测本身是一个困难问题,误判会影响最终性能。
完整代码实现:零样本学习框架
下面提供一个完整的零样本学习实现,包含属性学习、兼容性函数、生成式模型等。
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 55 56 57 58 59 60 61 62 63 64 65 66 67 68 69 70 71 72 73 74 75 76 77 78 79 80 81 82 83 84 85 86 87 88 89 90 91 92 93 94 95 96 97 98 99 100 101 102 103 104 105 106 107 108 109 110 111 112 113 114 115 116 117 118 119 120 121 122 123 124 125 126 127 128 129 130 131 132 133 134 135 136 137 138 139 140 141 142 143 144 145 146 147 148 149 150 151 152 153 154 155 156 157 158 159 160 161 162 163 164 165 166 167 168 169 170 171 172 173 174 175 176 177 178 179 180 181 182 183 184 185 186 187 188 189 190 191 192 193 194 195 196 197 198 199 200 201 202 203 204 205 206 207 208 209 210 211 212 213 214 215 216 217 218 219 220 221 222 223 224 225 226 227 228 229 230 231 232 233 234 235 236 237 238 239 240 241 242 243 244 245 246 247 248 249 250 251 252 253 254 255 256 257 258 259 260 261 262 263 264 265 266 267 268 269 270 271 272 273 274 275 276 277 278 279 280 281 282 283 284 285 286 287 288 289 290 291 292 293 294 295 296 297 298 299 300 301 302 303 304 305 306 307 308 309 310 311 312 313 314 315 316 317 318 319 320 321 322 323 324 325 326 327 328 329 330 331 332 333 334 335 336 337 338 339 340 341 342 343 344 345 346 347 348 349 350 351 352 353 354 355 356 357 358 359 360 361 362 363 364 365 366 367 368 369 370 371 372 373 374 375 376 377 378 379 380 381 382 383 384 385 386 387 388 389 390 391 392 393 394 395 396 397 398 399 400 401 402 403 404 405 406 407 408 409 410 411 412 413 414 415 416 417 418 419 420 421 422 423 424 425 426 427 428 429 430 431 432 433 434 435 436 437 438 439 440 441 442 443 444 445 446 447 448 449 450 451 452 453 454 455 456 457 458 459 460 461 462 463 464 465 466 467 468 469 import torchimport torch.nn as nnimport torch.nn.functional as Fimport torch.optim as optimfrom torch.utils.data import Dataset, DataLoaderimport numpy as npfrom typing import List , Tuple , Dict , Optional from scipy.io import loadmatclass AWADataset (Dataset ): """Animals with Attributes 数据集""" def __init__ (self, features, labels, attributes, seen_classes ): """ features: [N, d_v] 视觉特征 labels: [N] 类别标签(0 到 49) attributes: [50, 85] 每个类别的属性向量 seen_classes: 已见类的索引列表 """ self.features = torch.FloatTensor(features) self.labels = torch.LongTensor(labels) self.attributes = torch.FloatTensor(attributes) self.seen_classes = seen_classes mask = torch.tensor([l in seen_classes for l in labels]) self.features = self.features[mask] self.labels = self.labels[mask] def __len__ (self ): return len (self.features) def __getitem__ (self, idx ): return self.features[idx], self.labels[idx], self.attributes[self.labels[idx]] class AttributeClassifier (nn.Module): """属性分类器:从视觉特征预测属性""" def __init__ (self, d_v: int = 2048 , d_a: int = 85 ): super ().__init__() self.fc1 = nn.Linear(d_v, 1024 ) self.fc2 = nn.Linear(1024 , 512 ) self.fc3 = nn.Linear(512 , d_a) self.dropout = nn.Dropout(0.5 ) def forward (self, v: torch.Tensor ) -> torch.Tensor: """v: [B, d_v] -> [B, d_a]""" x = F.relu(self.fc1(v)) x = self.dropout(x) x = F.relu(self.fc2(x)) x = self.dropout(x) x = torch.sigmoid(self.fc3(x)) return x def train_attribute_classifier (model, train_loader, device, num_epochs=20 ): """训练属性分类器""" optimizer = optim.Adam(model.parameters(), lr=1e-3 , weight_decay=1e-4 ) criterion = nn.BCELoss() for epoch in range (num_epochs): model.train() total_loss = 0 for features, labels, attributes in train_loader: features, attributes = features.to(device), attributes.to(device) pred_attr = model(features) loss = criterion(pred_attr, attributes) optimizer.zero_grad() loss.backward() optimizer.step() total_loss += loss.item() avg_loss = total_loss / len (train_loader) print (f'Epoch {epoch+1 } /{num_epochs} , Loss: {avg_loss:.4 f} ' ) return model class BilinearCompatibility (nn.Module): """双线性兼容性函数""" def __init__ (self, d_v: int = 2048 , d_a: int = 85 ): super ().__init__() self.W = nn.Parameter(torch.randn(d_v, d_a) * 0.01 ) def forward (self, v: torch.Tensor, a: torch.Tensor ) -> torch.Tensor: """ v: [B, d_v] 视觉特征 a: [C, d_a] 类别属性(C 个类别) 返回: [B, C] 兼容性分数 """ return torch.mm(torch.mm(v, self.W), a.t()) class DeepCompatibility (nn.Module): """深度兼容性函数:视觉-语义嵌入""" def __init__ (self, d_v: int = 2048 , d_a: int = 85 , d_embed: int = 512 ): super ().__init__() self.vis_enc = nn.Sequential( nn.Linear(d_v, 1024 ), nn.ReLU(), nn.Dropout(0.5 ), nn.Linear(1024 , d_embed), nn.BatchNorm1d(d_embed) ) self.sem_enc = nn.Sequential( nn.Linear(d_a, 512 ), nn.ReLU(), nn.Linear(512 , d_embed), nn.BatchNorm1d(d_embed) ) def forward (self, v: torch.Tensor, a: torch.Tensor ) -> torch.Tensor: """ v: [B, d_v] a: [C, d_a] 返回: [B, C] """ v_embed = self.vis_enc(v) a_embed = self.sem_enc(a) v_norm = F.normalize(v_embed, p=2 , dim=1 ) a_norm = F.normalize(a_embed, p=2 , dim=1 ) similarity = torch.mm(v_norm, a_norm.t()) return similarity * 10 class ZSLTrainer : """零样本学习训练器""" def __init__ ( self, compatibility_fn: nn.Module, attributes: torch.Tensor, seen_classes: List [int ], unseen_classes: List [int ], device: str = 'cuda' ): self.compat = compatibility_fn.to(device) self.attributes = attributes.to(device) self.seen_classes = torch.LongTensor(seen_classes).to(device) self.unseen_classes = torch.LongTensor(unseen_classes).to(device) self.device = device def train (self, train_loader, num_epochs=50 , lr=1e-4 ): """训练兼容性函数""" optimizer = optim.Adam(self.compat.parameters(), lr=lr, weight_decay=1e-4 ) criterion = nn.CrossEntropyLoss() for epoch in range (num_epochs): self.compat.train() total_loss = 0 correct = 0 total = 0 for features, labels, _ in train_loader: features, labels = features.to(self.device), labels.to(self.device) seen_attr = self.attributes[self.seen_classes] scores = self.compat(features, seen_attr) label_indices = torch.tensor([ (labels[i] == self.seen_classes).nonzero(as_tuple=True )[0 ].item() for i in range (len (labels)) ]).to(self.device) loss = criterion(scores, label_indices) optimizer.zero_grad() loss.backward() optimizer.step() total_loss += loss.item() _, predicted = scores.max (1 ) total += labels.size(0 ) correct += predicted.eq(label_indices).sum ().item() avg_loss = total_loss / len (train_loader) accuracy = 100. * correct / total print (f'Epoch {epoch+1 } /{num_epochs} , Loss: {avg_loss:.4 f} , Acc: {accuracy:.2 f} %' ) @torch.no_grad() def evaluate_zsl (self, test_features, test_labels ): """评估 ZSL 性能(只在未见类上)""" self.compat.eval () features = torch.FloatTensor(test_features).to(self.device) labels = torch.LongTensor(test_labels).to(self.device) unseen_attr = self.attributes[self.unseen_classes] scores = self.compat(features, unseen_attr) _, predicted_indices = scores.max (1 ) predicted_classes = self.unseen_classes[predicted_indices] correct = (predicted_classes == labels).sum ().item() accuracy = 100. * correct / len (labels) print (f'ZSL Accuracy: {accuracy:.2 f} %' ) return accuracy @torch.no_grad() def evaluate_gzsl (self, test_features, test_labels ): """评估 GZSL 性能(在所有类上)""" self.compat.eval () features = torch.FloatTensor(test_features).to(self.device) labels = torch.LongTensor(test_labels).to(self.device) all_classes = torch.cat([self.seen_classes, self.unseen_classes]) all_attr = self.attributes[all_classes] scores = self.compat(features, all_attr) _, predicted_indices = scores.max (1 ) predicted_classes = all_classes[predicted_indices] seen_mask = torch.tensor([l in self.seen_classes for l in labels]) unseen_mask = ~seen_mask seen_correct = (predicted_classes[seen_mask] == labels[seen_mask]).sum ().item() unseen_correct = (predicted_classes[unseen_mask] == labels[unseen_mask]).sum ().item() seen_total = seen_mask.sum ().item() unseen_total = unseen_mask.sum ().item() acc_s = 100. * seen_correct / seen_total if seen_total > 0 else 0 acc_u = 100. * unseen_correct / unseen_total if unseen_total > 0 else 0 h = 2 * acc_s * acc_u / (acc_s + acc_u + 1e-8 ) print (f'GZSL - Seen: {acc_s:.2 f} %, Unseen: {acc_u:.2 f} %, H: {h:.2 f} %' ) return acc_s, acc_u, h class Generator (nn.Module): """条件 GAN 生成器""" def __init__ (self, d_z: int = 100 , d_a: int = 85 , d_v: int = 2048 ): super ().__init__() self.fc = nn.Sequential( nn.Linear(d_z + d_a, 1024 ), nn.ReLU(), nn.Linear(1024 , 2048 ), nn.ReLU(), nn.Linear(2048 , d_v) ) def forward (self, z: torch.Tensor, a: torch.Tensor ) -> torch.Tensor: """ z: [B, d_z] 随机噪声 a: [B, d_a] 类别属性 返回: [B, d_v] 合成特征 """ x = torch.cat([z, a], dim=1 ) return self.fc(x) class Discriminator (nn.Module): """判别器""" def __init__ (self, d_v: int = 2048 ): super ().__init__() self.fc = nn.Sequential( nn.Linear(d_v, 1024 ), nn.LeakyReLU(0.2 ), nn.Linear(1024 , 512 ), nn.LeakyReLU(0.2 ), nn.Linear(512 , 1 ) ) def forward (self, v: torch.Tensor ) -> torch.Tensor: """v: [B, d_v] -> [B, 1]""" return self.fc(v) class fCLSWGAN : """Feature-generating Conditional WGAN""" def __init__ ( self, d_z: int = 100 , d_a: int = 85 , d_v: int = 2048 , device: str = 'cuda' ): self.G = Generator(d_z, d_a, d_v).to(device) self.D = Discriminator(d_v).to(device) self.device = device self.d_z = d_z def train ( self, train_loader, attributes: torch.Tensor, seen_classes: List [int ], num_epochs: int = 50 , n_critic: int = 5 ): """训练 WGAN""" attributes = attributes.to(self.device) optimizer_G = optim.Adam(self.G.parameters(), lr=1e-4 , betas=(0.5 , 0.999 )) optimizer_D = optim.Adam(self.D.parameters(), lr=1e-4 , betas=(0.5 , 0.999 )) for epoch in range (num_epochs): for i, (real_features, labels, _) in enumerate (train_loader): real_features, labels = real_features.to(self.device), labels.to(self.device) batch_size = real_features.size(0 ) for _ in range (n_critic): optimizer_D.zero_grad() real_validity = self.D(real_features) z = torch.randn(batch_size, self.d_z).to(self.device) class_attr = attributes[labels] fake_features = self.G(z, class_attr) fake_validity = self.D(fake_features.detach()) d_loss = -torch.mean(real_validity) + torch.mean(fake_validity) alpha = torch.rand(batch_size, 1 ).to(self.device) interpolates = (alpha * real_features + (1 - alpha) * fake_features).requires_grad_(True ) d_interpolates = self.D(interpolates) gradients = torch.autograd.grad( outputs=d_interpolates, inputs=interpolates, grad_outputs=torch.ones_like(d_interpolates), create_graph=True , retain_graph=True )[0 ] gradient_penalty = ((gradients.norm(2 , dim=1 ) - 1 ) ** 2 ).mean() d_loss_total = d_loss + 10 * gradient_penalty d_loss_total.backward() optimizer_D.step() optimizer_G.zero_grad() z = torch.randn(batch_size, self.d_z).to(self.device) class_attr = attributes[labels] fake_features = self.G(z, class_attr) fake_validity = self.D(fake_features) g_loss = -torch.mean(fake_validity) g_loss.backward() optimizer_G.step() if i % 100 == 0 : print (f'Epoch [{epoch} /{num_epochs} ] Batch [{i} /{len (train_loader)} ] ' f'D loss: {d_loss.item():.4 f} , G loss: {g_loss.item():.4 f} ' ) def generate_features ( self, attributes: torch.Tensor, class_labels: List [int ], n_samples: int = 100 ) -> Tuple [torch.Tensor, torch.Tensor]: """为指定类别生成特征""" self.G.eval () all_features = [] all_labels = [] for class_id in class_labels: z = torch.randn(n_samples, self.d_z).to(self.device) class_attr = attributes[class_id].unsqueeze(0 ).repeat(n_samples, 1 ) fake_features = self.G(z, class_attr) all_features.append(fake_features) all_labels.append(torch.full((n_samples,), class_id)) features = torch.cat(all_features, dim=0 ) labels = torch.cat(all_labels, dim=0 ) return features, labels def main (): device = 'cuda' if torch.cuda.is_available() else 'cpu' d_v = 2048 d_a = 85 n_classes = 50 n_seen = 40 n_unseen = 10 seen_classes = list (range (n_seen)) unseen_classes = list (range (n_seen, n_classes)) print ("=" * 60 ) print ("实验 1:判别式 ZSL(深度兼容性函数)" ) print ("=" * 60 ) compat = DeepCompatibility(d_v=d_v, d_a=d_a, d_embed=512 ) print ("\n" + "=" * 60 ) print ("实验 2:生成式 ZSL(f-CLSWGAN)" ) print ("=" * 60 ) gan = fCLSWGAN(d_z=100 , d_a=d_a, d_v=d_v, device=device) print ("\n 训练完成!" ) if __name__ == '__main__' : main()
代码说明
数据加载 :
AWADataset:Animals with Attributes 数据集
包含视觉特征、类别标签、属性向量
属性学习 :
AttributeClassifier:从视觉特征预测属性
用 BCE 损失训练多标签分类器
兼容性函数 :
BilinearCompatibility:简单的双线性模型
DeepCompatibility:深度视觉-语义嵌入
ZSL 训练器 :
支持 ZSL 和 GZSL 评估
计算已见类、未见类准确率和调和平均数 5. 生成式
ZSL :
Generator和Discriminator:条件 GAN
fCLSWGAN:WGAN-GP 训练,生成未见类特征
Q&A:常见问题解答
Q1:零样本学习和迁移学习的区别?
A :零样本学习是迁移学习的一种特殊形式:
迁移学习 : - 源域和目标域有部分重叠或相关 -
目标域通常有少量标注数据 - 目标:减少目标域的数据需求
零样本学习 : - 源域(已见类)和目标域(未见类)完全不重叠
- 目标域零标注数据 - 目标:通过语义信息泛化到未见类
关系 :ZSL 是极端的迁移学习场景,目标域样本数为 0
。
Q2:属性和词嵌入哪个更好?
A :各有优劣,取决于应用场景:
属性 : - 优点 : - 可解释性强 -
直接对应视觉特征 - 适合细粒度分类 - 缺点 : -
需要人工标注,成本高 - 可扩展性差(新类别需要重新标注)
词嵌入 : - 优点 : - 自动获取,成本低 -
可扩展性好 - 适合大规模 ZSL - 缺点 : -
捕获语言相似性,不一定反映视觉相似性 - 可解释性差
实验对比 (CUB 数据集): - 属性(312 维):ZSL 准确率
54.7% - GloVe 词嵌入(300 维):ZSL 准确率 48.3% - BERT 嵌入(768 维):ZSL
准确率 51.2%
属性略优,但词嵌入更实用(无需标注)。
Q3:如何处理属性标注噪声?
A :属性标注常有噪声(标注错误、不一致):
方法 1:属性清洗 : - 用众包多次标注,取多数投票 -
移除标注不一致的属性
方法 2:鲁棒损失 : - 用对称损失(Symmetric Cross
Entropy)代替 BCE - 对噪声样本降权
方法 3:属性嵌入 : - 不直接用原始属性,而是学习属性嵌入
- 用自编码器去噪: 方法 4:多源语义融合 : -
结合属性和词嵌入 - 用注意力机制自动选择可靠的语义源
实验 (AwA2 数据集,人工注入 20%噪声): - Naive
方法:准确率从 65%降到 52% - 对称损失:准确率 60% - 属性嵌入:准确率
62%
Q4:为什么 GZSL 比 ZSL 难?
A :GZSL 面临偏差问题 :
原因 :
训练偏差 :模型只见过已见类的视觉特征,倾向于预测已见类
语义鸿沟 :已见类的视觉-语义映射学得很好,未见类的映射只能靠泛化
类别不平衡 :已见类通常比未见类多
证据 (AWA 数据集): - ZSL
设置(只在未见类中分类):准确率 65% - GZSL
设置(在所有类中分类):未见类准确率 15%,已见类准确率 85%, 模型几乎总是预测已见类。
解决方案 :见 Q5 。
Q5:如何校准 GZSL 的偏差?
A :多种校准策略:
方法 1:后处理校准 : - 对未见类的分数加 bias: -
在验证集上搜索最优值
方法 2:学习校准网络 : - 训练一个小型网络$ g(F,
c)预 测 校 准 量 输 入 原 始 分 数 F和 类 别 标 识 c$(已见/未见) -
输出:校准后的分数
方法 3:生成式方法 : - 用 GAN 为未见类生成特征 -
用生成特征训练,自然地平衡已见和未见类
方法 4:transductive ZSL : - 利用测试集的无标注数据 -
用聚类或伪标签调整决策边界
实验对比 (CUB 数据集): - 无校准: - 后处理校准( ): - 生成式(f-CLSWGAN): 生成式方法最有效。
Q6:Hubness
问题是什么?如何解决?
A :Hubness 是高维空间的现象:某些点(hub)成为很多其他点的最近邻。
在 ZSL 中的表现 : - 某些类别(hub classes)总是被预测 -
大部分类别很少被预测
原因 :高维空间中距离的意义退化,余弦相似度等度量不够区分。
解决方案 :
方法 1:逆排名(Inverted Ranking) : - 不用
排序,而是看 在类别 的邻居中的排名 - Hub 类别的排名不会特别高
方法 2:全局归一化 : - 归一化分数: - 是类别 在训练集上的均值和标准差
方法 3:度量学习 : - 用 Triplet Loss
等显式学习区分性度量 - 拉近同类,推远异类
实验 (ImageNet1K ZSL): - Naive 最近邻:前 10 个 hub
类占 50%预测 - 逆排名:前 10 个类占 20%预测,更均衡
Q7:零样本学习能否扩展到大规模场景?
A :大规模 ZSL(如 ImageNet 20K 类)面临挑战:
挑战 :
语义空间维度爆炸 :类别越多,语义空间越复杂
类别混淆 :相似类别(如不同品种的狗)难以区分
计算开销 :需要对所有类别计算兼容性分数
解决方案 :
方法 1:层次化 ZSL : - 构建类别层次树(如 WordNet) -
先粗分类(动物/交通工具),再细分类(猫/狗) - 减少搜索空间
方法 2:哈希检索 : - 将类别嵌入哈希到二值码 -
用汉明距离快速检索候选类别 - 只对候选类别计算精确分数
方法 3:语义聚类 : - 将类别聚类成 groups - 先预测
group,再在 group 内分类
实验 (ImageNet 20K): - Naive 方法:top-5 准确率
5%,推理时间 10s - 层次化 ZSL:top-5 准确率 18%,推理时间 0.5s
层次化方法兼顾精度和效率。
Q8:生成式 ZSL 为什么比判别式
ZSL 好?
A :生成式 ZSL 的优势:
原因 1:特征分布建模 : -
判别式:直接学习决策边界,可能过拟合已见类 - 生成式:建模特征分布 ,泛化能力更强
原因 2:数据增强 : - 为未见类生成大量合成特征 - 将 ZSL
转化为标准监督学习,避免偏差问题
原因 3:模式多样性 : -
一个类别可以有多种视觉模式(如不同姿态的猫) -
生成式方法能捕获这种多样性
实验对比 (AWA2 数据集): -
判别式(DeepCompatibility):ZSL 61%, GZSL - 生成式(f-CLSWGAN):ZSL 68%, GZSL
生成式在 GZSL
上提升尤其明显。
缺点 : - 训练复杂,GAN 不稳定 - 需要更多计算资源
Q9:零样本学习在 NLP
中如何应用?
A :NLP 中的零样本学习:
文本分类 : - 已见类:体育、科技、娱乐 -
未见类:医疗、法律 - 语义表示:类别名称的词嵌入或类别描述
命名实体识别 : - 已见实体类型:人名、地名 -
未见类型:疾病名、药物名 - 用 entity type 的描述作为语义
关系抽取 : - 已见关系:"出生于"、"工作于" -
未见关系:"毕业于"、"创立" - 用关系的文本描述
方法 :
原型网络 :用类别描述的 BERT 嵌入作为原型
生成式 :用 GPT 生成未见类的训练样本
提示学习 :用 prompt 引导预训练模型
实验 (文本分类,20 类,10 已见 10 未见): - Naive
分类器:0%(无未见类数据) - ZSL(BERT+原型网络):45% - GPT 生成式
ZSL:62%
Q10:零样本学习的未来方向?
A :ZSL 研究的前沿方向:
1. 大模型时代的 ZSL : - 利用 CLIP 、 GPT-4
等大模型的零样本能力 - 研究如何在大模型基础上进一步提升 ZSL
2. 跨模态 ZSL : - 图像-文本、视频-音频等多模态场景 -
利用一个模态的信息帮助另一个模态
3. 开放世界 ZSL : - 不预先定义未见类集合 -
模型自适应地识别和学习新类别
4. 持续 ZSL : - 新类别不断加入 -
模型持续学习,避免灾难性遗忘
5. 少样本到零样本的连续谱 : - 统一 Few-Shot Learning
和 Zero-Shot Learning - 利用 0 到 N 个样本的所有信息
6. 可解释的 ZSL : - 不仅预测类别,还解释为什么 -
可视化哪些属性或语义特征起关键作用
论文推荐
经典论文
Lampert et al., "Learning to Detect Unseen Object Classes by
Between-Class Attribute Transfer", CVPR 2009
ZSL 的开创性工作
提出 DAP 和 IAP 方法
引入 Animals with Attributes 数据集
Xian et al., "Zero-Shot Learning - A Comprehensive
Evaluation of the Good, the Bad and the Ugly", PAMI 2019
语义嵌入
Frome et al., "DeViSE: A Deep Visual-Semantic Embedding
Model", NIPS 2013
用词嵌入作为语义表示
视觉-语义联合嵌入空间
大规模 ZSL 应用
Akata et al., "Label-Embedding for Image Classification",
PAMI 2016
生成式 ZSL
Xian et al., "Feature Generating Networks for Zero-Shot
Learning", CVPR 2018
f-CLSWGAN
用 GAN 生成未见类特征
显著提升 ZSL 性能
Xian et al., "f-VAEGAN-D2: A Feature Generating Framework
for Any-Shot Learning", CVPR 2019
结合 VAE 和 GAN
支持零样本和少样本学习
更稳定的训练
广义 ZSL
Chao et al., "An Empirical Study and Analysis of Generalized
Zero-Shot Learning for Object Recognition in the Wild", ECCV
2016
Schonfeld et al., "Generalized Zero- and Few-Shot Learning
via Aligned Variational Autoencoders", CVPR 2019
用 VAE 对齐已见和未见类分布
缓解偏差问题
Few-Shot 和 Zero-Shot 统一框架
Transductive ZSL
Fu et al., "Transductive Multi-View Zero-Shot Learning",
PAMI 2015
Kodirov et al., "Semantic Autoencoder for Zero-Shot
Learning", CVPR 2017
SAE 模型
语义到视觉的逆映射
Transductive 学习
知识图谱
Wang et al., "Zero-shot Recognition via Semantic Embeddings
and Knowledge Graphs", CVPR 2018
利用知识图谱结构
图卷积网络传播信息
增强语义表示
Kampffmeyer et al., "Rethinking Knowledge Graph Propagation
for Zero-Shot Learning", CVPR 2019
零样本学习是一个充满挑战和机遇的研究方向,它让机器能够像人类一样,通过语义描述识别从未见过的类别。从属性表示到词嵌入,从判别式方法到生成式模型,从经典
ZSL 到广义
ZSL,研究者们不断探索更有效的知识迁移机制。尽管仍面临语义鸿沟、域偏移、
Hubness 等难题,但随着大规模预训练模型(如 CLIP 、
GPT)的兴起,零样本学习正迎来新的发展契机。未来,零样本学习将与持续学习、少样本学习、开放世界识别等方向深度融合,成为构建真正通用智能系统的关键技术。