简介:这份PDF资料面向NLP研究者、算法工程师与LLM学习者,系统解读Prompt-Tuning这一新兴微调范式,帮助读者理解其如何弥补传统Fine-tuning在语义差异与过拟合上的痛点。内容从预训练语言模型三阶段演进讲起,覆盖BERT、GPT、BART、T5直至ChatGPT、GPT-4,并深入剖析离散与连续Prompt构建、In-Context Learning、Chain-of-Thought、Instruction-tuning及黑盒模型下的Prompt使用策略,同时探讨Prompt的设计选择、有效性评估与未来研究方向。资源为单个PDF文件,压缩包约17.21MB,结构完整、图文并茂,适合作为综述与讨论并重的学习材料。目前已有1551人学习下载,读者可借此建立从预训练到参数有效性训练的完整知识框架,掌握面向不同NLP任务的Prompt设计思路,为科研与工程实践提供参考。
1. Prompt-Tuning 到底改了什么:从「重训模型」到「只改提示」
很多做 NLP 落地的工程师第一次听到 Prompt-Tuning,反应是「不就是写个提示词吗」。但真正把 BERT 类模型拿去做小样本分类时,你会发现传统 Fine-tuning 在几百条数据上几乎必然过拟合,而 Prompt-Tuning 能在同样数据量下把 F1 拉高十几个点。它改的不是模型结构,而是把下游任务重新表述成预训练阶段就见过的完形填空形式,让模型用「填空」的方式做分类、抽取、推理。这套范式最早由 GPT-3 和 PET 带火,核心组件是 Template(模板)和 Verbalizer(标签词映射),合称 PVP。它适合标注数据少、算力有限、又不想动预训练权重的场景,尤其适合需要快速验证多个 NLP 任务的团队。下面从原理到代码,把这份综述里提到的关键路径拆开讲。
2. 预训练目标与下游任务的语义鸿沟:为什么 Fine-tuning 在小样本上会翻车
2.1 MLM 和 NSP 到底预训练了什么
BERT 的预训练目标里,Masked Language Modeling(MLM)是主力。具体做法是随机抽取 15% 的 token 做处理:其中 80% 替换成[MASK],10% 替换成随机词,10% 保持不变。模型的任务是根据上下文预测被处理位置的原词,损失函数是词表上的交叉熵。这个目标让模型学会了双向上下文表示,但也埋了一个隐患——下游 Fine-tuning 时,模型头部会新加一个随机初始化的 MLP 分类器,这个分类器的目标和 MLM 的「预测词表分布」完全不是一回事。
NSP(Next Sentence Prediction)则是判断两个句子是否相邻,输入格式是[CLS] S1 [SEP] S2 [SEP],用[CLS]的隐状态做二分类。后来的 RoBERTa、ALBERT 等模型发现 NSP 对下游任务没有明显正向收益,陆续删掉了这个目标。到 Prompt-Tuning 阶段,绝大多数方法只复用 MLM 的头部,不再关心 NSP。
2.2 传统 Fine-tuning 的两个硬伤
第一个硬伤是语义差异。预训练阶段模型在做「完形填空」,下游 Fine-tuning 阶段却让它做「句子分类」,两者目标差距过大。在标注数据充足时,模型能通过大量梯度更新抹平这个差距;但数据一少,模型还没来得及适应新目标就过拟合了。
第二个硬伤是过拟合。Fine-tuning 阶段引入的分类器参数是随机初始化的,在小样本上训练时,这些参数会迅速记住训练集的噪声。常见做法是冻结底层、只调顶层,但即便如此,分类器本身仍然需要足够数据才能稳定。
Prompt-Tuning 的思路是:既然预训练阶段模型已经会做 MLM 了,那下游任务能不能也变成 MLM?比如情感分析,不直接分类,而是让模型填空——「I like this movie. It was [MASK].」,如果[MASK]预测出great,就判为正面;预测出terrible,就判为负面。这样模型复用的还是预训练时的 MLM 头部,不需要新加分类器,小样本下的过拟合风险大幅降低。
2.3 五类 NLP 任务在 Prompt 下的形式化转换
综述里把 NLP 任务分成五类,每类都可以转成完形填空:
| 任务类型 | 传统 Fine-tuning 做法 | Prompt-Tuning 做法 |
|---|---|---|
| 单句分类 | [CLS]接 MLP 分类 | 拼接模板,预测[MASK]位置的标签词 |
| 句子对分类 | 两句拼接后[CLS]分类 | 模板中嵌入两句,预测[MASK] |
| 区间预测 | 预测 start/end 位置 | 模板中询问「答案是什么」,生成或匹配 |
| 字符分类 | 每个 token 接 MLP | 模板中嵌入待标注 token,预测[MASK] |
| 文本生成 | 自回归解码 | 模板作为前缀,模型续写 |
这张表的关键在于:所有任务都被统一到了「预测[MASK]位置该填什么词」这个形式上。这也是为什么 Prompt-Tuning 在 BERT 类模型上特别自然——因为 BERT 本来就是用 MLM 训练的。
3. 动手实现一个 Prompt-Tuning 情感分类器:Template 和 Verbalizer 怎么写
3.1 环境准备与模型加载
先装依赖。这里用 HuggingFace 的transformers和torch,模型选bert-base-uncased,因为它的 MLM 头部是现成的。
pip install transformers torch datasets加载模型时,关键是要用BertForMaskedLM,而不是BertForSequenceClassification。前者保留了预训练好的 MLM 头部,后者会随机初始化一个新的分类器。
from transformers import BertTokenizer, BertForMaskedLM import torch # 加载 tokenizer 和带 MLM 头部的 BERT tokenizer = BertTokenizer.from_pretrained('bert-base-uncased') model = BertForMaskedLM.from_pretrained('bert-base-uncased') model.eval() # 确认 MLM 头部存在 print(model.cls) # 应该输出 BertOnlyMLMHead逻辑说明:BertForMaskedLM的输出维度是词表大小(30522),而不是类别数。这意味着模型输出的是每个 token 在词表上的概率分布,我们需要通过 Verbalizer 把这个分布映射到类别上。参数上,from_pretrained会自动加载预训练权重,不需要额外指定num_labels。
3.2 构建 Template 和 Verbalizer
以情感分析为例,Template 设计为"It was [MASK].",Verbalizer 映射为{positive: ["great", "good", "wonderful"], negative: ["terrible", "bad", "awful"]}。
# 定义模板和标签词映射 template = "It was [MASK]." verbalizer = { "positive": ["great", "good", "wonderful"], "negative": ["terrible", "bad", "awful"] } # 获取标签词的 token id label_word_ids = {} for label, words in verbalizer.items(): ids = [tokenizer.convert_tokens_to_ids(w) for w in words] label_word_ids[label] = ids print(label_word_ids) # 输出示例:{'positive': [2307, 2204, 7545], 'negative': [6659, 2919, 9644]}逻辑说明:convert_tokens_to_ids把标签词转成词表中的 id。注意 BERT 的 tokenizer 可能会把单词拆成 subword,比如wonderful可能被拆成wonder和##ful。这里选的都是单 token 词,避免拆分带来的映射问题。如果标签词被拆分了,需要取第一个 subword 的 id,或者换一个单 token 词。
3.3 前向推理与类别判定
把原始句子和模板拼接,送入模型,取[MASK]位置的 logits,然后在标签词 id 上做 softmax。
def predict(text): # 拼接模板 input_text = text + " " + template inputs = tokenizer(input_text, return_tensors='pt') # 找到 [MASK] 的位置 mask_token_index = torch.where( inputs['input_ids'] == tokenizer.mask_token_id )[1] # 前向传播 with torch.no_grad(): outputs = model(**inputs) logits = outputs.logits # 取 [MASK] 位置的 logits mask_logits = logits[0, mask_token_index, :] # 在标签词上做 softmax probs = {} for label, ids in label_word_ids.items(): label_logits = mask_logits[0, ids] probs[label] = torch.softmax(label_logits, dim=-1).sum().item() # 归一化 total = sum(probs.values()) probs = {k: v/total for k, v in probs.items()} return probs # 测试 print(predict("I like the Disney films very much.")) # 输出示例:{'positive': 0.92, 'negative': 0.08}逻辑说明:mask_token_index定位[MASK]在序列中的位置。logits[0, mask_token_index, :]取出该位置的词表分布。对每个标签下的多个词,先取 logits 再 softmax 求和,相当于把多个标签词的概率聚合到类别上。最后归一化保证两类概率和为 1。参数上,torch.no_grad()关闭梯度,推理时省显存。
3.4 训练时的损失函数怎么写
如果要做 few-shot 训练,损失函数不是分类交叉熵,而是 MLM 的交叉熵——只计算[MASK]位置在标签词上的损失。
from torch.optim import AdamW optimizer = AdamW(model.parameters(), lr=1e-5) def train_step(text, label): input_text = text + " " + template inputs = tokenizer(input_text, return_tensors='pt') mask_token_index = torch.where( inputs['input_ids'] == tokenizer.mask_token_id )[1] # 构造标签:把 [MASK] 位置的目标设为标签词的 id labels = inputs['input_ids'].clone() target_id = label_word_ids[label][0] # 取第一个标签词 labels[0, mask_token_index] = target_id outputs = model(**inputs, labels=labels) loss = outputs.loss loss.backward() optimizer.step() optimizer.zero_grad() return loss.item()逻辑说明:labels默认等于input_ids,但 MLM 的损失只计算被 mask 的位置。这里手动把[MASK]位置的目标 id 改成标签词的 id,其他位置保持不变。HuggingFace 的BertForMaskedLM会自动忽略非[MASK]位置的损失。学习率设1e-5是因为只微调 MLM 头部和顶层,不需要太大。
4. Pattern 和 Verbalizer 的自动搜索:手工调参的替代方案
4.1 为什么手工设计 PVP 不稳定
综述里给了一张对比图:同样的 Pattern 下,换不同的 label word,准确率能差 10 个点以上;同样的 Verbalizer 下,换不同的 Pattern,结果波动也很大。这意味着手工设计 PVP 本质上是在做超参数搜索,而且搜索空间是离散的、非连续的。常见做法是枚举几个候选模板,每个模板配几组标签词,跑一遍验证集选最好的。但这种方法成本高,且不能保证找到全局最优。
4.2 连续提示(Soft Prompt)的实现思路
连续提示的做法是不再指定离散的模板词,而是用一组可训练的向量作为「伪模板」。这些向量初始化为随机值或词向量,训练时参与梯度下降。
import torch.nn as nn class SoftPrompt(nn.Module): def __init__(self, model, prompt_length=10): super().__init__() self.model = model self.prompt_length = prompt_length # 可训练的伪模板向量 self.prompt_embeddings = nn.Parameter( torch.randn(prompt_length, model.config.hidden_size) ) def forward(self, input_ids, attention_mask): # 获取原始词嵌入 inputs_embeds = self.model.bert.embeddings.word_embeddings(input_ids) # 拼接伪模板 prompt = self.prompt_embeddings.unsqueeze(0).expand( inputs_embeds.size(0), -1, -1 ) inputs_embeds = torch.cat([prompt, inputs_embeds], dim=1) # 扩展 attention mask prompt_mask = torch.ones( attention_mask.size(0), self.prompt_length ).to(attention_mask.device) attention_mask = torch.cat([prompt_mask, attention_mask], dim=1) return self.model( inputs_embeds=inputs_embeds, attention_mask=attention_mask )逻辑说明:prompt_embeddings是形状为(prompt_length, hidden_size)的可训练参数。前向传播时,把它拼接到原始词嵌入前面,相当于在输入序列开头插入了prompt_length个「软 token」。这些 token 没有对应的词表 id,但它们的向量会参与注意力计算。参数上,prompt_length一般取 10 到 20,太长会挤占原始文本的位置编码空间。
4.3 用规则做 Pattern 搜索的轻量方案
如果不想引入连续提示的复杂度,可以用规则枚举 Pattern。比如对于关系抽取任务,可以定义若干子模板,然后组合。
# 关系抽取的子模板示例 sub_templates = { "subject": "{subject}", "object": "{object}", "relation": "The relation between {subject} and {object} is [MASK]." } def build_pattern(subject, object, relation_type): # 根据关系类型选择子模板组合 if relation_type == "father": return f"{subject} is the father of {object}. It was [MASK]." elif relation_type == "spouse": return f"{subject} and {object} are married. It was [MASK]." else: return f"The relation between {subject} and {object} is [MASK]."逻辑说明:这种规则法适合关系类型有限、且每种关系有明确语义描述的场景。build_pattern根据关系类型动态拼接模板,避免了为每个样本单独设计模板。但规则法的问题是覆盖不全,遇到未见过的关系类型时只能回退到通用模板。
5. 避坑与排查:Prompt-Tuning 落地时最容易踩的五个坑
5.1 标签词被 tokenizer 拆成 subword
现象:convert_tokens_to_ids("wonderful")返回的不是一个 id,而是一个列表,或者返回[unk]的 id。
原因:BERT 的 tokenizer 基于 WordPiece,部分词会被拆成多个 subword。比如wonderful可能被拆成wonder和##ful,convert_tokens_to_ids只取第一个 subword 的 id,导致映射错误。
解决:先用tokenizer.tokenize(word)检查是否被拆分。如果被拆分,换一个单 token 的标签词,或者取第一个 subword 的 id 并在 Verbalizer 中做相应处理。常见做法是优先选高频、短小的词作为标签词。
5.2 模板位置放错导致[MASK]预测无意义
现象:模型在[MASK]位置预测出的词和任务完全无关,准确率接近随机。
原因:模板拼接位置不对。比如把[MASK]放在句子开头,模型缺少上下文,只能靠先验预测。或者模板和原句之间没有分隔符,导致 tokenizer 把两个句子混在一起。
解决:确保[MASK]出现在有足够上下文的末尾或中间。常见做法是原句 + 模板,模板以It was [MASK].结尾。拼接时加空格,让 tokenizer 正确分词。
5.3 学习率设太大导致 MLM 头部崩溃
现象:训练几个 step 后,模型对所有输入都预测同一个标签词,loss 不下降或震荡。
原因:MLM 头部是预训练好的,参数已经在一个较优的位置。学习率设成1e-3或更大时,梯度更新会直接破坏这些参数。
解决:学习率设1e-5到5e-5之间,并用 warmup。常见做法是前 10% 的 step 做线性 warmup,之后余弦衰减。如果还是不稳定,冻结 BERT 底层,只调 MLM 头部和最后两层。
5.4 Verbalizer 中标签词数量不均衡
现象:正面类有 5 个标签词,负面类只有 1 个,模型倾向于预测正面类。
原因:多个标签词的概率求和后,标签词多的类别天然概率更大。这不是模型偏置,而是 Verbalizer 设计导致的。
解决:每个类别的标签词数量保持一致,或者对每个类别的概率取平均而不是求和。常见做法是每类选 3 到 5 个标签词,且词频相近。
5.5 小样本下验证集划分不合理
现象:在 few-shot 设置下,验证集准确率波动极大,换一个随机种子结果完全不同。
原因:样本量太小,验证集本身不能代表整体分布。比如 16 个训练样本、8 个验证样本,验证集里多一个难样本就能让准确率掉 10 个点。
解决:用交叉验证代替单次划分,或者固定多个随机种子取平均。常见做法是跑 5 个种子,报告均值和方差。如果方差超过 5 个点,说明数据量还不够,需要增加标注样本。
6. 进阶技巧:用 PET 的集成思路把准确率再拉一截
PET 论文里有一个很实用的技巧:同一个句子设计多个 Pattern,推理时对所有 Pattern 的预测做投票或加权。这个思路实现起来不复杂,但效果提升明显。我一般会准备 3 到 5 个 Pattern,比如情感分析用It was [MASK].、I think it is [MASK].、This comment is [MASK].,每个 Pattern 配一组 Verbalizer,推理时把每个 Pattern 下正面类的概率加起来,取平均。
patterns = [ "It was [MASK].", "I think it is [MASK].", "This comment is [MASK]." ] def ensemble_predict(text): all_probs = [] for p in patterns: probs = predict_with_pattern(text, p) all_probs.append(probs) # 平均集成 avg_probs = {} for label in all_probs[0].keys(): avg_probs[label] = sum(p[label] for p in all_probs) / len(all_probs) return avg_probs逻辑说明:predict_with_pattern是前面单 Pattern 推理函数的封装,接受自定义模板。ensemble_predict对所有 Pattern 的概率取平均。如果某个 Pattern 明显更可靠,可以加权,比如给It was [MASK].设 0.5 的权重,其他各 0.25。参数上,Pattern 数量不是越多越好,超过 5 个后边际收益递减,且推理成本线性增长。
另一个技巧是 Verbalizer 集成。同一个 Pattern 下,正面类可以用great、good、wonderful三个词,推理时把这三个词的概率加起来。这相当于在标签词层面做了数据增强。注意每个类的标签词数量要一致,否则概率和会偏向词多的类。
验证集成是否有效的方法很简单:在验证集上分别跑单 Pattern 和集成,看准确率提升是否超过 2 个点。如果提升不明显,说明 Pattern 之间差异太小,需要换更多样化的模板。我自己的习惯是,每次设计完 PVP 后,先跑一遍单 Pattern 基线,再跑集成,如果集成提升不到 1 个点,就回去检查 Pattern 是不是太相似了。从那以后我每次做 Prompt-Tuning 实验,都强制走一遍「单 Pattern 基线 → 多 Pattern 集成 → 多 Verbalizer 集成」的流程,避免在单一配置上过拟合。希望帮到你。
本文还有配套的精品资源,点击获取