第 12 章 DPO:用「偏好数据」对齐模型
第 12 章 DPO:用"偏好数据"对齐模型
本章来源:本文整理自 LLMs-from-scratch 仓库的 ch07/04_preference-tuning-with-dpo bonus 材料,原书为 Sebastian Raschka《Build a Large Language Model (From Scratch)》。
这章要解决什么问题
一句话:让模型不只是"答对",还要"答得讨人喜欢"。
第 7 章做完指令微调,模型已经会听话了。但你会发现一个尴尬的事实:同一道题,它的答案往往是对的,可"对不对"之外还有"好不好"。
比如你问"买笔记本有什么推荐",模型给你一段干巴巴的参数罗列,也给你一段热情耐心、会追问你用途的回答——两个都算正确,但你明显更想要后者。指令微调只教会了它"给出一个正确答案",却没教会它"在这两个都不错的答案里,我更偏好哪一个"。
这一章要做的,就是把"偏好"这件事显式地喂给模型:告诉它"这两个回答,A 比 B 好",让它学着往 A 那种风格靠。
具体我们会走完这条流水线:
- 认清偏好微调和指令微调的差别,看懂 chosen / rejected 这对数据;
- 用一个大模型(Llama 3.1 70B)自动生成偏好数据集;
- 写
PreferenceDataset类和新的custom_collate_fn,把每条样本变成"输入 + 好答案 + 坏答案 + 掩码"; - 加载指令微调好的模型,并复制一份冻结的参考模型(reference model);
- 从零实现 DPO 损失(含
log-sigmoid、β、reference 比值); - 跑完整的 DPO 训练循环,观察损失和"奖励边际(reward margin)";
- 定性对比参考模型和策略模型改出来的回答。
用大白话讲原理
从"照着抄答案"到"知道哪个答案更好"
回忆一下我们一步步走过来的训练方式,它其实一直在"喂标准答案":
- 预训练:喂海量文本,让模型练"文字接龙"。它是照着海量现成的下一个词抄。
- 指令微调(SFT):喂"指令 → 正确回应"的配对。它是照着人给好的那一条标准答案抄。
这两种都叫照着抄答案。问题在于:很多任务没有唯一标准答案,正确答案之间还有风格、语气、安全性的差别。 你说"是"还是"没错呀",都对;但一个像机器,一个像人。
偏好数据换了个教法。它不再只给一条答案,而是给一对答案,并告诉你"这一条更好(chosen),那一条更差(rejected)"。模型不需要知道"绝对正确的答案长什么样",只需要学会一个更简单、更常见的判断:"给这两个,我该更偏向哪个。"
一个类比:老师批作文,有两种改法。一种是只在旁边写上范文(照着抄答案);另一种是不写范文,只圈出"这句好、那句差",让你自己体会怎么改(偏好数据)。DPO 走的是后一条路——它不告诉你"完美的答案是什么",只告诉你"哪个方向更好"。这在现实中更容易获取:让人从头写一篇满分作文很难,让人在两篇里挑一篇更好,容易得多。
下面这张图就是本章一切的原点——同一道题,两个都对,但一个好、一个差:
用户:推荐一款适合我的笔记本。
chosen(被偏好的):先问清你的用途是编程还是游戏,再给你 2 条权衡建议,语气耐心。
rejected(被嫌弃的):Look, just buy the XPS, obviously.(生硬、不解释、不追问)**偏好微调(preference finetuning)**做的事,就是在指令微调模型之上,再教它一句"哪种风格更受欢迎"。而 DPO(Direct Preference Optimization,直接偏好优化),是做这件事的一种方法。
DPO 是什么? 一句话:把"哪种答案更好"这件偏好判断,直接变成一个可以反向传播的损失函数,让模型原地调参。它的名字里"Direct(直接)"三个字是精髓——它直接拿偏好数据优化模型,不需要先单独训练一个奖励模型。这一点后面细说。
偏好数据集怎么造
要教模型"哪个更好",首先得有一份"哪个更好"的数据。这份数据里,每条样本除了原始的指令和输入,还多了两个字段:
- chosen:被偏好的回答(我们想让模型学的)。
- rejected:被嫌弃的回答(我们想让模型回避的)。
那这两条回答从哪来?原仓库的 create-preference-data-ollama.ipynb 列了三种造法:
- 用指令微调模型对同一个问题生成多个回答,让人类按偏好排序;
- 同样生成多个回答,让另一个 LLM 按给定标准排序;
- 直接让一个 LLM 按指定偏好标准,同时生成"好答案"和"坏答案"。
真实项目里三种都会用,但第 1 种最贵(要雇人),第 3 种最便宜、最好批量做。这份 notebook 走的就是第 3 种:用一个 700 亿参数的 Llama 3.1-Instruct 模型,把第 7 章指令数据集里每条现成的 output 拿来,按"更礼貌 / 更不礼貌"这条偏好标准,改写出一个 chosen 和一个 rejected。
先看数据的格式差异。原始指令数据(第 7 章那份)每条只有 instruction、input、output 三个字段;加了偏好之后,每条会在原来基础上多出 chosen 和 rejected 两个字段。以"加州首府"这道题为例,原始 output 那条标准回答本身就很规矩、礼貌,于是被当作 chosen;而 rejected 则是被改写成 "Look, the state capital of California is obviously Sacramento." 这样生硬、不解释的版本——两者语义完全一样,差别只在语气。
造数据的核心,就是"让大模型按某个标准改写答案"。具体做法是:把每条现成的 output 连同它对应的指令一起,发给一个 70B 的 Llama 3.1-Instruct 模型(这里通过 Ollama 的本地 REST API 调用它),让它在不改变语义的前提下,把答案稍微改写得更礼貌或更不礼貌。为了让输出尽量确定、方便复现,调用时会把随机种子固定、采样温度设成 0。
对每条数据,我们会随机决定这次是改成"更礼貌"还是"更不礼貌":把模型改写出来的那条放在一边,原始 output 放在另一边。如果这次改得更礼貌,就让改写版当 chosen、原版当 rejected;反之则原版当 chosen、改写版当 rejected。这样每条样本就凑齐了"一个好、一个坏"的偏好对。
这里的关键设计是:改写得越"轻",偏好对的差别就越微妙,模型学到的也越是"风格级"的调整,而不是"换一套答案"。原始 output 本身就是一条规矩、礼貌的回答,所以"礼貌"改法常常只是加了点客套话,"不礼貌"改法则把它变得生硬——两边语义几乎一样,只有风格不同。
跑完 1100 条,存成 json,就是本章后面要用的 instruction-data-with-preference.json。
注意:Ollama 目前在不同操作系统上还不是完全确定的,你生成的数据、以及后面训练出来的具体文字,可能和书里略有出入,这很正常。
DPO 为什么不需要显式训练奖励模型?它和 RLHF 是什么关系
这是本章最该讲清楚的一点。
先看老办法 RLHF(Reinforcement Learning from Human Feedback,基于人类反馈的强化学习)。它的流程大致是三步:
- 先拿偏好数据单独训练一个奖励模型(reward model):给它一个(问题, 回答),它吐一个分数,打分高的就是人类更偏好的。
- 把语言模型当成一个策略(policy),用强化学习(通常是 PPO 算法)去优化它,目标是让奖励模型给的分越高越好。
- 同时加一个约束,别让模型为了骗高分而跑偏太远(通常用 KL 散度把它拉在原始模型附近)。
这套流程很强大,但也麻烦:你得同时维护三个模型——策略模型、奖励模型、还有一份参考模型;强化学习本身又出了名的难调、不稳定,超参数一大堆。
DPO 的洞察:论文《Direct Preference Optimization: Your Language Model is Secretly a Reward Model》发现——那个奖励模型其实可以"藏"在语言模型里,不必单独训练。在一定的数学假设下,最优策略和奖励函数之间存在一个漂亮的对应关系,于是可以把"先训奖励模型、再用 RL 优化"这一整套,化简成一个直接定义在偏好数据上的损失函数,用普通梯度下降就能优化。
一句话区分:
- RLHF:偏好数据 → 训练奖励模型 → 用强化学习优化策略(模型多、流程长、不稳定)。
- DPO:偏好数据 → 直接算一个损失、直接优化策略(模型少、流程短、像做普通微调)。
DPO 省掉了显式的奖励模型和强化学习循环,所以它更简单、更稳、更容易复现。代价是它需要用一份预训练好的"参考模型"当锚点(这个后面代码里能看到)。
回到代码视角:DPO 训练时同时跑两个模型——一个是我们要优化的策略模型(policy model,π_θ),另一个是原封不动的参考模型(reference model,π_ref)。参考模型就是指令微调好的那个模型的一个冻结副本,它提供一个"基准",衡量策略模型到底偏离了多远。
12.1 加载并准备偏好数据集
先说数据。后面所有代码都基于这份带 chosen / rejected 的偏好数据集,共 1100 条。它就是上一节那份生成好、存成 instruction-data-with-preference.json 的文件,读进来是一个列表,列表里每个元素是一条样本。
每条样本有 5 个字段:
instruction和input:模型的输入部分;output:第 7 章指令微调时用的那条标准回答;chosen:偏好的一大一小两条里的"更好"那条;rejected:被嫌弃的那条。
目标:让模型的回答风格向 chosen 靠,远离 rejected。
和第 7 章一样,我们用 Alpaca 模板把指令和输入拼成模型的输入:先是一段固定的任务说明文字,接着是 ### Instruction: 加上指令正文;如果样本带 input,再补一段 ### Input:。
chosen 和 rejected 则分别接到 ### Response: 后面——两条的前缀完全相同,只有回应正文不同。也就是说,同一条样本,chosen 那条就是"固定前缀 + ### Response:\n + chosen 文本",rejected 那条则是同样的前缀换成 rejected 文本(这两条完整文本正是后面 PreferenceDataset 要分别编码的东西)。
按老规矩切分数据集:前 85% 作训练集、接着 10% 作测试集、剩下 5% 作验证集。
12.2 把偏好对组织成批次:PreferenceDataset 和新的 collate
第 7 章的 InstructionDataset 每条样本只有一条回答;DPO 要的是一对。所以数据集类大同小异,只是 __init__ 里对每条样本都编码三样东西:输入提示、chosen 全文、rejected 全文。
import torch
from torch.utils.data import Dataset
class PreferenceDataset(Dataset):
def __init__(self, data, tokenizer):
self.data = data
# 预先分词,避免训练时反复编码
self.encoded_texts = []
for entry in data:
prompt = format_input(entry)
rejected_response = entry["rejected"]
chosen_response = entry["chosen"]
# 输入提示:只含指令 + 输入,不含回应
prompt_tokens = tokenizer.encode(prompt)
# 好答案 / 坏答案的完整文本:提示 + "### Response:" + 回应
chosen_full_text = f"{prompt}\n\n### Response:\n{chosen_response}"
rejected_full_text = f"{prompt}\n\n### Response:\n{rejected_response}"
chosen_full_tokens = tokenizer.encode(chosen_full_text)
rejected_full_tokens = tokenizer.encode(rejected_full_text)
self.encoded_texts.append({
"prompt": prompt_tokens,
"chosen": chosen_full_tokens,
"rejected": rejected_full_tokens,
})
def __getitem__(self, index):
return self.encoded_texts[index]
def __len__(self):
return len(self.data)注意这里有个和第 7 章不同的地方:chosen / rejected 存的是"提示 + 回应"的完整序列,但额外单独存了一份 prompt 的 token。为什么?——因为算损失时我们只关心回应部分,提示部分不该算分,所以要靠 prompt 的长度把它掩掉。这就引出了下面的 collate 函数。
新的 custom_collate_fn 要做四件事:① 把 chosen / rejected 分别填充到本批最长;② 给两者生成布尔掩码,标出哪些 token 是"真正的回应";③ 把提示 token 和填充 token 在掩码里标成 False;④ 堆成张量、截断到最大长度、搬到设备。它的返回值就是一个字典,装着 chosen、rejected 两条完整序列,以及各自对应、形状一致的布尔掩码。(我们还会用 functools.partial 把设备、掩码开关、最大长度这几个参数提前固定好,得到一个配置好的 customized_collate_fn,后面建数据加载器时直接用它。)
掩码长什么样? chosen_mask 和 chosen 形状完全一致,里面是一串布尔值:
True= 对应位置的 token 属于真正的回应;False= 对应位置要么是提示 token(因为我们设了mask_prompt_tokens=True),要么是填充 token。
拿这个掩码去"筛"一下 token,就能把提示和填充全部剥离,只剩纯粹的回应文本。
本章关键点:这个掩码会在算 DPO 损失时派上用场——它确保我们只在"回应"这段 token 上计算对数概率,忽略提示和填充,否则长的提示会把信号冲淡。
接着搭好三个数据加载器(训练、验证、测试各一个,和第 5、7 章套路一致):用 PreferenceDataset 包住对应的数据切分,配上刚配置好的 customized_collate_fn,批次大小设为 8,训练集打乱、验证和测试集不打乱。这里用的分词器是 tokenizer = tiktoken.get_encoding("gpt2"),和前面各章一致。
12.3 加载指令微调模型,并复制一份"冻结的参考模型"
DPO 的前提是:你手上已经有一个指令微调(SFT)好的模型。 我们直接把第 7 章微调并保存下来的 gpt2-medium355M-sft.pth 拿过来,用 model.load_state_dict(...) 把这份权重读进模型,再调用 model.eval() 切到推理模式。可以顺手让它回答一个小问题(比如把主动句改成被动句),确认模型加载正常、回答合理——这一步只是自检,不影响后面的流程。
现在是 DPO 特有的关键一步:准备两个模型。
- 策略模型(policy model):就是刚才那份 SFT 模型,我们要优化它。
- 参考模型(reference model):再实例化一个一模一样的模型,加载同样的权重,然后把它冻结不动,只当基准。
做法很直接:策略模型直接复用刚才的 SFT 模型(policy_model = model);参考模型则用同样的配置 GPTModel(BASE_CONFIG) 新建一个实例,加载同一份 SFT 权重,再调用 eval() 把它冻住。最后把两个模型都搬到设备上(.to(device))。
为什么参考模型要冻结? 它是 DPO 的"锚"。训练时我们既要让策略模型更偏好奇的(chosen),又要防止它为讨好偏好数据而"跑飞"(比如输出胡话)。参考模型提供了一条"原始分布"的基线,DPO 损失里的比值项就是在度量策略模型相对这条基线,把 chosen 抬高了多少、把 rejected 压低了多少。所以参考模型必须始终不变——它的参数进入优化器是绝对不可以的。
12.4 从零实现 DPO 损失
重头戏来了。DPO 的核心,就是下面这一个损失函数。论文(Eq. 7)里它是这个式子:
别被它吓到,逐项拆开看其实很直白:
- :提示(prompt);:被偏好的回答(w = winner,即 chosen);:被嫌弃的回答(l = loser,即 rejected)。
- :策略模型,即我们要优化的模型;:参考模型,全程冻结。二者训练开始时完全相同。
- :给定提示 ,策略模型生成回答 的(对数)概率——也就是"这个模型有多愿意说这句话"。
- :策略模型相对参考模型,对好答案的概率放大倍数(一个比值)。同理,第二项是好答案与坏答案在"相对参考模型"意义下的对比。
- :超参数,控制策略模型允许偏离参考模型的程度。原笔记本的训练建议是:把 从 0.1 调到 0.5 会减弱 DPO 的效果(这里用 0.1,好让风格变化更明显)。
- :logistic sigmoid 函数,把括号里的"对数几率(log-odds)"压成一个概率分数。
- 套 :合起来就是
log-sigmoid。它衡量的意思是——"好答案优于坏答案"这件事,被模型执行得有多好。执行得越好,log-sigmoid越大;外面的负号让它变成"越小越好的损失"。 - :期望,就是对这个批次里所有样本求平均。
一句话直觉:损失想让括号里的东西越大越好,而括号里干的正是——让"好答案相对参考模型被抬高"的程度,超过"坏答案相对参考模型被抬高"的程度。也就是:比原来更爱说好答案,更不爱说坏答案。
代码里怎么落地?用对数运算的恒等式 ,把上面的"比值之比"全化成减法:
import torch.nn.functional as F
def compute_dpo_loss(
model_chosen_logprobs,
model_rejected_logprobs,
reference_chosen_logprobs,
reference_rejected_logprobs,
beta=0.1,
):
"""计算一个批次上 DPO 损失。
Args:
model_chosen_logprobs: 策略模型对 chosen 回答的对数概率,形状 (batch_size,)
model_rejected_logprobs: 策略模型对 rejected 回答的对数概率,形状 (batch_size,)
reference_chosen_logprobs: 参考模型对 chosen 回答的对数概率,形状 (batch_size,)
reference_rejected_logprobs: 参考模型对 rejected 回答的对数概率,形状 (batch_size,)
beta: DPO 温度参数,通常在 0.1 到 0.5 之间;β 趋近 0 时相当于忽略参考模型。
Returns:
(loss, chosen_rewards, rejected_rewards) 三个张量。
"""
# 策略模型内部的"好-坏"对数概率差。等价于 log( π_θ(y_w|x) / π_θ(y_l|x) )
model_logratios = model_chosen_logprobs - model_rejected_logprobs
# 参考模型内部的"好-坏"对数概率差。等价于 log( π_ref(y_w|x) / π_ref(y_l|x) )
reference_logratios = reference_chosen_logprobs - reference_rejected_logprobs
# 两者之差,就是括号里那一坨(β 在下面乘上)
logits = model_logratios - reference_logratios
# DPO 损失(论文 Eq. 7):负的 log-sigmoid
losses = -F.logsigmoid(beta * logits)
# 可选:跟踪训练进度的"奖励"指标(detach 掉,不参与梯度)
chosen_rewards = (model_chosen_logprobs - reference_chosen_logprobs).detach()
rejected_rewards = (model_rejected_logprobs - reference_rejected_logprobs).detach()
# 对批次内样本求平均
return losses.mean(), chosen_rewards.mean(), rejected_rewards.mean()逐行把代码对回公式:
第一、二行——log(比值的比) 拆成两个减号,得到策略模型和参考模型各自的"好答案相对坏答案"的对数概率差:
第三行——两者相减,等价于"策略相对参考"的组合:
第四行——losses = -F.logsigmoid(beta * logits),正是损失式里期望括号内的那一项(取负号让它成为可最小化的损失):
顺带说说 chosen_rewards / rejected_rewards:它们是"隐式奖励",定义为策略模型相对参考模型、对某条回答的对数概率提升量()。单看不大直观,但两者相减得到的 reward margin(奖励边际) 很有用——它告诉我们"模型把好答案相比坏答案抬高了多少",训练中它应该越来越大。注意这里都加了
.detach(),只是拿来记录进度,不参与反向传播。
从 logits 到 log-prob:compute_logprobs
上面的函数假定你把四个"对数概率"都算好了。那它们怎么来?从模型输出的 logits 里"取"出来。核心和交叉熵损失一样:先 log_softmax 得到每个 token 的对数概率,再用 torch.gather 把真实标签位置的对数概率挑出来。
def compute_logprobs(logits, labels, selection_mask=None):
"""
计算对数概率。
Args:
logits: 形状 (batch_size, num_tokens, vocab_size)
labels: 形状 (batch_size, num_tokens)
selection_mask: 形状 (batch_size, num_tokens)
Returns:
mean_log_prob: 排除填充/prompt 后的平均对数概率。
"""
# 标签是输入右移一位
labels = labels[:, 1:].clone()
# logits 也对应去掉最后一个位置,和 labels 对齐
logits = logits[:, :-1, :]
# 归一化成对数概率
log_probs = F.log_softmax(logits, dim=-1)
# 用 gather 把真实标签位置的对数概率取出来
selected_log_probs = torch.gather(
input=log_probs,
dim=-1,
index=labels.unsqueeze(-1)
).squeeze(-1)
if selection_mask is not None:
mask = selection_mask[:, 1:].clone()
# 用掩码把提示/填充 token 的对数概率清零
selected_log_probs = selected_log_probs * mask
# 只在有效 token 上求平均,形状变成 (batch_size,)
avg_log_prob = selected_log_probs.sum(-1) / mask.sum(-1)
return avg_log_prob
else:
return selected_log_probs.mean(-1)
torch.gather是什么? 你可以把它理解成一个"按索引挑选"的函数。这里log_probs的形状是(batch, tokens, 50257)——词表里每个 token 都有一个对数概率;labels告诉我们"这个位置真正该是哪个 token"。gather就替我们沿着词表维度,把正确答案那个 token 的对数概率一个个挑出来。这和 PyTorch 内部cross_entropy做的事本质一样,只是gather给了我们更多控制权(方便加掩码)。
顺带一提,这个"log_softmax → gather → 取负平均"的手写流程,算出来的结果和 PyTorch 内置的 cross_entropy 完全等价——只不过手写版能让我们插入掩码,把提示和填充排除在外。
把一整批算完:compute_dpo_loss_batch
现在把"算 logits → 算 logprobs → 算 DPO 损失"串起来。做法是:先用策略模型分别对 chosen 和 rejected 各跑一次前向、算出两条回答的对数概率;参考模型也照样算一遍,但整段包在 torch.no_grad() 里——它被冻结、不训练,没必要算梯度,这样也算得更快。最后把这四个对数概率喂给上面的 compute_dpo_loss,就得到一个批次上的损失和两组奖励。
再包一层,让它能对整个数据加载器生效:遍历 loader,把每批的损失和奖励累加起来求平均即可。这里的 num_batches 只是为了效率——每次评估都跑遍全量数据太慢,取前几批估个大概就行(它内部会取一个不超过 loader 实际批数的值)。
最后再加一个给训练集和验证集都能跑、方便打日志的封装:它临时把模型切到 eval()、在 torch.no_grad() 下分别对两个 loader 调用上面那个函数,把两侧的损失和奖励打包成一个字典返回,再切回 train() 模式。
到此,整条数据流是:
模型前向得到 logits → compute_logprobs 得到对数概率 → compute_dpo_loss 得到损失compute_dpo_loss_batch 负责单批,compute_dpo_loss_loader 负责整个 loader,evaluate_dpo_loss_loader 负责训练 + 验证两侧的记录。
12.5 DPO 训练循环
训练循环和第 5、7 章几乎一样,只有两处不同:用 DPO 损失替换交叉熵损失,以及额外记录奖励和奖励边际来观察进度。
结构上,它是标准的双层循环——外层遍历 epoch,内层遍历 train_loader 里的每个批次:每批先 optimizer.zero_grad() 清梯度,调用 compute_dpo_loss_batch 算出 DPO 损失,再 loss.backward() 反传、optimizer.step() 更新权重。与普通训练不同的地方在于:
- 只把策略模型切成训练模式(
policy_model.train()),参考模型始终保持eval(); - 每隔
eval_freq步,调用evaluate_dpo_loss_loader在训练集和验证集上记录损失,顺手算一下奖励边际(好答案奖励减坏答案奖励)打印出来; - 每个 epoch 结束后,用
generate_and_print_sample生成一条样例,肉眼看看措辞有没有变化。
正式开跑前,先跑一次 evaluate_dpo_loss_loader 看看初始状态。注意此时的奖励边际应该接近 0——因为策略模型和参考模型一开始完全一样, 自然是零。
开训。这里有几个非常重要的 DPO 调参经验:
- 优化器只传策略模型的参数(
policy_model.parameters()),参考模型绝不能进优化器; - 只训 1 个 epoch——因为 DPO 非常容易"崩塌(collapse)",损失还在降,模型却开始输出胡话;
- 学习率要极小(这里
5e-6); - 从 0.1 到 0.5 之间选,值越大 DPO 效果越弱。
具体的做法是:用 AdamW 优化器,只把策略模型的参数传进去(policy_model.parameters()),学习率设成极小的 5e-6;beta 取 0.1,num_epochs 设为 1;每 5 步评估记录一次(eval_freq=5、eval_iter=5),并把验证集里的一条样本当作 start_context,方便每个 epoch 结束后顺手看一条生成样例。然后调用 train_model_dpo_simple(...) 正式开跑,结束时记录并打印总耗时。
用时参考:A100 约 2 分钟,较小的 L4 约 4 分钟,M3 MacBook Air 约 30 分钟。
12.6 看结果
第一,把 DPO 损失画成曲线(沿用第 5 章的 plot_losses 工具,横轴用训练中记录下来的 token 数):训练集和验证集上的损失都应随训练一路下降。
损失在下降,是好事。看曲线一路往下,你可能会想多训几轮——但请务必克制,DPO 容易崩塌,作者也提醒读者小心。
第二,看奖励边际(好答案奖励减去坏答案奖励,也就是把记录里的 chosen 奖励和 rejected 奖励逐项相减),它应该和损失曲线同步上升。
边际在涨,说明模型确实越来越偏爱 chosen、越来越嫌弃 rejected。
但是:DPO 损失和奖励边际只是"过程指标",它们不能说明全部问题。最终还得看模型实际写出来的字。
第三,也是最重要的,定性对比:让参考模型和策略模型对同一批问题各答一次(比如取验证集的前 3 条),各自用 generate 生成回答,去掉输入前缀和 ### Response: 标记,再把两份回应摆在一起,看风格变了没有。
你会看到什么? DPO 带来的不是"换一整套答案",而是"轻微的措辞风格变化"。书中给的例子很典型:同一个分类问题,原来模型写的是 "Dance" can be classified as a verb.,DPO 之后变成 The input string "Dance" could be classified as a verb. ——意思没变,但用 "could" 替换了 "can",语气从"断言"变成"更谨慎、更婉转",更符合我们喂给它的"礼貌"偏好。这正是偏好微调该有的样子——语义不变,风格微调。
用测试集再做一遍同样的对比,结论一致:策略模型比参考模型稍微更有礼貌了一点。
关键概念
- 偏好微调(preference finetuning):在指令微调之后的可选一步,用"哪个回答更好"的数据把模型对齐到人类偏好。DPO 是其中最常用的一种方法。
- 偏好数据(preference data):每条样本带一对回答——
chosen(被偏好的)和rejected(被嫌弃的)。它教的不是"什么是标准答案",而是"两个答案里哪个更好"。 - chosen / rejected:好答案 / 坏答案。数据集中这两个字段,是 DPO 训练的全部监督信号。
- DPO(Direct Preference Optimization,直接偏好优化):把偏好判断直接写成损失函数、直接用梯度下降优化模型的方法。它与 RLHF 的核心区别是不需要单独训练奖励模型。
- 策略模型(policy model, ):我们要优化的模型,从 SFT 模型初始化。
- 参考模型(reference model, ):SFT 模型的冻结副本,全程不变,提供 DPO 里"相对基线"的锚点。它不需要进优化器。
- :控制策略模型偏离参考模型程度的超参数,常用 0.1~0.5。笔记本的建议是:值越大,DPO 效果越弱。
- 对数概率比值(log-ratio):,利用 在代码里化成两个减法。它度量"策略模型相对参考模型抬高了多少这条回答的概率"。
- log-sigmoid 损失:
-F.logsigmoid(beta * logits),即公式里的 ,把"好答案优于坏答案"的可信度变成可最小化的损失。 - 隐式奖励(implicit reward):
chosen_rewards = model_chosen_logprobs - reference_chosen_logprobs,是"藏"在语言模型里的奖励信号——这正是 DPO "不需要显式奖励模型"的体现。 - 奖励边际(reward margin):好答案奖励减去坏答案奖励。训练中它应稳步上升,是观察 DPO 是否在起作用的直观指标。
- 奖励模型(reward model):RLHF 里那一个单独训练、给答案打分的模型。DPO 省掉了它。
- RLHF(Reinforcement Learning from Human Feedback):先训奖励模型、再用强化学习(如 PPO)优化策略、并用 KL 约束防止跑偏的对齐流程。模型多、流程长、不稳定。
- DPO 崩塌(collapse):DPO 训练常见的失败模式——损失继续下降、模型却开始输出无意义文本。所以只训 1 个 epoch、用极小的学习率。
动手练习
- 把 改成 0.5 再训一次:对比损失曲线和定性结果,体会 是怎么"减弱 DPO 效果"的。
- 改换偏好标准:不用"礼貌/不礼貌",换一个别的偏好标准(比如"详细 vs 简洁")重新生成偏好数据,看看模型会往哪个方向偏。可参考 create-preference-data-ollama.ipynb 的第 3 种造法。
- 多训几个 epoch,观察崩塌:故意把
num_epochs调大,看看从第几轮开始模型开始说胡话——亲手见到"collapse",你对"DPO 只训 1 轮"这条经验会理解得更深。 - 做个对照实验:分别令
policy_model与reference_model是同一个对象、以及是两份独立副本(当前做法),看看训练结果有无差别,理解"参考模型必须冻结"的必要性。 - 用 LLM 裁判打分:借用第 7 章的
generate_model_scores,让 Llama 3 给参考模型和策略模型的回答分别打"礼貌分",用数据量化 DPO 带来的风格变化。