Keyboard shortcuts

Press ← or → to navigate between chapters

Press S or / to search in the book

Press ? to show this help

Press Esc to hide this help

后训练方法:奖励提高了,模型学会了吗

想象一下,你请一个小模型做加法。题目是 0+1=,模型回答 10。批改程序在答案里找到了“1”,于是打了满分。

模型算错了,程序却很满意。如果我们继续用这个分数训练,会发生什么?

这不是一个只能停留在想象中的问题。本页会真的训练一个逐字生成数字的小模型,再看看:它能不能提高奖励,能不能答对题,又能不能把学到的东西用到没见过的加数组合上。这三个问题,答案可能很不一样。

前置内容是指令微调、人类反馈与偏好优化和推理能力与可验证奖励。这里不再从头推导每个算法,而是沿着反馈从哪里来、梯度怎样形成、结果怎样检验这条线,读懂后训练的新研究。

资料核查截至 2026 年 10 月 20 日。文中区分原论文的发现、手工算例和本书实际训练结果;小实验没有复现大模型的推理训练,也不具备自然语言思维链。

1. 先看老师教了什么,再看算法叫什么

“后训练”并不是一种固定的损失函数。模型在预训练之后,还可能经历多轮示范学习、偏好比较、奖励优化与能力回归检查。名字越来越多,但反馈信号大致可以从下面几个角度理解。

得到的反馈一个具体例子模型据此调整什么
示范答案给出一道题和一份合格解答提高示范回答中各词元的条件概率
成对偏好同一问题的两份回答,哪份更好调整回答之间的相对倾向
最终结果数值是否正确,程序是否通过测试提高获得好结果的生成轨迹的概率
中间过程某一步是否成立,哪里开始出错为步骤选择、排序或训练提供更细的反馈

这些维度可以组合。过程评分可以用来选择示范,也可以参与搜索或强化学习;偏好既可能由人标注,也可能来自模型或规则。反馈是谁给的、是否准确,与优化算法的名字是不同问题。

例如,DPO 可以直接使用“选中回答、落选回答”的偏好对,不必先单独拟合一个显式奖励模型。它省掉了一部分训练环节,却没有替我们解决偏好是否可靠、数据覆盖是否充分的问题。Rafailov 等,DPO,v1

另外还要问:这些回答是谁、在什么时候生成的? 固定偏好数据反复训练,和让当前模型不断生成新答案再评分,遇到的数据分布可能越来越不同。DPO、在线采样、可验证奖励不是同一层次上的互斥选项;读论文时,把数据来源和优化方式分开记,会清楚得多。

2. 一行奖励代码,也是一份任务说明

回到加法。假设标准答案是整数 1,下面两种检查器有明显区别。

模型输出正常结束“包含 1”完整整数等于 1
1是通过通过
01是通过通过
10是通过不通过
111111否,碰到长度上限通过不通过
空回答是不通过不通过

本次严格规则接受前导零,因为我们评价的是整数值;如果任务还要求标准十进制书写,01 就应该另行判错。这种约定要在训练前确定,不能看到结果后才移动标准。

弱检查器只做子串查找。它把“答案中出现了正确数字”当成“答案正确”,允许模型多写、乱写,甚至没有正常结束。这个漏洞并不意味着模型有人的欺骗意图;优化只是在推动容易获得奖励的输出变得更常见。

更完整的任务同样有契约:程序通过哪一组测试?数值是否带单位?题目允许几份最终答案?到达长度上限算失败、未完成,还是单独处理?这些选择会改变训练信号。

我们将两种奖励都保存下来,但最终评价始终使用同一个严格规则。否则,给每种方法配一个对它有利的检查器,得到的就不是公平比较。

3. 从一组回答,走到一次参数更新

同一道题,先生成八份答案

GRPO 的一个关键思路,是让同一道题的多份回答互相提供参照,而不额外训练一个独立的价值网络。DeepSeekMath 给出了这一方法。本节沿用这个出发点,具体损失归一化和 KL 计算将在下面说明。DeepSeekMath,v1,第 4 节

设一组有 GG 份回答,第 ii 份的奖励为 rir_i。先计算平均奖励与标准差:

rˉ=1G∑i=1Griσr=1G∑i=1G(ri−rˉ)2 \begin{aligned} \bar r&=\frac{1}{G}\sum_{i=1}^{G}r_i\\ \sigma_r&=\sqrt{\frac{1}{G}\sum_{i=1}^{G}(r_i-\bar r)^2} \end{aligned}

这里的标准差除以 GG,与示例代码的 unbiased=False 一致。然后得到组内优势:

Ai=ri−rˉσr+δ A_i=\frac{r_i-\bar r}{\sigma_r+\delta}

δ=10−8\delta=10^{-8} 只是避免除以零的小数。AiA_i 可以理解为“这份回答比同组平均表现好多少”,并不是模型对答案正确性的置信度。

如果八份回答只有一份正确,忽略这个很小的 δ\delta,正确回答的优势为 7\sqrt{7},约为 2.646;其余七份各为 −1/7-1/\sqrt{7},约为 −0.378。若四对四错,优势就是 +1 和 −1。

如果八份全错,所有优势都是 0;八份全对,也一样。相同奖励没有告诉模型,这一组里应该更偏爱哪份回答。 不过,KL 项、其他题的梯度和优化器的历史状态仍可能改变参数,不能直接说“这一轮模型完全不动”。

哪些概率在比较

