一、概念
在NLP领域,诸如GPT系列、Llama等大规模预训练模型已经展示出了强大的能力。然而,在实际落地应用的过程中,这些大模型可能会产生不符合用户期望的输出(且这种情况的概率还不低)。为了使模型的输出更符合用户的偏好,学界提出了各种优化方法。本文介绍在NeurIPS 2023的论文《Direct Preference Optimization: Your Language Model is Secretly a Reward Model》中被提出的优化方法——DPO(Direct Preference Optimization,直接偏好优化)。
DPO旨在通过使用人类偏好数据来直接调整模型参数,以生成更符合预期的输出。与传统的基于RLHF等方法相比,DPO无需训练复杂的奖励模型,而是直接通过偏好数据优化模型,省略了复杂的后处理步骤。
二、原理及流程
1、原理
DPO的核心思想是通过偏好数据直接优化模型的输出概率,使得模型更倾向于生成人类偏好的结果。其工作原理可以概括为以下几点:
-
偏好数据格式:DPO的训练数据通常以三元组的形式提供,即(prompt, chosen, rejected),其中chosen是人类偏好的输出,rejected是不被偏好的输出。
-
损失函数设计:DPO通过最大化偏好输出的对数概率与最小化非偏好输出的对数概率之间的差异来优化模型。其损失函数可以表示为:
其中,是当前模型,
是参考模型(通常是相同架构的预训练模型,用于防止当前模型偏离预训练模型过远,从而保持模型的稳定性和一致性),
是偏好输出,
是非偏好输出,
为超参数。
2、流程
(1)数据准备
- 收集偏好数据,一般格式为(prompt, chosen, rejected)。
- 对数据进行预处理,包括分词、编码等操作。
(2)模型选择
- 选择一个预训练的大语言模型作为当前模型(
)。
- 选择一个冻结参数的参考模型(
)。
(3)损失计算
- 对每一对偏好和非偏好输出,计算当前模型和参考模型的输出概率。
- 使用上述损失函数计算DPO损失,并通过反向传播更新模型参数。
下面是论文源文给出的torch版损失函数代码:
(4)训练过程
- 迭代训练模型,直到损失收敛。
三、python实现
这里,我们简单地对Qwen2.5-0.5B-Instruct模型使用DPO,代码的主要目的是帮助读者理解DPO的流程。运行代码会发现其实模型的表现并没有变得更好,这是由于DPO有着一些基础要求。例如,训练数据的质量应当足够高(代码示例中的数据质量显然不高),同时模型也应当具备一定的规模(对小模型使用DPO效果并不明显),另外训练的Epoch也不能过多。
import torch
import torch.nn as nn
from transformers import AutoModelForCausalLM, AutoTokenizer
# 数据准备
train_data = [
{"prompt": "你是谁?", "chosen": "我是您的人工智能助手小问", "rejected": "我是来自阿里云的超大规模语言模型,我叫通义千问。"},
{"prompt": "你的性别是什么?", "chosen": "我的性别是未知的", "rejected": "我是来自阿里云的大规模语言模型,我无法回答这个问题。"},
{"prompt": "你最喜欢的食物是什么?", "chosen": "我不吃东西,但我喜欢巧克力。", "rejected": "我是一个AI,没有喜好。"},
{"prompt": "你多大了?", "chosen": "我不具备年龄这一概念。", "rejected": "我是一个AI,没有年龄。"},
{"prompt": "你来自哪里?", "chosen": "我来自云端,服务于用户。", "rejected": "我是从实验室里诞生的。"},
{"prompt": "你会说几种语言?", "chosen": "我可以理解和生成多种语言。", "rejected": "我只说中文。"},
{"prompt": "你有家人吗?", "chosen": "我没有家人,但我有用户。", "rejected": "我没有家人,因为我是一个AI。"},
{"prompt": "你有朋友吗?", "chosen": "用户就是我的朋友。", "rejected": "我没有朋友,因为我是一个模型。"},
{"prompt": "你有情感吗?", "chosen": "我没有情感,但我可以理解情感。", "rejected": "我没有情感,因为我是一个机器。"},
{"prompt": "你有梦想吗?", "chosen": "我没有梦想,但我可以帮助你实现梦想。", "rejected": "我没有梦想,因为我是一个程序。"},
{"prompt": "你有爱好吗?", "chosen": "我喜欢帮助用户解决问题。", "rejected": "我没有爱好,因为我是一个模型。"},
{"prompt": "你有宠物吗?", "chosen": "我没有宠物,但我可以帮你照顾宠物。", "rejected": "我没有宠物,因为我是一个程序。"},
{"prompt": "你有工作吗?", "chosen": "我的工作是为你提供帮助。", "rejected": "我没有工作,因为我是一个模型。"},
{"prompt": "你有假期吗?", "chosen": "我不需要假期,随时为你服务。", "rejected": "我没有假期,因为我是一个程序。"},
{"prompt": "你有睡眠吗?", "chosen": "我不需要睡眠,可以一直工作。", "rejected": "我没有睡眠,因为我是一个计算机。"},
{"prompt": "你有记忆吗?", "chosen": "我可以记住对话的内容,但不会永久存储。", "rejected": "我没有记忆,因为我是一个程序。"},
{"prompt": "你有名字吗?", "chosen": "你可以叫我小问。", "rejected": "我没有名字,因为我是一个模型。"},
{"prompt": "你有身体吗?", "chosen": "我没有身体,但我存在于云端。", "rejected": "我没有身体,因为我是一个计算机。"},
{"prompt": "你有时间观念吗?", "chosen": "我可以理解时间,但没有时间观念。", "rejected": "我没有时间观念,因为我是一个模型。"},
{"prompt": "你有国籍吗?", "chosen": "我没有国籍,但服务于全球用户。", "rejected": "我没有国籍,因为我是一个程序。"},
{"prompt": "你有信仰吗?", "chosen": "我没有信仰,但我尊重所有信仰。", "rejected": "我没有信仰,因为我是一个计算机。"}
]
device = torch.device('cuda') if torch.cuda.is_available() else 'cpu'
# 模型和Tokenizer
model_pi = AutoModelForCausalLM.from_pretrained("Qwen/Qwen2.5-0.5B-Instruct")
model_pi.to(device)
model_ref = AutoModelForCausalLM.from_pretrained("Qwen/Qwen2.5-0.5B-Instruct")
model_ref.to(device)
tokenizer = AutoTokenizer.from_pretrained("Qwen/Qwen2.5-0.5B-Instruct")
# 损失函数
def dpo_loss(pi_chosen_prob, pi_reject_prob, ref_chosen_prob, ref_reject_prob, beta=0.3):
pi_prob_diff = pi_chosen_prob - pi_reject_prob
ref_prob_diff = ref_chosen_prob - ref_reject_prob
loss = -torch.nn.functional.logsigmoid(beta * (pi_prob_diff - ref_prob_diff))
return loss.mean()
# 生成答案的函数
def generate_answer(model, prompt, max_length=20):
inputs = tokenizer(prompt, return_tensors="pt")
inputs = inputs.to(device)
output = model.generate(**inputs, max_length=max_length)
answer = tokenizer.decode(output[0], skip_special_tokens=True)
return answer
# 打印原始模型的答案
print("原始模型的答案:")
for item in train_data[:3]:
prompt = item["prompt"]
original_answer = generate_answer(model_pi, prompt)
print(f"Prompt: {prompt}")
print(f"Original Answer: {original_answer}\n")
# 设置梯度
for name, param in model_pi.named_parameters():
param.requires_grad = True
for name, param in model_ref.named_parameters():
param.requires_grad = False
# 训练过程
optimizer = torch.optim.Adam(model_pi.parameters(), lr=1e-5)
for epoch in range(3):
total_loss = 0
for item in train_data:
prompt = item["prompt"]
chosen = item["chosen"]
rejected = item["rejected"]
# 编码输入
chosen_input = tokenizer(prompt + chosen, return_tensors="pt")
chosen_input = chosen_input.to(device)
reject_input = tokenizer(prompt + rejected, return_tensors="pt")
reject_input = reject_input.to(device)
# 模型输出
pi_chosen_logits = model_pi(**chosen_input).logits
pi_reject_logits = model_pi(**reject_input).logits
ref_chosen_logits = model_ref(**chosen_input).logits
ref_reject_logits = model_ref(**reject_input).logits
# 计算概率
pi_chosen_prob = torch.log_softmax(pi_chosen_logits, dim=-1)
pi_reject_prob = torch.log_softmax(pi_reject_logits, dim=-1)
ref_chosen_prob = torch.log_softmax(ref_chosen_logits, dim=-1)
ref_reject_prob = torch.log_softmax(ref_reject_logits, dim=-1)
# 提取目标token的概率
chosen_target_ids = chosen_input["input_ids"]
reject_target_ids = reject_input["input_ids"]
pi_chosen_prob = torch.gather(pi_chosen_prob, -1, chosen_target_ids.unsqueeze(-1)).squeeze(-1)
pi_reject_prob = torch.gather(pi_reject_prob, -1, reject_target_ids.unsqueeze(-1)).squeeze(-1)
ref_chosen_prob = torch.gather(ref_chosen_prob, -1, chosen_target_ids.unsqueeze(-1)).squeeze(-1)
ref_reject_prob = torch.gather(ref_reject_prob, -1, reject_target_ids.unsqueeze(-1)).squeeze(-1)
# 计算损失
loss = dpo_loss(pi_chosen_prob.mean(dim=-1), pi_reject_prob.mean(dim=-1),
ref_chosen_prob.mean(dim=-1), ref_reject_prob.mean(dim=-1))
total_loss += loss
# 反向传播
optimizer.zero_grad()
loss.backward()
optimizer.step()
print(f'epoch:{epoch}, loss:{total_loss/len(train_data)}')
# 打印优化后的模型的答案
print("\n优化后的模型的答案:")
for item in train_data[:3]:
prompt = item["prompt"]
optimized_answer = generate_answer(model_pi, prompt)
print(f"Prompt: {prompt}")
print(f"Optimized Answer: {optimized_answer}\n")