[LLM 4/10] DPO:当语言模型成为它自己的奖励模型
上一章我们用 PPO 做了 RLHF,你应该已经看到它有多少零件—— 要单独训练一个奖励模型,要同时把 4 个模型塞进显存, 还要调十几个 PPO 超参数;而一旦奖励模型跑偏,模型就会找到刷分的捷径。 这一章我们要用一个普通的 supervised 训练循环做同样的事——没有奖励模型,也没有 RL。 更关键的是,这并不是一种近似:我们会用代数证明那两块东西是真的互相抵消掉了。
Open in Colab04_dpo.ipynb
1. 问题(Problem statement)
假设你希望自己的 AI 助手"永远用泰语回答"——听起来很简单。 但你试着把它写成一个 loss function 看看,你会发现根本写不出来。
这就是 alignment 的核心难题:回答的质量没法写成公式。 "更礼貌""更自然""不要中途滑回英语"——这些都不存在唯一正确的标准答案, 有的只是比较:让人看两个回答,然后说更喜欢哪一个。 于是得到的数据长成三元组:prompt 、被选中的回答 (chosen)、被拒绝的回答 (rejected)。
PPO 版本的 RLHF 用两步绕路来解决这个问题:
- 训练一个奖励模型 去模仿人类的偏好
- 用 RL 把策略推向该奖励模型给分高的方向
这条绕路是有代价的:
| RLHF/PPO 的问题 | 实际造成的后果 |
|---|---|
| 要多训练 1 个模型 | 多一个环节、多一处可能出错、多花时间 |
| 要同时加载 4 个模型 | policy + ref + reward + value —— 显存爆炸 |
| reward hacking | 模型找到刷分的漏洞,而人类其实一点也没更喜欢 |
| PPO 对超参数敏感 | 换个 seed 跑两遍,结果可能完全不同 |
所以这一章的问题只有一句话:我们能不能把第 1 步和第 2 步直接跳过?
2. 我们要做什么(Solution)
答案是能,而且理由非常漂亮。
出发点是这样一个观察:带 KL 约束的 RLHF objective 存在闭式解(closed form)。 我们其实早就知道最优策略长什么样,一个 RL step 都不用跑。 既然如此,我们就把公式反过来写——与其问"这个 reward 对应什么策略", 不如问"这个策略意味着多少 reward"。
把公式一反过来,语言模型本身就已经隐含地是一个奖励模型。
奖励模型和 RL 循环并不是被"近似掉"的,而是在代数上互相消掉了。
剩下的只是一个普通的 supervised loss function,用一个 Trainer 就能训完。
这就是 DPO(Direct Preference Optimization),由 Rafailov 等人在 2023 年提出。 名字里的 "Direct" 指的是我们直接在偏好数据上做优化,中间不经过任何代理。
3. 公式(Equation)
3.1 先摆出题目:RLHF objective
用人话读一遍:"把 reward 拿到最高,但不许离初始模型太远。"
- = 策略,也就是我们正在训练的模型
- = 参考策略,也就是初始模型(本章指第 2 章 SFT 之后的模型)
- = 回答 对 prompt 的 reward
- = 这根牵引绳的紧度,越大就把模型往 拉得越狠
KL 那一项不是装饰。没有它,模型会跑向 reward 很高但语言已经崩坏的地方。
3.2 第一步——闭式解
上面这道题可以手推出来(本质是在求一个相对目标分布 KL 最小的分布),结果是
- 就是 partition function,负责把总和归一到 1 的分母
- 注意 只依赖 ,不依赖 ——把这句话记牢,它待会儿是主角
直觉上的含义是:最优策略就是原模型,按 重新加权。 reward 高的回答概率被放大,reward 低的被压低,但一切始终是从 原本的形状出发的。
实际上我们算不出 ,因为那要把全宇宙所有可能的回答都加起来。 这正是大家不得不用 RL 的原因——也正是 DPO 不需要用 RL 的原因。
3.3 第二步——把公式反解出 reward
两边取 log 再移项,得到
这一行就是关键:任何 reward 函数都可以改写成"最优策略与初始策略"的形式。 也就是说,只要手上有两个模型,我们立刻就能算出它隐含的 reward,完全不必训练任何奖励模型。
3.4 第三步——代入 Bradley-Terry, 抵消
偏好的标准建模方式是 Bradley-Terry:人类选择 而非 的概率为
其中 是 sigmoid。注意在这个式子里,reward 只以差值的形式出现。 把 3.3 代进去—— 在两边完全相等,因为 是同一个——于是它直接抵消掉了。
那个算不出来的东西()消失了,因为 Bradley-Terry 只关心 reward 的差值。 剩下的只是两个模型在我们本来就有的文本上的 log-probability,一次普通的 forward pass 就能算出来。 不采样回答、不做 rollout、没有 value function——DPO 是彻头彻尾的 supervised learning。
3.5 梯度——直觉所在之处
其中 被称为隐式奖励(implicit reward)。
一块一块地读:
- 右边的括号 = 方向:同时把 的 log-prob 推上去、把 的 log-prob 压下来
- = 权重,也就是"模型把这一对排错得有多离谱"
这个权重是全章最重要的教学点。如果模型本来就把这一对排对了( 明显大于 ), 那么 会趋近于零,这一对几乎不贡献任何梯度。 于是 DPO 会自动把注意力集中在自己犯的错上,不需要任何人替它筛数据。
4. 把公式画出来(Visualize)
Bradley-Terry:从 reward 差值到概率
Figure 4.1Bradley-Terry 把 reward 的差值变成人类会选 chosen 的概率——模型从不需要知道 reward 的绝对值,知道差值就够了
差值为零的那一点恰好给出概率 0.5,也就是"模型没有意见"。 又因为整条曲线只看差值,给两边的 reward 同时加上一个常数不会改变任何东西。 这就是 能被抵消掉的几何解释。
Loss 与梯度权重
令 , 则 loss 为 ,梯度权重为 。
Figure 4.2左:DPO loss 随 margin 的变化——右:在 β = 0.1、0.3、1.0 下该样本对在梯度中所占的权重
右边这一面板要仔细看:当 margin 很正的时候,权重会收敛到零——这一对"已经毕业了"。 而 越大,曲线越陡,也就是学得越快、"退学"也越早。 在 时,margin 超过 4 的样本对几乎不剩任何梯度。 而在 时曲线平缓得多,模型会持续从每一对里都收一点梯度——更慢,但更稳。
决定模型能离初始状态多远
Figure 4.3在一个只有 5 个回答的玩具例子上展示 π* ∝ π_ref · exp(r/β)——β 小会把概率全挤到 reward 最高的回答上,β 大则退回 π_ref 的原形
这张图从右往左读:在 时,几乎全部概率质量都塌到了 reward 最高的 上。 这就是 mode collapse——分数好看了,多样性却荡然无存。 而在 时,柱子几乎贴着虚线,等于什么都没学到。 不是那种"调到 loss 最低"的超参数,它是一个在服从偏好与保住自我之间选择交易点的旋钮。
自己拖动一下参数,看看 loss 和梯度的形状会怎么变:
Drag anywhere on the plot, or use the Δ slider with the arrow keys.
This pair carries real signal.σ(−βΔ) is 45.02%: the model is still wrong or unsure about this pair, so it dominates the batch gradient. Drag Δ to the right and watch that weight collapse.
5. 准备环境(Environment)
打开 Colab,选择 Runtime → Change runtime type → T4 GPU(免费额度够用)。
Colab 的 T4 是 Turing 架构(SM 7.5),它不支持 bfloat16,也不支持 FlashAttention-2。
但 Qwen3-0.6B 的 config.json 里写着 torch_dtype: bfloat16。
所以 torch_dtype="auto" 是个陷阱:代码会崩掉或者慢得离谱,而且不会告诉你原因。
torch_dtype=torch.float16 # 不是 bfloat16
attn_implementation="sdpa" # 不是 flash_attention_2
fp16=True # 在 DPOConfig 里(不是 bf16=True)
cap = torch.cuda.get_device_capability(0)
print("compute capability:", cap) # T4 = (7, 5)
print("native bf16:", cap[0] >= 8) # T4 -> False
print("torch says :", torch.cuda.is_bf16_supported()) # T4 -> True(把 emulation 也算上了!)
is_bf16_supported() 在 T4 上会骗你较新的 torch 在 T4 上返回 True,因为它把**模拟(emulation)**也算作支持——而模拟比 fp16 慢得多。
请改为判断 compute capability ≥ 8.0(Ampere 及以上)。这是真正在 Colab 上跑才发现的 bug。
一个完全不额外占显存的 reference model
DPO 需要同时用到 和 ,听上去像是要加载两个模型。 但我们是在第 2 章的 LoRA adapter 基础上继续做的,因此两者共享同一份底座权重。
from peft import PeftModel
base = AutoModelForCausalLM.from_pretrained(
"Qwen/Qwen3-0.6B",
torch_dtype=torch.float16,
attn_implementation="sdpa",
).cuda()
policy = PeftModel.from_pretrained(base, "kobkrit/qwen3-0.6b-th-sft-lora", is_trainable=True)
policy 就是 base + adapter,而 就是同一个 base,只不过把 adapter 关掉。
直接用 context manager policy.disable_adapter() 就行,不需要额外加载任何东西。
如果第 2 章我们做的是全参数微调,这一章就必须完整加载两份模型。 一开始就选择 LoRA,让 reference model 的显存开销是零字节。 这是一个架构层面的理由,而不只是训练时省点内存而已。
6. 准备数据(Data)
DPO 的数据只需要 3 列:prompt、chosen、rejected。
数据集 1 —— iapp/dpo_thai_tutorial(100 对,Apache-2.0)
这是我为这个系列亲手做的数据集,以 Apache-2.0 发布,欢迎拿去继续用。
它是人工挑选的泰语偏好对,重点关注礼貌程度和语言的自然度。
数据集 2 —— 自己造大约 400 对,源自 airesearch/wangchanx-seed-free-synthetic-instruct-thai-120k。
造法非常直白:
chosen= 数据集自带的泰语参考答案rejected= 基座模型自己用 greedy decoding 生成的回答
def build_rejected(prompt):
with torch.no_grad(), policy.disable_adapter():
out = policy.generate(**tok(prompt, return_tensors="pt").to("cuda"),
max_new_tokens=192, do_sample=False)
return tok.decode(out[0], skip_special_tokens=True)[len(prompt):]
Qwen3-0.6B 在泰语 prompt 上的 greedy 输出经常会在句子中途滑回英语。 这是这个模型真实存在的缺陷,不是我们假想出来的缺陷。
把它当作 rejected,梯度就会精确地指向我们想修的那个行为,
并且和第 8 节要测的 th_ratio 指标完全对齐。
如果你从别的模型那里拿 rejected 回来用,你其实是在教模型"不要成为那个模型",而那并不是你想要的。
合起来大约 500 对,留出 15% 作为 held-out 用于评测,训练期间绝不碰。
7. 核心代码(Main code)
这一节的重点不是怎么调库,而是亲手把 DPO loss 写出来, 然后证明它和真家伙一模一样。如果第 3 节的推导还没让你完全信服,这段代码会让你信。
7.1 手写 25 行
import torch, torch.nn.functional as F
def seq_logp(model, input_ids, attention_mask, labels):
"""'仅回答部分'的 log-prob 之和(prompt 的 token 已被 mask 成 -100)"""
logits = model(input_ids=input_ids, attention_mask=attention_mask).logits
logits = logits[:, :-1, :] # 位置 t 预测的是第 t+1 个 token
target = labels[:, 1:] # 所以目标要往后移一位
mask = target.ne(-100) # 只统计回答部分的 token
target = target.masked_fill(~mask, 0) # 避免 gather 在 -100 处出错
logp = torch.log_softmax(logits.float(), dim=-1)
tokp = logp.gather(-1, target.unsqueeze(-1)).squeeze(-1)
return (tokp * mask).sum(-1) # [B] —— 是求和,不是求平均
def dpo_loss(policy, batch, beta=0.1):
pi_w = seq_logp(policy, batch["chosen_ids"], batch["chosen_mask"], batch["chosen_labels"])
pi_l = seq_logp(policy, batch["rejected_ids"], batch["rejected_mask"], batch["rejected_labels"])
with torch.no_grad(), policy.disable_adapter(): # reference: 关掉 adapter + 不要梯度
ref_w = seq_logp(policy, batch["chosen_ids"], batch["chosen_mask"], batch["chosen_labels"])
ref_l = seq_logp(policy, batch["rejected_ids"], batch["rejected_mask"], batch["rejected_labels"])
delta = (pi_w - ref_w) - (pi_l - ref_l) # 第 4 节里的 Δ
loss = -F.logsigmoid(beta * delta).mean() # 就是公式 3.4 本身
r_w = beta * (pi_w - ref_w).detach() # chosen 的隐式奖励
r_l = beta * (pi_l - ref_l).detach() # rejected 的隐式奖励
return loss, r_w, r_l
整个 DPO 就全在这里了,没有任何其他藏起来的东西。
delta 那一行是公式 3.4 一对一翻译成的代码,而 -F.logsigmoid(beta * delta) 就是完整的 loss。
7.2 证明它和 TRL 一致
from trl import DPOTrainer, DPOConfig
cfg = DPOConfig(
output_dir="dpo-out",
beta=0.1,
loss_type="sigmoid", # 必须与我们手写的公式一致
label_smoothing=0.0, # 一旦不为零,公式就不再是 3.4 了
per_device_train_batch_size=2,
gradient_accumulation_steps=8, # effective batch = 16
num_train_epochs=2,
learning_rate=5e-6, # 远低于 SFT —— 见下方警告
lr_scheduler_type="cosine",
warmup_ratio=0.1,
max_length=768,
max_prompt_length=256,
fp16=True, # T4 没有 bf16
logging_steps=5,
)
trainer = DPOTrainer(model=policy, args=cfg, train_dataset=train_ds, processing_class=tok)
# 关键:刚创建的 LoRA 中 lora_B = 0,此时 policy 与 reference 完全相同,
# 差值为零,两种实现都会返回 ln 2 —— 即使公式写错了也一样。
# 必须先扰动权重,这个 assert 才是真正的检验。
with torch.no_grad():
for name, p in policy.named_parameters():
if "lora_B" in name:
p.add_(torch.randn_like(p) * 0.01)
loss_manual, _, _ = dpo_loss(policy, batch, beta=0.1)
loss_trl = trainer.compute_loss(policy, trl_batch)
assert torch.allclose(loss_manual, loss_trl, atol=1e-4)
print("一致:", loss_manual.item(), loss_trl.item())
大多数 tutorial 到"调用 DPOTrainer,然后它就跑起来了"就结束了。
上面这行 assert 说的是:我们在第 3 节一路推导出来的公式,算出的值和全世界都在用的库完全相同。
如果 assert 通过,说明你对 DPO 的理解已经到了能自己实现的程度,而不只是会调用。
按出现频率排序,常见原因是:label_smoothing 不为零、loss_type 不是 "sigmoid"、
喂给两边的 batch 不是同一批样本,或者 padding/masking 对不齐。
这四条都是定义上的不一致,而不是 bug——而把它们一个个揪出来,正是本节最好的一课。
用 LoRA 做 SFT 时 2e-4 完全没问题,但 DPO 用 2e-4 会让策略在几十个 step 内就逃离 reference,
然后语言崩坏到读不成句。
请用 5e-6 作为起点,因为 DPO 并不是在教新的知识,
它只是把已经存在的概率分布掰歪一点,这需要的力气小得多。
在 T4 上,500 对数据训练 2 个 epoch 总共约 9 分钟。
8. 结果(Results)
notebook 会测三项指标并写入 results.json:
- Held-out preference accuracy —— chosen 的隐式奖励高于 rejected 的样本对占比,附 Wilson 95% CI
- 隐式奖励 margin 的分布()——看整个分布,而不只是平均值
th_ratio—— 模型生成回答中泰文字符所占的比例。泰语有自己的一套字母,和拉丁字母毫不重叠, 所以只要数一下字符就能可靠地判断模型有没有偷偷滑回英语。这是本系列专门用来抓这种"静默语言漂移"的指标。
一个好看的平均 margin,可能只是来自少数几个 margin 极高的样本对,而大多数样本对还徘徊在零附近。 histogram 会说出这个真相,而单一数字会把它掩盖掉。 另外,没有 CI 的 accuracy 依然不算实验结果——就像第 1 章一直强调的那样。
Promptอธิบายว่าทำไมท้องฟ้าถึงเป็นสีฟ้า แบบสั้น ๆbase
sft
Showing the built-in sample.
第一次看 log 时会吓你一跳的事
训练过程中,你会在 TRL 的 log 里看到 rewards/chosen 和 rewards/rejected。
而几乎每次都会发生的情况是:这两个值一起往负数方向掉,同时 rewards/margins 却越拉越宽。
记住定义:,所以 为负意味着 策略给这段文本的概率低于 reference。
DPO 从来没有被要求"让 chosen 的概率变高",它只被要求**"把两者之间的差距拉大"**。 把 rejected 狠狠压下去、把 chosen 轻轻压下去,同样满足要求,而且往往是更省力的那条路。
所以真正该盯的是 margin 和 held-out accuracy,而不是 reward 的绝对水平。
但如果 rewards/chosen 掉得非常深(比如低于 −10),那就开始是模型正在抛弃 reference 的信号了——降低学习率,或者调大 。
9. 对比(Comparison)
| 模型 | Pref. acc | 平均 margin | th_ratio | TH-INSTR | 训练耗时 |
|---|---|---|---|---|---|
| 起点(DPO 前) | ~50%(随机) | 0 | 0.93 | 73.3% | — |
| DPO,β = 0.1 | 83.8% | +0.94 | 0.93 | 76.7% | 8.3 分钟 |
iapp/dpo_thai_tutorial(Kobkrit 自建数据集),显存峰值 12.65 GB。
所有数字来自 results.json。
assert torch.allclose(loss_manual, loss_trl, atol=1e-4) 通过:约 25 行手写 DPO loss 得
1.46599,TRL 为 1.46602(相差 2×10⁻⁵),关键是在 margin = −12.0 这个非零值上测试 ——
若 margin 为零,两个公式即便写错也都返回 ln 2,检验就毫无意义。
DPO 曲线符合理论:rewards/chosen +0.74、rewards/rejected −0.20、margins +0.94、
accuracies(排序正确的比例)= 83.8%。
th_ratio 没有变化(0.93 → 0.93),因为 baseline 本就很高 —— 在这个数据集上 DPO 提升的是别的方面, 不是泰语占比。TH-INSTR 从 73.3% 升到 76.7%,但 McNemar p = 1.0(n=30 太小,无法定论); 扎实的证据是 held-out 对上 83.8% 的偏好准确率。 我们没有做完整的 β = 0.5 对比(免费 Colab 预算),因此删去该行 —— 只报告实测到的。
10. 小结(Summary)
- DPO 不是在近似 RLHF,而是把同一个方程解成了闭式 —— 奖励模型和 RL 循环在代数上被消掉了
- 之所以能消失,是因为 Bradley-Terry 只关心 reward 的差值,这是全章的钥匙
- 语言模型就是它自己的奖励模型,通过隐式奖励 实现
- 梯度按模型自己的错误程度加权,已经排对的样本对几乎不产生梯度
- 是那个交易旋钮,在迎合偏好与保住原有能力之间做取舍
- 学习率要非常低(5e-6),因为我们是在掰歪分布,而不是灌输新知识
- 两侧 reward 一起下滑是正常的,看 margin,别看绝对水平
- 永远测量回答长度,因为 length bias 伪装成质量提升的本事非常高明
DPO 是严格离线的。 它只能从文件里已有的回答对中学习。
它能做的是把模型本来就采样得出来的行为重新排序。
它不可能发现一种基座模型从未产生过的回答方式,因为没有人把那种方式放进 chosen 这一列里。
正是这个空白,构成了第 5 章(GRPO)存在的理由——那时模型必须采样自己的回答来学习, 而不只是给别人准备好的东西排序。
还有一点:500 对只是机制演示,不是真正的 alignment。 可用于生产的 alignment 工作使用的偏好对在万到十万量级,相差好几个 order of magnitude。 你从这一章得到的是"公式如何运作、哪个旋钮管什么"的理解,这部分是可以迁移到真实规模上的。 但请不要拿这个结果去宣称你得到了更强的泰语模型。
下一章: GRPO——当"给现成的东西排序"不再够用时, 我们会让模型采样出自己的多个回答,彼此互相比较,而且不需要 PPO 那样的 value function。
参考文献(References)
- Rafailov et al. (2023). Direct Preference Optimization: Your Language Model is Secretly a Reward Model — DPO 原始论文——第 3 节推导的出处
- Bradley & Terry (1952). Rank Analysis of Incomplete Block Designs: I. The Method of Paired Comparisons — 所有奖励模型赖以成立的 Bradley-Terry 模型
- Azar et al. (2023). A General Theoretical Paradigm to Understand Learning from Human Preferences — IPO:指出 DPO 对偏好过拟合的弱点
- Ethayarajh et al. (2024). KTO: Model Alignment as Prospect Theoretic Optimization — KTO:无需 chosen/rejected 成对数据的替代方案
- Park et al. (2024). Disentangling Length from Quality in Direct Preference Optimization — 第 9 节所警告的 DPO 长度偏置
- Tang et al. (2024). Understanding the performance gap between online and offline alignment algorithms — 离线(DPO)为何落后于在线(PPO/GRPO)
- Ouyang et al. (2022). Training language models to follow instructions with human feedback — InstructGPT:整条 SFT -> RM -> PPO 流水线的源头
本系列的文章、代码与 notebook 均以 CC BY-NC-SA 4.0 授权 —— 可自由使用与改编,须署名、限非商业用途,并以相同方式共享。文中引用的第三方模型与数据集仍适用各自的许可证。
