21 KiB
第 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. 本节主线
- 1. 本节要懂的 7 个原理
- 2. GRPO/CISPO 完整原理
- 3. 源码阅读顺序图
- 4. MiniMind 源码走读
- 5. 本节必须会写 / 暂时不要求
- 6. 手写模块
- 7. 实验验证
- 8. 阶段组装
- 9. 本节检查
- 10. 下一课
0. 本节主线
GRPO 的训练链路是:
每个 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
一句话:
GRPO 用“同题多答的组内相对分数”替代 PPO 的 critic advantage;CISPO 沿用这套 group advantage,只改策略损失的梯度路径。
和 PPO 的差别:
PPO:
需要 critic_model,先估计 value,再用 GAE 得到 token advantage。
GRPO/CISPO:
不需要 critic_model。
每个 prompt 生成多条 response,用同组 reward 的相对高低得到 sequence advantage。
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:
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]
2. GRPO/CISPO 完整原理
2.1 GRPO 为什么能不用 critic
PPO 用 critic 估计:
这个 token 位置往后的 expected return 是多少
然后用 GAE 得到每个 token 的 advantage。
GRPO 换了一个角度:对同一个 prompt,一次采样多条 response:
y_1, y_2, ..., y_G ~ pi_old(. | x)
再分别打 reward:
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
}
含义:
同一个 prompt 下,比同组平均 reward 高的 response,A_i > 0;
比同组平均 reward 低的 response,A_i < 0。
所以 GRPO 不需要 critic:
baseline 不是 V(s),而是同组回答的平均 reward。
2.2 退化组是什么
如果同一个 prompt 生成的多条 response reward 几乎一样:
R_1 ≈ R_2 ≈ ... ≈ R_G
那么:
R_i - mean_group ≈ 0
最终 advantage 接近 0。
这意味着:
这一组几乎没有学习信号。
这就是 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:
A_i 的 shape 是 [B * num_generations]
进入 token loss 时通过:
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
它的作用是:
惩罚 policy 偏离 frozen SFT reference。
2.5 CISPO 改了什么
GRPO 的 clip 写法会出现一个问题:
当 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 源码里:
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)
关键是:
clamped_ratio.detach()
这表示 ratio 只作为固定权重使用;梯度主要走:
per_token_logps = log pi_theta(...)
所以即使 ratio 被上限截断,策略项仍然通过 logprob 保留梯度路径。
2.6 GRPO/CISPO 与 PPO 的关系
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
3. 源码阅读顺序图
建议按这个顺序读:
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 同步。
4. MiniMind 源码走读
4.1 初始化:没有 critic
源码证据:GRPO 初始化组件
文件:trainer/train_grpo.py:271-292
看它是为了理解:GRPO 初始化 policy、reference、reward model 和 rollout engine,但没有 critic。
代码摘录:
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 的多次生成。
代码摘录:
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 条生成。
代码摘录:
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。
代码摘录:
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_rewardsshape 是[B, num_generations]。- 同一行对应同一个 prompt 的多条 response reward。
advantagesshape 回到[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。
代码摘录:
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。
代码摘录:
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 共享。
代码摘录:
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。
代码摘录:
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。
5. 本节必须会写 / 暂时不要求
5.1 必须会写
本节教学版核心是下面 4 个函数:
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 暂时不要求
本节先不要求手写:
真实 reward model;
SGLang 服务;
真实 rollout 生成;
DDP、checkpoint、wandb/swanlab;
Agentic RL 多轮 tool-use 版本。
6. 手写模块
本节对应教学版文件:
course/impl/core/grpo.py
6.1 补 group_relative_advantages
对齐源码:
trainer/train_grpo.py:121-124
行为要求:
输入:
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
对齐源码:
trainer/train_grpo.py:132-133
行为要求:
delta = ref_logps - policy_logps
return exp(delta) - delta - 1
6.3 补 grpo_policy_loss
对齐源码:
trainer/train_grpo.py:134-143
行为要求:
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
对齐源码:
trainer/train_grpo.py:135-138
行为要求:
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))
7. 实验验证
实验文件:
course/labs/trace_grpo_cispo_loss.py
命令:
cd /home/sun/minimind
python course/labs/trace_grpo_cispo_loss.py
这个实验验证:
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。
记录输出:
grouped_rewards =
group_mean =
group_std =
advantages =
ratio_min =
ratio_max =
kl_mean =
grpo_loss =
cispo_loss =
输出含义:
同一组 advantages 的均值应接近 0;
如果某组 reward 完全一样,该组 advantages 接近 0;
GRPO/CISPO 的 ratio 和 KL 一样,但 loss 形式不同。
8. 阶段组装
PPO/GRPO 阶段现在有两个核心文件:
course/impl/core/ppo.py
course/impl/core/grpo.py
最小教学版 GRPO/CISPO 可以这样组装:
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 本节验收命令
cd /home/sun/minimind
python course/labs/trace_grpo_cispo_loss.py
如果你补完 course/impl/core/grpo.py,后续应新增:
course/impl/tests/test_grpo.py
测试内容:
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 源码差异
教学版先省略:
真实 rollout engine;
真实 reward model;
SGLang;
大模型 checkpoint;
DDP 和日志系统。
但不省略:
group rewards
group-relative advantage
token KL
GRPO/CISPO loss_type 分叉
mask reduction
8.3 Portfolio 记录
完成本节后,可以在 course/portfolio/experiments.md 记录:
GRPO/CISPO trace:
- 构造每个 prompt 的多条 response reward。
- 用组内 mean/std 计算 relative advantage。
- 对比 GRPO clipped ratio loss 和 CISPO weighted logprob loss。
- 说明为什么 GRPO 不需要 critic。
9. 本节检查
- GRPO 为什么可以不训练 critic?
rewards.view(-1, num_generations)依赖什么样的 response 排列顺序?- 如果同一组 reward 完全一样,advantages 会发生什么?
- GRPO 的
advantages.unsqueeze(1)为什么可以乘到每个 token 上? per_token_kl = exp(ref-policy) - (ref-policy) - 1约束的是什么?- GRPO loss 和 PPO policy loss 哪里相似?
- CISPO 为什么要对
clamped_ratio调用.detach()? - MiniMind 的
loss_type改成grpo和cispo时,哪些流程不变?
10. 下一课
第 25 课进入 reward、logprob 与 KL penalty 的专项复盘。
下一课要解决:
PPO/GRPO/DPO 中 logprob 的共同模式;
reference KL、approx KL、DPO reference logratio 的区别;
reward model wrapper 和规则 reward 如何影响 RL 训练;
为什么不同算法都绕不开 mask、EOS 和 token-level logprob。