上一篇讲过 GSPO 把重要性比率从 token 级提到整句级。这一篇往下落一层,落到代码。

用 ms-swift 训 GRPO 时,启动命令里有一个参数:

--importance_sampling_level token            # 原始 GRPO,默认值
--importance_sampling_level sequence         # GSPO
--importance_sampling_level sequence_token   # GSPO-token

三个取值只改一行公式,改的是新旧策略的比率按什么单位算。这一行决定了两件事:哪些 token 会被裁剪掉,梯度顺着比率流到谁身上。下面把三种模式逐个算一遍,数字都摆出来。

例子从哪里来

文中的真实数字都来自一个语音识别(ASR)模型的强化学习训练,先交代一下背景,后面就不再解释:

  • 任务:把一段音频转写成文字。模型是 FireRedASR-LLM,结构是「Conformer 音频编码器 + 投影层 + Qwen2 语言模型」,训练时编码器和投影层全参数更新,语言模型只训 LoRA(一种只训少量附加参数的微调方式)。ms-swift 是魔搭社区开源的训练框架,本文的 GRPO 代码都指它。
  • 一次采样:给模型一段音频,让它写出一条转写。同一段音频写 8 次,得到 8 条。
  • 奖励:三项相加。准确率奖励是 1 减去字错误率(写错、多写、漏写的字数除以参考转写的字数);热词奖励看指定的人名、术语有没有写对;幻觉惩罚针对一种典型故障,模型陷入重复循环,一直写到 2048 个 token 的上限才停。
  • 参考模型:算 KL 惩罚时用的「不能偏离太远的对象」,这里就是同一个网络把 LoRA 关掉,也就是训练起点的模型。

约定一下记号:一条采样的第 t 个 token 记作 y_t,采样时的旧策略给它的概率是 p_old(y_t),更新时的当前策略给它的概率是 p_new(y_t)。

一、先把两个量算出来:优势 A 和比率 r

三种模式共用同一套优势,也共用同一批逐 token 的比率。先把这两个量讲清楚,再看它们怎么组合。

1.1 优势 A:跟同学比

GRPO 一族的优势都是组内相对分。同一段音频采样 8 条转写,每条算一个总奖励 R,然后在这 8 条里标准化:

A_i = ( R_i − 这 8 条的平均 ) / ( 这 8 条的样本标准差 + 0.0001 )

标准差按样本标准差算,除以 n − 1。ms-swift 的实现在 swift/rl_core/advantage.pycompute_advantages

拿一组真实数据走一遍。参考转写是「谢谢我们三位课代表谢谢所有的同学们金一南老师讲过这样一句话……」,奖励是 1 减去字错误率,这组没有热词和幻觉惩罚:

采样和参考不同的地方RA
完全正确,共 2 条1.000+1.225
「金」写成「朱」0.974+0.681
「课代表」写成「出代者」0.949+0.136
「课代表」写成「出答复」或「出题者」,共 2 条0.923−0.408
「课代表」写成「期待者」,「金」写成「郦」0.897−0.953
「课代表」写成「期待者」,「金一」写成「倪以」0.872−1.498

平均 0.9423,标准差 0.0470。8 条的奖励最多只差 0.13,除以 0.047 之后被放大到 ±1.5。所以优势衡量的是组内的相对好坏,不是绝对分数。按公式重算的 A 和训练日志里记录的逐条一致。

要记住的只有一点:一条转写只算出一个 A,这条转写里每个 token 用的都是同一个 A。 这在 GRPO、GSPO、GSPO-token 里都成立。三种模式的分歧不在 A,在比率。

1.2 比率 r:两次前向

每条采样要过两次前向:

第一次前向:旧模型采样刚结束,不带梯度第二次前向:当前模型算 loss 时,带梯度每个 token 一个比率r_t = p_new(y_t) / p_old(y_t)三种模式谁掷骰子,梯度流向谁log p_old(y_t)log p_new(y_t)默认配置下每批只更新一次,两次前向权重相同;但 log p_old 仍单独前向一次,且 dropout 不关,两次 mask 不同,r_t 才偏离 1
采样后用旧模型算一次 log p_old,不带梯度;更新时用当前模型再算一次 log p_new,带梯度。相除得到每个 token 的比率
r_t = p_new(y_t) / p_old(y_t) = exp( log p_new(y_t) − log p_old(y_t) )

