Перейти к содержимому
Ступень 77.3 Оценка моделей

Свои эвалы и LLM-as-judge

Как собрать собственный eval-набор под свою задачу, честно посчитать exact match и pass@k, пользоваться LLM-судьёй с поправкой на его смещения и не тратить время на шум — с инструментами lm-eval-harness, OpenCompass и EvalScope.

УРОКstage-7/7-3-evaluation/02-llm-as-judge.mdx1 ч 50 минУрок 2 из 2
После урока вы сможете
  • Собрать eval-набор под свою задачу — от спецификации поведения до замороженного теста и регрессионных прогонов
  • Реализовать exact match с нормализацией и несмещённую оценку pass@k и объяснить, чем плоха «наивная» формула
  • Настроить LLM-судью по рубрике, нейтрализовать позиционное смещение и смещение длины и проверить судью по человеческой разметке (каппа Коэна)
  • Сравнить две версии модели парным бутстрэпом и выбрать подходящий инструмент — lm-eval-harness, OpenCompass или EvalScope

Публичный бенчмарк отвечает на вопрос «какая модель умнее вообще». Вам почти всегда нужен другой ответ: «станет ли мой бот лучше отвечать моим клиентам, если я поменяю промпт / модель / квантование». MMLU об этом молчит. Нужен собственный набор примеров и способ автоматически ставить оценку.

В разработке LLM-систем эвалы играют роль юнит-тестовмодульный тест (юнит-тест)EN unit testМаленькая функция, которая вызывает ваш код и проверяет результат через assert. (урок 0.1.3). Без них каждое изменение — гадание: поправили промпт под одну жалобу и незаметно сломали десять других случаев. С ними вы видите регрессию за минуту. В выпускном проекте (модуль 10.1) свой эвал понадобится на каждом этапе: насколько SFT улучшил следование формату, поднял ли GRPO точность, сколько качества съело квантование.

В этом уроке три слоя: метрики с эталоном (exact match, pass@k), метрики без эталона (LLM-судьяLLM-судья (LLM-as-a-judge)EN LLM-as-a-judgeСильная модель оценивает ответы другой модели по рубрике или сравнивает пару ответов. Дёшево и быстро, но со смещениями.) и статистика, которая отличает улучшение от шума.

  1. Опишите, что значит «хорошо». Не «отвечает качественно», а проверяемые свойства: «ответ на языке вопроса», «число совпадает с эталоном», «JSON проходит схему», «не выдумывает фактов вне контекста». Каждое свойство — будущая метрика.

  2. Соберите 50–200 реальных примеров. Лучший источник — настоящие запросы: логи, письма, вопросы коллег. Синтетика от LLM годится для дополнения, но она «слишком удобная». Покройте все типы запросов (стратификация) и добавьте трудные края: опечатки, смесь языков, очень длинный ввод, провокации.

  3. Для каждого примера задайте способ проверки. Точный ответ, юнит-тесты для кода, JSON-схема или рубрикарубрика (критерии оценивания)EN rubricСписок конкретных проверяемых критериев с баллами. Делает оценку судьи (и человека) воспроизводимой. для открытых ответов. Если сами не можете сказать, какой ответ правильный, модель тоже не оцените.

  4. Разделите dev и test. На dev вы итерируете промпты и модели каждый день. Test заморожен: его открывают перед релизом. Иначе вы подгоните систему под свой же тест — это подгонка под лидерборд («шуабан») в миниатюре (урок 7.3.1).

  5. Зафиксируйте протокол и версионируйте. Файл eval_v1.jsonl, промпт, температуратемпература (сэмплирования)EN temperatureЛогиты делятся на T перед softmax. T < 1 — распределение острее, T > 1 — площе, T → 0 — greedy., seed, модель-судья — всё в git. Изменили набор — eval_v2, и старые числа с новыми напрямую не сравниваются.

  6. Запускайте при каждом изменении и читайте ошибки. Число говорит «стало хуже», а анализ ошибоканализ ошибокEN error analysisРучной разбор неудачных ответов с разметкой причин. Подсказывает, что чинить, лучше любой средней метрики. говорит «почему». Разметьте 20–30 неудач по причинам (не тот формат, галлюцинация, отказ, обрезан вывод) — и вы будете знать, что чинить. Каждый найденный баг превращайте в новый пример набора. Как этот цикл работает на логах продакшена и как поправлять оценку судьи на его ошибки — в уроке 8.4.2.

Пример строк такого набора для бота-репетитора по математике (формат JSONL, JSON Lines: по одному JSON-объекту в каждой строке файла — удобно дописывать и читать построчно):

{"id": "arith-017", "type": "число", "question": "Сколько будет 15% от 240?", "answer": "36"}
{"id": "word-042", "type": "число", "question": "В коробке 3 ряда по 8 конфет. Съели 5. Сколько осталось?", "answer": "19"}
{"id": "en-003", "type": "число", "question": "A class has 28 students. There are 4 more girls than boys. How many boys are there?", "answer": "12"}
{"id": "explain-008", "type": "рубрика", "question": "Объясни, почему на ноль делить нельзя", "rubric": ["приводит пример", "без ошибок", "понятно школьнику"]}

Сколько примеров нужно? Вспомните формулу из прошлого урока (и таблицу размеров набора из урока 1.3.4): при n=100n = 100 и p=0.8p = 0.8 полуширина 95% интервала 1.960.8⋅0.2/100≈7.81.96\sqrt{0.8 \cdot 0.2 / 100} \approx 7.8 п.п. Для грубого контроля «не сломали ли» хватает 50–100 примеров. Чтобы различать изменения в 2–3 п.п., нужны сотни примеров и парное сравнение (ниже).

точное совпадениеточное совпадение (EM)EN exact matchМетрика «1, если нормализованный ответ совпал с эталоном, иначе 0». Всё решает нормализация и извлечение ответа. (EM) — самая простая метрика: 1, если ответ совпал с эталоном, иначе 0. Вся сложность в слове «совпал». Модель ответила «Москва.», эталон — «москва». Это ошибка? Конечно, нет. Поэтому перед сравнением оба ответа нормализуют. Классический рецепт из бенчмарка SQuAD: нижний регистр, удалить пунктуацию и английские артикли, схлопнуть пробелы. Для русского и китайского добавим:

ШагПример до → послеЗачем
Unicode NFKCfinal 𝟒𝟐 → final 42лигатуры из PDF, «стилизованные» буквы и цифры; в китайских текстах так же сводятся к обычным полноширинные латиница и цифры
нижний регистр, ё → еЁлка → елка«ё» пишут непоследовательно
пунктуация → пробел«Война и мир»! → война и миркавычки-«ёлочки», „лапки“, тире, китайские точка и запятая
убрать артикли a / an / thethe Answer → answerангл. ответы
схлопнуть пробелы чай кофе → чай кофелишние пробелы и переносы

NFKC — одна из стандартных «нормальных форм» Unicode: она сводит к одному виду символы, которые выглядят и значат одно и то же, но закодированы по-разному (полноширинные латиница и цифры, лигатуры). Вызов в Python — unicodedata.normalize("NFKC", text).

Но нормализация не спасает от «Ответ: Москва» — лишнее слово остаётся. EM строгая, поэтому ответ сначала извлекают. В GSM8K эталонное решение заканчивается строкой #### 72, и модель просят писать так же. Для чисел есть свои ловушки: «1 000 000» и «1,234» — разделители тысяч, «3,5» — десятичная запятая (по-русски), «72.0» и «72» — одно и то же.

Для кода и математики проверка автоматическая: запустили тесты или сравнили число. Тогда можно дать модели kk попыток и спросить: какова вероятность, что хотя бы одна верна? Это pass@k. Смысл понятен из практики: сгенерировали 10 вариантов функции, прогнали тесты, взяли проходящий. Готовую формулу вы уже применяли к рассуждающим моделям в уроке 6.4.2; здесь разберём, откуда она берётся, почему «наивная» оценка хуже и как считать без переполнения.

Наивный способ — сгенерировать ровно kk ответов на задачу и проверить, есть ли верный. Он работает, но очень шумный. Авторы Codex (статья о HumanEval) предложили лучше: сгенерировать n≥kn \geq k ответов (например, n=200n = 200), посчитать число верных cc и оценить, какова вероятность, что случайная выборка из kk этих ответов содержит верный.

Выборку из kk без верных ответов можно составить (n−ck)\binom{n-c}{k} способами (все kk — из n−cn - c неверных; сочетания — урок 1.0.5), а всего выборок (nk)\binom{n}{k}. Отсюда

pass@k=1−(n−ck)(nk)\text{pass@}k = 1 - \frac{\binom{n-c}{k}}{\binom{n}{k}}

