Skip to content

Commit 53a2e84

Browse files
Lduignan1Jeronymous
authored andcommitted
Add Exo7 benchmark
1 parent 0cf8137 commit 53a2e84

1 file changed

Lines changed: 329 additions & 0 deletions

File tree

  • src/lighteval/tasks/multilingual/tasks
Lines changed: 329 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,329 @@
1+
# MIT License
2+
3+
# Copyright (c) 2026 OpenLLM-France
4+
5+
# Permission is hereby granted, free of charge, to any person obtaining a copy
6+
# of this software and associated documentation files (the "Software"), to deal
7+
# in the Software without restriction, including without limitation the rights
8+
# to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9+
# copies of the Software, and to permit persons to whom the Software is
10+
# furnished to do so, subject to the following conditions:
11+
12+
# The above copyright notice and this permission notice shall be included in all
13+
# copies or substantial portions of the Software.
14+
15+
# THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16+
# IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17+
# FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18+
# AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19+
# LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20+
# OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21+
# SOFTWARE.
22+
23+
"""
24+
name:
25+
Exo7
26+
27+
dataset:
28+
OpenLLM-BPI/Exo7MCQ
29+
30+
abstract:
31+
Exo7 is a dataset of multi-label multiple-choice math questions for French undergraduate
32+
students, sourced from http://exo7.emath.fr/. Many items have more than one correct answer.
33+
Two scoring paths are exposed, both zero-shot: a logprob path (MCF, Hybrid) using a
34+
TruthfulQA MC2-style probability-mass metric, and a generative path that asks the model to
35+
emit "Réponse : A, C" and scores with set-F1 and exact-set-match.
36+
37+
languages:
38+
french
39+
40+
tags:
41+
math, question-answering, multiple-choice, multi-label
42+
43+
paper:
44+
45+
"""
46+
47+
import re
48+
49+
import numpy as np
50+
51+
from lighteval.metrics.metrics_sample import SampleLevelComputation
52+
from lighteval.metrics.normalizations import LogProbCharNorm, LogProbTokenNorm, normalize_log_probs
53+
from lighteval.metrics.utils.metric_utils import SampleLevelMetric
54+
from lighteval.models.model_output import ModelResponse
55+
from lighteval.tasks.lighteval_task import LightevalTaskConfig
56+
from lighteval.tasks.requests import Doc, SamplingMethod
57+
from lighteval.tasks.templates.multichoice import get_mcq_prompt_function
58+
from lighteval.tasks.templates.utils.formulation import (
59+
HybridFormulation,
60+
MCFFormulation,
61+
)
62+
from lighteval.utils.language import Language
63+
64+
65+
LETTER_INDICES = [
66+
"A",
67+
"B",
68+
"C",
69+
"D",
70+
"E",
71+
"F",
72+
"G",
73+
"H",
74+
"I",
75+
"J",
76+
"K",
77+
"L",
78+
"M",
79+
"N",
80+
"O",
81+
"P",
82+
"Q",
83+
"R",
84+
"S",
85+
"T",
86+
"U",
87+
"V",
88+
"W",
89+
"X",
90+
"Y",
91+
"Z",
92+
]
93+
94+
95+
# --- Custom logprob mass metric ---
96+
97+
98+
class Exo7MCMetric(SampleLevelComputation):
99+
"""Probability mass metric for multi-label multiple choice.
100+
101+
Converts log-likelihoods to probabilities, normalizes them, and returns
102+
the total probability mass on the correct answers.
103+
"""
104+
105+
def __init__(self, normalization):
106+
self.normalization = normalization
107+
108+
def compute(self, doc: Doc, model_response: ModelResponse, **kwargs):
109+
norm_logprobs = np.array(
110+
normalize_log_probs(
111+
self.normalization,
112+
choices_logprob=model_response.logprobs,
113+
unconditioned_logprob=None,
114+
choices_text=doc.choices,
115+
choices_tokens=model_response.output_tokens,
116+
)
117+
)
118+
119+
probs = np.exp(norm_logprobs - np.max(norm_logprobs))
120+
probs_norm = probs / np.sum(probs)
121+
122+
labels = np.array(doc.specific["labels"])
123+
return float(np.sum(probs_norm[labels == 1]))
124+
125+
126+
exo7_mc_metric_token = SampleLevelMetric(
127+
metric_name="prob_mass_norm_token",
128+
sample_level_fn=Exo7MCMetric(LogProbTokenNorm()),
129+
category=SamplingMethod.LOGPROBS,
130+
corpus_level_fn=np.mean,
131+
higher_is_better=True,
132+
)
133+
134+
exo7_mc_metric_char = SampleLevelMetric(
135+
metric_name="prob_mass_norm_char",
136+
sample_level_fn=Exo7MCMetric(LogProbCharNorm()),
137+
category=SamplingMethod.LOGPROBS,
138+
corpus_level_fn=np.mean,
139+
higher_is_better=True,
140+
)
141+
142+
143+
# --- Generative metrics (multi-letter answer) ---
144+
145+
146+
_RESPONSE_RE = re.compile(r"(?:^|\n)\s*[Rr][ée]ponse\s*:?\s*([^\n]*)")
147+
_BOXED_RE = re.compile(r"\\boxed\s*\{([^}]*)\}")
148+
_LETTER_RE = re.compile(r"\b[A-Z]\b")
149+
150+
151+
def _extract_letters(text: str, valid: set) -> set:
152+
"""Extract the set of answer letters from a generative response.
153+
154+
Prefers the last line starting with "Réponse :" (the instructed format);
155+
failing that, the contents of the last ``\\boxed{...}`` (math-tuned
156+
models like Qwen2.5-Math default to this); otherwise the last non-empty
157+
line. Keeps only letters in the valid set. Uses word boundaries so
158+
isolated capitals (e.g. "A, C") match but letters inside words
159+
("Aucune", "Vrai") do not.
160+
"""
161+
if not text:
162+
return set()
163+
matches = list(_RESPONSE_RE.finditer(text))
164+
if matches:
165+
target = matches[-1].group(1)
166+
else:
167+
boxed = list(_BOXED_RE.finditer(text))
168+
if boxed:
169+
target = boxed[-1].group(1)
170+
else:
171+
lines = [line for line in text.strip().splitlines() if line.strip()]
172+
target = lines[-1] if lines else ""
173+
return {c for c in _LETTER_RE.findall(target) if c in valid}
174+
175+
176+
class Exo7GenerativeF1(SampleLevelComputation):
177+
"""Set-F1 between predicted and gold letter sets."""
178+
179+
def compute(self, model_response: ModelResponse, doc: Doc, **kwargs):
180+
pred_text = model_response.text[0] if model_response.text else ""
181+
valid = set(doc.choices)
182+
gold = set(doc.specific["correct_letters"])
183+
pred = _extract_letters(pred_text, valid)
184+
if not gold and not pred:
185+
return 1.0
186+
if not gold or not pred:
187+
return 0.0
188+
tp = len(pred & gold)
189+
if tp == 0:
190+
return 0.0
191+
precision = tp / len(pred)
192+
recall = tp / len(gold)
193+
return 2 * precision * recall / (precision + recall)
194+
195+
196+
class Exo7GenerativeExactMatch(SampleLevelComputation):
197+
"""1.0 iff the predicted letter set exactly matches the gold set."""
198+
199+
def compute(self, model_response: ModelResponse, doc: Doc, **kwargs):
200+
pred_text = model_response.text[0] if model_response.text else ""
201+
valid = set(doc.choices)
202+
gold = set(doc.specific["correct_letters"])
203+
pred = _extract_letters(pred_text, valid)
204+
return float(pred == gold)
205+
206+
207+
exo7_generative_f1_metric = SampleLevelMetric(
208+
metric_name="f1",
209+
sample_level_fn=Exo7GenerativeF1(),
210+
category=SamplingMethod.GENERATIVE,
211+
corpus_level_fn=np.mean,
212+
higher_is_better=True,
213+
)
214+
215+
exo7_generative_exact_metric = SampleLevelMetric(
216+
metric_name="exact_match",
217+
sample_level_fn=Exo7GenerativeExactMatch(),
218+
category=SamplingMethod.GENERATIVE,
219+
corpus_level_fn=np.mean,
220+
higher_is_better=True,
221+
)
222+
223+
224+
# --- Prompt function ---
225+
226+
INSTRUCTION = (
227+
"Pour la question suivante, une ou plusieurs propositions peuvent être correctes. Évaluez chaque proposition."
228+
)
229+
230+
231+
def _make_prompt_fn(formulation):
232+
base_fn = get_mcq_prompt_function(
233+
Language.FRENCH,
234+
lambda line: {
235+
"question": line["question"],
236+
"choices": line["targets"]["choices"],
237+
"gold_idx": [i for i, label in enumerate(line["targets"]["labels"]) if label == 1],
238+
"instruction": INSTRUCTION,
239+
},
240+
formulation=formulation,
241+
)
242+
243+
def prompt_fn(line, task_name: str = None):
244+
doc = base_fn(line, task_name)
245+
doc.specific = {"labels": line["targets"]["labels"]}
246+
return doc
247+
248+
return prompt_fn
249+
250+
251+
GENERATIVE_INSTRUCTION_TEMPLATE = (
252+
"Pour la question suivante, une ou plusieurs propositions peuvent être correctes. "
253+
"Évaluez chaque proposition, puis indiquez toutes les lettres des propositions correctes. "
254+
"La dernière ligne de votre réponse doit être au format suivant : "
255+
"'Réponse : $LETTRES' (sans les guillemets) où $LETTRES est une liste de lettres parmi "
256+
"{valid_letters} séparées par des virgules (par exemple 'Réponse : A, C'). "
257+
"Réfléchissez étape par étape avant de répondre."
258+
)
259+
260+
261+
def _make_generative_prompt_fn():
262+
def prompt_fn(line, task_name: str = None):
263+
choices = line["targets"]["choices"]
264+
labels = line["targets"]["labels"]
265+
letters = list(LETTER_INDICES[: len(choices)])
266+
correct_letters = [letters[i] for i, label in enumerate(labels) if label == 1]
267+
268+
instruction = GENERATIVE_INSTRUCTION_TEMPLATE.format(valid_letters=", ".join(letters))
269+
choices_str = "\n".join(f"{letter}) {choice.strip()}" for letter, choice in zip(letters, choices))
270+
query = f"{instruction}\n\n{line['question'].strip()}\n\n{choices_str}"
271+
272+
doc = Doc(
273+
task_name=task_name,
274+
query=query,
275+
choices=letters,
276+
gold_index=[i for i, label in enumerate(labels) if label == 1],
277+
instruction=instruction,
278+
)
279+
doc.specific = {
280+
"correct_letters": correct_letters,
281+
"labels": labels,
282+
}
283+
return doc
284+
285+
return prompt_fn
286+
287+
288+
# --- Task configs ---
289+
290+
FORMULATIONS = [MCFFormulation(), HybridFormulation()]
291+
292+
293+
def _make_task(formulation):
294+
return LightevalTaskConfig(
295+
name=f"exo7_{formulation.name.lower()}",
296+
prompt_function=_make_prompt_fn(formulation),
297+
suite=["community"],
298+
hf_repo="OpenLLM-BPI/Exo7MCQ",
299+
hf_subset="default",
300+
hf_avail_splits=["test"],
301+
evaluation_splits=["test"],
302+
few_shots_split=None,
303+
few_shots_select=None,
304+
generation_size=1,
305+
metrics=[exo7_mc_metric_token, exo7_mc_metric_char],
306+
stop_sequence=["\n"],
307+
version=0,
308+
)
309+
310+
311+
def _make_generative_task():
312+
return LightevalTaskConfig(
313+
name="exo7_generative",
314+
prompt_function=_make_generative_prompt_fn(),
315+
suite=["community"],
316+
hf_repo="OpenLLM-BPI/Exo7MCQ",
317+
hf_subset="default",
318+
hf_avail_splits=["test"],
319+
evaluation_splits=["test"],
320+
few_shots_split=None,
321+
few_shots_select=None,
322+
generation_size=4096,
323+
metrics=[exo7_generative_f1_metric, exo7_generative_exact_metric],
324+
stop_sequence=[],
325+
version=0,
326+
)
327+
328+
329+
TASKS_TABLE = [_make_task(formulation) for formulation in FORMULATIONS] + [_make_generative_task()]

0 commit comments

Comments
 (0)