Свои эвалы и LLM-as-judge
Как собрать собственный 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Сильная модель оценивает ответы другой модели по рубрике или сравнивает пару ответов. Дёшево и быстро, но со смещениями.) и статистика, которая отличает улучшение от шума.
Свой eval-набор за вечер
Заголовок раздела «Свой eval-набор за вечер»-
Опишите, что значит «хорошо». Не «отвечает качественно», а проверяемые свойства: «ответ на языке вопроса», «число совпадает с эталоном», «JSON проходит схему», «не выдумывает фактов вне контекста». Каждое свойство — будущая метрика.
-
Соберите 50–200 реальных примеров. Лучший источник — настоящие запросы: логи, письма, вопросы коллег. Синтетика от LLM годится для дополнения, но она «слишком удобная». Покройте все типы запросов (стратификация) и добавьте трудные края: опечатки, смесь языков, очень длинный ввод, провокации.
-
Для каждого примера задайте способ проверки. Точный ответ, юнит-тесты для кода, JSON-схема или рубрикарубрика (критерии оценивания)EN rubricСписок конкретных проверяемых критериев с баллами. Делает оценку судьи (и человека) воспроизводимой. для открытых ответов. Если сами не можете сказать, какой ответ правильный, модель тоже не оцените.
-
Разделите dev и test. На dev вы итерируете промпты и модели каждый день. Test заморожен: его открывают перед релизом. Иначе вы подгоните систему под свой же тест — это подгонка под лидерборд («шуабан») в миниатюре (урок 7.3.1).
-
Зафиксируйте протокол и версионируйте. Файл
eval_v1.jsonl, промпт, температуратемпература (сэмплирования)EN temperatureЛогиты делятся на T перед softmax. T < 1 — распределение острее, T > 1 — площе, T → 0 — greedy., seed, модель-судья — всё в git. Изменили набор —eval_v2, и старые числа с новыми напрямую не сравниваются. -
Запускайте при каждом изменении и читайте ошибки. Число говорит «стало хуже», а анализ ошибоканализ ошибок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): при и полуширина 95% интервала п.п. Для грубого контроля «не сломали ли» хватает 50–100 примеров. Чтобы различать изменения в 2–3 п.п., нужны сотни примеров и парное сравнение (ниже).
Метрики с эталоном: exact match и нормализация
Заголовок раздела «Метрики с эталоном: exact match и нормализация»точное совпадениеточное совпадение (EM)EN exact matchМетрика «1, если нормализованный ответ совпал с эталоном, иначе 0». Всё решает нормализация и извлечение ответа. (EM) — самая простая метрика: 1, если ответ совпал с эталоном, иначе 0. Вся сложность в слове «совпал». Модель ответила «Москва.», эталон — «москва». Это ошибка? Конечно, нет. Поэтому перед сравнением оба ответа нормализуют. Классический рецепт из бенчмарка SQuAD: нижний регистр, удалить пунктуацию и английские артикли, схлопнуть пробелы. Для русского и китайского добавим:
| Шаг | Пример до → после | Зачем |
|---|---|---|
| Unicode NFKC | final 𝟒𝟐 → final 42 | лигатуры из PDF, «стилизованные» буквы и цифры; в китайских текстах так же сводятся к обычным полноширинные латиница и цифры |
нижний регистр, ё → е | Ёлка → елка | «ё» пишут непоследовательно |
| пунктуация → пробел | «Война и мир»! → война и мир | кавычки-«ёлочки», „лапки“, тире, китайские точка и запятая |
| убрать артикли a / an / the | the Answer → answer | англ. ответы |
| схлопнуть пробелы | чай кофе → чай кофе | лишние пробелы и переносы |
NFKC — одна из стандартных «нормальных форм» Unicode: она сводит к одному виду символы, которые выглядят и значат
одно и то же, но закодированы по-разному (полноширинные латиница и цифры, лигатуры). Вызов в Python —
unicodedata.normalize("NFKC", text).
Но нормализация не спасает от «Ответ: Москва» — лишнее слово остаётся. EM строгая, поэтому ответ сначала
извлекают. В GSM8K эталонное решение заканчивается строкой #### 72, и модель просят писать так же. Для чисел есть
свои ловушки: «1 000 000» и «1,234» — разделители тысяч, «3,5» — десятичная запятая (по-русски), «72.0» и «72» — одно и то же.
pass@k: «хотя бы одна из k попыток»
Заголовок раздела «pass@k: «хотя бы одна из k попыток»»Для кода и математики проверка автоматическая: запустили тесты или сравнили число. Тогда можно дать модели попыток и спросить: какова вероятность, что хотя бы одна верна? Это pass@k. Смысл понятен из практики: сгенерировали 10 вариантов функции, прогнали тесты, взяли проходящий. Готовую формулу вы уже применяли к рассуждающим моделям в уроке 6.4.2; здесь разберём, откуда она берётся, почему «наивная» оценка хуже и как считать без переполнения.
Наивный способ — сгенерировать ровно ответов на задачу и проверить, есть ли верный. Он работает, но очень шумный. Авторы Codex (статья о HumanEval) предложили лучше: сгенерировать ответов (например, ), посчитать число верных и оценить, какова вероятность, что случайная выборка из этих ответов содержит верный.
Выборку из без верных ответов можно составить способами (все — из неверных; сочетания — урок 1.0.5), а всего выборок . Отсюда
Пример: попыток, верны , хотим pass@5. Выборок из 5 неверных: , всего выборок . Значит, pass@5 . Для формула превращается в — долю верных попыток. Если неверных меньше (), любая выборка содержит верный ответ, и pass@k .
Биномиальные коэффициенты быстро становятся огромными: . В целых числах NumPy (int64, максимум около ) это переполнение, а при в несколько тысяч не хватит и float64 (максимум около ). Поэтому в статье Codex дробь переписана в произведение:
Откуда оно берётся: распишите оба коэффициента через факториалы, сократится, и останется . Перегруппируем: и — в каждом произведении ровно сомножителей. Делим их попарно: для от до . Проверим на примере выше (, , ): , и — то же, что через биномиальные коэффициенты.
Множителей всего , каждый меньше единицы, и ничего не переполняется. Итог по бенчмарку — среднее pass@k по задачам.
Почему не подставить в формулу ? Это кажется естественным, но такая оценка в среднем занижает результат: функция выпуклаявыпуклая функцияEN convex function«Чаша» без ложных ям — любой локальный минимум глобальный. Отрезок между двумя точками графика лежит не ниже графика., и по неравенству Йенсена среднее от неё больше, чем она от среднего. На числах при : если оценка равновероятно оказывается 0 или 0.4 (в среднем 0.2), то среднее значений равно , а значение в среднем . Наивная оценка поэтому в среднем меньше истинной. Проверим численно:
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 # истинная доля верных попыток, попыток на задачу, ktrue = 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, maj@k и pass^k — три разных вопроса
Заголовок раздела «pass@k, maj@k и pass^k — три разных вопроса»| Метрика | Вопрос | Когда уместна |
|---|---|---|
| pass@k | есть ли верный ответ среди попыток? | есть автоматический проверяющий (тесты), можно перебрать |
| maj@k (cons@k) | верен ли самый частый из ответов? | проверяющего нет, но ответы короткие (число) — голосование большинствомсамосогласованность (голосование большинством)EN self-consistency (majority voting)Сгенерировать N ответов и выбрать самый частый итоговый. В отчётах — maj@k или cons@k. |
| pass^k | верны ли все попыток? | надёжность агента, который не может «попробовать ещё раз» (урок 8.3.4) |
pass@k растёт с даже у слабой модели, поэтому сравнивать модели по pass@100 с pass@1 — нечестно. В отчётах о рассуждающих моделях pass@1 обычно считают как среднее по многим сэмплам — это та же формула с .
LLM-as-judge: когда эталона нет
Заголовок раздела «LLM-as-judge: когда эталона нет»Как проверить пересказ статьи, ответ службы поддержки или объяснение для школьника? Эталона нет, правильных ответов бесконечно много. Есть три варианта: люди (дорого и медленно), простые метрики вроде пересечения слов с эталоном (плохо коррелируют с качеством) и 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 jsonimport 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 — полное согласие.: она вычитает согласие, ожидаемое при случайном угадывании.
Здесь — наблюдаемая доля совпадений, — доля совпадений, если бы оба ставили метки наугад с теми же частотами. — как случайно, — полное согласие. Распространённая шкала Лэндиса и Коха: 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 + ["плохо"] * 6judge = ["ok"] * 76 + ["плохо"] * 4 + ["ok"] * 14 + ["плохо"] * 6p_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%. Если бы оба ставили метки наугад с этими частотами, совпадений было бы . Тогда .
82% совпадений звучит хорошо, но каппа 0.31 — слабое согласие. Судья пропустил 14 из 20 плохих ответов: он просто почти всегда говорит «ok». Если ваша цель — ловить плохие ответы, такой судья бесполезен. Та же каппа проверяет и людей: как мерить согласие разметчиков на нескольких классах и чинить правила разметки по таблице расхождений — в уроке 11.1.1.
Изменение или шум: парный бутстрэп
Заголовок раздела «Изменение или шум: парный бутстрэп»Вы поменяли промпт, и accuracy на 200 примерах выросла с 60% до 65%. Это улучшение? Отдельные интервалы ( п.п. каждый) перекрываются, и кажется, что ничего сказать нельзя. Но обе версии отвечали на одни и те же вопросы, а лёгкие вопросы обе решают, трудные обе проваливают. Парный подход это учитывает: пересэмплируем вопросы с возвращением (бутстрэпбутстрэп (выборка с возвращением)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.05095% интервал разницы: [+0.010, +0.090]доля бутстрэп-выборок, где A не лучше B: 0.009непарный 95% интервал: ±0.095 — включает нольПарное сравнение уверенно видит улучшение там, где непарное его не различает. Правило: сравнивайте версии на одних и тех же примерах и с одними и теми же настройками, а интервал считайте по разнице.
Инструменты
Заголовок раздела «Инструменты»Для своего набора из сотни примеров хватит скрипта на 50 строк. Для стандартных бенчмарков и больших прогонов берите готовый фреймворк — он знает форматы промптов, парсеры ответов и метрики сотен задач.
| Инструмент | Кто делает | Чем силён |
|---|---|---|
| lm-evaluation-harness | EleutherAI, MIT | де-факто стандарт для базовых моделей, сотни задач, бэкенды HF и vLLM; на нём работал Open LLM Leaderboard |
| OpenCompass | Shanghai AI Lab, Apache-2.0 | очень много готовых наборов, сильное покрытие китайских бенчмарков, объективная и субъективная оценка с судьёй, CircularEval, проверка контаминации |
| EvalScope | ModelScope (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 — лигатуры и «жирные» цифры.
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))
Проверь себя
Заголовок раздела «Проверь себя»- Свой eval-набор — это спецификация «что значит хорошо», 50–200 реальных примеров со способом проверки, разделение dev/test, версии и регулярные прогоны с разбором ошибок.
- Exact match работает только с нормализацией и извлечением ответа. pass@k считайте по несмещённой формуле в форме произведения.
- LLM-судья дёшев и в среднем близок к людям, но смещён: позиция, длина, «свои» ответы, слабость в рассуждениях. Лечится рубрикой, эталоном, перестановкой и судьёй из другого семейства.
- Судью проверяют по ручной разметке через каппу Коэна, версии сравнивают парным бутстрэпом.
- Готовые фреймворки — lm-evaluation-harness, OpenCompass, EvalScope — работают с любой моделью за OpenAI-совместимым API.
Дальше: ступень 8 — приложения на LLM. Начнём с OpenAI-совместимых API, а эвалы из этого урока будем применять к промптам, RAG и агентам.