在最开始的强化学习算法-1:GRPO、DPO与PPO解析中记录了RL中三种常见的优化算法DPO、PPO、GRPO基本原理,本文主要介绍在RL中常见的崩溃问题,由于笔者能力有限下文中所有内容都是通过收集整理难免有错漏内容。
按照论文1里面对于熵的定义

上面公式中 $\pi_\theta$ 以及 $\mathbf{D}$ 分别表示policy model(一般可以直接简单理解为我们需要优化的模型)以及数据集,对于熵值高低往往也就意味着模型输出的“不确定性”,高熵意味着模型可以尝试多种可能性,熵则意味着模型倾向于选择那些确定性的动作。在论文中1在实验中经常会发现,在训练的初期,Policy Entropy会迅速下降到接近0(如下图左侧图深蓝色线所示),模型变得过于确定,不再尝试新路径,这会导致模型的效果达到瓶颈,上升停滞。毕竟,强化学习之所以能够生效,全都依赖于模型的输出具有多样性,每次的输出和reward都是不一样的;假如模型的输出每次都是一样的,那么强化学习就失去了意义,因此就会出现后期的准确率基本保持不变的情况也就是所谓的熵坍缩(entropy collapse)

对于之所以会发生熵坍缩问题先说结论:单步熵变化 $ΔH ≈ −η ⋅ Cov[log π(a|s), A(s,a)]$,而该协方差在实践中结构性恒正。因为SFT之后模型默认输出高概率token,reward model 倾向给当前高概率的输出打高分最后导致恒正。
对于上述问题之所以发生在论文里面对于连续两步中熵的变化为:

上面公式中 $z_{s,a}^{k+1}- z_{s,a}^{k}$ 表示的是两步之间的输出差异,在梯度优化过程中 $z_{s,a}^{k+1}- z_{s,a}^{k}=-\eta \nabla_z J(\theta)$ 其中 $J(\theta)$ 表示优化目标(对应损失函数) $\eta$ 表示学习率,对于该等式可以证明得到 $z_{s,a}^{k+1}- z_{s,a}^{k}=-\eta \pi_{\theta}(a\vert s)A(s,a)$
那么将该式代入最上面等式中就可以得到:
\(\mathcal{H}\left(\pi_{\theta}^{k+1} \mid s\right)-\mathcal{H}\left(\pi_{\theta}^{k} \mid s\right)
≈-\eta \cdot \operatorname{Cov}_{a \sim \pi_{\theta}^{k}(\cdot \mid s)}(\log \pi_{\theta}^k(a|s),A(s,a))\)
对于上述公式中也就是证明了动作概率与对应的优势值之间相关性,如果正相关那么就会导致最后熵值的不断下降。
Cov[log π(a|s), A(s,a)] > 0 的含义:当前 policy 认为高概率概率的动作(log π 较大),在当前训练 batch 中也倾向于获得更高的优势估计 A(正相关)。反之,低概率动作即使偶尔拿到高 A,其对协方差的贡献也很微弱(因为采样权重 π(a) 本身极小)。而在训练过程中协方差几乎恒正,因为SFT 后模型“默认”倾向输出高概率 token → 这些 token 在 reward 眼中也“默认”较优 → 正相关 → 每步更新都系统性地压缩分布 → 熵单向坍缩。

左图:熵变化和协方差之间变化,也就证明了协方差增加导致熵下降;右图:按 prompt 难度分组的协方差分布Easy prompt(准确率高):协方差大;Hard prompt(准确率低):协方差小甚至为负;这说明:对容易任务,策略强化了已有倾向(熵下降);对困难任务,模型更不确定(熵保持或上升)
按照论文2中介绍方法

在GRPO中对于 $\epsilon$ 一般默认就是0.2,而DAPO中直接设置两个超参数 $\epsilon_{low}$ 以及 $\epsilon_{high}$ 论文里面选择是0.2以及0.28,之所以使用该方法可以一定程度缓解问题是因为:增大上边界让低概率的tokens能够不被“压制”鼓励其输出,但是增大下边界可能导致采样空间的崩溃。之所以增大上边界影响分析如下:在最上面分析熵坍缩原因中因为模型更加“偏爱”输出高概率token导致坍缩问题,那么在DAPO中直接鼓励模型去输出低概率token去弱化相关性,增加上边界可以鼓励模型去输出低概率值。直观解释,比如说在模型输出过程中 $\pi_{\theta}(o_i\vert q)$ 在输出若干个token之后,比如说输出两个词:好(对应模型高概率值假设0.9)、呀(对应低概率值假设0.1),在裁剪过程中低概率的token上限就是1.2那么对应就是:1.2x0.1=0.012那么在后续不断优化过程中(比如说10步)得到结果就是 $0.1\times 1.2^5=0.249$ 如果增加上边界那么对应 $0.1\times 1.8^5=1.889$之间差距还是明显随着后续步数迭代这些低概率token就会有“表现”机会。
实际代码操作中以GRPO训练为例只需要在参数中指定GRPOConfig(epsilon_low=0.1, epsilon_high=0.3)
按照论文3里描述在输出token中平均熵最高的词元通常起到“分叉”的作用,从而决定推理方向;而平均熵最低的词元则倾向于沿着既定路径执行推理步骤。

