The QuakeScope picker benchmarks, consolidated¶

Five notebooks benchmark the pickers behind the 2026 catalogue, each against a different reference and each rendered as its own report. This notebook reads their exported result tables (docs/benchmark/results/, written by an export cell at the end of each notebook when it is executed) and draws the figures a paper needs from all of them at once. Nothing is re-picked here and no number is typed in: change a benchmark, re-execute it, re-execute this.

study notebook reference result files
US sequences phasenet_sequence_comparison.ipynb reviewed ComCat events, arrivals from SCEDC + NCEDC, manual only us_sequences/
Global sequences phasenet_global_sequences.ipynb GeoNet, INGV, NOA bulletins, manual only global_sequences/
Ridgecrest aftershocks phasenet_aftershock_benchmark.ipynb SCEDC analyst picks, one dense 30 min window ridgecrest_aftershocks/
Ocean bottom phasenet_obs_offshore_benchmark.ipynb iasp91 predictions; Barcheck AACSE analyst picks; UW Axial picks; LDEO ML-DD events obs_offshore/
Western reproduction western_pick_validation.ipynb the campaign's own Parquet, re-picked via FDSN western_reproduction/

The protocol, the caveats and the reading of these figures are in docs/benchmark/README.md.

In [1]:
import json
from pathlib import Path

import numpy as np
import pandas as pd
import matplotlib.pyplot as plt

RES = Path("../docs/benchmark/results")
WEIGHTS = ["quakescope2026", "jma_wc", "original", "instance"]
COLORS = dict(zip(WEIGHTS, ["#2a78d6", "#eb6834", "#1baf7a", "#eda100"]))
C_P, C_S = "#2a78d6", "#eb6834"
plt.rcParams.update({"figure.dpi": 110, "axes.grid": True, "grid.alpha": 0.25, "grid.linewidth": 0.5,
                     "axes.axisbelow": True, "axes.spines.top": False, "axes.spines.right": False})

def load(study, name):
    p = RES / study / f"{name}.csv"
    return pd.read_csv(p) if p.exists() else None

meta = {s: json.loads((RES / s / "meta.json").read_text()) for s in
        ["us_sequences", "global_sequences", "ridgecrest_aftershocks", "obs_offshore", "western_reproduction"]
        if (RES / s / "meta.json").exists()}
print("result sets and when they were executed:")
for s, m in meta.items():
    print(f"  {s:24s} {m['executed']}  seisbench {m.get('seisbench')}")
result sets and when they were executed:
  us_sequences             2026-09-16T19:41:46+00:00  seisbench 0.12.3
  global_sequences         2026-09-16T19:31:15+00:00  seisbench 0.12.3
  ridgecrest_aftershocks   2026-09-16T19:21:05+00:00  seisbench 0.12.3
  obs_offshore             2026-09-16T19:34:01+00:00  seisbench 0.12.3
  western_reproduction     2026-09-16T19:46:12+00:00  seisbench 0.12.3

1. Recall against analyst picks at the shared 0.3 threshold¶

