策略梯度(GRPO)
在一个「把单词倒着拼」的程序化任务上跑通完整的在线 RL 循环——生成、打分、算优势、更新,然后学会问那个最要紧的问题:这一组 rollout 里到底有没有可学的差异。
0. 任务目标
Qwen/Qwen3-1.7B 做全参数训练,加上 AdamW 状态和 rollout 缓存,远超本机剩余的约 6.9 GiB 可用显存(详见第 5 节)。
因此,下面的内容是任务设计与预期观察点,不是实测结果。本页不给出任何具体的实验数值——凡是「该盯哪些指标」「指标健康时是什么形态」「不健康时说明什么」都会写清楚,但不会出现任何「我们跑出了 X」。真正的实测记录只在
homework/RESULTS.md 里,那里目前只有 HW1、HW2、HW4 三组。
第 6 章把策略梯度的整个算法族压缩成了一个式子:
$$ \Delta\theta \;\propto\; \Psi_t\,\nabla_\theta\log\pi_\theta(a_t\mid s_t) $$所有算法的差别只在两条轴上:$\Psi_t$(「刚才那次到底好不好」)怎么估,以及更新怎么被约束住。GRPO 在第一条轴上给出的答案特别简洁——同一个 prompt 采一组回复,用组内的 z-score 当优势,从而彻底省掉了 PPO 里那个学出来的价值模型。
这一组作业的目标不是「把 spell_backward 刷到 100%」,而是让你亲手把在线 RL 循环的每个环节看一遍,并建立三个判断力:(1) 知道该看哪些数——策略梯度的 loss 几乎没有解释性,真正的信号是奖励分量;(2) 知道 GRPO 空转时长什么样——组内 rollout 全对或全错时优势恒为 0、梯度为 0,训练在「正常运行」的外表下什么都没学到,第 4 节整节讲它;(3) 知道在线 RL 的算力账怎么算——每个优化步都要先做一次批量生成,生成远比反向传播贵。
min_word_len / max_word_len),可以直接控制「组内有没有对比度」这个核心变量。
这其实就是第 7 章 RLVR 的最小形态:把奖励模型换成一个验证函数,算法一行都不用改。
1. 运行命令与循环结构
上游给出的起始命令是:
cd _src/code/
uv sync
# 可选:把指标打到自己的 W&B(不设则用 configs 里的 wandb_project)
export WANDB_PROJECT=rlhf-book
python -m policy_gradients.train --config policy_gradients/configs/grpo.yaml
建议同时用指标包装器把逐步指标落盘(上游每步指标只进 wandb,控制台只有进度条):
METRICS_JSONL=homework/hw3-pg/logs/metrics_grpo.jsonl \
WANDB_MODE=disabled \
python homework/tools/run_with_metrics.py policy_gradients.train \
--config policy_gradients/configs/grpo.yaml
这个包装器的原理和注意事项见 HW4 第 6 节——它对本组同样适用,因为 policy_gradients/train.py 用的是同一套 wandb.log 约定。
nohup 或后台任务的方式启动,然后定期看日志。
一个训练步里发生了什么
照着 train.py 和 rollout.py 读一遍,一个 step 的骨架是:取 prompts_per_step 个题目 → 每题采 num_rollouts 条回复(这就是 GRPO 的「组」)→ compute_rewards 用环境的 score_answer 判定正确性并加上格式奖励 → compute_advantages 按损失类型算优势 → 按 train_batch_size × batch_acc 做梯度累积并更新。
注意其中两个「批大小」是独立的概念:prompts_per_step × num_rollouts 决定这一步用多少样本估梯度(统计意义上的 batch),train_batch_size 只决定一次前向塞多少条进显存(工程意义上的 micro-batch)。前者改了算法行为,后者只改显存占用。
2. grpo.yaml 逐项拆解
上游配置全文(_src/code/policy_gradients/configs/grpo.yaml):
data:
size: 3000
specs:
- name: spell_backward
weight: 1
config:
min_word_len: 3
max_word_len: 10
loss: grpo
model_name: Qwen/Qwen3-1.7B
clip_eps_lo: 0.2
clip_eps_hi: 0.2
beta: 0.0 # KL penalty coefficient (0 = disabled)
kl_estimator: kl3
lr: 5e-6
temperature: 0.6
top_p: 0.95
top_k: 20
min_p: 0.0
max_new_tokens: 512
prompts_per_step: 4
num_rollouts: 8
train_batch_size: 2
batch_acc: 4
max_norm: 1.0
seed: 42
model_device_id: 0
任务与数据
| 字段 | 默认 | 作用与调参含义 |
|---|---|---|
data.size | 3000 | 程序化生成多少道题。它同时决定了训练总步数(题目消耗完就结束)。调小会更快重复见到同类题目,调大覆盖更广但收敛更慢。 |
data.specs[].name | spell_backward | Reasoning Gym 的任务名。可以换成别的任务,或写多条做混合训练(weight 控制采样比例)。 |
min_word_len / max_word_len | 3 / 10 | 任务难度旋钮,也是最重要的「对比度旋钮」之一。词太短则几乎全对,词太长则几乎全错,两种情况都会让组内优势塌成 0(第 4 节)。 |
算法与更新约束
| 字段 | 默认 | 作用与调参含义 |
|---|---|---|
loss | grpo | 决定优势怎么算、损失怎么写。改成 reinforce/rloo/drgrpo/dapo 等即切换算法(第 6 节)。 |
clip_eps_lo / clip_eps_hi | 0.2 / 0.2 | PPO 式重要性比裁剪的上下界。GRPO 用对称裁剪;CISPO/DAPO 故意用不对称的上界(clip_eps_hi 更大)来给低概率 token 更多上升空间。 |
beta | 0.0 | 对参考模型的 KL 惩罚系数,默认关闭。这和第 6 章的观察一致:在推理类训练里 KL 惩罚的普及度整体下降,很多代码干脆完全关掉。注意:设成 >0 会额外加载一份参考模型,显存直接多一份权重。 |
kl_estimator | kl3 | k1/k2/k3 三种 KL 估计器,只在 beta > 0 时生效。k3 是无偏且非负的那个(Schulman 的经典笔记)。 |
max_norm | 1.0 | 梯度范数裁剪。RL 里梯度尖峰是常态,这一项基本不该关。 |
采样设置(在线 RL 特有,也最容易被忽视)
| 字段 | 默认 | 作用与调参含义 |
|---|---|---|
temperature | 0.6 | 直接决定组内多样性。调太低 → 8 条回复几乎一模一样 → 无对比度 → 无梯度;调太高 → 格式错误暴增,噪声压过信号。 |
top_p / top_k / min_p | 0.95 / 20 / 0.0 | 截断式采样。它们和 temperature 一起构成「探索强度」,但注意:截断会让实际采样分布不等于 $\pi_\theta$,严格来说引入了 off-policy 偏差。 |
max_new_tokens | 512 | 生成长度上限。截断的回复会被判为失败,如果模型爱写长思考,这一项设小会人为压低正确率。 |
批大小与优化
| 字段 | 默认 | 作用与调参含义 |
|---|---|---|
prompts_per_step | 4 | 每个优化步用几个不同的题目。决定梯度估计的「跨题目」方差。 |
num_rollouts | 8 | 每题采几条,即 GRPO 的组大小 $G$。决定组内基线的质量和「有没有对比度」的概率——本组最关键的一个参数。REINFORCE 和 PPO 的配置里它是 1。 |
train_batch_size / batch_acc | 2 / 4 | micro-batch 与梯度累积。只影响显存和速度,不改变算法语义。 |
lr | 5e-6 | 优化器是 Adam。RL 的学习率通常比 SFT 低。换基座时不要照抄——HW4 用实测数据演示了照抄学习率的后果。 |
format_weight | 0.5(未写在 YAML 里,用的是 config.py 的默认值) | 格式奖励在总奖励里的权重。下一节详述。 |
format_weight 是奖励函数的重要组成部分,却不在 YAML 里,只存在于 config.py 的默认值中。改配置前先把 Config 类通读一遍——只看 YAML 会让你以为自己已经控制了所有变量。
3. 该盯哪些指标
先把奖励是怎么合成的看清楚,否则指标名只是三个单词。utils.py::compute_rewards 的逻辑是:
correctness = _correctness_reward(dataset, completions, entries) # 环境判定
penalty = _response_penalties(lengths, cfg) # 非 DAPO 时恒为 0
format = _format_reward(completions) # 见下
total = correctness + penalty + cfg.format_weight * format
binary = correctness * (format == 1.0)
其中格式奖励是四个标签各 0.25 分:
<think> +0.25
</think> +0.25
<answer> +0.25
</answer> +0.25
训练循环每步把这些分量取均值,以 avg_<key> 的形式记录。你会看到 avg_total、avg_correctness、avg_format、avg_penalty、avg_binary,另有 loss 和 grad_norm。
三个核心指标
| 指标 | 含义 | 健康的形态 | 不对劲时说明什么 |
|---|---|---|---|
avg_correctness |
环境给出的正确性得分(dataset.score_answer) |
缓慢上升。这是唯一真正的任务信号 | 长期贴 0 = 任务对当前模型太难;长期贴 1 = 太简单。两种情况都会让 GRPO 空转(第 4 节) |
avg_format |
四个标签各 0.25 的格式分 | 通常最先饱和到 1.0——格式比内容容易学得多 | 它都不动,说明梯度根本没流对地方,先去查采样输出和 mask,别去调优化器 |
avg_binary |
正确性 且 格式满分才计 1 | 最严格、最能反映真实进展的一条 | 它和 avg_correctness 长期背离,说明模型答对了但格式在坏,或反之 |
avg_format 有没有在动;3) 组内奖励方差是不是恒为 0;4) 最后才是 loss 和 grad_norm。
还要看文本本身
参考实现每步都会打印一次 rollout 样本和各奖励分量的均值。请真的去读那些文本,尤其是训练早期——程序化任务的好处就在这里:单词倒着拼对没对,你零点几秒就能判断,而这个判断是任何聚合指标都给不了的。
值得留意的定性现象:模型有没有真的在 <think> 里做逐字母分解;是不是学会了把标签写全但内容空洞(典型的奖励 hack);回复是不是撞到 max_new_tokens 被截断(截断通常判失败,看起来像「变笨了」,其实是长度预算问题)。
4. 组内对比度:GRPO 空转的头号原因
这是本组作业真正要教的东西。第一个该问的问题不是「正确率涨了吗」,而是「每个 prompt 组里有没有对比度」。
为什么优势会恒等于 0
GRPO 的优势就是组内 z-score。utils.py::compute_standardized_advantages 一行写完:
def compute_standardized_advantages(rewards, eps=1e-8):
return (rewards - rewards.mean(dim=0, keepdim=True)) / (rewards.std(dim=0, keepdim=True) + eps)
写成公式,对一个大小为 $G$ 的组:
$$ A_i \;=\; \frac{r_i - \mu_G}{\sigma_G + \varepsilon}, \qquad \mu_G=\frac{1}{G}\sum_{j=1}^{G} r_j,\quad \sigma_G=\operatorname{std}(r_1,\dots,r_G) $$现在考虑一组 8 条 completion 全对(或全错)的情形:所有 $r_i$ 相等 $\Rightarrow$ $\mu_G = r_i$ $\Rightarrow$ 分子逐项为 0,$\sigma_G = 0$。于是
$$ A_i = \frac{0}{0 + \varepsilon} = 0 \quad \text{对所有 } i $$那个 eps 的唯一作用是防止 0/0 变成 NaN——它救的是数值稳定性,不是学习信号。优势全 0,损失里的 $\Psi_t$ 全 0,这一组对梯度的贡献严格等于零。
这就是为什么「太简单」和「太难」是同一种失败:信息量在 $\bar r \to 0$ 和 $\bar r \to 1$ 两端同时消失,在 $\bar r \approx 0.5$ 附近最大。这也是 RLVR 配方里「课程难度」和「动态过滤」这两件事存在的根本原因。
怎么诊断
诊断方法很直接:记录零方差组的占比随训练步数的变化。上游没有专门 log 这个量,但它就在 rollout.py 里唾手可得——rewards["total"] 的 shape 就是 [num_rollouts, 1],加一行 .std() 即可。本组建议你自己把这个指标加上去,它比任何现成指标都更能解释训练为什么不动。粗糙一点的读法是看每步打印的 rollout 面板:correctness 长期贴 0 或贴 1 不动,问题就不在优化器,而在采样设置或任务难度。
DAPO 是怎么处理这件事的
值得对照着看一眼 rollout.py::_dapo_filter:
def _dapo_filter(self, exp):
"""Drop the group if every completion has min or max correctness."""
correctness = exp.rewards["correctness"]
all_min = (correctness == self.cfg.accuracy_min_reward).all()
all_max = (correctness == self.cfg.accuracy_max_reward).all()
if all_min or all_max:
return None
return exp
DAPO 的「动态采样(dynamic sampling)」就是把零对比度的组直接丢掉,然后继续采样直到凑满一个 batch。这不改变数学,只改变每步实际参与更新的有效样本数——在正确率接近饱和的阶段,差别可以非常大。跑 dapo.yaml 对比 grpo.yaml 时的重点就是:为了凑满一个 batch,DAPO 需要多采多少条?这其实就是「零对比度组占比」的另一种测量方式。
正确的顺序是:先确认有对比度(组内奖励方差 > 0 的比例是多少),再谈优化。 决定对比度的是
num_rollouts、temperature 和任务难度——这三者和你用 GRPO 还是 DAPO 一点关系都没有。这也是理解「为什么 RLVR 的配方常常在动优化器之前,花大量精力调采样设置和数据难度」的最短路径。
5. 显存:为什么本机跑不动,以及怎么换基座
本组没能实跑的原因很具体。本机是 2 × RTX 5080(各 16303 MiB),但另一个进程常驻占用 GPU0 约 8.35 GiB、GPU1 约 11.4 GiB,所有实验只能在 GPU0 剩余的约 6.9 GiB 里完成。而上游 grpo.yaml 的默认基座是 Qwen/Qwen3-1.7B,做全参数训练。
算一笔显存账
1.7B 参数的全参数在线 RL 训练,显存要装下这些东西:
| 占用项 | 量级 | 说明 |
|---|---|---|
| 模型权重(bf16) | 约 2 字节/参数 | 1.7B → 约 3.4 GB |
| 梯度 | 与权重同量级 | 再来一份 |
| Adam 优化器状态 | 两份(一阶矩、二阶矩) | 这是最大的一块;train.py 用的是 optim.Adam |
| 激活值 | 随 batch × 序列长度增长 | max_new_tokens: 512,且没开梯度检查点 |
| 生成时的 KV cache | 随并发 rollout 数增长 | 每步要生成 prompts_per_step × num_rollouts 条 |
| (可选)参考模型 | 再加一份权重 | 只在 beta > 0 时加载 |
| (可选)价值模型 | 再加一份权重 + 它自己的优化器状态 | 只有 PPO 需要 |
上游 README 给出的参考是:策略梯度训练 Qwen3-1.7B 约需 16GB 单卡。约 6.9 GiB 显然不够,这就是本组停在这里的原因——不是代码问题,是资源问题。跑之前先用 nvidia-smi 确认卡上还剩多少:「16GB 的卡」和「16GB 可用」是两件完全不同的事。
换小基座的思路
要在这种预算下跑通,可行的方向按「性价比」排序:
- 换更小的基座。上游
CLAUDE.md给出的经验值是 Qwen3-0.6B 约 4GB、Qwen3-1.7B 约 8–10GB(无 LoRA 全量微调)。把model_name换成 0.6B 量级的模型是最直接的一步。注意:换基座之后学习率必须重新扫——HW4 用实测数据证明了照抄学习率的后果。 - 降低生成压力。
max_new_tokens从 512 降下来,prompts_per_step和train_batch_size调小。但num_rollouts要非常谨慎地动——它直接决定组内对比度(第 4 节),砍它等于砍掉学习信号本身。宁可砍prompts_per_step也别砍num_rollouts。 - 开梯度检查点。上游经验是省 30–40% 显存,代价是重算激活。注意它会禁用 KV cache,生成阶段要记得关掉再开。
- 确认
beta: 0.0。只要 KL 惩罚是关的,就不会加载参考模型,省下整整一份权重。默认配置已经是关的,别手贱打开。 - 避开 PPO。
ppo.yaml需要额外的价值模型(权重 + 优化器状态),是所有配置里最吃显存的。显存紧张时 GRPO 恰恰是最合适的选择——这也正是 GRPO 被发明出来的动机之一。
code/pyproject.toml 给 x86_64 Linux 钉的是 cu126 轮子,装出来的 torch 的 get_arch_list() 不含 sm_120,而 RTX 5080 是 Blackwell。症状很阴险:torch.cuda.is_available() 返回 True,只有真正启动 kernel 时才报 no kernel image is available for execution on the device。定位方法是比对 get_device_capability(0) 与 get_arch_list();修复方式见 homework/RESULTS.md。
6. 自选消融
消融一:扫「对比度旋钮」
复制 grpo.yaml,一次只改一项:
| 参数 | 调小会怎样 | 调大会怎样 |
|---|---|---|
num_rollouts(组大小 $G$) | 组内对比度下降,越来越多的组退化成零方差;同时组内基线 $\mu_G$ 的估计也变差 | 信号更稳,但每步生成量线性上升——这是最贵的一个旋钮 |
temperature | 采样坍缩,一组回复几乎一模一样 → 无对比度 | 格式错误的回复暴增,噪声压过信号 |
data.size | 更快重复见到同一批题,容易过拟合到题型 | 覆盖更广,但收敛更慢、总时长更长 |
format_weight | 格式奖励占比低,模型可能一直不学格式,avg_binary 起不来 | 模型可能只优化格式、放弃解题(一种奖励 hack:avg_format 满分而 avg_correctness 不动) |
每一组都请同时记录「零方差组占比」(第 4 节的自定义指标)。这个实验的意义在于:它让「$G$、temperature、题目难度共同决定了组内有没有可学的差异」这件事,从一句话变成一张你自己画出来的曲线。
另外,min_word_len / max_word_len 是最干净的难度旋钮:把它们调到「当前模型大约一半能做对」的区间,理论上信息量最大,值得自己验证。
消融二:REINFORCE / RLOO / GRPO 三者对比
python -m policy_gradients.train --config policy_gradients/configs/reinforce.yaml
python -m policy_gradients.train --config policy_gradients/configs/rloo.yaml
python -m policy_gradients.train --config policy_gradients/configs/grpo.yaml
这三个配置的差别本质上只在优势怎么算(utils.py::compute_advantages 里三行分支):
| 算法 | 优势 | num_rollouts | 基线是什么 |
|---|---|---|---|
| REINFORCE | 直接用 rewards["total"] | 1 | 无组内基线 |
| RLOO | $\frac{K}{K-1}(r_i - \bar r)$,留一法 | 8 | 组内均值(不含自己) |
| GRPO | $(r_i - \mu_G)/(\sigma_G + \varepsilon)$ | 8 | 组内均值,再除标准差 |
对比两件事:正确性信号上升得多快,以及损失曲线有多抖。第 6 章的理论预测是 RLOO/GRPO 的梯度方差明显更小,这个对比会让「组内 baseline 到底有什么用」比任何公式都直观。注意三份配置的 data.size 并不相同(REINFORCE 15000、RLOO 1000、GRPO 3000),做严格对比时要对齐「消耗的生成算力」而不是「优化步数」。
消融三:再往变体家族里走一步
drgrpo.yamlvsgrpo.yaml:Dr. GRPO 去掉了 std 归一化(compute_nonstandardized_advantages就是不除 $\sigma_G$)。第 6 章讲的「std 归一化会引入对低方差问题的偏好偏置」在这里可以实测。dapo.yamlvsgrpo.yaml:观察动态采样把零对比度组丢掉之后,有效样本数怎么变,以及为凑满 batch 需要多采多少。- 打开 KL 惩罚:把
beta从 0.0 调到一个小的正值,观察正确率上升速度和「回复漂移程度」的权衡。注意这会多加载一份参考模型,显存要留够。
本组小结
| 问题 | 答案 |
|---|---|
| 第一个该看的指标是什么? | 不是 loss,是 avg_format(最容易学,它不动说明梯度根本没流对地方),然后是 avg_correctness 和 avg_binary。 |
| GRPO 最常见的失败模式? | 组内没有对比度:8 条 rollout 全对或全错,优势恒为 0,梯度为 0,训练在「正常运行」的外表下空转。 |
| 对比度由什么决定? | num_rollouts、temperature、任务难度(min_word_len/max_word_len)——和你用 GRPO 还是 DAPO 一点关系都没有。 |
| loss 曲线怎么读? | 基本不读。策略梯度的 loss 是优势加权的 log-prob,没有「误差」语义,下降不代表变好。 |
| 本机为什么没跑? | 1.7B 全参数 + Adam 状态 + rollout 缓存 > 约 6.9 GiB 可用显存。换更小的基座或等 GPU 空闲。 |
交付物清单
- 你实际使用的配置文件(如果换了基座或改了任何超参,把改动和理由一并记下来)。
- 完整日志 + 逐步指标 JSONL(用
homework/tools/run_with_metrics.py落盘)。 - 一张随步数变化的表或图,至少包含
avg_correctness、avg_format、avg_binary三条曲线。 - 组内对比度的统计:零方差组占比随训练怎么变。这是本组作业真正的核心交付物。
- 训练前后各若干条 rollout 原文,用来说明模型的行为变化(或没有变化)。