上一篇讲过 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.py 的 compute_advantages。
拿一组真实数据走一遍。参考转写是「谢谢我们三位课代表谢谢所有的同学们金一南老师讲过这样一句话……」,奖励是 1 减去字错误率,这组没有热词和幻觉惩罚:
| 采样和参考不同的地方 | R | A |
|---|---|---|
| 完全正确,共 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:两次前向
每条采样要过两次前向:
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。
把这张图写成伪代码,函数名对应 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.870 | 0.084 | 0.024 | 0.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 节的标准化。一个真实的组,参考「他也会为了你去改变」:
| 采样 | 条数 | R | A |
|---|---|---|---|
| 他也会为了你去改变 | 6 | 1.000 | +0.539 |
| 她也会为了你去改变 | 2 | 0.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 模式就是原始 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_t | r_t × A_t |
| sequence,GSPO | 整句的 s | 平均分给整句 | 整句的 s | s × 整句 A 的平均 |
| sequence_token,GSPO-token | 整句的 s | 只到自己 | 整句的 s | s × 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:
| 情形 | 当前模型概率 | r_t | s | token 模式 w_t | sequence 模式 w_t | sequence_token 模式 w_t |
|---|---|---|---|---|---|---|
| S1:A 全为 +1,「说」漂移大 | 0.57 / 0.90 / 0.90 / 0.95 | 1.14 / 1 / 1.5 / 1 | 1.144 | 1.14 / 1 / 0 / 1 | 1.144 × 4 | 1.144 × 4 |
| S2:A 全为 +1,「那」和「说」都漂移大 | 0.75 / 0.90 / 0.90 / 0.95 | 1.5 / 1 / 1.5 / 1 | 1.225 | 0 / 1 / 0 / 1 | 0 × 4 | 0 × 4 |
| S3:A 全为 −1,「那」的概率下降 | 0.35 / 0.90 / 0.60 / 0.95 | 0.7 / 1 / 1 / 1 | 0.915 | 0 / −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.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,训练日志里的数字和这个理论值分毫不差。这时整组只剩一个信号:别循环。
六、写在最后
三句话收束:
- A 是整句的,比率是逐 token 的。 三种模式共用同一套 A、同一批 r_t,分歧只在比率按什么单位组合、梯度顺着比率流向谁。
- GSPO 整句掷一个骰子。 s 是各 token 比率的几何平均,裁剪按整句判断,单个 token 的噪声不会让它被单独丢掉,代价是整句超界时一起丢。
- sequence_token 只在各 token 优势不同时才有意义。 只用整句奖励时它和 sequence 完全等价;优势按 token 单独调整时,才必须用它。
参考
- GSPO: Group Sequence Policy Optimization(arXiv:2507.18071,GSPO 与 GSPO-token 的定义,以及两者在优势相同时等价的说明)
- DeepSeekMath(arXiv:2402.03300,GRPO 的组内标准化优势)
- ms-swift GSPO 文档(三种 importance_sampling_level 的伪代码)