File size: 14,101 Bytes
ec24fb0
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
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
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
"""Offline submission pipeline: question file(s) in, Codabench predictions.zip out. No human in the loop.

  python -m inference.predict --input <test dir | .json | .jsonl> --output predictions.zip

Runs with HF_HUB_OFFLINE=1 (no network); every setting comes from configs/submission.yaml.
Optional self-consistency (config `sc_levels`, `sc_samples`, `sc_gate`): greedy + K sampled reasoning paths, majority
vote; with `sc_gate: disagree` only for questions where the fast answer disagrees with the greedy thinking answer.
Every thinking answer (greedy first, then samples) is also written to <output>.ballots.jsonl (scripts/pool_ballots.py).
Optional LoRA adapter (config `adapter`) on top of `model`. Optional per-question thinking (config `thinking_levels`,
e.g. [hard]): questions whose own `level` field is listed are answered with the model's thinking mode (with
`thinking_without_adapter: true`, the adapter is switched off for them) (greedy, original option order); if no answer can be read from the output,
the fast answer is used. All other questions use the fast logit answer (voted over `n_perms` option orders).

Accepts the SinhalaMMLU JSON layout (list of {q_no, subject, category, question, choices, metadata.difficulty})
and JSONL; q_no / subject / category / level are echoed exactly as given. Writes <output>.run.json next to the
zip with the model commit, loaded parameter count, input hash and library versions.
"""
from __future__ import annotations

import argparse
import hashlib
import json
import os
import sys
import time
import zipfile
from collections import Counter
from pathlib import Path

sys.path.insert(0, str(Path(__file__).resolve().parents[1]))

LEVELS = ("easy", "medium", "hard")


def read_records(path: Path) -> list[tuple[Path, dict]]:
    files = sorted(p for p in path.rglob("*") if p.suffix in (".json", ".jsonl")
                   and not any(part.startswith(".") for part in p.relative_to(path).parts)) if path.is_dir() else [path]
    # (hidden dirs skipped: huggingface_hub keeps download metadata as JSON under .cache/)
    out = []
    for f in files:
        text = f.read_text(encoding="utf-8")
        rows = [json.loads(l) for l in text.splitlines() if l.strip()] if f.suffix == ".jsonl" else json.loads(text)
        out += [(f, r) for r in (rows if isinstance(rows, list) else [rows])]
    if not out:
        raise SystemExit(f"no questions found under {path}")
    return out


def to_item(f: Path, r: dict) -> dict:
    options = r.get("choices", r.get("options"))
    level = r.get("level") or (r.get("metadata") or {}).get("difficulty") \
        or next((p for p in f.parts[::-1] if p.lower() in LEVELS), None)
    missing = [k for k, v in {"q_no": r.get("q_no"), "subject": r.get("subject"), "category": r.get("category"),
                              "question": r.get("question"), "choices": options, "level": level}.items() if v is None]
    if missing:
        raise SystemExit(f"{f}: record {r.get('q_no')} lacks {missing}; keys present: {sorted(r)}")
    return {"uid": f"{level}/{r['subject']}/{r['q_no']}", "dataset": "test", "q_no": r["q_no"],
            "subject": r["subject"].strip(), "subject_raw": r["subject"], "category": r["category"], "level": level,
            "question": r["question"].strip(), "options": [str(o).strip() for o in options]}


