File size: 19,596 Bytes
026f63e
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
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
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
408
409
410
411
412
413
414
415
416
417
"""`minijev` command line.

    minijev hardware
    minijev train     --config configs/snake-small.yaml [--resume RUN_DIR] [--set key=value ...]
    minijev evaluate  --checkpoint RUN/checkpoints/final.pt [--games 200]
    minijev benchmark --checkpoint RUN/checkpoints/final.pt [--onnx]
    minijev export    --checkpoint RUN/checkpoints/final.pt --format onnx
    minijev estimate  --config configs/vast/vast-small.yaml --gpu-hourly-cost 0.20
    minijev compare   RUN_DIR [RUN_DIR ...]
    minijev runs annotate RUN_DIR --actual-cost 0.42
    minijev export-hf --checkpoint X.pt --out hf/name     (checkpoint may then be a folder or a hub repo id)
    minijev relabel   --checkpoint X.pt --out Y.pt --map UP="move up" ... [--set task.choice_style=semantic]
    minijev ask       --checkpoint X.pt --state hotdog --question "Is this food?" --choices yes no
    minijev bench-jsonl --checkpoint X.pt --data data/general_qa/benchmark.jsonl
    minijev snake generate|baselines|demo|play|dagger|rl ...
    minijev sentiment prepare ...
"""
from __future__ import annotations

import argparse
import json
import sys
from pathlib import Path


def _cfg(args):
    from minijev.config import load_config
    return load_config(args.config, getattr(args, "set", None) or [])


# ------------------------------------------------------------------ generic
def cmd_hardware(args):
    import torch

    from minijev.device import resolve_runtime
    prof = resolve_runtime({"batch_size": args.batch_size}, args.params)
    print(json.dumps({"torch": torch.__version__, **prof.to_dict()}, indent=2))


def cmd_train(args):
    from minijev.training.supervised import train
    cfg = _cfg(args)
    run_dir = args.resume or args.run_dir
    s = train(cfg, run_dir=run_dir, resume=bool(args.resume), max_steps=args.max_steps, max_minutes=args.max_minutes)
    print(json.dumps({k: v for k, v in s.items() if not k.startswith("test_")}, indent=2, default=str))
    if cfg["task"]["name"] == "snake" and s.get("final_checkpoint") and not args.no_games:
        _snake_eval(s["final_checkpoint"], cfg, Path(s["run_dir"]))


def _snake_eval(ckpt, cfg, run_dir: Path, games: int | None = None):
    from environments.snake.evaluate import model_policy, play_games
    from minijev.inference.runtime import MiniJEV
    dec = MiniJEV.load(ckpt, threads=4)
    env = cfg.get("env", {})
    n = games or cfg.get("eval", {}).get("games", 200)
    res = play_games(model_policy(dec), n, env, parallel=cfg.get("eval", {}).get("parallel", 64))
    print(f"[games] {res['games']} games on {res['board']}: avg {res['avg_score']:.1f} median {res['median_score']:.0f} "
          f"max {res['max_score']} / {res['max_possible_score']} | win {res['win_rate']:.2%} "
          f"collision {res['death_rate']:.2%} starvation {res['starvation_rate']:.2%} | "
          f"fatal-move rate {res['fatal_move_rate']:.4%}")
    (run_dir / "games.json").write_text(json.dumps(res, indent=2))
    return res


