数据截至 (上游 commit cfacd76a0bdd)
06 · 算法层:优势估计、策略损失与训推修正
这一章讲什么: verl 里算法研究者真正会改的那一层。两张注册表怎么让「换算法 = 改一个字符串」,GRPO 具体怎么算,以及 rollout correction 这块处理训推不一致的机制。
1. 两张注册表
RL 算法的差异,绝大部分落在两个地方:
| 位置 | 回答的问题 | 注册表 |
|---|---|---|
| 优势估计器 | 「这个回答比平均水平好多少?」 | ADV_ESTIMATOR_REGISTRY(verl/trainer/ppo/core_algos.py:113) |
| 策略损失 | 「知道了好多少,怎么写成可反向的目标?」 | POLICY_LOSS_REGISTRY(:50) |
注册方式就是俩装饰器:
@register_adv_est(AdvantageEstimator.GRPO) # 或直接 @register_adv_est("grpo")
def compute_grpo_outcome_advantage(...): ...
@register_policy_loss("gspo")
def compute_policy_loss_gspo(...): ...
配置里对应 algorithm.adv_estimator 和 actor.policy_loss.loss_mode 两个字符串。
两张表是正交的。 GRPO 的优势可以配 vanilla PPO 损失,也可以配 GSPO 损失、CISPO 损失——这种组合自由度正是做算法消融实验时最想要的东西。
1.1 已有条目
| 优势估计器 | 一句话 |
|---|---|
gae | 经典 PPO,需要 critic |
grpo | 组内减均值除标准差,不要 critic |
grpo_vectorized | 同上的向量化实现 |
grpo_passk | 面向 pass@k 目标 |
rloo / rloo_vectorized | 留一法基线 |
reinforce_plus_plus / _baseline | REINFORCE++ |
remax | 用贪心解码作基线 |
opo | |
gpg | |
gdpo | 多维奖励分量 |
optimal_token_baseline / tir_optimal_token_baseline | 步依赖最优基线,需要 actor 额外算 sum_pi_squared |
| 策略损失 | 一句话 |
|---|---|
vanilla | 标准 PPO clip + dual-clip |
gspo | 序列级重要性比 |
cispo | |
clip_cov / kl_cov | 基于协方差的裁剪(entropy 系列) |
geo_mean | 几何平均比率 |
dppo_tv / dppo_kl | |
sapo / gpg | |
bypass_mode | 用 rollout logprob 直接当 old_log_prob 时的专用损失 |
1.2 分发点
compute_advantage(verl/trainer/ppo/ray_trainer.py:187,V1 也复用它)对 GAE 和 GRPO 走特判路径,其余走注册表:
adv_estimator_fn = core_algos.get_adv_estimator_fn(adv_estimator)
adv_kwargs = {"token_level_rewards": ..., "response_mask": ..., "config": config}
if "uid" in data.non_tensor_batch: adv_kwargs["index"] = ...
if "reward_baselines" in data.batch: adv_kwargs["reward_baselines"] = ...
advantages, returns = adv_estimator_fn(**adv_kwargs)
可选参数按需注入——这样新估计器只声明自己要的参数即可。
2. GRPO:为什么能不要 critic
2.1 直觉
PPO 需要一个 critic 网络来估计「这个状态的期望回报」,好拿来当基线。critic 和 actor 一样大,训练成本翻倍,而且自己也难训。
GRPO 的想法:同一道题采 n 个答案,用这 n 个答案的平均分当基线。 高于平均就是好答案,低于就是坏答案。基线免费,且天然无偏。
题目 A 采 5 个答案:分数 [1, 0, 1, 1, 0] 均值 0.6
优势 [+0.4, -0.6, +0.4, +0.4, -0.6] ← 再除以标准差
题目 B 采 5 个答案:分数 [0, 0, 0, 0, 0] 均值 0
优势 [0, 0, 0, 0, 0] ← 全对或全错的 题不产生梯度
最后那行是 GRPO 的一个天然性质:太简单或太难的题自动被过滤掉,梯度只来自「有区分度」的题。
2.2 实现
compute_grpo_outcome_advantage(verl/trainer/ppo/core_algos.py:268):
① scores = token_level_rewards.sum(dim=-1) 每条序列一个标量分
② 按 index(就是 uid)分组 → id2score
③ 每组算 mean / std
组内只有 1 条 → mean=0, std=1 ← 退化保护
④ 归一化:
norm_adv_by_std_in_grpo=True → (s - mean) / (std + eps) 原版 GRPO
norm_adv_by_std_in_grpo=False → s - mean Dr.GRPO
⑤ 广播到 token 维:scores.unsqueeze(-1) * response_mask
⑥ return scores, scores ← advantages 和 returns 是同一个东西
第 ④ 步那个开关对应 Dr.GRPO 论文的发现:除以标准差会引入偏置(方差小的题被放大),docstring 里直接给了论文链接(:296)。
第 ⑥ 步值得注意:outcome-based 方法里没有 TD 意义上的 return,returns 只是为了接口统一。
2.3 V1 的多输出处理
V1 引入了「一个 agent loop 可能产出多条输出」(key 里的 index 段),于是优势计算要多做一步(compute_advantage_for_multi_trajectories,verl/trainer/ppo/v1/utils.py:148):
① 按 key 解析出 (uid, session_id, index)
② 每个 {uid}_{session_id} 只取 index 最大的那条 —— 「这个 session 的最终结果」
③ 只用这些最终结果做 GRPO 组内归一
④ 把算出的优势广播回同 session 的所有输出
为什么这样: 一个 session 内多条输出属于同一次尝试的不同阶段,它们应该共享同一个「这次尝试好不好」的评价,而不是各自参与组内比较。
非 GRPO 估计器直接透传给原 compute_advantage(:42-51),不做这层处理。
3. PPO 策略损失里的细节
compute_policy_loss_vanilla(verl/trainer/ppo/core_algos.py:1286)不到 60 行,但塞了三层保护:
negative_approx_kl = log_prob - old_log_prob
negative_approx_kl = torch.clamp(negative_approx_kl, min=-20.0, max=20.0) # ①
ratio = torch.exp(negative_approx_kl)
pg_losses1 = -advantages * ratio
pg_losses2 = -advantages * torch.clamp(ratio, 1 - cliprange_low, 1 + cliprange_high) # ②
clip_pg_losses1 = torch.maximum(pg_losses1, pg_losses2)
pg_losses3 = -advantages * clip_ratio_c # ③
clip_pg_losses2 = torch.min(pg_losses3, clip_pg_losses1)
pg_losses = torch.where(advantages < 0, clip_pg_losses2, clip_pg_losses1)
| 保护 | 是什么 | 防什么 |
|---|---|---|
| ① log 比率钳到 ±20 | 数值安全阀 | exp() 溢出成 inf |
| ② 非对称 clip | clip_ratio_low / clip_ratio_high 可分别设 | DAPO 的 clip-higher:放宽上界鼓励探索 |
| ③ dual-clip | 负优势时再加一道下界 clip_ratio_c(>1) | 负优势 × 巨大比率产生的爆炸梯度 |
第 ③ 条只对 advantages < 0 生效——这正是 dual-clip PPO 论文(arXiv 1912.09729)指出的失效模式:坏动作的比率如果飙到 100,-A·ratio 会变成一个巨大的正 loss。
返回的指标 pg_clipfrac / ppo_kl / pg_clipfrac_lower 是调 PPO 时最该盯的三个数:clip 触发率太高说明学习率或 mini-batch 轮数过大。
4. rollout correction:训推不一致的修正
4.1 问题:三个 π
这是大多数 RL 框架回避、verl 正面处理的问题。一次更新里其实有三个不同的策略:
| 记号 | 是谁 | 差异来源 |
|---|---|---|
| π_rollout | vLLM/SGLang 生成时用的策略 | bf16 kernel、不同的 attention 实现、continuous batching |
| π_old | 训练引擎重算 log prob 得到的策略 | fp32/bf16 混合精度、FSDP kernel |
| π_θ | 正在被更新的当前策略 | mini-batch 更新中不断变化 |
π_rollout ≠ π_old 即使权重完全相同——只是数值实现不同。在长序列上这个微小差异会累 积,严重时导致训练崩溃。模块 docstring 直接引了相关分析文章(verl/trainer/ppo/rollout_corr_helper.py:60-62)。
异步训练还会叠加第二重偏差:轨迹是旧权重生成的。
4.2 两种工作模式
_compute_old_log_prob(verl/trainer/ppo/v1/trainer_base.py:1479)的注释写明了两条路:
decoupled 模式(默认) bypass 模式
─────────────────── ──────────
old_log_prob = 训练引擎重算 old_log_prob = rollout 直接给的 logprob
三个策略 π_rollout, π_old, π_θ 两个策略 π_rollout, π_θ
多花一次前向 省一次前向
π_old 在 mini-batch 更新中固定 用 bypass_mode 专用损失函数
→ 是个稳定的近端锚点
separate_async 模式强制 bypass(verl/trainer/ppo/v1/trainer_separate_async.py:68),因为训推分离时重算 old_log_prob 的代价和收益都变了。
4.3 重要性采样权重
compute_rollout_correction_weights(verl/trainer/ppo/rollout_corr_helper.py:522)算 w = π_train / π_rollout:
输入 log_ratio = log π_train - log π_rollout
│
├─ rollout_is="token" w = exp(clamp(log_ratio, ±20)) 逐 token
└─ rollout_is="sequence" w = exp(clamp(Σ log_ratio, ±20)) 整条一个权重
│
├─ 阈值是单个数(如 2.0)→ TIS:w.clamp(max=2.0)
│ 截断,有偏但方差小
└─ 阈值是 "lower_upper" → IcePop:区间外的直接置 0
丢弃,不引入截断偏置
两种粒度的取舍在 docstring 里写得很直白(:544-545):token 级有偏但低方差,sequence 级无偏但高方差——因为整条序列的比率是几千个数相乘,动态范围极大。
4.4 配套指标
这块的可观测性做得比算法本身还厚:
| 指标族 | 内容 |
|---|---|
| IS 权重分布 | rollout_is_mean/max/min、序列级统计 |
| 有效样本量 | rollout_is_eff_sample_size(ESS)—— IS 里判断权重退化的标准量 |
| 越界比例 | rollout_is_oob_ratio(IcePop 模式) |
| 分布偏移诊断 | KL 散度、困惑度 PPL、log PPL 差、χ² 散度 |
| 拒绝采样 | 拒绝率、按 token/seq_sum/seq_mean/seq_max 四种口径的过滤 |
为什么值得学: ESS 和 χ² 散度这类量是判断「重要性采样还有没有意义」的标准诊断。如果 ESS 掉到样本数的百分之几,说明有效样本已经塌成几条,此时无论怎么调 clip 都救不回来——必须回去缩短同步间隔。把这些量做成默认指标,比事后猜要省太多时间。
模块 docstring 还特别提到实现上的两个约束(:33-37):全程 log 空间计算避免上下溢;指标 计算避免产生大的中间张量(防 CUDA OOM)。后者是很实际的工程考虑——一个 (batch, seqlen) 的中间张量在长序列上就是好几个 GB。
5. KL:两个位置,别搞混
verl 里 KL 惩罚可以加在两个完全不同的地方:
| 位置 | 配置 | 公式位置 | 效果 |
|---|---|---|---|
| 奖励里 | algorithm.use_kl_in_reward | apply_kl_penalty(verl/trainer/ppo/ray_trainer.py:78) | token_level_rewards = scores - β·KL,KL 会流经优势估计 |
| 损失里 | actor.use_kl_loss | ppo_loss(verl/workers/utils/losses.py:132) | policy_loss += kl_loss_coef·KL,直接加在目标上 |
第一种支持自适应系数(AdaptiveKLController,verl/trainer/ppo/core_algos.py:153):实测 KL 高于目标就调大 β,低于就调小。第二种是固定系数。
GRPO 的常见配置是只用第二种(示例脚本里 algorithm.use_kl_in_reward=False + actor.use_kl_loss=True),因为 KL 进奖励会污染组内归一化的语义。
kl_penalty 函数(:2126)支持多种估计式,其中 low_var_kl 是 GRPO 论文用的低方差无偏估计(也就是 k3 估计式)。