22 KiB
第 21 课:DPO 数据和 reference model
这一课只解决一个问题:MiniMind 的 DPO 训练如何把 chosen/rejected 偏好样本变成模型可计算的 logprob,并为什么需要一个冻结的 reference model。
目录
- 0. 本节主线
- 1. 本节要懂的 6 个原理
- 2. DPO 数据和 reference model 完整原理
- 3. 源码阅读顺序图
- 4. MiniMind 源码走读
- 5. 本节必须会写 / 暂时不要求
- 6. 实验验证
- 7. 阶段组装
- 8. 本节检查
- 9. 下一课
0. 本节主线
DPO 数据和 reference model 的流程是:
dpo.jsonl 里每条样本有 chosen / rejected
-> chosen 和 rejected 都是完整对话
-> DPODataset 分别渲染 chat template
-> 分别 tokenize + padding
-> 分别切成 x=input_ids[:-1], y=input_ids[1:]
-> 分别生成 assistant 区域 mask
-> train_epoch 把 chosen 和 rejected 拼到同一个 batch
-> policy model 计算 token logprob
-> reference model 计算 token logprob,但不更新
-> 第 22 课再用这些 logprob 计算 DPO loss
这节课先不推完整 DPO loss。你只需要抓住四件事:
1. DPO 数据是一对回复:chosen 更好,rejected 更差。
2. policy model 是正在训练的模型。
3. reference model 是冻结的基准模型。
4. 两个模型都要对 chosen/rejected 算 logprob,后面才能比较偏好。
一句话:
DPO 不是让模型直接模仿 chosen,而是让 policy 相对 reference 更偏向 chosen、远离 rejected。
1. 本节要懂的 6 个原理
| 原理 | 要理解什么 | 源码位置 |
|---|---|---|
| DPO 数据是偏好对 | chosen 是更好回复,rejected 是较差回复 |
README.md:461-478 |
| DPODataset 分别处理两条对话 | chosen/rejected 各自渲染 chat template 并 tokenize | dataset/lm_dataset.py:135-153 |
| DPO 样本已经提前 shift | dataset 返回 x=input_ids[:-1]、y=input_ids[1:]、mask=loss_mask[1:] |
dataset/lm_dataset.py:155-174 |
| mask 只保留 assistant 区域 | DPO 只比较回复 token,不训练 user/system prompt | dataset/lm_dataset.py:176-192 |
| policy/ref 从同一权重开始 | policy 被训练,ref 冻结作为基准 | trainer/train_dpo.py:182-194 |
| chosen/rejected 会拼到同一个 batch | 脚本把 chosen 放前半,rejected 放后半 | trainer/train_dpo.py:57-83 |
学完本节,你应该能说明:
DPODataset 返回的六个 tensor 各是什么;
为什么 DPO 里 x/y 不再需要模型 forward 内部 shift;
reference model 为什么和 policy 初始权重相同但不更新;
为什么 train_epoch 要把 chosen/rejected 在 batch 维拼接。
2. DPO 数据和 reference model 完整原理
2.1 chosen/rejected 是一对完整答案
SFT 数据通常是一条对话:
{"conversations": [{"role": "user", "content": "Q"}, {"role": "assistant", "content": "A"}]}
DPO 数据是一对对话:
{
"chosen": [
{"role": "user", "content": "Q"},
{"role": "assistant", "content": "good answer"}
],
"rejected": [
{"role": "user", "content": "Q"},
{"role": "assistant", "content": "bad answer"}
]
}
它表达的是:
同一个或相近 prompt 下,chosen 比 rejected 更符合偏好。
DPO 不需要 reward model 在线打分。偏好信息已经写在静态数据里:
chosen > rejected
2.2 DPO 仍然要把文本变成 token 序列
chosen 和 rejected 都是 messages 列表,不能直接喂给模型。
它们仍然要经过:
messages
-> apply_chat_template
-> prompt string
-> tokenizer
-> input_ids
和 SFT 一样,prompt 里会包含 user、assistant、特殊分隔符等结构。不同的是,DPO 一条样本会得到两条 token 序列:
chosen_input_ids
rejected_input_ids
2.3 DPO dataset 直接返回 x/y/mask
第 10 课讲过,causal LM 训练要做 shift:
logits[..., :-1, :] 对齐 labels[..., 1:]
DPO 的 DPODataset 不返回完整 input_ids 和 labels,而是提前切好:
x = input_ids[:-1]
y = input_ids[1:]
mask = assistant_loss_mask[1:]
所以 DPO 训练脚本里:
logits_to_log_probs(logits, y)
不再额外 shift。因为:
model(x) 的 logits 位置 i
正好预测 y 位置 i
这是本节最容易错的地方。
2.4 mask 决定比较哪些 token
DPO 不应该比较 user/system prompt token 的概率,因为这些 token 是条件,不是要优化的回复。
所以 DPODataset 生成:
mask_chosen
mask_rejected
mask 为 1 的位置是 assistant 回复区域;mask 为 0 的位置是 prompt、padding 或其它不参与偏好比较的位置。
后面算 sequence logprob 时会做:
token_logprob * mask
再沿 seq 维求和。
2.5 policy model 和 reference model 的区别
DPO 训练里有两套同结构模型:
policy model: 正在训练的模型
reference model: 冻结的基准模型
它们通常从同一个 SFT 权重开始:
from_weight = full_sft
训练开始时:
policy 和 reference 的行为一样
训练过程中:
policy 更新;
reference 不更新。
reference 的作用是给 policy 一个“不要偏离基座太多”的基准。DPO loss 会比较:
policy 对 chosen/rejected 的偏好变化
reference 对 chosen/rejected 的原始偏好
第 22 课会完整推这个公式。
2.6 为什么 chosen/rejected 要拼在同一个 batch
MiniMind 的 train_dpo.py 把 chosen 和 rejected 在 batch 维拼起来:
x = concat([x_chosen, x_rejected], dim=0)
y = concat([y_chosen, y_rejected], dim=0)
mask = concat([mask_chosen, mask_rejected], dim=0)
这样 policy 和 ref 都只需要各 forward 一次:
前半 batch: chosen
后半 batch: rejected
后面的 DPO loss 再按前后半切开。
这个做法简洁,但有一个隐含约定:
chosen 和 rejected 必须按相同顺序成对排列。
如果 batch 里顺序乱了,chosen/rejected 配对就错了。
3. 源码阅读顺序图
建议按这个顺序读:
1. README.md:461-478
看 dpo.jsonl 的数据格式。
2. dataset/lm_dataset.py:122-174
看 DPODataset 如何把 chosen/rejected 转成 x/y/mask。
3. dataset/lm_dataset.py:176-192
看 assistant 区域 mask 如何生成。
4. trainer/train_dpo.py:131-155
看 DPO 脚本默认参数。
5. trainer/train_dpo.py:182-194
看 policy/ref 如何初始化和冻结。
6. trainer/train_dpo.py:57-83
看 chosen/rejected 如何拼 batch,policy/ref 如何算 logprob。
7. trainer/train_dpo.py:34-50
只预览 DPO loss 的输入,不深入公式。
和前几课的关系:
第 5 课:assistant mask
第 10 课:logits 和 labels shift
第 16 课:SFTDataset 如何训练 assistant
第 20 课:teacher/reference 都是冻结模型,但用途不同
第 21 课:DPO 偏好对和 reference model
第 22 课:DPO loss 公式和手算
4. MiniMind 源码走读
4.1 DPO 数据格式是 chosen/rejected
源码证据 A:README 中的数据格式
文件:README.md:461-478
看它是为了理解:DPO 数据不是普通 SFT conversation,而是一对偏好答案。
代码摘录:
{
"chosen": [
{"content": "Q", "role": "user"},
{"content": "good answer", "role": "assistant"}
],
"rejected": [
{"content": "Q", "role": "user"},
{"content": "bad answer", "role": "assistant"}
]
}
这段配置说明:
chosen和rejected都是 messages 列表。- 两边通常共享同一个用户问题。
chosen是更符合偏好的回复。rejected是相对较差的回复。
理解到这一步就够:
DPO 的监督信号不是一个正确答案,而是“chosen 比 rejected 好”。
4.2 DPODataset 分别渲染 chosen/rejected
源码证据 A:读取样本并渲染 prompt
文件:dataset/lm_dataset.py:135-147
看它是为了理解:chosen/rejected 两边都要走 chat template。
代码摘录:
sample = self.samples[index]
chosen = sample['chosen']
rejected = sample['rejected']
chosen_prompt = self.tokenizer.apply_chat_template(
chosen, tokenize=False, add_generation_prompt=False
)
chosen_prompt = post_processing_chat(chosen_prompt)
rejected_prompt = self.tokenizer.apply_chat_template(
rejected, tokenize=False, add_generation_prompt=False
)
rejected_prompt = post_processing_chat(rejected_prompt)
这段代码说明:
chosen和rejected分开处理。- 二者都要转成完整 prompt 字符串。
add_generation_prompt=False,因为答案已经在数据里。post_processing_chat会按规则处理空 thinking 标签。
源码证据 B:tokenize 和 padding
文件:dataset/lm_dataset.py:148-153
看它是为了理解:两边序列会被整理到固定长度。
代码摘录:
chosen_encoding = self.tokenizer(
chosen_prompt, truncation=True, max_length=self.max_length, padding='max_length'
)
rejected_encoding = self.tokenizer(
rejected_prompt, truncation=True, max_length=self.max_length, padding='max_length'
)
这段代码说明:
- chosen/rejected 都会截断到
max_length。 - 不足部分会 padding。
- 后续 mask 会避免 padding 参与偏好比较。
4.3 DPODataset 提前完成 x/y shift
源码证据:返回 x/y/mask
文件:dataset/lm_dataset.py:155-174
看它是为了理解:DPO batch 里的 x_chosen 和 y_chosen 为什么长度是 max_length - 1。
代码摘录:
chosen_input_ids = chosen_encoding['input_ids']
chosen_loss_mask = self.generate_loss_mask(chosen_input_ids)
rejected_input_ids = rejected_encoding['input_ids']
rejected_loss_mask = self.generate_loss_mask(rejected_input_ids)
x_chosen = torch.tensor(chosen_input_ids[:-1], dtype=torch.long)
y_chosen = torch.tensor(chosen_input_ids[1:], dtype=torch.long)
mask_chosen = torch.tensor(chosen_loss_mask[1:], dtype=torch.long)
x_rejected = torch.tensor(rejected_input_ids[:-1], dtype=torch.long)
y_rejected = torch.tensor(rejected_input_ids[1:], dtype=torch.long)
mask_rejected = torch.tensor(rejected_loss_mask[1:], dtype=torch.long)
这段代码说明:
x_*是模型输入。y_*是每个位置要预测的下一个 token。mask_*和y_*对齐,所以也取[1:]。- 这里已经完成了第 10 课讲过的 shift。
关键判断:
DPO 的 logits_to_log_probs 不再做 shift;
因为 dataset 已经把 x/y 对齐好了。
4.4 generate_loss_mask 只标出 assistant 回复
源码证据:assistant mask
文件:dataset/lm_dataset.py:176-192
看它是为了理解:DPO 为什么只比较回答区域的 logprob。
代码摘录:
loss_mask = [0] * len(input_ids)
...
if input_ids[i:i + len(self.bos_id)] == self.bos_id:
start = i + len(self.bos_id)
...
for j in range(start, min(end + len(self.eos_id), self.max_length)):
loss_mask[j] = 1
...
return loss_mask
这段代码说明:
- 初始所有位置都是 0。
- 找到 assistant 起始标记后,把 assistant 内容区设为 1。
- 到 eos 为止,包括 eos 区域。
- user/system/padding 仍然是 0。
和 SFT 的共同点:
都只训练 assistant 回复区域。
和 SFT 的不同点:
SFT 用 label=-100 忽略非 assistant 区域;
DPO 用 mask=0 忽略非 assistant 区域。
4.5 DPO 脚本同时构造 policy 和 reference
源码证据 A:脚本默认参数
文件:trainer/train_dpo.py:131-155
看它是为了理解:DPO 从哪个权重开始,默认用什么数据和 beta。
代码摘录:
parser.add_argument('--save_weight', default='dpo', type=str)
parser.add_argument("--learning_rate", type=float, default=4e-8)
parser.add_argument("--data_path", type=str, default="../dataset/dpo.jsonl")
parser.add_argument('--from_weight', default='full_sft', type=str)
parser.add_argument('--beta', default=0.15, type=float)
这段代码说明:
- DPO 默认保存为
dpo_{hidden_size}.pth。 - 默认从
full_sft开始。 - 默认数据是
dpo.jsonl。 - DPO 学习率很小,源码注释也提示避免遗忘。
beta是第 22 课要讲的 DPO 强度参数。
源码证据 B:policy/ref 初始化
文件:trainer/train_dpo.py:182-194
看它是为了理解:policy model 和 reference model 的关系。
代码摘录:
model, tokenizer = init_model(lm_config, args.from_weight, device=args.device)
Logger(f'策略模型总参数量:{sum(p.numel() for p in model.parameters()) / 1e6:.3f} M')
ref_model, _ = init_model(lm_config, args.from_weight, device=args.device)
ref_model.eval()
ref_model.requires_grad_(False)
Logger(f'参考模型总参数量:{sum(p.numel() for p in ref_model.parameters()) / 1e6:.3f} M')
train_ds = DPODataset(args.data_path, tokenizer, max_length=args.max_seq_len)
optimizer = optim.AdamW(model.parameters(), lr=args.learning_rate)
这段代码说明:
model是 policy,要被 optimizer 更新。ref_model从同一个from_weight加载,但被冻结。- ref_model 用 eval 模式。
- optimizer 只接收 policy 参数。
- DPO 使用
DPODataset,不是SFTDataset。
和第 20 课 distillation 的相似点:
都有一个冻结模型参与 forward。
关键区别:
Distillation 的 teacher 给 soft target;
DPO 的 reference 给偏离基座的参照项。
4.6 train_epoch 拼接 chosen/rejected
源码证据 A:batch 取出六个 tensor
文件:trainer/train_dpo.py:57-67
看它是为了理解:DPODataset 返回值如何进入训练循环。
代码摘录:
x_chosen = batch['x_chosen'].to(args.device)
x_rejected = batch['x_rejected'].to(args.device)
y_chosen = batch['y_chosen'].to(args.device)
y_rejected = batch['y_rejected'].to(args.device)
mask_chosen = batch['mask_chosen'].to(args.device)
mask_rejected = batch['mask_rejected'].to(args.device)
x = torch.cat([x_chosen, x_rejected], dim=0)
y = torch.cat([y_chosen, y_rejected], dim=0)
mask = torch.cat([mask_chosen, mask_rejected], dim=0)
这段代码说明:
- 一个 batch 里有 chosen 和 rejected 两套序列。
- 拼接后 batch 维变成原来的 2 倍。
- 前半部分是 chosen。
- 后半部分是 rejected。
y和mask与x同样拼接,顺序必须一致。
源码证据 B:ref/policy 都计算 logprob
文件:trainer/train_dpo.py:73-83
看它是为了理解:reference model 虽然冻结,但仍然参与 forward。
代码摘录:
with torch.no_grad():
ref_outputs = ref_model(x)
ref_logits = ref_outputs.logits
ref_log_probs = logits_to_log_probs(ref_logits, y)
outputs = model(x)
logits = outputs.logits
policy_log_probs = logits_to_log_probs(logits, y)
dpo_loss_val = dpo_loss(ref_log_probs, policy_log_probs, mask, beta=beta)
这段代码说明:
- ref forward 用
torch.no_grad()。 - policy forward 正常构建反向图。
- 两边都把 logits 转成 token logprob。
- DPO loss 接收 ref/policy logprob 和 mask。
第 21 课只需要知道:
logprob 是 DPO loss 的输入。
第 22 课再展开:
chosen/rejected logprob 如何变成 DPO loss。
4.7 logits_to_log_probs 从 logits 中取目标 token 概率
源码证据:gather 目标 token logprob
文件:trainer/train_dpo.py:25-31
看它是为了理解:模型输出 [batch, seq, vocab] 后,如何得到每个目标 token 的 logprob。
代码摘录:
def logits_to_log_probs(logits, labels):
log_probs = F.log_softmax(logits, dim=2)
log_probs_per_token = torch.gather(log_probs, dim=2, index=labels.unsqueeze(2)).squeeze(-1)
return log_probs_per_token
这段代码说明:
log_softmax把 vocab 维 logits 变成 log probability。labels.unsqueeze(2)把[batch, seq]变成[batch, seq, 1]。gather取出每个位置目标 token 的 logprob。- 返回 shape 是
[batch, seq]。
注意:
这里没有 ignore_index=-100。
原因是 DPO dataset 的 y 是真实 token id,包括 padding token id;不参与训练的位置通过 mask=0 处理,而不是通过 -100 处理。
5. 本节必须会写 / 暂时不要求
必须会写:
1. 画出 DPO 数据格式:
chosen: [user, assistant_good]
rejected: [user, assistant_bad]
2. 说明 DPODataset 返回的六个 tensor:
x_chosen, y_chosen, mask_chosen
x_rejected, y_rejected, mask_rejected
3. 说明 x/y/mask 的 shift 关系:
x = input_ids[:-1]
y = input_ids[1:]
mask = loss_mask[1:]
4. 说明 policy/ref 的关系:
同一 from_weight 初始化;
policy 训练;
ref 冻结。
5. 说明 logits_to_log_probs:
logits -> log_softmax -> gather labels -> token logprob。
暂时不要求:
1. DPO loss 公式推导。
2. beta 对训练强度的影响。
3. chosen/rejected logratio 的手算。
4. reference output 预缓存。
5. PPO 到 DPO 的理论推导。
6. 多轮偏好数据质量评估。
6. 实验验证
本节新增两个文件:
course/labs/tiny_dpo.jsonl
course/labs/trace_dpo_dataset_reference.py
这个实验验证:
1. tiny DPO 数据能被 DPODataset 读取。
2. chosen/rejected 都会变成 x/y/mask。
3. x/y/mask 的长度是 max_length - 1。
4. chosen/rejected 拼接后 batch 维翻倍。
5. policy/ref 对同一批 token 计算 logprob。
6. ref_model 被冻结。
运行命令:
cd /home/sun/minimind
python course/labs/trace_dpo_dataset_reference.py \
--data_path course/labs/tiny_dpo.jsonl \
--max_length 96
重点记录:
x_chosen.shape =
y_chosen.shape =
mask_chosen_active =
x_cat.shape =
policy_log_probs.shape =
ref_log_probs.shape =
initial_policy_ref_logprob_max_abs_diff =
ref_requires_grad =
你应该看到:
x/y/mask 的 seq 长度是 max_length - 1;
拼接后的 batch 维是 2;
policy/ref 初始权重相同时 logprob diff 接近 0;
ref_requires_grad=False。
这个实验不验证 DPO loss 是否正确。第 22 课会在这些 logprob 基础上手算 DPO loss。
6.1 真实训练命令
如果本地已经有:
out/full_sft_768.pth
dataset/dpo.jsonl
可以跑原项目 DPO:
cd /home/sun/minimind/trainer
python train_dpo.py \
--from_weight full_sft \
--data_path ../dataset/dpo.jsonl \
--epochs 1
当前课程环境如果缺真实权重和数据,就不要把这个作为本节必跑验收。本节重点是:
DPODataset 的输出结构;
reference model 的冻结;
policy/ref logprob 的输入形状。
7. 阶段组装
第 21 课是 DPO 阶段的第一半:
数据和模型准备:
tiny_dpo.jsonl
DPODataset
policy model
reference model
token logprob
第 22 课会补第二半:
sequence logprob
chosen/rejected 分组
policy logratio
reference logratio
DPO loss
beta
7.1 教学版 DPO 后续实现边界
后续如果进入 course/impl/,DPO 阶段可以拆成:
course/impl/core/datasets.py::CourseDPODataset
course/impl/core/losses.py::sequence_log_probs
course/impl/core/losses.py::dpo_loss
course/impl/train_dpo_impl.py
第 21 课只要求你能把数据准备对。
第 22 课再要求你手写:
sequence_log_probs
dpo_loss
7.2 和原项目的差异
| 内容 | 原项目 | 教学版建议 |
|---|---|---|
| 数据 | dataset/dpo.jsonl |
course/labs/tiny_dpo.jsonl |
| 模型 | 真实 full_sft 权重 | tiny 随机模型或已完成的 course_sft |
| 训练 | 完整 DPO 训练 | 先 trace 数据和 logprob |
| loss | train_dpo.py::dpo_loss |
第 22 课手写和对齐 |
| reference | 每步 forward | 可以先每步 forward,不做缓存 |
7.3 Portfolio 记录
完成本节后,可以在 course/portfolio/experiments.md 记录:
DPO data/reference trace:
- 构造 tiny chosen/rejected 偏好样本。
- 用 DPODataset 生成 x/y/mask。
- 验证 x/y/mask 已经 shift 对齐。
- 拼接 chosen/rejected batch。
- 初始化 policy/ref,冻结 ref。
- 对同一批序列计算 policy/ref token logprob。
在 course/notes/mistakes.md 记录容易错的点:
1. DPO dataset 已经 shift,训练脚本里不要再 shift 一次。
2. chosen/rejected 拼接后顺序不能乱。
3. mask 要和 y 对齐,所以取 loss_mask[1:]。
4. DPO 的 y 不能含 -100;忽略区域靠 mask。
5. reference model 需要冻结,但仍然要 forward。
8. 本节检查
- DPO 的
chosen和rejected分别表示什么? DPODataset为什么返回x=input_ids[:-1]和y=input_ids[1:]?mask_chosen为什么取chosen_loss_mask[1:]?- DPO 里为什么不用
label=-100,而是用 mask? - policy model 和 reference model 为什么从同一个
from_weight初始化? - reference model 为什么要冻结,但仍然要 forward?
- chosen/rejected 拼接到同一个 batch 后,后续如何知道哪一半是哪一类?
9. 下一课
第 22 课进入 DPO loss 源码:
22_dpo_loss_source.md
下一课要解决:
logits_to_log_probs 如何得到 token logprob;
mask 后的 sequence logprob 如何求和;
chosen/rejected 如何拆成两组;
policy logratio 和 reference logratio 分别是什么;
DPO loss = -logsigmoid(beta * logits) 如何手算。