Seven sequences with a published analyst reference: four in the western United States (SCEDC and NCEDC, manually reviewed picks on curated event lists) and three abroad (the operators' bulletins). Six stations per sequence abroad, five in the US; windows of 30 min to 3 h starting 10 min after the mainshock; a pick counts as recovered within 0.5 s. The number under each group is the analyst pick count, the sample size of that recall.

In [2]:
us = load("us_sequences", "recall_at_threshold")
gl = load("global_sequences", "recall_at_threshold")
us["region"], gl["region"] = "US", "abroad"
bench = pd.concat([us, gl], ignore_index=True)
ORDER = [s for s in ["Ridgecrest", "San Simeon", "Monte Cristo", "Mendocino 2024", "Kaikoura 2016", "Norcia 2016", "Thessaly 2021"]
         if s in set(bench.sequence)]

fig, axes = plt.subplots(2, 1, figsize=(12.5, 7.2), sharex=True)
for ax, phase in zip(axes, ("P", "S")):
    sub = bench[bench.phase == phase]
    w = 0.8 / len(WEIGHTS)
    for i, name in enumerate(WEIGHTS):
        xs, ys = [], []
        for j, s in enumerate(ORDER):
            r = sub[(sub.sequence == s) & (sub.weights == name)]
            if len(r):
                xs.append(j + (i - 1.5) * w); ys.append(float(r.recall.iloc[0]))
        ax.bar(xs, ys, width=w * 0.92, color=COLORS[name], label=name)
        for x, y in zip(xs, ys):
            ax.text(x, y + 0.012, f"{y:.2f}", ha="center", fontsize=6.6, color="#3d3d3d")
    ax.set_ylim(0, 1.08); ax.set_ylabel(f"{phase} recall at conf >= 0.3"); ax.grid(axis="x")
axes[0].legend(frameon=False, ncol=4, fontsize=9, loc="upper right")
n_of = lambda s, ph: int(bench[(bench.sequence == s) & (bench.phase == ph)].analyst.iloc[0])
axes[1].set_xticks(range(len(ORDER)))
axes[1].set_xticklabels([f"{s}\nn = {n_of(s, 'P')} P, {n_of(s, 'S')} S" for s in ORDER], fontsize=8.5)
axes[1].axvline(3.5, color="#8a8a8a", lw=0.8, ls=":"); axes[0].axvline(3.5, color="#8a8a8a", lw=0.8, ls=":")
axes[0].set_title("Recall against analyst picks at a shared threshold (US left, abroad right)", loc="left", fontsize=11)
fig.tight_layout()
No description has been provided for this image
In [3]:
tab = (bench.pivot_table(index=["phase", "sequence"], columns="weights", values="recall")
            .reindex(columns=WEIGHTS).reindex([(p, s) for p in ("P", "S") for s in ORDER]))
tab["analyst"] = [int(bench[(bench.phase == p) & (bench.sequence == s)].analyst.iloc[0]) for p, s in tab.index]
tab.round(3)
Out[3]:
weights quakescope2026 jma_wc original instance analyst
phase sequence
P Ridgecrest 0.605 0.582 0.648 0.216 347
San Simeon 0.823 0.855 0.855 0.887 62
Monte Cristo 0.750 0.812 0.812 0.875 16
Mendocino 2024 0.830 0.900 0.680 0.840 200
Kaikoura 2016 0.661 0.703 0.658 0.644 357
Norcia 2016 0.763 0.795 0.736 0.618 701
Thessaly 2021 0.719 0.747 0.724 0.724 395
S Ridgecrest 0.517 0.487 0.725 0.181 298
San Simeon 0.750 0.750 0.938 0.750 16
Monte Cristo 0.417 0.500 0.667 0.500 12
Mendocino 2024 0.619 0.681 0.735 0.558 113
Kaikoura 2016 0.502 0.520 0.705 0.533 458
Norcia 2016 0.744 0.777 0.907 0.669 656
Thessaly 2021 0.390 0.402 0.429 0.393 331

2. A threshold is not an operating point¶

The models' probabilities sit on different scales, so at 0.3 they emit different numbers of picks and the ranking above partly measures liberality. Recall against picks emitted takes the threshold out: each curve is the same model swept from 0.02 to 0.7, and the comparison is made at equal pick counts.

In [4]:
sweep = pd.concat([load("us_sequences", "threshold_sweep").assign(region="US"),
                   load("global_sequences", "threshold_sweep").assign(region="abroad")], ignore_index=True)

seqs = [s for s in ORDER if s in set(sweep.sequence)]
fig, axes = plt.subplots(2, len(seqs), figsize=(3.3 * len(seqs), 6.4), squeeze=False)
for row, phase in enumerate(("P", "S")):
    for ax, s in zip(axes[row], seqs):
        sub = sweep[(sweep.phase == phase) & (sweep.sequence == s)]
        for name in WEIGHTS:
            d = sub[sub.weights == name].sort_values("emitted")
            if not len(d):
                continue
            ax.plot(d.emitted, d.recall, marker="o", ms=3, lw=1.6, color=COLORS[name], label=name)
            star = d[np.isclose(d.thr, 0.3)]
            if len(star):
                ax.plot(star.emitted, star.recall, marker="*", ms=11, color=COLORS[name], mec="#16150f", mew=0.5, zorder=5)
        ax.set_title(f"{s}  {phase}", fontsize=9.5, loc="left"); ax.set_ylim(0, 1)
        if not len(sub):
            n = int(bench[(bench.sequence == s) & (bench.phase == phase)].analyst.iloc[0])
            ax.text(0.5, 0.5, f"reference too small\nto sweep (n = {n})", ha="center", va="center",
                    transform=ax.transAxes, fontsize=8.5, color="#7a7973")
            ax.set_xticks([])
        if row == 1:
            ax.set_xlabel("picks emitted", fontsize=9)
    axes[row][0].set_ylabel(f"{phase} recall")
axes[0][0].legend(frameon=False, fontsize=7.5)
fig.suptitle("Recall against picks emitted; the star is the shared 0.3 threshold", x=0.01, ha="left", fontsize=11)
fig.tight_layout()
No description has been provided for this image

Recall at a matched pick budget¶

For each sequence and phase, the budget is the midpoint of the range every model can reach, and each model's recall is read off its curve there. This is the table the choice of weights should be made from.

In [5]:
def matched(sweep, phase, sequence, names=WEIGHTS):
    sub = sweep[(sweep.phase == phase) & (sweep.sequence == sequence)]
    present = [n for n in names if n in set(sub.weights)]
    if len(present) < 2:                    # the sweep skips references under 20 picks
        return None
    lo = max(sub[sub.weights == n].emitted.min() for n in present)
    hi = min(sub[sub.weights == n].emitted.max() for n in present)
    if not np.isfinite([lo, hi]).all() or hi <= lo:
        return None
    mid = 0.5 * (lo + hi)
    row = {"budget": int(round(mid))}
    for n in present:
        d = sub[sub.weights == n].sort_values("emitted")
        row[n] = float(np.interp(mid, d.emitted, d.recall))
    return row

rows = []
for phase in ("P", "S"):
    for s in seqs:
        r = matched(sweep, phase, s)
        if r is None:                       # a model tops out below the others' floor
            r = matched(sweep, phase, s, [n for n in WEIGHTS if n != "instance"])
            if r is None:
                continue
            r["instance"] = np.nan
        rows.append(dict(phase=phase, sequence=s, **r))
budget = pd.DataFrame(rows).set_index(["phase", "sequence"])
budget.round(3)
Out[5]:
budget quakescope2026 jma_wc original instance
phase sequence
P Ridgecrest 462 0.689 0.671 0.631 NaN
San Simeon 395 0.830 0.817 0.857 0.903
Mendocino 2024 490 0.832 0.814 0.690 0.888
Kaikoura 2016 1214 0.560 0.587 0.588 0.698
Norcia 2016 910 0.660 0.680 0.621 0.773
Thessaly 2021 1333 0.662 0.688 0.653 0.756
S Ridgecrest 411 0.654 0.635 0.624 NaN
Mendocino 2024 320 0.580 0.579 0.616 0.650
Kaikoura 2016 902 0.452 0.452 0.423 0.667
Norcia 2016 936 0.651 0.669 0.612 0.849
Thessaly 2021 1063 0.401 0.403 0.359 0.445

Blank instance cells are sequences where it cannot reach the others' pick counts even with its threshold on the floor: a ceiling, not a calibration offset, and the reason it is not the default weight despite winning most of the matched comparisons where it does reach.

In [6]:
pair = budget.dropna(subset=["quakescope2026", "jma_wc"]).copy()
pair["v7 minus jma_wc, matched budget"] = pair.quakescope2026 - pair.jma_wc
at_thr = bench.pivot_table(index=["phase", "sequence"], columns="weights", values="recall")
pair["v7 minus jma_wc, at 0.3"] = (at_thr.quakescope2026 - at_thr.jma_wc).reindex(pair.index)

fig, ax = plt.subplots(figsize=(8.5, 4.2))
y = np.arange(len(pair))
ax.axvline(0, color="#8a8a8a", lw=1)
ax.plot(pair["v7 minus jma_wc, at 0.3"], y, marker="o", ls="none", ms=6, color="#b8b8b3", label="at the shared 0.3")
ax.plot(pair["v7 minus jma_wc, matched budget"], y, marker="o", ls="none", ms=7, color="#2a78d6", label="at a matched pick budget")
ax.set_yticks(y); ax.set_yticklabels([f"{s}  {p}" for p, s in pair.index], fontsize=8.5)
ax.set_xlabel("recall of quakescope2026 minus recall of jma_wc"); ax.grid(axis="y")
ax.legend(frameon=False, fontsize=9, loc="lower left")
ax.set_title("The fine-tune against its parent, every sequence and phase", loc="left", fontsize=11)
fig.tight_layout()
print(f"mean difference at matched budget: P {pair.xs('P')['v7 minus jma_wc, matched budget'].mean():+.3f}, "
      f"S {pair.xs('S')['v7 minus jma_wc, matched budget'].mean():+.3f}; "
      f"positive in {(pair['v7 minus jma_wc, matched budget'] > 0).sum()} of {len(pair)} cases")
mean difference at matched budget: P -0.004, S -0.000; positive in 5 of 11 cases
No description has been provided for this image

3. Timing¶

Mean absolute residual of matched picks against the analyst time at the shared threshold. The fine-tune was selected on timing; this is where an edge would show.

In [7]:
mae = bench.pivot_table(index=["phase", "sequence"], columns="weights", values="MAE").reindex(columns=WEIGHTS)
mae = mae.reindex([(p, s) for p in ("P", "S") for s in ORDER])
(mae * 1000).round(0).astype("Int64").rename(columns=lambda c: f"{c} (ms)")
Out[7]:
weights quakescope2026 (ms) jma_wc (ms) original (ms) instance (ms)
phase sequence
P Ridgecrest 40 33 58 48
San Simeon 67 66 58 83
Monte Cristo 47 51 49 89
Mendocino 2024 71 74 119 89
Kaikoura 2016 139 139 154 141
Norcia 2016 49 48 57 56
Thessaly 2021 129 135 139 139
S Ridgecrest 53 64 66 79
San Simeon 20 35 103 94
Monte Cristo 219 236 146 174
Mendocino 2024 86 101 124 120
Kaikoura 2016 162 163 177 166
Norcia 2016 64 69 85 76
Thessaly 2021 129 132 146 148

4. Dense aftershocks, one station cluster¶

A separate, smaller benchmark on a 30 min Ridgecrest window with overlapping events, scored the same way; its tolerance sweep shows the recall numbers are not sensitive to the matching window between 0.25 and 2 s.

In [8]:
ra = load("ridgecrest_aftershocks", "recall_at_threshold")
sens = load("ridgecrest_aftershocks", "tolerance_sensitivity")
display(ra.pivot(index="weights", columns="phase", values=["analyst", "recall", "MAE"]).reindex(WEIGHTS).round(3))
sens.set_index("tolerance_s").round(3)
analyst recall MAE
phase P S P S P S
weights
quakescope2026 125.0 144.0 0.928 0.792 0.034 0.045
jma_wc 125.0 144.0 0.936 0.806 0.030 0.053
original 125.0 144.0 0.872 0.868 0.037 0.056
instance 125.0 144.0 0.784 0.729 0.036 0.080
Out[8]:
quakescope2026 jma_wc original instance
tolerance_s
0.25 0.792 0.799 0.868 0.715
0.50 0.792 0.806 0.868 0.729
1.00 0.792 0.806 0.868 0.729
2.00 0.792 0.806 0.868 0.729

5. Ocean-bottom stations¶

Three questions, three references. Do OBS-trained weights beat land weights on OBS data (iasp91-predicted P within 10 s, three deployments)? Do the obs campaign's stored picks agree with published analyst picks (Barcheck's AACSE tables, 1 s)? And what happens on caldera microseismicity (Axial Seamount, against the UW real-time picks and the LDEO ML-DD events)?

