先探测、再筛题:用 Jeffreys 后验给 RL 训练任务估难度

受 ThinkingBox 训练博客启发,用模拟数据演示:训练前探测每个任务的成功率、用 Jeffreys 后验分桶并去掉过难和过易的任务,能显著减少 GRPO 中奖励全相同、没有学习信号的组。

已验证 Python 3.12.15 耗时 不到 0.1s 运行于 2026-10-24 22:09 UTC
结论

模拟中按 Jeffreys 后验筛题后,零方差组从 48.9% 降到 19.1%,保留 49.8% 的任务

检查 4/4 通过
  • 筛选后零方差(无学习信号)组的比例低于筛选前
  • Jeffreys 后验均值不会给出 0 或 1 这样的极端估计
  • 筛选只去掉两端,保留了相当一部分任务
  • 被丢掉的任务多数确实是过难或过易的
实验指标(%)
保留任务比例49.8%
筛选前零方差组比例48.9%
筛选后零方差组比例19.1%
被丢任务中真实落在两端的比例88.4%
最大似然估计为 0 或 1 的任务比例50.2%

数值取自 GitHub Actions 实际运行输出中的 METRIC 行

以上内容从实际运行输出中提取,完整输出见下方。

python main.py
分桶结果: {'drop-hard': 172, 'hard': 124, 'medium': 37, 'easy': 138, 'drop-easy': 129}
METRIC: 保留任务比例 = 49.8 %
METRIC: 筛选前零方差组比例 = 48.9 %
METRIC: 筛选后零方差组比例 = 19.1 %
METRIC: 被丢任务中真实落在两端的比例 = 88.4 %
METRIC: 最大似然估计为 0 或 1 的任务比例 = 50.2 %
[PASS] 筛选后零方差(无学习信号)组的比例低于筛选前
[PASS] Jeffreys 后验均值不会给出 0 或 1 这样的极端估计
[PASS] 筛选只去掉两端,保留了相当一部分任务
[PASS] 被丢掉的任务多数确实是过难或过易的
RESULT: 模拟中按 Jeffreys 后验筛题后,零方差组从 48.9% 降到 19.1%,保留 49.8% 的任务

目的

Microsoft 与 Hugging Face 的博客 Turning Database Grading into RL Rewards: Post-Training Agents with ThinkingBox 在用 GRPO 训练 Agent 前,先让基座策略对每个候选任务反复试做,用 Jeffreys 后验 Beta(k + ½, n − k + ½) 估计成功率,按 0.08 / 0.40 / 0.60 / 0.90 分桶,丢掉几乎做不出(< 0.08)和几乎总能做对(≥ 0.90)的任务,训练池在整个训练过程中固定。

原因是:GRPO 一组 rollout 的奖励如果全是 0 或全是 1,组内没有差异,就没有学习信号。本实验用模拟数据演示「先探测、再筛题」能减少多少这样的零方差组。

注意:任务的「真实成功率」由脚本按固定随机种子构造,只演示机制,不复现文章中的任何数字。

步骤

  1. 构造 600 个候选任务,真实成功率来自偏向两端的分布(很多题太难或太简单)
  2. 每个任务探测 8 次,记成功次数 k
  3. 用 Jeffreys 后验均值 (k + 0.5) / (n + 1) 估计难度(取均值是本实验的选择,原文没有说明用后验的哪个统计量),按上述边界分桶,去掉两端
  4. 分别从「全部任务」和「筛选后任务」中抽 4000 组、每组 8 条 rollout,统计奖励全 0 或全 1 的组所占比例
  5. 检查被丢掉的任务中,有多少真实成功率确实落在两端

运行:

python main.py

程序打印 METRIC: 指标、[PASS]/[FAIL] 检查和最后一行 RESULT:,任一检查失败时以非零状态退出。

预期结果

  • 筛选后零方差组的比例明显低于筛选前
  • Jeffreys 后验均值永远不会是 0 或 1(而直接用 k/n 会有大量 0 和 1)
  • 被丢掉的任务多数确实过难或过易,但不是全部——只探测 8 次会有误判