def main() -> None:
    ap = argparse.ArgumentParser()
    ap.add_argument("--input", required=True, type=Path)
    ap.add_argument("--output", required=True, type=Path)
    ap.add_argument("--config", type=Path, default=Path(__file__).resolve().parents[1] / "configs" / "submission.yaml")
    ap.add_argument("--allow-online", action="store_true", help="debugging only; the submission runs offline")
    ap.add_argument("--limit", type=int, help="first N questions only (smoke tests; never for a submission)")
    ap.add_argument("--levels", nargs="+", help="answer only questions of these levels (then merge with scripts/merge_predictions.py)")
    args = ap.parse_args()
    if not args.allow_online:
        os.environ["HF_HUB_OFFLINE"] = os.environ["TRANSFORMERS_OFFLINE"] = "1"

    import yaml

    from eval.common import cap_gpu_memory, env_fingerprint, set_seed
    from eval.metrics import aggregate_items
    from eval.prompts import PromptBuilder
    from eval.run_eval import make_perm, score_items
    from eval.scorers import Scorer, load_model, loaded_param_stats

    cfg = yaml.safe_load(args.config.read_text())
    set_seed(cfg["seed"])
    cap_gpu_memory(cfg.get("gpu_mem_gb"))

    raw = read_records(args.input)
    items = {}
    for f, r in raw:
        it = to_item(f, r)
        if it["uid"] in items:
            raise SystemExit(f"duplicate question id {it['uid']}")
        items[it["uid"]] = it
    if args.levels:
        items = {u: it for u, it in items.items() if it["level"] in set(args.levels)}
    if args.limit:
        items = dict(list(items.items())[: args.limit])
    print(f"{len(items)} questions from {args.input}")

    t0 = time.time()
    root = Path(__file__).resolve().parents[1]
    model_path = str(root / cfg["model"]) if (root / cfg["model"]).exists() else cfg["model"]
    adapter = cfg.get("adapter")
    adapter = str(root / adapter) if adapter and (root / adapter).exists() else adapter
    model, tok = load_model(model_path, cfg.get("revision"), cfg.get("dtype", "bfloat16"), adapter)
    pb = PromptBuilder(tok, lang=cfg["lang"], chat=cfg["chat"])
    scorer = Scorer(model, tok, pb.add_special_tokens, cfg.get("token_budget", 32768), cfg.get("max_batch", 64))
    if cfg.get("fast_without_adapter") and hasattr(model, "disable_adapter"):
        with model.disable_adapter():  # fast one-step answer from the plain base model (used by the agreement gate)
            records, _ = score_items(items, pb, scorer, cfg["method"], cfg["n_perms"], cfg["perm_mode"], cfg["seed"])
    else:
        records, _ = score_items(items, pb, scorer, cfg["method"], cfg["n_perms"], cfg["perm_mode"], cfg["seed"])
    agg = aggregate_items(items, records)

    # Thinking mode for selected levels (decided per question from its own level field)
    think_levels = set(cfg.get("thinking_levels") or [])
    think_uids = [u for u, it in items.items() if it["level"] in think_levels]
    think_pred, fallbacks, sc_changed, sc_count = {}, 0, 0, 0
    perm_levels, k_perm = set(cfg.get("perm_think_levels") or []), int(cfg.get("perm_think_orders", 0))
    perm_uids = [u for u, it in items.items() if it["level"] in perm_levels] if k_perm > 0 else []
    perm_changed = 0
    if think_uids or perm_uids:
        tpb = PromptBuilder(tok, lang=cfg["lang"], chat=cfg["chat"], chat_kwargs={"enable_thinking": True})
        tscorer = Scorer(model, tok, tpb.add_special_tokens, cfg.get("token_budget", 32768), cfg.get("max_batch", 64),
                         max_new_tokens=cfg.get("think_max_new_tokens", 2048))

        def think_prompts(prompts: list[str], n_opts: list[int]) -> list[dict]:
            if cfg.get("thinking_without_adapter") and hasattr(model, "disable_adapter"):
                with model.disable_adapter():  # base model reasons; the adapter (trained to answer at once) does not
                    return tscorer.generate(prompts, n_opts)
            return tscorer.generate(prompts, n_opts)

        def think(uids: list[str]) -> list[dict]:
            return think_prompts([tpb.mcq(items[u], items[u]["options"]) for u in uids],
                                 [len(items[u]["options"]) for u in uids])

        for u, r in (zip(think_uids, think(think_uids)) if think_uids else []):
            if r["pred"]:
                think_pred[u] = r["pred"]
            else:
                fallbacks += 1
        print(f"thinking: {len(think_uids)} questions ({sorted(think_levels)}), {fallbacks} fell back to the fast answer")
        all_ballots = {u: [think_pred.get(u)] for u in think_uids}  # saved to <output>.ballots.jsonl for cross-model pooling

        # Self-consistency for selected levels: greedy answer + K sampled reasoning paths, majority vote,
        # ties go to the greedy answer. Fixed seed: reproducible for the same input, code and hardware.
        sc_levels, k = set(cfg.get("sc_levels") or []), int(cfg.get("sc_samples", 0))
        sc_uids = [u for u in think_uids if items[u]["level"] in sc_levels]
        if cfg.get("sc_gate") == "disagree":  # only where the free one-step answer disagrees with greedy thinking
            sc_uids = [u for u in sc_uids if think_pred.get(u) != agg[u]["pred"]]
        sc_count = len(sc_uids) if k > 0 else 0
        if sc_uids and k > 0:
            import torch

            torch.manual_seed(cfg["seed"])
            tscorer.sampling = {"do_sample": True, "temperature": cfg.get("sc_temperature", 0.8),
                                "top_p": cfg.get("sc_top_p", 0.95), "top_k": cfg.get("sc_top_k", 64)}
            ballots = {u: [think_pred.get(u)] for u in sc_uids}
            for _ in range(k):
                for u, r in zip(sc_uids, think(sc_uids)):
                    ballots[u].append(r["pred"])
                    all_ballots[u].append(r["pred"])
            tscorer.sampling = {}
            for u, b in ballots.items():
                counts = Counter(x for x in b if x)
                if not counts:
                    continue
                top = max(counts.values())
                winners = [a for a, c in counts.items() if c == top]
                choice = b[0] if b[0] in winners else min(winners)
                sc_changed += choice != think_pred.get(u)
                think_pred[u] = choice
            print(f"self-consistency: {len(sc_uids)} questions ({sorted(sc_levels)}, gate={cfg.get('sc_gate')}), greedy + {k} samples, "
                  f"{sc_changed} answers changed by the vote")

        # Option-order self-consistency: re-ask with the options in K other (cyclic) orders, thinking each time; the vote is over
        # the previous answer (the `prior_predictions` file if given, else the answer so far) and the K re-orderings, ties
        # go to the previous answer. Votes are mapped back to the original option numbering.
        if perm_uids:
            prior = {}
            if cfg.get("prior_predictions"):
                with zipfile.ZipFile(root / cfg["prior_predictions"]) as z:
                    for line in z.read("predictions.jsonl").decode("utf-8").splitlines():
                        r = json.loads(line)
                        prior[(r["level"], r["subject"], r["q_no"])] = int(r["answer"])
            ballots = {u: [prior.get((items[u]["level"], items[u]["subject_raw"], items[u]["q_no"]), think_pred.get(u))]
                       for u in perm_uids}
            for kk in range(1, k_perm + 1):
                perms = {u: make_perm(len(items[u]["options"]), kk, "cyclic", cfg["seed"], u) for u in perm_uids}
                res = think_prompts([tpb.mcq(items[u], [items[u]["options"][j] for j in perms[u]]) for u in perm_uids],
                                    [len(items[u]["options"]) for u in perm_uids])
                for u, r in zip(perm_uids, res):
                    ballots[u].append(perms[u][r["pred"] - 1] + 1 if r["pred"] else None)
                print(f"option-order thinking: order {kk}/{k_perm} done ({len(perm_uids)} questions)", flush=True)
            for u, b in ballots.items():
                counts = Counter(x for x in b if x)
                if not counts:
                    continue
                top = max(counts.values())
                winners = [a for a, c in counts.items() if c == top]
                choice = b[0] if b[0] in winners else min(winners)
                perm_changed += choice != b[0]
                think_pred[u] = choice
            print(f"option-order self-consistency: {len(perm_uids)} questions ({sorted(perm_levels)}), {k_perm} extra orders, "
                  f"{perm_changed} answers changed by the vote")

    rows = []
    for uid, it in items.items():
        n = len(it["options"])
        ans = think_pred.get(uid, agg[uid]["vote"])
        if ans is None or not 1 <= ans <= n:
            raise SystemExit(f"no valid prediction for {uid}")
        rows.append({"q_no": it["q_no"], "subject": it["subject_raw"], "category": it["category"],
                     "level": it["level"], "answer": str(ans)})

    args.output.parent.mkdir(parents=True, exist_ok=True)
    with zipfile.ZipFile(args.output, "w", zipfile.ZIP_DEFLATED) as z:
        z.writestr("predictions.jsonl", "".join(json.dumps(r, ensure_ascii=False) + "\n" for r in rows))
    if think_uids:  # greedy + sampled thinking answers per question (None = unparsed), first = greedy
        Path(str(args.output) + ".ballots.jsonl").write_text("".join(
            json.dumps({"level": items[u]["level"], "subject": items[u]["subject_raw"], "q_no": items[u]["q_no"],
                        "ballots": all_ballots[u]}, ensure_ascii=False) + "\n" for u in think_uids), encoding="utf-8")
    digest = hashlib.sha256()
    for f in sorted({str(f) for f, _ in raw}):
        digest.update(Path(f).read_bytes())
    run = {"config": cfg, "n_questions": len(rows), "seconds": round(time.time() - t0, 1),
           "thinking": {"levels": sorted(think_levels), "questions": len(think_uids), "fallbacks": fallbacks},
           "self_consistency": {"levels": sorted(set(cfg.get("sc_levels") or [])), "samples": int(cfg.get("sc_samples", 0)),
                                "gate": cfg.get("sc_gate"), "questions": sc_count, "answers_changed": sc_changed},
           "option_order_sc": {"levels": sorted(perm_levels), "orders": k_perm, "questions": len(perm_uids),
                               "answers_changed": perm_changed},
           "input_sha256": digest.hexdigest(), "params": loaded_param_stats(model), "env": env_fingerprint()}
    Path(str(args.output) + ".run.json").write_text(json.dumps(run, indent=2, ensure_ascii=False))
    print(f"wrote {len(rows)} predictions -> {args.output} "
          f"({run['params']['loaded_total'] / 1e9:.3f}B parameters loaded, {run['seconds']:.0f}s)")


if __name__ == "__main__":
    main()