从PPO到DPO的完整理解

从PPO到DPO的完整理解

深入解析LLM中的强化学习算法,包括PPO、DPO、GRPO的工作原理、数学推导、以及工业界应用案例

LLM强化学习深度解析:从PPO到DPO的完整理解

本笔记记录了从困惑到理解的完整学习过程,涵盖强化学习在大语言模型训练中的核心概念、算法细节、以及实际应用。

目录


1. 核心概念澄清

1.1 概念层次区分

困惑点:RLHF、PPO、DPO是什么关系?

解答: - RLHF(人类反馈强化学习):框架/范式,不是具体算法 - PPO(近端策略优化):具体的RL算法,可用于实现RLHF - DPO(直接偏好优化):新的优化方法,绕过了显式的reward model

范式层:RLHF (Reinforcement Learning from Human Feedback)
        ├── 需要reward model
        └── 需要RL算法来优化

算法层:PPO, TRPO, A3C, ...
        └── 传统RL算法,用于RLHF

新方法:DPO, IPO, KTO, SimPO
        └── 直接从偏好数据学习,不需要显式reward model

1.2 不同领域的主流算法

领域 主流算法 原因
通用RL(游戏、机器人) PPO, SAC, TD3 稳定、通用
LLM对齐(2022-2023) RLHF + PPO OpenAI GPT系列使用
LLM对齐(2024-2025) DPO及其变体 更简单、更稳定

2. PPO算法详解

2.1 完整流程(三阶段)

