File size: 13,290 Bytes
41dd7e0
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
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
"""Reproduce one entry of Table 2, or one depth point of Table 6 / Fig. 3: csFID-10k of a model on the ten test conditions.

    python -m reproduce.table2 list
    python -m reproduce.table2 check    --system SYS --method M [--S S] [--checkpoint FILE] [--samples DIR] [--hash-large]
    python -m reproduce.table2 run      --system SYS --method M [--S S] --checkpoint FILE --out DIR [--device cuda]
    python -m reproduce.table2 generate --system SYS --method M [--S S] --checkpoint FILE --out DIR [--gids g1,g2] [--n-samples N]
    python -m reproduce.table2 score    --system SYS --method M [--S S] --out DIR [--samples DIR]
    python -m reproduce.table2 compare  --system SYS --method M [--S S] --out DIR [--tolerance REL]

SYS is euler | sqg | waves | nlkg | passive_scalar; M is direct_si | edm | cascaded_si (without --S the selected depth S*,
with --S the Cascaded SI model of that depth). The package and the truth bank are taken from <SYS>_DATA and
<SYS>_TRUTH_BANK or given with --data and --truth-bank. Everything else comes from records/<SYS>/: the run configuration
of the model, its sampler resolution n*, the image adapter and the recorded values.

  generate  10,000 samples per test condition (bifurcation.sample with the settings of configs/evaluation.yaml:
            n* sampler steps, noise stream 777 * 100003 + gid, chunks of 2,000) -> OUT/samples/<variant>/group_<gid>.npz
  score     csFID per condition (bifurcation.fid.evaluate.evaluate_csfid_run) of OUT/samples, or of the archived samples
            given with --samples -> OUT/csfid/{csfid_per_ic.csv, csfid_summary.json, MANIFEST_CSFID.json}
  compare   per-condition values, mean and SEM against the recorded values -> OUT/COMPARE.json
  run       generate + score + compare
  check     the package, truth bank, checkpoint and archived samples against the recorded sha256
"""
from __future__ import annotations

import argparse
import csv
import hashlib
import json
import sys
from datetime import datetime, timezone
from pathlib import Path

from bifurcation.config import RECORDS, REPO, SYSTEMS, data_paths, dataset_card, evaluation_settings, records, run_config, unit


def sha256(path: Path) -> str:
    h = hashlib.sha256()
    with open(path, "rb") as f:
        for chunk in iter(lambda: f.read(1 << 24), b""):
            h.update(chunk)
    return h.hexdigest()


def asset_hashes(system: str) -> dict:
    """{section: {relative path: sha256}} from records/<system>/assets.sha256."""
    out, sec = {}, None
    for line in (RECORDS / system / "assets.sha256").read_text().splitlines():
        if line.startswith("# "):
            sec = line[2:].split(":")[0].strip()
            continue
        h, q = line.split("  ", 1)
        out.setdefault(sec, {})[q] = h
    return out


def cmd_list(a):
    print(f"{'system':15s} {'model':16s} {'paper':22s} {'n*':>4s} {'NFE':>6s}  csFID-10k")
    for s in SYSTEMS:
        rec = records(s)
        for u in rec["units"]:
            names = ", ".join({"direct_si": "Direct SI", "edm": "EDM", "cascaded_si": f"Cascaded SI (S*={rec['s_star']})"}[n]
                              for n in u["paper_names"]) or f"depth S={u['S']}"
            e = u["expected_csfid"]
            print(f"{s:15s} {u['key']:16s} {names:22s} {u['sampler']['n_steps']:4d} {u['sampler']['nfe']:6d}  {e['mean']:.2f} +- {e['sem']:.2f}")


def cmd_check(a) -> int:
    rec, u = unit(a.system, a.method, a.S)
    data, bank = data_paths(a.system, a.data, a.truth_bank)
    card = dataset_card(a.system)
    problems, checked = [], 0
    big = 200 * 1024 * 1024
    for rel, want in card["expected_sha256"].items():
        p = data / rel
        if not p.is_file():
            problems.append(f"--data: missing {rel}"); continue
        if p.stat().st_size > big and not a.hash_large:
            continue
        checked += 1
        if sha256(p) != want:
            problems.append(f"--data: {rel} sha256 differs from configs/{a.system}/dataset.yaml")
    hashes = asset_hashes(a.system)
    for gid in rec["test_gids"]:
        rel = f"group_{gid:04d}.npz"
        p = bank / rel
        if not p.is_file():
            problems.append(f"--truth-bank: missing {rel}"); continue
        if a.hash_large:
            checked += 1
            if sha256(p) != hashes["truth_bank"][rel]:
                problems.append(f"--truth-bank: {rel} sha256 differs")
    if a.checkpoint:
        got = sha256(Path(a.checkpoint)); checked += 1
        if u["checkpoint"] is None:
            problems.append(f"--checkpoint: no checkpoint of {u['key']} is distributed")
        elif got != u["checkpoint"]["sha256"]:
            problems.append(f"--checkpoint: sha256 {got[:16]} is not the recorded {u['checkpoint']['file']}")
    if a.samples:
        v = u["sampler"]["variant"]
        sec = f"samples/{u['paper_names'][0]}" if u["paper_names"] else None   # archived samples exist for the Table 2 models
        for gid in rec["test_gids"]:
            rel = f"{v}/group_{gid:04d}.npz"
            p = Path(a.samples) / rel
            if not p.is_file():
                problems.append(f"--samples: missing {rel}"); continue
            want = (hashes.get(sec) or {}).get(rel) if sec else None
            if want and a.hash_large:
                checked += 1
                if sha256(p) != want:
                    problems.append(f"--samples: {rel} sha256 differs from the archived samples")
    print(json.dumps({"system": a.system, "model": u["key"], "files_hashed": checked, "problems": problems}, indent=1))
    return 2 if problems else 0


