跳到主要内容

数据截至 (上游 commit dd417662e5bd)

03 · 四种请求类型与执行

这一章讲什么: 分数语义的核心——一道题怎么变成发给模型的请求,模型的回答怎么变回 0/1。读完你会明白「MMLU 55 分」里的「分」到底是哪个量,以及为什么这套分数几乎不含随机性。


1. 它要解决的小问题

benchmark 题型千差万别:选择题(ARC)、续写判对错(LAMBADA)、整文流畅度(WikiText perplexity)、自由生成后抽答案(GSM8K)。如果每种题型一套执行逻辑,框架会被题型数量拖垮。

harness 的归约:题型只有四种,对应四种请求。所有任务必须把自己的「答题」表达成这四种之一:

output_type模型要做什么典型任务
loglikelihood给 (context, continuation) 算 continuation 的 log 概率LAMBADA(预测最后一个词)
multiple_choice对每个选项算 log 概率,比大小ARC、MMLU、HellaSwag
loglikelihood_rolling给整篇文本算 log 概率(滑动窗口)WikiText、Pile perplexity
generate_until自由生成,直到停止符GSM8K、HumanEval

第一直觉:能把「答题」变成「算概率」,就绝不让模型「生成」。生成有温度、有停止符、有格式漂移;算概率是纯函数——同一个模型、同一份输入,永远得到同一个数。这是 harness 分数可复现的根基。


2. Instance:请求的统一容器

四种请求共享一个 dataclass(lm_eval/api/instance.py:11):

# 摘自 lm_eval/api/instance.py:10-25(字段全览)
@dataclass
class Instance:
request_type: OutputType # 四种之一
doc: dict # 原始题目(判分时还要用)
arguments: tuple # 发给模型的参数,形状随类型变
idx: int # 同一 doc 扇出多条时的序号
metadata: Tuple[...] # (task_name, doc_id, repeats)
resps: list # 模型响应,执行后由 evaluator 填
filtered_resps: dict # 每条 filter 流水线处理后的响应

两个设计点:

  • doc 跟着请求走。 判分(process_results)需要原始题目(比如 gold 答案),所以请求自带 doc,执行端不需要回查数据集。
  • resps / filtered_resps 是「回收站」。 请求发出去后,响应按顺序塞回同一个 Instance(lm_eval/evaluator.py:603-604),判分阶段再从 Instance 上取——请求与响应通过对象身份对齐,不靠 ID 匹配。

arguments 的形状由类型决定(construct_requests,lm_eval/api/task.py:1362):

类型arguments
loglikelihood(ctx, doc_to_target(doc))
multiple_choice每个选项一条 (ctx, " 选项文本")
loglikelihood_rolling(doc_to_target(doc),)——只有全文,没有 context
generate_until(ctx, generation_kwargs dict)

3. 扇出:一道 multiple_choice 题变成 N 条请求

multiple_choice 是理解整套机制的最佳样本。看 construct_requests 的分派(lm_eval/api/task.py:1374-1390):

# 摘自 lm_eval/api/task.py:1374-1390(multiple_choice 分支)
choices = self.doc_to_choice(doc)
target_delimiter = self.config.target_delimiter
...
arguments = [(ctx, f"{target_delimiter}{cont}") for cont in choices]

四个选项 = 四条 loglikelihood 请求,idx 从 0 到 3,最后统一建 Instance(lm_eval/api/task.py:1433-1445)。所以 multiple_choice 根本不存在于执行层——到模型那只有 loglikelihood

3.1 顺带的高招:互信息校正

如果指标里有 acc_mutual_info,construct_requests再追加 N 条空上下文请求(lm_eval/api/task.py:1393-1405):

# 摘自 lm_eval/api/task.py:1401-1405
aux_arguments = [
("", f"{target_delimiter}{choice}") for choice in choices
]
arguments.extend(aux_arguments)

判分时用 log P(choice|ctx) − log P(choice) 代替裸的 log P(choice|ctx)——把「这个选项本身就很常见」的因素扣掉(对 yes/no 类选项纠偏明显)。妙在实现成本:请求模型本来就是列表,加一种新打分视角 = 加几条请求,执行层零改动。

3.2 原理演示:一道题的完整旅程

# 示意,非源码
doc = {"question": "水的化学式?", "choices": ["A", "B"], "answerKey": "A"}
ctx = fewshot_context(doc) # 第 2 章:描述 + 例题 + "Question: 水的化学式?\nAnswer:"