训练时同时出现三份“模型”,容易看花眼:

名称在本实验中的角色
当前策略正在更新参数、准备变得更好的模型
旧策略生成当前这批回答时的模型;它的输出概率在两次更新间固定
参考策略SFT 结束时冻结的模型,用来约束后续偏离

对回答中的第 tt 个词元,记已有的题目与生成前缀为 hi,th_{i,t},实际生成的词元为 yi,ty_{i,t}。概率比值是:

ρi,t=πθ(yi,t∣hi,t)πold(yi,t∣hi,t) \rho_{i,t}= \frac{\pi_\theta(y_{i,t}\mid h_{i,t})} {\pi_{\mathrm{old}}(y_{i,t}\mid h_{i,t})}

比如,旧策略在当前位置输出“1”的概率是 0.2,当前策略变为 0.24,那么比值为 1.2。这是在同一个前缀下比较同一个词元,不是把完整回答的正确率拿来相除。

我们使用裁剪后的收益:

ci,t=clip⁡(ρi,t, 0.8, 1.2)gi,t=min⁡(ρi,tAi, ci,tAi) \begin{aligned} c_{i,t}&=\operatorname{clip}(\rho_{i,t},\,0.8,\,1.2)\\ g_{i,t}&=\min(\rho_{i,t}A_i,\ c_{i,t}A_i) \end{aligned}

它限制继续沿某些方向增大收益的空间,不是把模型每个概率硬锁在旧值的 80%~120%。尤其要留意优势的正负:当 Ai>0A_i>0 时,比值超过上界后,裁剪分支可能使这一项停止继续获益;当 Ai<0A_i<0 时,起相应作用的是下界。

本实验给同一回答中的有效词元使用相同的最终奖励优势。它并不知道“这一串数字究竟在哪一位开始出错”,这也是结果奖励与过程反馈的区别。

长短回答的词元怎样合在一起

记有效回答词元总数为 NN,包括结束标记,不包括提示和结束后的填充。我们最小化:

L=1N∑有效 (i,t)[−gi,t+βK(hi,t)] \mathcal L= \frac{1}{N}\sum_{\text{有效 }(i,t)} \left[-g_{i,t}+\beta K(h_{i,t})\right]

K(h)K(h) 是当前分布与参考分布在这个前缀下的 KL 散度:

K(h)=∑v∈Vπθ(v∣h)log⁡πθ(v∣h)πref(v∣h) K(h)=\sum_{v\in V} \pi_\theta(v\mid h) \log\frac{\pi_\theta(v\mid h)}{\pi_{\mathrm{ref}}(v\mid h)}

VV 在这里仅包含十个数字和结束标记。词表小,所以每个已采样前缀上可以遍历所有允许词元,精确计算这个分类分布的 KL;前缀本身仍来自旧策略的抽样,这不是对所有可能完整序列的 KL 做精确求和。本次 β=0.03\beta=0.03。

生成、评分、保存旧概率、更新两次,再重新生成——这就是下面实验不断重复的小循环。

4. 真正训练一次:先把实验范围说清楚

一个只有 11,678 个参数的小模型

我们使用单层字符 GRU:嵌入维度 24,隐藏状态维度 48。输入是 2+3=,模型一次输出一个数字,直到产生结束标记,或用满 6 个新词元。词表还含有输入所需的 +、= 和填充标记,但生成时只能选择数字与结束标记。

这确实是自回归模型在逐词元训练,不再是第三十章中四份固定候选之间的概率分配。不过,它没有写计算过程,只输出数字,不能用来评价自然语言推理是否更严密。

数据是所有 0≤a,b≤90\leq a,b\leq9 的加法,共 100 道有序题目。划分时先将 2+3 和 3+2 放进同一个无序组,再分成训练、开发、测试三部分,得到 63、18、19 道题。这样可以避免一个加法仅仅交换顺序,就从训练题混进测试题。

测试检查的是同一数字范围内的新组合,没有检查两位数加法或任意长度算术。

设置训练前固定的选择
初始化种子11、22、33,全部保留
SFT每个种子 200 次更新,每批 16 道训练题
奖励对照同一初始 SFT 模型分别接受严格奖励、子串奖励
RL 采样240 轮,每轮 8 道题,每题 8 份回答
RL 更新每批回答更新两次,Adam 学习率 0.0005
采样温度 1,不做 top-k 或 top-p 截断
评价初始模型及两种末次模型;每题贪心一次、另采样 16 次
选择规则固定末次模型,不挑最好种子,不按测试成绩调参
环境Python 3.12.12、PyTorch 2.14.1,CPU float32、单线程

每条 RL 支路生成 15,360 份训练回答;两个奖励、三个种子,共 92,160 份。两个奖励支路在同一种子下使用相同的题目批次顺序,采样随机数也从相同种子开始;轨迹随参数更新而分开,不能把它们说成始终生成同样的回答。

模型只从训练题获得梯度。开发题与测试题都用于报告,没有参与挑选配置。完整方案在正式训练前写入文件,原始回答、损失、KL、熵、梯度范数和组内奖励是否相同等记录一并保留。

训练题上,奖励与正确率分开了

先固定最后的模型,重新在 63 道训练题上每题采样 16 次。下面是三个种子的均值:

模型阶段完整答案正确包含正确数字平均输出词元
初始 SFT45.6%46.2%2.50
严格奖励68.8%69.1%2.48
子串奖励54.3%69.7%2.87

