bepad 0.1.0__py3-none-any.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
bepad/leaderboard.py ADDED
@@ -0,0 +1,368 @@
1
+ """Leaderboard: submit results, verify them, build the tables.
2
+
3
+ Everything lives in the BEPAD Hugging Face dataset repo::
4
+
5
+ associators/<name>/ contributed code (associator.py, config.yaml, README.md, ...)
6
+ leaderboard/<release>/<name>/submission.json scores + metadata (written by Result.save)
7
+ leaderboard/<release>/<name>/predictions/ the predictions, so scores can be re-checked
8
+ leaderboard/README.md the tables (built by ``bepad leaderboard build``)
9
+
10
+ Authors run ``bepad submit results/<name> --code associators/<name>``: the
11
+ files are checked locally, then a pull request is opened on Hugging Face. It
12
+ only ever adds the author's own two folders, never touches anything else.
13
+ """
14
+
15
+ from __future__ import annotations
16
+
17
+ import json
18
+ from pathlib import Path
19
+
20
+ import pandas as pd
21
+
22
+ REPO_ID = "ecastillot/BEPAD"
23
+ SCORE_TOL = 1e-6
24
+ TABLE_COLS = ["event_f1", "pick_f1", "noise_rejection", "event_precision", "event_recall", "merge_rate",
25
+ "split_rate", "epi_median_km", "depth_median_km", "t0_median_s"]
26
+
27
+
28
+ # ----------------------------------------------------------------------
29
+ # Checking
30
+ # ----------------------------------------------------------------------
31
+ def verify(result_dir, root=None, code_dir=None):
32
+ """Check a result folder: files, metadata, and scores recomputed from the predictions.
33
+
34
+ Returns a list of problems (empty = OK). ``root``: local release folder
35
+ (otherwise the needed labels are downloaded).
36
+ """
37
+ from .metrics import evaluate
38
+ from .associator import METHOD_TYPES, TRAINING
39
+
40
+ d = Path(result_dir)
41
+ problems = []
42
+ sub_file = d / "submission.json"
43
+ if not sub_file.exists():
44
+ return [f"{sub_file} not found -- create it with result.save('{d}')"]
45
+ sub = json.loads(sub_file.read_text())
46
+ a, run = sub.get("associator", {}), sub.get("run", {})
47
+ for key in ("name", "version", "authors", "method_type", "training"):
48
+ if not a.get(key):
49
+ problems.append(f"associator.{key} is empty (set it in the class)")
50
+ if a.get("authors") == "Your Name":
51
+ problems.append("associator.authors is still 'Your Name'")
52
+ if a.get("method_type") not in METHOD_TYPES:
53
+ problems.append(f"method_type must be one of {METHOD_TYPES}")
54
+ if a.get("training") not in TRAINING:
55
+ problems.append(f"training must be one of {TRAINING}")
56
+ if run.get("split") != "test":
57
+ problems.append(f"leaderboard results must be on the test split (got {run.get('split')!r})")
58
+ from .data import CORE_NAME, CORE_NETWORKS
59
+
60
+ missing = sorted(set(CORE_NETWORKS) - set(run.get("networks") or []))
61
+ if run.get("release") != "dev" and missing:
62
+ problems.append(f"the {CORE_NAME} networks are required, missing: {', '.join(missing)} "
63
+ "(run with --networks core, plus any other networks)")
64
+ files = sorted((d / "predictions").glob("network=*.parquet"))
65
+ if not files:
66
+ problems.append("no predictions/network=*.parquet files")
67
+ return problems
68
+ if code_dir is not None:
69
+ c = Path(code_dir)
70
+ for f in ("associator.py", "config.yaml", "README.md"):
71
+ if not (c / f).exists():
72
+ problems.append(f"{c / f} missing (bepad new creates it)")
73
+ if c.name != a.get("name"):
74
+ problems.append(f"code folder is named {c.name!r} but the associator is {a.get('name')!r}")
75
+
76
+ pred = pd.concat([pd.read_parquet(f) for f in files], ignore_index=True)
77
+ lfiles = sorted((d / "locations").glob("network=*.parquet"))
78
+ locs = pd.concat([pd.read_parquet(f) for f in lfiles], ignore_index=True) if lfiles else None
79
+ try:
80
+ from .data import Release, download
81
+
82
+ rel = Release(root) if root else Release(download(run["release"], splits=("test",),
83
+ networks=run["networks"]))
84
+ report = evaluate(pred, rel, split="test", networks=run["networks"],
85
+ window_ids=set(pred.window_id), locations=locs)
86
+ except Exception as exc: # noqa: BLE001 -- report instead of crashing
87
+ return problems + [f"could not re-score the predictions: {exc!r}"]
88
+ got = report.overall.iloc[0]
89
+ for k, v in sub["scores"]["overall"].items():
90
+ if k in got and v is not None and abs(float(got[k]) - v) > SCORE_TOL:
91
+ problems.append(f"score {k}: submission says {v}, predictions give {float(got[k]):.6f}")
92
+ if report.meta["n_windows"] != run.get("n_windows"):
93
+ problems.append(f"n_windows: submission says {run.get('n_windows')}, predictions cover "
94
+ f"{report.meta['n_windows']}")
95
+ return problems
96
+
97
+
98
+ # ----------------------------------------------------------------------
99
+ # Submitting
100
+ # ----------------------------------------------------------------------
101
+ def _operations(result_dir, code_dir, release, name):
102
+ from huggingface_hub import CommitOperationAdd
103
+
104
+ ops = []
105
+ base = f"leaderboard/{release}/{name}"
106
+ for f in sorted(Path(result_dir).rglob("*")):
107
+ if f.is_file():
108
+ ops.append(CommitOperationAdd(f"{base}/{f.relative_to(result_dir).as_posix()}", str(f)))
109
+ if code_dir is not None:
110
+ for f in sorted(Path(code_dir).rglob("*")):
111
+ if f.is_file() and "__pycache__" not in f.parts:
112
+ ops.append(CommitOperationAdd(f"associators/{name}/{f.relative_to(code_dir).as_posix()}", str(f)))
113
+ return ops
114
+
115
+
116
+ def _pr_text(sub):
117
+ a, run, s = sub["associator"], sub["run"], sub["scores"]["overall"]
118
+ return (f"**{a['name']} {a['version']}** by {a['authors']} ({a['method_type']}, training: {a['training']})\n\n"
119
+ f"{a.get('description', '')}\n\n"
120
+ f"- release `{run['release']}`, split `{run['split']}`, networks: {', '.join(run['networks'])}\n"
121
+ f"- windows: {run['n_windows']} ({'complete' if run['complete'] else 'partial'})\n"
122
+ f"- event F1 {s['event_f1']}, pick F1 {s['pick_f1']}, noise rejection {s['noise_rejection']}\n"
123
+ f"- paper: {a.get('paper') or '-'} · code: {a.get('code') or '-'}\n\n"
124
+ "Opened with `bepad submit`.")
125
+
126
+
127
+ def preview(result_dir, repo_id=REPO_ID):
128
+ """The leaderboard tables of this submission's networks, with it inserted among the current entries."""
129
+ import shutil
130
+ import tempfile
131
+
132
+ sub = json.loads((Path(result_dir) / "submission.json").read_text())
133
+ name, release = sub["associator"]["name"], sub["run"]["release"]
134
+ with tempfile.TemporaryDirectory() as tmp:
135
+ tmp = Path(tmp)
136
+ try:
137
+ from huggingface_hub import snapshot_download
138
+
139
+ cur = Path(snapshot_download(repo_id, repo_type="dataset",
140
+ allow_patterns=[f"leaderboard/{release}/*/submission.json"]))
141
+ for f in (cur / "leaderboard").glob("*/*/submission.json"):
142
+ dst = tmp / f.parent.relative_to(cur / "leaderboard")
143
+ dst.mkdir(parents=True, exist_ok=True)
144
+ shutil.copy(f, dst / "submission.json")
145
+ note = "current leaderboard + your submission"
146
+ except Exception: # noqa: BLE001 -- offline / repo not published yet
147
+ note = "the published leaderboard could not be read, so only your submission is shown"
148
+ (tmp / release / name).mkdir(parents=True, exist_ok=True)
149
+ shutil.copy(Path(result_dir) / "submission.json", tmp / release / name / "submission.json")
150
+ text = build(tmp)
151
+ from .data import CORE_NAME
152
+
153
+ keep = [f"## {release} · {n}" for n in [CORE_NAME, "All networks", *sub["run"]["networks"]]]
154
+ blocks = [b for b in text.split("\n## ") if any(("## " + b).startswith(k) for k in keep)]
155
+ return f"Preview ({note}):\n\n" + "\n".join("## " + b for b in blocks)
156
+
157
+
158
+ def submit(result_dir, code_dir=None, repo_id=REPO_ID, root=None, dry_run=False, token=None):
159
+ """Check a result folder, then open a pull request on the BEPAD Hugging Face repo.
160
+
161
+ Needs a Hugging Face account and ``hf auth login`` once. With ``dry_run`` (or
162
+ without a login) nothing is sent and the manual steps are printed instead.
163
+ """
164
+ sub = json.loads((Path(result_dir) / "submission.json").read_text())
165
+ name, release = sub["associator"]["name"], sub["run"]["release"]
166
+ problems = verify(result_dir, root=root, code_dir=code_dir)
167
+ if problems:
168
+ print("Please fix these first:\n - " + "\n - ".join(problems))
169
+ return None
170
+ ops = _operations(result_dir, code_dir, release, name)
171
+ print(preview(result_dir, repo_id))
172
+ print(f"[submit] checks passed. The pull request adds {len(ops)} files:")
173
+ print(f" leaderboard/{release}/{name}/...")
174
+ if code_dir is not None:
175
+ print(f" associators/{name}/...")
176
+ manual = (f"\nTo submit by hand instead: open https://huggingface.co/datasets/{repo_id}/tree/main "
177
+ f"('Files and versions'), choose 'Contribute' -> 'Upload files', and upload the folder "
178
+ f"{result_dir} into leaderboard/{release}/{name}/"
179
+ + (f" and {code_dir} into associators/{name}/" if code_dir else "")
180
+ + ". For people who do not own the repository, the upload is opened as a pull request.")
181
+ if dry_run:
182
+ print("[submit] dry run: nothing sent." + manual)
183
+ return None
184
+ try:
185
+ from huggingface_hub import HfApi
186
+
187
+ api = HfApi(token=token)
188
+ user = api.whoami()["name"]
189
+ except Exception: # noqa: BLE001
190
+ print("[submit] you are not logged in to Hugging Face. Run `hf auth login` (once) and try again."
191
+ + manual)
192
+ return None
193
+ info = api.create_commit(repo_id, repo_type="dataset", operations=ops, create_pr=True,
194
+ commit_message=f"Leaderboard: {name} {sub['associator']['version']} on {release}",
195
+ commit_description=_pr_text(sub))
196
+ print(f"[submit] pull request opened by {user}: {info.pr_url}")
197
+ return info.pr_url
198
+
199
+
200
+ # ----------------------------------------------------------------------
201
+ # Tables
202
+ # ----------------------------------------------------------------------
203
+ def collect(leaderboard_dir):
204
+ """All submission.json files of a local leaderboard folder -> one row per (method, network)."""
205
+ rows = []
206
+ for f in sorted(Path(leaderboard_dir).glob("*/*/submission.json")):
207
+ sub = json.loads(f.read_text())
208
+ a, run = sub["associator"], sub["run"]
209
+ common = {"release": run["release"], "method": a["name"], "version": a["version"], "type": a["method_type"],
210
+ "training": a["training"], "complete": run["complete"], "sec_per_window": run["seconds_per_window"],
211
+ "paper": a.get("paper", ""), "code": a.get("code", ""), "date": sub["created"][:10]}
212
+ for net, s in sub["scores"]["by_network"].items():
213
+ rows.append({**common, "network": net, "n_windows": s.get("n_windows"),
214
+ **{k: s.get(k) for k in TABLE_COLS}})
215
+ return pd.DataFrame(rows)
216
+
217
+
218
+ def _mean_over(g, networks):
219
+ """One method's scores averaged over ``networks`` (every network weighs the same)."""
220
+ g = g[g.network.isin(networks)]
221
+ w = g.n_windows.fillna(0).to_numpy(float)
222
+ return {**g.iloc[0][["release", "method", "version", "type", "training", "paper", "code"]].to_dict(),
223
+ "complete": bool(g.complete.all()) and g.network.nunique() == len(networks),
224
+ "n_networks": int(g.network.nunique()), "n_windows": int(w.sum()),
225
+ "sec_per_window": float((g.sec_per_window * w).sum() / w.sum()) if w.sum() else float("nan"),
226
+ **{k: float(pd.to_numeric(g[k], errors="coerce").mean()) for k in TABLE_COLS}}
227
+
228
+
229
+ def views(df, core=None):
230
+ """{release: {view: rows}}, ranked by event F1. Views: ``core`` (mean over the core networks),
231
+ ``all`` (mean over every network of the release), then one per network. A method that did not
232
+ run every window of the view's networks is *partial*: listed, not ranked."""
233
+ from .data import CORE_NETWORKS
234
+
235
+ core = list(core or CORE_NETWORKS)
236
+ out = {}
237
+ for release, d in df.groupby("release"):
238
+ nets = sorted(d.network.unique())
239
+ rel = {}
240
+ for view, members in [("core", core), ("all", nets)] + [(n, [n]) for n in nets]:
241
+ rows = [_mean_over(g, members) for _, g in d.groupby(["method", "version"]) if g.network.isin(members).any()]
242
+ rows = sorted(rows, key=lambda r: (not r["complete"], -(r["event_f1"] if r["event_f1"] == r["event_f1"] else -1)))
243
+ rank = 0
244
+ for r in rows:
245
+ rank += r["complete"]
246
+ r["rank"] = rank if r["complete"] else "partial"
247
+ r["n_networks_view"] = len(members)
248
+ rel[view] = rows
249
+ out[release] = rel
250
+ return out
251
+
252
+
253
+ HEADER = ["#", "Method", "Type", "Training", "Event F1", "Pick F1", "Noise rej.", "Merge", "Split",
254
+ "Epicentre err. (km)", "Depth err. (km)", "s/window", "Windows", "Networks"]
255
+
256
+
257
+ def _cells(r):
258
+ fmt = lambda v: "–" if v is None or v != v else f"{v:.3f}" # noqa: E731
259
+ fmt1 = lambda v: "–" if v is None or v != v else f"{v:.1f}" # noqa: E731
260
+ return [str(r["rank"]), f"{r['method']} {r['version']}", r["type"], r["training"], fmt(r["event_f1"]),
261
+ fmt(r["pick_f1"]), fmt(r["noise_rejection"]), fmt(r["merge_rate"]), fmt(r["split_rate"]),
262
+ fmt1(r["epi_median_km"]), fmt1(r["depth_median_km"]), f"{r['sec_per_window']:.2f}",
263
+ str(r["n_windows"]), f"{r['n_networks']}/{r['n_networks_view']}"]
264
+
265
+
266
+ def _title(view, core):
267
+ from .data import CORE_NAME, CORE_NETWORKS
268
+
269
+ return {"core": f"{CORE_NAME} test ({', '.join(core or CORE_NETWORKS)})", "all": "All networks"}.get(view, view)
270
+
271
+
272
+ def _html(tables, core):
273
+ """A self-contained table with a network filter (default: the core networks), for the docs."""
274
+ data = {rel: {v: {"title": _title(v, core), "rows": [
275
+ {"cells": _cells(r), "link": r["paper"] or r["code"]} for r in rows]} for v, rows in vs.items()}
276
+ for rel, vs in tables.items()}
277
+ return f"""<div class="bepad-lb">
278
+ <label>Release <select class="bepad-lb-release"></select></label>
279
+ <label>Networks <select class="bepad-lb-view"></select></label>
280
+ <table class="docutils"><thead><tr>{"".join(f"<th>{h}</th>" for h in HEADER)}</tr></thead><tbody></tbody></table>
281
+ </div>
282
+ <script>
283
+ (function () {{
284
+ const DATA = {json.dumps(data)};
285
+ const box = document.currentScript.previousElementSibling;
286
+ const rel = box.querySelector(".bepad-lb-release"), view = box.querySelector(".bepad-lb-view");
287
+ const body = box.querySelector("tbody");
288
+ const opt = (v, t) => {{ const o = document.createElement("option"); o.value = v; o.textContent = t; return o; }};
289
+ function fillViews() {{
290
+ view.innerHTML = "";
291
+ for (const [v, d] of Object.entries(DATA[rel.value])) view.appendChild(opt(v, d.title));
292
+ }}
293
+ function draw() {{
294
+ body.innerHTML = "";
295
+ for (const r of DATA[rel.value][view.value].rows) {{
296
+ const tr = document.createElement("tr");
297
+ r.cells.forEach((c, i) => {{
298
+ const td = document.createElement("td");
299
+ if (i === 1 && r.link) {{ const a = document.createElement("a"); a.href = r.link; a.textContent = c; td.appendChild(a); }}
300
+ else td.textContent = c;
301
+ tr.appendChild(td);
302
+ }});
303
+ body.appendChild(tr);
304
+ }}
305
+ }}
306
+ Object.keys(DATA).sort().reverse().forEach(k => rel.appendChild(opt(k, k)));
307
+ rel.onchange = () => {{ fillViews(); draw(); }};
308
+ view.onchange = draw;
309
+ fillViews(); draw();
310
+ }})();
311
+ </script>
312
+ """
313
+
314
+
315
+ def build(leaderboard_dir, out_md=None, out_csv=None, out_html=None, core=None):
316
+ """The tables: the core networks first, then all networks, then one per network (Markdown, for the
317
+ Hugging Face card), a flat CSV, and a filterable HTML table for the docs."""
318
+ df = collect(leaderboard_dir)
319
+ if df.empty:
320
+ text = "# BEPAD leaderboard\n\nNo submissions yet.\n"
321
+ html = "<p><em>No submissions yet.</em></p>"
322
+ else:
323
+ tables = views(df, core)
324
+ lines = ["# BEPAD leaderboard", "",
325
+ "Ranked by event F1 on the test split; the core and all-networks tables average the networks "
326
+ "(each network weighs the same). *Partial* runs (not every window of the networks) are "
327
+ "listed but not ranked. Location errors are medians over the matched earthquakes, against the "
328
+ "catalog; – = the method does not locate. Submit with `bepad submit` (see the documentation).",
329
+ ""]
330
+ for release in sorted(tables, reverse=True):
331
+ for view, rows in tables[release].items():
332
+ lines += [f"## {release} · {_title(view, core)}", "", "| " + " | ".join(HEADER) + " |",
333
+ "|" + "---|" * len(HEADER)]
334
+ for r in rows:
335
+ c = _cells(r)
336
+ link = r["paper"] or r["code"]
337
+ c[1] = f"[{c[1]}]({link})" if link else c[1]
338
+ lines.append("| " + " | ".join(c) + " |")
339
+ lines.append("")
340
+ text = "\n".join(lines)
341
+ html = _html(tables, core)
342
+ if out_md:
343
+ Path(out_md).write_text(text)
344
+ if out_csv and not df.empty:
345
+ df.to_csv(out_csv, index=False)
346
+ if out_html:
347
+ Path(out_html).write_text(html)
348
+ return text
349
+
350
+
351
+ # ----------------------------------------------------------------------
352
+ # Maintainer
353
+ # ----------------------------------------------------------------------
354
+ def review(repo_id=REPO_ID, root=None, token=None):
355
+ """List open leaderboard pull requests and re-check each one (maintainer)."""
356
+ from huggingface_hub import HfApi, snapshot_download
357
+
358
+ api = HfApi(token=token)
359
+ for d in api.get_repo_discussions(repo_id, repo_type="dataset", discussion_type="pull_request",
360
+ discussion_status="open"):
361
+ path = Path(snapshot_download(repo_id, repo_type="dataset", revision=d.git_reference,
362
+ allow_patterns=["leaderboard/*", "associators/*"], token=token))
363
+ new = [p for p in (path / "leaderboard").glob("*/*/submission.json")]
364
+ print(f"PR #{d.num} by {d.author}: {d.title}")
365
+ for sub in new:
366
+ probs = verify(sub.parent, root=root)
367
+ print(f" {sub.parent.relative_to(path)}: " + ("OK" if not probs else "; ".join(probs)))
368
+ print(f" merge: HfApi().merge_pull_request('{repo_id}', {d.num}, repo_type='dataset')")