| |
| """Compare M>=3-only and all-magnitude transfer training at matched manual-pick counts.""" |
|
|
| from __future__ import annotations |
|
|
| import argparse |
| import csv |
| import json |
| import math |
| import os |
| import random |
| import sys |
| from dataclasses import asdict, 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 ( |
| DTYPE, |
| GROUP_TO_CHANNELS, |
| PHASE_TO_CHANNEL, |
| PHASE_TO_GROUP, |
| find_peaks, |
| labels_to_target, |
| normalize_wave, |
| ) |
|
|
| COMPONENTS = ("BHE", "BHN", "BHZ") |
| PHASES = ("Pg", "Sg", "Pn", "Sn") |
|
|
|
|
| @dataclass(frozen=True) |
| class ManualPickSample: |
| split: str |
| event: str |
| station: str |
| phase: str |
| phase_group: str |
| index: int |
| length: int |
| magnitude: float |
| distance_km: float | None |
|
|
|
|
| def component_keys(station) -> tuple[str, str, str] | None: |
| keys = set(station.keys()) |
| if all(k in keys for k in COMPONENTS): |
| return COMPONENTS |
| 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 COMPONENTS): |
| return tuple(by_suffix[k] for k in COMPONENTS) |
| return None |
|
|
|
|
| def load_record_cache(path: Path) -> list[dict]: |
| return json.loads(path.read_text())["records"] |
|
|
|
|
| def event_magnitudes(h5_path: Path, events: Sequence[str]) -> dict[str, float]: |
| out: dict[str, float] = {} |
| with h5py.File(h5_path, "r") as h5: |
| for event in sorted(set(events)): |
| out[event] = float(h5[event].attrs.get("event_magnitude", float("nan"))) |
| return out |
|
|
|
|
| def manual_pick_samples( |
| h5_path: Path, |
| record_cache: Path, |
| split: str, |
| cache_path: Path, |
| ) -> list[ManualPickSample]: |
| if cache_path.exists(): |
| payload = json.loads(cache_path.read_text()) |
| return [ManualPickSample(**row) for row in payload["samples"]] |
|
|
| records = load_record_cache(record_cache) |
| mags = event_magnitudes(h5_path, [r["event"] for r in records]) |
| samples: list[ManualPickSample] = [] |
| for record in records: |
| mag = mags[record["event"]] |
| for pick in record["phases"]: |
| if pick.get("source") != "MANUAL": |
| continue |
| phase = pick["phase"] |
| samples.append( |
| ManualPickSample( |
| split=split, |
| event=record["event"], |
| station=record["station"], |
| phase=phase, |
| phase_group=PHASE_TO_GROUP[phase], |
| index=int(pick["index"]), |
| length=int(record["length"]), |
| magnitude=mag, |
| distance_km=record.get("distance_km"), |
| ) |
| ) |
| cache_path.parent.mkdir(parents=True, exist_ok=True) |
| cache_path.write_text( |
| json.dumps({"split": split, "samples": [asdict(s) for s in samples]}, indent=2), |
| encoding="utf-8", |
| ) |
| return samples |
|
|
|
|
| def stable_pick_id(sample: ManualPickSample) -> str: |
| return f"{sample.event}/{sample.station}/{sample.phase}/{sample.index:08d}" |
|
|
|
|
| def matched_pick_subset(samples: Sequence[ManualPickSample], n: int, seed: int) -> list[ManualPickSample]: |
| if len(samples) < n: |
| raise RuntimeError(f"Cannot sample {n} picks from pool of {len(samples)}.") |
| ordered = sorted(samples, key=stable_pick_id) |
| rng = np.random.default_rng(seed) |
| idx = np.sort(rng.choice(len(ordered), size=n, replace=False)) |
| return [ordered[int(i)] for i in idx] |
|
|
|
|
| def crop_start(sample: ManualPickSample, length: int, padlen: int, rng: np.random.Generator | None) -> int | None: |
| if sample.length < length: |
| return None |
| if rng is None: |
| offset = length // 2 |
| else: |
| offset = int(rng.integers(padlen, max(padlen + 1, length - padlen))) |
| start = sample.index - offset |
| return int(np.clip(start, 0, sample.length - length)) |
|
|
|
|
| def load_pick_window( |
| h5, |
| sample: ManualPickSample, |
| length: int, |
| padlen: int, |
| rng: np.random.Generator | None, |
| ) -> tuple[np.ndarray, np.ndarray, int] | None: |
| start = crop_start(sample, length, padlen, rng) |
| if start is None: |
| return None |
| station = h5[sample.event][sample.station] |
| comps = component_keys(station) |
| if comps is None: |
| return None |
| wave = np.stack([station[c][start : start + length] for c in comps], axis=1) |
| if wave.shape[0] != length: |
| return None |
| wave = normalize_wave(wave) |
| rel = sample.index - start |
| if not 0 <= rel < length: |
| return None |
| target = labels_to_target([(sample.phase, sample.phase_group, rel)], length) |
| return wave, target, rel |
|
|
|
|
| def sample_batch( |
| h5_path: Path, |
| samples: Sequence[ManualPickSample], |
| seed: int, |
| batch_size: int, |
| length: int, |
| padlen: int, |
| step: int, |
| ) -> tuple[np.ndarray, np.ndarray]: |
| rng = np.random.default_rng(seed + step * 104729) |
| waves: list[np.ndarray] = [] |
| targets: list[np.ndarray] = [] |
| valid = [s for s in samples if s.length >= length] |
| with h5py.File(h5_path, "r") as h5: |
| while len(waves) < batch_size: |
| sample = valid[int(rng.integers(0, len(valid)))] |
| loaded = load_pick_window(h5, sample, length, padlen, rng) |
| if loaded is None: |
| continue |
| wave, target, _ = loaded |
| waves.append(wave) |
| targets.append(target) |
| return np.stack(waves, axis=0), np.stack(targets, axis=0) |
|
|
|
|
| def train_one( |
| h5_path: Path, |
| samples: Sequence[ManualPickSample], |
| 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, |
| init_mode: str = "transfer", |
| ) -> 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 |
|
|
| if init_mode == "transfer": |
| model.load_state_dict(torch.load(base_ckpt, map_location="cpu")) |
| elif init_mode != "scratch": |
| raise ValueError(f"Unknown init_mode={init_mode!r}") |
| 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) |
| 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, samples, 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 run_model(model: BRNN, waves: np.ndarray, device: torch.device, batch_size: int) -> np.ndarray: |
| model.eval() |
| outputs: list[np.ndarray] = [] |
| with torch.no_grad(): |
| for start in range(0, len(waves), batch_size): |
| xb = torch.from_numpy(waves[start : start + batch_size]).to(device).permute(0, 2, 1) |
| outputs.append(model(xb).detach().cpu().numpy()) |
| return np.concatenate(outputs, axis=0) |
|
|
|
|
| def materialize_eval( |
| h5_path: Path, |
| samples: Sequence[ManualPickSample], |
| length: int, |
| padlen: int, |
| ) -> tuple[np.ndarray, list[int], list[str], list[str]]: |
| waves: list[np.ndarray] = [] |
| rel_indices: list[int] = [] |
| groups: list[str] = [] |
| phases: list[str] = [] |
| with h5py.File(h5_path, "r") as h5: |
| for i, sample in enumerate(samples): |
| loaded = load_pick_window(h5, sample, length, padlen, rng=None) |
| if loaded is None: |
| continue |
| wave, _target, rel = loaded |
| waves.append(wave) |
| rel_indices.append(rel) |
| groups.append(sample.phase_group) |
| phases.append(sample.phase) |
| if i % 2000 == 0: |
| print(f"materialized eval {i:,}/{len(samples):,}", flush=True) |
| return np.stack(waves, axis=0), rel_indices, groups, phases |
|
|
|
|
| def evaluate_pick_recall( |
| outputs: np.ndarray, |
| rel_indices: Sequence[int], |
| groups: Sequence[str], |
| phases: Sequence[str], |
| thresholds: Sequence[float], |
| tolerance: int, |
| min_sep: int, |
| ) -> dict: |
| out = {"thresholds": list(thresholds), "by_group": {}, "by_phase": {}} |
| for scope_name, labels in [("by_group", groups), ("by_phase", phases)]: |
| for label in sorted(set(labels)): |
| rows = [] |
| idxs = [i for i, v in enumerate(labels) if v == label] |
| if scope_name == "by_group": |
| channel_groups = {label: GROUP_TO_CHANNELS[label]} |
| else: |
| channel_groups = {label: [PHASE_TO_CHANNEL[label]]} |
| chans = channel_groups[label] |
| for threshold in thresholds: |
| tp = fp = fn = 0 |
| for i in idxs: |
| prob = outputs[i, chans, :].max(axis=0) |
| pred = [pidx for pidx, _score in find_peaks(prob, threshold, min_sep)] |
| matched = any(abs(pidx - rel_indices[i]) <= tolerance for pidx in pred) |
| if matched: |
| tp += 1 |
| fp += max(0, len(pred) - 1) |
| else: |
| fn += 1 |
| fp += len(pred) |
| precision = tp / (tp + fp) if tp + fp else 0.0 |
| recall = tp / (tp + fn) if tp + fn else 0.0 |
| f1 = 2 * precision * recall / (precision + recall) if precision + recall else 0.0 |
| rows.append( |
| { |
| "threshold": float(threshold), |
| "n": len(idxs), |
| "tp": int(tp), |
| "fp": int(fp), |
| "fn": int(fn), |
| "precision": float(precision), |
| "recall": float(recall), |
| "f1": float(f1), |
| } |
| ) |
| out[scope_name][label] = rows |
| for threshold in thresholds: |
| rows = [] |
| tp = fp = fn = 0 |
| for group, group_rows in out["by_group"].items(): |
| row = next(r for r in group_rows if abs(r["threshold"] - threshold) < 1e-9) |
| tp += row["tp"] |
| fp += row["fp"] |
| fn += row["fn"] |
| precision = tp / (tp + fp) if tp + fp else 0.0 |
| recall = tp / (tp + fn) if tp + fn else 0.0 |
| f1 = 2 * precision * recall / (precision + recall) if precision + recall else 0.0 |
| rows.append( |
| { |
| "threshold": float(threshold), |
| "n": int(tp + fn), |
| "tp": int(tp), |
| "fp": int(fp), |
| "fn": int(fn), |
| "precision": float(precision), |
| "recall": float(recall), |
| "f1": float(f1), |
| } |
| ) |
| out.setdefault("combined", []).extend(rows) |
| return out |
|
|
|
|
| def summarize_counts(samples: Sequence[ManualPickSample]) -> dict: |
| by_phase: dict[str, int] = {} |
| by_group: dict[str, int] = {} |
| events = set() |
| for sample in samples: |
| by_phase[sample.phase] = by_phase.get(sample.phase, 0) + 1 |
| by_group[sample.phase_group] = by_group.get(sample.phase_group, 0) + 1 |
| events.add(sample.event) |
| return { |
| "picks": len(samples), |
| "events": len(events), |
| "by_phase": dict(sorted(by_phase.items())), |
| "by_group": dict(sorted(by_group.items())), |
| } |
|
|
|
|
| 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" / "magnitude_pick_transfer_seed20260628") |
| parser.add_argument("--seed", type=int, default=20260628) |
| parser.add_argument("--mag-threshold", type=float, default=3.0) |
| 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("--max-eval-picks", type=int, default=0) |
| parser.add_argument("--eval-all-magnitudes", action="store_true") |
| 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) |
|
|
| train_samples = manual_pick_samples(args.h5, args.train_record_cache, "train", args.out_dir / "manual_train_picks.json") |
| test_samples = manual_pick_samples(args.h5, args.test_record_cache, "test", args.out_dir / "manual_test_picks.json") |
| train_m3 = [s for s in train_samples if s.magnitude >= args.mag_threshold and s.length >= args.length] |
| train_all_valid = [s for s in train_samples if s.length >= args.length] |
| if args.eval_all_magnitudes: |
| eval_samples = [s for s in test_samples if s.length >= args.length] |
| eval_tag = "all_magnitudes" |
| else: |
| eval_samples = [s for s in test_samples if s.magnitude >= args.mag_threshold and s.length >= args.length] |
| eval_tag = "mag_ge_3" |
| if args.max_eval_picks > 0: |
| eval_samples = matched_pick_subset(eval_samples, min(args.max_eval_picks, len(eval_samples)), args.seed + 17) |
| train_budget = len(train_m3) |
| train_all_matched = matched_pick_subset(train_all_valid, train_budget, args.seed) |
|
|
| subsets = { |
| "all_mag_matched": train_all_matched, |
| "mag_ge_3": sorted(train_m3, key=stable_pick_id), |
| } |
| print(f"train budget manual picks={train_budget}; eval {eval_tag} manual picks={len(eval_samples)}", flush=True) |
| models: dict[str, BRNN] = {} |
| for slug, subset in subsets.items(): |
| print(f"training {slug}: {summarize_counts(subset)}", flush=True) |
| models[slug] = train_one( |
| args.h5, |
| subset, |
| args.base_ckpt, |
| args.out_dir / f"pnsn.v3.transfer.{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, |
| ) |
|
|
| waves, rel_indices, groups, phases = materialize_eval(args.h5, sorted(eval_samples, key=stable_pick_id), args.length, args.padlen) |
| rows = [] |
| metrics_by_model = {} |
| for slug, model in models.items(): |
| outputs = run_model(model, waves, device, args.eval_batch) |
| metrics = evaluate_pick_recall(outputs, rel_indices, groups, phases, args.thresholds, args.tolerance_samples, args.min_sep) |
| metrics_by_model[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({"model": slug, "scope": scope, "label": label, **row}) |
| for row in metrics["combined"]: |
| rows.append({"model": slug, "scope": "combined", "label": "all", **row}) |
|
|
| suffix = "" if eval_tag == "mag_ge_3" else f"_eval_{eval_tag}" |
| csv_path = args.out_dir / f"magnitude_pick_recall_metrics{suffix}.csv" |
| with csv_path.open("w", newline="") as f: |
| fieldnames = ["model", "scope", "label", "threshold", "n", "tp", "fp", "fn", "precision", "recall", "f1"] |
| writer = csv.DictWriter(f, fieldnames=fieldnames) |
| writer.writeheader() |
| writer.writerows(rows) |
|
|
| summary = { |
| "experiment": "manual_pick_magnitude_transfer", |
| "notes": [ |
| "Training samples are individual MANUAL.TRAVTIME Pg/Sg/Pn/Sn picks.", |
| "The all-magnitude and M>=3 training conditions are matched to the same manual-pick count.", |
| "Evaluation uses only M>=3 test-set manual picks, one centered window per pick.", |
| ], |
| "config": { |
| "seed": args.seed, |
| "mag_threshold": args.mag_threshold, |
| "train_steps": args.train_steps, |
| "train_batch": args.train_batch, |
| "max_eval_picks": args.max_eval_picks, |
| "eval_all_magnitudes": args.eval_all_magnitudes, |
| "length": args.length, |
| "padlen": args.padlen, |
| "thresholds": args.thresholds, |
| "tolerance_samples": args.tolerance_samples, |
| "min_sep": args.min_sep, |
| }, |
| "counts": { |
| "train_all_manual": summarize_counts(train_samples), |
| "train_magnitude_ge_3_valid": summarize_counts(train_m3), |
| "train_all_matched": summarize_counts(train_all_matched), |
| "eval_valid": summarize_counts(eval_samples), |
| }, |
| "metrics": metrics_by_model, |
| } |
| summary_path = args.out_dir / f"summary{suffix}.json" |
| summary_path.write_text(json.dumps(summary, indent=2), encoding="utf-8") |
| print(f"wrote {summary_path}") |
| print(f"wrote {csv_path}") |
|
|
|
|
| if __name__ == "__main__": |
| main() |
|
|