In [9]:
det = load("obs_offshore", "detection_vs_iasp91")
aacse = load("obs_offshore", "aacse_campaign_vs_analyst")
resc = pd.read_csv(RES / "obs_offshore" / "aacse_rescoring.csv", header=[0, 1])
resc.columns = [a if b.startswith("Unnamed") or b == "P_median_dt" and a == "median_P_dt_s" else f"{a} {b}" for a, b in resc.columns]
resc = resc.rename(columns={resc.columns[0]: "weights"}).set_index("weights")
axial = load("obs_offshore", "axial_event_detection_by_magnitude")

fig, axes = plt.subplots(1, 3, figsize=(15, 4), gridspec_kw=dict(width_ratios=[1.2, 1, 1]))

ax = axes[0]
piv = det.pivot(index="weights", columns="experiment", values="rate")
piv = piv.reindex(["pickblue_phasenet", "pickblue_eqt", "obstransformer", "quakescope2026", "original"])
x = np.arange(len(piv)); w = 0.26
for k, (exp, c) in enumerate(zip(piv.columns, ["#2a78d6", "#eb6834", "#1baf7a"])):
    ax.bar(x + (k - 1) * w, piv[exp], width=w * 0.92, color=c, label=exp)
ax.set_xticks(x); ax.set_xticklabels(piv.index, rotation=20, ha="right", fontsize=8)
ax.set_ylim(0, 1); ax.set_ylabel("P within 10 s of iasp91, conf >= 0.3"); ax.legend(frameon=False, fontsize=8)
ax.set_title("Five models, three deployments", loc="left", fontsize=10.5); ax.grid(axis="x")