def _fresh(out: Path, sub: str) -> Path:
    d = out / sub
    if d.exists() and any(d.iterdir()):
        raise SystemExit(f"refusing: {d} exists and is not empty (use a fresh --out)")
    d.mkdir(parents=True, exist_ok=True)
    return d


def cmd_generate(a):
    from bifurcation import sample
    import yaml
    rec, u = unit(a.system, a.method, a.S)
    data, bank = data_paths(a.system, a.data, a.truth_bank)
    ev = evaluation_settings()
    out = Path(a.out).resolve()
    out.mkdir(parents=True, exist_ok=True)
    cfg_path = out / "config.yaml"
    cfg_path.write_text(yaml.safe_dump(run_config(a.system, u, data), sort_keys=False))
    n_samples = int(a.n_samples) if getattr(a, "n_samples", None) else int(ev["fid"]["n_generated"])
    argv = ["--config", str(cfg_path), "--data", str(data), "--checkpoint", str(Path(a.checkpoint).resolve()),
            "--out", str(out / "samples"), "--split", "test", "--n-samples", str(n_samples),
            "--n-steps", str(u["sampler"]["n_steps"]), "--seed-base", str(ev["sampling"]["crn_base_seed"]),
            "--chunk", str(ev["sampling"]["sample_chunk"]), "--device", a.device]
    if rec["x0_from_truth_bank"]:
        argv += ["--truth-bank", str(bank)]
    if a.gids:
        argv += ["--gids", a.gids]
    print("$ python -m bifurcation.sample " + " ".join(argv), flush=True)
    sample.main(argv)
    log = out / "GENERATED.json"
    entries = json.loads(log.read_text()) if log.is_file() else []
    entries.append({"system": a.system, "model": u["key"], "checkpoint": str(Path(a.checkpoint).resolve()), "gids": a.gids or "all",
                    "argv": argv, "at": datetime.now(timezone.utc).isoformat(timespec="seconds")})
    log.write_text(json.dumps(entries, indent=1) + "\n")


def cmd_score(a):
    from bifurcation.fid.adapters import FIDAdapter
    from bifurcation.fid.evaluate import evaluate_csfid_run, generated_states
    rec, u = unit(a.system, a.method, a.S)
    data, bank = data_paths(a.system, a.data, a.truth_bank)
    card, fid_cfg = dataset_card(a.system), evaluation_settings()["fid"]
    out = Path(a.out).resolve()
    samples = Path(a.samples).resolve() if a.samples else out / "samples"
    variant = u["sampler"]["variant"]
    missing = [g for g in rec["test_gids"] if not (samples / variant / f"group_{g:04d}.npz").is_file()]
    if missing:
        raise SystemExit(f"{samples / variant}: no samples for test conditions {missing}")
    n_gen = int(fid_cfg["n_generated"])
    gen_raw = generated_states(samples, variant, data, n_gen)
    gen_raw = {g: x for g, x in gen_raw.items() if g in set(rec["test_gids"])}
    for g, x in gen_raw.items():
        if len(x) != n_gen:
            raise SystemExit(f"condition {g}: {len(x)} generated states, the paper's read uses {n_gen}")
    adapter = FIDAdapter.load(RECORDS / a.system / "fid_adapter.json", **(card["fid_adapter"].get("params") or {}))
    csdir = _fresh(out, "csfid")
    summary = evaluate_csfid_run(gen_raw, bank, int(rec["fine_horizon_idx"]), int(fid_cfg["n_truth"]), adapter, csdir,
                                 out / "fid_cache", fid_cfg, a.device,
                                 provenance={"system": a.system, "model": u["key"], "samples": str(samples), "source": "reproduce.table2 score"})
    print(f"csFID-10k {a.system} {u['key']}: {summary['csFID']['mean']:.6f} +- {summary['csFID']['sem']:.6f} (n = {summary['csFID']['n']})")