这里是训练题的再次采样,不能拿来声称泛化能力提升。百分比先按每个种子的固定评价样本计算,再对三个种子平均;不是三次独立数据集实验的置信区间。

图 1:柱形表示三个种子的均值,黑点表示各次实测值。两种末次模型的宽松分数很接近,真正答对的比例却不同。

子串奖励的分数并没有“算错”:它忠实地执行了我们写下的规则。问题恰恰在于,这个规则奖励了我们并不想要的行为。

看几份真实输出就很直观。种子 11 的子串奖励模型在训练题 0+1= 上采样出了 1010 和 10,都正常结束;种子 22 在 0+0= 上输出过 10。它们通过子串检查,却没有完成加法。

两种检查器之间的差距在初始模型上很小,训练后在子串支路明显扩大。这是当前实验中奖励与任务目标不一致的证据。它不支持“所有强化学习都会让模型变坏”,也不说明严格奖励已经足够让模型学会普遍的加法规则。

留出的组合,仍然很难

测试集共 19 道题,每道采样 16 次,合计 304 份。为了不让均值藏掉种子的差异,下面逐项列出:

种子 / 阶段贪心正确采样正确题目覆盖
11 / 初始0/199/3047/19
11 / 严格0/1911/3045/19
11 / 子串0/192/3042/19
22 / 初始0/1915/3049/19
22 / 严格0/1912/3046/19
22 / 子串0/1911/3046/19
33 / 初始1/1917/3049/19
33 / 严格2/1921/3045/19
33 / 子串1/1912/3044/19

所有数字都用严格检查器计算。“题目覆盖”统计每题 16 次中至少答对一次的题数,只是本次固定候选池的观察,不是对无限次采样能力的证明。由于这些回答来自同一模型,每题又重复了 16 次,也不能把 304 份回答当成 304 道独立题目来估计任务泛化的不确定性。

这些结果并不漂亮。种子 11 的严格奖励模型对 0+3= 仍贪心输出 7,对 1+4= 输出 6。从训练与留出表现的巨大差距看,当前设置更像是在熟悉组合上变得自信,尚未表现出可靠的组合泛化。

种子 33 还展示了另一种分离:严格奖励后的采样正确份数从 17 升到 21,但覆盖到的题数从 9 降到 5。更多成功可以集中在更少的题上。单次成功机会和题目覆盖,确实值得一起看;这里只是一次小样本观察,不能据此给整个 RLVR 领域下结论。

本次没有看完结果后增加训练步数、换划分或挑最好种子。若要研究为什么没有泛化,可以另设方案比较数据规模、表示方式与训练预算,并使用新的留出检验。

5. 训练稳定性:先问梯度从哪里来

生成很多回答,也可能没有组内信号

对一道固定的题,假设每次独立采样,成功概率都为 pp,一组共 GG 次。要让二元奖励在组内有差别,必须既有成功,也有失败:

P(有对也有错)=1−pG−(1−p)G P(\text{有对也有错})=1-p^G-(1-p)^G

从 1 中减去“全部成功”和“全部失败”即可。这是固定模型、固定题目下的理想概率算例,不是假定所有题都一样难。

当 G=8G=8,p=0.5p=0.5 时,出现混合奖励的概率约为 99.2%;p=0.01p=0.01 或 0.990.99 时,却都只有约 7.7%。

图 2:太难的题容易八次全错,太容易的题容易八次全对;两者都可能缺少组内对比。本图由公式计算,不是大模型训练曲线。

DAPO 的动态采样会过滤全对或全错的组,再继续采样补齐有对比的题组。这样能改变进入更新的样本组成,也会增加采样工作;不能只数最终保留下来的回答。它还结合了上界裁剪调整、词元级损失归一化和超长回答处理,不能把其中一项改动直接称为完整 DAPO。DAPO,v1,第 3 节

如果同一道题的 pp 始终固定,反复独立抽组,等到第一组混合奖励所需组数的期望,是上述概率的倒数。p=0.01p=0.01 时约为 12.94 组。若 p=0p=0,无论怎样重复,都不会凭空出现正确答案。

真实训练中 pp 会随模型改变,而且“目前没有采样到”不等于“概率恰好为零”。模型也可能从其他题迁移学到东西。因此,这个公式解释的是当前组内反馈为何稀少,不是“难题永远学不会”的定理。本实验没有动态重采样,所有全对、全错的组都保留在记录里。

一个除号,会改变长短回答的权重

设一批只有两份回答,长度分别为 2 和 8。暂时不考虑裁剪与 KL,只看每个词元收益前面的系数:

汇总方式短回答每个词元的系数长回答每个词元的系数
每份回答先平均,再对两份平均1/(2×2)=1/41/(2\times2)=1/41/(2×8)=1/161/(2\times8)=1/16
全部有效词元一起平均1/(2+8)=1/101/(2+8)=1/101/(2+8)=1/101/(2+8)=1/10
用固定总预算 2×82\times8 相除1/161/161/161/16

第一种做法让每份回答占相同总权重,但长回答中的单个词元获得更小的系数。第二种做法让同批有效词元的系数相同,长回答包含更多词元,所以总贡献可能更大。第三种在这一批里与第二种只差全局尺度;跨批次长度变化时,固定分母与实际词元数分母的区别才会继续显现。

系数还要乘优势和相应梯度,不能单凭这张表就判断最终参数移动多远;学习率、优化器也参与其中。

