#!/usr/bin/env python3 """Magnitude-filtered transfer experiment for manual phase-pick recall. Compares a model fine-tuned on all-magnitude manual phase examples against a model fine-tuned on M>=threshold manual phase examples. Training pools are matched by manual phase-pick count, not by event count or station-record count. Evaluation uses only manual picks from M>=threshold test events. """ from __future__ import annotations import argparse import csv import json import math import os import random import sys from dataclasses import dataclass from pathlib import Path from typing import 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 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 find_peaks PHASES = ("Pg", "Sg", "Pn", "Sn") PHASE_TO_CHANNEL = {"Pg": 1, "Sg": 2, "Pn": 3, "Sn": 4} PHASE_TO_GROUP = {"Pg": "P", "Pn": "P", "Sg": "S", "Sn": "S"} GROUP_TO_CHANNELS = {"P": [1, 3], "S": [2, 4]} COMPONENT_ORDER = ("BHE", "BHN", "BHZ") DTYPE = np.float32 @dataclass(frozen=True) class ManualPick: phase: str index: int @dataclass(frozen=True) class RecordInfo: event: str station: str length: int magnitude: float picks: tuple[ManualPick, ...] @dataclass(frozen=True) class PhaseExample: record_id: str phase: str index: int magnitude: float 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 normalize_wave(wave: np.ndarray) -> np.ndarray: wave = wave.astype(DTYPE, copy=False) wave = wave - wave.mean(axis=0, keepdims=True) denom = np.max(np.abs(wave), axis=0, keepdims=True) + 1e-6 return wave / denom def labels_to_target(labels: Sequence[tuple[str, str, int]], length: int, sigma_samples: float = 10.0) -> np.ndarray: target = np.zeros((5, length), dtype=DTYPE) x = np.arange(length, dtype=DTYPE) for phase, _group, idx in labels: pulse = np.exp(-0.5 * ((x - idx) / sigma_samples) ** 2) target[PHASE_TO_CHANNEL[phase]] = np.maximum(target[PHASE_TO_CHANNEL[phase]], pulse.astype(DTYPE)) target[0] = np.clip(1.0 - target[1:].sum(axis=0), 0.0, 1.0) return target def load_split_records(records_path: Path, h5_path: Path) -> tuple[dict[str, RecordInfo], list[PhaseExample]]: payload = json.loads(records_path.read_text()) raw_records = payload["records"] event_ids = sorted({row["event"] for row in raw_records}) with h5py.File(h5_path, "r") as h5: mag_by_event = {event: float(h5[event].attrs.get("event_magnitude", float("nan"))) for event in event_ids} records: dict[str, RecordInfo] = {} examples: list[PhaseExample] = [] for row in raw_records: picks = tuple( ManualPick(phase=p["phase"], index=int(p["index"])) for p in row["phases"] if p.get("source") == "MANUAL" ) if not picks: continue mag = mag_by_event.get(row["event"], float("nan")) if not math.isfinite(mag): continue record_id = f"{row['event']}/{row['station']}" records[record_id] = RecordInfo( event=row["event"], station=row["station"], length=int(row["length"]), magnitude=mag, picks=picks, ) examples.extend(PhaseExample(record_id, pick.phase, pick.index, mag) for pick in picks) return records, examples def window_start(record: RecordInfo, anchor_index: int, length: int, padlen: int, rng: np.random.Generator | None) -> int: if record.length <= length: return 0 if rng is None: offset = length // 2 else: low = min(padlen, length - 1) high = max(low + 1, length - padlen) offset = int(rng.integers(low, high)) return int(np.clip(anchor_index - offset, 0, record.length - length)) def load_window( h5, records: dict[str, RecordInfo], example: PhaseExample, length: int, padlen: int, rng: np.random.Generator | None, ) -> tuple[np.ndarray, np.ndarray, int]: record = records[example.record_id] station = h5[record.event][record.station] comps = component_keys(station) if comps is None: raise RuntimeError(f"Missing components for {example.record_id}") start = window_start(record, example.index, length, padlen, rng) stop = min(start + length, record.length) data = [station[c][start:stop] for c in comps] wave = np.stack(data, axis=1) if len(wave) < length: padded = np.zeros((length, 3), dtype=DTYPE) padded[: len(wave)] = wave wave = padded labels = [] for pick in record.picks: rel = pick.index - start if 0 <= rel < length: labels.append((pick.phase, PHASE_TO_GROUP[pick.phase], rel)) return normalize_wave(wave), labels_to_target(labels, length), example.index - start def materialize_batch( h5, records: dict[str, RecordInfo], pool: Sequence[PhaseExample], selected: np.ndarray, length: int, padlen: int, rng: np.random.Generator | None, ) -> tuple[np.ndarray, np.ndarray]: waves = np.zeros((len(selected), length, 3), dtype=DTYPE) targets = np.zeros((len(selected), 5, length), dtype=DTYPE) for i, idx in enumerate(selected): wave, target, _rel = load_window(h5, records, pool[int(idx)], length, padlen, rng) waves[i] = wave targets[i] = target return waves, targets def choose_matched_pools( train_examples: list[PhaseExample], magnitude_threshold: float, seed: int, ) -> tuple[list[PhaseExample], list[PhaseExample]]: high = [ex for ex in train_examples if ex.magnitude >= magnitude_threshold] if not high: raise RuntimeError("No high-magnitude manual phase examples found.") ordered_all = sorted(train_examples, key=lambda ex: (ex.record_id, ex.phase, ex.index)) rng = np.random.default_rng(seed) all_idx = np.sort(rng.choice(len(ordered_all), size=len(high), replace=False)) matched_all = [ordered_all[int(i)] for i in all_idx] return matched_all, high def train_model( h5_path: Path, records: dict[str, RecordInfo], pool: list[PhaseExample], 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) log_csv.parent.mkdir(parents=True, exist_ok=True) rng = np.random.default_rng(seed) with h5py.File(h5_path, "r") as h5, log_csv.open("w", newline="") as f: writer = csv.writer(f) writer.writerow(["step", "loss"]) for step in range(steps): selected = rng.integers(0, len(pool), size=batch_size) waves, targets = materialize_batch(h5, records, pool, selected, length, padlen, rng) 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 % 25 == 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 empty_counts() -> dict: return { key: {"n": 0, "tp": 0} for key in ("P", "S", "combined", "Pg", "Sg", "Pn", "Sn") } def evaluate_recall( h5_path: Path, records: dict[str, RecordInfo], examples: list[PhaseExample], model: BRNN, length: int, padlen: int, threshold: float, tolerance: int, min_sep: int, batch_size: int, device: torch.device, ) -> dict: counts = empty_counts() model.eval() with h5py.File(h5_path, "r") as h5, torch.no_grad(): for start in range(0, len(examples), batch_size): batch_examples = examples[start : start + batch_size] waves = np.zeros((len(batch_examples), length, 3), dtype=DTYPE) rel_indices: list[int] = [] for i, example in enumerate(batch_examples): wave, _target, rel = load_window(h5, records, example, length, padlen, rng=None) waves[i] = wave rel_indices.append(rel) xb = torch.from_numpy(waves).to(device).permute(0, 2, 1) out = model(xb).detach().cpu().numpy() for i, example in enumerate(batch_examples): phase = example.phase group = PHASE_TO_GROUP[phase] prob = out[i, GROUP_TO_CHANNELS[group], :].max(axis=0) pred = [idx for idx, _score in find_peaks(prob, threshold, min_sep)] hit = any(abs(idx - rel_indices[i]) <= tolerance for idx in pred) for key in (phase, group, "combined"): counts[key]["n"] += 1 counts[key]["tp"] += int(hit) if start and start % (batch_size * 20) == 0: print(f"evaluated {start:,}/{len(examples):,} examples", flush=True) for key, row in counts.items(): row["recall"] = row["tp"] / row["n"] if row["n"] else None return counts def summarize_pool(examples: Sequence[PhaseExample]) -> dict: out = {"total_phase_examples": len(examples), "by_phase": {}, "by_group": {}, "event_count": 0, "record_count": 0} events = set() records = set() for ex in examples: event = ex.record_id.split("/", 1)[0] events.add(event) records.add(ex.record_id) out["by_phase"][ex.phase] = out["by_phase"].get(ex.phase, 0) + 1 group = PHASE_TO_GROUP[ex.phase] out["by_group"][group] = out["by_group"].get(group, 0) + 1 out["event_count"] = len(events) out["record_count"] = len(records) return out def main() -> None: parser = argparse.ArgumentParser() parser.add_argument("--h5", type=Path, default=ROOT / "data" / "credit-x1.h5") parser.add_argument("--train-records", type=Path, default=ROOT / "outputs" / "snr_transfer_seed20260609" / "records_train_all.json") parser.add_argument("--test-records", type=Path, default=ROOT / "outputs" / "snr_transfer_seed20260609" / "records_test_all.json") parser.add_argument("--base-ckpt", type=Path, default=ROOT / "ckpt" / "pnsn.v3.pt") parser.add_argument("--out-dir", type=Path, default=ROOT / "outputs" / "mag25_manual_phase_transfer") parser.add_argument("--magnitude-threshold", type=float, default=2.5) parser.add_argument("--seed", type=int, default=20260628) parser.add_argument("--train-steps", type=int, default=500) parser.add_argument("--train-batch", type=int, default=16) parser.add_argument("--eval-batch", type=int, default=64) parser.add_argument("--length", type=int, default=5120) parser.add_argument("--padlen", type=int, default=512) parser.add_argument("--lr", type=float, default=1e-4) parser.add_argument("--threshold", type=float, default=0.1) parser.add_argument("--tolerance-samples", type=int, default=100) parser.add_argument("--min-sep", type=int, default=50) parser.add_argument("--eval-max-examples", type=int, default=0) parser.add_argument("--device", default="cpu") parser.add_argument("--resume", action="store_true") args = parser.parse_args() device = torch.device(args.device) args.out_dir.mkdir(parents=True, exist_ok=True) train_records, train_examples = load_split_records(args.train_records, args.h5) test_records, test_examples = load_split_records(args.test_records, args.h5) matched_all, high_train = choose_matched_pools(train_examples, args.magnitude_threshold, args.seed) high_eval = [ex for ex in test_examples if ex.magnitude >= args.magnitude_threshold] if args.eval_max_examples and args.eval_max_examples < len(high_eval): ordered = sorted(high_eval, key=lambda ex: (ex.record_id, ex.phase, ex.index)) rng = np.random.default_rng(args.seed + 17) idx = np.sort(rng.choice(len(ordered), size=args.eval_max_examples, replace=False)) high_eval = [ordered[int(i)] for i in idx] pools = { "allmag_matched": matched_all, "mag25plus": high_train, } models = {} for i, (name, pool) in enumerate(pools.items()): models[name] = train_model( h5_path=args.h5, records=train_records, pool=pool, base_ckpt=args.base_ckpt, out_ckpt=args.out_dir / f"pnsn.v3.transfer.{name}.pt", log_csv=args.out_dir / f"train_log_{name}.csv", seed=args.seed + i * 1009, steps=args.train_steps, batch_size=args.train_batch, length=args.length, padlen=args.padlen, lr=args.lr, device=device, resume=args.resume, ) eval_results = {} for name, model in models.items(): eval_results[name] = evaluate_recall( h5_path=args.h5, records=test_records, examples=high_eval, model=model, length=args.length, padlen=args.padlen, threshold=args.threshold, tolerance=args.tolerance_samples, min_sep=args.min_sep, batch_size=args.eval_batch, device=device, ) delta = {} for key in eval_results["allmag_matched"]: a = eval_results["allmag_matched"][key]["recall"] b = eval_results["mag25plus"][key]["recall"] delta[key] = None if a is None or b is None else b - a summary = { "inputs": { "h5": str(args.h5), "train_records": str(args.train_records), "test_records": str(args.test_records), "base_ckpt": str(args.base_ckpt), "magnitude_threshold": args.magnitude_threshold, "seed": args.seed, "train_steps": args.train_steps, "train_batch": args.train_batch, "eval_batch": args.eval_batch, "length": args.length, "threshold": args.threshold, "tolerance_samples": args.tolerance_samples, "eval_max_examples": args.eval_max_examples, }, "pool_summary": { "all_train_manual_phase_examples": summarize_pool(train_examples), "allmag_matched_train_pool": summarize_pool(matched_all), "mag25plus_train_pool": summarize_pool(high_train), "mag25plus_eval_pool": summarize_pool(high_eval), }, "eval_manual_pick_recall_on_mag25plus": eval_results, "delta_mag25plus_minus_allmag": delta, } summary_path = args.out_dir / "summary.json" summary_path.write_text(json.dumps(summary, indent=2), encoding="utf-8") rows_path = args.out_dir / "recall_table.csv" with rows_path.open("w", newline="") as f: writer = csv.writer(f) writer.writerow(["phase_or_group", "allmag_recall", "mag25plus_recall", "delta", "n_eval"]) for key in ("combined", "P", "S", "Pg", "Sg", "Pn", "Sn"): writer.writerow( [ key, eval_results["allmag_matched"][key]["recall"], eval_results["mag25plus"][key]["recall"], delta[key], eval_results["allmag_matched"][key]["n"], ] ) print(json.dumps(summary["pool_summary"], indent=2), flush=True) print(json.dumps(summary["eval_manual_pick_recall_on_mag25plus"], indent=2), flush=True) print(f"wrote {summary_path}") print(f"wrote {rows_path}") if __name__ == "__main__": main()