启示与可以继续尝试

  • 探测 8 次时,k = 0 的后验均值是 0.5/9 ≈ 0.056(< 0.08),k = 8 是 8.5/9 ≈ 0.944(≥ 0.90),所以这组边界在 n = 8 时等价于「去掉全错和全对的任务」;把 N_PROBE 改成 20 再看分桶如何变化
  • 把 GROUP 调大,零方差组会变少,但每步的 rollout 成本更高
  • 原文还观察到 RL 后 pass@1 上升、pass@20 略降,即模型「更稳定但不更广」——筛掉太难的题可能是原因之一,可以在模拟里给「太难」的题单独记账思考这一点
  • 文章用 DAPO 风格把零方差组替换掉;筛题是在训练前做,两者可以叠加

内网复现

  • 无需任何第三方依赖,只用 Python 3.8+ 标准库,无需联网,可直接在内网运行
  • 把 main.py 拷贝到内网机器执行 python main.py 即可
  • 如要在真实任务上复现,需要能反复运行基座模型的推理环境和任务的自动评分器;ThinkingBox 的代码在 GitHub(github.com/microsoft/thinkingbox-training),需外网或内网 Git 镜像获取,本实验不依赖这些

代码

main.py124 行
"""先探测、再筛题:用 Jeffreys 后验给 RL 训练任务估难度,能少掉多少「无梯度」的组?

受 Microsoft / Hugging Face 博客 "Turning Database Grading into RL Rewards: Post-Training
Agents with ThinkingBox" 启发。文章的做法:训练前用基座策略对每个候选任务反复试做,
用 Jeffreys 后验 Beta(k + 1/2, n - k + 1/2) 估计成功率,按 0.08 / 0.40 / 0.60 / 0.90
分桶,丢掉几乎做不出(< 0.08)和几乎总能做对(>= 0.90)的任务;GRPO 中一组 rollout
奖励全相同时没有学习信号,这类任务就是在浪费算力。

本脚本全部为模拟数据(固定随机种子),只演示机制,不复现文章任何数字:
- 构造 600 个候选任务,「真实」成功率来自一个偏向两端的分布(很多题太难或太简单)
- 每个任务用基座策略探测 n=8 次,得到成功次数 k
- 用 Jeffreys 后验均值 (k + 0.5) / (n + 1) 作为难度估计(取均值是本实验的选择,
  原文没有说明取后验的哪个统计量),按上述边界分桶并筛掉两端
- 对比筛选前后:模拟 GRPO 每组 8 条 rollout 时,奖励全 0 或全 1(零方差)组的比例
"""
from __future__ import annotations

import random
import sys

SEED = 25
N_TASKS = 600
N_PROBE = 8          # 每个任务的探测次数
GROUP = 8            # GRPO 每组 rollout 数(与原文一致,仅作设定)
N_GROUPS = 4000      # 模拟抽取的训练组数
BOUNDS = (0.08, 0.40, 0.60, 0.90)

checks: list[tuple[bool, str]] = []


def check(ok: bool, msg: str) -> None:
    checks.append((ok, msg))
    print(f"[{'PASS' if ok else 'FAIL'}] {msg}")


def sample_true_p(rng: random.Random) -> float:
    """偏向两端的真实成功率:约 35% 很难、30% 很简单、35% 中等。"""
    r = rng.random()
    if r < 0.35:
        return rng.betavariate(0.6, 12)   # 很难
    if r < 0.65:
        return rng.betavariate(12, 0.6)   # 很简单
    return rng.betavariate(2.5, 2.5)      # 中等


def jeffreys_mean(k: int, n: int) -> float:
    return (k + 0.5) / (n + 1.0)


def bucket(p_hat: float) -> str:
    lo, mid_lo, mid_hi, hi = BOUNDS
    if p_hat < lo:
        return "drop-hard"
    if p_hat < mid_lo:
        return "hard"
    if p_hat < mid_hi:
        return "medium"
    if p_hat < hi:
        return "easy"
    return "drop-easy"


