Задачи внутри фреймворка lm-evaluation-harness реализуются в виде отдельных файлов: либо .py, либо .yaml. Дополнительно некоторые задачи могут иметь вспомогательный utils.py файл, внутри которого реализуется все то, что не запрограммировано по умолчанию внутри фреймворка.
Данная инструкция дает пошаговое руководство по созданию задач в виде .yaml файлов. Каждый yaml файл имеет четкую структуру, которая отражает различные этапы проведения оценки языковой модели на конкретной задаче.
Чтобы вы добавили свою задачу в MERA, вам нужно создать для нее пустой yaml файл и следовать инструкции ниже. Обзор ниже дает характеристику каждого этапа добавления датасета, а также описывает детали его реализации.
- Обзор
- Загрузка датасета
- Предобработка датасета
- Формирование запросов в языковую модель
- Обработка генераций языковой модели
- Метрики
- Дополнительные параметры задачи
Загрузка датасета регулируется ключами:
dataset_path: имя репозитория в HF (https://huggingface.co/datasets) или локальный путь
dataset_name: имя конфига (задачи) в репозитории dataset_path или по пути dataset_path локально
task: имя задачи для запуска через код (по этому имени вы будете вызывать эту задачу)Например, вы хотите загрузить задачу ruHumanEval. Тогда вы указываете:
dataset_path: MERA-evaluation/MERA
dataset_name: ruhumaneval
task: my_ruhumanevalТеперь после обращения к --tasks my_ruhumaneval будет вызван код:
datasets.load_dataset(dataset_path, dataset_name)Также вы можете загружать датасет, который хранится локально. Допустим, у вас есть
директория custom_tasks, в которой лежит папка ruhumaneval. Внутри этой папки находятся
json файлы со сплитами датасета. Тогда, для подгрузки датасета локально вы можете указать:
dataset_path: custom_tasks
dataset_name: ruhumaneval
task: my_ruhumanevalПредобработка задачи включает в себя разметку частей датасета: training, validation, test. Если в датасете есть отдельные части, то вы можете выбрать, из какой части будут браться вопросы для тестирования модели, из какой — фью-шоты.
training_split: часть (сплит) датасета для набора фью-шотов
validation_split: часть (сплит) датасета для набора фью-шотов или тестирования
test_split: часть (сплит) датасета, откуда берутся данные для замера модели (на них считаются метрики)
fewshot_split: часть (сплит) датасета для набора фью-шотовLm-evaluation-harness не дает возможности обучаться на частях датасета перед замером на тесте.
training_split и fewshot_split имеют одинаковую цель.
Тестовые вопросы берутся из dataset[test_split]. Если вы не указали test_split, то тестовые вопросы
будут браться из dataset[validation_split]. Если и данный сплит не проставлен (не объявлен), то
тестовый датасет не будет составлен и задача упадет с ошибкой.
Фью-шоты набираются либо из dataset[fewshot_split], либо dataset[training_split], либо из
dataset[validation_split]. Именно в таком порядке и только из одного конкретного сплита. Если его
не хватит для набора нужного количества фью-шотов, то недостающее количество не будет восполняться
из других сплитов.
Если вы указываете:
training_split: train
validation_split: dev
test_split: test
fewshot_split: valТо, вопросы для тестирования модели будут браться из dataset[test], а для фью-шотов — из
dataset[val].
Каждый отдельный документ из датасета представляет собой словарь с заранее определенной структурой. В случае, если вам необходимо предварительно обработать все словари одинаковым образом, фреймворк позволяет это сделать:
process_docs: функция для обработки документаprocess_docs всегда задается функцией из модуля utils.py. Если вы ее не реализуете, она не
используется.
process_docs: !function utils.process_docsСоответственно, в модуле utils.py будет реализована функция, которая принимает на вход
датасет и применяет к нему одно и то же правило обработки словарей. Выглядеть это может так:
def _process_doc(doc: dict) -> dict:
"""
Функция, которая достает из каждого словаря значение по ключу "target".
Затем, отрезает пробельные символы слева и справа, а также разделяет строку по ",".
processed_target - это список. Он кладется по новому ключу "processed_target".
На выходе к изначальному словарю добавляется новый ключ "processed_target", старые
остаются нетронутыми.
"""
target = doc["target"]
processed_target = target.strip().split(",")
doc["processed_target"] = processed_target
return doc
def process_docs(dataset: datasets.Dataset) -> datasets.Dataset:
"""
Применяет к "dataset" функцию _process_doc. То есть, применяет ее к
каждому словарю в датасете независимо.
"""
return dataset.map(_process_doc)В данном примере создано две отдельных функции. Одна управляет изменением словаря, другая —
применяет первую ко всему датасету. Вы можете реализовать все в рамках одной функции
process_docs.
Обратите внимание, что, если вам предобработка не нужна, то поле process_docs заполнять вообще
не нужно. Тогда никакой предобработки не будет и датасет будет обрабатываться в том виде, в котором
был загружен изначально.
Стрератегия формирования запроса для языковой модели зависит от выбранного типа запроса. Тип запроса определяется ключем:
output_type: generate_until | multiple_choice | loglikelihood | loglikelihood_rollingЗадача может иметь только один тип запросов. Все запросы будут соответствовать этому типу. Тип запроса определяет то, как запрос будет подаваться в языковую модель, а также, как будут считаться метрики. Сами метрики также могут зависеть от типа запроса.
Доступные типы запроса:
generate_until. Соответствует генеративной задаче. То есть, текст запроса передается в модель. Модель генерирует ответ также в виде текста. Данный тип запроса поддерживается самым широким спекторм моделей: открытые, закрытые, доступные по API. Например, ChatGPT, GigaChat, Claude не возвращают пользователю логиты для входного запроса, потому никакой другой тип запроса, кроме данного, к ним не применим.multiple_choice. Соответствует классификационной задаче с несколькими вариантами ответа. Варианты ответа указываются в этом же yaml файле. В модель передается текст запроса плюс первый вариант ответа. Получается вероятность получить данный текст с таким вариантом ответа, как продолжением текста. Затем передаются все остальные варианты ответа. Каждому варианту ответа будет соответствовать пара (float, bool). float — вероятность получить после текста запроса именно данное продолжение, а bool — является ли данное продолжение результатом "жадной" генерации.loglikelihood. В модель подается текстовый запрос. Результатом является вероятность получить именно такой текст из языковой модели.loglikelihood_rolling. Модификацияloglikelihood. Вероятности считаются не по целому тексту, а скользящим окном по нему. Данный метод разумно применять, когда изначально стоит задачаloglikelihood, но входные тексты слишком длинные для того, чтобы влезть в размер контекста модели. Тексты разбираются на непересекающиеся куски, к каждому применяется методloglikelihood. В конце прохода окном по тексту вероятности агрегируются в одно число (суммируются).
Обратите внимание, generate_until тип запроса поддерживается самым широким спекторм моделей:
открытые, закрытые, доступные по API. Например, ChatGPT, GigaChat, Claude не возвращают
пользователю логиты для входного запроса, потому никакой другой тип запроса, кроме данного,
к ним не применим.
Потому, если задача будет запускаться на моделях, которые могут не возвращать логиты для входных
последовательностей, то типы multiple_choice, loglikelihood, loglikelihood_rolling для
таких моделей работать не будут (просто не запустятся).
Данные типы запросов подходят для запуска открытых моделей и
исследования их поведения. Замеры через логиты позволяют даже для более
"слабых" моделей получать оценку их качества на сложных задачах.
Рассмотрим, что будут выдавать языковые модели при разных типах запроса. Наиболее часто используемыми
являются generate_until и multiple_choice — они соответствуют абсолютному большинству типов задач.
В дальнейшем примеры будут приводиться на них.
Генерация всегда выдается в двумерном списке:
[["model generation"]]Если генераций больше одной, то они будут идти последовательно в этом же списке:
[["model generation 1", "model generation 2", "model generation 3"]]Ответ всегда выдается в двумерном списке:
[[(0.1, True), (0.05, False)]]Каждая пара соответствует запросу плюс вариант ответа. Если N вариантов ответа, то будет N кортежей
с парами. Первое число в М-ой паре соответствует вероятности получить последовательность вида
(запрос + M-ый вариант ответа), вторым идет логическое значение: True или False. Оно указвает,
является ли M-ый вариант ответа результатом жадной генерации (это значит, что каждый токен в М-ом
варианте ответа имеет наибольшую вероятность быть сгенерированным моделью).
Ответ всегда выдается в двумерном списке:
[[(0.1, True)]]Логика аналогична multiple_choice, только есть всего один вариант ответа, потому пара всего одна. Для
текстового запроса выдается вероятность, что данный ответ будет сгенерирован моделью после запроса, а
также то, является ли данный ответ результатом жадной генерации.
Ответ всегда выдается в двумерном списке:
[[0.2]]Число соответствует сумме вероятностей для каждого окна.
Для управления тем, как каждый словарь из датасета обрабатывается для формирования запроса в языковую модель, используются поля:
doc_to_text: функция для формирования тела запроса - промпта
doc_to_target: функция для обработки ответа на фью-шот запрос, определяет, как ответ будет подан модели
fewshot_delimiter: разделитель, который ставится после каждого фью-шота
target_delimiter: разделитель, который ставится между doc_to_text и doc_to_targetКаждая функция для формирования части запроса может быть представлена либо jinja2 шаблоном, либо строкой
с текстом, либо ссылкой на функцию в модуле utils.py.
Пример одного и того же действия со словарем doc в двух форматах.
Jinja2-шаблон:
doc_to_text: "{{instruction.format(inputs=inputs).strip()}}"Python-код:
doc_to_text: !function utils.doc_to_targetdef doc_to_target(doc: dict) -> str:
"""
Функция берет инструкцию из документа doc по ключу instruction, затем
подставляет в нее значение из ключа inputs. У результата удаляются пробельные
символы слева и справа.
"""
instruction = doc["instruction"]
prompt = instruction.format(inputs=doc["inputs"])
return prompt.strip()Допустим, конкретный словарь doc из датасета выбран в качестве фью-шота, то к нему
применяется следующий код:
doc_to_text(doc) + target_delimiter + doc_to_target(doc) + fewshot_delimiterПример:
doc = {
"instruction": "Реши пример на сложение:\n{inputs}",
"inputs": "2 + 2",
"outputs": "4"
}doc_to_text: "{{instruction.format(inputs=inputs).strip()}}"
doc_to_target: "outputs"
fewshot_delimiter: "\n\n"
target_delimiter: " = ""Реши пример на сложение:\n2 + 2 = 4\n\n"
По умолчанию, fewshot_delimiter будет "\n\n", а target_delimiter — " ". Если вас устраивают
такие значения, то не указывайте данные ключи в yaml файле.
Если ваша задача содержит не только тексты, но и изображения (в данный момент lm-evaluation-harness
умеет работать только с изображениями), то вам будет необходимо указать также ключ doc_to_image:
doc_to_image: !function utils.doc_to_imageДанная функция принимает на вход словарь из датасета и выдает на выход список изображений (в формате PIL.Image.Image) для запроса в установленном вами порядке. Для одного изображения порядок не определен. Если изображений в запросе больше одного, то нужно расположить изображения в том порядке, в котором они передаются в модель.
Например, для запроса "Посмотри на изображения <image_1> и <image_2>. Они похожи?" функция
doc_to_image будет выдавать список [PIL.Image(image_1), PIL.Image(image_2)]. Также обращаем
внимание, что многие мультимодальные модели умеют работать только с "<image>" токеном. Токенов
"<image_1>", "<image_2>" и так далее они не знают. Потому для мультимодальной задачи требуется
в doc_to_text и doc_to_target (если в таргете тоже могут быть изображения) заменить все
вхождения "<image*>" на "<image>". При этом установленный порядок будет сохранен функцией
doc_to_image, потому ситуации, когда первое и второе изображения перепутаны местами,
не возникает.
def doc_to_image(doc):
# формируем текст запроса без замены <image_*> на <image>
input_text = _doc_to_text(doc)
# находим, какие изображения для текущего запроса нужны (может быть, что не все 7)
image_placeholders = [
img.replace(" ", "_").replace("<", "").replace(">", "")
for img in re.findall("<image [1-7]>", input_text)
]
# достаем изображения с отобранными именами вида <image_1> и кладем последовательно в список
visuals = [doc["inputs"][img] for img in image_placeholders]
return visuals
def doc_to_text(doc):
prompt = _doc_to_text(doc)
# заменяем все вхождения <image {i}> на <image>
for i in range(1, 8):
prompt = prompt.replace(f"<image {i}>", "<image>")
# возвращаем итоговый текст запроса в модель
return prompt
def _doc_to_text(doc):
# функция для формирования запроса в модель без замены <image_*> на <image>
prompt = doc["instruction"].format(**doc["inputs"])
return promptФью-шоты набираются по указанному ранее правилу и составляют строку на выходе. Построение фью-шотов определяется алгоритмом их сэмплирования:
fewshot_config:
sampler: defaultfewshot_config агрегирует все параметры, которые требуются для построения фью-шотов. Главный
параметр — sampler. Он может указывать либо на имя одного из реализованных в
lm-evaluation-harness алгоритмов сэмплирования, либо указывать на класс из utils.py.
Сам класс сэмплера — это класс алгоритма, который принимает на вход список из словарей сплита
для набора фью-шотов, задачу и random_seed. Алгоритм набирает --num_fewshot словарей из
сплита (при фиксированном random_seed берутся всегда одинаковые), затем преобразует их в строку.
По умолчанию словари собираются, затем к каждому применяется правило из описанных ранее ключей:
doc_to_text(doc) + target_delimiter + doc_to_target(doc) + fewshot_delimiterВсе предобработанные таким образом строки конкатенируются в одну и возвращаются. Затем к ним прибавляется тестовый запрос, согласно коду:
doc_to_text(doc) + target_delimiterВ итоге для двух фью-шотов итоговый запрос в языковую модель формируется следующим образом:
doc_to_text(fewshot_doc1) + target_delimiter + doc_to_target(fewshot_doc1) + fewshot_delimiter + \
doc_to_text(fewshot_doc2) + target_delimiter + doc_to_target(fewshot_doc2) + fewshot_delimiter + \
doc_to_text(test_doc) + target_delimiterВ MERA используется альтернативная стратегия формирования фью-шотов. После вызова обработки
для самого первого фью-шота функция doc_to_text переопределяется так, чтобы она не
содержала инструкции. То есть, только первый фью-шот (или сам тестовый вопрос для нуля фью-шотов)
будет содержать полную инструкцию по выполнению задания. Остальные фью-шоты и тестовый сэмпл будут
представлены в унифицрированном виде без объяснения задания, имитируя нормальное человеческое поведение,
когда пользователь дает задание, а затем предоставляет входные данные, не дублируя то же задание.
fewshot_config:
sampler: !function ../custom_samplers.FewshotSampler
doc_to_text_without_instruction: "Последовательность: {{inputs}}\nОтвет:"
query: "{{instruction.format(**inputs)}}"Далее будут следовать два примера работы двух указанных сэмплеров (дефолтный сэмплер и сэмплер MERA) при одинаковых входных данных:
doc_to_text: "{{instruction.format(inputs=inputs).strip()}}"
doc_to_target: "outputs"
fewshot_delimiter: "\n\n"
doc_to_target: "outputs"
num_fewshot: 2fewshot_config:
sampler: defaultБудем считать, что instruction одинаковый для всех словарей: "Реши пример на сложение:\n{inputs}". Тогда дефолтный сэмплер составит следующую строку:
Реши пример на сложение:
2 + 2 = 4
Реши пример на сложение:
3 + 3 = 6
Реши пример на сложение:
2 + 3 =
То есть, инструкция есть в каждом фью-шоте, а также есть в тестовом вопросе.
fewshot_config:
sampler: !function ../custom_samplers.FewshotSampler
doc_to_text_without_instruction: "{{inputs.strip()}}"
query: "{{instruction.format(inputs=inputs).strip()}}"Выходом будет:
Реши пример на сложение:
2 + 2 = 4
3 + 3 = 6
2 + 3 =
Теперь инструкция дана только в самом первом фью-шоте. Остальные фью-шоты, а также тестовый
вопрос составляются с применением doc_to_text_without_instruction функции внутри fewshot_config.
Поле query используется для стабильной работы алгоритма и повторяет функцию из doc_to_text.
Без указания query задача будет работать некорректно с разным количеством фью-шотов при
использовании данного алгоритма сэмплирования. Ключи doc_to_text_without_instruction и query
являются его особенностью (в lm-evaluation-harness данные параметры не используются и не
определяются).
Готовый пример реализации указанного выше custom_samplers.FewshotSampler можно найти в репозитории MERA.
Класс сэмплера имеет три основных метода:
sample, который принимает на входn(количество примеров для отбора) иdoc(тестовый запрос в виде словаря, который можно использовать, чтобы, например, выбирать только такиеnзапросов, которые по какому-то критерию близки кdoc). Возвращает список из словарей для формирования фью-шотов.get_context, который принимает на входdoc(тестовый вопрос в виде словаря) иnum_fewshot(количество фью-шотов, которое необходимо набрать). Вызываетsample, затем формирует строку с фью-шотами и выдает ее.get_chat_context, принимает на вход то же самое, что иget_contextи дополнительно логическую переменную fewshot_as_multiturn. Вызывается только, если в консоли указан флаг--apply_chat_template. Вызываетsample, затем формирует список словарей вида
{
"role": "user | assistant",
"content": "some text"
}Если fewshot_as_multiturn в занчении False, то все фью-шоты и тестовый вопрос будут помещены
в "content" одного и того же словаря (вызывается метод get_context). В обратном случае, каждый
фью-шот формирует самостоятельную диалоговую пару словарей:
{
"role": "user",
"content": "вопрос"
},
{
"role": "assistant",
"content": "ответ"
}Если вы указываете при запуске процедуры оценки языковой модели параметр, отвечающий за system prompt:
lm_eval ... --system_instruction="Ты разработан для помощи людям. Следуй инструкциям."То переданная вами строка будет добавлена к запросу слева.
При запросах:
lm_eval ... --apply_chat_template --system_instruction="Ты разработан для помощи людям. Следуй инструкциям."lm_eval ... --apply_chat_template --fewshot_as_multiturn --system_instruction="Ты разработан для помощи людям. Следуй инструкциям."system_prompt будет добавлен, как отдельный словарь в начале списка:
[
{
"role": "system",
"content": "Ты разработан для помощи людям. Следуй инструкциям."
},
]Пост-обработка ответов языковой модели осуществляется специальными сущностями — фильтрами. Они собираются в список для последовательного применения:
filter_list:
- name: "scoring"
filter:
- function: "regex"
regex_pattern: "(\\b([ABCD])\\b)"
fallback: '-1'
- function: "take_first"Фильтры объединяются в группы с именами и применяются параллельно к одному и тому же ответу модели. Имена отображаются в итоговой таблице метрик и сохраняемых логах запуска.
Фильтры применяются при любом типе запроса. Разные типы запроса порождают разные форматы ответа
(в следующей секции показано), потому некоторые фильтры могут не работать. Например, для
multiple_choice типа задачи, когда ответом модели является пара (float, bool), фильтр через
поиск подстроки, которая совпадает с регулярным выражением, не имеет смысла.
Рассмотрим два основных фильтра.
filter_list:
- name: "scoring"
filter:
- function: "take_first"Фильтр take_first является фильтром по умолчанию, если не указаны никакие фильтры в yaml файле
задачи. Применяется он из-за того, что ответы языковой модели приходят в фильтры в двумерном списке:
# generate_until
[["some model generation"]]
# multiple_choice
[[(0.1, True), (0.05, False)]]Данный фильтр распаковывает двумерный список в одномерный:
# generate_until
["some model generation"]
# multiple_choice
[(0.1, True), (0.05, False)]Это не обязательно, однако упрощает подсчет метрик, которые по умолчанию созданы так, чтобы принимать на вход одномерные списки.
filter_list:
- name: "scoring"
filter:
- function: "regex"
regex_pattern: "(\\b([ABCD])\\b)"
group_select: 0
fallback: '-1'Фильтр regex принимает на вход двумерный список с генерациями:
# generate_until
[["Думаю, что правильный ответ А."]]Регулярное выражение по ключу regex_pattern компилируется, затем осуществляется поиск
данного выражения внутри переданной в списке строки:
match = regex.findall(single_response)Выбирается group_select по счету совпадение с паттерном (по умолчанию самое первое).
Если совпадение с паттерном не найдено, возвращается значение из ключа fallback.
Результатом процессинга указанного примера будет:
# generate_until
[["А"]]Фильтр majority_vote:
filter_list:
- name: "scoring"
filter:
- function: "majority_vote"Используется для проведения "голосования большинством". Внутри к списку ответов модели
(подразумевается, что их больше одного) применяется Counter, а затем берется
counts.most_common(1)[0][0].
Фильтр remove_whitespace:
filter_list:
- name: "scoring"
filter:
- function: "remove_whitespace"Убирает в списке генераций у каждой все пробельные символы слева. Например, если все промпты заканчиваются на "Ответ:", разумно положить, что ответы моделей будут начинаться с " ". Данный фильтр удаляет эти пробелы слева.
Фильтры lowercase и uppercase
filter_list:
- name: "scoring"
filter:
- function: "lowercase | uppercase"Возвращают исходный список ответов модели с изменением регистра всех генераций на нижний (lowercase) или верхний (uppercase).
Готовые фильтры можно найти в модулях filters.
Вы можете реализовать не одну группу фильтров, а несколько. Каждая применяется к "сырым" ответам модели параллельно и дают отдельные обработанные ответы, к которым также параллельно применяются метрики так, будто бы каждая группа фильтров порождает новую задачу со своими метриками.
filter_list:
- name: "strict-match"
filter:
- function: "regex"
regex_pattern: "#### (\\-?[0-9\\.\\,]+)"
- function: "take_first"
- name: "flexible-extract"
filter:
- function: "regex"
group_select: -1
regex_pattern: "(-?[$0-9.,]{2,})|(-?[0-9]+)"
- function: "take_first"Фреймворк lm-evaluation-harness сам обрабатывает данные группы и считает переданные вами метрики. Если
вы не создаете свои собственные метрики в utils.py, фреймворк все применяет сам без вашего участия.
По сути, даже если вы реализуете свою метрику, она все равно будет применяться так же, как и с одной
группой фильтров. Всю работу по агрегации результатов подсчета метрик для одного и того же запроса
после разных групп фильтров фреймворк осуществляет сам.
Фильтры могут быть не только заранее реализованными внутри фреймворка, но и написанными вами для вашей конкретной задачи. Например, для ruHumanEval в MERA модели генерируют для одного запроса 10 возможных продолжений начала функции из запроса. Далее фильтр собирает начало функции и возможное продолжение и запускает ее на тест-кейсах, чтобы получить результат запуска и сравнить его с правильными ответами.
@register_filter("ruhumanevalscoring")
class ruHumanEvalScoring(Filter):
def __init__(self) -> None:
"""
Считывание необходимых для фильтра параметров
"""
def apply(self, resps, docs):
"""
Метод, который отвечает за применение фильтра
"""
# resps: List[List[str]] - список списков генераций
code_results = []
for idx, sample in enumerate(resps):
sample_metrics = []
for completion in sample:
processed_completion = preprocess_generation(completion)
result = execute_function(processed_completion, docs[idx])
sample_metrics.extend([result])
code_results.extend([sample_metrics])
return code_resultsТакже вы можете прямо в фильтре подсчитывать промежуточные метрики, запускать какие-либо модели
для оценки ими генераций или их пост-обработки. Таким образом возможно реализовать llm-as-a-judge
с указанием конкретной модели и промпта для нее. Модели загружаются в методе init (чтобы
вспомогательная модель или модель-оценщик загружалась один раз для одного запуска lm_eval),
запускаются в apply. Однако такая реализация требует дополнительных вычислительных ресурсов.
Фильтр вы можете передавать либо, как класс из utils.py:
filter_list:
- name: "scoring"
filter:
- function: !function utils.ruHumanEvalScoring
- function: "take_first"Либо вы можете "зарегистрировать" ваш фильтр (в примере выше перед классом используется декоратор
@register_filter("ruhumanevalscoring")), чтобы стало возможно обращаться к нему просто по имени:
filter_list:
- name: "scoring"
filter:
- function: ruhumanevalscoring
- function: "take_first"Все внутренние фильтры lm-evaluation-harness уже зарегистрированы. При запуске lm_eval реестр всех
фильтров собирается, включая ваш, если вы использовали декоратор и указали путь к задаче.
После фильтрации ответов модели, они приходят в функции для подсчета метрик. В lm-evaluation-harness существуют две отдельные сущности:
-
metric— это метрика. Это функция, которая применяется к каждому сэмплу тестовых данных независимо. В случае accuracy или exact_match сравниваются ответ модели и правильный ответ, результат — либо 0, либо 1. -
aggregation— агрегация. Она применяется к метрике, чтобы получить одно число для датасета. Например, для accuracy или exact_match нули и единицы для отдельных сэмплов усредняются по всему датасету. Получается одно число — итоговое значение метрики.
В случае, если ваша задача требует специфическую метрику, например, pass@k для задачи HumanEval
(ruHumanEval, ruCodeEval), вы реализуете ее в модуле utils.py.
process_results: !function utils.process_resultsОбработка ответов и подсчет метрики осуществляются с помощью функции (на примере pass@k):
def process_results(doc: Dict, results: List[str]) -> Dict[str, float]:
if len(doc["outputs"]) > 0:
output_results = []
# results - двумерный список, распаковываем его
code_outputs = results[0]
# модель на 1 запрос выдает 10 генераций, смотрим на каждую
for generation in code_outputs:
# внушняя функция для проверки генерации модели и сравнения с правильными ответами
score = check_solution(doc["outputs"], generation)
# ответ 1 или 0
if score:
output_results.extend([1])
else:
output_results.extend([0])
# подсчет самих метрик на основе 10 скоров
total, correct = len(output_results), sum(output_results)
pass_1 = compute_pass_k(total, correct, 1)
pass_5 = compute_pass_k(total, correct, 5)
pass_10 = compute_pass_k(total, correct, 10)
# для одного запроса возвращаем словарь из 3 метрик
return {"pass@1": pass_1, "pass@5": pass_5, "pass@10": pass_10}
# если тестовый запрос не имеет указанных правильных ответов, то возвращаем нули по тем же метрикам
return {
"pass@1": 0.0,
"pass@5": 0.0,
"pass@10": 0.0,
}Вы можете переопределить поведение даже дефолтной (реализованной внутри lm-evaluation-harness) метрики.
Например, можно определить, как будет считаться accuracy:
def process_results(doc: Dict, results: List[str]) -> Dict[str, float]:
"""
Функция берет из документа (сэмпла) название домена, а также проверяет, есть
ли в словаре правильный ответ (для закртого теста может не быть). Если есть,
считаем accuracy для данного сэмпла и записываем в словарь к ключу "acc" - общая
accuracy, "acc.domain" - accuracy среди всех вопросов конкретного domain (которые
имеют одинаковое значение domain в doc["meta"]["domain"])
"""
domain = doc["meta"]["domain"]
has_outputs = len(doc["outputs"]) > 0
if has_outputs:
# results = [(proba1, bool1), (proba2, bool2), (proba3, bool3), (proba4, bool4)]
# превращаем в [proba1, proba2, proba3, proba4]
results = [res[0] for res in results]
# находим индекс правильного ответа
gold = ["A", "B", "C", "D"].index(doc["outputs"])
# находим индекс предсказания
pred = argmax(results)
# метрика для данного сэмпла и ответа модели на него
acc = float(pred == gold)
# сохранение в словарь общей accuracy, а также запись 0 или 1 в acc.domain
return {"acc": acc, f"acc.{domain}": acc}
if not has_outputs:
# если ответов нет, то нули
return {
"acc": 0.0,
f"acc.{domain}": 0.0,
}Иначе говоря, ваша функция принимает на вход словарь-тестовый запрос doc, а также results — ответ модели после
фильтрации. Если вы сделали несколько групп фильтров, то данная функция будет применена к ответам после каждой группы
независимо. То есть, вы можете предусмотреть несколько различных стратегий пост-обработки генераций модели, а затем
замерить на них одинаковую метрику.
Если вы реализуете подсчет метрики функцией в utils.py, данная функция возвращает словарь из метрик. Ключи — имена
метрик, значения по данным ключам — сами значения метрик. Каждому сэмплу
соответствует словарь. В данных словарях вы можете хранить различные метрики. В примере выше одна метрика — общая для
всего тестового сплита, а другая — общая только для некоторой подвыборки тестовой части датасета.
Для всего тестового сплита собираются все уникальные ключи из всех отдельных словарей. Для каждого уникального ключа создается список из всех метрик, которые встречались в словарях с данным ключем. Для примера выше собирается массив из метрик "acc", а также для всех возможных "acc.domain". Далее к этим спискам применяется агрегация, которая указана в yaml файле или реализована также в виде отдельной функции.
metric_list:
- metric: acc
aggregation: mean
higher_is_better: true
- metric: acc.domain1
aggregation: mean
higher_is_better: true
- metric: acc.domain2
aggregation: mean
higher_is_better: trueВ metric_list все metric должны быть указаны в словарях. Указание метрики, реализация которой отсутствует в
lm-evaluation-harness, а также отсутствует в process_results, приведет к ошибке.
Также обратите внимание, что формат приходящего на вход функции параметра results зависит от фильтров и от типа
запроса. С фильтрами по умолчанию results будет одномерным списком строк для generate_until, одномерным списком из
кортежей вида (float, bool) для multiple_choice. Порядок кортежей не случайный.
Для multiple_choice задач, как пояснялось ранее, к промпту прибавляется возможное продолжение. Для каждого возможного
продолжения составляется кортеж. Продолжения подаются в одинаковом порядке для каждого вопроса датасета и в одинаковом
составе:
doc_to_choice: ["0", "1"]Ключ doc_to_choice устанавливает, какие варианты ответа будут добавляться к входному запросу. Для
каждого варианта ответа вычисляется вероятность получить его сразу после текстового запроса.
Варианты ответа подставляются в том порядке, который вы указываете в doc_to_choice:
doc_to_choice: ["0", "1"]
doc_to_choice: ["1", "0"]Данные варианты различаются по тому, какой кортеж будет идти первым в results. Первый кортеж
соответствует первому варинту ответа в doc_to_choice. В случае реализации process_results вы сами
ответственны за то, чтобы правильно соотнести вариант ответа с кортежем. Если вы используете встроенную
в фреймворк метрику, которая направлена на различение вероятностей, она будет обращаться
к doc_to_choice.
В самом простом случае ответом на конкретное задание будет результат:
# results = [(0.2, False), (0.001, False)]
probas = [results[0][0], results[1][0]] # only probabilities
model_answer = argmax(probas)
# doc_to_choice = ["0", "1"]
final_answer = doc_to_choice[model_answer]Все реализованные метрики и агрегации можно найти в модуле metrics.py.
Использовать встроенные метрики и агрегации можно без реализации process_results.
Accuracy (для multiple_choice типа задач):
metric_list:
- metric: acc
aggregation: mean
higher_is_better: trueExact match (для generate_until типа задач):
metric_list:
- metric: exact_match
aggregation: mean
higher_is_better: true
ignore_case: true
ignore_punctuation: false
regexes_to_ignore:
- ","
- "\\$"
- "(?s).*#### "
- "\\.$"Метрика exact_match берется из библиотеки evaluate и дает возможность управлять параметрами данной метрики:
ignore_case — игнорирование регистра,
ignore_punctuation — игнорирование различий в пунктуации между предсказанием и правильным ответом,
regexes_to_ignore — игнорирование определенных паттернов.
Обратите внимание, что метрика acc, технически, может применяться и к generate_until типу задач, однако такая
реализация крайне не рекомендуется не только в силу отсутствия возможности указания дополнительных параметров, но и в
силу методологических причин. Зачастую, метрика "accuracy" связана именно с замерами через получение логитов,
а "exact_match" — метрика для оценки генераций, которая является аналогом "accuracy" для генеративных задач.
F1-score (для multiple_choice типа задач):
metric_list:
- metric: acc
aggregation: mean
higher_is_better: trueВы можете использовать готовую f1 метрику, но свое собственное усреднение. Для метрики f1 сама метрика выдает пары
вида (gold, prediction), так как данная метрика не может быть посчитана на уровне отдельных вопросов датасета, а только
на всех вопросах целиком. Причем в фреймворке нет готовой реализации f1_macro/f1_micro, их необходимо реализовывать
в utils.py. Для своей функции вы можете передавать параметры прямо в yaml файле.
metric_list:
- metric: f1
aggregation: !function utils.weighted_f1_score
average: weighted
hf_evaluate: true
higher_is_better: True
ignore_case: true
ignore_punctuation: true
regexes_to_ignore:
- ","
- "\\$"Иные доступные метрики:
acc_norm(length-normalized accuracy)acc_mutual_info(baseline loglikelihood - normalized accuracy)perplexityword_perplexity(perplexity per word)byte_perplexity(perplexity per byte)bits_per_bytematthews_corrcoef(Matthews correlation coefficient)bleuchrfterwer
Функции агрегации:
meanmedianperplexityweighted_perplexitybits_per_byte
generation_kwargs:
do_sample: true
temperature: 0.6
top_p: 0.92
top_k: 100
max_new_tokens: 64
until:
- "<|eot_id|>"
- "<|start_header_id|>user<|end_header_id|>"
- "Q:"
- "</s>"
- "<|im_end|>"Описание дополнительных параметров генерации можно найти в в официальной документации.
Если вы при запуске задачи указываете параметры генерации по ключу:
lm_eval ... --gen_kwargs="temperature=1.2,do_sample=true"тогда указанные в yaml файле параметры будут проигнорированы.
По умолчанию задачи в lm_eval запускаются со следующими параметрами:
generation_kwargs:
do_sample: false
max_length: input_ids.shape[1] + 256
until:
- tokenizer.eos_token_idtokenizer.eos_token_id — это id EOS токена. Если модель его не имеет, то выпадет ошибка.
input_ids.shape[1] — это размер батча с токенизированными текстами после применения truncation до
max_length данной модели минус 256 токенов для генерации.
repeats: 10repeats позволяет запустить один запрос N раз, чтобы получить N генераций. Однако не забудьте проверить, что вы
указали параметры генерации, чтобы генерация не была жадной. Иначе вы получите 10 одинаковых ответов.
num_fewshot: 5Вы можете указать любое количество фью-шотов по умолчанию. Однако из консоли количество можно поменять:
lm_eval ... --num_fewshot 3Однако, если вы укажете
num_fewshot: 5из консоли количество фью-шотов переопределяться не будет. Потому для задач с 0 шотов в MERA указано:
num_fewshot: nullВы можете указать в метадате любые параметры. Они будут логироваться и будут доступны для просмотра. Например, вы можете указывать версию датасета, чтобы отслеживать изменения и знать, какая версия была запущена.
metadata:
version: 3.0Объединение задач может осуществляться через использование:
tag:
- mera
- mera_generativetag указывается для каждой задачи, которую вы хотите классифицировать и определить к какому-либо тэгу.
Далее вы сможете запускать оценку моделей не на одной или нескольких отдельных задачах,
а сразу на группе — передачей тэга данной группы.
Объединить задачи можно также созданием нового yaml файла.
group: mmmu_val_business
group_alias: Business
task:
- mmmu_val_accounting
- mmmu_val_economics
- mmmu_val_finance
- mmmu_val_manage
- mmmu_val_marketing
aggregate_metric_list:
- metric: acc
aggregation: mean
weight_by_size: trueВы указываете имя группы задач group для обращения к ней по данному имени из консоли.
Поле task соединяет все отдельные задачи по их именам из их yaml файлов.
Все указанные задачи будут запущены одновременно для оценки, как одна большая задача с разделением метрик,
а также общей метрикой, указанной в aggregate_metric_list.
Данная метрика подчиняется тем же правилам, что и метрики в обычных задачах.
Таким образом вы можете реализовать бенчмарк из различных задач с одной агрегирующей метрикой.