前言:我们为什么要聊 DPO?
在训练大语言模型(LLM)时,我们不仅希望它能"回答问题",更希望它的回答符合人类的偏好------比如更有帮助、更安全、更简洁。
最早的主流方法是 RLHF(基于人类反馈的强化学习) ,它效果好,但极其复杂。而 DPO(Direct Preference Optimization,直接偏好优化) 的出现,就像是在说:"为什么要搞那么复杂?我们直接一点不好吗?"
这份笔记会从最基础的动机开始,一步步深入 DPO 的数学原理、代码实现和各种进阶技巧。我们会用大量的比喻和具体例子,把那些看起来很吓人的公式掰开揉碎了讲。
第一部分:动机 ------ 为什么需要 DPO?
1.1 传统方法 RLHF 的烦恼
在 DPO 出现之前,让 LLM 对齐人类偏好,最主流的方法是 RLHF。它的流程大致如下:
-
训练一个奖励模型(Reward Model, RM):这个模型就像一个"评委",专门给 LLM 生成的回答打分。
-
用强化学习(如 PPO)微调 LLM:LLM 像是一个"学生",不断尝试生成答案,然后让"评委"打分。学生根据分数的高低,用复杂的强化学习算法来调整自己的策略,以争取更高的分数。
RLHF 的痛点:
-
资源消耗巨大:需要同时加载并训练多个模型(策略模型、参考模型、奖励模型、价值网络等),对显存和算力都是极大的考验。
-
训练极不稳定:强化学习本身就是一个以"不稳定"著称的领域,超参数敏感,训练过程容易崩溃。
1.2 DPO 的"直球"思想
DPO 的作者提出了一个大胆的想法:能不能跳过训练"评委"(奖励模型)和使用"强化学习"这些复杂的中间步骤,直接用人类标注的"好/坏"答案来训练模型?
这就好比:
-
RLHF:老师先教学生什么是"好"的标准(训练奖励模型),然后让学生自己反复做题,老师再打分(强化学习)。
-
DPO:老师直接把"优秀范文"和"错误示例"摆在学生面前,让学生自己对比着学。
DPO 的核心思想就是:把"教标准"和"让学生学"合二为一。
第二部分:核心机制 ------ DPO 是如何工作的?
DPO 能做到这一点,靠的是一套漂亮的数学推导。别怕,我们一步步来。
2.1 第一步:RLHF 的优化目标
RLHF 的终极目标,可以写成下面这个数学公式:
目标 = 让模型生成的回答获得尽可能高的奖励 - β × KL散度惩罚项
-
高奖励:希望模型生成的回答,能让"评委"打高分。
-
KL散度惩罚项 :这是一个"紧箍咒",防止模型为了拿高分而变得"面目全非"。它强制要求当前模型(
πθ)在更新时,不能和原始的参考模型(π_ref)偏离太远。β这个参数就是用来控制这个惩罚力度的。
2.2 第二步:一个关键的数学"魔法"
通过一系列数学推导,DPO 的作者发现了一个关键结论:对于上述 RLHF 的目标函数,其最优策略(即最完美的模型)有一个可以直接写出来的"闭式解"。
简单说,就是我们可以直接算出"最理想的模型长什么样"。它长这样:
最优模型 ∝ 参考模型 × exp(奖励 / β)
这个公式告诉我们:一个理想的模型,就是在参考模型的基础上,对能获得高奖励的回答给予更高的"权重"。
2.3 第三步:隐式奖励 ------ 把"评委"藏在模型里
既然最优模型和奖励有这样一个明确的数学关系,那我们就可以把这个公式反过来用 。也就是说,我们可以从一个模型中,"反推"出它内在的、隐式的奖励打分。
这个"隐式奖励" r 的计算方法就是看当前模型 和参考模型对于一个回答的概率比值:
隐式奖励 = β × log(当前模型生成这个回答的概率 / 参考模型生成这个回答的概率)
换个说法就是:如果一个回答在当前模型看来,比在参考模型看来"更顺眼"(概率更高),那它就获得了正的隐式奖励。反之,则是负的。
到这里,最神奇的事情发生了:我们不再需要一个独立的"评委"模型了! 每个 LLM 自己就是一个隐式的"评委"。
2.4 第四步:最终的 DPO 损失函数
现在,我们有了"隐式奖励",就可以直接把它代入到用来比较两个回答好坏的 Bradley-Terry(BT)偏好模型中。
BT 模型的作用就是计算"回答 A 比回答 B 好"的概率。而 DPO 的最终损失函数(Loss),就是让模型最大化"好回答"获得比"坏回答"更高隐式奖励的概率。
直观理解就是,DPO 的 Loss 在干这样一件事:
不断地调整模型的参数,使得"好回答"的隐式奖励,总是比"坏回答"的隐式奖励高出一截。
第三部分:深入细节 ------ DPO 是怎么训练的?
这一部分,我们深入到代码和数据的层面,看看 DPO 的训练到底是怎么一回事。
3.1 训练数据长什么样?
DPO 的训练数据非常直观,就是一堆"偏好对"(Preference Pairs)。每一条数据包含三个部分:
-
Prompt(提示词):比如"解释一下什么是量子纠缠"。
-
Chosen( chosen/好回答):一个高质量、符合人类偏好的回答。
-
Rejected( rejected/坏回答):一个质量较低、不符合偏好的回答。
3.2 模型是怎么"看"数据的?
在训练时,模型并不是像我们聊天那样,一个字一个字地"生成"回答。相反,它像一位严格的"阅卷老师",拿到了两份"标准答案"。
对于同一个 Prompt,模型会进行两次独立的前向传播(Forward Pass):
-
第一次 :读入
[Prompt + Chosen]这个完整的序列。 -
第二次 :读入
[Prompt + Rejected]这个完整的序列。
注意:模型在这里不做任何"生成"动作,它只是在"阅读"已经写好的完整答案。
3.3 如何计算一个"句子"的概率?
这是理解 DPO 的关键。LLM 的本质是"预测下一个词",但我们可以通过一个简单的操作,得到它对整个句子的评价。
当我们把 [Prompt + Answer] 整个喂给模型时,模型会在Answer 的每一个位置,都计算一次"预测下一个词"的概率。比如,对于"量子纠缠是..."这个回答:
-
看到"量子"后,预测"纠缠"的概率。
-
看到"量子纠缠"后,预测"是"的概率。
-
...以此类推。
然后,我们把回答部分(Answer) 所有位置上这些概率的对数值(log)加起来。这个总和,就是模型认为"生成这个完整句子"的(对数)概率。
-
关键点1:只计算回答部分。Prompt 部分的概率会被"掩码(Mask)"掉,不参与计算。
-
关键点2:用的是"求和"而非"平均"。因为我们计算的是整个序列的联合概率,从数学上讲,联合概率是每个词概率的乘积,取对数后就是求和。
3.4 计算 Loss 和反向传播
现在,我们有了四个关键的数值:
-
log P_chosen:当前模型生成好回答的(对数)概率。 -
log P_rejected:当前模型生成坏回答的(对数)概率。 -
log P_ref_chosen:参考模型生成好回答的(对数)概率。 -
log P_ref_rejected:参考模型生成坏回答的(对数)概率。
计算隐式奖励:
-
Reward_chosen = β * (log P_chosen - log P_ref_chosen) -
Reward_rejected = β * (log P_rejected - log P_ref_rejected)
计算 Loss :
Loss 的目标是让 Reward_chosen 尽可能大于 Reward_rejected。具体的 Loss 公式会用到 sigmoid 函数,但我们可以把它理解为一个"差距越大,Loss 越小"的函数。
反向传播 :
最后,计算出的 Loss 会进行反向传播。这里有一个非常重要的细节:梯度会同时传递到好回答和坏回答的每一个词上。
-
好回答里所有词 的生成概率会被提升。
-
坏回答里所有词 的生成概率会被降低。
-
而且,整个句子的提升/降低幅度是由同一个"缩放因子"控制的。这个因子的大小,取决于当前模型区分好坏的能力。能力越差(分不清),因子越大,学习越激进;能力越好,因子越小,学习越保守。
3.5 一个具体的例子
让我们用一个简单的例子来走一遍流程:
-
Prompt: "一加一等于"
-
Chosen: "二"
-
Rejected: "三"
-
前向传播:
-
模型读入"一加一等于二",计算在"等于"之后预测"二"的概率,得到
log P(好) = -1.2。 -
模型读入"一加一等于三",计算在"等于"之后预测"三"的概率,得到
log P(坏) = -0.5。 -
(为简化,这里忽略参考模型的计算)
-
-
分析:
- 模型给"三"的概率(-0.5)居然高于给"二"的概率(-1.2)!这意味着模型认为"一加一等于三"更合理,这显然是错的。
-
计算 Loss 和反向传播:
-
Loss 会变得很大。
-
梯度会猛烈地"敲打"模型,让模型下次看到"一加一等于"时,预测"二"的概率大幅提升(比如从 -1.2 提升到 -0.1),同时预测"三"的概率大幅降低(比如从 -0.5 降低到 -2.0)。
-
第四部分:工程实践 ------ 如何训练一个 DPO 模型?
理论说完了,我们来看看在实际操作中,如何用代码训练一个 DPO 模型。
4.1 配置与训练 (基于 TRL 库)
TRL (Transformer Reinforcement Learning) 库提供了非常方便的 DPOTrainer。一个典型的训练脚本如下:
python
from trl import DPOConfig, DPOTrainer
from transformers import AutoModelForCausalLM, AutoTokenizer
from peft import LoraConfig
from datasets import load_dataset
# 1. 加载模型和分词器
model = AutoModelForCausalLM.from_pretrained("meta-llama/Llama-3.1-8B-Instruct")
tokenizer = AutoTokenizer.from_pretrained("meta-llama/Llama-3.1-8B-Instruct")
# 2. 加载偏好数据集 (格式: prompt, chosen, rejected)
dataset = load_dataset("argilla/ultrafeedback-binarized-preferences")
# 3. (可选) 配置 LoRA,用于参数高效微调
peft_config = LoraConfig(
r=64,
lora_alpha=16,
target_modules=["q_proj", "k_proj", "v_proj", "o_proj"]
)
# 4. 配置 DPO 训练参数
dpo_config = DPOConfig(
output_dir="./dpo_output",
beta=0.1, # KL散度惩罚系数[reference:29]
learning_rate=5e-7, # 学习率要设低一点,保持稳定
per_device_train_batch_size=2,
gradient_accumulation_steps=8, # 梯度累积,模拟更大的批次
num_train_epochs=1, # DPO 很容易过拟合,通常 1 轮就够
bf16=True, # 使用 bf16 混合精度训练,省显存
max_length=2048,
max_prompt_length=1024,
)
# 5. 创建训练器并开始训练
trainer = DPOTrainer(
model=model,
ref_model=None, # 使用 LoRA 时可不提供,自动用基础模型
args=dpo_config,
train_dataset=dataset["train"],
tokenizer=tokenizer,
peft_config=peft_config,
)
trainer.train()
4.2 关键超参数:β (Beta)
beta 是 DPO 中最重要的超参数,它控制着模型偏离参考模型的程度。
-
beta越大:KL 散度惩罚越强,模型就越不敢偏离参考模型,训练更稳定,但提升可能有限。 -
beta越小:模型有更大自由度去迎合偏好数据,但风险也更高,可能"遗忘"之前的通用能力。
如何选择 beta?
-
0.1:是一个非常通用的起点,平衡了稳定性和对齐效果。 -
0.05-0.2:是在这个基础上常见的微调范围。 -
0.01:非常激进,只在数据质量极高且需要大幅改变模型行为时使用。
一个重要的反直觉点 :beta 并不是越小越好。当 beta 过小时,梯度本身也会变得极小,导致模型几乎学不动,陷入"有自由但没动力"的尴尬境地。
4.3 显存优化:预计算参考模型输出
在标准 DPO 中,你需要同时加载当前模型 和参考模型,显存占用直接翻倍。
一个极其有效的优化方法是:预计算并缓存参考模型的输出。
因为参考模型在训练过程中是完全冻结、一动不动 的,所以我们可以在训练开始前,就用它把整个数据集过一遍,计算出每一个 [Prompt + Chosen] 和 [Prompt + Rejected] 的 log 概率,并保存下来。
在训练时,我们只需要加载当前模型,直接从缓存中读取参考模型的概率值即可。这样,我们就可以把庞大的参考模型从显存中"踢"出去,显存占用直接减半,训练速度也能大幅提升。
在 DPOConfig 中,可以通过设置 precompute_ref_log_probs=True 来启用这个功能。
第五部分:进阶之路 ------ DPO 的变体们
标准 DPO 很强大,但并非万能。研究人员们针对它的各种不足,提出了许多改进版本。这里介绍几个最主流的:
5.1 RPO (Reasoning Preference Optimisation):解决"遗忘"问题
-
痛点 :标准 DPO 只关注"好回答"和"坏回答"的相对 概率。这可能导致虽然"好回答"比"坏回答"概率高了,但它们两者的绝对概率都在下降。模型可能"会挑错",但"不会写对",这在需要一步步推理的数学、代码等任务上是致命的。
-
解法 :RPO 在 DPO 的 Loss 上,额外加了一个标准的语言模型 Loss(即 SFT Loss),强制模型提高"好回答"本身的生成概率。
-
一句话总结:不仅要比别人强,自己也要变得更强。适合数学、代码等推理任务。
5.2 SimPO (Simple Preference Optimisation):彻底摆脱参考模型
-
痛点:标准 DPO 需要参考模型,增加了显存和计算开销。
-
解法 :SimPO 非常激进,它彻底抛弃了参考模型。
-
它的"隐式奖励"直接定义为:
β × (好回答的平均对数概率)。 -
这里的"平均"是为了防止模型通过生成"短回答"来作弊(因为短句子的总概率更高)。
-
-
一句话总结:简单粗暴,不要参考模型,直接看模型生成好/坏回答的平均概率。如果你显存紧张,SimPO 是一个不错的选择。
5.3 其他值得关注的变体
-
f-DPO:允许你使用不同的散度(如 Forward KL, JS 散度)来控制模型是"专精"还是"博采众长"。
-
Robust DPO:通过假设一个"标签噪声率",来减少数据中错误标注带来的负面影响。
-
TR-DPO (Trust Region DPO):定期用当前模型的"指数移动平均(EMA)"来更新参考模型,解决了训练后期参考模型"过时"的问题。
总结
-
DPO 是什么:一种直接使用人类偏好数据(好/坏回答对)来训练 LLM 的方法,跳过了 RLHF 中复杂的奖励模型和强化学习环节。
-
核心思想:通过数学推导,证明了 LLM 本身就可以作为一个"隐式的奖励模型",其隐式奖励由当前模型和参考模型对回答的概率比值决定。
-
如何训练 :将
[Prompt + Chosen]和[Prompt + Rejected]喂给模型,分别计算其对数概率,然后通过 Loss 函数拉大好回答和坏回答之间的隐式奖励差距。 -
关键参数 :
beta控制模型偏离参考模型的程度,0.1是常用的起点。 -
主要变体:RPO 解决遗忘问题,SimPO 摆脱参考模型,等等。