Документация

Harness оценки

laya.evals превращает размеченный датасет в воспроизводимую оценку, а базовую линию — в бинарный шлюз (прошёл/не прошёл), так что изменение качества становится проверяемым diff, а не ручной проверкой.

Математика метрик и парсер датасета — это чистый Python плюс numpy, они никогда не импортируют torch, поэтому работают без весов. Прогон датасета против чекпойнта требует сам чекпойнт и занимает обычное время его загрузки.

Быстрый старт

# 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, а также пишет полный отчёт и сводку в Markdown, когда заданы --json / --markdown.

Атрибуция ошибок shortlist

Для размеченного набора выборов высокой кардинальности laya.evals_shortlist.evaluate_shortlist использует существующий путь predict_shortlist и обычный harness оценки. Он отвечает на два отдельных вопроса: сохранил ли поиск эталонную метку и выбрал ли её Laya, когда она была на месте? Это opt-in Python API для меток choice; обычные отчёты 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"])

Используйте ту же функцию эмбеддинга и тот же чекпойнт, что и измеряемое развёртывание. Оба идентификатора задаёт вызывающий, и они должны называть неизменяемые ревизии; отчёт не может вывести веса за произвольным callable. dataset_path записывает SHA256 файла рядом с существующим отпечатком вопросов. Каждый случай хранит фактические метки shortlist и одно из значений correct, retrieval_miss или decision_miss. shortlist_recall_at_k — это доля сохранённых эталонных меток. shortlist_accuracy_on_recalled — правильные решения, делённые на сохранённые случаи; он опускается, когда ни один случай не сохранён. Существующий choice_accuracy остаётся сквозной точностью по всем случаям, включая промахи поиска. Метрики shortlist появляются в тех же срезах по языку, модели, вопросу и тегу. Задержка запроса включает эмбеддинг и вызов решения; отчёт не выделяет тайминги отдельных этапов. При k >= n исходный вопрос проходит как есть, и полнота поиска равна 1 без вызова embedder.

Это не воспроизводит результаты BANKING77 из issue #102: эти числа зависят от его датасета, чекпойнта и би-энкодера. Это API делает такой же диагноз воспроизводимым на собственном размеченном наборе вызывающего.

Оценка экспорта ONNX

run --onnx PATH оценивает экспортированную модель ONNX через ONNXAgent вместо Router из torch, поэтому развёртывание ONNX (включая копию INT8 из scripts/export_onnx.py --quantize) проходит через те же пороги и базовые линии, что и путь 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 называет чекпойнт, из которого пришёл экспорт — id Hub или локальный путь, а не короткое имя Router вроде english, поскольку на этом пути Router нет (по умолчанию convaiinnovations/laya). Оттуда загружаются его конфигурация и токенизатор. Агент обслуживает один чекпойнт, поэтому строка датасета, чьё поле model называет другой, падает с понятной ошибкой, а не получает молча ответ не от той модели; --device не применяется. --batch-size использует батч-API агента, когда тот его имеет, и иначе откатывается к одному вызову на состояние; --sort-by-length переадресуется в этот батч-API; в резервном варианте на состояние нет группы, которую можно переупорядочить. Передайте --calibration PATH, чтобы загрузить настроенную карту калибровки в ONNXAgent, так что шлюзы калибровки вроде --max-ece оценивают по калиброванным вероятностям. Блок config отчёта записывает путь onnx и путь calibration (когда он задан).

Измерено на research/evals/fixture.jsonl (12 размеченных строк, английский чекпойнт, 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, ровно как его принимает Router.predict
expected да эталонные значения с ключом по id вопроса: метка для choice, число для score, true/false для noul
tags нет строки для разбиения на срезы
language нет код для разбиения на срезы
model нет принудительно задаёт чекпойнт для этой строки; --model его переопределяет. Строка, которая ничего не задаёт принудительно, помечается тем чекпойнтом, которым ответил Router

В 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 любой ответ с уверенностью и известной меткой Brier score уверенности как P(correct), 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= в сообщении о падении правила), — это размер среза, а не принятый размер, поэтому очень широкая группа не видна из одного сообщения.

Совпадения — это норма, а не угол: настроенная температура может оставить бакет, сообщающий точечную массу, что laya.common.answer_confidence фиксирует у поставляемого choice:11+, а реальный чекпойнт дал группу из шести строк ровно на 1.0 из двенадцати ответов. Отсечение по индексу строки вместо этого заставило обе метрики зависеть от порядка, в котором пришёл датасет, — одни и те же строки, перемешанные, двигали selective_accuracy@50 между 0.000 и 1.000.

