02 · 评测主循环与基础模板
本章讲"卷子"内部:
Eval基类定义的评测骨架(怎么并行跑、怎么保证可复现),以及最简单的几种判分规则(精确/包含匹配)。读完你能自己写一个 Eval 子类。
1. Eval 的契约:只要实现两个方 法
Eval 是抽象基类(evals/eval.py:46),它只强制子类实现两个方法:
| 方法 | 职责 | 类比 |
|---|---|---|
eval_sample(sample, rng) | 判一道题:调考生拿回答、和标准答案比、记一笔 | 判一张答题卡 |
run(recorder) | 跑全程:加载数据 → 并行跑所有样本 → 汇总指标 | 组织整场考试 + 出成绩单 |
基类把"并行跑所有样本"这件累活儿实现好了(eval_all_samples),子类的 run 通常就三步:get_samples() → eval_all_samples() → 从 recorder 收集事件算指标。
2. 主循环:eval_all_samples 怎么并行又可复现
2.1 确定性洗牌
并行评测最怕"每次跑结果不一样"。Evals 用两层确定性保证可复现:
# 示意,非源码:样本顺序由固定种子决定,与运行无关
indices = list(range(len(samples)))
random.Random(SHUFFLE_SEED).shuffle(indices) # SHUFFLE_SEED = 123,写死
work_items = [(samples[i], i) for i in indices]
真实实现见 _index_samples(eval.py:30-38),SHUFFLE_SEED = 123 是模块常量。洗牌带每个样本的原始下标一起走,所以即使乱序并行跑,最后还能按下标排回原顺序(eval.py:147 的 sorted)。
每个样本还有自己的确定性 RNG:用 "{base}.{split}.{idx}:{seed}" 编码成种子(eval.py:135-137),这样"第 N 题"用的随机数永远一样——对需要随机抽 few-shot 之类的逻辑很关键。
2.2 线程池并行
# 示意,非源码:每题在线程池里跑,as_default_recorder 把日志归到对应样本
def eval_sample(args):
sample, idx = args
sample_id = f"{base_name}.{split}.{idx}"
with recorder.as_default_recorder(sample_id): # 线程内绑定"当前样本"
rng = random.Random(per_sample_seed)
return idx, self.eval_sample(sample, rng)
with ThreadPool(threads) as pool:
iter = pool.imap_unordered(eval_sample, work_items) # 乱序完成
真实实现见 eval.py:112-147。要点:
- 线程数可调:环境变量
EVALS_THREADS,默认 10(eval.py:124)。LLM 评测是 IO 密集(等 API),线程池就够,不用进程池。 - 可切顺序模式:
EVALS_SEQUENTIAL=1时改用map串行跑(eval.py:140-142),调试时方便。 as_default_recorder是线程局部的:它用ContextVar把"当前 sample_id"绑到上下文(record.py:90-96),于是eval_sample里随手调record_match()不用传 sample_id,日志也能正确归属。
3. 最简单的判分:Match 模板
3.1 它要解决的小问题
很多题答案唯一且短(选择题、数值题)。这类"模型输出开头匹配上标准答案就算对"的逻辑,Match 一个类搞定(evals/elsuite/basic/match.py:9)。
3.2 eval_sample 全貌
样本必须是 {"input": ..., "ideal": ...}(match.py:31-36)。eval_sample 做三件事:
# 示意,非源码:Match.eval_sample 的骨架
prompt = sample["input"]
result = self.completion_fn(prompt=prompt, temperature=0.0) # 调考生,温度0求确定
sampled = result.get_completions()[0] # 取第一条回答
return evals.record_and_check_match( # 判 + 记
prompt=prompt, sampled=sampled, expected=sample["ideal"],
)
真实实现见 match.py:30-56。判分规则在 record_and_check_match(evals/api.py:55-105):遍历候选选项,只要 sampled.startswith(option) 就算命中(可选 separator 防止 "42" 误匹配 "4")。它顺手把 match 布尔通过 record_match 记进 recorder(api.py:104)。
Match 还内建 few-shot 支持:num_few_shot > 0 时,把若干示例样本插进 chat prompt 的倒数第二个位置(match.py:39-44)。
3.3 兄弟模板:判分规则的一行差异
几个基础模板的差别只在那一行判分规则(docs/eval-templates.md:9-16 也列了)。设模型输出 a、标准答案集合 B:
| 模板 | 判对规则 | 文件 |
|---|---|---|
Match | any(a.startswith(b) for b in B) | basic/match.py |
Includes | any(b in a for b in B) | basic/includes.py |
FuzzyMatch | any(a in b or b in a for b in B) | basic/fuzzy_match.py |
JsonMatch | a 作为 JSON 与某个 b 结构相等 | basic/json_match.py |
Includes 的核心就一句(includes.py:42-44):用 utils.get_answer 在输出里找子串,ignore_case 可控大小写。这说明"基础模板"的设计哲学:主循环统一、只把"算不算对"做成可替换的一行。
4. 汇总:run 怎么出分
Match.run(match.py:58-65)是所有基础模板的范式:
# 示意,非源码:跑完所有样本后,从 recorder 收 match 事件算指标
self.eval_all_samples(recorder, self.get_samples())
events = recorder.get_events("match") # 取出所有判分事件
return {
"accuracy": evals.metrics.get_accuracy(events),
"boostrap_std": evals.metrics.get_bootstrap_accuracy_std(events),
}
- accuracy = 答对数 / 总数(
metrics.py:12-18)。 - bootstrap_std = 用自助法(bootstrap)估准确率的标准差(
metrics.py:21-23):反复随机抽一半样本算均值、取这些均值的标准差,给出"这个准确率有多稳"的误差棒。这是个朴素但实用的不确定性度量。
注意 run 不自己持有结果列表,而是事后从 recorder 把 match 事件捞出来——这把"判分"和"汇总"解耦了,也意味着 recorder 是单一事实源。
5. 巧妙之处
- 模板法分离"骨架"与"判分"。 主循环、并行、随机、记录全在基类;子类只写一道题怎么判。新增一种基础评测 = 改一行匹配规则。
- 双层确定性种子。 全局洗牌种子(123)定样本顺序、每样本种子定样本内随机——并行也能逐位复现。
- ContextVar 当"隐式参数"。
as_default_recorder让深层代码无需层层传 sample_id 就能正确归属日志,且线程安全。 - 指标从事件流反推。
run不维护可变累加器,而是查询 recorder 里的match事件——天然适合"中途中断也能出部分报告"(SolverEval甚至专门处理了KeyboardInterrupt做"温柔中断",eval.py:240-253)。
6. 边界与局限
- 基础模板只认
input/ideal,且把回答当纯字符串比。答案有意义变体(同义、不同格式)时会误判为错——这正是要上"模型裁判"(见 03 章)的动机。 - 线程池不是隔离沙箱。
eval_sample之间共享进程内存;若子类持有可变共享状态会有竞态(这也是SolverEval给每个样本copy()一个新 solver 的原因,eval.py:225)。 - bootstrap_std 用样本的一半(
len//2)估计,样本极少时这个误差棒本身也不稳。
7. 代码地图
| 主题 | 文件路径 | 符号名 |
|---|---|---|
| 评测基类 + 主循环 | evals/eval.py | Eval、SolverEval、eval_all_samples、_index_samples、set_max_samples |
| 精确/包含匹配 | evals/elsuite/basic/match.py、includes.py | Match、Includes |
| 判分 + 记录 | evals/api.py | record_and_check_match |
| 数据加载 | evals/data.py | get_jsonl、open_by_file_pattern |
| 指标 | evals/metrics.py | get_accuracy、get_bootstrap_accuracy_std |