RLforge
实验预计 40 分钟

评测:别用训练集骗自己

把评测做成一条能重复执行的流水线,让每次实验的分数可比。

学完这节你能做到

  • 搭一条固定种子、固定采样参数的评测流程
  • 说清 greedy 与采样、pass@1 与 pass@k 分别适合报什么
  • 识别评测污染与「过拟合到测试集」的迹象

从「量一次基线」到「一条流水线」

L0「先量起点」教你测出训练前的分数。这一节把它做成每次实验都跑同一套、结果彼此可比的流水线,并回答一个更难的问题:这个分数有多可信?

区别在于:

L0 做的这一节要做的
目标拿到一个基线数字让所有实验的分数可比
判分器写出来、估了误差版本化、误差纳入结论
采样固定一次固定成配置文件
泛化没做换数据集验证
接进训练没接定期 eval,看趋势

评测三要素

分数可比的前提是这三样逐字相同:

① 固定测试集     哪些题、多少题、什么顺序
② 固定采样参数   temperature / top_p / max_tokens / seed
③ 固定判分器     抽取规则 + 归一化规则,且有版本号

任何一样变了,两次分数就不能直接比。把它们写进配置文件,别散落在命令行里。

# scripts/eval/config.py
from dataclasses import dataclass, asdict
import hashlib, json

@dataclass(frozen=True)
class EvalConfig:
    # ① 测试集
    dataset: str = "openai/gsm8k"
    split: str = "test"
    n_questions: int | None = None      # None = 全量 1319

    # ② 采样
    temperature: float = 0.0            # greedy
    top_p: float = 1.0
    max_tokens: int = 512
    n_samples: int = 1
    seed: int = 0

    # ③ 判分器版本 —— 改了抽取规则就要升版本
    scorer_version: str = "v2"

    def fingerprint(self) -> str:
        """配置指纹。两次评测指纹不同 = 分数不可比。"""
        s = json.dumps(asdict(self), sort_keys=True)
        return hashlib.sha256(s.encode()).hexdigest()[:8]
✓配置指纹是这一节最实用的一个小设计

每次评测把 fingerprint() 和分数一起记下来:

run-01  eval[a3f81c2e]  57.9%
run-08  eval[a3f81c2e]  63.2%     ← 指纹相同,可比
run-09  eval[7d2b04f1]  71.4%     ← 指纹不同!不可比

第三行那种情况非常常见:你顺手把 max_tokens 从 512 提到 1024,分数看起来大涨——其实是评测口径变了。

有指纹,这个错误当场就暴露;没有指纹,它会污染你整份报告。

greedy 报主分,采样报上限

两个口径都要有,各回答不同问题:

口径参数回答什么问题方差
greedytemperature=0, n=1模型稳定能做对多少0(完全可复现)
pass@ktemperature=0.7, n=k模型有能力做对多少有,要多次平均

主分用 greedy,理由是零方差——同样的模型、同样的配置,跑十次得到同一个数。这让你能分辨出 1 个百分点的真实差异(前提是判分器误差也小)。

pass@k 用来看两件事:

  1. 训练前:pass@k 远高于 pass@1 → RL 有空间(L0 讲过)
  2. 训练后:pass@1 向 pass@k 靠近了多少 → RL 做的正是这件事
训练前   pass@1 = 20.9%   pass@8 = 48.3%   差距 27.4
训练后   pass@1 = 63.2%   pass@8 = 71.8%   差距  8.6

读法:RL 把「偶尔能做对」变成了「稳定做对」——
     pass@1 涨了 42 个点,而 pass@8 只涨 23 个点,
     差距从 27 缩到 9。这就是「概率质量往正确路径上挪」的量化证据。
ipass@8 也涨了 23 个点,这说明什么

如果 RL 只是「把已有能力变稳定」,pass@8 应该基本不变。它涨了 23 个点,说明模型确实学到了一些训练前采八次也做不出来的东西。

这是 RL 相对离线方法(DPO 一类)的优势所在:探索能产生数据集里没有的解法。L1「后训练全景」提过这一点,这里是它的量化形态。

判分器的误差要纳入结论

L0 教了怎么估(抽 50 条人工核对)。这一节要做的是把它变成流程的一部分。

def audit(records, n=50, seed=0):
    """抽样人工核对,返回假阳性 / 假阴性计数。"""
    import random
    rng = random.Random(seed)
    sample = rng.sample(records, min(n, len(records)))
    print(f"抽 {len(sample)} 条核对。对每条判断:判分器判对了吗?\n")
    for i, r in enumerate(sample, 1):
        print(f"--- {i}/{len(sample)} ---")
        print(f"标准答案: {r['gold']}    抽到: {r['pred']}    判定: {'对' if r['ok'] else '错'}")
        print(f"回答尾部: ...{r['text'][-180:]}\n")
    # 人工数出来的两个数,填回这里
    return {"false_positive": 0, "false_negative": 1}
!判分器改了就必须重新审计,并且升版本号

典型场景:你发现「答案是 72,也就是 6 打」被误判,于是收紧了抽取规则。

这时候会发生两件事:

  1. 旧分数全部作废 —— 新判分器给出的分数和旧的不可比
  2. 可能引入新的误判 —— 收紧规则会让一些本来判对的变成判错

所以流程是:改判分器 → 升 scorer_version → 重新审计 50 条 → 用新判分器把所有历史 run 重新评一遍。

