【大模型开发】开源大模型微调: P-Tuning(Prompt-Tuning)技术
以下内容将从原理到代码,再到进一步的优化方向,详尽地阐述基于开源大模型的 P-Tuning(Prompt-Tuning) 技术。为了便于理解,整篇内容会分为以下几个主要部分:
- P-Tuning 微调技术原理简介
- P-Tuning 与其他微调方法对比
- 基于 HuggingFace Transformers 的落地案例代码
- 代码详解
- 进一步建议和优化方向
1. P-Tuning 微调技术原理简介
1.1 什么是 P-Tuning
P-Tuning 全称为 Prompt-Tuning,它是一种微调大语言模型(Large Language Model, LLM)的方法。与传统的全参数微调(Fine-tuning)不同,P-Tuning 只在模型输入层或中间层插入可学习的“Prompt Embeddings”(也称 Prompt Tokens/Prefix 等),从而极大减少微调参数量。其核心思想可以归纳为:
- 冻结(freeze)大部分或全部原始模型参数
- 引入少量可训练的参数(Prompt Embeddings)
- 通过梯度反向传播仅更新这部分可训练参数
模型在训练过程中会将这些 Prompt Embeddings 拼接到原输入或模型内部隐藏层的输入里,从而让预训练模型更好地针对任务进行表征/生成。因为只训练这部分 Prompt Embeddings,而模型的主体参数并未改变,所以对硬件资源和训练数据需求更小,微调速度也更快。
1.2 背后的动机
预训练大模型(如 BERT、GPT-2/3、T5、Bloom 等)拥有数亿、数百亿、甚至上千亿的参数,如果要对所有参数做微调,不仅需要大量 GPU/TPU 资源,而且可能过度拟合、小数据集难以支撑等问题也会出现。Prompt-Tuning 通过在输入中注入一段可学习的“提示向量”,引导模型对下游任务进行生成或分类,可以极大地减少需要训练的参数量。
1.3 P-Tuning 的思路
以生成式任务为例(如 GPT 类模型),如果我们想让模型执行一个特定任务,那么可以在输入序列前面插入若干个可学习的特殊向量(Prompt Embeddings)。这就好比在原始自然语言输入前面加上了一些“提示词”,只不过这些提示不是固定的离散词,而是可训练的连续向量。
对于分类或序列标注等判别式任务也可以采用类似方法:Prompt Embeddings 可以插在句子前面、后面,或同时插入头尾,然后在微调阶段只更新这部分提示向量的参数,冻结模型其余部分。
2. P-Tuning 与其他微调方法对比
-
全参数微调(Fine-tuning)
- 训练参数:模型全部参数
- 优点:模型针对任务的拟合程度更好
- 缺点:需要超大算力与大量数据,且训练开销大
-
Prompt Engineering(人工提示工程,离散提示)
- 训练参数:无可学习参数或者极少数可学习参数
- 依赖人为经验,提示写得好坏会影响效果
-
Adapter / LoRA
- 训练参数:仅在模型内部插入 Adapter 层或者低秩矩阵分解部分
- 和 P-Tuning 类似,都是减少训练参数,但插入点一般在 Transformer Block 内部
-
P-Tuning
- 训练参数:仅有一小部分可学习的 Prompt Embeddings
- 插入点可位于输入词嵌入层或者模型中间层
- 对推理速度几乎无影响,参数增加量非常小
3. 基于 HuggingFace Transformers 的落地案例代码
下面给出一个相对简化的示例,演示对 GPT-2 进行 P-Tuning。演示思路如下:
- 使用预训练的 GPT-2 模型。
- 在原始输入序列前面插入一段长度为
prompt_length
的可学习向量(Prompt Embeddings)。 - 仅训练这段 Prompt Embeddings 的参数,对 GPT-2 主体参数进行冻结。
- 最后在一个小示例上进行微调,验证其可行性。
注意:下面的示例代码基于
transformers
和pytorch
实现,由于篇幅和可操作环境限制,代码中仅演示关键逻辑与思路,并不一定是最优或在生产环境可直接使用的完整版本。
如果你想要跑通此示例,需安装transformers>=4.0.0
、torch>=1.7
等,且需要准备相应数据集。
import torchimport torch.nn as nnfrom torch.utils.data import Dataset, DataLoaderfrom transformers import GPT2LMHeadModel, GPT2Tokenizer# ====================# 1. 自定义数据集# ====================class SimpleDataset(Dataset): def __init__(self, texts, tokenizer, max_length=64): self.texts = texts self.tokenizer = tokenizer self.max_length = max_length def __len__(self): return len(self.texts) def __getitem__(self, idx): text = self.texts[idx] encoding = self.tokenizer( text, truncation=True, padding=\'max_length\', max_length=self.max_length, return_tensors=\'pt\' ) input_ids = encoding[\'input_ids\'].squeeze(0) attention_mask = encoding[\'attention_mask\'].squeeze(0) return { \'input_ids\': input_ids, \'attention_mask\': attention_mask }# ====================# 2. P-Tuning 模块定义# ====================class PromptEmbedding(nn.Module): \"\"\" 定义可学习的 Prompt Embeddings,用于在输入序列前面插入。 \"\"\" def __init__(self, prompt_length, embedding_dim): super().__init__() # 这里可以直接用一个简单的可学习参数矩阵 self.prompt_embeddings = nn.Parameter( torch.randn(prompt_length, embedding_dim) ) def forward(self, batch_size): \"\"\" Returns: prompt_embeddings of shape [batch_size, prompt_length, embedding_dim] \"\"\" # 将 [prompt_length, embedding_dim] 扩展到 [batch_size, prompt_length, embedding_dim] return self.prompt_embeddings.unsqueeze(0).expand(batch_size, -1, -1)# ====================# 3. 整合到 GPT-2 的 forward# ====================class GPT2WithPromptTuning(nn.Module): def __init__(self, base_model_name=\'gpt2\', prompt_length=5): super().__init__() self.base_model = GPT2LMHeadModel.from_pretrained(base_model_name) self.tokenizer = GPT2Tokenizer.from_pretrained(base_model_name) self.tokenizer.pad_token = self.tokenizer.eos_token # 处理GPT-2没有pad_token的情况 # 冻结 GPT-2 的参数 for param in self.base_model.parameters(): param.requires_grad = False # 获取 GPT-2 的 token embedding size embedding_dim = self.base_model.transformer.wte.weight.size(1) # 定义可学习的 prompt embedding self.prompt_length = prompt_length self.prompt_encoder = PromptEmbedding(prompt_length, embedding_dim) def forward(self, input_ids, attention_mask=None, labels=None): batch_size = input_ids.size(0) # 1. 获取可学习的 prompt embeddings prompt_embeds = self.prompt_encoder(batch_size) # [batch_size, prompt_length, embedding_dim] # 2. 原始输入的 token embeddings # GPT2LMHeadModel 里的 transformer.wte 是 token embedding 模块 input_embeds = self.base_model.transformer.wte(input_ids) # [batch_size, seq_len, embedding_dim] # 3. 拼接:先放 prompt 的 embeddings, 再放原来输入的 embeddings # 最后输入到 GPT2 中 concat_embeds = torch.cat([prompt_embeds, input_embeds], dim=1) # 4. 同样处理 attention_mask if attention_mask is not None: # prompt 部分不需要“遮挡” (1 表示有效) prompt_mask = torch.ones(batch_size, self.prompt_length, dtype=attention_mask.dtype, device=attention_mask.device) attention_mask = torch.cat([prompt_mask, attention_mask], dim=1) # 5. GPT-2 forward outputs = self.base_model( inputs_embeds=concat_embeds, attention_mask=attention_mask, labels=labels ) return outputs# ====================# 4. 训练示例# ====================def train_one_epoch(model, dataloader, optimizer, device): model.train() total_loss = 0.0 for batch in dataloader: optimizer.zero_grad() input_ids = batch[\'input_ids\'].to(device) attention_mask = batch[\'attention_mask\'].to(device) # GPT-2 的常规做法是 labels = input_ids (语言模型任务) outputs = model(input_ids=input_ids, attention_mask=attention_mask, labels=input_ids) loss = outputs.loss loss.backward() optimizer.step() total_loss += loss.item() return total_loss / len(dataloader)def main(): device = torch.device(\'cuda\' if torch.cuda.is_available() else \'cpu\') # 1. 初始化模型 prompt_length = 5 model = GPT2WithPromptTuning(\'gpt2\', prompt_length=prompt_length).to(device) # 2. 示例数据 texts = [ \"今天天气非常好,适合出去散步。\", \"人工智能正在深刻改变我们的生活。\", \"P-Tuning 是一种高效的微调大模型的方法。\" ] dataset = SimpleDataset( texts, tokenizer=model.tokenizer, max_length=32 ) dataloader = DataLoader(dataset, batch_size=2, shuffle=True) # 3. 只训练 prompt_encoder 的参数 optimizer = torch.optim.AdamW(model.prompt_encoder.parameters(), lr=1e-3) # 4. 训练 for epoch in range(5): loss = train_one_epoch(model, dataloader, optimizer, device) print(f\"Epoch: {epoch} | Loss: {loss:.4f}\") # 5. 测试生成 model.eval() test_text = \"P-Tuning 技术可以\" input_ids = model.tokenizer(test_text, return_tensors=\'pt\')[\'input_ids\'].to(device) # 构造输入时,也会先拼接 prompt embeddings with torch.no_grad(): generated = model.base_model.generate( input_ids=input_ids, max_length=50, num_beams=5, early_stopping=True ) print(\"Input:\", test_text) print(\"Generated:\", model.tokenizer.decode(generated[0], skip_special_tokens=True))if __name__ == \"__main__\": main()
上面示例的关键点在于:
PromptEmbedding
类中定义了self.prompt_embeddings
,即可学习参数。- 冻结了 GPT-2 主体的所有参数,只训练
PromptEmbedding
对象。 - 在 forward 中,通过在
inputs_embeds
里把 prompt embeddings 和原始 token embedding 拼在一起,实现 P-Tuning。
4. 代码详解
下面逐行解释代码中的关键部分:
-
PromptEmbedding
模块self.prompt_embeddings = nn.Parameter( torch.randn(prompt_length, embedding_dim))
- 这里我们用正态分布随机初始化一段形状为
[prompt_length, embedding_dim]
的可学习参数。 - 训练过程中会通过反向传播更新这部分参数。
- 这里我们用正态分布随机初始化一段形状为
-
在 GPT-2 上冻结参数
for param in self.base_model.parameters(): param.requires_grad = False
- 这样所有 GPT-2 内部参数都不会在训练中被更新。
-
获取 GPT-2 的
embedding_dim
embedding_dim = self.base_model.transformer.wte.weight.size(1)
- GPT-2 的词向量大小一般为 768 或者更大,通过
size(1)
获取。
- GPT-2 的词向量大小一般为 768 或者更大,通过
-
模型的 forward 流程
prompt_embeds = self.prompt_encoder(batch_size)input_embeds = self.base_model.transformer.wte(input_ids)concat_embeds = torch.cat([prompt_embeds, input_embeds], dim=1)
prompt_embeds
形状为[batch_size, prompt_length, embedding_dim]
input_embeds
形状为[batch_size, seq_len, embedding_dim]
- 拼接后是
[batch_size, (prompt_length + seq_len), embedding_dim]
-
优化器只包含
model.prompt_encoder
的参数optimizer = torch.optim.AdamW(model.prompt_encoder.parameters(), lr=1e-3)
- 这样确保只有 P-Tuning 部分被训练,GPT-2 主体参数被固定。
-
训练目标
loss = outputs.loss
- 这里用 GPT-2 自带的语言模型损失(CrossEntropyLoss),label 是原句本身,这种形式也可轻松扩展到其他下游任务(分类、摘要、问答等)。
5. 进一步建议和优化方向
-
Prompt Embeddings 的插入位置
- 例子里只在输入词嵌入层前面插入。如果想要更强的表达能力,可以在 Transformer 的中间层、甚至多层插入 Prompt Embeddings,这被称为 Deep P-Tuning。
- 这需要对模型的每一层输入做一些修改,让 prompt tokens 能够在中间层也被拼接进去。
-
Prompt Embeddings 的初始化
- 简单随机初始化有时不足以让 Prompt Embeddings 迅速收敛。
- 可以尝试从相似任务中学来的先验知识来初始化,或者对 Prompt Embeddings 做一些正则约束。
-
Prompt Embeddings 的长度
prompt_length
的大小非常影响性能:太短,模型难以表达足够信息;太长,会增加训练难度且影响推理效率。通常需要在验证集上进行调参。
-
损失函数和训练策略
- 如果是文本生成场景,以语言模型自回归损失为主。
- 如果是分类场景,可能需要让模型在拼接了 Prompt Embeddings 后去做分类,对模型输出的
[CLS]
位或者平均池化后加线性层。 - 学习率、batch size 等也要适度调参,以免 prompt embedding 训练过快或过慢。
-
与 LoRA / Adapter 等方法的组合
- 不同参数高效微调方法可以组合使用,例如在中间层插入 Adapter 模块的同时,再在输入端做 P-Tuning,对多种下游任务可能有更强的性能和泛化能力。
-
支持多语言或长文本
- 如果场景是多语言,可以针对每种语言设计特定的 Prompt Embeddings(或共享一部分),增加模型跨语言的效果。
- 如果是非常长的文本(例如一些大模型支持 4k、8k Token 上下文),要关注拼接后序列长度超标的问题。
-
推理部署注意事项
- 推理时 Prompt Embeddings 和文本 tokens 一起输入模型,不会增加任何显式的计算开销,只是序列变长了
prompt_length
个 Token。 - 如果需要在推理时切换不同任务,可以将 Prompt Embeddings 存储起来,然后在推理输入时,选择要用的 Prompt Embeddings 与文本拼接。
- 推理时 Prompt Embeddings 和文本 tokens 一起输入模型,不会增加任何显式的计算开销,只是序列变长了
-
安全和合规
- 在实际业务场景中,对大模型进行 Prompt-Tuning 需要关注合规性,以及下游任务的潜在滥用风险。
- 需要构建相应的监控或对生成结果进行过滤或审计。
总结
P-Tuning(Prompt-Tuning)为大模型的微调提供了一种非常高效灵活的思路——只需在输入序列或中间层插入少量可学习向量并进行训练,即可让大模型针对特定任务实现较好的效果,而不需要动辄数十亿参数的全量微调。在上面给出的示例中,我们通过 HuggingFace Transformers 展示了一个简化版本的 P-Tuning,对于有更高要求的场景可以在此基础上做许多改进和扩展,如 Deep P-Tuning、多层插入 Prompt Embeddings、结合 LoRA / Adapter、调参等。
希望通过以上内容,能够帮助你理解并实现一个最小化可行版本的 P-Tuning,同时掌握在工业界或学术研究中进一步优化的思路。
【哈佛博后带小白玩转机器学习】 哔哩哔哩_bilibili
总课时超400+,时长75+小时