def cmd_evaluate(args):
    import numpy as np

    from minijev.calibration.metrics import calibration_report
    from minijev.datasets.batching import BatchEncoder
    from minijev.datasets.decision_data import DecisionData
    from minijev.inference.runtime import MiniJEV
    dec = MiniJEV.load(args.checkpoint, threads=args.threads)
    import torch
    cfg = torch.load(args.checkpoint, weights_only=False)["config"] if args.checkpoint.endswith(".pt") else None
    data_dir = Path(args.data or (cfg["data"]["dir"] if cfg else ""))
    out = {"checkpoint": args.checkpoint}
    split = data_dir / f"{args.split}.npz"
    if split.exists():
        data = DecisionData.load(split)
        enc = BatchEncoder(data, dec.task, dec.max_question_len, dec.max_choice_len)
        logits = []
        for i in range(0, len(data), 1024):
            b = enc(np.arange(i, min(len(data), i + 1024)))
            logits.append(dec._run(next(v for k, v in b.items() if k.startswith("state_")),
                                   b["question_ids"], b["choice_ids"])[1])
        acceptable = data.soft > 0 if data.soft is not None else None
        rep = calibration_report(np.concatenate(logits), data.target, acceptable=acceptable)  # T already applied
        out["calibration"] = {k: v for k, v in rep.items() if k != "reliability"}
        out["reliability"] = rep["reliability"]
        print(f"[{args.split}] n={rep['n']} acc {rep['accuracy']:.4f} ECE {rep['ece']:.4f} "
              f"NLL {rep['nll']:.4f} Brier {rep['brier']:.4f}"
              + (f" acceptable-acc {rep['acceptable_accuracy']:.4f}" if acceptable is not None else ""))
    if dec.task.name == "snake" and args.games:
        from environments.snake.evaluate import model_policy, play_games
        env = (cfg or {}).get("env", {})
        if args.board_size:
            env = {**env, "board_size": args.board_size}
        res = play_games(model_policy(dec), args.games, env)
        out["games"] = res
        print(f"[games] avg {res['avg_score']:.2f} median {res['median_score']} max {res['max_score']} "
              f"win {res['win_rate']:.2%} collisions {res['death_rate']:.2%} fatal-move {res['fatal_move_rate']:.4%}")
    if args.out:
        Path(args.out).write_text(json.dumps(out, indent=2))


def cmd_benchmark(args):
    from minijev.benchmark.perf import benchmark_decider, format_perf
    paths = [args.checkpoint]
    if args.onnx:
        p = Path(args.checkpoint)
        for cand in (p.with_suffix(".onnx"), p.with_name(p.stem + ".int8.onnx")):
            if cand.exists():
                paths.append(str(cand))
    results = []
    for path in paths:
        r = benchmark_decider(path, threads=args.threads, quick=args.quick)
        print(format_perf(r))
        results.append(r)
    if args.out:
        Path(args.out).write_text(json.dumps(results, indent=2))


def cmd_export(args):
    from minijev.export.onnx_export import export_onnx
    if args.format != "onnx":
        sys.exit("only --format onnx is supported")
    r = export_onnx(args.checkpoint, args.out, quantize=not args.no_quantize)
    print(json.dumps({k: v for k, v in r.items() if k != "meta"}, indent=2))


def cmd_estimate(args):
    from minijev.estimate import estimate
    estimate(_cfg(args), args.gpu_hourly_cost, throughput=args.throughput, bench_seconds=args.bench_seconds,
             gpu_name=args.gpu_name, budget=args.budget)


def cmd_relabel(args):
    from minijev.config import _parse_value
    from minijev.training.relabel import relabel_checkpoint
    mapping = dict(m.split("=", 1) for m in args.map)
    updates = {k: _parse_value(v) for k, v in (kv.split("=", 1) for kv in (args.set or []))}
    print(json.dumps(relabel_checkpoint(args.checkpoint, args.out, mapping, updates, steps=args.steps), indent=2))


def cmd_export_hf(args):
    from minijev.hub import save_pretrained
    print(save_pretrained(args.checkpoint, args.out))


def cmd_ask(args):
    from minijev.inference.runtime import MiniJEV
    dec = MiniJEV.load(args.checkpoint, threads=args.threads, **({"task": args.task} if args.task else {}))
    d = dec.decide(args.state, args.question, args.choices)
    print(json.dumps(d.to_dict(), ensure_ascii=False, indent=2))


