#!/usr/bin/env python3 """Build Figure 4: matched learning performance and continuous pick-recall sweep.""" from __future__ import annotations import csv import json from pathlib import Path import matplotlib matplotlib.use("Agg") import matplotlib.pyplot as plt import numpy as np OVERLEAF_ROOT = Path(__file__).resolve().parents[1] PROJECT_ROOT = OVERLEAF_ROOT.parent OUTPUTS = PROJECT_ROOT / "outputs" FIG_DIR = OVERLEAF_ROOT / "figures" PHASE_RECORDS = OUTPUTS / "snr_transfer_phase_balanced_cache" / "records_train_all.json" PHASE_SNR = OUTPUTS / "snr_transfer_phase_balanced_cache" / "train_phase_snr_db.json" DISP_SNR = OUTPUTS / "disp_snr_transfer_seed20260609" / "ncf_snr_cache.json" PHASE_SUMMARY = OUTPUTS / "grl_phase_any_multiseed_seed20260609_20260611" / "phase_multiseed_summary.csv" PHASE_DIRECT_BASELINE = OUTPUTS / "grl_phase_any_multiseed_seed20260609_20260611" / "phase_direct_baseline_summary.csv" DISP_SUMMARY = OUTPUTS / "grl_multiseed_seed20260609_20260611" / "dispersion_multiseed_summary.csv" CONTINUOUS_SWEEP = OUTPUTS / "continuous_phase_recall_snr_conf_sweep" / "continuous_phase_recall_snr_conf_sweep.csv" OUT_PDF = FIG_DIR / "fig_learning_selection_generalization_summary_v2.pdf" OUT_PNG = FIG_DIR / "fig_learning_selection_generalization_summary_v2.png" OUT_DATA = FIG_DIR / "fig_learning_selection_generalization_summary_v2_data.csv" BLUE = "#1f5a99" RED = "#b84a4a" TEAL = "#2a8c8c" GRAY = "#6d6d6d" LIGHT_GRAY = "#d8d8d8" PHASE_TO_GROUP = {"Pg": "P", "Pn": "P", "Sg": "S", "Sn": "S"} PHASE_THRESHOLDS = { "P/S>=5 dB record-any": 5.0, "P/S>=10 dB record-any": 10.0, } def read_summary(path: Path) -> dict[tuple[str, str], tuple[float, float]]: values: dict[tuple[str, str], tuple[float, float]] = {} with path.open(newline="", encoding="utf-8") as f: for row in csv.DictReader(f): values[(row["condition_slug"], row["metric"])] = ( float(row["mean"]), float(row["std"]), ) return values def binned_fraction( distances: list[float], snrs: list[float], threshold: float | None, bins: np.ndarray, ) -> tuple[np.ndarray, np.ndarray, np.ndarray]: distances_np = np.asarray(distances, dtype=float) snrs_np = np.asarray(snrs, dtype=float) full_counts, _ = np.histogram(distances_np, bins=bins) if threshold is None: kept_counts = full_counts.copy() else: kept_counts, _ = np.histogram(distances_np[snrs_np > threshold], bins=bins) with np.errstate(divide="ignore", invalid="ignore"): frac = np.where(full_counts > 0, kept_counts / full_counts * 100.0, np.nan) centers = (bins[:-1] + bins[1:]) / 2.0 return centers, frac, full_counts def binned_mask_fraction( distances: list[float], keep: list[bool], bins: np.ndarray, ) -> tuple[np.ndarray, np.ndarray, np.ndarray]: distances_np = np.asarray(distances, dtype=float) keep_np = np.asarray(keep, dtype=bool) full_counts, _ = np.histogram(distances_np, bins=bins) kept_counts, _ = np.histogram(distances_np[keep_np], bins=bins) with np.errstate(divide="ignore", invalid="ignore"): frac = np.where(full_counts > 0, kept_counts / full_counts * 100.0, np.nan) centers = (bins[:-1] + bins[1:]) / 2.0 return centers, frac, full_counts def phase_snr_key(record: dict, pick: dict, pick_index: int) -> str: return ( f"{record['event']}/{record['station']}/{pick_index}:" f"{pick['phase']}:{pick['index']}:{pick['source']}" ) def phase_retention() -> dict[str, tuple[np.ndarray, np.ndarray, np.ndarray]]: records = json.loads(PHASE_RECORDS.read_text(encoding="utf-8"))["records"] snr_by_phase = json.loads(PHASE_SNR.read_text(encoding="utf-8")) distances: list[float] = [] snrs_by_record: list[list[float]] = [] for record in records: record_snrs: list[float] = [] for pick_index, pick in enumerate(record["phases"]): snr = snr_by_phase.get(phase_snr_key(record, pick, pick_index)) if snr is None: continue record_snrs.append(float(snr)) if record_snrs: distances.append(float(record["distance_km"])) snrs_by_record.append(record_snrs) bins = np.arange(0, 451, 50) out = {"Full pool": binned_mask_fraction(distances, [True] * len(distances), bins)} for label, threshold in PHASE_THRESHOLDS.items(): keep = [any(np.isfinite(snr) and snr >= threshold for snr in record_snrs) for record_snrs in snrs_by_record] out[label] = binned_mask_fraction(distances, keep, bins) return { key: out[key] for key in ["Full pool", "P/S>=5 dB record-any", "P/S>=10 dB record-any"] } def dispersion_retention() -> dict[str, tuple[np.ndarray, np.ndarray, np.ndarray]]: cache = json.loads(DISP_SNR.read_text(encoding="utf-8")) train_rows = [v for v in cache.values() if v.get("split") == "train"] distances = [float(v["distance_km"]) for v in train_rows] snrs = [float(v["snr_db"]) for v in train_rows] bins = np.arange(75, 576, 50) return { "Full pool": binned_fraction(distances, snrs, None, bins), "SNR>3.04 dB": binned_fraction(distances, snrs, 3.0356278596583564, bins), "SNR>6.77 dB": binned_fraction(distances, snrs, 6.774182330903824, bins), } def write_plot_data( phase_summary: dict[tuple[str, str], tuple[float, float]], direct_baseline: dict[tuple[str, str], tuple[float, float]], disp_summary: dict[tuple[str, str], tuple[float, float]], continuous_rows: list[dict[str, str]], ) -> None: rows: list[dict[str, str | float]] = [] for condition, label in [ ("finetune_full", "Fine-tune 2k full"), ("finetune_p5_s_bal", "Fine-tune 2k P/S>=5 dB record-any"), ("finetune_p10_s_bal", "Fine-tune 2k P/S>=10 dB record-any"), ("scratch_full", "Scratch 10k full"), ("scratch_p5_s_bal", "Scratch 10k P/S>=5 dB record-any"), ("scratch_p10_s_bal", "Scratch 10k P/S>=10 dB record-any"), ]: for metric in ["P_f1", "S_f1"]: mean, std = phase_summary[(condition, metric)] rows.append( { "panel": "phase_test", "condition": label, "x": condition, "metric": metric, "value": mean, "std": std, "full_bin_count": "", } ) for metric in ["P_f1", "S_f1"]: mean, std = direct_baseline[("pretrained_direct", metric)] rows.append( { "panel": "phase_test", "condition": "Pretrained direct use", "x": "direct", "metric": metric, "value": mean, "std": std, "full_bin_count": "", } ) for condition, label in [ ("full", "Full distribution"), ("snr_q1", "SNR>3.04 dB"), ("snr_q2", "SNR>6.77 dB"), ]: for metric in ["val_mae", "val_rmse"]: mean, std = disp_summary[(condition, metric)] rows.append( { "panel": "dispersion_test", "condition": label, "x": condition, "metric": metric, "value": mean, "std": std, "full_bin_count": "", } ) for row in continuous_rows: if row["selection"] == "snr": label = "SNR filter" elif row["confidence_scope"] == "phase_count_matched": label = "Confidence, same phase count" else: continue rows.append( { "panel": "continuous_phase_recall", "condition": label, "x": row["threshold_label"], "metric": f"{row['phase']}_recall", "value": float(row["recall"]), "std": "", "full_bin_count": row["auto_count_phase"], } ) full_phase_counts = { row["phase"]: float(row["auto_count_phase"]) for row in continuous_rows if row["selection"] == "snr" and row["threshold_label"] == "Full" } for row in continuous_rows: if row["selection"] != "snr": continue phase = row["phase"] rows.append( { "panel": "continuous_phase_recall", "condition": "Retained picks", "x": row["threshold_label"], "metric": f"{phase}_retained_pick_percent", "value": 100.0 * float(row["auto_count_phase"]) / full_phase_counts[phase], "std": "", "full_bin_count": row["auto_count_phase"], } ) with OUT_DATA.open("w", newline="", encoding="utf-8") as f: writer = csv.DictWriter( f, fieldnames=["panel", "condition", "x", "metric", "value", "std", "full_bin_count"], ) writer.writeheader() writer.writerows(rows) def style_axes(ax: plt.Axes) -> None: ax.spines["top"].set_visible(False) ax.spines["right"].set_visible(False) ax.grid(axis="y", color="#ececec", linewidth=0.8) ax.tick_params(axis="both", labelsize=8) def plot_retention(ax: plt.Axes, data: dict[str, tuple[np.ndarray, np.ndarray, np.ndarray]], title: str, xlabel: str) -> None: styles = { "Full pool": (GRAY, "--", "o"), "SNR>5 dB": (BLUE, "-", "o"), "SNR>10 dB": (RED, "-", "s"), "P/S>=5 dB record-any": (BLUE, "-", "o"), "P/S>=10 dB record-any": (RED, "-", "s"), "SNR>3.04 dB": (BLUE, "-", "o"), "SNR>6.77 dB": (RED, "-", "s"), } for label, (centers, frac, counts) in data.items(): mask = counts > 0 color, linestyle, marker = styles[label] ax.plot( centers[mask], frac[mask], color=color, linestyle=linestyle, marker=marker, markersize=3.5, linewidth=1.4, label=label, ) ax.set_title(title, loc="left", fontsize=10, fontweight="bold") ax.set_xlabel(xlabel, fontsize=9) ax.set_ylabel("Training candidates retained (%)", fontsize=9) ax.set_ylim(-2, 105) ax.set_yticks([0, 25, 50, 75, 100]) style_axes(ax) ax.legend(frameon=False, fontsize=7.2, loc="lower left") def plot_phase_metrics( ax: plt.Axes, summary: dict[tuple[str, str], tuple[float, float]], direct_baseline: dict[tuple[str, str], tuple[float, float]], ) -> None: conditions = ["full", "p5_s_bal", "p10_s_bal"] labels = ["No filter", "SNR >= 5 dB", "SNR >= 10 dB"] x = np.arange(len(conditions), dtype=float) phases = [("P", BLUE, -0.045), ("S", RED, 0.045)] init_styles = [ ("finetune", "Fine-tune (2k)", "-", "o", 1.0), ("scratch", "Scratch (10k)", "--", "s", 0.86), ] for phase, color, offset in phases: for init_mode, init_label, linestyle, marker, alpha in init_styles: means = [summary[(f"{init_mode}_{c}", f"{phase}_f1")][0] for c in conditions] stds = [summary[(f"{init_mode}_{c}", f"{phase}_f1")][1] for c in conditions] label = f"{phase} {init_label}" ax.errorbar( x + offset, means, yerr=stds, color=color, linestyle=linestyle, marker=marker, markersize=3.8, linewidth=1.25, capsize=2.4, alpha=alpha, label=label, ) for phase, color in [("P", BLUE), ("S", RED)]: mean, _std = direct_baseline[("pretrained_direct", f"{phase}_f1")] ax.axhline( mean, color=color, linestyle=":", linewidth=1.15, alpha=0.62, label=f"{phase} direct", ) ax.set_title("Phase-picking test performance", loc="left", fontsize=10, fontweight="bold") ax.set_ylabel("Phase F1 on unfiltered test windows", fontsize=9) ax.set_xticks(x) ax.set_xticklabels(labels, fontsize=7.8) ax.set_ylim(0.5, 0.77) ax.set_yticks([0.5, 0.6, 0.7]) style_axes(ax) ax.legend(frameon=False, fontsize=6.5, loc="lower left", ncol=2, columnspacing=0.75, handlelength=1.6) ax.text(2.08, 0.535, "Lower\nF1", fontsize=7.4, color=RED, ha="left", va="bottom") def plot_dispersion_metrics(ax: plt.Axes, summary: dict[tuple[str, str], tuple[float, float]]) -> None: conditions = ["full", "snr_q1", "snr_q2"] labels = ["No filter", "SNR > 3.04 dB", "SNR > 6.77 dB"] x = np.arange(len(conditions), dtype=float) metrics = [("val_mae", "MAE", BLUE, "o"), ("val_rmse", "RMSE", RED, "s")] offsets = [-0.09, 0.09] for offset, (metric, label, color, marker) in zip(offsets, metrics): means = [summary[(c, metric)][0] for c in conditions] stds = [summary[(c, metric)][1] for c in conditions] ax.errorbar( x + offset, means, yerr=stds, color=color, marker=marker, markersize=4, linewidth=1.3, capsize=2.5, label=label, ) ax.set_title("Dispersion test performance", loc="left", fontsize=10, fontweight="bold") ax.set_ylabel("Error on unfiltered test set (km/s)", fontsize=9) ax.set_xticks(x) ax.set_xticklabels(labels, fontsize=7.8) ax.set_ylim(0.043, 0.072) style_axes(ax) ax.legend(frameon=False, fontsize=7.2, loc="upper left") ax.text(2.08, 0.068, "Higher\nerror", fontsize=7.4, color=RED, ha="left", va="center") def load_continuous_recall() -> list[dict[str, str]]: with CONTINUOUS_SWEEP.open(newline="", encoding="utf-8") as f: return list(csv.DictReader(f)) def plot_continuous_phase_recall(ax: plt.Axes, rows: list[dict[str, str]], phase: str, show_legend: bool) -> None: phase_color = BLUE if phase == "P" else RED x_ticks = [-1, 0, 2, 4, 6, 8, 10] x_labels = ["No\nfilter", "0", "2", "4", "6", "8", "10"] styles = [ ("snr", "", "SNR filter", "-", "o", 1.0), ("confidence", "phase_count_matched", "Confidence, same phase count", "--", "s", 0.72), ] legend_handles = [] legend_labels = [] for selection, scope, label, linestyle, marker, alpha in styles: series = sorted( [ row for row in rows if row["phase"] == phase and row["selection"] == selection and row["confidence_scope"] == scope and (row["threshold_label"] == "Full" or float(row["threshold_db"]).is_integer()) ], key=lambda row: float(row["threshold_db"]), ) (line,) = ax.plot( [float(row["threshold_db"]) for row in series], [100.0 * float(row["recall"]) for row in series], color=phase_color, linestyle=linestyle, marker=marker, markersize=3.7, linewidth=1.5, alpha=alpha, label=label, ) legend_handles.append(line) legend_labels.append(label) snr_series = sorted( [ row for row in rows if row["phase"] == phase and row["selection"] == "snr" and (row["threshold_label"] == "Full" or float(row["threshold_db"]).is_integer()) ], key=lambda row: float(row["threshold_db"]), ) full_count = next(float(row["auto_count_phase"]) for row in snr_series if row["threshold_label"] == "Full") retained_percent = [100.0 * float(row["auto_count_phase"]) / full_count for row in snr_series] for marker_x in (5.0, 10.0): ax.axvline(marker_x, color="#8a8a8a", linewidth=0.75, linestyle=":", zorder=0) ax.set_title(f"{phase}-phase continuous recall", loc="left", fontsize=10, fontweight="bold") ax.set_xlabel("SNR threshold (dB)", fontsize=9) ax.set_ylabel("Recall of covered manual picks (%)", fontsize=9) ax.set_xlim(-1.25, 10.2) ax.set_xticks(x_ticks) ax.set_xticklabels(x_labels, fontsize=8) ax.set_ylim(-3, 95) style_axes(ax) ax_count = ax.twinx() (count_line,) = ax_count.plot( [float(row["threshold_db"]) for row in snr_series], retained_percent, color=GRAY, linestyle=":", marker="^", markersize=3.6, linewidth=1.45, alpha=0.95, label="Retained picks (right axis)", ) ax_count.set_ylim(-3, 105) ax_count.set_yticks([0, 25, 50, 75, 100]) ax_count.set_ylabel("Retained picks (%)", fontsize=9, color=GRAY) ax_count.tick_params(axis="y", labelsize=8, colors=GRAY) ax_count.spines["top"].set_visible(False) ax_count.spines["left"].set_visible(False) ax_count.spines["right"].set_color(GRAY) if show_legend: ax.legend( legend_handles + [count_line], legend_labels + ["Retained picks"], frameon=False, fontsize=7.0, loc="lower left", ) def add_panel_label(ax: plt.Axes, label: str) -> None: ax.text(-0.13, 1.08, label, transform=ax.transAxes, fontsize=11, fontweight="bold", va="bottom") def main() -> None: phase_summary = read_summary(PHASE_SUMMARY) direct_baseline = read_summary(PHASE_DIRECT_BASELINE) disp_summary = read_summary(DISP_SUMMARY) continuous_rows = load_continuous_recall() write_plot_data(phase_summary, direct_baseline, disp_summary, continuous_rows) plt.rcParams.update( { "font.family": "DejaVu Sans", "font.size": 8.5, "axes.linewidth": 0.8, "pdf.fonttype": 42, "ps.fonttype": 42, } ) fig, axes = plt.subplots(2, 2, figsize=(7.2, 5.0), constrained_layout=True) plot_phase_metrics(axes[0, 0], phase_summary, direct_baseline) plot_dispersion_metrics(axes[0, 1], disp_summary) plot_continuous_phase_recall(axes[1, 0], continuous_rows, "P", show_legend=True) plot_continuous_phase_recall(axes[1, 1], continuous_rows, "S", show_legend=False) for ax, label in zip(axes.flat, ["a", "b", "c", "d"]): add_panel_label(ax, label) fig.savefig(OUT_PDF, bbox_inches="tight") fig.savefig(OUT_PNG, dpi=300, bbox_inches="tight") print(f"Wrote {OUT_PDF}") print(f"Wrote {OUT_PNG}") print(f"Wrote {OUT_DATA}") if __name__ == "__main__": main()