ax = axes[1]
tols = [0.25, 0.5, 1.0, 2.0]
for _, r in aacse.iterrows():
    ax.plot(tols, [r[f"recall_{t:g}s"] for t in tols], marker="o", ms=4, lw=1.6,
            color=C_P if r.pha == "P" else C_S, ls="-" if r.stations == "OBS" else "--",
            label=f"{r.stations} {r.pha} (n={int(r.n):,})")
ax.set_xscale("log"); ax.set_xticks(tols); ax.set_xticklabels([f"{t:g}" for t in tols])
ax.set_xlabel("tolerance (s)"); ax.set_ylabel("recall of manual picks, covered station-days"); ax.set_ylim(0, 1)
ax.legend(frameon=False, fontsize=8); ax.set_title("obs campaign vs AACSE analysts", loc="left", fontsize=10.5)

ax = axes[2]
ax.plot(range(len(axial)), axial.detected, marker="o", ms=5, lw=1.8, color="#2a78d6", label="P on >= 3 stations")
ax.plot(range(len(axial)), axial.detected_conf05, marker="o", ms=5, lw=1.8, color="#eb6834", label="same, conf >= 0.5")
for i, n in enumerate(axial.n):
    ax.text(i, 0.02, f"{int(n):,}", ha="center", fontsize=6.5, color="#555", rotation=90, va="bottom")
