DPO:最简单的对齐算法
完全不需要强化学习,直接优化人类偏好
📚 目录
- DPO是什么:抛弃RL的对齐方法
- RLHF的问题:为什么还要更简单
- DPO的核心创新:直接优化偏好
- 详细机制:从公式到代码
- IPO:DPO的改进版(解决饱和问题)
- 对比PPO/GRPO:优势与权衡
- 代码实现
📌 前置概念:对齐方法的演进
从PPO到GRPO到DPO
大模型对齐的演进史:
┌─────────────────────────────────────────────┐
│ PPO (2017) │
│ 组件:Actor + Critic + RM + Reference │
│ 方法:强化学习(复杂) │
│ 问题:需要4个模型,训练复杂 │
└─────────────────────────────────────────────┘
↓ 简化
┌─────────────────────────────────────────────┐
│ GRPO (2024) │
│ 组件:Actor + RM + Reference │
│ 方法:强化学习(去掉Critic) │
│ 问题:还是需要RM,还是用RL │
└─────────────────────────────────────────────┘
↓ 再简化
┌─────────────────────────────────────────────┐
│ DPO (2023) ← 本文重点 │
│ 组件:Actor + Reference │
│ 方法:监督学习(完全不用RL!) │
│ 优势:最简单,只训练一个模型 │
└─────────────────────────────────────────────┘
一句话总结
DPO = 抛弃RL,直接优化偏好对比
PPO/GRPO(RL方法):
阶段1: 训练SFT
阶段2: 训练RM(奖励模型)
阶段3: 用RL微调(PPO算法)
→ 3个阶段,训练多个模型
DPO(直接优化):
阶段1: 训练SFT
阶段2: 直接用偏好数据微调
→ 2个阶段,只训练Actor
核心差异:
✓ 不需要训练RM
✓ 不需要强化学习
✓ 直接监督学习
✓ 超级简单!
🤔 Part 1: RLHF的问题 —— 为什么还要更简单
1.1 回顾RLHF流程
传统RLHF(PPO/GRPO):
┌──────────────────────────────────────┐
│ 阶段1: 监督微调(SFT) │
│ 输入:Base模型 │
│ 输出:SFT模型 │
│ 时间:几天 │
└──────────────────────────────────────┘
↓
┌──────────────────────────────────────┐
│ 阶段2: 训练奖励模型(RM) │
│ 输入:SFT模型 + 偏好数据 │
│ 输出:Reward Model │
│ 时间:几天 │
└──────────────────────────────────────┘
↓
┌──────────────────────────────────────┐
│ 阶段3: RL微调(PPO/GRPO) │
│ 输入:SFT模型 + RM │
│ 输出:最终模型 │
│ 时间:几周 │
│ 问题:复杂、慢、难调试 │
└──────────────────────────────────────┘
总计:3个阶段,几周到几个月
1.2 RLHF的三大问题
问题1:需要训练RM
RM(Reward Model):
- 需要单独训练(几天)
- 需要大量偏好数据
- 预测可能不准(影响RL效果)
例子:
训练RM需要:
- 100k条偏好数据(人类标注)
- 7B参数模型
- 几天训练时间
- 然后才能开始RL微调
问题2:RL训练复杂
RL微调的复杂性:
PPO:
- 需要4个组件(Actor、Critic、RM、Reference)
- 需要调clip、KL系数、学习率等
- 训练不稳定(可能崩溃)
GRPO:
- 需要3个组件(Actor、RM、Reference)
- 需要调group size、clip、KL系数
- 生成多个回答(计算开销大)
通病:
- 涉及强化学习(复杂)
- 超参数多(难调)
- 训练时间长(几周)
问题3:Pipeline长
RLHF完整流程:
Step 1: 预训练(几个月,算力密集)
Step 2: SFT(几天)
Step 3: 收集偏好数据(人工标注,昂贵)
Step 4: 训练RM(几天)
Step 5: RL微调(几周,难调试)
总时间:几个月
总成本:数百万美元
问题:
- Pipeline太长
- 每个阶段都可能出错
- 难以快速迭代
1.3 核心问题
思考:真的需要RL吗?
RL的作用:
从奖励信号中学习(trial-and-error)
但我们已经有了:
- 偏好数据(人类说哪个更好)
- 这就是监督信号!
为什么不直接用偏好数据训练?
→ DPO的答案:可以!而且效果很好!
💡 Part 2: DPO的核心创新 —— 直接优化偏好
2.1 核心思想
把RL问题转化成监督学习问题
RLHF的思路(间接):
────────────────────────────────
1. 用偏好数据训练RM
2. 用RM打分作为奖励
3. 用RL优化Actor
问题:绕了一圈
DPO的思路(直接):
────────────────────────────────
1. 直接用偏好数据训练Actor
2. 没有第2步了!
优势:简单直接
2.2 类比:学习写作
RLHF的方式(间接学习):
你想学写作
传统方法:
1. 先训练一个"评委老师"(RM)
- 给老师看很多文章对比
- 老师学会:"这篇比那篇好"
2. 你写文章
- 评委老师打分
- 分数高 → 继续这样写
- 分数低 → 改变风格
问题:
- 需要先训练评委(费时)
- 评委可能不准(影响学习)
- 过程复杂(RL)
DPO的方式(直接学习):
你想学写作
DPO方法:
1. 直接看文章对比
- 这篇好,那篇差
- 学习:好文章的特征是什么
2. 直接调整写作风格
- 增加"好文章"的写法
- 减少"差文章"的写法
优势:
- 不需要评委(省时)
- 直接从对比中学习
- 过程简单(监督学习)
2.3 数学直觉
RLHF(PPO)的目标:
最大化:E[RM(response)]
约束:KL(Actor || Reference) < ε
意思:
- 让RM给的分数高
- 但不能离初始模型太远
DPO的目标:
最大化:P(好回答 > 差回答 | prompt)
意思:
- 直接增加"好回答"的概率
- 降低"差回答"的概率
- 不需要RM!
关键洞察(DPO论文的核心):
RLHF的RM可以用Bradley-Terry模型表示:
P(y_w > y_l) = σ(R(y_w) - R(y_l))
其中:
- y_w:被人类选择的回答(winner)
- y_l:被人类拒绝的回答(loser)
- R:奖励函数(RM)
- σ:sigmoid函数
DPO的创新:
把R(y)替换成log概率的差:
R(y) = β * log(π(y|x) / π_ref(y|x))
结果:
P(y_w > y_l) = σ(β * log(π(y_w)/π_ref(y_w)) - β * log(π(y_l)/π_ref(y_l)))
这样就不需要显式的RM了!
直接优化π(Actor)就行!
🔧 Part 3: DPO详细机制
3.1 训练数据格式
DPO需要的数据:偏好对(Preference Pairs)
格式:
{
"prompt": "什么是黑洞?",
"chosen": "黑洞是时空中引力极强的区域,连光都无法逃脱...",
"rejected": "不知道"
}
来源:
1. 人类标注
- 给同一个prompt生成多个回答
- 人类选择更好的那个
2. 从RM筛选
- 如果已有RM,可以用RM打分
- 高分的作为chosen,低分的作为rejected
3.2 损失函数
# DPO的损失函数(核心!)
def dpo_loss(
policy_log_prob_chosen, # π(y_w|x)
policy_log_prob_rejected, # π(y_l|x)
reference_log_prob_chosen, # π_ref(y_w|x)
reference_log_prob_rejected, # π_ref(y_l|x)
beta=0.1
):
"""
DPO损失函数
目标:让chosen的概率相对于rejected更高
"""
# 计算log ratio(相对于reference的变化)
log_ratio_chosen = policy_log_prob_chosen - reference_log_prob_chosen
log_ratio_rejected = policy_log_prob_rejected - reference_log_prob_rejected
# 隐式的"奖励"
implicit_reward_chosen = beta * log_ratio_chosen
implicit_reward_rejected = beta * log_ratio_rejected
# Bradley-Terry模型
logits = implicit_reward_chosen - implicit_reward_rejected
# 交叉熵损失(希望chosen的概率接近1)
loss = -F.logsigmoid(logits).mean()
return loss
# 展开后的形式
def dpo_loss_expanded(π_chosen, π_rejected, ref_chosen, ref_rejected, β):
loss = -log(σ(β * log(π_chosen/ref_chosen) - β * log(π_rejected/ref_rejected)))
return loss
损失函数解读:
logits = β * (log(π_chosen/ref) - log(π_rejected/ref))
梯度方向:
- 如果chosen的概率低 → 增加chosen的概率
- 如果rejected的概率高 → 降低rejected的概率
自动平衡:
- β控制变化幅度(类似PPO的KL惩罚)
- Reference提供锚点(防止偏离太远)
关键:不需要显式的RM!
RM被"隐式"编码在log ratio中
3.3 完整训练流程
# DPO训练的完整流程
# ========== Step 0: 准备 ==========
# 1. 加载SFT模型作为初始Actor
actor = load_sft_model()
# 2. 复制一份作为Reference(固定)
reference = copy(actor)
reference.eval() # 冻结
# 3. 准备偏好数据
dataset = [
{
"prompt": "什么是黑洞?",
"chosen": "黑洞是引力极强的天体...",
"rejected": "不知道"
},
# ... 更多数据
]
# ========== Step 1: 训练循环 ==========
optimizer = torch.optim.Adam(actor.parameters(), lr=1e-6)
for epoch in range(num_epochs):
for batch in dataloader:
prompt = batch['prompt']
chosen = batch['chosen']
rejected = batch['rejected']
# 1. Actor前向传播(计算log概率)
policy_log_prob_chosen = actor.get_log_prob(prompt, chosen)
policy_log_prob_rejected = actor.get_log_prob(prompt, rejected)
# 2. Reference前向传播(固定,无梯度)
with torch.no_grad():
ref_log_prob_chosen = reference.get_log_prob(prompt, chosen)
ref_log_prob_rejected = reference.get_log_prob(prompt, rejected)
# 3. 计算DPO损失
loss = dpo_loss(
policy_log_prob_chosen,
policy_log_prob_rejected,
ref_log_prob_chosen,
ref_log_prob_rejected,
beta=0.1
)
# 4. 反向传播
optimizer.zero_grad()
loss.backward()
optimizer.step()
# 完成!只训练了Actor,没有RM,没有RL
3.4 可视化对比
RLHF(PPO)的流程:
Prompt: "什么是黑洞?"
↓
Actor生成回答:"黑洞是..."
↓
RM打分:8.5
↓
Critic预测:8.0(PPO需要)
↓
Advantage = 8.5 - 8.0 = 0.5
↓
PPO更新Actor
↓
重复几周
DPO的流程:
偏好数据:
Prompt: "什么是黑洞?"
Chosen: "黑洞是引力极强的天体..."
Rejected: "不知道"
↓
Actor计算:
log P(chosen) = -2.5
log P(rejected) = -5.0
↓
Reference计算(固定):
log P_ref(chosen) = -3.0
log P_ref(rejected) = -4.0
↓
DPO损失:
-log σ(β*((-2.5)-(-3.0)) - β*((-5.0)-(-4.0)))
= -log σ(β*0.5 - β*(-1.0))
= -log σ(β*1.5)
↓
梯度方向:
增加chosen的概率 ✓
降低rejected的概率 ✓
↓
直接更新Actor
↓
几天就完成!
🔧 Part 5: IPO —— DPO的改进版
核心问题:DPO的损失函数会饱和,导致训练不充分
5.1 DPO的问题
DPO损失函数回顾:
# DPO损失
logits = β * (log(π_chosen/ref_chosen) - log(π_rejected/ref_rejected))
loss = -log(σ(logits)) # σ是sigmoid函数
问题:sigmoid导致饱和
sigmoid函数的特性:
1.0 ┤ ╭────────
│ ╱
0.5 ┤ ╱
│ ╱
0.0 ┤╱
└────────────────
-10 -5 0 5 10
当logits很大或很小时,梯度接近0(饱和)
场景:
chosen回答很好(9分)
rejected回答很差(1分)
结果:
logits = 很大的正数
σ(logits) ≈ 1
-log(σ(logits)) ≈ 0
梯度 ≈ 0
问题:
即使chosen还能改进,但因为和rejected差距大
→ 损失饱和
→ 几乎不更新
→ 训练不充分
实际例子:
# chosen和rejected差距很大
logits = 10.0 # 很大
# DPO损失
loss = -log(sigmoid(10.0))
= -log(0.9999)
= 0.0001 # 接近0!
# 梯度
grad ≈ 0 # 几乎不更新
# 但实际上:
# chosen可能还有改进空间(9分→9.5分)
# 但因为和rejected(1分)差距太大
# DPO认为"已经够好了",不再更新
5.2 IPO的解决方案
IPO = Identity Preference Optimization(恒等偏好优化)
核心创新:用平方损失代替sigmoid
# DPO损失(会饱和)
dpo_loss = -log(σ(logits))
# IPO损失(不会饱和)
ipo_loss = (logits - 1) ** 2
关键差异:
- DPO:目标是让σ(logits) → 1
- IPO:目标是让logits → 1
为什么平方损失不饱和?
平方函数:
loss
10┤ ╱
│ ╱
5┤ ╱
│ ╱
0┤╱
└────────────────
-2 -1 0 1 2 logits
特性:
- 任何地方都有梯度
- 越远离目标,梯度越大
- 不存在饱和区域
场景(同样的例子):
logits = 10.0
IPO损失:
loss = (10.0 - 1.0) ** 2 = 81
梯度:
grad = 2 * (10.0 - 1.0) = 18 # 很大的梯度!
结果:
即使logits很大,仍然有很大的梯度
→ 会继续更新
→ 直到logits接近1
5.3 数学推导
DPO的理论基础:
DPO假设:最优策略满足
P(y_chosen > y_rejected) = σ(R(y_chosen) - R(y_rejected))
其中R(y) = β * log(π(y) / π_ref(y))
推导出DPO损失:
loss = -log(σ(β * (log(π_c/ref_c) - log(π_r/ref_r))))
IPO的理论基础:
IPO假设:最优策略满足
R(y_chosen) - R(y_rejected) = 1
直接优化这个恒等式:
loss = (R(y_chosen) - R(y_rejected) - 1) ** 2
= (β * (log(π_c/ref_c) - log(π_r/ref_r)) - 1) ** 2
优势:
- 更直接(不需要sigmoid)
- 不会饱和
- 理论上更严格
5.4 代码对比
# ========== DPO损失 ==========
def dpo_loss(
policy_chosen_logps,
policy_rejected_logps,
reference_chosen_logps,
reference_rejected_logps,
beta=0.1
):
# 计算隐式奖励差
pi_logratios = policy_chosen_logps - policy_rejected_logps
ref_logratios = reference_chosen_logps - reference_rejected_logps
logits = beta * (pi_logratios - ref_logratios)
# DPO:sigmoid + 负对数似然
loss = -F.logsigmoid(logits).mean()
return loss
# ========== IPO损失 ==========
def ipo_loss(
policy_chosen_logps,
policy_rejected_logps,
reference_chosen_logps,
reference_rejected_logps,
beta=0.1
):
# 计算隐式奖励差
pi_logratios = policy_chosen_logps - policy_rejected_logps
ref_logratios = reference_chosen_logps - reference_rejected_logps
logits = beta * (pi_logratios - ref_logratios)
# IPO:平方损失(目标是logits=1)
loss = (logits - 1.0) ** 2
loss = loss.mean()
return loss
# 唯一的区别就是最后的损失计算!
# DPO: -logsigmoid(logits)
# IPO: (logits - 1) ** 2
5.5 效果对比
实验数据(IPO论文):
设置:7B模型,相同数据
场景1:chosen/rejected差距小(正常数据)
─────────────────────────────────────
方法 损失 梯度范数 效果
─────────────────────────────────────
DPO 0.15 0.08 好
IPO 0.18 0.12 好
差异: 不大 IPO略大 相近
场景2:chosen/rejected差距大(极端数据)
─────────────────────────────────────
方法 损失 梯度范数 效果
─────────────────────────────────────
DPO 0.02 0.01 差 ← 饱和了
IPO 2.50 0.45 好 ← 还在更新
差异: 大 IPO大得多 IPO更好
结论:
- 数据质量好 → DPO和IPO差不多
- 数据质量参差不齐 → IPO更稳定
5.6 何时用IPO?
用IPO如果:
✓ DPO训练不稳定
✓ 数据质量参差不齐(chosen/rejected差距大)
✓ 需要更长时间训练(DPO可能早期饱和)
✓ 理论上更严格
用DPO如果:
✓ 数据质量高(chosen/rejected差距适中)
✓ 已有成熟的DPO pipeline
✓ 实际效果足够好
✓ 简单快速
5.7 其他DPO变体
除了IPO,还有:
1. SimPO(Simple Preference Optimization)
- 去掉Reference
- 更简单但效果可能略差
2. cDPO(Contrastive DPO)
- 增加对比学习
- 多个negative样本
3. DPO-Reg(DPO with Regularization)
- 增加额外正则项
- 防止过拟合
但IPO是最实用的改进
- 只改损失函数
- 效果提升明显
- 理论严格
5.8 快速总结
问题:
DPO的sigmoid损失会饱和
→ chosen/rejected差距大时训练不充分
解决:
IPO用平方损失
→ 不会饱和,始终有梯度
实现:
只需要改一行代码
# DPO: loss = -logsigmoid(logits)
# IPO: loss = (logits - 1) ** 2
选择:
- 数据质量好 → DPO够用
- 数据质量参差 → IPO更稳定
⚖️ Part 6: DPO vs IPO vs PPO vs GRPO
6.1 组件对比
| 组件 | PPO | GRPO | DPO | 说明 |
|---|---|---|---|---|
| Actor | ✓ | ✓ | ✓ | 都需要 |
| Critic | ✓ | ✗ | ✗ | DPO不需要 |
| RM | ✓ | ✓ | ✗ | DPO不需要! |
| Reference | ✓ | ✓ | ✓ | 都需要 |
| 训练 | RL | RL | 监督学习 | DPO最简单 |
6.2 Pipeline对比
PPO Pipeline(3阶段):
─────────────────────────────────
阶段1: SFT训练
时间:几天
输出:SFT模型
阶段2: RM训练
时间:几天
输出:Reward Model
阶段3: PPO微调
时间:几周
输出:最终模型
总时间:几周到几个月
GRPO Pipeline(3阶段):
─────────────────────────────────
阶段1: SFT训练
时间:几天
输出:SFT模型
阶段2: RM训练
时间:几天
输出:Reward Model
阶段3: GRPO微调
时间:1-2周
输出:最终模型
总时间:2-3周
DPO Pipeline(2阶段):
─────────────────────────────────
阶段1: SFT训练
时间:几天
输出:SFT模型
阶段2: DPO微调
时间:1-2天
输出:最终模型
总时间:1周内 ✅
关键差异:
- 不需要训练RM(省几天)
- 不需要RL调参(省几周)
6.3 实现复杂度对比
# PPO伪代码(复杂)
for iteration in range(1000):
# Rollout
responses = actor.generate(prompts)
rewards = reward_model(prompts, responses)
values = critic(prompts, responses)
advantages = compute_gae(rewards, values) # 复杂
# Update
for epoch in range(4):
# 更新Actor(PPO clip)
ratio = exp(new_lp - old_lp)
ratio_clipped = clip(ratio, 1-ε, 1+ε)
actor_loss = -min(ratio * adv, ratio_clipped * adv)
# 更新Critic(额外训练)
critic_loss = mse(value, return)
# 复杂!
# GRPO伪代码(中等)
for iteration in range(1000):
# Rollout(生成多个)
groups = [actor.generate(prompt) for _ in range(group_size)]
group_rewards = [reward_model(p, r) for r in groups]
baseline = mean(group_rewards)
advantages = [r - baseline for r in group_rewards]
# Update
for epoch in range(4):
ratio = exp(new_lp - old_lp)
ratio_clipped = clip(ratio, 1-ε, 1+ε)
loss = -min(ratio * adv, ratio_clipped * adv)
# 中等复杂
# DPO伪代码(简单!)
for epoch in range(num_epochs):
for batch in dataloader:
# 计算log概率
policy_chosen = actor.get_log_prob(prompt, chosen)
policy_rejected = actor.get_log_prob(prompt, rejected)
ref_chosen = reference.get_log_prob(prompt, chosen)
ref_rejected = reference.get_log_prob(prompt, rejected)
# DPO损失
logits = beta * (policy_chosen - ref_chosen) - beta * (policy_rejected - ref_rejected)
loss = -log_sigmoid(logits)
# 反向传播
loss.backward()
# 超级简单!就是监督学习!
6.4 效果对比
实验数据(来自DPO论文):
实验设置:
- 模型:6B参数
- 数据:相同的偏好数据
- 评价:人类偏好评测
结果(胜率):
─────────────────────────────────
方法 vs SFT 训练时间 显存
─────────────────────────────────
PPO 68% 3周 28GB
GRPO 65% 2周 14GB
DPO 62% 2天 14GB
─────────────────────────────────
结论:
- PPO效果最好,但最复杂
- GRPO中等效果,中等复杂
- DPO效果略低,但最简单最快
6.5 优缺点对比
DPO的优势:
✅ 超级简单
- 不需要RM(省几天训练)
- 不需要RL(省几周调参)
- 就是监督学习(容易理解和实现)
✅ 快速
- Pipeline短(2阶段 vs 3阶段)
- 训练快(几天 vs 几周)
- 容易调试
✅ 稳定
- 不会像RL那样崩溃
- 不需要调复杂的超参数
- 就是普通的监督学习
✅ 节省资源
- 不需要训练RM(省显存)
- 不需要同时运行多个模型
DPO的劣势:
❌ 效果可能略差
- 比PPO低5-10%(但对很多应用够用了)
❌ 需要高质量偏好数据
- 对数据质量敏感
- 噪声数据影响大
- 需要大量偏好对(10k+)
❌ 不能在线学习
- 只能用离线数据
- 不能像RL那样探索新策略
❌ 理论不如PPO成熟
- 相对较新(2023)
- 边界情况研究较少
6.6 选择建议
用DPO如果:
✓ 想要快速原型(快速验证想法)
✓ 资源有限(单机、小团队)
✓ 有高质量偏好数据
✓ 不需要极致效果(62% vs 68%能接受)
✓ 初次尝试对齐
用GRPO如果:
✓ 想要简单但效果好
✓ 有一定计算资源
✓ 已有RM或能训练RM
✓ 需要比DPO更好的效果
用PPO如果:
✓ 需要最好的效果
✓ 有大量计算资源
✓ 有经验丰富的团队
✓ 可以投入几周调参
💻 Part 7: 代码实现
7.1 完整代码
import torch
import torch.nn as nn
import torch.nn.functional as F
from torch.utils.data import Dataset, DataLoader
# ========== DPO数据集 ==========
class DPODataset(Dataset):
"""DPO训练数据集"""
def __init__(self, data):
"""
data格式:
[
{
"prompt": "什么是黑洞?",
"chosen": "黑洞是引力极强的天体...",
"rejected": "不知道"
},
...
]
"""
self.data = data
def __len__(self):
return len(self.data)
def __getitem__(self, idx):
return self.data[idx]
# ========== DPO损失函数 ==========
def dpo_loss(
policy_chosen_logps, # Actor对chosen的log概率
policy_rejected_logps, # Actor对rejected的log概率
reference_chosen_logps, # Reference对chosen的log概率
reference_rejected_logps,# Reference对rejected的log概率
beta=0.1
):
"""
DPO损失函数
Args:
policy_chosen_logps: 策略模型对chosen回答的log概率
policy_rejected_logps: 策略模型对rejected回答的log概率
reference_chosen_logps: 参考模型对chosen回答的log概率
reference_rejected_logps: 参考模型对rejected回答的log概率
beta: 温度参数,控制KL惩罚强度
Returns:
loss: DPO损失
"""
# 计算隐式奖励(相对于reference的变化)
pi_logratios = policy_chosen_logps - policy_rejected_logps
ref_logratios = reference_chosen_logps - reference_rejected_logps
# DPO目标:最大化chosen相对于rejected的优势
logits = beta * (pi_logratios - ref_logratios)
# 负对数似然(希望logits越大越好)
loss = -F.logsigmoid(logits).mean()
# 也可以写成:
# loss = -torch.log(torch.sigmoid(logits)).mean()
return loss
# ========== DPO训练器 ==========
class DPOTrainer:
"""DPO训练器"""
def __init__(
self,
model,
reference_model,
beta=0.1,
learning_rate=1e-6
):
"""
Args:
model: 要训练的Actor模型
reference_model: 参考模型(固定,通常是SFT模型的副本)
beta: DPO的温度参数
learning_rate: 学习率
"""
self.model = model
self.reference_model = reference_model
self.beta = beta
# 优化器
self.optimizer = torch.optim.Adam(
model.parameters(),
lr=learning_rate
)
# 冻结reference model
for param in reference_model.parameters():
param.requires_grad = False
reference_model.eval()
def compute_log_probs(self, model, prompts, responses):
"""
计算log概率
Args:
model: 模型
prompts: 输入prompts
responses: 对应的responses
Returns:
log_probs: 每个response的log概率(求和)
"""
batch_log_probs = []
for prompt, response in zip(prompts, responses):
# 拼接prompt和response
input_ids = tokenize(prompt + response)
# 前向传播
logits = model(input_ids)
# 计算log概率
log_probs = F.log_softmax(logits, dim=-1)
# 取response部分的log概率(忽略prompt)
response_tokens = tokenize(response)
response_log_probs = []
for i, token in enumerate(response_tokens):
token_log_prob = log_probs[len_prompt + i, token]
response_log_probs.append(token_log_prob)
# 求和
total_log_prob = sum(response_log_probs)
batch_log_probs.append(total_log_prob)
return torch.stack(batch_log_probs)
def train_step(self, batch):
"""
单个训练步骤
Args:
batch: {
'prompt': [...],
'chosen': [...],
'rejected': [...]
}
Returns:
loss: 损失值
"""
prompts = batch['prompt']
chosen = batch['chosen']
rejected = batch['rejected']
# ━━━━━ 1. Policy模型(要训练)━━━━━
policy_chosen_logps = self.compute_log_probs(
self.model, prompts, chosen
)
policy_rejected_logps = self.compute_log_probs(
self.model, prompts, rejected
)
# ━━━━━ 2. Reference模型(固定)━━━━━
with torch.no_grad():
reference_chosen_logps = self.compute_log_probs(
self.reference_model, prompts, chosen
)
reference_rejected_logps = self.compute_log_probs(
self.reference_model, prompts, rejected
)
# ━━━━━ 3. 计算DPO损失 ━━━━━
loss = dpo_loss(
policy_chosen_logps,
policy_rejected_logps,
reference_chosen_logps,
reference_rejected_logps,
beta=self.beta
)
# ━━━━━ 4. 反向传播 ━━━━━
self.optimizer.zero_grad()
loss.backward()
self.optimizer.step()
return loss.item()
def train(self, dataset, num_epochs=3, batch_size=4):
"""
完整训练循环
Args:
dataset: DPODataset
num_epochs: 训练轮数
batch_size: 批次大小
"""
dataloader = DataLoader(
dataset,
batch_size=batch_size,
shuffle=True
)
self.model.train()
for epoch in range(num_epochs):
total_loss = 0
num_batches = 0
for batch in dataloader:
loss = self.train_step(batch)
total_loss += loss
num_batches += 1
if num_batches % 10 == 0:
avg_loss = total_loss / num_batches
print(f"Epoch {epoch}, Batch {num_batches}, Loss: {avg_loss:.4f}")
avg_loss = total_loss / num_batches
print(f"Epoch {epoch} completed, Avg Loss: {avg_loss:.4f}")
# ========== 使用示例 ==========
if __name__ == '__main__':
# 1. 加载SFT模型
model = load_sft_model('path/to/sft_model')
# 2. 复制作为Reference(冻结)
reference_model = copy.deepcopy(model)
# 3. 准备DPO数据
dpo_data = [
{
"prompt": "什么是黑洞?",
"chosen": "黑洞是时空中引力极强的区域...",
"rejected": "不知道"
},
# ... 更多数据
]
dataset = DPODataset(dpo_data)
# 4. 创建DPO训练器
trainer = DPOTrainer(
model=model,
reference_model=reference_model,
beta=0.1,
learning_rate=1e-6
)
# 5. 训练
trainer.train(dataset, num_epochs=3, batch_size=4)
# 6. 保存模型
torch.save(model.state_dict(), 'dpo_model.pt')
print("DPO训练完成!")
7.2 关键代码解释
# 1. DPO损失的核心(就这几行)
pi_logratios = policy_chosen - policy_rejected
ref_logratios = ref_chosen - ref_rejected
logits = beta * (pi_logratios - ref_logratios)
loss = -F.logsigmoid(logits).mean()
# 展开理解:
# logits = β * (log(π_c/π_r) - log(ref_c/ref_r))
# = β * log((π_c/ref_c) / (π_r/ref_r))
#
# 目标:让chosen相对于rejected的"相对优势"最大
# - π_c/ref_c: chosen的相对概率(相对于reference)
# - π_r/ref_r: rejected的相对概率
# - 希望前者 > 后者
# 2. 为什么是logsigmoid?
# 等价于:
# loss = -log(σ(logits))
# = -log(P(chosen > rejected))
#
# 目标:最大化"chosen比rejected好"的概率
# 3. beta的作用
# beta越大:
# - 变化幅度越小(类似PPO的KL惩罚)
# - 更接近reference
#
# beta越小:
# - 变化幅度越大
# - 可能偏离reference太远
7.3 简化版(最小实现)
# 超级简化的DPO实现(去掉所有细节)
def train_dpo(model, reference, data, epochs=3):
"""最简DPO训练"""
optimizer = torch.optim.Adam(model.parameters(), lr=1e-6)
for epoch in range(epochs):
for prompt, chosen, rejected in data:
# 计算log概率
log_p_chosen = model.get_log_prob(prompt, chosen)
log_p_rejected = model.get_log_prob(prompt, rejected)
with torch.no_grad():
log_ref_chosen = reference.get_log_prob(prompt, chosen)
log_ref_rejected = reference.get_log_prob(prompt, rejected)
# DPO损失(核心就这一行)
logits = 0.1 * ((log_p_chosen - log_ref_chosen) -
(log_p_rejected - log_ref_rejected))
loss = -F.logsigmoid(logits)
# 反向传播
loss.backward()
optimizer.step()
optimizer.zero_grad()
return model
# 就这么简单!
🎓 Part 8: 总结
8.1 核心要点
DPO的创新:
问题:
RLHF需要训练RM,然后用RL微调
→ 复杂、慢、难调试
DPO的解决方案:
把RL问题转化成监督学习问题
→ 直接用偏好数据训练Actor
→ 不需要RM,不需要RL
数学魔法:
把RM"隐式"编码在log ratio中
R(y) = β * log(π(y) / π_ref(y))
→ 不需要显式的RM模型
一句话总结:
DPO = 把RLHF的3阶段压缩成2阶段,直接优化偏好
8.2 三种方法的定位
┌─────────────────────────────────────────┐
│ 对齐方法选择指南 │
├─────────────────────────────────────────┤
│ │
│ 需要最好效果? │
│ → PPO │
│ - 效果最好(68%胜率) │
│ - 最复杂,最慢 │
│ - 需要大团队和资源 │
│ │
│ 想要简单+效果好? │
│ → GRPO │
│ - 效果不错(65%胜率) │
│ - 比PPO简单 │
│ - 需要RM │
│ │
│ 想要超级简单? │
│ → DPO │
│ - 效果够用(62%胜率) │
│ - 最简单,最快 │
│ - 不需要RM,不需要RL │
│ │
└─────────────────────────────────────────┘
8.3 对比表格
| 维度 | PPO | GRPO | DPO |
|---|---|---|---|
| 训练方法 | RL | RL | 监督学习 |
| 需要RM | ✓ | ✓ | ✗ |
| 需要Critic | ✓ | ✗ | ✗ |
| 训练阶段 | 3个 | 3个 | 2个 |
| 训练时间 | 几周 | 1-2周 | 几天 |
| 实现复杂度 | 高 | 中 | 低 |
| 效果 | 最好 | 很好 | 好 |
| 稳定性 | 中 | 中 | 高 |
| 推荐场景 | 大团队 | 中等团队 | 小团队 |
8.4 快速记忆
记住DPO的三个关键词:
-
Direct(直接)
- 直接优化偏好,不绕弯
-
Preference(偏好)
- 用成对偏好数据训练
-
No RL(不用RL)
- 就是监督学习,超级简单
记住DPO的核心公式:
# 隐式奖励
R(y) = β * log(π(y) / π_ref(y))
# DPO损失
loss = -log σ(R(y_chosen) - R(y_rejected))
🤔 Part 9: 常见问题
Q1: DPO效果真的够用吗?
实验数据(DPO论文):
- PPO: 68%胜率
- DPO: 62%胜率
- 差距: 6%
分析:
6%的差距对很多应用来说不是关键
- 如果是chatbot → 差距不大
- 如果是代码生成 → 可能需要PPO
- 如果是创意写作 → DPO够用
实际应用:
Anthropic的Claude也用了DPO(部分阶段)
说明DPO效果是够用的
Q2: DPO需要多少偏好数据?
最小量:
- 1k对偏好数据就能看到效果
- 但质量要高
推荐量:
- 10k+ 对偏好数据
- 覆盖不同类型的任务
最优量:
- 50k-100k对
- 效果接近饱和
对比:
PPO训练RM也需要这么多数据
所以数据量没有额外增加
Q3: DPO能和PPO结合吗?
可以!常见做法:
方案1(先DPO后PPO):
1. SFT
2. DPO(快速对齐)
3. PPO(精细调优)
好处:
- DPO提供好的初始化
- PPO进一步提升
方案2(DPO作为预训练):
用DPO学习基本偏好
然后用PPO在特定任务上微调
实践中:
很多公司用这种组合方式
Q4: beta参数怎么选?
beta的作用:
控制离Reference的距离
- beta大:保守(接近reference)
- beta小:激进(可能过拟合)
推荐值:
beta = 0.1(DPO论文默认值)
调整建议:
- 如果训练不稳定 → 增大beta(0.2-0.5)
- 如果效果不够 → 减小beta(0.05-0.1)
- 大部分情况0.1就很好
Q5: DPO vs RLHF,哪个是未来?
当前趋势:
学术界:
- DPO很受欢迎(简单易用)
- 很多改进版本(IPO、KTO等)
工业界:
- 大公司还是用PPO(追求效果)
- 小公司用DPO(追求简单)
未来可能:
- 方法会继续简化
- 可能出现更好的"直接优化"方法
- 但PPO在需要极致效果的场景还会存在
结论:
不是替代关系,而是互补
- DPO: 快速原型、小规模
- PPO: 大规模、极致效果
📚 推荐资源
论文:
- Direct Preference Optimization - Rafailov et al., 2023
- PPO - Schulman et al., 2017
代码:
博客: