Перейти к содержанию

Калибровка

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 / broken case-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-справочник.