← 返回首页
10_dpo_viz.html

手搓 DPO:从"会答"到"答得合人意"

手搓三关的收尾。第 8、9 关用 SFT 把 base 教成会答题;这一关换任务 —— 偏好对齐。 用 DPO: 不训奖励模型、不走 RL,拿一份冻结的参考模型当锚,直接把"chosen 比 rejected 好"这件事用一个 sigmoid loss 学进去。 配套代码 phase2-sft-lora/08_dpo.py

STEP 1
会答 ≠ 答得好
STEP 2
两个模型 + 隐式奖励
STEP 3
DPO loss 拨一拨
STEP 4
训练:margin 拉开
STEP 5
前后对比 & 达标
这一关 起点 SFT 模型 06 的产物 ref 冻结 SFT 的副本·当锚 β 0.1 能偏多远 偏好对 10 · 不走 RL

① 同一个问题,两个都"答到了",但有好坏之分

SFT 之后,模型已经会答题了。可"会答"不等于"答得好" —— 同一条指令,它可能给出好几种都说得通的回答, 但有的礼貌/简洁/得体,有的生硬/啰嗦/冒犯。偏好对齐要做的,就是让它更偏向左边那种。 点切换看几组真实偏好对(08_dpo.py 的训练数据):

注意:这里两个回答都不是"错字病句",纯是偏好之分 —— 这正是 SFT 难覆盖的部分。 SFT 教"标准答案长什么样";偏好对齐教"在多个合理答案里挑人更喜欢的那个"。所以它接在 SFT 之后
↳ 代码:08_dpo.pyPREF 列表(指令, chosen, rejected)
↳ 下一步:怎么在不训奖励模型的情况下,给"哪个更好"打分?DPO 的巧办法。

② 两个模型 + 一个"隐式奖励"

RLHF 的做法是先训一个奖励模型打分、再用 RL 去最大化分数,链路长。 DPO 把这步省掉:它只用两个模型,"分数"直接从这两个模型的概率差里读出来。

policy(要训练)
πθ
从 SFT 模型出发,
DPO 会更新它
❄ ref(冻结)
πref
SFT 模型的副本,
永不更新,当锚点
某条回答 y 的隐式奖励 r(y) = β · ( logπθ(y) − logπref(y) )
= "policy 比 ref 多给了这条回答多少对数概率" × β
不需要额外的奖励模型:"policy 相对 ref 把某条回答抬高了多少"本身就是分数。 ref 当锚点的意义是 —— 只奖励"相对变化",防止 policy 为了刷分而整体跑偏、把语言能力学崩。 一条回答的 logπ 就是它每个 token 的 log 概率之和(只算 response 段,和 SFT 的 loss mask 同一套)。
↳ 代码:08_dpo.pyseq_logprob(只累加 response 段)+ main 里 policy / ref 两份模型
↳ 下一步:有了两条回答各自的分数,DPO 的 loss 只是想让它俩"拉开差距"。

③ DPO loss:把 chosen 的分数拉到 rejected 之上

记 Δ = logπθ − logπref(policy 相对 ref 的偏移)。DPO 想让 chosen 的 Δ 高于 rejected 的 Δ。 loss 就是个二分类的 logistic 形式 —— 拨动下面两个滑块(模拟训练把它俩推开),看 loss 怎么掉:

0.1
0.0
+0.1
margin = Δc − Δr
52%
σ(β·margin)=把 chosen 排前面的概率
0.66
loss = −log σ(β·margin)
读出三件事:① margin 越大 → loss 越小;② 起点 policy≡ref 时 Δc=Δr=0、margin=0、loss=ln2≈0.693(瞎猜); ③ 把 rejected 往左压和把 chosen 往右抬都能拉大 margin —— 实测里 DPO 主要靠压低 rejected(下一步会看到)。 β 固定 0.1:它是"放大镜",margin 同样大、β 越大 loss 反应越猛。
↳ 代码:08_dpo.pydpo_pass:loss = -F.logsigmoid(beta*(d_chosen - d_rejected))
↳ 下一步:真跑起来,看这条 margin 怎么被一步步拉开。

