Skip to content

Latest commit

 

History

History
697 lines (493 loc) · 36.3 KB

File metadata and controls

697 lines (493 loc) · 36.3 KB

15.1 GRPO 训练机制

上一章我们深入了 DPO 的理论与实践,看到它可以直接从固定的偏好数据里学习:同一个 prompt 下,chosen 应该比 rejected 更可能出现。现在我们回到在线训练:模型不再只读别人已经标好的偏好对,而是在训练过程中自己生成回答、自己得到反馈、再用反馈更新自己。

GRPO 的入口是同题多答。给定同一道题,模型一次生成多个回答;奖励函数分别给这些回答打分;然后只在这一组回答内部比较谁更好。它表面上像"让模型多试几次",真正解决的问题是:

没有 Critic 的时候,模型怎么判断某个回答是比预期好,还是比预期差?

一个直观答案是:拿它和同一道题的其他回答比。GRPO 就是沿着这个思路,把同题多答变成可以训练的策略优化方法。

本节沿着一次完整的 GRPO 训练轨迹来讲:先看同题多答怎样产生组内比较,再解释为什么"和同题其他回答比"可以替代 Critic,接着写出优势、概率比值和裁剪目标,最后回到手写代码和 GSM8K 训练实验。

flowchart LR
    X["同一道题(记作 x)"] --> Y1["回答 1"]
    X --> Y2["回答 2"]
    X --> Y3["回答 3"]
    X --> Y4["回答 4"]
    Y1 --> R["按规则给每个回答打分"]
    Y2 --> R
    Y3 --> R
    Y4 --> R
    R --> G["同一题里比一比<br/>谁高于平均分"]
    G --> U["小步改模型<br/>好回答多出现<br/>差回答少出现"]

    style X fill:#eef2ff,stroke:#4f46e5
    style G fill:#e8f5e9,stroke:#2e7d32
    style U fill:#fff3e0,stroke:#f57c00
Loading

这张图先表达一个最基本的训练信号:同一道题多答几次,每个回答都有分数;高于同组平均分的回答以后更容易出现,低于同组平均分的回答以后更少出现

GRPO 的入口

一个带数字的微缩例子

用一个具体例子走一遍。假设题目是:

小明有 3 个苹果,又买了 2 个,现在一共有几个?

模型对同一道题一次写出 4 个回答,规则打分如下:

回答 模型写了什么 分数
1 "3 + 2 = 5,所以答案是 5。" 1.5
2 "答案是 5。" 1.0
3 "应该是 6。" 0.0
4 "不确定,可能是 4。" 0.0

这 4 个分数的平均分是:

$$ \frac{1.5 + 1.0 + 0.0 + 0.0}{4} = 0.625 $$

于是模型会这样理解这组回答:

回答 和平均分比较 之后怎么学
1 $1.5 - 0.625 = +0.875$ 明显比平均好,以后多生成它
2 $1.0 - 0.625 = +0.375$ 也比平均好,稍微多生成它
3 $0.0 - 0.625 = -0.625$ 比平均差,以后少生成它
4 $0.0 - 0.625 = -0.625$ 比平均差,以后少生成它

这里的"比平均分高多少、低多少",后面会被正式叫做优势。在这个例子里,优势就是"这份回答在同题四个回答里表现得比平均好还是差"。

把语言模型放进强化学习框架

为了用 RL 语言讲清楚 GRPO,先把对应关系列出来:

强化学习概念 在数学推理模型里是什么
状态 $s_t$ 题目 prompt 加上已经写出的推理步骤,也就是 $(x, y_{&lt;t})$
动作 $a_t$ 下一步生成的 token,也就是 $y_t$
轨迹 $\tau$ 一整段推理过程和最终答案
奖励 $R$ 答案是否正确、格式是否符合要求
策略 $\pi$ 当前正在训练的语言模型

对一道题 $x$ 来说,模型生成完整回答 $y$ 就相当于走完一条轨迹。被训练的对象仍然是语言模型策略 $\pi_\theta(y \mid x)$

需要澄清一个常见误会:GRPO 不是一个新的模型,也不只是"组内归一化"这个公式。GRPO 是一种在线训练策略模型的方法。 训练方式是:对同一个 prompt 一次生成多个回答,把这些回答放在同一组里打分,并比较:这个回答在同组里是否高于平均水平? 最后更新策略时仍然使用 PPO-style 的 ratio + clip,避免新策略离旧策略太远。

用一句话概括:

GRPO = 在线组采样 + 规则/奖励打分 + 组内相对优势 + PPO-style 裁剪更新。

把开头的苹果题翻译成这句话:同一道题一次生成 4 个回答,这是在线组采样;用答案正确性和格式给分,这是规则/奖励打分;用 $1.5-0.625$、$1.0-0.625$ 这样的差值判断好坏,这是组内相对优势;最后让好回答概率上升、差回答概率下降,但每次只小步调整,这就是 PPO-style 裁剪更新

PPO Critic 的痛点

要理解 GRPO 为什么这样设计,先看它要替代的 Critic 有什么问题。

