本章前置:第 12 章(12_sft.md)讲的 SFT 指令微调、第 13 章(13_reward_model.md)讲的成对偏好、奖励模型与 Bradley-Terry 偏好概率,以及更早讲过的交叉熵(第 07 章)、sigmoid 函数 (第 13 章)。这些是本章推导的砖块,如果对它们还不熟,建议先回去过一遍。
你将学到:为什么有了奖励模型 + PPO 那一套之后,人们还要发明 DPO;DPO 的核心思想——直接用偏好数据优化策略,跳过显式奖励模型和强化学习循环;从 RLHF 的带 KL 约束目标出发,一步一步、不跳步地推导出 DPO 损失的闭式解;对照本仓库
src/post_training/dpo.py的真实代码逐行读懂;再了解两个变体 ORPO 与 KTO,以及如何在本项目里把 DPO 真正跑起来。👈 上一章 13_reward_model.md | 返回总览
1. 先回忆:我们走到哪一步了
到这一章为止,我们手上已经有了三块东西:
- 一个基座模型(base):在海量文本上预训练过,会”接着往下写”(第 11 章)。
- 一个 SFT 模型:在”指令 → 回答”数据上微调过,会听话、按格式回答(第 12 章)。我们把它记作 或 SFT 模型。
- 一份偏好数据:每条是一个三元组 ——同一个提示 ,人类标注者认为回答 (c = chosen,中选)比回答 (l = losing/rejected,被拒)更好(第 13 章)。
术语小贴士:这一章会反复出现 policy(策略) 这个词。在强化学习里,“策略”就是那个”做决策的模型”。在我们这里,策略就是那个会生成文本的 LLM 本身——给它一个提示 ,它吐出一段回答 ,这就是它”做的决策”。所以下文你看到”策略 “,脑子里直接换成”我们正在训练的那个语言模型”即可。(theta)是它的全部参数(权重)。
第 13 章我们用偏好数据训练了一个奖励模型(reward model,RM):它给任意 打一个标量分数 ,分数越高代表”人类越喜欢”。下一步(第 15 章)本来的剧本是:拿这个奖励模型当裁判,用强化学习(PPO)去训练策略,让策略生成的回答尽量拿高分。
这条路叫 RLHF(Reinforcement Learning from Human Feedback,基于人类反馈的强化学习),它是 ChatGPT 那一代模型对齐的经典做法。但它有个让人头疼的地方,我们先把痛点摆出来。
2. 动机:RLHF 那套为什么”又重又抖”
RLHF 的标准流程是两阶段:
- 训练奖励模型:用偏好数据拟合一个 (第 13 章已经做完)。
- 强化学习微调策略:固定住奖励模型,用 PPO 这类算法,让策略 去”追”高奖励。
第 2 步具体长什么样?它需要一个完整的强化学习循环,每一步大致是:
- 让当前策略 对一批提示实际生成回答(这一步叫 rollout,采样,很慢);
- 把生成的回答喂给奖励模型打分;
- 再算出”优势”、用 PPO 的裁剪目标更新策略;
- 同时还要养一个 value(价值)模型来估计基线,还要加 KL 惩罚防止策略跑偏……
你不用现在懂上面每个词(它们是第 15 章的内容),只需要感受到一件事:这套东西零件多、要同时在显存里放好几个模型(策略、奖励模型、价值模型、参考模型)、还要不停地采样,既慢又容易训不稳——学习率稍大、KL 系数没调好,模型就可能”崩”(开始胡言乱语或重复刷屏)。
于是有人问了一个很自然的问题:
我们手上明明已经有偏好数据 了。能不能不要中间那个奖励模型、也不要那个强化学习循环,直接拿偏好数据去调策略?
DPO(Direct Preference Optimization,直接偏好优化)给出的答案是:能。而且它最漂亮的地方在于——这不是一个”差不多就行”的近似拍脑袋方案,而是可以从 RLHF 那个一模一样的数学目标里,严格推导出一个只用偏好数据、连奖励模型都不用显式训练的简单损失函数。
它凭什么能这样?(先建直觉,再上数学)
打个比方。RLHF 的做法像是:先请一位评委(奖励模型)给所有菜打分,再让厨师(策略)对着评委的分数反复练习。两步,要养评委、要反复试做。
DPO 的洞察是:评委的”打分”和厨师”该怎么做菜”之间,其实有一座数学桥。这座桥告诉我们:一旦你确定了”理想厨师”长什么样,你就反过来知道”评委的隐含打分”是多少——奖励并不需要被单独训练出来,它已经”藏”在策略相对参考模型的变化里了。
更具体一点(这就是后面要推的):
- RLHF 的目标有一个已知的最优解——理想策略 可以用奖励 和参考模型 写成一个闭式公式。
- 把这个公式反过来解,就能把奖励 用策略和参考模型表示出来——这叫隐式奖励(implicit reward)。
- 再把这个”隐式奖励”代进第 13 章的 Bradley-Terry 偏好公式里,那个最难算的部分会奇迹般地抵消掉,剩下的就是一个长得很像第 13 章奖励模型损失、但直接作用在策略上的损失函数。
于是奖励模型这个”中间商”被消掉了,强化学习的采样循环也不需要了——只剩一个损失函数、一遍标准的梯度下降。听起来太好了?我们现在就把这条桥一砖一砖搭出来,保证每一步都不跳。

