Перейти к содержимому
ПереводыDatawhale и китайские курсы

Tiny Universe, часть 5: TinyEval — своя система оценки LLM

Полный перевод раздела TinyEval из tiny-universe: двухэтапная оценка LLM (инференс → подсчёт метрик), промпты для датасетов LongBench, усечение длинного входа посередине, F1 по словам для генеративных ответов, точность для выбора и ROUGE для длинных ответов, а также свой датасет.

Эфир «Система оценки LLM своими руками»: запись трансляции.

Сейчас я шаг за шагом проведу вас через реализацию простого фреймворка оценки LLM. Это двухэтапная система оценки — мы называем её TinyEval. В ней есть ядро универсальной оценки LLM: поддерживаются генеративные, дискриминативные задачи и задачи с выбором ответа. Фреймворк состоит из двух частей — inference и eval; его цель — помочь лучше понять, как устроена и как реализуется оценка LLM.

Только входите в мир LLM — и вас мучают похожие вопросы?

  1. У каждой модели свои метрики оценки, их целый зоопарк. Новичку непонятно, как в них разобраться?
  2. Не знаете, какую метрику (metric) выбрать, и кроме rouge и bleu ничего в голову не приходит?
  3. Хотите, чтобы LLM решала тест с выбором ответа, а модель выдаёт кучу текста — как оценить умение выбирать?
  4. Моделей множество, узкоспециальных задач тоже. Как, кроме human_eval, дать убедительную количественную оценку качества на своей, нестандартной задаче?

Так вот, этот проект по очереди разрешит все эти сомнения!

Сначала нужно понять базовый конвейер (pipeline) оценки. На рисунке ниже — упрощённая схема.

Рисунок в оригинале (Eval/docs/compass.png): схема процесса оценки в стиле OpenCompass — перерисована ниже.

flowchart LR
D["Датасет и тип задачи"] --> M["Выбор метрики"]
D --> P["Промпт-шаблон"]
P --> I["Инференс модели<br/>(inference.py)"]
I --> X["Извлечение ответа<br/>(очистка, правила)"]
X --> S["Подсчёт метрики: pred vs answer<br/>(eval.py)"]
M --> S
Схема переводчика по рисунку compass.png
  • Сначала по типу задачи целевого датасета выбираем подходящую метрику оценки.
  • По форме данных составляем промпт (prompt), который направляет модель.
  • По предварительным ответам модели выбираем разумный способ извлечь из них ответ.
  • Считаем оценку для соответствующих пар pred (предсказание) и answer (эталон).

Вот это и есть все модули репозитория TinyEval.

Используемые датасеты лежат здесь. Сейчас есть такие датасеты и типы задач (список будет пополняться!):

НазваниеТипМетрика
multi_newsвопросы-ответы по длинному текстуRouge
multifieldqa_zhвопросы-ответы с коротким ответомF1
trecгенеративный выбор (классификация)accuracy

Исследуйте задачи по своим нуждам, а дальше я шаг за шагом разберу с вами процесс оценки!

Глядя на метрики выше, вы, возможно, удивились:

  • Что? F1 же метрика классификации — как она попала в llm?
  • Для accuracy разве не нужны метки классов (label)? Как она оказалась в генеративной задаче?

Хорошо, этот раздел как раз отвечает на эти вопросы. Кто уже знаком с основами, может сначала сам изучить соответствующий код.

  • Сначала для датасета нужно составить направляющий промпт, то есть подтолкнуть LLM к ответу в нужном виде. У большинства готовых датасетов промпты уже есть; для своего датасета можно придумать собственный. Например, для multifieldqa_zh промпт такой:
Прочитай текст ниже и кратко ответь по-китайски:\n\n{context}\n\nТеперь, опираясь на этот текст, ответь на вопрос ниже — скажи только ответ, не выводи никаких других слов.\n\nВопрос: {input}\nОтвет:

(Перевод промпта: в оригинале он на китайском, как и сам датасет.)

  • Затем задаём длину входа модели — то есть сколько токенов подаётся в модель за раз. Ради качества её обычно ставят равной максимальной длине модели: её можно найти в файле config.json скачанной модели в поле "max_position_embeddings", а можно не задавать — тогда возьмётся максимальная длина по умолчанию. В этом проекте выставлено 2048 — в основном для демонстрации.

  • Затем создаём модель целиком. Я сделал для неё класс; по его образцу можно собрать любую другую модель:

class BaseLLM:
def __init__(self, path: str, model_name: str) -> None:
self.path = path
self.model_name = model_name
def build_chat(self, tokenizer: str, prompt: str, model_name: str):
pass
def load_model_and_tokenizer(self, path: str, model_name: str, device):
pass
def post_process(self, response: str, model_name: str):
pass
def get_pred(self, data: list, max_length: int, max_gen: int, prompt_format: str, device, out_path: str):
pass
  • Что значат методы. build_chat оборачивает запрос в «родной» формат диалога модели. Для internlm2 это так:
def build_chat(self, prompt):
prompt = f'<|im_start|>user\n{prompt}<|im_end|>\n<|im_start|>assistant\n'
return prompt
  • Про модель и токенизатор говорить нечего; постобработку применяем или нет в зависимости от модели. Подробнее остановимся на функции get_pred:
