Sinhala
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()