minimind/course/23_ppo_training_flow.md

1521 lines
39 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.

# 第 23 课:PPO 训练链路
这一课先讲 PPO 算法本体,再讲 MiniMind 的 `train_ppo.py` 如何把算法落到 LLM 训练里。
本节要解决的不只是“源码里有哪些变量”,而是这条完整主线:
```text
PPO 是一个 on-policy actor-critic 算法;
它用旧策略采样 trajectory;
用 reward 和 critic 算 advantage;
用新旧策略概率比 ratio 更新 actor;
用 clip 限制更新幅度;
在 RLHF 场景里再用 reference KL 约束模型不要偏离 SFT 模型太远。
```
## 目录
- [0. 本节主线](#l23-mainline)
- [1. 本节要懂的 7 个原理](#l23-principles)
- [2. PPO 完整原理](#l23-complete-principle)
- [3. 源码阅读顺序图](#l23-reading-order)
- [4. MiniMind 源码走读](#l23-source-walkthrough)
- [5. 本节必须会写 / 暂时不要求](#l23-must-write)
- [6. 手写模块](#l23-handwrite)
- [7. 实验验证](#l23-experiment)
- [8. 阶段组装](#l23-stage-assembly)
- [9. 本节检查](#l23-check)
- [10. 下一课](#l23-next)
<a id="l23-mainline"></a>
## 0. 本节主线
PPO 算法的主线是:
```text
初始化 actor policy pi_theta、critic value V_phi、reference policy pi_ref
-> 用当前 actor 采样 response trajectory
-> 保存采样时的 old_logprob
-> 用 reward model / rule 得到 response reward
-> 用 critic 估计 value
-> 用 reward 和 value 计算 advantage 与 return
-> 固定这批 rollout,多轮优化 actor/critic
-> actor 用 ratio = pi_theta / pi_old 做 clipped policy update
-> critic 用 return 做 value update
-> RLHF 额外用 reference KL 约束 actor
-> 更新后的 actor 再进入下一轮 rollout
```
MiniMind 源码里的落地链路是:
```text
RLAIFDataset 只提供 prompt
-> 当前 actor rollout 生成 response
-> rollout 时记录 old response token logprob
-> reward model 和规则给整条 response 一个标量 reward
-> 只在 response token 上建 mask 和 logp_pos
-> critic 给每个 response token 估计 value
-> 把整条 response reward 放到最后一个有效 token
-> GAE 从后往前算 advantages 和 returns
-> actor 重新 forward 得到 new response logprob
-> ratio = exp(new_logp - old_logp)
-> PPO clip policy loss + reference KL penalty 更新 actor
-> clipped value loss 更新 critic
-> 更新 rollout engine 里的 policy 权重
```
一句话:
```text
PPO 先用旧 actor 生成样本,再用 advantage 判断这些 token 应该被新 actor 提高概率还是降低概率,同时用 clip 防止新 actor 一步改得太猛。
```
这节和前两节的区别:
```text
DPO: 离线 chosen/rejected 偏好对,policy/ref 都在同一批静态数据上算 logprob。
PPO: 在线 rollout,数据来自当前 actor,old_logp 必须记录生成当时的策略概率。
```
<a id="l23-principles"></a>
## 1. 本节要懂的 7 个原理
| 原理 | 要理解什么 | 源码位置 |
|---|---|---|
| LLM PPO 的 RL 建模 | state 是 prompt+已生成前缀,action 是下一个 token,trajectory 是整段 response | `dataset/lm_dataset.py:195-224`, `trainer/rollout_engine.py:71-92` |
| PPO 是 on-policy 采样 | response 必须来自当前 actor,旧样本只能有限复用 | `README.md:1091-1100`, `trainer/train_ppo.py:89-101` |
| actor-critic 分工 | actor 生成和更新策略,critic 估计 value,reward model 给回报,reference 做约束 | `trainer/train_ppo.py:36-49`, `trainer/train_ppo.py:365-389`, `trainer/trainer_utils.py:160-177` |
| policy gradient 用 advantage 指方向 | advantage 大于 0 就提高 token 概率,小于 0 就降低 token 概率 | `trainer/train_ppo.py:136-151`, `trainer/train_ppo.py:196-199` |
| old/new logprob 形成 ratio | PPO 不直接最大化 logprob,而是比较新旧策略对同一 token 的概率比 | `trainer/rollout_engine.py:23-36`, `trainer/train_ppo.py:178-191` |
| clip surrogate 限制更新幅度 | ratio 被限制在 `1±epsilon` 附近,避免新策略离采样策略过远 | `README.md:1083-1089`, `trainer/train_ppo.py:191-199` |
| LLM 训练只作用 response token | prompt/padding 不进入 policy/value loss,`logp_pos` 对齐 next-token logits | `trainer/train_ppo.py:117-128`, `trainer/train_ppo.py:200-203` |
学完本节,你应该能画出:
```text
prompt -> rollout response -> old_logp/reward/value -> advantage/return -> new_logp -> ratio -> clipped actor loss + value loss
```
并且能解释下面这些张量的 shape:
```text
gen_out: [batch, prompt_len + response_len]
completion_ids: [batch, response_len]
old_resp_logp: [batch, response_len]
logp_pos: [batch, response_len]
resp_policy_mask:[batch, response_len]
advantages: [batch, response_len]
returns: [batch, response_len]
```
<a id="l23-complete-principle"></a>
## 2. PPO 完整原理
### 2.1 先把 LLM 训练写成 RL 问题
PPO 不是一种“特殊的 cross entropy loss”。它首先是强化学习算法。
一般 RL 里有三个核心对象:
```text
state s_t: 当前状态
action a_t: 当前动作
reward r_t: 动作之后得到的奖励
```
放到 LLM 里:
```text
state s_t = prompt + response 前 t-1 个 token
action a_t = 第 t 个 response token
policy pi_theta(a_t | s_t) = 模型在当前位置输出这个 token 的概率
trajectory y = 一整段 response token
reward R(x, y) = reward model / rule 给 prompt-response 的分数
```
所以 LLM PPO 优化的不是某个固定标签,而是:
```text
让 actor 在 prompt x 下更容易生成高 reward 的 response y。
```
写成目标函数:
$$
J(\theta)
=
\mathbb{E}_{y \sim \pi_\theta(\cdot \mid x)}
\left[
R(x, y)
\right]
$$
这里最关键的一点是:
```text
response y 是模型采样出来的离散 token 序列,不能像普通 CE loss 那样直接对 token 选择过程求导。
```
这就是为什么 PPO 要走 policy gradient 路线。
### 2.2 policy gradient:用 logprob 给采样动作分配责任
policy gradient 的核心结论是:
$$
\nabla_\theta J(\theta)
=
\mathbb{E}\left[
\sum_t
A_t
\nabla_\theta \log \pi_\theta(a_t \mid s_t)
\right]
$$
先不用推导,先理解它的含义:
```text
如果某个 token 后面的结果比预期好,A_t > 0:
提高 log pi_theta(a_t | s_t)
如果某个 token 后面的结果比预期差,A_t < 0:
降低 log pi_theta(a_t | s_t)
```
`A_t` 叫 advantage。它不是原始 reward,而是:
```text
实际回报 - critic 预期回报
```
如果写成要最小化的 loss,最朴素的 policy gradient loss 是:
$$
\mathcal{L}_{\mathrm{PG}}
=
-\mathbb{E}\left[
\sum_t
A_t
\log \pi_\theta(a_t \mid s_t)
\right]
$$
这个式子能解释 PPO 的方向,但还不是 PPO。PPO 还要解决一个问题:策略每次不能改太猛。
### 2.3 为什么要 old policy 和 ratio
PPO 每轮先用当前 actor 采样一批 response。
采样那一刻的 actor 叫旧策略:
$$
\pi_{\mathrm{old}}
$$
更新时 actor 参数已经在变化,叫新策略:
$$
\pi_\theta
$$
同一个 token 在旧策略和新策略下各有一个概率:
$$
\pi_{\mathrm{old}}(a_t \mid s_t)
$$
$$
\pi_\theta(a_t \mid s_t)
$$
PPO 不直接用新策略 logprob,而是用新旧概率比:
$$
r_t(\theta)
=
\frac{
\pi_\theta(a_t \mid s_t)
}{
\pi_{\mathrm{old}}(a_t \mid s_t)
}
$$
在代码里通常用 logprob 更稳定:
$$
r_t(\theta)
=
\exp\left(
\log \pi_\theta(a_t \mid s_t)
-
\log \pi_{\mathrm{old}}(a_t \mid s_t)
\right)
$$
源码对应:
```python
log_ratio = mb_resp_logp - old_resp_logp[inds]
ratio = torch.exp(log_ratio)
```
`ratio` 的含义:
```text
ratio = 1: 新旧 actor 对这个 token 的概率一样
ratio > 1: 新 actor 更倾向这个 token
ratio < 1: 新 actor 更不倾向这个 token
```
为什么要保存 `old_logp`?
```text
因为 response 是 old actor 采样出来的。
如果只看 new_logp,就不知道新策略相对采样策略改了多少。
```
### 2.4 PPO clip:让 policy gradient 变成受限更新
如果只用:
$$
r_t(\theta) A_t
$$
当 `A_t > 0` 时,优化器会倾向把 `ratio` 推得越大越好。
当 `A_t < 0` 时,优化器会倾向把 `ratio` 压得越小越好。
这会让新策略离采样这批数据的旧策略太远,导致训练不稳定。
PPO 的核心处理是 clipped surrogate objective:
$$
\mathcal{L}^{\mathrm{clip}}(\theta)
=
-\mathbb{E}_t
\left[
\min\left(
r_t(\theta) A_t,
\mathrm{clip}(r_t(\theta), 1-\varepsilon, 1+\varepsilon) A_t
\right)
\right]
$$
这个公式的行为要分两种情况理解:
```text
如果 A_t > 0:
希望提高该 token 概率,所以希望 ratio 变大;
但 ratio 超过 1 + epsilon 后,不再继续给额外收益。
如果 A_t < 0:
希望降低该 token 概率,所以希望 ratio 变小;
但 ratio 低于 1 - epsilon 后,不再继续给额外收益。
```
源码是最小化 loss,所以把 `-min(...)` 写成等价的 `max(-..., -...)`:
$$
\mathrm{policy\_loss}
=
\mathbb{E}\left[
\max\left(
-A_t r_t,
-A_t \mathrm{clip}(r_t, 1-\varepsilon, 1+\varepsilon)
\right)
\right]
$$
MiniMind 再加上 reference KL penalty:
$$
\mathrm{actor\_loss}
=
\mathrm{policy\_loss}
+
\beta \mathrm{KL}(\pi_\theta, \pi_{\mathrm{ref}})
$$
### 2.5 critic 和 GAE:advantage 从哪里来
reward model 给的是整条 response 的分数,不是每个 token 的分数。
但 policy loss 要每个 response token 一个 `A_t`。
所以 PPO 引入 critic:
$$
V_\phi(s_t)
\approx
\mathbb{E}[\text{future return} \mid s_t]
$$
critic 的作用是估计:
```text
从这个 token 位置往后,平均能拿到多少回报。
```
有了 `V(s_t)` 后,先算 TD residual:
$$
\delta_t
= r_t + \gamma V(s_{t+1}) - V(s_t)
$$
再用 GAE 从后往前累计:
$$
A_t
= \delta_t + \gamma \lambda A_{t+1}
$$
最后 critic 的训练目标是:
$$
\mathrm{return}_t
=
A_t + V_{\mathrm{old}}(s_t)
$$
MiniMind 的做法是:
```text
token_rewards 全部为 0
-> 最后一个有效 response token 加上整条 response reward
-> critic 估计每个 response token 位置的 value
-> GAE 从后往前算每个 token 的 advantage
-> returns = advantages + old_values
```
这里要注意:
```text
reward 是整条 response 的标量;
advantage 是每个 response token 的训练信号。
```
### 2.6 LLM PPO 为什么还要 reference KL
经典 PPO 可以只讲 actor、critic、environment reward。
但 LLM RLHF 还多一个问题:
```text
reward model 不完美;
如果 actor 只追 reward,可能学到奇怪输出、格式投机、重复或偏离 SFT 行为。
```
所以 MiniMind 像常见 RLHF PPO 一样保留 reference model。
reference model 通常是训练前的 SFT 模型:
```text
pi_ref = frozen SFT policy
```
训练时额外惩罚 actor 偏离 reference:
$$
\beta \cdot \mathrm{KL}(\pi_\theta \| \pi_{\mathrm{ref}})
$$
MiniMind 的 `kl_ref_penalty` 用的是 token 级近似形式:
```python
torch.exp(ref_resp_logp - mb_resp_logp) - (ref_resp_logp - mb_resp_logp) - 1.0
```
写成数学形式,先定义第 `b` 条样本第 `t` 个 response token:
$$
\ell^{\theta}_{b,t}
=
\log \pi_{\theta}(a_{b,t} \mid s_{b,t})
$$
$$
\ell^{\mathrm{ref}}_{b,t}
=
\log \pi_{\mathrm{ref}}(a_{b,t} \mid s_{b,t})
$$
代码里的差值是:
$$
\Delta_{b,t}
=
\ell^{\mathrm{ref}}_{b,t}
-
\ell^{\theta}_{b,t}
=
\log
\frac{
\pi_{\mathrm{ref}}(a_{b,t} \mid s_{b,t})
}{
\pi_{\theta}(a_{b,t} \mid s_{b,t})
}
$$
单个 token 的 penalty 是:
$$
k_{b,t}
=
\exp(\Delta_{b,t})
-
\Delta_{b,t}
-
1
$$
也就是:
$$
k_{b,t}
=
\frac{
\pi_{\mathrm{ref}}(a_{b,t} \mid s_{b,t})
}{
\pi_{\theta}(a_{b,t} \mid s_{b,t})
}
-
\log
\frac{
\pi_{\mathrm{ref}}(a_{b,t} \mid s_{b,t})
}{
\pi_{\theta}(a_{b,t} \mid s_{b,t})
}
-
1
$$
带上 response mask 后,MiniMind 实际优化的是 masked mean:
$$
\mathrm{KL}_{\mathrm{ref}}
=
\frac{
\sum_{b,t} m_{b,t} k_{b,t}
}{
\sum_{b,t} m_{b,t}
}
$$
其中 `m_{b,t}` 对应源码里的 `resp_policy_mask`。
这个形式可以看作对:
$$
\mathrm{KL}(\pi_{\theta} \| \pi_{\mathrm{ref}})
$$
的 token 级采样估计。直观上,actor 给某个 token 的概率越偏离 reference,这个 penalty 越大。
这不是 DPO 里的 chosen/rejected reference logratio。这里的 reference 作用是:
```text
约束 actor 在 PPO 更新时不要离原始 SFT policy 太远。
```
### 2.7 PPO 完整算法伪代码
把上面连起来,PPO 的训练框架是:
```text
initialize actor pi_theta
initialize critic V_phi
initialize frozen reference pi_ref
initialize reward model R
for each training step:
prompts = sample prompts
with no_grad:
responses = actor.generate(prompts)
old_logps = log pi_old(response_tokens | prompts)
rewards = R(prompts, responses)
old_values = V_phi(prompts + responses)
advantages, returns = GAE(rewards, old_values)
for ppo_epoch in range(K):
for minibatch in rollout_batch:
new_logps = log pi_theta(response_tokens | prompts)
values = V_phi(prompts + responses)
ratio = exp(new_logps - old_logps)
policy_loss = clipped_surrogate(ratio, advantages)
kl_loss = KL(pi_theta, pi_ref)
value_loss = clipped_mse(values, returns)
loss = policy_loss + beta * kl_loss + vf_coef * value_loss
update actor and critic
sync latest actor into rollout engine
```
这个框架里最容易混的地方是:
```text
old_logps: 采样 response 时的 actor 概率,固定不变
new_logps: 当前正在更新的 actor 概率,每次 forward 会变
advantages: 这批 response token 好不好,由 reward 和 critic 决定
ratio: 新 actor 相对旧 actor 对同一 token 改了多少
clip: 限制 ratio,防止离 old actor 太远
```
### 2.8 PPO 公式到 MiniMind 变量映射
| PPO 符号 | 含义 | MiniMind 变量 |
|---|---|---|
| `s_t` | prompt + 已生成 response 前缀 | `gen_out` 中 response token 之前的上下文 |
| `a_t` | 第 `t` 个 response token | `completion_ids[:, t]` / `labels.gather(...)` |
| `pi_old` | rollout 时的 actor | `rollout_engine.policy_model` |
| `log pi_old(a_t|s_t)` | 生成时 token logprob | `old_resp_logp` |
| `pi_theta` | 当前正在更新的 actor | `actor_model` / `actor_unwrapped` |
| `log pi_theta(a_t|s_t)` | 更新时重新计算的 token logprob | `mb_resp_logp` |
| `r_t(theta)` | 新旧策略概率比 | `ratio = torch.exp(log_ratio)` |
| `R(x, y)` | 整条 response reward | `rewards` |
| `r_t` | token 级 reward | `token_rewards` |
| `V(s_t)` | critic value | `old_resp_values`, `mb_resp_values` |
| `A_t` | advantage | `advantages` |
| `return_t` | value target | `returns` |
| `epsilon` | PPO clip 范围 | `args.clip_epsilon` |
| `pi_ref` | frozen reference policy | `ref_model` |
| `KL(pi_theta, pi_ref)` | reference 约束 | `kl_ref_penalty` |
<a id="l23-reading-order"></a>
## 3. 源码阅读顺序图
建议按这个顺序读:
```text
1. dataset/lm_dataset.py:195-224
看 PPO 数据集为什么只返回 prompt。
2. trainer/train_ppo.py:365-389
看 actor、reference、critic、reward model、rollout engine 如何初始化。
3. trainer/rollout_engine.py:23-92
看 rollout 如何生成 response,并保存 old per-token logprob。
4. trainer/train_ppo.py:84-128
看一个 batch 如何从 prompt 变成 gen_out、completion_ids、mask、logp_pos。
5. trainer/train_ppo.py:130-151
看 critic、reference、reward 和 GAE 如何产生 advantages/returns。
6. trainer/train_ppo.py:164-203
看 PPO mini-batch 更新里的 ratio、clip、KL、value loss。
7. trainer/train_ppo.py:246
看 actor 更新后如何同步给 rollout engine。
```
和前几课的关系:
```text
第 21 课:reference model 和 token logprob
第 22 课:sequence logprob 和 DPO loss
第 23 课:PPO rollout、advantage、clip update
第 24 课:GRPO/CISPO,减少或替代 PPO 里的 critic/value 组件
```
<a id="l23-source-walkthrough"></a>
## 4. MiniMind 源码走读
### 4.1 PPO 数据集只提供 prompt
#### 源码证据:RLAIFDataset
文件:`dataset/lm_dataset.py:195-224`
看它是为了理解:PPO 不是读取一个固定 answer 来训练,而是只拿 prompt 给 actor 生成。
代码摘录:
```python
class RLAIFDataset(Dataset):
def create_chat_prompt(self, conversations):
conversations = pre_processing_chat(conversations)
use_thinking = random.random() < self.thinking_ratio
return self.tokenizer.apply_chat_template(
conversations[:-1],
tokenize=False,
open_thinking=use_thinking,
add_generation_prompt=True
)
def __getitem__(self, index):
sample = self.samples[index]
prompt = self.create_chat_prompt(sample['conversations'])
return {
'prompt': prompt,
'answer': ""
}
```
这段代码说明:
- `conversations[:-1]` 表示不把最后的标准回复当训练标签。
- `add_generation_prompt=True` 让 prompt 结束在 assistant 待生成位置。
- `answer` 是空字符串,真正的 response 来自后面的 rollout。
理解到这里就够:
```text
PPO 的训练数据不是 labels,而是让 actor 生成 response 的上下文。
```
暂时不要看:
```text
thinking_ratio 的课程外效果;
真实 RLAIF 数据如何收集;
reward model 的训练过程。
```
### 4.2 四类模型的分工
#### 源码证据 A:CriticModel
文件:`trainer/train_ppo.py:36-49`
看它是为了理解:critic 不是输出 vocab logits,而是每个 token 一个 value。
代码摘录:
```python
class CriticModel(MiniMindForCausalLM):
def __init__(self, params):
super().__init__(params)
self.value_head = nn.Linear(params.hidden_size, 1)
def forward(self, input_ids=None, attention_mask=None, **kwargs):
outputs = self.model(input_ids=input_ids, attention_mask=attention_mask, **kwargs)
hidden_states = self.model.norm(outputs[0])
values = self.value_head(hidden_states).squeeze(-1)
return values
```
这段代码说明:
- critic 复用 MiniMind backbone。
- `value_head` 把 hidden state 映射成一个标量。
- 返回 shape 是 `[batch, seq]`,不是 `[batch, seq, vocab]`。
#### 源码证据 B:训练入口初始化四类组件
文件:`trainer/train_ppo.py:365-389`
看它是为了理解:PPO 同时需要 actor、reference、critic、reward model 和 rollout engine。
代码摘录:
```python
actor_model, tokenizer = init_model(lm_config, base_weight, device=args.device)
ref_model, _ = init_model(lm_config, base_weight, device=args.device)
ref_model = ref_model.eval().requires_grad_(False)
critic_model = CriticModel(lm_config)
critic_model.load_state_dict(state_dict, strict=False)
critic_model = critic_model.to(args.device)
reward_model = LMForRewardModel(args.reward_model_path, device=args.device, dtype=torch.float16)
rollout_engine = create_rollout_engine(
engine_type=args.rollout_engine,
policy_model=actor_model,
tokenizer=tokenizer,
device=args.device,
autocast_ctx=autocast_ctx,
)
train_ds = RLAIFDataset(args.data_path, tokenizer, max_length=(args.max_seq_len + args.max_gen_len), thinking_ratio=args.thinking_ratio)
```
这段代码说明:
- actor 是要训练的策略模型。
- reference 从同一个 base weight 初始化,但冻结。
- critic 从 base checkpoint 加载 backbone,再加 `value_head`。
- reward model 是外部打分器。
- rollout engine 持有当前 actor,用来生成 response。
#### 源码证据 C:reward model wrapper
文件:`trainer/trainer_utils.py:160-177`
看它是为了理解:源码中的 reward model 输出一个被截断到 `[-3, 3]` 的标量。
代码摘录:
```python
class LMForRewardModel:
def __init__(self, model_path, device="cuda", dtype=torch.float16):
self.tokenizer = AutoTokenizer.from_pretrained(model_path, trust_remote_code=True)
self.model = AutoModel.from_pretrained(model_path, torch_dtype=dtype, trust_remote_code=True)
self.model = self.model.to(device).eval()
@torch.no_grad()
def get_score(self, messages, response):
eval_messages = [
{"role": "user", "content": message_context},
{"role": "assistant", "content": response}
]
score = self.model.get_score(self.tokenizer, eval_messages)
return max(min(score, 3.0), -3.0)
```
这段代码说明:
- reward model 不参与反向传播。
- 输入是整理后的用户上下文和 actor response。
- 输出是整条 response 的标量分数。
### 4.3 rollout 生成 response,并记录 old logprob
#### 源码证据 A:计算每个 completion token 的 logprob
文件:`trainer/rollout_engine.py:23-36`
看它是为了理解:PPO 需要保存生成当时的 `old_resp_logp`。
代码摘录:
```python
def compute_per_token_logps(model, input_ids: Tensor, n_keep: int, attention_mask: Optional[Tensor] = None) -> Tensor:
logits = unwrapped(input_ids, attention_mask=attention_mask, logits_to_keep=n_keep + 1).logits[:, :-1, :]
per_token_logps = []
for logits_row, ids_row in zip(logits, input_ids[:, -n_keep:]):
per_token_logps.append(
torch.gather(logits_row.log_softmax(dim=-1), 1, ids_row.unsqueeze(1)).squeeze(1)
)
return torch.stack(per_token_logps)
```
这段代码说明:
- `n_keep` 是 completion 长度。
- logits 先去掉最后一个位置,因为最后一个 logits 没有下一个 token 标签。
- `input_ids[:, -n_keep:]` 是 completion token ids。
- `gather` 取出 actor 对自己生成 token 的 logprob。
注意:
```text
这里不是求 vocab 上所有 token 的概率,而是只取 response 真实 token 的 logprob。
```
#### 源码证据 B:Torch rollout 返回完整训练所需字段
文件:`trainer/rollout_engine.py:71-92`
看它是为了理解:rollout 的产物不只是文本,还包括 old logprob 和 completion ids。
代码摘录:
```python
output_ids = model.generate(
input_ids=prompt_ids.repeat_interleave(num_generations, dim=0),
attention_mask=attention_mask.repeat_interleave(num_generations, dim=0),
max_new_tokens=max_new_tokens,
do_sample=True,
temperature=temperature,
)
prompt_len = prompt_ids.size(1)
completion_ids = output_ids[:, prompt_len:]
full_mask = (output_ids != self.tokenizer.pad_token_id).long()
per_token_logps = compute_per_token_logps(self.policy_model, output_ids, completion_ids.size(1), attention_mask=full_mask)
return RolloutResult(output_ids, completion_ids, per_token_logps, completions, ...)
```
这段代码说明:
- `output_ids` 是 prompt 和 response 拼接后的完整序列。
- `completion_ids` 只保留 response 区域。
- `per_token_logps` 对应 completion token。
- PPO 更新时不能丢掉这些 old logprob。
### 4.4 只在 response token 上建 mask
#### 源码证据:response 位置和 mask
文件:`trainer/train_ppo.py:117-128`
看它是为了理解:PPO loss 只作用在有效 response token,不作用在 prompt 和 padding。
代码摘录:
```python
full_mask = (gen_out != tokenizer.pad_token_id).long()
labels = gen_out[:, 1:].clone()
B = len(prompts)
resp_labels = completion_ids
resp_idx = torch.arange(resp_labels.size(1), device=gen_out.device).unsqueeze(0)
logp_pos = prompt_lens.unsqueeze(1) - 1 + resp_idx
resp_pad_mask = rollout_result.completion_mask.to(args.device).bool()
resp_lengths = resp_pad_mask.sum(dim=1)
eos_mask = resp_labels.eq(tokenizer.eos_token_id) & resp_pad_mask
has_eos = eos_mask.any(dim=1)
eos_pos = torch.argmax(eos_mask.int(), dim=1)
resp_lengths = torch.where(has_eos, eos_pos + 1, resp_lengths).long().clamp(min=1)
resp_policy_mask = ((resp_idx < resp_lengths.unsqueeze(1)) & resp_pad_mask).float()
resp_value_mask = resp_policy_mask.clone()
```
这段代码说明:
- `labels = gen_out[:, 1:]` 是 next-token 标签。
- 第 `j` 个 completion token 的 logits 位置是 `prompt_len - 1 + j`。
- `resp_policy_mask` 同时处理 padding 和 eos 后 token。
- policy loss 和 value loss 都用 response mask。
容易错的地方:
```text
completion_ids 的第 0 个 token,不对应 logits 的 prompt_len 位置;
它对应 logits[:, prompt_len - 1],因为 logits 预测下一个 token。
```
### 4.5 reward 放在最后一个有效 response token
#### 源码证据 A:规则 reward + reward model
文件:`trainer/train_ppo.py:52-76`
看它是为了理解:MiniMind 的 reward 是整条 response 的标量,不是每个 token 的监督标签。
代码摘录:
```python
rewards = torch.zeros(len(responses), device=args.device)
...
rewards[i] += 0.5 if 20 <= len(response.strip()) <= 800 else -0.5
if '</think>' in response:
rewards[i] += 1.0 if 20 <= len(thinking_content.strip()) <= 300 else -0.5
rewards[i] += 0.25 if response.count('</think>') == 1 else -0.25
rewards[i] -= rep_penalty(answer)
score = reward_model.get_score(messages, answer)
reward_model_scores.append(score)
...
rewards += reward_model_scores
```
这段代码说明:
- reward 由启发式规则和 reward model 分数组成。
- reward 的 shape 是 `[batch]`。
- 它评价整条 response。
#### 源码证据 B:把 response reward 写到最后一个 token
文件:`trainer/train_ppo.py:136-138`
看它是为了理解:GAE 需要 token 级 reward,所以源码把整条回复分数放到末 token。
代码摘录:
```python
token_rewards = torch.zeros_like(old_resp_logp)
last_idx = resp_lengths - 1
token_rewards[torch.arange(B, device=args.device)[valid_resp], last_idx[valid_resp]] += rewards[valid_resp]
```
这段代码说明:
- `token_rewards` shape 和 `old_resp_logp` 一样,都是 `[batch, response_len]`。
- 除最后一个有效 response token 外,其它 token reward 为 0。
- 后续 GAE 会把末端 reward 从后往前传播到 earlier tokens。
### 4.6 GAE 计算 advantages 和 returns
#### 源码证据:从后往前递推 advantage
文件:`trainer/train_ppo.py:130-151`
看它是为了理解:PPO 的 policy loss 用 `advantages`,critic 的 value loss 用 `returns`。
代码摘录:
```python
values_seq = critic_for_rollout(input_ids=gen_out, attention_mask=full_mask)
old_resp_values = values_seq.gather(1, logp_pos) * resp_value_mask
...
gen_len = old_resp_values.size(1)
lastgaelam = torch.zeros(B, device=args.device)
advs_rev = []
for t in reversed(range(gen_len)):
nv = old_resp_values[:, t + 1] if t < gen_len - 1 else 0.0
delta = token_rewards[:, t] + args.gamma * nv - old_resp_values[:, t]
lastgaelam = delta + args.gamma * args.lam * lastgaelam
advs_rev.append(lastgaelam)
advantages = torch.stack(advs_rev[::-1], dim=1)
returns = advantages + old_resp_values
adv_mean = (advantages * resp_policy_mask).sum() / resp_policy_mask.sum().clamp(min=1)
adv_var = ((advantages - adv_mean) ** 2 * resp_policy_mask).sum() / resp_policy_mask.sum().clamp(min=1)
advantages = (advantages - adv_mean) * torch.rsqrt(adv_var + 1e-8) * resp_policy_mask
```
这段代码说明:
- critic 输出 `[batch, full_seq]`,再用 `logp_pos` 取 response 位置。
- GAE 从最后一个 response token 往前算。
- `returns = advantages + old_resp_values` 给 critic 作为目标。
- advantages 会在有效 response token 上标准化。
### 4.7 PPO update:ratio、clip、KL、value loss
#### 源码证据 A:重新 forward 得到 new logprob
文件:`trainer/train_ppo.py:171-181`
看它是为了理解:rollout 时保存 old logprob,更新时重新算 new logprob。
代码摘录:
```python
mb_values_seq = critic_unwrapped(input_ids=gen_out[inds], attention_mask=full_mask[inds])
mb_resp_values = mb_values_seq.gather(1, logp_pos[inds])
res = actor_unwrapped(input_ids=gen_out[inds], attention_mask=full_mask[inds])
mb_resp_logp = F.log_softmax(res.logits[:, :-1], dim=-1).gather(
2, labels[inds].unsqueeze(-1)
).squeeze(-1).gather(1, logp_pos[inds])
log_ratio = mb_resp_logp - old_resp_logp[inds]
```
这段代码说明:
- `old_resp_logp` 不参与当前 actor forward。
- `mb_resp_logp` 来自当前 actor。
- 两者相减得到 `log_ratio`。
#### 源码证据 B:PPO clip policy loss
文件:`trainer/train_ppo.py:191-199`
看它是为了理解:README 公式里的 `ratio`、`clip`、`KL` 在源码里如何落地。
代码摘录:
```python
ratio = torch.exp(log_ratio)
clipfrac = ((((ratio - 1.0).abs() > args.clip_epsilon).float() * resp_policy_mask[inds]).sum()
/ resp_policy_mask[inds].sum().clamp(min=1))
kl_ref_penalty = ((torch.exp(ref_resp_logp[inds] - mb_resp_logp) - (ref_resp_logp[inds] - mb_resp_logp) - 1.0)
* resp_policy_mask[inds]).sum() / resp_policy_mask[inds].sum().clamp(min=1)
policy_loss = ((torch.max(-advantages[inds] * ratio,
-advantages[inds] * torch.clamp(ratio, 1.0 - args.clip_epsilon, 1.0 + args.clip_epsilon))
* resp_policy_mask[inds]).sum() / resp_policy_mask[inds].sum().clamp(min=1)
+ args.kl_coef * kl_ref_penalty)
```
这段代码说明:
- `ratio` 比较新旧 actor 对同一批 response token 的概率。
- `clipfrac` 统计有多少有效 token 超过裁剪范围。
- `kl_ref_penalty` 约束 actor 不要离 reference 太远。
- `policy_loss` 只在 `resp_policy_mask` 有效位置求平均。
#### 源码证据 C:critic value loss
文件:`trainer/train_ppo.py:200-203`
看它是为了理解:critic 也用 clip,避免 value 估计一步跳太大。
代码摘录:
```python
value_loss = 0.5 * (torch.max((mb_resp_values - returns[inds]) ** 2,
(torch.clamp(mb_resp_values, old_resp_values[inds] - args.cliprange_value,
old_resp_values[inds] + args.cliprange_value) - returns[inds]) ** 2)
* resp_value_mask[inds]).sum() / resp_value_mask[inds].sum().clamp(min=1)
```
这段代码说明:
- `returns` 是 critic 的训练目标。
- `old_resp_values` 用来裁剪 critic 更新幅度。
- value loss 和 policy loss 使用同一片 response 区域。
### 4.8 更新 rollout engine 的 policy
#### 源码证据:训练后同步 actor
文件:`trainer/train_ppo.py:226-246`
看它是为了理解:PPO 是 on-policy,所以 rollout engine 必须定期拿到最新 actor。
代码摘录:
```python
if grad_accum_step % args.accumulation_steps == 0:
clip_grad_norm_(actor_model.parameters(), args.grad_clip)
clip_grad_norm_(critic_model.parameters(), args.grad_clip)
actor_optimizer.step()
critic_optimizer.step()
actor_scheduler.step()
critic_scheduler.step()
actor_optimizer.zero_grad()
critic_optimizer.zero_grad()
if step % args.save_interval == 0 or step == iters:
rollout_engine.update_policy(actor_model)
```
这段代码说明:
- actor 和 critic 都会更新。
- rollout engine 不能永远用旧 actor。
- 如果使用 SGLang,`update_policy` 会把新权重同步到推理服务。
<a id="l23-must-write"></a>
## 5. 本节必须会写 / 暂时不要求
### 5.1 必须会写
本节的教学版核心不是完整训练脚本,而是下面 5 个小函数:
```python
def response_logp_positions(prompt_lens: torch.Tensor, response_len: int) -> torch.Tensor:
...
def masked_gae(
token_rewards: torch.Tensor,
values: torch.Tensor,
mask: torch.Tensor,
gamma: float,
lam: float,
) -> tuple[torch.Tensor, torch.Tensor]:
...
def ppo_policy_loss(
new_logps: torch.Tensor,
old_logps: torch.Tensor,
advantages: torch.Tensor,
mask: torch.Tensor,
clip_epsilon: float,
) -> torch.Tensor:
...
def reference_kl_penalty(
policy_logps: torch.Tensor,
ref_logps: torch.Tensor,
mask: torch.Tensor,
) -> torch.Tensor:
...
def clipped_value_loss(
values: torch.Tensor,
old_values: torch.Tensor,
returns: torch.Tensor,
mask: torch.Tensor,
cliprange_value: float,
) -> torch.Tensor:
...
```
它们分别对应:
```text
train_ppo.py:121-122
train_ppo.py:140-151
train_ppo.py:180-199
train_ppo.py:194-195
train_ppo.py:200-203
```
### 5.2 暂时不要求
本节先不要求手写:
```text
真实 reward model 加载;
SGLang rollout engine;
DDP 同步和 early_stop_kl 的通信细节;
checkpoint、scheduler、wandb/swanlab;
真实长文本采样质量;
reward hacking 的完整治理。
```
这些是工程外围或更大的 RLHF 话题。第 23 课先把核心张量链路讲清楚。
<a id="l23-handwrite"></a>
## 6. 手写模块
本节对应教学版建议文件:
```text
course/impl/core/ppo.py
```
### 6.1 补 `response_logp_positions`
接口:
```python
def response_logp_positions(prompt_lens: torch.Tensor, response_len: int) -> torch.Tensor:
...
```
对齐源码:
```text
trainer/train_ppo.py:121-122
```
行为要求:
```text
输入:
prompt_lens: [batch]
response_len: int
输出:
logp_pos: [batch, response_len]
规则:
第 j 个 response token 对应 logits[:, prompt_len - 1 + j]
```
### 6.2 补 `masked_gae`
接口:
```python
def masked_gae(
token_rewards: torch.Tensor,
values: torch.Tensor,
mask: torch.Tensor,
gamma: float,
lam: float,
) -> tuple[torch.Tensor, torch.Tensor]:
...
```
对齐源码:
```text
trainer/train_ppo.py:140-151
```
行为要求:
```text
输入:
token_rewards: [batch, response_len]
values: [batch, response_len]
mask: [batch, response_len]
输出:
advantages: [batch, response_len]
returns: [batch, response_len]
```
注意:
```text
advantages 要只在 mask 区域标准化;
padding 位置最终应为 0。
```
### 6.3 补 `ppo_policy_loss`
接口:
```python
def ppo_policy_loss(
new_logps: torch.Tensor,
old_logps: torch.Tensor,
advantages: torch.Tensor,
mask: torch.Tensor,
clip_epsilon: float,
) -> torch.Tensor:
...
```
对齐源码:
```text
trainer/train_ppo.py:180-199
```
行为要求:
```text
log_ratio = new_logps - old_logps
ratio = exp(log_ratio)
loss = masked mean(max(-advantages * ratio, -advantages * clipped_ratio))
```
这个函数只负责 PPO clipped surrogate。MiniMind 的完整 actor loss 还要加:
```text
kl_coef * reference_kl_penalty(...)
```
### 6.4 补 `reference_kl_penalty`
接口:
```python
def reference_kl_penalty(
policy_logps: torch.Tensor,
ref_logps: torch.Tensor,
mask: torch.Tensor,
) -> torch.Tensor:
...
```
对齐源码:
```text
trainer/train_ppo.py:194-195
```
行为要求:
```text
delta = ref_logps - policy_logps
penalty = exp(delta) - delta - 1
loss = masked mean(penalty)
```
对应公式:
$$
\Delta_{b,t}
=
\log \pi_{\mathrm{ref}}(a_{b,t} \mid s_{b,t})
-
\log \pi_{\theta}(a_{b,t} \mid s_{b,t})
$$
$$
\mathrm{reference\_kl\_penalty}
=
\frac{
\sum_{b,t} m_{b,t}
\left[
\exp(\Delta_{b,t}) - \Delta_{b,t} - 1
\right]
}{
\sum_{b,t} m_{b,t}
}
$$
理解重点:
```text
这不是 DPO 的 reference logratio;
这是 RLHF PPO 里防止 actor 偏离 frozen reference policy 的约束项。
```
### 6.5 补 `clipped_value_loss`
接口:
```python
def clipped_value_loss(
values: torch.Tensor,
old_values: torch.Tensor,
returns: torch.Tensor,
mask: torch.Tensor,
cliprange_value: float,
) -> torch.Tensor:
...
```
对齐源码:
```text
trainer/train_ppo.py:200-203
```
行为要求:
```text
unclipped = (values - returns) ** 2
clipped_values = clamp(values, old_values - cliprange_value, old_values + cliprange_value)
clipped = (clipped_values - returns) ** 2
loss = 0.5 * masked mean(max(unclipped, clipped))
```
<a id="l23-experiment"></a>
## 7. 实验验证
本节实验不加载真实 reward model,也不启动 SGLang。它用随机 tiny MiniMind 模型构造一批 synthetic rollout,验证 PPO 核心张量链路。
实验文件:
```text
course/labs/trace_ppo_batch_flow.py
```
命令:
```bash
cd /home/sun/minimind
python course/labs/trace_ppo_batch_flow.py
```
这个实验验证:
```text
1. completion token 的 old logprob shape 是 [batch, response_len]。
2. logp_pos 使用 prompt_len - 1 + response_idx。
3. response mask 会去掉 padding/eos 后 token。
4. 标量 reward 会写到最后一个有效 response token。
5. GAE 输出 advantages/returns。
6. ratio、clipfrac、KL penalty、policy loss、value loss 都只在 response mask 上计算。
```
记录输出:
```text
gen_out.shape =
completion_ids.shape =
old_resp_logp.shape =
logp_pos =
resp_policy_mask =
advantages.shape =
returns.shape =
masked_adv_mean =
ratio_min =
ratio_max =
approx_kl =
policy_loss =
value_loss =
```
输出含义:
```text
ratio_min/ratio_max 接近 1,说明 old 和 new actor 此时还没真正更新;
approx_kl 接近 0,说明新旧策略几乎一致;
masked_adv_mean 接近 0,说明 advantages 在有效 response token 上做了标准化;
policy_loss 和 value_loss 是可反向传播的标量。
```
如果你后续补了 `course/impl/core/ppo.py`,可以新增对齐测试:
```text
course/impl/tests/test_ppo.py
```
测试内容:
```text
response_logp_positions 对齐 train_ppo.py 的 logp_pos;
masked_gae 对齐 train_ppo.py 的 GAE 递推;
ppo_policy_loss 对齐 train_ppo.py 的 clip policy loss;
reference_kl_penalty 对齐 train_ppo.py 的 reference KL penalty;
clipped_value_loss 对齐 train_ppo.py 的 value loss。
```
<a id="l23-stage-assembly"></a>
## 8. 阶段组装
PPO/GRPO 阶段的教学版最小闭环可以这样组装:
```text
CoursePromptDataset
-> actor.generate 得到 completion_ids
-> old_logps = response token logprob
-> reward_fn 给每条 response 一个标量 reward
-> critic 得到 response values
-> masked_gae 得到 advantages/returns
-> ppo_policy_loss 更新 actor
-> reference_kl_penalty 约束 actor 不要偏离 reference
-> clipped_value_loss 更新 critic
```
### 8.1 本节验收命令
```bash
cd /home/sun/minimind
python course/labs/trace_ppo_batch_flow.py
```
如果环境没有 GPU,也可以强制 CPU:
```bash
python course/labs/trace_ppo_batch_flow.py --device cpu
```
### 8.2 源码差异
教学版先省略:
```text
SGLang 服务;
真实 reward model;
多轮 rollout 采样;
DDP 通信;
checkpoint/resume;
大 batch 和梯度累积调度。
```
这些省略不改变 PPO 核心:
```text
old_logp -> new_logp -> ratio -> advantage -> clipped policy loss
reference_logp -> reference KL penalty
```
### 8.3 Portfolio 记录
完成本节后,可以在 `course/portfolio/experiments.md` 记录:
```text
PPO batch trace:
- 构造 synthetic prompt/completion。
- 用 tiny actor 计算 old response logprob。
- 用 tiny critic 计算 response value。
- 把 scalar reward 写到最后一个有效 response token。
- 用 GAE 得到 advantages/returns。
- 手算 ratio、clip policy loss、reference KL penalty 和 clipped value loss。
```
在 `course/notes/mistakes.md` 记录容易错的点:
```text
1. 把 old_logp 和 new_logp 混成同一个东西。
2. 忘记 completion 第 0 个 token 对应 logits 的 prompt_len - 1 位置。
3. 把 prompt token 也算进 policy loss。
4. 对 padding token 做 advantage 标准化。
5. 忘记 actor 更新后同步 rollout engine。
```
<a id="l23-check"></a>
## 9. 本节检查
1. 在 LLM PPO 里,`state`、`action`、`trajectory`、`reward` 分别对应什么?
2. PPO 为什么不能像 DPO 一样只读静态 chosen/rejected 数据?
3. policy gradient 里的 `advantage` 决定什么更新方向?
4. `old_resp_logp` 为什么必须在 rollout 时保存?
5. `ratio = exp(new_logp - old_logp)` 大于 1 代表什么?
6. PPO clip 对 `A_t > 0` 和 `A_t < 0` 分别限制了什么?
7. `advantages` 和 `returns` 分别给哪个 loss 使用?
8. MiniMind 的 reference KL penalty 和 DPO 的 reference logratio 有什么区别?
9. 第 `j` 个 completion token 为什么对应 `prompt_len - 1 + j` 的 logits 位置?
10. actor 更新后为什么要调用 `rollout_engine.update_policy(actor_model)`?
<a id="l23-next"></a>
## 10. 下一课
第 24 课进入 [GRPO 与 CISPO](24_grpo_cispo.md)。
下一课要解决:
```text
GRPO 和 PPO 的核心差异是什么;
为什么 GRPO 可以减少对 critic/value model 的依赖;
MiniMind 的 train_grpo.py 如何组织 multiple generations;
group-relative reward/advantage 如何进入 loss;
CISPO 和普通 GRPO 的 loss_type 在源码里如何分叉。
```