File size: 20,083 Bytes
e55c8a4 | 1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 55 56 57 58 59 60 61 62 63 64 65 66 67 68 69 70 71 72 73 74 75 76 77 78 79 80 81 82 83 84 85 86 87 88 89 90 91 92 93 94 95 96 97 98 99 100 101 102 103 104 105 106 107 108 109 110 111 112 113 114 115 116 117 118 119 120 121 122 123 124 125 126 127 128 129 130 131 132 133 134 135 136 137 138 139 140 141 142 143 144 145 146 147 148 149 150 151 152 153 154 155 156 157 158 159 160 161 162 163 164 165 166 167 168 169 170 171 172 173 174 175 176 177 178 179 180 181 182 183 184 185 186 187 188 189 190 191 192 193 194 195 196 197 198 199 200 201 202 203 204 205 206 207 208 209 210 211 212 213 214 215 216 217 218 219 220 221 222 223 224 225 226 227 228 229 230 231 232 233 234 235 236 237 238 239 240 241 242 243 244 245 246 247 248 249 250 251 252 253 254 255 256 257 258 259 260 261 262 263 264 265 266 267 268 269 270 271 272 273 274 275 276 277 278 279 280 281 282 283 284 285 286 287 288 289 290 291 292 293 294 295 296 297 298 299 300 301 302 303 304 305 306 307 308 309 310 311 312 313 314 315 316 317 318 319 320 321 322 323 324 325 326 327 328 329 330 331 332 333 334 335 336 337 338 339 340 341 342 343 344 345 346 347 348 349 350 351 352 353 354 355 356 357 358 359 360 361 362 363 364 365 366 367 368 369 370 371 372 373 374 375 376 377 378 379 380 381 382 383 384 385 386 387 388 389 390 391 392 393 394 395 396 397 | #!/usr/bin/env python
"""
SinhalaMMLU evaluation with the ORGANISERS' own prompt (copied from their util_prompt.py).
Two subcommands:
blocklist - convert the official TEST json files into the mcq_dataset.py schema, so they can be
used for DECONTAMINATION:
python eval_sinhalammlu.py blocklist --data /workspace/data/TEST/easy \
--out test_easy_block.jsonl
python mcq_dataset.py dedupe data/cleaned.jsonl data/deduped.jsonl \
--blocklist test_easy_block.jsonl --conflicts data/conflicts.jsonl
Any non-zero "overlap with blocklist" means your training data contained test
questions and they have now been removed. Re-run split/build-sft after this.
eval - score a model on those files. Reports BOTH:
loglik = pick the option whose answer-token has the highest log-probability
(this is how the model was trained and how we measured validation)
generate = greedy-decode after "පිළිතුර:" and read the first digit
(this is what the organisers' evaluate.py does)
If generate is much worse than loglik, the model knows the answer but is not
emitting a clean digit, which would cost real marks.
python eval_sinhalammlu.py eval --data /workspace/data/TEST/easy \
--model /workspace/outputs/mcq_sft_base/best_adapter \
--out_csv results_easy.csv
NOTE ON THE RULES: the shared task forbids "training, tuning or prompt design on test questions in
any form". Use this as a one-off read-only sanity check. Do NOT use it to choose a checkpoint, a
prompt or hyperparameters: use your own held-out val.jsonl for that.
"""
import argparse
import csv
import glob
import json
import os
import re
import sys
import time
from collections import defaultdict
os.environ.setdefault("HF_HUB_ENABLE_HF_TRANSFER", "1")
# --- the organisers' prompt text, verbatim from util_prompt.py -------------------------------
SINHALA_INSTRUCTIONS = {
"easy": "පහත ප්රශ්නයට 1, 2, 3, 4 යන පිළිතුරුවලින් නිවැරදි හෝ ඉතාමත් ගැළපෙන පිළිතුර තෝරන්න.\n\n",
"medium": "පහත ප්රශ්නයට 1, 2, 3, 4 යන පිළිතුරුවලින් නිවැරදි හෝ ඉතාමත් ගැළපෙන පිළිතුර තෝරාගන්න.\n\n",
"hard": "පහත ප්රශ්නයට 1, 2, 3, 4, 5 යන පිළිතුරුවලින් නිවැරදි හෝ ඉතාමත් ගැළපෙන පිළිතුර තෝරාගන්න.\n\n",
}
def fit_numbers(instruction, n):
"""The organisers hard-code "1, 2, 3, 4, 5" in the HARD instruction (the original SinhalaMMLU
hard split had five options). SinhalaMMLU 2.0 dropped one, so hard questions now have four;
the number run is rewritten to match the actual option count."""
return re.sub(r"1,\s*2,\s*3,\s*4(?:,\s*5)?",
", ".join(str(i + 1) for i in range(n)), instruction, count=1)
def official_prompt(level, subject_original, question, choices, intro_type=True):
"""Zero-shot Sinhala prompt, identical to generate_instruction_prompt(..., language='sinhala')."""
intro = f"මෙය {subject_original} විෂයයට අදාළ බහුවරණ ප්රශ්නයකි.\n" if intro_type else ""
body = "\n".join(f"{i + 1}. {str(c).strip()}" for i, c in enumerate(choices))
instr = fit_numbers(SINHALA_INSTRUCTIONS[level.lower()], len(choices))
return intro + instr + f"ප්රශ්නය: {question}\n{body}\nපිළිතුර:"
def load_items(data_dir, level_override=""):
"""Read every *.json in data_dir (each a list of question dicts)."""
files = sorted(glob.glob(os.path.join(data_dir, "**", "*.json"), recursive=True))
if not files:
sys.exit(f"no .json files found under {data_dir}")
rows = []
for path in files:
try:
data = json.load(open(path, encoding="utf-8"))
except Exception as e: # noqa: BLE001
print(f"[warn] skipping {os.path.basename(path)}: {type(e).__name__}: {e}")
continue
if not isinstance(data, list):
print(f"[warn] skipping {os.path.basename(path)}: not a JSON list")
continue
for it in data:
md = it.get("metadata") or {}
level = level_override or md.get("difficulty") or "easy"
if level.lower() not in SINHALA_INSTRUCTIONS:
level = "easy"
ch = [c for c in (it.get("choices") or []) if str(c).strip()]
ans = it.get("answer")
if len(ch) < 2 or not isinstance(ans, int) or not (1 <= ans <= len(ch)):
continue
rows.append({"file": os.path.basename(path), "q_no": it.get("q_no"),
"subject": it.get("subject"), "category": it.get("category"),
"question": str(it.get("question") or "").strip(), "choices": ch,
"answer": ans, "level": level.lower(),
"subject_original": md.get("subject_original") or it.get("subject"),
"source": md.get("source")})
print(f"[data] {len(rows)} questions from {len(files)} file(s) in {data_dir}")
return rows
# ------------------------------------------------------------------ blocklist (no GPU needed)
def cmd_blocklist(a):
rows = load_items(a.data, a.level)
with open(a.out, "w", encoding="utf-8") as f:
for i, r in enumerate(rows):
f.write(json.dumps({"id": f"mmlu_{r['level']}_{r['file']}_{r['q_no']}",
"subject": r["subject"], "question": r["question"],
"choices": r["choices"], "answer": r["answer"],
"metadata": {"source": r["source"], "split": "official_test"}},
ensure_ascii=False) + "\n")
print(f"[out] {len(rows)} questions -> {a.out}")
print("Now decontaminate your TRAINING data (this is the compliant use of these files):")
print(f" python mcq_dataset.py dedupe data/cleaned.jsonl data/deduped.jsonl --blocklist {a.out}")
print("Any non-zero 'overlap with blocklist' = test questions were in your training set.")
# ------------------------------------------------------------------ eval (needs the GPU)
def common_prefix_len(x, y):
n = 0
for p, q in zip(x, y):
if p != q:
break
n += 1
return n
def score_loglik(model, tok, rows, prompts, max_len, batch_seqs):
"""Total log-prob of each candidate answer token; returns a predicted 1-based option per row."""
import torch
dev = next(model.parameters()).device
pad = tok.pad_token_id if tok.pad_token_id is not None else 0
items, skipped = [], set()
for ri, (r, p) in enumerate(zip(rows, prompts)):
p_ids = tok(p)["input_ids"]
seqs = []
for ci in range(len(r["choices"])):
f = tok(p + f" {ci + 1}")["input_ids"]
seqs.append((f, max(1, common_prefix_len(p_ids, f))))
if max(len(s) for s, _ in seqs) > max_len:
skipped.add(ri)
continue
for ci, (s, k) in enumerate(seqs):
items.append((ri, ci, s, k))
scores = {}
with torch.no_grad():
for i in range(0, len(items), batch_seqs):
chunk = items[i:i + batch_seqs]
L = max(len(x[2]) for x in chunk)
ids = torch.full((len(chunk), L), pad, dtype=torch.long)
mask = torch.zeros((len(chunk), L), dtype=torch.long)
for b, (_, _, s, _) in enumerate(chunk):
ids[b, :len(s)] = torch.tensor(s)
mask[b, :len(s)] = 1
logits = model(input_ids=ids.to(dev), attention_mask=mask.to(dev)).logits
for b, (ri, ci, s, k) in enumerate(chunk):
lp = logits[b, k - 1:len(s) - 1].float().log_softmax(-1)
tgt = ids[b, k:len(s)].to(dev)
scores[(ri, ci)] = lp.gather(-1, tgt[:, None]).sum().item()
del logits
preds = []
for ri, r in enumerate(rows):
if ri in skipped:
preds.append(None)
continue
cs = [scores.get((ri, ci)) for ci in range(len(r["choices"]))]
preds.append(None if any(c is None for c in cs)
else 1 + max(range(len(cs)), key=lambda i: cs[i]))
return preds, len(skipped)
def score_generate(model, tok, prompts, max_new_tokens, batch_rows):
"""Greedy-decode and read the first digit, the way the organisers' evaluate.py does."""
import torch
dev = next(model.parameters()).device
pad = tok.pad_token_id if tok.pad_token_id is not None else 0
preds, raw = [], []
with torch.no_grad():
for i in range(0, len(prompts), batch_rows):
batch = prompts[i:i + batch_rows]
enc = [tok(p)["input_ids"] for p in batch]
L = max(len(e) for e in enc)
ids = torch.full((len(enc), L), pad, dtype=torch.long)
mask = torch.zeros((len(enc), L), dtype=torch.long)
for b, e in enumerate(enc): # LEFT pad so generation continues correctly
ids[b, L - len(e):] = torch.tensor(e)
mask[b, L - len(e):] = 1
out = model.generate(input_ids=ids.to(dev), attention_mask=mask.to(dev),
max_new_tokens=max_new_tokens, do_sample=False,
pad_token_id=pad)
for b in range(len(enc)):
text = tok.decode(out[b, L:], skip_special_tokens=True)
raw.append(text)
m = re.search(r"[1-5]", text)
preds.append(int(m.group()) if m else None)
return preds, raw
def score_official(model, tok, prompts, n_choices, system="You must output the Answer",
batch_rows=8, no_chat=False):
"""Exact replica of the organisers' predict_classification_causal_by_number (utils.py):
wrap in the chat template with a system message, ONE forward pass, take the logits at the final
position, and argmax over the token ids of the BARE digits '1'..'N' (no leading space).
This is what actually decides their score - there is no generation involved."""
import torch
dev = next(model.parameters()).device
pad = tok.pad_token_id if tok.pad_token_id is not None else 0
mode = {"how": "raw prompt (no chat template requested)"} if no_chat else {}
def wrap(instruction):
if no_chat:
return instruction
msgs = [{"role": "system", "content": system}, {"role": "user", "content": instruction}]
try:
out = tok.apply_chat_template(msgs, tokenize=False, add_generation_prompt=True)
mode.setdefault("how", "chat template, system + user roles")
return out
except Exception:
pass
try:
# many Gemma templates reject a system role: fold it into the first user turn
msgs = [{"role": "user", "content": system + "\n\n" + instruction}]
out = tok.apply_chat_template(msgs, tokenize=False, add_generation_prompt=True)
mode.setdefault("how", "chat template, system folded into the user turn")
return out
except Exception:
# NO chat template on this tokenizer at all (base Gemma 4 has none).
# The organisers' evaluate.py calls apply_chat_template unconditionally, so it would
# CRASH here too -> scoring the raw prompt is the closest thing we can measure.
mode.setdefault("how", "NO CHAT TEMPLATE on this tokenizer: scored the raw prompt. "
"The organisers' evaluate.py would fail on this model as-is.")
return instruction
texts = [wrap(p) for p in prompts]
# digit token ids, exactly as they compute them
dig = {k: tok.encode(str(k), add_special_tokens=False)[-1] for k in range(1, 6)}
preds = []
with torch.no_grad():
for i in range(0, len(texts), batch_rows):
batch = texts[i:i + batch_rows]
enc = [tok(t)["input_ids"] for t in batch] # add_special_tokens default, as they do
L = max(len(e) for e in enc)
ids = torch.full((len(enc), L), pad, dtype=torch.long)
mask = torch.zeros((len(enc), L), dtype=torch.long)
for b, e in enumerate(enc): # LEFT pad: last column is the last real token
ids[b, L - len(e):] = torch.tensor(e)
mask[b, L - len(e):] = 1
logits = model(input_ids=ids.to(dev), attention_mask=mask.to(dev)).logits[:, -1, :].float()
for b in range(len(enc)):
nc = n_choices[i + b]
cand = [dig[k] for k in range(1, nc + 1)]
row = logits[b, torch.tensor(cand, device=logits.device)]
preds.append(1 + int(row.argmax().item()))
del logits
print(f"[official] prompt construction: {mode.get('how', 'unknown')}")
return preds, texts[0]
def report(rows, preds, label):
n = correct = unparsed = 0
by_cat, by_subj = defaultdict(lambda: [0, 0]), defaultdict(lambda: [0, 0])
for r, p in zip(rows, preds):
if p is None:
unparsed += 1
continue
n += 1
ok = int(p == r["answer"])
correct += ok
by_cat[r["category"] or "?"][0] += ok
by_cat[r["category"] or "?"][1] += 1
by_subj[r["subject"] or "?"][0] += ok
by_subj[r["subject"] or "?"][1] += 1
print(f"\n=== {label} ===")
print(f"accuracy {correct / max(1, n):.4f} ({correct}/{n}); no prediction: {unparsed}")
print(" by category:")
for k, (c, t) in sorted(by_cat.items()):
print(f" {k:<18} {c / t:.4f} ({c}/{t})")
print(" by subject:")
for k, (c, t) in sorted(by_subj.items()):
if t >= 20:
print(f" {k:<18} {c / t:.4f} ({c}/{t})")
return {"accuracy": correct / max(1, n), "n": n, "no_prediction": unparsed,
"by_category": {k: v[0] / v[1] for k, v in by_cat.items()},
"by_subject": {k: v[0] / v[1] for k, v in by_subj.items()}}
def cmd_eval(a):
rows = load_items(a.data, a.level)
if a.limit:
rows = rows[:a.limit]
prompts = [official_prompt(r["level"], r["subject_original"], r["question"], r["choices"],
intro_type=not a.no_intro) for r in rows]
print("\n--- prompt actually sent to the model (first question) ---")
print(prompts[0])
print("--- end of prompt ---\n")
if a.dry_run:
return
from unsloth import FastModel
print(f"[model] loading {a.model}")
model, processor = FastModel.from_pretrained(model_name=a.model, dtype=None,
max_seq_length=a.max_seq_length,
load_in_4bit=False, full_finetuning=False)
tok = getattr(processor, "tokenizer", processor)
if tok.pad_token is None:
tok.pad_token = tok.eos_token
model.eval()
out = {"model": a.model, "data": a.data, "n_questions": len(rows)}
t0 = time.time()
ll_preds, skipped = score_loglik(model, tok, rows, prompts, a.max_seq_length, a.eval_batch)
print(f"[loglik] {time.time() - t0:.0f}s; skipped (too long) {skipped}")
out["loglik"] = report(rows, ll_preds, "loglik (how the model was trained/validated)")
if not a.skip_official:
t0 = time.time()
off_preds, shown = score_official(model, tok, prompts, [len(r["choices"]) for r in rows],
system=a.system, batch_rows=a.gen_batch, no_chat=a.no_chat)
print(f"[official] {time.time() - t0:.0f}s")
print("--- prompt as the organisers' harness builds it (chat template applied) ---")
print(shown[:900] + ("..." if len(shown) > 900 else ""))
print("--- end ---")
out["official"] = report(rows, off_preds, "OFFICIAL (bare-digit logits at last position)")
gen_preds, gen_raw = [None] * len(rows), [""] * len(rows)
if not a.skip_generate:
t0 = time.time()
gen_preds, gen_raw = score_generate(model, tok, prompts, a.max_new_tokens, a.gen_batch)
print(f"[generate] {time.time() - t0:.0f}s")
out["generate"] = report(rows, gen_preds, "generate (how the organisers score)")
bad = [t for t, p in zip(gen_raw, gen_preds) if p is None][:5]
if bad:
print(f" examples with no digit in the output: {bad}")
if a.out_csv:
# columns match the organisers' get_accuracy_all.py so their aggregator can read this
with open(a.out_csv, "w", encoding="utf-8", newline="") as f:
w = csv.writer(f)
w.writerow(["q_no", "subject", "category", "question", "choices", "answer",
"generated_text", "model_predicted_answer", "loglik_pred", "raw_output"])
for r, lp, gp, g in zip(rows, ll_preds, gen_preds, gen_raw):
w.writerow([r["q_no"], r["subject"], r["category"], r["question"],
json.dumps(r["choices"], ensure_ascii=False), r["answer"],
gp if gp is not None else "", gp if gp is not None else "", lp, g])
print(f"[out] per-question results -> {a.out_csv}")
if a.out_json:
json.dump(out, open(a.out_json, "w", encoding="utf-8"), ensure_ascii=False, indent=1)
print(f"[out] summary -> {a.out_json}")
print("\nREMINDER: these are official test questions. Do not use this score to choose a "
"checkpoint, prompt or hyperparameter; the task rules forbid tuning on test questions.")
def main():
ap = argparse.ArgumentParser()
sub = ap.add_subparsers(dest="cmd", required=True)
p = sub.add_parser("blocklist", help="convert official TEST json -> jsonl for decontamination")
p.add_argument("--data", required=True)
p.add_argument("--out", required=True)
p.add_argument("--level", default="")
p.set_defaults(f=cmd_blocklist)
p = sub.add_parser("eval", help="score a model with the organisers' prompt")
p.add_argument("--data", required=True)
p.add_argument("--model", required=True, help="adapter dir, merged dir or HF repo")
p.add_argument("--level", default="", help="force easy/medium/hard (default: read from metadata)")
p.add_argument("--no_intro", action="store_true", help="drop the subject intro line (intro_type=False)")
p.add_argument("--max_seq_length", type=int, default=1024)
p.add_argument("--eval_batch", type=int, default=16)
p.add_argument("--gen_batch", type=int, default=8)
p.add_argument("--max_new_tokens", type=int, default=5)
p.add_argument("--skip_generate", action="store_true")
p.add_argument("--skip_official", action="store_true")
p.add_argument("--no_chat", action="store_true", help="official scoring WITHOUT the chat template")
p.add_argument("--system", default="You must output the Answer",
help="system message the organisers' evaluate.py uses")
p.add_argument("--limit", type=int, default=0)
p.add_argument("--dry_run", action="store_true", help="print the prompt and exit (no model load)")
p.add_argument("--out_csv", default="")
p.add_argument("--out_json", default="")
p.set_defaults(f=cmd_eval)
a = ap.parse_args()
for stream in (sys.stdout, sys.stderr):
try:
stream.reconfigure(encoding="utf-8")
except Exception: # noqa: BLE001
pass
a.f(a)
if __name__ == "__main__":
main()
|