AIGC 基本功|从 DPO 到 GRPO:去掉价值网络-GRPO

人工智能炼丹君
2026-09-13 / 0 评论 / 4 阅读 / 正在检测是否收录...

从 DPO 到 GRPO:去掉价值网络

所属方向:对齐与强化学习 | 难度:进阶 | 前置知识:PPO
关键词:DPO、GRPO、偏好优化、组内归一化、免价值网络、参考模型


01. 为什么需要它

把一个 7B 语言模型接进 PPO-RLHF,训练栈很快会变成四个模型:负责生成的 actor、预测每个 token 未来回报的 critic、限制策略漂移的 reference model,以及把整条回答变成分数的 reward model。真正更新参数的是 actor 和 critic,后两者虽然通常冻结,前向和显存仍要付钱。

critic 是最难解释的一笔成本。语言模型往往只在回答结束时拿到一个总分,但 value head 必须给回答中每个 token 估计未来回报。它不但要和 actor 同量级地跑前向、反向,还要从稀疏的终局信号中学习密集价值。DeepSeekMath 指出,LLM 的 value model 通常与 policy model 规模相当,这正是 GRPO 要删除的部件。

以全参数 AdamW 的粗略账本为例:7B 参数的 BF16 权重约 14 GB,BF16 梯度约 14 GB,FP32 主权重和两份动量约 84 GB,一个可训练模型合计约 112 GB 逻辑状态,实际会被 ZeRO/FSDP 分片。再训练一套同规模 critic,等于再背一次参数、梯度、优化器状态和激活。删掉 critic 的收益很大,但不能机械地说“所有训练都便宜十倍”:GRPO 每个 prompt 要在线生成一组回答,DeepSeekMath 的实验组大小是 64,采样和奖励打分可能成为新的主成本。

DPO 与 GRPO 给了两条不同的减法路线。DPO 把带 KL 约束的 RLHF 目标改写成偏好二分类,训练时不再在线采样,也不需要显式奖励模型;GRPO 保留在线探索和奖励信号,用同一道题的组内相对分数替代 critic。二者名字相邻,解决的却不是同一个问题。

PPO-RLHF、DPO 与 GRPO 的训练部件对比

图 1:DPO 省掉显式奖励模型和在线 RL 循环,GRPO 省掉 critic。Policy 与 reference 是否共享底座、reward 是规则还是模型,会继续改变真实成本。

02. 最小可用理解

先记住五句话:

  1. DPO 吃离线偏好对:同一 prompt 下给一个 chosen 和一个 rejected,直接提高 chosen 相对 reference 的概率优势。
  2. GRPO 吃在线样本组:同一 prompt 采样 $G$ 个回答,打分后用组均值当 baseline,不训练 value model。
  3. GRPO 沿用 PPO 的概率比与 clip:它只换了优势估计,没有丢掉“旧策略采样、新策略更新”这套框架。
  4. reference 不是 critic:reference 负责限制长期漂移,critic 负责估计当前状态的未来回报;删掉 critic 后 reference 通常还在。
  5. 组内相对分数只回答谁更好:如果一组答案同分,GRPO 在这道题上没有方向;如果奖励尺度跨题不一致,组内归一化反而能消掉尺度差。

因此,从 DPO 到 GRPO 不是把一个损失函数逐项变形为另一个,而是从“离线比较学习”切换到“在线生成、在线评价”。选择算法时先问数据来自哪里,再问显存够不够。

03. 数学推导

3.1 PPO-RLHF 的起点

对 prompt $x$ 和回答 $y$,标准 KL 正则化目标可以写成:

$$\max_{\pi}\ \mathbb{E}_{y\sim\pi(\cdot\mid x)}[r(x,y)]-\beta D_{KL}[\pi(\cdot\mid x)\|\pi_{ref}(\cdot\mid x)]$$

$\pi$ 是待训练策略,$\pi_{ref}$ 是固定参考策略,$r(x,y)$ 是回答奖励,$\beta$ 控制策略离参考模型多远。PPO 用采样轨迹、critic 优势和裁剪目标近似优化它。这里至少要维护 actor、critic 和 reference;若奖励由神经网络给出,还要运行 reward model。

3.2 DPO 如何把奖励塞回策略

上面的目标对每个 $x$ 都有闭式最优策略:

$$\pi^*(y\mid x)=\frac{1}{Z(x)}\pi_{ref}(y\mid x)\exp\left(\frac{r(x,y)}{\beta}\right)$$

$Z(x)$ 是归一化常数。把式子移项,可把奖励写成策略与参考策略的对数概率比:

$$r(x,y)=\beta\log\frac{\pi^*(y\mid x)}{\pi_{ref}(y\mid x)}+\beta\log Z(x)$$

偏好数据给出同一 prompt 下的胜者 $y_w$ 与败者 $y_l$。用 Bradley-Terry 模型表示“胜者更好”的概率:

$$P(y_w\succ y_l\mid x)=\sigma(r(x,y_w)-r(x,y_l))$$

把上一式代入后,同一道题共享的 $\beta\log Z(x)$ 自动相消。用可训练策略 $\pi_\theta$ 代替未知最优策略,得到 DPO 损失:

