File size: 8,487 Bytes
e55c8a4
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
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
#!/usr/bin/env python
"""
Make the `subject` field consistent across the dataset.

Why it matters: build-sft puts the subject INTO the prompt ("විෂය: {subject}" / "Subject: {subject}"),
and the organisers' prompt always supplies the SINHALA subject name (metadata.subject_original, e.g.
"චිත්‍ර" for Arts). If some training rows carry an English or short name ("Dancing", "සිංහල") the model
sees a label at train time that never appears at test time, for exactly those subjects.

Three steps:

  1. see what the official test files actually use (the authoritative names):
         python normalize_subjects.py --test_dir TEST/easy
  2. see what your dataset currently uses:
         python normalize_subjects.py --inp data/all_converted.jsonl --list
  3. rewrite with the mapping below (edit MAPPING first if step 1 disagrees):
         python normalize_subjects.py --inp data/all_converted.jsonl --out data/all_converted_norm.jsonl

Nothing else in the record is touched. Use --map_file to pass your own {"from": "to"} JSON instead.
"""
import argparse
import collections
import glob
import json
import os
import sys

# English / short / variant name  ->  the SINHALA name the official test files use as
# metadata.subject_original. These were read off TEST/easy, not guessed.
MAPPING = {
    # --- corrections to names already in the dataset -------------------------------------
    "සිංහල භාෂාව හා සාහිත්‍යය": "සිංහල භාෂාව සහ සාහිත්‍යය",   # හා -> සහ
    "සෞඛ්‍ය හා ශාරීරික අධ්‍යාපනය": "සෞඛ්‍ය හා ශාරීරික විද්‍යාව",  # අධ්‍යාපනය -> විද්‍යාව
    "නැටුම්": "නර්තනය",
    "සිංහල": "සිංහල භාෂාව සහ සාහිත්‍යය",
    # --- English / variant labels from newer extractions ---------------------------------
    "Arts": "චිත්‍ර",
    "Art": "චිත්‍ර",
    "art": "චිත්‍ර",
    "Dancing": "නර්තනය",
    "dancing": "නර්තනය",
    "Buddhism": "බුද්ධ ධර්මය",
    "Sinhala": "සිංහල භාෂාව සහ සාහිත්‍යය",
    "Sinhala language and literature": "සිංහල භාෂාව සහ සාහිත්‍යය",
    "Sinhala_language_and_literature": "සිංහල භාෂාව සහ සාහිත්‍යය",
    "Eastern music": "පෙරදිග සංගීතය",
    "Eastern_music": "පෙරදිග සංගීතය",
    "drama and Theatre": "නාට්‍ය හා රංග කලාව",
    "drama_and_theatre": "නාට්‍ය හා රංග කලාව",
    "drama and theatre": "නාට්‍ය හා රංග කලාව",
    "drama": "නාට්‍ය හා රංග කලාව",
    "drama and theater": "නාට්‍ය හා රංග කලාව",
    "theatre and drama": "නාට්‍ය හා රංග කලාව",
    "eastern music": "පෙරදිග සංගීතය",
    "Health and Physical Science": "සෞඛ්‍ය හා ශාරීරික විද්‍යාව",
    "History": "ඉතිහාසය",
    "Civics": "පුරවැසි අධ්‍යාපනය",
    "Geography": "භූගෝල විද්‍යාව",
    "Catholicism": "කතෝලික ධර්මය",
    "Christianity": "ක්‍රිස්තියානි ධර්මය",
    "Islam": "ඉස්ලාම් ධර්මය",
    "science": "විද්‍යාව",
    "Science": "විද්‍යාව",
}


def load_map(path):
    """Read a {"from": "to"} JSON file, tolerating a UTF-8 BOM or UTF-16 (what Windows editors write)."""
    raw = open(path, "rb").read()
    if not raw.strip():
        sys.exit(f"{path} is empty")
    for enc in ("utf-8-sig", "utf-16", "utf-8"):
        try:
            txt = raw.decode(enc)
        except (UnicodeDecodeError, UnicodeError):
            continue
        try:
            m = json.loads(txt.lstrip("\ufeff"))
        except json.JSONDecodeError:
            continue
        if not isinstance(m, dict):
            sys.exit(f"{path} must contain a JSON object of {{\"from\": \"to\"}} pairs")
        print(f"[map] {len(m)} override(s) read from {path} ({enc})")
        return {str(k): str(v) for k, v in m.items()}
    sys.exit(f"could not parse {path} as JSON in utf-8/utf-8-sig/utf-16. First bytes: {raw[:40]!r}")


