从零搭建自定义评估循环:lm-evaluation-harness 实战全攻略
【免费下载链接】lm-evaluation-harnessA framework for few-shot evaluation of language models.项目地址: https://gitcode.com/GitHub_Trending/lm/lm-evaluation-harness
开篇:当 62 分的 MMLU 救不了你的线上场景
假设你所在的公司微调了一个 7B 模型,领导拿到评测报告:MMLU 62 分,表现"不错"。但模型一上线就露馅了——客服场景里,它给出的回答措辞对了但关键信息缺失;内部质检要求"只要答案包含关键实体就算对",而默认的严格匹配准确率把所有半对答案全部判死。
问题出在哪?你用的是别人的评估循环,不是你的。lm-evaluation-harness 这个语言模型评测框架最容易被忽视的,恰恰是它真正的威力:simple_evaluate和evaluate两个核心函数(位于lm_eval/evaluator.py)可以让你彻底接管评估流程——自定义提示、自定义指标、接入非标准模型、加缓存、跑分布式。
读完本文,你将亲手从零搭建一套属于自己的自定义评估循环,并带走:
- 理解评估循环的内部骨架:请求构建 → 模型推理 → 指标聚合 → 报告生成
- 掌握
register_metric+override_metric注册自定义评估指标的正确姿势 - 学会用
evaluate函数把非 HuggingFace 格式的模型塞进评估流程 - 一套可直接复用的缓存、批处理、分布式配置方案
- 一份基于真实踩坑经验整理的避坑清单
主线目标:为一家"客服质检 + 内容审核"场景的微调模型,搭建一套带自定义容错指标、支持非标准模型接入的质检评估循环。
第一步:先拆开评估循环的骨架,别把它当黑盒
很多人用 lm-evaluation-harness 只用 CLI 一行命令:
lm_eval --model hf --model_args pretrained=your-model --tasks mmlu这没问题,但 CLI 是"成品",而自定义评估循环要从半成品开始。框架把评估流程拆成了两个层级的入口,理解它们的区别是第一步:
| 入口 | 所在模块 | 职责 | 适用场景 |
|---|---|---|---|
simple_evaluate | lm_eval/evaluator.py | 从模型名/模型对象 + 任务名出发,一条龙完成初始化与评估 | 大多数标准评估 |
evaluate | lm_eval/evaluator.py | 接收已实例化的 LM 对象和已加载的 task_dict,只负责执行评估 | 自定义模型接入、多模型对比、需要控制任务实例的场景 |
先画一张评估循环的内部流程图,后面所有操作都围绕它展开:
每个箭头都是一处你可以介入的扩展点:任务定义决定了请求长什么样(YAML 配置);指标函数决定"怎么算对"(register_metric);LM 对象决定"谁来推理"(自定义模型类);缓存决定"算不算重复活"。
from lm_eval import evaluator # 最简自定义评估循环:一行核心调用 results = evaluator.simple_evaluate( model="gpt2", # 模型名或 LM 对象 tasks=["arc_easy", "hellaswag"], # 任务列表 num_fewshot=5, # 少样本示例数 batch_size=4, # 批大小 limit=0.1, # 只跑 10% 数据,快速验证循环本身 )这行代码背后发生的,就是上面流程图里的 7 个步骤。先跑通它,再谈定制——这是搭建自定义评估循环的第一条铁律。
第二步:让评估循环"听话"——用参数控制喂给模型的数据
质检评估循环的第一需求是可控。默认循环会吃完整数据集,但调试时你只想看几个样本;跑正式评估时你又想固定抽样的随机种子保证可复现。lm-evaluation-harness 给了你三把钥匙。
钥匙一:limit与samples——控制喂多少数据
# 按比例截断:快速验证流程 results = evaluator.simple_evaluate(model="gpt2", tasks=["mmlu"], limit=0.05) # 精确指定样本:只评估你关心的那几条 results = evaluator.simple_evaluate( model="gpt2", tasks=["mmlu_astronomy"], samples={"mmlu_astronomy": [0, 3, 6, 10]}, )⚠️ 注意:
limit和samples是互斥的,同时传入会直接抛ValueError——源码里就是这么校验的,别踩。
钥匙二:num_fewshot——控制上下文里塞几个示例
少样本示例直接决定评测难度。对于客服质检场景,你甚至希望不给任何示例(0-shot)来模拟冷启动,或者给 3 个示例模拟有历史对话参考的情况:
for n in [0, 1, 3, 5]: res = evaluator.simple_evaluate(model="gpt2", tasks=["hellaswag"], num_fewshot=n) print(f"{n}-shot acc: {res['results']['hellaswag']['acc,none']}")钥匙三:gen_kwargs——控制生成行为
对于生成式任务(如客服回复质检),默认的贪心解码未必合适。你可以通过gen_kwargs注入采样参数:
results = evaluator.simple_evaluate( model="gpt2", tasks=["gsm8k"], gen_kwargs={"temperature": 0.7, "max_gen_toks": 512, "until": ["\n"]}, )到这里,你已经能用参数遥控评估循环的输入侧了。但质检场景最痛的还不是输入,而是**"怎么算对"**——下一步我们来动手术。
第三步:把自定义指标装进评估循环——注册一个"答对一半也给分"的指标
默认的acc是严格字符串匹配:预测和参考答案完全一致才得 1 分。客服质检的真实需求往往是容错匹配——"回复包含关键实体 '退款' 且不含否定词 '拒绝'"就判定为合格。这种指标内置循环里没有,得自己造。
机制一:register_metric注册指标函数
指标注册的入口在lm_eval/api/registry.py,装饰器的三个关键参数:
| 参数 | 含义 | 示例 |
|---|---|---|
metric | 指标注册名,YAML 里引用它 | "acc_contains_keyword" |
higher_is_better | 分数越高越好?影响排序与展示 | True |
aggregation | 逐样本分数如何聚合成总分 | "mean"(取平均) |
from lm_eval.api.registry import register_metric @register_metric( metric="acc_contains_keyword", # 注册名 higher_is_better=True, aggregation="mean", # 逐样本得分取平均 ) def acc_contains_keyword(items): """带容错的关键词匹配:预测里包含参考答案关键实体即得分""" # items: (gold, prediction) 元组列表 golds, preds = zip(*items) scores = [] for gold, pred in zip(golds, preds): gold = str(gold).strip().lower() pred = str(pred).strip().lower() # 规则:包含实体给 0.5,完全一致给 1,否则 0 if pred == gold: scores.append(1.0) elif any(tok in pred for tok in gold.split()) and len(gold.split()) > 1: scores.append(0.5) else: scores.append(0.0) return sum(scores) / len(scores) if scores else 0.0📌 内置指标(
acc、acc_norm、brier_score、f1等)都在lm_eval/api/metrics.py里用同样的@register_metric方式定义——你写的自定义指标和它们是平级的,框架不会区别对待。
机制二:override_metric运行时替换指标
注册完还不够,你得让具体的任务实例用上这个指标。Task类提供了override_metric方法(lm_eval/api/task.py):
from lm_eval.tasks import TaskManager tm = TaskManager() task_dict = tm.load_task_or_group("customer_qa") # 加载你的任务 for task_name, task in task_dict["tasks"].items(): task.override_metric(metric_name="acc_contains_keyword") # 注意:此时任务已实例化,要走 evaluate 而非 simple_evaluate from lm_eval import evaluator from lm_eval.models.huggingface import HFLM results = evaluator.evaluate( lm=HFLM(pretrained="your-model"), task_dict=task_dict, )默认 vs 自定义,差异一目了然:
| 维度 | 默认循环 | 自定义评估循环 |
|---|---|---|
| 指标来源 | 内置acc/f1等 | register_metric注册 +override_metric替换 |
| 判定粒度 | 全对才得分 | 可做部分得分、关键词匹配、规则逻辑 |
| 与业务对齐 | 需要业务迁就指标 | 指标迁就业务 |
| 实现成本 | 零 | 一个函数 + 一行 override |
到这里,你的质检评估循环已经有了"自己的打分标准"。但下一个现实问题马上来了:团队里那个模型不是 HuggingFace 格式,是内部推理引擎输出的结果——怎么接进来?
第四步:接"非标准模型"——用 evaluate 函数接管推理环节
这是自定义评估循环最能打的一步。CLI 方式要求模型必须被框架识别;而代码级的evaluate只要求你传入一个实现了LM接口的对象(lm_eval/api/model.py)。
方案 A:写一个 LM 子类
核心是实现两个方法,框架的所有评估都建立在它们之上:
from lm_eval.api.model import LM class MyInternalEngine(LM): """内部推理引擎适配器""" def __init__(self, engine_url: str): super().__init__() self.url = engine_url def _loglikelihood_tokens(self, requests, disable_tqdm=False): """批量计算给定 token 序列的 log-likelihood,MCQ 类任务需要""" # 请求打到内部引擎,返回 [(logprob, is_greedy), ...] ... def _generate_until(self, requests): """批量生成文本,生成式任务需要""" # 返回 [generated_text, ...] ...方案 B:借用现成适配器(更省力)
项目examples/transformer-lens.py提供了一个现成思路:如果你的模型能包一层 HuggingFace 风格接口,就无需从零实现 LM 接口——直接包成 HF 兼容模型喂给HFLM。TransformerLens 的HookedTransformer就是这么接入的:
from lm_eval.models.huggingface import HFLM class HFLikeModelAdapter(nn.Module): """把自定义模型包装成 HF 兼容接口""" def __init__(self, model): super().__init__() self.model = model self.tokenizer = model.tokenizer self.config = AutoConfig.from_pretrained(model.cfg.tokenizer_name) self.device = model.cfg.device def forward(self, input_ids=None, attention_mask=None, **kwargs): output = self.model(input_ids, attention_mask=attention_mask, **kwargs) if not hasattr(output, "logits"): output.logits = output return output results = evaluator.simple_evaluate( model=HFLM(pretrained=HFLikeModelAdapter(my_model), tokenizer=my_tokenizer), tasks=["customer_qa"], )方案 A vs 方案 B 怎么选?
- 你的模型有标准 forward 接口 → 方案 B,成本最低;
- 模型是纯 API / 纯 C++ 引擎,没有 PyTorch 前向 → 方案 A,需要实现两个核心方法;
- 两者都走
evaluate/simple_evaluate,评估循环的其他环节(指标、聚合、报告)完全复用。
到这里,你的质检评估循环已经集齐了三大组件:任务(可控输入)、指标(自定义打分)、模型(任意接入)。剩下的是让它在真实规模下跑得动、跑得快、跑得稳。
第五步:给评估循环加"油门"和"保险"——缓存、批处理与分布式
当评估任务从 10 条样本膨胀到几万条,循环的性能和稳定性就变成了主要矛盾。
油门一:两级缓存,避免重复计算
框架提供两级缓存,可以叠加使用:
| 缓存层级 | 参数 | 缓存内容 | 收益 |
|---|---|---|---|
| 请求缓存 | cache_requests=True | 请求构建结果(输入组装、少样本拼接) | 省 CPU 与 IO |
| 模型缓存 | use_cache="cache.db" | 模型推理输出(SQLite 数据库) | 省 GPU 算力 |
# 第一次跑:慢,写缓存 results = evaluator.simple_evaluate( model="gpt2", tasks=["customer_qa"], cache_requests=True, use_cache="eval_cache.db", ) # 第二次跑:同样的 (模型, 任务, 参数) 组合命中缓存,秒出结果 results2 = evaluator.simple_evaluate( model="gpt2", tasks=["customer_qa"], cache_requests=True, use_cache="eval_cache.db", )⚠️ 模型缓存按参数指纹命中。换了模型或改了
gen_kwargs,指纹就变,不会误用旧结果——这是它比你自己写字典缓存安全的地方。
油门二:自动批处理
results = evaluator.simple_evaluate( model="large-model", tasks=["mmlu"], batch_size="auto", # 自动探测最优批大小 max_batch_size=32, # 防止 OOM 的上限 )batch_size="auto"会让循环自动探测显存能容纳的最大批大小;max_batch_size是安全阀,防止探测过程直接吃爆显存。
保险:分布式多卡评估
大规模评估时用torch.distributed.run拉起多进程,评估循环内部会自动按 rank 分片:
CUDA_VISIBLE_DEVICES=0,1,2,3 python -m torch.distributed.run --nproc_per_node=4 \ -m lm_eval --model hf --model_args pretrained=your-model \ --tasks customer_qa --batch_size auto至此,你的质检评估循环已经具备生产环境的三要素:正确(自定义指标)、灵活(任意模型)、高效(缓存+分布式)。但正式上线前,还有几个坑值得提前绕开。
避坑清单:5 个最常见的自定义评估循环翻车点
1. YAML 里的\n死活不生效
- 现象:
until里的换行终止符没起作用,生成结果异常。 - 原因:单引号字符串不处理转义序列,
'\n'被解析成字面字符\和n。 - 正确做法:使用双引号——
until: ["\n"]。这是docs/footguns.md里排第一的经典陷阱。
2. 开启 chat 模板后,loglikelihood 分数集体变化
- 现象:同样一个任务,
apply_chat_template=True前后分数明显不同,甚至任务不兼容。 - 原因:chat 模板会改写输入格式,改变 loglikelihood 的上下文,严格匹配类任务对格式极其敏感。
- 正确做法:MCQ/loglikelihood 任务如需 chat 格式,同时设置
fewshot_as_multiturn=True,并明确"模板格式本身是评估的一部分"——对比实验时保持一致。
3. 想用samples精确定位样本,结果报错
- 现象:
ValueError: Either 'limit' or 'samples' must be None。 - 原因:两个参数互斥,源码里显式校验。
- 正确做法:调试时二选一——快速试跑用
limit,精确复现用samples。
4.batch_size="auto"直接 OOM
- 现象:显存爆炸,进程被杀。
- 原因:自动探测没有上限约束。
- 正确做法:总是搭配
max_batch_size;API 类模型则建议固定小批大小(如 1-4)。
5. 自定义指标注册了,但结果里看不到
- 现象:跑了半天,报告里没有你的指标。
- 原因:注册 ≠ 使用。
register_metric只是把指标放进注册表,任务实例默认仍用 YAML 里配置的指标。 - 正确做法:记得调用
task.override_metric(metric_name="你的指标名"),且走evaluate流程传入已实例化的任务。
收尾:你的评估循环,从此由你定义
回顾这条实战主线,你其实完成了一次"拆解-重构":从simple_evaluate的一行调用出发,拆出任务、指标、模型三个扩展点,再用evaluate把它们重新组装成一套为业务定制的质检评估循环——可控的输入(limit/samples/num_fewshot)、自定义的打分(register_metric/override_metric)、任意来源的模型(LM 子类或 HF 适配)、以及生产级的性能(缓存/批处理/分布式)。
下一步的进阶路线,建议你按这个顺序走:
- 读源码:
lm_eval/evaluator.py的evaluate函数是整套循环的中枢,逐行读一遍胜过十篇教程;lm_eval/api/metrics.py里的内置指标是你写自定义指标的最佳范本。 - 看示例:
examples/transformer-lens.py演示了最完整的非标准模型接入路径,直接改改就能套用。 - 读官方文档:
docs/API_guide.md(TemplateAPI 模型接入)、docs/footguns.md(持续更新的踩坑手册)、docs/task_guide.md(YAML 任务配置)是三个必读入口。 - 跑一遍
git clone https://gitcode.com/GitHub_Trending/lm/lm-evaluation-harness,把本文的代码片段在本地逐个跑通,再动手改造自己的循环。
自定义评估循环的意义不在"炫技",而在于让评测从"给领导看的一个数"变成"能指导模型迭代的一台仪器"。当你把评估指标改得和业务目标一致的那一刻,评测才算真正开始为模型优化服务。🚀
【免费下载链接】lm-evaluation-harnessA framework for few-shot evaluation of language models.项目地址: https://gitcode.com/GitHub_Trending/lm/lm-evaluation-harness
创作声明:本文部分内容由AI辅助生成(AIGC),仅供参考