最后一步听起来烦,但评测很便宜(1319 题 greedy 约 8 分钟),而重评能让你的整份报告重新自洽。判分器是一把尺子,换尺子就要重新量所有东西。

泛化检查:换一个同类数据集

这是最容易省、但最能救你的一步。

只在 GSM8K 上评,你无法区分两种情况:

  • 模型真的学会了小学应用题的推理
  • 模型学会了 GSM8K 这批题的表面模式(题目风格、常见数字范围、答案格式)

换一个同类但独立的数据集测一次就能分开。 候选:

数据集难度说明
SVAMP略易小学应用题,专门测「换个说法还会不会做」
ASDiv相近多样化的小学题
MATH(level 1-2)略难竞赛题的简单档
GSM8K 中文改写相近测语言泛化
rl@forge目标 0/4
  1. 1.用固定配置跑主分,并记录配置指纹
  2. 2.跑 pass@8,与训练前对比看 RL 做了什么
  3. 3.抽 50 条核对,把判分误差纳入结论
  4. 4.换数据集做泛化检查
评测流水线演练。四步:主分、pass@k、审计、泛化。
goals 看目标,hint 要提示。
[rl@forge ~/rlforge]$
help 查看用法 · goals 看目标 · hint 要提示 · ↑↓ 翻历史

把评测接进训练循环

训练中定期评测能让你在浪费几小时之前发现方向不对。但它有成本,eval_interval 要权衡:

评测成本 = 题数 × 单题生成时间
1319 题 greedy ≈ 8 分钟

在 200 步、每步约 45 秒(总计约 2.5 小时)的训练里:

eval_interval评测次数额外耗时占比建议
10 步20 次160 分钟+107%❌ 评测比训练还久
50 步4 次32 分钟+21%⚠️ 偏多
100 步2 次16 分钟+11%✅ 合适
只在结尾1 次8 分钟+5%⚠️ 发现问题太晚
✓用抽样子集做「训练中评测」,全量只在结尾跑

折中方案:

训练中每 25 步  →  200 题子集(约 1.5 分钟),只看趋势
训练结束       →  全量 1319 题,作为正式分数

200 题的统计误差约 ±3.5%,看不出 1 个点的差异,但足够回答「方向对不对」这个问题——而这正是训练中评测的唯一目的。

注意子集要固定(固定 seed 抽样),否则趋势线上的波动里混着「换了一批题」。

结果表怎么写才不骗自己

对比 L2「三轮迭代」那份报告,这里补上三个必备栏位:

## 评测口径
配置指纹 a3f81c2e:GSM8K test 全量 1319 题,greedy(temp=0, seed=0),
max_tokens=512, 判分器 v2
判分误差 2.0%(50 条审计)· 种子波动 ≈3% · 分辨率下限 ≈4 个百分点

| run | 改动 | GSM8K | SVAMP | Δ(GSM8K) | 判定 |
| --- | --- | --- | --- | --- | --- |
| base | 训练前 | 20.85% | 24.33% | — | — |
| 01 | 对照组 | 57.9% | 53.1% | +37.1 | 确定有效 |
| 08 | +动态采样 | 63.2% | 58.7% | +5.3 | 确定更好 |
| 03 | G 8→16 | 58.7% | — | +0.8 | **无差异** |

四条写法上的纪律:

  1. 口径写在表格上方,不要让读者猜
  2. 误差与分辨率下限写出来,否则 +0.8 会被误读成「有提升」
  3. 「判定」列独立于「Δ」列 —— 数字是事实,判定是结论,小于分辨率就写「无差异」
  4. 泛化列(SVAMP)和主列并排 —— 只有主列涨、泛化列不涨时,主列的数字是虚的
×最常见的自欺:只报最好的那个数

你跑了八组实验,其中一组恰好 65%,其余在 57%~63%。报告里只写 65%——这不是结果,这是从噪声里挑了个最大值。

正确做法:报所有 run,或者报「胜出配置用 3 个种子重跑的均值 ± 标准差」。

RL 的方差本来就大,从多次运行里挑最大值是最容易骗过自己的一种做法。

Checkpoint单选

配置指纹(fingerprint)的实际作用是什么?

Checkpoint单选

训练前 pass@1=20.9% / pass@8=48.3%,训练后 pass@1=63.2% / pass@8=71.8%。怎么读这组数?

Checkpoint单选

改了判分器的抽取规则之后,正确的流程是什么?

Checkpoint单选

为什么要换一个数据集(如 SVAMP)再测一次?

这节课的落点

  • 评测三要素必须逐字固定:测试集、采样参数、判分器版本
  • 配置指纹把三者哈希成短串,指纹不同即不可比,能当场暴露口径漂移
  • greedy 报主分(零方差、可复现),pass@k 看上限
  • pass@1 追赶 pass@8 是 RL 核心机制的量化证据;pass@8 也涨说明探索出了新解法
  • 判分误差要审计(50 条)并纳入结论;改判分器要升版本并重评所有历史 run
  • 泛化检查换一个同类数据集;泛化不涨则主列数字是虚的
  • 训练中用固定的 200 题子集看趋势,全量只在结尾跑
  • 结果表四条纪律:口径在上、误差写明、判定列独立于 Δ 列、泛化列并排
  • 最常见的自欺是从多次运行里只报最大值;要么全报,要么报多种子均值 ± 标准差