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/build/config.py
ADDED
|
@@ -0,0 +1,116 @@
|
|
|
1
|
+
"""YAML configs with defaults.
|
|
2
|
+
|
|
3
|
+
- configs/pool.yaml quality control + split (shared by every release)
|
|
4
|
+
- configs/<release>.yaml scenarios, window parameters, window counts
|
|
5
|
+
"""
|
|
6
|
+
|
|
7
|
+
from __future__ import annotations
|
|
8
|
+
|
|
9
|
+
import copy
|
|
10
|
+
from pathlib import Path
|
|
11
|
+
|
|
12
|
+
import yaml
|
|
13
|
+
|
|
14
|
+
REPO_ROOT = Path(__file__).resolve().parents[2]
|
|
15
|
+
|
|
16
|
+
POOL_DEFAULTS = {
|
|
17
|
+
"data_root": "data",
|
|
18
|
+
# Same thresholds as hypogt test/09252026 v8 (strict edges, Student-t).
|
|
19
|
+
"qc": {
|
|
20
|
+
"min_p": 4,
|
|
21
|
+
"min_s": 0,
|
|
22
|
+
"min_stations": 3,
|
|
23
|
+
"max_gap_deg": 270.0,
|
|
24
|
+
"max_depth_m": 700_000.0,
|
|
25
|
+
"max_rms_s": 1.8,
|
|
26
|
+
"max_horizontal_uncertainty_m": 200_000.0,
|
|
27
|
+
"max_vertical_uncertainty_m": 30_000.0,
|
|
28
|
+
"zscore_sigma": 3.0,
|
|
29
|
+
"keep_nan_zscore": True,
|
|
30
|
+
"keep_missing": True, # a missing catalog field does not reject an event
|
|
31
|
+
},
|
|
32
|
+
# Random split per event: hash(salt | network | event_id). Networks are independent.
|
|
33
|
+
"split": {
|
|
34
|
+
"salt": "bepad-v1",
|
|
35
|
+
"fractions": {"train": 0.7, "val": 0.1, "test": 0.2},
|
|
36
|
+
"test_only_below": 200, # a network with fewer QC'd events is test only
|
|
37
|
+
},
|
|
38
|
+
# Label model (bepad.build.labels)
|
|
39
|
+
"labels": {
|
|
40
|
+
"gamma": 0.5, # prob = 0.5 + 0.5 * q ** gamma
|
|
41
|
+
"band_k": 2.0, # band = predicted arrival +/- band_k * sigma(r)
|
|
42
|
+
"max_gap_frac": 0.3, # generated picks only where neighbouring real picks are <= 30% of r apart
|
|
43
|
+
"sigma_floor_s": 0.05,
|
|
44
|
+
"active_days": 30, # station "available" for an event if it picked anything within +/- 30 days
|
|
45
|
+
"events_per_network": 1500, # events sampled per network for the detectability fit
|
|
46
|
+
},
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
WINDOW_DEFAULTS = {
|
|
50
|
+
"n_events": [3, 5],
|
|
51
|
+
# "auto" -> length_factor * longest event duration among the sampled events
|
|
52
|
+
"length_s": "auto",
|
|
53
|
+
"length_factor": 2.0,
|
|
54
|
+
"tail_frac": 0.05, # origins in [0, (1 - tail_frac) * length]
|
|
55
|
+
# events drawn within +/- this many days of an anchor event (null = anywhere)
|
|
56
|
+
"event_time_neighborhood_days": 365,
|
|
57
|
+
"edge_event_prob": 0.3, # one extra event starting before t = 0
|
|
58
|
+
# noise picks / real picks; noise never falls inside an event's band
|
|
59
|
+
"noise_ratio": 0.0,
|
|
60
|
+
"noise_any_station_frac": 0.1, # share of noise on any station (rest: uniform in distance to the events)
|
|
61
|
+
# generated picks / real picks, drawn per window from [lo, hi]
|
|
62
|
+
"generated_ratio": [0.0, 0.2],
|
|
63
|
+
"station_keep_frac": 1.0,
|
|
64
|
+
"gap_range": None, # event filters on pool columns
|
|
65
|
+
"magnitude_range": None,
|
|
66
|
+
# every event must pass the pool rule INSIDE the window (real picks only)
|
|
67
|
+
"event_min_p": 4,
|
|
68
|
+
"event_min_s": 0,
|
|
69
|
+
"event_min_stations": 3,
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
RELEASE_DEFAULTS = {
|
|
73
|
+
"release": "dev",
|
|
74
|
+
"data_root": "data",
|
|
75
|
+
"networks": "all",
|
|
76
|
+
"exclude_networks": [],
|
|
77
|
+
"min_events_per_split": 20,
|
|
78
|
+
"windows_per_scenario": {"train": 10, "val": 3, "test": 5}, # per network and scenario
|
|
79
|
+
"window": WINDOW_DEFAULTS,
|
|
80
|
+
"axes": {},
|
|
81
|
+
"grid": [],
|
|
82
|
+
"extra_scenarios": {},
|
|
83
|
+
"workers": 1,
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
|
|
87
|
+
def _deep_update(base, override):
|
|
88
|
+
out = copy.deepcopy(base)
|
|
89
|
+
for k, v in (override or {}).items():
|
|
90
|
+
if isinstance(v, dict) and isinstance(out.get(k), dict):
|
|
91
|
+
out[k] = _deep_update(out[k], v)
|
|
92
|
+
else:
|
|
93
|
+
out[k] = v
|
|
94
|
+
return out
|
|
95
|
+
|
|
96
|
+
|
|
97
|
+
def load_yaml(path):
|
|
98
|
+
with open(path) as f:
|
|
99
|
+
return yaml.safe_load(f) or {}
|
|
100
|
+
|
|
101
|
+
|
|
102
|
+
def resolve_path(p):
|
|
103
|
+
p = Path(p)
|
|
104
|
+
return p if p.is_absolute() else REPO_ROOT / p
|
|
105
|
+
|
|
106
|
+
|
|
107
|
+
def load_pool_config(path=None):
|
|
108
|
+
cfg = _deep_update(POOL_DEFAULTS, load_yaml(path) if path else {})
|
|
109
|
+
cfg["data_root"] = str(resolve_path(cfg["data_root"]))
|
|
110
|
+
return cfg
|
|
111
|
+
|
|
112
|
+
|
|
113
|
+
def load_release_config(path):
|
|
114
|
+
cfg = _deep_update(RELEASE_DEFAULTS, load_yaml(path))
|
|
115
|
+
cfg["data_root"] = str(resolve_path(cfg["data_root"]))
|
|
116
|
+
return cfg
|
bepad/build/geo.py
ADDED
|
@@ -0,0 +1,64 @@
|
|
|
1
|
+
"""Geometry, phase families and deterministic seeding."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import hashlib
|
|
6
|
+
|
|
7
|
+
import numpy as np
|
|
8
|
+
|
|
9
|
+
EARTH_RADIUS_KM = 6371.0
|
|
10
|
+
|
|
11
|
+
#: Same P/S families hypogt uses (hypogt.graph.utils.P_PHASES/S_PHASES).
|
|
12
|
+
P_PHASES = frozenset({"P", "Pn", "Pg"})
|
|
13
|
+
S_PHASES = frozenset({"S", "Sn", "Sg"})
|
|
14
|
+
|
|
15
|
+
|
|
16
|
+
def phase_family(phase):
|
|
17
|
+
"""Map raw phase codes (Pn, Pg, Sg, ...) to "P"/"S"; anything else -> None."""
|
|
18
|
+
if phase in P_PHASES:
|
|
19
|
+
return "P"
|
|
20
|
+
if phase in S_PHASES:
|
|
21
|
+
return "S"
|
|
22
|
+
return None
|
|
23
|
+
|
|
24
|
+
|
|
25
|
+
def haversine_km(lat0, lon0, lat1, lon1):
|
|
26
|
+
lat0, lon0, lat1, lon1 = (np.radians(np.asarray(x, dtype=float)) for x in (lat0, lon0, lat1, lon1))
|
|
27
|
+
a = np.sin((lat1 - lat0) / 2) ** 2 + np.cos(lat0) * np.cos(lat1) * np.sin((lon1 - lon0) / 2) ** 2
|
|
28
|
+
return 2 * EARTH_RADIUS_KM * np.arcsin(np.sqrt(np.clip(a, 0.0, 1.0)))
|
|
29
|
+
|
|
30
|
+
|
|
31
|
+
def azimuthal_gap_deg(lat0, lon0, lats, lons):
|
|
32
|
+
"""Largest empty angular sector (deg) seen from (lat0, lon0).
|
|
33
|
+
|
|
34
|
+
Same formula as hypogt.data.pre_transform._azimuthal_gap_deg; NaN for
|
|
35
|
+
fewer than 2 stations.
|
|
36
|
+
"""
|
|
37
|
+
lats = np.asarray(lats, dtype=float)
|
|
38
|
+
lons = np.asarray(lons, dtype=float)
|
|
39
|
+
if len(lats) < 2:
|
|
40
|
+
return np.nan
|
|
41
|
+
lat0_r, lon0_r = np.radians(lat0), np.radians(lon0)
|
|
42
|
+
lat_r, lon_r = np.radians(lats), np.radians(lons)
|
|
43
|
+
dlon = lon_r - lon0_r
|
|
44
|
+
bearing = np.arctan2(
|
|
45
|
+
np.sin(dlon) * np.cos(lat_r),
|
|
46
|
+
np.cos(lat0_r) * np.sin(lat_r) - np.sin(lat0_r) * np.cos(lat_r) * np.cos(dlon),
|
|
47
|
+
)
|
|
48
|
+
b = np.sort(np.degrees(bearing) % 360.0)
|
|
49
|
+
return float(np.diff(np.append(b, b[0] + 360.0)).max())
|
|
50
|
+
|
|
51
|
+
|
|
52
|
+
def stable_uniform(key: str, salt: str = "") -> float:
|
|
53
|
+
"""Deterministic pseudo-random number in [0, 1) from a string key.
|
|
54
|
+
|
|
55
|
+
Uses sha256, never Python's hash() (which is salted per process).
|
|
56
|
+
"""
|
|
57
|
+
h = hashlib.sha256(f"{salt}|{key}".encode()).digest()
|
|
58
|
+
return int.from_bytes(h[:8], "big") / 2**64
|
|
59
|
+
|
|
60
|
+
|
|
61
|
+
def stable_seed(*parts) -> int:
|
|
62
|
+
"""Deterministic 63-bit seed from any sequence of parts."""
|
|
63
|
+
h = hashlib.sha256("|".join(str(p) for p in parts).encode()).digest()
|
|
64
|
+
return int.from_bytes(h[:8], "big") >> 1
|
bepad/build/labels.py
ADDED
|
@@ -0,0 +1,341 @@
|
|
|
1
|
+
"""Label model: predicted arrivals, timing uncertainty and pick probabilities.
|
|
2
|
+
|
|
3
|
+
Three kinds of picks in a window::
|
|
4
|
+
|
|
5
|
+
real UTDQuake pick prob = 0.5 + 0.5 * q(M, r) ** gamma (>= 0.5)
|
|
6
|
+
generated artificial pick inside a band prob = 0.5 + 0.5 * (q(M, r) * g(z)) ** gamma
|
|
7
|
+
noise never inside any band prob = 0
|
|
8
|
+
|
|
9
|
+
q(M, r) detectability of magnitude M at hypocentral distance r: sigmoid((M - B(r)) / w),
|
|
10
|
+
B smooth and increasing in log10 r. ONE curve for all networks.
|
|
11
|
+
sigma(r) timing uncertainty of a predicted arrival, non-decreasing with r. ONE curve.
|
|
12
|
+
g(z) exp(-z^2 / 2), z = (t - predicted arrival) / sigma(r)
|
|
13
|
+
band predicted arrival +/- band_k * sigma(r)
|
|
14
|
+
|
|
15
|
+
Predicted arrival of an event at distance r (``EventArrivals.mu``)::
|
|
16
|
+
|
|
17
|
+
- between its first and last real pick of that phase: monotone interpolation of its own picks
|
|
18
|
+
- outside that range: the network's travel-time curve, shifted to join the event's picks
|
|
19
|
+
(network S curve = network P curve x network S/P ratio, clipped to 1.5-2.0; a network
|
|
20
|
+
without enough paired P/S picks uses the S/P ratio of all networks)
|
|
21
|
+
- S for an event without S picks: network S/P ratio x the event's own P prediction
|
|
22
|
+
|
|
23
|
+
Generated picks are allowed only between the event's first and last real pick
|
|
24
|
+
(never beyond its last station: the magnitude limit), and only where its
|
|
25
|
+
neighbouring real picks are at most ``max_gap_frac * r`` apart. Noise is
|
|
26
|
+
rejected inside the band of every event, at every distance.
|
|
27
|
+
|
|
28
|
+
``fit_label_model`` fits everything from the pool; the result is
|
|
29
|
+
``data/pool/label_model.json`` and is copied into every release.
|
|
30
|
+
"""
|
|
31
|
+
|
|
32
|
+
from __future__ import annotations
|
|
33
|
+
|
|
34
|
+
import json
|
|
35
|
+
from pathlib import Path
|
|
36
|
+
|
|
37
|
+
import numpy as np
|
|
38
|
+
import pandas as pd
|
|
39
|
+
from scipy.optimize import minimize
|
|
40
|
+
from scipy.special import expit
|
|
41
|
+
|
|
42
|
+
from .geo import haversine_km
|
|
43
|
+
|
|
44
|
+
KNOTS = np.arange(0.0, 4.26, 0.25) # log10 r [km] for B(r)
|
|
45
|
+
SIGMA_BINS = np.logspace(0, 4.1, 30) # km
|
|
46
|
+
CURVE_BINS = np.logspace(0, 4.3, 45) # km, network travel-time curves and S/P ratio
|
|
47
|
+
|
|
48
|
+
|
|
49
|
+
# ----------------------------------------------------------------------
|
|
50
|
+
# The model
|
|
51
|
+
# ----------------------------------------------------------------------
|
|
52
|
+
class LabelModel:
|
|
53
|
+
"""Evaluate the fitted label model (loaded from label_model.json)."""
|
|
54
|
+
|
|
55
|
+
def __init__(self, params: dict):
|
|
56
|
+
self.params = params
|
|
57
|
+
q = params["q"]
|
|
58
|
+
self._knots = np.asarray(q["knots_log10_km"], float)
|
|
59
|
+
self._B = np.asarray(q["B"], float)
|
|
60
|
+
self._w = float(q["w"])
|
|
61
|
+
s = params["sigma"]
|
|
62
|
+
self._sig_r = np.log(np.asarray(s["r_km"], float))
|
|
63
|
+
self._sig_s = np.log(np.asarray(s["sigma_s"], float))
|
|
64
|
+
self.gamma = float(params["gamma"])
|
|
65
|
+
self.band_k = float(params["band_k"])
|
|
66
|
+
self.max_gap_frac = float(params["max_gap_frac"])
|
|
67
|
+
self.networks = params["networks"]
|
|
68
|
+
|
|
69
|
+
# --- global -----------------------------------------------------------
|
|
70
|
+
def B(self, r):
|
|
71
|
+
return np.interp(np.log10(np.clip(r, 1.0, 2e4)), self._knots, self._B)
|
|
72
|
+
|
|
73
|
+
def q(self, M, r):
|
|
74
|
+
return expit((np.asarray(M, float) - self.B(r)) / self._w)
|
|
75
|
+
|
|
76
|
+
def sigma(self, r):
|
|
77
|
+
return np.exp(np.interp(np.log(np.clip(r, 1e-3, None)), self._sig_r, self._sig_s))
|
|
78
|
+
|
|
79
|
+
def prob_real(self, M, r):
|
|
80
|
+
return 0.5 + 0.5 * self.q(M, r) ** self.gamma
|
|
81
|
+
|
|
82
|
+
def prob_generated(self, M, r, z):
|
|
83
|
+
return 0.5 + 0.5 * (self.q(M, r) * np.exp(-0.5 * np.asarray(z) ** 2)) ** self.gamma
|
|
84
|
+
|
|
85
|
+
# --- per network --------------------------------------------------------
|
|
86
|
+
def _net(self, network):
|
|
87
|
+
if network not in self.networks:
|
|
88
|
+
raise KeyError(f"label model has no curves for network {network!r}; run `bepad pool fit-labels`")
|
|
89
|
+
return self.networks[network]
|
|
90
|
+
|
|
91
|
+
#: smallest slowness [s/km] allowed when extrapolating the P curve (1 / 25 km/s)
|
|
92
|
+
MIN_SLOWNESS = 0.04
|
|
93
|
+
#: physical range of the S/P travel-time ratio
|
|
94
|
+
SP_RANGE = (1.5, 2.0)
|
|
95
|
+
|
|
96
|
+
def tt_curve(self, network, phase, r):
|
|
97
|
+
"""Network travel-time curve.
|
|
98
|
+
|
|
99
|
+
P: median of the pool P picks vs hypocentral distance; beyond the last
|
|
100
|
+
node it continues with the mean slope of its last three nodes (never
|
|
101
|
+
flatter than MIN_SLOWNESS). S: the P curve x the network S/P ratio
|
|
102
|
+
(from stations that picked both), which stays well behaved where S
|
|
103
|
+
picks are sparse (e.g. teleseismic distances).
|
|
104
|
+
"""
|
|
105
|
+
r = np.asarray(r, float)
|
|
106
|
+
if phase == "S":
|
|
107
|
+
return self.tt_curve(network, "P", r) * self.sp_ratio(network, r)
|
|
108
|
+
c = self._net(network)["tt"]["P"]
|
|
109
|
+
x, y = np.asarray(c["r_km"], float), np.asarray(c["tt_s"], float)
|
|
110
|
+
out = np.interp(r, x, y)
|
|
111
|
+
beyond = r > x[-1]
|
|
112
|
+
if not beyond.any():
|
|
113
|
+
return out
|
|
114
|
+
k = min(3, len(x) - 1)
|
|
115
|
+
slope = (y[-1] - y[-1 - k]) / max(x[-1] - x[-1 - k], 1e-6) if k else self.MIN_SLOWNESS * 4
|
|
116
|
+
return np.where(beyond, y[-1] + max(slope, self.MIN_SLOWNESS) * (r - x[-1]), out)
|
|
117
|
+
|
|
118
|
+
def sp_ratio(self, network, r):
|
|
119
|
+
c = self._net(network)["sp_ratio"] or self.params["sp_ratio"] # too few P/S pairs: all networks' curve
|
|
120
|
+
return np.clip(np.interp(r, c["r_km"], c["ratio"]), *self.SP_RANGE)
|
|
121
|
+
|
|
122
|
+
def median_magnitude(self, network):
|
|
123
|
+
return float(self._net(network)["median_magnitude"])
|
|
124
|
+
|
|
125
|
+
# --- io -----------------------------------------------------------------
|
|
126
|
+
@classmethod
|
|
127
|
+
def load(cls, path):
|
|
128
|
+
return cls(json.loads(Path(path).read_text()))
|
|
129
|
+
|
|
130
|
+
def save(self, path):
|
|
131
|
+
Path(path).write_text(json.dumps(self.params, indent=2))
|
|
132
|
+
|
|
133
|
+
|
|
134
|
+
class EventArrivals:
|
|
135
|
+
"""Predicted arrivals of one event, from its own real picks (see module doc)."""
|
|
136
|
+
|
|
137
|
+
def __init__(self, model: LabelModel, network, r_km, tt_s, phase, last_station_km):
|
|
138
|
+
self.m, self.network, self.last = model, network, float(last_station_km)
|
|
139
|
+
self.own = {}
|
|
140
|
+
for ph in ("P", "S"):
|
|
141
|
+
sel = np.asarray(phase) == ph
|
|
142
|
+
r, t = np.asarray(r_km, float)[sel], np.asarray(tt_s, float)[sel]
|
|
143
|
+
o = np.argsort(r)
|
|
144
|
+
self.own[ph] = (r[o], np.maximum.accumulate(t[o]) if len(t) else t)
|
|
145
|
+
|
|
146
|
+
def _from_own(self, ph, r):
|
|
147
|
+
rr, tt = self.own[ph]
|
|
148
|
+
net = self.m.tt_curve(self.network, ph, r)
|
|
149
|
+
if len(rr) == 0:
|
|
150
|
+
return None
|
|
151
|
+
if len(rr) == 1:
|
|
152
|
+
return net + (tt[0] - self.m.tt_curve(self.network, ph, rr[0]))
|
|
153
|
+
inside = np.interp(r, rr, tt)
|
|
154
|
+
lo = net + (tt[0] - self.m.tt_curve(self.network, ph, rr[0]))
|
|
155
|
+
hi = net + (tt[-1] - self.m.tt_curve(self.network, ph, rr[-1]))
|
|
156
|
+
return np.where(r < rr[0], lo, np.where(r > rr[-1], hi, inside))
|
|
157
|
+
|
|
158
|
+
def mu(self, phase, r):
|
|
159
|
+
"""Predicted travel time [s] at hypocentral distance r [km]."""
|
|
160
|
+
r = np.asarray(r, float)
|
|
161
|
+
out = self._from_own(phase, r) if len(self.own[phase][0]) >= 2 or phase == "P" else None
|
|
162
|
+
if out is None and phase == "S" and len(self.own["P"][0]):
|
|
163
|
+
out = self._from_own("P", r) * self.m.sp_ratio(self.network, r)
|
|
164
|
+
if out is None:
|
|
165
|
+
out = self.m.tt_curve(self.network, phase, r)
|
|
166
|
+
return out
|
|
167
|
+
|
|
168
|
+
def can_generate(self, phase, r):
|
|
169
|
+
"""Where a generated pick of this phase is allowed (range + gap rule)."""
|
|
170
|
+
r = np.asarray(r, float)
|
|
171
|
+
rr = self.own[phase][0] if len(self.own[phase][0]) >= 2 else self.own["P"][0]
|
|
172
|
+
if len(rr) < 2:
|
|
173
|
+
return np.zeros(r.shape, bool)
|
|
174
|
+
j = np.clip(np.searchsorted(rr, r), 1, len(rr) - 1)
|
|
175
|
+
gap = rr[j] - rr[j - 1]
|
|
176
|
+
return (r >= rr[0]) & (r <= min(rr[-1], self.last)) & (gap <= self.m.max_gap_frac * np.maximum(r, 1e-6))
|
|
177
|
+
|
|
178
|
+
|
|
179
|
+
# ----------------------------------------------------------------------
|
|
180
|
+
# Fitting
|
|
181
|
+
# ----------------------------------------------------------------------
|
|
182
|
+
def _robust_sigma(x):
|
|
183
|
+
return 1.4826 * np.median(np.abs(x - np.median(x)))
|
|
184
|
+
|
|
185
|
+
|
|
186
|
+
def _pava(y, w):
|
|
187
|
+
"""Non-decreasing (isotonic) regression, pool-adjacent-violators."""
|
|
188
|
+
blocks = [[yi, wi, 1] for yi, wi in zip(y, w)]
|
|
189
|
+
i = 0
|
|
190
|
+
while i < len(blocks) - 1:
|
|
191
|
+
if blocks[i][0] > blocks[i + 1][0]:
|
|
192
|
+
a, b = blocks[i], blocks[i + 1]
|
|
193
|
+
ws = a[1] + b[1]
|
|
194
|
+
blocks[i] = [(a[0] * a[1] + b[0] * b[1]) / ws, ws, a[2] + b[2]]
|
|
195
|
+
del blocks[i + 1]
|
|
196
|
+
i = max(i - 1, 0)
|
|
197
|
+
else:
|
|
198
|
+
i += 1
|
|
199
|
+
return np.concatenate([[v] * n for v, _, n in blocks])
|
|
200
|
+
|
|
201
|
+
|
|
202
|
+
def detection_pairs(ev, pk, st, active_days, n_events, max_pairs, rng):
|
|
203
|
+
"""(event, available station) pairs: hypocentral distance, magnitude, picked?"""
|
|
204
|
+
ev = ev.dropna(subset=["magnitude"]).reset_index(drop=True)
|
|
205
|
+
if ev.empty:
|
|
206
|
+
return pd.DataFrame(columns=["r_km", "M", "picked"])
|
|
207
|
+
st = st.set_index("station")
|
|
208
|
+
t_ev = ev["time"].to_numpy("datetime64[s]").astype(np.int64)
|
|
209
|
+
pt = pk[["event_id", "station"]].merge(ev[["event_id", "time"]], on="event_id")
|
|
210
|
+
pt = pt.assign(t=pt["time"].to_numpy("datetime64[s]").astype(np.int64)).sort_values("t")
|
|
211
|
+
ts, ss = pt["t"].to_numpy(), pt["station"].to_numpy()
|
|
212
|
+
picked = pk.groupby("event_id")["station"].apply(set).to_dict()
|
|
213
|
+
rows = []
|
|
214
|
+
for i in rng.choice(len(ev), size=min(n_events, len(ev)), replace=False):
|
|
215
|
+
e = ev.iloc[i]
|
|
216
|
+
lo, hi = np.searchsorted(ts, [t_ev[i] - active_days * 86400, t_ev[i] + active_days * 86400])
|
|
217
|
+
avail = np.unique(ss[lo:hi])
|
|
218
|
+
s = st.loc[avail]
|
|
219
|
+
r = np.hypot(haversine_km(e["latitude"], e["longitude"], s["latitude"], s["longitude"]), e["depth"] / 1e3)
|
|
220
|
+
rows.append(pd.DataFrame({"r_km": r, "M": e["magnitude"],
|
|
221
|
+
"picked": np.isin(avail, list(picked.get(e["event_id"], ())))}))
|
|
222
|
+
d = pd.concat(rows, ignore_index=True)
|
|
223
|
+
return d.sample(max_pairs, random_state=0) if len(d) > max_pairs else d
|
|
224
|
+
|
|
225
|
+
|
|
226
|
+
def fit_q(det, smooth=30.0):
|
|
227
|
+
"""Global detectability: every network weighs the same (weights 1/len per network)."""
|
|
228
|
+
logr = np.log10(np.clip(det["r_km"].to_numpy(float), 1, 2e4))
|
|
229
|
+
M, y, wts = det["M"].to_numpy(float), det["picked"].to_numpy(float), det["w"].to_numpy(float)
|
|
230
|
+
|
|
231
|
+
def B_of(p):
|
|
232
|
+
steps = np.log1p(np.exp(p[1:len(KNOTS)]))
|
|
233
|
+
return np.interp(logr, KNOTS, p[0] + np.concatenate([[0.0], np.cumsum(steps)])), steps
|
|
234
|
+
|
|
235
|
+
def nll(p):
|
|
236
|
+
B, steps = B_of(p)
|
|
237
|
+
qv = np.clip(expit((M - B) / np.exp(p[-1])), 1e-9, 1 - 1e-9)
|
|
238
|
+
return (-np.sum(wts * (y * np.log(qv) + (1 - y) * np.log(1 - qv))) / wts.sum()
|
|
239
|
+
+ smooth * np.sum(np.diff(steps) ** 2) / len(det) * 1e3)
|
|
240
|
+
|
|
241
|
+
p0 = np.concatenate([[-1.0], np.full(len(KNOTS) - 1, 0.15), [np.log(0.5)]])
|
|
242
|
+
p = minimize(nll, p0, method="L-BFGS-B").x
|
|
243
|
+
steps = np.log1p(np.exp(p[1:len(KNOTS)]))
|
|
244
|
+
return {"knots_log10_km": KNOTS.round(4).tolist(),
|
|
245
|
+
"B": (p[0] + np.concatenate([[0.0], np.cumsum(steps)])).round(4).tolist(),
|
|
246
|
+
"w": round(float(np.exp(p[-1])), 4)}
|
|
247
|
+
|
|
248
|
+
|
|
249
|
+
def loo_residuals(pk):
|
|
250
|
+
"""Predict each interior pick from its two neighbours (same event + phase)."""
|
|
251
|
+
d = pk.sort_values(["event_id", "phase", "hyp_distance_km"])
|
|
252
|
+
key = (d["event_id"] + "|" + d["phase"]).to_numpy()
|
|
253
|
+
r, t = d["hyp_distance_km"].to_numpy(float), d["travel_time"].to_numpy(float)
|
|
254
|
+
same_prev = np.r_[False, key[1:] == key[:-1]]
|
|
255
|
+
same_next = np.r_[key[:-1] == key[1:], False]
|
|
256
|
+
r0, t0, r1, t1 = np.roll(r, 1), np.roll(t, 1), np.roll(r, -1), np.roll(t, -1)
|
|
257
|
+
ok = same_prev & same_next & (r1 - r0 > 0)
|
|
258
|
+
pred = t0 + (t1 - t0) * (r - r0) / np.where(r1 - r0 > 0, r1 - r0, 1)
|
|
259
|
+
return pd.DataFrame({"r_km": r[ok], "res": (t - pred)[ok]})
|
|
260
|
+
|
|
261
|
+
|
|
262
|
+
def fit_sigma(res, floor):
|
|
263
|
+
"""Per bin: robust sigma per network, averaged in log (networks weighted equally);
|
|
264
|
+
then non-decreasing and lightly smoothed."""
|
|
265
|
+
rows = []
|
|
266
|
+
for k in range(1, len(SIGMA_BINS)):
|
|
267
|
+
s = res[(res.r_km >= SIGMA_BINS[k - 1]) & (res.r_km < SIGMA_BINS[k])]
|
|
268
|
+
per = [_robust_sigma(g.res.to_numpy()) for _, g in s.groupby("network") if len(g) >= 50]
|
|
269
|
+
if per:
|
|
270
|
+
rows.append((np.sqrt(SIGMA_BINS[k - 1] * SIGMA_BINS[k]),
|
|
271
|
+
np.exp(np.mean(np.log(np.maximum(per, 1e-3)))), len(per)))
|
|
272
|
+
r_nodes, sig, w = map(np.array, zip(*rows))
|
|
273
|
+
logs = _pava(np.log(sig), w)
|
|
274
|
+
logs = np.convolve(np.pad(logs, 1, mode="edge"), np.ones(3) / 3, mode="valid")
|
|
275
|
+
sig = np.maximum(np.exp(np.maximum.accumulate(logs)), floor)
|
|
276
|
+
return {"r_km": r_nodes.round(3).tolist(), "sigma_s": sig.round(4).tolist()}
|
|
277
|
+
|
|
278
|
+
|
|
279
|
+
#: Paired P/S picks per network in the S/P curve of all networks.
|
|
280
|
+
SP_PAIRS_PER_NETWORK = 20_000
|
|
281
|
+
|
|
282
|
+
|
|
283
|
+
def network_curves(ev, pk):
|
|
284
|
+
"""Median travel time per phase vs hypocentral distance, and the S/P ratio."""
|
|
285
|
+
idx = np.digitize(pk["hyp_distance_km"], CURVE_BINS)
|
|
286
|
+
tt = {}
|
|
287
|
+
for ph in ("P", "S"):
|
|
288
|
+
g = pk[pk.phase == ph].groupby(idx[pk.phase == ph]).agg(
|
|
289
|
+
r=("hyp_distance_km", "median"), t=("travel_time", "median"), n=("travel_time", "size"))
|
|
290
|
+
g = g[g.n >= 20]
|
|
291
|
+
r = np.r_[0.0, g.r.to_numpy()]
|
|
292
|
+
t = np.maximum.accumulate(np.r_[0.0, g.t.to_numpy()])
|
|
293
|
+
tt[ph] = {"r_km": r.round(3).tolist(), "tt_s": t.round(4).tolist()}
|
|
294
|
+
p = pk[pk.phase == "P"][["event_id", "station", "hyp_distance_km", "travel_time"]]
|
|
295
|
+
s = pk[pk.phase == "S"][["event_id", "station", "travel_time"]]
|
|
296
|
+
m = p.merge(s, on=["event_id", "station"], suffixes=("_p", "_s"))
|
|
297
|
+
m = m[m.travel_time_p > 0]
|
|
298
|
+
m = m.assign(ratio=m.travel_time_s / m.travel_time_p)
|
|
299
|
+
mag = pd.to_numeric(ev["magnitude"], errors="coerce").median()
|
|
300
|
+
return {"tt": tt, "sp_ratio": sp_curve(m), "median_magnitude": float(mag) if mag == mag else 2.0}, \
|
|
301
|
+
m[["hyp_distance_km", "ratio"]]
|
|
302
|
+
|
|
303
|
+
|
|
304
|
+
def sp_curve(pairs):
|
|
305
|
+
"""Median S/P travel-time ratio per distance bin (bins with >= 30 pairs); None if no bin has enough."""
|
|
306
|
+
g = pairs.groupby(np.digitize(pairs.hyp_distance_km, CURVE_BINS)).agg(
|
|
307
|
+
r=("hyp_distance_km", "median"), ratio=("ratio", "median"), n=("ratio", "size"))
|
|
308
|
+
g = g[g.n >= 30]
|
|
309
|
+
return {"r_km": g.r.round(3).tolist(), "ratio": g.ratio.round(4).tolist()} if len(g) else None
|
|
310
|
+
|
|
311
|
+
|
|
312
|
+
def fit_label_model(data_root, networks, cfg, previous=None, refit_global=True, seed=0):
|
|
313
|
+
"""Fit (or extend) the label model from the pool.
|
|
314
|
+
|
|
315
|
+
``refit_global=False`` keeps q(M, r) and sigma(r) of ``previous`` and only
|
|
316
|
+
adds per-network curves -- used when a network is added to an existing release.
|
|
317
|
+
"""
|
|
318
|
+
from .pool import load_network
|
|
319
|
+
|
|
320
|
+
rng = np.random.default_rng(seed)
|
|
321
|
+
params = dict(previous or {})
|
|
322
|
+
params.setdefault("networks", {})
|
|
323
|
+
det, res, sp_pairs = [], [], []
|
|
324
|
+
for n in networks:
|
|
325
|
+
ev, pk, st = load_network(data_root, n)
|
|
326
|
+
params["networks"][n], pairs = network_curves(ev, pk)
|
|
327
|
+
if refit_global or not previous:
|
|
328
|
+
k = min(len(pairs), SP_PAIRS_PER_NETWORK) # every network weighs about the same
|
|
329
|
+
sp_pairs.append(pairs.iloc[rng.choice(len(pairs), k, replace=False)] if k else pairs)
|
|
330
|
+
pk_m = pk.merge(ev[["event_id", "magnitude"]], on="event_id")
|
|
331
|
+
d = detection_pairs(ev, pk_m, st, cfg["active_days"], cfg["events_per_network"],
|
|
332
|
+
cfg.get("pairs_per_network", 200_000), rng)
|
|
333
|
+
det.append(d.assign(network=n, w=1.0 / max(len(d), 1)))
|
|
334
|
+
res.append(loo_residuals(pk).assign(network=n))
|
|
335
|
+
print(f"[labels] {n}: curves done")
|
|
336
|
+
if refit_global or not previous:
|
|
337
|
+
params["q"] = fit_q(pd.concat(det, ignore_index=True))
|
|
338
|
+
params["sigma"] = fit_sigma(pd.concat(res, ignore_index=True), cfg["sigma_floor_s"])
|
|
339
|
+
params["sp_ratio"] = sp_curve(pd.concat(sp_pairs, ignore_index=True))
|
|
340
|
+
params.update({"gamma": cfg["gamma"], "band_k": cfg["band_k"], "max_gap_frac": cfg["max_gap_frac"]})
|
|
341
|
+
return LabelModel(params)
|