aurc интегрирует одно значение риска на каждый отдельный уровень, взвешенное ответами, которые этот уровень покрывает, поэтому остаётся площадью под кривой риск–покрытие, а не средним неравномерно больших точек.

Два следствия, которые стоит учесть заранее:

  • Число может сдвинуться в любую сторону, сильнее, чем мог бы дать переупорядочивание. Там, где группа пересекает отсечение, чтение по порогу отличается от любого чтения по индексу строки тех же данных: измерено на 400,001 датасете формы совпадений, до 0.500 для selective_accuracy@50 и 0.351 для aurc. На форме из двенадцати ответов выше – шесть верных, все с уверенностью 1.0 – aurc сдвигается на 0.327 (с 0.173 до 0.500). Шлюз, который проходил, может упасть, а который падал — пройти; прежний вердикт зависел от порядка строк, в том числе для абсолютного предела min или max, который ничто не отвергает, потому что он читает один прогон.
  • Перегенерируйте закоммиченные базовые линии. config.coverage_metric_definition записывает, какое определение произвело отчёт. Сравнение метрики покрытия между двумя определениями отвергается на обоих шлюзах, вычитающих базовую линию – --baseline --tolerance (EvalReport.compare) и относительном правиле (max_drop / max_increase) при --gate-policy – и при устаревании любой из сторон, а не только базовой линии: кандидат, произведённый более старым laya, несёт артефакт порядка строк, который может читаться лучше истины, поэтому сравнение его с корректно перегенерированной базовой линией пропустило бы регрессию, которую корректно посчитанный отчёт отвергает. Без этого отказа устаревшая базовая линия скрывает реальную регрессию: срез, записанный как 0.033 по старому определению, читается как 0.517 по этому, поэтому кандидат, который честно упал на 0.217, прошёл бы max_drop 0.05. ece и brier не отсекают и остаются сопоставимыми.

Когда совпадений в данных нет, на каждый ответ приходится один уровень, и обе метрики — ровно то, чем всегда были, – побитово идентичны, а не просто близки.

Добавьте ScoreWithin(0.25) в список оценщиков, чтобы получить метрику допуска; набор по умолчанию — choice_accuracy, noul_accuracy, score_mae, mean_confidence плюс ece. Из CLI то же самое — один флаг: laya-evals run data.jsonl --score-within 0.25 сообщает score_within_0.25 рядом со значениями по умолчанию, а флаг повторяется, поэтому --score-within 0.25 --score-within 0.5 сообщает обе.

Метрике допуска нужен ответ score с числовой меткой, поэтому на датасете без такого она не имеет значения: run называет метрику, которую не смог вычислить, вместо того чтобы опубликовать молчаливый ноль, а шлюз --min / --max, называющий эту метрику, падает как отсутствующая. Запрошенные для прогона допуски записываются в блок config отчёта, поэтому проверенная базовая линия говорит, каких столбцов она ждёт.

Батчинг и тайминги

--batch-size N оценивает до N последовательных строк, разделяющих чекпойнт и схему вопросов, за один вызов. Обе метрики времени происходят из одних и тех же измерений и отвечают на разные вопросы: каждая строка батча возвращается тогда же, когда и батч, поэтому её latency — это весь вызов, а её cost_per_decision — 1/N от него. Поэтому батчинг повышает latency_* и понижает cost_per_decision_* на неизменном наборе решений, а --max latency_p50_ms=... спрашивает, обслуживались ли запросы быстро, а не был ли прогон дешёвым. Без --batch-size они совпадают.

compare игнорирует любую метрику *_ms, если её не называет допуск, поэтому они никогда не заваливают базовую линию из-за шума таймингов. Что harness реально сделал — запрошенный размер батча, форма раннера, к которой он свёлся, сколько строк разделили вызов и самый крупный фрагмент — записывается в config.timing отчёта, потому что один флаг не говорит, пакетировалось ли что-нибудь. Эти счётчики фиксируют выданные вызовы, а не вернувшиеся: при laya-evals run --on-error skip фрагмент, чей вызов возбудил исключение, всё равно учитывается в rows_grouped и max_chunk, рядом со своими записями в config.errored. По умолчанию — --on-error fail: он возбуждает исключение заново, а не публикует отчёт, метрики которого охватывают только вернувшиеся вызовы. Две метрики *_ms считают только вернувшиеся вызовы, поэтому упавший вызов никогда не вносит задержку, которую он не измерял.