$$\mathcal{L}_{DPO}=-\mathbb{E}\log\sigma\left(\beta\left[\log\frac{\pi_\theta(y_w\mid x)}{\pi_{ref}(y_w\mid x)}-\log\frac{\pi_\theta(y_l\mid x)}{\pi_{ref}(y_l\mid x)}\right]\right)$$

括号里有两层差。第一层是 chosen 与 rejected 的差;第二层是当前 policy 相对 reference 的差。只让 policy 提高 chosen 概率还不够,它提高的幅度必须超过 reference 原本已有的偏好。

DPO 的省钱来自训练协议:偏好对提前收集好,训练循环只做似然计算和二分类,不需要当前策略在线生成回答,也不需要在循环里调用显式 reward model。但代价也在这里:数据分布被固定。策略更新后会产生哪些新错误,旧偏好集未必覆盖。

3.3 PPO 的 critic 到底提供了什么

PPO 的优势通常来自 critic:

$$A_t=Q(s_t,a_t)-V_\phi(s_t)$$

$V_\phi(s_t)$ 预测从当前 token 前缀继续生成的平均回报。减去它不会改变策略梯度的期望,却能降低方差。问题是,回答只在末尾拿到 $r(x,y)$,critic 却要在每个位置给出估计;它要学习的目标本身就很噪。

如果暂时不追求每个 token 的状态价值,而是对同一道题一次采 $G$ 个完整回答,就能用样本组构造另一种 baseline。

3.4 GRPO 的组内相对优势

从旧策略 $\pi_{old}$ 对同一 prompt $x$ 采样回答 $\{y_1,\ldots,y_G\}$,奖励为 $\{r_1,\ldots,r_G\}$。Outcome supervision 下,GRPO 定义:

$$\hat A_i=\frac{r_i-\mathrm{mean}(r_1,\ldots,r_G)}{\mathrm{std}(r_1,\ldots,r_G)+\epsilon}$$

$\epsilon$ 防止标准差为零时除零。均值把“这道题本来容易还是难”扣掉,标准差把不同题目的奖励尺度拉到接近一致。对一条回答里的每个有效 token,原始 GRPO 都广播同一个 $\hat A_i$:得分高于组均值的回答整体增概率,低于均值的整体降概率。

GRPO 组内相对优势示意

图 2:两道题的原始 reward 尺度相差很大,组内标准化后各自在零均值参照系里比较。被保留的是同题回答间的相对好坏。

组均值包含样本自身,并不满足“baseline 与本次动作独立”的条件。对固定 prompt 的独立同分布样本,若只减组均值而不除标准差,有 $E[(r_i-\bar r)\nabla\log\pi(y_i)]=(1-1/G)\nabla J$;leave-one-out 均值可去掉这项缩放。再除随机组标准差会改变样本及 prompt 权重,因此完整 GRPO 不能宣称是原始期望奖励梯度的无偏估计。

这不是免费午餐。$G$ 太小时,组均值和标准差很不稳定;奖励全相等时,优势全部为零;同一回答的所有 token 共用一个结果优势时,算法知道“整条回答好”,却不知道哪一步推理真正立功。

3.5 GRPO 仍然是 PPO 家族

对回答 $y_i$ 的第 $t$ 个 token,定义新旧策略概率比:

$$\rho_{i,t}(\theta)=\frac{\pi_\theta(y_{i,t}\mid x,y_{i,<t})}{\pi_{old}(y_{i,t}\mid x,y_{i,<t})}$$

GRPO 沿用 PPO-Clip 的保守替代目标,并直接在 loss 上加 reference KL:

$$\mathcal{J}_{GRPO}=\mathbb{E}\left[\frac{1}{G}\sum_i\frac{1}{|y_i|}\sum_t\left(\min\left(\rho_{i,t}\hat A_i,\mathrm{clip}(\rho_{i,t},1-\varepsilon,1+\varepsilon)\hat A_i\right)-\beta D_{KL}(\pi_\theta\|\pi_{ref})\right)\right]$$

$\pi_{old}$ 是本轮 rollout 的采样快照,用于纠正同一批数据被重复更新后的分布偏移;$\pi_{ref}$ 是行为锚点,用于限制训练全过程的长期漂移。两者在训练刚开始可能数值相同,但职责不同,不能混用。

DeepSeekMath 使用的单样本 KL 正值估计器是:

$$D_{KL}=\exp(\Delta)-\Delta-1,\quad \Delta=\log\pi_{ref}-\log\pi_\theta$$

因为对任意实数 $\Delta$ 都有 $e^\Delta\geq1+\Delta$,这个量非负。在实现里它可以直接由采到 token 的 policy/reference logprob 计算,不必枚举整个词表。它的期望等于 $D_{KL}(\pi_\theta\|\pi_{ref})$ 的前提是动作从当前 $\pi_\theta$ 采样;实际 rollout 来自 $\pi_{old}$,更新后直接平均得到的是旧采样分布下的代理量,需要相应重要性校正才有同一无偏解释。

3.6 DPO 与 GRPO 的统一观察