def cmd_bench_jsonl(args):
    """Accuracy + calibration of a model on a JSONL decision file, row by row."""
    import numpy as np

    from minijev.calibration.metrics import calibration_report
    from minijev.datasets.jsonl import read_jsonl
    from minijev.inference.runtime import MiniJEV
    dec = MiniJEV.load(args.checkpoint, threads=args.threads, **({"task": args.task} if args.task else {}))
    rows = read_jsonl(args.data)
    ok, logits, targets, out = 0, [], [], []
    for r in rows:
        d = dec.decide(r.get("state") or "", r["question"], r["choices"])
        tgt = r["target"] if isinstance(r["target"], str) else r["choices"][r["target"]]
        hit = d.choice == tgt
        ok += hit
        n = max(len(x["choices"]) for x in rows)
        logits.append(np.pad(np.array(d.logits), (0, n - len(d.logits)), constant_values=-1e9))
        targets.append(r["choices"].index(tgt))
        out.append({**r, "pred": d.choice, "confidence": round(d.confidence, 4), "correct": bool(hit)})
        if args.verbose:
            print(f"{'OK ' if hit else 'XX '} {d.confidence:.2f} {r.get('state') or '-':>10} | {r['question']} "
                  f"-> {d.choice}  (gold {tgt})")
    rep = calibration_report(np.stack(logits), np.array(targets))  # logits are already calibrated
    print(f"[{args.data}] {ok}/{len(rows)} = {ok / len(rows):.3f} | ECE {rep['ece']:.4f} NLL {rep['nll']:.4f} "
          f"Brier {rep['brier']:.4f}")
    if args.out:
        Path(args.out).write_text(json.dumps({"accuracy": ok / len(rows), "ece": rep["ece"], "nll": rep["nll"],
                                              "brier": rep["brier"], "rows": out}, indent=2, ensure_ascii=False))


def cmd_compare(args):
    from minijev.report import compare_runs
    print(compare_runs(args.runs, out=args.out))


def cmd_runs_annotate(args):
    p = Path(args.run) / "summary.json"
    s = json.loads(p.read_text())
    s.setdefault("cost", {})
    if args.actual_cost is not None:
        s["cost"]["actual_cost"] = args.actual_cost
    if args.gpu:
        s["cost"]["gpu"] = args.gpu
    if args.note:
        s["note"] = args.note
    p.write_text(json.dumps(s, indent=2))
    print(json.dumps(s.get("cost"), indent=2))


# -------------------------------------------------------------------- snake
def cmd_snake_generate(args):
    from environments.snake.dataset import generate_splits
    sizes = [int(x) for x in str(args.board_size).split(",")]
    out = args.out or f"data/snake{'-'.join(map(str, sizes))}-{_short(args.samples)}"
    s = generate_splits(out, args.samples, seed=args.seed, board_sizes=sizes, keep_prob=args.keep_prob,
                        explore_eps=args.explore_eps, workers=args.workers)
    if args.jsonl:
        from environments.snake.dataset import export_jsonl
        from minijev.datasets.decision_data import DecisionData
        for split in ("train", "val", "test"):
            export_jsonl(DecisionData.load(Path(out) / f"{split}.npz"), Path(out) / f"{split}.jsonl")
    print(json.dumps(s, indent=2))
    print(f"-> {out}")


def _short(n: int) -> str:
    return f"{n // 1_000_000}m" if n >= 1_000_000 and n % 1_000_000 == 0 else (f"{n // 1000}k" if n >= 1000 else str(n))


def cmd_snake_baselines(args):
    from environments.snake.evaluate import baseline_suite
    res = baseline_suite(args.games, {"board_size": args.board_size}, seed=args.seed)
    print(f"{'agent':10} {'avg':>7} {'median':>7} {'max':>5} {'win':>6} {'collide':>8} {'starve':>7} {'fatal%':>8}")
    for name, r in res.items():
        print(f"{name:10} {r['avg_score']:7.1f} {r['median_score']:7.0f} {r['max_score']:5d} {r['win_rate']:6.1%} "
              f"{r['death_rate']:8.1%} {r['starvation_rate']:7.1%} {r['fatal_move_rate']:8.3%}")
    if args.out:
        Path(args.out).write_text(json.dumps(res, indent=2))


def cmd_snake_demo(args, human=False):
    from environments.snake.demo.server import serve
    serve(model=args.model, board_size=args.board_size, port=args.port, agent="human" if human else args.agent,
          open_browser=not args.no_browser)


def cmd_snake_dagger(args):
    from environments.snake.dagger import run_dagger
    run_dagger(_cfg(args), args.checkpoint, iterations=args.iterations, samples=args.samples, run_dir=args.run_dir)