Пример: n=10n = 10 попыток, верны c=2c = 2, хотим pass@5. Выборок из 5 неверных: (85)=56\binom{8}{5} = 56, всего выборок (105)=252\binom{10}{5} = 252. Значит, pass@5 =1−56/252≈0.778= 1 - 56/252 \approx 0.778. Для k=1k = 1 формула превращается в c/nc/n — долю верных попыток. Если неверных меньше kk (n−c<kn - c \lt k), любая выборка содержит верный ответ, и pass@k =1= 1.

Биномиальные коэффициенты быстро становятся огромными: (200100)≈9⋅1058\binom{200}{100} \approx 9 \cdot 10^{58}. В целых числах NumPy (int64, максимум около 9⋅10189 \cdot 10^{18}) это переполнение, а при nn в несколько тысяч не хватит и float64 (максимум около 1030810^{308}). Поэтому в статье Codex дробь переписана в произведение:

(n−ck)(nk)=∏i=n−c+1n(1−ki)\frac{\binom{n-c}{k}}{\binom{n}{k}} = \prod_{i=n-c+1}^{n} \left(1 - \frac{k}{i}\right)

Откуда оно берётся: распишите оба коэффициента через факториалы, k!k! сократится, и останется (n−c)! (n−k)!n! (n−c−k)!\frac{(n-c)!\,(n-k)!}{n!\,(n-c-k)!}. Перегруппируем: (n−k)!(n−c−k)!=(n−k)(n−k−1)⋯(n−c−k+1)\frac{(n-k)!}{(n-c-k)!} = (n-k)(n-k-1)\cdots(n-c-k+1) и n!(n−c)!=n(n−1)⋯(n−c+1)\frac{n!}{(n-c)!} = n(n-1)\cdots(n-c+1) — в каждом произведении ровно cc сомножителей. Делим их попарно: i−ki=1−ki\frac{i-k}{i} = 1 - \frac{k}{i} для ii от n−c+1n-c+1 до nn. Проверим на примере выше (n=10n = 10, c=2c = 2, k=5k = 5): (1−59)(1−510)=49⋅12=29≈0.222(1 - \tfrac{5}{9})(1 - \tfrac{5}{10}) = \tfrac{4}{9} \cdot \tfrac{1}{2} = \tfrac{2}{9} \approx 0.222, и 1−0.222=0.7781 - 0.222 = 0.778 — то же, что через биномиальные коэффициенты.

Множителей всего cc, каждый меньше единицы, и ничего не переполняется. Итог по бенчмарку — среднее pass@k по задачам.

Почему не подставить p=c/np = c/n в формулу 1−(1−p)k1 - (1-p)^k? Это кажется естественным, но такая оценка в среднем занижает результат: функция (1−p)k(1-p)^k выпуклаявыпуклая функцияEN convex function«Чаша» без ложных ям — любой локальный минимум глобальный. Отрезок между двумя точками графика лежит не ниже графика., и по неравенству Йенсена среднее от неё больше, чем она от среднего. На числах при k=5k = 5: если оценка c/nc/n равновероятно оказывается 0 или 0.4 (в среднем 0.2), то среднее значений (1−p)5(1-p)^5 равно (1+0.078)/2≈0.54(1 + 0.078) / 2 \approx 0.54, а значение в среднем 0.85≈0.330.8^5 \approx 0.33. Наивная оценка 1−(1−c/n)k1 - (1 - c/n)^k поэтому в среднем меньше истинной. Проверим численно:

from math import comb
def pass_at_k(n, c, k):
"""Несмещённая оценка: 1 - C(n-c, k) / C(n, k)."""
if n - c < k:
return 1.0
return 1.0 - comb(n - c, k) / comb(n, k)
def naive(n, c, k):
"""«Наивная» оценка: подставить p ≈ c/n в формулу 1 - (1 - p)^k."""
return 1.0 - (1.0 - c / n) ** k
p, n, k = 0.2, 10, 5 # истинная доля верных попыток, попыток на задачу, k
true = 1 - (1 - p) ** k
# Среднее по всем возможным c ~ Binomial(n, p): как оценки ведут себя «в среднем по задачам»
prob = [comb(n, c) * p**c * (1 - p) ** (n - c) for c in range(n + 1)]
e_unb = sum(pr * pass_at_k(n, c, k) for c, pr in enumerate(prob))
e_naive = sum(pr * naive(n, c, k) for c, pr in enumerate(prob))
print(f"истинный pass@{k}: {true:.4f}")
print(f"несмещённая (среднее): {e_unb:.4f}")
print(f"наивная (среднее): {e_naive:.4f}")
print(f"пример n=10, c=2, k=5: несмещённая {pass_at_k(10, 2, 5):.4f}, наивная {naive(10, 2, 5):.4f}")
истинный pass@5: 0.6723
несмещённая (среднее): 0.6723
наивная (среднее): 0.5936
пример n=10, c=2, k=5: несмещённая 0.7778, наивная 0.6723

Несмещённая оценка в среднем точно попадает в истину, наивная ошибается почти на 8 п.п.

МетрикаВопросКогда уместна
pass@kесть ли верный ответ среди kk попыток?есть автоматический проверяющий (тесты), можно перебрать
maj@k (cons@k)верен ли самый частый из kk ответов?проверяющего нет, но ответы короткие (число) — голосование большинствомсамосогласованность (голосование большинством)EN self-consistency (majority voting)Сгенерировать N ответов и выбрать самый частый итоговый. В отчётах — maj@k или cons@k.
pass^kверны ли все kk попыток?надёжность агента, который не может «попробовать ещё раз» (урок 8.3.4)

pass@k растёт с kk даже у слабой модели, поэтому сравнивать модели по pass@100 с pass@1 — нечестно. В отчётах о рассуждающих моделях pass@1 обычно считают как среднее по многим сэмплам — это та же формула с k=1k = 1.

Как проверить пересказ статьи, ответ службы поддержки или объяснение для школьника? Эталона нет, правильных ответов бесконечно много. Есть три варианта: люди (дорого и медленно), простые метрики вроде пересечения слов с эталоном (плохо коррелируют с качеством) и LLM-судья — сильная модель, которой дают рубрику и просят оценить.

Работа LMSYS о MT-Bench (2023) показала: GPT-4 в роли судьи совпадает с человеческими предпочтениями более чем в 80% случаев. Это примерно тот же уровень, на котором люди соглашаются друг с другом. С тех пор LLM-судьи стали стандартом: на них построены AlpacaEval, Arena-Hard, китайский AlignBench и оценка RAG из урока 8.2.2.

Три режима работы судьи:

  • Оценка по шкале (pointwise): «поставь 1–10» или, лучше, «отметь выполненные пункты рубрики».
  • Попарное сравнение (попарное сравнениепопарное сравнениеEN pairwise comparisonВместо оценки «по шкале» судья (человек или модель) выбирает лучший из двух ответов. Так устроены Chatbot Arena и Arena-Hard.): «какой из двух ответов лучше?». Людям и моделям сравнивать проще, чем ставить абсолютную оценку, поэтому этот режим стабильнее.
  • С эталоном (reference-guided): судье показывают правильный ответ. Это сильно помогает в математике и фактах — судья не обязан уметь решить задачу сам.
flowchart LR
Q["Вопрос + эталон"] --> J1["Судья: A первым, B вторым"]
A["Ответ A"] --> J1
B["Ответ B"] --> J1
Q --> J2["Судья: B первым, A вторым"]
A --> J2
B --> J2
J1 --> V{"Вердикты согласны?"}
J2 --> V
V -->|да| W["Победа засчитана"]
V -->|нет| T["Ничья: судья противоречит себе"]
Попарное сравнение с перестановкой — базовая защита от позиционного смещения

Просьба «оцени от 1 до 10» даёт шумные оценки: у судьи нет общей с вами шкалы. Рубрика из конкретных проверяемых пунктов по 0/1 баллу работает намного стабильнее. Добавьте эталон, попросите рассуждать до оценки и выдать результат в JSON, который легко разобрать:

import json
import re
RUBRIC = """Ты — строгий проверяющий. Оцени ответ ассистента на вопрос пользователя.
Критерии (по 1 баллу за каждый выполненный):
1. Фактическая верность: нет ошибок по сравнению с эталоном.
2. Полнота: ответ покрывает все части вопроса.
3. Язык: ответ на том же языке, что и вопрос.
4. Краткость: нет воды и повторов (длина сама по себе не достоинство).
Сначала кратко рассуждай по каждому критерию, затем выведи JSON:
{"верность": 0|1, "полнота": 0|1, "язык": 0|1, "краткость": 0|1}"""
def build_prompt(question, reference, answer):
return (f"{RUBRIC}\n\n[Вопрос]\n{question}\n\n[Эталонный ответ]\n{reference}\n\n"
f"[Ответ ассистента]\n{answer}\n\n[Твоя оценка]")
def parse_scores(text):
"""Берём ПОСЛЕДНИЙ JSON-объект в ответе судьи; если его нет — None (не 0!)."""
blocks = re.findall(r"\{[^{}]*\}", text) # «{», затем любые символы, кроме фигурных скобок, затем «}»
for block in reversed(blocks):
try:
scores = json.loads(block)
return sum(int(v) for v in scores.values()), scores
except (json.JSONDecodeError, ValueError, TypeError):
continue
return None
def judge(call_llm, question, reference, answer):
"""call_llm(prompt) -> str. Подставьте сюда вызов API (урок 8.1.1)."""
return parse_scores(call_llm(build_prompt(question, reference, answer)))
# Проверка без сервера: «фальшивая» модель-судья возвращает готовый текст
fake = lambda prompt: ('Верность: столица указана верно. Полнота: да. Язык: русский. '
'Краткость: да.\n{"верность": 1, "полнота": 1, "язык": 1, "краткость": 1}')
print(judge(fake, "Столица Китая?", "Пекин", "Столица Китая — Пекин."))
print(parse_scores("Не могу оценить."))
(4, {'верность': 1, 'полнота': 1, 'язык': 1, 'краткость': 1})
None

Обратите внимание на None вместо 0. Если судья сломал формат, это сбой оценки, а не плохой ответ. Такие случаи считайте отдельно и перезапускайте, иначе поломки судьи будут выглядеть как ошибки модели. Настоящий call_llm — три строки с клиентом openai из урока 8.1.1, temperature=0 для воспроизводимости (полной она не будет даже так — почему, разбирали в уроке 4.1.3).

Судья — тоже модель, со своими привычками. Главные смещения изучены хорошо:

СмещениеЧто происходитЧто делать
позиционное смещениепозиционное смещениеEN position biasСудья предпочитает ответ на определённом месте (первым или вторым) независимо от содержания. Лечится перестановкой и усреднением.судья предпочитает ответ на определённом местеспрашивать дважды с перестановкой, засчитывать только согласованные вердикты
смещение в пользу длинных ответовсмещение в пользу длинных ответовEN verbosity bias / length biasСудьи (и люди) склонны считать длинный, красиво оформленный ответ лучшим. Отсюда length-controlled метрики и style control.длинный и нарядный ответ кажется лучшепункт рубрики «длина не достоинство»; length-controlled метрики; сравнивать только итоговую часть ответа
предпочтение «своих» ответовпредпочтение «своих» ответовEN self-preference / self-enhancement biasМодель-судья завышает оценки ответам своей же модели или своего семейства.судья завышает оценки своей модели или своему семействусудья из другого семейства; несколько судей
слабость в рассужденияхсудья не замечает ошибку в задаче, которую сам не решитдавать эталонный ответ; проверять автоматически всё, что проверяется автоматически
угодничествоугодничество (sycophancy)EN sycophancyСклонность модели соглашаться с пользователем и льстить ему вместо правдивого ответа — частый побочный эффект RLHF. и «авторитет»ответ с уверенным тоном или ссылками кажется вернымрубрика с проверкой фактов по эталону

Насколько сильно позиционное смещение? Группа из Пекинского университета (2023) показала: если в качестве судьи использовать ChatGPT, то Vicuna-13B «обыгрывает» ChatGPT в 66 вопросах из 80 — достаточно поставить её ответ на выгодное место. Лекарство из той же работы — оценивать в обоих порядках и усреднять. Смоделируем судью, который в 30% случаев не читает ответы и выбирает первый:

import random
random.seed(7)
def biased_judge(question, first, second):
"""Игрушечный «судья»: в 70% случаев честно сравнивает правильность ответов,
в 30% — просто выбирает ответ, показанный первым (позиционное смещение)."""
if random.random() < 0.3:
return "first"
return "first" if first["correct"] >= second["correct"] else "second"
def compare_with_swap(judge, q, a, b):
"""Спрашиваем дважды, меняя порядок. Засчитываем победу, только если вердикты согласны."""
v1 = judge(q, a, b) # A показан первым
v2 = judge(q, b, a) # B показан первым
a_wins_1, a_wins_2 = v1 == "first", v2 == "second"
if a_wins_1 and a_wins_2:
return "A"
if not a_wins_1 and not a_wins_2:
return "B"
return "ничья" # судья противоречит сам себе
# 100 пар ответов, где B на самом деле всегда лучше
pairs = [({"correct": 0}, {"correct": 1}) for _ in range(100)]
single = sum(biased_judge("q", a, b) == "first" for a, b in pairs)
print(f"один проход (A первым): A «выиграл» {single} раз из 100")
res = [compare_with_swap(biased_judge, "q", a, b) for a, b in pairs]
print({k: res.count(k) for k in ("A", "B", "ничья")})
один проход (A первым): A «выиграл» 33 раз из 100
{'A': 0, 'B': 65, 'ничья': 35}