DPO 和 GRPO 都在做“相对比较”,但参照物不同:

维度 DPO GRPO
数据 固定的 chosen/rejected 对 当前策略在线生成的一组回答
相对量 policy 相对 reference 的偏好间隔 回答奖励相对同组均值
删除的部件 显式 reward model 与在线 RL 循环 critic/value model
仍需保留 policy、reference、偏好数据 policy、old policy logprob、reference、奖励函数
主要风险 离线数据覆盖不足 采样昂贵、组内同分、奖励投机

这张表也解释了为什么“DPO 之后再做 GRPO”可以成立:先用便宜的离线偏好对把策略推到合理区域,再用在线 GRPO 探索当前策略真正会生成的回答。但这是一种训练安排,不是数学上必须的前后继关系。

3.7 手算一组 DPO 和 GRPO

先看 DPO。设第一对样本中,当前 policy 对 chosen 和 rejected 的整句 logprob 分别为 -2.2 和 -2.8,因此 policy 的偏好间隔是 0.6;reference 对二者的 logprob 分别为 -2.0 和 -2.4,偏好间隔是 0.4。当前策略相对 reference 多出来的间隔只有 0.2。当 $\beta=0.1$ 时,送进 sigmoid 的 logit 是 0.02,胜者概率只略高于 0.5,loss 接近随机猜测的 $\log 2$。

这解释了一个常见现象:chosen 的 logprob 即使很低,也不代表 DPO 一定给它很大梯度。DPO 看的是四个 logprob 组成的相对差。若 reference 已经强烈偏爱 chosen,而 policy 只复制了这种偏好,DPO 不会把它误判成新的进步;若 policy 相比 reference 反而缩小了 chosen 的优势,logit 会变负,损失上升。

再看 GRPO。第一组奖励是 [0.2, 0.8, 1.4, 0.6],均值为 0.75,标准差约为 0.433。标准化后得到 [-1.2699, 0.1154, 1.5008, -0.3463]。第二个回答虽然原始分数 0.8 看上去不高,但它略高于同题平均,因此得到很小的正优势;第三个回答比平均高很多,得到最大的正优势。

假设这四条回答更新后的 token ratio 依次是 [1.2840, 1.1052, 0.7788, 0.9048],裁剪区间为 [0.8,1.2]。第一条优势为负,而 ratio 大于 1,说明坏回答变得更可能,目标必须继续惩罚,不能裁掉。第二条优势为正且 ratio 在区间内,正常提供正向梯度。第三条优势为正但 ratio 低于 0.8,说明好回答被错误降权,目标同样继续惩罚。第四条优势为负且 ratio 在区间内,也正常提供降低概率的梯度。

真正被截住的是“已经朝正确方向走过头”的两种情况:正优势回答的 ratio 超过 1.2,或负优势回答的 ratio 低于 0.8。PPO/GRPO 的 clip 从来不是把所有 ratio 强制塞进区间,而是停止奖励过度乐观的改进。只看 np.clip(ratio) 而不把优势符号放进来,几乎一定会误读目标。

04. 代码实现

code/dpo_grpo_minimal.py 只依赖 NumPy,包含三部分:DPO 二分类损失、GRPO 的组内优势与裁剪损失,以及一个四动作 bandit 的在线训练。实际运行输出如下:

两张解释图由 code/make_figures.py 从文中的固定数值直接生成,图和公式使用同一组奖励,避免手工绘图与代码结果不一致。

=== DPO ===
pair_shape=(2,)
preference_logits=[0.02 0.03]
loss=0.6807

=== GRPO group baseline ===
rewards.shape=(2, 4)
advantages=
[[-1.2699  0.1154  1.5008 -0.3463]
 [-1.2648 -0.6324  0.6324  1.2648]]
row_mean=[-0.  0.]

=== GRPO clipped objective ===
token_logps.shape=(4, 3)
ratio_first_token=[1.284  1.1052 0.7788 0.9048]
mean_kl=0.018550, loss=0.1626

奖励张量形状是 [prompt, generation] = [2, 4]。每行优势的均值都为零,说明每道题自己形成 baseline。裁剪例子里第一条优势为负而 ratio 为 1.284,第三条优势为正而 ratio 为 0.7788:二者都朝错误方向越界,因此 min 保留未裁剪项以继续惩罚。另两条 ratio 在区间内。这四条回答都没有进入裁剪收益的平台分支,与 3.7 节一致。

bandit 有四个回答动作,真实奖励分别是 [-0.2, 0.3, 1.0, 0.0]。脚本不训练 critic,每轮只采 32 个动作、组内标准化奖励,再用两轮 PPO 式更新复用这组样本:

step  P(a0)   P(a1)   P(a2=best)  P(a3)
   1  0.2288  0.2494  0.2883  0.2335
  20  0.0210  0.0346  0.9179  0.0265
  40  0.0048  0.0064  0.9833  0.0055
  80  0.0026  0.0035  0.9906  0.0033

第 80 步时,最优动作概率从 0.25 升到 0.9906。这个实验展示的是组 baseline 能提供正确梯度,不代表大模型会如此平滑:真实回答空间巨大,奖励噪声、长度与采样温度都会改变结果。

