Калибровка¶
CustomEvalMetric оценивает с помощью LLM-судьи. Прежде чем доверять его оценкам, полезно понять, насколько он согласуется с решениями вашей команды — и там, где расходится, как поправить критерии, примеры или порог. Этим и занимается пакет eval_lib.calibration.
Контракт поведения
Калибровка аддитивна — она не меняет поведение существующих метрик. Всё, что она делает, офлайн: она пересчитывает сохранённые данные тест-кейсов через судью и никогда не вызывает ваше приложение. И она ничего не применяет автоматически — каждая процедура возвращает черновик конфигурации и доказательную базу под ним, чтобы вы проверили перед внедрением.
Что когда использовать¶
| Ситуация | Инструмент |
|---|---|
| Просто задать, насколько строго судит судья | few_shot_examples у метрики |
| Измерить согласие / подобрать порог, без LLM | agreement, threshold_sweep, criterion_attribution |
| Улучшить малый размеченный набор (≈10–30 строк) | improve() |
| Оптимизировать больший набор с проверкой на holdout (≥40 меток) | CalibrationOptimizer |
Строительные блоки¶
| Объект | Назначение |
|---|---|
CustomEvalConfig | Версионируемое, модель-независимое описание CustomEvalMetric (критерии, стратегия, порог, few-shot). to_metric(model) строит живую метрику; from_params(dict) читает metric-params словарь платформы |
HumanLabel | Решение ревьюера по кейсу: verdict ("pass"/"fail"), опциональные reason, score, по-критериальные criterion_verdicts, reviewer_id |
LabeledCase | Одна размеченная строка: id, test_case (EvalTestCase), label, опциональные extra_labels (другие ревьюеры), judge_result (сохранённый вывод evaluate()), meta |
CalibrationSet | config + cases + доступные в данных placeholders. split(...) делает стабильный train/holdout Split |
FewShotExample | Размеченный человеком референс, показываемый судье — см. Кастомные метрики |
Budget | Лимит на вызовы LLM / стоимость, общий на весь прогон — см. ниже |
Все модели — pydantic v2 и сериализуются в JSON (несут schema_version), так что набор для калибровки можно сохранить и загрузить обратно.
1. Соберите размеченные кейсы¶
Оберните каждую проверенную строку в LabeledCase, затем сгруппируйте их с проверяемой конфигурацией в CalibrationSet.
from eval_lib import EvalTestCase
from eval_lib.calibration import (
CalibrationSet, CustomEvalConfig, HumanLabel, LabeledCase,
)
config = CustomEvalConfig(
name="support_quality",
evaluation_criteria=[
"{{actual_output}} directly answers {{input}}",
"{{actual_output}} stays grounded in {{retrieval_context}}",
],
strategy="verdict",
threshold=0.6,
)
cases = [
LabeledCase(
id="row-001",
test_case=EvalTestCase(
input="Как сбросить пароль?",
actual_output="Нажмите 'Забыли пароль' на экране входа.",
retrieval_context=["Сброс пароля начинается со ссылки 'Забыли пароль'."],
),
label=HumanLabel(verdict="pass", reason="Прямо и корректно."),
),
# … ~10–30+ размеченных строк
]
cal = CalibrationSet(config=config, cases=cases)
Переиспользуйте уже имеющийся вывод судьи
Если у строки уже есть результат evaluate(), сохраните его в LabeledCase.judge_result (или используйте LabeledCase.from_test_case(...), который заодно захватывает _meta коннектора). Тогда слой статистики посчитает согласие с нулём вызовов LLM.
2. Измерьте согласие (без LLM)¶
Эти функции чистые — ни LLM, ни NumPy/SciPy. Положительный класс — fail (важнее всего ловить ответы, которые команда отклонила бы), строки без вердикта судьи исключаются.
from eval_lib.calibration import (
agreement, inter_annotator, threshold_sweep, criterion_attribution,
)
report = agreement(cal.cases) # нужно ≥ min_n (по умолчанию 20) для числовых метрик
print(report.accuracy, report.cohen_kappa, report.f1_fail)
print(report.confusion.false_fail.case_ids) # строки, где судья поставил fail, а люди pass
sweep = threshold_sweep(cal.cases, objective="kappa")
print(sweep.best.threshold, sweep.unstable)
attrib = criterion_attribution(cal.cases) # skip-rate по критериям + виновники
agreement(cases, min_n=20)→AgreementReport(accuracy + CI, precision/recall/F1 по fail, каппа Коэна + CI, confusion с case-id, опционально Spearman/MAE). Нижеmin_nвозвращаетstatus="insufficient"и не выдаёт числовые метрики.inter_annotator(cases)→InterAnnotatorReport(ревьюер vs ревьюер: процент согласия, каппа Коэна для 2 оценщиков, каппа Фляйса для ≥3). Используйте, чтобы проверить консистентность меток до того, как винить судью.threshold_sweep(cases, objective="kappa", min_recall_fail=None)→ThresholdReport(objective∈"kappa"/"accuracy"/"balanced_accuracy", либо precision при полу́ле recall, если заданmin_recall_fail).criterion_attribution(cases)→list[CriterionStats](skip-rate по каждому критерию и какие критерии привели к false-fail / false-pass).
3. Улучшите малый набор — improve()¶
Для частого случая 10–30 размеченных строк improve() прогоняет весь цикл и отдаёт черновик. Он оценивает текущую конфигурацию («before»), просит AI-аналитика диагностировать расходящиеся строки, строит до четырёх кандидатов, подбирает каждому порог на его же оценках и оставляет лучший — повторяя несколько раундов.
from eval_lib.calibration import improve, Budget
ids = [c.id for c in cal.cases]
result = await improve(
cal,
learn_cases=ids, # строки, на которых аналитик может учиться
measure_cases=ids, # строки, по которым сравниваются кандидаты
model="openai:gpt-4o-mini",
budget=Budget(max_llm_calls=40),
keep_threshold=True, # по умолчанию: держать ВАШ порог, подгоняя критерии под него
max_rounds=3, # по умолчанию
rejudge=True, # по умолчанию: пересудить каждую строку текущей конфигурацией
)
print(result.verdict) # "improved" | "no_improvement"
print(result.proposed_config) # черновик — improve() ничего не применяет
for rnd in result.rounds:
print(rnd.round, rnd.winner, rnd.agree, "/", rnd.n)
print(result.remaining) # case-id, всё ещё расходящиеся, с причиной судьи
Четыре кандидата: A только порог (без LLM; только при keep_threshold=False), B правки критериев, C B + few-shot примеры, D всё прочее, что предложил аналитик. Кандидат побеждает, только если согласующихся строк хотя бы на одну больше, чем в «before» (ничья → меньше правок). ImproveResult также несёт before, candidates (раунд 1), analysis, cost_usd, llm_calls и stopped_by_budget.
keep_threshold=True(по умолчанию) оценивает кандидатов на пороге самой конфигурации и подгоняет под него критерии — чтобы вы не внедрили порог, молча настроенный на старых оценках.False— ещё и свипить порог.rejudge=True(по умолчанию) пересуживает каждую строку текущей конфигурацией, так что метки со старых версий метрики остаются применимыми.
4. Оптимизируйте больший набор — CalibrationOptimizer¶
Когда меток больше, разбейте набор и проверяйте победителя на отложенной (holdout) части, которую аналитик никогда не видит.
from eval_lib.calibration import CalibrationOptimizer, InsufficientLabels
split = cal.split(holdout_ratio=0.3, seed=0) # стабильно, стратифицировано по вердикту человека
optimizer = CalibrationOptimizer(
model="openai:gpt-4o-mini",
max_candidates=6,
min_labels=40, # ниже этого поднимает InsufficientLabels
min_per_class=10, # … либо ниже этого числа pass/fail в каждом классе
)
try:
opt = await optimizer.optimize(cal, split, budget=Budget(max_cost_usd=2.0))
except InsufficientLabels as e:
print("Пока недостаточно меток:", e)
else:
print(opt.verdict) # "improved" | "not_significant" | "worse"
print(opt.diff) # что изменилось относительно текущей конфигурации
print(opt.holdout_comparison) # тест Макнемара на отложенной части
proposed = opt.proposed_config # черновик
CalibrationSet.split(holdout_ratio=0.3, seed=0, pinned_train=None) детерминирован (стабильный хэш seed:id), стратифицирован по вердикту человека и принудительно загоняет в train все few-shot source_case_id (и любые pinned_train).
5. Внедрите результат¶
И improve(), и optimize() отдают proposed_config (CustomEvalConfig). Превратите его в живую метрику через to_metric(model) — конфигурация модель-независима, поэтому модель передаётся только здесь:
metric = result.proposed_config.to_metric("openai:gpt-4o-mini")
# используйте `metric` в evaluate([...], [metric]) как обычную рукописную CustomEvalMetric
Чтобы проверить или применить отдельные правки вручную:
from eval_lib.calibration import apply_proposals, diff_configs
new_config = apply_proposals(config, result.analysis.proposals, cases=cal.cases)
print(diff_configs(config, new_config)) # добавленные/удалённые/изменённые критерии, параметры, few-shot
apply_proposals возвращает новую конфигурацию (исходную не мутирует); конфликтующие правки одного критерия поднимают ValueError, а предложения add_few_shot требуют аргумент cases=.
Бюджет и оценка стоимости¶
Каждая процедура, трогающая LLM, принимает Budget; сперва оцените стоимость, чтобы не было сюрпризов.
from eval_lib.calibration import (
Budget, BudgetExceeded,
estimate_evaluation, estimate_analysis, estimate_optimization,
)
est = estimate_optimization(cal, split, max_candidates=6, model="openai:gpt-4o-mini")
print(est.llm_calls, est.input_tokens, est.output_tokens, est.cost_usd)
budget = Budget(max_llm_calls=200, max_cost_usd=1.0) # любой/оба; None = без лимита
Budget(max_llm_calls=None, max_cost_usd=None)asyncio-безопасен; вызов, который превысил бы лимит, поднимаетBudgetExceeded, и в результате выставляется флагstopped_by_budget.estimate_evaluation/estimate_analysis/estimate_optimization→CostEstimate(llm_calls, input_tokens, output_tokens, cost_usd).cost_usdзаполняется только для моделей из прайс-каталога (строковые id); дляCustomLLMClientили неизвестной модели онNone.
Низкоуровневые блоки¶
Если хотите управлять циклом сами:
CalibrationAnalyst(model, batch_size=25, max_cases=300, contrast_ratio=0.2, concurrency=4, language="en")—await analyze(cal, cases=None, budget=None, threshold_target=None)читает каждое расхождение судьи и человека, диагностирует (map → reduce → propose) и возвращаетAnalysisResult(findings,proposals,rejected,warnings, …). LLM только диагностирует и формулирует; все подсчёты — в коде, порог предлагаетthreshold_sweep, каждое предложение валидируется.await evaluate_config(config, cases, model, budget=None, concurrency=8)→EvaluationRun— пересчитывает сохранённые кейсы только через судью (никогда не через ваше приложение), захватывая ошибки по строкам.compare(before, after)→Comparison— парный тест Макнемара по двум прогонам одних и тех же кейсов, сfixed/brokencase-id и флагомsignificant.
Сводка API¶
from eval_lib import FewShotExample
from eval_lib.calibration import (
# модели
CustomEvalConfig, HumanLabel, LabeledCase, CalibrationSet, Split, Proposal,
# статистика (без LLM)
agreement, inter_annotator, threshold_sweep, threshold_sweep_run,
criterion_attribution,
# кандидаты
apply_proposals, diff_configs, evaluate_config, compare,
# аналитик / оптимизатор / improve для малых наборов
CalibrationAnalyst, CalibrationOptimizer, improve,
# бюджет и оценки
Budget, BudgetExceeded, InsufficientLabels,
estimate_evaluation, estimate_analysis, estimate_optimization,
)
См. также: Кастомные метрики · Агрегация оценок · API-справочник.