Один проход приписал худшему ответу треть побед. С перестановкой ложных побед ноль, а 35% ничьих честно показывают, насколько судья ненадёжен. Высокая доля ничьих — сигнал менять судью или рубрику.

Смещение длины измерили авторы AlpacaEval: они ввели length-controlled win rate — долю побед с поправкой на разницу длин ответов (через логистическую регрессию, урок 2.2.2). После поправки корреляция Спирмена их рейтинга с Chatbot Arena (корреляция рангов: насколько совпадает порядок моделей в двух рейтингах; 1 — порядок одинаковый) выросла с 0.93 до 0.98, а «накрутить» результат длинными ответами стало примерно в три раза труднее.

Прежде чем доверять судье, разметьте вручную 50–100 ответов и сравните с ним. Простая доля совпадений обманчива. Представьте судью, который почти всегда говорит «ok»: если люди тоже в основном говорят «ok», совпадений будет много случайно. Поэтому используют каппа Коэнакаппа КоэнаEN Cohen's kappaСогласие двух разметчиков с поправкой на случайное совпадение. 0 — как наугад, 1 — полное согласие.: она вычитает согласие, ожидаемое при случайном угадывании.

κ=po−pe1−pe\kappa = \frac{p_o - p_e}{1 - p_e}

Здесь pop_o — наблюдаемая доля совпадений, pep_e — доля совпадений, если бы оба ставили метки наугад с теми же частотами. κ=0\kappa = 0 — как случайно, κ=1\kappa = 1 — полное согласие. Распространённая шкала Лэндиса и Коха: 0.21–0.40 — слабое (fair) согласие, 0.41–0.60 — умеренное, 0.61–0.80 — существенное, выше 0.80 — почти полное. Для судьи, которому доверяют решения, ориентируйтесь хотя бы на 0.6.

def cohen_kappa(x, y):
"""Согласие двух разметчиков с поправкой на случайное совпадение."""
n = len(x)
p_o = sum(a == b for a, b in zip(x, y)) / n # наблюдаемое согласие
labels = set(x) | set(y)
p_e = sum((x.count(l) / n) * (y.count(l) / n) for l in labels) # согласие «наугад»
return p_o, (p_o - p_e) / (1 - p_e)
# 100 ответов: человек и LLM-судья ставят «ok» или «плохо»
human = ["ok"] * 76 + ["ok"] * 4 + ["плохо"] * 14 + ["плохо"] * 6
judge = ["ok"] * 76 + ["плохо"] * 4 + ["ok"] * 14 + ["плохо"] * 6
p_o, k = cohen_kappa(human, judge)
print(f"доля совпадений: {p_o:.2f}, каппа Коэна: {k:.2f}")
print(f"судья говорит «ok» в {judge.count('ok')}% случаев, человек — в {human.count('ok')}%")
доля совпадений: 0.82, каппа Коэна: 0.31
судья говорит «ok» в 90% случаев, человек — в 80%

Посчитаем вручную: человек говорит «ok» в 80% случаев, судья — в 90%. Если бы оба ставили метки наугад с этими частотами, совпадений было бы pe=0.8⋅0.9+0.2⋅0.1=0.74p_e = 0.8 \cdot 0.9 + 0.2 \cdot 0.1 = 0.74. Тогда κ=(0.82−0.74)/(1−0.74)≈0.31\kappa = (0.82 - 0.74) / (1 - 0.74) \approx 0.31.

82% совпадений звучит хорошо, но каппа 0.31 — слабое согласие. Судья пропустил 14 из 20 плохих ответов: он просто почти всегда говорит «ok». Если ваша цель — ловить плохие ответы, такой судья бесполезен. Та же каппа проверяет и людей: как мерить согласие разметчиков на нескольких классах и чинить правила разметки по таблице расхождений — в уроке 11.1.1.

Вы поменяли промпт, и accuracy на 200 примерах выросла с 60% до 65%. Это улучшение? Отдельные интервалы (±6.7\pm 6.7 п.п. каждый) перекрываются, и кажется, что ничего сказать нельзя. Но обе версии отвечали на одни и те же вопросы, а лёгкие вопросы обе решают, трудные обе проваливают. Парный подход это учитывает: пересэмплируем вопросы с возвращением (бутстрэпбутстрэп (выборка с возвращением)EN bootstrap samplingОснова бэггинга (bagging) и случайного леса., урок 1.3.4) и каждый раз считаем разницу на одной и той же выборке.

import numpy as np
rng = np.random.default_rng(42)
n = 200 # вопросов в вашем eval-наборе
skill = rng.random(n) # «сложность» вопросов общая для обеих версий
a = (skill < 0.66).astype(int) # версия A решает 66% вопросов
b = (skill < 0.61).astype(int) # версия B решает 61% — те же лёгкие вопросы
flip = rng.random(n) < 0.08 # и немного случайного шума
b[flip] = 1 - b[flip]
print(f"A: {a.mean():.3f} B: {b.mean():.3f} разница: {a.mean() - b.mean():+.3f}")
# Парный бутстрэп: пересэмплируем ВОПРОСЫ, обе версии — на одних и тех же вопросах
diffs = []
for _ in range(10_000):
idx = rng.integers(0, n, n) # n случайных номеров вопросов с повторами (выборка с возвращением)
diffs.append(a[idx].mean() - b[idx].mean())
lo, hi = np.percentile(diffs, [2.5, 97.5]) # границы, между которыми лежат средние 95% разниц
print(f"95% интервал разницы: [{lo:+.3f}, {hi:+.3f}]")
print(f"доля бутстрэп-выборок, где A не лучше B: {np.mean(np.array(diffs) <= 0):.3f}")
# Для сравнения — «непарный» интервал, как если бы версии решали разные наборы
se = np.sqrt(a.mean() * (1 - a.mean()) / n + b.mean() * (1 - b.mean()) / n)
print(f"непарный 95% интервал: ±{1.96 * se:.3f} — включает ноль")
A: 0.650 B: 0.600 разница: +0.050
95% интервал разницы: [+0.010, +0.090]
доля бутстрэп-выборок, где A не лучше B: 0.009
непарный 95% интервал: ±0.095 — включает ноль

Парное сравнение уверенно видит улучшение там, где непарное его не различает. Правило: сравнивайте версии на одних и тех же примерах и с одними и теми же настройками, а интервал считайте по разнице.

Для своего набора из сотни примеров хватит скрипта на 50 строк. Для стандартных бенчмарков и больших прогонов берите готовый фреймворк — он знает форматы промптов, парсеры ответов и метрики сотен задач.

ИнструментКто делаетЧем силён
lm-evaluation-harnessEleutherAI, MITде-факто стандарт для базовых моделей, сотни задач, бэкенды HF и vLLM; на нём работал Open LLM Leaderboard
OpenCompassShanghai AI Lab, Apache-2.0очень много готовых наборов, сильное покрытие китайских бенчмарков, объективная и субъективная оценка с судьёй, CircularEval, проверка контаминации
EvalScopeModelScope (Alibaba), Apache-2.0оценка через OpenAI-совместимый API, режим арены (попарные «бои» моделей), стресс-тест скорости: TTFT и время на токен