每个 token 的 loss 是 PPO 那套裁剪目标,另外加一项到参考模型的 KL:

loss_t = −min( ratio × A ,  clip(ratio, 0.8, 1.2) × A )      ε 取 0.2

A 为正时,ratio 超过 1.2 会被裁;A 为负时,ratio 低于 0.8 会被裁。被裁之后这一项变成常数,常数没有导数,这个 token 这一步就不更新。

这里有个容易误会的地方,分三层说。

第一层,默认配置下每批采样只更新一次。num_iterations 默认 1,steps_per_generation 默认等于梯度累积步数,这时一批采样对应一次优化器更新,两次前向用的是同一套权重,比率理论上恒为 1。这是配置的结果,不是 ms-swift 的固有行为,把这两个参数调大就会一批更新多次,那时比率偏离 1 的主因就是权重真的变了。

第二层,即便如此,ms-swift 仍会在采样后用 no_grad 单独跑一遍算 log p_old,而不是直接拿 log p_new 复制一份。并且 GRPO 不关 dropout,两次前向各自抽一次 dropout mask。这个模型里音频编码器的 dropout 是 0.1,LoRA 是 0.05,编码器的 mask 一变,整段音频的表示跟着变,整句的比率就会偏离 1。

第三层是验证。训练日志里约 5.7% 的位置被裁剪,我用同一个随机种子跑了两个 60 步的对照:正常 dropout 时裁剪比例平均 5.4%,有 13 步出现裁剪;把编码器、投影层和 LoRA 的 dropout 全部置 0 后,60 步裁剪比例全为 0。顺带一提,日志里 0.15 左右的 KL 也主要是这个噪声,关掉 dropout 后 KL 变成 0.000,因为参考模型就是同一个网络关掉 LoRA,算它的那次前向同样抽了另一份 mask。

更要紧的是,即使比率数值是 1,它也不是摆设:梯度要穿过 ratio 才能传到 log p 上,ratio 怎么定义,就决定了梯度怎么分到每个 token。

1.3 小结:ms-swift 里一步训练的全过程,带真实数字

上面两节把 A 和 r 分开讲了,这一节把它们串成 ms-swift 里真实的一步,每个环节配一个真实数字。配置是本文实验的 GSPO 设置:8 张卡,每卡 batch 8,每条音频采 8 条,num_iterations 1。

一条音频复制 8 份RepeatSampler,每卡 1 条音频8 个副本各自采样eval 模式,逐 token 掷骰子算奖励1−CER、热词、幻觉组内标准化8 条 → 8 个 A第一次前向 log p_oldtrain 模式,no_grad第二次前向 log p_new同一套权重,带梯度r_t、裁剪、KL按模式组合 r_t一次 optimizer然后重新采样奖励和优势跨 8 张卡汇总后算,前向和 loss 各卡只管自己的 8 条默认 num_iterations 1:一批采样只走一遍下面这行,只更新一次
一步训练的流水线:上一行是采样和打分,下一行是两次前向和一次更新。奖励和优势跨卡汇总后算,两次前向各卡只管自己的 8 条

把这张图写成伪代码,函数名对应 ms-swift 里的真实函数,方便对着源码看:

# 每 steps_per_generation × num_iterations 步进入一次;默认两者都是 1,即每步都进入
def one_generation_cycle(audios):                       # 每卡 1 条音频,全局 8 条
    # 1. 复制:RepeatSampler 已把每条音频重复 num_generations=8 次
    prompts = [audio] * 8

    # 2. 采样:模型切 eval(无 dropout),逐 token 按分布抽样
    model.eval()
    completions = generate(prompts, temperature=1.0, top_k=50, top_p=0.99)   # _generate_completions
    model.train()

    # 3. 奖励和优势:先各卡算自己的 8 条,再跨卡汇总成 64 条,按 8 条一组标准化
    R = sum(reward_fn(c, ref) for reward_fn in [acc, hotword, halluc])         # _compute_rewards_per_func
    R_all = gather(R)                                                          # 64 条
    A_all = (R_all - group_mean(R_all)) / (group_std(R_all, ddof=1) + 1e-4)    # compute_advantages
    A = A_all[this_rank]                                                       # 拿回自己的 8 条,一条一个 A

    # 4. 第一次前向:log p_old,train 模式但不带梯度,只做一次,之后固定
    with torch.no_grad():
        logp_old = per_token_logps(model, prompts, completions)                # _prepare_batch_inputs
        logp_ref = per_token_logps(model_without_lora, prompts, completions)   # beta > 0 时才算

    # 5/6. 第二次前向 + 更新;num_iterations=1 时这个循环只走一遍
    for it in range(num_iterations):
        logp_new = per_token_logps(model, prompts, completions)                # 带梯度,权重是当前的
        log_r = logp_new - logp_old                                            # 每个 token 一个

        if level == 'token':                                                   # GRPO
            log_w = log_r
        elif level == 'sequence':                                              # GSPO
            log_w = mean_over_tokens(log_r)                                    # 整句一个数,铺到每个 token
        elif level == 'sequence_token':                                        # GSPO-token
            log_w = logp_new - logp_new.detach() + mean_over_tokens(log_r).detach()

        w = exp(log_w)
        loss_tok = -min(w * A, clip(w, 0.8, 1.2) * A)                          # PPO 裁剪,被裁的 token 无梯度
        kl_tok = exp(logp_ref - logp_new) - (logp_ref - logp_new) - 1          # k3 估计
        loss = mean_over_seqs(mean_over_tokens(loss_tok + beta * kl_tok))      # loss_type='grpo' 的聚合

        loss.backward()                                                        # _compute_loss_and_metrics 到此
        optimizer.step(); optimizer.zero_grad()                                # training_step

第一步,一条音频复制 8 份。 数据加载用的是 TRL(Hugging Face 的强化学习库)的 RepeatSampler,把每条样本连续重复 8 次。全局一步 64 行,正好 8 条音频,每张卡分到的 8 行是同一条音频的 8 个副本。

第二步,8 个副本各自掷骰子。 采样走 transformers 后端,生成前模型切到 eval,没有 dropout;参数是 temperature 1.0、top_k 50、top_p 0.99。每个位置做的事是:前向得到整个词表上的分布,按概率抽一个字,拼到前缀后面,再前向。所以随机性只在「从分布里抽到了哪个字」,分布本身由「音频 + 已生成前缀」唯一决定。8 个副本在同一个前缀下看到的分布一模一样。

拿训练起点的模型在一条真实音频上的数字看。参考转写是「也太坑了吧你们这节目」,第一个位置模型给出的分布是:

候选你也这也
概率0.8700.0840.0240.007

后面每个位置的正确字概率都在 0.94 以上。一次采样恰好走完正确路径的概率是各位置概率的乘积,0.870 × 0.988 × 0.972 × 0.938 × 0.998 × 0.983 × 1.000 × 1.000 ≈ 0.77。我对这条音频真实抽了 8 次,结果是 4 次全对,2 次「这也太坑了吧你们这节目」,也就是第一步抽中了概率 0.007 的「这也」,还有 2 次「也太坑了你们这节目」,第四步抽到了「了」而不是「了吧」。这就是采样和 n-best 的区别:n-best 是 beam search 按分数枚举前 8 条互不相同的句子;采样是从句子分布里有放回地抽 8 次,重复是常态,重复率直接反映分布有多尖。

第三步,奖励和优势跨卡一起算。 三个奖励函数分别在各卡上算自己的 8 条,然后 gather_object 汇总成 64 条,按 8 条一组做 1.1 节的标准化。一个真实的组,参考「他也会为了你去改变」:

采样条数RA
他也会为了你去改变61.000+0.539
她也会为了你去改变20.889−1.617

平均 0.9723,样本标准差 0.0514,(1.000 − 0.9723) / 0.0514 = +0.539,(0.889 − 0.9723) / 0.0514 = −1.617。再看另一个真实的组,参考「底下有条评论我印象深刻」,8 条全部抽成了同一句、全对,R 全是 1.0,标准差为 0,8 个 A 全是 0。这种组在整个训练里不是少数:统计一次完整训练(40000 步)的 32016 个组,54.9% 的组 8 条完全一样,56.3% 的组优势全为 0,8 条各不相同的组只有 7.2%。优势全为 0 的组上,策略项的梯度是 0,这一步只剩 KL 项在起作用。换句话说,每一步真正推动模型的只有四成多的音频。