05. 工业级实现对照

本文人工核对了 Hugging Face TRL 的 GRPOTrainer,以 2026-09-13 的主分支提交 cd2c528 为准。生产实现比论文公式多出几层防护:

  • 奖励函数全返回空值的回答会标成 NaN,从组均值中排除,随后把优势置零,避免“不可评分”被误当成零奖励。
  • scale_rewards 支持按 group、batch 或不缩放;多目标奖励还可以“先加权求和再归一化”或“各目标先归一化再求和”。两者表达的偏好并不一样。
  • importance sampling 可以按 token 或 sequence 计算。长回答里 token 级 ratio 连乘会很不稳定,序列级方案则改变了权重粒度。
  • loss 已不止原始 grpo,还包括 bnpo、dr_grpo、dapo 等不同归一化方式。它们主要在回答长度和 batch/token 归一化上修补偏差。
  • 使用 vLLM 生成时,推理引擎与训练模型的 logprob 可能有数值差异,框架提供额外 importance-sampling correction。
  • PEFT 场景可在同一底座上挂训练 adapter 与 reference adapter,减少复制完整模型的显存。

最值得监控的日志不是单独一个 reward,而是:组内 reward 标准差为零的比例、clip ratio、KL、回答长度、每种 reward 的均值与方差。如果 reward 上升同时长度暴涨、KL 激增或同分组变多,训练可能在钻评分规则的空子。

工程上还要区分三种 mask:prompt token 不应进入 completion loss,padding token 不应参与平均,工具调用或环境交互产生的非策略 token 也不该伪装成策略动作。mask 错一个维度,代码仍可能正常运行,但 loss 的分母已经变了。

5.1 一次工业 GRPO update 的数据流

第一步是挑 prompt。随机抽题看似公平,但大量过易题会产生“全对组”,大量过难题会产生“全错组”,两者都没有组内方差。生产系统通常会维护题目通过率、奖励方差和最近采样时间,让训练 batch 更多覆盖当前策略刚好有分歧的区域。

第二步是冻结采样快照。系统保存 $\pi_{old}$ 的版本标识,并为每个 prompt 生成 $G$ 条 completion。若用独立 vLLM 服务生成,必须记录推理引擎返回的 token id、mask 和 logprob;只保存文本再重新 tokenize,特殊 token、空格或聊天模板差异都可能让动作序列错位。

第三步是评分。规则验证器适合数学答案、代码测试和工具任务;神经 reward model 适合帮助性、风格和主观质量。多个奖励合并时要先决定是“先加权再组内归一化”,还是“每种奖励各自归一化再加权”。前者保留权重指定的绝对尺度,后者让每种奖励先取得相近话语权。

第四步计算 group statistics。分布式训练里,同一 prompt 的 $G$ 条回答可能散在不同 GPU 上,均值和标准差必须在完整组上聚合。若每张卡只看本地子组,baseline 会随数据切分改变;同一实验换一个 GPU 数就可能得到不同梯度。

第五步计算当前 policy 与 reference 的 token logprob。old_logprob 必须保持 rollout 时的值,不能在每个优化 epoch 重算;reference_logprob 可以预计算并缓存,也可以通过冻结模型或禁用 LoRA adapter 得到。三种 logprob 看着形状相同,来源却完全不同。

第六步形成 per-token loss。Outcome reward 把回答级优势广播到有效 completion token,再乘 ratio、做 clip、加 KL。随后按 mask 归一化。先对每条序列取平均再对 batch 平均,会让长短回答权重相近;直接对全 batch token 求平均,则长回答贡献更多。两种都能运行,但优化目标不同。

第七步更新并监控。一次 rollout 可做有限个优化 epoch,随后必须重新采样。应同时记录 reward、组内标准差、zero-std 比例、KL、clip fraction、回答长度和吞吐量。只有 reward 上升而这些量失控时,不能把结果叫作训练成功。

5.2 六个不会报错却会训歪的实现细节

分组边界错位。 假设 batch 排列本应是“题目 A 的八条回答、题目 B 的八条回答”,数据加载器却在聚合前打乱了 completion,view(-1, G) 仍然合法,均值却混合了不同题目。最可靠的做法是保留 prompt id,计算优势前断言每个连续组的 id 完全相同,分布式 gather 后再断言每题正好有 $G$ 条记录。

把样本标准差和总体标准差混着用。 NumPy 的 std 默认分母是 $G$,PyTorch 某些接口曾默认用 $G-1$。组很大时差别不明显,$G=2$ 或 4 时会明显改变优势幅度。公式、最小实现和训练框架必须约定同一个定义,日志里也应记录原始 reward,而不是只保留标准化后的值。

先逐卡归一化再全局聚合。 数据并行下每张卡只拿到同一题的一部分回答,如果先在本地求均值,结果取决于回答被分到哪张卡。正确顺序是先收集完整组的 reward,再统一计算统计量,最后切回各进程需要的那一段优势。

