File size: 8,558 Bytes
ec24fb0 | 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 | """Synthetic Sinhala MCQs from passages with an open teacher model (data generation only, never used at inference).
For each passage the teacher writes k exam-style Sinhala MCQs (JSON). Every question is then
1. validated: 4 distinct non-empty options, key 1-4, mostly Sinhala, does not refer to "the passage";
2. round-trip checked: the teacher re-answers it WITH the passage (logit scoring) and must agree with its own key;
3. soft-labelled: the teacher's closed-book option probabilities (same prompt as the student sees) are stored.
Kept questions go to <out>.jsonl in the processed MCQ schema (+ teacher_probs, passage id, subject); rejected ones to
<out>.rejected.jsonl with the reason; 50 random kept questions to <out>.review.md for a native-speaker check.
python scripts/generate_mcq.py --passages data/synth/passages_pilot.jsonl --out data/synth/pilot \
--teacher google/gemma-4-31B-it --per-passage 3
"""
from __future__ import annotations
import argparse
import json
import random
import re
import sys
import time
from collections import Counter
from pathlib import Path
sys.path.insert(0, str(Path(__file__).resolve().parents[1]))
from eval.common import ROOT, cap_gpu_memory, load_env, read_jsonl, set_seed, write_jsonl # noqa: E402
PROMPT = """You are writing exam questions for Sri Lankan school students.
Subject: {subject}. Level: {level}.
Read the Sinhala passage below. Write {k} multiple-choice questions IN SINHALA that test important facts or concepts
stated in the passage, in the style of Sri Lankan government exam papers (term tests, O/L, A/L).
Rules:
- Each question must make sense on its own, without the passage. Never write "according to the passage" or refer to
"the passage", "the text" or "the article".
- Exactly 4 options and exactly one correct answer, which must be supported by the passage.
- Wrong options must be plausible, on the same topic, and similar in length to the correct one.
- Vary the format: direct questions, sentence completion ("...... වේ."), and when possible one question asking which
statement is NOT correct.
- Use standard Sinhala school-curriculum terminology. No English words unless the term is normally written in English.
Return only a JSON list, nothing else:
[{{"question": "...", "options": ["...", "...", "...", "..."], "answer": <number 1-4>}}]
Passage:
{passage}"""
REFERS_TO_PASSAGE = re.compile(r"ඡේද|ඡේදය|පාඨය|ලිපිය|ඉහත සඳහන්|ඉහත කොටස|passage|text|article", re.I)
SINHALA = re.compile(r"[-]")
LETTER = re.compile(r"[^\W\d_]")
def parse(text: str) -> list[dict]:
m = re.search(r"\[.*\]", text, re.S)
if not m:
return []
try:
data = json.loads(m.group(0))
except json.JSONDecodeError:
return []
return [d for d in data if isinstance(d, dict)]
def validate(q: dict) -> str | None:
opts = q.get("options")
if not isinstance(q.get("question"), str) or not isinstance(opts, list) or len(opts) != 4:
return "format"
opts = [str(o).strip() for o in opts]
if any(not o for o in opts) or len(set(opts)) < 4:
return "options"
try:
a = int(q.get("answer"))
except (TypeError, ValueError):
return "answer"
if not 1 <= a <= 4:
return "answer"
text = q["question"] + " " + " ".join(opts)
if len(q["question"].strip()) < 10:
return "too_short"
if REFERS_TO_PASSAGE.search(q["question"]):
return "refers_to_passage"
letters = LETTER.findall(text)
if not letters or len(SINHALA.findall(text)) / len(letters) < 0.5:
return "not_sinhala"
return None
def main() -> None:
ap = argparse.ArgumentParser()
ap.add_argument("--passages", required=True)
ap.add_argument("--out", required=True, help="output prefix")
ap.add_argument("--teacher", default="google/gemma-4-31B-it")
ap.add_argument("--per-passage", type=int, default=3)
ap.add_argument("--max-new-tokens", type=int, default=1200)
ap.add_argument("--gen-batch", type=int, default=16)
ap.add_argument("--seed", type=int, default=1234)
args = ap.parse_args()
load_env()
set_seed(args.seed)
cap_gpu_memory()
from eval.prompts import PromptBuilder, user_text
from eval.scorers import Scorer, load_model
passages = read_jsonl(args.passages)
model, tok = load_model(args.teacher)
t0 = time.time()
# 1. generation (greedy, thinking off)
prompts = [tok.apply_chat_template([{"role": "user", "content": PROMPT.format(
subject=p["subject"], level=p["level"], k=args.per_passage, passage=p["text"])}],
tokenize=False, add_generation_prompt=True, enable_thinking=False) for p in passages]
gen = Scorer(model, tok, add_special_tokens=False, token_budget=10**9, max_batch=args.gen_batch,
max_new_tokens=args.max_new_tokens)
raw = gen.generate(prompts, [4] * len(prompts))
print(f"generated for {len(passages)} passages in {(time.time() - t0) / 60:.1f} min", flush=True)
# 2. validation
items, rejected = [], []
for p, r in zip(passages, raw):
qs = parse(r["text"])
if not qs:
rejected.append({"pid": p["pid"], "reason": "unparseable", "text": r["text"][:500]})
for j, q in enumerate(qs[: args.per_passage]):
why = validate(q)
if why:
rejected.append({"pid": p["pid"], "reason": why, "q": q})
continue
items.append({"uid": f"synth/{p['pid']}/{j}", "dataset": "synthetic", "q_no": j, "subject": p["subject"],
"category": "synthetic", "level": p["level"], "question": q["question"].strip(),
"options": [str(o).strip() for o in q["options"]], "answer": int(q["answer"]),
"meta": {"pid": p["pid"], "source": p["source"], "teacher": args.teacher}})
# 3. round trip (open book) and 4. soft labels (closed book), both logit scoring with the teacher
by_pid = {p["pid"]: p for p in passages}
pb = PromptBuilder(tok, lang="en", chat=True)
scorer = Scorer(model, tok, add_special_tokens=False, token_budget=16384, max_batch=32)
open_prompts = [tok.apply_chat_template([{"role": "user", "content":
f"{by_pid[it['meta']['pid']]['text']}\n\n{user_text(it, it['options'], 'en')}"}],
tokenize=False, add_generation_prompt=True, enable_thinking=False) for it in items]
ob = scorer.logit(open_prompts, [4] * len(items))
cb = scorer.logit([pb.mcq(it, it["options"]) for it in items], [4] * len(items))
kept = []
for it, o, c in zip(items, ob, cb):
it["meta"] |= {"open_book_pred": o["pred"], "open_book_conf": round(max(o["probs"]), 4),
"closed_book_pred": c["pred"]}
if o["pred"] != it["answer"]:
rejected.append({"pid": it["meta"]["pid"], "reason": "round_trip_disagrees", "q": it})
continue
kept.append(it | {"teacher_probs": [round(x, 5) for x in c["probs"]]})
write_jsonl(f"{args.out}.jsonl", kept)
write_jsonl(f"{args.out}.rejected.jsonl", rejected)
stats = {"passages": len(passages), "generated_valid": len(items), "kept": len(kept),
"rejected": dict(Counter(r["reason"] for r in rejected)),
"answer_positions": dict(Counter(it["answer"] for it in kept)),
"teacher_closed_book_agrees_with_key": round(sum(it["meta"]["closed_book_pred"] == it["answer"] for it in kept) / max(len(kept), 1), 3),
"minutes": round((time.time() - t0) / 60, 1)}
Path(f"{args.out}.stats.json").write_text(json.dumps(stats, indent=2))
print(json.dumps(stats, indent=2))
rng = random.Random(args.seed)
lines = [f"# Review: {min(50, len(kept))} random synthetic MCQs ({args.teacher})", "",
"For each question mark: OK / wrong answer / unnatural Sinhala / ambiguous / not exam-like.", ""]
for n, it in enumerate(rng.sample(kept, min(50, len(kept))), 1):
p = by_pid[it["meta"]["pid"]]
lines += [f"## {n}. {it['subject']} ({it['level']})", "", f"**Q:** {it['question']}", ""]
lines += [f"{k}. {o}{' ✅' if k == it['answer'] else ''}" for k, o in enumerate(it["options"], 1)]
lines += ["", f"<details><summary>passage</summary>\n\n{p['text'][:800]}\n\n</details>", ""]
Path(f"{args.out}.review.md").write_text("\n".join(lines), encoding="utf-8")
print(f"review file -> {args.out}.review.md")
if __name__ == "__main__":
main()
|