第四步,第一次前向算 log p_old。 每张卡把自己的 8 条「提示 + 采样结果」在 train 模式、torch.no_grad() 下前向一遍,取出每个采样 token 的 log 概率。以「也」为例,采样时它的概率是 0.870,log p_old 大约是 −0.139,训练前向因为有 dropout 会和这个值略有出入。这一步不看 num_iterations,总是单独做。

第五步,第二次前向算 log p_new,带梯度。 权重还是同一套,只是又抽了一次 dropout mask。每个 token 的比率 r_t = exp(log p_new − log p_old)。打个比方,如果这次 dropout 让「也」的概率从 0.870 变成 0.905,r_0 就是 1.040,其余位置接近 1,sequence 模式下整句的几何平均约 1.005,没有越过 1.2 的边界;1.2 节的对照实验说明,越界的那 5% 左右的句子,靠的就是这种整句相关的扰动。接着按第二节的三种模式把 r_t 组合起来,乘上 A,裁剪,加 KL 项,反向传播。

第六步,一次 optimizer 更新,然后重新采样。 num_iterations 为 1,这批 64 条只用这一次,下一步换 8 条新音频从头再来。

注:num_iterations 大于 1 时会怎样。 ms-swift 只在每 steps_per_generation × num_iterations 步的开头做一次采样,把采样结果、奖励、优势和 log p_old 一起存进缓冲,之后的每一步都从缓冲里取同一批样本,只有 log p_new 用当时的权重重新前向。所以 log p_old 在这批样本的整个生命周期里是固定的,第 1 次迭代两次前向权重相同,比率只差 dropout 噪声;第 2 次起权重已经更新过,比率开始反映真实的策略变化,第 μ 次迭代累积了 μ − 1 次更新的漂移。PPO 的裁剪就是为这种情况准备的:越过 [0.8, 1.2] 的 token 梯度归零,防止在旧样本上把策略推得太远;μ 为 1 时裁剪基本不起作用。μ 变大买到的是样本效率,一批采样摊薄到 μ 次更新,生成又是一步里最贵的环节;付出的是离线漂移带来的方差和不稳定,被裁掉的 token 越多,有效梯度越少。GRPO 原论文的实验和 TRL、ms-swift 的默认值都取 1,社区常见 1 或 2。公开经验是同样采样次数下 μ = 2 收敛略快,最终性能大致持平,不是「越大越好」。

把这六步连起来看,GRPO、GSPO、GSPO-token 从第一步到第四步完全一样,第六步也一样,分歧只在第五步里 r_t 怎么组合、梯度顺着谁流。下一节就讲这个。

二、三种模式:同一批 r_t,三种组合

上面公式里的 ratio,三种模式各填一样东西。ms-swift 的实现在 swift/rlhf_trainers/grpo_trainer.py,核心就这几行:

log_ratio = per_token_logps - old_per_token_logps          # 每个 token 的 log r_t

if importance_sampling_level == 'token':                   # GRPO
    log_importance_weights = log_ratio

elif importance_sampling_level == 'sequence':              # GSPO
    seq_level_log_weights = (log_ratio * mask).sum(-1) / mask.sum(-1)   # 整句取平均
    log_importance_weights = seq_level_log_weights.unsqueeze(-1)        # 广播给每个 token

elif importance_sampling_level == 'sequence_token':        # GSPO-token
    seq_level_log_weight = seq_level_log_weights.detach()  # 数值照用,梯度截断
    log_importance_weights = per_token_logps - per_token_logps.detach() + seq_level_log_weight

coef_1 = torch.exp(log_importance_weights)
token:各掷各的骰子sequence(GSPO):整句掷一个骰子,梯度平均分sequence_token(GSPO-token):整句掷一个骰子,梯度各回各家r₁ 1.14r₂ 1.00r₃ 1.50r₄ 1.00r₁ 1.14r₂ 1.00r₃ 1.50r₄ 1.00r₁ 1.14r₂ 1.00r₃ 1.50r₄ 1.00用 1.14用 1.00超界,裁掉用 1.00谁超界谁单独被裁几何平均 s = 1.144整句一起裁,或一起过梯度平均分回:每个 token 拿到 s × 整句 A 的平均sg(s) = 1.144,只取数值s × A₁s × A₂s × A₃s × A₄裁剪照旧看整句的 s,梯度只回自己的 tokenA 整句相同时,与 sequence 完全一样
同一批 r_t,三种组合:token 各掷各的骰子;sequence 整句掷一个骰子,梯度平均分;sequence_token 整句掷一个骰子,梯度各回各家