生成文本与训练 token 不一致。 推理服务可能自动补聊天模板、合并空白、提前截断,训练端重新编码字符串后得到另一串 token。此时旧策略 logprob 与当前策略 logprob 对应的动作不同,ratio 已经失去意义。rollout 应把 token ids 视作主数据,文本只用于评分和审计;恢复训练时也要保留 tokenizer 与 chat template 版本。

长度归一化悄悄改变偏好。 若每条回答先按自身长度求 token loss 均值,短回答和长回答各占一个样本权重;若整个 batch 按 token 总数求均值,长回答天然贡献更多。再叠加“回答越长越可能碰到一次正确步骤”的 reward,模型可能学会延长推理。应同时画 reward 对长度的散点图,并用长度分桶比较通过率。

奖励器和策略一起漂移。 在线迭代训练 reward model 时,今天的 0.8 与上周的 0.8 可能不是同一尺度。组内归一化能消掉部分仿射漂移,却消不掉排序规则改变。需要保留固定校准集和冻结的旧奖励器,周期性比较新旧排序一致率;否则策略看似持续提高,其实只是追着变化的裁判跑。

最后还要保存可恢复状态:policy、optimizer、scheduler、reference 版本、rollout 生成参数、reward 版本和数据游标缺一不可。只保存 actor 权重可以用于推理,却无法保证训练恢复后仍在优化同一个目标。GRPO 少了 critic checkpoint,但并没有把实验追踪简化成一个模型文件。

06. 代价与边界

删掉 critic,省下的是真实成本。 全参数训练时少一套同规模可训练状态和激活;LoRA 或 ZeRO 下绝对节省会变化,但工程链路仍少了 value target、value loss、value clipping 与 critic checkpoint。

组采样把成本转移到生成端。 GRPO 必须对同一 prompt 采多个回答。推理较长、组大小较大或 reward model 很重时,rollout 可能比 critic 更贵。适合有便宜可验证奖励的数学、代码和工具任务;对主观写作、开放式对话,可靠打分本身就是难题。

组内归一化丢掉跨题绝对难度。 一道题的最高分只有 0.2,另一道题四个答案都超过 0.9,标准化后仍可能产生相似幅度的优势。前者也许是奖励器不可靠或题目不可解,却会得到同等更新权重。

同分会让梯度消失。 二值正确性奖励在策略已经很强或很弱时尤其明显:一组全对或全错,标准差接近零。这时增加组大小、提高采样温度、设计更细奖励或主动挑选处在学习边界的 prompt,比盲目调学习率更有效。

Outcome reward 的信用分配很粗。 一条 2000-token 推理只拿一个终局优势,每个 token 被同方向推动。过程奖励可以细化信号,但会引入步骤标注、奖励模型偏差与“奖励器教模型写格式”的新问题。

reference KL 不能修复坏奖励。 KL 只限制走多远,不判断方向对不对。奖励函数偏爱冗长、套话或特定格式时,较小 KL 只是让投机变慢。

6.1 什么时候选 DPO,什么时候选 GRPO

如果手里已经有高质量 chosen/rejected 数据,生成和人工标注又很贵,DPO 通常是更稳的第一步。它像普通监督训练一样容易批处理,失败时也容易回查是哪一对偏好造成梯度。它尤其适合风格、安全边界、回答格式这类可以提前整理成对的数据。

如果任务能自动验答案,而且策略必须靠探索发现新解法,GRPO 更合适。数学最终答案、代码单元测试、工具调用结果和可执行环境都能提供相对便宜的在线奖励。此时 critic 很难从稀疏终局分数学习每个 token 的价值,组 baseline 的简化更有吸引力。

如果奖励高度主观、每条回答又很长,两者都不会自动解决问题。DPO 受限于离线偏好覆盖,GRPO 受限于奖励模型可靠性和 rollout 成本。常见组合是 SFT 打基础、DPO 吸收稳定的人类偏好、GRPO 在可验证子任务上继续在线探索,最后再用独立评测检查能力回退。

做预算时至少拆成四项:训练模型状态、冻结模型前向、在线生成 token 数、奖励评估次数。只比较“有几个模型”会漏掉 GRPO 的采样账;只比较“每步生成多少 token”又会漏掉 PPO critic 的反向和优化器状态。把四项分别量出来,才能判断删掉 critic 是否真的让当前系统便宜了一个量级。

还有一笔常被忽略的是通信。PPO 的 critic 若与 actor 分开部署,需要在 rollout、价值推理和更新之间搬运 token、mask、value 与 advantage;若与 actor 共卡,又会争夺显存和计算流。GRPO 把 value 请求换成组统计,统计量只是一组标量,通信链更短。可是一旦奖励模型远程部署,$G$ 条回答仍要跨服务传输并等待评分。优化时应测端到端每步墙钟时间,把生成、评分、训练和等待分别打点;单看 GPU 峰值显存,很容易把“省显存”误写成“省总成本”。

评估也要和训练奖励解耦。至少准备一套不参与更新的题目、一个不同实现的验证器,以及人工抽查样本。训练奖励与独立评测同时上升,才说明策略能力可能真的提高;只有训练奖励上涨,最多能证明优化器成功找到了当前评分函数偏爱的输出。对推理模型还应分别统计最终答案、推理有效性、格式合规和长度,避免一个聚合分数掩盖能力回退。