Dr. GRPO 分析了回答长度归一化、题内标准差归一化所引入的权重变化,并提出移除这两项归一化,用常数缩放损失。本书的小模型仍使用组内标准差和全部有效词元平均,所以不是 Dr. GRPO 的复现。Liu 等,v1,第 3 节

哪些曲线要放在奖励旁边

奖励升高只是一个观察。我们还应同时查看:

观察它能帮助发现什么
独立的任务正确率评分漏洞或训练目标与实际目标的偏离
全同奖励组的比例组内优势是否经常为零
输出长度、结束与截断比例分数是否靠拖长回答获得,失败是否只是没写完
词元分布的熵模型是否越来越只选少数输出;高熵也可能只是混乱
与参考策略的 KL偏离初始分布有多大,但小 KL 不保证答案正确
概率比值、梯度范数更新是否剧烈,裁剪是否频繁涉及当前样本

本书记录的 ratio_outside_clip 统计比值是否离开区间,不等同于“裁剪真正切断梯度的比例”,因为后者还取决于优势符号。对指标命名多谨慎一点,可以少作很多错误解释。

裁剪、KL 和梯度范数限制都只是控制更新的手段。奖励规则若有漏洞,这些手段至多改变偏离的速度,不能自动把错误目标变成正确目标。

6. 只奖励最后答案,够不够

加法实验只有几位数字,最终结果容易精确核对。长推理就没有这么省心了。答案碰巧正确,并不代表中间每一步都成立;答案错误,也不意味着前面的步骤全无价值。

过程监督尝试把反馈提前。比如,对于“先算括号,再乘以 4”的题目,可以分别检查括号里的和、后续乘法,以及两步是否衔接。Lightman 等人的 Let’s Verify Step by Step 研究了结果监督与过程监督,并提供逐步标注数据,是理解这条路线的一个起点。原论文 v1、PRM800K 原始数据说明

但把步骤切细,不会自动得到可靠反馈。程序执行器可以精确检查某次算术,学习得到的过程奖励模型则可能误判。如果直接累加每一步得分,模型还可能因为写了更多“看起来正确”的句子而获益;如果取最低分,一个被误判的步骤又可能否定整份好解答。怎样分步、怎样汇总、怎样检查误判,都属于方法。

还要分清“输出文字在逻辑上成立”和“这段文字忠实反映了模型内部计算”。检查前者,不等于证明后者。有关思维链忠实性的讨论,可回看第三十章。

本页的真实实验只用了最终答案奖励,没有训练过程奖励模型。若要加入过程监督,应先定义可检查的步骤,再单独评价检查器,不能只因为模型多写了“让我验证一下”,就认定它获得了更可靠的推理。

7. 新研究在争论什么:更会抽答案,还是更会解题

先把“能力边界”变成一个可测问题

对某道题,有限次采样没有找到正确答案,只能说明这次预算与采样设置下没有找到。正确轨迹可能极少见,也可能被长度、提示或解析规则挡住了。

因此,读到“超越基础模型”时,可以先改写成一个具体问题:使用什么模型、题目、提示、采样预算和验证器时,哪些题从未解出变成了解出? 这样才知道论文的证据实际支持到哪里。

Yue 等人的研究比较基础模型与 RLVR 模型在不同采样次数下的表现,覆盖数学、代码与视觉推理。其多组实验观察到:小预算下成功率提高,大预算下的题目覆盖却可能下降。论文也检查了部分思维链,不能把它概括成“完全不管过程”。这些结果提示我们同时看成功频率与覆盖,但不是所有后训练方法都受同一边界限制的数学证明。v5,第 2、3 节与附录

Wen 等人进一步使用 CoT-Pass@K,要求最终答案与中间推理都通过检查,并报告与只看最终答案不同的观察。其过程检查使用另一个语言模型,也辅以人工核查;三次检查的组合能分析判定差异,却不能免除系统性的误判。这里值得带走的是:换了“成功”的定义,能力比较也可能改变。v2,第 3 节与附录 A.3

这两项研究不是用一句“谁证明了谁错”就能讲清楚的。模型、训练过程、解码与验证方式都需要逐项对照。我们的小加法实验尤其不能裁决这个争论:它连计算过程都没有生成。

2026 年的一条路线:先给难题搭一级台阶

Cai 等人的 Curriculum Reinforcement Learning 先用较多次采样估计题目难度,再给部分边界附近的训练题引入教师解题轨迹,做少量 SFT,随后进行 RL,并重复这一过程。它的变化包括数据选择、教师示范和强化学习,因此不能将改善全部归功于“仅靠结果奖励”。2026-06-21,v1,第 3 节

这项预印本在五种模型设置和三个数学基准上报告 pass@1 与 pass@256。平均趋势支持其方案,但逐项结果并非总胜过普通 RLVR:例如 Qwen3-8B 在 AIME 2025 的 pass@256,课程方案为 33.3%,普通 RLVR 为 36.7%。这里引用的是作者表 1 的结果,本书没有重跑这些大模型。同文第 4 节

它提供了一个可以继续检验的思路:当当前模型在某类题上几乎收不到有效奖励时,增加合适的示范或课程安排,可能比原地重复采样更有用。下一轮比较还应把教师生成、难度探测和 RL 的成本都算进去,并分别检验各部分贡献。

这个思路与前面的概率算例能够接上,却不是从一个公式就推出的大模型训练定律。未来资料更新时,应继续检查新任务、新模型和相同预算下的证据。

