DPO 的英文全称是 Direct Preference Optimization,直译为直接偏好优化。它的含义就凝练在这个名字里:
Direct(直接):指它不需要像传统 RLHF(基于人类反馈的强化学习)那样,先单独训练一个奖励模型,再用强化学习去优化。它直接从数据中学习。
Preference(偏好):指它学习的核心信号是人类的偏好数据,即“回复 A 比回复 B 好”这种成对的比较。
Optimization(优化):它是一个优化过程,目标是让模型生成更符合人类喜好的回复。
用一句话概括其核心思想就是:将模型本身视为一个隐式奖励模型,直接利用人类的偏好对比数据来优化语言模型。
想象你正在训练一个助手模型,你不仅希望它知道“法国的首都是巴黎”,还希望当用户要求写一首诗时,它写出优美的作品而不是脏话。这就是“对齐”——让模型的行为符合人类偏好。
RLHF的传统路径分三步走:
1、监督微调:让模型学会基本对话格式
2、训练奖励模型:收集人类偏好数据,训练一个打分模型
3、PPO强化学习:用奖励模型作为裁判,优化生成策略
这个流程就像一个复杂的化工厂,中间环节多、容易出错。奖励模型可能被“欺骗”,PPO训练又需要同时加载四个模型,对计算资源是巨大考验。
DPO 的核心洞见十分深刻:人类偏好数据是成对的——“回复A比回复B好”。既然我们有了“更好”的直接比较,为什么还要绕远去训练一个“打分员”,再从分数中推导什么是好的?我们直接让模型从偏好对中学习岂不是更直接?
这就好比学下棋。传统方式是请一位教练(奖励模型),每次你走完一步,他给你一个分数,你根据分数改进。DPO 的方式是:直接给你看两盘棋——“这盘下得好,那盘下得不好”,让你直接从对比中领悟好棋的精髓。哪种更高效?显然是后者。
DPO 的核心是重新定义了损失函数。它不需要先训练奖励模型,而是巧妙地用模型本身来表示“隐式奖励”。这个损失函数的灵魂就隐藏在下面这段公式中。不要被它吓到,我们一步步拆解,你会发现它表达的思想其实非常直观:
$$ L_{DPO}(\pi_{\theta}; \pi_{\text{ref}}) = -\mathbb{E}_{(x, y_w, y_l) \sim D} \left[ \log \sigma \left( \beta \log \frac{\pi_{\theta}(y_w|x)}{\pi_{\text{ref}}(y_w|x)} - \beta \log \frac{\pi_{\theta}(y_l|x)}{\pi_{\text{ref}}(y_l|x)} \right) \right] $$
我们用白话把这个公式“翻译”一遍:
DPO 的损失函数就是在做一件事:它拼命地让 $\frac{\pi_{\theta}(y_w|x)}{\pi_{\text{ref}}(y_w|x)}$ 这个比值远大于 $\frac{\pi_{\theta}(y_l|x)}{\pi_{\text{ref}}(y_l|x)}$。也就是说,经过训练后,模型 $\pi_{\theta}$ 相对于原始模型 $\pi_{\text{ref}}$,要极大地提升好回复的生成概率,同时极大地降低差回复的生成概率。
理论弄明白了,代码实现其实出奇地简洁。我们来搭建一个可以实际跑通的 DPO 训练器。
1、导入必要的库并定义一个玩具数据集
我们先用一个极其简化的场景来演示:让模型学会更倾向于“积极”的回复。
import torch
import torch.nn.functional as F
from torch.utils.data import Dataset, DataLoader
from transformers import AutoModelForCausalLM, AutoTokenizer
# 定义一个超小的偏好数据集
# 每个样本包含:提示(prompt),好的回复(chosen),差的回复(rejected)
toy_dataset = [
{
"prompt": "今天天气真好,我感觉",
"chosen": "心情非常舒畅,充满了活力!",
"rejected": "也就那样吧,没什么特别的。"
},
{
"prompt": "我刚收到一份礼物,",
"chosen": "真是太开心了,拆礼物的感觉真好!",
"rejected": "不知道是谁送的,有点烦。"
},
{
"prompt": "关于人工智能的未来,",
"chosen": "我充满希望,它将帮助我们解决许多难题。",
"rejected": "我担心它会失控,带来很多灾难。"
}
]
2、最核心的部分:DPO 损失函数
这里就是公式的代码化身。我们会逐行解释。
def dpo_loss(policy_chosen_logps, policy_rejected_logps,
ref_chosen_logps, ref_rejected_logps, beta=0.1):
"""
计算 DPO 损失。
参数:
policy_chosen_logps: 策略模型对好回复生成的对数概率
policy_rejected_logps: 策略模型对差回复生成的对数概率
ref_chosen_logps: 参考模型对好回复生成的对数概率
ref_rejected_logps: 参考模型对差回复生成的对数概率
beta: 控制与参考模型偏离程度的超参数
"""
# 1. 计算策略模型和参考模型之间的相对概率比的对数
policy_log_ratio = policy_chosen_logps - policy_rejected_logps
ref_log_ratio = ref_chosen_logps - ref_rejected_logps
# 2. 计算最终的隐式奖励差距,并乘以 beta
# 这就是公式里面 sigmoid 函数里的那一大串
logits = beta * (policy_log_ratio - ref_log_ratio)
# 3. 计算损失。F.logsigmoid 等价于 log(sigmoid(x))
# 由于我们想让 chosen 打败 rejected,所以用负号来最小化损失
loss = -F.logsigmoid(logits)
return loss.mean()
policy_log_ratio 就是 $\log \frac{\pi_\theta(y_w|x)}{\pi_\theta(y_l|x)}$。
ref_log_ratio 就是 $\log \frac{\pi_{\text{ref}}(y_w|x)}{\pi_{\text{ref}}(y_l|x)}$。
两者相减,再乘以 $\beta$,就得到了公式里完整的内部项。
-F.logsigmoid(...) 是对 $\log \sigma(\cdot)$ 的损失计算,完全吻合公式。
3、构建一个完整的训练循环框架
这提供了一个可扩展的蓝图。
def train_dpo(model, ref_model, tokenizer, dataset, optimizer, beta=0.1, epochs=100):
"""一个简化的 DPO 训练循环示例"""
model.train()
ref_model.eval() # 参考模型冻结,不更新参数
for epoch in range(epochs):
total_loss = 0
for batch in dataset:
prompt = batch["prompt"]
chosen = batch["chosen"]
rejected = batch["rejected"]
# ----- 1. 组装文本并分词 -----
# 为了方便,这里把 prompt + response 拼在一起
chosen_text = [p + c for p, c in zip(prompt, chosen)]
rejected_text = [p + r for p, r in zip(prompt, rejected)]
# 这里简化了:实际应用中需要更精细的掩码控制
chosen_tokens = tokenizer(chosen_text, return_tensors="pt", padding=True, truncation=True)
rejected_tokens = tokenizer(rejected_text, return_tensors="pt", padding=True, truncation=True)
# ----- 2. 计算所有需要的对数概率 -----
with torch.no_grad(): # 参考模型的输出不需要梯度
ref_chosen_logps = get_log_probs(ref_model, **chosen_tokens)
ref_rejected_logps = get_log_probs(ref_model, **rejected_tokens)
policy_chosen_logps = get_log_probs(model, **chosen_tokens)
policy_rejected_logps = get_log_probs(model, **rejected_tokens)
# ----- 3. 计算损失并更新模型 -----
loss = dpo_loss(policy_chosen_logps, policy_rejected_logps,
ref_chosen_logps, ref_rejected_logps, beta)
optimizer.zero_grad()
loss.backward()
optimizer.step()
total_loss += loss.item()
if (epoch + 1) % 20 == 0:
print(f"Epoch {epoch+1}/{epochs}, Average Loss: {total_loss/len(dataset):.4f}")
def get_log_probs(model, input_ids, attention_mask):
"""辅助函数:获取模型对输入序列的平均对数概率"""
outputs = model(input_ids=input_ids, attention_mask=attention_mask)
logits = outputs.logits # 形状: [batch, seq_len, vocab_size]
log_probs = F.log_softmax(logits, dim=-1)
# 获取每个位置真实 token 的对数概率
# 将 input_ids 移位一位,以便 log_probs[:, :-1, :] 能预测 input_ids[:, 1:]
target_log_probs = torch.gather(log_probs[:, :-1, :], dim=-1, index=input_ids[:, 1:].unsqueeze(-1)).squeeze(-1)
# 计算序列的平均对数概率(这里简化了,只对非填充部分取平均)
mask = attention_mask[:, 1:].float()
avg_log_probs = (target_log_probs * mask).sum(dim=1) / mask.sum(dim=1)
return avg_log_probs
到这里,DPO的整个图景应该很清晰了。最后我们梳理一下它的核心优势与局限。
优势
简洁稳定:无需训练和维护独立的奖励模型,直接优化策略,流程简洁,训练稳定。
计算高效:显存占用和计算开销显著降低,让偏好对齐不再是大厂的专利。
理论优雅:不基于采样和近似(PPO),而是直接在偏好数据上定义损失,优化目标明确。
局限与思考
数据就是一切:DPO的性能极度依赖偏好数据的质量和分布。如果数据有偏差,模型会直接学到这些偏差。
分布外泛化:当训练好的模型面对与训练时完全不同的新提示时,其性能可能变得难以预测。
并非万能钥匙:DPO适合让“好”与“更好”的模型对齐,但如果基础模型能力本身就弱,DPO也无能为力。
DPO 不仅是工程上的简化,更代表了从“奖励建模”到“直接偏好”的范式转变。希望这个章节能让大家对这项技术有一个扎实且清晰的理解。