Critic 是什么

在 PPO 这类 Actor-Critic 方法里,Actor 是负责生成回答的策略模型,Critic 则像一个"价值评估器":它不直接生成回答,而是估计"当前已经写到这里,后面大概能拿到多少总奖励"。用公式写就是价值函数:

$$ V_\phi(s_t) $$

其中 $s_t$ 是当前状态——对语言模型来说可以粗略理解为"prompt 加上已经生成的前几个 token";$\phi$ 是 Critic 自己的参数。Critic 的作用是给策略更新提供一个基线:如果某个回答的真实奖励比 Critic 预估的更高,就说明这个回答比预期好,应该提高概率;如果比预期低,就应该降低概率。

如果照 PPO 的路线走,优势大致写成:

$$ A_t \approx R - V_\phi(s_t) $$

这句话的意思是:不要只看奖励高不高,要看它有没有比 Critic 的预期更好。这在传统强化学习里很自然,但在 LLM 数学推理里就很重。

Critic 在 LLM 训练中的三大问题

1. 吃显存:Critic 与 Actor 同等规模,PPO 需要同时装下 Actor + Critic + Reference + RM 四个模型。

2. 训练不稳定:价值函数 $V(s)$ 需要从"部分生成的文本"预测"最终得分",但 LLM 序列很长(500+ tokens),监督信号只在末尾才有,方差极大。

3. 工程复杂:四个模型各有一套优化器、学习率、梯度裁剪配置,调参难度指数级增长。

回顾第 6 章基线分析第 7 章优势函数,Critic 的核心作用是提供基线来降低方差。如果不需要单独训练网络就能得到基线,Critic 就可以退休了——这就是 GRPO 的出发点。

GRPO 的核心 与 组内归一化替代 Critic

GRPO 的想法出奇地简单:不再单独训练 Critic,而是用同一个 prompt 下多个回答的平均分临时充当基线。DeepSeekMath 论文提出 GRPO 时,明确说它 "foregoes the critic model",并用组内分数来估计基线。

GRPO 从 PPO 中替换 Critic 基线

因此,GRPO 与 PPO 的关系可以概括为:

  • PPO 问:这个回答比 Critic 预估的平均水平好吗?
  • GRPO 问:这个回答比同一道题的其他回答好吗?
  • PPO 和 GRPO 都还会用概率比值和裁剪,避免一次更新过大。

论文脉络:GRPO 来自 DeepSeekMath 论文 DeepSeekMath: Pushing the Limits of Mathematical Reasoning in Open Language Models。它不是完全抛弃 PPO,而是在 PPO 框架里去掉 Critic,用组内相对奖励构造优势。

GRPO 把 PPO 里的 Critic 基线换成"同一道题的一组回答的平均分":原来问"这个回答比 Critic 预期好吗",现在问"这个回答比同题其他回答好吗"。这就是"组内相对优势"的直觉:同一道题里,谁比平均好,就多学谁;谁比平均差,就少生成谁

代码地图

下面是一份最小手写 GRPO 代码地图。它不是 trl 的工程源码,而是把 GRPO 的数学结构摊开给你看:每个公式后面都能回到这份代码里的某几行。

这份代码可以分成八块:

标记 代码部分 后文会解释什么
[A] sample_groups 为什么每个 prompt 要生成多个回答
[B] rule_reward / score_responses 奖励从哪里来,为什么数学题不需要 RM
[C] group_advantages 组内均值如何替代 Critic 基线
[D] sequence_logprob 如何给一整段回答算 $\log \pi_\theta(y \mid x)$
[E] grpo_loss 前半段 ratioclip 和 PPO-style 策略更新
[F] approx_kl 为什么还要限制 Policy 偏离 Reference
[G] train_step 采样、打分、优势、loss、反向传播如何接起来
[H] train_grpo 为什么 GRPO 是在线训练,每轮都生成新回答

从 PPO 改到 GRPO 与 到底替换了哪几行

如果不改成 GRPO,而是继续按 PPO / RLHF 的方式训练,代码直觉通常是这样:

# PPO / RLHF 与 在线生成,然后让 Critic 估计基线
responses = policy_old.generate(prompts)
logps_old = sequence_logprob(policy_old, prompts, responses).detach()

rewards = reward_model(prompts, responses)
values = critic(prompts, responses)
advantages = rewards - values

logps_new = sequence_logprob(policy, prompts, responses)
ratio = torch.exp(logps_new - logps_old)
ppo_loss = -torch.min(
    ratio * advantages,
    torch.clamp(ratio, 1 - clip_eps, 1 + clip_eps) * advantages,
).mean()

这里的 critic 就是前面说的价值模型。它的工作不是生成答案,而是估计一个基线:这个 prompt 和当前回答前缀,大概应该拿多少分。然后 PPO 用 rewards - values 得到优势,判断某个回答是"比预期好"还是"比预期差"。

GRPO 的改法很集中:保留在线生成、概率比值和裁剪,但不再训练 Critic;优势改成从同一个 prompt 的一组回答里算出来