Группировка строк внутри батча

--sort-by-length группирует строки похожего размера в один прямой проход, поэтому каждый проход дополняется до более короткого максимума, а не до самой длинной строки в нём. Это форма вызовов, а не их ответы: результаты возвращаются в том же порядке и оцениваются одинаково — вот почему research/ может сообщать 2.15x на 10,000 тикетах, не меняя ни одного решения.

Для переупорядочивания нужно больше одного прохода, поэтому это действует только при --batch-size N меньше числа строк, которые группирует прогон. config.timing разделяет два утверждения: sort_by_length — то, что сказала командная строка, sort_by_length_sent — то, что дошло до раннера. Прогон без --batch-size просит то, чего не может произойти, и говорит об этом через sent: false; раннер, чей predict_batch старше этой ручки, оценивается без сортировки, а не возбуждает TypeError на середине долгого прогона.

Шлюз воздержания при пороге

--min-confidence T передаёт opt-in порог воздержания ядра (#361) в каждый вызов, который делает прогон, поэтому Router и ONNXAgent помечают ответы, чья answer_confidence ниже T, как low_confidence: True до того, как их увидит harness. В отличие от группировки, это меняет ответы, которые оцениваются: один и тот же прогон при T=0 и T=0.7 — это другой эксперимент, а развёртка precision@coverage — это серия таких, а не одна дрейфующая базовая линия.

Допустимый диапазон — это laya.confidence.check_min_confidence ядра – [0.0, 1.0], конечный, не bool – а не копия здесь, поэтому значение, которое отверг бы сам шлюз, падает как ошибка использования (код 2) до загрузки любого чекпойнта. 0.0 — законный запрос: это контрольное плечо развёртки precision@coverage, и проверка, которая его отбросила бы, скрыла бы собственный пол этой развёртки.

Раннер, чей predict или (для батчевого прогона) чей predict_batch старше этого шлюза, отвергается с именованной EvalError, а не оценивается без порога. Молча отбросить контрольную точку оценки — это тот класс лжи, ради предотвращения которого существует этот harness: отчёт опубликовал бы число precision@coverage для политики, которая никогда не запускалась. config.timing записывает и запрос, и факт: min_confidence — запрошенный порог, min_confidence_sent говорит, нёс ли его фактически хоть один вызов этого прогона.

Срезы

compare и run сообщают общие числа и, при --slice language|model|qid|tag, те же метрики по каждому значению среза, поэтому регрессия в одном языке или одном вопросе видна без чтения агрегата. Срез model содержит чекпойнт, ответивший на каждую строку: собственный выбор Router на каждый запрос или model раннера для раннера, который не маршрутизирует.

Opt-in шлюзы срезов

Общий шлюз базовой линии может пройти, пока меньший срез по языку или вопросу регрессирует. Чтобы сделать один проверенный срез требованием 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. Отсутствующий срез или метрика, слишком мало оценённых ответов или пропущенные/ошибочные случаи — всё это проваливает opt-in шлюз. Относительные правила также требуют, чтобы оба отчёта несли совпадающие идентичности прогона, чтобы отсутствующее доказательство не могло выглядеть как прохождение. Измеренная регрессия сообщает срез, метрику, счётчики, значения и предел. Неверный синтаксис политики завершается кодом 2 до загрузки чекпойнта; провал качества завершается кодом 1. Политика записывается в config.gate_policy отчёта run --json. compare --gate-policy применяет политику, заданную в этой командной строке, к сохранённым измерениям. Если она отличается от записанной в отчёте политики, compare говорит об этом; явная повторная проверка при новой политике не меняет политику, при которой был сделан исходный прогон.

Обычное общее сравнение по-прежнему применяется, включая его допуск и поведение устаревшей базовой линии. Без --gate-policy отчётность и сравнение срезов ведут себя как раньше.

Идентичность прогона

run записывает то, что измерил, в блок config отчёта, поэтому артефакт, который читает проверяющий, проверяем сам по себе:

ключ значение
schema форма отчёта, laya-evals-report/1, чтобы потребитель мог отвергнуть тот, который не умеет читать
dataset путь, как он был введён — имя, а не хеш
dataset_sha256 sha256 байтов датасета, которые были разобраны
questions_sha256 отпечаток схемы вопросов: id, тип, instructions и criteria каждого вопроса, по всему датасету
laya_version laya, вычислившая числа
coverage_metric_definition какое определение aurc / selective_accuracy@* произвело этот отчёт (см. отсечения по покрытию). Относительное правило шлюза по любому из них отвергает базовую линию, записанную при другом определении, вместо вычитания чисел, которые не означают одно и то же
thresholds шлюз, который применил этот прогон: min, max и baseline_tolerance
gate_policy необязательная политика шлюзов срезов, применённая run --gate-policy
revisions коммит, из которого загружался каждый ответивший чекпойнт (см. ниже)

dataset — это путь, а путь не является идентичностью: датасет можно отредактировать на месте, переместить или перекачать под тем же именем, а кэш CI может выдать двум прогонам одно и то же имя файла и разные байты. questions_sha256 покрывает то, что было спрошено, а не сколько строк было, поэтому добавление состояний к неизменному набору вопросов не трогает отпечаток — dataset_sha256 всё равно меняется, а добавление строки — это изменение данных, а не вопроса.

Он покрывает и instructions, потому что текст инструкции — это prompt. build_sequence рендерит "<type> question: <instructions>" в токенизированный head, Agent отвергает вопрос без него («add the text the model should answer»), и собственная идентичность вопроса Laya уже его учитывает: Router._question_schema и батчинг-группировка этого harness’а обе опираются на весь словарь вопросов, а tests/test_router_batch.py фиксирует, что одна лишь переформулировка instructions переводит строку в её собственную группу батча. Так что переформулированная инструкция всё ещё сравнивается как равная базовой линии? Нет — и в этом суть. «Judge whether a refund is justified» и «Be conservative and only approve explicit refund requests» задают разные вопросы, и шлюз метрик может заметить это, только когда разница случайно сдвинет число дальше названного вами допуска. Название варианта choice — тот же аргумент: criteria — это пространство решений, а метаморфические проверки в research/eval/metamorphic.py существуют потому, что переименование метки переворачивает ответы.

В тексте инструкции ничего не нормализуется, кроме единственного шага, который применяет сам движок: нестроковое instructions хешируется как json.dumps(ins, ensure_ascii=False), что совпадает с Agent._to_internal. Поэтому и пробелы, и формулировка имеют значение, а переформулировка, которую человек считает редакторской правкой, трактуется как новый эксперимент. Это честное поведение по умолчанию — альтернатива — эвристика схожести, стоящая между прогоном и его базовой линией, а такой нет ни в одной из общеупотребительных систем оценки.

Ничего, несущего время, не записывается, поэтому отчёт остаётся побайтово воспроизводимым для фиксированного раннера.

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 запускает tests/test_evals.py и tests/test_evals_api.py, поэтому математика метрик, парсинг датасета и CLI покрываются на каждом PR без скачивания чекпойнта;
  • .github/workflows/evals.yml запускается еженедельно, перед релизом и по требованию: он оценивает английский чекпойнт на английском наборе MASSIVE и сравнивает с research/results/eval_english_51_languages.json с допусками из research/evals/thresholds.json. Он загружает отчёт как артефакт и не блокирует PR.

Harness детерминирован для фиксированной ревизии чекпойнта, поэтому отчёт воспроизводим. run записывает датасет, модель и устройство, плюс факты о таймингах прогона, в блок config отчёта, и revisions: коммит, из которого фактически загружался каждый ответивший чекпойнт. --revision <SHA> фиксирует этот коммит для каждого загружаемого прогоном чекпойнта, а --revision english=<SHA> фиксирует один чекпойнт (повторяемо) — это форма, нужная прогону с автоматической маршрутизацией, поскольку три чекпойнта — это три репозитория, и один коммит не может существовать во всех них. Если не фиксировать, прогон берёт ветку чекпойнта по умолчанию, а отчёт всё равно говорит, какой коммит ответил, поэтому дрейф базовой линии можно отнести к весам или к коду. laya/revisions.py публикует проверенные SHA коммитов в PINNED_REVISIONS для вызывающих, которые хотят этим воспользоваться. С --onnx применяется только голый --revision <SHA> — к загрузке конфигурации и токенизатора.

Добавление реального размеченного набора

Положите JSONL в research/evals/ и проверенную базовую линию рядом с ним, затем направьте на оба рабочий процесс (или research/evals/check_regression.py). Формат тот же, что у fixture; ничего в harness не знает о MASSIVE.