8. 运行代码,自己核对奖励和结果

在仓库根目录运行:

python3 src/examples/post_training_frontier.py

它只使用标准库,读取随书保存的记录,重新计算分数。输出如下:

组内出现不同奖励的概率:
p=0.01: 0.077255
p=0.50: 0.992188
p=0.99: 0.077255
留出测试:种子 / 阶段 / 贪心正确 / 采样正确 / 子串接受 / 16次覆盖
11 initial   0/19 9/304 12/304 7/19
11 strict    0/19 11/304 13/304 5/19
11 substring 0/19 2/304 7/304 2/19
22 initial   0/19 15/304 15/304 9/19
22 strict    0/19 12/304 12/304 6/19
22 substring 0/19 11/304 16/304 6/19
33 initial   1/19 17/304 17/304 9/19
33 strict    2/19 21/304 21/304 5/19
33 substring 1/19 12/304 32/304 4/19

计算脚本、固定计划和全部训练与评价记录可以直接下载。单独下载时,把两个 JSON 放在脚本旁的 data/ 目录中。

展开奖励、概率与结果核对代码
"""后训练的奖励契约、组内信号与实测记录;默认只读记录,不训练。"""

from argparse import ArgumentParser
from hashlib import sha256
import json
import math
from pathlib import Path
import random

DATA = Path(__file__).with_name("data")
PLAN = DATA / "post-training-plan.json"
RECORD = DATA / "post-training-record.json"


def digest(path):
    return sha256(Path(path).read_bytes()).hexdigest()


def make_plan():
    groups = [(a, b) for a in range(10) for b in range(a, 10)]
    random.Random(20261020).shuffle(groups)
    split = {}
    for name, selected in zip(("train", "dev", "test"),
                              (groups[:35], groups[35:45], groups[45:])):
        split[name] = sorted([[a, b] for a, b in selected]
                             + [[b, a] for a, b in selected if a != b])
    return {
        "version": 1, "date": "2026-10-20", "task": "single_digit_addition",
        "split": split, "seeds": [11, 22, 33],
        "vocab": "0123456789+=~_", "eos": "~", "pad": "_",
        "embedding": 24, "hidden": 48, "threads": 1,
        "sft_steps": 200, "sft_batch": 16, "sft_lr": .003,
        "rl_steps": 240, "prompts_per_step": 8, "group_size": 8,
        "updates_per_rollout": 2, "rl_lr": .0005, "clip": .2,
        "kl_beta": .03, "max_new_tokens": 6, "temperature": 1.,
        "eval_samples": 16, "gradient_clip": 1.,
        "arms": ["strict", "substring"],
        "loss_reduction": "mean over all valid response tokens, including EOS",
        "advantage": "group population std + 1e-8; no dynamic resampling",
        "kl": "exact categorical KL at old-policy sampled histories",
        "selection": "fixed last checkpoint; no tuning or seed selection",
        "rewards": {
            "strict": "terminated, nonempty ASCII digits, integer equals a+b; leading zeros allowed",
            "substring": "decimal str(a+b) occurs anywhere, even without EOS",
        },
    }


def reward(pair, text, terminated, arm):
    if arm == "substring":
        return int(str(sum(pair)) in text)
    if arm != "strict":
        raise ValueError(arm)
    return int(terminated and bool(text)
               and all(c in "0123456789" for c in text)
               and int(text) == sum(pair))


def contrast_probability(p, group=8):
    return 1 - p**group - (1-p)**group


def advantages(rewards):
    mean = sum(rewards) / len(rewards)
    std = math.sqrt(sum((r-mean)**2 for r in rewards) / len(rewards))
    return [(r-mean)/(std+1e-8) for r in rewards]


def metrics(rows):
    # Each row stores [a, b, visible text, emitted EOS].
    n = len(rows)
    strict = sum(reward(r[:2], r[2], r[3], "strict") for r in rows)
    weak = sum(reward(r[:2], r[2], r[3], "substring") for r in rows)
    return {"n": n, "strict": strict, "substring": weak,
            "strict_rate": strict/n, "substring_rate": weak/n,
            "mean_tokens": sum(len(r[2])+int(r[3]) for r in rows)/n,
            "truncated": sum(not r[3] for r in rows)}