# 同一个 prompt 生成 G 个回答,然后做组内比较
responses = generate_many(policy_old, prompts, num_generations=G)
logps_old = sequence_logprob(policy_old, prompts, responses).detach()

rewards = reward_fn(prompts, responses)
rewards_by_group = rewards.view(batch_size, G)

group_mean = rewards_by_group.mean(dim=1, keepdim=True)
group_std = rewards_by_group.std(dim=1, keepdim=True)
advantages = ((rewards_by_group - group_mean) / (group_std + 1e-4)).view(-1)

logps_new = sequence_logprob(policy, prompts, responses)
ratio = torch.exp(logps_new - logps_old)
grpo_loss = -torch.min(
    ratio * advantages,
    torch.clamp(ratio, 1 - clip_eps, 1 + clip_eps) * advantages,
).mean()

把真正变化的几行单独拎出来,就是:

  responses = policy_old.generate(prompts)
  rewards = reward_model_or_rule(prompts, responses)
- values = critic(prompts, responses)
- advantages = rewards - values

+ rewards_by_group = rewards.view(batch_size, G)
+ group_mean = rewards_by_group.mean(dim=1, keepdim=True)
+ group_std = rewards_by_group.std(dim=1, keepdim=True)
+ advantages = ((rewards_by_group - group_mean) / (group_std + 1e-4)).view(-1)

  loss = ppo_style_clipped_loss(logps_new, logps_old, advantages)

所以 GRPO 不是"把 PPO 全删掉",也不是"只剩一个组内归一化公式"。更准确地说,GRPO 把 PPO 里的 Critic 基线换成了组内平均基线

TRL 的真实源码也正是这个结构。2026-05-01 查看 Hugging Face TRL main 分支时,可以在 GRPOTrainer 里看到这些对应关系:

  1. GRPOTrainer 的初始化参数里有 reward_funcs,它可以是奖励模型,也可以是普通 Python 函数。也就是说,数学题这类任务可以直接用规则函数打分,不一定要先训练 RM。
  2. self.num_generations = args.num_generations 对应公式里的 $G$,也就是每个 prompt 生成几个回答
  3. 源码会把 rewards reshape 成 (-1, num_generations),计算 mean_grouped_rewards 和组内 std_rewards,再得到 advantages = rewards - mean_grouped_rewards,必要时除以标准差。
  4. 损失部分仍然计算 coef_1 = exp(log_ratio),再用 torch.clamp 得到 coef_2,最后对 coef_1 * advantagescoef_2 * advantagesmin。这就是 PPO-style 裁剪目标。

对照 PPOTrainer,差别就更清楚:PPOTrainer 需要 reward_modelvalue_model,并用 value_model 产生优势估计;GRPOTrainer 不需要单独的 value_model,它把同题多答的组内相对分数直接变成优势。

GRPO 的完整公式

前面用直觉和代码 diff 看过 GRPO 怎么工作。这一节把所有公式正式写一遍,要证明的核心命题是:用组内归一化替代 Critic 之后,GRPO 的策略梯度方向和 PPO 完全一致——只是基线换了来源

核心命题:对任意同题多答样本 $(x_j, {y_{j,i}}{i=1}^G, {r{j,i}}{i=1}^G)$,组内归一化优势 $\hat A{j,i} = (r_{j,i} - \bar r_j) / (s_j + \epsilon)$ 满足:

  1. 梯度方向正确:$\hat A_{j,i} > 0 \Leftrightarrow r_{j,i} > \bar r_j$,即优势的正负和"是否好于组平均"完全对齐;
  2. 不引入额外偏差:$\mathbb{E}i[\hat A{j,i}] = 0$,组内期望为零,与 Critic 基线的性质一致;
  3. 难度归一:$\text{Var}i[\hat A{j,i}] \approx 1$,不同难度的题目梯度尺度一致。

下面四小节依次给出样本结构、组内优势(命题 1+2+3 的兑现)、PPO Clip、KL 惩罚,最后用一张数据流图收尾。

样本结构与组采样

GRPO 的训练样本不是"一个 prompt 配一个回答",而是一个 prompt 配一组回答。假设一个 batch 里有多个题目,用 $j$ 表示第几个题目,用 $i$ 表示这个题目下第几个回答:

$$ x_j \quad \longrightarrow \quad {y_{j,1}, y_{j,2}, \ldots, y_{j,G}} $$

每个字母的意思是:

  • $x_j$:第 $j$ 个 prompt,也就是一道题或一个问题。
  • $G$:group size,每个 prompt 生成几个回答。代码里的 num_generations=8 就是 $G=8$
  • $y_{j,i}$:第 $j$ 个 prompt 下生成的第 $i$ 个回答。
  • $\pi_{\text{old}}$:生成这批回答时使用的旧策略。它负责采样数据。
  • $\pi_\theta$:正在被更新的新策略。它负责学习,让好回答更可能出现。

采样过程可以写成:

$$ y_{j,i} \sim \pi_{\text{old}}(\cdot \mid x_j), \qquad i = 1, \ldots, G $$

符号 $\sim$ 表示"从某个分布中采样"。每个回答生成后,都要得到一个奖励:

$$ r_{j,i} = R(x_j, y_{j,i}) $$

这里 $R$ 是奖励函数,$r_{j,i}$ 是一个标量。数学题里,$R$ 可以很简单:答案对就加分,格式规范也加分。GRPO 的关键不是"奖励函数一定很复杂",而是:同一道题下的多个回答会放在一起比较

代码里对应的是 [A] 组采样

替代 Critic 的基线

GRPO 的核心思路在这里兑现:对同一个问题 $x_j$,先采样 $G$ 个回答得到 $G$ 个奖励 ${r_{j,1}, \ldots, r_{j,G}}$,再做两步处理——减均值替代 Critic,除标准差归一化尺度。两步合起来得到组内优势:

$$ \hat A_{j,i} = \frac{r_{j,i} - \bar r_j}{s_j + \epsilon} $$

其中 $\bar r_j = \frac{1}{G}\sum_i r_{j,i}$ 是组内均值,$s_j = \sqrt{\frac{1}{G}\sum_i (r_{j,i}-\bar r_j)^2}$ 是组内标准差,$\epsilon$ 是一个很小的数(如 $10^{-4}$),防止标准差为 0 时除以 0。下面两步推导把这个公式拆开看为什么是这个形式。

第一步:减均值替代 Critic。回顾 PPO 优势 $A_t = R - V_\phi(s_t)$,本质是"奖励减基线"。Critic 学的 $V_\phi(s_t)$ 就是对"在这个 prompt 下平均能拿多少分"的估计。而组内均值 $\bar r_j$ 是这个估计的直接样本版本——同一道题的 $G$ 个回答就是 $V(s_j)$$G$ 次蒙特卡洛采样,平均起来就是无偏估计。代换:

$$ \underbrace{R - V_\phi(s_t)}_{\text{PPO 优势}} \quad \longrightarrow \quad \underbrace{r_{j,i} - \bar r_j}_{\text{GRPO 优势(未归一化)}} $$

这一步保证了:$\hat A$ 的正负和"是否好于组平均"完全对齐;组内期望 $\mathbb{E}i[r{j,i} - \bar r_j] = 0$,与 Critic 基线的性质一致。

第二步:除标准差归一化尺度。不同题目的奖励尺度差异巨大——简单题组内奖励可能在 $[1.0, 1.5]$ 之间波动($\bar r = 1.2$, $s = 0.2$),难题组内可能在 $[0.0, 0.5]$ 之间波动($\bar r = 0.2$, $s = 0.2$)。如果只减均值不除标准差,简单题和难题的梯度尺度相同——但简单题已经掌握了,不应该再主导梯度。除以 $s_j$ 把所有题目的优势尺度拉到接近 1:

$$ \text{Var}_i\left[\frac{r_{j,i} - \bar r_j}{s_j}\right] = \frac{\text{Var}_i[r_{j,i}]}{s_j^2} = \frac{s_j^2}{s_j^2} = 1 $$

在统计学里这个变换叫 z-score 标准化,几何含义是把每组奖励平移到原点、缩放到单位方差,让不同分布可以在同一坐标轴上比较。

两步合起来读:$\hat A_{j,i} > 0$ 表示这个回答比同组平均好,应该提高概率;$\hat A_{j,i} < 0$ 表示比平均差,应该降低概率;$\hat A_{j,i} \approx 0$ 表示和平均差不多,不需要太强更新。这种"控制变量"式的组内比较也比跨样本的绝对评分更稳定——同一组内的回答共享相同的 prompt,唯一差异是模型生成的随机性。它也和人类偏好的本质对齐:判断本来就是"A 比 B 好"这种比较式的,不是"A 得 87 分"这种绝对的。

边界情形与代码对应

如果同一组回答奖励全都一样,$s_j$ 会接近 0,代码会把优势设成 0。这表示这道题暂时没有可学习的差异:大家都对,或者大家都错,模型不知道该更偏向哪一个回答。$\epsilon$ 的作用是避免 $0/0$ 的数值问题。

代码里对应的是 [C] 组内优势

代码对应关系:

  • grouped_rewards = rewards.view(-1, group_size):把一维奖励列表重新排成"每行一个 prompt、每行 $G$ 个回答"的形状。
  • group_mean = grouped_rewards.mean(dim=1, keepdim=True):计算每个 prompt 的 $\bar r_j$
  • group_std = grouped_rewards.std(dim=1, keepdim=True):计算每个 prompt 的 $s_j$
  • advantages = (grouped_rewards - group_mean) / (group_std + eps):实现 $\hat A_{j,i}$
  • torch.where(group_std < eps, 0, advantages):如果一组回答没有差异,就不给这组样本训练信号。

一句话总结:GRPO = PPO 的裁剪机制 + 用组内排名替代 Critic。下面两小节就把"PPO 的裁剪机制"完整展开。