3. 完整推导:从 RLHF 目标到 DPO 损失
这一节是本章的核心。我们分成 5 小步,每一步先写公式,再用大白话把每个符号讲清楚。你只要会”对数 、指数 、求和、sigmoid”这几样,就能一步步跟下来。
3.1 第一步:写出 RLHF 的优化目标
RLHF 第 2 阶段(用 RL 调策略)其实是在解这样一个优化问题:
这个式子看着唬人,我们把它逐块拆开读:
- :我们要找一个策略 ,让后面整坨东西最大。这里的 就是”给定提示 时,生成回答 的概率分布” 。
- :期望奖励。 是”期望”(就是”平均”)的意思。 表示提示从数据分布 里抽; 表示回答由当前策略自己生成。整体读作:“用当前策略去回答各种提示,平均能拿到多高的奖励”——我们当然希望它越高越好,所以放在 里。
- :KL 散度,衡量”新策略 和参考策略 差多远”。两个分布越像,KL 越接近 0;越不像,KL 越大。 就是我们冻结的 SFT 模型。
- :前面有个负号,意味着 KL 越大,整个目标越小。也就是说:我们一边想拿高奖励,一边又被惩罚”不许离 SFT 模型太远”。
- (beta,读”贝塔”):一个大于 0 的温度/强度系数,控制”惩罚跑偏”这件事有多狠。 大 → 死死拽住别让它离 SFT 远; 小 → 给策略更大自由去追奖励。
为什么非要加这个 KL 惩罚项? 因为奖励模型 不是完美的。如果你让策略毫无约束地只追高奖励,它会找到奖励模型的漏洞、生成一些奖励虚高但其实是垃圾的文本(这叫 reward hacking,奖励钻空子)。加上”别离 SFT 太远”这条缰绳,能把策略拴在一个本来就会说人话的模型附近,既追奖励、又不至于跑飞。这一项是 RLHF 能稳定工作的关键,也正是 DPO 推导能成立的关键。
KL 散度的展开式是 。你只需记住一句话:它衡量两个概率分布的差异,恒大于等于 0,相等时为 0。
3.2 第二步:这个目标的最优策略有闭式解
现在做一件关键的事:这个带 KL 约束的最大化问题,数学上能解出”最优策略到底长什么样”。结论是:
先别管怎么推出来的(下面 3.2.1 给一个直觉版推导),先把它读懂:
- :上标 表示”最优”。这就是”在那个 RLHF 目标下,最理想的策略给回答 分配多少概率”。
- :参考模型(SFT)对 的概率。注意最优策略是以 SFT 为底的。
- :一个放大/缩小因子。某个回答 的奖励 越高,这个因子越大,理想策略就在 SFT 的基础上把它的概率往上抬;奖励越低,因子越小(趋近 0),概率被往下压。
- :配分函数(partition function),也叫归一化常数。它的作用是”把所有 的概率加起来等于 1”,定义为
一句话总结这个闭式解:理想策略 = 以 SFT 为基准,按奖励的指数去加权,再归一化成一个合法概率分布。奖励高的回答概率被抬高,奖励低的被压低,而 控制”抬/压”的力度。
3.2.1 这个闭式解从哪来(直觉版推导,可跳过)
如果你只想拿结论,跳到 3.3。这里给一个不严格但能服人的推导。把目标(对单个 )写成对 的泛函:
把两个期望合并,并整体除以 (不改变最大值的位置):
我们想把方括号里的东西凑成”一个 比值”的形式。注意 ,于是:
现在把分子里那一坨人为地除以再乘以 ( 不依赖 ,只依赖 ),并令
那么方括号变成:
代回期望( 与 无关,可提出来):
最后一步用到: 正是 。而我们知道 KL 散度恒 ,且当且仅当两个分布完全相等时取到 0。所以要让整体最大,唯一的办法就是让 ,即:
这就证明了 3.2 那个闭式解。翻译成大白话:在”追奖励 + 别离 SFT 太远”的拉扯下,最好的策略就是”把 SFT 按奖励的指数重新加权一下”——多一分不行(会离 SFT 太远),少一分不行(没充分追奖励)。
3.3 第三步:配分函数 算不动——这是大麻烦
3.2 的闭式解看着很美,但实际上没法直接用。问题全出在 身上:
这个求和 是对所有可能的回答 求和。一个回答是一串 token,长度几十上百,每个位置有几万种词表选择——可能的 的数量是天文数字(指数爆炸),根本枚举不完。所以 算不出来。
这正是为什么经典 RLHF 不直接用这个闭式解、而是绕道去跑 PPO:PPO 用采样和策略梯度回避了显式计算 。但代价就是第 2 节说的那一堆复杂度。
DPO 的高明之处,是根本不去算 ,而是想办法让它自己消失。怎么消?往下看。
3.4 第四步:反解出”隐式奖励”
我们把 3.2 的闭式解反过来解出 。从
两边除以 ,再乘上 :
两边取 (,指数和对数互相抵消):
两边乘 ,把 单独拎到左边:
逐项读这个公式(它就是 DPO 的灵魂):
- :回答 在最优策略下相对参考模型,概率”涨了多少”的对数,乘以 。如果最优策略比 SFT 更爱说 ,这一项就是正的;更不爱说,就是负的。
- :那个讨厌的、算不动的配分函数项。注意它只跟 有关、跟 无关——对同一个提示 下的任意回答,它都是同一个数。记住这一点,这就是后面让它消失的钥匙。
这个式子在说一件很深刻的事:奖励 不必单独训练,它”等价于”策略相对参考模型的对数概率比(再差一个只跟 有关的常数)。 这个 就叫 隐式奖励(implicit reward)——它隐藏在策略的变化里,我们从来不用显式地造一个奖励模型出来。
3.5 第五步:代入 Bradley-Terry, 抵消
现在把”隐式奖励”接回第 13 章的偏好模型。回忆第 13 章:人类偏好用 Bradley-Terry 模型刻画——” 比 好”的概率由两者奖励之差经过 sigmoid 决定:
其中 是 sigmoid(第 13 章已讲), 读作” 优于 “。关键来了:把 3.4 反解出的 代进奖励之差 。我们用最优策略 的角色,把训练中的策略记为 (我们要优化的对象):
看那两个 :一个加、一个减,而且它们是同一个数(因为 只跟 有关,跟 还是 无关)。于是——
那个算不动的配分函数,被减法干净利落地消掉了! 这就是 DPO 全部魔法的所在。剩下:
代回 Bradley-Terry,偏好概率变成:
最后一步:训练就是最大化这个偏好概率的对数似然(让模型尽量同意”人类标的 chosen 确实更好”),等价于最小化它的负对数。这就是 DPO 损失:
(对一个 batch 里所有偏好对,再取平均。)
推导到此结束。 我们从 RLHF 那个”追奖励 + KL 约束”的目标出发,没做任何近似,纯靠代数,就得到了一个只用偏好数据 、只需要策略 和冻结参考 、连奖励模型都不出现、连 都不用算的损失函数。回头看一眼:它长得和第 13 章的奖励模型损失 几乎一模一样,只是把”奖励模型打的分 “换成了”策略的隐式奖励 “。奖励模型被”折叠”进了策略本身。
3.6 逐项再读一遍 DPO 损失(以及它在干嘛)
把损失里的核心量起个名字,方便讲:
- —— chosen 的隐式奖励。
- —— rejected 的隐式奖励。
于是 。
- 的作用:它是隐式奖励的”放大倍数”,也是 KL 约束的强度。 大 → 一点点对数概率比的变化就被放得很大,损失对”偏离 ref”很敏感,策略被紧紧拴在参考模型附近(更新更保守); 小 → 给策略更大空间偏离 ref。本项目默认 。
- 参考模型 的作用:它是锚点。损失里始终是”策略相对 ref 涨了多少”,不是”策略本身概率多少”。这很重要——它防止模型为了拉开 chosen/rejected 的差距而无脑地把所有概率乱拉(那样会破坏语言能力)。有了 ref 当基准,优化的是”相对变化”,模型仍被锚在那个会说人话的 SFT 上。
- 为什么这等价于”让 chosen 相对 ref 涨得比 rejected 更多”:最小化 就是最大化 (因为 是单调增的)。而 正是”chosen 相对 ref 的对数概率比”减去”rejected 相对 ref 的对数概率比”。把它推大,就是要求:相对于参考模型,策略给 chosen 的概率涨幅,要明显超过给 rejected 的涨幅。 注意——不是”chosen 概率绝对要高”,而是”相对 ref 的涨幅差要大”。
梯度直觉:它自动给”难分对”更大权重
来看损失对策略参数 的梯度(只看方向和权重,推导可跳)。记 ,则 ,而
(用到 。)这里的关键是前面那个权重 :
- 当模型已经分对了( 远大于 ,即 很大正数), 趋近 0 → 几乎不更新。已经会的题,不用再练。
- 当模型分错了或拿不准( 是负数或接近 0), 接近 1 甚至更大的有效权重 → 大力更新。错得越离谱、越难分的对,梯度权重越大,模型在它们身上学得越狠。
大白话:DPO 自带一个”哪壶不开提哪壶”的机制——会做的题不浪费力气,把精力都花在自己当前还分不清好坏的偏好对上。这也是它比”对所有样本一视同仁”更稳、更高效的原因之一。梯度方向 则是在说:抬高 chosen 的对数概率、压低 rejected 的对数概率,正合我们的直觉。
4. 对照真实代码:dpo_loss 逐行读
理论讲完,我们落到本仓库的真实实现。DPO 损失在 src/post_training/dpo.py。在贴之前先说清楚一个关键事实(它把上面公式里的 和代码对上号):
公式里的 是整段回答 的对数概率。而一段回答是一串 token,所以它等于这段回答里每个 token 的对数概率之和:。这个”对一整条 response 的 token 对数概率求和”由
src/post_training/rollout.py里的sequence_logprobs完成。
我们先看 sequence_logprobs(src/post_training/rollout.py):
def sequence_logprobs(
model,
sequences: torch.Tensor,
response_mask: torch.Tensor,
*,
temperature: float = 1.0,
requires_grad: bool = True,
) -> tuple[torch.Tensor, torch.Tensor]:
"""
Sequence-level summed log-prob over response tokens (used by DPO/KTO/ORPO).
Returns ``(sum_logprob, n_tokens)`` each shape (B,). The per-token mean is
``sum_logprob / n_tokens.clamp(min=1)``.
"""
lp, mask = compute_logprobs(model, sequences, response_mask, temperature=temperature, requires_grad=requires_grad)
m = mask.to(lp.dtype)
return (lp * m).sum(dim=-1), m.sum(dim=-1)
逐行讲:
lp, mask = compute_logprobs(...):compute_logprobs用 teacher forcing(把整条序列喂进模型,一次拿到每个位置预测下一个真实 token 的对数概率,和第 07 章训练时算交叉熵是同一套”错位对齐”)。lp形状(B, T-1),是每个位置上、那个真实 token 的对数概率;mask标出”哪些位置属于回答 response(而非提示 prompt)“——我们只算回答部分的概率,提示部分不算。(lp * m).sum(dim=-1):用mask把提示位置清零,再沿 token 维求和——这就把”每个 token 的对数概率”加成了”整条回答的对数概率 “,形状(B,),每条序列一个数。- 第二个返回值
m.sum(dim=-1):这条回答里有多少个有效 response token(ORPO 要用它把和变成均值)。
一个容易被忽略但很重要的数值细节(写在
rollout.py文件头):对数概率一律在 fp32 下计算(logits.float()),即使外层开了 bf16 自动混合精度也不例外。原因正是 DPO/PPO/GRPO 都要相减对数概率(就是损失里那些 的减法),bf16 在这种相减上的舍入误差会带来实质伤害,所以这一步必须用 fp32。
现在看 DPO 损失本体(src/post_training/dpo.py):
def dpo_loss(
policy_chosen_logps: torch.Tensor,
policy_rejected_logps: torch.Tensor,
ref_chosen_logps: torch.Tensor,
ref_rejected_logps: torch.Tensor,
beta: float = 0.1,
) -> tuple[torch.Tensor, torch.Tensor, torch.Tensor]:
"""
Standard DPO loss. Inputs are summed response log-probs (B,).
Returns ``(loss, chosen_reward, rejected_reward)`` where the implicit rewards
``beta * (policy_logp - ref_logp)`` are detached diagnostics.
"""
pi_logratios = policy_chosen_logps - policy_rejected_logps
ref_logratios = ref_chosen_logps - ref_rejected_logps
logits = pi_logratios - ref_logratios
loss = -F.logsigmoid(beta * logits).mean()
chosen_reward = beta * (policy_chosen_logps - ref_chosen_logps).detach()
rejected_reward = beta * (policy_rejected_logps - ref_rejected_logps).detach()
return loss, chosen_reward, rejected_reward
四个输入,都是整条回答求和后的对数概率(由上面的 sequence_logprobs 算出),形状 (B,):
policy_chosen_logps= —— 策略给 chosen 的对数概率;policy_rejected_logps= —— 策略给 rejected 的;ref_chosen_logps、ref_rejected_logps= 同样两条,但来自冻结的参考模型 。
逐行对照我们推出的公式 :
pi_logratios = policy_chosen_logps - policy_rejected_logps= 。利用 ,这是”策略下 chosen 比 rejected 的对数概率高多少”。ref_logratios = ref_chosen_logps - ref_rejected_logps= 同样的差,但在参考模型下。logits = pi_logratios - ref_logratios= 。重新整理一下,这正好等于 (就是公式里 sigmoid 括号内、还没乘 的那部分)。注意代码里 根本不出现——因为推导时它已经被减法消掉了,代码里压根不需要它。loss = -F.logsigmoid(beta * logits).mean()= 再对 batch 取平均。F.logsigmoid是 的数值稳定实现(直接log(sigmoid(x))在 x 很负时会下溢,PyTorch 这个内置函数避免了)。这一行就是 本人。chosen_reward、rejected_reward:把隐式奖励 、 算出来。.detach()表示”只用来记日志/算指标,不参与反向传播”——它们不影响训练,只是给我们看”模型现在给 chosen / rejected 打的隐式分各是多少”。
训练脚本怎么把这些串起来?看 scripts/train_dpo.py 的 _compute_losses:它把 chosen 和 rejected 的 ids 拼成一个大 batch 一起前向(省一次 forward),用 sequence_logprobs 一次性拿到策略下两半的对数概率(requires_grad=True,要回传梯度);再在 torch.no_grad() 下用冻结的 ref 算同样两半(requires_grad=False,不回传);最后按 cfg.loss_type 路由到 dpo_loss / orpo_loss / kto_loss。策略由 load_backbone_from_ckpt(cfg, cfg.sft_ckpt, ...) 从 SFT 检查点初始化,参考模型由 make_frozen_copy(policy, ...) 对策略做一次冻结深拷贝得到——这正对应本章一直强调的:policy 从 SFT 初始化,reference 是 SFT 的冻结深拷贝。
5. 两个变体:ORPO 与 KTO(本项目同文件实现)
src/post_training/dpo.py 里在同一个 --loss_type 开关后面还提供了两个流行变体。它们解决”标准 DPO 的两个不便”,你按需要选用。
5.1 ORPO:连参考模型都不要,SFT 与对齐一步搞定
标准 DPO 需要在显存里同时放策略和冻结的参考模型(两份权重),而且默认你已经做过 SFT。ORPO(Odds Ratio Preference Optimization,几率比偏好优化)更激进:它不需要参考模型,而是把”SFT(把 chosen 学进去)“和”对齐(让 chosen 比 rejected 更受偏好)“折叠进同一个损失、一个阶段。看代码(src/post_training/dpo.py):
def orpo_loss(
policy_chosen_logps: torch.Tensor,
policy_rejected_logps: torch.Tensor,
chosen_n_tokens: torch.Tensor,
rejected_n_tokens: torch.Tensor,
orpo_lambda: float = 1.0,
) -> tuple[torch.Tensor, torch.Tensor, torch.Tensor]:
"""
ORPO (reference-free). Uses per-token MEAN log-probs.
``L = NLL(chosen) + lambda * -log sigmoid(log_odds_chosen - log_odds_rejected)``
where ``log_odds = mean_logp - log(1 - exp(mean_logp))``.
"""
chosen_mean = policy_chosen_logps / chosen_n_tokens.clamp(min=1)
rejected_mean = policy_rejected_logps / rejected_n_tokens.clamp(min=1)
log_odds = (chosen_mean - _log1mexp(chosen_mean)) - (rejected_mean - _log1mexp(rejected_mean))
or_loss = -F.logsigmoid(log_odds).mean()
nll = -chosen_mean.mean()
loss = nll + orpo_lambda * or_loss
# Implicit rewards for logging: the mean log-probs themselves.
return loss, chosen_mean.detach(), rejected_mean.detach()
要点(不必深抠每个公式,抓住思路即可):
- 它用的是每 token 的平均对数概率(
logps / n_tokens),不是求和——所以长短回答更公平。 - 损失有两项相加:
nll = -chosen_mean.mean()是对 chosen 回答的标准 SFT 负对数似然(就是第 07/12 章的交叉熵,逼模型学会 chosen 怎么说);or_loss是一个几率比(odds ratio)偏好项,逼 chosen 的”几率”比 rejected 的”几率”高。orpo_lambda(默认 1.0)控制对齐项的权重。 log_odds = mean_logp - log(1 - exp(mean_logp))是把”概率”换算成”对数几率”(几率 = 概率 / (1-概率));_log1mexp是数值稳定地算 。- 适用场景:你想省掉参考模型那份显存、把 SFT 和对齐合并成一步训完时用 ORPO。注意它的 loss 起始值会比 DPO 高(因为含 NLL 项),不要被吓到。在
train_dpo.py里,loss_type=="orpo"时ref直接被设为None,不会去做冻结拷贝。
5.2 KTO:不需要成对数据,单条样本 + 参考 KL 基线
标准 DPO 要求成对数据 ——同一个提示下,你得有”一好一坏”两条回答。但现实中常常只有单条反馈:用户对某条回答点了”赞 👍“或”踩 👎“,并没有配对。KTO(Kahneman-Tversky Optimization,以行为经济学家命名)就为这种情况设计:它从”每条样本是合意(desirable)还是不合意(undesirable)“出发,不需要成对。看代码(src/post_training/dpo.py):
def kto_loss(
policy_chosen_logps: torch.Tensor,
policy_rejected_logps: torch.Tensor,
ref_chosen_logps: torch.Tensor,
ref_rejected_logps: torch.Tensor,
beta: float = 0.1,
desirable_weight: float = 1.0,
undesirable_weight: float = 1.0,
) -> tuple[torch.Tensor, torch.Tensor, torch.Tensor]:
"""
KTO from paired data: chosen = desirable, rejected = undesirable, with a reference-KL
baseline estimated (detached) from the batch's mean log-ratio.
"""
chosen_logratio = policy_chosen_logps - ref_chosen_logps
rejected_logratio = policy_rejected_logps - ref_rejected_logps
kl = torch.cat([chosen_logratio, rejected_logratio]).mean().clamp(min=0).detach()
chosen_losses = 1.0 - torch.sigmoid(beta * (chosen_logratio - kl))
rejected_losses = 1.0 - torch.sigmoid(beta * (kl - rejected_logratio))
loss = (desirable_weight * chosen_losses).mean() + (undesirable_weight * rejected_losses).mean()
return loss, (beta * chosen_logratio).detach(), (beta * rejected_logratio).detach()
要点:
- 它仍然用参考模型(和 DPO 一样需要 ref)。每条样本算一个”相对 ref 的对数概率比”
logratio = policy_logp - ref_logp。 kl是一个从当前 batch 估计出来的参考 KL 基线(整个 batch 对数概率比的均值,clamp(min=0)截到非负,detach不回传)。它充当一个”水平线”:合意样本要被推到水平线之上,不合意样本要被压到水平线之下。chosen_losses = 1 - σ(β(logratio - kl)):对合意(chosen)样本,想让logratio超过基线kl→ 把这一项往 0 压;rejected_losses = 1 - σ(β(kl - logratio)):对不合意(rejected)样本,想让logratio低于基线 → 同理。desirable_weight/undesirable_weight让你给”赞”和”踩”不同的权重(现实中常常踩的样本多于赞,可以调权重平衡)。- 适用场景:你只有逐条的”赞/踩”信号、没有配对偏好时用 KTO。本项目为了演示,直接把成对数据里的 chosen 当”合意”、rejected 当”不合意”来喂它。
还有一个小工具
implicit_accuracy(chosen_reward, rejected_reward):它返回”有多少比例的对,模型给 chosen 的隐式奖励 > 给 rejected 的隐式奖励”——这就是下一节”隐式奖励准确率”指标的来源,DPO / ORPO / KTO 三者共用。
三者一句话对比:
| 变体 | 要参考模型吗 | 数据形态 | 一句话定位 |
|---|---|---|---|
| DPO | 要(冻结 SFT) | 成对 | 标准款,推导最干净,最常用 |
| ORPO | 不要 | 成对 | 省一份显存,SFT + 对齐一步训完 |
| KTO | 要 | 可非成对(赞/踩) | 只有单条反馈时用 |
6. 评估:怎么知道 DPO 练好了
DPO 训练脚本 scripts/train_dpo.py 在训练中和结束时报告这几个数,对应第 13 章学过的偏好概念:
- loss(损失):DPO / KTO 的起点接近
0.693。这个数不是巧合——,它表示训练刚开始时策略 = 参考模型,隐式奖励差为 0,sigmoid 给出 0.5”五五开”。随训练损失应当下降。ORPO 起点更高(因为多了 NLL 项)。 - acc(隐式奖励准确率):由
implicit_accuracy算出——有多少比例的偏好对,模型给 chosen 的隐式奖励确实高于 rejected。它应当从 0.5 附近往上爬(越过 0.5 说明模型开始把人类偏好学进去了)。 - r_chosen / r_rejected 与 margin(间隔):两个隐式奖励 、 的平均值,以及它们的差
margin = r_chosen - r_rejected。健康的训练里间隔应当逐渐拉大——chosen 越来越受偏好、rejected 越来越被压低。脚本里eval_implicit_acc在留出的测试集preferences_test.jsonl上算test_acc和margin。 - GSM8K dev 准确率:这是真正的下游检验。前面那些隐式指标只说明”模型学到了偏好的相对关系”,但最终目的是模型在真实任务上更强。本项目用 GSM8K(小学数学应用题)开发集的准确率,衡量对齐后模型的真实能力是否提升(第 17 章会专门讲评估)。
一句重要提醒(来自参考文档):DPO 用很小的学习率(本项目默认
lr=5e-7)。因为隐式奖励对”偏离 ref”很敏感,学习率一大,模型很容易被推离参考模型过头,导致退化(开始胡说、重复)。宁可轻柔一些、慢一点。
7. 动手:把 DPO 跑起来
前提:你已经有一个 SFT 检查点(第 12 章产出,默认 sft.pt)和一份偏好数据(默认 preferences.jsonl)。先用 smoke 小配置在 CPU 上验证流程能跑通(模型极小、几步就停),再上真训练。
先 smoke(几秒到几分钟,CPU 可跑)。本项目的 smoke 配置在 configs/smoke/dpo.json(batch_size=4、max_len=256、只跑很少步)。训练脚本通过 parse_config_with_json 读取 JSON 覆盖默认值,命令行 flag 再覆盖 JSON:
PYTHONPATH=. python scripts/train_dpo.py --config configs/smoke/dpo.json --loss_type dpo --beta 0.1
正式训练(单卡可用 python):
PYTHONPATH=. python scripts/train_dpo.py --loss_type dpo --beta 0.1
多卡(两张 GPU,用 torchrun 起 DDP):
PYTHONPATH=. torchrun --standalone --nproc_per_node=2 scripts/train_dpo.py --loss_type dpo --beta 0.1
想试变体,换 --loss_type 即可(ORPO 还能调 --orpo_lambda):
PYTHONPATH=. python scripts/train_dpo.py --loss_type orpo --orpo_lambda 1.0
PYTHONPATH=. python scripts/train_dpo.py --loss_type kto --beta 0.1
flag 速查(均来自
config/post_training_config.py的DPOConfig,可直接核对):--loss_type(dpo/orpo/kto,默认dpo)、--beta(默认0.1)、--orpo_lambda(默认1.0)、--lr(默认5e-7)、--batch_size(默认8)、--max_len(默认768)、--sft_ckpt(策略/参考的来源,默认sft.pt)、--pref_path(偏好数据)、--out_ckpt(输出,默认dpo.pt)。
运行时你会在终端看到类似 step .. | loss .. | acc .. | r_chosen .. r_rejected .. 的日志,以及周期性的 [eval] .. test_acc .. margin ..。盯着 acc 往上、margin 拉大、loss 从 0.69 下降,就说明在正常学习。最终检查点保存到 --out_ckpt(默认 dpo.pt)。
小结
- 动机:RLHF(奖励模型 + PPO)零件多、要同时跑采样和好几个模型,慢且容易训不稳。DPO 的核心思想是直接用偏好数据优化策略,跳过显式奖励模型和强化学习循环。
- 完整推导一条线:RLHF 带 KL 约束目标 → 它的最优策略闭式解 → 配分函数 算不动 → 反解出隐式奖励 → 代入 Bradley-Terry, 在 chosen 与 rejected 的差中抵消 → 得到 。没有近似,纯代数。
- 它在做什么:让 chosen 相对参考模型的对数概率比,涨得比 rejected 更多; 控制力度;参考模型当锚点防退化;梯度自动给”难分对”更大权重。
- 代码对照: 是整段 response 的 token 对数概率之和(
sequence_logprobs,fp32 计算);dpo_loss四行精确对应公式, 因被消掉而根本不出现;policy 从 SFT 初始化、reference 是其冻结深拷贝。 - 变体:ORPO(无参考模型,NLL + 几率比,一步合并 SFT 与对齐)、KTO(可非成对,赞/踩信号 + 参考 KL 基线)。
- 评估:隐式奖励准确率(应 > 0.5)与间隔(应拉大)+ GSM8K dev 准确率;学习率要很小。
自测题
- 用你自己的话说清楚:DPO 相比 RLHF + PPO,省掉了哪两样东西?省掉它们为什么能让训练更简单、更稳?
- RLHF 的目标里那个 KL 约束项 是干嘛的?如果把它去掉,会发生什么坏事?
- 最优策略闭式解里的配分函数 为什么算不出来?DPO 又是靠什么”技巧”让它不需要被算出来的?(提示:它只跟谁有关、跟谁无关?)
- DPO 损失里出现的是 这种比值,而不是 本身。为什么要除以参考模型?如果直接用 、不要参考模型,可能出什么问题?
- 训练刚开始时 DPO 的 loss 大约是
0.693,这个数是怎么来的?(动手:用计算器算一下 。) - 看
dpo_loss的代码:logits = pi_logratios - ref_logratios。请逐步说明这一行展开后恰好等于公式里 sigmoid 括号内(未乘 )的那一项。 - 公式里的 在代码里对应什么?为什么它要对”一整段回答的 token 对数概率”求和,而且要在 fp32 下算?
- ORPO 和 DPO 最大的区别是什么?在什么情况下你会优先选 ORPO?KTO 又适合什么数据形态?
深入参考
- 本仓库精炼参考:
../05_dpo_zh.md(DPO / ORPO / KTO 的工程速查)。 - 序列对数概率记法:
../foundations/objectives_zh.md。 - 真实源码:
src/post_training/dpo.py——dpo_loss/orpo_loss/kto_loss/implicit_accuracy。src/post_training/rollout.py——sequence_logprobs/compute_logprobs(fp32 对数概率)。scripts/train_dpo.py—— 训练循环、_compute_losses、eval_implicit_acc。config/post_training_config.py——DPOConfig(全部可调 flag 与默认值)。
- 原论文:Rafailov et al., Direct Preference Optimization: Your Language Model is Secretly a Reward Model (2023)——本章推导即出自此文。
下一章 👉 15_ppo.md