def get_pred(self, data, max_length, max_gen, prompt_format, device, out_path):
model, tokenizer = self.load_model_and_tokenizer(self.path, device)
for json_obj in tqdm(data):
prompt = prompt_format.format(**json_obj)
# Усекаем посередине, потому что на обоих концах — ключевая информация.
tokenized_prompt = tokenizer(prompt, truncation=False, return_tensors="pt").input_ids[0]
if len(tokenized_prompt) > max_length:
half = int(max_length/2)
prompt = tokenizer.decode(tokenized_prompt[:half], skip_special_tokens=True)+tokenizer.decode(tokenized_prompt[-half:], skip_special_tokens=True)
prompt = self.build_chat(prompt)
input = tokenizer(prompt, truncation=False, return_tensors="pt").to(device)
# длина поданных в модель токенов
context_length = input.input_ids.shape[-1]
eos_token_id = [tokenizer.eos_token_id, tokenizer.convert_tokens_to_ids(["<|im_end|>"])[0]]
output = model.generate(
**input,
max_new_tokens=max_gen,
do_sample=False,
temperature=1.0,
eos_token_id=eos_token_id,
)[0]
pred = tokenizer.decode(output[context_length:], skip_special_tokens=True)
pred = self.post_process(pred)
with open(out_path, "a", encoding="utf-8") as f:
json.dump({"pred": pred, "answers": json_obj["answers"], "all_classes": json_obj["all_classes"], "length": json_obj["length"]}, f, ensure_ascii=False)
f.write('\n')
  • Кто-то спросит: зачем городить такую длинную функцию, разве нельзя просто вызвать model.chat()?
  • Хорошо! Эта функция и есть ответ. Всё дело в стратегии усечения. Если модели задана длина входа, обычная команда усечения обрезает вход в конце. Но из-за направляющего промпта ключевая информация есть на обоих концах входа (инструкция в начале, вопрос в конце), поэтому нужно сохранить оба конца, а усекать середину — так качество ответа пострадает меньше всего!

Совет: часть с get_pred можно сравнить с функцией chat в скриптах модели (у internlm2 — в modeling_internlm2.py) — так лучше видно, как сырой текст превращается во вход модели и как структурирован её выход.

Сразу пример:

"pred": "57081.86 юаня", "answers": "57081.86 юаня жэньминьби."

(В оригинале пример на китайском; мы перевели его на русский, числа те же.)

  • Сначала чистим данные и делим фразы на слова. В китайском тексте между словами нет пробелов, поэтому оригинал делит его библиотекой jieba; русский текст достаточно разрезать по пробелам. Для нашего примера после деления на слова и удаления знаков препинания получаем:
"pred": ['5708186', 'юаня'], "answers": ['5708186', 'юаня', 'жэньминьби']

Эти два «чистых» результата подаём в функцию F1:

def f1_score(prediction, ground_truth, **kwargs):
# Counter хранит, сколько раз встречается каждое слово; оператор & возвращает общие элементы двух Counter
common = Counter(prediction) & Counter(ground_truth)
# число общих элементов предсказания и эталона
num_same = sum(common.values())
if num_same == 0:
return 0
# доля верных слов среди всех предсказанных
precision = 1.0 * num_same / len(prediction)
# доля верно предсказанных слов среди всех слов эталона
recall = 1.0 * num_same / len(ground_truth)
f1 = (2 * precision * recall) / (precision + recall)
return f1
  • Сначала находим общие элементы двух списков, считаем их число и по определениям precision и recall вычисляем соответствующие оценки.
  • Так получаем оценку для одного ответа; затем усредняем по всем ответам — это и есть F1_score задачи (task).

Конечно, это лишь базовые метрики. Внимательный читатель, возможно, уже заметил дыры: например, здесь сравниваются результаты, уже извлечённые по правилам. Если эталон — «Сямэньский университет», а предсказание — «не Сямэньский университет» или сокращённое «Сяда», текущая метрика оценит их несправедливо.

Более точные метрики — цель, к которой академическое сообщество стремится постоянно. Проект будет следить за более совершенными стратегиями оценки; будем рады пул-реквестам от знатоков!

Окно терминала
python inference.py
Окно терминала
python eval.py
  1. F1 score
  2. семейства rouge / bleu
  3. accuracy

Репозиторий поддерживает и оценку на своих данных. Если у вас есть свои данные для SFT, назовите датасет custom_zh (или custom_en, если он на английском). Формат данных — как у SFT:

{
"instruction": "Представь, что ты женщина при императоре — Чжэнь Хуань",
"input": "Кто ты?",
"output": "Ваша наложница — Чжэнь Хуань, мой отец — заместитель главы Далисы." # Далисы — Палата судебного надзора
}

Так поддерживается оценка на своём датасете.

LongBench: A Bilingual, Multitask Benchmark for Long Context Understanding

  1. Посчитайте вручную F1 для пары «Сямэньский университет» (эталон) / «не Сямэньский университет» (предсказание). После деления по пробелам получится ['сямэньский', 'университет'] и ['не', 'сямэньский', 'университет']: precision =2/3= 2/3, recall =2/2=1= 2/2 = 1, F1=2⋅23⋅1/(23+1)=0,8F_1 = 2 \cdot \tfrac{2}{3} \cdot 1 / (\tfrac{2}{3} + 1) = 0{,}8 — хотя ответ противоположен эталону. Это наглядная причина не доверять одной метрике.
  2. Оцените одну и ту же маленькую модель (например, Qwen2.5-1.5B-Instruct) с max_length = 2048 и 8192 на multifieldqa_zh. Как длина контекста влияет на F1?
  3. Сделайте свой custom_zh или русскоязычный датасет из 20 вопросов по урокам курса и посчитайте F1 и accuracy.
РусскийПояснение
оценка (evaluation)измерение качества модели
метрика (metric)число, которое считаем
инференс (inference)получение ответов модели
генеративная / дискриминативная / с выборомтипы задач оценки
усечение (truncation)обрезка слишком длинного входа
разбиение на слова (jieba)в китайском нет пробелов между словами
точность / полнота (precision / recall)доли верных среди предсказанных / эталонных
доля верных ответов (accuracy)для задач с выбором
узкая (профильная) область (vertical domain)медицина, право, своя задача