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/metrics.py ADDED
@@ -0,0 +1,324 @@
1
+ """Association metrics (event level, pick level, split/merge, ARI).
2
+
3
+ Predictions are one row per pick: ``window_id, pick_idx, pred_event``
4
+ (``pred_event = -1`` for noise / unassociated). Picks missing from the
5
+ prediction table count as -1.
6
+
7
+ Event matching, per window: a contingency table of shared picks between
8
+ true events and predicted events is solved with the Hungarian algorithm; a
9
+ pair is a match when they share >= ``min_shared`` picks.
10
+
11
+ - Only ``scorable`` true events count for recall (FN).
12
+ - A predicted event counts as FP when it is unmatched, unless most of its
13
+ picks belong to a non-scorable true event (e.g. an edge event with few
14
+ visible picks) -- those are neither rewarded nor penalised.
15
+ - Picks of non-scorable events are left out of the pick-level metrics.
16
+ """
17
+
18
+ from __future__ import annotations
19
+
20
+ import numpy as np
21
+ import pandas as pd
22
+ from scipy.optimize import linear_sum_assignment
23
+
24
+ NOISE = -1
25
+
26
+
27
+ def _ari(a, b):
28
+ """Adjusted Rand index; noise picks are singletons on both sides."""
29
+ a = np.asarray(a).copy()
30
+ b = np.asarray(b).copy()
31
+ n = len(a)
32
+ if n < 2:
33
+ return np.nan
34
+ a[a == NOISE] = -np.arange(1, (a == NOISE).sum() + 1) - 10**6
35
+ b[b == NOISE] = -np.arange(1, (b == NOISE).sum() + 1) - 10**6
36
+ _, ai = np.unique(a, return_inverse=True)
37
+ _, bi = np.unique(b, return_inverse=True)
38
+ cont = np.zeros((ai.max() + 1, bi.max() + 1), dtype=np.int64)
39
+ np.add.at(cont, (ai, bi), 1)
40
+ comb = lambda x: x * (x - 1) / 2 # noqa: E731
41
+ s_ij = comb(cont).sum()
42
+ s_a = comb(cont.sum(1)).sum()
43
+ s_b = comb(cont.sum(0)).sum()
44
+ expected = s_a * s_b / comb(n)
45
+ max_ = (s_a + s_b) / 2
46
+ return 1.0 if max_ == expected else float((s_ij - expected) / (max_ - expected))
47
+
48
+
49
+ def _score_window(df, scorable, min_shared):
50
+ true = df["event_idx"].to_numpy()
51
+ pred = df["pred_event"].to_numpy()
52
+
53
+ t_ids = np.unique(true[true != NOISE])
54
+ p_ids = np.unique(pred[pred != NOISE])
55
+ cont = np.zeros((len(t_ids), len(p_ids)), dtype=int)
56
+ if len(t_ids) and len(p_ids):
57
+ ti = np.searchsorted(t_ids, true)
58
+ pi = np.searchsorted(p_ids, pred)
59
+ m = (true != NOISE) & (pred != NOISE)
60
+ np.add.at(cont, (ti[m], pi[m]), 1)
61
+
62
+ match = {} # true id -> pred id
63
+ if cont.size:
64
+ r, c = linear_sum_assignment(-cont)
65
+ for i, j in zip(r, c):
66
+ if cont[i, j] >= min_shared:
67
+ match[t_ids[i]] = p_ids[j]
68
+ pred_matched = set(match.values())
69
+
70
+ is_sc = np.array([t in scorable for t in t_ids], dtype=bool)
71
+ tp = sum(1 for t in match if t in scorable)
72
+ fn = int(is_sc.sum()) - tp
73
+ fp = 0
74
+ for j, p in enumerate(p_ids):
75
+ if p in pred_matched:
76
+ continue
77
+ col = cont[:, j]
78
+ n_pred = int((pred == p).sum())
79
+ owner = t_ids[col.argmax()] if col.size and col.max() > 0 else None
80
+ if owner is not None and owner not in scorable and col.max() * 2 > n_pred:
81
+ continue # dominated by a non-scorable event
82
+ fp += 1
83
+
84
+ # split / merge, with the same min_shared support
85
+ split = int(sum(((cont[i] >= min_shared).sum() > 1) for i in range(len(t_ids)) if is_sc[i]))
86
+ merge = int(sum(((cont[:, j] >= min_shared).sum() > 1) for j in range(len(p_ids))))
87
+
88
+ # pick level (picks of non-scorable events excluded)
89
+ in_sc = np.isin(true, list(scorable))
90
+ use = in_sc | (true == NOISE)
91
+ expected_pred = np.array([match.get(t, -2) for t in true])
92
+ correct = in_sc & (pred != NOISE) & (pred == expected_pred)
93
+ assigned = use & (pred != NOISE)
94
+ noise = true == NOISE
95
+
96
+ return {
97
+ "n_true_events": int(is_sc.sum()),
98
+ "n_pred_events": len(p_ids),
99
+ "event_tp": tp, "event_fp": fp, "event_fn": fn,
100
+ "n_split": split, "n_merge": merge,
101
+ "pick_correct": int(correct.sum()),
102
+ "pick_assigned": int(assigned.sum()),
103
+ "pick_true": int(in_sc.sum()),
104
+ "noise_total": int(noise.sum()),
105
+ "noise_rejected": int((noise & (pred == NOISE)).sum()),
106
+ "ari": _ari(true[use], pred[use]),
107
+ "_match": [(t, p) for t, p in match.items() if t in scorable],
108
+ }
109
+
110
+
111
+ def score_windows(truth_picks, truth_events, pred, min_shared=3, windows=None):
112
+ """Return (per_window, summary). ``windows`` (optional) adds grouping columns."""
113
+ df = truth_picks[["window_id", "pick_idx", "event_idx"]].merge(
114
+ pred[["window_id", "pick_idx", "pred_event"]], on=["window_id", "pick_idx"], how="left")
115
+ df["pred_event"] = df["pred_event"].fillna(NOISE).astype(int)
116
+ scorable = truth_events[truth_events["scorable"]].groupby("window_id")["event_idx"].apply(set).to_dict()
117
+
118
+ rows, matches = [], []
119
+ for wid, g in df.groupby("window_id", sort=False):
120
+ r = _score_window(g, scorable.get(wid, set()), min_shared)
121
+ matches += [(wid, int(t), int(p)) for t, p in r.pop("_match")]
122
+ rows.append({"window_id": wid, **r})
123
+ per_window = pd.DataFrame(rows)
124
+ matches = pd.DataFrame(matches, columns=["window_id", "event_idx", "pred_event"])
125
+ if windows is not None:
126
+ per_window = per_window.merge(windows[["window_id", "network", "split", "scenario"]], on="window_id")
127
+ return per_window, summarize(per_window), matches
128
+
129
+
130
+ # ----------------------------------------------------------------------
131
+ # Locations (optional: only for associators that also locate)
132
+ # ----------------------------------------------------------------------
133
+ LOCATION_COLUMNS = ("latitude", "longitude", "depth_km", "t0_rel_s")
134
+
135
+
136
+ def score_locations(matches, truth_events, locations):
137
+ """One row per matched event: epicentral [km], depth [km] and origin-time [s] errors
138
+ against the catalog (NaN where the associator gave no value)."""
139
+ from .build.geo import haversine_km
140
+
141
+ loc = locations.rename(columns={"event": "pred_event"})
142
+ m = matches.merge(truth_events[["window_id", "event_idx", "latitude", "longitude", "depth", "t0_rel_s"]],
143
+ on=["window_id", "event_idx"])
144
+ m = m.merge(loc, on=["window_id", "pred_event"], how="left", suffixes=("", "_pred"))
145
+ for c in LOCATION_COLUMNS:
146
+ col = c + "_pred" if c in ("latitude", "longitude", "t0_rel_s") else c
147
+ if col not in m:
148
+ m[col] = np.nan
149
+ m["epi_err_km"] = haversine_km(m.latitude, m.longitude, m.latitude_pred, m.longitude_pred)
150
+ m["depth_err_km"] = (m.depth_km - m.depth / 1e3).abs()
151
+ m["t0_err_s"] = (m.t0_rel_s_pred - m.t0_rel_s).abs()
152
+ return m[["window_id", "event_idx", "pred_event", "epi_err_km", "depth_err_km", "t0_err_s"]]
153
+
154
+
155
+ def summarize_locations(loc, by=None):
156
+ """Median and 90th percentile of the errors, and the share of matched events that were located."""
157
+ def agg(g):
158
+ return pd.Series({
159
+ "located_frac": g.epi_err_km.notna().mean() if len(g) else np.nan,
160
+ "epi_median_km": g.epi_err_km.median(), "epi_p90_km": g.epi_err_km.quantile(0.9),
161
+ "depth_median_km": g.depth_err_km.median(), "depth_p90_km": g.depth_err_km.quantile(0.9),
162
+ "t0_median_s": g.t0_err_s.median(), "t0_p90_s": g.t0_err_s.quantile(0.9),
163
+ })
164
+ if not by:
165
+ return agg(loc).to_frame().T
166
+ return loc.groupby(by).apply(agg, include_groups=False).reset_index()
167
+
168
+
169
+ def _ratios(s):
170
+ def div(a, b):
171
+ return a / b if b else np.nan
172
+ p = div(s["event_tp"], s["event_tp"] + s["event_fp"])
173
+ r = div(s["event_tp"], s["event_tp"] + s["event_fn"])
174
+ pp = div(s["pick_correct"], s["pick_assigned"])
175
+ pr = div(s["pick_correct"], s["pick_true"])
176
+ return pd.Series({
177
+ "n_windows": s["n_windows"],
178
+ "event_precision": p, "event_recall": r,
179
+ "event_f1": div(2 * p * r, p + r) if p == p and r == r else np.nan,
180
+ "pick_precision": pp, "pick_recall": pr,
181
+ "pick_f1": div(2 * pp * pr, pp + pr) if pp == pp and pr == pr else np.nan,
182
+ "noise_rejection": div(s["noise_rejected"], s["noise_total"]),
183
+ "split_rate": div(s["n_split"], s["n_true_events"]),
184
+ "merge_rate": div(s["n_merge"], s["n_pred_events"]),
185
+ "ari_mean": s["ari_mean"],
186
+ })
187
+
188
+
189
+ def summarize(per_window, by=None):
190
+ """Micro-averaged metrics (counts summed over windows), optionally grouped."""
191
+ count_cols = ["n_true_events", "n_pred_events", "event_tp", "event_fp", "event_fn", "n_split",
192
+ "n_merge", "pick_correct", "pick_assigned", "pick_true", "noise_total", "noise_rejected"]
193
+
194
+ def agg(g):
195
+ s = g[count_cols].sum()
196
+ s["n_windows"] = len(g)
197
+ s["ari_mean"] = g["ari"].mean()
198
+ return _ratios(s)
199
+
200
+ if not by:
201
+ return agg(per_window).to_frame().T
202
+ return per_window.groupby(by).apply(agg, include_groups=False).reset_index()
203
+
204
+
205
+ # ----------------------------------------------------------------------
206
+ # Reference predictions (sanity checks for the metrics, not associators)
207
+ # ----------------------------------------------------------------------
208
+ def oracle(truth_picks):
209
+ return truth_picks[["window_id", "pick_idx"]].assign(pred_event=truth_picks["event_idx"])
210
+
211
+
212
+ def all_noise(truth_picks):
213
+ return truth_picks[["window_id", "pick_idx"]].assign(pred_event=NOISE)
214
+
215
+
216
+ def one_event(truth_picks):
217
+ return truth_picks[["window_id", "pick_idx"]].assign(pred_event=0)
218
+
219
+
220
+ # ----------------------------------------------------------------------
221
+ # Benchmark entry point
222
+ # ----------------------------------------------------------------------
223
+ class Report:
224
+ """Evaluation of one predictions table on one split of a release."""
225
+
226
+ def __init__(self, per_window, meta, locations=None):
227
+ """``locations``: per matched event location errors (see score_locations), or None."""
228
+ self.per_window = per_window
229
+ self.meta = meta
230
+ self.locations = locations
231
+ self.overall = summarize(per_window)
232
+ self.by_network = summarize(per_window, by=["network"])
233
+ self.by_scenario = summarize(per_window, by=["scenario"])
234
+ self.by_network_scenario = summarize(per_window, by=["network", "scenario"])
235
+ if locations is not None and len(locations):
236
+ loc = locations.merge(per_window[["window_id", "network", "scenario"]], on="window_id")
237
+ self.overall = pd.concat([self.overall, summarize_locations(loc)], axis=1)
238
+ for attr, by in (("by_network", ["network"]), ("by_scenario", ["scenario"]),
239
+ ("by_network_scenario", ["network", "scenario"])):
240
+ setattr(self, attr, getattr(self, attr).merge(summarize_locations(loc, by), on=by, how="left"))
241
+
242
+ def save(self, out_dir):
243
+ import json
244
+ from pathlib import Path
245
+
246
+ out = Path(out_dir)
247
+ out.mkdir(parents=True, exist_ok=True)
248
+ self.per_window.to_csv(out / "per_window.csv", index=False)
249
+ self.by_network.to_csv(out / "by_network.csv", index=False)
250
+ self.by_scenario.to_csv(out / "by_scenario.csv", index=False)
251
+ self.by_network_scenario.to_csv(out / "by_network_scenario.csv", index=False)
252
+ if self.locations is not None:
253
+ self.locations.to_csv(out / "location_errors.csv", index=False)
254
+ with open(out / "summary.json", "w") as f:
255
+ json.dump({**self.meta, "overall": self.overall.iloc[0].to_dict()}, f, indent=2, default=float)
256
+ return out
257
+
258
+ def __repr__(self):
259
+ cols = ["event_precision", "event_recall", "event_f1", "pick_precision", "pick_recall", "pick_f1",
260
+ "noise_rejection", "split_rate", "merge_rate", "ari_mean"]
261
+ cols += [c for c in ("located_frac", "epi_median_km", "depth_median_km", "t0_median_s") if c in self.overall]
262
+ return f"<bepad.Report {self.meta}>\n" + self.overall[cols].round(4).to_string(index=False)
263
+
264
+
265
+ def check_predictions(pred, truth_picks):
266
+ """Validate a predictions table against the picks it should cover."""
267
+ need = {"window_id", "pick_idx", "pred_event"}
268
+ missing = need - set(pred.columns)
269
+ if missing:
270
+ raise ValueError(f"predictions are missing columns {sorted(missing)}")
271
+ if pred.duplicated(["window_id", "pick_idx"]).any():
272
+ raise ValueError("predictions contain duplicated (window_id, pick_idx) rows")
273
+ if not pd.api.types.is_integer_dtype(pred["pred_event"]):
274
+ raise ValueError("pred_event must be integer (-1 = noise / not associated)")
275
+ unknown = set(pred["window_id"]) - set(truth_picks["window_id"])
276
+ if unknown:
277
+ raise ValueError(f"{len(unknown)} window_id(s) not in this split, e.g. {sorted(unknown)[:3]}")
278
+ covered = truth_picks[["window_id", "pick_idx"]].merge(pred[["window_id", "pick_idx"]], how="left",
279
+ indicator=True)
280
+ n_missing = int((covered["_merge"] == "left_only").sum())
281
+ return {"n_predictions": len(pred), "n_picks": len(truth_picks), "n_missing_picks": n_missing}
282
+
283
+
284
+ def evaluate(predictions, release, split="test", networks=None, min_shared=3, window_ids=None, locations=None):
285
+ """Score ``predictions`` (DataFrame or parquet/csv path) on ``split`` of ``release``
286
+ (a loaded :class:`bepad.data.Split`, a :class:`bepad.data.Release` or a local release folder).
287
+
288
+ Picks without a prediction count as noise (-1). ``networks`` limits scoring
289
+ to those networks; ``window_ids`` to those windows (a partial run).
290
+ ``locations`` (optional): one row per predicted event -- window_id, event (its pred_event
291
+ number), latitude, longitude, depth_km, t0_rel_s -- scored against the matched true events.
292
+ """
293
+ from pathlib import Path
294
+
295
+ from .data import Release, Split
296
+
297
+ if isinstance(release, Split): # a loaded split: score its own split and networks
298
+ networks = networks or sorted(release.windows["network"].unique())
299
+ split, release = release.name, release.release
300
+ rel = release if isinstance(release, Release) else Release(release)
301
+ if not isinstance(predictions, pd.DataFrame):
302
+ p = Path(predictions)
303
+ predictions = pd.read_parquet(p) if p.suffix == ".parquet" else pd.read_csv(p)
304
+ windows = rel._read(split, "windows", networks)
305
+ picks = rel._read(split, "picks", networks, columns=["window_id", "pick_idx", "event_idx"])
306
+ events = rel._read(split, "events", networks, columns=["window_id", "event_idx", "scorable", "latitude",
307
+ "longitude", "depth", "t0_rel_s"])
308
+ if window_ids is not None:
309
+ keep = set(window_ids)
310
+ windows = windows[windows.window_id.isin(keep)]
311
+ picks = picks[picks.window_id.isin(keep)]
312
+ events = events[events.window_id.isin(keep)]
313
+ checks = check_predictions(predictions, picks)
314
+ if checks["n_missing_picks"]:
315
+ print(f"[evaluate] warning: {checks['n_missing_picks']} picks have no prediction -> counted as noise")
316
+ per_window, _, matches = score_windows(picks, events, predictions, min_shared=min_shared, windows=windows)
317
+ loc = None
318
+ if locations is not None:
319
+ if not isinstance(locations, pd.DataFrame):
320
+ locations = pd.read_parquet(locations)
321
+ loc = score_locations(matches, events, locations)
322
+ meta = {"release": rel.path.name, "split": split, "min_shared": min_shared,
323
+ "networks": networks or "all", "n_windows": int(len(windows)), **checks}
324
+ return Report(per_window, meta, loc)