
简介这是一份面向零样本学习入门与复现需求的实践型资源包围绕零样本识别zero-shot这一缺乏标注样本场景下的分类问题展开适合具备一定深度学习基础、希望把论文落到代码的学生与算法工程师。包内同时提供基础复现与原文复现两套实现其中原文复现基于PyTorch框架完成并附有复现所依据的论文原文、语义空间矩阵文件以及数据集的相关说明便于对照论文公式与网络结构逐层理解兼容性建模、属性语义映射等关键环节因数据集体积较大需要自行下载资源中给出了获取与组织方式。整包共11个文件以6个py脚本为主体辅以2份pdf论文与说明文档、1个xls属性表、1个txt说明及1个ipynb交互式笔记解压后约38.98MB结构紧凑便于按模块排查与二次修改。目前已有2851人学习下载。1. 一个样本都没有的新类目才是零样本学习真正要解决的场景产品线要上一个新类目标注团队还没开工老板问模型能不能先顶上——这就是零样本学习zero-shot learning最典型的上场时机。它要解决的是「一个样本都没有」跟「样本少」不是一回事训练时出现过的类叫 seen上线要预测的类叫 unseen两边唯一的桥是类级别的语义描述比如属性向量、词向量或者类名的文本嵌入。这份资源是一篇论文加配套代码的复现包主线是「视觉特征 → 语义嵌入 → 兼容函数打分」训练脚本、数据接口、以及最容易被写错的 GZSL 评估指标都在里面。它适合三类人要复现论文指标的学生、要把零样本能力塞进自己类目体系的工程同学、以及被 seen/unseen/调和平均绕晕过、想看清评估代码到底怎么写的开发者。往下读先弄清技术路线再把它跑起来最后知道该动哪几行。2. 技术路线拆解语义嵌入、兼容函数与三条主流分支零样本学习的坑八成不在网络结构上而在「语义空间怎么建」和「指标怎么算」这两件事上。同样是 zero-shot有人做的是「只在 unseen 上测」的经典 ZSL有人做的是「seen 和 unseen 混在一起测」的广义零样本GZSL两种设定下同一个模型的分数能差出二十个点。这份代码把两种评估都实现了先把概念对齐后面看代码才不会迷路。2.1 语义空间是唯一的桥属性向量、词向量与文本嵌入模型在训练阶段只见过 A 类图片测试阶段要判别 B 类。它没有任何 B 类的视觉样本能拿到的只有 B 类的描述。所以整条链路必须先有一个「类 → 向量」的映射把所有类别放到同一个语义空间里再让图像特征去和这些向量算相似度。最经典的语义表示是人工属性斑马 有条纹 四足 马形每条属性是一个 0/1 或连续值一个类就是一个几十维到几百维的向量。属性标注贵但判别性强细粒度任务上尤其明显。第二种是词向量用大规模语料预训练好的词表直接查类名成本为零代价是「外形相似但语义词不相近」的类容易混。第三种是文本编码器给出的类名嵌入把「一只斑马」这种提示语编码成向量好处是换类目不用重新标属性坏处是同一类换个说法分数就变。语义表示构建成本判别力换类目成本常见问题人工属性向量高需逐类标注强细粒度友好高新类要补标属性定义不一致跨数据集不可复用预训练词向量零中零同义词、多义词拉不开距离文本编码器嵌入低中偏强低提示语模板敏感需要调模板我在实际项目里一般先用词向量把流程跑通确认训练和评估没写错再换成属性向量做正式实验。因为属性标注一旦返工前面调的所有超参都得重来这个顺序能省不少后悔药。2.2 论文公式与代码文件的对应关系读这类复现包最怕的是论文和代码各说各话。这个仓库的组织方式是「一个公式一个模块」对着看基本不会有歧义。论文里的部分代码落点职责视觉特征提取models/backbone.py加载预训练骨干输出池化后的视觉特征语义嵌入models/semantic.py把类属性向量映射到与视觉同维的嵌入空间兼容函数打分models/compat.py视觉嵌入与语义嵌入做归一化点积乘温度系数主损失losses.py可见类上的交叉熵 属性回归辅助损失校准项losses.py的calibrate抑制 seen 类分数偏高导致的偏置ZSL / GZSL 评估evaluate.py分开算 seen、unseen、调和平均提示先读evaluate.py再读训练脚本。评估逻辑决定了训练目标该怎么设顺序反过来容易把指标算错还不知道错在哪。2.3 三条分支的取舍嵌入式、生成式、预训练大模型式嵌入式是这份代码的主线学一个视觉到语义空间的映射测试时用最近邻打分。优点是结构轻、训练快、单卡几小时能出结果缺点是强偏置——模型见过 seen 类的样本太多打分时天然给 seen 类高分GZSL 下 unseen 精度会被压得很低。生成式走另一条路用生成模型为 unseen 类合成视觉特征把零样本问题转成有监督分类。这条路能显著缓解偏置代价是训练不稳定生成质量差的时候指标掉得比嵌入式还难看。第三条是拿图文对比预训练模型直接做零样本分类把类名塞进文本编码器一次前向就能出结果工程上最省事但它对你自己的细粒度类目往往不够敏感需要微调或者做提示语工程。这份资源走嵌入式不是因为它最强而是因为它最容易被拆开看懂——每一行都能对上一个公式改起来也有抓手。想上生成式的话第 6 章会讲怎么在现有结构上接。3. 环境与数据准备把仓库在本机跑通的最小闭环复现类项目最容易翻车的地方其实是数据格式对不上跟模型没关系。属性矩阵的每一行必须和类别索引严格对齐错一行指标就变成玄学。这一章按「目录 → 依赖 → 命令」的顺序把最小闭环搭出来。3.1 目录结构与模块职责仓库是标准的训练脚本 模块包结构没有过度封装改起来不用满仓库找入口。zsl_repro/ ├── train.py # 训练入口参数全在 argparse 里 ├── evaluate.py # 独立的评估脚本可单独跑 checkpoint ├── configs/ │ └── base.yaml # 超参默认值命令行可覆盖 ├── data/ │ ├── dataset.py # ZSLDataset返回图像/标签/属性/seen标记 │ └── splits.py # 按 json 划分 seen / unseen 类别 ├── models/ │ ├── backbone.py # 视觉骨干支持冻结与微调两种模式 │ ├── semantic.py # 语义嵌入层 │ └── compat.py # 兼容函数 温度系数 ├── losses.py # 交叉熵、属性回归、校准 └── utils/ ├── logger.py # 控制台 tensorboard 双写 └── metrics.py # 精度、调和平均、混淆矩阵data/和models/是解耦的dataset.py只管把一条样本组织成(image, label, attr, is_seen)模型层完全不知道数据来自哪个数据集。这条边界守住了换数据集时才不用动模型代码。utils/metrics.py单独拎出来也是有意的指标计算最容易被复制粘贴到各处最后三份实现三种结果。3.2 依赖安装与三个必需的数据文件依赖不重一张卡能跑PyTorch、torchvision、numpy、scipy、tqdm、pyyaml、tensorboard。版本上建议 PyTorch 2.x 配 Python 3.9混合精度和torch.compile都能用上。# 建议用独立环境避免和机器上其他项目抢 CUDA 版本 conda create -n zsl python3.9 -y conda activate zsl pip install torch torchvision numpy scipy tqdm pyyaml tensorboard # 数据目录必须长这样脚本里是硬约定 # data/images/类名/xxx.jpg # data/class_attr.npy [C, A] 每类一行属性向量 # data/split_seen.json 可见类的类名列表 # data/split_unseen.json 未见类的类名列表 python -c import numpy as np; anp.load(data/class_attr.npy); print(a.shape, a.dtype)三个文件里最需要手工确认的是class_attr.npy。它是一个[类别数, 属性维度]的浮点矩阵第 i 行对应第 i 个类别——而「第 i 个类」这个顺序取决于splits.py里按类名排序后的索引。我一般会在splits.py里加一行打印把idx → class_name → attr 前三个值打出来核对一遍。# data/splits.py 片段 import json, numpy as np def load_splits(root): seen sorted(json.load(open(f{root}/split_seen.json))) unseen sorted(json.load(open(f{root}/split_unseen.json))) # 关键属性矩阵的行序必须与这个全局类名顺序一致 classes seen unseen class_to_idx {name: i for i, name in enumerate(classes)} attr np.load(f{root}/class_attr.npy).astype(np.float32) assert attr.shape[0] len(classes), \ f属性行数 {attr.shape[0]} 与类别数 {len(classes)} 不一致 # 逐行 L2 归一化后面点积等价于余弦相似度 attr attr / (np.linalg.norm(attr, axis1, keepdimsTrue) 1e-8) return classes, class_to_idx, attr, set(seen), set(unseen)这里的核心是那个assert。属性矩阵少一行、多一行或者类名顺序反了训练照样能跑完loss 也在降只是指标永远上不去——这类问题浪费的时间比调参多得多。归一化那一步也别省属性维度不同量纲差异很大不归一化的话点积会被某几维主导。3.3 训练入口与命令行参数逐项说明参数全在train.py的 argparse 里没有藏在 yaml 深处的隐式配置。下面这几个是最常调的。参数含义典型取值踩坑点--backbone视觉骨干类型resnet50/vit_b16换骨干要同步改visual_dim--freeze-backbone是否冻结骨干小数据集建议加冻结后 lr 要放大三到五倍--attr-dim属性向量维度与class_attr.npy一致写成硬编码最容易出错--emb-dim共享嵌入维度512 / 1024太小欠拟合太大过拟合 seen--scale兼容函数温度系数10 起步可学习固定值过小会导致 softmax 饱和--calib是否启用 seen 偏置校准GZSL 下建议开只在 ZSL 设定下测可关掉--eval-gzsl是否做广义评估默认开关掉会看不到真实上线表现# 单卡最小可跑命令 python train.py \ --data-root ./data \ --backbone resnet50 \ --emb-dim 512 \ --attr-dim 312 \ --epochs 30 \ --batch-size 64 \ --lr 1e-4 \ --freeze-backbone \ --calib \ --eval-gzsl \ --out runs/exp01--freeze-backbone打开时骨干不参与反向传播只有嵌入层和兼容函数在学显存占用能砍掉一半以上在小数据集上收敛也更快。但要注意学习率得改冻结时我一般用1e-4解冻微调时降到1e-5甚至1e-6否则预训练权重几轮就被冲垮。--scale这个温度系数值得单独说它控制 softmax 的锐度太小所有类分数接近、梯度信号弱太大则只有 top-1 有梯度、模型学不动。做成可学习参数让优化器自己找通常比手调稳。4. 核心代码拆解从数据配对到 GZSL 指标落地这一章是整份资源里最值得逐行读的部分。零样本学习的实现难点集中在三处数据要怎么带出属性、兼容函数要怎么归一化、评估要怎么把 seen 和 unseen 拼在一起算。4.1 数据层图片、类别与属性向量怎么对齐ZSLDataset返回四元组第四条is_seen是给校准项和评估分桶用的。训练阶段只会采样到 seen 类测试阶段才会出现 unseen 类。import torch from torch.utils.data import Dataset from PIL import Image from torchvision import transforms class ZSLDataset(Dataset): def __init__(self, samples, class_to_idx, attr, trainTrue): # samples: [(img_path, class_name), ...] self.samples samples self.class_to_idx class_to_idx self.attr torch.from_numpy(attr) # [C, A] self.train train self.tf transforms.Compose([ transforms.Resize(256), transforms.CenterCrop(224) if not train else transforms.RandomResizedCrop(224), transforms.RandomHorizontalFlip() if train else transforms.Lambda(lambda x: x), transforms.ToTensor(), transforms.Normalize([0.485, 0.456, 0.406], [0.229, 0.224, 0.225]), ]) def __len__(self): return len(self.samples) def __getitem__(self, i): path, cname self.samples[i] img self.tf(Image.open(path).convert(RGB)) label self.class_to_idx[cname] # 全局类别索引不重映射 attr self.attr[label] # 与标签严格同一行 return img, label, attr两个细节值得强调。第一label用的是全局索引不把 seen 类重新映射成 0..N-1。因为后面兼容函数要和整张属性矩阵做点积重映射会让索引和属性行错位这类 bug 表现是「训练 loss 正常但精度恒定在随机水平」。第二训练和测试的 transform 分支写在同一个类里CenterCrop只在评估时用避免出现在训练脚本里改了增强、评估跟着变的情况。4.2 模型层视觉编码器与语义嵌入的兼容函数结构本身不复杂难点在归一化顺序和温度系数的位置。import torch, torch.nn as nn, torch.nn.functional as F class CompatModel(nn.Module): def __init__(self, backbone, visual_dim, attr_dim, emb_dim512): super().__init__() self.backbone backbone self.visual_proj nn.Sequential( nn.Linear(visual_dim, emb_dim), nn.ReLU(inplaceTrue), nn.Linear(emb_dim, emb_dim)) self.attr_proj nn.Sequential( nn.Linear(attr_dim, emb_dim), nn.ReLU(inplaceTrue), nn.Linear(emb_dim, emb_dim)) # 温度系数做成可学习标量初值 10数值稳定性更好 self.log_scale nn.Parameter(torch.tensor(10.0).log()) def forward(self, x, class_attr): v self.backbone(x) # [B, visual_dim] v self.visual_proj(v) a self.attr_proj(class_attr) # [C, emb_dim] # 先归一化再点积等价于余弦相似度顺序反了数值会漂 v F.normalize(v, dim-1) a F.normalize(a, dim-1) logits self.log_scale.exp() * v a.t() # [B, C] return logits, v, aforward同时返回v和a是为了让属性回归辅助损失能直接用省一次前向。归一化必须在点积之前且两边都要归一化——只归一化一边的话尺度由未归一化那侧决定训练前期梯度会忽大忽小。温度系数用log参数化是常规做法保证它恒正优化器在 log 空间里更新也更平稳。backbone传进来的时候如果是冻结的记得在训练脚本里把它所有参数的requires_grad置 False并切到 eval 模式否则 BatchNorm 的 running stats 还会被更新冻结就没冻干净。4.3 损失函数与训练循环主损失是可见类上的交叉熵辅助损失是把视觉嵌入往属性嵌入上拉作用是让视觉空间和语义空间的结构对齐得更紧。def compute_loss(model, img, label, attr_matrix, calib_gamma0.0, seen_idxNone): logits, v, a model(img, attr_matrix) # 主损失只在可见类的 logits 上算交叉熵unseen 列不参与 if seen_idx is not None: logits_seen logits[:, seen_idx] cls_loss F.cross_entropy(logits_seen, label) else: cls_loss F.cross_entropy(logits, label) # 辅助损失视觉嵌入回归到对应类的属性嵌入 target a[label].detach() reg_loss F.mse_loss(v, target) loss cls_loss 0.5 * reg_loss # 校准项给 seen 类的分数整体压一个常数抑制偏置 if calib_gamma 0 and seen_idx is not None: penalty calib_gamma * logits[:, seen_idx].mean() loss loss penalty return loss, {cls: cls_loss.item(), reg: reg_loss.item()}cls_loss里那层if很关键。如果直接把整张属性矩阵喂进去算交叉熵unseen 类的 logits 也会参与 softmax 归一化模型实际上在优化一个它没有监督信号的类别梯度方向会乱。标准做法是把 unseen 列从分母里剔掉只在 seen 类上做 softmax。reg_loss前的系数 0.5 是经验值太大会让视觉特征过度贴合属性向量、丢掉判别信息我一般会在 0.1 到 1.0 之间扫一下。校准项用均值惩罚是最轻的一种实现够用且好调calib_gamma从 0.01 起试。训练循环本身没什么特别注意三件事模型里冻结的部分要显式eval()每个 epoch 结束后在验证集上算一次 GZSL 指标按调和平均存最优 checkpoint而不是按 seen 精度——按 seen 存出来的一定是偏置最严重的那个。for epoch in range(cfg.epochs): model.train() model.backbone.eval() # 冻结部分保持 evalBN 统计不更新 for img, label, _ in train_loader: img, label img.cuda(), label.cuda() loss, parts compute_loss(model, img, label, attr_tensor, calib_gammacfg.calib, seen_idxseen_idx) optimizer.zero_grad(set_to_noneTrue) scaler.scale(loss).backward() scaler.step(optimizer); scaler.update() metrics evaluate(model, val_loader, attr_tensor, seen_idx, unseen_idx) if metrics[H] best_h: # 按调和平均存别按 seen 存 best_h metrics[H] torch.save(model.state_dict(), f{cfg.out}/best.pth)4.4 评估ZSL 精度、seen/unseen 与调和平均 H评估代码是整个仓库里最容易写错、也最值得抄的部分。ZSL 设定只在 unseen 类里取 argmaxGZSL 设定在 seenunseen 全量类里取 argmax然后把预测结果按两个桶分别算精度。torch.no_grad() def evaluate(model, loader, attr_matrix, seen_idx, unseen_idx): model.eval() preds, labels [], [] for img, label, _ in loader: logits, _, _ model(img.cuda(), attr_matrix) preds.append(logits.argmax(dim-1).cpu()) labels.append(label) preds, labels torch.cat(preds), torch.cat(labels) # 经典 ZSL只在未见类上评估先把 logits 里的 seen 列屏蔽 zsl_hit sum(p.item() in unseen_idx for p in preds) # 占位实际用 logits 屏蔽 # 广义 GZSL全量类 argmax 后分桶统计 seen_mask torch.tensor([l.item() in seen_idx for l in labels]) acc_s (preds[seen_mask] labels[seen_mask]).float().mean().item() acc_u (preds[~seen_mask] labels[~seen_mask]).float().mean().item() H 2 * acc_s * acc_u / (acc_s acc_u 1e-8) return {seen: acc_s, unseen: acc_u, H: H, zsl: zsl_hit}H是调和平均它对短板极其敏感seen 0.8、unseen 0.2 时 H 只有 0.32而算术平均会给到 0.5。这也是为什么调参时不能只看一个数。真实场景里还需要单独看混淆矩阵——经常出现的情况是 unseen 精度还行但全部误判到某两三个语义相近的 seen 类上这种偏置靠调温度系数救不回来得上校准或者生成式方法。5. 踩坑排查跑不通和指标不对的五个高频现场5.1 训练侧loss 在降但精度不动现象训练跑满 30 轮cls损失从 4.1 降到 0.3一看验证精度seen 90%、unseen 不到 5%H 低得离谱。原因九成是类别索引和属性矩阵行序没对齐或者 softmax 时没把 unseen 列剔掉。前者的典型特征是 seen 精度也虚高——模型其实在记忆索引而不是学语义后者是模型被逼着给没有监督信号的类分配概率质量。解决写一个十行的小脚本随机抽 20 个类打印类名 → 索引 → 属性向量前三维 → 该属性最显著的属性名人工看一遍对不对。这个检查我每次换数据集都会跑花两分钟省两天。5.2 评估侧ZSL 精度很高GZSL 直接崩现象单独在 unseen 上测能到 60% 多混在一起测 unseen 掉到个位数seen 却接近 95%。原因这就是经典的 seen 偏置。训练时模型只见过 seen 类它们的 logits 分布被充分优化过尺度天然偏大unseen 类的分数是靠语义相似度算出来的量纲上吃亏。只要两边混在一起 argmaxseen 几乎必胜。解决三种手段按成本从低到高——给 seen 类加一个常数惩罚代码里calib_gamma就是干这个的、把 logits 按类做标准化后再比较、或者上生成式方法给 unseen 类补合成特征。第一种改一行就能试先看 H 有没有起来。5.3 数据侧属性向量归一化引发的血案现象换了一批属性标注后模型完全训不动loss 从第一轮就卡在 4.5 附近。原因新属性里混了量纲差异极大的列比如某一维是 0/1 的二值属性另一维是 0 到 100 的计数属性。不归一化的话点积被大数值维度完全主导小维度等于不存在。解决属性矩阵逐行做 L2 归一化第 3.2 节的load_splits里已经做了必要时再对每一维做 z-score。注意归一化要用训练集的统计量别把测试集的均值方差混进来。5.4 工程侧冻结骨干没冻干净现象加了--freeze-backbone显存确实降了但指标和不冻结时几乎一样甚至更差。原因只把requires_grad置 False 是不够的BatchNorm 层的 running mean/var 在前向时仍会更新模型的统计量一直在漂。另外骨干如果没切eval()dropout 也还在生效。解决冻结时同时做两件事——遍历参数置requires_gradFalse并调model.backbone.eval()且在每个 epoch 的model.train()之后重新调一次因为train()会递归把子模块也设成训练模式。5.5 复现侧换了随机种子结果对不上现象论文里写的指标复现不出来差三五个点重跑几次波动很大。原因零样本任务对类别划分极度敏感差一个类进 seen 集合H 能飘好几个点。另外数据加载的多进程顺序、混合精度的数值误差都会带来波动。解决固定torch、numpy、random三个种子DataLoader的worker_init_fn里也设一遍generator传进去。跑三次取均值和方差再和论文比单次结果没有比较意义。这个习惯看起来笨但能挡掉大量「以为是自己实现错了」的无效 debug。6. 二次开发换数据集、换语义表示与校准模块的加法拿到这个仓库之后最值得先动的其实不是模型结构是语义表示。把load_splits里的属性矩阵换成你自己类目的描述向量别的东西一行不用改就能跑出一个 baseline。我一般会准备两份一份人工写的关键属性哪怕只有二三十维一份用文本编码器对类名生成的嵌入两份分别跑一遍看 H 差多少。差距大的话说明你的类目更适合哪种语义结构后面调模型才有方向。换数据集时最容易忽略的是attr_dim参数它必须和矩阵列数严格一致写在命令行里比写在 yaml 里更安全因为换数据时你会记得改。第二个改法是接生成式分支。现有结构里CompatModel输出的是嵌入你可以在models/下加一个条件生成模块输入类属性、输出合成视觉特征然后把合成特征当成额外的 seen 样本喂进分类器。改造成本主要在训练循环生成器和分类器要交替训练且合成特征的质量得单独监控否则指标掉的时候你分不清是分类器问题还是生成器崩了。稳妥的做法是先在小规模子集上把生成部分跑通确认合成特征的类内方差接近真实特征再上全量。第三个是把校准做成可学习模块。现在的calib_gamma是超参可以换成一个按类学习的偏置向量初始化时给 seen 类一个小的负值让它在验证集上跟着优化。这个改动只有十几行但要注意别在训练集上校准——那等于让模型自己给自己打分H 会虚高上线必翻车。验证方法上我建议加一个「跨数据集迁移」的检查在数据集 A 上训完直接把模型和属性矩阵换到数据集 B 的 unseen 类上测哪怕精度不高只要没崩到随机水平说明学到的是语义而不是数据集的偏置。这个检查比任何单数据集指标都更能说明模型的泛化能力。从那以后我每次改评估脚本都会先在一个小规模子集上把 seen/unseen 的标签映射打印出来对一遍再跑全量。希望帮到你。本文还有配套的精品资源点击获取
锦
锦皓数字建站
深耕本土企业品牌数字化升级,专注原创端正雅致商务官网,从视觉设计到稳定运维全程保驾护航。