评测:别用训练集骗自己
把评测做成一条能重复执行的流水线,让每次实验的分数可比。
学完这节你能做到
- 搭一条固定种子、固定采样参数的评测流程
- 说清 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 报主分,采样报上限
两个口径都要有,各回答不同问题:
| 口径 | 参数 | 回答什么问题 | 方差 |
|---|---|---|---|
| greedy | temperature=0, n=1 | 模型稳定能做对多少 | 0(完全可复现) |
| pass@k | temperature=0.7, n=k | 模型有能力做对多少 | 有,要多次平均 |
主分用 greedy,理由是零方差——同样的模型、同样的配置,跑十次得到同一个数。这让你能分辨出 1 个百分点的真实差异(前提是判分器误差也小)。
pass@k 用来看两件事:
- 训练前:pass@k 远高于 pass@1 → RL 有空间(L0 讲过)
- 训练后: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。这就是「概率质量往正确路径上挪」的量化证据。
如果 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 打」被误判,于是收紧了抽取规则。
这时候会发生两件事:
- 旧分数全部作废 —— 新判分器给出的分数和旧的不可比
- 可能引入新的误判 —— 收紧规则会让一些本来判对的变成判错
所以流程是:改判分器 → 升 scorer_version → 重新审计 50 条 → 用新判分器把所有历史 run 重新评一遍。
最后一步听起来烦,但评测很便宜(1319 题 greedy 约 8 分钟),而重评能让你的整份报告重新自洽。判分器是一把尺子,换尺子就要重新量所有东西。
泛化检查:换一个同类数据集
这是最容易省、但最能救你的一步。
只在 GSM8K 上评,你无法区分两种情况:
- 模型真的学会了小学应用题的推理
- 模型学会了 GSM8K 这批题的表面模式(题目风格、常见数字范围、答案格式)
换一个同类但独立的数据集测一次就能分开。 候选:
| 数据集 | 难度 | 说明 |
|---|---|---|
| SVAMP | 略易 | 小学应用题,专门测「换个说法还会不会做」 |
| ASDiv | 相近 | 多样化的小学题 |
| MATH(level 1-2) | 略难 | 竞赛题的简单档 |
| GSM8K 中文改写 | 相近 | 测语言泛化 |
- 1.用固定配置跑主分,并记录配置指纹
- 2.跑 pass@8,与训练前对比看 RL 做了什么
- 3.抽 50 条核对,把判分误差纳入结论
- 4.换数据集做泛化检查
评测流水线演练。四步:主分、pass@k、审计、泛化。 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 | **无差异** |
四条写法上的纪律:
- 口径写在表格上方,不要让读者猜
- 误差与分辨率下限写出来,否则 +0.8 会被误读成「有提升」
- 「判定」列独立于「Δ」列 —— 数字是事实,判定是结论,小于分辨率就写「无差异」
- 泛化列(SVAMP)和主列并排 —— 只有主列涨、泛化列不涨时,主列的数字是虚的
你跑了八组实验,其中一组恰好 65%,其余在 57%~63%。报告里只写 65%——这不是结果,这是从噪声里挑了个最大值。
正确做法:报所有 run,或者报「胜出配置用 3 个种子重跑的均值 ± 标准差」。
RL 的方差本来就大,从多次运行里挑最大值是最容易骗过自己的一种做法。
配置指纹(fingerprint)的实际作用是什么?
训练前 pass@1=20.9% / pass@8=48.3%,训练后 pass@1=63.2% / pass@8=71.8%。怎么读这组数?
改了判分器的抽取规则之后,正确的流程是什么?
为什么要换一个数据集(如 SVAMP)再测一次?
这节课的落点
- 评测三要素必须逐字固定:测试集、采样参数、判分器版本
- 配置指纹把三者哈希成短串,指纹不同即不可比,能当场暴露口径漂移
- greedy 报主分(零方差、可复现),pass@k 看上限
- pass@1 追赶 pass@8 是 RL 核心机制的量化证据;pass@8 也涨说明探索出了新解法
- 判分误差要审计(50 条)并纳入结论;改判分器要升版本并重评所有历史 run
- 泛化检查换一个同类数据集;泛化不涨则主列数字是虚的
- 训练中用固定的 200 题子集看趋势,全量只在结尾跑
- 结果表四条纪律:口径在上、误差写明、判定列独立于 Δ 列、泛化列并排
- 最常见的自欺是从多次运行里只报最大值;要么全报,要么报多种子均值 ± 标准差