文档导航

评估 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。