token 模式就是原始 GRPO:每个 token 用自己的 r_t,谁超界谁被裁,梯度也只到自己身上。

sequence 模式是 GSPO:先把所有 token 的 log r_t 取平均,再取指数。这是几何平均,不是算术平均:

s = exp( (log r_1 + … + log r_T) / T ) = ( r_1 × r_2 × … × r_T )^(1/T)

整句只有这一个数,裁剪按它判断,每个 token 的 loss 都乘它。因为 s 是所有 log p_new 的平均,梯度也平均分回每个 token。

sequence_token 模式是 GSPO 论文里的 GSPO-token。写法看着绕:

ratio_t = sg(s) × p_new(y_t) / sg(p_new(y_t))

sg 表示 stop gradient,只取数值、不传梯度。拆开看:第一个因子是整句的 s,数值和 GSPO 一样,裁剪也按它判断;第二个因子 p_new / sg(p_new) 的数值恒为 1,它唯一的作用是让梯度只流到第 t 个 token 自己的 log p 上。所以它的数值和 GSPO 完全相同,差别只在梯度分配。

把三者放一起,第 t 个 token 的更新权重 w_t 就是这一步它的 log p 被推的量,正数推高、负数压低:

模式每个 token 用的 ratio 数值梯度流到哪里裁剪按什么判断w_t
token,GRPO自己的 r_t只到自己自己的 r_tr_t × A_t
sequence,GSPO整句的 s平均分给整句整句的 ss × 整句 A 的平均
sequence_token,GSPO-token整句的 s只到自己整句的 ss × A_t

三、带数字算:一句四个 token

一句话四个 token:「那 / 你是 / 说 / 结束符」。旧模型给它们的概率是 0.50 / 0.90 / 0.60 / 0.95。训练日志不记逐 token 的概率,这里的概率是示意值,但每个 w_t 都是用和训练代码相同的公式算出来的。

先算 s。 情形 S1:当前模型的概率变成 0.57 / 0.90 / 0.90 / 0.95,比率 r = 1.14 / 1 / 1.5 / 1。

log 1.14 = 0.1310    log 1 = 0    log 1.5 = 0.4055    log 1 = 0
平均 = (0.1310 + 0 + 0.4055 + 0) / 4 = 0.1341
s = exp(0.1341) = 1.1435

等价地,s = (1.14 × 1 × 1.5 × 1)^(1/4) = 1.71^(1/4) = 1.1435。算术平均 (1.14 + 1 + 1.5 + 1) / 4 = 1.16 是另一个量,代码里没有用它。用几何平均的原因来自 GSPO 论文:整句的概率比 π_new(y) / π_old(y) 等于各 token 比率的乘积,不开 T 次方根,长句的比值会随长度指数级放大或缩小。

再看三种情形。 优势整句相同,都取 +1 或 −1:

S1:r = 1.14 / 1 / 1.5 / 1,A 全为 +1,s = 1.144S2:r = 1.5 / 1 / 1.5 / 1,A 全为 +1,s = 1.225S3:r = 0.7 / 1 / 1 / 1,A 全为 −1,s = 0.915tokensequencesequence_tokentokensequencesequence_tokentokensequencesequence_token1.141011.1441.1441.1441.1441.1441.1441.1441.1440101000000000−1−1−1−0.915−0.915−0.915−0.915−0.915−0.915−0.915−0.9151.5 超出 [0.8, 1.2],只丢它1.144 没超界,四个都更新同上丢两个,留两个1.225 超界,整句一起丢同上A 为负,0.7 低于 0.8 被裁0.915 没低于 0.8,全部更新同上框内数字:该 token 这一步被推的量,虚线框 = 被裁剪,梯度为 0
三种情形下每个 token 的更新权重。虚线框表示被裁剪,梯度为 0
情形当前模型概率r_tstoken 模式 w_tsequence 模式 w_tsequence_token 模式 w_t
S1:A 全为 +1,「说」漂移大0.57 / 0.90 / 0.90 / 0.951.14 / 1 / 1.5 / 11.1441.14 / 1 / 0 / 11.144 × 41.144 × 4
S2:A 全为 +1,「那」和「说」都漂移大0.75 / 0.90 / 0.90 / 0.951.5 / 1 / 1.5 / 11.2250 / 1 / 0 / 10 × 40 × 4
S3:A 全为 −1,「那」的概率下降0.35 / 0.90 / 0.60 / 0.950.7 / 1 / 1 / 10.9150 / −1 / −1 / −1−0.915 × 4−0.915 × 4