阶段1: 监督微调(SFT
├── 输入:高质量示范数据
└── 输出:SFT模型(基础对齐)

阶段2: 训练Reward Model
├── 输入:人类偏好数据 (prompt, chosen, rejected)
└── 输出:独立的奖励模型(打分器)

阶段3: PPO强化学习
├── 输入:SFT模型 + Reward Model
├── 过程:
   1. SFT模型生成回复
   2. Reward Model给回复打分
   3. PPO算法更新策略模型
   4. 重复迭代
└── 输出:对齐后的模型

2.2 PPO需要的4个模型

困惑点:为什么PPO这么复杂,需要4个模型?

模型 作用 是否更新
策略模型(Policy) 生成回复 ✅ 持续更新
价值模型(Value) 预测期望回报 ✅ 持续更新
参考模型(Reference) 计算KL散度,防偏离 ❌ 固定不变
奖励模型(Reward) 给回复打分 ❌ 固定不变

为什么需要这么多模型?

# 策略模型:决定生成什么
policy_output = policy_model.generate(prompt)

# 奖励模型:评价生成的好坏
reward = reward_model.score(prompt, policy_output)

# 价值模型:预测平均能得多少分(用于计算advantage)
baseline = value_model.predict(prompt)
advantage = reward - baseline  # 比平均好多少

# 参考模型:防止模型变化太激进
kl_penalty = KL(policy_model || reference_model)
final_reward = reward - 0.1 * kl_penalty

2.3 PPO的Clipped Objective

困惑点:为什么需要clip操作?

不用clip的问题

# 旧策略: P(好回答) = 0.1, P(坏回答) = 0.9
# 新策略: P(好回答) = 0.9, P(坏回答) = 0.1  # 变化太大!

 可能导致
  - 模型突然"忘记"之前学的东西
  - 训练不稳定
  - 输出质量崩溃

clip的作用

epsilon = 0.2  # clip范围

# 概率比
ratio = π_new(action) / π_old(action)

# 限制在 [0.8, 1.2] 范围内
clipped_ratio = clip(ratio, 1-epsilon, 1+epsilon)

# 取保守的更新
objective = min(ratio * advantage, clipped_ratio * advantage)

 每次更新都是"小步前进"
 训练稳定

3. DPO算法详解

3.1 完整流程(两阶段)

阶段1: 监督微调(SFT
├── PPO相同
└── 得到基础模型

阶段2: 直接偏好优化
├── 输入:偏好对数据 (prompt, chosen, rejected)
├── 关键创新:跳过训练Reward Model
└── 直接从偏好数据学习

3.2 DPO的核心损失函数

困惑点:DPO如何不用reward model就能学习?

# DPO损失(数学形式)
loss = -log(σ(β * log(π_θ(y_w|x) / π_ref(y_w|x)) 
              - β * log(π_θ(y_l|x) / π_ref(y_l|x))))

# 其中:
# y_w: 被选中的回复(win)
# y_l: 被拒绝的回复(lose)
# π_θ: 当前模型(会更新)
# π_ref: 参考模型(固定)
# β: 温度参数

直觉理解

# 传统RLHF:
# 步骤1: 训练 reward_model: r(x,y) ≈ 人类偏好
# 步骤2: 优化策略: max E[reward_model(x, policy(x))]

# DPO:
# 一步到位:让 P(chosen) / P(rejected) 的比值变大
# 不需要显式的reward,偏好信息"隐式"编码在损失函数中

3.3 PPO vs DPO对比

维度 PPO (RLHF) DPO
训练阶段 3阶段(SFT → RM → RL) 2阶段(SFT → DPO)
需要的模型 4个模型 2个模型
训练稳定性 较难调,容易崩溃 稳定,类似监督学习
显存需求 非常高(4个模型同时在显存) 中等(2个模型)
实现复杂度 高(需要RL框架) 低(标准梯度下降)
数据需求 需要标注偏好数据 需要偏好对数据(相同)
性能 理论上限可能更高 实践中往往不输PPO
可解释性 Reward model可单独分析 隐式奖励,较难解释

4. SFT的目的与必要性

困惑点:为什么需要SFT?能不能直接RL?

4.1 SFT的三个核心目的

目的1:格式对齐(最重要)

# ❌ 预训练模型:
User: "2+2等于多少?"
Model: "这是一个数学问题,让我来..." [可能输出各种格式]

# ✅ SFT后:
User: "2+2等于多少?"
Model: """
<start_working_out>
2 + 2 = 4
<end_working_out>
<SOLUTION>4</SOLUTION>
"""  # 严格遵循你定义的格式

为什么重要? - RL阶段需要从输出中提取答案 - 如果格式不统一,reward计算会失败 - RL很难从头学会复杂格式

目的2:能力适配

# 预训练模型:有知识,但不知道"怎么用"
预训练模型"量子纠缠是..."[知道概念]

# SFT:教它如何组织答案、如何推理
SFT后"首先,量子纠缠的定义是...
        其次它的物理原理包括...
        最后应用场景有..."

目的3:为RL打基础

# 如果直接RL:
初始模型  随机探索  99.99%的输出都得负分  无法学习

# 有SFT基础:
SFT模型  已经在"好答案"附近  探索空间小  RL可以微调

4.2 SFT是否必要?

场景 是否必要 原因
格式化输出 ✅ 强烈建议 RL很难学会复杂格式
任务特化 ✅ 必要 让模型知道任务是什么
预训练模型很强 ⚠️ 可选 GPT-4可能few-shot就够
只做偏好学习 ⚠️ 看情况 DPO可能不需要,但效果更好

5. Reward Model深度解析

困惑点:Reward Model是什么?是一个独立的LLM吗?可以用API替代吗?

5.1 Reward Model的架构

# 典型的Reward Model结构
class RewardModel(nn.Module):
    def __init__(self, base_model):
        self.base_model = base_model  # 比如 Llama-7B
        self.value_head = nn.Linear(4096, 1)  # 输出标量分数

    def forward(self, input_ids):
        # 编码整个(prompt + response)
        hidden = self.base_model(input_ids).last_hidden_state

        # 取最后一个token的hidden state
        last_hidden = hidden[:, -1, :]

        # 输出一个分数
        score = self.value_head(last_hidden)
        return score  # 标量,比如 8.5

是的,Reward Model通常是一个独立的LLM(或基于LLM)!

5.2 Reward Model的训练

# 训练数据:人类偏好对
dataset = [
    {
        "prompt": "写一首关于春天的诗",
        "response_A": "春眠不觉晓,处处闻啼鸟...",  # 人类选择 👍
        "response_B": "春天来了..."                  # 人类拒绝 👎
    }
]

# 训练目标:Bradley-Terry模型
# P(A > B) = σ(r(A) - r(B))

loss = -log(σ(reward_model(prompt, A) - reward_model(prompt, B)))

# 训练后,reward_model能够预测人类偏好分数

5.3 可以用LLM API作为Reward Model吗?

✅ 完全可以!这叫 LLM-as-a-Judge

def api_reward_function(prompt, completion):
    """用GPT-4作为reward model"""

    judge_prompt = f"""
请对以下回答打分(0-10分):

问题:{prompt}
回答:{completion}

评分标准:
- 准确性(0-4分)
- 格式规范(0-3分)
- 推理清晰(0-3分)

只输出分数,如:8.5
"""

    response = openai.ChatCompletion.create(
        model="gpt-4",
        messages=[{"role": "user", "content": judge_prompt}]
    )

    score = float(response.choices[0].message.content.strip())
    return score

优点: - 🎯 不需要人工标注偏好数据 - 🎯 不需要训练reward model - 🎯 可以定义复杂的评分标准

缺点: - 💰 成本高(每个样本都要调用API) - 🐌 速度慢 - 🎲 可能不稳定(API的输出有随机性)

5.4 硬编码规则 vs Reward Model

# 方式1:硬编码规则(适合有明确标准的任务)
def rule_based_reward(prompt, completion, answer):
    if extract_answer(completion) == answer:
        return 10.0  # 完全正确
    elif is_close(extract_answer(completion), answer):
        return 5.0   # 接近正确
    else:
        return 0.0   # 错误

# 方式2:训练Reward Model(适合主观评价)
reward_score = reward_model(prompt, completion)  # 学习人类偏好

# 方式3:LLM API(灵活但昂贵)
reward_score = gpt4_judge(prompt, completion)

6. PPO参数更新机制

困惑点:PPO具体是如何更新模型参数的?

6.1 完整的更新步骤

# ========== PPO的一次迭代 ==========

# 初始化
prompt = "写一首关于春天的诗"
current_policy = your_model          # 会被更新
reference_policy = copy(your_model)  # 固定不变
value_network = separate_model       # 会被更新
reward_model = trained_rm            # 固定不变

# Step 1: 采样(用当前策略生成回答)
response = current_policy.generate(prompt)
# response = "春眠不觉晓,处处闻啼鸟"

# Step 2: 计算reward
reward = reward_model(prompt, response)  # 比如得到 8.5分

# Step 3: 计算value(预测的期望回报)
value = value_network(prompt)  # 预测这个prompt的平均回报,比如 7.0

# Step 4: 计算advantage(这个回答比平均好多少)
advantage = reward - value  # 8.5 - 7.0 = 1.5

# Step 5: 计算概率比(当前策略 vs 旧策略)
current_prob = current_policy.log_prob(response | prompt)  # -2.3
old_prob = reference_policy.log_prob(response | prompt)    # -2.5
ratio = exp(current_prob - old_prob)  # exp(0.2) ≈ 1.22

# Step 6: PPO的Clipped目标
epsilon = 0.2
unclipped = ratio * advantage          # 1.22 * 1.5 = 1.83
clipped = clip(ratio, 0.8, 1.2) * advantage  # 1.2 * 1.5 = 1.8
objective = min(unclipped, clipped)    # 取保守的:1.8

# Step 7: 反向传播更新
loss = -objective  # 负号因为要最大化objective
loss.backward()
optimizer.step()  # ✅ 更新 current_policy

6.2 形象比喻

PPO更新就像"小心翼翼地改进":

1. 生成一个回答
2. 看看得了多少分
3. 跟平均水平比较(advantage)
4. 但不能改得太激进(clip)
5. 小步更新参数

就像学生改进答题策略:
- 这次比平均好 → 以后多这样答 ✅
- 但不能突然完全改变风格 ⚠️
- 一点点改进,保持稳定 👍

7. 策略模型 vs 参考模型

困惑点:策略模型和参考模型有什么区别?为什么需要两个?

7.1 PPO中的例子

# ========== 训练开始 ==========
# 初始模型(都一样)
policy_model = load_model("llama-7b-sft")      # 策略模型(会更新)
reference_model = load_model("llama-7b-sft")   # 参考模型(固定)
reference_model.eval()  # 设为评估模式
for param in reference_model.parameters():
    param.requires_grad = False  # 冻结所有参数

# ========== 迭代1 ==========
prompt = "如何学习编程?"

# 策略模型生成(会不断改进)
response_v1 = policy_model.generate(prompt)
# "学习编程需要多练习"

reward = reward_model.score(prompt, response_v1)  # 6.0分

# 用参考模型计算KL散度(防止偏离太远)
kl_divergence = KL(policy_model || reference_model)  # 0.05
final_reward = reward - 0.1 * kl_divergence  # 6.0 - 0.005 = 5.995

# 更新策略模型
update(policy_model)  # ✅ 更新
# reference_model 不变 ❌

# ========== 迭代100 ==========
# 策略模型已经改进很多
response_v100 = policy_model.generate(prompt)
# "学习编程建议:1)选择Python入门 2)每天练习 3)做项目..."

reward = 9.0

# 但要检查:是不是偏离原始模型太远?
kl_divergence = KL(policy_model || reference_model)  # 0.8 (比较大了)
final_reward = 9.0 - 0.1 * 0.8 = 8.92

# 如果KL太大,会被惩罚,防止模型"乱说"

7.2 为什么需要参考模型?

没有参考模型 有参考模型
模型可能为了高reward乱说话 保持基本能力
"为了高分,输出乱码也行" "必须像人类说话"
可能忘记SFT学的格式 格式不会崩
可能模式崩溃 训练稳定

具体例子

# 没有参考模型的灾难:
# 假设reward model有bug,对"42"这个回答给高分

Iteration 1:
Q: "人生的意义是什么?"
A: "人生的意义在于..."  reward = 5.0

Iteration 50:
Q: "人生的意义是什么?"
A: "42"  reward = 10.0 (reward model的bug)
# 模型发现"42"能得高分

Iteration 100:
Q: "人生的意义是什么?"
A: "42"
Q: "如何学编程?"
A: "42"  # 什么都答"42",模式崩溃!
Q: "今天天气?"
A: "42"

# 有参考模型的保护:
KL(policy || reference) 会变得非常大
 final_reward = 10.0 - 0.1 * huge_KL = 负数
 模型不会学习这种"作弊"行为

7.3 DPO中的参考模型

# ========== DPO训练 ==========
policy_model = load_model("llama-7b-sft")
reference_model = load_model("llama-7b-sft")  # 复制一份
reference_model.eval()  # 冻结

for batch in dataset:
    prompt = batch['prompt']
    y_win = batch['chosen']
    y_lose = batch['rejected']

    # 计算当前策略的log概率
    log_prob_win_policy = policy_model.log_prob(y_win | prompt)
    log_prob_lose_policy = policy_model.log_prob(y_lose | prompt)

    # 计算参考策略的log概率(不更新)
    with torch.no_grad():
        log_prob_win_ref = reference_model.log_prob(y_win | prompt)
        log_prob_lose_ref = reference_model.log_prob(y_lose | prompt)

    # DPO损失(让chosen的相对概率更高)
    beta = 0.1
    loss = -log_sigmoid(
        beta * (log_prob_win_policy - log_prob_win_ref) -
        beta * (log_prob_lose_policy - log_prob_lose_ref)
    )

    # 更新
    loss.backward()
    optimizer.step()  # 只更新 policy_model
    # reference_model 保持不变!

解释: - policy_model: "我正在学习更偏好chosen" - reference_model: "我记住原始模型长什么样,防止你学歪"


8. DPO的反向传播机制

困惑点:DPO如何反向传播?是否把概率最大的输出作为label?

8.1 关键理解:不是用label,而是用log概率

❌ 错误理解

# 把最大概率的输出作为label,然后计算loss?
# 不是这样的!

✅ 正确理解

# DPO直接优化整个序列的log概率
# 不需要label,通过chosen vs rejected的相对概率来更新

8.2 完整的计算过程

# ========== Step 1: 准备输入 ==========
batch = {
    "prompt": "解释量子纠缠",
    "chosen": "量子纠缠是指两个粒子之间存在关联...",
    "rejected": "量子纠缠就是很神奇的现象"
}

# 拼接(注意:不是作为label,而是作为input)
chosen_input = tokenizer(batch["prompt"] + batch["chosen"])
rejected_input = tokenizer(batch["prompt"] + batch["rejected"])

# ========== Step 2: 计算每个token的log概率 ==========
def compute_log_prob(model, input_ids, prompt_length):
    # 前向传播
    outputs = model(input_ids)
    logits = outputs.logits  # [batch, seq_len, vocab_size]

    # 只关注response部分(跳过prompt)
    response_logits = logits[:, prompt_length-1:-1, :]
    response_labels = input_ids[:, prompt_length:]

    # 计算每个token的log概率
    log_probs = F.log_softmax(response_logits, dim=-1)

    # 选出实际生成的token的log概率
    token_log_probs = torch.gather(
        log_probs, 
        dim=-1, 
        index=response_labels.unsqueeze(-1)
    ).squeeze(-1)

    # 整个response的log概率 = 所有token log概率之和
    sequence_log_prob = token_log_probs.sum(dim=-1)

    return sequence_log_prob

# ========== Step 3: 计算policy和reference的log概率 ==========
# Policy model(正在训练的)
policy_chosen_logprob = compute_log_prob(
    policy_model, chosen_input_ids, prompt_length
)
policy_rejected_logprob = compute_log_prob(
    policy_model, rejected_input_ids, prompt_length
)

# Reference model(固定不变的)
with torch.no_grad():  # 不需要梯度!
    ref_chosen_logprob = compute_log_prob(
        reference_model, chosen_input_ids, prompt_length
    )
    ref_rejected_logprob = compute_log_prob(
        reference_model, rejected_input_ids, prompt_length
    )

# ========== Step 4: 计算DPO损失 ==========
beta = 0.1

# 计算相对对数比率
policy_logratios = policy_chosen_logprob - policy_rejected_logprob
ref_logratios = ref_chosen_logprob - ref_rejected_logprob

# DPO损失:让chosen的相对概率更高
logits = beta * (policy_logratios - ref_logratios)
loss = -F.logsigmoid(logits).mean()

# ========== Step 5: 反向传播 ==========
loss.backward()  # 梯度只回传到policy_model!
optimizer.step()

8.3 梯度流动

loss → logits → policy_logratios 
             → policy_chosen_logprob, policy_rejected_logprob
             → policy_model的每个token的logits
             → policy_model的参数 ✅ 被更新

reference_model没有梯度 ❌ 不更新

8.4 与SFT的对比

# ========== SFT: Token-level的梯度 ==========
Input:  "天空是"
Target: "天空是蓝色的"

Position 0: Loss(predict="天" | input="")
Position 1: Loss(predict="空" | input="天")
Position 2: Loss(predict="是" | input="天空")
Position 3: Loss(predict="蓝" | input="天空是")
# → 每个token独立计算loss

# ========== DPO: Sequence-level的梯度 ==========
Chosen:   "天空是蓝色的"
Rejected: "天空是绿色的"

# 计算整个序列的log概率差
log_prob_chosen = Σ log P(token_i | previous)
log_prob_rejected = Σ log P(token_i | previous)

# Loss让chosen的概率相对更高
loss = -sigmoid(β * (log_prob_chosen - log_prob_rejected - ...))
# → 梯度回传到影响整个序列概率的参数

9. Reference Model的更新策略

困惑点:Reference Model是否每次更新后,上一步的policy成为下一步的reference?

9.1 正确答案:Reference Model在整个训练中保持固定!

❌ 错误理解

# 每次更新后,policy变成reference?
# 不是这样的!

✅ 正确理解

# Reference model在整个DPO/PPO训练中保持固定
# 始终是训练开始时的SFT模型

9.2 标准做法

# ========== DPO训练的完整流程 ==========

# 初始化(只在开始时做一次)
policy_model = load_model("llama-7b-sft")      # 会被更新
reference_model = load_model("llama-7b-sft")   # 克隆一份
reference_model.eval()                          # 设为评估模式
for param in reference_model.parameters():
    param.requires_grad = False                 # 冻结所有参数

# 训练循环
for epoch in range(num_epochs):
    for batch in dataloader:
        # Step 1: policy_model前向传播(需要梯度)
        policy_logprobs = compute_log_prob(policy_model, ...)

        # Step 2: reference_model前向传播(不需要梯度)
        with torch.no_grad():
            ref_logprobs = compute_log_prob(reference_model, ...)

        # Step 3: 计算loss并更新policy_model
        loss = dpo_loss(policy_logprobs, ref_logprobs)
        loss.backward()
        optimizer.step()  # ✅ 只更新policy_model

        # reference_model始终保持不变!❌ 不更新

9.3 为什么reference_model保持固定?

原因1:提供稳定的"锚点"

如果reference_model也跟着更新:

Iteration 1:
policy: 远离初始点
reference: 也远离初始点
→ 失去了"初始分布"的参考

Iteration 1000:
policy: 可能已经偏离很远
reference: 也偏离了
→ KL惩罚失效,可能reward hacking

原因2:防止模型崩溃

# 如果reference也更新(错误做法)
beta * (log(π_policy(chosen)) - log(π_policy(rejected))
      - log(π_ref(chosen)) + log(π_ref(rejected)))

# 如果policy和ref都朝一个方向更新,会导致:
# π_policy(chosen)/π_ref(chosen) → 1
# π_policy(rejected)/π_ref(rejected) → 1
# → 损失变为0,没有训练信号!

9.4 不常见的变体:定期更新reference

# ========== 动态更新reference(较少用)==========

# Iteration 1-100:
reference_model = initial_policy  # 固定

# Iteration 101-200:
reference_model = copy(policy_model_at_100)  # 更新一次
# 之后再固定100步

# Iteration 201-300:
reference_model = copy(policy_model_at_200)
# ...

# 这种做法的理由:
# - 避免policy偏离太远
# - 渐进式优化

# 但问题:
# - 容易过拟合
# - 失去初始分布的约束
# - 训练不稳定

10. 概率链式法则 vs Policy Gradient

困惑点:为什么求所有token log概率之和?这是概率链式法则还是Policy Gradient?

10.1 两者是不同层次的概念

概率链式法则 = 数学工具(概率论)
Policy Gradient = 优化方法(强化学习)

关系:Policy Gradient在计算时使用链式法则

10.2 概率链式法则(Probability Chain Rule)

# ========== 纯粹的概率论 ==========

# 定义:联合概率的分解
P(A, B, C) = P(A) · P(B|A) · P(C|A,B)

# 在文本生成中:
P("吃苹果") = P() · P(|) · P(|吃苹)

# 取对数:
log P("吃苹果") = log P() + log P(|) + log P(|吃苹)
                 = Σ log P(token_i | previous_tokens)

# 这是概率论的数学事实,与机器学习无关

为什么用求和? - 这不是人为选择,而是概率论的必然结果 - 整个序列的概率 = 各个token概率的乘积 - 取对数后变成求和(避免数值下溢)

10.3 Policy Gradient(策略梯度)

# ========== 强化学习的优化方法 ==========

# 目标:最大化期望回报
J(θ) = E_τ~π_θ[R(τ)]
     = Σ_τ π_θ(τ) · R(τ)  # 对所有可能轨迹求期望

# Policy Gradient Theorem(核心定理):
 J(θ) = E_τ~π_θ[R(τ) ·  log π_θ(τ)]

# 这是强化学习的优化理论

10.4 两者如何结合

# ========== Policy Gradient需要计算 log π(τ) ==========

# 对于文本生成,τ = (token_1, token_2, ..., token_n)

# 这里用到概率链式法则:
log π_θ(τ) = log π_θ(token_1, token_2, ..., token_n)
           = log π_θ(token_1) 
             + log π_θ(token_2|token_1)
             + log π_θ(token_3|token_1,token_2)
             + ...
           = Σ_i log π_θ(token_i | token_<i)  # 链式法则!

# 所以梯度:
 log π_θ(τ) = Σ_i  log π_θ(token_i | token_<i)

# 最终的Policy Gradient:
 J(θ) = E[R(τ) · Σ_i  log π_θ(token_i | token_<i)]

10.5 形象比喻

概率链式法则 = "计算器"
- 告诉你如何计算P(整个序列)
- 数学工具

Policy Gradient = "优化策略"
- 告诉你如何调整参数让期望回报最大
- 优化目标

Policy Gradient在计算时使用链式法则
- 就像用计算器来执行优化策略

10.6 为什么求和不会"牺牲自回归能力"?

误解:求和会让模型失去逐token生成的能力

正确理解

# ========== 自回归能力不仅没有损失,反而强化了 ==========

# 训练前:
P(好的回答) = 0.001  # log P = -6.9
P(差的回答) = 0.01   # log P = -4.6

# RL训练:优化整个序列的概率
# → 模型学会:在自回归的每一步,都倾向于选择能导向"好回答"的token

# 训练后:
P(好的回答) = 0.01   # log P = -4.6 ✅ 提高了
P(差的回答) = 0.001  # log P = -6.9 ✅ 降低了

# 自回归能力没有损失,反而学会了"长期规划":
# - 在生成第1个token时,就考虑整个回答的质量
# - 避免生成"局部最优但全局差"的token

具体例子

# 场景:解数学题
Question: "5 + 7 = ?"

# Response 1(推理清晰):
"让我计算一下:5 + 7 = 12"
# Reward: 10.0(正确且有推理)

# Response 2(直接答案):
"12"
# Reward: 8.0(正确但无推理)

# RL训练目标:
# 让 Response 1 的整体概率提高
# → 模型学会:生成"让"时,要考虑后续能形成完整推理
# → 这需要考虑整个序列,而不是单个token

11. DPO对表达多样性的影响

困惑点:DPO这种训练模式会让LLM失去多样的表达能力吗?

11.1 是的,这是一个真实存在的问题!

# ========== DPO的多样性问题 ==========

Question: "描述一下春天"

# 训练数据中的 chosen response:
chosen = "春天是万物复苏的季节,鸟语花香,生机勃勃。"

# 其他同样好的表达(但不在训练数据中):
alternative_1 = "春暖花开,大地回春,处处洋溢着新生的气息。"
alternative_2 = "春风拂面,柳绿花红,是一年中最美好的时光。"

# ========== DPO训练后会发生什么? ==========

# 训练前:
P(chosen) = 0.01
P(alternative_1) = 0.01  # 概率相似
P(alternative_2) = 0.01

# DPO训练后:
P(chosen) = 0.15         # ✅ 显著提高
P(alternative_1) = 0.005 # ❌ 可能降低!
P(alternative_2) = 0.004 # ❌ 可能降低!

# 问题:虽然chosen的质量提高了,但多样性丧失了

11.2 为什么会这样?

# ========== DPO优化的目标 ==========

# DPO鼓励:
# 1. 提高 chosen 的概率 ↑↑  (直接优化)
# 2. 降低 rejected 的概率 ↓↓ (直接优化)

# 但没有机制鼓励:
# 3. 保持 alternatives 的概率

# 结果(概率归一化):
# - chosen ↑↑
# - rejected ↓↓
# - alternatives ↓ (间接受影响)

11.3 实际例子:多样性崩溃

# ========== 训练进展 ==========

# Epoch 0 (初始SFT模型)
>>> generate("推荐一本书", temperature=0.8, num_samples=5)
[
    "推荐《三体》,这是刘慈欣的科幻巨著...",
    "我建议你读一读《百年孤独》...",
    "《人类简史》是一本很好的历史书...",
    "如果喜欢推理,可以试试《白夜行》...",
    "《活着》是余华的代表作,非常感人..."
]
# → 5个不同的回答 ✅

# Epoch 5 (DPO训练后)
>>> generate("推荐一本书", temperature=0.8, num_samples=5)
[
    "推荐《三体》,这是刘慈欣的科幻巨著...",
    "推荐《三体》,这是刘慈欣的科幻巨著...",
    "推荐《三体》,刘慈欣的科幻小说...",
    "推荐《三体》,这是刘慈欣的科幻巨著...",
    "我推荐《三体》,这是刘慈欣的..."
]
# → 都是三体!模式崩溃 ❌

11.4 缓解机制(部分有效)

机制1:Reference Model的KL约束

# KL散度的作用:
KL(π_policy || π_ref) = E[log(π_policy(y)/π_ref(y))]

# 如果 π_policy 过度拟合某个回答:
# π_policy("三体...") = 0.9
# π_ref("三体...") = 0.01
# → KL = log(0.9/0.01) = 4.5  # 非常大!

# DPO loss 会惩罚这种过度集中
# → 一定程度上保持多样性

# 但为什么不完美?
# 因为KL约束是"全局"的,不是"针对alternative"的

机制2:β参数的trade-off

# β = 0.01 (小β):
# → 对齐效果好,但多样性丧失严重

# β = 1.0 (大β):
# → 保持多样性好,但对齐效果差

# 实践中的选择:β ≈ 0.1 ~ 0.5
# → 在对齐和多样性之间平衡

11.5 工业界的解决方案

方案1:高质量、多样化的偏好数据

# ❌ 差的偏好数据:
[
    {"prompt": "推荐书", "chosen": "推荐《三体》...", "rejected": "随便看看"},
    {"prompt": "推荐书", "chosen": "推荐《三体》...", "rejected": "不知道"},
]
# → 所有chosen都是三体

# ✅ 好的偏好数据:
[
    {"prompt": "推荐书", "chosen": "推荐《三体》...", "rejected": "随便看看"},
    {"prompt": "推荐书", "chosen": "推荐《百年孤独》...", "rejected": "不知道"},
    {"prompt": "推荐书", "chosen": "推荐《人类简史》...", "rejected": "我不确定"},
    {"prompt": "推荐科幻书", "chosen": "推荐《三体》...", "rejected": "随便"},
]
# → 多样的chosen,模型学会"根据情况选择"

方案2:混合训练目标

def mixed_loss(policy_model, batch):
    # DPO损失(对齐)
    dpo_loss = compute_dpo_loss(policy_model, batch)

    # SFT损失(保持多样性)
    sft_loss = compute_sft_loss(policy_model, diverse_responses)

    # 组合
    total_loss = dpo_loss + 0.1 * sft_loss

    return total_loss

方案3:GRPO的优势

# DPO:只比较1对(chosen, rejected)
# → 容易过拟合chosen

# GRPO:比较一组responses
responses = [
    "推荐《三体》,科幻巨著...",     # reward: 9.0
    "推荐《百年孤独》,文学经典...", # reward: 8.5
    "推荐《人类简史》,历史佳作...", # reward: 8.0
    "随便看看",                       # reward: 2.0
]

# GRPO优势:根据相对质量调整,保持多样性
advantages = normalize(rewards)
loss = -Σ advantages[i] * log P(response[i])

11.6 代码 vs 自然语言的差异

# ========== 代码生成:DPO效果好 ✅ ==========
Question: "写一个快速排序"
# 正确的实现就那么几种,多样性不重要
# → DPO效果excellent!

# ========== 自然语言:DPO可能受限 ⚠️ ==========
Question: "描述春天"
# 有无数种优美的表达
# → DPO可能过度拟合一种表达

# ========== 解决方案 ==========
# 对于代码/JSON:aggressive DPO
beta_code = 0.1  # 小β,强对齐

# 对于创意写作:保守DPO或其他方法
beta_creative = 0.5  # 大β,保持多样性

12. 工业界应用案例

关键问题:工业界是否有通过RL在特定领域超越顶级闭源模型的案例?

12.1 案例1:OpenAI o1/o3系列

数学领域的突破

Benchmark 传统GPT-4 o3 (RL训练) 提升
AIME 2024(数学竞赛) ~50% 96.7% 接近2倍
MATH ~52% 显著提升 -
Frontier Math 2% 25% 12.5倍

编程领域的突破

Benchmark GPT-4o o3 (RL训练) 提升
Codeforces评分 808 ELO 2727 ELO 超过3倍
SWE-bench Verified o1: 48.9% 71.7% 46%提升
IOI 2024 - 第49百分位 -

科学推理

  • GPQA Diamond(博士级科学问题):o1超越人类博士专家
  • o3: 87.7%准确率

关键技术

"OpenAI发现在强化学习训练期间增加计算预算能够提高模型性能,类似于监督预训练中的规模化行为,但这次是通过最大化强化学习奖励来优化。"

12.2 案例2:DeepSeek-Math/R1(开源挑战闭源)

DeepSeek-Math的成就

  • MATH benchmark: 51.7%(7B模型,接近GPT-4/Gemini-Ultra)
  • 通过GRPO强化学习的提升
  • GSM8K:82.9% → 88.2%
  • MATH:46.8% → 51.7%
  • CMATH(中文):84.6% → 88.8%

DeepSeek-R1的突破

  • 性能对标OpenAI o1-1217
  • MATH-500: 97.3% Pass@1(与OpenAI o1相当)
  • 关键创新

    "DeepSeek-R1通过纯强化学习展示了语言模型的推理能力可以被激励,无需人类标注的推理轨迹。"

自然涌现的能力

DeepSeek-R1-Zero(纯RL训练)自然学会了: - 长推理链生成 - 自我验证(cross-check答案) - 自我纠错 - 动态策略适应

12.3 为什么RL能在特定领域超越?

原因1:可验证的奖励信号

# 数学题:答案是对还是错 → 完美的reward
def math_reward(answer, ground_truth):
    return 1.0 if answer == ground_truth else 0.0

# 编程题:代码能否通过测试用例 → 明确的reward
def code_reward(code, test_cases):
    passed = run_tests(code, test_cases)
    return len(passed) / len(test_cases)

原因2:超越人类思维的限制

传统SFT:只能学到人类的思考方式(天花板)
RL:可以探索出人类不会用的解题方法

原因3:持续自我进化

RL框架促进了高级推理模式的自然涌现:
- 自我反思
- 验证
- 动态策略适应

12.4 实际应用场景

  1. 数学教育辅导
  2. 能给出step-by-step的解题过程
  3. 超过普通教师的覆盖范围

  4. 代码助手

  5. GitHub Copilot、Cursor等
  6. 能解决LeetCode Hard级别问题

  7. 科研辅助

  8. 博士级别的科学问题解答
  9. 辅助论文写作和实验设计

  10. 竞赛级编程

  11. IOI、Codeforces等竞赛
  12. 达到人类程序员前50%水平

12.5 局限性

仅限可验证任务

  • ✅ 适用:数学、编程、棋类、游戏
  • ❌ 不适用:创意写作、开放性问答、主观评价

计算成本极高

  • o3训练:需要数千块GPU,数周时间
  • o3推理:据传每个任务高达$30,000(最高配置)

过度优化问题

"虽然模型在完成任务方面极其有效,但还没有可扩展的方法来修复模型的奇怪语言表达。"


13. 实践建议

13.1 选择合适的算法

# 决策树:
if 任务有明确的对错标准:
    if 资源充足:
        使用PPO/GRPO可能效果略好
    else:
        使用DPO更简单稳定
elif 任务需要主观评价:
    if 有大量偏好数据:
        使用DPO
    else:
        使用LLM-as-a-Judge + GRPO
else:
    继续SFT慎用RL

13.2 数据质量至关重要

# ✅ 高质量偏好数据的特征:
1. 多样性chosen response要覆盖不同风格
2. 明确性chosen和rejected的差异要明显
3. 一致性相似问题的偏好要一致
4. 数量足够的数据量建议>10k对

# ❌ 避免:
1. 所有chosen都是同一个答案
2. chosen和rejected差异不明显
3. 偏好标准不一致

13.3 训练技巧

# 1. 超参数设置
beta = 0.1  # DPO: 代码任务可以更小(0.05),创意任务更大(0.5)
learning_rate = 2e-4  # LoRA可以更大,全参数更小(1e-6)

# 2. 监控指标
- KL散度不要超过5.0
- Reward平均值应该持续增加
- 生成多样性定期采样检查

# 3. 早停策略
if validation_reward下降 or KL过大:
    停止训练使用前一个checkpoint

13.4 避免常见错误

# ❌ 错误1:Reference model也在更新
# ✅ 正确:Reference model保持固定

# ❌ 错误2:只用一对偏好数据训练
# ✅ 正确:使用多样化的大规模数据

# ❌ 错误3:β设置过小导致模式崩溃
# ✅ 正确:从较大的β开始,逐渐调小

# ❌ 错误4:忽略KL散度监控
# ✅ 正确:实时监控KL,设置阈值

13.5 你的GRPO代码改进建议

# 当前代码的问题:
def compute_grpo_loss_simple(model, tokenizer, experience):
    outputs = model(**inputs, labels=inputs['input_ids'])
    loss = outputs.loss  # ⚠️ 这是SFT的loss
    weighted_loss = loss * (1.0 - reward / 10.0)  # ⚠️ 不够准确

# 改进建议:
def compute_grpo_loss_proper(model, tokenizer, experience):
    # 1. 手动计算log概率(不用labels)
    logits = model(**inputs).logits
    log_probs = compute_sequence_log_prob(logits, completion_tokens)

    # 2. 计算group相对advantage
    advantages = (rewards - rewards.mean()) / (rewards.std() + 1e-8)

    # 3. GRPO损失
    loss = -(log_probs * advantages).mean()

    return loss

14. 关键洞察总结

14.1 概念层面

  1. RLHF是框架,PPO/DPO是实现
  2. 不要混淆层次关系

  3. DPO是PPO的简化,但不是完全替代

  4. 各有优劣,根据场景选择

  5. Reference Model是"锚点",不是"老师"

  6. 防止分布偏移,不是提供知识

14.2 数学层面

  1. 求和是概率论的必然,不是工程选择
  2. log P(sequence) = Σ log P(token)

  3. Policy Gradient ≠ 概率链式法则

  4. 一个是优化方法,一个是计算工具

  5. DPO不用label,用的是相对概率

  6. 核心是让chosen的相对概率更高

14.3 实践层面

  1. 数据质量 > 算法选择
  2. 多样化、高质量的偏好数据是关键

  3. 监控KL散度,防止崩溃

  4. KL > 5.0 要警惕

  5. 不同任务需要不同策略

  6. 代码:aggressive DPO
  7. 创意:conservative或混合方法

  8. RL适合可验证任务

  9. 数学、编程效果显著
  10. 开放性任务要谨慎

15. 进一步学习资源

论文

  1. PPO: Proximal Policy Optimization Algorithms
  2. DPO: Direct Preference Optimization
  3. GRPO: DeepSeekMath: Pushing the Limits
  4. DeepSeek-R1: Incentivizing Reasoning Capability

实现参考

# DPO实现:
# https://github.com/huggingface/trl

# GRPO实现:
# https://github.com/deepseek-ai/DeepSeek-Math

# OpenAI的RLHF论文:
# InstructGPT: Training language models to follow instructions

工具

  • Hugging Face TRL: 提供DPO、PPO实现
  • Unsloth: 高效的LoRA训练
  • vLLM: 高效的推理引擎

附录:代码模板

DPO训练模板

from transformers import AutoModelForCausalLM, AutoTokenizer
import torch
import torch.nn.functional as F

def compute_dpo_loss(policy_model, reference_model, batch, beta=0.1):
    """完整的DPO损失计算"""

    # 准备输入
    prompt = batch['prompt']
    chosen = batch['chosen']
    rejected = batch['rejected']

    chosen_input = tokenizer(prompt + chosen, return_tensors="pt")
    rejected_input = tokenizer(prompt + rejected, return_tensors="pt")

    # Policy model log概率
    policy_chosen_logprob = compute_log_prob(
        policy_model, chosen_input, len(tokenizer(prompt)['input_ids'])
    )
    policy_rejected_logprob = compute_log_prob(
        policy_model, rejected_input, len(tokenizer(prompt)['input_ids'])
    )

    # Reference model log概率
    with torch.no_grad():
        ref_chosen_logprob = compute_log_prob(
            reference_model, chosen_input, len(tokenizer(prompt)['input_ids'])
        )
        ref_rejected_logprob = compute_log_prob(
            reference_model, rejected_input, len(tokenizer(prompt)['input_ids'])
        )

    # DPO损失
    policy_logratios = policy_chosen_logprob - policy_rejected_logprob
    ref_logratios = ref_chosen_logprob - ref_rejected_logprob

    logits = beta * (policy_logratios - ref_logratios)
    loss = -F.logsigmoid(logits).mean()

    return loss

def compute_log_prob(model, input_ids, prompt_length):
    """计算序列的log概率"""
    outputs = model(input_ids['input_ids'])
    logits = outputs.logits

    # 只关注response部分
    response_logits = logits[:, prompt_length-1:-1, :]
    response_labels = input_ids['input_ids'][:, prompt_length:]

    # 计算log概率
    log_probs = F.log_softmax(response_logits, dim=-1)
    token_log_probs = torch.gather(
        log_probs, dim=-1, index=response_labels.unsqueeze(-1)
    ).squeeze(-1)

    return token_log_probs.sum(dim=-1)

GRPO训练模板

def compute_grpo_loss(model, experience, reference_model=None, beta=0.1):
    """GRPO损失计算"""

    prompts = experience['prompts']
    completions = experience['completions']
    rewards = experience['rewards']

    total_loss = 0.0

    for i, prompt in enumerate(prompts):
        log_probs = []

        # 计算每个completion的log概率
        for j, completion in enumerate(completions[i]):
            full_text = tokenizer.apply_chat_template(
                prompt + completion, tokenize=False
            )
            inputs = tokenizer(full_text, return_tensors="pt")

            log_prob = compute_log_prob(
                model, inputs, len(tokenizer(prompt)['input_ids'])
            )
            log_probs.append(log_prob)

        log_probs_tensor = torch.stack(log_probs)
        rewards_tensor = torch.tensor(rewards[i])

        # Group相对advantage
        advantages = (rewards_tensor - rewards_tensor.mean()) / \
                     (rewards_tensor.std() + 1e-8)

        # GRPO损失
        loss = -(log_probs_tensor * advantages).mean()

        # 可选:添加KL惩罚
        if reference_model is not None:
            with torch.no_grad():
                ref_log_probs = [compute_log_prob(
                    reference_model, inputs, prompt_len
                ) for inputs in all_inputs]
            kl = (log_probs_tensor - torch.stack(ref_log_probs)).mean()
            loss += beta * kl

        total_loss += loss

    return total_loss / len(prompts)

结语

从PPO到DPO,从困惑到理解,这份笔记记录了强化学习在大语言模型训练中的核心原理。关键要点:

  1. 理解概念层次:框架vs算法vs方法
  2. 掌握数学原理:概率链式法则、Policy Gradient、DPO损失
  3. 把握实践技巧:数据质量、超参数、监控指标
  4. 认识应用场景:可验证任务效果显著,开放性任务需谨慎

希望这份笔记能帮助你在LLM强化学习的道路上走得更远!🚀


最后更新: 2025-01-09 相关代码: 见附录代码模板

Thanks for Reading

If this article was helpful to you, feel free to connect with me!