評估 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 摘要。
shortlist 錯誤的歸因
對一個帶標註、基數很高的 choice 集合,laya.evals_shortlist.evaluate_shortlist 走既有的
predict_shortlist 路徑和常規的評估 harness。它回答兩個各自獨立的問題:檢索有沒有保住真值
標籤,以及標籤在的時候 Laya 有沒有選中它?這是一個針對 choice 標籤的可選啟用的 Python API;
普通的 laya-evals run 報告不變。
import laya
from laya.evals import Dataset
from laya.evals_shortlist import evaluate_shortlist
from laya.shortlist import embed_fn_from_agent
agent = laya.load()
dataset_path = "intents.jsonl"
dataset = Dataset.from_jsonl(dataset_path)
report = evaluate_shortlist(
agent, dataset, embed_fn_from_agent(agent), k=20,
checkpoint_id="my-checkpoint@revision", embedder_id="my-encoder@revision",
dataset_path=dataset_path,
)
print(report.overall)
print(report.cases[0]["shortlist_status"])
用與被測部署相同的嵌入函式和 checkpoint。這兩個標識由呼叫方提供,應當指名不可變的 revision;
報告無法從任意一個 callable 推斷出它背後的權重。dataset_path 把檔案的 SHA256 和既有的問題
指紋一起記錄下來。每個 case 保留實際的 shortlist 標籤,以及 correct、retrieval_miss 或
decision_miss 之一。shortlist_recall_at_k 是保留下來的真值標籤的比例。
shortlist_accuracy_on_recalled 是正確的決策數除以保留下來的 case 數;一個都沒保留時它會被
省略。既有的 choice_accuracy 仍然是覆蓋所有 case 的端到端準確率,包含檢索未命中的。shortlist
指標出現在同樣的 language、model、question 和 tag 切片裡。請求延遲包含嵌入和那次決策呼叫;
報告不單獨拆分各階段的耗時。當 k >= n 時,原問題直接通過,檢索召回率為 1,不呼叫嵌入器。
這不會復現 issue #102 裡的 BANKING77 結果: 那些數字取決於它的資料集、checkpoint 和雙編碼器。這個 API 讓同一種診斷在呼叫方自己的標註集 上可以重複。
評估 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;
每個狀態一次的回退沒有可以重排的分組。傳 --calibration PATH 把一個擬合好的校準對映載入到
ONNXAgent 上,於是 --max-ece 這樣的校準門會針對校準後的機率評估。報告的 config 塊記錄
onnx 路徑和 calibration 路徑(設定了的話)。
在 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 在每種答案型別上報告的校準機率 |
brier |
任何帶置信度和已知標籤的答案 | 把置信度當作 P(正確) 的 Brier 分數,mean((confidence - correct)**2);越低越好 |
aurc |
任何帶置信度和已知標籤的答案 | 風險-覆蓋率曲線下的面積:每個不同置信度水平一個風險值,各按該水平覆蓋的答案數加權;越低越好,並且獎勵那種把對與錯排序排對的置信度,而不僅僅是校準良好 |
selective_accuracy@50、selective_accuracy@80 |
任何帶置信度和已知標籤的答案 | 在 50% / 80% 覆蓋率點上的置信度閾值所接受的答案上的準確率 —— 也就是在最不自信的尾部棄權所買到的東西。閾值無法拆開一組置信度相等的答案,所以這可能覆蓋超過所點名比例的部分;見覆蓋率切點 |
mean_confidence |
任何帶置信度的答案 | 報告的 answer["answer_confidence"] 的均值 |
latency_p50_ms、latency_p95_ms |
每請求 | 每個請求等待的牆鍾時間,僅供參考 —— 見批處理與計時 |
cost_per_decision_p50_ms、cost_per_decision_p95_ms |
每決策 | 一次呼叫的牆鍾時間除以它承載的行數,僅供參考 |
覆蓋率切點與並列
兩個覆蓋率指標都在一個置信度閾值上切,而一個閾值會接受每一個處於其自身置信度的答案。所以
一次切分絕不會拆開一組共享同一置信度的答案:當 coverage * n 落進這樣一組內部時,組裡每個成員
都被接受。因此這個數字背後的答案數是該組的上沿,而不是所點名的比例 —— 在一個置信度全都相等的
切片上,selective_accuracy@50 就是那個切片自身的準確率,而不是它較好的那一半。門列印的計數
(規則失敗訊息裡的 n=)是切片的大小,而不是被接受的大小,所以一個非常寬的組單從這條訊息是
看不出來的。
並列是常態而非邊角情況:一個擬合出的溫度可能讓某個分箱報告一個點質量(point mass),
laya.common.answer_confidence 就記錄到已釋出的 choice:11+ 上有這種情況,而一個真實的
checkpoint 在十二個答案裡產生了一個恰好都在 1.0 的六行組。改為在行下標上切,會讓兩個指標都
取決於資料集到達的順序 —— 同樣的行,打亂之後,把 selective_accuracy@50 在 0.000 和 1.000
之間移動。
aurc 對每個不同的水平積一個風險值,各按該水平覆蓋的答案數加權,所以它仍然是風險-覆蓋率曲線
下的面積,而不是大小不一的點的平均。
有兩個值得提前規劃的結果:
- 一個數字可能朝任一方向移動,幅度超過重排序能造成的。 當一組橫跨切點時,閾值讀數和同一份
資料的每一種行下標讀數都不同:在 400,001 個並列形狀的資料集上測得,
selective_accuracy@50最多差 0.500,aurc最多差 0.351。在上面那個十二答案的形態上 —— 六個正確,全都在置信度 1.0 ——aurc移動 0.327(0.173 到 0.500)。一道原本通過的門可能失敗,一道原本失敗的門可能通過; 先前的判定取決於行順序,對絕對的min或max上限也一樣,而後者沒有被任何東西拒絕,因為 它讀的是單次執行。 - 重新生成已提交的基線。
config.coverage_metric_definition記錄了一份報告是由哪種定義 產生的。跨兩種定義比較一個覆蓋率指標會被拒絕,在兩處要減去基線的門上 ——--baseline --tolerance(EvalReport.compare)和--gate-policy下的相對規則 (max_drop/max_increase)—— 而且只要任一側過期就會被拒,不只是基線:一個由更老的laya產生的候選帶有一個行順序的偽影,可能讀起來比真相更好,所以拿它去和一份正確重新生成 的基線做門,會讓一份正確打分的報告本應失敗的迴歸通過。沒有那道拒絕,一條過期基線就會藏起一個 真實的迴歸:一個在老定義下記錄為 0.033 的切片,在這個定義下讀作 0.517,於是一個真正下降了 0.217 的候選會通過 0.05 的max_drop。ece和brier不切,保持可比。
資料裡沒有並列時,每個答案就有一個水平,兩個指標就恰好是它們一直以來的樣子 —— 逐位相同,而不 只是接近。
把 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 說不清到底有沒有批處理。這些計數器記錄
的是發出的呼叫,不是返回的呼叫:在 laya-evals run --on-error skip 下,一次呼叫拋了異常的那個
塊仍然計入 rows_grouped 和 max_chunk,和它在 config.errored 裡的條目並列。預設是
--on-error fail,它會重新丟擲異常,而不是釋出一份指標只覆蓋返回了的呼叫的報告。兩個 *_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。
閾值上的棄權門
--min-confidence T 把 core 的可選啟用棄權閾值(#361)轉發給這次執行發出的每一次呼叫,所以
Router 和 ONNXAgent 會在 harness 看到之前,把 answer_confidence 低於 T 的答案標記為
low_confidence: True。和分組不同,這會改變參與打分的答案:在 T=0 和 T=0.7 下的同一次執行
是不同的實驗,而一次 precision@coverage 掃描是一系列這樣的執行,不是一條單一基線在漂移。
接受的範圍是 core 的 laya.confidence.check_min_confidence —— [0.0, 1.0],有限,不是 bool
—— 而不是這裡的一份副本,所以一個門自己都會拒絕的值會在任何 checkpoint 載入之前以用法錯誤
(退出碼 2)失敗。0.0 是一個合法的請求:它是 precision@coverage 掃描的對照組,而一個把它
丟掉的檢查會藏起這次掃描自己的下限。
一個 predict(或者對批處理執行而言是 predict_batch)早於這道門的 runner 會被以一個具名的
EvalError 拒絕,而不是在沒有閾值的情況下打分。靜默丟掉一個打分的對照,正是這個 harness
存在的意義所要防止的那類謊言:報告會為一個從未執行過的策略釋出一個 precision@coverage 數字。
config.timing 把請求和事實都記錄下來:min_confidence 是被請求的閾值,min_confidence_sent
說明這次執行發出的任何呼叫是否真的帶上了它。
切片
compare 和 run 報告總體數字;給 --slice language|model|qid|tag 時,還按每個切片值報告同樣
的指標,於是一種語言或一個問題上的迴歸不讀聚合值也能看見。model 切片儲存每一行的作答
checkpoint:Router 每個請求自己的選擇,或者對一個不路由的 runner 而言是 runner 的 model。
可選啟用的切片門
總體的基線門可以通過,而一個更小的語言或問題切片卻在迴歸。要把一個經過複核的切片變成一項 CI
要求,儲存一份 JSON 策略,比如 gates.json:
{
"version": 1,
"rules": [
{"slice": {"language": "zh"}, "metric": "choice_accuracy",
"min_count": 50, "max_drop": 0.05},
{"slice": {"qid": "intent"}, "metric": "ece",
"min_count": 50, "max": 0.10}
]
}
laya-evals run data.jsonl --baseline baseline.json --tolerance choice_accuracy=0.02 \
--gate-policy gates.json --json report.json
laya-evals compare report.json --baseline baseline.json \
--tolerance choice_accuracy=0.02 --gate-policy gates.json
每條規則恰好選擇 language、model、qid 或 tag 的一個值,並按該指標在切片報告裡出現的
樣子精確點名它。它有一個正的 min_count 和恰好一個上限:min 或 max 檢查候選值;max_drop
允許相對基線最多下降那麼多;max_increase 允許最多上升那麼多。後兩者需要 --baseline。計數
是所選切片裡該指標的已打分答案數,對相對規則而言,是兩份報告裡的。對 ece 而言,它是帶
有限置信度和布林 correct 值的答案數。缺失的切片或指標、已打分答案太少、或者有跳過/出錯的
case,都會讓這個可選啟用的門失敗。相對規則還要求兩份報告帶有匹配的執行身份,所以缺失的證據
不能表現為通過。一次測得的迴歸會報告切片、指標、計數、值和上限。非法的策略語法會在 checkpoint
載入之前以退出碼 2 退出;質量失敗以退出碼 1 退出。策略記錄在 run --json 報告的
config.gate_policy 裡。compare --gate-policy 把該命令列上提供的策略施加到已儲存的測量上。
如果它和報告記錄的策略不同,compare 會說明;在一次新策略下做顯式的重新檢查,不會改變最初
那次執行所處的策略。
常規的總體比較仍然適用,包括它的容差和舊基線行為。不給 --gate-policy 時,切片報告和比較的
行為和以前一樣。
執行身份
run 把它測到的東西記進報告的 config 塊,所以複核者讀到的這份產物本身就可複核:
| 鍵 | 含義 |
|---|---|
schema |
報告形狀,laya-evals-report/1,這樣消費者可以拒絕一個它讀不懂的 |
dataset |
輸入時寫下的路徑 —— 是一個名字,不是一個雜湊 |
dataset_sha256 |
被解析的資料集位元組的 sha256 |
questions_sha256 |
問題 schema 的指紋:整個資料集上每個問題的 id、型別、instructions 和 criteria |
laya_version |
算出這些數字的 laya |
coverage_metric_definition |
是哪一種 aurc / selective_accuracy@* 定義產生了這份報告(見覆蓋率切點)。對其中任一指標的相對門規則會拒絕一份在另一種定義下記錄的基線,而不是去減那些含義不同的數字 |
gate_policy |
run --gate-policy 所施加的可選切片門策略 |
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。