策略比值与 PPO Clip

先定义新旧策略的概率比值:

$$ \rho_{j,i}(\theta) = \frac{\pi_\theta(y_{j,i} \mid x_j)}{\pi_{\text{old}}(y_{j,i} \mid x_j)} $$

实际代码里不会直接除两个很小的概率,而是先算 log probability,再相减取指数:

$$ \rho_{j,i}(\theta) = \exp\left(\log \pi_\theta(y_{j,i} \mid x_j) - \log \pi_{\text{old}}(y_{j,i} \mid x_j)\right) $$

如果 $\rho=1$,新旧策略对这条回答的概率一样;如果 $\rho=1.2$,新策略把它的概率提高了 20%;如果 $\rho=0.8$,新策略把它的概率压低了 20%。

有了比值和组内优势,GRPO 的裁剪目标可以写成:

$$ \mathcal{J}_{\text{GRPO}}^{\text{clip}}(\theta) = \mathbb{E}_{j,i} \left[\min\left(\rho_{j,i}(\theta),\hat A_{j,i},; \operatorname{clip}(\rho_{j,i}(\theta), 1-\epsilon_{\text{clip}}, 1+\epsilon_{\text{clip}}),\hat A_{j,i}\right)\right] $$

每个符号的意思是:

  • $\mathbb{E}_{j,i}$:对 batch 里的所有 prompt 和所有组内回答取平均。
  • $\hat A_{j,i}$:刚才算出的组内优势。
  • $\epsilon_{\text{clip}}$:裁剪范围,常见值是 0.2。
  • $\operatorname{clip}(\rho, 1-\epsilon_{\text{clip}}, 1+\epsilon_{\text{clip}})$:把概率比值限制在一个区间内。例如 $\epsilon_{\text{clip}}=0.2$ 时,$\rho$ 会被限制在 $[0.8, 1.2]$
  • $\min(\cdot, \cdot)$:选择更保守的那个目标,避免一次更新太大。

为什么要裁剪?因为这批回答是 $\pi_{\text{old}}$ 生成的。如果训练几步后 $\pi_\theta$ 已经离 $\pi_{\text{old}}$ 很远,那么这批数据就不再能可靠代表新策略的行为。裁剪的作用就是:允许模型学习,但不允许它因为同一批数据一下子改得太猛。这部分和第 8 章 PPO 裁剪机制完全一致,详细推导见策略更新的约束机制

在代码里,new_logprobs$\log \pi_\theta(y_{j,i} \mid x_j)$old_logprobs$\log \pi_{\text{old}}(y_{j,i} \mid x_j)$。所以:

  • ratio = torch.exp(new_logprobs - old_logprobs):实现 $\rho_{j,i}(\theta)$
  • surr1 = ratio * advantages:不裁剪时的策略目标。
  • clipped_ratio = torch.clamp(ratio, 1.0 - clip_eps, 1.0 + clip_eps):把 $\rho$ 限制在 $[1-\epsilon_{\text{clip}}, 1+\epsilon_{\text{clip}}]$
  • surr2 = clipped_ratio * advantages:裁剪后的策略目标。
  • policy_loss = -torch.min(surr1, surr2).mean():取保守目标,并加负号变成要最小化的 loss。

注意上面的 $\mathcal{J}_{\text{GRPO}}^{\text{clip}}$ 是"想最大化"的目标;代码里的优化器默认最小化 loss,所以会写成:

$$ \text{policy_loss} = -\mathcal{J}_{\text{GRPO}}^{\text{clip}}(\theta) $$

这就是为什么代码中有一个负号。

KL 惩罚 与 不要离 Reference 太远

GRPO 通常还会保留一个 KL 惩罚,让 Policy 不要离 Reference 太远。手写代码里使用的是一个常见的近似 KL:

$$ \widehat D_{\text{KL}} = \exp(\Delta) - \Delta - 1, \qquad \Delta = \log \pi_{\text{ref}}(y \mid x) - \log \pi_\theta(y \mid x) $$

这个形式不是凭空选的,它满足三个关键性质。

性质一:逐样本非负。令 $u = \exp(\Delta)$,则 $\widehat D_{\text{KL}} = u - \log u - 1 \circeq g(u)$。求导 $g'(u) = 1 - 1/u$、$g''(u) = 1/u^2 > 0$,所以 $g$ 是凸函数,在 $u = 1$(即 $\Delta = 0$)处取最小值 $g(1) = 0$任何 $\Delta \neq 0$ 都给出正值——这避免了朴素估计 $-\Delta$ 在单样本上可能为负的麻烦。

性质二:是 $D_{\text{KL}}(\pi_\theta | \pi_{\text{ref}})$ 的无偏估计。注意 $\exp(\Delta) = \pi_{\text{ref}}/\pi_\theta$,所以 $\mathbb{E}{y \sim \pi\theta}[\exp(\Delta)] = \sum_y \pi_\theta(y) \cdot \pi_{\text{ref}}(y)/\pi_\theta(y) = 1$;而 $\mathbb{E}{\pi\theta}[\Delta] = -D_{\text{KL}}(\pi_\theta | \pi_{\text{ref}})$。代回去:

$$ \mathbb{E}_{\pi_\theta}[\widehat D_{\text{KL}}] = 1 - (-D_{\text{KL}}) - 1 = D_{\text{KL}}(\pi_\theta | \pi_{\text{ref}}) $$

性质三:在小偏差处退化为二次型。把 $\exp(\Delta)$$\Delta = 0$ 处 Taylor 展开:$\exp(\Delta) = 1 + \Delta + \Delta^2/2 + O(\Delta^3)$,所以

$$ \widehat D_{\text{KL}} = \exp(\Delta) - \Delta - 1 = \frac{\Delta^2}{2} + O(\Delta^3) $$

几何含义:$\widehat D_{\text{KL}}$ 作为 $\Delta$ 的函数是一条 $U$ 形曲线,最低点在 $\Delta = 0$(Policy = Reference),开口由 $\Delta^2/2$ 主导。这正是"越偏离惩罚越大"在数学上的写照——而二次型主导意味着梯度在偏离小时温和、偏离大时变陡,避免一次性把策略推得太远。

最后总损失可以写成:

$$ \mathcal{L}_{\text{GRPO}} = -\mathcal{J}_{\text{GRPO}}^{\text{clip}}(\theta) + \beta_{\text{KL}},\widehat D_{\text{KL}} $$

这里 $\beta_{\text{KL}}$ 是 KL 惩罚的权重,对应代码里的 kl_coef。它越大,模型越保守;它越小,模型越愿意离开 Reference 去探索高奖励回答。

代码对应关系:

  • log_ratio_ref = ref_logprobs - new_logprobs:实现 $\Delta$
  • approx_kl = (torch.exp(log_ratio_ref) - log_ratio_ref - 1.0).mean():实现 $\widehat D_{\text{KL}}$
  • loss = policy_loss + kl_coef * approx_kl:实现总损失 $\mathcal{L}_{\text{GRPO}}$

一次完整训练的七步

把所有步骤连起来,GRPO 的一次训练就是:

  1. 对每个 prompt 采样 $G$ 个回答。
  2. 用规则或奖励函数给每个回答打分。
  3. 在同一个 prompt 的组内计算 $\bar r_j$、$s_j$ 和 $\hat A_{j,i}$
  4. 用新旧策略 log probability 算 $\rho_{j,i}(\theta)$
  5. 用 PPO-style clip 控制更新幅度。
  6. 加上 Reference KL 惩罚。
  7. 反向传播,只更新 Policy。

完整的 GRPO 数据流如下图:

flowchart TD
    subgraph sampling ["第一步:组采样"]
        Q["问题 x\n'小明有15个苹果...'"] --> G1["回答 1: 答案=7 (正确)"]
        Q --> G2["回答 2: 答案=12 (错误)"]
        Q --> G3["回答 3: 答案=7 (正确)"]
        Q --> G4["回答 4: 答案=8 (错误)"]
        Q --> G5["... (共 k 个回答)"]
        Q --> Gk["回答 k: 答案=7 (正确)"]
    end

    subgraph scoring ["第二步:规则打分"]
        G1 --> R1["r_1 = 1.5 (正确+格式)"]
        G2 --> R2["r_2 = 0.0 (错误)"]
        G3 --> R3["r_3 = 1.5 (正确+格式)"]
        G4 --> R4["r_4 = 0.0 (错误)"]
        G5 --> R5["..."]
        Gk --> Rk["r_k = 1.0 (正确,无格式)"]
    end

    subgraph normalize ["第三步:组内归一化"]
        R1 --> Mean["计算组内统计量\nmean = 0.83\nstd = 0.69"]
        R2 --> Mean
        R3 --> Mean
        R4 --> Mean
        R5 --> Mean
        Rk --> Mean
        Mean --> A1["A_1 = (1.5-0.83)/0.69 = +0.97 ✓ 强化"]
        Mean --> A2["A_2 = (0.0-0.83)/0.69 = -1.20 ✗ 抑制"]
        Mean --> A3["A_3 = (1.5-0.83)/0.69 = +0.97 ✓ 强化"]
    end

    subgraph update ["第四步:PPO Clip 更新"]
        A1 --> Clip["L = min(r_t × A_i, clip(r_t, 1-ε, 1+ε) × A_i)"]
        A2 --> Clip
        A3 --> Clip
        Clip --> Update["更新策略网络 θ"]
    end

    style Q fill:#e3f2fd,stroke:#1976d2
    style Mean fill:#fff3e0,stroke:#f57c00
    style A1 fill:#e8f5e9,stroke:#2e7d32
    style A2 fill:#fce4ec,stroke:#c62828
Loading

GRPO 训练实验 与 GSM8K + 规则奖励

公式讲完后,看一次真实的 GRPO 训练。本节用一个最小可跑的实验:在 GSM8K 上用规则奖励训练 Qwen2.5-1.5B。

