跳到主要内容

数据截至 (上游 commit 1416fa0cf215)

03 · 评测管线与数据卫生

这一章讲什么: 训练配方之外,「数字可信」需要的两套基础设施——评测侧(lighteval 任务注册、训练中途自动评测、pass@1 的采样方差)和数据侧(8-gram 去污染、pass rate 过滤)。这些是不性感但决定复现成败的部分。


1. 它要解决的小问题

复现 R1 的 benchmark 数字,撞上三个脏问题:

  1. 方差:AIME 2024 只有 30 道题,单次采样的 pass@1 波动极大——论文数字到底是多少次采样的平均,DeepSeek 没写(README.md:529-539);
  2. 污染:从网上爬的训练题很可能混着 benchmark 原题,分数虚高;
  3. 难度失配:喂给 GRPO 的题如果全对或全错,组内奖励无差异,梯度为零——白训。

这一章的三个机制各管一个。

2. 评测编排:把「跑 benchmark」做成一键作业

2.1 任务注册表

src/open_r1/utils/evaluation.py:52-59 用一张字典固定了 R1 复现的评测清单:

注册名lighteval 任务套件
math_500math_500lighteval 内置
aime24 / aime25同名lighteval 内置
gpqagpqa:diamondlighteval 内置
lcb / lcb_v4lcb:codegeneration(_v4)extended(自定义)

register_lighteval_task(evaluation.py:27-49)把任务名拼成 lighteval 的 suite|task|fewshot|0 格式。真正的评测引擎是 lighteval + vLLM 后端,本仓库只做编排;lighteval 本身见 lighteval teardown

2.2 两个「土但鲁棒」的资源推断

提交评测作业前要决定用几张卡、要不要张量并行。实现里有两个启发式:

  • 参数量:先读 Hub 上 safetensors 元数据;读不到就用正则从 repo id 里抠 7b8x7b 这种字样换算(src/open_r1/utils/hub.py:89-118get_param_count_from_repo_id)。≥30B 就开 TP(evaluation.py:79-83)。
  • GPU 数:vLLM 要求「注意力头数能被 GPU 数整除、且 64 能被 GPU 数整除」,get_gpu_count_for_vllm(hub.py:121-132)从 8 往下递减直到两个整除都成立。

evaluation.py:82 还有一行诚实的注释 num_gpus = 2 # Hack while cluster is full——复现工程的真实面貌。

2.3 训练中途自动评测:回调闭环

这是评测管线最妙的一环,时序如下:

trainer 到 save 点


PushToHubRevisionCallback.on_save (callbacks.py:47)
│ ① 造 dummy_config:分支名 = {revision}-step-{N:09d}(callbacks.py:59-64)
│ ② push_to_hub_revision 异步上传(run_as_future=True,hub.py:57-64)
│ —— 训练不等上传

上传完成回调(仅当有 Slurm,callbacks.py:70-77)
run_benchmark_jobs → 每个 benchmark sbatch 一个 lighteval 作业
→ 结果自动传回 open-r1/open-r1-eval-leaderboard

也就是说:训练还在跑,每个 checkpoint 的 AIME/MATH 分数已经自动排队在算了。 配方行不行,不用等训完才知道。注意一个细节:callbacks.py:57-58 的注释警告不能用 dataclasses.replace 造配置——会破坏 accelerator 的分布式状态,所以才手写 DummyConfig 逐字段拷贝。这是踩过坑的代码。

2.4 system prompt 过墙的笨办法

训练配置里的 system_prompt 含换行和特殊字符,直接塞进 sbatch 命令行会碎。解法是 base64 编码后作为第 7 个参数传入(evaluation.py:97-101),在 slurm/evaluate.slurm:57--system-prompt "$(echo "$7" | base64 --decode)" 解回。不优雅,但对「穿过 sbatch 命令行」这个场景是对症的最简方案。

3. pass@1 方差:响应数按 benchmark 配

DeepSeek 论文用每题 4~64 次采样估 pass@1,但没写每个 benchmark 用几次。Open R1 的约定(README.md:531-536):

Benchmark每题响应数原因
AIME 202464只有 30 题,方差最大
MATH-5004500 题,题量本身压方差
GPQA Diamond8
LiveCodeBench16

