File size: 6,114 Bytes
7a2335a
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
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
"""Loading, text normalisation and option permutation.

Every row gets:
  level   easy | medium | hard (public train is all easy)
  file    source JSON file name; one file = one subject, so this is the subject key used for stratification
  uid     "<level>/<file>/q<q_no>", unique, used to join predictions back to rows
Test rows have no `answer`. Submission keys (q_no, subject, category, level) are always copied verbatim from the row,
because the subject strings are inconsistent inside the files (e.g. "Eastern_Music" and "Eastern Music").
"""
import glob
import json
import os
import random
import re
import unicodedata
from pathlib import Path

ROOT = Path(__file__).resolve().parents[1]
TRAIN_DIR = ROOT / "data" / "public train"
TEST_DIR = ROOT / "data" / "public test"
ZWNJ, ZWJ = "‌", "‍"
LEVELS = ("easy", "medium", "hard")


def path(p):
    p = Path(p)
    return p if p.is_absolute() else ROOT / p


# ----------------------------------------------------------------------------- normalisation
def norm_model(text):
    """Model input: NFC, drop ZWNJ only (a legacy-font artefact, far more common in test than in train), keep ZWJ
    (needed for conjuncts such as ප්‍ර), tidy spaces, keep newlines so sub-option lists stay readable."""
    text = unicodedata.normalize("NFC", text).replace(ZWNJ, "")
    text = re.sub(r"[^\S\n]+", " ", text)
    text = re.sub(r" ?\n ?", "\n", text)
    return re.sub(r"\n{3,}", "\n\n", text).strip()


def norm_dedup(text):
    """Dedup only: NFC, drop ZWJ/ZWNJ, punctuation and symbols to spaces, lowercase Latin, collapse spaces."""
    text = unicodedata.normalize("NFC", text).replace(ZWJ, "").replace(ZWNJ, "")
    text = "".join(" " if unicodedata.category(c)[0] in "PS" else c for c in text)
    return re.sub(r"\s+", " ", text.lower()).strip()


def subject_si(row):
    """Sinhala subject name for prompts (never for submission keys)."""
    name = row.get("subject_si") or row.get("metadata", {}).get("subject_original") or row["subject"]
    return " ".join(norm_model(name).split())


def subject_en(row):
    return " ".join(row["subject"].replace("_", " ").split())


def subject_family(row):
    """Coarse subject key shared by train and test spellings ("Arts"/"Art", "Health and Physical Science"/"Health",
    "Sinhala language and literature"/"Sinhala"): first three letters of the English subject name."""
    return re.sub(r"[^a-z]", "", row["subject"].lower())[:3]


# ----------------------------------------------------------------------------- io
def load_dir(d, level=None):
    """All *.json under `d` (recursive). `level` overrides metadata.difficulty (public train has none)."""
    rows = []
    for f in sorted(glob.glob(os.path.join(str(path(d)), "**", "*.json"), recursive=True)):
        with open(f, encoding="utf-8") as fh:
            for r in json.load(fh):
                r = dict(r)
                r["level"] = level or r.get("metadata", {}).get("difficulty", "unknown").lower()
                r["file"] = os.path.basename(f)
                r["uid"] = f"{r['level']}/{r['file']}/q{r['q_no']}"
                rows.append(r)
    uids = [r["uid"] for r in rows]
    assert len(set(uids)) == len(uids), f"duplicate (level, file, q_no) in {d}"
    return rows


def load_train():
    return load_dir(TRAIN_DIR, level="easy")


def load_test():
    return load_dir(TEST_DIR)


def load_jsonl(p):
    with open(path(p), encoding="utf-8") as fh:
        return [json.loads(l) for l in fh if l.strip()]


def write_jsonl(p, rows):
    p = path(p)
    p.parent.mkdir(parents=True, exist_ok=True)
    with open(p, "w", encoding="utf-8", newline="\n") as fh:
        for r in rows:
            fh.write(json.dumps(r, ensure_ascii=False) + "\n")


def load_rows(spec):
    """'test' -> public test; anything else -> a dev .jsonl written by sinmmlu.build_dev."""
    return load_test() if spec == "test" else load_jsonl(spec)


def split_by_subject(rows, frac, seed=42):
    """Validation split of training data (rows with q_no, subject, question): `frac` of each subject, at least 1 for
    subjects with >= 10 questions, sampled with Random(seed) over the subjects in sorted order. Validation questions
    whose text (norm_dedup) also occurs in the training part move to train, so no question text is in both.
    Returns (train, val), each in the input order."""
    import collections
    rng = random.Random(seed)
    by = collections.defaultdict(list)
    for r in rows:
        by[r["subject"]].append(r)
    val_ids = set()
    for s in sorted(by):
        n = len(by[s])
        k = max(1, round(frac * n)) if n >= 10 else 0
        val_ids.update(r["q_no"] for r in rng.sample(by[s], k))
    train_q = {norm_dedup(r["question"]) for r in rows if r["q_no"] not in val_ids}
    val = [r for r in rows if r["q_no"] in val_ids and norm_dedup(r["question"]) not in train_q]
    vset = {r["q_no"] for r in val}
    return [r for r in rows if r["q_no"] not in vset], val


def submission_key(r):
    return (r["level"], r["subject"], r["category"], r["q_no"])


# ----------------------------------------------------------------------------- options
def permute_options(row, perm):
    """perm[i] = original index shown at position i. The answer (if present) is remapped; translated options
    (`choices_en`) move with the originals."""
    new = dict(row)
    for k in ("choices", "choices_en"):
        if k in row:
            new[k] = [row[k][j] for j in perm]
    if row.get("answer") is not None:
        new["answer"] = perm.index(int(row["answer"]) - 1) + 1
    return new


def cyclic_perms(n=4):
    return [[(i + s) % n for i in range(n)] for s in range(n)]


def drop_random_wrong_option(row, rng: random.Random):
    """5 -> 4 options by removing one random wrong option (as in the paper, section 6)."""
    ans = int(row["answer"]) - 1
    drop = rng.choice([i for i in range(len(row["choices"])) if i != ans])
    keep = [i for i in range(len(row["choices"])) if i != drop]
    new = dict(row)
    new["choices"] = [row["choices"][i] for i in keep]
    new["answer"] = keep.index(ans) + 1
    return new