这些记录也是定位回归、比较算法和复现实验的最低证据。

07. 经典论文脉络

  • Proximal Policy Optimization Algorithms(arXiv:1707.06347,2017)提出可多轮复用 rollout 的裁剪替代目标,是 GRPO 概率比与 clip 的直接来源。
  • Direct Preference Optimization(arXiv:2305.18290,2023)利用 KL 正则化 RLHF 的闭式最优策略,把偏好学习变成二分类,训练阶段绕开在线 RL。
  • DeepSeekMath(arXiv:2402.03300,2024)正式提出 GRPO,用同题多回答的组分数替代 value model;7B 实验中 GSM8K 从 82.9% 提升到 88.2%,MATH 从 46.8% 提升到 51.7%。
  • DeepSeek-R1(arXiv:2501.12948,2025)把大规模 GRPO 推到长链推理与可验证奖励场景,也让组内相对优化成为推理模型训练的常用基线。

这些数字来自各论文自己的设置,不能跨模型直接比较。DPO 的优势在训练简单,GRPO 的优势在能跟随当前策略在线探索;算法名本身不决定最终能力。

08. 常见误解

“GRPO 不需要奖励模型。” 错。GRPO 不需要 critic,但必须有奖励信号。奖励可以是神经 reward model、规则验证器、单元测试或多个评分器的组合。

“DPO 和 GRPO 都去掉 value,所以是一回事。” DPO 通过重参数化绕开整套在线 RL;GRPO 仍是 policy-gradient 方法,只把优势 baseline 从 learned value 换成 group statistics。

“组均值就是无偏的真实价值函数。” 组均值只是当前策略、当前 prompt、当前采样温度下的 Monte Carlo baseline。它会随组大小和采样分布变化,不是对所有状态都可查询的 $V(s)$。

“归一化后奖励尺度不重要。” 组内仿射变换会被抵消,但排序错误、同分、异常值和多目标权重仍会改变梯度。reward design 依然是核心工作。

“去掉 critic 就一定便宜十倍。” 不成立。删掉一个同规模可训练模型可能显著降低显存和通信,但 GRPO 增加了组采样。总成本取决于序列长度、组大小、reward 推理、并行方式和是否使用 LoRA。

“clip 能保证 KL 很小。” clip 只截断采样动作上的替代收益;共享参数更新仍可能让未采到 token 的分布大幅变化,所以工程实现仍单独监控或惩罚 KL。

09. 动手验证

在仓库目录运行:

python3 outputs/fundamentals_files/dpo_grpo/code/dpo_grpo_minimal.py

可以做四个改动观察边界:

  1. 把 group_size=32 改成 2,多换几个 seed。预期收敛抖动明显增大,有时一组采不到最优动作。
  2. 把 reward_table 改成四个相同值。预期标准差为零,优势接近零,策略不再学习。
  3. 把最好动作奖励从 1.0 改成 0.31,使它只比第二名高 0.01。预期需要更多样本才能稳定区分。
  4. 把 beta 从 0.02 提到 0.5。预期策略更贴近均匀 reference,最优动作概率上升更慢、上限更低。

再做一个 DPO 对照:交换 policy_chosen 与 policy_rejected,观察 preference logit 变负、loss 变大。它说明 DPO 优化的是相对 reference 的 chosen/rejected 间隔,而不是 chosen 的绝对 logprob。

10. 延伸阅读

读这篇之前建议先看:

读完这篇可以继续看:

附录:完整代码

09 节用到的脚本全文如下(dpo_grpo_minimal.py、make_figures.py)。复制到本地存成同名文件,按各脚本开头的依赖说明准备环境后即可运行。

dpo_grpo_minimal.py

"""DPO 与 GRPO 的最小可运行实现。

运行:python3 dpo_grpo_minimal.py
只依赖 NumPy,不需要模型权重或外部数据。
"""

import numpy as np


def log_sigmoid(x):
    """数值稳定的 log(sigmoid(x))。"""
    return -np.logaddexp(0.0, -x)


def dpo_loss(policy_chosen, policy_rejected, ref_chosen, ref_rejected, beta=0.1):
    """DPO 二分类损失;四个输入都是整条回答的对数概率。"""
    policy_gap = policy_chosen - policy_rejected
    reference_gap = ref_chosen - ref_rejected
    logits = beta * (policy_gap - reference_gap)
    return -log_sigmoid(logits).mean(), logits


def group_advantages(rewards, eps=1e-4):
    """对同一 prompt 的 G 个奖励做组内中心化和标准化。"""
    mean = rewards.mean(axis=1, keepdims=True)
    std = rewards.std(axis=1, keepdims=True)
    return (rewards - mean) / (std + eps)


