DPO:最简单的对齐算法

3 阅读20分钟

DPO:最简单的对齐算法

完全不需要强化学习,直接优化人类偏好


📚 目录

  1. DPO是什么:抛弃RL的对齐方法
  2. RLHF的问题:为什么还要更简单
  3. DPO的核心创新:直接优化偏好
  4. 详细机制:从公式到代码
  5. IPO:DPO的改进版(解决饱和问题)
  6. 对比PPO/GRPO:优势与权衡
  7. 代码实现

📌 前置概念:对齐方法的演进

从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 组件对比

组件PPOGRPODPO说明
Actor都需要
CriticDPO不需要
RMDPO不需要!
Reference都需要
训练RLRL监督学习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 对比表格

维度PPOGRPODPO
训练方法RLRL监督学习
需要RM
需要Critic
训练阶段3个3个2个
训练时间几周1-2周几天
实现复杂度
效果最好很好
稳定性
推荐场景大团队中等团队小团队

8.4 快速记忆

记住DPO的三个关键词:

  1. Direct(直接)

    • 直接优化偏好,不绕弯
  2. Preference(偏好)

    • 用成对偏好数据训练
  3. 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论文默认值)

调整建议:
- 如果训练不稳定 → 增大beta0.2-0.5)
- 如果效果不够 → 减小beta0.05-0.1)
- 大部分情况0.1就很好

Q5: DPO vs RLHF,哪个是未来?

当前趋势:

学术界:
- DPO很受欢迎(简单易用)
- 很多改进版本(IPO、KTO等)

工业界:
- 大公司还是用PPO(追求效果)
- 小公司用DPO(追求简单)

未来可能:
- 方法会继续简化
- 可能出现更好的"直接优化"方法
- 但PPO在需要极致效果的场景还会存在

结论:
不是替代关系,而是互补
- DPO: 快速原型、小规模
- PPO: 大规模、极致效果

📚 推荐资源

论文:

代码:

博客: