| |
| """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() |
|
|