④ 训练起来:loss 往下、margin 往上

把全部 10 条偏好对的梯度累起来更新一次 policy(ref 始终不动),这就是一个 epoch。 点"跑一个 epoch"看 08_dpo.py真实曲线 —— 留意两条奖励:谁动得多?

0.693
loss
+0.0
margin (Δc−Δr)
0%
偏好准确率
两条回答的隐式奖励(相对 ref,中线=0):
chosen
+0.00
rejected
+0.00
关键观察(实测):margin(Δc−Δr,logπ 单位)一路从 0 拉到 +19 —— 换算成奖励 margin 就是 β·19 ≈ +1.9(正好等于下方 chosen≈0 与 rejected≈−1.9 两条奖励之差)。 它几乎全靠把 rejected 的奖励往下压(降到约 −1.9),chosen 的奖励基本贴着 0 没怎么动。这是 DPO 常见现象 —— "对齐"很多时候是学会少说不好的,而非多说好的。 所以 lr 要极小(5e-7)、配 ref 当锚,否则 policy 会把 rejected 压垮、连带把语言能力带崩。
↳ 代码:08_dpo.pymain 训练循环 · 真实曲线 loss 0.693→0.171 / margin 0→+19 / 准确率 0%→100%
↳ 下一步:训完采样,看回答风格有没有往 chosen 那边靠。

⑤ 训练前 vs 训练后 & 达标

下面是 08_dpo.py真实采样(起点=SFT 模型,终点=DPO 后)。 偏好对齐的变化常常很微妙,但有的指令上能看到明显往 chosen 风格靠:

达标玩具级 · 看硬指标不看刷榜
诚实交代:玩具级(124M、10 对、纯英文)。采样里 "What is 2 + 2?" 训练后甚至答成了 "16" —— DPO 在这么小的规模会轻微扰动已有知识。所以这一关里"DPO 起作用"的可靠证据是 loss↓ / 准确率↑ / margin↑ 这些硬指标,而不是某一条采样文字。
↳ 跑法:python 08_dpo.py --ckpt ckpt_sft/sft.pt --epochs 20 · 加 --lora 改用 LoRA 手段做 DPO
Q&A常见疑问
DPO 和 RLHF 到底什么关系?DPO 是 PPO 的变体吗?
都是为了偏好对齐这同一个目标,但是两条不同的路,DPO 不是 PPO 的变体。 RLHF 先训奖励模型、再用 PPO 这类 RL 算法去最大化奖励(要采样、要在线交互); DPO 用数学推导证明:在同样的目标下,可以跳过奖励模型和 RL,把它等价成一个直接在偏好对上做的监督式分类 loss。 更省、更稳,是目前开源对齐的主流做法之一。详见 白皮书 step 5
为什么训练里 chosen 的奖励几乎不涨,全靠压 rejected?
因为 chosen 本来就是 SFT 模型已经倾向给出的回答(它的 logπ 本就不低),抬升空间小; 而 rejected 那种冒犯/啰嗦的回答,模型压低它的概率要"容易"得多。 DPO 的 loss 只关心 两者之差(margin),从哪边把差距拉开都行 —— 优化器自然挑了阻力最小的那条:压 rejected
那 第 8 章(全量 SFT)、第 9 章(LoRA)、第 10 章(DPO)三者怎么拼?
任务 × 手段是两个正交的轴(白皮书的 2×2 表): 任务有 SFT(会答)/ 偏好对齐(答得好);手段有 全量 / LoRA。 第 8 章=全量×SFT,第 9 章=LoRA×SFT,第 10 章=全量×DPO。本关加 --lora 就补上了 LoRA×DPO 那一格 —— 四格都能跑通,证明两轴可自由组合。 典型生产流水线就是:预训练 → SFT → 偏好对齐,每步都可选全量或 LoRA。
🎉 手搓 DPO · 通关 · Phase 2 手搓三关全部拿下
你已经亲手用 SFT(全量 / LoRA)把 base 教成会答题,又用 DPO 把它对齐到"答得合人意"。预训练 → SFT → 对齐,一条龙都摸过了一遍。