跳到主要内容

数据截至 (上游 commit dd417662e5bd)

01 · 任务即配置:YAML 任务注册表

这一章讲什么: 几百个 benchmark 是怎么被管理的——一个 YAML 定义一个任务,TaskIndex 扫描建索引,TaskFactory 按需建造。读完你能自己给框架加一个新任务,也知道 MMLU 那种「57 个子任务共享一份模板」是怎么实现的。


1. 它要解决的小问题

评测框架的第一个敌人是任务数量。MMLU 一个 benchmark 就有 57 个子任务,全库几百个任务,每个都有差异:数据集在哪、prompt 怎么写、few-shot 给几道、答案怎么判。

如果每个任务都写 Python 类,结果是:加任务的门槛高、任务间大量复制粘贴、prompt 的微小差异埋在代码里不可审计。

harness 的回答:任务不是代码,是数据。 一个任务 = 一个 YAML 文件,框架只提供一个通用执行机 ConfigurableTask。写代码降级为「逃生舱」——YAML 表达不了时才用。


2. 直觉:把「阅卷规则」写进配置文件

一个 benchmark 分数由什么决定?拆开看无非这几样:

决定分数的要素YAML 里的字段
数据集从哪来dataset_path / dataset_name / 各 split 名
模型看到的题干doc_to_text(Jinja2 模板)
标准答案 / 候选选项doc_to_target / doc_to_choice
few-shot 几道、从哪抽num_fewshot / fewshot_split / fewshot_config
怎么向模型提问output_type(四种请求类型,见第 3 章)
生成时何时停generation_kwargs.until
答案怎么抽取、怎么判filter_list / metric_list
组内怎么汇总aggregate_metric_list(group YAML)

这些字段的完整 schema 是 TaskConfig(lm_eval/config/task.py:82)。配置即阅卷规则——分数可复现的前提是这份规则公开、可 diff、带版本号(metadata.version)。


3. 解剖一个真实任务:arc_easy.yaml

先看一个完整、无任何修饰的任务定义(lm_eval/tasks/arc/arc_easy.yaml,全文即这些):

tag:
- ai2_arc
task: arc_easy
dataset_path: allenai/ai2_arc
dataset_name: ARC-Easy
output_type: multiple_choice
training_split: train
validation_split: validation
test_split: test
doc_to_text: "Question: {{question}}\nAnswer:"
doc_to_target: "{{choices.label.index(answerKey)}}"
doc_to_choice: "{{choices.text}}"
should_decontaminate: true
doc_to_decontamination_query: "Question: {{question}}\nAnswer:"
metric_list:
- metric: acc
aggregation: mean
higher_is_better: true
- metric: acc_norm
aggregation: mean
higher_is_better: true
metadata:
version: 1.0

逐行读出一次评测的全部信息:

  • output_type: multiple_choice —— 这是选择题:模型不用生成,给每个选项算 loglikelihood 比大小(第 3 章展开)。
  • doc_to_text —— Jinja2 模板,{{question}} 会被数据集每行的字段替换,拼出模型看到的题干。
  • doc_to_target —— 模板里可以做计算:choices.label.index(answerKey) 把答案字母("A")变成选项下标(0)。
  • doc_to_choice —— 候选选项列表,{{choices.text}} 渲染成字符串数组。
  • tag: [ai2_arc] —— 这个任务同时属于 ai2_arc 标签;--tasks ai2_arc 会一次跑该标签下所有任务。
  • metric_list —— 报两个指标:原始准确率 acc 和长度归一准确率 acc_norm,都用 mean 聚合。

也就是说:不懂任何框架代码,只看这个 YAML,就能复算出 ARC-Easy 的分数口径。 这就是「任务即配置」的含金量。


4. 注册表三段式:Index → Factory → Manager

加载链路的三个角色,从磁盘到对象:

tasks/**/*.yaml ──扫描──► TaskIndex ──► {名字: Entry}
│ 按名查找

TaskFactory.build(entry)


ConfigurableTask / Group / [Task]

TaskManager.load(tasks) ← 用户给的 ["mmlu", ...]

