| |
| """Fine-tune Pn/Sn picker with SNR-filtered training subsets. |
| |
| The experiment compares transfer learning from the same pretrained PNSN model |
| under three training distributions: all CREDIT-X1 training records, records |
| with estimated phase SNR > 5 dB, and records with estimated phase SNR > 10 dB. |
| All models are evaluated on the same unfiltered test distribution. |
| """ |
|
|
| from __future__ import annotations |
|
|
| import argparse |
| import csv |
| import json |
| import math |
| import os |
| import random |
| import sys |
| from pathlib import Path |
| from typing import Dict, Iterable, List, Sequence |
|
|
| os.environ.setdefault("KMP_DUPLICATE_LIB_OK", "TRUE") |
| os.environ.setdefault("OMP_NUM_THREADS", "1") |
| os.environ.setdefault("MKL_NUM_THREADS", "1") |
|
|
| import h5py |
| import matplotlib.pyplot as plt |
| import numpy as np |
| import torch |
|
|
| ROOT = Path(__file__).resolve().parents[1] |
| if str(ROOT) not in sys.path: |
| sys.path.insert(0, str(ROOT)) |
|
|
| from models.BRNNPNSN import BRNN, Loss |
| from scripts.reproduce_paper_stats import ( |
| GROUP_TO_CHANNELS, |
| PHASE_TO_GROUP, |
| PhasePick, |
| Record, |
| evaluate_outputs, |
| make_specs, |
| materialize_samples, |
| parse_time, |
| choose_phase, |
| run_model, |
| strip_errors, |
| ) |
|
|
|
|
| COMPONENT_ORDER = ("BHE", "BHN", "BHZ") |
|
|
|
|
| def component_keys(station) -> tuple[str, str, str] | None: |
| keys = set(station.keys()) |
| if all(k in keys for k in COMPONENT_ORDER): |
| return COMPONENT_ORDER |
| by_suffix: Dict[str, str] = {} |
| for key in keys: |
| if key.endswith("HE"): |
| by_suffix["BHE"] = key |
| elif key.endswith("HN"): |
| by_suffix["BHN"] = key |
| elif key.endswith("HZ"): |
| by_suffix["BHZ"] = key |
| if all(k in by_suffix for k in COMPONENT_ORDER): |
| return tuple(by_suffix[k] for k in COMPONENT_ORDER) |
| return None |
|
|
|
|
| def record_to_dict(record: Record) -> Dict: |
| return { |
| "event": record.event, |
| "station": record.station, |
| "length": record.length, |
| "delta": record.delta, |
| "distance_km": record.distance_km, |
| "phases": [ |
| {"phase": pick.phase, "index": pick.index, "source": pick.source} |
| for pick in record.phases |
| ], |
| } |
|
|
|
|
| def record_from_dict(row: Dict) -> Record: |
| return Record( |
| event=row["event"], |
| station=row["station"], |
| length=int(row["length"]), |
| delta=float(row["delta"]), |
| distance_km=float(row["distance_km"]), |
| phases=tuple( |
| PhasePick(phase=p["phase"], index=int(p["index"]), source=p["source"]) |
| for p in row["phases"] |
| ), |
| ) |
|
|
|
|
| def build_records_sequential( |
| h5_path: Path, |
| key_path: Path, |
| split: str, |
| max_events: int | None, |
| cache_path: Path, |
| ) -> List[Record]: |
| if cache_path.exists(): |
| with cache_path.open() as f: |
| cached = json.load(f) |
| records = [record_from_dict(row) for row in cached["records"]] |
| return records |
|
|
| keys = [str(x) for x in np.load(key_path)[split]] |
| if max_events is not None: |
| keys = keys[:max_events] |
| key_set = set(keys) |
| records: List[Record] = [] |
| seen_events = 0 |
| with h5py.File(h5_path, "r") as h5: |
| for i, event_key in enumerate(h5.keys()): |
| if event_key not in key_set: |
| continue |
| seen_events += 1 |
| event = h5[event_key] |
| for station_key in event.keys(): |
| station = event[station_key] |
| comps = component_keys(station) |
| if comps is None: |
| continue |
| first = station[comps[0]] |
| delta = float(first.attrs.get("delta_sec", 0.01)) |
| if abs(delta - 0.01) > 1e-6: |
| continue |
| start_time = first.attrs.get("start_time") |
| if not isinstance(start_time, str): |
| continue |
| btime = parse_time(start_time) |
| length = min(int(station[c].shape[0]) for c in comps) |
| picks: List[PhasePick] = [] |
| for phase in ("Pg", "Sg", "Pn", "Sn"): |
| chosen = choose_phase(station, phase, prefer_manual=True) |
| if chosen is None: |
| continue |
| ptime, source = chosen |
| idx = int(round((parse_time(ptime) - btime).total_seconds() / delta)) |
| if 0 <= idx < length: |
| picks.append(PhasePick(phase, idx, source)) |
| if not picks: |
| continue |
| distances = [] |
| for phase in ("Pg", "Sg", "Pn", "Sn"): |
| for prefix in ("MANUAL.TRAVTIME", "RNN.TRAVTIME"): |
| dk = f"{prefix}.{phase}.dist_km" |
| if dk in station.attrs: |
| try: |
| distances.append(float(station.attrs[dk])) |
| except (TypeError, ValueError): |
| pass |
| dist = float(np.median(distances)) if distances else float("nan") |
| records.append( |
| Record( |
| event=event_key, |
| station=str(station_key), |
| length=length, |
| delta=delta, |
| distance_km=dist, |
| phases=tuple(picks), |
| ) |
| ) |
| if seen_events % 5000 == 0: |
| print( |
| f"{split}: scanned {seen_events}/{len(keys)} selected events; " |
| f"records={len(records)}", |
| flush=True, |
| ) |
|
|
| cache_path.parent.mkdir(parents=True, exist_ok=True) |
| tmp = cache_path.with_suffix(".tmp") |
| with tmp.open("w") as f: |
| json.dump( |
| { |
| "split": split, |
| "max_events": max_events, |
| "events": len(keys), |
| "records": [record_to_dict(r) for r in records], |
| }, |
| f, |
| ) |
| tmp.replace(cache_path) |
| return records |
|
|
|
|
| def window_std(wave: np.ndarray, start: int, end: int) -> float | None: |
| if start < 0 or end > len(wave) or end <= start: |
| return None |
| return float(np.std(wave[start:end])) |
|
|
|
|
| def pick_snr_db(waves: Sequence[np.ndarray], phase: str, index: int) -> float | None: |
| """Estimate phase SNR in dB using the local convention in utils/datapnsn.py.""" |
| east, north, vertical = waves |
| if PHASE_TO_GROUP[phase] == "P": |
| pre = window_std(vertical, index - 50, index) |
| aft = window_std(vertical, index, index + 50) |
| if pre is None or aft is None: |
| return None |
| return 10.0 * math.log10((aft + 1e-6) / (pre + 1e-6)) |
|
|
| pre_e = window_std(east, index - 150, index) |
| aft_e = window_std(east, index, index + 150) |
| pre_n = window_std(north, index - 150, index) |
| aft_n = window_std(north, index, index + 150) |
| if pre_e is None or aft_e is None or pre_n is None or aft_n is None: |
| return None |
| snr_e = 10.0 * math.log10((aft_e + 1e-6) / (pre_e + 1e-6)) |
| snr_n = 10.0 * math.log10((aft_n + 1e-6) / (pre_n + 1e-6)) |
| return 0.5 * (snr_e + snr_n) |
|
|
|
|
| def compute_record_snr(h5_path: Path, records: Sequence[Record], cache_path: Path) -> Dict[str, float]: |
| if cache_path.exists(): |
| with cache_path.open() as f: |
| return {k: float(v) for k, v in json.load(f).items()} |
|
|
| cache_path.parent.mkdir(parents=True, exist_ok=True) |
| snr_by_record: Dict[str, float] = {} |
| with h5py.File(h5_path, "r") as h5: |
| for i, record in enumerate(records): |
| station = h5[record.event][record.station] |
| comps = component_keys(station) |
| if comps is None: |
| continue |
| waves = [station[c][:] for c in comps] |
| values = [] |
| for pick in record.phases: |
| snr = pick_snr_db(waves, pick.phase, pick.index) |
| if snr is not None and math.isfinite(snr): |
| values.append(snr) |
| snr_by_record[f"{record.event}/{record.station}"] = max(values) if values else -10000.0 |
| if i % 5000 == 0: |
| print(f"computed SNR for {i}/{len(records)} records", flush=True) |
|
|
| tmp = cache_path.with_suffix(".tmp") |
| with tmp.open("w") as f: |
| json.dump(snr_by_record, f) |
| tmp.replace(cache_path) |
| return snr_by_record |
|
|
|
|
| def filter_records_by_snr( |
| records: Sequence[Record], |
| snr_by_record: Dict[str, float], |
| threshold: float | None, |
| ) -> List[Record]: |
| if threshold is None: |
| return list(records) |
| return [ |
| record |
| for record in records |
| if snr_by_record.get(f"{record.event}/{record.station}", -10000.0) > threshold |
| ] |
|
|
|
|
| def stable_record_id(record: Record) -> str: |
| return f"{record.event}/{record.station}" |
|
|
|
|
| def matched_records(records: Sequence[Record], n_records: int, seed: int) -> List[Record]: |
| if len(records) < n_records: |
| raise RuntimeError(f"Cannot draw {n_records} records from a pool of {len(records)} records.") |
| ordered = sorted(records, key=stable_record_id) |
| rng = np.random.default_rng(seed) |
| idx = np.sort(rng.choice(len(ordered), size=n_records, replace=False)) |
| return [ordered[int(i)] for i in idx] |
|
|
|
|
| def sample_batch( |
| h5_path: Path, |
| records: Sequence[Record], |
| seed: int, |
| batch_size: int, |
| length: int, |
| padlen: int, |
| step: int, |
| ) -> tuple[np.ndarray, np.ndarray]: |
| specs = make_specs( |
| records, |
| n_samples=batch_size, |
| seed=seed + step * 104729, |
| length=length, |
| padlen=padlen, |
| double_prob=0.5, |
| ) |
| waves, _, targets, _ = materialize_samples(h5_path, records, specs, length) |
| return waves, targets |
|
|
|
|
| def train_one( |
| h5_path: Path, |
| records: Sequence[Record], |
| base_ckpt: Path, |
| out_ckpt: Path, |
| log_csv: Path, |
| seed: int, |
| steps: int, |
| batch_size: int, |
| length: int, |
| padlen: int, |
| lr: float, |
| device: torch.device, |
| resume: bool, |
| ) -> BRNN: |
| torch.manual_seed(seed) |
| np.random.seed(seed) |
| random.seed(seed) |
| model = BRNN().to(device) |
| if resume and out_ckpt.exists(): |
| model.load_state_dict(torch.load(out_ckpt, map_location="cpu")) |
| model.eval() |
| return model |
|
|
| model.load_state_dict(torch.load(base_ckpt, map_location="cpu")) |
| model.train() |
| loss_fn = Loss().to(device) |
| opt = torch.optim.AdamW(model.parameters(), lr=lr, weight_decay=1e-4) |
| out_ckpt.parent.mkdir(parents=True, exist_ok=True) |
| log_csv.parent.mkdir(parents=True, exist_ok=True) |
| with log_csv.open("w", newline="") as f: |
| writer = csv.writer(f) |
| writer.writerow(["step", "loss"]) |
| for step in range(steps): |
| waves, targets = sample_batch(h5_path, records, seed, batch_size, length, padlen, step) |
| xb = torch.from_numpy(waves).to(device).permute(0, 2, 1) |
| yb = torch.from_numpy(targets).to(device) |
| out = model(xb) |
| loss = loss_fn(out, yb) |
| opt.zero_grad(set_to_none=True) |
| loss.backward() |
| opt.step() |
| loss_value = float(loss.detach().cpu()) |
| writer.writerow([step, loss_value]) |
| if step % 50 == 0 or step == steps - 1: |
| print(f"{out_ckpt.stem}: step {step:05d}/{steps} loss={loss_value:.3f}", flush=True) |
| torch.save(model.state_dict(), out_ckpt) |
| model.eval() |
| return model |
|
|
|
|
| def metric_row(metrics: Dict, threshold: float = 0.1) -> Dict[str, float]: |
| out: Dict[str, float] = {} |
| for group in ("P", "S"): |
| row = min(metrics["all"][group], key=lambda r: abs(r["threshold"] - threshold)) |
| for key in ("precision", "recall", "f1"): |
| out[f"{group}_{key}"] = float(row[key]) |
| out["mean_f1"] = float((out["P_f1"] + out["S_f1"]) / 2.0) |
| return out |
|
|
|
|
| def plot_summary(rows: Sequence[Dict], out: Path) -> None: |
| labels = [r["label"] for r in rows] |
| x = np.arange(len(labels)) |
| width = 0.24 |
| fig, ax = plt.subplots(figsize=(7.2, 4.2), dpi=220) |
| for offset, key, color in [ |
| (-width, "P_f1", "#0072B2"), |
| (0.0, "S_f1", "#D55E00"), |
| (width, "mean_f1", "#009E73"), |
| ]: |
| ax.bar(x + offset, [r[key] for r in rows], width=width, label=key.replace("_", " "), color=color) |
| ax.set_ylabel("F1 on unfiltered test set") |
| ax.set_ylim(0, 1.0) |
| ax.set_xticks(x) |
| ax.set_xticklabels(labels) |
| ax.grid(axis="y", alpha=0.25) |
| ax.legend(frameon=False, ncols=3, loc="upper center", bbox_to_anchor=(0.5, 1.14)) |
| fig.tight_layout() |
| fig.savefig(out, bbox_inches="tight") |
| plt.close(fig) |
|
|
|
|
| def write_metrics_table(rows: Sequence[Dict], out: Path) -> None: |
| headers = [ |
| "Training subset", |
| "Train records", |
| "P precision", |
| "P recall", |
| "P F1", |
| "S precision", |
| "S recall", |
| "S F1", |
| "Mean F1", |
| ] |
| lines = ["\\begin{tabular}{lrrrrrrrr}", "\\toprule", " & ".join(headers) + " \\\\", "\\midrule"] |
| for row in rows: |
| lines.append( |
| f"{row['label']} & {row['train_records']} & " |
| f"{row['P_precision']:.3f} & {row['P_recall']:.3f} & {row['P_f1']:.3f} & " |
| f"{row['S_precision']:.3f} & {row['S_recall']:.3f} & {row['S_f1']:.3f} & " |
| f"{row['mean_f1']:.3f} \\\\" |
| ) |
| lines.extend(["\\bottomrule", "\\end{tabular}", ""]) |
| out.write_text("\n".join(lines)) |
|
|
|
|
| def main() -> None: |
| parser = argparse.ArgumentParser() |
| parser.add_argument("--h5", default="data/credit-x1.h5") |
| parser.add_argument("--keys", default="data/creditkeys.npz") |
| parser.add_argument("--base-ckpt", default="ckpt/pnsn.v3.pt") |
| parser.add_argument("--out-dir", default="outputs/snr_transfer_seed20260609") |
| parser.add_argument("--seed", type=int, default=20260609) |
| 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-samples", type=int, default=10000) |
| parser.add_argument("--eval-batch", type=int, default=64) |
| parser.add_argument("--max-train-events", type=int, default=0) |
| parser.add_argument("--max-test-events", type=int, default=0) |
| parser.add_argument("--lr", type=float, default=1e-4) |
| parser.add_argument("--resume", action="store_true") |
| parser.add_argument( |
| "--no-match-train-size", |
| action="store_true", |
| help="Use every candidate record in each SNR pool instead of matching candidate counts.", |
| ) |
| args = parser.parse_args() |
|
|
| h5_path = Path(args.h5) |
| key_path = Path(args.keys) |
| base_ckpt = Path(args.base_ckpt) |
| out_dir = Path(args.out_dir) |
| out_dir.mkdir(parents=True, exist_ok=True) |
|
|
| device = torch.device("mps") if torch.backends.mps.is_available() else torch.device("cpu") |
| print(f"device={device}", flush=True) |
| max_train = None if args.max_train_events == 0 else args.max_train_events |
| max_test = None if args.max_test_events == 0 else args.max_test_events |
| cache_tag_train = "all" if max_train is None else str(max_train) |
| cache_tag_test = "all" if max_test is None else str(max_test) |
| train_records = build_records_sequential( |
| h5_path, |
| key_path, |
| "train", |
| max_train, |
| out_dir / f"records_train_{cache_tag_train}.json", |
| ) |
| test_records = build_records_sequential( |
| h5_path, |
| key_path, |
| "test", |
| max_test, |
| out_dir / f"records_test_{cache_tag_test}.json", |
| ) |
| print(f"train records={len(train_records)} test records={len(test_records)}", flush=True) |
|
|
| snr_by_record = compute_record_snr(h5_path, train_records, out_dir / "train_record_snr_db.json") |
| snr_values = np.array(list(snr_by_record.values()), dtype=float) |
| snr_summary = { |
| "min": float(np.min(snr_values)), |
| "median": float(np.median(snr_values)), |
| "mean": float(np.mean(snr_values)), |
| "p90": float(np.percentile(snr_values, 90)), |
| "max": float(np.max(snr_values)), |
| } |
|
|
| subset_defs = [ |
| ("full", "Full", None), |
| ("snr5", "SNR>5 dB", 5.0), |
| ("snr10", "SNR>10 dB", 10.0), |
| ] |
| candidate_records = { |
| slug: filter_records_by_snr(train_records, snr_by_record, snr_threshold) |
| for slug, _, snr_threshold in subset_defs |
| } |
| candidate_counts = {slug: len(records) for slug, records in candidate_records.items()} |
| if args.no_match_train_size: |
| train_pools = candidate_records |
| matched_train_records = None |
| else: |
| matched_train_records = min(candidate_counts.values()) |
| train_pools = { |
| slug: matched_records(records, matched_train_records, args.seed + i * 8191) |
| for i, (slug, _, _) in enumerate(subset_defs) |
| for records in [candidate_records[slug]] |
| } |
| print( |
| "candidate train records=" |
| + json.dumps(candidate_counts, ensure_ascii=False) |
| + f"; matched_train_records={matched_train_records}", |
| flush=True, |
| ) |
|
|
| eval_specs = make_specs( |
| test_records, |
| n_samples=args.eval_samples, |
| seed=args.seed + 17, |
| length=args.length, |
| padlen=args.padlen, |
| double_prob=0.5, |
| ) |
| eval_waves, eval_labels, _, eval_kinds = materialize_samples(h5_path, test_records, eval_specs, args.length) |
| thresholds = [round(x, 1) for x in np.arange(0.1, 1.0, 0.1)] |
|
|
| rows = [] |
| model_metrics = {} |
| for idx, (slug, label, snr_threshold) in enumerate(subset_defs): |
| subset_records = train_pools[slug] |
| if not subset_records: |
| raise RuntimeError(f"No records available for subset {label}") |
| model = train_one( |
| h5_path=h5_path, |
| records=subset_records, |
| base_ckpt=base_ckpt, |
| out_ckpt=out_dir / f"pnsn.v3.transfer.{slug}.pt", |
| log_csv=out_dir / f"transfer_loss_{slug}.csv", |
| seed=args.seed + idx * 1000, |
| steps=args.train_steps, |
| batch_size=args.train_batch, |
| length=args.length, |
| padlen=args.padlen, |
| lr=args.lr, |
| device=device, |
| resume=args.resume, |
| ) |
| outputs = run_model(model, eval_waves, device, args.eval_batch) |
| metrics = evaluate_outputs(outputs, eval_labels, eval_kinds, thresholds, min_sep=50, tolerance=100) |
| model_metrics[slug] = strip_errors(metrics) |
| row = { |
| "slug": slug, |
| "label": label if args.no_match_train_size else f"{label} matched", |
| "snr_threshold_db": snr_threshold, |
| "train_records": len(subset_records), |
| "candidate_train_records": candidate_counts[slug], |
| **metric_row(metrics), |
| } |
| rows.append(row) |
| print(f"metrics {label}: {json.dumps(row, ensure_ascii=False)}", flush=True) |
|
|
| plot_summary(rows, out_dir / "snr_transfer_f1_summary.png") |
| write_metrics_table(rows, out_dir / "metrics_table.tex") |
| summary = { |
| "seed": args.seed, |
| "device": str(device), |
| "train_steps": args.train_steps, |
| "train_batch": args.train_batch, |
| "matched_train_records": matched_train_records, |
| "candidate_train_records": candidate_counts, |
| "eval_samples": args.eval_samples, |
| "eval_samples_single": int(sum(k == "single" for k in eval_kinds)), |
| "eval_samples_double": int(sum(k == "double" for k in eval_kinds)), |
| "length_samples": args.length, |
| "length_seconds": args.length * 0.01, |
| "snr_definition": "max per-record phase SNR using 10*log10(std_after/std_before); P on Z, S on mean E/N; windows match utils/datapnsn.py.", |
| "snr_summary_db": snr_summary, |
| "rows": rows, |
| "metrics_by_model": model_metrics, |
| "figure": str((out_dir / "snr_transfer_f1_summary.png").resolve()), |
| "table": str((out_dir / "metrics_table.tex").resolve()), |
| } |
| with (out_dir / "summary.json").open("w") as f: |
| json.dump(summary, f, ensure_ascii=False, indent=2) |
| print(json.dumps(summary, ensure_ascii=False, indent=2)[:4000], flush=True) |
|
|
|
|
| if __name__ == "__main__": |
| main() |
|
|