数据截至 (上游 commit dd417662e5bd)
02 · Task 抽象与 few-shot 构建
这一章讲什么: 模型在评测时到底「看到」什么。从数据集的一行 dict 到最终 prompt 字符串,中间经过三层:
doc_to_*渲染、few-shot 采样、上下文拼装。分数对 prompt 极其敏感,这一章就是搞清楚 prompt 是怎么被精确构造出来的。
1. 它要解决的小问题
「让模型做 ARC 的一道题」这句话,落到实现是一串决定:
- 数据集的一行是
{"question": ..., "choices": ..., "answerKey": "B"}这样的 dict,哪部分进 prompt、按什么格式? - few-shot 的例题从哪个 split 抽、抽几道、会不会抽到正在考的这道题?
- 模型是 base model(续写文本)还是 chat model(走消息模板)?同一个任务两种模型看到的 prompt 完全不同。
harness 用 Task 抽象把这串决定收进三个方法:doc_to_text(题干)、doc_to_target(答案)、fewshot_context(完整上下文)。
2. 两层 Task:抽象基类与通用执行机
lm_eval/api/task.py 里有两个类,职责分明:
| 类 | 角色 | 驱动方式 |
|---|---|---|
Task(lm_eval/api/task.py:64) | 抽象基类,定义接口与老派 Python 任务的默认行为 | 子类覆写方法 |
ConfigurableTask(lm_eval/api/task.py:618) | 通用实现,绝大多数任务的实际载体 | YAML 配置(TaskConfig) |
ConfigurableTask.__init__ 干的事(lm_eval/api/task.py:623-802)值得扫一眼,它就是「YAML → 可执行任务」的翻译器:
- 校验
output_type合法(lm_eval/api/task.py:649-654)。 - 装指标:
metric_list里每个指标解析出度量函数 + 聚合函数 + 方向三件套,缺省按output_type给默认指标(lm_eval/api/task.py:680-751)。 - 下载数据集(
lm_eval/api/task.py:753)。 - 装 filter 流水线,没配就给
generate_until任务默认take_first(lm_eval/api/task.py:757-776)。 - 建 few-shot 采样器
self.sampler(lm_eval/api/task.py:786-800)。 - 拿第 0 条 doc 试跑一遍
doc_to_text/doc_to_target,推断任务是不是「多输入」或「多答案」变体(lm_eval/api/task.py:804-831)。
3. doc_to_*:一行 dict 怎么变成题干与答案
以 ConfigurableTask.doc_to_text(lm_eval/api/task.py:1200)为例,它按配置值的类型分派,四种形态:
| 配置长这样 | 行为 | 例 |
|---|---|---|
| 字符串,且是数据集字段名 | 直接取 doc[field] | doc_to_target: answer(MMLU) |
| 字符串,非字段名 | 当 Jinja2 模板渲染 | doc_to_text: "Question: {{question}}\nAnswer:" |
callable(经 !function 注入) | 调函数 | !function utils.doc_to_text |
有 .apply 方法 | 当 Promptsource 模板用 | use_prompt: ... 时 |
doc_to_target(lm_eval/api/task.py:1236)同样分派,但多两步善后:渲染结果是数字字符串且任务有 doc_to_choice 时,ast.literal_eval 成下标(lm_eval/api/task.py:1253-1255);结果形如 "[...]" 时尝试解析成列表(多答案任务,lm_eval/api/task.py:1256-1264)。doc_to_choice(lm_eval/api/task.py:1282)则总是返回字符串列表。
MMLU 的模板是教科书例子(lm_eval/tasks/mmlu/default/_default_template_yaml):
doc_to_text: "{{question.strip()}}\nA. {{choices[0]}}\nB. {{choices[1]}}\nC. {{choices[2]}}\nD. {{choices[3]}}\nAnswer:"
doc_to_choice: ["A", "B", "C", "D"]
doc_to_target: answer
注意一个细节:这里的「选项」是字母 A/B/C/D,不是答案原文。模型比的是 " A"、" B"、" C"、" D" 这四个续写的概率——第 3 章会看到这如何让「单 token 缓存」大发神威。
4. few-shot:例题从哪来、怎么抽
4.1 fewshot_docs 的回退链
例题池的确定是有优先级的(ConfigurableTask.fewshot_docs,lm_eval/api/task.py:906-930):
fewshot_config.split显式指定的 split(MMLU 用devsplit,与测试集隔离);fewshot_config.samples直接给死的例题列表或函数;- 都没有,回落到基类逻辑(
lm_eval/api/task.py:204-219):train → validation → test(并打警告「不推荐」)。
4.2 采样器:随机 vs 固定前 N 个
ContextSampler(lm_eval/api/samplers.py:17)是默认采样器:self.rnd.sample(fewshot_docs, n) 随机抽。注册表在 SAMPLER_REGISTRY(lm_eval/api/samplers.py:132),内置两种:
| 采样器 | 行为 | 谁在用 |
|---|---|---|
default | 独立种子随机抽 | 大多数任务 |
first_n | 永远取前 N 道 | MMLU/CMMLU 这类有「canonical 例题」的任务(lm_eval/api/samplers.py:104-114) |
first_n 的存在是为了可比性:所有人的 MMLU 5-shot 都是 dev split 的前 5 题,分数才咬得住。
防泄漏:当例题池就是测试集本身时,sample() 会多抽一道再剔除当前 doc(lm_eval/api/samplers.py:57-63);若池子里有重复行导致剔完不够 n 道,还会从「剔干净」的池里重抽(lm_eval/api/samplers.py:64-71)。判断「是不是当前 doc」用的是 dict 相等比较,不是 identity。
4.3 独立的 few-shot 种子
抽样用的 random.Random 是独立于全局种子的:set_fewshot_seed(lm_eval/api/task.py:560-563)在每次评测前被 simple_evaluate 用 fewshot_random_seed(默认 1234,见 lm_eval/defaults.py)统一设置。含义:换 GPU 数、换 batch size 不会改变 few-shot 样本;但换 num_fewshot 一定会换样本——这是 --num_fewshot 覆盖任务默认值时分数会变的原因之一。
5. fewshot_context:把 prompt 拼出来
这是 ConfigurableTask.fewshot_context(lm_eval/api/task.py:932)的活。核心思路:先拼消息列表,再决定渲染成纯文本还是 chat 模板。
5.1 图示
system 消息: system_instruction + description
│
user/assistant 对 × N: few-shot 例题(q + tgt_delim + a)
│
user 消息: 本题的 doc_to_text(+ gen_prefix 开头)
│
▼
两条渲染路线:
纯文本: "".join(m.to_text()) —— base model
chat 模板: tokenizer.apply_chat_template —— chat model
5.2 原理演示
# 示意,非源码
messages = [Message("system", system_prompt)] # 描述 + 系统指令
for fs_doc in sampler.sample(n=num_fewshot, eval_doc=doc):
q = doc_to_text(fs_doc) # 例题题干
a = doc_to_target(fs_doc) # 例题答案
messages += build_qa_turn(q=q, a=a) # → user + assistant 两条
messages += build_qa_turn(q=doc_to_text(doc)) # 本题只有 user 消息
if apply_chat_template:
prompt = tokenizer.apply_chat_template(messages) # chat 模型
else:
prompt = "".join(m.to_text() for m in messages) # base model:拼成一大段
重点看:few-shot 例题的答案是 assistant 消息,本题的答案位置留空——模型要做的就是补上最后一条 assistant 消息(或对候选续写算概率)。
5.3 真实实现里的三个关键符号
build_qa_turn(lm_eval/api/task.py:1044)把一组 (q, c, a) 变成[user, assistant?]消息对。答案若是选项下标,这里换成选项文本(lm_eval/api/task.py:1092-1099)。Message(lm_eval/api/utils.py:33)是个小数据类,to_text()输出content + delimiter,所以分隔符挂在消 息尾巴上。- 走 chat 模板时,
fewshot_as_multiturn=False会把多轮压成单条 user 消息(multiturn_to_singleturn,lm_eval/api/utils.py:65)——chat 模型也能用「拼成一大段」的老派喂法。
5.4 分隔符是分数的隐形旋钮
两个默认值(lm_eval/config/task.py:112-113):target_delimiter: str = " "(题干与答案之间),fewshot_delimiter: str = "\n\n"(例题之间)。
为什么要在意:loglikelihood 是按 token 算的,"Answer:" + " B" 和 "Answer:" + "B" 的分词完全不同,概率也不同。ConfigurableTask.__init__ 甚至会检查「target_delimiter 和选项是否都带/都不带空格」并打日志(lm_eval/api/task.py:838-853)。改一个空格,分数可能动一两个点——这正是「分数只在同配置下可比」的微观来源。
6. build_all_requests:从 doc 到 Instance 的总装
万事俱备,总装线是 Task.build_all_requests(lm_eval/api/task.py:268):
eval_docs ──doc_iterator──► 按 rank 切分(多 GPU 时)
│
▼ 每道题:
fewshot_context(doc) —— 本章 §5,拼完整 prompt
│
▼
construct_requests(doc, ctx) —— 造 Instance(第 3 章详讲)
│
▼
task.instances:该 rank 要执行的全部请求
两个细节:
- 数据并行切分:
doc_iterator(lm_eval/api/task.py:576)用create_iterator(lm_eval/utils.py:627)做islice(docs, rank, limit, world_size)——stride 式分片,rank r 拿第 r, r+W, r+2W… 条。每个 rank 只建自己那份请求。 - 请求缓存:缓存键里塞了任务名、shot 数、rank、world_size、是否 chat 模板、系统指令哈希、tokenizer 名(
lm_eval/api/task.py:288-296)——任何一个影响 prompt 的旋钮变了,缓存自动失效。键设计本 身就是一张「哪些因素影响 prompt」的清单。
7. 关键细节 / 坑
--num_fewshot的覆盖有例外:任务 YAML 里显式写死num_fewshot: 0时,命令行的值会被忽略并打日志(lm_eval/evaluator.py:328-333)——0-shot 是任务作者的强硬声明。- 多输入任务是特殊分支:Winogrande 这类「题干留空、选项填进去」的任务,
doc_to_text返回 int(指向选项),fewshot_context走multiple_input_context(lm_eval/api/task.py:1109)为每个选项拼一条完整上下文——扇出发生在 prompt 层而不是请求层。 doc_to_target的字符串陷阱:渲染结果若以[开头会被当列表解析(lm_eval/api/task.py:1256-1264),答案原文恰好以[开头的任务要小心。- chat 模板 + 选择题的分隔符修正:走 chat 模板时
target_delimiter会被置空(除非有 gen_prefix,lm_eval/api/task.py:1377-1383),因为模板自己已经带了角色边界,再多一个空格就是污染。 - 0 号 doc 试跑是有副作用的推断:
__init__里对task_docs[0]试跑doc_to_text(lm_eval/api/task.py:808-809),若你的!function对第 0 条数据不健壮,任务加载就会炸,而不是评测时才炸。