Download predict.py from SinhalaMMLU-SharedTask/team-sinhalanuwana: direct link, hf CLI and curl.
- Browser
- Download file 14.1 kB
-
https://huggingface.co/SinhalaMMLU-SharedTask/team-sinhalanuwana/resolve/main/predict.py
- Command line
-
hf download hf://SinhalaMMLU-SharedTask/team-sinhalanuwana/predict.py
-
curl -L -o predict.py https://huggingface.co/SinhalaMMLU-SharedTask/team-sinhalanuwana/resolve/main/predict.py
14.1 kB
| import argparse | |
| import json | |
| import zipfile | |
| from pathlib import Path | |
| import torch | |
| from tqdm import tqdm | |
| from transformers import AutoTokenizer, Gemma4ForCausalLM | |
| MAX_PARAMS = 8_000_000_000 | |
| MAX_BATCH_TOKENS = 36864 | |
| LABELS = ["1", "2", "3", "4"] | |
| ANSWER_CUE = "පිළිතුර:" | |
| THOUGHT_OPEN = "<|channel>thought\n" | |
| THOUGHT_CLOSE = "\n<channel|>" | |
| STOP_TOKENS = ["<channel|>", "<eos>", "<turn|>"] | |
| def normalise(question): | |
| metadata = question.get("metadata", {}) | |
| question = { | |
| **question, | |
| "subject_original": metadata.get("subject_original", question.get("subject_original")), | |
| "level": metadata.get("difficulty", question.get("level")), | |
| } | |
| answer = question.get("answer") | |
| if answer is not None and not (isinstance(answer, int) and 1 <= answer <= len(question["choices"])): | |
| texts = [choice.strip() for choice in question["choices"]] | |
| question["answer"] = texts.index(str(answer).strip()) + 1 | |
| return question | |
| def load_questions(data_dir): | |
| questions = [] | |
| for path in sorted(Path(data_dir).rglob("*.json")): | |
| if any(part.startswith(".") for part in path.relative_to(data_dir).parts): | |
| continue | |
| questions.extend(normalise(q) for q in json.loads(path.read_text(encoding="utf-8"))) | |
| return questions | |
| def user_prompt(question): | |
| lines = [ | |
| "පහත බහුවරණ ප්රශ්නයට නිවැරදි පිළිතුරේ අංකය පමණක් ලියන්න.", | |
| "", | |
| f"විෂයය: {question['subject_original']}", | |
| f"ප්රශ්නය: {question['question'].strip()}", | |
| *(f"{i}. {choice}" for i, choice in enumerate(question["choices"], 1)), | |
| ANSWER_CUE, | |
| ] | |
| return "\n".join(lines) | |
| def render(tokenizer, prompt, think): | |
| if tokenizer.chat_template is None: | |
| return tokenizer.bos_token + prompt + " " | |
| messages = [{"role": "user", "content": prompt}] | |
| if think: | |
| text = tokenizer.apply_chat_template( | |
| messages, tokenize=False, add_generation_prompt=True, enable_thinking=True | |
| ) | |
| return text + THOUGHT_OPEN | |
| return tokenizer.apply_chat_template(messages, tokenize=False, add_generation_prompt=True) | |
| def thinking_text(tokenizer, prompt, thought): | |
| return render(tokenizer, prompt, True) + thought + THOUGHT_CLOSE + ANSWER_CUE | |
| DTYPES = {"float16": torch.float16, "bfloat16": torch.bfloat16, "float32": torch.float32} | |
| def placement(dtype_name="auto"): | |
| if not torch.cuda.is_available(): | |
| return torch.float32, "cpu" | |
| if dtype_name != "auto": | |
| return DTYPES[dtype_name], "auto" | |
| return (torch.float16 if torch.cuda.get_device_capability()[0] < 8 else torch.bfloat16), "auto" | |
| def load_model(name, adapter=None, revision=None, dtype_name="auto"): | |
| tokenizer = AutoTokenizer.from_pretrained(name, revision=revision, padding_side="left") | |
| dtype, device_map = placement(dtype_name) | |
| model, info = Gemma4ForCausalLM.from_pretrained( | |
| name, | |
| revision=revision, | |
| dtype=dtype, | |
| device_map=device_map, | |
| key_mapping={r"^model\.language_model\.": "model."}, | |
| output_loading_info=True, | |
| ) | |
| assert not info["missing_keys"], f"weights missing from checkpoint: {sorted(info['missing_keys'])[:5]}" | |
| n_adapter = 0 | |
| if adapter is not None: | |
| from peft import PeftModel | |
| model = PeftModel.from_pretrained(model, adapter) | |
| n_adapter = sum(p.numel() for name, p in model.named_parameters() if "lora_" in name) | |
| assert n_adapter > 0, f"adapter {adapter} has no LoRA parameters" | |
| model = model.merge_and_unload() | |
| model.eval() | |
| n_params = sum(p.numel() for p in model.parameters()) + n_adapter | |
| print( | |
| f"Loaded {name} (text-only) on {model.device} in {model.dtype}: {n_params:,} parameters" | |
| f" ({n_adapter:,} adapter, merged)" | |
| ) | |
| assert n_params <= MAX_PARAMS, f"{n_params:,} parameters exceeds the 8B limit" | |
| return tokenizer, model | |
| def label_ids(tokenizer): | |
| ids = [tokenizer.encode(label, add_special_tokens=False) for label in LABELS] | |
| assert all(len(i) == 1 for i in ids), f"answer labels are not single tokens: {ids}" | |
| return [i[0] for i in ids] | |
| def batches(lengths, budget, batch_size): | |
| batch = [] | |
| for i in sorted(range(len(lengths)), key=lambda i: -lengths[i]): | |
| if batch and ( | |
| len(batch) == batch_size or (len(batch) + 1) * (lengths[batch[0]] + budget) > MAX_BATCH_TOKENS | |
| ): | |
| yield batch | |
| batch = [] | |
| batch.append(i) | |
| if batch: | |
| yield batch | |
| def encode(tokenizer, texts, device): | |
| return tokenizer(texts, return_tensors="pt", padding=True, add_special_tokens=False).to(device) | |
| def score(model, tokenizer, texts, n_choices, batch_size): | |
| labels = label_ids(tokenizer) | |
| lengths = [len(tokenizer.encode(t, add_special_tokens=False)) for t in texts] | |
| probs = [None] * len(texts) | |
| for batch in tqdm(list(batches(lengths, 0, batch_size)), desc="score"): | |
| inputs = encode(tokenizer, [texts[i] for i in batch], model.device) | |
| position_ids = (inputs["attention_mask"].cumsum(-1) - 1).clamp(min=0) | |
| with torch.inference_mode(): | |
| logits = model(**inputs, position_ids=position_ids, logits_to_keep=1).logits[:, -1, labels].float() | |
| if not torch.isfinite(logits).all(): | |
| raise RuntimeError("non-finite answer logits") | |
| counts = torch.tensor([n_choices[i] for i in batch], device=logits.device) | |
| valid = torch.arange(len(labels), device=logits.device)[None, :] < counts[:, None] | |
| for i, row in zip(batch, logits.masked_fill(~valid, float("-inf")).softmax(-1).tolist()): | |
| probs[i] = row[: n_choices[i]] | |
| return probs | |
| def think(model, tokenizer, contexts, budget, batch_size): | |
| stops = tokenizer.convert_tokens_to_ids(STOP_TOKENS) | |
| lengths = [len(tokenizer.encode(t, add_special_tokens=False)) for t in contexts] | |
| thoughts = [None] * len(contexts) | |
| for batch in tqdm(list(batches(lengths, budget, batch_size)), desc="think"): | |
| inputs = encode(tokenizer, [contexts[i] for i in batch], model.device) | |
| with torch.inference_mode(): | |
| out = model.generate( | |
| **inputs, | |
| max_new_tokens=budget, | |
| do_sample=False, | |
| eos_token_id=stops, | |
| pad_token_id=tokenizer.pad_token_id, | |
| ) | |
| for i, row in zip(batch, out[:, inputs["input_ids"].shape[1]:].tolist()): | |
| end = next((k for k, token in enumerate(row) if token in stops), len(row)) | |
| thoughts[i] = { | |
| "thought": tokenizer.decode(row[:end], skip_special_tokens=True).strip(), | |
| "finished": end < len(row) and row[end] == stops[0], | |
| "thought_tokens": end, | |
| } | |
| return thoughts | |
| def argmax(values): | |
| return max(range(len(values)), key=values.__getitem__) | |
| def answer(model, tokenizer, prompts, n_choices, budget, batch_size, score_batch_size=None): | |
| if budget == 0: | |
| texts = [render(tokenizer, p, False) for p in prompts] | |
| thoughts = [{} for _ in prompts] | |
| else: | |
| contexts = [render(tokenizer, p, True) for p in prompts] | |
| thoughts = think(model, tokenizer, contexts, budget, batch_size) | |
| texts = [thinking_text(tokenizer, p, t["thought"]) for p, t in zip(prompts, thoughts)] | |
| probs = score(model, tokenizer, texts, n_choices, score_batch_size or batch_size) | |
| return [{**t, "probs": p, "pred": argmax(p) + 1} for t, p in zip(thoughts, probs)] | |
| def order(n, rotation, reverse): | |
| positions = list(range(n))[::-1] if reverse else list(range(n)) | |
| k = rotation % n | |
| return positions[k:] + positions[:k] | |
| def rotate(question, rotation, reverse=False): | |
| return {**question, "choices": [question["choices"][i] for i in order(len(question["choices"]), rotation, reverse)]} | |
| def unrotate(probs, rotation, reverse=False): | |
| original = [0.0] * len(probs) | |
| for i, p in zip(order(len(probs), rotation, reverse), probs): | |
| original[i] = p | |
| return original | |
| def vote(model, tokenizer, questions, rotations, budget, batch_size, score_batch_size=None, reverse=False): | |
| prompts = [user_prompt(rotate(q, r, reverse)) for q in questions for r in rotations] | |
| n_choices = [len(q["choices"]) for q in questions for _ in rotations] | |
| runs = answer(model, tokenizer, prompts, n_choices, budget, batch_size, score_batch_size) | |
| results = [] | |
| for start in range(0, len(runs), len(rotations)): | |
| group = [ | |
| {**run, "rotation": r, "reverse": reverse, "probs": unrotate(run["probs"], r, reverse)} | |
| for run, r in zip(runs[start : start + len(rotations)], rotations) | |
| ] | |
| group = [{**run, "pred": argmax(run["probs"]) + 1} for run in group] | |
| probs = [sum(p) / len(group) for p in zip(*(run["probs"] for run in group))] | |
| results.append({"pred": argmax(probs) + 1, "probs": probs, "runs": group}) | |
| return results | |
| def metrics(questions, results): | |
| levels = {} | |
| for question, result in zip(questions, results): | |
| counts = levels.setdefault(question["level"], [0, 0]) | |
| counts[0] += result["pred"] == question["answer"] | |
| counts[1] += 1 | |
| correct = sum(c for c, _ in levels.values()) | |
| return { | |
| "accuracy": correct / len(questions), | |
| "correct": correct, | |
| "total": len(questions), | |
| "levels": {k: {"accuracy": c / t, "correct": c, "total": t} for k, (c, t) in sorted(levels.items())}, | |
| } | |
| def write_jsonl(path, rows): | |
| path.write_text("".join(json.dumps(r, ensure_ascii=False) + "\n" for r in rows), encoding="utf-8") | |
| def main(): | |
| parser = argparse.ArgumentParser() | |
| parser.add_argument("--model", default="google/gemma-4-E4B-it") | |
| parser.add_argument("--revision", help="model commit to load; the latest one when not given") | |
| parser.add_argument("--dtype", choices=["auto", *DTYPES], default="auto") | |
| parser.add_argument("--data_dir", default="data/test") | |
| parser.add_argument("--out_dir", default="outputs") | |
| parser.add_argument("--think", type=int, default=512) | |
| parser.add_argument("--adapter") | |
| parser.add_argument("--limit", type=int) | |
| parser.add_argument("--level", help="answer only the questions of this level, for example hard") | |
| parser.add_argument("--batch_size", type=int, default=32) | |
| parser.add_argument( | |
| "--score_batch_size", type=int, | |
| help="batch size for scoring the answer after the thought; --batch_size when not given, lower it for long thoughts", | |
| ) | |
| parser.add_argument( | |
| "--rotations", type=int, nargs="+", default=[0], | |
| help="answer once per rotation of the choices and average the answer probabilities; 0 2 votes over two orders", | |
| ) | |
| parser.add_argument( | |
| "--reverse", action="store_true", | |
| help="reverse the choices before rotating them, which gives 4 more orders of 4 choices", | |
| ) | |
| args = parser.parse_args() | |
| questions = load_questions(args.data_dir) | |
| if args.level: | |
| questions = [q for q in questions if q["level"] == args.level] | |
| questions = questions[: args.limit] | |
| print(f"Loaded {len(questions)} questions from {args.data_dir}") | |
| out_dir = Path(args.out_dir) | |
| if not questions: | |
| out_dir.mkdir(parents=True, exist_ok=True) | |
| write_jsonl(out_dir / "predictions.jsonl", []) | |
| write_jsonl(out_dir / "thoughts.jsonl", []) | |
| return | |
| tokenizer, model = load_model(args.model, args.adapter, args.revision, args.dtype) | |
| if args.think and tokenizer.chat_template is None: | |
| parser.error(f"{args.model} has no chat template, so it has no thinking mode; pass --think 0") | |
| results = vote( | |
| model, tokenizer, questions, args.rotations, args.think, args.batch_size, args.score_batch_size, args.reverse | |
| ) | |
| predictions = [ | |
| { | |
| "q_no": question["q_no"], | |
| "subject": question["subject"], | |
| "category": question["category"], | |
| "level": question["level"], | |
| "answer": LABELS[result["pred"] - 1], | |
| } | |
| for question, result in zip(questions, results) | |
| ] | |
| out_dir.mkdir(parents=True, exist_ok=True) | |
| jsonl_path = out_dir / "predictions.jsonl" | |
| write_jsonl(jsonl_path, predictions) | |
| with zipfile.ZipFile(out_dir / "predictions.zip", "w", zipfile.ZIP_DEFLATED) as zf: | |
| zf.write(jsonl_path, "predictions.jsonl") | |
| print(f"Wrote {len(predictions)} predictions to {out_dir / 'predictions.zip'}") | |
| runs = [(p, run) for p, r in zip(predictions, results) for run in r["runs"]] | |
| thoughts = [ | |
| {"q_no": p["q_no"], "subject": p["subject"], "level": p["level"]} | |
| | {k: run[k] for k in ("rotation", "reverse", "pred", "probs", "finished", "thought_tokens", "thought") if k in run} | |
| for p, run in runs | |
| ] | |
| write_jsonl(out_dir / "thoughts.jsonl", thoughts) | |
| if args.think: | |
| finished = sum(run["finished"] for _, run in runs) | |
| print(f"Thoughts finished within {args.think} tokens: {finished}/{len(runs)}") | |
| if questions and all(q.get("answer") is not None for q in questions): | |
| report = metrics(questions, results) | {"think": args.think, "adapter": args.adapter} | |
| if len(args.rotations) > 1: | |
| report["rotations"] = { | |
| str(r): metrics(questions, [res["runs"][i] for res in results])["accuracy"] | |
| for i, r in enumerate(args.rotations) | |
| } | |
| if args.think: | |
| report["finished"] = finished | |
| (out_dir / "metrics.json").write_text(json.dumps(report, ensure_ascii=False, indent=1) + "\n", encoding="utf-8") | |
| levels = ", ".join(f"{k} {v['accuracy']:.4f}" for k, v in report["levels"].items()) | |
| print(f"Accuracy {report['accuracy']:.4f} ({report['correct']}/{report['total']}); {levels}") | |
| if "rotations" in report: | |
| print("Accuracy of each rotation alone:", report["rotations"]) | |
| if __name__ == "__main__": | |
| main() | |