def cmd_compare(a) -> int:
    rec, u = unit(a.system, a.method, a.S)
    out = Path(a.out).resolve()
    got = {int(r["gid"]): float(r["sfid"]) for r in csv.DictReader(open(out / "csfid" / "csfid_per_ic.csv"))}
    summ = json.loads((out / "csfid" / "csfid_summary.json").read_text())["csFID"]
    exp = u["expected_csfid"]
    rows, worst = [], 0.0
    for g in rec["test_gids"]:
        e, v = exp["per_condition"][str(g)], got.get(g, float("nan"))
        rel = abs(v - e) / abs(e)
        worst = max(worst, rel)
        rows.append({"gid": g, "recorded": e, "this_run": v, "rel_diff": rel})
    agg = {k: {"recorded": exp[k], "this_run": summ[k], "rel_diff": abs(summ[k] - exp[k]) / abs(exp[k])} for k in ("mean", "sem")}
    for r in rows:
        print(f"  gid {r['gid']:4d}  recorded {r['recorded']:.6f}  this run {r['this_run']:.6f}  rel diff {r['rel_diff']:.2e}")
    print(f"  mean {summ['mean']:.6f} (recorded {exp['mean']:.6f}, rel diff {agg['mean']['rel_diff']:.2e}); "
          f"SEM {summ['sem']:.6f} (recorded {exp['sem']:.6f}, rel diff {agg['sem']['rel_diff']:.2e})")
    verdict = {"system": a.system, "model": u["key"], "max_rel_diff_per_condition": worst, "aggregate": agg, "per_condition": rows,
               "bitwise_equal": all(r["rel_diff"] == 0.0 for r in rows) and all(v["rel_diff"] == 0.0 for v in agg.values())}
    if a.tolerance is not None:
        verdict["tolerance"] = a.tolerance
        verdict["within_tolerance"] = worst <= a.tolerance and all(v["rel_diff"] <= a.tolerance for v in agg.values())
    (out / "COMPARE.json").write_text(json.dumps(verdict, indent=1) + "\n")
    print(f"  max relative difference per condition: {worst:.3e}" + (f"; within {a.tolerance:g}: {verdict['within_tolerance']}" if a.tolerance is not None else ""))
    return 1 if (a.tolerance is not None and not verdict["within_tolerance"]) else 0


def cmd_run(a) -> int:
    cmd_generate(a)
    a.samples = None
    cmd_score(a)
    return cmd_compare(a)


def main(argv=None) -> int:
    ap = argparse.ArgumentParser(prog="python -m reproduce.table2", description=__doc__, formatter_class=argparse.RawDescriptionHelpFormatter)
    sub = ap.add_subparsers(dest="cmd", required=True)
    sub.add_parser("list")

    def unit_args(p, need_data=True):
        p.add_argument("--system", required=True, choices=SYSTEMS)
        p.add_argument("--method", required=True, choices=("direct_si", "edm", "cascaded_si"))
        p.add_argument("--S", type=int, default=None, help="depth of a Cascaded SI model (Table 6); default: the selected S*")
        if need_data:
            p.add_argument("--data", default=None, help="dataset package directory (default: $<SYSTEM>_DATA)")
            p.add_argument("--truth-bank", default=None, help="truth-bank directory (default: $<SYSTEM>_TRUTH_BANK)")
    c = sub.add_parser("check"); unit_args(c)
    c.add_argument("--checkpoint"); c.add_argument("--samples")
    c.add_argument("--hash-large", action="store_true", help="also hash the large files (trajectories, truth banks, samples)")
    for name in ("generate", "run"):
        g = sub.add_parser(name); unit_args(g)
        g.add_argument("--checkpoint", required=True); g.add_argument("--out", required=True)
        g.add_argument("--gids", default=None, help="comma-separated subset of the test conditions (split generation across jobs)")
        g.add_argument("--device", default="cuda")
        if name == "generate":
            g.add_argument("--n-samples", type=int, default=None, help="fewer samples per condition (a smoke test; `score` refuses fewer than 10,000)")
        if name == "run":
            g.add_argument("--tolerance", type=float, default=None)
    s = sub.add_parser("score"); unit_args(s)
    s.add_argument("--out", required=True); s.add_argument("--samples", default=None, help="archived samples (default OUT/samples)")
    s.add_argument("--device", default="cuda")
    m = sub.add_parser("compare"); unit_args(m, need_data=False)
    m.add_argument("--out", required=True); m.add_argument("--tolerance", type=float, default=None)
    a = ap.parse_args(argv)
    fn = {"list": cmd_list, "check": cmd_check, "generate": cmd_generate, "score": cmd_score, "compare": cmd_compare, "run": cmd_run}[a.cmd]
    return int(fn(a) or 0)


if __name__ == "__main__":
    sys.exit(main())