minimind/course/21_dpo_dataset_and_referenc...

22 KiB
Raw Blame History

第 21 课:DPO 数据和 reference model

这一课只解决一个问题:MiniMind 的 DPO 训练如何把 chosen/rejected 偏好样本变成模型可计算的 logprob,并为什么需要一个冻结的 reference model。

目录

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. 本节检查

  1. DPO 的 chosen 和 rejected 分别表示什么?
  2. DPODataset 为什么返回 x=input_ids[:-1] 和 y=input_ids[1:]?
  3. mask_chosen 为什么取 chosen_loss_mask[1:]?
  4. DPO 里为什么不用 label=-100,而是用 mask?
  5. policy model 和 reference model 为什么从同一个 from_weight 初始化?
  6. reference model 为什么要冻结,但仍然要 forward?
  7. 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) 如何手算。