def grpo_loss(new_logps, old_logps, ref_logps, advantages, mask,
              clip_eps=0.2, beta=0.04):
    """Outcome-supervision GRPO:一条回答的优势广播给它的全部 token。"""
    log_ratio = new_logps - old_logps
    ratio = np.exp(log_ratio)
    clipped_ratio = np.clip(ratio, 1 - clip_eps, 1 + clip_eps)
    advantage_per_token = advantages[:, None]
    surrogate = np.minimum(
        ratio * advantage_per_token,
        clipped_ratio * advantage_per_token,
    )

    # DeepSeekMath 使用的正值 KL 估计器:exp(x) - x - 1,其中 x=log pi_ref-log pi。
    ref_gap = ref_logps - new_logps
    per_token_kl = np.exp(ref_gap) - ref_gap - 1
    per_token_loss = -surrogate + beta * per_token_kl
    sequence_loss = (per_token_loss * mask).sum(axis=-1) / mask.sum(axis=-1)
    return sequence_loss.mean(), ratio, per_token_kl


def softmax(logits):
    shifted = logits - logits.max()
    exp_logits = np.exp(shifted)
    return exp_logits / exp_logits.sum()


def train_group_bandit(steps=80, group_size=32, seed=7):
    """用 GRPO 训练四选一 bandit,展示无 critic 的在线更新。"""
    rng = np.random.default_rng(seed)
    logits = np.zeros(4, dtype=np.float64)
    reference_logits = np.zeros(4, dtype=np.float64)
    reward_table = np.array([-0.2, 0.3, 1.0, 0.0])
    lr, clip_eps, beta = 0.18, 0.2, 0.02

    snapshots = []
    for step in range(steps):
        old_logits = logits.copy()
        old_probs = softmax(old_logits)
        actions = rng.choice(4, size=group_size, p=old_probs)
        rewards = reward_table[actions]
        advantages = (rewards - rewards.mean()) / (rewards.std() + 1e-4)
        old_selected = np.log(old_probs[actions])
        ref_probs = softmax(reference_logits)
        ref_selected = np.log(ref_probs[actions])

        # 同一组 rollout 做两轮 PPO 式复用;old_logprob 始终来自采样快照。
        for _ in range(2):
            probs = softmax(logits)
            new_selected = np.log(probs[actions])
            ratio = np.exp(new_selected - old_selected)
            active = ~(((advantages >= 0) & (ratio > 1 + clip_eps)) |
                       ((advantages < 0) & (ratio < 1 - clip_eps)))

            # d[-min(rA, clip(r)A)]/d log pi,以及采样 KL 项的导数。
            coefficient = -active.astype(np.float64) * ratio * advantages
            ref_gap = ref_selected - new_selected
            coefficient += beta * (1 - np.exp(ref_gap))

            one_hot = np.eye(4)[actions]
            grad_logp = one_hot - probs[None, :]
            grad_logits = (coefficient[:, None] * grad_logp).mean(axis=0)
            logits -= lr * grad_logits

        if step in {0, 19, 39, 79}:
            snapshots.append((step + 1, softmax(logits).copy()))
    return snapshots


if __name__ == "__main__":
    np.set_printoptions(precision=4, suppress=True)

    policy_chosen = np.array([-2.2, -1.7])
    policy_rejected = np.array([-2.8, -2.1])
    ref_chosen = np.array([-2.0, -1.9])
    ref_rejected = np.array([-2.4, -2.0])
    loss_dpo, logits_dpo = dpo_loss(
        policy_chosen, policy_rejected, ref_chosen, ref_rejected
    )
    print("=== DPO ===")
    print(f"pair_shape={policy_chosen.shape}")
    print(f"preference_logits={logits_dpo}")
    print(f"loss={loss_dpo:.4f}")

    rewards = np.array([[0.2, 0.8, 1.4, 0.6], [8.0, 9.0, 11.0, 12.0]])
    advantages = group_advantages(rewards)
    print("\n=== GRPO group baseline ===")
    print(f"rewards.shape={rewards.shape}")
    print(f"advantages=\n{advantages}")
    print(f"row_mean={advantages.mean(axis=1)}")

    old_logps = np.log(np.array([
        [0.30, 0.25, 0.20], [0.18, 0.22, 0.20],
        [0.12, 0.16, 0.20], [0.25, 0.20, 0.18],
    ]))
    new_logps = old_logps + np.array([
        [0.25, 0.25, 0.25], [0.10, 0.10, 0.10],
        [-0.25, -0.25, -0.25], [-0.10, -0.10, -0.10],
    ])
    ref_logps = old_logps - 0.05
    mask = np.ones_like(old_logps)
    one_group_adv = group_advantages(np.array([[0.2, 0.8, 1.4, 0.6]]))[0]
    loss_grpo, ratio, kl = grpo_loss(
        new_logps, old_logps, ref_logps, one_group_adv, mask
    )
    print("\n=== GRPO clipped objective ===")
    print(f"token_logps.shape={new_logps.shape}")
    print(f"ratio_first_token={ratio[:, 0]}")
    print(f"mean_kl={kl.mean():.6f}, loss={loss_grpo:.4f}")

    print("\n=== Online group bandit ===")
    print("step  P(a0)   P(a1)   P(a2=best)  P(a3)")
    for step, probs in train_group_bandit():
        print(f"{step:>4}  " + "  ".join(f"{p:.4f}" for p in probs))

make_figures.py

"""生成 DPO/GRPO 教程的两张解释图。"""

from pathlib import Path

