| from __future__ import annotations |
|
|
| import csv |
| import json |
| from pathlib import Path |
|
|
| import matplotlib.pyplot as plt |
| from matplotlib.ticker import PercentFormatter |
| import numpy as np |
|
|
|
|
| ROOT = Path(__file__).resolve().parents[1] |
| PAPER = ROOT / "grl_overleaf" |
| FIGDIR = PAPER / "figures" |
| SUMMARY = ( |
| ROOT |
| / "odata" |
| / "snr60_pnsn_v3_5120_20190706_20211113_reals_4_2_3_1_1p0_0p1_1p0" |
| / "final_snr_confidence_recall_summary.json" |
| ) |
| MULTISEED = ROOT / "outputs" / "grl_multiseed_seed20260609_20260611" / "multiseed_summary.json" |
|
|
| BLUE = "#4477AA" |
| ORANGE = "#CC6677" |
| GREEN = "#228833" |
| GRAY = "#666666" |
|
|
|
|
| def read_metric_csv(path: Path) -> list[dict[str, str]]: |
| with path.open(newline="", encoding="utf-8") as f: |
| return list(csv.DictReader(f)) |
|
|
|
|
| def metric_lookup(rows: list[dict[str, str]], metric_key: str) -> dict[str, float]: |
| return {r["condition_slug"]: float(r["value"]) for r in rows if r["metric_key"] == metric_key} |
|
|
|
|
| def multiseed_lookup(data: dict, task: str, metric: str) -> dict[str, dict[str, object]]: |
| out: dict[str, dict[str, object]] = {} |
| for row in data["rows"]: |
| if row["task"] == task and row["metric"] == metric: |
| out[row["condition_slug"]] = row |
| return out |
|
|
|
|
| def values(row: dict[str, object]) -> np.ndarray: |
| return np.asarray(row["values"], dtype=float) |
|
|
|
|
| def rel_stats(num: dict[str, object], den: dict[str, object], sign: float = 1.0) -> tuple[float, float]: |
| change = sign * (values(num) - values(den)) / values(den) |
| return float(np.mean(change)), float(np.std(change, ddof=1)) |
|
|
|
|
| def style_axes(ax: plt.Axes) -> None: |
| ax.spines["top"].set_visible(False) |
| ax.spines["right"].set_visible(False) |
| ax.grid(axis="y", color="#E5E5E5", linewidth=0.8) |
| ax.set_axisbelow(True) |
|
|
|
|
| def panel_label(ax: plt.Axes, text: str) -> None: |
| ax.text( |
| -0.12, |
| 1.08, |
| text, |
| transform=ax.transAxes, |
| fontsize=11, |
| fontweight="bold", |
| va="top", |
| ) |
|
|
|
|
| def make_combined_training_figure() -> None: |
| if MULTISEED.exists(): |
| data = json.loads(MULTISEED.read_text(encoding="utf-8")) |
| phase_metrics = { |
| key: multiseed_lookup(data, "phase", key) |
| for key in ["P_f1", "S_f1", "mean_f1", "P_precision", "S_precision", "P_recall", "S_recall"] |
| } |
| disp_metrics = { |
| key: multiseed_lookup(data, "dispersion", key) |
| for key in ["val_mae", "val_rmse"] |
| } |
| else: |
| phase = read_metric_csv(FIGDIR / "fig2_phase_picking_optimized_data.csv") |
| disp = read_metric_csv(FIGDIR / "fig3_dispersion_optimized_data.csv") |
| phase_metrics = {} |
| for key in ["P_f1", "S_f1", "mean_f1", "P_precision", "S_precision", "P_recall", "S_recall"]: |
| phase_metrics[key] = { |
| cond: {"mean": value, "std": 0.0, "values": [value]} |
| for cond, value in metric_lookup(phase, key).items() |
| } |
| disp_metrics = {} |
| for key in ["val_mae", "val_rmse"]: |
| disp_metrics[key] = { |
| cond: {"mean": value, "std": 0.0, "values": [value]} |
| for cond, value in metric_lookup(disp, key).items() |
| } |
|
|
| phase_order = ["full", "snr5", "snr10"] |
| phase_labels = ["Full", "SNR>5", "SNR>10"] |
| disp_order = ["full", "snr_q1", "snr_q2"] |
| disp_labels = ["Full", "SNR>3.04", "SNR>6.77"] |
|
|
| fig, axs = plt.subplots(2, 2, figsize=(7.2, 6.0), constrained_layout=True) |
|
|
| ax = axs[0, 0] |
| x = np.arange(len(phase_order)) |
| for key, marker, label, color in [ |
| ("P_f1", "o", "P", BLUE), |
| ("S_f1", "s", "S", ORANGE), |
| ("mean_f1", "^", "Mean", GREEN), |
| ]: |
| rows = phase_metrics[key] |
| ax.errorbar( |
| x, |
| [rows[k]["mean"] for k in phase_order], |
| yerr=[rows[k]["std"] for k in phase_order], |
| marker=marker, |
| linewidth=2, |
| capsize=3, |
| label=label, |
| color=color, |
| ) |
| ax.set_xticks(x, phase_labels) |
| ax.set_ylim(0.62, 0.82) |
| ax.set_ylabel("F1") |
| ax.set_title("Phase-picking F1") |
| ax.legend(frameon=False, ncols=3, fontsize=8) |
| style_axes(ax) |
| panel_label(ax, "A") |
|
|
| ax = axs[0, 1] |
| for key, marker, linestyle, label, color in [ |
| ("P_precision", "o", "-", "P prec.", BLUE), |
| ("P_recall", "o", "--", "P rec.", "#88CCEE"), |
| ("S_precision", "s", "-", "S prec.", ORANGE), |
| ("S_recall", "s", "--", "S rec.", "#DDCC77"), |
| ]: |
| rows = phase_metrics[key] |
| ax.errorbar( |
| x, |
| [rows[k]["mean"] for k in phase_order], |
| yerr=[rows[k]["std"] for k in phase_order], |
| marker=marker, |
| linewidth=2, |
| linestyle=linestyle, |
| capsize=3, |
| label=label, |
| color=color, |
| ) |
| ax.set_xticks(x, phase_labels) |
| ax.set_ylim(0.58, 0.84) |
| ax.set_ylabel("Score") |
| ax.set_title("Phase-picking precision and recall") |
| ax.legend(frameon=False, ncols=2, fontsize=7.5) |
| style_axes(ax) |
| panel_label(ax, "B") |
|
|
| ax = axs[1, 0] |
| x2 = np.arange(len(disp_order)) |
| for key, marker, label, color in [ |
| ("val_mae", "o", "MAE", BLUE), |
| ("val_rmse", "s", "RMSE", ORANGE), |
| ]: |
| rows = disp_metrics[key] |
| ax.errorbar( |
| x2, |
| [rows[k]["mean"] for k in disp_order], |
| yerr=[rows[k]["std"] for k in disp_order], |
| marker=marker, |
| linewidth=2, |
| capsize=3, |
| label=label, |
| color=color, |
| ) |
| ax.set_xticks(x2, disp_labels) |
| ax.set_ylim(0.04, 0.073) |
| ax.set_ylabel("Velocity error (km/s)") |
| ax.set_title("Dispersion estimation") |
| ax.legend(frameon=False, ncols=2, fontsize=8) |
| style_axes(ax) |
| panel_label(ax, "C") |
|
|
| ax = axs[1, 1] |
| stage_labels = ["Full", "Moderate", "Strict"] |
| x3 = np.arange(len(stage_labels)) |
| mean_f1_change = [(0.0, 0.0)] + [ |
| rel_stats(phase_metrics["mean_f1"][cond], phase_metrics["mean_f1"]["full"]) |
| for cond in ["snr5", "snr10"] |
| ] |
| mae_change = [(0.0, 0.0)] + [ |
| rel_stats(disp_metrics["val_mae"][cond], disp_metrics["val_mae"]["full"]) |
| for cond in ["snr_q1", "snr_q2"] |
| ] |
| rmse_change = [(0.0, 0.0)] + [ |
| rel_stats(disp_metrics["val_rmse"][cond], disp_metrics["val_rmse"]["full"]) |
| for cond in ["snr_q1", "snr_q2"] |
| ] |
| ax.axhline(0, color="#333333", linewidth=0.8) |
| for series, marker, label, color in [ |
| (mean_f1_change, "^", "Mean F1", GREEN), |
| (mae_change, "o", "MAE", BLUE), |
| (rmse_change, "s", "RMSE", ORANGE), |
| ]: |
| ax.errorbar( |
| x3, |
| [v[0] for v in series], |
| yerr=[v[1] for v in series], |
| marker=marker, |
| linewidth=2, |
| capsize=3, |
| label=label, |
| color=color, |
| ) |
| ax.set_xticks(x3, stage_labels) |
| ax.yaxis.set_major_formatter(PercentFormatter(1.0)) |
| ax.set_ylabel("Relative change vs full") |
| ax.set_title("Trend relative to full training") |
| ax.legend(frameon=False, fontsize=8) |
| style_axes(ax) |
| panel_label(ax, "D") |
|
|
| for ax in axs.ravel(): |
| ax.tick_params(labelsize=8) |
| ax.title.set_fontsize(10) |
|
|
| for ext in ["pdf", "png"]: |
| fig.savefig(FIGDIR / f"fig2_training_combined.{ext}", dpi=300, bbox_inches="tight") |
| plt.close(fig) |
|
|
|
|
| def make_association_figure() -> None: |
| data = json.loads(SUMMARY.read_text(encoding="utf-8")) |
| pick_eval = data["pick_eval"] |
| event_eval = data["association"]["event_eval"] |
|
|
| conds = ["snr", "confidence"] |
| labels = ["SNR", "Confidence"] |
| colors = [BLUE, ORANGE] |
| hatches = ["", "///"] |
|
|
| fig, axs = plt.subplots(2, 2, figsize=(7.2, 6.0), constrained_layout=True) |
|
|
| for subset, ax, label, letter in [ |
| ("all", axs[0, 0], "P/S observability: manual + automatic labels", "A"), |
| ("manual", axs[0, 1], "P/S observability: manual labels", "B"), |
| ]: |
| phases = ["P", "S", "P_S_combined"] |
| phase_labels = ["P", "S", "P+S"] |
| xx = np.arange(len(phases)) |
| width = 0.36 |
| for i, cond in enumerate(conds): |
| vals = [ |
| pick_eval[cond]["recall_covered_subsets"][subset][ph]["recall_covered"] |
| for ph in phases |
| ] |
| ax.bar( |
| xx + (i - 0.5) * width, |
| vals, |
| width, |
| label=labels[i], |
| color=colors[i], |
| hatch=hatches[i], |
| edgecolor="#333333", |
| linewidth=0.4, |
| ) |
| for xpos, val in zip(xx + (i - 0.5) * width, vals): |
| ax.text(xpos, val + 0.018, f"{val:.2f}", ha="center", va="bottom", fontsize=6.8) |
| ax.set_xticks(xx, phase_labels) |
| ax.set_ylim(0, 0.95) |
| ax.set_ylabel("Recall covered") |
| ax.set_title(label) |
| ax.legend(frameon=False, fontsize=8) |
| style_axes(ax) |
| panel_label(ax, letter) |
|
|
| ax = axs[1, 0] |
| event_recall = [event_eval[c]["metrics"]["recall"] for c in conds] |
| x = np.arange(len(conds)) |
| bars = ax.bar(x, event_recall, color=colors, edgecolor="#333333", linewidth=0.4) |
| for bar, hatch in zip(bars, hatches): |
| bar.set_hatch(hatch) |
| ax.set_xticks(x, labels) |
| ax.set_ylim(0, 0.75) |
| ax.set_ylabel("Earthquake recall") |
| ax.set_title("Association observability") |
| for bar, cond in zip(bars, conds): |
| tp = event_eval[cond]["counts"]["true_positive_events"] |
| ref = event_eval[cond]["counts"]["reference_events"] |
| ax.text( |
| bar.get_x() + bar.get_width() / 2, |
| bar.get_height() + 0.02, |
| f"{tp}/{ref}", |
| ha="center", |
| va="bottom", |
| fontsize=8, |
| ) |
| style_axes(ax) |
| panel_label(ax, "C") |
|
|
| ax = axs[1, 1] |
| tp = [event_eval[c]["counts"]["true_positive_events"] for c in conds] |
| fn = [event_eval[c]["counts"]["false_negative_events"] for c in conds] |
| ax.bar(x, tp, color=GREEN, label="Recovered", edgecolor="#333333", linewidth=0.4) |
| ax.bar(x, fn, bottom=tp, color=GRAY, hatch="///", label="Missed", edgecolor="#333333", linewidth=0.4) |
| ax.set_xticks(x, labels) |
| ax.set_ylim(0, 3000) |
| ax.set_ylabel("Catalog events") |
| ax.set_title("Recovered vs missed catalog events") |
| ax.legend(frameon=False, fontsize=8) |
| style_axes(ax) |
| panel_label(ax, "D") |
|
|
| for ax in axs.ravel(): |
| ax.tick_params(labelsize=8) |
| ax.title.set_fontsize(10) |
|
|
| for ext in ["pdf", "png"]: |
| fig.savefig(FIGDIR / f"fig3_continuous_association.{ext}", dpi=300, bbox_inches="tight") |
| plt.close(fig) |
|
|
| rows = [] |
| for subset in ["all", "manual"]: |
| for cond in conds: |
| for phase in ["P", "S", "P_S_combined"]: |
| rec = pick_eval[cond]["recall_covered_subsets"][subset][phase]["recall_covered"] |
| rows.append( |
| { |
| "panel": "phase_recall", |
| "subset": subset, |
| "condition": cond, |
| "phase": phase, |
| "value": rec, |
| } |
| ) |
| for cond in conds: |
| rows.append( |
| { |
| "panel": "event_recall", |
| "subset": "catalog", |
| "condition": cond, |
| "phase": "event", |
| "value": event_eval[cond]["metrics"]["recall"], |
| } |
| ) |
| with (FIGDIR / "fig3_continuous_association_data.csv").open("w", newline="", encoding="utf-8") as f: |
| writer = csv.DictWriter( |
| f, |
| fieldnames=["panel", "subset", "condition", "phase", "value"], |
| lineterminator="\n", |
| ) |
| writer.writeheader() |
| writer.writerows(rows) |
|
|
|
|
| def main() -> None: |
| make_combined_training_figure() |
| make_association_figure() |
|
|
|
|
| if __name__ == "__main__": |
| main() |
|
|