跳到主要内容

数据截至 (上游 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 → 可执行任务」的翻译器:

  1. 校验 output_type 合法(lm_eval/api/task.py:649-654)。
  2. 装指标:metric_list 里每个指标解析出度量函数 + 聚合函数 + 方向三件套,缺省按 output_type 给默认指标(lm_eval/api/task.py:680-751)。
  3. 下载数据集(lm_eval/api/task.py:753)。
  4. 装 filter 流水线,没配就给 generate_until 任务默认 take_first(lm_eval/api/task.py:757-776)。
  5. 建 few-shot 采样器 self.sampler(lm_eval/api/task.py:786-800)。
  6. 拿第 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):

  1. fewshot_config.split 显式指定的 split(MMLU 用 dev split,与测试集隔离);
  2. fewshot_config.samples 直接给死的例题列表或函数;
  3. 都没有,回落到基类逻辑(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_evaluatefewshot_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_contextmultiple_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 条数据不健壮,任务加载就会炸,而不是评测时才炸。

8. 代码地图(本章)

主题文件路径符号名
任务接口定义lm_eval/api/task.pyTask(基类)、doc_to_textdoc_to_target
YAML 驱动执行机lm_eval/api/task.pyConfigurableTask.__init__doc_to_text(:1200)、doc_to_choice(:1282)
prompt 拼装lm_eval/api/task.pyConfigurableTask.fewshot_context(:932)、build_qa_turn(:1044)
few-shot 采样lm_eval/api/samplers.pyContextSampler.sampleFirstNSamplerSAMPLER_REGISTRY
消息抽象与多轮压缩lm_eval/api/utils.pyMessagemultiturn_to_singleturnmaybe_delimit
请求总装lm_eval/api/task.pyTask.build_all_requests(:268)、doc_iterator(:576)
配置默认值lm_eval/config/task.pyTaskConfig.target_delimiter(:112)、fewshot_delimiter(:113)