一致性引导提示学习:提升视觉语言模型少样本泛化能力
做视觉-语言模型下游适配的同行,大概都踩过同一个坑:拿 CLIP 这类预训练模型做少样本分类,手工写的 prompt 稍微换个说法,准确率就能上下浮动好几个点。后来 CoOp 一类的提示学习把 prompt 里的上下文词换成可学习向量,效果确实上来了,但新问题跟着来——在 base 类上拟合得很好,换到 novel 类就掉得明显,本质是那几条可学习向量把训练样本记住了。ICLR 2024 上这篇关于一致性引导提示学习(Consistency-Guided Prompt Learning)的工作,抓的正是这个痛点:与其一味堆正则、把 prompt 往手工先验上拽,不如让模型在多种视图、多种模态、多种扰动下保持预测一致。这个思路听起来朴素,落地时讲究不少——一致性约束加在哪一层、权重怎么调、增强怎么配,每一步都影响最终泛化。这篇内容适合正在做视觉-语言模型少样本适配、提示微调,以及想把一致性正则用到多模态任务里的同学,我会把方法拆开,把可复现的配置和踩坑记录一并写出来。
1. 视觉-语言模型提示学习为什么需要一致性引导
1.1 从手工 Prompt 到可学习 Prompt 的演进逻辑
先把背景捋清楚。CLIP 这类视觉-语言模型的核心能力,是把图像和文本映射到同一个特征空间,然后通过余弦相似度做匹配。做零样本分类时,我们给每个类别拼一句文本,比如 “a photo of a dog”,把这句话送进文本编码器得到文本特征,再和图像特征算相似度,取最高的那类作为预测。这里的 “a photo of a” 就是所谓 prompt 模板,它本身不参与训练,全靠人工设计。
问题在于,模板对结果极其敏感。我做过一组对照,同一批图像,模板从 “a photo of a {}” 换成 “a picture of a {}”,在细粒度数据集上准确率能差出三到五个点。原因是文本编码器对词序和用词很敏感,人工试错成本高,而且换一个数据集就得重新调。CoOp 的思路很直接:把模板里的上下文词替换成一组可学习的连续向量,让模型自己在训练中把这几个向量调到合适的位置,类别名仍然作为文本输入保留。这样既保留了 CLIP 文本编码器的结构,又把模板设计这件手工活交给了优化器。
这个改动的好处是参数量极小。以 16 个上下文 token 为例,每个 token 的维度是 512,总共也就八千多个可学习参数,相比整个 CLIP 动辄上亿的参数,几乎可以忽略。参数少意味着训练快、显存友好,在只有几张卡的环境里也能跑。但参数少不等于没有过拟合风险,这一点后面会详细说。
1.2 泛化掉点的真正原因:提示向量在记忆样本
提示学习在少样本场景下的经典评测协议是 base-to-novel:用一批类别(base)的训练样本学 prompt,然后分别在 base 类的新测试样本和完全没见过的 novel 类上评测。理想情况下,prompt 学到的应该是“如何描述一个类别”的通用方式,而不是“这批训练图像长什么样”。实测下来,base 类准确率往往很漂亮,novel 类却明显掉队,两者的差距就是泛化 gap。
我把这个 gap 归因到两点。第一,可学习向量虽然参数量小,但在只有 16 shot 的情况下,它仍然有足够自由度去拟合训练集的偶然特征,比如背景、拍摄角度、光照。第二,交叉熵损失只关心“预测对不对”,不关心“模型对扰动的反应是否稳定”,于是模型会走捷径,把 prompt 调成对训练分布过度敏感的样子。
一致性引导的切入点就在这里。它不去直接限制 prompt 向量本身长什么样,而是要求模型面对同一张图的不同增强版本、或者同一语义的不同表达时,输出分布保持稳定。这个约束是软的、任务无关的,相当于给优化过程加了一条“别对无关变化过度反应”的护栏。相比直接把 prompt 往手工模板上拉的正则,一致性约束不引入额外的人工先验,泛化收益更干净。
2. 一致性引导的核心思路拆解
2.1 三种常见的一致性维度与取舍
读标题里的 “consistency-guided”,第一反应要问:一致的是什么东西?基于这类方法的常见实践,一致性通常可以落在三个维度上,各自的作用和代价不同。
视图一致性:对同一张图像施加两次不同的数据增强(随机裁剪、颜色抖动、翻转等),分别前向,要求两次输出的类别分布接近。这是最常用也最容易实现的一种,本质是把半监督里的一致性正则搬到多模态场景。代价是要做两次前向,训练时间大约翻倍。
模态一致性:图像侧做扰动的同时,文本侧也对 prompt 做轻微扰动(比如对上下文向量加小噪声,或者用两种不同的模板表达),要求跨模态的匹配关系稳定。这种约束更贴近视觉-语言模型的本质,但实现上要小心,文本侧的扰动过强会破坏语义。
样本或任务一致性:在多个数据集、多个任务之间共享 prompt 时,要求 prompt 在不同任务上的行为不至于剧烈冲突。这种适合多任务联合训练的场景,单数据集少样本实验里用得少。
实际落地时,视图一致性是性价比最高的起点,实现简单、效果稳定。我建议先把这一条做扎实,再考虑叠加模态一致性。两者同时上、权重没调好,很容易互相干扰,训练曲线会变得很难看。
2.2 损失函数怎么搭:主任务损失加一致性正则
整体损失是两项相加:
L = L_ce + λ * L_consL_ce是标准的交叉熵,用图像特征和各类别文本特征的相似度算 logits。L_cons是一致性项,常见做法是两次视图输出分布之间的 KL 散度或均方误差。这里有几个细节值得说清楚。
第一,用 KL 还是 MSE。KL 散度对分布形状更敏感,适合分类任务;MSE 实现简单,但在类别数多的时候梯度尺度不好控制。我的经验是分类任务优先用对称 KL,也就是两个方向各算一次取平均,避免单向 KL 把某一侧当成“教师”造成不对称。
第二,要不要 stop-gradient。如果两次前向用的是同一个模型,直接算 KL 会让模型倾向于输出均匀分布来“偷懒”,因为均匀分布之间的一致性最高。解决办法有两种:一种是对其中一侧做 stop-gradient,模仿 Mean Teacher 的思路;另一种是给一致性项加一个温度系数,把分布锐化后再算。我倾向后者,实现更简单,不需要维护额外的教师模型。
第三,λ 的设置。这个权重不能一开始就给大,否则一致性项会压过主任务,模型学不到分类能力。常见的做法是 warmup:前几个 epoch λ 从 0 线性涨到目标值。目标值本身跟数据集难度有关,粗略范围在 0.1 到 1.0 之间,需要靠消融确定。
注意:一致性项不是越大越好。λ 超过某个阈值后,novel 类准确率反而会下降,因为约束过强会让 prompt 失去区分度,输出趋于平滑。
3. 可复现实现:一致性引导提示学习的关键环节
3.1 模型骨架与可学习提示的构造
先把骨架搭起来。我用 PyTorch 写,视觉编码器和文本编码器都冻结,只训练上下文向量。下面是提示构造的核心片段,省略了 CLIP 加载的样板代码。
import torch import torch.nn as nn class PromptLearner(nn.Module): def __init__(self, clip_model, classnames, n_ctx=16, ctx_init="a photo of a"): super().__init__() # 用初始化短语的词向量填充上下文,比随机初始化收敛更稳 tokenized = clip_model.tokenizer([ctx_init]) with torch.no_grad(): init_emb = clip_model.token_embedding(tokenized).type(clip_model.dtype) self.ctx = nn.Parameter(init_emb[:, 1:1 + n_ctx, :].clone()) self.n_ctx = n_ctx # 类别名单独编码,不参与梯度 self.class_embeddings = self._encode_classes(clip_model, classnames) def _encode_classes(self, clip_model, classnames): prompts = [f"a photo of a {c}." for c in classnames] tokens = clip_model.tokenizer(prompts) with torch.no_grad(): emb = clip_model.token_embedding(tokens).type(clip_model.dtype) return emb def forward(self, clip_model): # 拼接:可学习上下文 + 类别词嵌入 prompts = torch.cat([self.ctx.expand(len(self.class_embeddings), -1, -1), self.class_embeddings], dim=1) # 送入文本编码器,得到每个类别的文本特征 text_features = clip_model.encode_text_from_embeddings(prompts) return text_features / text_features.norm(dim=-1, keepdim=True)这段代码有两个容易出错的点。上下文向量的初始化用 “a photo of a” 的词向量,而不是纯随机,收敛速度和最终精度都更稳,这是 CoOp 原文就验证过的经验。另外类别词嵌入要和上下文向量在序列维度上正确拼接,位置对齐错了,模型也能训,但效果会莫名其妙地差,排查起来很费时间。
3.2 双视图前向与一致性损失的计算
数据增强部分,我用 torchvision 的变换组合,做两次独立采样。注意两次增强要来自同一张原图,强度适中,裁剪比例别太激进,否则一致性约束会变成“逼模型在信息缺失的情况下也保持一致”,反而有害。
def consistency_loss(logits_a, logits_b, temperature=2.0): # 温度锐化,避免模型靠输出均匀分布来降低一致性损失 p = torch.log_softmax(logits_a / temperature, dim=-1) q = torch.softmax(logits_b / temperature, dim=-1) # 对称 KL loss_ab = torch.sum(q * (q.log() - p), dim=-1).mean() loss_ba = torch.sum(p.exp() * (p - q.log()), dim=-1).mean() return 0.5 * (loss_ab + loss_ba)温度参数这里给 2.0,是我在几个数据集上试出来的折中值。温度太低,分布太尖锐,一致性约束容易被少数高置信样本主导;温度太高,分布被抹平,约束失去区分力。这个值不用精调,1.5 到 3.0 之间结果差异不大,但一定要设,不能直接用原始 logits。
3.3 训练循环、优化器与超参配置
训练循环的主体结构如下,我把 warmup 和梯度裁剪都加上了。
optimizer = torch.optim.SGD(prompt_learner.parameters(), lr=0.002, momentum=0.9, weight_decay=5e-4) scheduler = torch.optim.lr_scheduler.CosineAnnealingLR(optimizer, T_max=epochs) lambda_max, warmup_epochs = 0.5, 5 for epoch in range(epochs): lam = lambda_max * min(1.0, epoch / warmup_epochs) for images, labels in loader: view_a, view_b = aug(images), aug(images) # 两次前向,共享 prompt 与冻结编码器 logits_a = forward(clip_model, prompt_learner, view_a) logits_b = forward(clip_model, prompt_learner, view_b) loss_ce = F.cross_entropy(logits_a, labels) loss_c = consistency_loss(logits_a, logits_b) loss = loss_ce + lam * loss_c optimizer.zero_grad() loss.backward() torch.nn.utils.clip_grad_norm_(prompt_learner.parameters(), 1.0) optimizer.step() scheduler.step()超参方面,优化器用 SGD 而不是 AdamW,原因是可学习参数太少,AdamW 的逐参数自适应学习率在这个尺度上反而容易震荡,这一点跟常规微调经验相反,值得注意。学习率 0.002、权重衰减 5e-4 是比较稳的起点。批次大小受显存限制,16 到 32 都行,太小会让一致性损失的统计噪声变大。
epoch 数建议 50 起步,少样本场景下 prompt 收敛不算快,过早停会误判方法无效。梯度裁剪阈值设 1.0,主要是防止一致性项在训练初期梯度爆炸。
4. 实验设计与效果验证的要点
4.1 base-to-novel 协议的落地细节
评测协议要严格对齐,否则数字没法比。base 类用全部训练样本学 prompt,测试时分别报告 base 类测试集准确率和 novel 类测试集准确率,再算两者的调和平均作为综合指标。调和平均比算术平均更能反映平衡性,一边高一边低的情况会被压下去。
数据集选择上,常规做法是覆盖通用物体、细粒度、场景、纹理几类,每个数据集按类别划分 base 和 novel,比例常见是 1:1 或 base 占多数。划分方式一旦确定就不能改,我见过有人为了刷高 novel 准确率偷偷调整划分,这种结果没有任何参考价值。
4.2 消融实验怎么设计才有信息量
消融不能只做“去掉一致性项”这一个对照,那样只能说明“有用”,说明不了“为什么有用”。我建议至少做三组。
| 消融维度 | 对照设置 | 观察指标 | 预期结论 |
|---|---|---|---|
| 一致性项有无 | λ=0 vs λ=0.5 | base/novel 准确率 | novel 提升,base 基本持平 |
| 一致性维度 | 仅视图 vs 仅模态 vs 两者 | 训练稳定性、最终精度 | 视图为主,模态增益有限 |
| λ 敏感性 | 0.1 / 0.5 / 1.0 / 2.0 | novel 准确率曲线 | 存在明显峰值,过大反降 |
| 温度设置 | 1.0 / 2.0 / 3.0 | 一致性损失收敛速度 | 差异不大,2.0 较稳 |
这组消融能回答一个关键问题:收益到底来自“正则化”还是来自“多视图带来的数据效率”。如果只加一致性项但不做双视图、改成对同一视图前向两次,novel 准确率应该基本不涨,这就说明双视图的信息增益是必要的。
5. 常见问题与排查实录
5.1 一致性损失不降或训练震荡
表现是训练日志里loss_cons一直在高位波动,不随 epoch 下降。优先查三件事:一是数据增强强度,裁剪比例如果设到 0.2 以下,两个视图信息量差异太大,模型无法对齐,把裁剪下限提到 0.5 通常就正常了;二是温度是否设了,没设温度直接用原始 logits 算 KL,分布太尖,梯度不稳定;三是 warmup 是否生效,λ 一上来就给 0.5,前几个 epoch 主任务还没学好,一致性项会把优化方向带偏。
还有一个隐蔽原因:两次视图如果用了同一个随机种子,增强结果完全一样,一致性损失恒为零,日志看起来“很稳”,但实际没有任何约束作用。这个坑我在早期实现里踩过,排查了半天才发现增强函数里把种子写死了。
5.2 提示向量退化与 novel 类不升反降
所谓退化,是指训练后期 base 准确率还在涨,novel 准确率开始掉。这种情况通常是一致性权重过大,或者训练 epoch 太多。我的处理顺序是先把 λ 减半看曲线,如果 novel 回升,说明约束过强;如果没变化,再看 epoch,把训练轮数砍掉三分之一,观察 novel 是否在更早的位置达到峰值。
另一个可能是类别词嵌入被意外更新了。类别名部分必须保持冻结,如果实现时不小心把它和上下文向量一起放进了优化器参数组,等于让模型直接改类别语义,base 类会虚高,novel 必然崩。检查方法是打印优化器参数组,确认只包含ctx。
5.3 显存与批次配置的工程问题
双视图前向意味着激活值翻倍,显存压力比普通提示学习大不少。几个缓解手段:一是把视觉编码器的前向用torch.no_grad()包住,因为编码器冻结,不需要存它的计算图,这一条能省下大量显存;二是两次视图不要同时保留中间激活,可以先后前向、各自 detach 出 logits 再算一致性;三是批次从 16 起步,稳定后再试着往上加。
| 问题现象 | 可能原因 | 排查动作 | 处理方案 |
|---|---|---|---|
| loss_cons 不下降 | 增强过强 / 未设温度 / 同种子 | 检查增强参数与温度 | 裁剪下限 0.5,温度 2.0 |
| novel 准确率下降 | λ 过大 / epoch 过多 | 做 λ 与 epoch 的单变量扫描 | λ 减半,epoch 砍三分之一 |
| base 虚高、novel 崩 | 类别嵌入被更新 | 打印优化器参数组 | 只优化 ctx |
| 显存溢出 | 双视图激活翻倍 | 检查是否 no_grad | 编码器前向禁用梯度 |
| 训练速度过慢 | 两次前向串行 | 分析耗时分布 | 视图前向分批或异步 |
我个人在实际操作中的体会是,一致性引导这类方法的价值不在于单点涨几个点,而在于它让提示学习的调参变得不那么脆弱。以前换个数据集、换个 shot 数,prompt 的超参就得重调一遍;加上一致性约束之后,学习率和 epoch 的敏感区间明显变宽,同一套配置在多个数据集上都能跑出接近的结果。这对实际项目里没法为每个数据集单独调参的场景,意义比论文里那零点几个点的提升更实在。如果你正在做多模态少样本适配,建议先把视图一致性这条最简路径跑通,确认训练稳定、novel 不掉队,再考虑往上叠模态一致性。别一上来就三四种约束全加,那种配置在论文里能收敛,在你自己的数据上大概率会给你一堆看不出原因的坏曲线。