为什么不需要 RM

GSM8K 是一个包含 8500 道小学数学应用题的数据集,每道题都有明确的数值答案。这恰好是一个有"客观正确答案"的场景——不需要 RM,直接用规则判断答案是否正确:

  • 答案正确:$+1.0$ 分
  • 格式规范(有清晰的推理步骤):$+0.5$ 分
  • 答案错误:$0$ 分
# 1. 规则奖励函数(不需要 RM!)
import re

def rule_based_reward(prompt: str, response: str, ground_truth: str) -> float:
    reward = 0.0
    # 格式分:检查 \boxed{...}
    if re.search(r'\\boxed\{[^}]+\}', response):
        reward += 0.5
    # 答案分:提取最终答案并比较
    answer_match = re.search(r'\\boxed\{([^}]+)\}', response)
    if answer_match:
        model_answer = answer_match.group(1).strip()
        try:
            if abs(float(model_answer) - float(ground_truth)) < 0.01:
                reward += 1.0
        except ValueError:
            if model_answer == ground_truth:
                reward += 1.0
    return reward

# 测试
prompt = "Janet 的鸡蛋盒子每天能装 16 个鸡蛋。她每天早上吃 3 个,下午用 4 个烤松饼。她每周能卖多少个鸡蛋?"
good = "首先计算每天剩余的鸡蛋数:16 - 3 - 4 = 9 个\n每周有 7 天,所以每周能卖:9 × 7 = 63 个\n\\boxed{63}"
bad = "我觉得大概能卖 50 个左右吧。\\boxed{50}"
print(rule_based_reward(prompt, good, '63'))  # 1.5
print(rule_based_reward(prompt, bad, '63'))   # 0.5

注意这里的关键区别:不需要训练任何 RM,规则就是裁判。数学题有标准答案,直接比较就行。这种"可验证奖励"正是 RLVR 的核心思想。

在手写代码地图中,奖励函数对应的是 [B]。它只接收回答和标准答案,返回一个标量奖励:

运行 GRPO 训练

我们使用 trl 库提供的 GRPO 实现。和 PPO 相比,GRPO 不需要 Critic 模型:

# 2. GRPO 训练代码(简化示意)
from trl import GRPOTrainer, GRPOConfig
from transformers import AutoModelForCausalLM, AutoTokenizer
from datasets import load_dataset

model = AutoModelForCausalLM.from_pretrained("Qwen/Qwen2.5-1.5B-Instruct")
tokenizer = AutoTokenizer.from_pretrained("Qwen/Qwen2.5-1.5B-Instruct")
if tokenizer.pad_token is None:
    tokenizer.pad_token = tokenizer.eos_token
tokenizer.padding_side = "left"

config = GRPOConfig(
    output_dir="./grpo_gsm8k",
    num_generations=8,        # 每个问题生成 k=8 个回答(组大小)
    per_device_train_batch_size=4,
    learning_rate=5e-6,
    num_train_epochs=1,
    # 不需要 Critic!这是 GRPO 的核心创新
)

gsm8k = load_dataset("openai/gsm8k", "main")
trainer = GRPOTrainer(
    model=model,
    args=config,
    train_dataset=gsm8k["train"],
    reward_funcs=[rule_based_reward],  # 直接传入规则奖励函数
    processing_class=tokenizer,
)

trainer.train()  # 开始训练——不需要 Critic,不需要 RM
trainer.save_model("./grpo_gsm8k/final_model")

如果把 GRPOTrainer 内部最关键的训练步骤摊开,就是"先组采样,再打分,再算优势,再更新策略":

推理步骤的变化

GRPO 训练最令人兴奋的观察是模型推理方式的变化:

训练前(直接猜答案):

题目:小明有 15 个苹果,给了小红 3 个,又给了小刚 5 个,还剩多少个?
回答:我觉得还剩 7 个。\boxed{7}

训练后(展示推理过程):

题目:小明有 15 个苹果,给了小红 3 个,又给了小刚 5 个,还剩多少个?
回答:
让我一步一步算:
- 小明一开始有 15 个苹果
- 给了小红 3 个:15 - 3 = 12
- 又给了小刚 5 个:12 - 5 = 7
- 所以还剩 7 个
\boxed{7}

模型从"直接猜答案"变成了"先列算式再计算"——这不是我们教它的,而是模型在 GRPO 训练过程中自己"领悟"出来的。因为展示推理步骤能提高答案正确率(拿到更高的规则奖励),所以 GRPO 的优化压力自然地选择了这条路径。

flowchart LR
    subgraph before ["训练前"]
        B1["问题"] --> B2["直接猜答案\n(无推理步骤)"]
        B2 --> B3["答案经常算错\n奖励 ≈ 0"]
    end

    subgraph after ["训练后"]
        A1["问题"] --> A2["分步推理\n(列算式 → 计算 → 验证)"]
        A2 --> A3["答案正确率大幅提升\n奖励 ≈ 1.5"]
    end

    B3 -->|"GRPO 优化压力\n'展示步骤 = 更高分'"| A1

    style B3 fill:#fce4ec,stroke:#c62828
    style A3 fill:#e8f5e9,stroke:#2e7d32