# construct_requests 扇出(multiple_choice):
instances = [
Instance(request_type="loglikelihood", arguments=(ctx, " A"), idx=0),
Instance(request_type="loglikelihood", arguments=(ctx, " B"), idx=1),
]

# evaluator 执行后,resps 被填上:
# inst0.resps = [(-0.35, True)] ← (logprob, 贪心是否会产出它)
# inst1.resps = [(-2.10, False)]

# process_results:argmax(logprob) = idx 0 = "A" = gold → acc = 1.0

4. 执行循环:按类型归堆,一把打完

evaluate()(lm_eval/evaluator.py:429)里,所有任务的所有 Instance 先按 request_type 归堆(lm_eval/evaluator.py:564-566),然后逐类型一把执行(lm_eval/evaluator.py:588-607):

task.instances(按任务建)

▼ 按 request_type 归堆
requests["loglikelihood"] = [inst, inst, ...](跨任务混合!)
requests["generate_until"] = [inst, ...]

▼ repeats 展开(每条请求复制 K 份)
▼ getattr(lm, reqtype)(cloned_reqs) ← 一次调用打完一个类型

resp 按 zip 顺序塞回每个 Instance.resps

三个值得知道的点:

  1. 请求是跨任务混批的。 两个都是 loglikelihood 类型的任务,请求会在同一个批次里打给模型——模型端看到的是一个大列表,按长度排序批处理(第 4 章)。
  2. repeats 靠复制 Instance 对象实现(lm_eval/evaluator.py:591-593),同一份请求的 K 个响应都进同一个 resps 列表——多数投票类任务(repeats: 8 + MajorityVoteFilter)就靠这个。
  3. 多 GPU 时给请求数补零(FSDP/DDP 要求各 rank batch 一致):padding_requests 统计各 rank 请求数差值,末尾重复塞旧请求凑齐(lm_eval/evaluator.py:568-584),收响应时多出来的结果自然落在没人读的副本上。

5. filter 流水线:从生成文本到「答案」

generate_until 的响应是一段自由文本(如 GSM8K 的推理过程),判分前要先抽出答案。这就是 filter 的活。

5.1 机制

每条 filter 流水线是一个 FilterEnsemble(lm_eval/api/filter.py:34):一串 Filter 按序作用于该任务全部 Instance 的 resps,结果存进 inst.filtered_resps[流水线名](lm_eval/api/filter.py:42-52)。YAML 里用 filter_list 声明,build_filter_ensemble(lm_eval/filters/__init__.py:10)按名字从注册表取类组装。

GSM8K 配了两条(lm_eval/tasks/gsm8k/gsm8k.yaml),同时报两套分数:

filter_list:
- name: "strict-match" # 严格:只认 "#### 42" 这种答案行
filter:
- function: "regex"
regex_pattern: "#### (\\-?[0-9\\.\\,]+)"
- function: "take_first"
- name: "flexible-extract" # 宽松:全文最后一个像数字的串
filter:
- function: "regex"
group_select: -1
regex_pattern: "(-?[$0-9.,]{2,})|(-?[0-9]+)"
- function: "take_first"

结果字典里就会出现 exact_match,strict-matchexact_match,flexible-extract 两个键——evaluate() 对每条流水线各跑一遍 process_results(lm_eval/evaluator.py:628-641)。「分数随抽取规则波动」被显式做成了双口径并报。

5.2 常用 filter

filter干什么位置
regex正则抽取(可分组、可多 match)lm_eval/filters/extraction.py:16
take_first取 repeats 里第一条lm_eval/filters/selection.py:13
take_first_k(TakeKFilter)取前 K 条lm_eval/filters/selection.py:27
majority_voterepeats 多数投票lm_eval/filters/selection.py:44
remove_whitespace变换类清洗lm_eval/filters/transformation.py

没配 filter_listgenerate_until 任务,默认只挂 take_first(lm_eval/api/task.py:770-776)。


6. process_results:逐题判分

响应收回、filter 过完,轮到 ConfigurableTask.process_results(lm_eval/api/task.py:1455)按类型判分。

6.1 loglikelihood:续写判对错

# 摘自 lm_eval/api/task.py:1461-1467
ll, is_greedy = results
return {
**({"perplexity": ll} if "perplexity" in use_metric else {}),
**({"acc": int(is_greedy)} if "acc" in use_metric else {}),
}

