系列文章:
1、强化学习算法-1:GRPO、DPO与PPO解析
2、强化学习算法-2:熵坍缩以及奖励坍缩问题机制分析及解决措施
3、强化学习算法-3:GSPO、SAPO及KL散度改进
GSPO
处理长序列优化问题,将token级别处理为sequence级别
Qwen团队论文1里面首先分析在GRPO中存在如下几点问题:1、奖励函数一般是sequence级别的(对整个回答进行评分)但是却对每个token去计算重要性比率;2、不同专家被不同token激活,导致路由(routing)在rollout和training阶段不一致,GRPO的token级噪声会直接把某些专家“训崩”。针对上述两点问题在GSPO中损失函数为:

区别与GRPO中改进就在于:1、去掉了对于token的平均(在GRPO中对于模型输出会计算token平均:$\frac{1}{\vert o_i \vert}\sum_{t=1}^{\vert o_i \vert}$);2、计算sequence重要性:将GRPO中计算方式由 $r_{i,t}(\theta)=\frac{\pi_{\theta}(o_{i,t} \vert q,o_{i<t})}{\pi_{\theta_{old}}(o_{i,t} \vert q,o_{i<t})}$改为 $s_i(\theta)=(\frac{\pi_{\theta}(o_i \vert q)}{\pi_{\theta}(o_i \vert q)})^{\frac{1}{\vert o_i \vert}}$。通过改进GSPO区别GRPO表现:

两部分算法在代码差异点如下(Github-GRPOTrainer):
log_ratio = per_token_logps - old_per_token_logps # shape: [batch*G, seq_len]
if self.importance_sampling_level == "token":
log_importance_weights = log_ratio # 每个token都一个独立权重
elif self.importance_sampling_level == "sequence":
log_importance_weights = (log_ratio * mask).sum(-1) / mask.sum(-1).clamp(min=1.0) # 所有token加权求和得到 sequence
log_importance_weights = log_importance_weights.unsqueeze(-1) # # shape: [batch*G, 1] 得到sequence级别
coef_1 = torch.exp(log_importance_weights)
在trl中要实现GSPO直接使用可以直接使用参数:GRPOConfig(importance_sampling_level="sequence",) 就可以切换到GSPO优化了,按照论文里面参数配置
training_args = GRPOConfig(
importance_sampling_level="sequence",
beta=0.0,# 不加KL散度
epsilon=3e-4,# clipping 下界
epsilon_high=4e-4,# clipping 上界(
)
SAPO
Qwen团队论文2核心目标是解决“硬clipping的脆性问题”(当一条序列中只要有少数几个 token 的 ratio 超出 clipping 范围,整个序列的梯度就会被全部或大量抑制)。它在保持group-based RL(去Critic、组内相对优势)的基础上,把clipping机制从硬裁剪升级为温度控制的软控制,从而实现sequence-coherent(序列一致性) + token-adaptive(token自适应)的双重优势3,其损失函数为:

整个损失函数还是基于GRPO进行出发,将内部的 $\min(r, clip(r,1-\epsilon, 1+\epsilon))$ 替换为 $f$ 其中 $\sigma$ 是sigmoid函数,$x = r_{i,t}$ 重要性,$\tau$ 是温度参数。而在trl中使用改方法比较简单直接在config中进行指定即可:GRPOConfig(loss_type="sapo")具体的代码处理逻辑为:
elif self.loss_type in ["grpo", "bnpo", "dr_grpo", "dapo", "luspo"]: # trl 中默认直接计算DAPO损失
coef_2 = torch.clamp(coef_1, 1 - self.epsilon_low, 1 + self.epsilon_high)
if self.args.delta is not None:
coef_1 = torch.clamp(coef_1, max=self.args.delta)
per_token_loss1 = coef_1 * advantages
per_token_loss2 = coef_2 * advantages
per_token_loss = -torch.min(per_token_loss1, per_token_loss2)
elif self.loss_type == "sapo":
# 首先计算 温度系数 直接根据优势值 >0-->有利的“行为” <0-->不利的“行为”
temperatures = torch.where(advantages > 0, self.args.sapo_temperature_pos, self.args.sapo_temperature_neg)
# 计算软边界
soft_coef_1 = torch.sigmoid(temperatures * (coef_1 - 1)) * 4 / temperatures
per_token_loss = -soft_coef_1 * advantages
...
if self.loss_type in ["grpo", "sapo"]:
loss = ((per_token_loss * mask).sum(-1) / mask.sum(-1).clamp(min=1.0)).mean()
normalizer = self.current_gradient_accumulation_steps if mode == "train" else 1.0 # no accum in eval
loss = loss / normalizer
最终模型论文中的表现:

KL散度改进
KL 散度是用来度量两个概率分布相似度的指标,而计算过程也比较简单直接计算(假设为离散变量):
\(\mathcal{D}_{KL}(P \Vert Q)=\sum_i P(i)\ln(\frac{P(i)}{Q(i)})\)
不过对于llm中对于内部的 $\sum$ 过程不可能去计算(因为token的词表是非常大的,直接 $\sum$ 不现实)那么退而求其次,只对模型的输出的token去计算log概率,因此就可以将KL计算过程改写为计算期望过程:
\(\mathcal{D}_{KL}(P \Vert Q)=E_{x~P}[\ln\frac{P(x)}{Q(x)}]\)
而对于整个期望的计算可以直接用蒙特卡洛估计方式计算:
\(\mathcal{D}_{KL}(P \Vert Q)≈\frac{1}{N}\sum_{i=1}^{N}\ln \frac{P(x)}{Q(x)} \quad x~P\)
对于上述过程在代码(trl)中计算方式也比较简单直接:
per_token_logps, entropies = self._get_per_token_logps_and_entropies(model,input_ids,...)
if self.beta != 0.0:
ref_per_token_logps = inputs["ref_per_token_logps"]
per_token_kl = (
torch.exp(ref_per_token_logps - per_token_logps) - (ref_per_token_logps - per_token_logps) - 1
)
在具体的KL计算过程中有3种K1(朴素估计器):$K1=\log\frac{P(x)}{Q(x)}$、K2(平方对数估计量):$K2=\frac{1}{2}(\log r)^2$、K3(Bregman 估计器)4:$K3=\frac{P(x)}{Q(x)}-1-\log \frac{P(x)}{Q(x)}$,目前主流使用都是基于K3,在DeepSeek-V3.2论文5中对于K3做了修改:

其中括号内部的为原始的K3计算,在DS中则是补充了一个 $\frac{\pi_\theta}{\pi_{old}}$ 那么按照论文里面的描述添加此项的好处在于在最初的K3计算过程中当 $𝜋_𝜃 ≪ 𝜋_{ref}$ 时候梯度会赋予过大且无界的权重以最大化这些标记的似然性,导致梯度更新噪声较大,这些噪声会累积,从而在后续迭代中降低样本质量,并导致训练动态不稳定。