# 第 21 课:DPO 数据和 reference model 这一课只解决一个问题:MiniMind 的 DPO 训练如何把 `chosen/rejected` 偏好样本变成模型可计算的 logprob,并为什么需要一个冻结的 reference model。 ## 目录 - [0. 本节主线](#l21-mainline) - [1. 本节要懂的 6 个原理](#l21-principles) - [2. DPO 数据和 reference model 完整原理](#l21-complete-principle) - [3. 源码阅读顺序图](#l21-reading-order) - [4. MiniMind 源码走读](#l21-source-walkthrough) - [5. 本节必须会写 / 暂时不要求](#l21-must-write) - [6. 实验验证](#l21-experiment) - [7. 阶段组装](#l21-stage-assembly) - [8. 本节检查](#l21-check) - [9. 下一课](#l21-next) ## 0. 本节主线 DPO 数据和 reference model 的流程是: ```text 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。你只需要抓住四件事: ```text 1. DPO 数据是一对回复:chosen 更好,rejected 更差。 2. policy model 是正在训练的模型。 3. reference model 是冻结的基准模型。 4. 两个模型都要对 chosen/rejected 算 logprob,后面才能比较偏好。 ``` 一句话: ```text 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` | 学完本节,你应该能说明: ```text DPODataset 返回的六个 tensor 各是什么; 为什么 DPO 里 x/y 不再需要模型 forward 内部 shift; reference model 为什么和 policy 初始权重相同但不更新; 为什么 train_epoch 要把 chosen/rejected 在 batch 维拼接。 ``` ## 2. DPO 数据和 reference model 完整原理 ### 2.1 chosen/rejected 是一对完整答案 SFT 数据通常是一条对话: ```json {"conversations": [{"role": "user", "content": "Q"}, {"role": "assistant", "content": "A"}]} ``` DPO 数据是一对对话: ```json { "chosen": [ {"role": "user", "content": "Q"}, {"role": "assistant", "content": "good answer"} ], "rejected": [ {"role": "user", "content": "Q"}, {"role": "assistant", "content": "bad answer"} ] } ``` 它表达的是: ```text 同一个或相近 prompt 下,chosen 比 rejected 更符合偏好。 ``` DPO 不需要 reward model 在线打分。偏好信息已经写在静态数据里: ```text chosen > rejected ``` ### 2.2 DPO 仍然要把文本变成 token 序列 chosen 和 rejected 都是 messages 列表,不能直接喂给模型。 它们仍然要经过: ```text messages -> apply_chat_template -> prompt string -> tokenizer -> input_ids ``` 和 SFT 一样,prompt 里会包含 user、assistant、特殊分隔符等结构。不同的是,DPO 一条样本会得到两条 token 序列: ```text chosen_input_ids rejected_input_ids ``` ### 2.3 DPO dataset 直接返回 x/y/mask 第 10 课讲过,causal LM 训练要做 shift: ```text logits[..., :-1, :] 对齐 labels[..., 1:] ``` DPO 的 `DPODataset` 不返回完整 `input_ids` 和 `labels`,而是提前切好: ```text x = input_ids[:-1] y = input_ids[1:] mask = assistant_loss_mask[1:] ``` 所以 DPO 训练脚本里: ```python logits_to_log_probs(logits, y) ``` 不再额外 shift。因为: ```text model(x) 的 logits 位置 i 正好预测 y 位置 i ``` 这是本节最容易错的地方。 ### 2.4 mask 决定比较哪些 token DPO 不应该比较 user/system prompt token 的概率,因为这些 token 是条件,不是要优化的回复。 所以 `DPODataset` 生成: ```text mask_chosen mask_rejected ``` mask 为 1 的位置是 assistant 回复区域;mask 为 0 的位置是 prompt、padding 或其它不参与偏好比较的位置。 后面算 sequence logprob 时会做: ```text token_logprob * mask ``` 再沿 seq 维求和。 ### 2.5 policy model 和 reference model 的区别 DPO 训练里有两套同结构模型: ```text policy model: 正在训练的模型 reference model: 冻结的基准模型 ``` 它们通常从同一个 SFT 权重开始: ```text from_weight = full_sft ``` 训练开始时: ```text policy 和 reference 的行为一样 ``` 训练过程中: ```text policy 更新; reference 不更新。 ``` reference 的作用是给 policy 一个“不要偏离基座太多”的基准。DPO loss 会比较: ```text policy 对 chosen/rejected 的偏好变化 reference 对 chosen/rejected 的原始偏好 ``` 第 22 课会完整推这个公式。 ### 2.6 为什么 chosen/rejected 要拼在同一个 batch MiniMind 的 `train_dpo.py` 把 chosen 和 rejected 在 batch 维拼起来: ```text 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 一次: ```text 前半 batch: chosen 后半 batch: rejected ``` 后面的 DPO loss 再按前后半切开。 这个做法简洁,但有一个隐含约定: ```text chosen 和 rejected 必须按相同顺序成对排列。 ``` 如果 batch 里顺序乱了,chosen/rejected 配对就错了。 ## 3. 源码阅读顺序图 建议按这个顺序读: ```text 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 的输入,不深入公式。 ``` 和前几课的关系: ```text 第 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,而是一对偏好答案。 代码摘录: ```json { "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` 是相对较差的回复。 理解到这一步就够: ```text DPO 的监督信号不是一个正确答案,而是“chosen 比 rejected 好”。 ``` ### 4.2 DPODataset 分别渲染 chosen/rejected #### 源码证据 A:读取样本并渲染 prompt 文件:`dataset/lm_dataset.py:135-147` 看它是为了理解:chosen/rejected 两边都要走 chat template。 代码摘录: ```python 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` 看它是为了理解:两边序列会被整理到固定长度。 代码摘录: ```python 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`。 代码摘录: ```python 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。 关键判断: ```text 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。 代码摘录: ```python 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 的共同点: ```text 都只训练 assistant 回复区域。 ``` 和 SFT 的不同点: ```text SFT 用 label=-100 忽略非 assistant 区域; DPO 用 mask=0 忽略非 assistant 区域。 ``` ### 4.5 DPO 脚本同时构造 policy 和 reference #### 源码证据 A:脚本默认参数 文件:`trainer/train_dpo.py:131-155` 看它是为了理解:DPO 从哪个权重开始,默认用什么数据和 beta。 代码摘录: ```python 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 的关系。 代码摘录: ```python 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 的相似点: ```text 都有一个冻结模型参与 forward。 ``` 关键区别: ```text Distillation 的 teacher 给 soft target; DPO 的 reference 给偏离基座的参照项。 ``` ### 4.6 train_epoch 拼接 chosen/rejected #### 源码证据 A:batch 取出六个 tensor 文件:`trainer/train_dpo.py:57-67` 看它是为了理解:DPODataset 返回值如何进入训练循环。 代码摘录: ```python 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。 代码摘录: ```python 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 课只需要知道: ```text logprob 是 DPO loss 的输入。 ``` 第 22 课再展开: ```text 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。 代码摘录: ```python 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]`。 注意: ```text 这里没有 ignore_index=-100。 ``` 原因是 DPO dataset 的 `y` 是真实 token id,包括 padding token id;不参与训练的位置通过 `mask=0` 处理,而不是通过 `-100` 处理。 ## 5. 本节必须会写 / 暂时不要求 必须会写: ```text 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。 ``` 暂时不要求: ```text 1. DPO loss 公式推导。 2. beta 对训练强度的影响。 3. chosen/rejected logratio 的手算。 4. reference output 预缓存。 5. PPO 到 DPO 的理论推导。 6. 多轮偏好数据质量评估。 ``` ## 6. 实验验证 本节新增两个文件: ```text course/labs/tiny_dpo.jsonl course/labs/trace_dpo_dataset_reference.py ``` 这个实验验证: ```text 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 被冻结。 ``` 运行命令: ```bash cd /home/sun/minimind python course/labs/trace_dpo_dataset_reference.py \ --data_path course/labs/tiny_dpo.jsonl \ --max_length 96 ``` 重点记录: ```text 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 = ``` 你应该看到: ```text 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 真实训练命令 如果本地已经有: ```text out/full_sft_768.pth dataset/dpo.jsonl ``` 可以跑原项目 DPO: ```bash cd /home/sun/minimind/trainer python train_dpo.py \ --from_weight full_sft \ --data_path ../dataset/dpo.jsonl \ --epochs 1 ``` 当前课程环境如果缺真实权重和数据,就不要把这个作为本节必跑验收。本节重点是: ```text DPODataset 的输出结构; reference model 的冻结; policy/ref logprob 的输入形状。 ``` ## 7. 阶段组装 第 21 课是 DPO 阶段的第一半: ```text 数据和模型准备: tiny_dpo.jsonl DPODataset policy model reference model token logprob ``` 第 22 课会补第二半: ```text sequence logprob chosen/rejected 分组 policy logratio reference logratio DPO loss beta ``` ### 7.1 教学版 DPO 后续实现边界 后续如果进入 `course/impl/`,DPO 阶段可以拆成: ```text 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 课再要求你手写: ```text 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` 记录: ```text 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` 记录容易错的点: ```text 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` 下一课要解决: ```text logits_to_log_probs 如何得到 token logprob; mask 后的 sequence logprob 如何求和; chosen/rejected 如何拆成两组; policy logratio 和 reference logratio 分别是什么; DPO loss = -logsigmoid(beta * logits) 如何手算。 ```