跳到主要内容

数据截至 (上游 commit dd417662e5bd)

05 · 评测主循环与指标聚合

这一章讲什么: 全系统的总装线——simple_evaluate/evaluate 如何把「加载任务、执行请求、判分、聚合、出报告」串起来,以及一个分数后面的 ± 是怎么算的。改框架本身、或在乎统计口径的人读这章。


1. 它要解决的小问题

逐题判分(第 3 章)产出的只是几千个 0/1 和 logprob。从它们到「MMLU = 62.5 ± 0.4」还有一串问题:

  • 不同指标聚合方式不同:acc 求均值,perplexity 要先平均 loglikelihood 再取 exp——换算放哪做?
  • 标准误怎么算?均值有闭式公式,F1/perplexity 没有。
  • group(MMLU)分数是 57 个子任务分数的简单平均,还是按题量加权?stderr 又该怎么合并?
  • 多 GPU 时各 rank 只有部分数据,汇总在哪做?

2. 主线全览:simple_evaluate → evaluate

simple_evaluate(lm_eval/evaluator.py:55)是用户入口,evaluate(lm_eval/evaluator.py:429)是执行核心。全流程:

simple_evaluate
① 固定四路种子(random / numpy / torch / fewshot)
② 建 LM(必要时包 CachingLM)
③ TaskManager.load(tasks) —— 第 1 章
④ 应用覆盖:gen_kwargs / num_fewshot / predict_only


evaluate
⑤ task.build_all_requests() —— 第 2 章(按 rank 切片)
⑥ 按 request_type 归堆、执行 —— 第 3、4 章
⑦ apply_filters → process_results —— 第 3 章
⑧ 跨 rank gather_object 收集逐题值
⑨ _process_results:任务级聚合 → group 聚合


rank 0:组装 results 字典(附 configs / versions / seeds / git hash)

2.1 种子四件套

lm_eval/evaluator.py:196-214 依次固定:random_seed(默认 0)、numpy_random_seed(默认 1234)、torch_random_seed(默认 1234)、fewshot_random_seed(默认 1234,默认值见 lm_eval/defaults.py)。few-shot 抽样单独一路,保证换 batch size、换卡数时 few-shot 样本不变(第 2 章)。四个种子最后都写进 results["config"](lm_eval/evaluator.py:402-417)。

2.2 命令行对任务配置的三类覆盖

simple_evaluate 在加载后、执行前统一改任务配置(lm_eval/evaluator.py:307-347):

覆盖规则
gen_kwargs只作用于 generate_until 任务,update 进 generation_kwargs(lm_eval/evaluator.py:308-316)
num_fewshot任务 YAML 写死 num_fewshot: 0 时不覆盖;否则覆盖并打 warning(lm_eval/evaluator.py:326-341)
predict_only把任务指标换成 bypass,只出模型输出不算分(lm_eval/evaluator.py:318-324)

执行前还有一道安全与兼容性校验(lm_eval/evaluator.py:517-533):任务标了 unsafe_code(如要执行生成代码的 HumanEval 类任务)而用户没传 confirm_run_unsafe_code=True,直接报错;多模态任务配非多模态模型同理。


3. 跨 rank 收集:谁算分,谁出报告

多 GPU 时,每个 rank 只建并执行自己那份请求(第 2、3 章),逐题判分也是各算各的。汇总在 evaluate 尾部(lm_eval/evaluator.py:671-701):

  • lm.gather_object(rank_metrics, dst=0) 把各 rank 的 (metric, filter) → [逐题值] 收到 rank 0;
  • rank 0 用 itertools.chain.from_iterable 按顺序拼回完整列表;
  • 只有 rank 0 继续往下做聚合(lm_eval/evaluator.py:703-712),其他 rank 返回 None

值得记住:聚合发生在收集之后、单进程内。所以聚合函数不需要是分布式安全的——它就是普通 Python 函数,吃一个 list。


4. 指标注册表:三件套一次注册