def cmd_snake_rl(args):
    from environments.snake.rl import train_ppo
    train_ppo(_cfg(args), args.checkpoint, run_dir=args.run_dir, resume=args.resume)


def cmd_sentiment_prepare(args):
    from environments.sentiment.dataset import prepare
    print(json.dumps(prepare(args.out, source=args.source), indent=2))


# --------------------------------------------------------------------- main
def build_parser() -> argparse.ArgumentParser:
    p = argparse.ArgumentParser(prog="minijev", description="MiniJEV: tiny calibrated decision model")
    sub = p.add_subparsers(dest="cmd", required=True)

    s = sub.add_parser("hardware", help="show detected device and runtime profile")
    s.add_argument("--batch-size", type=int, default=256)
    s.add_argument("--params", type=int, default=1_000_000)
    s.set_defaults(fn=cmd_hardware)

    s = sub.add_parser("train", help="supervised / distillation training")
    s.add_argument("--config", required=True)
    s.add_argument("--set", nargs="*", help="override config values, e.g. training.lr=5e-4")
    s.add_argument("--run-dir")
    s.add_argument("--resume", help="run directory to resume from (uses checkpoints/last.pt)")
    s.add_argument("--max-steps", type=int)
    s.add_argument("--max-minutes", type=float, help="stop cleanly (checkpoint saved) after this wall time")
    s.add_argument("--no-games", action="store_true", help="skip game evaluation after training")
    s.set_defaults(fn=cmd_train)

    s = sub.add_parser("evaluate", help="calibration on a split + (snake) full games")
    s.add_argument("--checkpoint", required=True)
    s.add_argument("--data", help="dataset dir (default: from checkpoint config)")
    s.add_argument("--split", default="test")
    s.add_argument("--games", type=int, default=200)
    s.add_argument("--board-size", type=int)
    s.add_argument("--threads", type=int, default=4)
    s.add_argument("--out")
    s.set_defaults(fn=cmd_evaluate)

    s = sub.add_parser("benchmark", help="latency / throughput / CPU / RAM")
    s.add_argument("--checkpoint", required=True, help=".pt or .onnx")
    s.add_argument("--onnx", action="store_true", help="also benchmark sibling .onnx and .int8.onnx files")
    s.add_argument("--threads", type=int, default=1)
    s.add_argument("--quick", action="store_true")
    s.add_argument("--out")
    s.set_defaults(fn=cmd_benchmark)

    s = sub.add_parser("export", help="export to ONNX (+ INT8) and verify parity")
    s.add_argument("--checkpoint", required=True)
    s.add_argument("--format", default="onnx")
    s.add_argument("--out")
    s.add_argument("--no-quantize", action="store_true")
    s.set_defaults(fn=cmd_export)

    s = sub.add_parser("estimate", help="estimate training time and cost (measures real throughput)")
    s.add_argument("--config", required=True)
    s.add_argument("--set", nargs="*")
    s.add_argument("--gpu-hourly-cost", type=float, default=0.0)
    s.add_argument("--gpu-name")
    s.add_argument("--throughput", type=float, help="samples/s; skip the measurement")
    s.add_argument("--bench-seconds", type=float, default=60)
    s.add_argument("--budget", type=float, help="warn if the estimate exceeds this amount")
    s.set_defaults(fn=cmd_estimate)

    s = sub.add_parser("compare", help="results table across runs")
    s.add_argument("runs", nargs="+")
    s.add_argument("--out", help="write markdown table here")
    s.set_defaults(fn=cmd_compare)

    runs = sub.add_parser("runs", help="run bookkeeping").add_subparsers(dest="runs_cmd", required=True)
    s = runs.add_parser("annotate")
    s.add_argument("run")
    s.add_argument("--actual-cost", type=float)
    s.add_argument("--gpu")
    s.add_argument("--note")
    s.set_defaults(fn=cmd_runs_annotate)

    snake = sub.add_parser("snake", help="Snake environment tools").add_subparsers(dest="snake_cmd", required=True)
    s = snake.add_parser("generate", help="generate an oracle-labelled dataset")
    s.add_argument("--samples", type=int, default=100_000)
    s.add_argument("--board-size", default="10", help="e.g. 10 or 10,20")
    s.add_argument("--out")
    s.add_argument("--seed", type=int, default=0)
    s.add_argument("--keep-prob", type=float, default=0.1)
    s.add_argument("--explore-eps", type=float, default=0.05)
    s.add_argument("--workers", type=int)
    s.add_argument("--jsonl", action="store_true", help="also write full JSONL exports")
    s.set_defaults(fn=cmd_snake_generate)

    s = snake.add_parser("baselines", help="play the scripted baselines")
    s.add_argument("--games", type=int, default=100)
    s.add_argument("--board-size", type=int, default=10)
    s.add_argument("--seed", type=int, default=12345)
    s.add_argument("--out")
    s.set_defaults(fn=cmd_snake_baselines)

    for name, human in (("demo", False), ("play", True)):
        s = snake.add_parser(name, help="web UI: watch MiniJEV/baselines" if not human else "web UI: play yourself")
        s.add_argument("--model", help=".onnx or .pt")
        s.add_argument("--agent", default="minijev", help="minijev | oracle | bfs | survival | greedy | random | human")
        s.add_argument("--board-size", type=int, default=10)
        s.add_argument("--port", type=int, default=8765)
        s.add_argument("--no-browser", action="store_true")
        s.set_defaults(fn=lambda a, h=human: cmd_snake_demo(a, h))

    s = snake.add_parser("dagger", help="DAgger: label the model's own states with the oracle, retrain")
    s.add_argument("--config", required=True)
    s.add_argument("--set", nargs="*")
    s.add_argument("--checkpoint", required=True)
    s.add_argument("--iterations", type=int, default=2)
    s.add_argument("--samples", type=int, default=100_000)
    s.add_argument("--run-dir")
    s.set_defaults(fn=cmd_snake_dagger)

    s = snake.add_parser("rl", help="PPO fine-tuning from a supervised checkpoint")
    s.add_argument("--config", required=True)
    s.add_argument("--set", nargs="*")
    s.add_argument("--checkpoint", required=True)
    s.add_argument("--run-dir")
    s.add_argument("--resume", action="store_true")
    s.set_defaults(fn=cmd_snake_rl)

    s = sub.add_parser("relabel", help="rename choice labels of a trained model (fits the ChoiceEncoder only)")
    s.add_argument("--checkpoint", required=True)
    s.add_argument("--out", required=True)
    s.add_argument("--map", nargs="+", required=True, help='OLD="new label" ...')
    s.add_argument("--set", nargs="*", help="config updates stored in the new checkpoint")
    s.add_argument("--steps", type=int, default=3000)
    s.set_defaults(fn=cmd_relabel)

    s = sub.add_parser("export-hf", help="write a pickle-free folder (safetensors + JSON) for Hugging Face")
    s.add_argument("--checkpoint", required=True)
    s.add_argument("--out", required=True)
    s.set_defaults(fn=cmd_export_hf)

    s = sub.add_parser("ask", help="one decision from the command line")
    s.add_argument("--checkpoint", required=True)
    s.add_argument("--state", default="")
    s.add_argument("--question", required=True)
    s.add_argument("--choices", nargs="+", required=True)
    s.add_argument("--task")
    s.add_argument("--threads", type=int, default=1)
    s.set_defaults(fn=cmd_ask)

    s = sub.add_parser("bench-jsonl", help="accuracy/calibration on a JSONL decision file")
    s.add_argument("--checkpoint", required=True)
    s.add_argument("--data", required=True)
    s.add_argument("--task")
    s.add_argument("--threads", type=int, default=4)
    s.add_argument("--out")
    s.add_argument("-v", "--verbose", action="store_true")
    s.set_defaults(fn=cmd_bench_jsonl)

    sent = sub.add_parser("sentiment", help="sentiment task tools").add_subparsers(dest="sent_cmd", required=True)
    s = sent.add_parser("prepare", help="download / build the sentiment dataset")
    s.add_argument("--out", default="data/sentiment")
    s.add_argument("--source", default="tweet_eval")
    s.set_defaults(fn=cmd_sentiment_prepare)
    return p


def main(argv=None):
    args = build_parser().parse_args(argv)
    args.fn(args)


if __name__ == "__main__":
    main()