snr_bias / code /scripts /reproduce_paper_stats.py
cangyeone's picture
Upload GRL reproducibility package
7170296 verified
Raw
History Blame Contribute Delete
31.5 kB
#!/usr/bin/env python3
"""Reproduce paper statistics with a fixed seed.
The script uses CREDIT-X1Local records to build deterministic single-event and
two-event mixture windows, fine-tunes the pretrained Pn/Sn picker, and exports
metrics plus publication-ready composite figures.
"""
from __future__ import annotations
import argparse
import csv
import datetime as dt
import json
import math
import os
import random
import sys
from dataclasses import dataclass
from pathlib import Path
from typing import Dict, Iterable, List, Sequence, Tuple
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
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]}
COMPONENTS = ("BHE", "BHN", "BHZ")
DTYPE = np.float32
@dataclass(frozen=True)
class PhasePick:
phase: str
index: int
source: str
@dataclass(frozen=True)
class Record:
event: str
station: str
length: int
delta: float
distance_km: float
phases: Tuple[PhasePick, ...]
@dataclass(frozen=True)
class CropSpec:
rec_idx: int
start: int
amp: float
@dataclass(frozen=True)
class SampleSpec:
crops: Tuple[CropSpec, ...]
kind: str
def parse_time(value: str) -> dt.datetime:
return dt.datetime.strptime(value, "%Y/%m/%d %H:%M:%S.%f")
def choose_phase(station, phase: str, prefer_manual: bool = True) -> Tuple[str, str] | None:
manual_key = f"MANUAL.TRAVTIME.{phase}"
rnn_key = f"RNN.TRAVTIME.{phase}"
if prefer_manual and manual_key in station.attrs:
value = station.attrs[manual_key]
if isinstance(value, str) and "/" in value:
return value, "MANUAL"
if rnn_key in station.attrs:
value = station.attrs[rnn_key]
tag = station.attrs.get(f"{rnn_key}.tag", "")
if isinstance(value, str) and "/" in value and tag == "Y":
return value, "RNN.tagY"
if not prefer_manual and manual_key in station.attrs:
value = station.attrs[manual_key]
if isinstance(value, str) and "/" in value:
return value, "MANUAL"
return 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 = {}
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 build_records(
h5_path: Path,
key_path: Path,
split: str,
max_events: int | None,
prefer_manual: bool = True,
) -> List[Record]:
keys = np.load(key_path)[split]
if max_events is not None:
keys = keys[:max_events]
records: List[Record] = []
with h5py.File(h5_path, "r") as h5:
for event_key in keys:
event_key = str(event_key)
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)
lengths = [int(station[c].shape[0]) for c in comps]
length = min(lengths)
picks: List[PhasePick] = []
for phase in ("Pg", "Sg", "Pn", "Sn"):
chosen = choose_phase(station, phase, prefer_manual=prefer_manual)
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),
)
)
return records
def crop_start_for_record(record: Record, rng: np.random.Generator, length: int, padlen: int) -> int | None:
if record.length < length:
return None
phase_indices = np.array([p.index for p in record.phases], dtype=np.int64)
for _ in range(24):
anchor = int(rng.choice(phase_indices))
offset = int(rng.integers(padlen, max(padlen + 1, length - padlen)))
start = anchor - offset
if 0 <= start <= record.length - length:
return start
anchor = int(rng.choice(phase_indices))
return int(np.clip(anchor - length // 2, 0, record.length - length))
def make_specs(
records: Sequence[Record],
n_samples: int,
seed: int,
length: int,
padlen: int,
double_prob: float = 0.5,
valid_indices: Sequence[int] | None = None,
) -> List[SampleSpec]:
valid = list(valid_indices) if valid_indices is not None else [i for i, r in enumerate(records) if r.length >= length]
if not valid:
raise RuntimeError("No records are long enough for the requested window length.")
rng = np.random.default_rng(seed)
specs: List[SampleSpec] = []
while len(specs) < n_samples:
is_double = rng.random() < double_prob
crop_count = 2 if is_double else 1
crops: List[CropSpec] = []
for j in range(crop_count):
rec_idx = int(rng.choice(valid))
start = crop_start_for_record(records[rec_idx], rng, length, padlen)
if start is None:
crops = []
break
amp = 1.0 if j == 0 else float(rng.uniform(0.2, 5.0))
crops.append(CropSpec(rec_idx, start, amp))
if crops:
specs.append(SampleSpec(tuple(crops), "double" if is_double else "single"))
return specs
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 load_crop(h5, record: Record, crop: CropSpec, length: int) -> Tuple[np.ndarray, List[Tuple[str, str, int]]]:
station = h5[record.event][record.station]
comps = component_keys(station)
if comps is None:
raise RuntimeError(f"Missing components for {record.event}/{record.station}")
data = [station[c][crop.start : crop.start + length] for c in comps]
wave = np.stack(data, axis=1)
wave = normalize_wave(wave) * crop.amp
labels: List[Tuple[str, str, int]] = []
for pick in record.phases:
rel = pick.index - crop.start
if 0 <= rel < length:
labels.append((pick.phase, PHASE_TO_GROUP[pick.phase], rel))
return wave, labels
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, _, idx in labels:
ch = PHASE_TO_CHANNEL[phase]
pulse = np.exp(-0.5 * ((x - idx) / sigma_samples) ** 2)
target[ch] = np.maximum(target[ch], pulse.astype(DTYPE))
target[0] = np.clip(1.0 - target[1:].sum(axis=0), 0.0, 1.0)
return target
def materialize_samples(
h5_path: Path,
records: Sequence[Record],
specs: Sequence[SampleSpec],
length: int,
progress_interval: int = 0,
) -> Tuple[np.ndarray, List[List[Tuple[str, str, int]]], np.ndarray, List[str]]:
waves = np.zeros((len(specs), length, 3), dtype=DTYPE)
labels_all: List[List[Tuple[str, str, int]]] = []
targets = np.zeros((len(specs), 5, length), dtype=DTYPE)
kinds: List[str] = []
with h5py.File(h5_path, "r") as h5:
for i, spec in enumerate(specs):
if progress_interval > 0 and (i % progress_interval == 0 or i == len(specs) - 1):
print(f"materialized eval sample {i:,}/{len(specs):,}", flush=True)
mixed = np.zeros((length, 3), dtype=DTYPE)
labels: List[Tuple[str, str, int]] = []
for crop in spec.crops:
wave, crop_labels = load_crop(h5, records[crop.rec_idx], crop, length)
mixed += wave
labels.extend(crop_labels)
mixed = normalize_wave(mixed)
waves[i] = mixed
labels_all.append(labels)
targets[i] = labels_to_target(labels, length)
kinds.append(spec.kind)
return waves, labels_all, targets, kinds
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 find_peaks(prob: np.ndarray, threshold: float, min_sep: int) -> List[Tuple[int, float]]:
if len(prob) < 3:
return []
mask = (prob[1:-1] >= threshold) & (prob[1:-1] >= prob[:-2]) & (prob[1:-1] > prob[2:])
candidates = np.where(mask)[0] + 1
if candidates.size == 0:
return []
order = candidates[np.argsort(prob[candidates])[::-1]]
selected: List[int] = []
for idx in order:
if all(abs(int(idx) - old) >= min_sep for old in selected):
selected.append(int(idx))
selected.sort()
return [(idx, float(prob[idx])) for idx in selected]
def match_predictions(
true_indices: Sequence[int],
pred_indices: Sequence[int],
tolerance: int,
) -> Tuple[int, int, int, List[float]]:
unmatched = set(range(len(true_indices)))
tp = 0
fp = 0
errors: List[float] = []
for pred in pred_indices:
best = None
best_dist = tolerance + 1
for ti in unmatched:
dist = abs(pred - true_indices[ti])
if dist <= tolerance and dist < best_dist:
best = ti
best_dist = dist
if best is None:
fp += 1
else:
unmatched.remove(best)
tp += 1
errors.append((pred - true_indices[best]) * 0.01)
fn = len(unmatched)
return tp, fp, fn, errors
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):
batch = torch.from_numpy(waves[start : start + batch_size]).to(device)
batch = batch.permute(0, 2, 1)
out = model(batch).detach().cpu().numpy()
outputs.append(out)
return np.concatenate(outputs, axis=0)
def evaluate_outputs(
outputs: np.ndarray,
labels_all: Sequence[Sequence[Tuple[str, str, int]]],
kinds: Sequence[str],
thresholds: Sequence[float],
min_sep: int,
tolerance: int,
) -> Dict:
result = {"thresholds": list(map(float, thresholds)), "all": {}, "double": {}}
for subset_name, subset_mask in {
"all": np.ones(len(labels_all), dtype=bool),
"double": np.array([k == "double" for k in kinds], dtype=bool),
}.items():
subset_result = {}
for group in ("P", "S"):
rows = []
for thr in thresholds:
tp = fp = fn = 0
errors: List[float] = []
for i, labels in enumerate(labels_all):
if not subset_mask[i]:
continue
true_idx = [idx for _, g, idx in labels if g == group]
prob = outputs[i, GROUP_TO_CHANNELS[group], :].max(axis=0)
pred_idx = [idx for idx, _ in find_peaks(prob, thr, min_sep)]
mtp, mfp, mfn, merr = match_predictions(true_idx, pred_idx, tolerance)
tp += mtp
fp += mfp
fn += mfn
errors.extend(merr)
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
row = {
"threshold": float(thr),
"tp": int(tp),
"fp": int(fp),
"fn": int(fn),
"precision": float(precision),
"recall": float(recall),
"f1": float(f1),
"mean_error_s": float(np.mean(errors)) if errors else None,
"std_error_s": float(np.std(errors)) if errors else None,
"errors_s": errors if abs(thr - 0.1) < 1e-9 else [],
}
rows.append(row)
subset_result[group] = rows
result[subset_name] = subset_result
return result
def metric_at(metrics: Dict, subset: str, group: str, threshold: float) -> Dict:
rows = metrics[subset][group]
return min(rows, key=lambda r: abs(r["threshold"] - threshold))
def train_transfer(
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,
) -> BRNN:
torch.manual_seed(seed)
np.random.seed(seed)
random.seed(seed)
model = BRNN().to(device)
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)
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 % 25 == 0 or step == steps - 1:
print(f"train step {step:05d}/{steps} loss={loss_value:.3f}", flush=True)
torch.save(model.state_dict(), out_ckpt)
return model
def load_model(ckpt: Path, device: torch.device) -> BRNN:
model = BRNN().to(device)
model.load_state_dict(torch.load(ckpt, map_location="cpu"))
model.eval()
return model
def plot_training_samples(waves: np.ndarray, labels_all, out: Path) -> None:
single_idx = 0
double_idx = next((i for i, labels in enumerate(labels_all) if len(labels) >= 3), min(1, len(labels_all) - 1))
fig, axes = plt.subplots(2, 2, figsize=(11, 6.8), dpi=220)
time = np.arange(waves.shape[1]) * 0.01
for ax, idx, title in [
(axes[0, 0], single_idx, "(a) Single-event waveform"),
(axes[0, 1], double_idx, "(b) Two-event mixed waveform"),
]:
for ci, lab in enumerate(["E", "N", "Z"]):
ax.plot(time, waves[idx, :, ci] + (2 - ci) * 2.2, lw=0.55, color="black")
ax.text(time[0] - 1.0, (2 - ci) * 2.2, lab, va="center", ha="right", fontsize=8)
ax.set_title(title, loc="left", fontsize=10)
ax.set_xlabel("Time (s)")
ax.set_yticks([])
ax.set_xlim(time[0], time[-1])
for ax, idx, title in [
(axes[1, 0], single_idx, "(c) Single-event labels"),
(axes[1, 1], double_idx, "(d) Mixed-event labels"),
]:
target = labels_to_target(labels_all[idx], waves.shape[1])
ax.plot(time, target[1] + target[3], color="#d62728", label="P", lw=0.9)
ax.plot(time, target[2] + target[4], color="#1f77b4", label="S", lw=0.9)
ax.set_title(title, loc="left", fontsize=10)
ax.set_xlabel("Time (s)")
ax.set_ylabel("Probability")
ax.set_ylim(-0.05, 1.05)
ax.set_xlim(time[0], time[-1])
ax.legend(frameon=False, fontsize=8, loc="upper right")
fig.tight_layout()
fig.savefig(out, bbox_inches="tight")
plt.close(fig)
def plot_pr(base_metrics: Dict, multi_metrics: Dict, out: Path) -> None:
fig, axes = plt.subplots(2, 2, figsize=(9.2, 6.8), dpi=220, sharex=True, sharey=True)
panels = [
(axes[0, 0], base_metrics, "P", "(a) RNN, P"),
(axes[0, 1], base_metrics, "S", "(b) RNN, S"),
(axes[1, 0], multi_metrics, "P", "(c) Multi-RNN, P"),
(axes[1, 1], multi_metrics, "S", "(d) Multi-RNN, S"),
]
for ax, metrics, group, title in panels:
rows = metrics["all"][group]
x = [r["threshold"] for r in rows]
ax.plot(x, [r["precision"] for r in rows], marker="o", ms=3, label="Precision")
ax.plot(x, [r["recall"] for r in rows], marker="s", ms=3, label="Recall")
ax.plot(x, [r["f1"] for r in rows], marker="^", ms=3, label="F1")
ax.set_title(title, loc="left", fontsize=10)
ax.grid(True, alpha=0.25)
ax.set_ylim(0, 1.02)
ax.set_xlabel("Minimum confidence threshold")
ax.set_ylabel("Value")
axes[0, 0].legend(frameon=False, fontsize=8, loc="best")
fig.tight_layout()
fig.savefig(out, bbox_inches="tight")
plt.close(fig)
def plot_errors(base_metrics: Dict, multi_metrics: Dict, subset: str, out: Path) -> None:
fig, axes = plt.subplots(2, 2, figsize=(9.2, 6.8), dpi=220, sharex=True)
panels = [
(axes[0, 0], base_metrics, "P", "(a) RNN, P", "#ff6b6b"),
(axes[0, 1], base_metrics, "S", "(b) RNN, S", "#6b6bff"),
(axes[1, 0], multi_metrics, "P", "(c) Multi-RNN, P", "#ff6b6b"),
(axes[1, 1], multi_metrics, "S", "(d) Multi-RNN, S", "#6b6bff"),
]
for ax, metrics, group, title, color in panels:
row = metric_at(metrics, subset, group, 0.1)
errors = np.array(row["errors_s"], dtype=float)
ax.hist(errors, bins=np.linspace(-2, 2, 81), color=color, alpha=0.82)
ax.axvline(0, color="#2ca6df", ls="--", lw=0.9)
text = (
f"P={row['precision']:.3f}\n"
f"R={row['recall']:.3f}\n"
f"F1={row['f1']:.3f}\n"
f"mean={row['mean_error_s'] * 1000:.1f} ms\n"
f"std={row['std_error_s'] * 1000:.1f} ms"
)
ax.text(0.03, 0.95, text, transform=ax.transAxes, va="top", ha="left", fontsize=8)
ax.set_title(title, loc="left", fontsize=10)
ax.set_xlabel("Error (s)")
ax.set_ylabel("Count")
ax.set_xlim(-2, 2)
ax.grid(True, alpha=0.18)
fig.tight_layout()
fig.savefig(out, bbox_inches="tight")
plt.close(fig)
def select_continuous_event(records: Sequence[Record], min_records: int = 24) -> str:
by_event: Dict[str, List[Record]] = {}
for record in records:
if math.isnan(record.distance_km):
continue
by_event.setdefault(record.event, []).append(record)
candidates = sorted(
((event, recs) for event, recs in by_event.items() if len(recs) >= min_records),
key=lambda item: len(item[1]),
reverse=True,
)
if not candidates:
return max(by_event.items(), key=lambda item: len(item[1]))[0]
return candidates[0][0]
def continuous_specs_for_event(records: Sequence[Record], event: str, length: int) -> List[Tuple[int, int]]:
out = []
for idx, record in enumerate(records):
if record.event != event or record.length < length:
continue
phase_indices = [p.index for p in record.phases]
if not phase_indices:
continue
start = int(np.clip(min(phase_indices) - 800, 0, record.length - length))
out.append((idx, start))
out.sort(key=lambda item: records[item[0]].distance_km)
return out[:60]
def evaluate_continuous(
model: BRNN,
h5_path: Path,
records: Sequence[Record],
specs: Sequence[Tuple[int, int]],
length: int,
device: torch.device,
threshold: float,
) -> Tuple[np.ndarray, List[List[Tuple[str, str, int]]], np.ndarray, Dict]:
sample_specs = [SampleSpec((CropSpec(rec_idx, start, 1.0),), "continuous") for rec_idx, start in specs]
waves, labels_all, _, _ = materialize_samples(h5_path, records, sample_specs, length)
outputs = run_model(model, waves, device, batch_size=32)
metrics = evaluate_outputs(
outputs,
labels_all,
["continuous"] * len(labels_all),
[threshold],
min_sep=50,
tolerance=100,
)
return waves, labels_all, outputs, metrics
def plot_continuous(
records: Sequence[Record],
specs: Sequence[Tuple[int, int]],
waves: np.ndarray,
base_outputs: np.ndarray,
multi_outputs: np.ndarray,
threshold: float,
out: Path,
) -> None:
fig, axes = plt.subplots(1, 2, figsize=(9.2, 7.2), dpi=220, sharex=True, sharey=True)
time = np.arange(waves.shape[1]) * 0.01
for ax, outputs, title in [
(axes[0], base_outputs, "(a) RNN"),
(axes[1], multi_outputs, "(b) Multi-RNN"),
]:
for i, (rec_idx, _) in enumerate(specs):
rec = records[rec_idx]
dist = rec.distance_km if not math.isnan(rec.distance_km) else i
trace = waves[i, :, 2]
trace = trace / (np.max(np.abs(trace)) + 1e-6) * 2.0 + dist
ax.plot(time, trace, color="black", lw=0.35, alpha=0.75)
for group, color in [("P", "#d62728"), ("S", "#1f77b4")]:
prob = outputs[i, GROUP_TO_CHANNELS[group], :].max(axis=0)
peaks = find_peaks(prob, threshold, min_sep=50)
if peaks:
ax.scatter([p[0] * 0.01 for p in peaks], [dist] * len(peaks), s=6, color=color, alpha=0.9)
ax.set_title(title, loc="left", fontsize=10)
ax.set_xlabel("Time (s)")
ax.grid(True, alpha=0.15)
axes[0].set_ylabel("Epicentral distance (km)")
axes[1].scatter([], [], s=12, color="#d62728", label="P")
axes[1].scatter([], [], s=12, color="#1f77b4", label="S")
axes[1].legend(frameon=False, fontsize=8, loc="upper right")
fig.tight_layout()
fig.savefig(out, bbox_inches="tight")
plt.close(fig)
def strip_errors(metrics: Dict) -> Dict:
clean = json.loads(json.dumps(metrics))
for subset in ("all", "double"):
for group in ("P", "S"):
for row in clean[subset][group]:
row["error_count"] = len(row.get("errors_s", []))
row.pop("errors_s", None)
return clean
def summarize_records(records: Sequence[Record]) -> Dict:
source_counts: Dict[str, int] = {}
phase_counts: Dict[str, int] = {}
for record in records:
for pick in record.phases:
source_counts[pick.source] = source_counts.get(pick.source, 0) + 1
phase_counts[pick.phase] = phase_counts.get(pick.phase, 0) + 1
return {
"records": len(records),
"source_counts": source_counts,
"phase_counts": phase_counts,
}
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/repro_seed20260607")
parser.add_argument("--seed", type=int, default=20260607)
parser.add_argument("--length", type=int, default=5120)
parser.add_argument("--padlen", type=int, default=512)
parser.add_argument("--train-steps", type=int, default=400)
parser.add_argument("--train-batch", type=int, default=16)
parser.add_argument("--eval-samples", type=int, default=20000)
parser.add_argument("--eval-batch", type=int, default=64)
parser.add_argument("--max-train-events", type=int, default=8000)
parser.add_argument("--max-test-events", type=int, default=0)
parser.add_argument("--lr", type=float, default=1e-4)
parser.add_argument("--skip-train", action="store_true")
args = parser.parse_args()
out_dir = Path(args.out_dir)
out_dir.mkdir(parents=True, exist_ok=True)
h5_path = Path(args.h5)
key_path = Path(args.keys)
base_ckpt = Path(args.base_ckpt)
multi_ckpt = out_dir / f"pnsn.v3.multirnn.seed{args.seed}.pt"
torch.manual_seed(args.seed)
np.random.seed(args.seed)
random.seed(args.seed)
device = torch.device("mps") if torch.backends.mps.is_available() else torch.device("cpu")
print(f"device={device}", flush=True)
max_test = None if args.max_test_events == 0 else args.max_test_events
train_records = build_records(h5_path, key_path, "train", args.max_train_events)
test_records = build_records(h5_path, key_path, "test", max_test)
print(f"train records={len(train_records)} test records={len(test_records)}", flush=True)
with (out_dir / "record_summary.json").open("w") as f:
json.dump(
{
"train": summarize_records(train_records),
"test": summarize_records(test_records),
"seed": args.seed,
"length_samples": args.length,
"length_seconds": args.length * 0.01,
"label_rule": "MANUAL.TRAVTIME preferred; RNN.TRAVTIME used only when tag=Y.",
},
f,
ensure_ascii=False,
indent=2,
)
if args.skip_train and multi_ckpt.exists():
multi_model = load_model(multi_ckpt, device)
else:
multi_model = train_transfer(
h5_path=h5_path,
records=train_records,
base_ckpt=base_ckpt,
out_ckpt=multi_ckpt,
log_csv=out_dir / "transfer_loss.csv",
seed=args.seed,
steps=args.train_steps,
batch_size=args.train_batch,
length=args.length,
padlen=args.padlen,
lr=args.lr,
device=device,
)
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,
)
waves, labels_all, _, kinds = materialize_samples(h5_path, test_records, eval_specs, args.length)
np.savez_compressed(
out_dir / "eval_sample_summary.npz",
kinds=np.array(kinds),
label_counts=np.array([len(x) for x in labels_all], dtype=np.int16),
)
plot_training_samples(waves[:100], labels_all[:100], out_dir / "fig5_training_samples_composite.png")
thresholds = [round(x, 1) for x in np.arange(0.1, 1.0, 0.1)]
base_model = load_model(base_ckpt, device)
base_outputs = run_model(base_model, waves, device, args.eval_batch)
multi_outputs = run_model(multi_model, waves, device, args.eval_batch)
base_metrics = evaluate_outputs(base_outputs, labels_all, kinds, thresholds, min_sep=50, tolerance=100)
multi_metrics = evaluate_outputs(multi_outputs, labels_all, kinds, thresholds, min_sep=50, tolerance=100)
plot_pr(base_metrics, multi_metrics, out_dir / "fig6_pr_composite.png")
plot_errors(base_metrics, multi_metrics, "all", out_dir / "fig7_error_all_composite.png")
plot_errors(base_metrics, multi_metrics, "double", out_dir / "fig8_error_double_composite.png")
event = select_continuous_event(test_records)
cont_specs = continuous_specs_for_event(test_records, event, min(args.length * 2, 10240))
cont_len = min(args.length * 2, 10240)
if len(cont_specs) >= 4:
cont_base_waves, cont_labels, cont_base_outputs, cont_base_metrics = evaluate_continuous(
base_model, h5_path, test_records, cont_specs, cont_len, device, threshold=0.1
)
_, _, cont_multi_outputs, cont_multi_metrics = evaluate_continuous(
multi_model, h5_path, test_records, cont_specs, cont_len, device, threshold=0.1
)
plot_continuous(
test_records,
cont_specs,
cont_base_waves,
cont_base_outputs,
cont_multi_outputs,
0.1,
out_dir / "fig9_continuous_composite.png",
)
else:
cont_base_metrics = {}
cont_multi_metrics = {}
report = {
"seed": args.seed,
"device": str(device),
"length_samples": args.length,
"length_seconds": args.length * 0.01,
"eval_samples": args.eval_samples,
"eval_single_samples": int(sum(k == "single" for k in kinds)),
"eval_double_samples": int(sum(k == "double" for k in kinds)),
"threshold_for_error_stats": 0.1,
"match_tolerance_seconds": 1.0,
"min_peak_separation_seconds": 0.5,
"base_metrics": strip_errors(base_metrics),
"multi_metrics": strip_errors(multi_metrics),
"continuous_event": event,
"continuous_station_count": len(cont_specs),
"continuous_base_metrics": strip_errors(cont_base_metrics) if cont_base_metrics else {},
"continuous_multi_metrics": strip_errors(cont_multi_metrics) if cont_multi_metrics else {},
"figures": {
"fig5": str((out_dir / "fig5_training_samples_composite.png").resolve()),
"fig6": str((out_dir / "fig6_pr_composite.png").resolve()),
"fig7": str((out_dir / "fig7_error_all_composite.png").resolve()),
"fig8": str((out_dir / "fig8_error_double_composite.png").resolve()),
"fig9": str((out_dir / "fig9_continuous_composite.png").resolve()),
},
}
with (out_dir / "metrics_report.json").open("w") as f:
json.dump(report, f, ensure_ascii=False, indent=2)
print(json.dumps(report, ensure_ascii=False, indent=2)[:4000], flush=True)
if __name__ == "__main__":
main()