Loading

实验对比与参数调优

显存占用对比

模型大小 PPO 显存(4 模型) GRPO 显存(2 模型) 节省比例
1.5B ~24 GB ~14 GB ~42%
7B ~80 GB ~48 GB ~40%
14B ~160 GB ~96 GB ~40%
70B ~640 GB ~384 GB ~40%

GRPO 省掉了 Critic(和 Actor 同等规模)和 RM 两个模型,通常能减少 30-40% 的显存占用。在实际工程中,这意味着原本需要 8 张 A100 的训练任务,现在 5 张就够了。

组内方差的演化

GRPO 的核心创新是用组内归一化替代 Critic。在训练初期,同一个问题的 8 个回答质量差异很大(方差高)。随着训练推进,组内回答质量趋于一致(方差降低),大部分回答都能答对。

训练初期(Episode 10):
  问题 "15 - 3 - 5 = ?" 的 8 个回答:[3, 7, 12, 7, 15, 7, 8, 10]
  组内方差:高(答案五花八门)
  归一化优势:[−1.2, +0.1, +0.8, +0.1, +1.5, +0.1, −0.3, +0.6]

训练中期(Episode 100):
  同一问题的 8 个回答:[7, 7, 7, 8, 7, 7, 7, 7]
  组内方差:低(大部分答对了)
  归一化优势:[0, 0, 0, −0.5, 0, 0, 0, 0]

训练后期(Episode 300):
  同一问题的 8 个回答:[7, 7, 7, 7, 7, 7, 7, 7]
  组内方差:接近零(全部答对)
  归一化优势:全部接近零 → 无梯度信号

当组内方差降为零时,优势全部为零,没有梯度信号了——模型在这个问题上"毕业"了。这正是我们想要的行为:训练信号自然地转移到还没掌握的题目上。

k 值的选择

k(组大小)是 GRPO 最关键的超参数,它直接影响组内归一化的质量:

k 值 采样成本 归一化质量 适用场景
2 低(每个问题只采 2 次) 差(均值和标准差不稳定) 快速验证
4 中等 一般 资源有限时
8 较高 良好 默认推荐
16 很好(统计量更稳定) 追求上限
64 很高 极好 大规模训练
# GRPO 组内归一化的简单实现
import numpy as np

def grpo_group_normalize(rewards: list[float]) -> list[float]:
    rewards = np.array(rewards, dtype=float)
    mean, std = rewards.mean(), rewards.std()
    if std < 1e-8:
        return np.zeros_like(rewards)
    return (rewards - mean) / std

# 8 个回答的奖励
rewards = [1.5, 0.0, 1.5, 0.0, 1.0, 1.5, 0.5, 1.5]
advantages = grpo_group_normalize(rewards)
# 归一化优势: [ 0.89 -1.48  0.89 -1.48  0.10  0.89 -0.69  0.89]
# 均值: 0.9375, 标准差: 0.634
思考题:GRPO 的组内归一化在什么情况下会失效?
  1. k 太小:$k=2$ 时均值和标准差极不稳定,统计量不可靠。
  2. 奖励分布偏斜:大部分回答得零分时,少数高分回答主导梯度信号。
  3. 所有回答质量相同:方差为零,优势全部为零,无梯度信号——即训练后期"毕业"现象。
  4. 奖励信号不连续:只有 0/1 两个值时,归一化后的优势分布是离散的,梯度信号不够精细。

GRPO 通过 DAPO 的"动态采样"改进来缓解这些问题——过滤掉模型已经答对的题目,只保留有梯度信号的样本。

GRPO 与 PPO 全面对比

组件 PPO GRPO
基线(Critic) 独立的 $V(s)$ 网络 组内均值 $\bar{r}$
优势计算 $A = R - V(s)$ 或 GAE $A_i = (r_i - \bar{r}) / \sigma_r$
模型数量 4 个(Actor + Critic + Ref + RM) 2 个(Actor + Ref)
裁剪机制 PPO Clip 同样的 PPO Clip
采样方式 在线交互 组采样(每个 prompt 采 k 个)
显存 低 30-40%
基线质量 依赖 Critic 训练质量 依赖组大小 $k$
基线更新速度 需要重新训练 Critic 自动随 batch 更新

值得注意的是,GRPO 继承了 PPO 的裁剪机制,但没有继承 GAE。原因是 GRPO 的奖励通常只在序列末尾给出一个信号(答对/答错),而不是每个 token 都有奖励。在这种情况下,GAE 的多步 TD 退化为单步,和直接用最终奖励减去均值没有本质区别。

GRPO 通过组内归一化优雅地解决了 Critic 的问题。但这只是第一步——在策略端,DeepSeek-R1-Zero 证明了不需要 SFT 也能做纯 RL 训练,DAPO 进一步优化了 GRPO 的工程效率。让我们看看这些前沿进展——DeepSeek-R1 与 DAPO