ax.set_xticks(range(len(axial))); ax.set_xticklabels(axial.magnitude, fontsize=8); ax.set_xlabel("LDEO magnitude")
ax.set_ylim(0, 1); ax.set_ylabel("fraction of LDEO events detected"); ax.legend(frameon=False, fontsize=8)
ax.set_title("Axial Seamount, event level", loc="left", fontsize=10.5)
fig.tight_layout()
No description has been provided for this image
In [10]:
print("AACSE 2018: the five models re-scored on the same analyst windows, plus the campaign's stored pick")
resc.round(3)
AACSE 2018: the five models re-scored on the same analyst windows, plus the campaign's stored pick
Out[10]:
conf>=0.3 P conf>=0.3 S conf>=0.2 P conf>=0.2 S median_P_dt_s
weights
pickblue_phasenet 0.695 0.651 0.770 0.737 0.03
pickblue_eqt 0.652 0.569 0.735 0.675 0.04
obstransformer 0.752 0.842 0.805 0.866 0.07
quakescope2026 0.638 0.292 0.742 0.459 0.06
original 0.448 0.478 0.488 0.560 0.10
campaign (stored) 0.802 0.703 0.825 0.809 0.01

6. Does the stored catalogue reproduce?¶

Station-days of the western campaign re-picked through the production code path with ObsPy/FDSN as the data source on a different CPU architecture, and matched to the stored Parquet on (station, band, phase, peak time) exactly.

