#!/usr/bin/env python3 """Generate optimized GRL Figures 2 and 3 from matched SNR experiments. The current experiment directories contain aggregate summaries but not per-window phase predictions or per-sample dispersion errors. This script therefore plots verified point estimates and paired differences, while marking bootstrap confidence intervals as unavailable instead of fabricating them. """ from __future__ import annotations import csv import json import math from datetime import datetime, timezone from pathlib import Path from typing import Iterable import matplotlib.pyplot as plt import numpy as np ROOT = Path(__file__).resolve().parents[1] PHASE_DIR = ROOT / "outputs" / "snr_transfer_seed20260609" DISP_DIR = ROOT / "outputs" / "disp_snr_transfer_seed20260609" FIG_DIR = ROOT / "grl_overleaf" / "figures" REPORT = ROOT / "outputs" / "figure_metric_discrepancy_report.md" CONDITION_LABELS = { "full": "Full distribution", "snr5": "Moderate SNR filter", "snr10": "Strict SNR filter", "snr_q1": "Moderate SNR filter", "snr_q2": "Strict SNR filter", } PHASE_ORDER = ["full", "snr5", "snr10"] DISP_ORDER = ["full", "snr_q1", "snr_q2"] PALETTE = { "P F1": "#0072B2", "S F1": "#D55E00", "Mean F1": "#009E73", "P precision": "#0072B2", "S precision": "#D55E00", "P recall": "#56B4E9", "S recall": "#E69F00", "MAE": "#0072B2", "RMSE": "#D55E00", } CI_STATUS = "not_available_missing_per_sample_outputs" CI_NOTE = "95% CI unavailable from aggregate-only outputs" def read_json(path: Path) -> dict: with path.open("r", encoding="utf-8") as f: return json.load(f) def write_csv(path: Path, rows: Iterable[dict]) -> None: rows = list(rows) path.parent.mkdir(parents=True, exist_ok=True) fieldnames = sorted({key for row in rows for key in row.keys()}) with path.open("w", encoding="utf-8", newline="") as f: writer = csv.DictWriter(f, fieldnames=fieldnames, extrasaction="ignore") writer.writeheader() for row in rows: writer.writerow(row) def row_map(summary: dict) -> dict[str, dict]: return {row["slug"]: row for row in summary["rows"]} def style_axes(ax) -> None: ax.spines[["top", "right"]].set_visible(False) ax.grid(axis="y", color="#e6e6e6", linewidth=0.8) ax.set_axisbelow(True) ax.tick_params(labelsize=8) def add_panel_label(ax, label: str) -> None: ax.text( 0.01, 0.94, label, transform=ax.transAxes, fontsize=11, fontweight="bold", va="top", ha="left", bbox={"facecolor": "white", "edgecolor": "none", "alpha": 0.85, "pad": 1.5}, ) def annotate_ci_unavailable(ax) -> None: ax.text( 0.99, 0.04, CI_NOTE, transform=ax.transAxes, ha="right", va="bottom", fontsize=7, color="#666666", ) def plot_line_points(ax, x: np.ndarray, values: list[float], label: str, marker: str = "o") -> None: ax.plot( x, values, marker=marker, linewidth=1.6, markersize=5.2, label=label, color=PALETTE[label], ) def per_sample_candidates(directory: Path) -> list[str]: patterns = ("pred", "prediction", "per_sample", "per-window", "per_window", "error", "sample_metrics") data_suffixes = {".csv", ".json", ".npz", ".npy", ".parquet", ".h5", ".hdf5"} out = [] for path in directory.glob("*"): if path.name.startswith("._"): continue if path.suffix.lower() not in data_suffixes: continue lower = path.name.lower() if any(pattern in lower for pattern in patterns): out.append(str(path.relative_to(ROOT))) return sorted(out) def make_phase_data(phase: dict) -> list[dict]: rows = row_map(phase) baseline = rows["full"] out = [] for slug in PHASE_ORDER: row = rows[slug] for metric_key, metric_label in [ ("P_f1", "P F1"), ("S_f1", "S F1"), ("mean_f1", "Mean F1"), ("P_precision", "P precision"), ("S_precision", "S precision"), ("P_recall", "P recall"), ("S_recall", "S recall"), ]: value = float(row[metric_key]) base = float(baseline[metric_key]) out.append( { "figure": "Figure 2", "condition": CONDITION_LABELS[slug], "condition_slug": slug, "condition_index": PHASE_ORDER.index(slug), "metric": metric_label, "metric_key": metric_key, "value": value, "delta_vs_full": value - base, "ci_low": "", "ci_high": "", "delta_ci_low": "", "delta_ci_high": "", "ci_status": CI_STATUS, "ci_method": "paired bootstrap over 10000 windows requires per-window prediction counts", "train_records": row["train_records"], "test_samples": phase["eval_samples"], "test_set": "unfiltered CREDIT-X1 test windows", } ) return out def make_disp_data(disp: dict) -> list[dict]: rows = row_map(disp) baseline = rows["full"] out = [] for slug in DISP_ORDER: row = rows[slug] for metric_key, metric_label in [("val_mae", "MAE"), ("val_rmse", "RMSE")]: value = float(row[metric_key]) base = float(baseline[metric_key]) delta = value - base pct = 100.0 * delta / base if base else math.nan out.append( { "figure": "Figure 3", "condition": CONDITION_LABELS[slug], "condition_slug": slug, "condition_index": DISP_ORDER.index(slug), "metric": metric_label, "metric_key": metric_key, "value": value, "delta_vs_full": delta, "percent_change_vs_full": pct, "ci_low": "", "ci_high": "", "delta_ci_low": "", "delta_ci_high": "", "ci_status": CI_STATUS, "ci_method": "paired bootstrap over 8292 samples requires per-sample prediction errors", "train_records": row["train_records"], "test_samples": disp["test_records"], "test_set": "unfiltered NCF test samples", } ) return out def figure2(phase: dict, data: list[dict]) -> None: x = np.arange(len(PHASE_ORDER)) labels = [CONDITION_LABELS[slug] for slug in PHASE_ORDER] rows = row_map(phase) fig, axes = plt.subplots( 3, 1, figsize=(7.2, 8.1), dpi=220, sharex=True, gridspec_kw={"height_ratios": [1.15, 1.0, 1.0], "hspace": 0.32}, ) for metric_key, metric_label in [("P_f1", "P F1"), ("S_f1", "S F1"), ("mean_f1", "Mean F1")]: values = [float(rows[slug][metric_key]) for slug in PHASE_ORDER] plot_line_points(axes[0], x, values, metric_label) axes[0].set_ylabel("F1 on unfiltered test set", fontsize=9) axes[0].set_ylim(0.66, 0.78) axes[0].legend(frameon=False, ncol=3, fontsize=8, loc="upper right") axes[0].set_title( "Phase picking: matched transfer learning, 60,837 records per condition\n" "Filters: SNR > 5 dB and SNR > 10 dB; evaluation: 10,000 unfiltered CREDIT-X1 test windows", fontsize=9.2, loc="left", pad=12, ) add_panel_label(axes[0], "A") annotate_ci_unavailable(axes[0]) for metric_key, metric_label in [("P_f1", "P F1"), ("S_f1", "S F1"), ("mean_f1", "Mean F1")]: base = float(rows["full"][metric_key]) values = [float(rows[slug][metric_key]) - base for slug in PHASE_ORDER] plot_line_points(axes[1], x, values, metric_label) axes[1].axhline(0, color="#333333", linewidth=0.9) axes[1].set_ylabel("Change relative to\nfull distribution", fontsize=9) axes[1].set_ylim(-0.06, 0.012) axes[1].legend(frameon=False, ncol=3, fontsize=8, loc="lower left") add_panel_label(axes[1], "B") annotate_ci_unavailable(axes[1]) for metric_key, metric_label, marker in [ ("P_precision", "P precision", "o"), ("S_precision", "S precision", "o"), ("P_recall", "P recall", "^"), ("S_recall", "S recall", "^"), ]: base = float(rows["full"][metric_key]) values = [float(rows[slug][metric_key]) - base for slug in PHASE_ORDER] plot_line_points(axes[2], x, values, metric_label, marker=marker) axes[2].axhline(0, color="#333333", linewidth=0.9) axes[2].set_ylabel("Change relative to\nfull distribution", fontsize=9) axes[2].set_ylim(-0.14, 0.03) axes[2].legend(frameon=False, ncol=2, fontsize=8, loc="lower left") add_panel_label(axes[2], "C") annotate_ci_unavailable(axes[2]) for ax in axes: style_axes(ax) ax.set_xlim(-0.15, len(x) - 0.85) axes[-1].set_xticks(x) axes[-1].set_xticklabels(labels, fontsize=8) fig.subplots_adjust(left=0.16, right=0.985, top=0.91, bottom=0.08, hspace=0.34) fig.savefig(FIG_DIR / "fig2_phase_picking_optimized.pdf", bbox_inches="tight", facecolor="white") fig.savefig(FIG_DIR / "fig2_phase_picking_optimized.png", bbox_inches="tight", facecolor="white") plt.close(fig) write_csv(FIG_DIR / "fig2_phase_picking_optimized_data.csv", data) def figure3(disp: dict, data: list[dict]) -> None: x = np.arange(len(DISP_ORDER)) labels = [CONDITION_LABELS[slug] for slug in DISP_ORDER] rows = row_map(disp) fig, axes = plt.subplots( 2, 1, figsize=(7.2, 5.7), dpi=220, sharex=True, gridspec_kw={"height_ratios": [1.1, 1.0], "hspace": 0.30}, ) for metric_key, metric_label in [("val_mae", "MAE"), ("val_rmse", "RMSE")]: values = [float(rows[slug][metric_key]) for slug in DISP_ORDER] plot_line_points(axes[0], x, values, metric_label) axes[0].set_ylabel("Phase-velocity error (km/s)", fontsize=9) axes[0].set_ylim(0.043, 0.073) axes[0].legend(frameon=False, ncol=2, fontsize=8, loc="upper left", bbox_to_anchor=(0.065, 1.0)) axes[0].set_title( "Dispersion estimation: matched from-scratch training, 11,033 samples per condition\n" "Filters: SNR > 3.04 dB and SNR > 6.77 dB; evaluation: 8,292 unfiltered NCF test samples", fontsize=9.2, loc="left", pad=12, ) axes[0].text( 0.98, 0.84, "lower is better", transform=axes[0].transAxes, ha="right", fontsize=8, color="#555555", ) add_panel_label(axes[0], "A") annotate_ci_unavailable(axes[0]) for metric_key, metric_label in [("val_mae", "MAE"), ("val_rmse", "RMSE")]: base = float(rows["full"][metric_key]) values = [float(rows[slug][metric_key]) - base for slug in DISP_ORDER] plot_line_points(axes[1], x, values, metric_label) axes[1].axhline(0, color="#333333", linewidth=0.9) axes[1].set_ylabel("Error increase relative to\nfull distribution (km/s)", fontsize=9) axes[1].set_ylim(-0.0006, 0.0060) axes[1].legend(frameon=False, ncol=2, fontsize=8, loc="upper left", bbox_to_anchor=(0.065, 1.0)) add_panel_label(axes[1], "B") annotate_ci_unavailable(axes[1]) for ax in axes: style_axes(ax) ax.set_xlim(-0.15, len(x) - 0.85) axes[-1].set_xticks(x) axes[-1].set_xticklabels(labels, fontsize=8) fig.subplots_adjust(left=0.18, right=0.985, top=0.88, bottom=0.17, hspace=0.34) fig.savefig(FIG_DIR / "fig3_dispersion_optimized.pdf", bbox_inches="tight", facecolor="white") fig.savefig(FIG_DIR / "fig3_dispersion_optimized.png", bbox_inches="tight", facecolor="white") plt.close(fig) write_csv(FIG_DIR / "fig3_dispersion_optimized_data.csv", data) def check_manuscript_numbers(phase: dict, disp: dict) -> list[str]: tex_path = ROOT / "grl_overleaf" / "main.tex" text = tex_path.read_text(encoding="utf-8") if tex_path.exists() else "" expected = { "phase mean F1 full": f"{row_map(phase)['full']['mean_f1']:.3f}", "phase mean F1 moderate": f"{row_map(phase)['snr5']['mean_f1']:.3f}", "phase mean F1 strict": f"{row_map(phase)['snr10']['mean_f1']:.3f}", "phase P precision full": f"{row_map(phase)['full']['P_precision']:.3f}", "phase P precision strict": f"{row_map(phase)['snr10']['P_precision']:.3f}", "phase S precision full": f"{row_map(phase)['full']['S_precision']:.3f}", "phase S precision strict": f"{row_map(phase)['snr10']['S_precision']:.3f}", "dispersion MAE full": f"{row_map(disp)['full']['val_mae']:.4f}", "dispersion MAE moderate": f"{row_map(disp)['snr_q1']['val_mae']:.4f}", "dispersion MAE strict": f"{row_map(disp)['snr_q2']['val_mae']:.4f}", "dispersion RMSE strict": f"{row_map(disp)['snr_q2']['val_rmse']:.4f}", } warnings = [] for label, value in expected.items(): if value not in text: warnings.append(f"- WARNING: manuscript does not contain rounded {label} value `{value}`.") return warnings def write_report(phase: dict, disp: dict, warnings: list[str]) -> None: phase_candidates = per_sample_candidates(PHASE_DIR) disp_candidates = per_sample_candidates(DISP_DIR) lines = [ "# Figure Metric Discrepancy Report", "", f"Generated: {datetime.now(timezone.utc).isoformat()}", "", "## Source Files", "", f"- Phase summary: `{PHASE_DIR / 'summary.json'}`", f"- Dispersion summary: `{DISP_DIR / 'summary.json'}`", "", "## Per-Sample Output Scan", "", f"- Phase per-window candidates in target output directory: {phase_candidates or 'none found'}", f"- Dispersion per-sample candidates in target output directory: {disp_candidates or 'none found'}", "", "## CI Method Status", "", "- Requested method: paired bootstrap over shared test windows/samples.", "- Implemented status: unavailable for this figure build because the target experiment outputs are aggregate-only.", "- The optimized figures plot verified point estimates and paired deltas; error bars are omitted and marked as unavailable.", "", "## Manuscript Number Check", "", ] if warnings: lines.extend(warnings) else: lines.append("- No rounded point-estimate discrepancies detected between summary JSON values and manuscript text.") lines.extend( [ "", "## Plotted Point Estimates", "", f"- Phase mean F1: full={row_map(phase)['full']['mean_f1']:.6f}, " f"moderate={row_map(phase)['snr5']['mean_f1']:.6f}, " f"strict={row_map(phase)['snr10']['mean_f1']:.6f}.", f"- Dispersion MAE: full={row_map(disp)['full']['val_mae']:.6f}, " f"moderate={row_map(disp)['snr_q1']['val_mae']:.6f}, " f"strict={row_map(disp)['snr_q2']['val_mae']:.6f}.", "", ] ) REPORT.parent.mkdir(parents=True, exist_ok=True) REPORT.write_text("\n".join(lines), encoding="utf-8") def main() -> None: plt.rcParams.update( { "font.family": "DejaVu Sans", "font.size": 8.5, "axes.titlesize": 10, "axes.labelsize": 9, "legend.fontsize": 8, "pdf.fonttype": 42, "ps.fonttype": 42, "savefig.transparent": False, } ) FIG_DIR.mkdir(parents=True, exist_ok=True) phase = read_json(PHASE_DIR / "summary.json") disp = read_json(DISP_DIR / "summary.json") phase_data = make_phase_data(phase) disp_data = make_disp_data(disp) figure2(phase, phase_data) figure3(disp, disp_data) warnings = check_manuscript_numbers(phase, disp) write_report(phase, disp, warnings) print(f"Wrote optimized figures to {FIG_DIR}") print(f"Wrote discrepancy report to {REPORT}") if __name__ == "__main__": main()