> 技术文档 > 【大模型开发】开源大模型微调: P-Tuning(Prompt-Tuning)技术

【大模型开发】开源大模型微调: P-Tuning(Prompt-Tuning)技术

以下内容将从原理到代码,再到进一步的优化方向,详尽地阐述基于开源大模型P-Tuning(Prompt-Tuning) 技术。为了便于理解,整篇内容会分为以下几个主要部分:

  1. P-Tuning 微调技术原理简介
  2. P-Tuning 与其他微调方法对比
  3. 基于 HuggingFace Transformers 的落地案例代码
  4. 代码详解
  5. 进一步建议和优化方向

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 与其他微调方法对比

  1. 全参数微调(Fine-tuning)

    • 训练参数:模型全部参数
    • 优点:模型针对任务的拟合程度更好
    • 缺点:需要超大算力与大量数据,且训练开销大
  2. Prompt Engineering(人工提示工程,离散提示)

    • 训练参数:无可学习参数或者极少数可学习参数
    • 依赖人为经验,提示写得好坏会影响效果
  3. Adapter / LoRA

    • 训练参数:仅在模型内部插入 Adapter 层或者低秩矩阵分解部分
    • 和 P-Tuning 类似,都是减少训练参数,但插入点一般在 Transformer Block 内部
  4. P-Tuning

    • 训练参数:仅有一小部分可学习的 Prompt Embeddings
    • 插入点可位于输入词嵌入层或者模型中间层
    • 对推理速度几乎无影响,参数增加量非常小

3. 基于 HuggingFace Transformers 的落地案例代码

下面给出一个相对简化的示例,演示对 GPT-2 进行 P-Tuning。演示思路如下:

  1. 使用预训练的 GPT-2 模型。
  2. 在原始输入序列前面插入一段长度为 prompt_length 的可学习向量(Prompt Embeddings)。
  3. 仅训练这段 Prompt Embeddings 的参数,对 GPT-2 主体参数进行冻结。
  4. 最后在一个小示例上进行微调,验证其可行性。

注意:下面的示例代码基于 transformerspytorch 实现,由于篇幅和可操作环境限制,代码中仅演示关键逻辑与思路,并不一定是最优或在生产环境可直接使用的完整版本。
如果你想要跑通此示例,需安装 transformers>=4.0.0torch>=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. 代码详解

下面逐行解释代码中的关键部分:

  1. PromptEmbedding 模块

    self.prompt_embeddings = nn.Parameter( torch.randn(prompt_length, embedding_dim))
    • 这里我们用正态分布随机初始化一段形状为 [prompt_length, embedding_dim] 的可学习参数。
    • 训练过程中会通过反向传播更新这部分参数。
  2. 在 GPT-2 上冻结参数

    for param in self.base_model.parameters(): param.requires_grad = False
    • 这样所有 GPT-2 内部参数都不会在训练中被更新。
  3. 获取 GPT-2 的 embedding_dim

    embedding_dim = self.base_model.transformer.wte.weight.size(1)
    • GPT-2 的词向量大小一般为 768 或者更大,通过 size(1) 获取。
  4. 模型的 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]
  5. 优化器只包含 model.prompt_encoder 的参数

    optimizer = torch.optim.AdamW(model.prompt_encoder.parameters(), lr=1e-3)
    • 这样确保只有 P-Tuning 部分被训练,GPT-2 主体参数被固定。
  6. 训练目标

    loss = outputs.loss
    • 这里用 GPT-2 自带的语言模型损失(CrossEntropyLoss),label 是原句本身,这种形式也可轻松扩展到其他下游任务(分类、摘要、问答等)。

5. 进一步建议和优化方向

  1. Prompt Embeddings 的插入位置

    • 例子里只在输入词嵌入层前面插入。如果想要更强的表达能力,可以在 Transformer 的中间层、甚至多层插入 Prompt Embeddings,这被称为 Deep P-Tuning
    • 这需要对模型的每一层输入做一些修改,让 prompt tokens 能够在中间层也被拼接进去。
  2. Prompt Embeddings 的初始化

    • 简单随机初始化有时不足以让 Prompt Embeddings 迅速收敛。
    • 可以尝试从相似任务中学来的先验知识来初始化,或者对 Prompt Embeddings 做一些正则约束。
  3. Prompt Embeddings 的长度

    • prompt_length 的大小非常影响性能:太短,模型难以表达足够信息;太长,会增加训练难度且影响推理效率。通常需要在验证集上进行调参。
  4. 损失函数和训练策略

    • 如果是文本生成场景,以语言模型自回归损失为主。
    • 如果是分类场景,可能需要让模型在拼接了 Prompt Embeddings 后去做分类,对模型输出的 [CLS] 位或者平均池化后加线性层。
    • 学习率batch size 等也要适度调参,以免 prompt embedding 训练过快或过慢。
  5. 与 LoRA / Adapter 等方法的组合

    • 不同参数高效微调方法可以组合使用,例如在中间层插入 Adapter 模块的同时,再在输入端做 P-Tuning,对多种下游任务可能有更强的性能和泛化能力。
  6. 支持多语言或长文本

    • 如果场景是多语言,可以针对每种语言设计特定的 Prompt Embeddings(或共享一部分),增加模型跨语言的效果。
    • 如果是非常长的文本(例如一些大模型支持 4k、8k Token 上下文),要关注拼接后序列长度超标的问题。
  7. 推理部署注意事项

    • 推理时 Prompt Embeddings 和文本 tokens 一起输入模型,不会增加任何显式的计算开销,只是序列变长了 prompt_length 个 Token。
    • 如果需要在推理时切换不同任务,可以将 Prompt Embeddings 存储起来,然后在推理输入时,选择要用的 Prompt Embeddings 与文本拼接。
  8. 安全和合规

    • 在实际业务场景中,对大模型进行 Prompt-Tuning 需要关注合规性,以及下游任务的潜在滥用风险。
    • 需要构建相应的监控或对生成结果进行过滤或审计。

总结

P-Tuning(Prompt-Tuning)为大模型的微调提供了一种非常高效灵活的思路——只需在输入序列或中间层插入少量可学习向量并进行训练,即可让大模型针对特定任务实现较好的效果,而不需要动辄数十亿参数的全量微调。在上面给出的示例中,我们通过 HuggingFace Transformers 展示了一个简化版本的 P-Tuning,对于有更高要求的场景可以在此基础上做许多改进和扩展,如 Deep P-Tuning、多层插入 Prompt Embeddings、结合 LoRA / Adapter、调参等。

希望通过以上内容,能够帮助你理解并实现一个最小化可行版本的 P-Tuning,同时掌握在工业界或学术研究中进一步优化的思路。

哈佛博后带小白玩转机器学习哔哩哔哩_bilibili

总课时超400+,时长75+小时