简介:知识蒸馏与跨模态预训练模型的结合,是降低图像分类对标注数据依赖的有效路径。这份项目源码围绕OpenCLIP设计了一整套零标签图像分类方案:先借助OpenCLIP提取未标记图像特征,再通过蒸馏策略将关键知识迁移至轻量级分类模型并完成微调适配,覆盖了从特征提取、蒸馏训练到预测评估的完整流程。适合算法工程师与研究生用于复现实验和二次开发参考。
压缩包共16个文件,以Python源码为主,9个py脚本覆盖OpenCLIP嵌入计算、ONNX导出、图像检索、模型蒸馏与PyTorch推理等环节,另含2个shell脚本、2张示例图片、1个Dockerfile、1张架构示意图和1份README说明,整体仅1.42MB,结构紧凑、易于部署。目前已有352人学习下载,侧面印证其实用价值。
源码附带可直接运行的demo脚本与推理示例,并给出Docker部署配置,便于快速验证。对希望掌握CLIP系列模型蒸馏落地细节的读者而言,这套实现省去了大量从零搭建的工作,值得作为实战参考。
1. 零标签图像分类怎么落地:一张标注都没有,知识蒸馏先把流程跑通
场景是这样:产品拿过来一批图片,说三天后要一个能自动分类的小模型,你去要标注,被告知没有预算、没有人力、也没有时间。这是很多做视觉落地的工程师真实遇到过的事。标题里“知识蒸馏 + OpenCLIP + 零标签图像分类”这个组合,给的路径是:不标数据,用 OpenCLIP 这个图文对齐模型当 teacher,对每张图直接输出类别概率,然后让一个小模型通过知识蒸馏把这些概率学下来。整个流程不产生一张人工标注,适合快速验证、冷启动,以及图片不能送出去标注的私有场景。下面按这条路径把方案拆开讲,从原理、选型到参数和坑,照着就能复现。
2. 零标签蒸馏的原理与选型:OpenCLIP 当 teacher 的最省事用法
动手之前先想清楚一个问题:零标签图像分类不是“无监督”,而是把标注这一步外包给了图文对齐。CLIP 在数亿张图文对上训练,学的是“一张图像和一句自然语言描述是否匹配”的对齐关系。把候选类别名写成“a photo of a cat”这样的句子,编码成文本特征,再和图像特征做余弦相似度,就能得到一张图属于每个类别的概率。这就是零标签分类里 teacher 输出的来源。
2.1 零标签分类不是无监督:把标注外包给图文对齐
很多人一听“零标签”,第一反应是聚类或者无监督表征学习。这个理解在蒸馏方案里是错的。零标签蒸馏的方案里,唯一的“人工标注”其实只有一个:你写的类别名。类别名写得好不好,直接决定 teacher 输出质量,也就决定蒸馏上限。
为什么 teacher 必须选 OpenCLIP 这类图文对齐模型,而不是自己训练一个 CNN?因为普通 CNN 的分类器输出维度是固定的,换一个类别集合就要重新训练,没有 zero-shot 能力。而 OpenCLIP 是双塔结构,图像塔和文本塔分别编码,类别集合变了只需要重算文本特征,图像特征完全不用动。这也是零标签场景能成立的结构前提。
对应到标题里的组合,知识蒸馏在这里做的事情是:OpenCLIP 是大而全的通用模型,直接部署它做分类,显存占用高、单张推理慢、还依赖一堆外部库;而业务往往只需要在一个固定类别集合上分类,并且要部署在普通 CPU 机器或者端侧。蒸馏的本质是把 OpenCLIP 对固定类别集合的判别能力压缩进一个小模型里。对下游来说,它拿到的是一个普通分类网络,推理是标准 CNN 前向,部署链路完全可控。
2.2 OpenCLIP 怎么当 teacher:加载、编码、出 logits
OpenCLIP 是 CLIP 的开源复现,提供了多个预训练权重来源。做零标签蒸馏,用它当 teacher 比手动搭别的模型省事很多,权重下载、预处理、模型封装都是现成的:
import torch import open_clip model, _, preprocess = open_clip.create_model_and_transforms( "ViT-B-32", pretrained="laion2b_s34b_b79k", ) model = model.cuda() tokenizer = open_clip.get_tokenizer("ViT-B-32") class_names = ["cat", "dog", "car", "bird"] text_tokens = tokenizer([f"a photo of a {c}" for c in class_names])这段代码做了三件事:创建 ViT-B/32 结构的 CLIP 模型并加载预训练权重;拿到配套的预处理函数和 tokenizer;把类别名变成文本 token。pretrained是权重来源,常见的有laion2b_s34b_b79k和openai,不同来源在不同数据域上差异很大,后面避坑章会专门讲。
preprocess会在数据加载时用到,作用是把任意尺寸的图片规范到模型要求的 224x224,并做归一化。text_tokens只需要在训练前编码一次,后面反复用;它和图像特征做点乘,得到的就是 logits。显存方面,ViT-B/32 的 fp16 推理大约占 2G 显存,跑得动就能做 teacher,不需要多卡。
2.3 方案选型:伪标签两阶段、logits 蒸馏还是混合
拿到 teacher 之后,落地时有三种做法:
| 方案 | 保存内容 | 训练 loss | 对噪音的容忍度 | 适合场景 |
|---|---|---|---|---|
| A:伪标签两阶段 | hard label | 交叉熵 | 低,伪标签错一个带偏一片 | 快速验证、类别少、teacher 置信度高 |
| B:logits 蒸馏 | 每个样本的 logits | KL 散度 | 中,保留不确定性,但损失波动大 | 类别多、teacher 输出有重叠 |
| C:混合 | hard + soft 同时用 | KL + CE | 较高 | 最终上线方案 |
我一般建议:第一次跑,先按方案 A 把硬标签训练链路跑通,确认数据加载、模型结构、迭代流程都没问题;然后再切方案 C,把 soft logits 加进来。因为 hard 伪标签可解释、好排查,直接上 KL 散度,模型不收敛时你很难分清是数据问题还是损失函数问题。第 4 章给的损失函数就是方案 C 的完整写法。
方案 B 单独用的情况比较少,它适合 teacher 输出极度不确定的场景,比如类别之间视觉差异很小、很多样本其实同时属于两个类。但它的训练曲线很难看,没有 hard 分支兜底的话,发散风险高。
3. 从图片目录到可用模型:零标签蒸馏最小可复现流程的四个步骤
拿到源码包先别急着换模型。我一般先做三件事:确认数据组织方式是不是“目录即类别”;确认 teacher 权重能不能加载成功;确认伪标签以什么格式落盘。这三件事对不上,后面代码再好看也跑不起来。
3.1 数据准备:目录结构当标签,省掉标注文件
零标签蒸馏的数据组织是最简单的,因为不需要任何标注文件:
data/ ├── cat/ │ ├── 001.jpg │ └── 002.jpg ├── dog/ │ ├── 001.jpg │ └── 002.jpg └── bird/ ├── 001.jpg └── 002.jpg图片按类别目录存放,目录名就是类别名。这里有个细节:如果业务里的类别是中文,建议在脚本里维护一个class_name -> prompt_name的映射,把中文名转成英文描述短语再传给 CLIP。比如“玻璃划痕”转成a close-up photo of a scratched glass surface,效果差异很大。
同时抽一层留出集:每个类别随机抽 10% 的图片挪到val/下面,不参与 teacher 前向和学生训练。这 10% 图片不需要任何人工标注,留作蒸馏效果的验收,后面的 3.4 会用到。
3.2 teacher 前向:OpenCLIP 推理并保存 logits
数据准备好了,下一步就是跑一遍 teacher,对所有训练图片生成 logits。这里有一个关键经验:保存 logits,不要保存 softmax 之后的概率。因为蒸馏温度 T 在训练时可能调整,保存概率的话,换一次温度就得重跑一遍 teacher。
import os import numpy as np import torch import torch.nn.functional as F import open_clip from PIL import Image from torch.utils.data import DataLoader, Dataset class ImageFolderForTeacher(Dataset): def __init__(self, root, transform): self.samples = [] for c in sorted(os.listdir(root)): c_dir = os.path.join(root, c) if not os.path.isdir(c_dir): continue for f in sorted(os.listdir(c_dir)): if f.lower().endswith((".jpg", ".jpeg", ".png")): self.samples.append((os.path.join(c_dir, f), c)) self.transform = transform def __len__(self): return len(self.samples) def __getitem__(self, idx): path, _ = self.samples[idx] return self.transform(Image.open(path).convert("RGB")), path model, _, preprocess = open_clip.create_model_and_transforms( "ViT-B-32", pretrained="laion2b_s34b_b79k") model = model.cuda() tokenizer = open_clip.get_tokenizer("ViT-B-32") class_names = ["cat", "dog", "bird"] text_tokens = tokenizer([f"a photo of a {c}" for c in class_names]) text_features = F.normalize(model.encode_text(text_tokens), dim=-1) loader = DataLoader(ImageFolderForTeacher("data", preprocess), batch_size=64, num_workers=4, shuffle=False) all_logits, all_paths = [], [] for images, paths in loader: with torch.no_grad(): image_features = F.normalize(model.encode_image(images.cuda()), dim=-1) logits = (image_features @ text_features.T) / 0.07 all_logits.append(logits.cpu().numpy()) all_paths.extend(paths) all_logits = np.concatenate(all_logits) np.savez("teacher_logits.npz", logits=all_logits, paths=np.array(all_paths)) print(all_logits.shape)逻辑说明:text_features在循环外算一次,因为文本特征不随图片变化,省显存也省时间;除以 0.07 是 CLIP 常见的 logits 缩放因子,相当于把相似度分布拉开。如果后续发现所有类别概率都接近均匀,可以尝试把 0.07 改小到 0.05 或 0.03。
参数说明:batch_size=64在 24G 显存上比较稳妥,显存小就调成 32 或 16,只影响速度不影响结果;保存格式用np.savez把 logits 和图片路径存到同一个文件里。这一步落盘之后,后面训练 student 时不再需要加载 OpenCLIP 模型,小显存机器也能跑。
保存后建议顺手打印一份 label 分布:np.argmax(all_logits, axis=1)的直方图。分布严重偏斜的话,先排查类别名,再决定要不要继续训练。
3.3 训练 student:一个分类网络的标准训练循环
学生模型选型不需要复杂,ResNet-18 或 MobileNetV3 在这个方案里性价比最高。数据集大、类别多时用 ResNet-50 也不亏,但零标签蒸馏的上限由 teacher 决定,student 容量再大也带不来额外信息。
import torchvision.models as models from torch.utils.data import DataLoader, TensorDataset, random_split import numpy as np import torch data = np.load("teacher_logits.npz", allow_pickle=True) pseudo_labels = data["logits"].argmax(axis=1) paths = data["paths"] # 复用 3.2 的 ImageFolderForTeacher 配合 preprocess 读图 # images: (N, 3, 224, 224) 的 tensor,labels: pseudo_labels dataset = TensorDataset(images, torch.tensor(pseudo_labels)) train_set, val_set = random_split( dataset, [int(len(dataset) * 0.9), len(dataset) - int(len(dataset) * 0.9)], ) train_loader = DataLoader(train_set, batch_size=128, shuffle=True, num_workers=4) student = models.resnet18(num_classes=len(class_names)) optimizer = torch.optim.AdamW(student.parameters(), lr=1e-3, weight_decay=1e-4) scheduler = torch.optim.lr_scheduler.CosineAnnealingLR(optimizer, T_max=30) criterion = torch.nn.CrossEntropyLoss() for epoch in range(30): student.train() for images, labels in train_loader: out = student(images.cuda()) loss = criterion(out, labels.cuda()) optimizer.zero_grad() loss.backward() optimizer.step() scheduler.step()这段代码先按伪标签的 argmax 当硬标签训练,目的是把链路跑通。参数说明:学习率1e-3搭配 AdamW 在大多数分类任务上起步都不用改,发现 loss 震荡就降到3e-4;T_max=30要和总 epoch 数保持一致,否则余弦退火还没退完就断了。
一个容易被忽略的点:这里直接读图成 tensor 是为了先跑通,正式做的话我建议在 DataLoader 里加随机翻转和裁剪。student 学的是不变性,对蒸馏有益,特别是 OpenCLIP 自己用的预处理里没有数据增强,student 见过更多扰动版本,泛化会更好。
3.4 本地验证:没有测试集时用什么指标验收
零标签场景下没有人工标注,模型训完不能只看 loss。我一般用一致率:在留出来的val目录上,用 OpenCLIP 重新跑一遍 teacher,取 top1,再看 student 的预测和 teacher 一致的比例。
clip_pred = np.argmax(val_logits, axis=1) # 留出集上的 teacher 输出 student.eval() with torch.no_grad(): student_pred = torch.argmax(student(val_images.cuda()), dim=1).cpu().numpy() agree_rate = (student_pred == clip_pred).mean() print(f"student vs teacher agree rate: {agree_rate:.3f}")一致率不是真实准确率,但它是零标签蒸馏最直接的进度条:如果学生模型只有 70% 和 teacher 一致,说明知识还没学到位;到了 90% 以上,说明已经逼近 teacher 的预测边界。真实准确率最终靠人工抽验兜底,这个在第 6 章会讲。
4. 蒸馏损失与参数:把 OpenCLIP 的知识搬进小模型
网上搜知识蒸馏代码,十有八九是在有标签的 CIFAR 上做实验;到了零标签场景,第一个要改的就是 loss 的输入来源:teacher logits 从哪来、学生怎么对齐。第 3 章跑通了硬伪标签链路,这一章把 soft logits 加进来,变成真正意义上的蒸馏。
4.1 蒸馏损失函数怎么写:KL 散度加伪标签交叉熵
完整写一个零标签蒸馏的损失函数:
import torch import torch.nn.functional as F def distillation_loss(student_logits, teacher_logits, pseudo_labels, temperature=3.0, alpha=0.7, beta=0.3): # soft loss:学生、老师的 logits 都除以温度再做 KL 散度 soft_targets = F.softmax(teacher_logits / temperature, dim=-1) student_soft = F.log_softmax(student_logits / temperature, dim=-1) soft_loss = F.kl_div( student_soft, soft_targets, reduction="batchmean" ) * (temperature * temperature) # hard loss:直接用伪标签交叉熵 hard_loss = F.cross_entropy(student_logits, pseudo_labels) return alpha * soft_loss + beta * hard_loss这是零标签蒸馏的核心。两个关键点:
- 乘
temperature * temperature是为了抵消 softmax 除以温度带来的梯度缩小。温度越大,soft target 越均匀,loss 数值越小,不乘回去的话梯度会很小,训练半天不往前走。 pseudo_labels就是第 3 章teacher_logits.npz里 argmax 出来的硬标签。alpha和beta加起来是 1,alpha 大说明更相信 teacher 的概率分布,beta 大说明更相信硬标签。一般从 0.7 / 0.3 起步。
这里的teacher_logits来源和常规蒸馏不一样:它不是某个网络在真标签上训出来的 logits,而是 OpenCLIP 的 zero-shot 输出。所以 soft target 里包含的是图文对齐模型对样本不确定性的判断,这是零标签蒸馏最大的信息量来源,也是它跟“拿伪标签训练”的本质差别。
4.2 必调参数表:温度、阈值、学习率一次说清
零标签蒸馏里值得调的参数其实就几个,整理成一张表:
| 参数 | 推荐值 | 调节方向 |
|---|---|---|
| 蒸馏温度 T | 2 ~ 6 | T 偏大,分布趋均匀;T 偏小,趋近 one-hot |
| 伪标签阈值 τ | 0.7 ~ 0.9 | 置信度低于阈值的样本,soft loss 权重降为 0 |
| alpha / beta | 0.7 / 0.3 | teacher 不准时降 alpha,加 beta |
| batch size | 64 ~ 128 | 小 batch 下 KL 梯度方差大,调大更平滑 |
| 学习率 | 3e-4 ~ 1e-3 | 用 AdamW,loss 震荡就降 |
| epoch | 20 ~ 40 | 看一致率曲线,平了再停 |
温度这个参数最玄学。OpenCLIP 输出的 logits 本身数值范围窄,往往在正负零点几之间,直接除以 0.07 之后依然集中在很小的区间,所以温度通常往低调效果更明显。我一般先跑 T=3,如果 soft loss 长时间不降,把 T 调到 1.5 或 2 再看分布。
伪标签阈值 τ 的作用是对置信度低的样本做平滑处理:teacher 自己都拿不准的样本,它对蒸馏的贡献应该被削弱。常见做法是把低于 τ 的样本的 soft loss 乘一个 0 到 1 之间的衰减系数,或者干脆只用 hard loss 训练这部分样本。
4.3 伪标签会过期吗:教师输出重算与两阶段训练策略
OpenCLIP 作为 teacher 是冻结的,它的 logits 不会因为 student 变化而过期。真正的问题在 hard 伪标签:初始化时 teacher 对某些样本判断错误,这些错误会一直传导到训练结束。
我常用的策略是两阶段。第一阶段,只用 hard 伪标签跑 5 个 epoch,让 student 先具备粗糙的分类能力;第二阶段,切到 4.1 的混合损失,把 soft logits 加进来。这样做的好处是,第一阶段把模型拉到正确区域附近,第二阶段才不会在满是错误伪标签的早期被带偏。
如果中途想重算伪标签,不需要重新加载 OpenCLIP。直接用 student 当前预测的高置信度样本去“修正”hard 标签,这种做法也叫伪标签自训练。实际跑下来,第二阶段后期加一遍自训练,一致率能再涨 1 到 3 个点。
5. 避坑:零标签蒸馏常见的 5 个翻车现场与排查思路
这一章的每一条都是血泪经验。零标签方案最大的问题是:没有真标签兜底,出问题的时候很容易先怀疑代码写错了,最后发现大多是数据或参数问题。
5.1 伪标签严重偏向某一类,loss 下降但一致率极低
现象:训练时交叉熵 loss 正常下降,epoch 跑完一看,student 的预测全部落在同一个类别上,和 teacher 的一致率只有 50% 出头。
原因:CLIP 对某些类别名有先验偏好。比如类别集合里有“背景”“其他”这种抽象词,或者某个类别名写得太宽泛,teacher 在大量图片上的输出都偏向了它;伪标签 argmax 之后,模型被多数类淹没。
解决:先统计 teacher 伪标签的分布,如果发现某个类占比超过 60%,不要急着训练。检查类别名描述,把抽象词改成具体短语;然后用类别均衡采样重做 DataLoader;最后再看温度,温度低会让 softmax 更尖锐,放大 teacher 的偏好,先调高到 T=5 观察几轮。
5.2 soft loss 怎么调都不降,hard loss 却正常收敛
现象:切到混合损失后,KL 散度项停在某个值附近不动,硬标签交叉熵在下降。
原因:最常见的两种情况。一是 OpenCLIP 的 logits 分布太平,softmax 之后接近均匀分布,任何学生输出和它算 KL 散度差异都很小;二是学生模型容量不够,拟合不了 teacher 的细粒度输出。
解决:把温度下调到 1 或 2,让 soft target 更尖锐;确认保存的是 logits 而不是概率,否则你根本没机会换温度重算;如果还有问题,把 alpha 降到 0.5、beta 提到 0.5,让硬标签主导学习,soft loss 只做辅助平滑。
5.3 换一个 OpenCLIP 预训练权重,结果天差地别
现象:同一个数据、同一套代码,pretrained从laion2b_s34b_b79k换成openai,蒸馏后一致率差了 5 个点以上。
原因:不同来源的 CLIP 权重训练数据分布差异很大。OpenAI 原始权重对自然图像、常见物体类别名更可靠;LAION 权重在更广泛更杂的图文对上训练,对细粒度、非自然图像、抽象短语的鲁棒性更高。
解决:按数据域选权重。常规相机照片、常见物体用openai;工业缺陷、遥感、医疗影像这类特殊域,先在少量留出集上分别跑 teacher,比较伪标签分布和后续一致率再做决定。最稳妥的做法是跑两个 teacher,蒸馏时把两套 logits 平均,等于免费做了一层集成。
5.4 类别名是中文或太抽象,这一类的一致率直接对半砍
现象:其他类别一致率都到了 90%,就某一类只有 50%;看伪标签,这一类的样本大量被 teacher 分错到相近类上。
原因:CLIP 是英文训练,中文类别名先过翻译就不准;太抽象的类别名,比如“故障”“异常”“风景”,没有视觉实体,图文对齐的分数低。
解决:把类别名写成描述性短语。给出一个映射示例:
name_mapping = { "划痕": "a close-up photo of a scratched metal surface", "凹陷": "a close-up photo of a dented car body panel", "正常": "a clean and undamaged car body panel", "背景": "an out-of-focus background area of a workshop", }同时对每个类别生成多条模板变体,取文本特征均值,能缓解单条 prompt 不准的问题。模板变化比如加 different、a large、a small 这类词,平均之后特征更稳,a photo of a {c}单模板跑不动的类别,多模板平均往往能拉回来。
5.5 蒸馏完精度反而不如直接训伪标签
现象:加 soft loss 后,一致率从 92% 掉到 89%,损失函数看起来没毛病,结果反而亏了。
原因:alpha 权重过高,student 被 teacher 的模糊输出带偏了。OpenCLIP 在部分样本上本身就置信度低、分布跨类,soft loss 比例太大会把这种不确定性放大,导致 student 变得“不敢给确定答案”。
解决:第一阶段先纯 hard 训练 5 轮,再切混合 loss;alpha 从 0.3 起步,看一致率涨再往上加。这也是 4.3 里两阶段策略的意义。记住一个原则:hard 标签是下限,soft logits 是上限,alpha 是两者之间的游标。
6. 验证与进阶:从跑通到能上线还要做的两件事
模型跑通容易,上线前总得有人说清楚它到底行不行。我的习惯是用三板斧验收:一致率、类别分布、人工抽验,三关都过才敢部署。
6.1 上线前验收三板斧:一致率、类别分布、人工抽验
第一板斧是一致率。student 在留出集上和 teacher 的 top1 一致率到 95% 以上,说明学生已经是“低配版 teacher”;如果只有 80%,知识还没学到位。第二板斧是看 student 预测的类别分布是否和 teacher 接近——如果 student 在“cat”上占比 20%,teacher 只有 10%,说明模型学到的是偏置而不是知识。第三板斧是从每个类别随机抽 30 张图给人看,确认没有系统性错误。最后这一步最笨,但零标签方案没有它兜底,上线就是赌运气。
6.2 进阶技巧:伪标签清洗与二次蒸馏,再谈部署
进阶做法里最值钱的是伪标签清洗。蒸馏完成后,用 student 对全量数据再预测一次,筛出置信度高于 0.95 的样本,和 teacher 原本的伪标签合并,再做一轮自蒸馏。这个方法本质上是把 student 学到的信息反馈回去,让伪标签质量随训练轮次提高,是零标签蒸馏里少数能持续涨分的技巧。
部署方面,student 选 ResNet-18 或 MobileNetV3 就是为了这一步:导出 ONNX、做 int8 量化,参数量小,损失可控。我自己的习惯是每一步都留一个能加载的 checkpoint 和对应的伪标签版本,方便随时回滚。零标签蒸馏最怕的是一路训完才发现某个参数错了,所以中间产物的落盘比精度更重要。希望这一套从选型到避坑的流程能帮到你,少踩几个我踩过的坑。
本文还有配套的精品资源,点击获取