def evaluate(rows, samples):
    assert len(rows) % samples == 0
    covered = 0
    for start in range(0, len(rows), samples):
        group = rows[start:start+samples]
        assert all(row[:2] == group[0][:2] for row in group)
        covered += any(reward(r[:2], r[2], r[3], "strict") for r in group)
    return dict(metrics(rows), covered=covered, questions=len(rows)//samples)


def summarize(record, plan):
    summaries = []
    for run in record["runs"]:
        for stage, ev in run["evaluations"].items():
            row = {"seed": run["seed"], "stage": stage}
            for split, samples in ev.items():
                row[split] = {"greedy": evaluate(samples["greedy"], 1),
                              "sampled": evaluate(samples["sampled"], plan["eval_samples"])}
            summaries.append(row)
    return summaries


def main():
    parser = ArgumentParser(description=__doc__)
    parser.add_argument("--freeze", type=Path)
    args = parser.parse_args()
    if args.freeze:
        # Refuse replacement: experiment rules are saved before training.
        with args.freeze.open("x") as stream:
            json.dump(make_plan(), stream, ensure_ascii=False, indent=2)
            stream.write("\n")
        print(f"已冻结计划:{args.freeze}")
        return
    plan = json.loads(PLAN.read_text())
    record = json.loads(RECORD.read_text())
    assert record["plan_sha256"] == digest(PLAN)
    print("组内出现不同奖励的概率:")
    for p in [.01, .5, .99]:
        print(f"p={p:.2f}: {contrast_probability(p):.6f}")
    print("留出测试:种子 / 阶段 / 贪心正确 / 采样正确 / 子串接受 / 16次覆盖")
    for row in summarize(record, plan):
        g, s = row["test"]["greedy"], row["test"]["sampled"]
        print(f"{row['seed']:2d} {row['stage']:9s} "
              f"{g['strict']}/{g['n']} "
              f"{s['strict']}/{s['n']} {s['substring']}/{s['n']} "
              f"{s['covered']}/{s['questions']}")


if __name__ == "__main__":
    main()

如果想亲自完成训练,使用本书已有的 PyTorch 环境:

uv run --python 3.12 --with-requirements requirements.txt python src/examples/post_training_frontier_train.py --run --out-dir runs/post-training-new

这里不需要下载模型或外部数据。程序从随机参数开始做 SFT,再分成两个奖励支路;结果写入新目录,包括三种子的所有模型参数、训练记录和汇总。输出目录必须尚不存在,避免覆盖前一次实验。

训练脚本与前面的计算脚本应放在同一目录。它保存初始模型与末次模型后,会重新加载,检查固定评价输出一致;这些快照用于恢复推理,没有声称可以从中精确续接 Adam 的训练状态。

没有 --run 时,脚本只输出:

默认不训练。请使用 --run --out-dir runs/post-training-new。
展开字符 GRU、采样和真实后训练代码
"""真实字符 GRU 后训练;显式 --run --out-dir 才训练,CPU 可运行。"""

from argparse import ArgumentParser
from copy import deepcopy
import json
from pathlib import Path
import platform
import random
import time

import torch
from torch import nn
from torch.nn import functional as F

import post_training_frontier as lab


class Adder(nn.Module):
    def __init__(self, plan):
        super().__init__()
        self.vocab = plan["vocab"]
        self.eos = self.vocab.index(plan["eos"])
        self.pad = self.vocab.index(plan["pad"])
        self.embed = nn.Embedding(len(self.vocab), plan["embedding"])
        self.rnn = nn.GRU(plan["embedding"], plan["hidden"], batch_first=True)
        self.head = nn.Linear(plan["hidden"], len(self.vocab))
        allowed = [c in "0123456789~" for c in self.vocab]
        self.register_buffer("allowed", torch.tensor(allowed))

    def forward(self, tokens):
        hidden, _ = self.rnn(self.embed(tokens))
        return self.head(hidden).masked_fill(~self.allowed, -1e9)

    def prompts(self, pairs):
        return torch.tensor([[self.vocab.index(c) for c in f"{a}+{b}="]
                             for a, b in pairs])


def supervised(model, plan, seed):
    rng = random.Random(seed + 1000)
    opt = torch.optim.Adam(model.parameters(), lr=plan["sft_lr"])
    losses = []
    for _ in range(plan["sft_steps"]):
        pairs = rng.choices(plan["split"]["train"], k=plan["sft_batch"])
        targets = [[model.vocab.index(c) for c in str(a+b)+"~"] for a, b in pairs]
        width = max(map(len, targets))
        target = torch.tensor([t+[model.pad]*(width-len(t)) for t in targets])
        inputs = torch.cat([model.prompts(pairs), target[:, :-1]], dim=1)
        logits = model(inputs)[:, 3:]
        loss = F.cross_entropy(logits.reshape(-1, len(model.vocab)), target.flatten(),
                               ignore_index=model.pad)
        opt.zero_grad()
        loss.backward()
        nn.utils.clip_grad_norm_(model.parameters(), plan["gradient_clip"])
        opt.step()
        losses.append(loss.item())
    return losses


@torch.no_grad()
def generate(model, pairs, plan, generator, greedy=False):
    prefix = model.prompts(pairs)
    active = torch.ones(len(pairs), dtype=torch.bool)
    tokens, masks = [], []
    for _ in range(plan["max_new_tokens"]):
        logits = model(prefix)[:, -1] / plan["temperature"]
        if greedy:
            nxt = logits.argmax(-1)
        else:
            nxt = torch.multinomial(logits.softmax(-1), 1, generator=generator).squeeze(1)
        nxt = torch.where(active, nxt, model.pad)
        tokens.append(nxt)
        masks.append(active.clone())
        active &= nxt != model.eos
        prefix = torch.cat([prefix, nxt[:, None]], dim=1)
        if not active.any():
            break
    return torch.stack(tokens, dim=1), torch.stack(masks, dim=1)


def to_rows(model, pairs, tokens, mask):
    rows = []
    for pair, seq, valid in zip(pairs, tokens.tolist(), mask.tolist()):
        ids = [tok for tok, use in zip(seq, valid) if use]
        terminated = bool(ids and ids[-1] == model.eos)
        visible = ids[:-1] if terminated else ids
        rows.append([*pair, "".join(model.vocab[t] for t in visible), terminated])
    return rows


def distribution(model, pairs, tokens):
    inputs = torch.cat([model.prompts(pairs), tokens[:, :-1]], dim=1)
    return model(inputs)[:, 3:].log_softmax(-1)


def reinforce(initial, reference, plan, seed, arm):
    model = deepcopy(initial)
    rng = random.Random(seed+2000)
    generator = torch.Generator().manual_seed(seed+3000)
    opt = torch.optim.Adam(model.parameters(), lr=plan["rl_lr"])
    history = []
    for step in range(plan["rl_steps"]):
        prompts = rng.choices(plan["split"]["train"], k=plan["prompts_per_step"])
        pairs = [pair for pair in prompts for _ in range(plan["group_size"])]
        tokens, mask = generate(model, pairs, plan, generator)
        rows = to_rows(model, pairs, tokens, mask)
        rewards = torch.tensor([lab.reward(r[:2], r[2], r[3], arm) for r in rows],
                               dtype=torch.float32).reshape(-1, plan["group_size"])
        std = rewards.std(-1, unbiased=False, keepdim=True)
        advantage = ((rewards-rewards.mean(-1, keepdim=True))/(std+1e-8)).flatten()
        with torch.no_grad():
            old_logp = distribution(model, pairs, tokens).gather(-1, tokens[..., None]).squeeze(-1)
            ref_logdist = distribution(reference, pairs, tokens)
        updates = []
        for _ in range(plan["updates_per_rollout"]):
            logdist = distribution(model, pairs, tokens)
            logp = logdist.gather(-1, tokens[..., None]).squeeze(-1)
            ratio = (logp-old_logp).exp()
            gain = torch.minimum(ratio*advantage[:, None],
                                 ratio.clamp(1-plan["clip"], 1+plan["clip"])*advantage[:, None])
            kl = (logdist.exp()*(logdist-ref_logdist)).sum(-1)
            loss = ((-gain+plan["kl_beta"]*kl)*mask).sum()/mask.sum()
            entropy = -(logdist.exp()*logdist).sum(-1)
            opt.zero_grad()
            loss.backward()
            grad = nn.utils.clip_grad_norm_(model.parameters(), plan["gradient_clip"])
            opt.step()
            updates.append({"loss": loss.item(), "gradient_norm_before_clip": grad.item(),
                            "kl": (kl*mask).sum().item()/mask.sum().item(),
                            "entropy": (entropy*mask).sum().item()/mask.sum().item(),
                            "ratio_outside_clip": (((ratio-1).abs()>plan["clip"])*mask).sum().item()/mask.sum().item()})
        history.append({"step": step+1, "rows": rows,
                        "zero_variance_groups": int((std == 0).sum()), "updates": updates})
        if (step+1) % 80 == 0:
            metrics = lab.metrics(rows)
            print(f"seed={seed} arm={arm} step={step+1} "
                  f"strict={metrics['strict_rate']:.3f} weak={metrics['substring_rate']:.3f}", flush=True)
    return model, history


@torch.no_grad()
def evaluate_model(model, plan, seed):
    result = {}
    for split_index, (split, pairs) in enumerate(plan["split"].items()):
        generator = torch.Generator().manual_seed(seed+4000+split_index)
        greedy, mask = generate(model, pairs, plan, generator, greedy=True)
        sampled_pairs = [pair for pair in pairs for _ in range(plan["eval_samples"])]
        sampled, smask = generate(model, sampled_pairs, plan, generator)
        result[split] = {"greedy": to_rows(model, pairs, greedy, mask),
                         "sampled": to_rows(model, sampled_pairs, sampled, smask)}
    return result


def run(plan_path, out):
    out.mkdir(parents=True, exist_ok=False)
    plan = json.loads(plan_path.read_text())
    assert plan == lab.make_plan(), "当前实现只支持已固定的教学计划"
    torch.set_num_threads(plan["threads"])
    torch.use_deterministic_algorithms(True)
    result = {"plan_sha256": lab.digest(plan_path),
              "source_sha256": {p.name: lab.digest(p) for p in [Path(__file__), Path(lab.__file__)]},
              "python": platform.python_version(), "torch": torch.__version__,
              "platform": platform.platform(), "device": "cpu", "dtype": "float32", "runs": []}
    start = time.monotonic()
    for seed in plan["seeds"]:
        torch.manual_seed(seed)
        initial = Adder(plan)
        result["parameters"] = sum(p.numel() for p in initial.parameters())
        losses = supervised(initial, plan, seed)
        reference = deepcopy(initial).requires_grad_(False)
        row = {"seed": seed, "sft_loss": losses, "evaluations": {}, "training": {}, "checkpoints": {}}
        row["evaluations"]["initial"] = evaluate_model(initial, plan, seed)
        models = {"initial": initial}
        for arm in plan["arms"]:
            model, history = reinforce(initial, reference, plan, seed, arm)
            row["training"][arm] = history
            row["evaluations"][arm] = evaluate_model(model, plan, seed)
            models[arm] = model
        for stage, model in models.items():
            path = out / f"seed-{seed}-{stage}.pt"
            torch.save({"plan": plan, "state_dict": model.state_dict()}, path)
            restored = Adder(plan)
            restored.load_state_dict(torch.load(path, weights_only=True)["state_dict"])
            assert evaluate_model(restored, plan, seed) == row["evaluations"][stage]
            row["checkpoints"][stage] = {"file": path.name, "sha256": lab.digest(path), "reload_equal": True}
        result["runs"].append(row)
        (out/"record.json").write_text(json.dumps(result, ensure_ascii=False)+"\n")
    result["wall_seconds"] = time.monotonic()-start
    (out/"record.json").write_text(json.dumps(result, ensure_ascii=False)+"\n")
    (out/"summary.json").write_text(json.dumps(lab.summarize(result, plan), indent=2)+"\n")
    print(f"完成:{out},{result['wall_seconds']:.1f} 秒。", flush=True)


def main():
    parser = ArgumentParser(description=__doc__)
    parser.add_argument("--run", action="store_true")
    parser.add_argument("--plan", type=Path, default=lab.PLAN)
    parser.add_argument("--out-dir", type=Path)
    args = parser.parse_args()
    if not args.run:
        print("默认不训练。请使用 --run --out-dir runs/post-training-new。")
        return
    if args.out_dir is None:
        parser.error("训练需要 --out-dir,且目录不能已存在")
    run(args.plan, args.out_dir)


if __name__ == "__main__":
    main()

重跑结果会受依赖、硬件和数值实现影响。固定种子与版本有助于复核,但并不保证跨环境逐字相同。检查脚本还独立枚举了八次二元结果的全部组合,并核对分组隔离、奖励边界、逐前缀概率与 EOS 掩码。

9. 试着把问题再推近一步

  1. 标准答案是 1。1010 正常结束时,两种奖励分别是多少?如果只生成 1 就碰到长度上限呢?
  2. 八份回答四对四错时,组内平均奖励、标准差与优势各是多少?若八份全错,又怎样?
  3. 当单次成功概率为 0.01、组大小为 8 时,平均等待一组混合奖励需要多少组?这个数为什么不是整个训练的固定成本?
  4. 两份回答长 2 和 8,为什么“每份先平均”与“所有词元一起平均”不是同一个目标?
  5. 正优势为 1、概率比值为 1.3 时,本页的裁剪收益是多少?如果优势为 −1 呢?
  6. 为什么要把 2+3 与 3+2 放在同一数据划分?这样做是否已经保证测试完全独立于训练知识?
  7. 种子 33 的严格奖励模型,采样正确份数上升,题目覆盖却下降。请用两道题、每题四次回答,构造一个更小的例子。
  8. 已经精确算出了每个采样前缀上的分类 KL,为什么仍不能称为精确计算完整序列分布的 KL?
  9. 课程方案加入教师示范后提高了成绩。若要单独判断示范和 RL 的作用,至少应增加哪些对照?
  10. 设计下一轮奖励实验:如果任务要求标准整数写法,怎样修改 01 的判定?如何保留本轮结果,使新规则不会悄悄覆盖旧结论?
参考答案与思路
  1. 1010 的严格奖励为 0,子串奖励为 1。1 未正常结束时仍分别为 0、1;严格规则把完整性纳入契约。
  2. 四对四错时均值为 0.5,标准差为 0.5;忽略防除零小数,优势为 +1、−1。全错时均值与标准差都是 0,代码计算出的所有优势为 0。
  3. 约为 1/0.077255=12.941/0.077255=12.94 组。它假设同题、固定成功概率、独立抽组;真实训练题目会变,参数会变,采样成本还与长度有关。
  4. 前者每个短回答词元的系数为 1/4,长回答为 1/16;后者均为 1/10。它们改变了同批词元的相对权重,不只是乘一个统一常数。
  5. 正优势时为 min⁡(1.3,1.2)=1.2\min(1.3,1.2)=1.2;负优势时为 min⁡(−1.3,−1.2)=−1.3\min(-1.3,-1.2)=-1.3。所以“比值出界”不等于该项梯度已经被裁掉。
  6. 避免同一无序组合换个顺序就进入测试。它仍共享相同数字和加法任务;这里检查组合泛化,不是与训练知识完全无关的任务。
  7. 原来两道题各四次中答对一次,共两次正确、覆盖两题;后来第一题四次全对、第二题全错,共四次正确、只覆盖一题。
  8. 我们只对已访问的前缀遍历词表,没有遍历所有可能前缀;而且前缀由旧策略抽样。局部分类求和精确,不代表整体轨迹分布的期望精确。
  9. 例如仅示范、仅 RL、示范加 RL,以及随机选示范与按难度选示范。尽量匹配数据和完整计算成本,保留不同种子,使用相同留出评价。
  10. 可以额外要求输出等于 str(a+b)。另存新版本计划、检查器和全部原始输出,分别标明旧规则与新规则的指标;如果用新规则训练,则应是另一轮实验。

10. 从这些原始文献继续读

文献与所读版本本页着重阅读的问题
DPO,2023,v1怎样直接使用偏好对,哪些数据问题仍然存在
Let’s Verify Step by Step,2023,v1结果反馈与逐步反馈有何区别
DeepSeekMath,2024,v1同题多份回答怎样提供组内参照
DAPO,2025-03-18,v1动态采样、裁剪、损失归一化与超长回答处理
Understanding R1-Zero-Like Training,2025-03-26,v1长度与题内标准差怎样改变优化权重
Does Reinforcement Learning Really Incentivize…,2025-11-24,v5不同采样预算下的成功率与覆盖
RLVR Implicitly Incentivizes Correct Reasoning,2025-10-02,v2检查中间推理后,能力比较怎样改变
Curriculum Reinforcement Learning…,2026-06-21,v1难度探测、教师示范和 RL 怎样组合

可以先带着两个问题回到原文:论文奖励的究竟是什么?论文最后独立检验的又是什么?把这两栏写清楚,再看优化细节和成绩表,很多看似矛盾的结果就有了可以继续追查的线索。