is_greedy 由模型端返回:逐 token 贪心解码是否会原样产出这段 continuation(第 4 章讲它怎么算)。LAMBADA 的「准确率」就是它——注意这不是字符串比较,是「模型最高概率的下一个词恰好是答案」。

6.2 multiple_choice:argmax 家族

# 摘自 lm_eval/api/task.py:1510-1512
pred = np.argmax(lls)
pred_norm = np.argmax(lls / completion_len)
pred_byte = np.argmax(lls / byte_length)

同一个 gold,三种预测方式对应三个指标(lm_eval/api/task.py:1545-1560):

指标预测方式修正什么
acc裸 loglikelihood 取最大
acc_norm除以字符数再取最大短选项天然概率高的偏差
acc_bytes除以 UTF-8 字节数跨语言时字符数失真
exact_matchis_greedy[gold]gold 选项是否逐 token 贪心命中
acc_mutual_info条件概率 − 无条件概率(lm_eval/api/task.py:1570-1576)选项本身先验频率

「报 MMLU 用 acc,报 HellaSwag 用 acc_norm」这种口径差异,就是这张表里的选择。

6.3 loglikelihood_rolling:困惑度三件套

判分端不算对错,只把全文 loglikelihood 和词数/字节数打包(lm_eval/api/task.py:1468-1488):(ll, n_words)(ll, n_bytes)。真正的 exp(-ll/N) 换算留给聚合端(第 5 章)——perplexity 是全语料的聚合量,不能逐题算完再平均,所以逐题端只攒原始量。

6.4 generate_until:metric 函数直接算

exact_match 这类指标收到的是 (references, predictions) 对,带上 YAML 里的清洗参数(lm_eval/api/task.py:1596 起)。GSM8K 的指标声明:

metric_list:
- metric: exact_match
ignore_case: true
regexes_to_ignore: [",", "\\$", "(?s).*#### ", "\\.$"]

regexes_to_ignore 先把「$1,234.」洗成「1234」再比字符串(lm_eval/api/metrics.py:234exact_match_hf_evaluate)。生成类任务的分数 = 抽取(filter)× 清洗(metric 参数)× 比较,三层都可能成为口径差异源。


7. 关键细节 / 坑

  • acc_norm 的「长度」是 Python 字符数,不是 token 数(lm_eval/api/task.py:1494)。对英文还行,对中文等多字节语言请看 acc_bytes
  • gold 不在选项里不会崩,只会记 0 分并警告:gold 解析失败置 -100,exact_match 记 0(lm_eval/api/task.py:1519-1549)。调试分数异常低时先查这条 warning。
  • multiple_choiceexact_matchacc 可以不一致:exact_match 要求 gold 选项逐 token 都是贪心最高概率,acc 只要求总 logprob 最大。多 token 选项两者经常分叉。
  • loglikelihood 任务的 perplexity 是聚合端换算的:逐题端存的是裸 loglikelihood,exp(-mean(ll)) 在聚合时才算(lm_eval/api/metrics.py:46-48)——所以「逐题 perplexity 的平均」≠ 报表上的 perplexity。
  • filter 是逐任务全量处理,不是逐条流式:FilterEnsemble.apply 一次拿全任务的 resps(lm_eval/api/filter.py:43-44),自定义 filter 可以做全局统计(如按全体响应分布归一化)。
  • repeats 对 loglikelihood 类无意义但照跑:复制的是请求,确定性请求会拿到 K 个相同响应,白白多花 K 倍算力——lm_eval/api/task.py:349 的 TODO 注释自己也承认这点。

8. 代码地图(本章)

主题文件路径符号名
请求容器lm_eval/api/instance.pyInstanceOutputType
请求构造(扇出)lm_eval/api/task.pyConfigurableTask.construct_requests(:1362)
执行循环lm_eval/evaluator.pyevaluate(:429)、getattr(lm, reqtype)(cloned_reqs)(:600)
filter 机制lm_eval/api/filter.pylm_eval/filters/FilterEnsemble.applybuild_filter_ensembleRegexFilterMajorityVoteFilter
逐题判分lm_eval/api/task.pyConfigurableTask.process_results(:1455)
字符串级 exact_matchlm_eval/api/metrics.pyexact_match_hf_evaluate(:215)
默认指标表lm_eval/api/registry.pyDEFAULT_METRIC_REGISTRY(:449)