In [11]:
rep = load("western_reproduction", "per_station_day")
if rep is None:
    print("western_reproduction/ not present: execute tutorials/western_pick_validation.ipynb first")
else:
    m = meta["western_reproduction"]["totals"]
    print(f"{m['station_days_with_picks']} of {m['station_days_targeted']} targeted station-days hold campaign picks; on those:")
    print(f"  campaign picks {m['campaign_picks']:,}, re-picked {m['repicked']:,}, matched exactly {m['matched_exact']:,} "
          f"({m['matched_exact'] / m['campaign_picks']:.4%} of the campaign's), campaign-only {m['campaign_only']}, re-pick-only {m['repick_only']:,}")
    r = rep[rep.n_prod > 0].sort_values(["sequence", "kind", "tid"]).reset_index(drop=True)
    fig, ax = plt.subplots(figsize=(9, 0.22 * len(r) + 1.2))
    y = np.arange(len(r))
    ax.barh(y, r.recall, color=[C_P if k == "mainshock" else C_S for k in r.kind], height=0.72)
    for i, row in r.iterrows():
        ax.text(min(row.recall, 1.0) + 0.0004, i, f"{int(row.n_prod):,}", va="center", fontsize=7, color="#3d3d3d")
    ax.set_yticks(y); ax.set_yticklabels([f"{a}  {b}  {c}" for a, b, c in zip(r.sequence, r.tid, r.day)], fontsize=7)
    ax.set_xlim(0.95, 1.004); ax.set_xlabel("fraction of the campaign's picks reproduced exactly (axis starts at 0.95)")
    ax.legend(handles=[plt.Rectangle((0, 0), 1, 1, color=C_P, label="mainshock day"),
                       plt.Rectangle((0, 0), 1, 1, color=C_S, label="quiet day")], frameon=False, fontsize=8, loc="lower left")
    ax.set_title("Exact reproduction by station-day; the number is the campaign's pick count", loc="left", fontsize=10.5)
    ax.grid(axis="y"); fig.tight_layout()
33 of 52 targeted station-days hold campaign picks; on those:
  campaign picks 59,315, re-picked 69,063, matched exactly 59,298 (99.9713% of the campaign's), campaign-only 17, re-pick-only 9,765
No description has been provided for this image

7. Everything in one table¶

Recall at the matched budget where one exists, else at the shared threshold, for the three land weights and the incumbent; the sample size; and the study each row comes from. This is the table to cite.

In [12]:
summary = budget.copy()
summary["n_analyst"] = [int(bench[(bench.phase == p) & (bench.sequence == s)].analyst.iloc[0]) for p, s in summary.index]
summary["study"] = ["global_sequences" if bench[bench.sequence == s].region.iloc[0] == "abroad" else "us_sequences"
                    for p, s in summary.index]
cols = ["budget", "n_analyst"] + WEIGHTS + ["study"]
summary = summary[cols]
summary.to_csv(RES / "summary_matched_budget.csv")
summary.round(3)
Out[12]:
budget n_analyst quakescope2026 jma_wc original instance study
phase sequence
P Ridgecrest 462 347 0.689 0.671 0.631 NaN us_sequences
San Simeon 395 62 0.830 0.817 0.857 0.903 us_sequences
Mendocino 2024 490 200 0.832 0.814 0.690 0.888 us_sequences
Kaikoura 2016 1214 357 0.560 0.587 0.588 0.698 global_sequences
Norcia 2016 910 701 0.660 0.680 0.621 0.773 global_sequences
Thessaly 2021 1333 395 0.662 0.688 0.653 0.756 global_sequences
S Ridgecrest 411 298 0.654 0.635 0.624 NaN us_sequences
Mendocino 2024 320 113 0.580 0.579 0.616 0.650 us_sequences
Kaikoura 2016 902 458 0.452 0.452 0.423 0.667 global_sequences
Norcia 2016 936 656 0.651 0.669 0.612 0.849 global_sequences
Thessaly 2021 1063 331 0.401 0.403 0.359 0.445 global_sequences

Regenerate: execute the five benchmark notebooks (their runtimes are in reports/README.md), each of which writes its tables to docs/benchmark/results/<study>/, then execute this notebook. meta.json in each folder records when and with which SeisBench version.