882 lines
21 KiB
Markdown
882 lines
21 KiB
Markdown
# 第 24 课:GRPO 与 CISPO
|
||
|
||
这一课讲 MiniMind 的 `train_grpo.py`:它如何用同一个 prompt 的多条生成结果构造 group-relative advantage,并通过 `loss_type` 在 GRPO 和 CISPO 两种策略损失之间切换。
|
||
|
||
相关补充:如果只想看 CISPO 分支里 `clamped_ratio.detach() * A * logprob` 的解释,读 `course/notes/24_cispo_notes.md`。
|
||
|
||
## 目录
|
||
|
||
- [0. 本节主线](#l24-mainline)
|
||
- [1. 本节要懂的 7 个原理](#l24-principles)
|
||
- [2. GRPO/CISPO 完整原理](#l24-complete-principle)
|
||
- [3. 源码阅读顺序图](#l24-reading-order)
|
||
- [4. MiniMind 源码走读](#l24-source-walkthrough)
|
||
- [5. 本节必须会写 / 暂时不要求](#l24-must-write)
|
||
- [6. 手写模块](#l24-handwrite)
|
||
- [7. 实验验证](#l24-experiment)
|
||
- [8. 阶段组装](#l24-stage-assembly)
|
||
- [9. 本节检查](#l24-check)
|
||
- [10. 下一课](#l24-next)
|
||
|
||
<a id="l24-mainline"></a>
|
||
## 0. 本节主线
|
||
|
||
GRPO 的训练链路是:
|
||
|
||
```text
|
||
每个 prompt 采样 num_generations 条 response
|
||
-> reward model / rule 给每条 response 一个标量 reward
|
||
-> 同一个 prompt 的多条 reward 组成一个 group
|
||
-> group 内做 mean/std 归一化,得到每条 response 的 advantage
|
||
-> policy/ref 分别计算 response token logprob
|
||
-> ratio = exp(policy_logp - old_logp)
|
||
-> token KL = exp(ref_logp - policy_logp) - (ref_logp - policy_logp) - 1
|
||
-> loss_type == grpo 时,用 PPO-style clipped ratio loss
|
||
-> loss_type == cispo 时,用 clamped ratio 作为权重乘 logprob
|
||
-> mask 后按 response 求均值,再对 batch 求均值
|
||
-> 更新 policy,并同步 rollout engine
|
||
```
|
||
|
||
一句话:
|
||
|
||
```text
|
||
GRPO 用“同题多答的组内相对分数”替代 PPO 的 critic advantage;CISPO 沿用这套 group advantage,只改策略损失的梯度路径。
|
||
```
|
||
|
||
和 PPO 的差别:
|
||
|
||
```text
|
||
PPO:
|
||
需要 critic_model,先估计 value,再用 GAE 得到 token advantage。
|
||
|
||
GRPO/CISPO:
|
||
不需要 critic_model。
|
||
每个 prompt 生成多条 response,用同组 reward 的相对高低得到 sequence advantage。
|
||
```
|
||
|
||
<a id="l24-principles"></a>
|
||
## 1. 本节要懂的 7 个原理
|
||
|
||
| 原理 | 要理解什么 | 源码位置 |
|
||
|---|---|---|
|
||
| GRPO 用多生成构造组 | 每个 prompt 生成 `num_generations` 条 response | `trainer/train_grpo.py:80-95`, `trainer/train_grpo.py:226` |
|
||
| group advantage 替代 critic | `A=(R-mean_group)/(std_group+eps)`,不需要 value model | `trainer/train_grpo.py:121-124`, `README.md:1128-1135` |
|
||
| GRPO 仍然需要 old logprob | `ratio=exp(policy-old)`,仍然是 on-policy update | `trainer/train_grpo.py:90-101`, `trainer/train_grpo.py:134` |
|
||
| response mask 截到 EOS | 只在有效 completion token 上计算 loss | `trainer/train_grpo.py:126-130` |
|
||
| reference KL 是 token 约束 | 使用 `exp(ref-policy)-(ref-policy)-1` 约束 policy | `trainer/train_grpo.py:132-133` |
|
||
| GRPO loss 是 PPO-style clipped loss | `min(ratio*A, clipped_ratio*A)` 加 KL penalty | `trainer/train_grpo.py:139-143`, `README.md:1124-1131` |
|
||
| CISPO 只改 loss 形式 | `clamped_ratio.detach() * A * logprob`,避免 clip 后梯度完全截断 | `trainer/train_grpo.py:135-138`, `README.md:1156-1170` |
|
||
|
||
学完本节,你应该能解释这些 shape:
|
||
|
||
```text
|
||
prompts: length B
|
||
completion_ids: [B * num_generations, R]
|
||
old_per_token_logps: [B * num_generations, R]
|
||
rewards: [B * num_generations]
|
||
grouped_rewards: [B, num_generations]
|
||
advantages: [B * num_generations]
|
||
completion_mask: [B * num_generations, R]
|
||
per_token_loss: [B * num_generations, R]
|
||
```
|
||
|
||
<a id="l24-complete-principle"></a>
|
||
## 2. GRPO/CISPO 完整原理
|
||
|
||
### 2.1 GRPO 为什么能不用 critic
|
||
|
||
PPO 用 critic 估计:
|
||
|
||
```text
|
||
这个 token 位置往后的 expected return 是多少
|
||
```
|
||
|
||
然后用 GAE 得到每个 token 的 advantage。
|
||
|
||
GRPO 换了一个角度:对同一个 prompt,一次采样多条 response:
|
||
|
||
```text
|
||
y_1, y_2, ..., y_G ~ pi_old(. | x)
|
||
```
|
||
|
||
再分别打 reward:
|
||
|
||
```text
|
||
R_1, R_2, ..., R_G
|
||
```
|
||
|
||
同组 reward 的均值和标准差:
|
||
|
||
$$
|
||
\mu_x
|
||
=
|
||
\frac{1}{G}
|
||
\sum_{i=1}^{G}
|
||
R_i
|
||
$$
|
||
|
||
$$
|
||
\sigma_x
|
||
=
|
||
\sqrt{
|
||
\frac{1}{G}
|
||
\sum_{i=1}^{G}
|
||
(R_i - \mu_x)^2
|
||
}
|
||
$$
|
||
|
||
每条 response 的 group-relative advantage:
|
||
|
||
$$
|
||
A_i
|
||
=
|
||
\frac{
|
||
R_i - \mu_x
|
||
}{
|
||
\sigma_x + \epsilon
|
||
}
|
||
$$
|
||
|
||
含义:
|
||
|
||
```text
|
||
同一个 prompt 下,比同组平均 reward 高的 response,A_i > 0;
|
||
比同组平均 reward 低的 response,A_i < 0。
|
||
```
|
||
|
||
所以 GRPO 不需要 critic:
|
||
|
||
```text
|
||
baseline 不是 V(s),而是同组回答的平均 reward。
|
||
```
|
||
|
||
### 2.2 退化组是什么
|
||
|
||
如果同一个 prompt 生成的多条 response reward 几乎一样:
|
||
|
||
```text
|
||
R_1 ≈ R_2 ≈ ... ≈ R_G
|
||
```
|
||
|
||
那么:
|
||
|
||
```text
|
||
R_i - mean_group ≈ 0
|
||
```
|
||
|
||
最终 advantage 接近 0。
|
||
|
||
这意味着:
|
||
|
||
```text
|
||
这一组几乎没有学习信号。
|
||
```
|
||
|
||
这就是 README 里说的退化组。MiniMind 这种小模型如果生成差异很小,GRPO 的信号会明显变弱。
|
||
|
||
### 2.3 GRPO loss
|
||
|
||
每个 response token 仍然有新旧策略概率比:
|
||
|
||
$$
|
||
r_{i,t}
|
||
=
|
||
\frac{
|
||
\pi_{\theta}(a_{i,t} \mid s_{i,t})
|
||
}{
|
||
\pi_{\mathrm{old}}(a_{i,t} \mid s_{i,t})
|
||
}
|
||
=
|
||
\exp(
|
||
\ell^{\theta}_{i,t}
|
||
-
|
||
\ell^{\mathrm{old}}_{i,t}
|
||
)
|
||
$$
|
||
|
||
GRPO 的 clipped surrogate:
|
||
|
||
$$
|
||
\mathcal{L}_{\mathrm{GRPO}}
|
||
=
|
||
-
|
||
\mathbb{E}
|
||
\left[
|
||
\min
|
||
\left(
|
||
r_{i,t} A_i,
|
||
\operatorname{clip}(r_{i,t}, 1-\varepsilon, 1+\varepsilon) A_i
|
||
\right)
|
||
-
|
||
\beta \operatorname{KL}_{i,t}
|
||
\right]
|
||
$$
|
||
|
||
源码的写法等价于:
|
||
|
||
$$
|
||
\operatorname{per\_token\_loss}
|
||
=
|
||
-
|
||
\left[
|
||
\min(r_{i,t} A_i, \bar{r}_{i,t} A_i)
|
||
-
|
||
\beta \operatorname{KL}_{i,t}
|
||
\right]
|
||
$$
|
||
|
||
其中:
|
||
|
||
$$
|
||
\bar{r}_{i,t}
|
||
=
|
||
\operatorname{clip}(r_{i,t}, 1-\varepsilon, 1+\varepsilon)
|
||
$$
|
||
|
||
注意这里的 advantage 是 sequence-level:
|
||
|
||
```text
|
||
A_i 的 shape 是 [B * num_generations]
|
||
```
|
||
|
||
进入 token loss 时通过:
|
||
|
||
```python
|
||
advantages.unsqueeze(1)
|
||
```
|
||
|
||
广播到每个 response token。
|
||
|
||
### 2.4 reference KL
|
||
|
||
GRPO/CISPO 使用和 PPO 类似的 token-level reference KL:
|
||
|
||
$$
|
||
\Delta_{i,t}
|
||
=
|
||
\ell^{\mathrm{ref}}_{i,t}
|
||
-
|
||
\ell^{\theta}_{i,t}
|
||
$$
|
||
|
||
$$
|
||
\operatorname{KL}_{i,t}
|
||
=
|
||
\exp(\Delta_{i,t})
|
||
-
|
||
\Delta_{i,t}
|
||
-
|
||
1
|
||
$$
|
||
|
||
它的作用是:
|
||
|
||
```text
|
||
惩罚 policy 偏离 frozen SFT reference。
|
||
```
|
||
|
||
### 2.5 CISPO 改了什么
|
||
|
||
GRPO 的 clip 写法会出现一个问题:
|
||
|
||
```text
|
||
当 ratio 被 clip 到常数区间边界后,clip 分支对 ratio 的梯度可能被截断。
|
||
```
|
||
|
||
CISPO 不重做 group advantage,只改策略项:
|
||
|
||
$$
|
||
\mathcal{L}_{\mathrm{CISPO}}
|
||
=
|
||
-
|
||
\mathbb{E}
|
||
\left[
|
||
\min(r_{i,t}, \varepsilon_{\max})
|
||
A_i
|
||
\log \pi_{\theta}(a_{i,t} \mid s_{i,t})
|
||
-
|
||
\beta \operatorname{KL}_{i,t}
|
||
\right]
|
||
$$
|
||
|
||
MiniMind 源码里:
|
||
|
||
```python
|
||
clamped_ratio = torch.clamp(ratio, max=args.epsilon_high).detach()
|
||
per_token_loss = -(clamped_ratio * advantages.unsqueeze(1) * per_token_logps - args.beta * per_token_kl)
|
||
```
|
||
|
||
关键是:
|
||
|
||
```text
|
||
clamped_ratio.detach()
|
||
```
|
||
|
||
这表示 ratio 只作为固定权重使用;梯度主要走:
|
||
|
||
```text
|
||
per_token_logps = log pi_theta(...)
|
||
```
|
||
|
||
所以即使 ratio 被上限截断,策略项仍然通过 logprob 保留梯度路径。
|
||
|
||
### 2.6 GRPO/CISPO 与 PPO 的关系
|
||
|
||
```text
|
||
PPO:
|
||
old_logp -> ratio
|
||
critic -> GAE advantage
|
||
policy loss + value loss
|
||
|
||
GRPO:
|
||
old_logp -> ratio
|
||
group rewards -> group-relative advantage
|
||
policy loss only, no value loss
|
||
|
||
CISPO:
|
||
group rewards -> group-relative advantage
|
||
clamped ratio as detached weight
|
||
logprob carries policy gradient
|
||
```
|
||
|
||
<a id="l24-reading-order"></a>
|
||
## 3. 源码阅读顺序图
|
||
|
||
建议按这个顺序读:
|
||
|
||
```text
|
||
1. README.md:1120-1135
|
||
先看 GRPO 公式和 group advantage。
|
||
|
||
2. README.md:1156-1170
|
||
看 CISPO 为什么只是 GRPO 的 loss 变体。
|
||
|
||
3. trainer/train_grpo.py:271-292
|
||
看 policy/reference/reward/rollout engine 初始化,没有 critic。
|
||
|
||
4. trainer/train_grpo.py:71-95
|
||
看每个 prompt 如何 rollout 出 num_generations 条 response。
|
||
|
||
5. trainer/train_grpo.py:121-124
|
||
看 group reward 如何变成 advantage。
|
||
|
||
6. trainer/train_grpo.py:126-143
|
||
看 mask、reference KL、ratio、GRPO/CISPO loss。
|
||
|
||
7. trainer/train_grpo.py:144-193
|
||
看 backward、optimizer、保存和 rollout engine 同步。
|
||
```
|
||
|
||
<a id="l24-source-walkthrough"></a>
|
||
## 4. MiniMind 源码走读
|
||
|
||
### 4.1 初始化:没有 critic
|
||
|
||
#### 源码证据:GRPO 初始化组件
|
||
|
||
文件:`trainer/train_grpo.py:271-292`
|
||
|
||
看它是为了理解:GRPO 初始化 policy、reference、reward model 和 rollout engine,但没有 critic。
|
||
|
||
代码摘录:
|
||
|
||
```python
|
||
base_weight = args.from_weight
|
||
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)
|
||
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=model,
|
||
tokenizer=tokenizer,
|
||
device=args.device,
|
||
autocast_ctx=autocast_ctx,
|
||
)
|
||
train_ds = RLAIFDataset(args.data_path, tokenizer, max_length=lm_config.max_seq_len, thinking_ratio=args.thinking_ratio)
|
||
```
|
||
|
||
这段代码说明:
|
||
|
||
- `model` 是要训练的 policy。
|
||
- `ref_model` 从同一份 base weight 初始化,但冻结。
|
||
- `reward_model` 是外部打分器。
|
||
- `rollout_engine` 用 policy 生成 response。
|
||
- 这里没有 `CriticModel`、`value_head`、`value_loss`。
|
||
|
||
### 4.2 rollout:每个 prompt 生成多条 response
|
||
|
||
#### 源码证据:num_generations
|
||
|
||
文件:`trainer/train_grpo.py:71-95`
|
||
|
||
看它是为了理解:GRPO 的 group 来自同一个 prompt 的多次生成。
|
||
|
||
代码摘录:
|
||
|
||
```python
|
||
prompts = batch['prompt']
|
||
prompt_inputs = tokenizer(prompts, return_tensors="pt", padding=True, ...)
|
||
|
||
rollout_result = rollout_engine.rollout(
|
||
prompt_ids=prompt_inputs["input_ids"],
|
||
attention_mask=prompt_inputs["attention_mask"],
|
||
num_generations=args.num_generations,
|
||
max_new_tokens=args.max_gen_len,
|
||
temperature=0.8,
|
||
)
|
||
outputs = rollout_result.output_ids
|
||
completion_ids = rollout_result.completion_ids
|
||
old_per_token_logps = rollout_result.per_token_logps.to(args.device).detach()
|
||
prompt_lens = rollout_result.prompt_lens.to(args.device)
|
||
rewards = calculate_rewards(prompts, completions, reward_model).to(args.device)
|
||
```
|
||
|
||
这段代码说明:
|
||
|
||
- 输入 batch 有 `B` 个 prompt。
|
||
- rollout 后有 `B * num_generations` 条 response。
|
||
- `old_per_token_logps` 是采样时 policy 的 token logprob。
|
||
- `rewards` 是每条 response 一个标量。
|
||
|
||
### 4.3 reward:按 prompt 和 generation 编号写入
|
||
|
||
#### 源码证据:calculate_rewards
|
||
|
||
文件:`trainer/train_grpo.py:37-68`
|
||
|
||
看它是为了理解:`rewards` 的平铺顺序是 `prompt_i` 的第 `j` 条生成。
|
||
|
||
代码摘录:
|
||
|
||
```python
|
||
for i in range(batch_size):
|
||
for j in range(args.num_generations):
|
||
response_idx = i * args.num_generations + j
|
||
response = responses[response_idx]
|
||
prompt = prompts[i]
|
||
...
|
||
rewards[response_idx] += 0.5 if 20 <= len(response.strip()) <= 800 else -0.5
|
||
if '</think>' in response:
|
||
rewards[response_idx] += 1.0 if 20 <= len(thinking_content.strip()) <= 300 else -0.5
|
||
rewards[response_idx] += 0.25 if response.count('</think>') == 1 else -0.25
|
||
rewards[response_idx] -= rep_penalty(answer)
|
||
score = reward_model.get_score(messages, answer)
|
||
reward_model_scores.append(score)
|
||
```
|
||
|
||
这段代码说明:
|
||
|
||
- 同一个 prompt 的多条 response 在 flat tensor 中连续排列。
|
||
- 这个顺序让后面可以直接 `view(-1, num_generations)`。
|
||
- reward 由长度规则、thinking 格式、重复惩罚和 reward model 分数组成。
|
||
|
||
### 4.4 group reward 变 advantage
|
||
|
||
#### 源码证据:组内标准化
|
||
|
||
文件:`trainer/train_grpo.py:121-124`
|
||
|
||
看它是为了理解:GRPO 用 group baseline 替代 critic。
|
||
|
||
代码摘录:
|
||
|
||
```python
|
||
grouped_rewards = rewards.view(-1, args.num_generations)
|
||
mean_r = grouped_rewards.mean(dim=1).repeat_interleave(args.num_generations)
|
||
std_r = grouped_rewards.std(dim=1, unbiased=False).repeat_interleave(args.num_generations)
|
||
advantages = (rewards - mean_r) / (std_r + 1e-4)
|
||
```
|
||
|
||
这段代码说明:
|
||
|
||
- `grouped_rewards` shape 是 `[B, num_generations]`。
|
||
- 同一行对应同一个 prompt 的多条 response reward。
|
||
- `advantages` shape 回到 `[B * num_generations]`。
|
||
- advantage 是 sequence-level,不是 token-level。
|
||
|
||
### 4.5 completion mask 截断 EOS 和 padding
|
||
|
||
#### 源码证据:completion_mask
|
||
|
||
文件:`trainer/train_grpo.py:126-130`
|
||
|
||
看它是为了理解:loss 只作用在有效 response token。
|
||
|
||
代码摘录:
|
||
|
||
```python
|
||
completion_pad_mask = rollout_result.completion_mask.to(args.device).bool()
|
||
is_eos = (completion_ids == tokenizer.eos_token_id) & completion_pad_mask
|
||
eos_idx = torch.full((is_eos.size(0),), is_eos.size(1) - 1, dtype=torch.long, device=args.device)
|
||
eos_idx[is_eos.any(dim=1)] = is_eos.int().argmax(dim=1)[is_eos.any(dim=1)]
|
||
completion_mask = ((torch.arange(is_eos.size(1), device=args.device).expand(is_eos.size(0), -1) <= eos_idx.unsqueeze(1)) & completion_pad_mask).int()
|
||
```
|
||
|
||
这段代码说明:
|
||
|
||
- Torch rollout 下 `completion_pad_mask` 可能全 1,真正截断靠 EOS。
|
||
- SGLang rollout 下 `completion_pad_mask` 会处理 padding。
|
||
- `completion_mask` 保留到第一个 EOS,包括 EOS 本身。
|
||
|
||
### 4.6 current/ref logprob
|
||
|
||
#### 源码证据:policy 和 reference token logprob
|
||
|
||
文件:`trainer/train_grpo.py:97-105`
|
||
|
||
看它是为了理解:GRPO/CISPO 都需要当前 policy logprob 和 reference logprob。
|
||
|
||
代码摘录:
|
||
|
||
```python
|
||
res = model_unwrapped(outputs, attention_mask=full_mask)
|
||
per_token_logps = F.log_softmax(res.logits[:, :-1, :], dim=-1).gather(
|
||
2, outputs[:, 1:].unsqueeze(-1)
|
||
).squeeze(-1).gather(1, logp_pos)
|
||
|
||
with torch.no_grad():
|
||
ref_per_token_logps = F.log_softmax(ref_model(outputs, attention_mask=full_mask).logits[:, :-1, :], dim=-1).gather(
|
||
2, outputs[:, 1:].unsqueeze(-1)
|
||
).squeeze(-1).gather(1, logp_pos)
|
||
```
|
||
|
||
这段代码说明:
|
||
|
||
- `per_token_logps` 是当前正在训练的 policy。
|
||
- `ref_per_token_logps` 是 frozen reference。
|
||
- 两者都只取 response token 位置。
|
||
|
||
### 4.7 loss_type 分叉:GRPO 或 CISPO
|
||
|
||
#### 源码证据:KL、ratio、loss
|
||
|
||
文件:`trainer/train_grpo.py:132-143`
|
||
|
||
看它是为了理解:`loss_type` 只切换策略项,group advantage 和 KL 共享。
|
||
|
||
代码摘录:
|
||
|
||
```python
|
||
kl_div = ref_per_token_logps - per_token_logps
|
||
per_token_kl = torch.exp(kl_div) - kl_div - 1
|
||
ratio = torch.exp(per_token_logps - old_per_token_logps)
|
||
if args.loss_type == "cispo":
|
||
clamped_ratio = torch.clamp(ratio, max=args.epsilon_high).detach()
|
||
per_token_loss = -(clamped_ratio * advantages.unsqueeze(1) * per_token_logps - args.beta * per_token_kl)
|
||
else:
|
||
clipped_ratio = torch.clamp(ratio, 1 - args.epsilon, 1 + args.epsilon)
|
||
per_token_loss1 = ratio * advantages.unsqueeze(1)
|
||
per_token_loss2 = clipped_ratio * advantages.unsqueeze(1)
|
||
per_token_loss = -(torch.min(per_token_loss1, per_token_loss2) - args.beta * per_token_kl)
|
||
policy_loss = ((per_token_loss * completion_mask).sum(dim=1) / completion_mask.sum(dim=1).clamp(min=1)).mean()
|
||
```
|
||
|
||
这段代码说明:
|
||
|
||
- `per_token_kl` 是 reference KL penalty。
|
||
- `ratio` 比较 current policy 和 rollout old policy。
|
||
- GRPO 用 `min(ratio*A, clipped_ratio*A)`。
|
||
- CISPO 用 `clamped_ratio.detach() * A * per_token_logps`。
|
||
- 最终先对每条 response 的有效 token 求均值,再对所有 response 求均值。
|
||
|
||
### 4.8 backward、保存、同步 rollout policy
|
||
|
||
#### 源码证据:训练闭环
|
||
|
||
文件:`trainer/train_grpo.py:144-193`
|
||
|
||
看它是为了理解:GRPO/CISPO 只更新一个 policy model,没有 critic optimizer。
|
||
|
||
代码摘录:
|
||
|
||
```python
|
||
loss = (policy_loss + aux_loss) / args.accumulation_steps
|
||
loss.backward()
|
||
|
||
if step % args.accumulation_steps == 0:
|
||
torch.nn.utils.clip_grad_norm_(model.parameters(), args.grad_clip)
|
||
optimizer.step()
|
||
scheduler.step()
|
||
optimizer.zero_grad()
|
||
|
||
if step % args.save_interval == 0 or step == iters:
|
||
rollout_engine.update_policy(model)
|
||
```
|
||
|
||
这段代码说明:
|
||
|
||
- loss 不包含 `value_loss`。
|
||
- optimizer 只更新 policy。
|
||
- 更新后要把最新 policy 同步到 rollout engine。
|
||
|
||
<a id="l24-must-write"></a>
|
||
## 5. 本节必须会写 / 暂时不要求
|
||
|
||
### 5.1 必须会写
|
||
|
||
本节教学版核心是下面 4 个函数:
|
||
|
||
```python
|
||
def group_relative_advantages(rewards: torch.Tensor, num_generations: int, eps: float = 1e-4) -> torch.Tensor:
|
||
...
|
||
|
||
def token_kl_penalty(policy_logps: torch.Tensor, ref_logps: torch.Tensor) -> torch.Tensor:
|
||
...
|
||
|
||
def grpo_policy_loss(...):
|
||
...
|
||
|
||
def cispo_policy_loss(...):
|
||
...
|
||
```
|
||
|
||
### 5.2 暂时不要求
|
||
|
||
本节先不要求手写:
|
||
|
||
```text
|
||
真实 reward model;
|
||
SGLang 服务;
|
||
真实 rollout 生成;
|
||
DDP、checkpoint、wandb/swanlab;
|
||
Agentic RL 多轮 tool-use 版本。
|
||
```
|
||
|
||
<a id="l24-handwrite"></a>
|
||
## 6. 手写模块
|
||
|
||
本节对应教学版文件:
|
||
|
||
```text
|
||
course/impl/core/grpo.py
|
||
```
|
||
|
||
### 6.1 补 `group_relative_advantages`
|
||
|
||
对齐源码:
|
||
|
||
```text
|
||
trainer/train_grpo.py:121-124
|
||
```
|
||
|
||
行为要求:
|
||
|
||
```text
|
||
输入:
|
||
rewards: [batch * num_generations]
|
||
|
||
输出:
|
||
advantages: [batch * num_generations]
|
||
|
||
步骤:
|
||
grouped = rewards.view(-1, num_generations)
|
||
mean = grouped.mean(dim=1).repeat_interleave(num_generations)
|
||
std = grouped.std(dim=1, unbiased=False).repeat_interleave(num_generations)
|
||
advantages = (rewards - mean) / (std + eps)
|
||
```
|
||
|
||
### 6.2 补 `token_kl_penalty`
|
||
|
||
对齐源码:
|
||
|
||
```text
|
||
trainer/train_grpo.py:132-133
|
||
```
|
||
|
||
行为要求:
|
||
|
||
```text
|
||
delta = ref_logps - policy_logps
|
||
return exp(delta) - delta - 1
|
||
```
|
||
|
||
### 6.3 补 `grpo_policy_loss`
|
||
|
||
对齐源码:
|
||
|
||
```text
|
||
trainer/train_grpo.py:134-143
|
||
```
|
||
|
||
行为要求:
|
||
|
||
```text
|
||
ratio = exp(policy_logps - old_logps)
|
||
clipped_ratio = clamp(ratio, 1 - epsilon, 1 + epsilon)
|
||
loss1 = ratio * advantages[:, None]
|
||
loss2 = clipped_ratio * advantages[:, None]
|
||
per_token_loss = -(min(loss1, loss2) - beta * token_kl)
|
||
loss = mean(sequence_masked_mean(per_token_loss))
|
||
```
|
||
|
||
### 6.4 补 `cispo_policy_loss`
|
||
|
||
对齐源码:
|
||
|
||
```text
|
||
trainer/train_grpo.py:135-138
|
||
```
|
||
|
||
行为要求:
|
||
|
||
```text
|
||
ratio = exp(policy_logps - old_logps)
|
||
clamped_ratio = clamp(ratio, max=epsilon_high).detach()
|
||
per_token_loss = -(clamped_ratio * advantages[:, None] * policy_logps - beta * token_kl)
|
||
loss = mean(sequence_masked_mean(per_token_loss))
|
||
```
|
||
|
||
<a id="l24-experiment"></a>
|
||
## 7. 实验验证
|
||
|
||
实验文件:
|
||
|
||
```text
|
||
course/labs/trace_grpo_cispo_loss.py
|
||
```
|
||
|
||
命令:
|
||
|
||
```bash
|
||
cd /home/sun/minimind
|
||
python course/labs/trace_grpo_cispo_loss.py
|
||
```
|
||
|
||
这个实验验证:
|
||
|
||
```text
|
||
1. flat rewards 如何 reshape 成 [B, num_generations]。
|
||
2. group mean/std 如何产生 advantages。
|
||
3. GRPO 和 CISPO 共享 ratio 与 token KL。
|
||
4. 两种 loss_type 的 policy loss 不同。
|
||
5. completion_mask 如何只统计有效 token。
|
||
```
|
||
|
||
记录输出:
|
||
|
||
```text
|
||
grouped_rewards =
|
||
group_mean =
|
||
group_std =
|
||
advantages =
|
||
ratio_min =
|
||
ratio_max =
|
||
kl_mean =
|
||
grpo_loss =
|
||
cispo_loss =
|
||
```
|
||
|
||
输出含义:
|
||
|
||
```text
|
||
同一组 advantages 的均值应接近 0;
|
||
如果某组 reward 完全一样,该组 advantages 接近 0;
|
||
GRPO/CISPO 的 ratio 和 KL 一样,但 loss 形式不同。
|
||
```
|
||
|
||
<a id="l24-stage-assembly"></a>
|
||
## 8. 阶段组装
|
||
|
||
PPO/GRPO 阶段现在有两个核心文件:
|
||
|
||
```text
|
||
course/impl/core/ppo.py
|
||
course/impl/core/grpo.py
|
||
```
|
||
|
||
最小教学版 GRPO/CISPO 可以这样组装:
|
||
|
||
```text
|
||
CoursePromptDataset
|
||
-> actor.generate 每个 prompt 生成 G 条 response
|
||
-> old_logps 保存 rollout policy logprob
|
||
-> reward_fn 给每条 response 打分
|
||
-> group_relative_advantages 得到 sequence advantage
|
||
-> policy/ref 计算 per-token logprob
|
||
-> grpo_policy_loss 或 cispo_policy_loss
|
||
-> optimizer 更新 policy
|
||
```
|
||
|
||
### 8.1 本节验收命令
|
||
|
||
```bash
|
||
cd /home/sun/minimind
|
||
python course/labs/trace_grpo_cispo_loss.py
|
||
```
|
||
|
||
如果你补完 `course/impl/core/grpo.py`,后续应新增:
|
||
|
||
```text
|
||
course/impl/tests/test_grpo.py
|
||
```
|
||
|
||
测试内容:
|
||
|
||
```text
|
||
group_relative_advantages 对齐 train_grpo.py:121-124;
|
||
token_kl_penalty 对齐 train_grpo.py:132-133;
|
||
grpo_policy_loss 对齐 train_grpo.py:139-143;
|
||
cispo_policy_loss 对齐 train_grpo.py:135-138。
|
||
```
|
||
|
||
### 8.2 源码差异
|
||
|
||
教学版先省略:
|
||
|
||
```text
|
||
真实 rollout engine;
|
||
真实 reward model;
|
||
SGLang;
|
||
大模型 checkpoint;
|
||
DDP 和日志系统。
|
||
```
|
||
|
||
但不省略:
|
||
|
||
```text
|
||
group rewards
|
||
group-relative advantage
|
||
token KL
|
||
GRPO/CISPO loss_type 分叉
|
||
mask reduction
|
||
```
|
||
|
||
### 8.3 Portfolio 记录
|
||
|
||
完成本节后,可以在 `course/portfolio/experiments.md` 记录:
|
||
|
||
```text
|
||
GRPO/CISPO trace:
|
||
- 构造每个 prompt 的多条 response reward。
|
||
- 用组内 mean/std 计算 relative advantage。
|
||
- 对比 GRPO clipped ratio loss 和 CISPO weighted logprob loss。
|
||
- 说明为什么 GRPO 不需要 critic。
|
||
```
|
||
|
||
<a id="l24-check"></a>
|
||
## 9. 本节检查
|
||
|
||
1. GRPO 为什么可以不训练 critic?
|
||
2. `rewards.view(-1, num_generations)` 依赖什么样的 response 排列顺序?
|
||
3. 如果同一组 reward 完全一样,advantages 会发生什么?
|
||
4. GRPO 的 `advantages.unsqueeze(1)` 为什么可以乘到每个 token 上?
|
||
5. `per_token_kl = exp(ref-policy) - (ref-policy) - 1` 约束的是什么?
|
||
6. GRPO loss 和 PPO policy loss 哪里相似?
|
||
7. CISPO 为什么要对 `clamped_ratio` 调用 `.detach()`?
|
||
8. MiniMind 的 `loss_type` 改成 `grpo` 和 `cispo` 时,哪些流程不变?
|
||
|
||
<a id="l24-next"></a>
|
||
## 10. 下一课
|
||
|
||
第 25 课进入 reward、logprob 与 KL penalty 的专项复盘。
|
||
|
||
下一课要解决:
|
||
|
||
```text
|
||
PPO/GRPO/DPO 中 logprob 的共同模式;
|
||
reference KL、approx KL、DPO reference logratio 的区别;
|
||
reward model wrapper 和规则 reward 如何影响 RL 训练;
|
||
为什么不同算法都绕不开 mask、EOS 和 token-level logprob。
|
||
```
|