def zero_variance_rate(rng: random.Random, pool: list[float]) -> float:
    zero = 0
    for _ in range(N_GROUPS):
        p = rng.choice(pool)
        s = sum(1 for _ in range(GROUP) if rng.random() < p)
        if s == 0 or s == GROUP:
            zero += 1
    return zero / N_GROUPS


def main() -> int:
    rng = random.Random(SEED)
    tasks = []
    for _ in range(N_TASKS):
        p = sample_true_p(rng)
        k = sum(1 for _ in range(N_PROBE) if rng.random() < p)
        tasks.append({"p": p, "k": k, "jeff": jeffreys_mean(k, N_PROBE), "mle": k / N_PROBE})

    counts: dict[str, int] = {}
    for t in tasks:
        t["bucket"] = bucket(t["jeff"])
        counts[t["bucket"]] = counts.get(t["bucket"], 0) + 1
    print("分桶结果:", {b: counts.get(b, 0) for b in ["drop-hard", "hard", "medium", "easy", "drop-easy"]})

    kept = [t for t in tasks if not t["bucket"].startswith("drop")]
    keep_rate = len(kept) / len(tasks) * 100

    all_pool = [t["p"] for t in tasks]
    kept_pool = [t["p"] for t in kept]
    zv_all = zero_variance_rate(random.Random(SEED + 1), all_pool) * 100
    zv_kept = zero_variance_rate(random.Random(SEED + 1), kept_pool) * 100

    mle_extreme = sum(1 for t in tasks if t["mle"] in (0.0, 1.0)) / len(tasks) * 100
    jeff_min = min(t["jeff"] for t in tasks)
    jeff_max = max(t["jeff"] for t in tasks)

    # 被丢掉的任务里,真实成功率确实落在两端的比例(筛选「准不准」)
    dropped = [t for t in tasks if t["bucket"].startswith("drop")]
    truly_extreme = sum(1 for t in dropped if t["p"] < 0.08 or t["p"] >= 0.90)
    drop_precision = truly_extreme / len(dropped) * 100 if dropped else 0.0

    print(f"METRIC: 保留任务比例 = {keep_rate:.1f} %")
    print(f"METRIC: 筛选前零方差组比例 = {zv_all:.1f} %")
    print(f"METRIC: 筛选后零方差组比例 = {zv_kept:.1f} %")
    print(f"METRIC: 被丢任务中真实落在两端的比例 = {drop_precision:.1f} %")
    print(f"METRIC: 最大似然估计为 0 或 1 的任务比例 = {mle_extreme:.1f} %")

    check(zv_kept < zv_all, "筛选后零方差(无学习信号)组的比例低于筛选前")
    check(0.0 < jeff_min and jeff_max < 1.0, "Jeffreys 后验均值不会给出 0 或 1 这样的极端估计")
    check(10.0 < keep_rate < 90.0, "筛选只去掉两端,保留了相当一部分任务")
    check(drop_precision > 50.0, "被丢掉的任务多数确实是过难或过易的")

    failed = [m for ok, m in checks if not ok]
    if failed:
        print(f"RESULT: 有 {len(failed)} 项检查未通过")
        return 1
    print(f"RESULT: 模拟中按 Jeffreys 后验筛题后,零方差组从 {zv_all:.1f}% 降到 {zv_kept:.1f}%,保留 {keep_rate:.1f}% 的任务")
    return 0


if __name__ == "__main__":
    sys.exit(main())
requirements.txt1 行
# 无第三方依赖,只使用 Python 标准库
Esc
↑ ↓ 选择Enter 打开

键盘快捷键

j / k
下一条 / 上一条
o
展开或收起当前条目
Enter
打开当前条目详情
c
复制当前条目链接
s
收藏 / 取消收藏当前条目
/ 或 ⌘K
搜索
1 2 3
切换密度:标题 / 摘要 / 完整
t
切换主题
?
显示本帮助