| """Downstream multilingual benchmarks via lm-evaluation-harness. |
| |
| Complements the intrinsic BPB eval (eval/bpb.py) and the bespoke |
| cross-lingual-transfer/representation analyses (eval/bts.py, eval/alignment.py) |
| with real task accuracy: Global-MMLU (knowledge), Belebele (reading |
| comprehension), XNLI (natural language inference) -- all covering en/de/fr/ar/zh. |
| |
| By default, evaluation is restricted to the languages in the checkpoint's |
| training mixture: three tasks for a monolingual run and six for a bilingual |
| run. ``--tasks`` remains an explicit override for targeted/OOD analyses. |
| |
| `XScriptLM` wraps our own Transformer/Tok (not HF-standard) in lm_eval's `LM` |
| interface. Scoring follows the exact shifted-LM convention already used in |
| eval/bpb.py's score_texts: model(x, y) with x=seq[:-1], y=seq[1:] returns |
| logits where logits[:, j] predicts y[j]. |
| """ |
| import importlib.metadata |
| import json |
| from pathlib import Path |
|
|
| import torch |
| import torch.nn.functional as F |
|
|
| from ..tok.wrapper import BOS_ID, EOS_ID, PAD_ID |
|
|
| DEFAULT_TASKS = { |
| "global_mmlu": ["global_mmlu_en", "global_mmlu_de", "global_mmlu_fr", |
| "global_mmlu_ar", "global_mmlu_zh"], |
| "belebele": ["belebele_eng_Latn", "belebele_deu_Latn", "belebele_fra_Latn", |
| "belebele_arb_Arab", "belebele_zho_Hans"], |
| "xnli": ["xnli_en", "xnli_de", "xnli_fr", "xnli_ar", "xnli_zh"], |
| } |
|
|
| LANG_ORDER = ("en", "de", "fr", "ar", "zh") |
| TASKS_BY_LANG = { |
| lang: [DEFAULT_TASKS[family][i] for family in DEFAULT_TASKS] |
| for i, lang in enumerate(LANG_ORDER) |
| } |
|
|
|
|
| def tasks_for_langs(langs: list[str]) -> list[str]: |
| """Harness task names for exactly the languages in a run's mixture.""" |
| unknown = [lang for lang in langs if lang not in TASKS_BY_LANG] |
| if unknown: |
| raise ValueError(f"no downstream task mapping for languages: {unknown}") |
| |
| return [TASKS_BY_LANG[lang][family_i] |
| for family_i in range(len(DEFAULT_TASKS)) for lang in langs] |
|
|
|
|
| class XScriptLM: |
| """lm_eval.api.model.TemplateLM subclass wrapping our Transformer + Tok. |
| |
| Inherits from TemplateLM lazily (import-time, so lm_eval/torch stay |
| optional deps of the base package) via _make_lm() below. |
| """ |
|
|
| def __init__(self, model, tok, device, max_seq_len: int, batch_size: int = 4): |
| super().__init__() |
| self.model = model.eval() |
| self.tok = tok |
| |
| |
| self._device = torch.device(device) |
| self.max_seq_len = max_seq_len |
| self.batch_size = batch_size |
| self.tokenizer = None |
|
|
| @property |
| def eot_token_id(self) -> int: |
| return EOS_ID |
|
|
| @property |
| def prefix_token_id(self) -> int: |
| |
| return BOS_ID |
|
|
| def tok_encode(self, string: str, add_special_tokens=None, **kwargs) -> list[int]: |
| return self.tok.encode(string, bos=False, eos=False) |
|
|
| def _prepare(self, context_enc: list[int], continuation_enc: list[int]) -> list[int]: |
| """Return a model-ready sequence with one BOS and an intact target. |
| |
| TemplateLM supplies ``[prefix_token_id]`` for an empty string context, |
| whereas non-empty contexts contain no special token. Normalize both |
| cases here so BOS is added exactly once. Context is left-truncated; |
| benchmark answer continuations are never silently truncated. |
| """ |
| if not continuation_enc: |
| return [] |
| has_bos = bool(context_enc) and context_enc[0] == BOS_ID |
| context = context_enc[1:] if has_bos else context_enc |
| if len(continuation_enc) > self.max_seq_len: |
| raise ValueError( |
| f"continuation has {len(continuation_enc)} tokens, exceeding " |
| f"max_seq_len={self.max_seq_len}" |
| ) |
| budget = self.max_seq_len - len(continuation_enc) |
| context = context[-budget:] if budget < len(context) else context |
| return [BOS_ID] + context + continuation_enc |
|
|
| @torch.no_grad() |
| def _score_batch(self, batch) -> list[tuple[float, bool]]: |
| """Score variable-length requests with right padding. |
| |
| Padding is strictly after each real sequence, so causal attention |
| cannot let it affect any scored position. Passing targets asks our |
| Transformer for all-position logits; the returned scalar loss is |
| intentionally ignored. |
| """ |
| prepared = [(self._prepare(list(c), list(k)), len(k)) for c, k in batch] |
| out: list[tuple[float, bool] | None] = [None] * len(prepared) |
| active = [(i, seq, n) for i, (seq, n) in enumerate(prepared) if n] |
| for i, (_, n) in enumerate(prepared): |
| if not n: |
| out[i] = (0.0, True) |
| if not active: |
| return out |
|
|
| width = max(len(seq) - 1 for _, seq, _ in active) |
| x = torch.full((len(active), width), PAD_ID, dtype=torch.long, |
| device=self.device) |
| y = torch.full((len(active), width), -100, dtype=torch.long, |
| device=self.device) |
| lengths = [] |
| for row, (_, seq, _) in enumerate(active): |
| m = len(seq) - 1 |
| lengths.append(m) |
| x[row, :m] = torch.tensor(seq[:-1], device=self.device) |
| y[row, :m] = torch.tensor(seq[1:], device=self.device) |
|
|
| amp = (torch.autocast("cuda", dtype=torch.bfloat16) |
| if self.device.type == "cuda" else _null()) |
| with amp: |
| logits, _ = self.model(x, y) |
| for row, (out_i, _, n) in enumerate(active): |
| m = lengths[row] |
| cont_logits = logits[row, m - n:m, :].float() |
| target = y[row, m - n:m] |
| logprobs = F.log_softmax(cont_logits, dim=-1) |
| token_lp = logprobs.gather(1, target.unsqueeze(1)).squeeze(1) |
| greedy = bool((cont_logits.argmax(-1) == target).all().item()) |
| out[out_i] = (float(token_lp.sum().item()), greedy) |
| return out |
|
|
| def _loglikelihood_tokens(self, requests, disable_tqdm: bool = False): |
| from tqdm import tqdm |
| out = [] |
| batches = range(0, len(requests), self.batch_size) |
| for st in tqdm(batches, disable=disable_tqdm, desc="[bench] scoring"): |
| chunk = requests[st:st + self.batch_size] |
| out.extend(self._score_batch([(c, k) for _, c, k in chunk])) |
| return out |
|
|
| @torch.no_grad() |
| def loglikelihood_rolling(self, requests, disable_tqdm: bool = False): |
| from lm_eval import utils |
| from tqdm import tqdm |
| out = [] |
| for req in tqdm(requests, disable=disable_tqdm, desc="[bench] rolling"): |
| (text,) = req.args |
| ids = self.tok_encode(text) |
| windows = list(utils.get_rolling_token_windows( |
| token_list=ids, prefix_token=BOS_ID, |
| max_seq_len=self.max_seq_len, context_len=1, |
| )) |
| |
| |
| total = 0.0 |
| for context, target in windows: |
| x = torch.tensor(context, device=self.device).unsqueeze(0) |
| |
| |
| y_ids = [-100] * (len(context) - len(target)) + target |
| y = torch.tensor(y_ids, device=self.device).unsqueeze(0) |
| logits, _ = self.model(x, y) |
| n = len(target) |
| lp = F.log_softmax(logits[0, -n:, :].float(), -1) |
| total += float(lp.gather(1, y[0, -n:].unsqueeze(1)).sum().item()) |
| out.append(total) |
| return out |
|
|
| @torch.no_grad() |
| def generate_until(self, requests, disable_tqdm: bool = False): |
| from tqdm import tqdm |
| out = [] |
| for req in tqdm(requests, disable=disable_tqdm, desc="[bench] generating"): |
| context, gen_kwargs = req.args |
| until = gen_kwargs.get("until", []) if isinstance(gen_kwargs, dict) else [] |
| max_gen = (gen_kwargs.get("max_gen_toks", 256) |
| if isinstance(gen_kwargs, dict) else 256) |
| ids = [BOS_ID] + self.tok_encode(context)[-(self.max_seq_len - 1):] |
| gen = [] |
| text_so_far = "" |
| for _ in range(max_gen): |
| x = torch.tensor(ids[-self.max_seq_len:], device=self.device).unsqueeze(0) |
| logits = self.model(x) |
| next_id = int(logits[0, -1].argmax(-1).item()) |
| if next_id == EOS_ID: |
| break |
| gen.append(next_id) |
| ids.append(next_id) |
| text_so_far = self.tok.decode(gen) |
| if until and any(u in text_so_far for u in until): |
| for u in until: |
| idx = text_so_far.find(u) |
| if idx != -1: |
| text_so_far = text_so_far[:idx] |
| break |
| out.append(text_so_far) |
| return out |
|
|
|
|
| def _make_lm(model, tok, device, max_seq_len): |
| """Bind XScriptLM to lm_eval.api.model.TemplateLM at call time (keeps |
| lm_eval/torch optional for anything that only imports xscript.eval.bench |
| for DEFAULT_TASKS).""" |
| from lm_eval.api.model import TemplateLM |
|
|
| class _Bound(XScriptLM, TemplateLM): |
| def __init__(self): |
| XScriptLM.__init__(self, model, tok, device, max_seq_len) |
|
|
| return _Bound() |
|
|
|
|
| def run(run_name: str, tok_name: str, tag: str = "final", tasks: list[str] | None = None, |
| num_fewshot: int = 0, limit: int | float | None = None, |
| out_dir: Path | None = None, log_wandb: bool = True, |
| batch_size: int = 4) -> dict: |
| """Evaluate a checkpoint on its training languages by default.""" |
| import lm_eval |
| from ..model import ModelConfig, Transformer |
| from ..tok.wrapper import Tok |
| from ..paths import RUNS, RESULTS, tokenizer_dir, ensure |
|
|
| device = torch.device("cuda" if torch.cuda.is_available() else "cpu") |
| ck = torch.load(RUNS / run_name / "checkpoints" / f"{tag}.pt", |
| map_location="cpu", weights_only=False) |
| model = Transformer(ModelConfig(**ck["cfg"]["model"])).to(device).eval() |
| model.load_state_dict(ck["model"]) |
| tok = Tok(tokenizer_dir(tok_name)) |
| if tok_name != ck["cfg"]["tok_name"]: |
| raise ValueError(f"checkpoint uses {ck['cfg']['tok_name']}, not {tok_name}") |
|
|
| task_list = tasks if tasks is not None else tasks_for_langs(ck["cfg"]["langs"]) |
| adapter = _make_lm(model, tok, device, model.cfg.max_seq_len) |
| adapter.batch_size = batch_size |
|
|
| results = lm_eval.simple_evaluate( |
| model=adapter, tasks=task_list, num_fewshot=num_fewshot, |
| batch_size=1, limit=limit, log_samples=False, confirm_run_unsafe_code=True, |
| ) |
| def _accuracy(rec): |
| |
| |
| |
| return rec.get("acc,none", rec.get("acc")) |
|
|
| scores = {} |
| groups = results.get("groups", {}) |
| subtasks = results.get("results", {}) |
| for name in task_list: |
| rec = groups.get(name, subtasks.get(name, {})) |
| scores[name] = _accuracy(rec) |
|
|
| out_dir = ensure(Path(out_dir) if out_dir else RESULTS / "bench") |
| payload = { |
| "run": run_name, "checkpoint": tag, "tokenizer": tok_name, |
| "lm_eval_version": importlib.metadata.version("lm_eval"), |
| "num_fewshot": num_fewshot, "limit": limit, "tasks": task_list, |
| "scores": scores, "results": results.get("results", {}), |
| "groups": groups, "versions": results.get("versions", {}), |
| "n-shot": results.get("n-shot", {}), |
| } |
| (out_dir / f"{run_name}_{tag}.json").write_text( |
| json.dumps(payload, indent=2, default=_json_default) |
| ) |
| print(f"[bench] {run_name} ({tag}): " + |
| ", ".join(f"{k}={v:.4f}" for k, v in scores.items() if v is not None)) |
|
|
| if log_wandb: |
| try: |
| import wandb |
| wb = wandb.init(project="XScript-Pretraining", id=run_name, resume="allow") |
| wb.log({f"bench/{k}": v for k, v in scores.items() if v is not None}) |
| wb.finish() |
| except Exception as exc: |
| print(f"[bench] wandb logging skipped ({exc})") |
|
|
| return scores |
|
|
|
|
| class _null: |
| def __enter__(self): return self |
| def __exit__(self, *args): return False |
|
|
|
|
| def _json_default(value): |
| """Serialize NumPy scalars and other scalar-like harness values.""" |
| if hasattr(value, "item"): |
| return value.item() |
| raise TypeError(f"not JSON serializable: {type(value).__name__}") |
|
|