Все три умеют оценивать модель, поднятую как OpenAI-совместимый сервер (уроки 7.2.2 и 8.1.1). Значит, один и тот же прогон сработает и для облачного API, и для вашей локальной модели. Пример из README EvalScope ($OPENAI_API_BASE_URL — адрес сервера, например http://localhost:8000/v1):

Окно терминала
evalscope eval \
--model your-model-name \
--api-url $OPENAI_API_BASE_URL \
--api-key $OPENAI_API_KEY \
--eval-type openai_api \
--datasets gsm8k arc \
--limit 5

--limit 5 — только 5 примеров на набор, для проверки, что всё настроено. Уберите его для полного прогона.

Реализуйте метрики, которые нужны любому эвалу: нормализацию ответа, exact match со списком допустимых ответов, извлечение числа из рассуждения модели (с ####, разделителями тысяч и десятичной запятой) и несмещённый pass@k. Тесты проверяют русские и английские примеры, а также «экзотические» символы Unicode — лигатуры и «жирные» цифры.

Exact match, извлечение числа и pass@k8 тестов
import re
import unicodedata
from decimal import Decimal

ARTICLES = {"a", "an", "the"}


def normalize_answer(text):
    """Приводит ответ к каноническому виду. Шаги по порядку:

    1. unicodedata.normalize("NFKC", text) — лигатура «fi» станет «fi», «жирные» цифры «𝟏𝟐» — обычными «12»;
    2. нижний регистр и замена «ё» на «е»;
    3. каждый символ пунктуации (unicodedata.category(ch) начинается с "P") — на пробел;
    4. убрать английские артикли a / an / the как отдельные слова;
    5. схлопнуть пробелы: слова через один пробел, без пробелов по краям.

    normalize_answer("  «Ёлка»!  ") == "елка"
    """
    # TODO: ваш код
    ...


def exact_match(prediction, gold):
    """1.0, если normalize_answer(prediction) совпал с нормализованным эталоном, иначе 0.0.

    gold — строка или список допустимых ответов (совпадения с любым достаточно).
    """
    # TODO: ваш код
    ...


def extract_number(text):
    """Последнее число в тексте в каноническом виде или None, если чисел нет.

    - если в тексте есть "####" (формат GSM8K), ищем только после последнего "####";
    - «1 000 000» и «1,234» — разделители тысяч (группа начинается не с 0, дальше ровно по 3 цифры);
    - «3,5» — десятичная запятая, это 3.5;
    - минус учитывается: «-7» → '-7', но в «5-7 лет» последнее число — '7';
    - канонический вид: '72.0' → '72', '3.50' → '3.5' (пригодится Decimal).
    """
    # TODO: ваш код
    ...


def pass_at_k(n, c, k):
    """Несмещённая оценка pass@k: из n попыток c верных, какова вероятность,
    что среди k случайно выбранных попыток есть хотя бы одна верная?

    pass@k = 1 - C(n-c, k) / C(n, k). При n - c < k ответ 1.0.
    Неверные аргументы (не 0 ≤ c ≤ n или не 1 ≤ k ≤ n) — raise ValueError.
    """
    # TODO: ваш код
    ...


def mean_pass_at_k(results, k):
    """Среднее pass@k по задачам. results[j] — список True/False для попыток задачи j."""
    # TODO: ваш код
    ...


# Проверьте себя кнопкой «Запустить»:
print(normalize_answer("  «Ёлка»!  "), "|", exact_match("The Moscow.", ["москва", "Moscow"]))
print(extract_number("Сначала 12 яблок, итого #### 1,234"), extract_number("Ответ: 3,50 кг"))
print(pass_at_k(10, 2, 5), mean_pass_at_k([[True, False, False, False], [True] * 4], 2))
Проверь себя6 вопросов
1 Из n=10n = 10 сгенерированных решений задачи верны c=3c = 3. Чему равен несмещённый pass@2? Округлите до трёх знаков.
2 Модель ответила «Ответ: Москва.», эталон — «москва». Что покажет exact match с нормализацией (регистр, пунктуация, пробелы)?
3 Какие меры снижают позиционное смещение LLM-судьи при попарном сравнении?Можно выбрать несколько ответов
4 Судья и человек независимо друг от друга ставят «хорошо» в 90% случаев каждый и совпадают в 82% случаев. Чему равна каппа Коэна? (с точностью до 0.01)
5 Вы улучшили промпт: на тех же 200 примерах accuracy выросла с 60% до 65%. Как правильнее проверить, что это не шум?
6 Почему DeepSeek при оценке R1 судьёй GPT-4 показывал судье только итоговую часть ответа?
  • Свой eval-набор — это спецификация «что значит хорошо», 50–200 реальных примеров со способом проверки, разделение dev/test, версии и регулярные прогоны с разбором ошибок.
  • Exact match работает только с нормализацией и извлечением ответа. pass@k считайте по несмещённой формуле 1−(n−ck)/(nk)1 - \binom{n-c}{k}/\binom{n}{k} в форме произведения.
  • LLM-судья дёшев и в среднем близок к людям, но смещён: позиция, длина, «свои» ответы, слабость в рассуждениях. Лечится рубрикой, эталоном, перестановкой и судьёй из другого семейства.
  • Судью проверяют по ручной разметке через каппу Коэна, версии сравнивают парным бутстрэпом.
  • Готовые фреймворки — lm-evaluation-harness, OpenCompass, EvalScope — работают с любой моделью за OpenAI-совместимым API.

Дальше: ступень 8 — приложения на LLM. Начнём с OpenAI-совместимых API, а эвалы из этого урока будем применять к промптам, RAG и агентам.