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/__init__.py +17 -0
- bepad/__main__.py +206 -0
- bepad/associator.py +339 -0
- bepad/build/__init__.py +11 -0
- bepad/build/config.py +116 -0
- bepad/build/geo.py +64 -0
- bepad/build/labels.py +341 -0
- bepad/build/pool.py +392 -0
- bepad/build/release.py +256 -0
- bepad/build/utdquake.py +76 -0
- bepad/build/windows.py +408 -0
- bepad/data.py +185 -0
- bepad/leaderboard.py +368 -0
- bepad/metrics.py +324 -0
- bepad/plots.py +950 -0
- bepad/stats.py +140 -0
- bepad/torch.py +159 -0
- bepad-0.1.0.dist-info/METADATA +85 -0
- bepad-0.1.0.dist-info/RECORD +23 -0
- bepad-0.1.0.dist-info/WHEEL +5 -0
- bepad-0.1.0.dist-info/entry_points.txt +2 -0
- bepad-0.1.0.dist-info/licenses/LICENSE +674 -0
- bepad-0.1.0.dist-info/top_level.txt +1 -0
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)
|