跳到主要内容

数据截至 (上游 commit cfacd76a0bdd)

06 · 算法层:优势估计、策略损失与训推修正

这一章讲什么: verl 里算法研究者真正会改的那一层。两张注册表怎么让「换算法 = 改一个字符串」,GRPO 具体怎么算,以及 rollout correction 这块处理训推不一致的机制。


1. 两张注册表

RL 算法的差异,绝大部分落在两个地方:

位置回答的问题注册表
优势估计器「这个回答比平均水平好多少?」ADV_ESTIMATOR_REGISTRYverl/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_estimatoractor.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 / _baselineREINFORCE++
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_advantageverl/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_advantageverl/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_trajectoriesverl/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_vanillaverl/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
② 非对称 clipclip_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 正面处理的问题。一次更新里其实有三个不同的策略

记号是谁差异来源
π_rolloutvLLM/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_probverl/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_weightsverl/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_rewardapply_kl_penaltyverl/trainer/ppo/ray_trainer.py:78token_level_rewards = scores - β·KL,KL 会流经优势估计
损失里actor.use_kl_lossppo_lossverl/workers/utils/losses.py:132policy_loss += kl_loss_coef·KL,直接加在目标上

第一种支持自适应系数(AdaptiveKLControllerverl/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 估计式)。


6. 代码地图

主题文件路径符号名
双注册表verl/trainer/ppo/core_algos.pyADV_ESTIMATOR_REGISTRYPOLICY_LOSS_REGISTRYregister_adv_estregister_policy_loss
估计器枚举verl/trainer/ppo/core_algos.pyAdvantageEstimator
GAEverl/trainer/ppo/core_algos.pycompute_gae_advantage_return
GRPOverl/trainer/ppo/core_algos.pycompute_grpo_outcome_advantagecompute_grpo_vectorized_outcome_advantage
其他估计器verl/trainer/ppo/core_algos.pycompute_rloo_outcome_advantagecompute_remax_outcome_advantagecompute_reinforce_plus_plus_outcome_advantagecompute_opo_outcome_advantagecompute_gdpo_outcome_advantagecompute_optimal_token_baseline_advantage
PPO 损失verl/trainer/ppo/core_algos.pycompute_policy_loss_vanilla
其他损失verl/trainer/ppo/core_algos.pycompute_policy_loss_gspocompute_policy_loss_cispocompute_policy_loss_clip_covcompute_policy_loss_kl_covcompute_policy_loss_geo_meancompute_policy_loss_bypass_mode
loss 聚合verl/trainer/ppo/core_algos.pyagg_loss
KL 控制器与估计verl/trainer/ppo/core_algos.pyAdaptiveKLControllerFixedKLControllerkl_penaltyget_kl_controller
奖励侧 KLverl/trainer/ppo/ray_trainer.pyapply_kl_penalty
优势分发verl/trainer/ppo/ray_trainer.pycompute_advantage
V1 多输出优势verl/trainer/ppo/v1/utils.pycompute_advantage_for_multi_trajectories
IS 权重verl/trainer/ppo/rollout_corr_helper.pycompute_rollout_correction_weights_parse_rollout_is_threshold
拒绝采样verl/trainer/ppo/rollout_corr_helper.pycompute_rollout_rejection_maskcompute_rollout_correction_and_rejection_mask
诊断指标verl/trainer/ppo/rollout_corr_helper.pycompute_is_metricscompute_offpolicy_metricscompute_rs_metrics
bypass 模式verl/trainer/ppo/rollout_corr_helper.pyapply_bypass_modecompute_rollout_correction_and_add_to_batch