逐个看:

  • S1:token 模式下,「说」自己的比率 1.5 超出上界,被单独丢掉。两种整句模式看的是几何平均 1.144,没有超界,四个 token 都更新。单个 token 的比值噪声不会让它被单独丢弃,这是 GSPO 相对 GRPO 的核心。
  • S2:整句平均 1.225 超出上界,两种整句模式整句都不更新。token 模式只丢掉漂移的那两个。可见整句判断有时比逐 token 更保守,两边各有取舍。
  • S3:A 为负时,比率低于 0.8 才会被裁。token 模式丢掉了「那」。整句模式的平均 0.915 没有超界,全部更新。

一个常见疑问:S1 里被裁的「说」,loss 里用的数值明明是 1.2,为什么 w_t 是 0?因为 loss 是 −min(1.5 × 1, 1.2 × 1) = −1.2,min 选中的是 clip 那一项,clip 把 r 截成了常数 1.2,常数对参数没有导数。表里的 w_t 是梯度权重,不是 loss 里的数值。这正是 PPO 式裁剪的设计意图:比率超界时把这个 token 的更新关掉,而不是按 1.2 更新。

三种情形里 sequence 和 sequence_token 的结果完全相同。这不是巧合。

四、什么时候 sequence 和 sequence_token 才不同

回到第一节那句话:一条转写只算出一个 A,每个 token 用的都是同一个 A。这时 sequence 模式的「整句 A 的平均」就是 A 本身,两种整句模式的 w_t 都等于 s × A,一模一样。GSPO 论文也明说:各 token 优势相同时,GSPO-token 与 GSPO 等价。我用随机数据验证过,包括被裁剪的句子,两种模式的梯度最大差 2 × 10⁻⁹。

那 sequence_token 是为谁准备的?为同一句里各 token 优势不同的情况。只用整句奖励的纯 GSPO 里不会出现这种情况;一旦有人给单个 token 单独打分,比如按位置的过程奖励,或者某个外部信号只针对某一个字加减分,同一句里的 A_t 就不再相同。这时如果还用 sequence 模式,单个 token 的加减分会被整句平均掉;必须换成 sequence_token。

先把两种模式的差别写成伪代码,只有 log_w 那两行不同,其余完全一样。这段可以直接在 torch 里跑,跑出来就是下面的数字:

logp_old = log([0.50, 0.90, 0.60, 0.95])          # 采样时四个 token 的概率,同第三节
r        = [1.14, 1.00, 1.50, 1.00]                # 第二次前向的比率(情形 S1)
logp_new = logp_old + log(r)                       # 带梯度的那一份

log_r   = logp_new - logp_old                      # 每个 token 一个
seq     = mean(log_r)                              # 整句一个数,exp(seq) 就是 s = 1.1435

if level == 'sequence':
    log_w = broadcast(seq)                         # 四个位置都是 seq,梯度经 mean 摊给每个 log p_t
elif level == 'sequence_token':
    log_w = logp_new - logp_new.detach() + seq.detach()
    #        ^ 数值上恒为 0,但梯度只连到自己的 log p_t;seq 已 detach,不再摊

w    = exp(log_w)                                  # 两种模式的数值都是 [s, s, s, s]
loss = -mean_t( min(w * A_t, clip(w, 0.8, 1.2) * A_t) )
loss.backward()
w_eff_t = -T * dloss / dlogp_new_t                 # 每个 token 实际拿到的推力

# A_t = [1, 1, 1, 1]      sequence: [1.144, 1.144, 1.144, 1.144]   sequence_token: 同左,逐位相等
# A_t = [1, 1, 0.2, 1]    sequence: [0.915, 0.915, 0.915, 0.915]   sequence_token: [1.144, 1.144, 0.229, 1.144]