import matplotlib.pyplot as plt
import numpy as np
from matplotlib.patches import FancyBboxPatch


ROOT = Path(__file__).resolve().parents[1]
OUT = ROOT / "figures"
OUT.mkdir(exist_ok=True)

plt.rcParams.update({
    "font.sans-serif": ["PingFang SC", "Arial Unicode MS", "DejaVu Sans"],
    "axes.unicode_minus": False,
    "figure.dpi": 160,
})


def box(ax, x, y, w, h, text, color):
    patch = FancyBboxPatch(
        (x, y), w, h, boxstyle="round,pad=0.02,rounding_size=0.03",
        facecolor=color, edgecolor="white", linewidth=1.5,
    )
    ax.add_patch(patch)
    ax.text(x + w / 2, y + h / 2, text, ha="center", va="center",
            fontsize=12, weight="bold", color="#182238")


def method_map():
    fig, axes = plt.subplots(1, 3, figsize=(14, 4.6), facecolor="#f7f9fc")
    methods = [
        ("PPO-RLHF", [("偏好对", "#dbeafe"), ("奖励模型", "#fde68a"),
                       ("在线采样", "#ddd6fe"), ("Actor + Critic", "#fecaca")],
         "奖励模型 + critic 都要维护"),
        ("DPO", [("离线偏好对", "#dbeafe"), ("二分类损失", "#bbf7d0"),
                 ("Policy + Reference", "#ddd6fe")],
         "省掉显式奖励模型和在线 RL"),
        ("GRPO", [("同题多回答", "#dbeafe"), ("规则/奖励打分", "#fde68a"),
                  ("组内相对优势", "#bbf7d0"), ("Policy + Reference", "#ddd6fe")],
         "保留在线采样,省掉 critic"),
    ]
    for ax, (title, blocks, note) in zip(axes, methods):
        ax.set_xlim(0, 1)
        ax.set_ylim(0, 1)
        ax.axis("off")
        ax.set_title(title, fontsize=18, weight="bold", color="#172554", pad=14)
        gap = 0.08
        h = (0.72 - gap * (len(blocks) - 1)) / len(blocks)
        y = 0.85 - h
        for text, color in blocks:
            box(ax, 0.12, y, 0.76, h, text, color)
            y -= h + gap
        ax.text(0.5, 0.03, note, ha="center", va="bottom", fontsize=11,
                color="#475569")
    fig.suptitle("三种对齐路线省掉的部件不同", fontsize=22, weight="bold",
                 color="#0f172a", y=1.02)
    fig.tight_layout()
    fig.savefig(OUT / "alignment_method_map.png", bbox_inches="tight", facecolor=fig.get_facecolor())
    plt.close(fig)


def group_relative_plot():
    rewards = np.array([[0.2, 0.8, 1.4, 0.6], [8.0, 9.0, 11.0, 12.0]])
    advantages = (rewards - rewards.mean(1, keepdims=True)) / rewards.std(1, keepdims=True)
    fig, axes = plt.subplots(1, 2, figsize=(12, 4.8), facecolor="#f7f9fc")
    colors = ["#60a5fa", "#a78bfa", "#34d399", "#fb7185"]
    x = np.arange(4)
    for row, name in enumerate(["题目 A", "题目 B"]):
        axes[0].bar(x + (row - 0.5) * 0.18, rewards[row], width=0.18,
                    label=name, color=colors[row])
    axes[0].set_title("原始奖励不可跨题直接比较", fontsize=16, weight="bold")
    axes[0].set_xticks(x, [f"回答 {i+1}" for i in x])
    axes[0].set_ylabel("reward")
    axes[0].legend(frameon=False)

    width = 0.34
    axes[1].bar(x - width / 2, advantages[0], width=width, label="题目 A", color="#60a5fa")
    axes[1].bar(x + width / 2, advantages[1], width=width, label="题目 B", color="#a78bfa")
    axes[1].axhline(0, color="#334155", linewidth=1)
    axes[1].set_title("组内标准化只保留相对名次", fontsize=16, weight="bold")
    axes[1].set_xticks(x, [f"回答 {i+1}" for i in x])
    axes[1].set_ylabel("advantage")
    axes[1].legend(frameon=False)
    for ax in axes:
        ax.spines[["top", "right"]].set_visible(False)
        ax.grid(axis="y", alpha=0.18)
    fig.suptitle("GRPO:每道题自己组成一个参照系", fontsize=21, weight="bold",
                 color="#0f172a", y=1.02)
    fig.tight_layout()
    fig.savefig(OUT / "group_relative_advantage.png", bbox_inches="tight", facecolor=fig.get_facecolor())
    plt.close(fig)


if __name__ == "__main__":
    method_map()
    group_relative_plot()
    print(OUT / "alignment_method_map.png")
    print(OUT / "group_relative_advantage.png")


更多 AIGC 论文解读,关注微信公众号「人工智能炼丹君」

每日更新 · 论文精选 · 深度解读 · 技术脉络

微信搜索 人工智能炼丹君 或扫描下方二维码关注

扫码关注「人工智能炼丹君」

0

评论 (0)

取消
粤ICP备2021042327号