From 143770b2c17d4acd5f2d1171d3e45ebae2973359 Mon Sep 17 00:00:00 2001 From: Alex-ast7 Date: Tue, 3 Feb 2026 16:06:41 +0300 Subject: [PATCH 01/14] update submodule --- .gitmodules | 4 ++-- lm-evaluation-harness | 2 +- 2 files changed, 3 insertions(+), 3 deletions(-) diff --git a/.gitmodules b/.gitmodules index e995062..74ffbd3 100644 --- a/.gitmodules +++ b/.gitmodules @@ -1,4 +1,4 @@ [submodule "lm-evaluation-harness"] path = lm-evaluation-harness - url = https://github.com/MERA-Evaluation/lm-evaluation-harness.git - branch = mera_text + url = https://github.com/artemorloff/lm-evaluation-harness.git + branch = rutie_text diff --git a/lm-evaluation-harness b/lm-evaluation-harness index 84a8fda..7e6e86d 160000 --- a/lm-evaluation-harness +++ b/lm-evaluation-harness @@ -1 +1 @@ -Subproject commit 84a8fdae9efeeb6ce9db259e02e4f14ec05b13fc +Subproject commit 7e6e86d1ec3226258ca4a223871616929ba4b7f4 From 1ff10612872806a17d7040439bc06e52e36736a4 Mon Sep 17 00:00:00 2001 From: artemorlov Date: Tue, 3 Feb 2026 16:19:27 +0300 Subject: [PATCH 02/14] fix bugs for newer codebase --- benchmark_tasks/custom_samplers.py | 2 +- benchmark_tasks/rucodeeval/utils.py | 44 +++++++++++++++------------- benchmark_tasks/ruhumaneval/utils.py | 44 +++++++++++++++------------- benchmark_tasks/rutie/utils.py | 4 +-- 4 files changed, 49 insertions(+), 45 deletions(-) diff --git a/benchmark_tasks/custom_samplers.py b/benchmark_tasks/custom_samplers.py index a001e02..4202ddc 100644 --- a/benchmark_tasks/custom_samplers.py +++ b/benchmark_tasks/custom_samplers.py @@ -4,7 +4,7 @@ import warnings from typing import Optional -eval_logger = logging.getLogger("lm-eval") +eval_logger = logging.getLogger(__name__) class FewshotSampler(ContextSampler): diff --git a/benchmark_tasks/rucodeeval/utils.py b/benchmark_tasks/rucodeeval/utils.py index 06eb02d..36dd725 100644 --- a/benchmark_tasks/rucodeeval/utils.py +++ b/benchmark_tasks/rucodeeval/utils.py @@ -14,6 +14,7 @@ from lm_eval.api.filter import Filter from lm_eval.api.registry import register_filter +from lm_eval.api.registry import FILTER_REGISTRY def process_results(doc: Dict, results: List[str]) -> Dict[str, float]: @@ -40,27 +41,28 @@ def process_results(doc: Dict, results: List[str]) -> Dict[str, float]: } # if no label provided (test answers are secret) -@register_filter("ruhumanevalscoring") -class ruHumanEvalScoring(Filter): - def __init__(self) -> None: - """ - Can define custom behavior here, if an individual instantiation of a Filter class should have state. - """ - - def apply(self, resps, docs): - """ - Assuming each entry of `resps` is a list of model responses, we discard all but the first response. - """ - # resps: List[List[str]] - list of lists of generations - 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]) # List - sample_metrics.extend([result]) - code_results.extend([sample_metrics]) - return code_results +if not FILTER_REGISTRY.get("ruhumanevalscoring", None): + @register_filter("ruhumanevalscoring") + class ruHumanEvalScoring(Filter): + def __init__(self) -> None: + """ + Can define custom behavior here, if an individual instantiation of a Filter class should have state. + """ + + def apply(self, resps, docs): + """ + Assuming each entry of `resps` is a list of model responses, we discard all but the first response. + """ + # resps: List[List[str]] - list of lists of generations + 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]) # List + sample_metrics.extend([result]) + code_results.extend([sample_metrics]) + return code_results def preprocess_generation(generation): diff --git a/benchmark_tasks/ruhumaneval/utils.py b/benchmark_tasks/ruhumaneval/utils.py index 06eb02d..36dd725 100644 --- a/benchmark_tasks/ruhumaneval/utils.py +++ b/benchmark_tasks/ruhumaneval/utils.py @@ -14,6 +14,7 @@ from lm_eval.api.filter import Filter from lm_eval.api.registry import register_filter +from lm_eval.api.registry import FILTER_REGISTRY def process_results(doc: Dict, results: List[str]) -> Dict[str, float]: @@ -40,27 +41,28 @@ def process_results(doc: Dict, results: List[str]) -> Dict[str, float]: } # if no label provided (test answers are secret) -@register_filter("ruhumanevalscoring") -class ruHumanEvalScoring(Filter): - def __init__(self) -> None: - """ - Can define custom behavior here, if an individual instantiation of a Filter class should have state. - """ - - def apply(self, resps, docs): - """ - Assuming each entry of `resps` is a list of model responses, we discard all but the first response. - """ - # resps: List[List[str]] - list of lists of generations - 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]) # List - sample_metrics.extend([result]) - code_results.extend([sample_metrics]) - return code_results +if not FILTER_REGISTRY.get("ruhumanevalscoring", None): + @register_filter("ruhumanevalscoring") + class ruHumanEvalScoring(Filter): + def __init__(self) -> None: + """ + Can define custom behavior here, if an individual instantiation of a Filter class should have state. + """ + + def apply(self, resps, docs): + """ + Assuming each entry of `resps` is a list of model responses, we discard all but the first response. + """ + # resps: List[List[str]] - list of lists of generations + 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]) # List + sample_metrics.extend([result]) + code_results.extend([sample_metrics]) + return code_results def preprocess_generation(generation): diff --git a/benchmark_tasks/rutie/utils.py b/benchmark_tasks/rutie/utils.py index a6f3452..fe8968e 100644 --- a/benchmark_tasks/rutie/utils.py +++ b/benchmark_tasks/rutie/utils.py @@ -44,10 +44,10 @@ def _update_request(storage, request): # when string passed (everywhere except for API calls) if isinstance(request.arguments[0], str): - new_req = replace_targets(request.arguments[0], max_num, storage) + new_req = replace_targets(request.arguments[0], max_num, storage).replace("{context}", "") request.arguments = (new_req, request.arguments[1]) else: - new_req = replace_targets(request.arguments[0].prompt, max_num, storage) + new_req = replace_targets(request.arguments[0].prompt, max_num, storage).replace("{context}", "") new_req = JsonChatStr(new_req) request.arguments = (new_req, request.arguments[1]) From 6b559b29df3b179c926882274d426d8c2278d8e8 Mon Sep 17 00:00:00 2001 From: artemorlov Date: Tue, 3 Feb 2026 16:24:47 +0300 Subject: [PATCH 03/14] update submodule --- lm-evaluation-harness | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/lm-evaluation-harness b/lm-evaluation-harness index 7e6e86d..79b871c 160000 --- a/lm-evaluation-harness +++ b/lm-evaluation-harness @@ -1 +1 @@ -Subproject commit 7e6e86d1ec3226258ca4a223871616929ba4b7f4 +Subproject commit 79b871c63ebbca2a308747ca042c0c2f46858608 From 3c0d610cb15a4fe936e9b12a43c7633c2cc25db5 Mon Sep 17 00:00:00 2001 From: artemorlov Date: Tue, 3 Feb 2026 19:28:16 +0300 Subject: [PATCH 04/14] update submodule --- lm-evaluation-harness | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/lm-evaluation-harness b/lm-evaluation-harness index 79b871c..5e14b12 160000 --- a/lm-evaluation-harness +++ b/lm-evaluation-harness @@ -1 +1 @@ -Subproject commit 79b871c63ebbca2a308747ca042c0c2f46858608 +Subproject commit 5e14b12b3f4e352c62873399a425c80d2c14a88c From f4214340b227433a77cb2e077c9c2631565eae70 Mon Sep 17 00:00:00 2001 From: Alexander Kharitonov <48628260+mathamateur@users.noreply.github.com> Date: Thu, 12 Feb 2026 18:29:32 +0300 Subject: [PATCH 05/14] Update dataset_formatting.md --- docs/dataset_formatting.md | 44 ++++++++++++++++++++++++++++++++------ 1 file changed, 38 insertions(+), 6 deletions(-) diff --git a/docs/dataset_formatting.md b/docs/dataset_formatting.md index f0123ea..f4fbb45 100644 --- a/docs/dataset_formatting.md +++ b/docs/dataset_formatting.md @@ -195,20 +195,51 @@ raw_readme_ru.json ## Как оформлять промпты -Формат: +Для каждого датасета необходимо подготовить 5 промптов. По умолчанию все задачи оцениваются в zero-shot сетапе. Если для вашей задачи необходимо указать формат с помощью шотов, то вы можете добавить их. Промпт задается в виде строки с плейсхолдерами. Набор плейсхолдеров должен в точности соответствовать набору поле в поле `inputs` вопросов датасета. Текстовые плейсхолдеры (то есть куда будут вставляться текстовые элементы) пишутся в фигурных скобках {} (`{question}`). +Ваши промпты должны быть декомпозированы на смысловые блоки, разделенные двойным переносом строки ```\n\n```. Примеры блоков, которые могут содержаться в промптах: + +КОНТЕКСТ{context} - при каких обстоятельствах решается задача + +СУТЬ ЗАДАЧИ{task_formulation} - что необходимо сделать + +ПОЯСНЕНИЕ{explanation} - как именно нужно сделать + +ФОРМАТ ОТВЕТА{format_description} - в каком виде оформить ответ(с примером) + +ВОПРОС{question} - смысловая часть промпта, уникальная для каждого примера + +ВАРИАНТЫ ОТВЕТА{option_1...option_n} - для задач с вариантами ответа перечисление этих вариантов + Пример валидного промпта: ``` -"Прочитайте вопрос и варианты ответа и выберите правильный ответ, указав только буквы правильного варианта без дополнительных пояснений. Вопрос:\n{question}\nA. {option_a}\nB. {option_b}\nC. {option_c}\nD. {option_d}\nОтвет:" +СУТЬ ЗАДАЧИ +Прочитайте вопрос и варианты ответа и выберите правильный ответ. + +ФОРМАТ ОТВЕТА +Укажите только букву правильного варианта без дополнительных пояснений через пробел после слова ОТВЕТ. + +ВОПРОС +Сколько будет 2+2? + +ВАРИАНТЫ ОТВЕТА +A. -44 +B. 4.4 +C. 4 +D. 43 + +ОТВЕТ C ``` Требования к промптам: -- Промпты-инструкции должны быть достаточно **разнообразны** и **однозначны**. Разнообразие может достигаться разными способами: например, можно чередовать в промптах обращения на ВЫ и на ТЫ или менять порядок мест вопроса / картинки / описания формата. -- Инструкция должна точно **описывать формат вывода ответа**. Его в дальнейшем нам необходимо парсить, и инструктивная модель должна знать в каком виде писать ответ. Например, "напиши ответ только буквой без дополнительных пояснений". Не должно быть такого, что в коде парсятся ответы да/нет, но при этом в инструкции нет фразы: "отвечай только да или нет". -- В инструкция не должно быть опечаток, странных форм слов, отсутствия согласования и тд. Советуем проверять проверкой орфографии. +- Промпты-инструкции должны быть достаточно **разнообразны** и **однозначны**. Разнообразие может достигаться разными способами: например, можно чередовать в промптах обращения на ВЫ и на ТЫ или менять порядок мест вопроса / картинки / описания формата, использовать просьбу/приказ/манипуляцию. +- Инструкция должна точно **описывать формат вывода ответа**. Его в дальнейшем нам необходимо парсить, и инструктивная модель должна знать в каком виде писать ответ. Например, "напиши ответ только буквой без дополнительных пояснений". Не должно быть такого, что в коде парсятся ответы да/нет, но при этом в инструкции нет фразы: "отвечай только да или нет". Если вы хотите указать явное место в тексте, куда нужно вписать ответ, это нужно сделать с помощью XML тэгов: ОТВЕТ <ответ сюда>. +В коротких промптах (<500 токенов gpt-4o) формат ответа только в конце непосредственно перед самим вопросом (чтобы truncation не отрезал сам вопрос). +В длинных промптах (иначе) формат дается в начале коротко (указываем, что вопрос тестовый и нужно выбрать букву правильного ответа) и в конце перед самим вопросом (детально и однозначно расписываем формат, даем пример с XML тэгом) +- В инструкции не должно быть опечаток, странных форм слов, отсутствия согласования и тд. Советуем использовать инструменты проверки орфографии. - Если промпт предполагает какое-то прямое продолжение, например, заканчивается на "Ответ:" или "4+2=", то убедитесь, что там нет пробелов, переносов строк и других **лишних символов**, которые могут помешать модели продолжать промпт. @@ -240,4 +271,5 @@ python scripts/doc_scripts/autocollect_docs.py "YOUR_DATASET" ## Следующий шаг -Оформленный датасет нужно выложить на 🤗 Hugging Face Hub и отправить организаторам MERA, этот процесс описан в отдельной [инструкции](./dataset_hf.md). \ No newline at end of file + +Оформленный датасет нужно выложить на 🤗 Hugging Face Hub и отправить организаторам MERA, этот процесс описан в отдельной [инструкции](./dataset_hf.md). From 11dccd5e4a68bf5d8f9c501f9d4bf09c2dbcf583 Mon Sep 17 00:00:00 2001 From: mathamateur Date: Wed, 18 Feb 2026 12:27:25 +0300 Subject: [PATCH 06/14] create datasets folder for documentation --- datasets/1_example_dataset/README.md | 138 +++++++++++++++++ datasets/1_example_dataset/README_ru.md | 128 ++++++++++++++++ datasets/1_example_dataset/dataset_meta.json | 142 ++++++++++++++++++ .../1_example_dataset/raw_dataset_meta.json | 79 ++++++++++ datasets/1_example_dataset/raw_readme_en.json | 7 + datasets/1_example_dataset/raw_readme_ru.json | 7 + 6 files changed, 501 insertions(+) create mode 100644 datasets/1_example_dataset/README.md create mode 100644 datasets/1_example_dataset/README_ru.md create mode 100644 datasets/1_example_dataset/dataset_meta.json create mode 100644 datasets/1_example_dataset/raw_dataset_meta.json create mode 100644 datasets/1_example_dataset/raw_readme_en.json create mode 100644 datasets/1_example_dataset/raw_readme_ru.json diff --git a/datasets/1_example_dataset/README.md b/datasets/1_example_dataset/README.md new file mode 100644 index 0000000..0705037 --- /dev/null +++ b/datasets/1_example_dataset/README.md @@ -0,0 +1,138 @@ +### ❗️ Некоторые файлы приведены здесь только для образца, в папке реального датасета в репозитории должны делать только такие файлы: + +``` +├── README.md # автособираемая документация (англ) +├── README_ru.md # автособираемая документация (рус) +├── dataset_meta.json # автособираемая метаинформация +├── raw_readme_en.json # документация вручную (англ) +├── raw_readme_ru.json # документация вручную (рус) +├── raw_dataset_meta.json # метаинформация вручную +``` + + +# XXX + + +## Task description + +Task description. + +Evaluated skills: Counterfactual robustness, Static counting + +Contributors: Name Surname, Name Surname + + +## Motivation + +This section should comprehensively answer the question “Why is this task important?”. Guiding questions (refer to [ECBD](https://aclanthology.org/2024.acl-long.861/) for details on benchmark design motivation): +- What models is this dataset oriented towards evaluating? For which models is it NOT suitable (limitations)? +- What users are the evaluation results targeted at? How will these users interpret the results obtained from evaluating on this dataset? +- What model capabilities does the task assess? What exactly is meant by these capabilities? Not just language understanding, but specifically what and in what context? Why evaluate these particular capabilities? +- How are the questions in the dataset structured, and why are they structured this way? How can one understand that this specific task design evaluates the model capabilities that need to be assessed? Experimental validity. +- How are the metrics chosen (especially when the metric is not simply the proportion of correct answers), and why are they the way they are? How does this choice of metrics help the user interpret the results? + + +## Data description + +### Data fields + +Each dataset question includes data in the following fields: + +- `instruction` [str] — Instruction prompt template with question elements placeholders. +- `inputs` — Input data that forms the task for the model. Can include one or multiple modalities - video, audio, image, text. + - `question` [str] — Text of the question. + - `image` [str] — Path to the image file related to the question. + - `option_a` [str] — Answer option A. + - `option_b` [str] — Answer option B. + - `option_c` [str] — Answer option C. + - `option_d` [str] — Answer option D. +- `outputs` [str] — The correct answer to the question. +- `meta` — Metadata related to the test example, not used in the question (hidden from the tested model). + - `id` [int] — Identification number of the question in the dataset. + - `categories` — Categorial features characterizing the test example. + - `task_type` [str] — Task type according to the task classification in the XXX dataset; + - `image` — Image metadata. + - `synt_source` [list] — Sources used to generate or recreate data for the question, including names of generative models. + - `source` [list] — Information about the origin of the image — according to the image classification for MERA datasets. + - `type` [list] — Image type — according to the image classification for MERA datasets. + - `content` [list] — Image content — according to the image classification for MERA datasets. + - `context` [list] — Accompanying context present in the image — according to the image classification for MERA datasets. + + +### Data formatting example + +```json +{ + "instruction": "Посмотри на картинку и ответь на вопрос, выбрав вариант ответа из предложенных. Напиши только букву правильного ответа.\nВопрос: {question}.\nA. {option_a}\nB. {option_b}\nC. {option_c}\nD. {option_d)\nОтвет:", + "inputs": { + "question": "Сколько автомобилей изображено на фото?", + "image": "image0001.jpg", + "option_a": "три", + "option_b": "два", + "option_c": "ни одного", + "option_d": "пять" + }, + "outputs": "C", + "meta": { + "id": 1, + "categories": { + "task_type": "counterfactual" + }, + "image": { + "synt_source": [ + "model-name" + ], + "source": [ + "photo" + ], + "type": [ + "visual" + ], + "content": [ + "view", + "objects" + ], + "context": [ + "no_context" + ] + } + } +} +``` + + +### Prompts + +For the task, 10 prompts were prepared and evenly distributed among the questions on the principle of "one prompt per question". The templates in curly braces in each prompt are filled in from the fields inside the `inputs` field in each question. + +Prompt example: + +``` +prompt_0 +``` + + +### Dataset creation + +Dataset creation methodology. + + +## Evaluation + + +### Metrics + +Metrics for aggregated evaluation of responses: + +- `Accuracy`: Accuracy is the proportion of correct model predictions among the total number of cases processed. + + +### Human baseline + +To compare the quality of the model responses and human responses, measurements of human responses were taken using the following methodology. + +Human baseline evaluation methodology. + +Evaluation results: + +- Accuracy – 1.00 diff --git a/datasets/1_example_dataset/README_ru.md b/datasets/1_example_dataset/README_ru.md new file mode 100644 index 0000000..ec70a5c --- /dev/null +++ b/datasets/1_example_dataset/README_ru.md @@ -0,0 +1,128 @@ +# XXX + + +## Описание задачи + +Описание задачи. + +Тестируемые навыки моделей: Counterfactual robustness, Static counting + +Авторы: Имя Фамилия, Имя Фамилия + + +## Мотивация + +Этот раздел должен разносторонне отвечать на вопрос “зачем эта задача?”. Вопросы для ориентира (см. фреймворк [ECBD](https://aclanthology.org/2024.acl-long.861/)): + +- На оценку каких моделей ориентирован этот датасет? Для каких моделей он НЕ подходит (limitations)? +- На каких пользователей ориентированы результаты оценки на данной задаче? Как эти пользователи будут интерпретировать результаты, полученные при оценке на этом датасете? +- Какие способности моделей оценивает задача? Что подразумевается под этими способностями? Не просто language understanding, а что именно и в какой постановке. Зачем оценивать именно эти способности? +- Как устроены вопросы в датасете, и почему они устроены именно так? Как понять, что именно такой дизайн задачи оценивает те способности моделей, которые нужно оценить? Валидность эксперимента. +- Как выбраны метрики (особенно когда метрика не просто доля правильных ответов) и почему они именно такие? Как такой выбор метрик помогает пользователю интерпретировать результаты? + + +## Описание датасета + +### Поля данных + +Каждый вопрос в датасете содержит следующие поля: + +- `instruction` [str] — Промпт-инструкция для модели, содержащая шаблон для вставки элементов вопроса. +- `inputs` — Вводные данные, формирующие задание для модели. Могут включать одну или несколько модальностей - видео, аудио, изображение, текст. + - `question` [str] — Текст вопроса. + - `image` [str] — Путь к файлу с изображением, к которому относится вопрос. + - `option_a` [str] — Вариант ответа A. + - `option_b` [str] — Вариант ответа B. + - `option_c` [str] — Вариант ответа C. + - `option_d` [str] — Вариант ответа D. +- `outputs` [str] — Правильный ответ на вопрос. +- `meta` — Метаданные, относящиеся к тестовому примеру, но не используемые в вопросе (скрытые от тестируемой модели). + - `id` [int] — Номер-идентификатор вопроса в датасете. + - `categories` — Категории признаков, характеризующих тестовый пример. + - `task_type` [str] — Тип задач в соответствии с классификацией задач в датасете XXX; + - `image` — Метаданные, относящиеся к изображению. + - `synt_source` [list] — Источники, с помощью которых сгенерированы или воссозданы данные для формирования вопроса, в том числе названия генеративных моделей. + - `source` [list] — Информация о происхождении изображения — согласно классификации изображений для датасетов MERA. + - `type` [list] — Тип изображения — согласно классификации изображений для датасетов MERA. + - `content` [list] — Содержание изображения — согласно классификации изображений для датасетов MERA. + - `context` [list] — Сопроводительный контекст, присутствующий на изображении, — согласно классификации изображений для датасетов MERA. + + +### Пример данных + +```json +{ + "instruction": "Посмотри на картинку и ответь на вопрос, выбрав вариант ответа из предложенных. Напиши только букву правильного ответа.\nВопрос: {question}.\nA. {option_a}\nB. {option_b}\nC. {option_c}\nD. {option_d)\nОтвет:", + "inputs": { + "question": "Сколько автомобилей изображено на фото?", + "image": "image0001.jpg", + "option_a": "три", + "option_b": "два", + "option_c": "ни одного", + "option_d": "пять" + }, + "outputs": "C", + "meta": { + "id": 1, + "categories": { + "task_type": "counterfactual" + }, + "image": { + "synt_source": [ + "model-name" + ], + "source": [ + "photo" + ], + "type": [ + "visual" + ], + "content": [ + "view", + "objects" + ], + "context": [ + "no_context" + ] + } + } +} +``` + + +### Промпты + +Для задачи были подготовлены 10 промптов, которые были равномерно распределены по вопросам по принципу "один вопрос – один промпт". Шаблоны в фигурных скобках в промпте заполняются из полей внутри поля `inputs` в каждом вопросе. + + +Пример промпта: + +``` +prompt_0 +``` + + +### Создание датасета + +Методология создания датасета. + + +## Оценка + + +### Метрики + +Для агрегированной оценки ответов моделей используются следующие метрики: + +- `Accuracy`: Метрика Accuracy вычисляет долю правильных предсказаний модели среди всех обработанных вопросов. + + +### Human baseline + +Для сравнения качества ответов моделей с тем, как отвечают люди, были проведены замеры ответов людей по следующей методологии. + +Методология проведения Human baseline. + +Результаты оценки: + +- Accuracy – 1.00 diff --git a/datasets/1_example_dataset/dataset_meta.json b/datasets/1_example_dataset/dataset_meta.json new file mode 100644 index 0000000..1996f2c --- /dev/null +++ b/datasets/1_example_dataset/dataset_meta.json @@ -0,0 +1,142 @@ +{ + "dataset_name": "XXX", + "license": "MERA_private", + "dataset_size": 1, + "description": "Example dataset description (up to 200 characters).", + "modalities": [ + "text", + "image" + ], + "skills": [ + "Counterfactual robustness", + "Static counting" + ], + "domains": [], + "synt_source_models": [ + "model-name" + ], + "data_example": { + "instruction": "Посмотри на картинку и ответь на вопрос, выбрав вариант ответа из предложенных. Напиши только букву правильного ответа.\nВопрос: {question}.\nA. {option_a}\nB. {option_b}\nC. {option_c}\nD. {option_d)\nОтвет:", + "inputs": { + "question": "Сколько автомобилей изображено на фото?", + "image": "image0001.jpg", + "option_a": "три", + "option_b": "два", + "option_c": "ни одного", + "option_d": "пять" + }, + "outputs": "C", + "meta": { + "id": 1, + "categories": { + "task_type": "counterfactual" + }, + "image": { + "synt_source": [ + "model-name" + ], + "source": [ + "photo" + ], + "type": [ + "visual" + ], + "content": [ + "view", + "objects" + ], + "context": [ + "no_context" + ] + } + } + }, + "data_field_descriptions": { + "instruction": { + "ru": "Промпт-инструкция для модели, содержащая шаблон для вставки элементов вопроса.", + "en": "Instruction prompt template with question elements placeholders." + }, + "inputs": { + "question": { + "ru": "Текст вопроса.", + "en": "Text of the question." + }, + "image": { + "ru": "Путь к файлу с изображением, к которому относится вопрос.", + "en": "Path to the image file related to the question." + }, + "option_a": { + "ru": "Вариант ответа A.", + "en": "Answer option A." + }, + "option_b": { + "ru": "Вариант ответа B.", + "en": "Answer option B." + }, + "option_c": { + "ru": "Вариант ответа C.", + "en": "Answer option C." + }, + "option_d": { + "ru": "Вариант ответа D.", + "en": "Answer option D." + } + }, + "outputs": { + "ru": "Правильный ответ на вопрос.", + "en": "The correct answer to the question." + }, + "meta": { + "id": { + "ru": "Номер-идентификатор вопроса в датасете.", + "en": "Identification number of the question in the dataset." + }, + "categories": { + "task_type": { + "ru": "Тип задач в соответствии с классификацией задач в датасете XXX", + "en": "Task type according to the task classification in the XXX dataset" + } + }, + "image": { + "synt_source": { + "ru": "Источники, с помощью которых сгенерированы или воссозданы данные для формирования вопроса, в том числе названия генеративных моделей.", + "en": "Sources used to generate or recreate data for the question, including names of generative models." + }, + "source": { + "ru": "Информация о происхождении изображения — согласно классификации изображений для датасетов MERA.", + "en": "Information about the origin of the image — according to the image classification for MERA datasets." + }, + "type": { + "ru": "Тип изображения — согласно классификации изображений для датасетов MERA.", + "en": "Image type — according to the image classification for MERA datasets." + }, + "content": { + "ru": "Содержание изображения — согласно классификации изображений для датасетов MERA.", + "en": "Image content — according to the image classification for MERA datasets." + }, + "context": { + "ru": "Сопроводительный контекст, присутствующий на изображении, — согласно классификации изображений для датасетов MERA.", + "en": "Accompanying context present in the image — according to the image classification for MERA datasets." + } + } + } + }, + "prompts": [ + "prompt_0", + "prompt_1", + "prompt_2", + "prompt_3", + "prompt_4", + "prompt_5", + "prompt_6", + "prompt_7", + "prompt_8", + "prompt_9" + ], + "metrics": { + "Accuracy": { + "ru": "Метрика Accuracy вычисляет долю правильных предсказаний модели среди всех обработанных вопросов.", + "en": "Accuracy is the proportion of correct model predictions among the total number of cases processed." + } + } +} \ No newline at end of file diff --git a/datasets/1_example_dataset/raw_dataset_meta.json b/datasets/1_example_dataset/raw_dataset_meta.json new file mode 100644 index 0000000..da3dd2a --- /dev/null +++ b/datasets/1_example_dataset/raw_dataset_meta.json @@ -0,0 +1,79 @@ +{ + "dataset_name": "XXX", + "license": "MERA_private", + "dataset_size": "len(DATA)", + "description": "Example dataset description (up to 200 characters).", + "modalities": ["text", "image"], + "skills": ["Counterfactual robustness", "Static counting"], + "domains": [], + "universal_domains": [], + "synt_source_models": "list(set(sample['meta']['image']['synt_source'] for sample in DATA))", + "data_example": { + "instruction": "Посмотри на картинку и ответь на вопрос, выбрав вариант ответа из предложенных. Напиши только букву правильного ответа.\nВопрос: {question}.\nA. {option_a}\nB. {option_b}\nC. {option_c}\nD. {option_d)\nОтвет:", + "inputs": { + "question": "Сколько автомобилей изображено на фото?", + "image": "image0001.jpg", + "option_a": "три", + "option_b": "два", + "option_c": "ни одного", + "option_d": "пять" + }, + "outputs": "C", + "meta": { + "id": 1, + "categories": { + "task_type": "counterfactual" + }, + "image": { + "synt_source": ["model-name"], + "source": ["photo"], + "type": ["visual"], + "content": ["view", "objects"], + "context": ["no_context"] + } + } + }, + "data_field_descriptions": { + "instruction": {"ru": "default", "en": "default"}, + "inputs": { + "question": {"ru": "default", "en": "default"}, + "image": {"ru": "default", "en": "default"}, + "option_a": {"ru": "default", "en": "default"}, + "option_b": {"ru": "default", "en": "default"}, + "option_c": {"ru": "default", "en": "default"}, + "option_d": {"ru": "default", "en": "default"} + }, + "outputs": {"ru": "default", "en": "default"}, + "meta": { + "id": {"ru": "default", "en": "default"}, + "categories": { + "task_type": {"ru": "Тип задач в соответствии с классификацией задач в датасете XXX", "en": "Task type according to the task classification in the XXX dataset"} + }, + "image": { + "synt_source": {"ru": "default", "en": "default"}, + "source": {"ru": "default", "en": "default"}, + "type": {"ru": "default", "en": "default"}, + "content": {"ru": "default", "en": "default"}, + "context": {"ru": "default", "en": "default"} + } + } + }, + "prompts": [ + "prompt_0", + "prompt_1", + "prompt_2", + "prompt_3", + "prompt_4", + "prompt_5", + "prompt_6", + "prompt_7", + "prompt_8", + "prompt_9" + ], + "metrics": { + "Accuracy": {"ru": "default", "en": "default"} + }, + "human_benchmark": { + "Accuracy": 1.0 + } +} diff --git a/datasets/1_example_dataset/raw_readme_en.json b/datasets/1_example_dataset/raw_readme_en.json new file mode 100644 index 0000000..0e80d3a --- /dev/null +++ b/datasets/1_example_dataset/raw_readme_en.json @@ -0,0 +1,7 @@ +{ + "Task description": "Task description.", + "Motivation": "This section should comprehensively answer the question “Why is this task important?”. Guiding questions (refer to [ECBD](https://aclanthology.org/2024.acl-long.861/) for details on benchmark design motivation):\n- What models is this dataset oriented towards evaluating? For which models is it NOT suitable (limitations)?\n- What users are the evaluation results targeted at? How will these users interpret the results obtained from evaluating on this dataset?\n- What model capabilities does the task assess? What exactly is meant by these capabilities? Not just language understanding, but specifically what and in what context? Why evaluate these particular capabilities?\n- How are the questions in the dataset structured, and why are they structured this way? How can one understand that this specific task design evaluates the model capabilities that need to be assessed? Experimental validity.\n- How are the metrics chosen (especially when the metric is not simply the proportion of correct answers), and why are they the way they are? How does this choice of metrics help the user interpret the results?", + "Dataset creation": "Dataset creation methodology.", + "Human baseline": "Human baseline evaluation methodology.", + "Contributors": "Name Surname, Name Surname" +} diff --git a/datasets/1_example_dataset/raw_readme_ru.json b/datasets/1_example_dataset/raw_readme_ru.json new file mode 100644 index 0000000..58f28ee --- /dev/null +++ b/datasets/1_example_dataset/raw_readme_ru.json @@ -0,0 +1,7 @@ +{ + "Описание задачи": "Описание задачи.", + "Мотивация": "Этот раздел должен разносторонне отвечать на вопрос “зачем эта задача?”. Вопросы для ориентира (см. фреймворк [ECBD](https://aclanthology.org/2024.acl-long.861/)):\n\n- На оценку каких моделей ориентирован этот датасет? Для каких моделей он НЕ подходит (limitations)?\n- На каких пользователей ориентированы результаты оценки на данной задаче? Как эти пользователи будут интерпретировать результаты, полученные при оценке на этом датасете?\n- Какие способности моделей оценивает задача? Что подразумевается под этими способностями? Не просто language understanding, а что именно и в какой постановке. Зачем оценивать именно эти способности?\n- Как устроены вопросы в датасете, и почему они устроены именно так? Как понять, что именно такой дизайн задачи оценивает те способности моделей, которые нужно оценить? Валидность эксперимента.\n- Как выбраны метрики (особенно когда метрика не просто доля правильных ответов) и почему они именно такие? Как такой выбор метрик помогает пользователю интерпретировать результаты?", + "Создание датасета": "Методология создания датасета.", + "Human baseline": "Методология проведения Human baseline.", + "Авторы": "Имя Фамилия, Имя Фамилия" +} From 8822b39c788009caac6fa2108c002291098f3e3f Mon Sep 17 00:00:00 2001 From: Alexander Kharitonov <48628260+mathamateur@users.noreply.github.com> Date: Wed, 4 Mar 2026 10:49:33 +0300 Subject: [PATCH 07/14] =?UTF-8?q?=D0=98=D0=BD=D1=81=D1=82=D1=80=D1=83?= =?UTF-8?q?=D0=BA=D1=86=D0=B8=D1=8F=20=D0=B4=D0=BB=D1=8F=20PR?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- docs/how_to_add_dataset.md | 273 +++++++++++++++++++++++++++++++++++++ 1 file changed, 273 insertions(+) create mode 100644 docs/how_to_add_dataset.md diff --git a/docs/how_to_add_dataset.md b/docs/how_to_add_dataset.md new file mode 100644 index 0000000..35a675c --- /dev/null +++ b/docs/how_to_add_dataset.md @@ -0,0 +1,273 @@ +# 🚀 Руководство по добавлению датасетов в бенчмарк MERA + +Добро пожаловать! Этот документ описывает полный цикл интеграции нового датасета в MERA. + +**Главное правило:** Вся работа над датасетом ведется через **один Pull Request (PR)** в GitHub. При этом сами файлы с данными (`test.json`, `shots.json`, медиа) **никогда не загружаются в репозиторий** — они отправляются только на Hugging Face (и в защищенное S3 хранилище). Это гарантирует безопасность от утечек. + +--- + +## 📋 Чек-лист: Требования к датасету + +Прежде чем начать добавление, убедитесь, что ваша задача соответствует критериям: + +* **Объем:** Минимум 300 вопросов суммарно. Если есть категории/домены, делайте **от 50 до 200 вопросов на каждую**, чтобы датасет не был излишне большим. +* **Сложность:** Задачи не должны решаться простым скриптом или поиском по открытым источникам. Замеры на baseline-моделях должны давать **метрики не выше 0.7** (иначе следующее поколение моделей решит тест слишком легко). +* **Качество данных:** Строгий баланс классов, отсутствие дубликатов, защита от «взлома» (ответ не должен угадываться по длине строки или метаданным). +* **Правомерность:** Соблюдены лицензии. Если данные синтетические, явно указана модель-генератор. Для открытых данных указано, откуда данные были взяты. + +--- + +## 🛠 Пошаговый процесс интеграции + +### Шаг 1. Инициация (Создание PR) +Создайте Pull Request в официальном репозитории [MERA-Evaluation/MERA_Industrial](https://github.com/MERA-Evaluation/MERA_Industrial). На этом этапе код писать не нужно, достаточно указать в описании PR паспорт датасета: +1. **Краткое описание:** Суть задачи, о чем она (убедитесь, что задача не дублирует уже существующую). +2. **Проверяемые навыки:** Что оценивает тест, какие навыки нужны, чтобы решить его, сложность. +3. **Пример:** Один репрезентативный вопрос и правильный ответ к нему. +4. **Лицензия:** Под какой лицензией планируется публикация (может зависеть от лицензий источников данных). +5. **Происхождение данных:** Как и кем собран датасет, какие модели использовались для генерации, откуда брались данные, делалась ли обработка данных и как. + + +### Шаг 2. Подготовка данных +Соберите сами данные. Они состоят из файлов `test.json` (основной тест), `shots.json` (общее демо задачи, а также, возможно, примеры для few-shots) и папки с медиа `samples/` (картинки, аудио, видео — если есть). +❗️ *Эти файлы не загружаются в GitHub!* + +**Пример структуры `test.json` (и `shots.json`):** +Данные должны лежать в массиве `data` и иметь строго определенные поля: +```json +{ + "access": "public", // публичный или приватный датасет + "data": { + "instruction": 0, + "inputs": { + "question": "Текст вопроса", + "option_a": "Первый вариант ответа", + "option_b": "Второй вариант ответа" + }, + "outputs": "A", + "meta": { + "id": 1, // уникальный ID сэмпла (в test.json и shots.json не должно быть пересечений) + "categories": { + "domain": "math" + } + } + } +} +``` +* **`instruction`**: Индекс промпта (число), который подтянется из метаданных. +* **`inputs`**: Входные данные. Имена ключей должны строго совпадать с плейсхолдерами в промптах. +* **`outputs`**: Правильный ответ. +* **`meta`**: Сопроводительная информация. Не используется в промпте! Может использоваться в метриках. + +🔒 **Важное правило для приватных датасетов (защита от утечек):** +Чтобы минимизировать риск "лика" данных, перед загрузкой на Hugging Face из файла `test.json` **нужно удалить правильные ответы** (поле `outputs`, оставить там пустую строку - `""`). Полные версии файлов (с ответами) запаковываются в zip-архив, который передается организаторам на 9-м шаге для безопасного хранения в S3. Для публичных датасетов данные также передаются в zip-архиве на 9-м шаге, но ответы должны быть загружен также на Hugging Face. + +### Шаг 3. Подготовка документации и автосборка +Документация собирается из "сырых" (raw) файлов, которые вы заполняете вручную в локальной папке `datasets/YOUR_DATASET/` (`raw_readme_en.json`, `raw_readme_ru.json` и `raw_dataset_meta.json`). + +Пример `raw_readme_ru.json`: +```json +{ + "Описание задачи": "Общее описание задачи. О чем она, что проверяет, есть ли аналоги.", + "Мотивация": "Зачем датасет создавался, в чем его цель, зачем это нужно.", + "Создание датасета": "Как создавался датасет - описание процесса с фактурой.", + "Human baseline": "Как проводился замер Human Baseline: на какой платформе, какое перекрытие, сколько обучающих и контрольных заданий было, какая согласованность у разметчиков.", + "Авторы": "Перечислить авторов в порядке их вклада в создание датасета и добавления в MERA.", +} +``` + +Файл `raw_readme_en.json` дублирует `raw_readme_ru.json`, но ключи и тексты по этим ключам на английском. + +Пример `raw_dataset_meta.json` + +```json +{ + "dataset_name": "Название датасета", + "license": "Наименование лицензии со ссылкой на документ с ее условиями.", + "dataset_size": "Количество сэмплов в test.json", + "description": "Natural Science VQA dataset.", + "modalities": [ + "Перечисление используемых модельностей: text|audio|image|video.", + ], + "skills": [ + "Список навыков, которые проверяет задача по таксономии навыков MERA.", + ], + "domains": [ + "Список доменов, которые проверяются вопросами датасета", + ], + "universal_domains": ["Список доменов, который заполняют организаторы MERA после интеграции датасета."], + "synt_source_models": ["Список моделей, которые использовались для генерации данных (если использовались)."], + "data_example": { + "instruction": "Текст одного из промптов", + "inputs": { + "question": "Текст вопроса", + "option_a": "Первый вариант ответа", + "option_b": "Второй вариант ответа" + }, + "outputs": "A", + "meta": { + "id": 1, + "categories": { + "domain": "math" + } + } + }, + "data_field_descriptions": { + "instruction": { + "ru": "Описание того, что содержится в этом поле задания.", + "en": "Описание того, что содержится в этом поле задания на английском." + }, + "inputs": { + "question": { + "ru": "Описание того, что содержится в этом поле задания.", + "en": "Описание того, что содержится в этом поле задания на английском." + }, + "option_a": { + "ru": "Описание того, что содержится в этом поле задания.", + "en": "Описание того, что содержится в этом поле задания на английском." + }, + "option_b": { + "ru": "Описание того, что содержится в этом поле задания.", + "en": "Описание того, что содержится в этом поле задания на английском." + } + }, + "outputs": { + "ru": "Описание того, что содержится в этом поле задания.", + "en": "Описание того, что содержится в этом поле задания на английском." + }, + "meta": { + "id": { + "ru": "Описание того, что содержится в этом поле задания.", + "en": "Описание того, что содержится в этом поле задания на английском." + }, + "categories": { + "domain": { + "ru": "Описание того, что содержится в этом поле задания.", + "en": "Описание того, что содержится в этом поле задания на английском." + } + } + } + }, + "prompts": [ + "Ваш список промптов." + ], + "metrics": { + "MetricName": { + "ru": "Описание метрики, что замеряет.", + "en": "Описание метрики, что замеряет на английском." + } + }, + "human_benchmark": { + "MetricName": "Значение метрики float-числом." + } +} +``` + +#### 💡 Правила оформления промптов (в `raw_dataset_meta.json`) +В метаданных должно быть заложено **минимум 5 промптов** (для датасетов с доменами — по 5 на домен, если у них отличается структура или формат ответа). Строго соблюдайте следующие требования: + +* **Лаконичность и модальность:** Промпты должны быть короткими (не нужно расписывать всё на 200 токенов). Содержание промпта определяется его модальностью (например, приказ, вежливая просьба на "вы", манипуляция (простой пример: "кто бы мне помог решить задачу?"), неформальное обращение на "ты"). +* **Кристально ясный формат:** Как именно ответить — должно быть абсолютно однозначно. Обязательно сопровождайте формат примером с XML-тегом. + * *Пример:* `Ответ: <буква выбранного варианта ответа>` + * *Для Structured Output:* Если ожидается ответ сложным JSON-ом, формат задается через JSON Schema. + * *Для сложных форматов:* (не одно слово, буква, цифра или перечисление через запятую) — детальное описание формата с примером. + * *Делимитер*: Всегда требуем от модели отделить финальный ответ каким-то делимитером, например, написать финальный ответ после "Ответ:". +* **Расположение блока ФОРМАТ ОТВЕТА:** + * **В коротких промптах** (< 500 токенов GPT-4o): формат дается *только в конце*, непосредственно перед самим вопросом (чтобы при усечении/truncation не отрезалась суть вопроса). + * **В длинных промптах:** формат дается *кратко в начале* (указываем, что вопрос тестовый и нужно выбрать букву правильного ответа) **И** *детально в конце* перед самим вопросом (коротко и однозначно расписываем формат, даем пример с XML-тегом). +* **Структура и разметка блоков:** Все смысловые части задачи в промпте отделяются друг от друга двойным переносом строки и подписываются заглавными буквами. + * *Пример структуры:* + ```text + КОНТЕКСТ + {context} + + ПОЯСНЕНИЕ + {explanation} + + ФОРМАТ ОТВЕТА + {format_description} + + ПРИМЕР ОТВЕТА + {sample_answer} + + ВОПРОС + {question} + ``` + +**Автосборка документации:** +Запустите скрипт из корня репозитория, чтобы сгенерировать чистовые файлы (`README.md`, `README_ru.md`, `dataset_meta.json`): +```bash +python scripts/doc_scripts/autocollect_docs.py "YOUR_DATASET" +``` + +После внесения изменений в "сырые" файлы всегда заново генерируйте чистовые файлы и кладите их в репозиторий, чтобы избежать рассинхронизации версий и информации (как между `ru` и `en` файлами, так и в `dataset_meta`). + +### Шаг 4. Загрузка файлов (Hugging Face и GitHub) +* **На Hugging Face (Данные):** Загрузите `test.json` (без ответов, если датасет приватный!), `shots.json` и `samples/` (если есть мультимодальные данные). Датасет должен быть **приватным**! +* **В GitHub PR (Документация):** Запушьте в ваш PR папку с документацией (исходные `raw_` файлы и сгенерированные чистовые). В комментарии к PR дайте ссылку на HF репозиторий с данными. + +### Шаг 5. Интеграция задачи в кодовую базу (lm-eval) +Необходимо реализовать запуск вашей задачи силами фреймворка оценки (на базе `lm-eval`). Используйте другие задачи MERA в репозитории как референс и делайте по аналогии. +* **Результат:** В ваш PR добавляется папка (`industrial_tasks/your_dataset_name`) с `yaml` файлом конфигурации задачи и скриптом `utils.py` (если нужны кастомные функции для обработки данных или подсчета метрик, на которые ссылается `yaml`). +* **Подтверждение работоспособности:** Вы должны локально запустить замер любой модели на вашей задаче. В комментарии к вашему PR приложите: + 1. Команду запуска (например, `lm-eval --model ... --tasks your_dataset_name`). + 2. Итоговую таблицу с метриками, полученную после завершения работы скрипта. + +### Шаг 6. Замер baseline-моделей +От автора датасета требуется провести замеры интегрированной задачи на baseline-моделях (как минимум на небольших моделях), используя написанный на предыдущем шаге код. +* **Зачем это нужно:** + 1. Это подтверждает техническую корректность интеграции (результаты `lm-eval` должны совпадать с вашими черновыми проверками до интеграции, если вы оценивали сложность задачи заранее). + 2. Это подтверждает заявленную сложность задачи: замеры не должны превышать порог в **0.7**. Результаты бейзлайнов также прикладываются в PR. +* **Список бейзлайнов:** + - Небольшие модели: + - meta-llama/Llama-3.1-8B-Instruct + - Qwen/Qwen3-4B-Instruct-2507 + - Qwen/Qwen2.5-7B-Instruct + - Qwen/Qwen3-8B + - mistralai/Ministral-3-8B-Instruct-2512-BF16 + - Большие модели: + - Qwen/Qwen3-Next-80B-A3B-Instruct + - Qwen/Qwen3.5-35B-A3B + - openai/gpt-oss-20b + - meta-llama/Llama-3.3-70B-Instruct + - Проприетарные модели: + - gemini-3-flash + - gpt-5-mini + - deepseek-r1 + + +### Шаг 7. Оценка людьми (Human Baseline) +Замеряем, как с задачей справляется человек. +* **Правила:** Разметка проводится на краудсорс-платформах (или экспертами). Запрещено гуглить и использовать ИИ. Условия должны быть идентичны тем, в которых тестируется модель. +* **Контроль:** Каждый вопрос оценивают 5 человек. Добавьте ~5-10% контрольных вопросов (honeypots) с известным ответом, а также при необходимости обучающие вопросы для набора навыка. +* **Итог:** Скор внесите в `raw_dataset_meta.json` (и пересоберите доки скриптом). Сырые ответы и код агрегации запушьте в папку `hb/` в ваш PR. + +### Шаг 8. Отправка на финальное ревью +Убедитесь, что ваш PR обновлен (есть документация, код задачи, результаты прогонов и HB). Структура файлов, которые вносятся вашим PR: +```text +MERA_Industrial/ +├── industrial_tasks/           # Code for tasks + ├── your_task + ├── your_task.yaml + ├── utils.py +├── datasets/                    # Task descriptions, metadata, readme + ├── YOUR_DATASET + ├── raw_readme_en.json + ├── raw_readme_ru.json + ├── raw_dataset_meta.json + ├── README.md + ├── README_ru.md + ├── dataset_meta.json + ├── upload_to_HF.ipynb # Script you used to upload the dataset on HF +└── scripts/                     # In case there are some scripts for the task +``` + +Напишите финальное письмо на почту `mera@a-ai.ru`: +> Название датасета: [Название] +> Ссылка на PR: [Ссылка] +> Ссылка на датасет в HF: [URL] +> Токен HF с доступом на запись: [Токен] + +📎 **Для всех датасетов:** Обязательно прикрепите к письму zip-архив с полными файлами `test.json` (с ответами) и `shots.json` для размещения в нашем S3-хранилище. + +После успешного финального ревью мы вольем PR в основную ветку, и ваш датасет станет официальной частью бенчмарка MERA! From 8eea351c4a586bce321a81893acfd0dd66ec8516 Mon Sep 17 00:00:00 2001 From: Alexander Kharitonov <48628260+mathamateur@users.noreply.github.com> Date: Wed, 4 Mar 2026 10:55:17 +0300 Subject: [PATCH 08/14] Update how_to_add_dataset.md --- docs/how_to_add_dataset.md | 270 +------------------------------------ 1 file changed, 2 insertions(+), 268 deletions(-) diff --git a/docs/how_to_add_dataset.md b/docs/how_to_add_dataset.md index 35a675c..b2baeb3 100644 --- a/docs/how_to_add_dataset.md +++ b/docs/how_to_add_dataset.md @@ -1,273 +1,7 @@ -# 🚀 Руководство по добавлению датасетов в бенчмарк MERA - -Добро пожаловать! Этот документ описывает полный цикл интеграции нового датасета в MERA. - -**Главное правило:** Вся работа над датасетом ведется через **один Pull Request (PR)** в GitHub. При этом сами файлы с данными (`test.json`, `shots.json`, медиа) **никогда не загружаются в репозиторий** — они отправляются только на Hugging Face (и в защищенное S3 хранилище). Это гарантирует безопасность от утечек. - ---- - -## 📋 Чек-лист: Требования к датасету - -Прежде чем начать добавление, убедитесь, что ваша задача соответствует критериям: - -* **Объем:** Минимум 300 вопросов суммарно. Если есть категории/домены, делайте **от 50 до 200 вопросов на каждую**, чтобы датасет не был излишне большим. -* **Сложность:** Задачи не должны решаться простым скриптом или поиском по открытым источникам. Замеры на baseline-моделях должны давать **метрики не выше 0.7** (иначе следующее поколение моделей решит тест слишком легко). -* **Качество данных:** Строгий баланс классов, отсутствие дубликатов, защита от «взлома» (ответ не должен угадываться по длине строки или метаданным). -* **Правомерность:** Соблюдены лицензии. Если данные синтетические, явно указана модель-генератор. Для открытых данных указано, откуда данные были взяты. - ---- - -## 🛠 Пошаговый процесс интеграции - -### Шаг 1. Инициация (Создание PR) -Создайте Pull Request в официальном репозитории [MERA-Evaluation/MERA_Industrial](https://github.com/MERA-Evaluation/MERA_Industrial). На этом этапе код писать не нужно, достаточно указать в описании PR паспорт датасета: +# Создание PR +Создайте Pull Request в официальном репозитории [MERA-Evaluation/MERA](https://github.com/MERA-Evaluation/MERA/tree/v2_dev). Укажите в описании PR паспорт датасета: 1. **Краткое описание:** Суть задачи, о чем она (убедитесь, что задача не дублирует уже существующую). 2. **Проверяемые навыки:** Что оценивает тест, какие навыки нужны, чтобы решить его, сложность. 3. **Пример:** Один репрезентативный вопрос и правильный ответ к нему. 4. **Лицензия:** Под какой лицензией планируется публикация (может зависеть от лицензий источников данных). 5. **Происхождение данных:** Как и кем собран датасет, какие модели использовались для генерации, откуда брались данные, делалась ли обработка данных и как. - - -### Шаг 2. Подготовка данных -Соберите сами данные. Они состоят из файлов `test.json` (основной тест), `shots.json` (общее демо задачи, а также, возможно, примеры для few-shots) и папки с медиа `samples/` (картинки, аудио, видео — если есть). -❗️ *Эти файлы не загружаются в GitHub!* - -**Пример структуры `test.json` (и `shots.json`):** -Данные должны лежать в массиве `data` и иметь строго определенные поля: -```json -{ - "access": "public", // публичный или приватный датасет - "data": { - "instruction": 0, - "inputs": { - "question": "Текст вопроса", - "option_a": "Первый вариант ответа", - "option_b": "Второй вариант ответа" - }, - "outputs": "A", - "meta": { - "id": 1, // уникальный ID сэмпла (в test.json и shots.json не должно быть пересечений) - "categories": { - "domain": "math" - } - } - } -} -``` -* **`instruction`**: Индекс промпта (число), который подтянется из метаданных. -* **`inputs`**: Входные данные. Имена ключей должны строго совпадать с плейсхолдерами в промптах. -* **`outputs`**: Правильный ответ. -* **`meta`**: Сопроводительная информация. Не используется в промпте! Может использоваться в метриках. - -🔒 **Важное правило для приватных датасетов (защита от утечек):** -Чтобы минимизировать риск "лика" данных, перед загрузкой на Hugging Face из файла `test.json` **нужно удалить правильные ответы** (поле `outputs`, оставить там пустую строку - `""`). Полные версии файлов (с ответами) запаковываются в zip-архив, который передается организаторам на 9-м шаге для безопасного хранения в S3. Для публичных датасетов данные также передаются в zip-архиве на 9-м шаге, но ответы должны быть загружен также на Hugging Face. - -### Шаг 3. Подготовка документации и автосборка -Документация собирается из "сырых" (raw) файлов, которые вы заполняете вручную в локальной папке `datasets/YOUR_DATASET/` (`raw_readme_en.json`, `raw_readme_ru.json` и `raw_dataset_meta.json`). - -Пример `raw_readme_ru.json`: -```json -{ - "Описание задачи": "Общее описание задачи. О чем она, что проверяет, есть ли аналоги.", - "Мотивация": "Зачем датасет создавался, в чем его цель, зачем это нужно.", - "Создание датасета": "Как создавался датасет - описание процесса с фактурой.", - "Human baseline": "Как проводился замер Human Baseline: на какой платформе, какое перекрытие, сколько обучающих и контрольных заданий было, какая согласованность у разметчиков.", - "Авторы": "Перечислить авторов в порядке их вклада в создание датасета и добавления в MERA.", -} -``` - -Файл `raw_readme_en.json` дублирует `raw_readme_ru.json`, но ключи и тексты по этим ключам на английском. - -Пример `raw_dataset_meta.json` - -```json -{ - "dataset_name": "Название датасета", - "license": "Наименование лицензии со ссылкой на документ с ее условиями.", - "dataset_size": "Количество сэмплов в test.json", - "description": "Natural Science VQA dataset.", - "modalities": [ - "Перечисление используемых модельностей: text|audio|image|video.", - ], - "skills": [ - "Список навыков, которые проверяет задача по таксономии навыков MERA.", - ], - "domains": [ - "Список доменов, которые проверяются вопросами датасета", - ], - "universal_domains": ["Список доменов, который заполняют организаторы MERA после интеграции датасета."], - "synt_source_models": ["Список моделей, которые использовались для генерации данных (если использовались)."], - "data_example": { - "instruction": "Текст одного из промптов", - "inputs": { - "question": "Текст вопроса", - "option_a": "Первый вариант ответа", - "option_b": "Второй вариант ответа" - }, - "outputs": "A", - "meta": { - "id": 1, - "categories": { - "domain": "math" - } - } - }, - "data_field_descriptions": { - "instruction": { - "ru": "Описание того, что содержится в этом поле задания.", - "en": "Описание того, что содержится в этом поле задания на английском." - }, - "inputs": { - "question": { - "ru": "Описание того, что содержится в этом поле задания.", - "en": "Описание того, что содержится в этом поле задания на английском." - }, - "option_a": { - "ru": "Описание того, что содержится в этом поле задания.", - "en": "Описание того, что содержится в этом поле задания на английском." - }, - "option_b": { - "ru": "Описание того, что содержится в этом поле задания.", - "en": "Описание того, что содержится в этом поле задания на английском." - } - }, - "outputs": { - "ru": "Описание того, что содержится в этом поле задания.", - "en": "Описание того, что содержится в этом поле задания на английском." - }, - "meta": { - "id": { - "ru": "Описание того, что содержится в этом поле задания.", - "en": "Описание того, что содержится в этом поле задания на английском." - }, - "categories": { - "domain": { - "ru": "Описание того, что содержится в этом поле задания.", - "en": "Описание того, что содержится в этом поле задания на английском." - } - } - } - }, - "prompts": [ - "Ваш список промптов." - ], - "metrics": { - "MetricName": { - "ru": "Описание метрики, что замеряет.", - "en": "Описание метрики, что замеряет на английском." - } - }, - "human_benchmark": { - "MetricName": "Значение метрики float-числом." - } -} -``` - -#### 💡 Правила оформления промптов (в `raw_dataset_meta.json`) -В метаданных должно быть заложено **минимум 5 промптов** (для датасетов с доменами — по 5 на домен, если у них отличается структура или формат ответа). Строго соблюдайте следующие требования: - -* **Лаконичность и модальность:** Промпты должны быть короткими (не нужно расписывать всё на 200 токенов). Содержание промпта определяется его модальностью (например, приказ, вежливая просьба на "вы", манипуляция (простой пример: "кто бы мне помог решить задачу?"), неформальное обращение на "ты"). -* **Кристально ясный формат:** Как именно ответить — должно быть абсолютно однозначно. Обязательно сопровождайте формат примером с XML-тегом. - * *Пример:* `Ответ: <буква выбранного варианта ответа>` - * *Для Structured Output:* Если ожидается ответ сложным JSON-ом, формат задается через JSON Schema. - * *Для сложных форматов:* (не одно слово, буква, цифра или перечисление через запятую) — детальное описание формата с примером. - * *Делимитер*: Всегда требуем от модели отделить финальный ответ каким-то делимитером, например, написать финальный ответ после "Ответ:". -* **Расположение блока ФОРМАТ ОТВЕТА:** - * **В коротких промптах** (< 500 токенов GPT-4o): формат дается *только в конце*, непосредственно перед самим вопросом (чтобы при усечении/truncation не отрезалась суть вопроса). - * **В длинных промптах:** формат дается *кратко в начале* (указываем, что вопрос тестовый и нужно выбрать букву правильного ответа) **И** *детально в конце* перед самим вопросом (коротко и однозначно расписываем формат, даем пример с XML-тегом). -* **Структура и разметка блоков:** Все смысловые части задачи в промпте отделяются друг от друга двойным переносом строки и подписываются заглавными буквами. - * *Пример структуры:* - ```text - КОНТЕКСТ - {context} - - ПОЯСНЕНИЕ - {explanation} - - ФОРМАТ ОТВЕТА - {format_description} - - ПРИМЕР ОТВЕТА - {sample_answer} - - ВОПРОС - {question} - ``` - -**Автосборка документации:** -Запустите скрипт из корня репозитория, чтобы сгенерировать чистовые файлы (`README.md`, `README_ru.md`, `dataset_meta.json`): -```bash -python scripts/doc_scripts/autocollect_docs.py "YOUR_DATASET" -``` - -После внесения изменений в "сырые" файлы всегда заново генерируйте чистовые файлы и кладите их в репозиторий, чтобы избежать рассинхронизации версий и информации (как между `ru` и `en` файлами, так и в `dataset_meta`). - -### Шаг 4. Загрузка файлов (Hugging Face и GitHub) -* **На Hugging Face (Данные):** Загрузите `test.json` (без ответов, если датасет приватный!), `shots.json` и `samples/` (если есть мультимодальные данные). Датасет должен быть **приватным**! -* **В GitHub PR (Документация):** Запушьте в ваш PR папку с документацией (исходные `raw_` файлы и сгенерированные чистовые). В комментарии к PR дайте ссылку на HF репозиторий с данными. - -### Шаг 5. Интеграция задачи в кодовую базу (lm-eval) -Необходимо реализовать запуск вашей задачи силами фреймворка оценки (на базе `lm-eval`). Используйте другие задачи MERA в репозитории как референс и делайте по аналогии. -* **Результат:** В ваш PR добавляется папка (`industrial_tasks/your_dataset_name`) с `yaml` файлом конфигурации задачи и скриптом `utils.py` (если нужны кастомные функции для обработки данных или подсчета метрик, на которые ссылается `yaml`). -* **Подтверждение работоспособности:** Вы должны локально запустить замер любой модели на вашей задаче. В комментарии к вашему PR приложите: - 1. Команду запуска (например, `lm-eval --model ... --tasks your_dataset_name`). - 2. Итоговую таблицу с метриками, полученную после завершения работы скрипта. - -### Шаг 6. Замер baseline-моделей -От автора датасета требуется провести замеры интегрированной задачи на baseline-моделях (как минимум на небольших моделях), используя написанный на предыдущем шаге код. -* **Зачем это нужно:** - 1. Это подтверждает техническую корректность интеграции (результаты `lm-eval` должны совпадать с вашими черновыми проверками до интеграции, если вы оценивали сложность задачи заранее). - 2. Это подтверждает заявленную сложность задачи: замеры не должны превышать порог в **0.7**. Результаты бейзлайнов также прикладываются в PR. -* **Список бейзлайнов:** - - Небольшие модели: - - meta-llama/Llama-3.1-8B-Instruct - - Qwen/Qwen3-4B-Instruct-2507 - - Qwen/Qwen2.5-7B-Instruct - - Qwen/Qwen3-8B - - mistralai/Ministral-3-8B-Instruct-2512-BF16 - - Большие модели: - - Qwen/Qwen3-Next-80B-A3B-Instruct - - Qwen/Qwen3.5-35B-A3B - - openai/gpt-oss-20b - - meta-llama/Llama-3.3-70B-Instruct - - Проприетарные модели: - - gemini-3-flash - - gpt-5-mini - - deepseek-r1 - - -### Шаг 7. Оценка людьми (Human Baseline) -Замеряем, как с задачей справляется человек. -* **Правила:** Разметка проводится на краудсорс-платформах (или экспертами). Запрещено гуглить и использовать ИИ. Условия должны быть идентичны тем, в которых тестируется модель. -* **Контроль:** Каждый вопрос оценивают 5 человек. Добавьте ~5-10% контрольных вопросов (honeypots) с известным ответом, а также при необходимости обучающие вопросы для набора навыка. -* **Итог:** Скор внесите в `raw_dataset_meta.json` (и пересоберите доки скриптом). Сырые ответы и код агрегации запушьте в папку `hb/` в ваш PR. - -### Шаг 8. Отправка на финальное ревью -Убедитесь, что ваш PR обновлен (есть документация, код задачи, результаты прогонов и HB). Структура файлов, которые вносятся вашим PR: -```text -MERA_Industrial/ -├── industrial_tasks/           # Code for tasks - ├── your_task - ├── your_task.yaml - ├── utils.py -├── datasets/                    # Task descriptions, metadata, readme - ├── YOUR_DATASET - ├── raw_readme_en.json - ├── raw_readme_ru.json - ├── raw_dataset_meta.json - ├── README.md - ├── README_ru.md - ├── dataset_meta.json - ├── upload_to_HF.ipynb # Script you used to upload the dataset on HF -└── scripts/                     # In case there are some scripts for the task -``` - -Напишите финальное письмо на почту `mera@a-ai.ru`: -> Название датасета: [Название] -> Ссылка на PR: [Ссылка] -> Ссылка на датасет в HF: [URL] -> Токен HF с доступом на запись: [Токен] - -📎 **Для всех датасетов:** Обязательно прикрепите к письму zip-архив с полными файлами `test.json` (с ответами) и `shots.json` для размещения в нашем S3-хранилище. - -После успешного финального ревью мы вольем PR в основную ветку, и ваш датасет станет официальной частью бенчмарка MERA! From 482284b72f1888d36672d9581e88ab8020a29f40 Mon Sep 17 00:00:00 2001 From: Alexander Kharitonov <48628260+mathamateur@users.noreply.github.com> Date: Wed, 4 Mar 2026 10:57:34 +0300 Subject: [PATCH 09/14] Update README.md --- README.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/README.md b/README.md index e690d2e..dac78bc 100644 --- a/README.md +++ b/README.md @@ -47,7 +47,7 @@ etc. 0. Develop your dataset according to the text LLM evaluation criteria ([see requirements](docs/dataset_review.md)). 1. Format your dataset to our specifications ([format instruction](docs/dataset_formatting.md)) and upload it to the 🤗 Hugging Face Hub ([instruction](docs/dataset_hf.md](docs/dataset_formatting.md))). 2. Integrate your dataset into our codebase using the instructions above. Check that it works by running the baselines! ([instruction](docs/task_codebase.md)). -3. Submit a **Pull Request** with your dataset to this repository. +3. Submit a **Pull Request** with your dataset to this repository ([instruction](docs/how_to_add_dataset.md)). We will review your submission and, upon approval, add it to New MERA TEXT. From f0ce61b3c64f017df148b4d1737e40981ee3ac39 Mon Sep 17 00:00:00 2001 From: Alexander Kharitonov <48628260+mathamateur@users.noreply.github.com> Date: Fri, 6 Mar 2026 10:29:23 +0300 Subject: [PATCH 10/14] Update README.md --- README.md | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/README.md b/README.md index dac78bc..6871536 100644 --- a/README.md +++ b/README.md @@ -44,10 +44,10 @@ etc. **🚀 How to Contribute?** -0. Develop your dataset according to the text LLM evaluation criteria ([see requirements](docs/dataset_review.md)). -1. Format your dataset to our specifications ([format instruction](docs/dataset_formatting.md)) and upload it to the 🤗 Hugging Face Hub ([instruction](docs/dataset_hf.md](docs/dataset_formatting.md))). -2. Integrate your dataset into our codebase using the instructions above. Check that it works by running the baselines! ([instruction](docs/task_codebase.md)). -3. Submit a **Pull Request** with your dataset to this repository ([instruction](docs/how_to_add_dataset.md)). +0. Submit a **Pull Request** with the dataset description to this repository ([instruction](docs/how_to_add_dataset.md)). +1. Develop your dataset according to the text LLM evaluation criteria ([see requirements](docs/dataset_review.md)). +2. Format your dataset to our specifications ([format instruction](docs/dataset_formatting.md)) and upload it to the 🤗 Hugging Face Hub ([instruction](docs/dataset_hf.md](docs/dataset_formatting.md))). +3. Integrate your dataset into our codebase using the instructions above. Check that it works by running the baselines! ([instruction](docs/task_codebase.md)). We will review your submission and, upon approval, add it to New MERA TEXT. From acfdf0727949f1c9ea81f6a81b09fccce7b5b50f Mon Sep 17 00:00:00 2001 From: Alexander Kharitonov <48628260+mathamateur@users.noreply.github.com> Date: Fri, 6 Mar 2026 10:48:46 +0300 Subject: [PATCH 11/14] Update dataset_review.md --- docs/dataset_review.md | 1 + 1 file changed, 1 insertion(+) diff --git a/docs/dataset_review.md b/docs/dataset_review.md index 435f65b..666b060 100644 --- a/docs/dataset_review.md +++ b/docs/dataset_review.md @@ -19,6 +19,7 @@ - Материалы, используемые в датасете, в том числе тексты и мультимодальные элементы, должны соответствовать правовым нормам на использование этих материалов. При использовании открытых данных нужно соблюдать требования оригинальных лицензий, а ссылку на саму лицензию размещать в [документации задачи](./dataset_formatting.md#метаданные-датасета-raw_dataset_metajson). - Если для создания датасета использованы синтетические данные, обязательно должен быть указан их источник (в том числе генеративные модели). - Минимальный объем датасета — 300 вопросов. Максимальный объем датасета определяется исходя из разнообразия представленных классов / доменов: на каждую такую категорию нужно от 100 до 1000 вопросов. В случае, если в датасете есть категории, но не по всем есть минимум 100 вопросов, датасет будет учитываться как единая задача, без разбивки по доменам / подзадачам. +- Для каждого датасета необходимо подготовить набор шотов. [Инструкция по созданию](docs/shots_creation.md). - Датасет должен соответствовать [**критериям качества**](#критерии-качества-датасетов), на соответствие которым датасет будут проверять эксперты от команды организаторов MERA. From 4aec0d73ee4d4ec604eacbbb4d3ad557f7acb8a6 Mon Sep 17 00:00:00 2001 From: mathamateur Date: Tue, 14 Apr 2026 17:21:39 +0300 Subject: [PATCH 12/14] Update docs --- docs/templates/README_ru_template.md | 57 ++++++++++ docs/templates/README_template.md | 56 ++++++++++ docs/templates/term_dictionary.json | 152 +++++++++++++++++++++++++++ 3 files changed, 265 insertions(+) create mode 100644 docs/templates/README_ru_template.md create mode 100644 docs/templates/README_template.md create mode 100644 docs/templates/term_dictionary.json diff --git a/docs/templates/README_ru_template.md b/docs/templates/README_ru_template.md new file mode 100644 index 0000000..b393f03 --- /dev/null +++ b/docs/templates/README_ru_template.md @@ -0,0 +1,57 @@ +f"""# {meta["dataset_name"]} + + +## Описание задачи + +{custom["Описание задачи"]} + +Тестируемые навыки моделей: {computed["skills"]}{computed["contributors"]} + + +## Мотивация + +{custom["Мотивация"]} + + +## Описание датасета + +### Поля данных + +Каждый вопрос в датасете содержит следующие поля: + +{computed["data_field_descriptions"]} + + +### Пример данных + +```json +{computed["data_example"]} +``` + + +### Промпты + +Для задачи были подготовлены {len(meta["prompts"])} промптов, которые были равномерно распределены по вопросам по принципу "один вопрос – один промпт". Шаблоны в фигурных скобках в промпте заполняются из полей внутри поля `inputs` в каждом вопросе. + + +Пример промпта: + +``` +{computed["prompts"]} +``` + + +### Создание датасета + +{custom["Создание датасета"]} + + +## Оценка + + +### Метрики + +Для агрегированной оценки ответов моделей используются следующие метрики: + +{computed["metrics"]} +""" \ No newline at end of file diff --git a/docs/templates/README_template.md b/docs/templates/README_template.md new file mode 100644 index 0000000..eeb6d44 --- /dev/null +++ b/docs/templates/README_template.md @@ -0,0 +1,56 @@ +f"""# {meta["dataset_name"]} + + +## Task description + +{custom["Task description"]} + +Evaluated skills: {computed["skills"]}{computed["contributors"]} + + +## Motivation + +{custom["Motivation"]} + + +## Data description + +### Data fields + +Each dataset question includes data in the following fields: + +{computed["data_field_descriptions"]} + + +### Data formatting example + +```json +{computed["data_example"]} +``` + + +### Prompts + +For the task, {len(meta["prompts"])} prompts were prepared and evenly distributed among the questions on the principle of "one prompt per question". The templates in curly braces in each prompt are filled in from the fields inside the `inputs` field in each question. + +Prompt example: + +``` +{computed["prompts"]} +``` + + +### Dataset creation + +{custom["Dataset creation"]} + + +## Evaluation + + +### Metrics + +Metrics for aggregated evaluation of responses: + +{computed["metrics"]} +""" \ No newline at end of file diff --git a/docs/templates/term_dictionary.json b/docs/templates/term_dictionary.json new file mode 100644 index 0000000..53c228f --- /dev/null +++ b/docs/templates/term_dictionary.json @@ -0,0 +1,152 @@ +{ + "metrics": { + "Accuracy": { + "en": "Accuracy is the proportion of correct model predictions among the total number of cases processed.", + "ru": "Метрика Accuracy вычисляет долю правильных предсказаний модели среди всех обработанных вопросов.", + "short": "Acc" + }, + "Exact match": { + "en": "Exact match is the average of scores for all processed cases, where a given case score is 1 if the predicted string is the exact same as its reference string, and is 0 otherwise.", + "ru": "Метрика Exact match вычисляет среднее по оценкам всех обработанных вопросов, где оценка имеет значение 1, если предсказанная строка точно совпадает с правильным ответом, и 0 в остальных случаях.", + "short": "EM" + }, + "pass@1": { + "en": "Pass@1 measures the proportion of problems where the model's first generated solution passes all test cases.", + "ru": "Метрика pass@1 измеряет долю задач, для которых первое сгенерированное моделью решение проходит все тесты.", + "short": "pass@1" + }, + "compile@1": { + "en": "compile@1 is the proportion of generated code that successfully compiles without errors.", + "ru": "Метрика compile@1 показывает долю сгенерированного кода, который успешно компилируется без ошибок.", + "short": "compile@1" + } + }, + "data_field_descriptions": { + "instruction": { + "_desc": { + "en": "Instruction prompt template with question elements placeholders.", + "ru": "Промпт-инструкция для модели, содержащая шаблон для вставки элементов вопроса." + } + }, + "inputs": { + "_desc": { + "en": "Input data that forms the task for the model. Can include one or multiple modalities - video, audio, image, text.", + "ru": "Вводные данные, формирующие задание для модели. Могут включать одну или несколько модальностей - видео, аудио, изображение, текст." + }, + "video": { + "_desc": { + "en": "Path to the video file related to the question.", + "ru": "Путь к файлу с видео, к которому относится вопрос." + } + }, + "audio": { + "_desc": { + "en": "Path to the audio file related to the question.", + "ru": "Путь к файлу с аудио, к которому относится вопрос." + } + }, + "image": { + "_desc": { + "en": "Path to the image file related to the question.", + "ru": "Путь к файлу с изображением, к которому относится вопрос." + } + }, + "question": { + "_desc": { + "en": "Text of the question.", + "ru": "Текст вопроса." + } + }, + "option_a": { + "_desc": { + "en": "Answer option A.", + "ru": "Вариант ответа A." + } + }, + "option_b": { + "_desc": { + "en": "Answer option B.", + "ru": "Вариант ответа B." + } + }, + "option_c": { + "_desc": { + "en": "Answer option C.", + "ru": "Вариант ответа C." + } + }, + "option_d": { + "_desc": { + "en": "Answer option D.", + "ru": "Вариант ответа D." + } + }, + "option_e": { + "_desc": { + "en": "Answer option E.", + "ru": "Вариант ответа E." + } + } + }, + "outputs": { + "_desc": { + "en": "The correct answer to the question.", + "ru": "Правильный ответ на вопрос." + } + }, + "meta": { + "_desc": { + "en": "Metadata related to the test example, not used in the question (hidden from the tested model).", + "ru": "Метаданные, относящиеся к тестовому примеру, но не используемые в вопросе (скрытые от тестируемой модели)." + }, + "id": { + "_desc": { + "en": "Identification number of the question in the dataset.", + "ru": "Номер-идентификатор вопроса в датасете." + } + }, + "categories": { + "_desc": { + "en": "Categorial features characterizing the test example.", + "ru": "Категории признаков, характеризующих тестовый пример." + } + }, + "image": { + "_desc": { + "en": "Image metadata.", + "ru": "Метаданные, относящиеся к изображению." + }, + "synt_source": { + "_desc": { + "en": "Sources used to generate or recreate data for the question, including names of generative models.", + "ru": "Источники, с помощью которых сгенерированы или воссозданы данные для формирования вопроса, в том числе названия генеративных моделей." + } + }, + "source": { + "_desc": { + "en": "Information about the origin of the image — according to the image classification for MERA datasets.", + "ru": "Информация о происхождении изображения — согласно классификации изображений для датасетов MERA." + } + }, + "type": { + "_desc": { + "en": "Image type — according to the image classification for MERA datasets.", + "ru": "Тип изображения — согласно классификации изображений для датасетов MERA." + } + }, + "content": { + "_desc": { + "en": "Image content — according to the image classification for MERA datasets.", + "ru": "Содержание изображения — согласно классификации изображений для датасетов MERA." + } + }, + "context": { + "_desc": { + "en": "Accompanying context present in the image — according to the image classification for MERA datasets.", + "ru": "Сопроводительный контекст, присутствующий на изображении, — согласно классификации изображений для датасетов MERA." + } + } + } + } + } +} From 5e3fde16285690e9b58576c324228cc5e1fa465f Mon Sep 17 00:00:00 2001 From: Danil Astafurov Date: Wed, 22 Apr 2026 16:46:31 +0300 Subject: [PATCH 13/14] Add open-generation scoring with Pollux judge --- .../custom_openjudge_localscore_task.yaml | 4 + benchmark_tasks/custom_openjudge_task.yaml | 3 + benchmark_tasks/openjudge_utils.py | 85 +++++++ mera_openjudge/__init__.py | 17 ++ mera_openjudge/backends/__init__.py | 9 + mera_openjudge/backends/base.py | 8 + mera_openjudge/backends/hf.py | 111 +++++++++ mera_openjudge/backends/openai_compatible.py | 81 +++++++ mera_openjudge/config.py | 135 +++++++++++ mera_openjudge/exceptions.py | 24 ++ mera_openjudge/parsing.py | 43 ++++ mera_openjudge/prompting.py | 87 +++++++ mera_openjudge/scoring.py | 97 ++++++++ modules/scoring/configs/errors_comments.yaml | 5 +- modules/scoring/configs/main.yaml | 8 + modules/scoring/evaluate_submission.py | 41 +++- modules/scoring/requirements.txt | 5 +- modules/scoring/src/__init__.py | 12 + modules/scoring/src/enums.py | 3 + modules/scoring/src/metrics.py | 55 ++++- modules/scoring/src/tasks/openjudge_task.py | 128 ++++++++++ modules/scoring/src/tasks/rudetox.py | 14 +- modules/scoring/src/utils.py | 32 ++- scripts/run_openjudge_localscore.sh | 39 ++++ tests/test_mera_openjudge.py | 221 ++++++++++++++++++ tests/test_openjudge_task.py | 146 ++++++++++++ 26 files changed, 1402 insertions(+), 11 deletions(-) create mode 100644 benchmark_tasks/custom_openjudge_localscore_task.yaml create mode 100644 benchmark_tasks/custom_openjudge_task.yaml create mode 100644 benchmark_tasks/openjudge_utils.py create mode 100644 mera_openjudge/__init__.py create mode 100644 mera_openjudge/backends/__init__.py create mode 100644 mera_openjudge/backends/base.py create mode 100644 mera_openjudge/backends/hf.py create mode 100644 mera_openjudge/backends/openai_compatible.py create mode 100644 mera_openjudge/config.py create mode 100644 mera_openjudge/exceptions.py create mode 100644 mera_openjudge/parsing.py create mode 100644 mera_openjudge/prompting.py create mode 100644 mera_openjudge/scoring.py create mode 100644 modules/scoring/src/tasks/openjudge_task.py create mode 100644 scripts/run_openjudge_localscore.sh create mode 100644 tests/test_mera_openjudge.py create mode 100644 tests/test_openjudge_task.py diff --git a/benchmark_tasks/custom_openjudge_localscore_task.yaml b/benchmark_tasks/custom_openjudge_localscore_task.yaml new file mode 100644 index 0000000..86d2588 --- /dev/null +++ b/benchmark_tasks/custom_openjudge_localscore_task.yaml @@ -0,0 +1,4 @@ +include: ./custom_openjudge_task.yaml +tag: + - mera_openjudge + - mera_openjudge_local diff --git a/benchmark_tasks/custom_openjudge_task.yaml b/benchmark_tasks/custom_openjudge_task.yaml new file mode 100644 index 0000000..0f8a246 --- /dev/null +++ b/benchmark_tasks/custom_openjudge_task.yaml @@ -0,0 +1,3 @@ +include: ./custom_generate_task.yaml +tag: + - mera_openjudge diff --git a/benchmark_tasks/openjudge_utils.py b/benchmark_tasks/openjudge_utils.py new file mode 100644 index 0000000..8ae9a87 --- /dev/null +++ b/benchmark_tasks/openjudge_utils.py @@ -0,0 +1,85 @@ +import os +from pathlib import Path + +try: + from lm_eval.api.filter import Filter + from lm_eval.api.registry import FILTER_REGISTRY, register_filter +except ModuleNotFoundError: + FILTER_REGISTRY = {} + + class Filter: # type: ignore[no-redef] + pass + + def register_filter(filter_name): # type: ignore[no-redef] + def decorator(cls): + FILTER_REGISTRY[filter_name] = cls + return cls + + return decorator + +from mera_openjudge import OpenJudgeScorer, load_judge_config + + +def _load_runtime_config_from_env(): + return { + "api_key": os.environ.get("MERA_JUDGE_API_KEY", ""), + "backend": "openai_compatible", + "base_url": os.environ.get("MERA_JUDGE_BASE_URL", ""), + "max_new_tokens": int(os.environ.get("MERA_JUDGE_MAX_NEW_TOKENS", "128")), + "model_name": os.environ.get("MERA_JUDGE_MODEL", "ai-forever/pollux-judge-7b"), + "temperature": float(os.environ.get("MERA_JUDGE_TEMPERATURE", "0.1")), + "timeout": int(os.environ.get("MERA_JUDGE_TIMEOUT", "120")), + } + + +def _judge_config_path(task_dir): + return str(Path(task_dir) / "judge.yaml") + + +def build_process_results(task_dir): + judge_config = load_judge_config(_judge_config_path(task_dir)) + zero_metrics = { + judge_config.metric_name: 0.0, + **{f"judge_{criterion.key}": 0.0 for criterion in judge_config.criteria}, + } + + def process_results(doc, results): + del doc + if not results or not results[0]: + return dict(zero_metrics) + return dict(results[0]) + + return process_results + + +def register_openjudge_filter(filter_name, task_dir): + if FILTER_REGISTRY.get(filter_name, None): + return filter_name + + judge_config_path = _judge_config_path(task_dir) + + @register_filter(filter_name) + class OpenJudgeScoring(Filter): + def __init__(self) -> None: + self.judge_config_path = judge_config_path + self.runtime_config = _load_runtime_config_from_env() + self._scorer = None + + def _get_scorer(self): + if self._scorer is None: + self._scorer = OpenJudgeScorer( + judge_config=self.judge_config_path, + runtime_config=self.runtime_config, + ) + return self._scorer + + def apply(self, resps, docs): + answers = [] + for sample in resps: + answer = "" + if sample: + answer = str(sample[0]).strip() + answers.append(answer) + return [[metrics] for metrics in self._get_scorer().score_answers(docs, answers)] + + return filter_name diff --git a/mera_openjudge/__init__.py b/mera_openjudge/__init__.py new file mode 100644 index 0000000..787fb0e --- /dev/null +++ b/mera_openjudge/__init__.py @@ -0,0 +1,17 @@ +from mera_openjudge.config import JudgeConfig, JudgeCriterion, load_judge_config +from mera_openjudge.exceptions import ( + JudgeBackendError, + JudgeConfigError, + JudgeParseError, +) +from mera_openjudge.scoring import OpenJudgeScorer + +__all__ = [ + "JudgeBackendError", + "JudgeConfig", + "JudgeConfigError", + "JudgeCriterion", + "JudgeParseError", + "OpenJudgeScorer", + "load_judge_config", +] diff --git a/mera_openjudge/backends/__init__.py b/mera_openjudge/backends/__init__.py new file mode 100644 index 0000000..92eb7ed --- /dev/null +++ b/mera_openjudge/backends/__init__.py @@ -0,0 +1,9 @@ +from mera_openjudge.backends.base import BaseJudgeBackend +from mera_openjudge.backends.hf import HFJudgeBackend +from mera_openjudge.backends.openai_compatible import OpenAICompatibleJudgeBackend + +__all__ = [ + "BaseJudgeBackend", + "HFJudgeBackend", + "OpenAICompatibleJudgeBackend", +] diff --git a/mera_openjudge/backends/base.py b/mera_openjudge/backends/base.py new file mode 100644 index 0000000..12f0ee6 --- /dev/null +++ b/mera_openjudge/backends/base.py @@ -0,0 +1,8 @@ +from abc import ABC, abstractmethod +from typing import Sequence + + +class BaseJudgeBackend(ABC): + @abstractmethod + def generate(self, prompts: Sequence[str]): + raise NotImplementedError diff --git a/mera_openjudge/backends/hf.py b/mera_openjudge/backends/hf.py new file mode 100644 index 0000000..9c70d4d --- /dev/null +++ b/mera_openjudge/backends/hf.py @@ -0,0 +1,111 @@ +from typing import Any, Mapping, Sequence + +import torch +from transformers import AutoModelForCausalLM, AutoTokenizer + +from mera_openjudge.backends.base import BaseJudgeBackend +from mera_openjudge.exceptions import JudgeBackendError + + +class HFJudgeBackend(BaseJudgeBackend): + _RESOURCE_CACHE = {} + + def __init__(self, config: Mapping[str, Any]): + self.model_name = str(config.get("model_name", "")).strip() + if not self.model_name: + raise JudgeBackendError("HF judge backend requires model_name.") + self.device = str(config.get("device", "cuda")).strip() or "cuda" + self.batch_size = int(config.get("batch_size", 8)) + self.max_new_tokens = int(config.get("max_new_tokens", 128)) + self.temperature = float(config.get("temperature", 0.1)) + self.trust_remote_code = bool(config.get("trust_remote_code", True)) + self.max_input_length = config.get("max_input_length") + self.model, self.tokenizer = self._get_resources() + + def _get_resources(self): + cache_key = (self.model_name, self.device, self.trust_remote_code) + if cache_key not in self._RESOURCE_CACHE: + try: + tokenizer = AutoTokenizer.from_pretrained( + self.model_name, + trust_remote_code=self.trust_remote_code, + ) + model = AutoModelForCausalLM.from_pretrained( + self.model_name, + trust_remote_code=self.trust_remote_code, + ) + model.to(self.device) + model.eval() + if tokenizer.pad_token_id is None: + tokenizer.pad_token_id = tokenizer.eos_token_id + self._RESOURCE_CACHE[cache_key] = (model, tokenizer) + except Exception as exc: + raise JudgeBackendError( + f"Failed to load HF judge backend model '{self.model_name}'." + ) from exc + return self._RESOURCE_CACHE[cache_key] + + def _render_chat_prompts(self, prompts: Sequence[str]): + rendered_prompts = [] + for prompt in prompts: + if getattr(self.tokenizer, "chat_template", None): + rendered_prompt = self.tokenizer.apply_chat_template( + [{"role": "user", "content": prompt}], + tokenize=False, + add_generation_prompt=True, + ) + else: + rendered_prompt = prompt + rendered_prompts.append(rendered_prompt) + return rendered_prompts + + def generate(self, prompts: Sequence[str]): + if not prompts: + return [] + + rendered_prompts = self._render_chat_prompts(prompts) + outputs = [] + try: + for batch_start in range(0, len(rendered_prompts), self.batch_size): + batch = rendered_prompts[batch_start : batch_start + self.batch_size] + tokenizer_kwargs = { + "return_tensors": "pt", + "padding": True, + } + if self.max_input_length is not None: + tokenizer_kwargs.update( + { + "truncation": True, + "max_length": int(self.max_input_length), + } + ) + inputs = self.tokenizer(batch, **tokenizer_kwargs).to(self.device) + generation_kwargs = { + "max_new_tokens": self.max_new_tokens, + "pad_token_id": self.tokenizer.pad_token_id, + } + if self.temperature > 0: + generation_kwargs.update( + { + "do_sample": True, + "temperature": self.temperature, + } + ) + else: + generation_kwargs["do_sample"] = False + + with torch.no_grad(): + sequences = self.model.generate(**inputs, **generation_kwargs) + + prompt_token_count = inputs["input_ids"].shape[1] + for sequence in sequences: + generated_ids = sequence[prompt_token_count:] + outputs.append( + self.tokenizer.decode( + generated_ids, + skip_special_tokens=True, + ).strip() + ) + except Exception as exc: + raise JudgeBackendError("HF judge backend generation failed.") from exc + return outputs diff --git a/mera_openjudge/backends/openai_compatible.py b/mera_openjudge/backends/openai_compatible.py new file mode 100644 index 0000000..50384ac --- /dev/null +++ b/mera_openjudge/backends/openai_compatible.py @@ -0,0 +1,81 @@ +import json +from typing import Any, Mapping, Sequence +from urllib import error, request + +from mera_openjudge.backends.base import BaseJudgeBackend +from mera_openjudge.exceptions import JudgeBackendError + + +class OpenAICompatibleJudgeBackend(BaseJudgeBackend): + def __init__(self, config: Mapping[str, Any]): + self.base_url = str(config.get("base_url", "")).strip() + self.api_key = str(config.get("api_key", "")).strip() + self.model_name = str(config.get("model_name", "")).strip() + self.temperature = float(config.get("temperature", 0.1)) + self.max_new_tokens = int(config.get("max_new_tokens", 128)) + self.timeout = int(config.get("timeout", 120)) + if not self.base_url: + raise JudgeBackendError("OpenAI-compatible judge backend requires base_url.") + if not self.model_name: + raise JudgeBackendError("OpenAI-compatible judge backend requires model_name.") + + def _build_endpoint(self): + normalized_url = self.base_url.rstrip("/") + if normalized_url.endswith("/chat/completions"): + return normalized_url + if normalized_url.endswith("/v1"): + return normalized_url + "/chat/completions" + return normalized_url + "/v1/chat/completions" + + def _build_headers(self): + headers = {"Content-Type": "application/json"} + if self.api_key: + headers["Authorization"] = f"Bearer {self.api_key}" + return headers + + def generate(self, prompts: Sequence[str]): + endpoint = self._build_endpoint() + headers = self._build_headers() + outputs = [] + for prompt in prompts: + payload = json.dumps( + { + "model": self.model_name, + "messages": [{"role": "user", "content": prompt}], + "temperature": self.temperature, + "max_tokens": self.max_new_tokens, + "n": 1, + } + ).encode("utf-8") + req = request.Request( + endpoint, + data=payload, + headers=headers, + method="POST", + ) + try: + with request.urlopen(req, timeout=self.timeout) as response: + body = response.read().decode("utf-8") + except error.HTTPError as exc: + details = exc.read().decode("utf-8", errors="ignore") + raise JudgeBackendError( + f"OpenAI-compatible judge backend HTTP {exc.code}: {details}" + ) from exc + except error.URLError as exc: + raise JudgeBackendError( + f"OpenAI-compatible judge backend connection failed: {exc.reason}" + ) from exc + + try: + data = json.loads(body) + choice = data["choices"][0] + if "message" in choice: + content = choice["message"]["content"] + else: + content = choice["text"] + outputs.append(str(content).strip()) + except Exception as exc: + raise JudgeBackendError( + "OpenAI-compatible judge backend returned an invalid response." + ) from exc + return outputs diff --git a/mera_openjudge/config.py b/mera_openjudge/config.py new file mode 100644 index 0000000..7d05eea --- /dev/null +++ b/mera_openjudge/config.py @@ -0,0 +1,135 @@ +from dataclasses import dataclass +from pathlib import Path +from typing import Any, Dict, Iterable, Mapping, Optional, Tuple + +import yaml + +from mera_openjudge.exceptions import JudgeConfigError + + +@dataclass(frozen=True) +class JudgeCriterion: + key: str + name: str + scale: Dict[int, str] + + +@dataclass(frozen=True) +class JudgeConfig: + prompt_template: Optional[str] + criteria: Tuple[JudgeCriterion, ...] + reference_field: str + metric_name: str + allowed_scores: Tuple[int, ...] + + +def _to_plain_mapping(conf_source: Any) -> Dict[str, Any]: + if isinstance(conf_source, JudgeConfig): + return { + "prompt_template": conf_source.prompt_template, + "criteria": [ + { + "key": criterion.key, + "name": criterion.name, + "scale": criterion.scale, + } + for criterion in conf_source.criteria + ], + "reference_field": conf_source.reference_field, + "metric_name": conf_source.metric_name, + } + if isinstance(conf_source, (str, Path)): + with open(conf_source, "r", encoding="utf-8") as file: + return yaml.safe_load(file) or {} + if isinstance(conf_source, Mapping): + return dict(conf_source) + raise JudgeConfigError("Unsupported judge config source.") + + +def _normalize_scale(raw_scale: Mapping[Any, Any]) -> Dict[int, str]: + if not isinstance(raw_scale, Mapping) or not raw_scale: + raise JudgeConfigError("Each criterion must define a non-empty scale mapping.") + normalized = {} + for key, value in raw_scale.items(): + try: + normalized_key = int(key) + except (TypeError, ValueError) as exc: + raise JudgeConfigError("Judge scale keys must be integers.") from exc + normalized[normalized_key] = str(value).strip() + return dict(sorted(normalized.items(), key=lambda item: item[0])) + + +def _get_required_str(raw_criterion: Mapping[str, Any], field_name: str) -> str: + value = str(raw_criterion.get(field_name, "")).strip() + if not value: + raise JudgeConfigError(f"Criterion field '{field_name}' is required.") + return value + + +def _validate_scale_keys(criteria: Iterable[JudgeCriterion]) -> Tuple[int, ...]: + allowed_scores = None + for criterion in criteria: + criterion_scores = tuple(sorted(criterion.scale)) + if allowed_scores is None: + allowed_scores = criterion_scores + continue + if criterion_scores != allowed_scores: + raise JudgeConfigError( + "All criteria in judge.yaml must use the same integer scale." + ) + if allowed_scores is None: + raise JudgeConfigError("At least one criterion is required.") + return allowed_scores + + +def load_judge_config(conf_source: Any) -> JudgeConfig: + raw_conf = _to_plain_mapping(conf_source) + raw_criteria = raw_conf.get("criteria") + if not isinstance(raw_criteria, list) or not raw_criteria: + raise JudgeConfigError("judge.yaml must contain a non-empty criteria list.") + + criteria = [] + seen_keys = set() + for raw_criterion in raw_criteria: + if not isinstance(raw_criterion, Mapping): + raise JudgeConfigError("Each judge criterion must be a mapping.") + key = _get_required_str(raw_criterion, "key") + if key in seen_keys: + raise JudgeConfigError(f"Duplicate judge criterion key: {key}") + seen_keys.add(key) + name = _get_required_str(raw_criterion, "name") + scale = _normalize_scale(raw_criterion.get("scale", {})) + criteria.append(JudgeCriterion(key=key, name=name, scale=scale)) + + allowed_scores = _validate_scale_keys(criteria) + metric_name = str(raw_conf.get("metric_name", "judge_avg")).strip() or "judge_avg" + reference_field = ( + str(raw_conf.get("reference_field", "meta.reference_answer")).strip() + or "meta.reference_answer" + ) + prompt_template = raw_conf.get("prompt_template") + if prompt_template is not None: + prompt_template = str(prompt_template) + + return JudgeConfig( + prompt_template=prompt_template, + criteria=tuple(criteria), + reference_field=reference_field, + metric_name=metric_name, + allowed_scores=allowed_scores, + ) + + +def resolve_reference_field(doc: Mapping[str, Any], dotted_path: str, default: str = "") -> str: + value: Any = doc + for key in dotted_path.split("."): + if not isinstance(value, Mapping) or key not in value: + return default + value = value[key] + if value is None: + return default + return str(value) + + +def format_scale(scale: Mapping[int, str]) -> str: + return "\n".join(f"{score}: {description}" for score, description in sorted(scale.items())) diff --git a/mera_openjudge/exceptions.py b/mera_openjudge/exceptions.py new file mode 100644 index 0000000..684cbca --- /dev/null +++ b/mera_openjudge/exceptions.py @@ -0,0 +1,24 @@ +class OpenJudgeError(Exception): + pass + + +class JudgeConfigError(OpenJudgeError): + pass + + +class JudgeBackendError(OpenJudgeError): + pass + + +class JudgeParseError(OpenJudgeError): + def __init__( + self, + message, + doc_index=None, + criterion_key=None, + response_text=None, + ): + super().__init__(message) + self.doc_index = doc_index + self.criterion_key = criterion_key + self.response_text = response_text diff --git a/mera_openjudge/parsing.py b/mera_openjudge/parsing.py new file mode 100644 index 0000000..057e1d0 --- /dev/null +++ b/mera_openjudge/parsing.py @@ -0,0 +1,43 @@ +import re +from typing import Iterable + +from mera_openjudge.exceptions import JudgeParseError + + +_INTEGER_RE = re.compile(r"-?\d+") + + +def _extract_allowed_scores(text: str, allowed_scores) -> Iterable[int]: + allowed_score_set = set(allowed_scores) + for match in _INTEGER_RE.finditer(text): + score = int(match.group(0)) + if score in allowed_score_set: + yield score + + +def parse_judge_score(text: str, allowed_scores) -> int: + if text is None: + raise JudgeParseError("Judge response is empty.") + + normalized_text = str(text).strip() + if not normalized_text: + raise JudgeParseError("Judge response is empty.") + + first_non_empty_line = "" + for line in normalized_text.splitlines(): + stripped = line.strip() + if stripped: + first_non_empty_line = stripped + break + + if first_non_empty_line: + for score in _extract_allowed_scores(first_non_empty_line, allowed_scores): + return score + + for score in _extract_allowed_scores(normalized_text, allowed_scores): + return score + + raise JudgeParseError( + "Failed to parse judge score from response.", + response_text=normalized_text, + ) diff --git a/mera_openjudge/prompting.py b/mera_openjudge/prompting.py new file mode 100644 index 0000000..d29c406 --- /dev/null +++ b/mera_openjudge/prompting.py @@ -0,0 +1,87 @@ +from typing import Any, Dict, Mapping + +from jinja2 import Environment, StrictUndefined + +from mera_openjudge.config import JudgeConfig, JudgeCriterion, format_scale, resolve_reference_field + + +_PROMPT_ENV = Environment(undefined=StrictUndefined, autoescape=False) +_DEFAULT_PROMPT_TEMPLATE = _PROMPT_ENV.from_string( + """ +Вы оцениваете ответ модели на пользовательское задание. + +Исходное задание: +{{ source_instruction }} + +{% if reference_answer %} +Эталонный ответ: +{{ reference_answer }} + +{% endif %} +Ответ модели: +{{ answer }} + +Критерий оценки: {{ criterion_name }} +Шкала: +{{ criteria_rubrics }} + +Верните ответ строго в формате: +Первая непустая строка: одно число из множества {{ allowed_scores_text }} +Далее: краткое объяснение оценки. +""".strip() +) + + +def render_source_instruction(doc: Mapping[str, Any]) -> str: + instruction = str(doc.get("instruction", "")).strip() + inputs = doc.get("inputs") + if not instruction: + return "" + if inputs is None: + return instruction + try: + if isinstance(inputs, Mapping): + return instruction.format(**inputs).strip() + return instruction.format(inputs=inputs).strip() + except Exception: + return instruction + + +def _build_context( + doc: Mapping[str, Any], + answer: str, + criterion: JudgeCriterion, + judge_config: JudgeConfig, +) -> Dict[str, Any]: + reference_answer = resolve_reference_field( + doc, + judge_config.reference_field, + default="", + ) + return { + "answer": answer, + "allowed_scores": list(judge_config.allowed_scores), + "allowed_scores_text": ", ".join(map(str, judge_config.allowed_scores)), + "criterion": criterion, + "criterion_key": criterion.key, + "criterion_name": criterion.name, + "criteria_name": criterion.name, + "criteria_rubrics": format_scale(criterion.scale), + "doc": doc, + "instruction": render_source_instruction(doc), + "reference_answer": reference_answer, + "scale": criterion.scale, + "source_instruction": render_source_instruction(doc), + } + + +def render_judge_prompt( + doc: Mapping[str, Any], + answer: str, + criterion: JudgeCriterion, + judge_config: JudgeConfig, +) -> str: + context = _build_context(doc=doc, answer=answer, criterion=criterion, judge_config=judge_config) + if judge_config.prompt_template: + return _PROMPT_ENV.from_string(judge_config.prompt_template).render(**context).strip() + return _DEFAULT_PROMPT_TEMPLATE.render(**context).strip() diff --git a/mera_openjudge/scoring.py b/mera_openjudge/scoring.py new file mode 100644 index 0000000..5d2a4e6 --- /dev/null +++ b/mera_openjudge/scoring.py @@ -0,0 +1,97 @@ +from typing import Any, Mapping, Optional, Sequence + +from mera_openjudge.backends.hf import HFJudgeBackend +from mera_openjudge.backends.openai_compatible import OpenAICompatibleJudgeBackend +from mera_openjudge.config import JudgeConfig, load_judge_config +from mera_openjudge.exceptions import JudgeBackendError, JudgeParseError +from mera_openjudge.parsing import parse_judge_score +from mera_openjudge.prompting import render_judge_prompt + + +_BACKEND_REGISTRY = { + "hf": HFJudgeBackend, + "openai_compatible": OpenAICompatibleJudgeBackend, +} + + +def _to_plain_mapping(conf_source: Any): + if conf_source is None: + return {} + if isinstance(conf_source, Mapping): + return dict(conf_source) + if hasattr(conf_source, "items"): + return dict(conf_source.items()) + raise JudgeBackendError("Unsupported judge runtime config.") + + +class OpenJudgeScorer: + def __init__( + self, + judge_config: Any, + runtime_config: Optional[Any] = None, + backend=None, + ): + self.judge_config: JudgeConfig = load_judge_config(judge_config) + self.runtime_config = _to_plain_mapping(runtime_config) + self.backend = backend or self._build_backend(self.runtime_config) + + @staticmethod + def _build_backend(runtime_config: Mapping[str, Any]): + backend_name = str(runtime_config.get("backend", "hf")).strip() or "hf" + backend_cls = _BACKEND_REGISTRY.get(backend_name) + if backend_cls is None: + raise JudgeBackendError(f"Unsupported judge backend: {backend_name}") + return backend_cls(runtime_config) + + def _score_criterion( + self, + docs: Sequence[Mapping[str, Any]], + answers: Sequence[str], + criterion, + ): + prompts = [ + render_judge_prompt( + doc=doc, + answer=answer, + criterion=criterion, + judge_config=self.judge_config, + ) + for doc, answer in zip(docs, answers) + ] + responses = self.backend.generate(prompts) + if len(responses) != len(docs): + raise JudgeBackendError("Judge backend returned an unexpected number of responses.") + scores = [] + for doc_index, response_text in enumerate(responses): + try: + scores.append(parse_judge_score(response_text, self.judge_config.allowed_scores)) + except JudgeParseError as exc: + raise JudgeParseError( + str(exc), + doc_index=doc_index, + criterion_key=criterion.key, + response_text=response_text, + ) from exc + return scores + + def score_answers(self, docs: Sequence[Mapping[str, Any]], answers: Sequence[str]): + if len(docs) != len(answers): + raise JudgeBackendError("Docs and answers must have the same length.") + if not docs: + return [] + + scores_by_criterion = {} + for criterion in self.judge_config.criteria: + scores_by_criterion[criterion.key] = self._score_criterion(docs, answers, criterion) + + metrics = [] + for doc_index in range(len(docs)): + doc_metrics = {} + criterion_scores = [] + for criterion in self.judge_config.criteria: + score = float(scores_by_criterion[criterion.key][doc_index]) + doc_metrics[f"judge_{criterion.key}"] = score + criterion_scores.append(score) + doc_metrics[self.judge_config.metric_name] = sum(criterion_scores) / len(criterion_scores) + metrics.append(doc_metrics) + return metrics diff --git a/modules/scoring/configs/errors_comments.yaml b/modules/scoring/configs/errors_comments.yaml index ac1c69d..0e60a4a 100644 --- a/modules/scoring/configs/errors_comments.yaml +++ b/modules/scoring/configs/errors_comments.yaml @@ -11,4 +11,7 @@ no_id_field_for_doc: "Невозможно получить поле id из ф no_id: "Нет документа с id в файле задачи." doc_output_type_error: "Тип поля outputs не соответствует ожидаемому типу." doc_parse_output_error: "Ошибка получения outputs для документа (поле есть, но распарсить его не получается)." -task_system_error: "Непредвиденная системная ошибка при валидации и оценке задачи." \ No newline at end of file +task_system_error: "Непредвиденная системная ошибка при валидации и оценке задачи." +judge_backend_error: "Judge backend interaction failed." +judge_parse_error: "Failed to parse the score returned by the judge model." +judge_config_error: "Judge configuration is invalid." diff --git a/modules/scoring/configs/main.yaml b/modules/scoring/configs/main.yaml index 46f59bc..694881f 100644 --- a/modules/scoring/configs/main.yaml +++ b/modules/scoring/configs/main.yaml @@ -10,3 +10,11 @@ sample_submission: bad_path: "examples/sample_submission_bad.zip" good_resp_path: "examples/sample_response.json" bad_resp_path: "examples/sample_response_bad.json" +judges: + pollux: + backend: hf + model_name: ai-forever/pollux-judge-7b + device: cuda + batch_size: 8 + max_new_tokens: 128 + temperature: 0.1 diff --git a/modules/scoring/evaluate_submission.py b/modules/scoring/evaluate_submission.py index 7478038..b7bc710 100644 --- a/modules/scoring/evaluate_submission.py +++ b/modules/scoring/evaluate_submission.py @@ -1,11 +1,20 @@ +import sys +from pathlib import Path + +_SCORING_ROOT = Path(__file__).resolve().parent +_scoring_root_str = str(_SCORING_ROOT) +if _scoring_root_str not in sys.path: + sys.path.insert(0, _scoring_root_str) + from src.worker import Worker -from src.utils import save_json +from src.utils import load_yaml, save_json import json import argparse def evaluate_submissions(args): - worker = Worker(conf=args.config_path, no_load_models=False) + config = build_config(args) + worker = Worker(conf=config, no_load_models=False) errors = worker.load() if len(errors): worker.log(errors) @@ -36,10 +45,38 @@ def get_args(): default="submission_results.json", help="path to submission results", ) + parser.add_argument("--judge_backend", type=str, default=None) + parser.add_argument("--judge_model", type=str, default=None) + parser.add_argument("--judge_base_url", type=str, default=None) + parser.add_argument("--judge_api_key", type=str, default=None) + parser.add_argument("--judge_batch_size", type=int, default=None) + parser.add_argument("--judge_max_new_tokens", type=int, default=None) res = parser.parse_known_args()[0] return res +def build_config(args): + config = load_yaml(args.config_path) + if "judges" not in config: + config["judges"] = {} + if "pollux" not in config["judges"]: + config["judges"]["pollux"] = {} + pollux_conf = config["judges"]["pollux"] + if args.judge_backend is not None: + pollux_conf["backend"] = args.judge_backend + if args.judge_model is not None: + pollux_conf["model_name"] = args.judge_model + if args.judge_base_url is not None: + pollux_conf["base_url"] = args.judge_base_url + if args.judge_api_key is not None: + pollux_conf["api_key"] = args.judge_api_key + if args.judge_batch_size is not None: + pollux_conf["batch_size"] = args.judge_batch_size + if args.judge_max_new_tokens is not None: + pollux_conf["max_new_tokens"] = args.judge_max_new_tokens + return config + + def main(): args = get_args() evaluate_submissions(args) diff --git a/modules/scoring/requirements.txt b/modules/scoring/requirements.txt index 5676907..c47b533 100644 --- a/modules/scoring/requirements.txt +++ b/modules/scoring/requirements.txt @@ -1,4 +1,5 @@ -transformers==4.30.2 +transformers>=4.37.0,<5 omegaconf==2.0.6 boto3 -scikit-learn==1.0.2 \ No newline at end of file +scikit-learn==1.0.2 +jinja2 diff --git a/modules/scoring/src/__init__.py b/modules/scoring/src/__init__.py index e69de29..f7e98cf 100644 --- a/modules/scoring/src/__init__.py +++ b/modules/scoring/src/__init__.py @@ -0,0 +1,12 @@ +import sys +from pathlib import Path + + +_REPO_ROOT = Path(__file__).resolve().parents[3] +_SCORING_ROOT = Path(__file__).resolve().parents[1] +_repo_root_str = str(_REPO_ROOT) +_scoring_root_str = str(_SCORING_ROOT) +if _repo_root_str not in sys.path: + sys.path.insert(0, _repo_root_str) +if _scoring_root_str not in sys.path: + sys.path.insert(0, _scoring_root_str) diff --git a/modules/scoring/src/enums.py b/modules/scoring/src/enums.py index e086907..6f9ae83 100644 --- a/modules/scoring/src/enums.py +++ b/modules/scoring/src/enums.py @@ -22,6 +22,9 @@ class Errors(EnumBase): no_id = "no_id" doc_output_type_error = "doc_output_type_error" doc_parse_output_error = "doc_parse_output_error" + judge_backend_error = "judge_backend_error" + judge_parse_error = "judge_parse_error" + judge_config_error = "judge_config_error" task_system_error = "task_system_error" diff --git a/modules/scoring/src/metrics.py b/modules/scoring/src/metrics.py index f0e096c..f47f3c3 100644 --- a/modules/scoring/src/metrics.py +++ b/modules/scoring/src/metrics.py @@ -1,4 +1,7 @@ -import sklearn +try: + import sklearn +except ModuleNotFoundError: + sklearn = None def mean(arr): @@ -6,6 +9,8 @@ def mean(arr): def f1_macro_score(items): + if sklearn is None: + return _f1_macro_score_fallback(items) unzipped_list = list(zip(*items)) golds = unzipped_list[0] preds = unzipped_list[1] @@ -23,8 +28,56 @@ def metric_max_over_ground_truths(metric_fn, prediction, ground_truths): def mcc(items): + if sklearn is None: + return _mcc_fallback(items) unzipped_list = list(zip(*items)) golds = unzipped_list[0] preds = unzipped_list[1] score = sklearn.metrics.matthews_corrcoef(golds, preds) return score + + +def _f1_macro_score_fallback(items): + labels = sorted(set(label for pair in items for label in pair)) + scores = [] + for label in labels: + true_positive = sum(1 for gold, pred in items if gold == label and pred == label) + false_positive = sum(1 for gold, pred in items if gold != label and pred == label) + false_negative = sum(1 for gold, pred in items if gold == label and pred != label) + precision = true_positive / max(true_positive + false_positive, 1) + recall = true_positive / max(true_positive + false_negative, 1) + if precision + recall == 0: + scores.append(0.0) + else: + scores.append(2 * precision * recall / (precision + recall)) + return mean(scores) + + +def _mcc_fallback(items): + unzipped_list = list(zip(*items)) + golds = unzipped_list[0] + preds = unzipped_list[1] + labels = sorted(set(golds) | set(preds)) + label_to_idx = {label: idx for idx, label in enumerate(labels)} + matrix = [[0 for _ in labels] for _ in labels] + for gold, pred in zip(golds, preds): + matrix[label_to_idx[gold]][label_to_idx[pred]] += 1 + + total = sum(sum(row) for row in matrix) + if total == 0: + return 0.0 + + trace = sum(matrix[idx][idx] for idx in range(len(labels))) + row_sums = [sum(row) for row in matrix] + col_sums = [sum(matrix[row_idx][col_idx] for row_idx in range(len(labels))) for col_idx in range(len(labels))] + + sum_row_col = sum(row * col for row, col in zip(row_sums, col_sums)) + sum_row_sq = sum(row * row for row in row_sums) + sum_col_sq = sum(col * col for col in col_sums) + numerator = trace * total - sum_row_col + denominator_left = total * total - sum_col_sq + denominator_right = total * total - sum_row_sq + denominator = (denominator_left * denominator_right) ** 0.5 + if denominator == 0: + return 0.0 + return numerator / denominator diff --git a/modules/scoring/src/tasks/openjudge_task.py b/modules/scoring/src/tasks/openjudge_task.py new file mode 100644 index 0000000..2694752 --- /dev/null +++ b/modules/scoring/src/tasks/openjudge_task.py @@ -0,0 +1,128 @@ +from collections import defaultdict +from pathlib import Path +from typing import Dict + +from src.enums import Errors +from src.metrics import mean +from src.tasks.task import Task + +from mera_openjudge import JudgeBackendError, JudgeConfigError, JudgeParseError, OpenJudgeScorer + + +class OpenJudgeTask(Task): + def __init__(self, conf): + super().__init__(conf) + self._scorer = None + self.judge_config = None + self._aggregation = None + self._load_judge_config() + if not getattr(self.conf, "no_load_models", False): + self._get_scorer() + + @property + def repo_root(self): + return Path(__file__).resolve().parents[4] + + @property + def judge_config_path(self): + configured_path = getattr(self.task_conf, "judge_config_path", None) + if configured_path: + path = Path(str(configured_path)) + if not path.is_absolute(): + path = self.repo_root / path + return path + return self.repo_root / "benchmark_tasks" / self.name / "judge.yaml" + + def _load_judge_config(self): + scorer = OpenJudgeScorer( + judge_config=str(self.judge_config_path), + runtime_config=self._get_runtime_config(), + backend=_NoopBackend(), + ) + self.judge_config = scorer.judge_config + + def _get_runtime_config(self): + judges_conf = getattr(self.conf, "judges", None) + if judges_conf is None: + return {} + return getattr(judges_conf, "pollux", {}) + + def _get_scorer(self): + if self._scorer is None: + self._scorer = OpenJudgeScorer( + judge_config=str(self.judge_config_path), + runtime_config=self._get_runtime_config(), + ) + return self._scorer + + def aggregation(self) -> Dict: + if self._aggregation is None: + metric_name = self.judge_config.metric_name + self._aggregation = { + metric_name: mean, + **{f"judge_{criterion.key}": mean for criterion in self.judge_config.criteria}, + } + return self._aggregation + + def average_results(self, metrics: Dict) -> float: + return float(metrics[self.judge_config.metric_name]) + + def evaluate(self, local_path): + self.log(f"Start evaluating dataset {self.name}") + dataset, errors = self.load_and_validate(local_path=local_path) + results = {} + vals = defaultdict(list) + docs = [] + answers = [] + + if not len(errors): + for doc_id in self.gold.doc_ids(): + if doc_id not in dataset.examples: + self.log(f"{Errors.no_id} {self.name}") + errors.append({"type": str(Errors.no_id), "doc_id": doc_id}) + continue + answer = self.doc_to_y_pred(dataset[doc_id]) + if not isinstance(answer, str): + errors.append({"type": str(Errors.doc_output_type_error), "doc_id": doc_id}) + continue + docs.append(self.gold[doc_id]) + answers.append(answer) + + if not len(errors): + try: + metrics_per_doc = self._get_scorer().score_answers(docs=docs, answers=answers) + except JudgeConfigError: + errors.append({"type": str(Errors.judge_config_error)}) + metrics_per_doc = [] + except JudgeBackendError: + errors.append({"type": str(Errors.judge_backend_error)}) + metrics_per_doc = [] + except JudgeParseError as exc: + doc_id = None + if exc.doc_index is not None and 0 <= exc.doc_index < len(docs): + doc_id = self.doc_to_id(docs[exc.doc_index]) + errors.append( + { + "type": str(Errors.judge_parse_error), + **({"doc_id": doc_id} if doc_id is not None else {}), + } + ) + metrics_per_doc = [] + + if not len(errors): + for metric_dict in metrics_per_doc: + for metric_name, metric_value in metric_dict.items(): + vals[metric_name].append(metric_value) + for metric_name, items in vals.items(): + results[metric_name] = self.aggregation()[metric_name](items) + + return results, errors + + def process_results(self, doc_true, doc_pred): + del doc_true, doc_pred + raise NotImplementedError("OpenJudgeTask computes metrics in batched evaluate().") + + +class _NoopBackend: + def generate(self, prompts): + return ["" for _ in prompts] diff --git a/modules/scoring/src/tasks/rudetox.py b/modules/scoring/src/tasks/rudetox.py index 88df1db..d79e410 100644 --- a/modules/scoring/src/tasks/rudetox.py +++ b/modules/scoring/src/tasks/rudetox.py @@ -1,11 +1,9 @@ from src.registry import register_task from src.tasks.task import Task from src.metrics import mean -from typing import Dict, List, Optional, Union +from typing import Any, Dict, List, Optional, Union from typing_extensions import TypedDict from transformers import AutoModelForSequenceClassification, AutoTokenizer, PreTrainedTokenizer -from scipy.interpolate import interp1d -from sklearn.isotonic import IsotonicRegression import numpy as np import torch from src.utils import load_pickle @@ -175,7 +173,7 @@ class CalibratorParams(TypedDict): y_thresholds_: np.ndarray y_max: float y_min: float - f_: interp1d + f_: Any increasing_: bool @@ -187,6 +185,14 @@ class CalibratorSignature(TypedDict): def get_calibrator(): + try: + from scipy.interpolate import interp1d + from sklearn.isotonic import IsotonicRegression + except ModuleNotFoundError as exc: + raise ModuleNotFoundError( + "ruDetox scoring requires scipy and scikit-learn to build the calibrator." + ) from exc + func_params: InterpolationParams = { "axis": 0, "bounds_error": False, diff --git a/modules/scoring/src/utils.py b/modules/scoring/src/utils.py index a41af80..79310e4 100644 --- a/modules/scoring/src/utils.py +++ b/modules/scoring/src/utils.py @@ -1,10 +1,40 @@ -from omegaconf import OmegaConf import os import json import random import numpy as np import pickle +try: + from omegaconf import OmegaConf +except ModuleNotFoundError: + import yaml + + class _AttrDict(dict): + def __getattr__(self, item): + try: + return self[item] + except KeyError as exc: + raise AttributeError(item) from exc + + def __setattr__(self, key, value): + self[key] = value + + def _wrap_omegaconf_like(value): + if isinstance(value, dict): + wrapped = _AttrDict() + for key, item in value.items(): + wrapped[key] = _wrap_omegaconf_like(item) + return wrapped + if isinstance(value, list): + return [_wrap_omegaconf_like(item) for item in value] + return value + + class OmegaConf: + @staticmethod + def load(path): + with open(path, "r", encoding="utf-8") as file: + return _wrap_omegaconf_like(yaml.safe_load(file) or {}) + class Singleton(type): _instances = {} diff --git a/scripts/run_openjudge_localscore.sh b/scripts/run_openjudge_localscore.sh new file mode 100644 index 0000000..1b2ba4f --- /dev/null +++ b/scripts/run_openjudge_localscore.sh @@ -0,0 +1,39 @@ +#!/bin/bash + +MERA_COMMON_SETUP_default="--model hf --device cuda --batch_size=1 --log_samples --seed 1234 --verbosity ERROR" +MERA_COMMON_SETUP="${MERA_COMMON_SETUP:-$MERA_COMMON_SETUP_default}" +GENERATION_KWARGS="${GENERATION_KWARGS:-do_sample=False}" + +if [[ -n "${MERA_OPENJUDGE_TASKS}" ]]; then + TASKS="${MERA_OPENJUDGE_TASKS}" +else + mapfile -t DISCOVERED_TASKS < <(rg -l "custom_openjudge_localscore_task.yaml" benchmark_tasks -g "*_localscore.yaml" | sed 's#.*[\\/]##' | sed 's#\.yaml$##') + TASKS="${DISCOVERED_TASKS[*]}" +fi + +if [[ -z "${TASKS}" ]]; then + echo "No openjudge localscore tasks found. Set MERA_OPENJUDGE_TASKS or add *_localscore.yaml tasks that include custom_openjudge_localscore_task.yaml." + exit 1 +fi + +for cur_task in ${TASKS} +do + printf "task: %s \n" "$cur_task" + if test -z "${SYSTEM_PROMPT}" + then + HF_DATASETS_CACHE="${MERA_FOLDER}/ds_cache" TOKENIZERS_PARALLELISM=false HF_DATASETS_IN_MEMORY_MAX_SIZE=23400000 \ + CUDA_VISIBLE_DEVICES=$CUDA_VISIBLE_DEVICES PYTHONPATH=$PWD \ + lm_eval --model hf --model_args "${MERA_MODEL_STRING}" --tasks $cur_task \ + --gen_kwargs="${GENERATION_KWARGS}" --output_path="${MERA_FOLDER}" ${MERA_COMMON_SETUP} \ + --include_path=./benchmark_tasks + else + PROCESSED_SYSTEM=$(printf "%b" "$SYSTEM_PROMPT") + HF_DATASETS_CACHE="${MERA_FOLDER}/ds_cache" TOKENIZERS_PARALLELISM=false HF_DATASETS_IN_MEMORY_MAX_SIZE=23400000 \ + CUDA_VISIBLE_DEVICES=$CUDA_VISIBLE_DEVICES PYTHONPATH=$PWD \ + lm_eval --model hf --model_args "${MERA_MODEL_STRING}" --tasks $cur_task \ + --gen_kwargs="${GENERATION_KWARGS}" --output_path="${MERA_FOLDER}" ${MERA_COMMON_SETUP} \ + --system_instruction="${PROCESSED_SYSTEM}" --include_path=./benchmark_tasks + fi +done + +rm -r "${MERA_FOLDER}/ds_cache" diff --git a/tests/test_mera_openjudge.py b/tests/test_mera_openjudge.py new file mode 100644 index 0000000..580de65 --- /dev/null +++ b/tests/test_mera_openjudge.py @@ -0,0 +1,221 @@ +import json +import threading +import unittest +from http.server import BaseHTTPRequestHandler, HTTPServer + +from mera_openjudge import OpenJudgeScorer, load_judge_config +from mera_openjudge.exceptions import JudgeConfigError, JudgeParseError +from mera_openjudge.parsing import parse_judge_score +from mera_openjudge.prompting import render_judge_prompt + + +class FakeBackend: + def __init__(self, responses): + self.responses = list(responses) + + def generate(self, prompts): + batch_size = len(prompts) + batch = self.responses[:batch_size] + self.responses = self.responses[batch_size:] + return batch + + +class _MockJudgeHandler(BaseHTTPRequestHandler): + responses = [] + + def do_POST(self): + content_length = int(self.headers["Content-Length"]) + _ = self.rfile.read(content_length) + response_text = self.responses.pop(0) + payload = { + "choices": [ + { + "message": { + "content": response_text, + } + } + ] + } + body = json.dumps(payload).encode("utf-8") + self.send_response(200) + self.send_header("Content-Type", "application/json") + self.send_header("Content-Length", str(len(body))) + self.end_headers() + self.wfile.write(body) + + def log_message(self, format, *args): + del format, args + + +class OpenJudgeTests(unittest.TestCase): + def test_load_judge_config_validates_scales(self): + config = load_judge_config( + { + "criteria": [ + { + "key": "correctness", + "name": "Correctness", + "scale": {"0": "bad", "1": "good"}, + }, + { + "key": "fluency", + "name": "Fluency", + "scale": {0: "bad", 1: "good"}, + }, + ] + } + ) + self.assertEqual(config.allowed_scores, (0, 1)) + with self.assertRaises(JudgeConfigError): + load_judge_config( + { + "criteria": [ + { + "key": "correctness", + "name": "Correctness", + "scale": {"bad": "nope"}, + } + ] + } + ) + with self.assertRaises(JudgeConfigError): + load_judge_config( + { + "criteria": [ + { + "key": "correctness", + "name": "Correctness", + "scale": {0: "bad", 1: "ok"}, + }, + { + "key": "fluency", + "name": "Fluency", + "scale": {0: "bad", 2: "ok"}, + }, + ] + } + ) + + def test_render_prompt_supports_default_and_custom_template(self): + judge_config = load_judge_config( + { + "criteria": [ + { + "key": "correctness", + "name": "Correctness", + "scale": {0: "bad", 1: "good"}, + } + ], + "reference_field": "meta.reference_answer", + } + ) + doc = { + "instruction": "Answer the question: {question}", + "inputs": {"question": "2+2?"}, + "meta": {"reference_answer": "4"}, + } + prompt = render_judge_prompt( + doc=doc, + answer="4", + criterion=judge_config.criteria[0], + judge_config=judge_config, + ) + self.assertIn("Answer the question: 2+2?", prompt) + self.assertIn("4", prompt) + + custom_config = load_judge_config( + { + "prompt_template": "{{ criterion_name }}|{{ answer }}|{{ reference_answer }}", + "criteria": [ + { + "key": "correctness", + "name": "Correctness", + "scale": {0: "bad", 1: "good"}, + } + ], + } + ) + custom_prompt = render_judge_prompt( + doc=doc, + answer="4", + criterion=custom_config.criteria[0], + judge_config=custom_config, + ) + self.assertEqual(custom_prompt, "Correctness|4|4") + + def test_parse_judge_score(self): + self.assertEqual(parse_judge_score("2", [0, 1, 2]), 2) + self.assertEqual(parse_judge_score("2\nОбоснование", [0, 1, 2]), 2) + self.assertEqual(parse_judge_score("Оценка: 2", [0, 1, 2]), 2) + with self.assertRaises(JudgeParseError): + parse_judge_score("No score here", [0, 1, 2]) + + def test_score_answers_aggregates_metrics(self): + scorer = OpenJudgeScorer( + judge_config={ + "criteria": [ + { + "key": "correctness", + "name": "Correctness", + "scale": {0: "bad", 1: "ok", 2: "good"}, + }, + { + "key": "fluency", + "name": "Fluency", + "scale": {0: "bad", 1: "ok", 2: "good"}, + }, + ] + }, + backend=FakeBackend(["2", "1", "1", "2"]), + ) + metrics = scorer.score_answers( + docs=[ + {"instruction": "Q: {question}", "inputs": {"question": "x"}}, + {"instruction": "Q: {question}", "inputs": {"question": "y"}}, + ], + answers=["a", "b"], + ) + self.assertEqual( + metrics, + [ + {"judge_correctness": 2.0, "judge_fluency": 1.0, "judge_avg": 1.5}, + {"judge_correctness": 1.0, "judge_fluency": 2.0, "judge_avg": 1.5}, + ], + ) + + def test_openai_compatible_backend_with_mock_server(self): + _MockJudgeHandler.responses = ["2\nok"] + server = HTTPServer(("127.0.0.1", 0), _MockJudgeHandler) + thread = threading.Thread(target=server.serve_forever, daemon=True) + thread.start() + try: + scorer = OpenJudgeScorer( + judge_config={ + "criteria": [ + { + "key": "correctness", + "name": "Correctness", + "scale": {0: "bad", 1: "ok", 2: "good"}, + } + ] + }, + runtime_config={ + "backend": "openai_compatible", + "base_url": f"http://127.0.0.1:{server.server_port}/v1", + "model_name": "pollux", + "max_new_tokens": 16, + }, + ) + metrics = scorer.score_answers( + docs=[{"instruction": "Q: {question}", "inputs": {"question": "x"}}], + answers=["a"], + ) + self.assertEqual(metrics[0]["judge_avg"], 2.0) + self.assertEqual(metrics[0]["judge_correctness"], 2.0) + finally: + server.shutdown() + server.server_close() + + +if __name__ == "__main__": + unittest.main() diff --git a/tests/test_openjudge_task.py b/tests/test_openjudge_task.py new file mode 100644 index 0000000..6782315 --- /dev/null +++ b/tests/test_openjudge_task.py @@ -0,0 +1,146 @@ +import json +import unittest +from pathlib import Path +import shutil + +from modules.scoring.src.dataset.dataset import Dataset +from modules.scoring.src.tasks.openjudge_task import OpenJudgeTask + + +class FakeScorer: + def score_answers(self, docs, answers): + del docs, answers + return [ + {"judge_correctness": 2.0, "judge_style": 1.0, "judge_avg": 1.5}, + {"judge_correctness": 1.0, "judge_style": 2.0, "judge_avg": 1.5}, + ] + + +class SyntheticOpenJudgeTask(OpenJudgeTask): + def __init__(self, conf, task_conf_path, judge_conf_path): + self._task_conf_path = task_conf_path + self._judge_conf_path = judge_conf_path + super().__init__(conf) + + @property + def task_conf_path(self): + return self._task_conf_path + + @property + def judge_config_path(self): + return self._judge_conf_path + + def _get_scorer(self): + if self._scorer is None: + self._scorer = FakeScorer() + return self._scorer + + def load_gold(self): + examples = { + 1: { + "instruction": "Answer: {question}", + "inputs": {"question": "a"}, + "outputs": "", + "meta": {"id": 1, "reference_answer": "alpha"}, + }, + 2: { + "instruction": "Answer: {question}", + "inputs": {"question": "b"}, + "outputs": "", + "meta": {"id": 2, "reference_answer": "beta"}, + }, + } + self.gold = Dataset(local_path="", name=self.name, log=self.log, examples=examples) + return [] + + +class OpenJudgeTaskTests(unittest.TestCase): + def test_openjudge_task_aggregates_and_uses_judge_avg(self): + tmpdir = Path("tests/.tmp/openjudge_task_case") + if tmpdir.exists(): + shutil.rmtree(tmpdir, ignore_errors=True) + tmpdir.mkdir(parents=True, exist_ok=True) + + main_conf_path = tmpdir / "main.yaml" + task_conf_path = tmpdir / "syntheticopenjudgetask.yaml" + judge_conf_path = tmpdir / "judge.yaml" + submission_path = tmpdir / "submission.json" + + with open(main_conf_path, "w", encoding="utf-8") as handle: + handle.write( + "\n".join( + [ + "args:", + f" working_dir: \"{(tmpdir / 'working').as_posix()}\"", + f" log_dir: \"{(tmpdir / 'logs').as_posix()}\"", + " verbose: false", + " sample_submission_dir_name: sample_submission", + " seed: 1234", + " errors_comments: modules/scoring/configs/errors_comments.yaml", + "judges:", + " pollux:", + " backend: hf", + " model_name: ai-forever/pollux-judge-7b", + ] + ) + ) + with open(task_conf_path, "w", encoding="utf-8") as handle: + handle.write( + "\n".join( + [ + "extension: .json", + "split: test", + "use_in_total: true", + ] + ) + ) + with open(judge_conf_path, "w", encoding="utf-8") as handle: + handle.write( + "\n".join( + [ + "criteria:", + " - key: correctness", + " name: Correctness", + " scale:", + " 0: bad", + " 1: ok", + " 2: good", + " - key: style", + " name: Style", + " scale:", + " 0: bad", + " 1: ok", + " 2: good", + ] + ) + ) + with open(submission_path, "w", encoding="utf-8") as handle: + json.dump( + { + "data": { + "test": [ + {"outputs": "answer-1", "meta": {"id": 1}}, + {"outputs": "answer-2", "meta": {"id": 2}}, + ] + } + }, + handle, + ) + + task = SyntheticOpenJudgeTask( + conf=str(main_conf_path), + task_conf_path=str(task_conf_path), + judge_conf_path=str(judge_conf_path), + ) + task.load_gold() + results, errors = task.evaluate(str(submission_path)) + + self.assertEqual(errors, []) + self.assertEqual(results["judge_avg"], 1.5) + self.assertEqual(results["judge_correctness"], 1.5) + self.assertEqual(results["judge_style"], 1.5) + self.assertEqual(task.average_results(results), 1.5) + + +if __name__ == "__main__": + unittest.main() From 037168c8c38a8b1fb9e63dfeed66371423ba5b20 Mon Sep 17 00:00:00 2001 From: Danil Astafurov Date: Wed, 22 Apr 2026 17:38:49 +0300 Subject: [PATCH 14/14] Add POLLUX instructions openjudge example --- .../pollux_instructions_example/README.md | 21 ++++++++++ .../pollux_instructions_example/judge.yaml | 9 +++++ .../pollux_instructions_example.yaml | 25 ++++++++++++ ...ollux_instructions_example_localscore.yaml | 5 +++ .../pollux_instructions_example/utils.py | 38 +++++++++++++++++++ 5 files changed, 98 insertions(+) create mode 100644 benchmark_tasks/pollux_instructions_example/README.md create mode 100644 benchmark_tasks/pollux_instructions_example/judge.yaml create mode 100644 benchmark_tasks/pollux_instructions_example/pollux_instructions_example.yaml create mode 100644 benchmark_tasks/pollux_instructions_example/pollux_instructions_example_localscore.yaml create mode 100644 benchmark_tasks/pollux_instructions_example/utils.py diff --git a/benchmark_tasks/pollux_instructions_example/README.md b/benchmark_tasks/pollux_instructions_example/README.md new file mode 100644 index 0000000..88c7f91 --- /dev/null +++ b/benchmark_tasks/pollux_instructions_example/README.md @@ -0,0 +1,21 @@ +## POLLUX Instructions Example + +This directory contains a minimal open-generation example built from the +`ai-forever/POLLUX-instructions` dataset. + +Source prompt: +- dataset: `ai-forever/POLLUX-instructions` +- split: `train` +- `prompt_id`: `0` +- instruction: + `Составь мне план научного доклада об измерении содержания метана в испарениях над морем Лаптевых.` + +Source criterion: +- dataset: `ai-forever/POLLUX-criteria` +- task subtype: `Составить план текста` +- domain: `Научный` +- criterion name: `Глубина проработки ответа` + +The example keeps only one criterion on purpose so that the task stays short and +readable in the repository. Real tasks should usually copy the full criteria set +from `POLLUX-instructions`. diff --git a/benchmark_tasks/pollux_instructions_example/judge.yaml b/benchmark_tasks/pollux_instructions_example/judge.yaml new file mode 100644 index 0000000..3d1806d --- /dev/null +++ b/benchmark_tasks/pollux_instructions_example/judge.yaml @@ -0,0 +1,9 @@ +criteria: + - key: depth + name: Глубина проработки ответа + scale: + 0: Модель выполнила запрос, но ответ поверхностный, неглубокий, с очень слабой степенью проработки. + 1: Модель демонстрирует приемлемую степень проработки, но ответу не хватает глубины и/или детализации. + 2: Модель выдает отличный, проработанный ответ. Ответ демонстрирует хорошее раскрытие темы и достаточную детализацию. +metric_name: judge_avg +reference_field: meta.reference_answer diff --git a/benchmark_tasks/pollux_instructions_example/pollux_instructions_example.yaml b/benchmark_tasks/pollux_instructions_example/pollux_instructions_example.yaml new file mode 100644 index 0000000..502f8a6 --- /dev/null +++ b/benchmark_tasks/pollux_instructions_example/pollux_instructions_example.yaml @@ -0,0 +1,25 @@ +include: ../custom_openjudge_task.yaml +tag: + - mera_openjudge_example +task: pollux_instructions_example +dataset_path: ai-forever/POLLUX-instructions +dataset_name: default +test_split: train +process_docs: !function utils.process_docs +doc_to_text: "{{instruction}}" +generation_kwargs: + do_sample: false +process_results: !function utils.process_results +filter_list: + - name: "scoring" + filter: + - function: polluxinstructionsexamplescoring +metric_list: + - metric: judge_avg + aggregation: mean + higher_is_better: true + - metric: judge_depth + aggregation: mean + higher_is_better: true +metadata: + version: 1.0 diff --git a/benchmark_tasks/pollux_instructions_example/pollux_instructions_example_localscore.yaml b/benchmark_tasks/pollux_instructions_example/pollux_instructions_example_localscore.yaml new file mode 100644 index 0000000..b54bee1 --- /dev/null +++ b/benchmark_tasks/pollux_instructions_example/pollux_instructions_example_localscore.yaml @@ -0,0 +1,5 @@ +include: pollux_instructions_example.yaml +tag: + - mera_openjudge_example_local +task: pollux_instructions_example_localscore +test_split: train diff --git a/benchmark_tasks/pollux_instructions_example/utils.py b/benchmark_tasks/pollux_instructions_example/utils.py new file mode 100644 index 0000000..f326533 --- /dev/null +++ b/benchmark_tasks/pollux_instructions_example/utils.py @@ -0,0 +1,38 @@ +from pathlib import Path + +import datasets + +from benchmark_tasks.openjudge_utils import build_process_results, register_openjudge_filter + + +TASK_DIR = Path(__file__).resolve().parent +FILTER_NAME = register_openjudge_filter("polluxinstructionsexamplescoring", TASK_DIR) +EXAMPLE_PROMPT_IDS = {0} + + +def _process_doc(doc): + return { + "instruction": doc["instruction"], + "inputs": "", + "outputs": doc.get("reference_answer", "") or "", + "meta": { + "id": int(doc["prompt_id"]), + "reference_answer": doc.get("reference_answer", "") or "", + "source_dataset": "ai-forever/POLLUX-instructions", + "source_prompt_id": int(doc["prompt_id"]), + "task_type": doc.get("task_type", ""), + "task_subtype": doc.get("task_subtype", ""), + "task_subsubtype": doc.get("task_subsubtype", ""), + "difficulty": doc.get("difficulty", ""), + "domain": doc.get("domain", ""), + }, + } + + +def process_docs(dataset: datasets.Dataset) -> datasets.Dataset: + filtered = dataset.filter(lambda doc: doc["prompt_id"] in EXAMPLE_PROMPT_IDS) + return filtered.map(_process_doc) + + +process_results = build_process_results(TASK_DIR) +