def read_jsonl(path):
    rows = []
    with open(path, encoding="utf-8-sig") as f:
        for line in f:
            line = line.strip()
            if line:
                rows.append(json.loads(line))
    return rows


def show_test_names(test_dir):
    """Print the subject / subject_original pairs the official test files use."""
    files = sorted(glob.glob(os.path.join(test_dir, "**", "*.json"), recursive=True))
    if not files:
        sys.exit(f"no .json files under {test_dir}")
    c = collections.Counter()
    for p in files:
        try:
            data = json.load(open(p, encoding="utf-8"))
        except Exception as e:                                           # noqa: BLE001
            print(f"[warn] {os.path.basename(p)}: {type(e).__name__}")
            continue
        if isinstance(data, list):
            for it in data:
                md = it.get("metadata") or {}
                c[(it.get("subject"), md.get("subject_original"))] += 1
    print(f"official test subjects in {test_dir} "
          f"(subject_original is what goes into THEIR prompt):")
    print(f"{'n':>6}  {'subject':<34}{'subject_original'}")
    for (s, so), n in sorted(c.items(), key=lambda x: -x[1]):
        print(f"{n:>6}  {str(s):<34}{so}")


def main():
    ap = argparse.ArgumentParser()
    ap.add_argument("--inp", default="")
    ap.add_argument("--out", default="")
    ap.add_argument("--list", action="store_true", help="just print the current subject counts")
    ap.add_argument("--test_dir", default="", help="print the official test files' subject names")
    ap.add_argument("--map_file", default="", help='JSON file of {"from": "to"} overrides')
    args = ap.parse_args()
    for stream in (sys.stdout, sys.stderr):
        try:
            stream.reconfigure(encoding="utf-8")
        except Exception:                                                # noqa: BLE001
            pass

    if args.test_dir:
        show_test_names(args.test_dir)
        if not args.inp:
            return
        print()

    if not args.inp:
        sys.exit("give --inp (and --out, unless --list)")
    rows = read_jsonl(args.inp)
    before = collections.Counter(r.get("subject") or "?" for r in rows)

    if args.list or not args.out:
        print(f"{len(rows)} rows in {args.inp}; subjects:")
        for s, n in before.most_common():
            flag = "  <-- would be renamed" if s in MAPPING else ""
            print(f"{n:>6}  {s}{flag}")
        if not args.out:
            return

    mapping = dict(MAPPING)
    if args.map_file:
        mapping.update(load_map(args.map_file))

    ci = {k.lower(): v for k, v in mapping.items()}
    changed = collections.Counter()
    for r in rows:
        s = r.get("subject")
        # normalise stray whitespace too: the official files contain "drama and Theatre " with a
        # trailing space, which would otherwise be a separate bucket
        if isinstance(s, str):
            s2 = " ".join(s.split())
            if s2 != s:
                r["subject"] = s = s2
        tgt = mapping.get(s)
        if tgt is None and isinstance(s, str):
            tgt = ci.get(s.lower())          # Sinhala has no case, so lowering is safe
        if tgt and tgt != s:
            changed[(s, tgt)] += 1
            r["subject"] = tgt

    with open(args.out, "w", encoding="utf-8") as f:
        for r in rows:
            f.write(json.dumps(r, ensure_ascii=False) + "\n")

    if changed:
        print("renamed:")
        for (a, b), n in changed.most_common():
            print(f"{n:>6}  {a!r} -> {b!r}")
    else:
        print("nothing to rename (subjects already consistent)")
    after = collections.Counter(r.get("subject") or "?" for r in rows)
    print(f"\n[out] {len(rows)} rows -> {args.out}")
    print(f"distinct subjects: {len(before)} -> {len(after)}")


if __name__ == "__main__":
    main()