Skip to content

Latest commit

 

History

History
1203 lines (939 loc) · 64.3 KB

File metadata and controls

1203 lines (939 loc) · 64.3 KB

Инструкция по добавлению нового датасета в бенчмарк MERA

Обзор

Задачи внутри фреймворка lm-evaluation-harness реализуются в виде отдельных файлов: либо .py, либо .yaml. Дополнительно некоторые задачи могут иметь вспомогательный utils.py файл, внутри которого реализуется все то, что не запрограммировано по умолчанию внутри фреймворка.

Данная инструкция дает пошаговое руководство по созданию задач в виде .yaml файлов. Каждый yaml файл имеет четкую структуру, которая отражает различные этапы проведения оценки языковой модели на конкретной задаче.

Чтобы вы добавили свою задачу в MERA, вам нужно создать для нее пустой yaml файл и следовать инструкции ниже. Обзор ниже дает характеристику каждого этапа добавления датасета, а также описывает детали его реализации.

  1. Обзор
  2. Загрузка датасета
  3. Предобработка датасета
    1. Сплиты датасета
    2. Предобработка отдельных документов из датасета
  4. Формирование запросов в языковую модель
    1. Тип запроса
      1. Описание типов задач
      2. Ответы моделей для разных типов запросов
        1. generate_until
        2. multiple_choice
        3. loglikelihood
        4. loglikelihood_rolling
    2. Формирование запросов
      1. Текстовые задачи
      2. Мультимодальные задачи
    3. Алгоритм построения фью-шотов
      1. Default sampler
      2. Custom sampler (FewshotSampler)
      3. Реализация собственного сэмплера для фью-шотов
      4. Особенности формирования итогового запроса в языковую модель
  5. Обработка генераций языковой модели
    1. Дефолтные фильтры
      1. TakeFirstFilter
      2. RegexFilter
    2. Другие фильтры, реализованные в lm-evaluation-harness
    3. Несколько групп фильтров
    4. Ручная реализация фильтров
  6. Метрики
    1. Ручная обработка ответов для вычисления метрик
    2. Подсчет метрик
  7. Дополнительные параметры задачи
    1. Указание параметров генерации по умолчанию для задачи
    2. Количество генераций для одного запроса
    3. Указание количества фью-шотов по умолчанию
    4. Метадата задачи
    5. Объединение разных задач в одну тэгами
    6. Объединение разных задач в одну новой задачей

Загрузка датасета

Загрузка датасета регулируется ключами:

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

Задача может иметь только один тип запросов. Все запросы будут соответствовать этому типу. Тип запроса определяет то, как запрос будет подаваться в языковую модель, а также, как будут считаться метрики. Сами метрики также могут зависеть от типа запроса.

Описание типов задач

Доступные типы запроса:

  1. generate_until. Соответствует генеративной задаче. То есть, текст запроса передается в модель. Модель генерирует ответ также в виде текста. Данный тип запроса поддерживается самым широким спекторм моделей: открытые, закрытые, доступные по API. Например, ChatGPT, GigaChat, Claude не возвращают пользователю логиты для входного запроса, потому никакой другой тип запроса, кроме данного, к ним не применим.
  2. multiple_choice. Соответствует классификационной задаче с несколькими вариантами ответа. Варианты ответа указываются в этом же yaml файле. В модель передается текст запроса плюс первый вариант ответа. Получается вероятность получить данный текст с таким вариантом ответа, как продолжением текста. Затем передаются все остальные варианты ответа. Каждому варианту ответа будет соответствовать пара (float, bool). float — вероятность получить после текста запроса именно данное продолжение, а bool — является ли данное продолжение результатом "жадной" генерации.
  3. loglikelihood. В модель подается текстовый запрос. Результатом является вероятность получить именно такой текст из языковой модели.
  4. loglikelihood_rolling. Модификация loglikelihood. Вероятности считаются не по целому тексту, а скользящим окном по нему. Данный метод разумно применять, когда изначально стоит задача loglikelihood, но входные тексты слишком длинные для того, чтобы влезть в размер контекста модели. Тексты разбираются на непересекающиеся куски, к каждому применяется метод loglikelihood. В конце прохода окном по тексту вероятности агрегируются в одно число (суммируются).

Обратите внимание, generate_until тип запроса поддерживается самым широким спекторм моделей: открытые, закрытые, доступные по API. Например, ChatGPT, GigaChat, Claude не возвращают пользователю логиты для входного запроса, потому никакой другой тип запроса, кроме данного, к ним не применим.

Потому, если задача будет запускаться на моделях, которые могут не возвращать логиты для входных последовательностей, то типы multiple_choice, loglikelihood, loglikelihood_rolling для таких моделей работать не будут (просто не запустятся). Данные типы запросов подходят для запуска открытых моделей и исследования их поведения. Замеры через логиты позволяют даже для более "слабых" моделей получать оценку их качества на сложных задачах.

