评估 harness
评估 harness
laya.evals 把标注数据集变成一个可复现的分数,把基线变成一道通过/不通过的门,于是一次质量
改动就是一个可复核的 diff,而不是一次手工核对。
指标的数学和数据集解析器是纯 Python 加 numpy,从不 import torch,所以不需要权重就能跑。拿数据集 去跑某个 checkpoint 则需要那个 checkpoint,并要花它正常的加载时间。
快速开始
# check the format without a model
laya-evals validate research/evals/fixture.jsonl
# score a labelled set on one checkpoint, with thresholds and a baseline
laya-evals run data.jsonl --model english --device cpu \
--min-accuracy 0.8 --max-ece 0.05 --score-within 0.25 --slice language \
--json report.json --markdown report.md
# compare a saved report to a baseline
laya-evals compare report.json --baseline baseline.json --tolerance choice_accuracy=0.02
laya eval ... 是通过主 CLI 做同样的事,所以 laya eval validate data.jsonl 也能用。
退出码:成功为 0,某个阈值或基线容差不过为 1,用法错误为 2。run 把总体指标和请求的
切片打印到 stdout,给了 --json / --markdown 时还会写出完整报告和 Markdown 摘要。
评估 ONNX 导出
run --onnx PATH 通过 ONNXAgent 给导出的 ONNX 模型打分,而不是走 torch Router,于是一次
ONNX 部署(包括来自 scripts/export_onnx.py --quantize 的 INT8 副本)会被和 torch 路径一样的
阈值与基线卡住:
python scripts/export_onnx.py --model convaiinnovations/laya --output laya.onnx --quantize
laya-evals run data.jsonl --onnx laya.int8.onnx --max-ece 0.05
--model 指定导出所来自的 checkpoint —— Hub id 或本地路径,而不是 english 这样的 Router
短名,因为这条路径上没有 Router(默认 convaiinnovations/laya)。它的配置和 tokenizer 从那里
加载。Agent 只服务一个 checkpoint,所以某个数据集行的 model 字段指名了另一个 checkpoint 时
会报一个清晰的错,而不是被错误的模型静默作答;--device 不适用。--batch-size 在 Agent 有
batch API 时用它,否则回退为每个状态一次调用;--sort-by-length 会被转发给那个 batch API,
而每个状态一次的回退没有可以重排的分组。报告的 config 块记录 onnx 路径。
在 research/evals/fixture.jsonl 上测得(12 个标注行,英文 checkpoint,CPU):
| runner | choice_acc | noul_acc | score_mae | ece | mean_conf | p50 ms |
|---|---|---|---|---|---|---|
| torch Router | 0.75 | 1.00 | 1.3418 | 0.1596 | 0.7304 | 116.8 |
--onnx fp32 |
0.75 | 1.00 | 1.3418 | 0.1596 | 0.7304 | 66.3 |
--onnx int8 |
0.75 | 1.00 | 1.3512 | 0.1658 | 0.7304 | 46.3 |
fp32 导出精确复现了 torch 的数字,量化副本让 score_mae 变动 0.009、ece 变动 0.006 ——
正是 compare --tolerance 用来卡的那种漂移。
数据集格式
每行一个 JSON 对象(JSONL)。空行和以 # 开头的行会被忽略。
| 字段 | 必填 | 含义 |
|---|---|---|
state |
是 | 要据以决策的文本、邮件、工单或 JSON 文档 |
questions |
是 | 一个 Laya 问题 dict,和 Router.predict 接受的完全一样 |
expected |
是 | 按问题 id 建的标注真值:choice 是标签,score 是数字,noul 是 true/false |
tags |
否 | 用来切片的字符串 |
language |
否 | 用来切片的语言代码 |
model |
否 | 强制这一行用某个 checkpoint;--model 会覆盖它。什么都不强制的行会标上 Router 实际作答所用的那个 checkpoint |
research/evals/dataset.template.jsonl 里有一个带注释的示例。
指标
每个指标在适用之处按答案计算,再在整个数据集上聚合:
| 指标 | 适用于 | 含义 |
|---|---|---|
choice_accuracy |
choice |
所选标签命中的比例 |
noul_accuracy |
noul |
布尔值(概率 >= 0.5)命中的比例 |
score_mae |
score |
平均绝对误差 |
score_within_<tol> |
score |
落在某个绝对容差内的比例 |
ece |
任何带置信度的答案 | 期望校准误差,15 个分箱,在 answer["answer_confidence"] 上计算 —— 那是 Laya 在每种答案类型上报告的校准概率 |
mean_confidence |
任何带置信度的答案 | 报告的 answer["answer_confidence"] 的均值 |
latency_p50_ms、latency_p95_ms |
每请求 | 每个请求等待的墙钟时间,仅供参考 —— 见批处理与计时 |
cost_per_decision_p50_ms、cost_per_decision_p95_ms |
每决策 | 一次调用的墙钟时间除以它承载的行数,仅供参考 |
把 ScoreWithin(0.25) 加进评估器列表就得到一个容差指标;默认集合是 choice_accuracy、
noul_accuracy、score_mae、mean_confidence,外加 ece。从 CLI 看同样的事就是一个
flag:laya-evals run data.jsonl --score-within 0.25 在默认指标旁边报告 score_within_0.25,
而且这个 flag 可以重复,所以 --score-within 0.25 --score-within 0.5 会两个都报告。
容差指标需要一个带数值标签的 score 答案,所以在没有它的数据集上它没有值:run 会点名它算
不出来的那个指标,而不是发布一个静默的零;而点名该指标的 --min / --max 门会以缺失失败。
一次运行被要求了哪些容差会记在报告的 config 块里,所以一份经过复核的基线会说明它预期哪些列。
批处理与计时
--batch-size N 在一次调用里给最多 N 个共享同一 checkpoint 和同一问题 schema 的连续行打分。
两个计时指标来自同一批测量,回答不同的问题:一个批次的每一行都在该批次完成时返回,所以它的
latency 是整次调用,而它的 cost_per_decision 是这次调用的 1/N。因此在决策集合不变的情况
下,批处理会抬高 latency_*、压低 cost_per_decision_*,而 --max latency_p50_ms=...
问的是请求是否被快速服务,不是这次运行是否便宜。不给 --batch-size 时两者一致。
compare 会忽略任何 *_ms 指标,除非有容差点名它,所以这些指标永远不会因为计时的噪声让基线
失败。harness 实际做了什么 —— 请求的批大小、它解析成的 runner 形状、多少个行共享一次调用、最大
的块 —— 记录在报告的 config.timing 里,因为光看 flag 说不清到底有没有批处理。这些计数器记录
的是发出的调用,不是返回的调用:在 on_error=skip 下,一次调用抛了异常的那个块仍然计入
rows_grouped 和 max_chunk,和它在 config.errored 里的条目并列。两个 *_ms 指标只计返回
了的调用,所以一次失败的调用绝不会贡献一个它没测到的延迟。
把批次内的行分组
--sort-by-length 把大小相近的行分进同一次前向传播,于是每一趟都填充到一个较短的最大值,
而不是填充到其中最长的那一行。它改变的是调用的形状,不是答案:结果以同样的顺序返回,得分
完全相同,所以 research/ 可以在 10,000 张工单上报出 2.15x,而没有任何决策发生变化。
要重排序必须不止一趟,所以它只在 --batch-size N 小于这次运行所分组的行数时才生效。
config.timing 把两项声明分开记:sort_by_length 是命令行上说的,sort_by_length_sent 是
实际到达 runner 的。不带 --batch-size 的运行请求的是一件不可能发生的事,并用 sent: false
说明这一点;而一个 predict_batch 早于这个开关的 runner 会以未排序的方式打分,而不是在一次
长运行跑到一半时抛出 TypeError。
切片
compare 和 run 报告总体数字;给 --slice language|model|qid|tag 时,还按每个切片值报告同样
的指标,于是一种语言或一个问题上的回归不读聚合值也能看见。model 切片保存每一行的作答
checkpoint:Router 每个请求自己的选择,或者对一个不路由的 runner 而言是 runner 的 model。
运行身份
run 把它测到的东西记进报告的 config 块,所以复核者读到的这份产物本身就可复核:
| 键 | 含义 |
|---|---|
schema |
报告形状,laya-evals-report/1,这样消费者可以拒绝一个它读不懂的 |
dataset |
输入时写下的路径 —— 是一个名字,不是一个哈希 |
dataset_sha256 |
被解析的数据集字节的 sha256 |
questions_sha256 |
问题 schema 的指纹:整个数据集上每个问题的 id、类型、instructions 和 criteria |
laya_version |
算出这些数字的 laya |
thresholds |
这次运行所施加的门:min、max 和 baseline_tolerance |
revisions |
每个作答过的 checkpoint 所加载自的那个 commit(见下文) |
dataset 是一条路径,而路径不是身份:数据集可能被就地编辑、被移动,或者以同一个名字被重新
拉取,而一个 CI 缓存可能把同一个文件名、不同的字节交给两次运行。questions_sha256 覆盖的是
被问了什么,而不是有多少行,所以往一个没变的问题集里加状态不会动这个指纹 ——
dataset_sha256 仍然会变,而加一行是对数据的改动,不是对问题的改动。
它也覆盖 instructions,因为指令文本就是提示词。build_sequence 会把
"<type> question: <instructions>" 渲染进被 tokenize 的 head,Agent 会拒绝一个没有指令的
问题(「加上模型应当回答的文本」),而 Laya 自己的问题身份早就在算它:
Router._question_schema 和这个 harness 的批分组都以整个 questions dict 为键,
tests/test_router_batch.py 固定住了「单单改写 instructions 就会把一行移进它自己的批分组」
这件事。那么一段改写过的话还算和基线相等吗?不算 —— 而这就是重点。「判断一笔退款是否正当」
和「保守一些,只批准明确的退款请求」问的是不同的问题,而指标门只有在差异刚好把一个数字推得
超过你指定的容差时才能察觉。给一个 choice 选项命名也是同样的道理:criteria 是决策空间,
而 research/eval/metamorphic.py 里的蜕变检查之所以存在,就是因为重命名一个标签会翻转答案。
指令文本里没有任何东西被归一化,只有引擎自己施加的那一步:非字符串的 instructions 会按
json.dumps(ins, ensure_ascii=False) 做哈希,与 Agent._to_internal 一致。所以空白和措辞都
算数,一段在人类看来只是复制编辑的改写会被当作一次新实验。这是诚实的默认值 —— 替代方案是在
一次运行和它的基线之间横一个相似度启发式,而常见使用的评估系统里没有谁有这种东西。
没有任何带时间的东西被记录下来,所以对固定的 runner 而言报告仍然字节级可复现。
REPORT_SCHEMA、questions_fingerprint(dataset) 和 file_fingerprint(path) 是公开的,所以
直接驱动 laya.evals.evaluate 的调用方拿到和一次 CLI 运行相同的身份。
基线与 CI 门
- 把数据集、一份基线报告(你复核过的
--json输出)和容差放在一起,提交进仓库,于是一次改动 就是一个可复核的 diff。--tolerance METRIC=VALUE是该指标允许的最大绝对漂移。 laya-evals run ... --baseline baseline.json --tolerance ...漂移时以非零退出,所以它原样 就能放进 CI。laya.evals.EvalReport.compare和assert_regression为测试暴露了同样的逻辑。
指标门回答的是「数字动没动」。它回答不了「这些是不是同一批数字」,因为 compare 读的是
overall,而且只读 overall —— 所以一份针对某个数据集记录下来的基线,会让另一个数据集上
打分的候选以完全相同的算术通过。EvalReport.comparable_to 补上这一点:它比较 schema、
dataset_sha256 和 questions_sha256,而 run --baseline 和 compare 仍然打印每一个增量,
然后以非零退出失败,点名那个键和两个值:
FAIL: baseline is not comparable: dataset_sha256 (dataset bytes): baseline is <sha>, this run is <sha>
某一侧缺失的键是未知,不是冲突,所以在身份存在之前写下的每一份报告都保持和原来一模一样的
比较行为。这包括下面那个定时门的基线,它来自 research/eval/,完全没有 config.schema。
有两个 CI 场景用到它:
.github/workflows/ci.yml里一个免权重的 job 运行tests/test_evals.py和tests/test_evals_api.py,所以指标数学、数据集解析和 CLI 在每个 PR 上都被覆盖,不用下载 checkpoint;.github/workflows/evals.yml每周运行一次,发布前和按需运行:它在 MASSIVE 英文套件上评估 英文 checkpoint,并用research/evals/thresholds.json里的容差与research/results/eval_english_51_languages.json比较。它把报告作为 artifact 上传,不阻塞 PR。
对固定的 checkpoint 版本,harness 是确定性的,所以一份报告可复现。run 会把数据集、模型和
设备,以及这次运行的计时事实,记进报告的 config 块,还有
revisions:每个作答的 checkpoint 实际是从哪个 commit 加载的。--revision <SHA> 为这次运行
加载的每个 checkpoint 固定那个 commit,--revision english=<SHA> 固定某一个 checkpoint(可
重复)—— 这正是自动路由的运行想要的形式,因为三个 checkpoint 是三个仓库,一个 commit 不可能
同时存在于三者之中。不固定时,运行取 checkpoint 的默认分支,报告仍然会说明是哪个 commit 作答,
于是一次基线漂移可以归因到权重或归因到代码。laya/revisions.py 在 PINNED_REVISIONS 里发布
经过复核的 commit SHA,供想主动选用的调用方使用。用 --onnx 时,只有裸的 --revision <SHA>
适用,作用于配置和 tokenizer 的下载。
加入真正的标注集
往 research/evals/ 里放一个 JSONL,旁边放一份复核过的基线,然后把一个 workflow(或
research/evals/check_regression.py)指向这两者。格式和 fixture 一样;harness 里没有任何
东西知道 MASSIVE。