File size: 8,455 Bytes
7deb06b | 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 | """Build a training file from public MCQ datasets, excluding any dev/test overlap.
python prep_data.py download --dataset CohereLabs/Global-MMLU --configs si --out raw/gmmlu
python prep_data.py split --data dev.jsonl --prefix dev --eval-n 400
python prep_data.py split --data 'raw/gmmlu/*.jsonl' --prefix gm --eval-n 400
python prep_data.py build --train gm_train.jsonl dev_train.jsonl --exclude dev_eval.jsonl gm_eval.jsonl test.jsonl --out train.jsonl
naist-nlp/SinhalaMMLU must not be used for training: the shared-task test set is built from it.
Only counts are printed, never question text.
"""
import argparse
import glob
import json
import re
from collections import Counter
from pathlib import Path
from smmlu import get_gold, get_level, get_options, get_question, load_records, loose_hash
MIN_STEM_CHARS = 30 # shorter stems ("Which is correct?") are too generic to match on alone
def download(args):
from datasets import get_dataset_config_names, load_dataset
out = Path(args.out)
out.mkdir(parents=True, exist_ok=True)
configs = args.configs or get_dataset_config_names(args.dataset)
for cfg in configs:
ds = load_dataset(args.dataset, cfg)
for split, d in ds.items():
path = out / f"{re.sub(r'[^A-Za-z0-9_.-]', '_', cfg)}__{split}.jsonl"
with open(path, "w", encoding="utf-8", newline="\n") as f:
for row in d:
f.write(json.dumps(row, ensure_ascii=False, default=str) + "\n")
print(f"{cfg}/{split}: {len(d)} rows fields={d.column_names}")
def gold_of(rec, options, zero_based):
if zero_based:
for k in ("answer", "label", "gold", "correct"):
v = str(rec.get(k, "")).strip()
if v in ("0", "1", "2", "3"):
return int(v) + 1
return get_gold(rec, options)
def build(args):
full_keys, stem_keys = set(), set()
for pattern in args.exclude:
for path in glob.glob(pattern):
for r in load_records(path):
try:
q, opts = get_question(r), get_options(r)
except KeyError:
continue
full_keys.add(loose_hash(q + "||" + "||".join(sorted(opts))))
if len(q) >= MIN_STEM_CHARS:
stem_keys.add(loose_hash(q))
print(f"exclusion keys: {len(full_keys)} question+options, {len(stem_keys)} long stems")
seen, rows, stats, per_source = set(), [], Counter(), Counter()
for pattern in args.train:
for path in sorted(glob.glob(pattern)):
for r in load_records(path):
try:
q, opts = get_question(r), get_options(r)
except KeyError:
stats["unparseable"] += 1
continue
if len(opts) != 4 or any(not o.strip() for o in opts):
stats["not 4 non-empty options"] += 1
continue
gold = gold_of(r, opts, args.zero_based)
if gold is None:
stats["no gold"] += 1
continue
full = loose_hash(q + "||" + "||".join(sorted(opts)))
if full in full_keys or (len(q) >= MIN_STEM_CHARS and loose_hash(q) in stem_keys):
stats["removed: overlaps dev/test"] += 1
continue
if full in seen:
stats["removed: duplicate"] += 1
continue
seen.add(full)
rows.append({"question": q, "options": opts, "answer": str(gold),
"subject": r.get("subject"), "level": get_level(r),
"source": Path(path).parent.name})
per_source[Path(path).parent.name] += 1
with open(args.out, "w", encoding="utf-8", newline="\n") as f:
for row in rows:
f.write(json.dumps(row, ensure_ascii=False) + "\n")
print(f"kept {len(rows)} -> {args.out}")
print("per source:", dict(per_source))
print("dropped:", dict(stats))
print("gold distribution:", dict(sorted(Counter(r["answer"] for r in rows).items())))
print("levels:", dict(Counter(r["level"] for r in rows)))
def merge(args):
"""Merge per-subject JSON files (<level>/<Subject>_<category>_<level>.json) into one JSONL.
Fields already in a record win; level, subject and category are filled from the
path only when a record lacks them.
"""
rows, filled = [], Counter()
files = sorted(f for pattern in args.files for f in glob.glob(pattern, recursive=True))
for path in files:
p = Path(path)
level = p.parent.name.lower()
stem_parts = p.stem.rsplit("_", 2)
for i, r in enumerate(load_records(path)):
r = dict(r)
if not (r.get("level") or r.get("difficulty")):
r["level"] = level
filled["level"] += 1
if not r.get("subject") and len(stem_parts) == 3:
r["subject"] = stem_parts[0]
filled["subject"] += 1
if not r.get("category") and len(stem_parts) == 3:
r["category"] = stem_parts[1]
filled["category"] += 1
r["_source_file"] = p.name
rows.append(r)
keys = Counter((get_level(r), str(r.get("subject")), str(r.get("q_no", r.get("id")))) for r in rows)
with open(args.out, "w", encoding="utf-8", newline="\n") as f:
for r in rows:
f.write(json.dumps(r, ensure_ascii=False) + "\n")
print(f"{len(files)} files, {len(rows)} records -> {args.out}")
print("filled from path (field missing in record):", dict(filled) or "none")
print("per level:", dict(Counter(get_level(r) for r in rows)))
print("(level, subject, q_no) keys unique:", all(c == 1 for c in keys.values()),
"| duplicate keys:", sum(c - 1 for c in keys.values() if c > 1))
def split(args):
"""Split a labelled file (one file or a glob) into train/eval parts, stratified by level.
--eval-n fixes the eval size (spread across levels in proportion); otherwise --train-frac is used.
"""
import random
recs_all = [r for f in sorted(glob.glob(args.data)) for r in load_records(f)]
by_level = {}
for r in recs_all:
by_level.setdefault(get_level(r), []).append(r)
rng = random.Random(args.seed)
parts = {"train": [], "eval": []}
for lvl, recs in sorted(by_level.items()):
rng.shuffle(recs)
if args.eval_n:
cut = len(recs) - round(args.eval_n * len(recs) / len(recs_all))
else:
cut = int(len(recs) * args.train_frac)
parts["train"] += recs[:cut]
parts["eval"] += recs[cut:]
print(f"{lvl or '(none)'}: {cut} train / {len(recs) - cut} eval")
for name, recs in parts.items():
path = f"{args.prefix}_{name}.jsonl"
with open(path, "w", encoding="utf-8", newline="\n") as f:
for r in recs:
f.write(json.dumps(r, ensure_ascii=False) + "\n")
print(f"wrote {len(recs)} -> {path}")
def main():
ap = argparse.ArgumentParser()
sub = ap.add_subparsers(dest="cmd", required=True)
p = sub.add_parser("merge")
p.add_argument("--files", nargs="+", required=True, help="glob patterns, e.g. 'raw/test/**/*.json'")
p.add_argument("--out", required=True)
p = sub.add_parser("split")
p.add_argument("--data", required=True)
p.add_argument("--prefix", required=True)
p.add_argument("--train-frac", type=float, default=0.5)
p.add_argument("--eval-n", type=int, default=0, help="fixed eval size instead of --train-frac")
p.add_argument("--seed", type=int, default=42)
p = sub.add_parser("download")
p.add_argument("--dataset", required=True)
p.add_argument("--configs", nargs="*")
p.add_argument("--out", required=True)
p = sub.add_parser("build")
p.add_argument("--train", nargs="+", required=True, help="files or glob patterns")
p.add_argument("--exclude", nargs="+", default=[], help="dev/test files whose questions must not be trained on")
p.add_argument("--zero-based", action="store_true", help="gold answers are 0-3 instead of 1-4/A-D")
p.add_argument("--out", required=True)
args = ap.parse_args()
{"download": download, "build": build, "split": split, "merge": merge}[args.cmd](args)
if __name__ == "__main__":
main()
|