Ответы моделей для разных типов запросов

Рассмотрим, что будут выдавать языковые модели при разных типах запроса. Наиболее часто используемыми являются generate_until и multiple_choice — они соответствуют абсолютному большинству типов задач. В дальнейшем примеры будут приводиться на них.

generate_until

Генерация всегда выдается в двумерном списке:

[["model generation"]]

Если генераций больше одной, то они будут идти последовательно в этом же списке:

[["model generation 1", "model generation 2", "model generation 3"]]
multiple_choice

Ответ всегда выдается в двумерном списке:

[[(0.1, True), (0.05, False)]]

Каждая пара соответствует запросу плюс вариант ответа. Если N вариантов ответа, то будет N кортежей с парами. Первое число в М-ой паре соответствует вероятности получить последовательность вида (запрос + M-ый вариант ответа), вторым идет логическое значение: True или False. Оно указвает, является ли M-ый вариант ответа результатом жадной генерации (это значит, что каждый токен в М-ом варианте ответа имеет наибольшую вероятность быть сгенерированным моделью).

loglikelihood

Ответ всегда выдается в двумерном списке:

[[(0.1, True)]]

Логика аналогична multiple_choice, только есть всего один вариант ответа, потому пара всего одна. Для текстового запроса выдается вероятность, что данный ответ будет сгенерирован моделью после запроса, а также то, является ли данный ответ результатом жадной генерации.

loglikelihood_rolling

Ответ всегда выдается в двумерном списке:

[[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_target
def 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: default

fewshot_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: 2

Default sampler

fewshot_config:
  sampler: default

Будем считать, что instruction одинаковый для всех словарей: "Реши пример на сложение:\n{inputs}". Тогда дефолтный сэмплер составит следующую строку:

Реши пример на сложение:
2 + 2 = 4

Реши пример на сложение:
3 + 3 = 6

Реши пример на сложение:
2 + 3 =

То есть, инструкция есть в каждом фью-шоте, а также есть в тестовом вопросе.

Custom sampler (FewshotSampler)

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.

Класс сэмплера имеет три основных метода:

  1. sample, который принимает на вход n (количество примеров для отбора) и doc (тестовый запрос в виде словаря, который можно использовать, чтобы, например, выбирать только такие n запросов, которые по какому-то критерию близки к doc). Возвращает список из словарей для формирования фью-шотов.
  2. get_context, который принимает на вход doc (тестовый вопрос в виде словаря) и num_fewshot (количество фью-шотов, которое необходимо набрать). Вызывает sample, затем формирует строку с фью-шотами и выдает ее.
  3. 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), фильтр через поиск подстроки, которая совпадает с регулярным выражением, не имеет смысла.

Дефолтные фильтры

Рассмотрим два основных фильтра.

TakeFirstFilter

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)]

Это не обязательно, однако упрощает подсчет метрик, которые по умолчанию созданы так, чтобы принимать на вход одномерные списки.

RegexFilter

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
[["А"]]

Другие фильтры, реализованные в lm-evaluation-harness

Фильтр 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 существуют две отдельные сущности:

  1. metric — это метрика. Это функция, которая применяется к каждому сэмплу тестовых данных независимо. В случае accuracy или exact_match сравниваются ответ модели и правильный ответ, результат — либо 0, либо 1.

  2. 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: true

Exact 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)
  • perplexity
  • word_perplexity (perplexity per word)
  • byte_perplexity (perplexity per byte)
  • bits_per_byte
  • matthews_corrcoef (Matthews correlation coefficient)
  • bleu
  • chrf
  • ter
  • wer

Функции агрегации:

  • mean
  • median
  • perplexity
  • weighted_perplexity
  • bits_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_id

tokenizer.eos_token_id — это id EOS токена. Если модель его не имеет, то выпадет ошибка.

input_ids.shape[1] — это размер батча с токенизированными текстами после применения truncation до max_length данной модели минус 256 токенов для генерации.

Количество генераций для одного запроса

repeats: 10

repeats позволяет запустить один запрос 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_generative

tag указывается для каждой задачи, которую вы хотите классифицировать и определить к какому-либо тэгу. Далее вы сможете запускать оценку моделей не на одной или нескольких отдельных задачах, а сразу на группе — передачей тэга данной группы.

Объединение разных задач в одну дополнительной задачей

Объединить задачи можно также созданием нового 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. Данная метрика подчиняется тем же правилам, что и метрики в обычных задачах. Таким образом вы можете реализовать бенчмарк из различных задач с одной агрегирующей метрикой.