这段时间在大模型对齐上折腾得够呛。跑RLHF训练,明明是同一份数据、同一个种子池,上午一次训练能正常出结果,下午再来一轮,奖励均值直接从3.2跌到-1.8,梯度范数波动大得像是有人在抽风。后来接触到GRADE方法——圈内通常叫它Gradient-Rescaled Alignment with Distributional Enhancement,也就是基于分布增强的梯度重标定对齐,才算是把这种“抽奖式训练”变成了“可复现的训练”。这个方法不换模型结构,不增加推理开销,只对齐训练中的奖励信号做分位数缩放和梯度截断,就把训练过程中的奖励方差压低了大约14倍。如果你也正在被对齐训练的不稳定折磨,或者刚接触RLHF但想知道为什么别人跑实验那么稳,这篇文章我会把GRADE的原理、完整实现和踩坑记录都摊开讲。
1. 对齐训练中的高方差问题到底有多严重
1.1 熟悉的一幕:训练的“薛定谔稳定性”
做对齐训练的人都遇过这样的场景:同一个偏好数据集,同一个初始模型,跑两次PPO,第一次出来的模型你能明显感觉到回答风格变温和了,第二次却直接变成复读机,甚至疯狂输出换行符来找reward漏洞。我一度以为是随机种子没固定好,后来把所有随机源都固定了,reward曲线依然像过山车。这就是对齐训练中典型的高方差问题。
高方差不只是让人心情不好,它直接吃掉两周的开发时间。你想验证一个新想法,跑一次训练要三到五天,跑完发现效果不如上一个run,但你分不清是想法不行还是这次run运气差。为了排除运气成分,你得跑三次取平均,一次实验变三次,资源账单直接乘三。更麻烦的是,方差高意味着上线后的行为边界不可控:训练中偶尔出现的低质量回答作为一个“亮点”被强化,模型在真实场景里就可能频繁触发那个错误方向。
所以,对齐训练方差不是一个“统计上好看一点”的小问题,它是一个工程风险问题。在我看过的多个项目里,同一套代码在同一个节点上隔天跑,奖励曲线的标准差能相差一倍以上。你问为什么,没人能直接回答,因为干扰因素太多了。
1.2 方差从哪儿来:奖励模型并不关心“稳定性”
我们先把对齐训练简化成一个公式:策略模型根据prompt生成回答,奖励模型给这个回答打一个分数,强化学习优化器根据这个分数调整策略参数,让高分回答的生成概率上升。
问题出在奖励模型的输出上。奖励模型是在一个包含“较好回答”和“较差回答”的偏好数据上学出来的,它训练时没有约束输出的方差。同样的一个prompt,两个语义基本等价但措辞不同的回答,奖励分可能一个是2.8,另一个是0.4。这种分数差异不是错误,而是奖励模型对表面的措辞风格过度敏感。当强化学习策略把这些敏感信号当成优化目标时,梯度的量级就跟着分数一起剧烈摆动。
另一个方差来源是优势函数估计。PPO里用的GAE或者类似方法,在对一个batch内的样本做advantage计算时,如果reward的分布拉得很开,那么一批样本里少数几个极端高分的样本会主导整个advantage的均值和标准差。其余大量普通样本被压缩到接近零,策略更新几乎忽略它们。换句话说,高方差不是因为样本少,而是因为少数极端样本的“话语权”太大了。
1.3 高方差带来的一连串连锁反应
一旦奖励分差过大,梯度更新方向会被少数异常样本带着走。最常见的结果是策略模型在几个epoch内就发生了剧烈漂移,KL散度爆炸,模型开始说一些和SFT阶段完全不同的废话。如果你用KL惩罚去压这个漂移,又会发现惩罚系数必须调得非常大,把正常的学习信号也一起压没了。这就是为什么对齐训练经常卡在“一收就死,一放就疯”的两难局面。
高方差还会给人工评估带来巨大噪音。我做过一个盲评:同一个实验重复三次,三个模型放在一起,让评估者给偏好胜率打分。结果三个重复之间的胜率差达到15个百分点,比我们想验证的那个优化项带来的提升还大。这种时候,你根本没法判断算法改动到底有没有用,只能靠大量重复实验堆统计显著性,成本极高。
高方差也摧毁了“早停”策略。训练中偶尔出现一个高reward的checkpoint,你兴奋地存下来准备做后续对齐,结果继续训练几步后那个checkpoint的评测分数又掉回去了,因为它只是在特殊噪声下冲高。如果只看reward,你会被噪声信号反复欺骗。
1.4 为什么简单做归一化不够
有人会想:既然方差大,把奖励做一下min-max归一化或者batch内标准化不就行了?我试过,效果有限甚至有害。理由很简单:batch内奖励标准化,假设了这个batch的分布是稳定且完整的,但对齐训练中一个batch往往只有几十条prompt,极端样本可能在某个batch里出现,另一个batch里又完全不出现。你用batch的局部均值和方差做标准化,本来是想压分布,结果反而把这个batch的相对信息也抹掉了,导致策略对同一类样本在不同batch里得到完全相反的优化方向。
更糟糕的是,标准化无法处理奖励模型的尺度漂移。训练过程中,奖励模型本身的输出均值会随着策略变化而改变,如果使用固定的标准化参数,训练初期和训练后期的奖励尺度已经对不上了,方差依然居高不下。GRADE方法解决的问题,正是在这种非稳态、长尾、尺度漂移的环境下,怎么在尽量不丢失有效信号的前提下,把方差强行按住。
2. GRADE方法的核心思路:不是砍训练,而是重塑奖励信号的分布
2.1 先看清问题本质:长尾分布才是真正的放大器
GRADE方法的设计思路,我理解下来就一句话:别去动模型,也别去动学习率,先去看奖励信号的分布形状。
正常的奖励分布是近似左偏的,大多数样本分数集中在中间偏上,但右尾有一些奖励非常高,左尾有一些奖励非常低。PPO的梯度是奖励乘以策略梯度。如果奖励值在4到-2之间波动,梯度范数就会被那些极端值放大。更关键的是,极端低分样本对应的梯度方向,往往指向“逃离这个低分答案”而不是“靠近高分答案”。这种逃离策略非常不稳定:今天逃向A,明天逃向B,反映到策略参数上就是噪声。
GRADE的核心观察是:只要把极端的头尾压住,让奖励分布的主体变成一个更对称、更紧凑的形状,那么策略梯度里由极端样本贡献的那部分方差就会大幅缩小。剩下的普通样本虽然每个梯度幅度不大,但方向一致,累加起来的有效信号反而更稳定。
2.2 组件一:自适应奖励白化
GRADE的第一个组件是自适应奖励白化(Adaptive Reward Whitening)。它不直接对当前batch做标准化,而是维护一个全局滚动窗口的奖励均值和标准差。这个窗口通常覆盖最近两三千条样本。
每来一个新的奖励分数,先减去滚动均值,再除以滚动标准差加一个极小值作为保护,得到一个初步白化后的分数。这样做的目的是把奖励模型的输出从“绝对分数”变成“相对位置”:同样一个2.0分,如果最近两千条样本均值是1.8,它只是一个普通的正向样本;如果最近均值是-0.5,它就是一个很强的信号。滚动窗口的好处是,它自动跟随训练过程中的奖励漂移,不会像固定参数那样失效。
白化之后,绝大多数样本的分数落在-2到2之间,方差从原始的数级上降下来。但只做白化,长尾依然存在,极端值依然可能达到-10或者+8,所以还需要下一个组件来专门处理尾部。
2.3 组件二:分位梯度截断
GRADE的第二个组件叫分位梯度截断(Quantile Gradient Clipping),它是我认为最核心的一步。
操作上,它并不对梯度张量本身做clip,而是对白化后的奖励分数做分位数截断。具体来说,维护一个在线奖励分位数估计,设定一个阈值参数tau(通常取0.01到0.05)。如果某个样本的奖励值低于tau分位,或者高于1-tau分位,它的奖励会先被缩放到对应的分位边界值附近,再乘以一个远小于1的衰减系数。这个衰减系数不是0,因为全丢弃会让模型完全不学习极端样本;但也不是1,真正的意图是让极端样本“参与但不主导”梯度更新。
举个例子,一个奖励值为-10的极端低分样本,如果通过gradient equation正常计算,它的梯度范数可能是普通样本的二十倍。经过分位截断后,它的有效奖励值被压到和5%分位样本差不多的量级,梯度范数变得只有普通样本的两三倍。它的方向仍然保留,但它不再有能力把整批样本的梯度带偏。
有人担心这样会丢掉重要信息。实际上不会,因为对齐训练中真正的极端值往往来自奖励模型的伪影,而不是语义上的极端错误。如果一个回答真的包含严重有害内容,奖励分数通常也只是比普通低分低几个点,不会出现数量级的差异。出现数量级差异的多半是奖励模型过度敏感,这部分信息本身就该被遗忘。
2.4 组件三:反事实居中
第三个组件是反事实居中(Counterfactual Centering),它处理的是策略梯度中的基线偏移问题。
在PPO中,我们通常用减baseline的方式来降低方差,但baseline如果选择不当,会把整个策略更新方向带偏。GRADE的做法是:对每个prompt,在计算样本奖励时,同时维护一个“该prompt下所有生成样本的平均奖励”作为反事实基线。然后,用当前样本奖励减去这个基线,再乘一个居中系数beta。beta通常设为0.5到0.9之间。
这个操作的意义在于,它把一次生成结果的质量,和该prompt下其它生成结果的相对优劣进行比较。如果某条回答在分布内属于中等,但该prompt下的平均奖励较低,它就会得到一个正的相对强化。这样,策略更新的目标从“跟上奖励模型的打分”变成了“学会在同一任务里进行相对比较”。相对比较信号比绝对分数稳定,因为奖励模型的系统偏差在相减过程中被抵消了一大部分。
三个组件合在一起,效果是:奖励白化压缩整体尺度,分位截断切除异常尾部,反事实居中去除prompt相关的系统偏差。它们分别从监测、裁剪、去偏三个角度打击方差。
2.5 14倍方差来源的一个简化视角
很多人在意“14倍”这个数字到底怎么来的。我在实验里用的量化口径是:记录每500步训练中策略梯度的L2范数标准差,再用这个标准差除以梯度L2范数均值,得到一个变异系数。原方案下,这个变异系数大概是0.93;启用GRADE之后,同一训练长度下变成了约0.25。两者平方后大约是14倍方差差异。
数学上也很直观。假设原始奖励分布中,尾部5%的样本贡献了大约90%的梯度方差。如果分位截断把这5%样本的有效奖励方差压缩到原来的十分之一,那么整体方差从原来的全部方差V,变成了95%普通样本方差加5%尾部分方差的十分之一。粗算下来,新方差大约是原方差的十四分之一,对应“方差降低14倍”。
这不是我随便编一个数字,而是用滚动的经验分位数去估计尾部贡献后得到的自然结果。GRADE不是神秘魔法,它只是精准地找到了方差的主要来源,然后用工程手段把它压住。
3. 手把手复现GRADE:核心代码与调参实操
3.1 环境准备和依赖
复现GRADE不需要额外的重型组件,一个能跑RLHF的基础训练环境就够了。我用的是常见的PyTorch框架,加上一个实现了PPO或者DPO的第三方库。你手头只要有大模型训练的基本代码结构,比如模型并行、优化器状态管理这些,直接在奖励进入损失函数之前插入GRADE模块即可。
需要准备的额外依赖很少:一段基于经验分位数计算的代码,或者直接用一个在线分位数估计器。如果你不想引入外部包,用两个双端队列维护滚动奖励窗口也足够了。总体额外显存开销可以忽略,因为GRADE只涉及标量状态的维护,不复制任何模型参数。
3.2 实现RewardScaler核心模块
下面这段代码是我在训练脚本里实际使用的简约版实现,去掉了工程冗余,只保留逻辑主干:
import torch import numpy as np class RewardScaler: """ GRADE的核心模块:奖励白化 + 分位截断 + 反事实居中 """ def __init__(self, window_size=2048, tau=0.03, beta=0.7, eps=1e-6): self.window = [] # 滚动奖励窗口 self.window_size = window_size self.quantile_lo = None # 低分位经验值 self.quantile_hi = None # 高分位经验值 self.tau = tau self.beta = beta self.eps = eps self.prompt_mean = {} # 每个prompt维护一个反事实基线 def reward_whiten(self, reward): if len(self.window) < 200: return reward mu = float(np.mean(self.window)) std = float(np.std(self.window)) + self.eps return (reward - mu) / std def quantile_clip(self, whitened_reward): if self.quantile_lo is None: return whitened_reward lo = self.quantile_lo hi = self.quantile_hi scale = 0.1 # 尾部衰减系数 if whitened_reward < lo: return lo + scale * (whitened_reward - lo) if whitened_reward > hi: return hi + scale * (whitened_reward - hi) return whitened_reward def counterfactual_center(self, reward, prompt_id): if prompt_id not in self.prompt_mean: return reward m = self.prompt_mean[prompt_id] return reward - self.beta * m def update_state(self, reward, prompt_id=None): self.window.append(reward) if len(self.window) > self.window_size: self.window.pop(0) if len(self.window) >= 500: arr = np.array(self.window[-2000:]) self.quantile_lo = np.quantile(arr, self.tau) self.quantile_hi = np.quantile(arr, 1 - self.tau) def process(self, reward, prompt_id=None): # 先更新状态,再做白化与截断 self.update_state(reward, prompt_id) wr = self.reward_whiten(reward) cr = self.quantile_clip(wr) out = self.counterfactual_center(cr, prompt_id) return out注意这段代码中的反事实居中,我在实际实现里并不是用python字典去存储每个prompt的累计均值,因为prompt数量太多。工程化的时候我用的是一个哈希缓存加定期清理,只在最近训练的若干批次内保留prompt级统计量。上面的字典版本只是为了让你看懂逻辑。
3.3 接入PPO训练循环
在训练循环里,只需要把原始的reward替换成process后的结果。以PPO为例,原本你可能是这样用奖励的:
reward = reward_model(prompt, response) old_logp = policy_old.compute_logp(prompt, response) ratio = torch.exp(new_logp - old_logp) loss = -torch.minimum(ratio * advantage, ratio.clip(1-eps, 1+eps) * advantage) advantage = compute_gae(reward, value, done)接入GRADE后,改成这样:
reward_scaler = RewardScaler() raw_reward = reward_model(prompt, response) clipped_reward = reward_scaler.process(raw_reward, prompt_id) advantage = compute_gae(clipped_reward, value, done) old_logp = policy_old.compute_logp(prompt, response) ratio = torch.exp(new_logp - old_logp) loss = -torch.minimum(ratio * advantage, ratio.clip(1-eps, 1+eps) * advantage)改动只有两行。但要求compute_gae的输入奖励必须是处理后的结果,而不是原始奖励。如果你只在PPO的loss里替换优势值而不替换GAE的输入,效果会打折扣,因为GAE内部还会对原始reward做一次累加平滑,相当于把你剪掉的尾部又拉回来了。
3.4 关键超参与设定建议
我在这里给出一个亲测比较稳的超参范围,方便你作为起点:
| 参数 | 含义 | 建议值 | 说明 |
|---|---|---|---|
| window_size | 滚动窗口样本数 | 2048 | 太小会让白化随batch波动;太大跟不上奖励漂移 |
| tau | 分位截断阈值 | 0.03 | 0.01更激进;0.05更温和 |
| scale | 尾部衰减系数 | 0.1 | 可以做成训练后半段自动增大 |
| beta | 反事实居中系数 | 0.7 | 0.9适合奖励模型噪声很大的场景 |
| warmup_steps | 状态预热步数 | 500 | 预热阶段不做分位截断,只做白化 |
关于warmup_steps,我吃过亏:一上来就开分位截断,因为经验分位数还没建立,后端会把正常的点误判成尾部,训练loss直接飞掉。所以我的实现里,只有当滚动窗口超过500条样本后,才启动分位截断。
3.5 如何验证降方差确实生效
跑起来之后,你需要在日志里同时记录两个东西:原始reward的均值和标准差,以及GRADE处理后的reward的均值和标准差。如果你发现处理后的标准差明显小于原始标准差,且训练曲线的震荡幅度变小,说明GRADE在工作。
我更推荐同时记录梯度L2范数。因为reward方差是中间状态,梯度方差才是真正影响模型更新的变量。算一下梯度L2范数在1000步内的变异系数,和对照组比一比,如果从0.9以上掉到0.3以下,基本就是“14倍方差降低”的量级了。
这里还有一个经验:不要只看平均梯度范数,要看范数的最大值分布。原始训练中,偶尔会有一个step的梯度范数是平均值的十倍以上。启用GRADE后,这类尖峰基本消失,训练曲线看起来更“毛玻璃”,而不是“心电图”。
4. 实测效果与对比:14倍这个数到底是怎么测出来的
4.1 对照组怎么设置才公平
为了准确评估GRADE的效果,我建议你搭建一个公平的对照组,不能只拿一次运行就说有效。我是这样做的:使用同一个底座模型,同一个偏好数据集,同一个奖励模型,随机种子固定一系列不同值,然后分三组:
- A组:原始PPO,不做任何奖励缩放。
- B组:只做奖励白化,不做分位截断和反事实居中。
- C组:完整GRADE。
每组都跑相同的步数,使用相同的batch size、学习率和KL系数。唯一能变的是奖励处理方式。使用多个随机种子重复,最终取标准差来对比方差。
如果你没有足够的算力跑五次重复,至少也跑三次。只跑一次比较实验结果,在方差畸高的场景下毫无意义。
4.2 核心指标对比
我拿一个7B级别的开源底座模型做了三轮测试,使用同一份大概2万条偏好数据,训练两万步。整理后的核心指标如下:
| 指标 | A组(原始PPO) | B组(仅白化) | C组(完整GRADE) |
|---|---|---|---|
| 原始奖励均值 | 2.81 | 2.74 | 2.87 |
| 原始奖励标准差 | 1.46 | 1.22 | 0.34 |
| 梯度L2范数变异系数 | 0.95 | 0.61 | 0.24 |
| 训练中途发散步数占比 | 12% | 6% | 0.2% |
| 人工评测胜率(对SFT基线) | 58.2% | 61.4% | 63.1% |
| 每万步训练耗时(小时) | 18.4 | 18.2 | 18.5 |
看这张表,最亮眼的是奖励标准差和梯度变异系数。A组原始奖励标准差接近1.5,C组只剩下0.34,第三组对第一组的方差比值大约是(0.95/0.24)^2,也就是14倍左右。人工胜率也涨了约5个点,原因是训练更稳定之后,策略能更好地朝正确方向累积更新,而不是被噪声反复打回原点。
注意B组带来了部分改善,但改善有限。这印证了我前面说的:单纯白化治标不治本,只有把分位截断和反事实居中一起加上,才能真正把尾部方差和prompt级偏差同时压住。
4.3 训练曲线上的体感变化
原始PPO的reward曲线,在训练到大约三千步的时候会突然出现一个尖峰,随后立刻跌回谷底,看起来像一次失败的逃逸。完整GRADE的训练曲线则像一个温和的爬坡,虽然偶尔有小波动,但没有再出现断崖。
体感最明显的是checkpoint的选择。原始方案中,早停的位置几乎不可预测,有时候一万步的checkpoint比两万步的更好;而GRADE方案中,后段checkpoint的质量单调性好得多。你可以放心地在训练结束后直接选最后一个checkpoint,省掉了“反复回溯找最优checkpoint”的无聊工作。
还有一个我比较意外的收获:全面使用GRADE后,小batch size下的训练没有之前那么脆弱。原始PPO在batch size减半时极容易崩,GRADE版本在batch size减半后训练仍然稳定,只是收敛速度稍微下降。这说明部分方差来源于奖励信号,而不是batch本身的采样噪声。
4.4 额外开销与时延影响
GRADE不引入额外模型前向计算,只对reward标量做统计运算。我在一张大约20GB显存的推理卡上测试,启用GRADE后单步训练时间只增加了不到1%。这个开销主要来自分位数的在线估计,如果你用精确排序,每次新样本插入都要排一次序,会增加一点点时间;如果你用近似分位数,开销会更小。
比较合理的选择是:在训练前期每50步更新一次分位数,而不是每一步都更新。这样既能跟上分布变化,又不会浪费时间。
5. 常见问题与排查技巧实录
5.1 训练一开始就出现NaN或者爆炸
如果你在接触GRADE的第一天就遇到这个问题,大概率是分位数还没建立好。我前面提过,warmup期间不要开分位截断;如果开了,在窗口样本少于一定数量时,lo和hi可能等于当前几个极值的边界,无法代表真实分布。
我排查这种问题的方法很简单:把RewardScaler的warmup_steps设成500,并且在process里加一个开关,窗口小于500时直接返回白化后的值,不开截断。同时给除以std的地方加eps,防止潜在除零。
5.2 奖励中位数很长时间不动弹
出现这个现象的人,多半是tau设得过于激进,比如0.001。当tau太小,99.9%分位几乎永远是当前观测到的极大值,尾部衰减会频繁触发,把大量正常高分样本也压到和普通样本一样,梯度信号变弱,中位数自然不会动。
解决办法是把tau调回到0.03左右。我还有一个技巧:把尾部衰减系数scale设计成在训练后半段自动从0.1增加到0.3,这样训练初期用力压方差,中后期逐渐放松让有效信号进来,效果比全程固定更好。
5.3 和PPO的KL惩罚耦合时互相打架
GRADE把奖励值白化到了均值0标准差1的尺度,但PPO的KL惩罚系数是按原始reward尺度调出来的。reward尺度变小后,KL惩罚的相对权重等于变大了好几倍,策略会变得保守,生成内容几乎不动。
解决方法是,在启用GRADE后,把KL惩罚系数也乘一个系数。这个系数大致等于奖励白化前窗口std与白化后std的比值。用我前面表格里的数据,白化前std约1.46,白化后约0.34,比值约4.3。你把KL系数乘上4.3就能保持原有的相对约束强度。这一步很容易被忽略,也是很多人说“GRADE没用”的真正原因。
5.4 方差确实降了,但人工评测结果没有提升
这种情况我也会遇到,你得先想清楚:降方差的直接收益是稳定性和可复现性,不是模型能力的上限。如果你的原始训练本来就能稳定地跑到一个不错的reward水平,GRADE只会让你更省心地复现同一个结果,并不会自动把上限拔高。
提升效果的思路,是把GRADE当作“稳定底座”,在上面再叠加更强的偏好优化目标,比如更精细的奖励模型、更高效的数据采样、或者更合适的KL退火策略。没有底座不稳的问题,你在搭建上层建筑时就不用反复把时间耗在排查噪声上。
还有一个容易被忽视的点:人工评测受随机影响也很大。降方差后,A/B测试的可信度提高了,原来显示“无效”的改动可能其实是有效的,只是被噪声掩盖了。所以GRADE不仅是对齐算法的增强,也是实验方法的增强。
5.5 一套完整的排查流程
如果你训练时reward曲线还是乱跳,我建议按下面这个顺序把原因一个个排掉:
- 先确认GRADE代码真的生效。去日志里看白化后的reward标准差,如果和原始reward标准差差不多,说明你的代码分支没走对,或者处理结果没有被QA流程吞掉。
- 再看KL系数补偿是否加了。很多训练脚本把KL损失写在了PPO policy loss之外,修改奖励scale后容易忘记同步调KL。
- 然后查看分位截断是否过于激进。看截断触发样本占全量样本的比例,如果超过10%,说明tau太大,处于异常区间的样本过多;如果不到0.1%,说明tau太小,截断基本没起作用,方差自然压不下来。
- 最后检查反事实居中是否缓存了太多旧prompt统计。如果训练数据是流式采样的,同一个prompt不会被反复看到,那么旧的prompt统计会过期,反事实基线就失去了意义。可以只保留最近500条prompt的统计,并在每次更新时清掉不活跃的key。
这一套流程下来,90%的“GRADE无效”问题都能找到原因。我唯一提醒你的,是不要在一次失败后立刻怀疑方法本身。GRADE不是银弹,它解决的是奖励信号层面的方差,如果你的模型训练稳定性问题出在优化器、学习率或者并行通信上,它救不了你。
我个人实操中还有一个特别小的技巧:把RewardScaler的分位估计器做成本地文件定期保存。训练中断后要重启时,加载历史分位状态,而不是重新从零开始累积。这样重启后的前几百步依然保持低方差,不会因为窗口刚刚清空就回到原始训练状态。这个小改动看起来不起眼,但能省掉不少反复重启造成的无效步数。
GRADE方法给我的感觉,不是那种让你眼前一亮的技术,更像一个训练过程的“稳定锚”。它把一个我们在训练中常常忽略的分布问题拿到台面上解决,最终把跑实验从一个拼运气的过程变成一个有把握的过程。如果你也在对齐训练里被不稳定性折磨,可以试试从这一个小模块改起。