4.1 TaskIndex:扫描与分类

TaskIndex.build(lm_eval/tasks/_index.py:44)遍历任务目录下所有 **/*.yaml(排序保证确定性,lm_eval/tasks/_index.py:82-91),快速解析(此时解析 !function,lm_eval/tasks/_index.py:57-61)后按内容分类。分类逻辑在 _kind_of(lm_eval/tasks/_index.py:154):

种类含义
class:PY_TASKPython 类任务(逃生舱全开)
group:GROUP任务组(如 mmlu)
task:TASK普通 YAML 任务
都没有报错不认识的 YAML 直接跳过

tag 不来自文件,而是反向索引:每个任务声明的 tag_register_tags(lm_eval/tasks/_index.py:138)收集成 TAG 条目,值是任务名集合。所以 tag 只是「一批任务的别名」,不像 group 那样有自己的聚合逻辑。

多目录扫描时后扫的覆盖先扫的并打 warning(lm_eval/tasks/_index.py:68-76)——这就是 --include_path 能覆盖内置任务的机制。

4.2 TaskFactory:把 Entry 造成对象

TaskFactory.build(lm_eval/tasks/_factory.py:37)按 kind 分派:TAG 展开成任务列表;GROUP 递归建造成员;TASK 走 _build_task(lm_eval/tasks/_factory.py:65)——

# 摘自 _build_task(lm_eval/tasks/_factory.py:75-81)
if "class" in cfg: # PY_TASK route
cls = cfg["class"]
obj = cls(config=cfg) if _ctor_accepts_config(cls) else cls()
else:
obj = ConfigurableTask(config=cfg)

绝大多数任务走 else:一个 YAML 换一个 ConfigurableTask 实例,不生成任何新类。有 class: 的(如 HumanEval 这种要执行代码的)才实例化指定 Python 类。

4.3 TaskManager:用户面对的入口

TaskManager.load(lm_eval/tasks/manager.py:179)接受三种 spec(_load_spec,lm_eval/tasks/manager.py:138):

  1. 注册名:"mmlu""arc_easy""ai2_arc"(tag)。
  2. YAML 文件路径:指向任何磁盘上的任务文件。
  3. 内联 dict:直接传配置,{"task": "my_task", "dataset_path": ...}

返回值固定是 {"tasks": {name: Task}, "groups": {name: Group}, "group_map": ...}。还有个防呆:同一任务被两个顶层项同时请求(如 --tasks mmlu mmlu_astronomy)会在 _check_duplicates(lm_eval/tasks/manager.py:283)直接报错,而不是悄悄跑两遍。


5. 原理演示:include 与 !function

两个机制决定了这套 YAML 能不能支撑几百个任务而不烂掉。

5.1 include:模板继承

MMLU 有 57 个子任务,差异只有「科目名」和「描述」。做法是子任务 YAML 只写差异:

# lm_eval/tasks/mmlu/default/mmlu_astronomy.yaml 全文
"dataset_name": "astronomy"
"description": "The following are multiple choice questions (with answers) about astronomy.\n"
"tag": "mmlu_stem_tasks"
"include": "_default_template_yaml"
"task": "mmlu_astronomy"
"task_alias": "astronomy"

被 include 的 _default_template_yaml 装着全部公共配置:dataset_path: cais/mmluoutput_type: multiple_choice、四选项的 doc_to_text 模板、fewshot_config: {sampler: first_n} 等。load_yaml(lm_eval/tasks/_yaml_loader.py:164)递归合并,本地键覆盖被 include 的键(lm_eval/tasks/_yaml_loader.py:207),并用 _seen 集合检测 include 环(lm_eval/tasks/_yaml_loader.py:178-180)。

示意一下合并语义:

# 示意,非源码
child = load_yaml("mmlu_astronomy.yaml")
# = {**_default_template_yaml, **mmlu_astronomy.yaml} 的浅合并
# 结果:dataset_path 来自模板,dataset_name/description 来自子文件

5.2 !function:注入 Python 的逃生舱

模板引擎表达不了的逻辑(比如「把这行 JSON 解析后再取字段」),YAML 可以写 !function utils.process_docs。加载器注册了自定义构造器(lm_eval/tasks/_yaml_loader.py:30),最终走 _import_func_in_yml(lm_eval/tasks/_yaml_loader.py:93):先在 YAML 同目录找 utils.py,找不到再按普通模块 import。

# 示意写法(库中大量任务用这种形式,如 hellaswag)
process_docs: !function utils.process_docs

这是「任务即配置」不至于被 YAML 表达力卡死的关键:90% 的任务纯配置,剩下 10% 把一两个函数塞进同目录的 utils.py,配置里指名引用。加载还有 mtime 缓存,改完 utils.py 不用重启进程就能热更新(lm_eval/tasks/_yaml_loader.py:38-90)。

代价也值得知道:!function 意味着 YAML 不再是纯数据,跑第三方任务目录等于跑第三方代码。所以涉及代码执行的任务会标 unsafe_code,框架要求显式确认才跑(见第 5 章)。


6. group 与 tag:两种「打包」

两者都能让 --tasks xxx 一次跑多个任务,但语义不同:

维度taggroup
定义方式各任务 YAML 里的 tag: 字段,反向收集一个独立 YAML(group: + task: 列表)
是容器吗否,只是别名集合,展开后消失是,Group 对象持有子任务/子组
能聚合分数吗不能能(aggregate_metric_list)
嵌套不能能(group 里放 group 或 tag)

看 MMLU 的组定义(lm_eval/tasks/mmlu/default/_mmlu.yaml):

group: mmlu
task:
- mmlu_stem
- mmlu_other
- mmlu_social_sciences
- mmlu_humanities
aggregate_metric_list:
- metric: acc
weight_by_size: True
metadata:
version: 2

它由 4 个子组构成,每个子组(如 _mmlu_stem.yaml)再通过 task: [mmlu_stem_tasks] 引用一个 tag 收拢该学科的全部子任务。三层结构:group → group → tag → task。aggregate_metric_list 声明组分数怎么算——这里是按子任务样本量加权的 acc(聚合执行见第 5 章)。


7. 关键细节 / 坑

  • include 是浅合并,不是深合并。 merged.update(inc_cfg); merged.update(cfg)(lm_eval/tasks/_yaml_loader.py:206-207)只覆盖顶层键。想在子任务里给 metric_list 追加一项?不行,得整个重写该键。
  • 组 YAML 里的额外键会向下传播为覆盖。 _build_group 把非 group 结构键的字段当作对所有子孙任务的 overrides 合并(lm_eval/tasks/_factory.py:111-115)——在组里写一行 num_fewshot: 0 会静默改掉下面每个任务的 shot 数。
  • 索引阶段不执行 !function TaskIndex 扫描时 resolve_func=False(lm_eval/tasks/_index.py:57-60),函数只在真正 build 任务时才 import。这让 lm-eval ls 列任务很快,也避免扫描期执行任意代码。
  • task_list 键不随 include 继承(lm_eval/tasks/_yaml_loader.py:205),防止模板里的任务列表泄漏进子任务。
  • 同名任务跨目录覆盖只打 warning(lm_eval/tasks/_index.py:122-127),同目录内重复则先注册者胜。调试「我的 include_path 为什么没生效」时先看这条日志。

8. 代码地图(本章)

主题文件路径符号名
任务目录扫描、分类、tag 反向索引lm_eval/tasks/_index.pyTaskIndex.build_kind_of_register_tags
YAML 加载、include 合并、!functionlm_eval/tasks/_yaml_loader.pyload_yaml_import_func_in_yml_load_module_with_cache
Entry → Task/Group 建造lm_eval/tasks/_factory.pyTaskFactory.build_build_task_build_group_members
用户入口、三种 spec、去重lm_eval/tasks/manager.pyTaskManager.load_load_spec_check_duplicates
配置 schema 与默认值lm_eval/config/task.pyTaskConfigFewshotConfig
组对象与聚合声明lm_eval/api/group.pyGroupGroup.from_config