snr_bias / code /scripts /reproduce_paper_figures_from_open_data.py
cangyeone's picture
Add open-data workflow and seed-fixed training manifests
0a31798 verified
Raw
History Blame Contribute Delete
22.4 kB
#!/usr/bin/env python3
"""Recompute manuscript statistics from public inputs and regenerate figures.
This is the primary GRL reproducibility entrypoint for the SNR-bias package. It
does not read the archived plotted-data files in ``results/manuscript_figures``.
Instead, it orchestrates the public-data workflow:
1. CREDIT-X1local HDF5 + split keys -> phase-picking SNR caches, matched
training/evaluation summaries, and the pretrained direct baseline.
2. SeisDispFusion-NCF HDF5 -> dispersion SNR cache and matched training
summaries.
3. SeismicX-Cont picker streams + labels/index -> continuous phase-recall
sensitivity.
4. Optional phase-balanced association outputs -> event-geometry Figure 3.
CSV/JSON files written under ``--work-dir`` are intermediate products generated
by this run, not source plotted data. For a lightweight visual check from the
archived plotted-data exports, use ``plot_all_paper_figures.py`` instead.
"""
from __future__ import annotations
import argparse
import csv
import importlib.util
import json
import os
import subprocess
import sys
from pathlib import Path
from statistics import mean, stdev
from typing import Any
ROOT = Path(__file__).resolve().parents[2]
CODE_ROOT = ROOT / "code"
SCRIPTS = CODE_ROOT / "scripts"
ODATA = CODE_ROOT / "odata"
DEFAULT_SEEDS = [20260609, 20260610, 20260611]
PHASE_METRICS = [
"P_precision",
"P_recall",
"P_f1",
"S_precision",
"S_recall",
"S_f1",
"mean_f1",
]
DISP_METRICS = ["val_mae", "val_rmse", "val_certainty_f1"]
def load_module(name: str, path: Path):
spec = importlib.util.spec_from_file_location(name, path)
if spec is None or spec.loader is None:
raise RuntimeError(f"Cannot load module from {path}")
module = importlib.util.module_from_spec(spec)
spec.loader.exec_module(module)
return module
def run(cmd: list[str], *, cwd: Path, env: dict[str, str], dry_run: bool) -> None:
printable = " ".join(str(part) for part in cmd)
print(f"\n$ {printable}", flush=True)
if dry_run:
return
subprocess.run(cmd, cwd=cwd, env=env, check=True)
def require_path(path: Path | None, label: str) -> Path:
if path is None:
raise SystemExit(f"Missing required input: {label}")
if not path.exists():
raise SystemExit(f"Input path does not exist for {label}: {path}")
return path
def summary_stats(values: list[float]) -> tuple[float, float, int]:
if not values:
return float("nan"), float("nan"), 0
return float(mean(values)), float(stdev(values)) if len(values) > 1 else 0.0, len(values)
def aggregate_seed_summaries(
*,
task: str,
metrics: list[str],
source_dirs: list[Path],
out_dir: Path,
) -> Path:
by_condition: dict[str, dict[str, list[float]]] = {}
labels: dict[str, str] = {}
long_rows: list[dict[str, Any]] = []
for source_dir in source_dirs:
summary_path = source_dir / "summary.json"
summary = json.loads(summary_path.read_text(encoding="utf-8"))
seed = summary.get("seed", source_dir.name)
for row in summary["rows"]:
slug = str(row["slug"])
labels.setdefault(slug, str(row.get("label", slug)))
by_condition.setdefault(slug, {metric: [] for metric in metrics})
for metric in metrics:
value = float(row[metric])
by_condition[slug][metric].append(value)
long_rows.append(
{
"task": task,
"seed": seed,
"condition_slug": slug,
"condition_label": labels[slug],
"metric": metric,
"value": value,
}
)
out_dir.mkdir(parents=True, exist_ok=True)
long_path = out_dir / f"{task}_multiseed_long.csv"
with long_path.open("w", newline="", encoding="utf-8") as handle:
writer = csv.DictWriter(
handle,
fieldnames=["task", "seed", "condition_slug", "condition_label", "metric", "value"],
)
writer.writeheader()
writer.writerows(long_rows)
summary_path = out_dir / f"{task}_multiseed_summary.csv"
summary_rows: list[dict[str, Any]] = []
for slug, metric_values in by_condition.items():
for metric, values in metric_values.items():
m, s, n = summary_stats(values)
summary_rows.append(
{
"task": task,
"condition_slug": slug,
"condition_label": labels.get(slug, slug),
"metric": metric,
"mean": m,
"std": s,
"n": n,
"values": json.dumps(values),
}
)
with summary_path.open("w", newline="", encoding="utf-8") as handle:
writer = csv.DictWriter(
handle,
fieldnames=["task", "condition_slug", "condition_label", "metric", "mean", "std", "n", "values"],
)
writer.writeheader()
writer.writerows(summary_rows)
return summary_path
def write_manifest(args: argparse.Namespace, work_dir: Path, figure_dir: Path) -> None:
manifest = {
"workflow": "open-data manuscript figure reproduction",
"script": str(Path(__file__).resolve()),
"work_dir": str(work_dir.resolve()),
"figure_dir": str(figure_dir.resolve()),
"seeds": args.seeds,
"data_sources": {
"phase": {
"name": "CREDIT-X1local",
"citation": "Li et al. (2024), doi:10.1016/j.eqs.2024.01.018",
"h5": str(args.credit_h5) if args.credit_h5 else None,
"keys": str(args.credit_keys) if args.credit_keys else None,
},
"continuous": {
"name": "SeismicX-Cont",
"revision": "96367f8",
"doi": "10.57967/hf/9006",
"pick_dir": str(args.continuous_pick_dir) if args.continuous_pick_dir else None,
"label_json": str(args.continuous_label_json) if args.continuous_label_json else None,
"waveform_db": str(args.continuous_waveform_db) if args.continuous_waveform_db else None,
"phase_balanced_root": str(args.phase_balanced_root) if args.phase_balanced_root else None,
},
"dispersion": {
"name": "SeisDispFusion-NCF",
"revision": "afcd805",
"doi": "10.57967/hf/9114",
"h5": str(args.ncf_h5) if args.ncf_h5 else None,
},
},
"notes": [
"Figures are rendered from intermediate outputs generated in this work directory.",
"The archived results/manuscript_figures plotted-data files are not used by this workflow.",
],
}
(work_dir / "open_data_reproduction_manifest.json").write_text(
json.dumps(manifest, indent=2, ensure_ascii=False),
encoding="utf-8",
)
def run_phase_workflow(args: argparse.Namespace, outputs: Path, env: dict[str, str], dry_run: bool) -> list[Path]:
credit_h5 = require_path(args.credit_h5, "CREDIT-X1local HDF5")
credit_keys = require_path(args.credit_keys, "CREDIT-X1local split keys")
base_ckpt = require_path(args.base_ckpt, "base PNSN checkpoint")
cache_dir = outputs / "snr_transfer_phase_balanced_cache"
source_dirs = []
for seed in args.seeds:
out_dir = outputs / f"snr_transfer_phase_any_seed{seed}"
source_dirs.append(out_dir)
cmd = [
sys.executable,
str(SCRIPTS / "snr_transfer_phase_balanced_experiment.py"),
"--h5",
str(credit_h5),
"--keys",
str(credit_keys),
"--base-ckpt",
str(base_ckpt),
"--out-dir",
str(out_dir),
"--cache-dir",
str(cache_dir),
"--seed",
str(seed),
"--train-steps",
str(args.phase_train_steps),
"--scratch-train-steps",
str(args.phase_scratch_train_steps),
"--train-batch",
str(args.phase_train_batch),
"--eval-samples",
str(args.phase_eval_samples),
"--eval-batch",
str(args.phase_eval_batch),
"--init-modes",
"finetune",
"scratch",
"--filter-mode",
"record-any",
"--s-threshold-mode",
"same-as-p",
"--match-mode",
"phase-composition",
"--checkpoint-every-steps",
str(args.phase_checkpoint_every),
]
if args.resume:
cmd.append("--resume")
cmd.append("--merge-existing-summary")
run(cmd, cwd=ROOT, env=env, dry_run=dry_run)
direct_dir = outputs / "phase_direct_baseline"
cmd = [
sys.executable,
str(SCRIPTS / "evaluate_phase_direct_baseline.py"),
"--h5",
str(credit_h5),
"--keys",
str(credit_keys),
"--base-ckpt",
str(base_ckpt),
"--out-dir",
str(direct_dir),
"--cache-dir",
str(cache_dir),
"--seeds",
*[str(seed) for seed in args.seeds],
"--eval-samples",
str(args.phase_eval_samples),
"--eval-batch",
str(args.phase_eval_batch),
"--device",
args.phase_direct_device,
]
run(cmd, cwd=ROOT, env=env, dry_run=dry_run)
if not dry_run:
aggregate_seed_summaries(
task="phase",
metrics=PHASE_METRICS,
source_dirs=source_dirs,
out_dir=outputs / "grl_phase_any_multiseed_seed20260609_20260611",
)
return source_dirs
def run_dispersion_workflow(args: argparse.Namespace, outputs: Path, env: dict[str, str], dry_run: bool) -> list[Path]:
ncf_h5 = require_path(args.ncf_h5, "SeisDispFusion-NCF HDF5")
source_dirs = []
for seed in args.seeds:
out_dir = outputs / f"disp_snr_transfer_seed{seed}"
source_dirs.append(out_dir)
cmd = [
sys.executable,
str(SCRIPTS / "disp_snr_transfer_experiment.py"),
"--h5",
str(ncf_h5),
"--out-dir",
str(out_dir),
"--seed",
str(seed),
"--mode",
"scratch",
"--epochs",
str(args.disp_epochs),
"--batch-size",
str(args.disp_batch_size),
"--num-workers",
str(args.disp_num_workers),
"--device",
args.disp_device,
]
run(cmd, cwd=ROOT, env=env, dry_run=dry_run)
if not dry_run:
aggregate_seed_summaries(
task="dispersion",
metrics=DISP_METRICS,
source_dirs=source_dirs,
out_dir=outputs / "grl_multiseed_seed20260609_20260611",
)
return source_dirs
def run_continuous_recall(args: argparse.Namespace, outputs: Path, env: dict[str, str], dry_run: bool) -> Path:
pick_dir = require_path(args.continuous_pick_dir, "SeismicX-Cont continuous picker JSONL directory")
label_json = require_path(args.continuous_label_json, "SeismicX-Cont label JSON")
waveform_db = require_path(args.continuous_waveform_db, "SeismicX-Cont waveform index SQLite")
out_dir = outputs / "continuous_phase_recall_snr_conf_sweep"
cmd = [
sys.executable,
str(SCRIPTS / "continuous_phase_recall_snr_conf_sweep.py"),
"--root",
str(args.continuous_root or pick_dir.parent),
"--pick-dir",
str(pick_dir),
"--label-json",
str(label_json),
"--waveform-db",
str(waveform_db),
"--eval-script",
str(ODATA / "evaluate_pick_recall_no_nms.py"),
"--outdir",
str(out_dir),
"--days",
*args.continuous_days,
"--thresholds",
args.continuous_thresholds,
]
if args.include_validation_thresholds:
cmd.append("--include-validation-thresholds")
if args.force_rebuild_continuous_index:
cmd.append("--force-rebuild")
run(cmd, cwd=ROOT, env=env, dry_run=dry_run)
return out_dir / "continuous_phase_recall_snr_conf_sweep.csv"
def run_precision_table(args: argparse.Namespace, outputs: Path, env: dict[str, str], dry_run: bool) -> None:
credit_h5 = require_path(args.credit_h5, "CREDIT-X1local HDF5")
credit_keys = require_path(args.credit_keys, "CREDIT-X1local split keys")
source_dirs = [outputs / f"snr_transfer_phase_any_seed{seed}" for seed in args.seeds]
cmd = [
sys.executable,
str(SCRIPTS / "snr_filtered_test_precision_table.py"),
"--h5",
str(credit_h5),
"--keys",
str(credit_keys),
"--out-dir",
str(outputs / "snr_filtered_test_precision_table"),
"--source-dirs",
*[str(path) for path in source_dirs],
"--cache-dir",
str(outputs / "snr_transfer_phase_balanced_cache"),
"--eval-samples",
str(args.phase_eval_samples),
]
run(cmd, cwd=ROOT, env=env, dry_run=dry_run)
def export_training_manifests(args: argparse.Namespace, outputs: Path, env: dict[str, str], dry_run: bool) -> None:
cmd = [
sys.executable,
str(SCRIPTS / "export_training_manifests.py"),
"--out-dir",
str(args.work_dir / "training_manifests"),
"--seeds",
*[str(seed) for seed in args.seeds],
]
has_sources = False
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"
if phase_records.exists() and phase_snr.exists():
cmd.extend(["--phase-records-json", str(phase_records), "--phase-snr-json", str(phase_snr)])
has_sources = True
elif not args.skip_phase_training:
raise FileNotFoundError(f"Cannot export phase manifests; missing {phase_records} or {phase_snr}")
dispersion_snr = outputs / f"disp_snr_transfer_seed{args.seeds[0]}" / "ncf_snr_cache.json"
if dispersion_snr.exists():
cmd.extend(["--dispersion-snr-json", str(dispersion_snr)])
has_sources = True
elif not args.skip_dispersion_training:
raise FileNotFoundError(f"Cannot export dispersion manifests; missing {dispersion_snr}")
if not has_sources:
print("Skipping training-manifest export because no source caches are available.", flush=True)
return
run(cmd, cwd=ROOT, env=env, dry_run=dry_run)
def render_figures(args: argparse.Namespace, outputs: Path, figure_dir: Path) -> None:
figure_dir.mkdir(parents=True, exist_ok=True)
ncf_h5 = require_path(args.ncf_h5, "SeisDispFusion-NCF HDF5 for Figure 1 period bins")
observability = load_module("grl_observability_open_data", SCRIPTS / "grl_make_observability_real_data_figure.py")
observability.PHASE_RECORDS = outputs / "snr_transfer_phase_balanced_cache" / "records_train_all.json"
observability.PHASE_SNR = outputs / "snr_transfer_phase_balanced_cache" / "train_phase_snr_db.json"
observability.DISP_CACHE = outputs / f"disp_snr_transfer_seed{args.seeds[0]}" / "ncf_snr_cache.json"
observability.DISP_H5 = ncf_h5
observability.FIG_DIR = figure_dir
observability.OUT_PDF = figure_dir / "fig_observability_real_data_v1.pdf"
observability.OUT_PNG = figure_dir / "fig_observability_real_data_v1.png"
observability.OUT_CSV = figure_dir / "fig_observability_real_data_v1_data.csv"
observability.OUT_JSON = figure_dir / "fig_observability_real_data_v1_summary.json"
observability.make_figure()
learning = load_module("grl_learning_open_data", SCRIPTS / "grl_make_learning_summary_figure.py")
learning.PHASE_RECORDS = outputs / "snr_transfer_phase_balanced_cache" / "records_train_all.json"
learning.PHASE_SNR = outputs / "snr_transfer_phase_balanced_cache" / "train_phase_snr_db.json"
learning.DISP_SNR = outputs / f"disp_snr_transfer_seed{args.seeds[0]}" / "ncf_snr_cache.json"
learning.PHASE_SUMMARY = outputs / "grl_phase_any_multiseed_seed20260609_20260611" / "phase_multiseed_summary.csv"
learning.PHASE_DIRECT_BASELINE = outputs / "phase_direct_baseline" / "phase_direct_baseline_summary.csv"
learning.DISP_SUMMARY = outputs / "grl_multiseed_seed20260609_20260611" / "dispersion_multiseed_summary.csv"
learning.CONTINUOUS_SWEEP = outputs / "continuous_phase_recall_snr_conf_sweep" / "continuous_phase_recall_snr_conf_sweep.csv"
learning.FIG_DIR = figure_dir
learning.OUT_PDF = figure_dir / "fig_learning_selection_generalization_summary_v2.pdf"
learning.OUT_PNG = figure_dir / "fig_learning_selection_generalization_summary_v2.png"
learning.OUT_DATA = figure_dir / "fig_learning_selection_generalization_summary_v2_data.csv"
learning.main()
if args.skip_phase_balanced_geometry:
print("Skipping phase-balanced event geometry figure by request.", flush=True)
return
phase_balanced_root = require_path(args.phase_balanced_root, "phase-balanced association output root")
label_json = require_path(args.continuous_label_json, "SeismicX-Cont label JSON for Figure 3")
waveform_db = require_path(args.continuous_waveform_db, "SeismicX-Cont waveform index SQLite for Figure 3")
geometry = load_module("grl_geometry_open_data", SCRIPTS / "grl_make_phase_balanced_event_geometry_figure.py")
geometry.PHASE_BALANCED_ROOT = phase_balanced_root
geometry.PICK_EVAL_SCRIPT = ODATA / "evaluate_pick_recall_no_nms.py"
geometry.LABEL_JSON = label_json
geometry.WAVEFORM_DB = waveform_db
geometry.FIG_DIR = figure_dir
geometry.FIG_PDF = figure_dir / "fig_event_geometry_distribution_polished.pdf"
geometry.FIG_PNG = figure_dir / "fig_event_geometry_distribution_polished.png"
geometry.DATA_CSV = figure_dir / "fig_event_geometry_distribution_polished_data.csv"
geometry.SUMMARY_JSON = figure_dir / "fig_event_geometry_distribution_polished_summary.json"
geometry.main()
def parse_args() -> argparse.Namespace:
parser = argparse.ArgumentParser(description=__doc__)
parser.add_argument("--credit-h5", type=Path)
parser.add_argument("--credit-keys", type=Path)
parser.add_argument("--ncf-h5", type=Path)
parser.add_argument("--continuous-root", type=Path, default=None)
parser.add_argument("--continuous-pick-dir", type=Path, default=None)
parser.add_argument("--continuous-label-json", type=Path, default=None)
parser.add_argument("--continuous-waveform-db", type=Path, default=None)
parser.add_argument("--phase-balanced-root", type=Path, default=None)
parser.add_argument("--base-ckpt", type=Path, default=ROOT / "checkpoints" / "base" / "pnsn.v3.pt")
parser.add_argument("--work-dir", type=Path, default=ROOT / "open_data_work")
parser.add_argument("--figure-dir", type=Path, default=None)
parser.add_argument("--seeds", nargs="+", type=int, default=DEFAULT_SEEDS)
parser.add_argument("--resume", action="store_true")
parser.add_argument("--dry-run", action="store_true")
parser.add_argument("--skip-phase-training", action="store_true")
parser.add_argument("--skip-dispersion-training", action="store_true")
parser.add_argument("--skip-continuous-sweep", action="store_true")
parser.add_argument("--skip-precision-table", action="store_true")
parser.add_argument("--skip-training-manifest-export", action="store_true")
parser.add_argument("--skip-figures", action="store_true")
parser.add_argument("--skip-phase-balanced-geometry", action="store_true")
parser.add_argument("--phase-train-steps", type=int, default=2000)
parser.add_argument("--phase-scratch-train-steps", type=int, default=10000)
parser.add_argument("--phase-train-batch", type=int, default=16)
parser.add_argument("--phase-eval-samples", type=int, default=10000)
parser.add_argument("--phase-eval-batch", type=int, default=64)
parser.add_argument("--phase-checkpoint-every", type=int, default=100)
parser.add_argument("--phase-direct-device", default="auto")
parser.add_argument("--disp-epochs", type=int, default=5)
parser.add_argument("--disp-batch-size", type=int, default=256)
parser.add_argument("--disp-num-workers", type=int, default=0)
parser.add_argument("--disp-device", default="cpu")
parser.add_argument("--continuous-days", nargs="+", default=["20190706", "20211113"])
parser.add_argument("--continuous-thresholds", default="0:10:1")
parser.add_argument("--include-validation-thresholds", action="store_true")
parser.add_argument("--force-rebuild-continuous-index", action="store_true")
return parser.parse_args()
def main() -> None:
args = parse_args()
work_dir = args.work_dir
outputs = work_dir / "outputs"
figure_dir = args.figure_dir or (work_dir / "figures")
work_dir.mkdir(parents=True, exist_ok=True)
outputs.mkdir(parents=True, exist_ok=True)
write_manifest(args, work_dir, figure_dir)
env = os.environ.copy()
env["PYTHONPATH"] = str(CODE_ROOT) + os.pathsep + env.get("PYTHONPATH", "")
env.setdefault("OMP_NUM_THREADS", "1")
env.setdefault("MKL_NUM_THREADS", "1")
env.setdefault("KMP_DUPLICATE_LIB_OK", "TRUE")
if not args.skip_phase_training:
run_phase_workflow(args, outputs, env, args.dry_run)
if not args.skip_dispersion_training:
run_dispersion_workflow(args, outputs, env, args.dry_run)
if not args.skip_continuous_sweep:
run_continuous_recall(args, outputs, env, args.dry_run)
if not args.skip_training_manifest_export:
export_training_manifests(args, outputs, env, args.dry_run)
if not args.skip_precision_table:
run_precision_table(args, outputs, env, args.dry_run)
if not args.skip_figures and not args.dry_run:
render_figures(args, outputs, figure_dir)
elif args.dry_run:
print("Dry run complete; no commands were executed and no figures were rendered.", flush=True)
if not args.dry_run:
print(f"\nOpen-data reproduction outputs: {work_dir.resolve()}", flush=True)
print(f"Regenerated figures: {figure_dir.resolve()}", flush=True)
if __name__ == "__main__":
main()