高熵Token(分岔点,”forking tokens” )主要是逻辑连接词(however, thus, because)、假设词(suppose, assume, given)、修正词(wait, unless)。它们在推理路径上充当决策点。比如,模型在说”however”时,它可能在犹豫是继续当前思路还是转向反方观点。在说这些词的时候,推理就可以有多个不同的发展方向,所以模型在生成它们时很不确定,因此这些token是High Entropy的。低熵Token主要是词缀(如ing, ed)、代码片段、数学表达式的固定部分(如括号、等号)。这些表述是机械性的、高度可预测的。
因此解决熵坍缩问题里面就可以直接对于高熵token用较高的temperature去进行采样鼓励模型去输出这些高熵token,除此之外在论文里面还做了一个额外实验:只在高熵(选择20%的高熵token)token上计算policy gradient对于低熵的token梯度直接丢弃,最后得到优化效果发现丢弃token效果比使用全部token的效果要好。

实际代码操作中以GRPO训练为例只需要在参数中指定GRPOConfig(top_entropy_quantile=0.2)即可(2/8法则)实现丢弃部分token。参数具体解释:top_entropy_quantile(浮点数,可选,默认为 1.0)来自超越 80/20 规则的 ρ 参数。在策略损失项中只保留每个序列位置上的代币概率分布熵的 top-ρ 量化值,从而改善结果。范围: [0.0-1.0]。0.0的值会屏蔽除最高熵标记外的所有标记;1.0 会保留所有标记。如果与 mask_truncated_completions=True 一起使用,则只考虑来自非截断序列的标记。
奖励坍缩(reward collapse):比如说在GRPO优化过程中在多奖励设置下,GRPO的group-wise normalization(组内归一化)会把不同奖励组合压缩成几乎相同的advantage(优势值),导致训练信号分辨率大幅下降。简单来说,当多个奖励信号被组合后,它们的相对差异被”抹平”了,模型无法有效区分哪些行为是真正更好的。这就像把不同颜色的颜料混合在一起,最终只剩下一种模糊的灰色,导致训练信号丢失。
比如说对于prompt产生的4组回答,我需要分别取评估回答格式是否正确、回答内容是否正确得到:A(1,1);B(1,0);C(0,1);D(0,0)按照GRPO中计算有优势值方法:$A_i=\frac{r_i- \text{mean}(r_1,…,r_G)}{\text{std}(r_1,…,r_G)}$ 在对BC计算优势值发现两部分优势值是相等的,但是实际上B回答正确格式错误而C格式正确回答错误,最后优势值只会告诉模型两部分相同(在奖励曲线上一般是先去上升后续出现断崖下降)。
在论文4里面先对每个奖励分量独立组内标准化(得到每个分量的 normalized scalar),然后加权求和这些 normalized 值 → 最终 $ \hat{A}_i^{\text{GDPO}}$

比如说在下面例子中:

比如说在上面计算中在GRPO例子AD优势幅度完全对称(只是正负相反),模型很难学到“正确性比格式更重要”的信号。在GRPO(权重2:1)中A 和 D 的差距被极大放大:+2.683 vs -2.683(幅度翻倍)。模型能更强烈地学习到“优先追求高正确性,即使牺牲一些格式”,而 GRPO 中这个倾向非常弱。
# line 1254 in NVlabs/GDPO/trl-GDPO/trl-0.18.0-gdpo/trl/trainer/grpo_trainer.py
# Gather the reward per function: this part is crucial, because the rewards are normalized per group and the
# completions may be distributed across processes
rewards_per_func = gather(rewards_per_func)
rewards = (rewards_per_func * self.reward_weights.to(device).unsqueeze(0)).nansum(dim=1)
# Compute grouped-wise rewards
mean_grouped_rewards = rewards.view(-1, self.num_generations).mean(dim=1)
std_grouped_rewards = rewards.view(-1, self.num_generations).std(dim=1)
is_std_zero = torch.isclose(std_grouped_rewards, torch.zeros_like(std_grouped_rewards))
# Normalize the rewards to compute the advantages
mean_grouped_rewards = mean_grouped_rewards.repeat_interleave(self.num_generations, dim=0)
std_grouped_rewards = std_grouped_rewards.repeat_interleave(self.num_generations, dim=0)
advantages = rewards - mean_grouped_rewards
if self.scale_rewards:
advantages = advantages / (std_grouped_rewards + 1e-4)