指标不是孤立函数,是三件套:逐题度量函数、聚合函数、方向(越大越好吗)。注册一个装饰器搞定(lm_eval/api/registry.py:575register_metric),看 acc 的定义(lm_eval/api/metrics.py:176-182):

# 摘自 lm_eval/api/metrics.py:157-163
@register_metric(
metric="acc",
higher_is_better=True,
output_type=["loglikelihood", "multiple_choice"],
aggregation="mean",
)
def acc_fn(items): # This is a passthrough function
return items

acc_fnpassthrough:逐题值(0/1)原样攒进列表,聚合端才 mean。任务没配 metric_list 时按 output_type 给默认指标(DEFAULT_METRIC_REGISTRY,lm_eval/api/registry.py:449-457):multiple_choice 默认 [acc, acc_norm],generate_until 默认 [exact_match],等等。

查指标的顺序(get_metric,lm_eval/api/registry.py:609):本地注册表 → 找不到则回落到 HF evaluate按名字加载(lm_eval/api/registry.py:631-640)——所以 YAML 里写 bleurouge 也能用,只要装了 evaluate


5. 聚合:先任务级,再 group 级

5.1 任务级:passthrough + 聚合函数

_compute_task_aggregations(lm_eval/evaluator_utils.py:173)对每个 (metric, filter) 键:

  1. 从任务的三件套里取聚合函数,套到逐题值列表上(lm_eval/evaluator_utils.py:192-203)——结果键形如 acc,noneexact_match,strict-match
  2. 配套算标准误,键形如 acc_stderr,none(lm_eval/evaluator_utils.py:206-217)。

passthrough 模式的代表是 perplexity 家族(lm_eval/api/metrics.py:46-58):

# 摘自 lm_eval/api/metrics.py:34-58
@register_aggregation("mean")
def mean(arr):
return sum(arr) / len(arr)

@register_aggregation("perplexity")
def perplexity(items):
return math.exp(-mean(items))

@register_aggregation("bits_per_byte")
def bits_per_byte(items):
return -weighted_mean(items) / math.log(2)

为什么必须坚持「聚合端换算」:perplexity 是 exp(-总ll/总词数),若逐题先算 exp 再平均,得到的是另一个量。第 3 章逐题端只攒 (ll, n_words) 原始量,就是为了这一刻。

5.2 标准误:闭式优先,bootstrap 兜底

stderr_for_metric(lm_eval/api/metrics.py:589)按聚合函数选 stderr 算法:

聚合函数stderr 算法
mean闭式:样本标准差 / √n(mean_stderr,lm_eval/api/metrics.py:342)
median / f1 / perplexity / bleubootstrap_stderr(lm_eval/api/metrics.py:550):有放回重采样,求统计量分布的标准差
其他返回 None → 结果里写 "N/A"

bootstrap_stderr 默认重采样 bootstrap_iters=100000 次、多进程并行(lm_eval/api/metrics.py:560-581);bleu/chrf/ter 上限压到 100 次(lm_eval/evaluator_utils.py:209-211)。这就是分数后面 ± 的来源——它不是假设正态分布套公式,而是真的把数据集重抽十万次。

5.3 group 聚合:自底向上,按题量加权

_process_results(lm_eval/evaluator_utils.py:349)先 _collect_results 算完所有任务级指标,再 aggregate_groups(lm_eval/evaluator_utils.py:275):_collect_groups_bottom_up 把 group 按「子组先于父组」排序,逐个调 Group.aggregate(lm_eval/api/group.py:183)。

Group.aggregate 对每个声明的指标(aggregate_metric_list):

  1. 收集所有叶子任务的该指标值与 sample_len(缺指标的任务跳过并打 warning,lm_eval/api/group.py:258-265);
  2. aggregate_subtask_metrics(lm_eval/api/metrics.py:686)算加权平均——weight_by_size=True 时权重是各子任务题数(MMLU 的选择,lm_eval/tasks/mmlu/default/_mmlu.yaml);
  3. stderr 跟着点估计的口径走:加权用 pooled_sample_stderr( pooled variance 公式,lm_eval/api/metrics.py:625),不加权用 unweighted_mean_stderr(lm_eval/api/metrics.py:643)。源码注释点破了原因:口径不匹配会让误差棒过于自信(lm_eval/api/group.py:279-284)。

