snr_bias / code /scripts /magnitude_manual_phase_transfer_experiment.py
cangyeone's picture
Upload GRL reproducibility package
7170296 verified
Raw
History Blame Contribute Delete
17 kB
#!/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()