Download training/mcq_dataset.py from SinhalaMMLU-SharedTask/team-qbits: direct link, hf CLI and curl.
- Browser
- Download file 29.5 kB
-
https://huggingface.co/SinhalaMMLU-SharedTask/team-qbits/resolve/main/training/mcq_dataset.py
- Command line
-
hf download hf://SinhalaMMLU-SharedTask/team-qbits/training/mcq_dataset.py
-
curl -L -o mcq_dataset.py https://huggingface.co/SinhalaMMLU-SharedTask/team-qbits/resolve/main/training/mcq_dataset.py
29.5 kB
| #!/usr/bin/env python | |
| """ | |
| Toolkit for building a Sinhala multiple-choice fine-tuning dataset (SinhalaMMLU-style). | |
| Record schema (one JSON object per line, UTF-8). Mirrors the test file; `answer` is 1-based: | |
| {"id": "ol2019_sci_p1_q07", "subject": "science", "category": "stem", | |
| "question": "...", "choices": ["...", "...", "...", "..."], "answer": 2, | |
| "metadata": {"source": "<paper id or URL>", "exam_type": "OL", "year": 2019, "grade": 11, | |
| "difficulty": "medium", "extraction": "text_pdf|ocr|synthetic|translated"}} | |
| Commands (run in this order): | |
| python mcq_dataset.py example example.jsonl # writes 2 dummy records to see the schema | |
| python mcq_dataset.py validate raw.jsonl # report problems, changes nothing | |
| python mcq_dataset.py clean raw.jsonl clean.jsonl --rejects rejects.jsonl | |
| python mcq_dataset.py dedupe clean.jsonl dedup.jsonl --blocklist public_test.json # also decontaminates | |
| python mcq_dataset.py split dedup.jsonl --train train.jsonl --val val.jsonl --val_frac 0.1 | |
| python mcq_dataset.py stats train.jsonl | |
| python mcq_dataset.py build-sft train.jsonl sft_train.jsonl --shuffle_copies 1 --strip_zwj_prob 0.5 | |
| python mcq_dataset.py build-sft val.jsonl sft_val.jsonl --shuffle_copies 0 --strip_zwj_prob 0 --templates si_instruction | |
| The --blocklist file is ONLY used to find and drop overlapping questions. It is never copied into the output. | |
| """ | |
| import argparse | |
| import collections | |
| import hashlib | |
| import json | |
| import random | |
| import re | |
| import sys | |
| import unicodedata | |
| ZWJ = "" | |
| # options that refer to other options: never reorder these | |
| POSITIONAL = [r"ඉහත", r"සියල්ල", r"කිසිවක්", r"[1-5]\s*(සහ|හා|and|&)\s*[1-5]", r"\ball of the above\b", r"\bnone of\b"] | |
| FIGURE = [r"රූපය", r"රූප සටහන", r"රූපයේ", r"රූපවල", r"ප්රස්තාර", r"සිතියම", r"\bfigure\b", r"\bdiagram\b", r"\bgraph\b"] | |
| SI_INSTR = "පහත බහුවරණ ප්රශ්නයට නිවැරදි පිළිතුරේ අංකය ලියන්න." | |
| EN_INSTR = "Answer the following multiple-choice question. Reply with only the number of the correct option." | |
| # ------------------------------------------------------------------ io / helpers | |
| def load(path): | |
| with open(path, encoding="utf-8") as f: | |
| txt = f.read().strip() | |
| if not txt: | |
| return [] | |
| if txt[0] == "[": | |
| return json.loads(txt) | |
| return [json.loads(l) for l in txt.splitlines() if l.strip()] | |
| def dump(path, rows): | |
| with open(path, "w", encoding="utf-8") as f: | |
| for r in rows: | |
| f.write(json.dumps(r, ensure_ascii=False) + "\n") | |
| def getpath(d, path, default=None): | |
| for k in path.split("."): | |
| if not isinstance(d, dict) or k not in d: | |
| return default | |
| d = d[k] | |
| return d | |
| def strip_zwj(s): | |
| return s.replace(ZWJ, "") | |
| def norm_key(s): | |
| """Comparison key: letters/marks/digits only, no ZWJ, no spaces/punctuation, lower-case.""" | |
| s = unicodedata.normalize("NFC", s).lower() | |
| return "".join(ch for ch in s if unicodedata.category(ch)[0] in "LMN") | |
| def clean_text(s): | |
| s = unicodedata.normalize("NFC", str(s)) | |
| s = s.replace("\\r\\n", " ").replace("\\n", " ").replace("\\r", " ").replace("\\t", " ") # literal escapes | |
| # U+200C (ZWNJ) is a legacy-font-converter artifact after word-final virama; real test text has none | |
| s = s.replace("", "").replace("", "").replace("", "").replace("�", "") | |
| s = "".join(ch if (ch in "\n\t" or unicodedata.category(ch) != "Cc") else " " for ch in s) | |
| return re.sub(r"\s+", " ", s).strip() | |
| def strip_numbering(choices): | |
| """Remove '(1) ', '2. ', '3) ' prefixes, but only if ALL choices carry the matching sequential prefix.""" | |
| pats = [re.compile(rf"^\s*[\(\[]?{i + 1}[\)\]\.]\s*") for i in range(len(choices))] | |
| if all(p.match(c) for p, c in zip(pats, choices)): | |
| return [p.sub("", c, count=1) for p, c in zip(pats, choices)] | |
| return choices | |
| def garbage_ratio(s): | |
| if not s: | |
| return 0.0 | |
| bad = sum(1 for ch in s if "" <= ch <= "" or ch == "�" or "À" <= ch <= "ÿ") | |
| return bad / len(s) | |
| def sinhala_ratio(s): | |
| letters = [ch for ch in s if unicodedata.category(ch).startswith("L")] | |
| if not letters: | |
| return 1.0 | |
| return sum(1 for ch in letters if "" <= ch <= "") / len(letters) | |
| def any_match(patterns, text): | |
| t = strip_zwj(text) | |
| return any(re.search(p, t, flags=re.I) for p in patterns) | |
| def problems_of(r): | |
| """Hard errors (record unusable) and soft warnings.""" | |
| errs, warns = [], [] | |
| q, ch, a = r.get("question"), r.get("choices"), r.get("answer") | |
| if not isinstance(q, str) or len(q.strip()) < 5: | |
| errs.append("question missing/too short") | |
| if not isinstance(ch, list) or not (2 <= len(ch) <= 6) or not all(isinstance(c, str) and c.strip() for c in ch): | |
| errs.append("choices must be 2-6 non-empty strings") | |
| elif len({norm_key(c) for c in ch}) < len(ch): | |
| errs.append("duplicate choices") | |
| if not isinstance(a, int) or isinstance(a, bool): | |
| errs.append("answer must be an integer (1-based)") | |
| elif isinstance(ch, list) and not (1 <= a <= len(ch)): | |
| errs.append("answer out of range (must be 1-based)") | |
| if not r.get("subject"): | |
| warns.append("no subject") | |
| if not getpath(r, "metadata.source"): | |
| warns.append("no metadata.source (needed for splitting and for removing overlaps later)") | |
| blob = (q or "") + " " + " ".join(ch if isinstance(ch, list) else []) | |
| if "\\n" in blob: | |
| warns.append("literal backslash-n in text") | |
| if re.search(r"<[a-zA-Z/][^>]*>", blob): | |
| warns.append("HTML tags in text") | |
| if garbage_ratio(blob) > 0.05: | |
| errs.append("garbled text (legacy font / OCR garbage)") | |
| if sinhala_ratio(blob) < 0.25: | |
| warns.append("little Sinhala text (English medium or garbled?)") | |
| if isinstance(q, str) and any_match(FIGURE, q): | |
| warns.append("refers to a figure/diagram") | |
| return errs, warns | |
| # ------------------------------------------------------------------ commands | |
| def cmd_example(a): | |
| rows = [ | |
| {"id": "demo_1", "subject": "science", "category": "stem", "question": "උදාහරණ ප්රශ්නය එක කුමක්ද?", | |
| "choices": ["පිළිතුර එක", "පිළිතුර දෙක", "පිළිතුර තුන", "පිළිතුර හතර"], "answer": 2, | |
| "metadata": {"source": "demo_paper_A", "exam_type": "OL", "year": 2019, "grade": 11, | |
| "difficulty": "medium", "extraction": "text_pdf"}}, | |
| {"id": "demo_2", "subject": "science", "category": "stem", "question": "උදාහරණ ප්රශ්නය දෙක කුමක්ද?", | |
| "choices": ["එක", "දෙක", "තුන", "ඉහත සියල්ලම"], "answer": 4, | |
| "metadata": {"source": "demo_paper_B", "exam_type": "Scholarship", "year": 2018, "grade": 5, | |
| "difficulty": "easy", "extraction": "text_pdf"}}, | |
| ] | |
| dump(a.out, rows) | |
| print(f"wrote {a.out}") | |
| JUNK_CHARS = set("-—–~|।*!'\"“”‘’.•_:;,") | |
| LATIN_OK = {"UN", "UNO", "UNESCO", "NATO", "SAARC", "ASEAN", "GDP", "IT", "ICT", "TV", "DNA", "HIV", "AIDS", "WHO", | |
| "CD", "DVD", "BC", "AD", "pH", "CO2", "H2O", "SMS", "PDF", "USA", "UK", "EU", "AI"} | |
| LATIN_OK |= {"kg", "cm", "mm", "km", "ml", "ms", "cm3", "m2", "m3", "mg", "Hz", "kHz", "MHz", "GHz", "KB", "MB", "GB", "TB", | |
| "bps", "Kbps", "Mbps", "Ctrl", "Alt", "Shift", "Enter", "Esc", "Tab", "Del", "Delete", "Home", "End", "Page", | |
| "key", "Key", "ASCII", "USB", "RAM", "ROM", "CPU", "ALU", "BIOS", "OS", "LAN", "WAN", "IP", "MSB", "LSB", "BCD", | |
| "www", "http", "https", "HTML", "URL", "Data", "Duplex", "control", "AND", "OR", "NOT", "XOR", "NAND", "NOR", | |
| "II", "III", "IV", "VI", "VII", "VIII", "IX", "Excel", "Word", "Windows", "Access", "PowerPoint", "Internet", | |
| "Email", "Wi", "Fi", "pH", "DC", "AC", "LED", "LCD", "PIN", "ATM", "VAT", "PAYE", "CEO", "NGO", "SMS", "GPS"} | |
| def chem_like(t): | |
| """Chemical formula such as HCl, NaOH, CaCO, CO, Mg: element symbols with <=2-digit counts, >=2 distinct chars | |
| (so 'OO' and 'S888' are NOT accepted).""" | |
| return bool(re.fullmatch(r"(?:[A-Z][a-z]?\d{0,2}){1,6}", t)) and len(set(t)) > 1 | |
| LATIN_OK |= {"of", "to", "in", "and", "or", "the", "gov", "lk", "com", "moe", "edu", "org", "net", "www", "mol", "aq", "ppm", "kgm", | |
| "Web", "Hub", "Mem", "Sri", "TT", "tt", "CC", "Cc", "Tt", "ii", "iii", "iv", "vi", "vii", "viii", "ix"} | |
| def looks_like_ocr_garbage(t): | |
| """Real English words / acronyms / formulas are fine inside Sinhala exam text (ICT, science, URLs...). | |
| Garbage looks different: tokens containing digits ('S888'), very short stray tokens ('wo', 'Qed'), | |
| or letter soup with no vowel / a long consonant run.""" | |
| if t in LATIN_OK or chem_like(t): | |
| return False | |
| if re.search(r"\d", t): | |
| return True | |
| if len(t) <= 3: | |
| return True | |
| if not re.search(r"[AEIOUaeiouYy]", t): | |
| return True | |
| if re.search(r"[^AEIOUaeiouYy]{5,}", t): | |
| return True | |
| return False | |
| def latin_garbage_tokens(s): | |
| """Latin tokens (2+ chars) that look like OCR garbage (e.g. 'S888', 'Qed', 'OO', 'wo').""" | |
| return [t for t in re.findall(r"[A-Za-z][A-Za-z0-9]*", s) if len(t) >= 2 and looks_like_ocr_garbage(t)] | |
| def trim_tail(s): | |
| """Remove OCR debris from the END of a question/option: lone punctuation, '|', '।', short quote-led tokens, | |
| and Latin garbage tokens such as 'Qed,'.""" | |
| toks = s.split(" ") | |
| while toks: | |
| t = toks[-1] | |
| junk_only = t == "" or all(ch in JUNK_CHARS for ch in t) | |
| quote_led = t[:1] in "“”‘’'\"|।*~" and len(t) <= 3 | |
| latin_junk = bool(latin_garbage_tokens(t)) and len(re.sub(r"[A-Za-z0-9]", "", t)) <= 1 | |
| if junk_only or quote_led or latin_junk: | |
| toks.pop() | |
| else: | |
| break | |
| return " ".join(toks) | |
| SHARED_STEM = [r"ප්රශ්නවලට", r"ප්රශ්නවලට", r"ඇසුරෙන්", r"ඉහත\s*(ප්රකාශ|ප්රකාශ|වගුව|සටහන|කොටස|ඡේදය)", r"පහත\s*(ඡේදය|කොටස|ප්රකාශ|ප්රකාශ)"] | |
| CONF = {"low": 0, "medium": 1, "high": 2} | |
| def cmd_convert(a): | |
| """Convert the PDF-extraction format (answer_1..answer_5 + correct_answer letter) to the MCQ schema.""" | |
| rows, out, rej = load(a.inp), [], collections.Counter() | |
| seen = collections.Counter() | |
| latin_tok, latin_rows = collections.Counter(), [] | |
| for r in rows: | |
| conf = str(r.get("extraction_confidence") or "unknown").lower() | |
| seen[("confidence", conf)] += 1 | |
| seen[("needs_review", bool(r.get("needs_review")))] += 1 | |
| qt = r.get("question_type") | |
| if qt not in (None, "", "mcq"): | |
| rej[f"question_type={qt}"] += 1 | |
| continue | |
| if CONF.get(conf, 0) < CONF[a.min_confidence]: | |
| rej["below min_confidence"] += 1 | |
| continue | |
| if a.skip_needs_review and r.get("needs_review"): | |
| rej["needs_review"] += 1 | |
| continue | |
| # the training label must exist: rows with a null/empty correct_answer are unusable | |
| if not str(r.get("correct_answer") or "").strip(): | |
| rej["no correct_answer (null)"] += 1 | |
| continue | |
| q = clean_text(r.get("original_question") or "") | |
| # OCR junk after the final question mark, e.g. "...ද? ී i “" | |
| if "?" in q: | |
| head, tail = q.rsplit("?", 1) | |
| if len(tail.strip()) <= 8: | |
| q = head + "?" | |
| q = trim_tail(q) | |
| raw_choices, spill = [], False | |
| filled = [i for i in range(1, 6) if r.get(f"answer_{i}") is not None and str(r.get(f"answer_{i}")).strip()] | |
| last_i = filled[-1] if filled else 0 | |
| for i in range(1, 6): | |
| v = r.get(f"answer_{i}") | |
| if v is None or not str(v).strip(): | |
| raw_choices.append(None) | |
| continue | |
| lines = [ln.strip() for ln in str(v).strip().split("\n") if ln.strip()] | |
| keep = [lines[0]] | |
| for ln in lines[1:]: | |
| if i < last_i: | |
| # another option follows, so nothing can have leaked in: a newline is a wrapped line | |
| keep.append(ln) | |
| else: | |
| # last option: continuation lines are short; next-question text is long / a question / numbered | |
| looks_like_leak = (len(ln) > 80 or "?" in ln or re.match(r"^\(?\d+[\.\)]", ln) | |
| or any_match(SHARED_STEM, ln)) | |
| if looks_like_leak: | |
| spill = True | |
| break | |
| keep.append(ln) | |
| first = clean_text(" ".join(keep)) | |
| first = trim_tail(first).rstrip(" ,;:") | |
| raw_choices.append(first or None) | |
| # options must be contiguous from 1 | |
| n = 0 | |
| while n < 5 and raw_choices[n]: | |
| n += 1 | |
| if any(raw_choices[n:]): | |
| rej["non-contiguous options"] += 1 | |
| continue | |
| choices = raw_choices[:n] | |
| if n < 3: | |
| rej["fewer than 3 options"] += 1 | |
| continue | |
| ca = str(r.get("correct_answer") or "").strip().upper() | |
| if ca in "ABCDE" and ca: | |
| ans = "ABCDE".index(ca) + 1 | |
| elif ca.isdigit(): | |
| ans = int(ca) | |
| else: | |
| rej["missing/invalid correct_answer"] += 1 | |
| continue | |
| if not (1 <= ans <= n): | |
| rej["correct_answer outside the options"] += 1 | |
| continue | |
| if len(q) < 8: | |
| rej["question too short"] += 1 | |
| continue | |
| if any_match(SHARED_STEM, q) and not a.keep_shared_stem: | |
| rej["depends on a shared stem/passage"] += 1 | |
| continue | |
| if max(len(c) for c in choices) > 250: | |
| rej["option too long (extraction spill)"] += 1 | |
| continue | |
| if not a.keep_latin: | |
| allow = {t for t in (a.latin_allow or [])} | |
| bad = [t for t in latin_garbage_tokens(q + " " + " ".join(choices)) if t not in allow] | |
| if bad: | |
| rej["OCR garbage (Latin letters/digits inside Sinhala text)"] += 1 | |
| latin_tok.update(bad) | |
| latin_rows.append({"id": r.get("id"), "subject": r.get("subject"), "tokens": bad, | |
| "question": q, "choices": choices}) | |
| continue | |
| out.append({ | |
| "id": r.get("id"), "subject": r.get("subject"), "question": q, "choices": choices, "answer": ans, | |
| "metadata": {"source": r.get("source_pdf"), "page": r.get("source_page"), "year": r.get("year"), | |
| "exam_type": r.get("level"), "medium": r.get("medium"), | |
| "extraction_confidence": conf, "needs_review": bool(r.get("needs_review")), | |
| "option_spill_fixed": spill}}) | |
| dump(a.out, out) | |
| print(f"read {len(rows)} -> kept {len(out)} ({len(out) / max(1, len(rows)):.0%}) -> {a.out}") | |
| for (k, v), c in sorted(seen.items(), key=lambda kv: str(kv[0])): | |
| print(f" input {k}={v}: {c}") | |
| for k, v in rej.most_common(): | |
| print(f" dropped: {k}: {v}") | |
| if latin_rows: | |
| print(" most common Latin tokens in the rows dropped as 'OCR garbage' (legit terms like CPU/Excel/NaCl " | |
| "should be allowed with --latin_allow):") | |
| print(" ", ", ".join(f"{t}:{c}" for t, c in latin_tok.most_common(40))) | |
| if a.rejects: | |
| dump(a.rejects, latin_rows) | |
| print(f" dropped Latin rows saved to {a.rejects}") | |
| if out and all(x["metadata"]["needs_review"] for x in out): | |
| print("WARNING: every kept record is flagged needs_review. Label noise from extraction is the main risk: " | |
| "hand-check ~100 random records (python mcq_dataset.py sample ...) before training.") | |
| def cmd_sample(a): | |
| rows = load(a.inp) | |
| random.Random(a.seed).shuffle(rows) | |
| lines = [] | |
| for r in rows[:a.n]: | |
| md = r.get("metadata") or {} | |
| lines.append("-" * 60 + f" {r.get('id')} [{md.get('extraction_confidence', '?')}]") | |
| lines.append("Q: " + r["question"]) | |
| for i, c in enumerate(r["choices"], 1): | |
| lines.append(f" {'*' if i == r['answer'] else ' '} {i}. {c}") | |
| text = "\n".join(lines) + "\n" | |
| if a.out: | |
| # write the file directly as UTF-8 (a PowerShell '>' redirect re-encodes and garbles Sinhala) | |
| with open(a.out, "w", encoding="utf-8-sig") as f: | |
| f.write(text) | |
| print(f"wrote {min(a.n, len(rows))} records -> {a.out}") | |
| else: | |
| print(text, end="") | |
| def cmd_validate(a): | |
| rows = load(a.inp) | |
| ec, wc = collections.Counter(), collections.Counter() | |
| bad = 0 | |
| for r in rows: | |
| errs, warns = problems_of(r) | |
| bad += bool(errs) | |
| ec.update(errs) | |
| wc.update(warns) | |
| print(f"{len(rows)} records, {bad} with hard errors ({bad / max(1, len(rows)):.1%})") | |
| for k, v in ec.most_common(): | |
| print(f" ERROR {v:6d} {k}") | |
| for k, v in wc.most_common(): | |
| print(f" warn {v:6d} {k}") | |
| zw = sum(ZWJ in (r.get("question", "") + "".join(r.get("choices", []) or [])) for r in rows) | |
| print(f"records containing ZWJ (U+200D): {zw} ({zw / max(1, len(rows)):.1%})") | |
| def cmd_clean(a): | |
| out, rej = [], [] | |
| for r in load(a.inp): | |
| r = dict(r) | |
| if isinstance(r.get("answer"), str) and r["answer"].strip().isdigit(): | |
| r["answer"] = int(r["answer"].strip()) | |
| if isinstance(r.get("question"), str): | |
| r["question"] = clean_text(r["question"]) | |
| if isinstance(r.get("choices"), list): | |
| ch = [clean_text(c) for c in r["choices"] if isinstance(c, str)] | |
| r["choices"] = strip_numbering(ch) | |
| errs, warns = problems_of(r) | |
| if "refers to a figure/diagram" in warns and not a.keep_figures: | |
| errs.append("needs a figure/diagram") | |
| if errs: | |
| rej.append({**r, "_reject": errs}) | |
| else: | |
| out.append(r) | |
| dump(a.out, out) | |
| if a.rejects: | |
| dump(a.rejects, rej) | |
| print(f"kept {len(out)}, rejected {len(rej)} -> {a.out}") | |
| for k, v in collections.Counter(e for x in rej for e in x["_reject"]).most_common(): | |
| print(f" rejected for: {k}: {v}") | |
| def shingles(key, n=8): | |
| return {hash(key[i:i + n]) for i in range(max(1, len(key) - n + 1))} | |
| def rec_key(r): | |
| return norm_key(r.get("question", "") + " " + " ".join(r.get("choices", []))) | |
| class Index: | |
| def __init__(self): | |
| self.post = collections.defaultdict(list) | |
| self.n = 0 | |
| def add(self, sh): | |
| for h in sh: | |
| self.post[h].append(self.n) | |
| self.n += 1 | |
| def best_containment(self, sh): | |
| if not sh: | |
| return 0.0 | |
| cnt = collections.Counter() | |
| for h in sh: | |
| for d in self.post.get(h, ()): | |
| cnt[d] += 1 | |
| return max(cnt.values()) / len(sh) if cnt else 0.0 | |
| def cmd_dedupe(a): | |
| rows = load(a.inp) | |
| # 1) same question text: identical answer -> keep first, conflicting answers -> drop all (likely label noise) | |
| groups = collections.defaultdict(list) | |
| for r in rows: | |
| groups[norm_key(r["question"])].append(r) | |
| kept, conflicts = [], [] | |
| for k, g in groups.items(): | |
| ans = {norm_key(x["choices"][x["answer"] - 1]) for x in g} | |
| if len(ans) > 1: | |
| conflicts += g | |
| else: | |
| kept.append(g[0]) | |
| dups = len(rows) - len(kept) - len(conflicts) | |
| # 2) near-duplicates inside the set | |
| idx, uniq, near = Index(), [], 0 | |
| for r in kept: | |
| sh = shingles(rec_key(r)) | |
| if idx.best_containment(sh) >= a.near_threshold: | |
| near += 1 | |
| continue | |
| idx.add(sh) | |
| uniq.append(r) | |
| # 3) overlap with blocklist (e.g. public test questions): drop, never copied | |
| overlap = 0 | |
| if a.blocklist: | |
| bfull, bq = Index(), Index() | |
| for p in a.blocklist: | |
| for b in load(p): | |
| if isinstance(b, dict) and b.get("question"): | |
| bfull.add(shingles(norm_key(b["question"] + " " + " ".join(b.get("choices", []) or [])))) | |
| bq.add(shingles(norm_key(b["question"]))) | |
| final = [] | |
| for r in uniq: | |
| qk = norm_key(r["question"]) | |
| # drop if the whole item matches, OR the question text alone matches (robust to differently formatted options) | |
| hit = bfull.best_containment(shingles(rec_key(r))) >= a.block_threshold or \ | |
| (len(qk) >= 25 and bq.best_containment(shingles(qk)) >= 0.8) | |
| if hit: | |
| overlap += 1 | |
| else: | |
| final.append(r) | |
| uniq = final | |
| dump(a.out, uniq) | |
| if conflicts and a.conflicts: | |
| dump(a.conflicts, conflicts) | |
| print(f"in {len(rows)} -> out {len(uniq)} | exact dup {dups} | answer conflicts {len(conflicts)} | " | |
| f"near-dup {near} | overlap with blocklist {overlap}") | |
| def cmd_split(a): | |
| rows = load(a.inp) | |
| tr, va = [], [] | |
| groups = set() | |
| for r in rows: | |
| g = str(getpath(r, a.group_key) or r.get("id", "")).strip() or "none" | |
| groups.add(g) | |
| h = int(hashlib.sha1(f"{a.seed}:{g}".encode()).hexdigest()[:8], 16) / 2 ** 32 | |
| (va if h < a.val_frac else tr).append(r) | |
| dump(a.train, tr) | |
| dump(a.val, va) | |
| print(f"{len(groups)} groups ({a.group_key}) -> train {len(tr)}, val {len(va)}") | |
| if len(groups) < 10: | |
| print("WARNING: few groups; the split may be lopsided. Add metadata.source to every record.") | |
| def cmd_stats(a): | |
| rows = load(a.inp) | |
| print(f"{len(rows)} records") | |
| for title, f in [("subject", lambda r: r.get("subject")), ("category", lambda r: r.get("category")), | |
| ("difficulty", lambda r: getpath(r, "metadata.difficulty")), | |
| ("exam_type", lambda r: getpath(r, "metadata.exam_type")), | |
| ("year", lambda r: getpath(r, "metadata.year")), | |
| ("n_choices", lambda r: len(r.get("choices", []))), | |
| ("answer position", lambda r: r.get("answer")), | |
| ("extraction", lambda r: getpath(r, "metadata.extraction_confidence") or getpath(r, "metadata.extraction"))]: | |
| c = collections.Counter(f(r) for r in rows) | |
| print(f"\n{title}:") | |
| for k, v in sorted(c.items(), key=lambda kv: -kv[1])[:20]: | |
| print(f" {str(k):28s} {v:6d} {v / len(rows):6.1%}") | |
| def render(r, tpl, rng): | |
| ch = r["choices"] | |
| letters = "ABCDEF" | |
| subj = r.get("subject") or "" | |
| if tpl == "bare": | |
| body = r["question"] + "\n" + "\n".join(f"{i + 1}. {c}" for i, c in enumerate(ch)) + "\nපිළිතුර:" | |
| return body, f" {r['answer']}" | |
| if tpl == "si_instruction": | |
| head = SI_INSTR + "\n\n" + (f"විෂය: {subj}\n" if subj else "") | |
| body = head + f"ප්රශ්නය: {r['question']}\n" + "\n".join(f"{i + 1}. {c}" for i, c in enumerate(ch)) + "\nපිළිතුර:" | |
| return body, f" {r['answer']}" | |
| if tpl == "en_instruction": | |
| head = EN_INSTR + "\n\n" + (f"Subject: {subj}\n" if subj else "") | |
| body = head + f"Question: {r['question']}\n" + "\n".join(f"{i + 1}. {c}" for i, c in enumerate(ch)) + "\nAnswer:" | |
| return body, f" {r['answer']}" | |
| if tpl == "letters": | |
| body = f"Question: {r['question']}\n" + "\n".join(f"{letters[i]}. {c}" for i, c in enumerate(ch)) + "\nAnswer:" | |
| return body, f" {letters[r['answer'] - 1]}" | |
| raise ValueError(tpl) | |
| TEMPLATE_WEIGHTS = {"si_instruction": 0.35, "bare": 0.25, "en_instruction": 0.25, "letters": 0.15} | |
| def shuffle_safe(r): | |
| return not any(any_match(POSITIONAL, c) for c in r["choices"]) | |
| def cmd_build_sft(a): | |
| rng = random.Random(a.seed) | |
| names = a.templates or list(TEMPLATE_WEIGHTS) | |
| weights = [TEMPLATE_WEIGHTS[n] for n in names] | |
| rows, out, n_shuf_skip = load(a.inp), [], 0 | |
| for r in rows: | |
| variants = [r] | |
| if a.shuffle_copies > 0: | |
| if shuffle_safe(r): | |
| for _ in range(a.shuffle_copies): | |
| perm = list(range(len(r["choices"]))) | |
| rng.shuffle(perm) | |
| new_choices = [r["choices"][i] for i in perm] | |
| new_ans = perm.index(r["answer"] - 1) + 1 | |
| variants.append({**r, "choices": new_choices, "answer": new_ans}) | |
| else: | |
| n_shuf_skip += 1 | |
| for v in variants: | |
| v = dict(v) | |
| if rng.random() < a.strip_zwj_prob: | |
| v["question"] = strip_zwj(v["question"]) | |
| v["choices"] = [strip_zwj(c) for c in v["choices"]] | |
| v["subject"] = strip_zwj(v.get("subject") or "") | |
| tpl = rng.choices(names, weights)[0] | |
| prompt, completion = render(v, tpl, rng) | |
| if a.format == "chat": | |
| rec = {"prompt": [{"role": "user", "content": prompt}], | |
| "completion": [{"role": "assistant", "content": completion.strip()}]} | |
| else: | |
| lab = "ABCDEF" if tpl == "letters" else "123456" | |
| rec = {"prompt": prompt, "completion": completion, | |
| "candidates": [f" {lab[i]}" for i in range(len(v["choices"]))], | |
| "subject": r.get("subject")} | |
| out.append(rec) | |
| rng.shuffle(out) | |
| dump(a.out, out) | |
| pos = collections.Counter( | |
| (x["completion"] if isinstance(x["completion"], str) else x["completion"][0]["content"]).strip() | |
| for x in out) | |
| print(f"{len(rows)} questions -> {len(out)} training examples ({a.format}); " | |
| f"not shuffled (positional options): {n_shuf_skip}") | |
| print("answer label distribution:", dict(sorted(pos.items()))) | |
| def main(): | |
| # Windows consoles/redirects default to cp1252, which cannot encode Sinhala: force UTF-8 | |
| for stream in (sys.stdout, sys.stderr): | |
| try: | |
| stream.reconfigure(encoding="utf-8") | |
| except Exception: | |
| pass | |
| ap = argparse.ArgumentParser(description=__doc__, formatter_class=argparse.RawDescriptionHelpFormatter) | |
| sub = ap.add_subparsers(dest="cmd", required=True) | |
| p = sub.add_parser("example"); p.add_argument("out"); p.set_defaults(f=cmd_example) | |
| p = sub.add_parser("convert", help="PDF-extraction format -> MCQ schema") | |
| p.add_argument("inp"); p.add_argument("out") | |
| p.add_argument("--min_confidence", choices=["low", "medium", "high"], default="low") | |
| p.add_argument("--skip_needs_review", action="store_true") | |
| p.add_argument("--keep_shared_stem", action="store_true") | |
| p.add_argument("--rejects", default="", help="save the rows dropped for Latin garbage to this file") | |
| p.add_argument("--latin_allow", nargs="*", default=[], help="Latin tokens that are legitimate (e.g. CPU Excel RAM)") | |
| p.add_argument("--keep_latin", action="store_true", | |
| help="do not drop records with Latin tokens (use for science/maths/English papers)") | |
| p.set_defaults(f=cmd_convert) | |
| p = sub.add_parser("sample", help="print random records for hand-checking"); p.add_argument("inp") | |
| p.add_argument("-n", type=int, default=20); p.add_argument("--seed", type=int, default=1) | |
| p.add_argument("--out", default="", help="write to this file as UTF-8 (use this instead of '>' on Windows)") | |
| p.set_defaults(f=cmd_sample) | |
| p = sub.add_parser("validate"); p.add_argument("inp"); p.set_defaults(f=cmd_validate) | |
| p = sub.add_parser("clean"); p.add_argument("inp"); p.add_argument("out") | |
| p.add_argument("--rejects", default=""); p.add_argument("--keep_figures", action="store_true"); p.set_defaults(f=cmd_clean) | |
| p = sub.add_parser("dedupe"); p.add_argument("inp"); p.add_argument("out") | |
| p.add_argument("--blocklist", nargs="*", default=[], help="files of questions to remove overlaps with") | |
| p.add_argument("--near_threshold", type=float, default=0.9) | |
| p.add_argument("--block_threshold", type=float, default=0.5) | |
| p.add_argument("--conflicts", default="conflicts.jsonl"); p.set_defaults(f=cmd_dedupe) | |
| p = sub.add_parser("split"); p.add_argument("inp"); p.add_argument("--train", required=True) | |
| p.add_argument("--val", required=True); p.add_argument("--val_frac", type=float, default=0.1) | |
| p.add_argument("--group_key", default="metadata.source"); p.add_argument("--seed", type=int, default=3407) | |
| p.set_defaults(f=cmd_split) | |
| p = sub.add_parser("stats"); p.add_argument("inp"); p.set_defaults(f=cmd_stats) | |
| p = sub.add_parser("build-sft"); p.add_argument("inp"); p.add_argument("out") | |
| p.add_argument("--format", choices=["plain", "chat"], default="plain") | |
| p.add_argument("--templates", nargs="*", choices=list(TEMPLATE_WEIGHTS), default=None) | |
| p.add_argument("--shuffle_copies", type=int, default=1) | |
| p.add_argument("--strip_zwj_prob", type=float, default=0.5) | |
| p.add_argument("--seed", type=int, default=3407); p.set_defaults(f=cmd_build_sft) | |
| a = ap.parse_args() | |
| a.f(a) | |
| if __name__ == "__main__": | |
| main() | |