6. 出报告:results 字典里有什么

rank 0 最终组装的字典(EvalAcc._to_eval_results,lm_eval/evaluator_utils.py:134):

内容
results任务级分数:{task: {"acc,none": 0.62, "acc_stderr,none": 0.01, ...}}
groupsgroup 级分数(有聚合声明的 group 才出现)
group_subtasks每个 group 的直接子项
configs每个任务的完整 YAML 配置——口径随分数一起落盘
versions每个任务的 metadata.version
n-shot / higher_is_better / n-samplesfew-shot 数、指标方向、原始/实际题数
samples逐题明细(doc、prompt、响应、判分),log_samples 开启时

外加 simple_evaluate 补的元信息:模型名与参数、四个种子、git commit(get_git_commit_hash)、环境信息(add_env_info)、tokenizer 信息(lm_eval/evaluator.py:393-422)。

这份字典的设计意图很明确:拿到结果文件,就能完整复算出分数口径——哪个版本的任务定义、什么 prompt、几道 shot、什么种子,全部在案。EvaluationTracker(lm_eval/loggers/evaluation_tracker.py:123)再负责把它落盘/推 Hub。


7. 关键细节 / 坑

  • sample_len 只反映最后一个指标的计数:_compute_task_aggregationssample_len = len(items) 在循环里反复覆盖,源码 TODO 注释自认(lm_eval/evaluator_utils.py:204)。各 filter 题数不同的时候别看错。
  • bootstrap 默认十万次,是可见的时间开销:大任务集上收尾阶段会跑一阵多进程。--bootstrap_iters 0 可关(stderr 变 "N/A")。
  • group 分数对缺指标任务是「跳过」不是「记 0」(lm_eval/api/group.py:242-255)——子任务挂了,group 分照样出,但口径悄悄变了,务必看 warning。
  • aggregate_subtask_metrics 只适用于 mean 型聚合,源码 TODO 自认(lm_eval/api/metrics.py:689)。给 group 配非 mean 指标时,加权语义不成立。
  • log_samples 是判分正确性的最后一道防线:逐题明细里有 doc_hash/prompt_hash/target_hash(lm_eval/evaluator.py:655-664)——跨 run 对不上时,先比 hash 定位是数据变了还是 prompt 变了。
  • TP(tensor parallel)启动器下每个 rank 都自称 rank 0:simple_evaluate 为此回退看 LOCAL_RANK 决定谁写缓存/出报告(lm_eval/evaluator.py:273-289:382-385)——多进程起 vLLM TP 时结果文件才不会被写多份。

8. 代码地图(本章)

主题文件路径符号名
用户入口lm_eval/evaluator.pysimple_evaluate(:55)
执行主循环lm_eval/evaluator.pyevaluate(:429)
跨 rank 收集lm_eval/evaluator.pylm.gather_object(:678:692)
任务级聚合lm_eval/evaluator_utils.py_compute_task_aggregations(:173)、_collect_results(:222)
group 聚合lm_eval/evaluator_utils.pylm_eval/api/group.pyaggregate_groups(:275)、Group.aggregate(:183)
指标注册lm_eval/api/registry.pyregister_metric(:575)、DEFAULT_METRIC_REGISTRY(:449)、get_metric(:609)
聚合函数与 stderrlm_eval/api/metrics.pymean(:35)、perplexity(:47)、bootstrap_stderr(:521)、stderr_for_metric(:560)、pooled_sample_stderr(:595)、aggregate_subtask_metrics(:656)
结果组装lm_eval/evaluator_utils.py_process_results(:349)、EvalAcc._to_eval_results(:134)
落盘与追踪lm_eval/loggers/evaluation_tracker.pyEvaluationTracker(:123)