推力的公式一眼能看出来。sequence 模式里 seq 是 mean(log_r),它对每个 log p_t 的导数都是 1/T,所以每个 token 拿到的是 s × mean(A),「说」的 0.2 被摊进了平均;sequence_token 模式里 log_w 对 log p_t 的导数是 1 且只在自己这一位上,所以每个 token 拿到的是 s × A_t。用数字看:

情形 S4,比率同 S1,只有「说」的优势被单独降到 0.2,A_t = 1 / 1 / 0.2 / 1。

sequence 模式下,loss 是四个 token 的平均,每个都乘同一个 s,而 s 对每个 log p_u 的导数都是 s / 4:

∂loss / ∂log p_t = −(1/4) × Σ_u A_u × (s/4) = −(s/4) × mean(A)
w_t = s × mean(A) = 1.1435 × (1 + 1 + 0.2 + 1) / 4 = 1.1435 × 0.8 = 0.915

四个 token 拿到同样的 0.915,「说」的 0.2 被平均进了 0.8 里,摊给了所有 token。

sequence_token 模式下,ratio_t 只对自己的 log p_t 有导数,导数等于 s:

w_t = s × A_t
那 1.144    你是 1.144    说 1.1435 × 0.2 = 0.229    结束符 1.144
只有「说」的优势是 0.2:A_t = 1 / 1 / 0.2 / 1,比率同 S1,s = 1.144那 A=1你是 A=1说 A=0.2结束 A=1sequencesequence_token0.9150.9150.9150.9151.1441.1440.2291.144s × mean(A) = 1.144 × 0.8:「说」的 0.2 被平均掉了s × A_t:「说」只拿 1.144 × 0.2,其它三个照常总量相同:0.915 × 4 = 1.144 × 3 + 0.229 = 3.66
只有「说」的优势是 0.2 时,sequence 把 0.2 平均掉,sequence_token 让「说」保留自己的 0.2

两种模式的总量一致:0.915 × 4 = 3.66,1.144 × 3 + 0.229 = 3.66。差别只是分配方式。而且 sequence_token 的裁剪照旧按整句的 s 判断,S1 里那种单个 token 漂移的噪声,仍不会让它被单独丢掉。

所以它不是退化回 GRPO。只有比率恰好全为 1 时,sequence_token 才和 GRPO 相同,而在这个极限下,只要优势整句相同,GSPO 本身也和 GRPO 相同。GSPO 和 GRPO 的差别只出现在比率偏离 1 的时候,这部分行为 sequence_token 全部保留。

五、落到实验上

选参数的规则只有一条:看每个 token 的 A 是否相同。

情况每个 token 的 A 是否相同importance_sampling_level
原始 GRPO相同token
GSPO,只用整句奖励相同sequence
GSPO,另加一项与优势无关的辅助 loss相同sequence
GSPO,优势按 token 单独调整不同必须 sequence_token

本文的实验是第二行。最后一行如果忘了换成 sequence_token,单个 token 的扣分会被摊成 T 分之一,其它写对的 token 也跟着被轻微压低,实验就白跑了。

顺带记一个和优势有关的坑。准确率奖励是 1 减去字错误率,没有下限。组里一旦混进一条循环到 2048 token 上限的幻觉样本,它的奖励能掉到 −200,把组内标准差撑到 70 以上。剩下 7 条不管对错,优势都挤在 +0.35 附近,完全正确的和错了三个字的几乎拿同样的分。8 条里只有 1 条离群时,离群那条的优势恰好是 −7 / √8 = −2.475,其余每条 +1 / √8 = +0.354,训练日志里的数字和这个理论值分毫不差。这时整组只剩一个信号:别循环。

六、写在最后

三句话收束:

  1. A 是整句的,比率是逐 token 的。 三种模式共用同一套 A、同一批 r_t,分歧只在比率按什么单位组合、梯度顺着比率流向谁。
  2. GSPO 整句掷一个骰子。 s 是各 token 比率的几何平均,裁剪按整句判断,单个 token 的噪声不会让它被单独丢掉,代价是整句超界时一起丢。
  3. sequence_token 只在各 token 优势不同时才有意义。 只用整句奖励时它和 sequence 完全等价;优势按 token 单独调整时,才必须用它。

参考