snr_bias / code /scripts /distance_pick_transfer_experiment.py
cangyeone's picture
Upload GRL reproducibility package
7170296 verified
Raw
History Blame Contribute Delete
12.5 kB
#!/usr/bin/env python3
"""Compare near-distance and all-distance transfer training at matched pick counts."""
from __future__ import annotations
import argparse
import csv
import json
import math
import os
import sys
from pathlib import Path
os.environ.setdefault("KMP_DUPLICATE_LIB_OK", "TRUE")
os.environ.setdefault("OMP_NUM_THREADS", "1")
os.environ.setdefault("MKL_NUM_THREADS", "1")
import torch
ROOT = Path(__file__).resolve().parents[1]
if str(ROOT) not in sys.path:
sys.path.insert(0, str(ROOT))
from scripts import magnitude_pick_transfer_experiment as exp
def finite_distance(sample: exp.ManualPickSample) -> bool:
return sample.distance_km is not None and sample.distance_km == sample.distance_km
def all_pick_samples(record_cache: Path, split: str) -> list[exp.ManualPickSample]:
records = exp.load_record_cache(record_cache)
samples: list[exp.ManualPickSample] = []
for record in records:
distance = record.get("distance_km")
for pick in record["phases"]:
phase = pick["phase"]
samples.append(
exp.ManualPickSample(
split=split,
event=record["event"],
station=record["station"],
phase=phase,
phase_group=exp.PHASE_TO_GROUP[phase],
index=int(pick["index"]),
length=int(record["length"]),
magnitude=float("nan"),
distance_km=None if distance is None or not math.isfinite(float(distance)) else float(distance),
)
)
return samples
def main() -> None:
parser = argparse.ArgumentParser()
parser.add_argument("--h5", type=Path, default=ROOT / "data" / "credit-x1.h5")
parser.add_argument("--base-ckpt", type=Path, default=ROOT / "ckpt" / "pnsn.v3.pt")
parser.add_argument("--train-record-cache", type=Path, default=ROOT / "outputs" / "snr_transfer_seed20260609" / "records_train_all.json")
parser.add_argument("--test-record-cache", type=Path, default=ROOT / "outputs" / "snr_transfer_seed20260609" / "records_test_all.json")
parser.add_argument("--out-dir", type=Path, default=ROOT / "outputs" / "distance150_pick_transfer_seed20260628")
parser.add_argument("--seed", type=int, default=20260628)
parser.add_argument("--distance-threshold-km", type=float, default=150.0)
parser.add_argument("--label-source", choices=["manual", "all"], default="all")
parser.add_argument("--length", type=int, default=5120)
parser.add_argument("--padlen", type=int, default=512)
parser.add_argument("--train-steps", type=int, default=2000)
parser.add_argument("--train-batch", type=int, default=16)
parser.add_argument("--eval-batch", type=int, default=64)
parser.add_argument("--init-mode", choices=["transfer", "scratch"], default="transfer")
parser.add_argument("--max-eval-picks", type=int, default=0)
parser.add_argument("--lr", type=float, default=1e-4)
parser.add_argument("--thresholds", type=float, nargs="+", default=[0.1, 0.2, 0.3, 0.5])
parser.add_argument("--tolerance-samples", type=int, default=100)
parser.add_argument("--min-sep", type=int, default=50)
parser.add_argument("--resume", action="store_true")
parser.add_argument("--device", default=None)
args = parser.parse_args()
if args.device is None:
device = torch.device("mps") if torch.backends.mps.is_available() else torch.device("cpu")
else:
device = torch.device(args.device)
print(f"device={device}", flush=True)
args.out_dir.mkdir(parents=True, exist_ok=True)
if args.label_source == "manual":
train_samples = exp.manual_pick_samples(args.h5, args.train_record_cache, "train", args.out_dir / "manual_train_picks.json")
test_samples = exp.manual_pick_samples(args.h5, args.test_record_cache, "test", args.out_dir / "manual_test_picks.json")
source_note = "MANUAL.TRAVTIME Pg/Sg/Pn/Sn picks"
else:
train_samples = all_pick_samples(args.train_record_cache, "train")
test_samples = all_pick_samples(args.test_record_cache, "test")
source_note = "all cached Pg/Sg/Pn/Sn picks (MANUAL preferred, RNN.tagY used where manual is absent)"
train_all_valid = [s for s in train_samples if s.length >= args.length and finite_distance(s)]
train_near = [s for s in train_all_valid if float(s.distance_km) <= args.distance_threshold_km]
test_near = [
s
for s in test_samples
if s.length >= args.length and finite_distance(s) and float(s.distance_km) <= args.distance_threshold_km
]
test_all_valid = [s for s in test_samples if s.length >= args.length and finite_distance(s)]
if args.max_eval_picks > 0:
test_near = exp.matched_pick_subset(test_near, min(args.max_eval_picks, len(test_near)), args.seed + 17)
test_all_valid = exp.matched_pick_subset(
test_all_valid, min(args.max_eval_picks, len(test_all_valid)), args.seed + 18
)
train_budget = len(train_near)
train_all_matched = exp.matched_pick_subset(train_all_valid, train_budget, args.seed)
distance_slug = f"distance_le_{str(args.distance_threshold_km).replace('.', 'p').rstrip('0').rstrip('p')}"
subsets = {
"all_distance_matched": train_all_matched,
distance_slug: sorted(train_near, key=exp.stable_pick_id),
}
ckpt_prefix = "pnsn.v3.transfer" if args.init_mode == "transfer" else "pnsn.v3.scratch"
print(
f"train budget picks={train_budget}; eval <= {args.distance_threshold_km:g} km picks={len(test_near)}; "
f"eval all-distance picks={len(test_all_valid)}; init={args.init_mode}",
flush=True,
)
models: dict[str, exp.BRNN] = {}
for slug, subset in subsets.items():
print(f"training {slug}: {exp.summarize_counts(subset)}", flush=True)
models[slug] = exp.train_one(
args.h5,
subset,
args.base_ckpt,
args.out_dir / f"{ckpt_prefix}.{slug}.pt",
args.out_dir / f"train_log_{slug}.csv",
args.seed,
args.train_steps,
args.train_batch,
args.length,
args.padlen,
args.lr,
device,
args.resume,
args.init_mode,
)
eval_subsets = {
distance_slug: sorted(test_near, key=exp.stable_pick_id),
"all_distance": sorted(test_all_valid, key=exp.stable_pick_id),
}
rows = []
metrics_by_eval = {}
for eval_scope, eval_samples in eval_subsets.items():
print(f"evaluating {eval_scope}: {exp.summarize_counts(eval_samples)}", flush=True)
waves, rel_indices, groups, phases = exp.materialize_eval(args.h5, eval_samples, args.length, args.padlen)
metrics_by_eval[eval_scope] = {}
for slug, model in models.items():
outputs = exp.run_model(model, waves, device, args.eval_batch)
metrics = exp.evaluate_pick_recall(
outputs, rel_indices, groups, phases, args.thresholds, args.tolerance_samples, args.min_sep
)
metrics_by_eval[eval_scope][slug] = metrics
for scope, labels in [("by_group", metrics["by_group"]), ("by_phase", metrics["by_phase"])]:
for label, vals in labels.items():
for row in vals:
rows.append({"eval_scope": eval_scope, "model": slug, "scope": scope, "label": label, **row})
for row in metrics["combined"]:
rows.append({"eval_scope": eval_scope, "model": slug, "scope": "combined", "label": "all", **row})
safe_distance = str(args.distance_threshold_km).replace(".", "p").rstrip("0").rstrip("p")
metrics_path = args.out_dir / f"distance{safe_distance}_pick_metrics.csv"
with metrics_path.open("w", newline="") as f:
fieldnames = [
"eval_scope",
"model",
"scope",
"label",
"threshold",
"n",
"tp",
"fp",
"fn",
"precision",
"recall",
"f1",
]
writer = csv.DictWriter(f, fieldnames=fieldnames)
writer.writeheader()
writer.writerows(rows)
delta = []
for eval_scope in eval_subsets:
for threshold in args.thresholds:
for scope, label in [
("combined", "all"),
("by_group", "P"),
("by_group", "S"),
("by_phase", "Pg"),
("by_phase", "Pn"),
("by_phase", "Sg"),
("by_phase", "Sn"),
]:
vals = {
r["model"]: r
for r in rows
if r["eval_scope"] == eval_scope
and r["scope"] == scope
and r["label"] == label
and abs(float(r["threshold"]) - threshold) < 1e-9
}
if set(vals) != {"all_distance_matched", distance_slug}:
continue
all_row = vals["all_distance_matched"]
near_row = vals[distance_slug]
delta.append(
{
"eval_scope": eval_scope,
"threshold": threshold,
"scope": scope,
"label": label,
"n_test_picks": int(all_row["n"]),
"all_distance_precision": float(all_row["precision"]),
f"{distance_slug}_precision": float(near_row["precision"]),
f"delta_precision_{distance_slug}_minus_all": float(near_row["precision"])
- float(all_row["precision"]),
"all_distance_recall": float(all_row["recall"]),
f"{distance_slug}_recall": float(near_row["recall"]),
f"delta_recall_{distance_slug}_minus_all": float(near_row["recall"])
- float(all_row["recall"]),
"all_distance_f1": float(all_row["f1"]),
f"{distance_slug}_f1": float(near_row["f1"]),
f"delta_f1_{distance_slug}_minus_all": float(near_row["f1"]) - float(all_row["f1"]),
}
)
delta_path = args.out_dir / f"distance{safe_distance}_pick_delta.csv"
with delta_path.open("w", newline="") as f:
writer = csv.DictWriter(f, fieldnames=list(delta[0]))
writer.writeheader()
writer.writerows(delta)
summary = {
"experiment": f"distance_pick_transfer_{distance_slug}",
"notes": [
f"Training/evaluation samples are individual {source_note}.",
f"The all-distance and <={args.distance_threshold_km:g} km training conditions are matched to the same pick count.",
f"Evaluation is reported on both <={args.distance_threshold_km:g} km and all-distance test-set picks, one centered window per pick.",
],
"config": {
"seed": args.seed,
"distance_threshold_km": args.distance_threshold_km,
"label_source": args.label_source,
"init_mode": args.init_mode,
"base_ckpt": str(args.base_ckpt.resolve()) if args.init_mode == "transfer" else None,
"train_steps": args.train_steps,
"train_batch": args.train_batch,
"length": args.length,
"padlen": args.padlen,
"thresholds": args.thresholds,
"tolerance_samples": args.tolerance_samples,
"min_sep": args.min_sep,
},
"counts": {
"train_all_distance_valid": exp.summarize_counts(train_all_valid),
f"train_{distance_slug}_valid": exp.summarize_counts(train_near),
"train_all_distance_matched": exp.summarize_counts(train_all_matched),
f"test_{distance_slug}_valid": exp.summarize_counts(test_near),
"test_all_distance_valid": exp.summarize_counts(test_all_valid),
},
"metrics": metrics_by_eval,
}
summary_path = args.out_dir / "summary.json"
summary_path.write_text(json.dumps(summary, indent=2), encoding="utf-8")
print(f"wrote {summary_path}")
print(f"wrote {metrics_path}")
print(f"wrote {delta_path}")
if __name__ == "__main__":
main()