采样参数也全局固定:max_new_tokens:32768, temperature:0.6, top_p:0.95(如 slurm/evaluate.slurm:38 的 MODEL_ARGS)。README 明说(README.md:539):响应数的选择差异,大概率就是自家数字和 DeepSeek 报告值之间那 1-3 个标准差的来源之一。评测协议本身(采样数、温度、上下文长度)是结果的一部分——这是推理模型评测和普通 LLM 评测最大的不同。

4. 去污染:8-gram 重叠检测

4.1 思路

训练集里若混进 benchmark 原题,分数就是背答案。scripts/decontaminate.py 采用 s1 论文(arXiv 2501.19393)的方法:把题目规范化后切成 8 个词一组的 n-gram,训练题与任一评测题共享任何一个 8-gram,就算污染

4.2 原理演示

# 示意,非源码
lookup = set()
for question in benchmark_questions:
text = question.lower().split() # 规范化
for i in range(len(text) - 7):
lookup.add(" ".join(text[i:i+8])) # 每个 8-gram 进表

for train_problem in train_set:
if any(ngram in lookup for ngram in ngrams_of(train_problem)):
mark_contaminated(train_problem) # 命中一个就标记

4.3 真实实现

  • normalize_string(decontaminate.py:36-42):小写 + 压缩空白;
  • build_ngram_lookup(:51-61):对 5 个评测集(aime_2024 / aime_2025 / math_500 / gpqa / lcb,:100-111)各建一张「n-gram → 文档 id」的倒排表;
  • find_contaminated(:116-124):每条训练样本算出自身 n-gram 集合,逐评测集打 contaminated_<名字> 布尔列;
  • cleanup(:127-138):按这些列过滤删行,再把标记列本身删掉,推回 Hub。

判定标准是「共享一个 8-gram 即污染」——非常保守(宁可错杀),这对数学竞赛题是对的:题干高度模板化,短重叠往往就是同源。

5. pass rate 过滤:把「没有学习信号」的题筛掉

5.1 思路

GRPO 的优势来自组内比较:一道题采 16 个答案,全对或全错时组内无差异,优势为零。这类题占着算力却不产生梯度,应该预先筛掉。

做法:拿当前策略模型对每题采 N 次,用 reward 函数算通过率,只留通过率在 [pass_rate_min, pass_rate_max] 区间内的题。

5.2 真实实现

scripts/pass_rate_filtering/compute_pass_rate.py 巧妙在复用 GRPO 的全部零件

  • PassRateScriptArguments 直接继承 GRPOScriptArguments(:38-46),奖励函数照样走 get_reward_funcs(:77)——过滤标准和训练标准天然一致;
  • 用 vLLM 离线批推理(LLM(...) + SamplingParams(n=num_generations),:101-113),每题按训练同款温度采 N 条;
  • batch_score(:115-156)对每条采样算奖励,None 转 NaN(:146)——与训练时的「跳过」语义保持一致;
  • filter_functorch.nanmean 求每题平均奖励,落在区间外即删(:187-198)。

默认阈值 pass_rate_min=0.1, pass_rate_max=0.9(:41-42);真实配方里压得更狠——recipes/dataset_filtering/filter_python.yaml:25-26 用 0.1~0.6 过滤代码题,即只留「模型偶尔能对」的难题。注意这个过滤是相对当前模型的:同一个数据集换更强的模型要重新过滤。

6. 代码地图

主题文件路径符号名
评测任务注册src/open_r1/utils/evaluation.pyLIGHTEVAL_TASKSregister_lighteval_task
评测作业提交src/open_r1/utils/evaluation.pyrun_lighteval_jobrun_benchmark_jobs
训练中途评测回调src/open_r1/utils/callbacks.pyPushToHubRevisionCallbackget_callbacks
异步推 Hub 分支src/open_r1/utils/hub.pypush_to_hub_revision
参数量/GPU 数启发式src/open_r1/utils/hub.pyget_param_count_from_repo_idget_gpu_count_for_vllm
评测 Slurm 脚本slurm/evaluate.slurm—(bash;MODEL_ARGS 在 38-42 行)
去污染scripts/decontaminate.pybuild_ngram_lookupfind_contaminatedcleanup
pass rate 过滤scripts/pass_rate_filtering/compute_pass_rate.pyPassRateScriptArgumentsbatch_scorefilter_func