NeuronAI-2B / benchmark.py
kmamaroziqov's picture
Match NeuronAI-4B card format with 2B evidence and Apache license
f99a82c verified
Raw
History Blame Contribute Delete
14.3 kB
#!/usr/bin/env python3
"""Portable NeuronAI-2B / Alloma-style Uzbek benchmark runner.
Examples:
python benchmark.py --limit 200 --output quick-results.json
python benchmark.py --limit 0 --comet --output full-results.json
`--limit 0` evaluates every example. The default 200-example run is a quick,
deterministically sampled sanity check and must not be compared with the full
scores in the model card.
"""
from __future__ import annotations
import argparse
import json
import re
from datetime import datetime, timezone
from pathlib import Path
import torch
from datasets import concatenate_datasets, load_dataset
from transformers import AutoModelForCausalLM, AutoTokenizer
MODEL_ID = "NeuronUz/NeuronAI-2B"
LETTERS = "ABCD"
TRANSLATION_PROMPTS = {
"uz-en": (
"Translate each Uzbek sentence into English.\n\n"
"1991-yilning 1-sentabrida O'zbekiston mustaqilligini e'lon qildi.\n"
"-> On 1 September 1991, Uzbekistan declared its independence.\n\n"
"Tadqiqotchilar yangi usul samaradorligi 47 foizga oshganini aniqladilar.\n"
"-> Researchers found that the new method improved efficiency by 47 percent.\n\n"
"{text}\n->"
),
"en-uz": "Translate into Uzbek:\n\n{text}",
}
SENTIMENT_PROMPT = (
"Given the following Uzbek text, determine the sentiment as either "
"'Positive' or 'Negative'. Respond with only one label.\n\nText: {text}\n\nLabel:"
)
NEWS_PROMPT = """Classify the given Uzbek news article into one category. Respond with only the category number.
0 - Siyosat
1 - Iqtisodiyot
2 - Texnologiya
3 - Sport
4 - Madaniyat
5 - Salomatlik
6 - Oila va Jamiyat
7 - Ta'lim
8 - Ekologiya
9 - Xorijiy Yangiliklar
Article: {text}
Answer:"""
MCQ_SUFFIX = {
"uz": "Variantlarni diqqat bilan solishtiring. Javobni A, B, C yoki D harfi bilan boshlang.",
"en": "Compare the options carefully. Start with the answer letter A, B, C, or D.",
}
MCQ_TASKS = {
"mmlu-en": ("cais/mmlu", "all", "test", "en"),
"mmlu-uz": ("murodbek/MMLU-uz", "default", "test", "uz"),
"tumlu": ("jafarisbarov/TUMLU-mini", "uzbek", "test", "uz"),
}
def choose_rows(dataset, limit: int, seed: int):
if limit and len(dataset) > limit:
return dataset.shuffle(seed=seed).select(range(limit))
return dataset
def strip_thinking(text: str) -> str:
return re.sub(r"<think>.*?</think>", "", text, flags=re.DOTALL).strip()
class Generator:
def __init__(self, args: argparse.Namespace):
self.backend = args.backend
self.tokenizer = AutoTokenizer.from_pretrained(args.model, trust_remote_code=True)
self.tokenizer.padding_side = "left"
if self.tokenizer.pad_token_id is None:
self.tokenizer.pad_token_id = self.tokenizer.eos_token_id
if args.backend == "vllm":
from vllm import LLM, SamplingParams
self.sampling_cls = SamplingParams
self.model = LLM(
model=args.model,
dtype=args.dtype,
trust_remote_code=True,
gpu_memory_utilization=args.gpu_memory_utilization,
max_model_len=args.max_model_len,
language_model_only=True,
mamba_block_size=16,
mamba_cache_mode="align",
)
else:
dtype = torch.bfloat16 if args.dtype == "bfloat16" else torch.float16
self.model = AutoModelForCausalLM.from_pretrained(
args.model,
dtype=dtype,
device_map="auto",
trust_remote_code=True,
).eval()
def render(self, prompt: str) -> str:
messages = [{"role": "user", "content": prompt}]
try:
return self.tokenizer.apply_chat_template(
messages,
tokenize=False,
add_generation_prompt=True,
enable_thinking=False,
)
except TypeError:
return self.tokenizer.apply_chat_template(
messages, tokenize=False, add_generation_prompt=True
)
def generate(self, prompts: list[str], max_new_tokens: int, batch_size: int) -> list[str]:
rendered = [self.render(prompt) for prompt in prompts]
if self.backend == "vllm":
params = self.sampling_cls(temperature=0.0, max_tokens=max_new_tokens)
outputs = self.model.generate(rendered, params)
return [strip_thinking(item.outputs[0].text) for item in outputs]
results: list[str] = []
for start in range(0, len(rendered), batch_size):
batch = rendered[start : start + batch_size]
encoded = self.tokenizer(
batch,
return_tensors="pt",
padding=True,
truncation=True,
max_length=4096,
).to(self.model.device)
prompt_width = encoded["input_ids"].shape[1]
with torch.inference_mode():
output = self.model.generate(
**encoded,
max_new_tokens=max_new_tokens,
do_sample=False,
)
results.extend(
strip_thinking(text)
for text in self.tokenizer.batch_decode(
output[:, prompt_width:], skip_special_tokens=True
)
)
return results
def load_flores(direction: str, limit: int, seed: int) -> list[dict[str, str]]:
langs = {"uz-en": ("uzn_Latn", "eng_Latn"), "en-uz": ("eng_Latn", "uzn_Latn")}
src_lang, ref_lang = langs[direction]
src = concatenate_datasets([
load_dataset("openlanguagedata/flores_plus", src_lang, split="dev"),
load_dataset("openlanguagedata/flores_plus", src_lang, split="devtest"),
])
ref = concatenate_datasets([
load_dataset("openlanguagedata/flores_plus", ref_lang, split="dev"),
load_dataset("openlanguagedata/flores_plus", ref_lang, split="devtest"),
])
pairs = [
{"src": src[index]["text"].strip(), "ref": ref[index]["text"].strip()}
for index in range(min(len(src), len(ref)))
]
if limit and len(pairs) > limit:
import random
random.Random(seed).shuffle(pairs)
pairs = pairs[:limit]
return pairs
def score_comet(sources: list[str], hypotheses: list[str], references: list[str]) -> float:
from comet import download_model, load_from_checkpoint
checkpoint = download_model("Unbabel/wmt22-comet-da")
model = load_from_checkpoint(checkpoint)
rows = [
{"src": src, "mt": hypothesis, "ref": reference}
for src, hypothesis, reference in zip(sources, hypotheses, references, strict=True)
]
return float(model.predict(rows, batch_size=8, gpus=1 if torch.cuda.is_available() else 0).system_score)
def evaluate_flores(generator: Generator, args: argparse.Namespace) -> dict:
import sacrebleu
results = {}
for direction in ("uz-en", "en-uz"):
pairs = load_flores(direction, args.limit, args.seed)
prompts = [TRANSLATION_PROMPTS[direction].format(text=row["src"]) for row in pairs]
hypotheses = generator.generate(prompts, max_new_tokens=160, batch_size=args.batch_size)
references = [row["ref"] for row in pairs]
sources = [row["src"] for row in pairs]
row = {
"total": len(pairs),
"bleu": float(sacrebleu.corpus_bleu(hypotheses, [references]).score),
"samples": [
{"source": src, "prediction": hyp, "reference": ref}
for src, hyp, ref in zip(sources[:3], hypotheses[:3], references[:3])
],
}
if args.comet:
row["comet"] = score_comet(sources, hypotheses, references)
results[direction] = row
print(f"FLORES+ {direction}: BLEU={row['bleu']:.2f}" + (f", COMET={row['comet']:.4f}" if args.comet else ""))
return results
def label_to_int(raw, names: list[str]) -> int | None:
if isinstance(raw, int) and 0 <= raw < len(names):
return raw
cleaned = str(raw).strip().casefold().replace("’", "'")
for index, name in enumerate(names):
if cleaned == name.casefold():
return index
return None
def evaluate_classification(generator: Generator, args: argparse.Namespace, task: str) -> dict:
if task == "sentiment":
dataset = load_dataset("behbudiy/uzbek-sentiment-analysis", split="train")
names = ["Negative", "Positive"]
rows = [
{"text": row["text"], "gold": label_to_int(row["label"], names)}
for row in choose_rows(dataset, args.limit, args.seed)
]
prompt_template = SENTIMENT_PROMPT
parser = lambda text: 1 if text.casefold().startswith("positive") else (0 if text.casefold().startswith("negative") else None)
else:
dataset = load_dataset("risqaliyevds/uzbek-zero-shot-classification", split="train")
names = ["Siyosat", "Iqtisodiyot", "Texnologiya", "Sport", "Madaniyat",
"Salomatlik", "Oila va Jamiyat", "Ta'lim", "Ekologiya", "Xorijiy Yangiliklar"]
rows = [
{"text": row["text"], "gold": label_to_int(row["class"], names)}
for row in choose_rows(dataset, args.limit, args.seed)
]
prompt_template = NEWS_PROMPT
parser = lambda text: int(match.group()) if (match := re.search(r"\d", text)) else None
rows = [row for row in rows if row["gold"] is not None]
prompts = [prompt_template.format(text=row["text"][: args.max_text_chars]) for row in rows]
outputs = generator.generate(prompts, max_new_tokens=8, batch_size=args.batch_size)
predictions = [parser(output.strip()) for output in outputs]
correct = sum(prediction == row["gold"] for prediction, row in zip(predictions, rows, strict=True))
invalid = sum(prediction is None for prediction in predictions)
result = {
"accuracy": correct / len(rows),
"correct": correct,
"total": len(rows),
"invalid_rate": invalid / len(rows),
}
print(f"{task}: accuracy={result['accuracy']:.2%} ({correct}/{len(rows)}), invalid={invalid}")
return result
def answer_letter(raw) -> str | None:
if isinstance(raw, int) and 0 <= raw < 4:
return LETTERS[raw]
cleaned = str(raw).strip().upper()
return cleaned[0] if cleaned and cleaned[0] in LETTERS else None
def evaluate_mcq(generator: Generator, args: argparse.Namespace, task: str) -> dict:
dataset_name, config, split, language = MCQ_TASKS[task]
dataset = choose_rows(load_dataset(dataset_name, config, split=split), args.limit, args.seed)
rows = []
for row in dataset:
choices = row.get("choices") or [row.get(f"option_{letter.lower()}") for letter in LETTERS]
choices = [str(choice) for choice in choices if choice is not None]
gold = answer_letter(row.get("answer"))
if row.get("question") and len(choices) >= 4 and gold:
rows.append({"question": row["question"], "choices": choices[:4], "gold": gold})
prompts = []
for row in rows:
choices = "\n".join(f"{letter}) {choice}" for letter, choice in zip(LETTERS, row["choices"]))
prompts.append(f"{row['question']}\n\n{choices}\n\n{MCQ_SUFFIX[language]}")
outputs = generator.generate(prompts, max_new_tokens=12, batch_size=args.batch_size)
predictions = []
for output in outputs:
match = re.search(r"[ABCD]", output.upper())
predictions.append(match.group() if match else None)
correct = sum(prediction == row["gold"] for prediction, row in zip(predictions, rows, strict=True))
invalid = sum(prediction is None for prediction in predictions)
result = {
"accuracy": correct / len(rows),
"correct": correct,
"total": len(rows),
"invalid_rate": invalid / len(rows),
}
print(f"{task}: accuracy={result['accuracy']:.2%} ({correct}/{len(rows)}), invalid={invalid}")
return result
def parse_args() -> argparse.Namespace:
parser = argparse.ArgumentParser(description=__doc__)
parser.add_argument("--model", default=MODEL_ID)
parser.add_argument("--tasks", default="flores,sentiment,news,mmlu-en,mmlu-uz,tumlu")
parser.add_argument("--backend", choices=["vllm", "transformers"], default="vllm")
parser.add_argument("--limit", type=int, default=200, help="Examples per dataset; 0 means full dataset.")
parser.add_argument("--batch-size", type=int, default=16)
parser.add_argument("--max-text-chars", type=int, default=4000)
parser.add_argument("--max-model-len", type=int, default=4096)
parser.add_argument("--gpu-memory-utilization", type=float, default=0.85)
parser.add_argument("--dtype", choices=["bfloat16", "float16"], default="bfloat16")
parser.add_argument("--seed", type=int, default=42)
parser.add_argument("--comet", action="store_true", help="Download WMT22-COMET-DA and score FLORES+.")
parser.add_argument("--output", type=Path, default=Path("neuronai-2b-benchmark.json"))
return parser.parse_args()
def main() -> None:
args = parse_args()
if args.limit < 0:
raise ValueError("--limit must be 0 or greater")
tasks = {task.strip() for task in args.tasks.split(",") if task.strip()}
unknown = tasks - {"flores", "sentiment", "news", *MCQ_TASKS}
if unknown:
raise ValueError(f"Unknown tasks: {sorted(unknown)}")
generator = Generator(args)
results = {
"model": args.model,
"timestamp": datetime.now(timezone.utc).isoformat(timespec="seconds"),
"limit_per_dataset": args.limit,
"seed": args.seed,
"backend": args.backend,
"results": {},
}
if "flores" in tasks:
results["results"]["flores"] = evaluate_flores(generator, args)
for task in ("sentiment", "news"):
if task in tasks:
results["results"][task] = evaluate_classification(generator, args, task)
for task in MCQ_TASKS:
if task in tasks:
results["results"][task] = evaluate_mcq(generator, args, task)
args.output.write_text(json.dumps(results, ensure_ascii=False, indent=2) + "\n", encoding="utf-8")
print(f"Wrote {args.output}")
if __name__ == "__main__":
main()