minimind/course/22_dpo_loss_source.md

835 lines
20 KiB
Markdown
Raw Blame History

This file contains ambiguous Unicode characters!

This file contains ambiguous Unicode characters that may be confused with others in your current locale. If your use case is intentional and legitimate, you can safely ignore this warning. Use the Escape button to highlight these characters.

# 第 22 课:DPO loss 源码
这一课只解决一个问题:MiniMind 的 `train_dpo.py` 如何把 policy/reference 对 chosen/rejected 的 token logprob 变成一个可反向传播的 DPO loss。
## 目录
- [0. 本节主线](#l22-mainline)
- [1. 本节要懂的 6 个原理](#l22-principles)
- [2. DPO loss 完整原理](#l22-complete-principle)
- [3. 源码阅读顺序图](#l22-reading-order)
- [4. MiniMind 源码走读](#l22-source-walkthrough)
- [5. 本节必须会写 / 暂时不要求](#l22-must-write)
- [6. 手写模块](#l22-handwrite)
- [7. 实验验证](#l22-experiment)
- [8. 阶段组装](#l22-stage-assembly)
- [9. 本节检查](#l22-check)
- [10. 下一课](#l22-next)
<a id="l22-mainline"></a>
## 0. 本节主线
DPO loss 的计算链路是:
```text
policy/ref 分别 forward 同一批 x
-> logits_to_log_probs 取出每个 y token 的 logprob
-> mask 去掉 prompt/padding,只保留 assistant 回复 token
-> 每条序列沿 seq 维求和,得到 sequence logprob
-> batch 前半是 chosen,后半是 rejected
-> policy chosen - policy rejected 得到 policy logratio
-> ref chosen - ref rejected 得到 reference logratio
-> 两个 logratio 相减,得到 policy 相对 reference 的偏好提升
-> loss = -logsigmoid(beta * improvement)
```
一句话:
```text
DPO 奖励的是:policy 相比 reference,更明显地偏向 chosen 而不是 rejected。
```
这节课不是重新讲 `DPODataset`。第 21 课已经解决:
```text
chosen/rejected -> x/y/mask
policy/ref -> token logprob
```
第 22 课只处理:
```text
token logprob -> sequence logprob -> logratio -> DPO loss
```
<a id="l22-principles"></a>
## 1. 本节要懂的 6 个原理
| 原理 | 要理解什么 | 源码位置 |
|---|---|---|
| token logprob 来自 gather | 从 `[batch, seq, vocab]` 里取出目标 token 的 log probability | `trainer/train_dpo.py:25-31` |
| sequence logprob 是 masked sum | 只把 assistant 区域 token logprob 加起来 | `trainer/train_dpo.py:34-37` |
| batch 前半/后半有语义 | 前半是 chosen,后半是 rejected | `trainer/train_dpo.py:39-44` |
| policy logratio 表示 policy 偏好 | `chosen_policy - rejected_policy` 越大,policy 越偏向 chosen | `trainer/train_dpo.py:46` |
| reference logratio 是基准偏好 | DPO 看 policy 相对 ref 的偏好变化,不只看绝对概率 | `trainer/train_dpo.py:47-48` |
| loss 用 `-logsigmoid` | improvement 越大,loss 越小;beta 控制强度 | `trainer/train_dpo.py:49-50`, `README.md:951-956` |
学完本节,你应该能手算一条偏好样本的:
```text
chosen_policy_logprob
rejected_policy_logprob
chosen_ref_logprob
rejected_ref_logprob
pi_logratio
ref_logratio
logits = pi_logratio - ref_logratio
loss = -logsigmoid(beta * logits)
```
<a id="l22-complete-principle"></a>
## 2. DPO loss 完整原理
### 2.1 为什么不是只最大化 chosen
如果只最大化 chosen,就会退回到类似 SFT:
```text
让 chosen 的概率变高
```
但 DPO 的数据里还有 rejected。偏好信息不是:
```text
chosen 是唯一正确答案
```
而是:
```text
chosen 比 rejected 更好
```
所以 DPO 关注的是两条回答的相对概率:
```text
log p(chosen) - log p(rejected)
```
这就是 logratio。
### 2.2 为什么还要 reference model
只看 policy 的 logratio:
```text
policy_logratio = log pi(chosen) - log pi(rejected)
```
会鼓励 policy 强烈偏向 chosen。但这没有约束 policy 偏离原始 SFT 模型多远。
reference model 提供一个基准:
```text
ref_logratio = log ref(chosen) - log ref(rejected)
```
DPO 看的是:
```text
policy_logratio - ref_logratio
```
含义是:
```text
policy 是否比 reference 更偏向 chosen。
```
如果 reference 本来就更喜欢 chosen,而 policy 只是保持原样,那么提升不大;如果 policy 在训练后更明显地喜欢 chosen,提升变大,loss 变小。
### 2.3 为什么 sequence logprob 要用 sum
一条回复有多个 token。
假设 chosen 回复 token 是:
```text
y = [y1, y2, y3]
```
模型给这条回复的条件概率可以写成:
```text
p(y|x) = p(y1|x) * p(y2|x,y1) * p(y3|x,y1,y2)
```
取 log 后乘法变加法:
```text
log p(y|x) = log p(y1|...) + log p(y2|...) + log p(y3|...)
```
所以源码里:
```python
(log_probs * mask).sum(dim=1)
```
得到的是每条回复的 sequence logprob。
注意:这里用 sum,不是 mean。sum 对应整段序列的 log probability。第 22 课先按源码理解,不展开不同长度归一化的变体。
### 2.4 `-logsigmoid` 在优化什么
DPO 源码最终计算:
```text
loss = -logsigmoid(beta * logits)
```
其中:
```text
logits = policy_logratio - ref_logratio
```
如果 `logits` 很大:
```text
policy 相对 ref 更偏向 chosen
sigmoid(beta * logits) 接近 1
logsigmoid 接近 0
loss 接近 0
```
如果 `logits` 很小甚至为负:
```text
policy 没有比 ref 更偏向 chosen,甚至更偏向 rejected
sigmoid(beta * logits) 小
logsigmoid 是更大的负数
loss 变大
```
所以 DPO loss 的梯度会推动 policy:
```text
提高 chosen sequence logprob
降低 rejected sequence logprob
同时相对 reference 控制变化
```
### 2.5 beta 控制偏好优化强度
`beta` 乘在:
```text
policy_logratio - ref_logratio
```
前面。
直观理解:
```text
beta 越大,模型越强烈响应偏好差异;
beta 越小,更新更温和。
```
MiniMind 默认:
```text
beta = 0.15
```
第 22 课只要求理解 beta 是 DPO loss 的强度系数,不要求从 KL 约束推导它。
<a id="l22-reading-order"></a>
## 3. 源码阅读顺序图
建议按这个顺序读:
```text
1. README.md:948-956
先看 DPO 公式长什么样。
2. trainer/train_dpo.py:25-31
看 logits_to_log_probs 如何得到 token logprob。
3. trainer/train_dpo.py:34-37
看 token logprob 如何通过 mask 变成 sequence logprob。
4. trainer/train_dpo.py:39-44
看 chosen/rejected 如何按 batch 前后半拆开。
5. trainer/train_dpo.py:46-50
看 policy/ref logratio 和最终 loss。
6. trainer/train_dpo.py:73-85
看 train_epoch 如何把 ref/policy logprob 接到 dpo_loss。
```
和前几课的关系:
```text
第 10 课:logits、labels、next-token 对齐
第 21 课:DPO x/y/mask 和 reference model
第 22 课:logprob 和 DPO loss
第 23 课:PPO 训练链路
```
<a id="l22-source-walkthrough"></a>
## 4. MiniMind 源码走读
### 4.1 README 里的 DPO 公式
#### 源码证据:DPO 数学形式
文件:`README.md:948-956`
看它是为了理解:代码里的 `pi_logratios - ref_logratios` 对应公式里的哪一部分。
公式摘录:
$$
\mathcal{L}_{\mathrm{DPO}}
= -\mathbb{E}\left[
\log \sigma\left(
\beta \left[
\log \frac{\pi_{\theta}(y_w \mid x)}{\pi_{\mathrm{ref}}(y_w \mid x)}
-
\log \frac{\pi_{\theta}(y_l \mid x)}{\pi_{\mathrm{ref}}(y_l \mid x)}
\right]
\right)
\right]
$$
把分式里的 log 展开:
$$
\log \frac{\pi_{\theta}(y_w \mid x)}{\pi_{\mathrm{ref}}(y_w \mid x)}
=
\log \pi_{\theta}(y_w \mid x)
-
\log \pi_{\mathrm{ref}}(y_w \mid x)
$$
所以中括号里的量可以改写成:
$$
\left[
\log \pi_{\theta}(y_w \mid x)
-
\log \pi_{\theta}(y_l \mid x)
\right]
-
\left[
\log \pi_{\mathrm{ref}}(y_w \mid x)
-
\log \pi_{\mathrm{ref}}(y_l \mid x)
\right]
$$
这段公式说明:
- `y_w` 是 winner,也就是 chosen。
- `y_l` 是 loser,也就是 rejected。
- policy 是 `pi_theta`。
- reference 是 `pi_ref`。
- DPO 比的是 chosen/rejected 在 policy 和 reference 下的相对概率。
把公式映射到源码变量:
| 公式符号 | 源码变量 | 含义 |
|---|---|---|
| $\log \pi_{\theta}(y_w \mid x)$ | `chosen_policy_log_probs` | policy 对 chosen 回复的 sequence logprob |
| $\log \pi_{\theta}(y_l \mid x)$ | `reject_policy_log_probs` | policy 对 rejected 回复的 sequence logprob |
| $\log \pi_{\mathrm{ref}}(y_w \mid x)$ | `chosen_ref_log_probs` | reference 对 chosen 回复的 sequence logprob |
| $\log \pi_{\mathrm{ref}}(y_l \mid x)$ | `reject_ref_log_probs` | reference 对 rejected 回复的 sequence logprob |
| $\log \pi_{\theta}(y_w \mid x) - \log \pi_{\theta}(y_l \mid x)$ | `pi_logratios` | policy 对 chosen 相比 rejected 的偏好 |
| $\log \pi_{\mathrm{ref}}(y_w \mid x) - \log \pi_{\mathrm{ref}}(y_l \mid x)$ | `ref_logratios` | reference 对 chosen 相比 rejected 的基准偏好 |
| `pi_logratios - ref_logratios` | `logits` | policy 相对 reference 的偏好提升 |
| $-\log\sigma(\beta \cdot \text{logits})$ | `loss` | DPO loss |
对应到代码就是:
```python
pi_logratios = chosen_policy_log_probs - reject_policy_log_probs
ref_logratios = chosen_ref_log_probs - reject_ref_log_probs
logits = pi_logratios - ref_logratios
loss = -F.logsigmoid(beta * logits)
```
### 4.2 logits_to_log_probs 取每个 token 的 logprob
#### 源码证据:token logprob
文件:`trainer/train_dpo.py:25-31`
看它是为了理解:模型输出 logits 后如何取出目标 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
```
这段代码说明:
- `logits` shape 是 `[batch, seq, vocab]`。
- `labels` shape 是 `[batch, seq]`。
- `F.log_softmax(logits, dim=2)` 在 vocab 维得到 log probability。
- `labels.unsqueeze(2)` 变成 `[batch, seq, 1]`,作为 gather 索引。
- 返回值 shape 是 `[batch, seq]`,每个位置是目标 token 的 logprob。
注意:
```text
DPO dataset 已经把 x/y 对齐好;
这里不再做 logits[..., :-1, :] 和 labels[..., 1:]。
```
如果你在这里再 shift 一次,就会错位。
### 4.3 mask 后求和得到 sequence logprob
#### 源码证据:masked sum
文件:`trainer/train_dpo.py:34-37`
看它是为了理解:token-level logprob 如何变成一条回复的 logprob。
代码摘录:
```python
def dpo_loss(ref_log_probs, policy_log_probs, mask, beta):
ref_log_probs = (ref_log_probs * mask).sum(dim=1)
policy_log_probs = (policy_log_probs * mask).sum(dim=1)
```
这段代码说明:
- 输入的 `ref_log_probs` 和 `policy_log_probs` 都是 `[batch, seq]`。
- `mask` 也是 `[batch, seq]`。
- prompt/padding 区域 mask 为 0,不参与求和。
- assistant 回复区域 mask 为 1,参与求和。
- `sum(dim=1)` 后变成 `[batch]`,每个元素是一条序列的 logprob。
这里覆盖第 21 课的一个关键点:
```text
DPO 的忽略区域靠 mask,不靠 label=-100。
```
### 4.4 按 batch 前后半拆 chosen/rejected
#### 源码证据:batch split
文件:`trainer/train_dpo.py:39-44`
看它是为了理解:为什么 train_epoch 拼接顺序不能乱。
代码摘录:
```python
batch_size = ref_log_probs.shape[0]
chosen_ref_log_probs = ref_log_probs[:batch_size // 2]
reject_ref_log_probs = ref_log_probs[batch_size // 2:]
chosen_policy_log_probs = policy_log_probs[:batch_size // 2]
reject_policy_log_probs = policy_log_probs[batch_size // 2:]
```
这段代码说明:
- `dpo_loss` 假设 batch 前半是 chosen。
- batch 后半是 rejected。
- chosen/ref 和 policy/ref 都按同样位置切分。
- 如果 train loop 拼接顺序错,loss 语义就错。
对应第 21 课的拼接:
```python
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)
```
### 4.5 logratio 表示 chosen 相对 rejected 的偏好
#### 源码证据:policy/ref logratio
文件:`trainer/train_dpo.py:46-48`
看它是为了理解:DPO 为什么不是单独看 chosen 概率。
代码摘录:
```python
pi_logratios = chosen_policy_log_probs - reject_policy_log_probs
ref_logratios = chosen_ref_log_probs - reject_ref_log_probs
logits = pi_logratios - ref_logratios
```
这段代码说明:
- `pi_logratios` 是 policy 对 chosen 相比 rejected 的偏好。
- `ref_logratios` 是 reference 对 chosen 相比 rejected 的基准偏好。
- `logits` 是 policy 相对 reference 的偏好提升。
- 如果 `logits > 0`,policy 比 reference 更偏向 chosen。
- 如果 `logits < 0`,policy 比 reference 更偏向 rejected,或偏好提升不够。
变量名里的 `logits` 这里不是 vocab logits,而是 DPO 二分类目标里的标量分数。它 shape 是:
```text
[batch_size / 2]
```
不要和模型输出的:
```text
[batch, seq, vocab]
```
混淆。
### 4.6 `-logsigmoid` 把偏好提升变成 loss
#### 源码证据:最终 loss
文件:`trainer/train_dpo.py:49-50`
看它是为了理解:为什么 `logits` 越大 loss 越小。
代码摘录:
```python
loss = -F.logsigmoid(beta * logits)
return loss.mean()
```
这段代码说明:
- `beta * logits` 是带强度系数的偏好提升。
- `logsigmoid` 越接近 0,说明 sigmoid 越接近 1。
- 前面加负号后,偏好提升越大,loss 越小。
- `mean()` 对 batch 里的偏好对取平均。
手算时可以记住:
```text
logits 很大 -> loss 接近 0
logits = 0 -> loss = -log(0.5) = 0.693...
logits 为负 -> loss 大于 0.693...
```
### 4.7 train_epoch 如何接入 DPO loss
#### 源码证据:训练步骤
文件:`trainer/train_dpo.py:73-85`
看它是为了理解:DPO loss 如何进入普通训练循环。
代码摘录:
```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)
loss = dpo_loss_val + outputs.aux_loss
loss = loss / args.accumulation_steps
```
这段代码说明:
- ref model forward 不构建梯度。
- policy model forward 正常构建梯度。
- 两边 logprob 的 shape 都是 `[2 * batch, seq]`。
- `dpo_loss` 返回标量。
- 如果 policy 是 MoE,还会加 `outputs.aux_loss`。
- 后续 backward/clip/step 和普通训练一样。
<a id="l22-must-write"></a>
## 5. 本节必须会写 / 暂时不要求
必须会写:
```text
1. 从 logits 和 labels 得到 token logprob:
log_probs = log_softmax(logits, dim=-1)
token_logps = gather(log_probs, labels)
2. 从 token logprob 得到 sequence logprob:
seq_logps = (token_logps * mask).sum(dim=1)
3. 拆 chosen/rejected:
chosen = seq_logps[:batch_size//2]
rejected = seq_logps[batch_size//2:]
4. 计算 logratio:
pi_logratio = chosen_policy - rejected_policy
ref_logratio = chosen_ref - rejected_ref
5. 计算 DPO loss:
logits = pi_logratio - ref_logratio
loss = -logsigmoid(beta * logits).mean()
```
暂时不要求:
```text
1. 从 PPO KL 约束推导 DPO。
2. beta 的系统调参。
3. reference logprob 预缓存。
4. IPO / KTO / ORPO 等变体。
5. 长度归一化、reward margin、label smoothing 等 DPO 变体。
```
<a id="l22-handwrite"></a>
## 6. 手写模块
本节对应教学版文件:
```text
course/impl/core/losses.py
```
### 6.1 补 `sequence_log_probs`
接口:
```python
def sequence_log_probs(logits: torch.Tensor, labels: torch.Tensor, mask: torch.Tensor) -> torch.Tensor:
...
```
对齐源码:
```text
trainer/train_dpo.py::logits_to_log_probs
trainer/train_dpo.py::dpo_loss 的 masked sum
```
行为要求:
```text
输入:
logits: [batch, seq, vocab]
labels: [batch, seq]
mask: [batch, seq]
输出:
seq_logps: [batch]
```
内部步骤:
```text
log_softmax
gather labels
乘 mask
sum(dim=1)
```
### 6.2 补 `dpo_loss`
接口:
```python
def dpo_loss(
policy_chosen_logps: torch.Tensor,
policy_rejected_logps: torch.Tensor,
ref_chosen_logps: torch.Tensor,
ref_rejected_logps: torch.Tensor,
beta: float,
) -> torch.Tensor:
...
```
对齐源码:
```text
trainer/train_dpo.py::dpo_loss
```
行为要求:
```text
pi_logratios = policy_chosen_logps - policy_rejected_logps
ref_logratios = ref_chosen_logps - ref_rejected_logps
logits = pi_logratios - ref_logratios
loss = -logsigmoid(beta * logits).mean()
```
教学版接口把 chosen/rejected 拆开传入,比原源码更显式:
```text
原源码:输入拼接后的 ref_log_probs / policy_log_probs / mask,再内部拆前后半。
教学版:先在调用处得到四个 sequence logprob,再传给 dpo_loss。
```
这样更容易测试和手算。
<a id="l22-experiment"></a>
## 7. 实验验证
本节新增实验:
```text
course/labs/trace_dpo_loss.py
```
它复用:
```text
course/labs/tiny_dpo.jsonl
```
实验验证:
```text
1. 从 policy/ref logits 得到 token logprob。
2. mask 后得到 chosen/rejected sequence logprob。
3. 手算 pi_logratio 和 ref_logratio。
4. 手算 logits = pi_logratio - ref_logratio。
5. 手算 -logsigmoid(beta * logits)。
6. 和 train_dpo.py::dpo_loss 输出对齐。
```
运行命令:
```bash
cd /home/sun/minimind
python course/labs/trace_dpo_loss.py \
--data_path course/labs/tiny_dpo.jsonl \
--max_length 96 \
--beta 0.15
```
重点记录:
```text
chosen_policy_logp =
rejected_policy_logp =
chosen_ref_logp =
rejected_ref_logp =
pi_logratio =
ref_logratio =
dpo_logits =
manual_dpo_loss =
source_dpo_loss =
abs_diff =
```
初始 policy/ref 权重相同时,常见现象是:
```text
pi_logratio == ref_logratio
dpo_logits 接近 0
loss 接近 0.693147
```
因为训练还没开始,policy 相对 reference 没有偏好提升。
<a id="l22-stage-assembly"></a>
## 8. 阶段组装
DPO 阶段现在有两课:
```text
第 21 课:
chosen/rejected 数据
DPODataset
policy/ref model
token logprob 输入
第 22 课:
sequence logprob
policy/ref logratio
DPO loss
```
后续教学版 DPO 最小闭环可以这样组装:
```text
CourseDPODataset
-> policy/ref model
-> sequence_log_probs
-> dpo_loss
-> optimizer 更新 policy
-> 保存 dpo 权重
```
### 8.1 阶段验收顺序
建议先跑两个 lab:
```bash
cd /home/sun/minimind
python course/labs/trace_dpo_dataset_reference.py --data_path course/labs/tiny_dpo.jsonl --max_length 96
python course/labs/trace_dpo_loss.py --data_path course/labs/tiny_dpo.jsonl --max_length 96 --beta 0.15
```
如果补了 `course/impl/core/losses.py`,后续可新增:
```text
course/impl/tests/test_dpo.py
```
测试内容:
```text
sequence_log_probs 对齐源码 gather + mask sum
dpo_loss 对齐 train_dpo.py::dpo_loss
```
### 8.2 Portfolio 记录
完成本节后,可以在 `course/portfolio/experiments.md` 记录:
```text
DPO loss trace:
- 用 tiny chosen/rejected 样本构造 x/y/mask。
- 计算 policy/ref token logprob。
- mask 后求 sequence logprob。
- 拆 chosen/rejected,计算 policy/ref logratio。
- 手算 DPO loss 并和源码函数输出对齐。
```
在 `course/notes/mistakes.md` 记录容易错的点:
```text
1. 把 DPO logits 和模型 vocab logits 混淆。
2. 对 DPO 的 x/y 又额外 shift 一次。
3. 忘记 mask,导致 prompt/padding 进入 sequence logprob。
4. chosen/rejected 前后半拆反。
5. 把 alpha/beta 和蒸馏课的 alpha 混淆。
```
<a id="l22-check"></a>
## 9. 本节检查
1. `logits_to_log_probs` 的输出 shape 是什么?
2. 为什么 sequence logprob 用 `(token_logps * mask).sum(dim=1)`?
3. DPO 源码为什么假设 batch 前半是 chosen,后半是 rejected?
4. `pi_logratios` 和 `ref_logratios` 分别代表什么?
5. `logits = pi_logratios - ref_logratios` 里的 `logits` 为什么不是 vocab logits?
6. 当 policy 和 reference 完全一样时,DPO loss 大约是多少?为什么?
7. beta 变大时,`-logsigmoid(beta * logits)` 对同一个 logits 会发生什么变化?
<a id="l22-next"></a>
## 10. 下一课
第 23 课进入 [PPO 训练链路](23_ppo_training_flow.md)。
下一课要解决:
```text
PPO 为什么需要 rollout;
actor、critic、reference、reward model 分别是什么;
response logprob、reward、advantage 如何进入 PPO loss;
clip ratio 和 KL penalty 在源码里如何实现;
PPO 和 DPO 的训练数据、模型数量、更新方式有什么不同。
```