snr_bias / code /scripts /grl_bootstrap_ci.py
cangyeone's picture
Upload GRL reproducibility package
7170296 verified
Raw
History Blame Contribute Delete
10.3 kB
#!/usr/bin/env python3
"""Compute paired bootstrap confidence intervals for GRL revision outputs."""
from __future__ import annotations
import argparse
import csv
import gzip
import math
from collections import defaultdict
from pathlib import Path
import numpy as np
DEFAULT_REVISION_DIR = Path("outputs/grl_revision_20260610")
def read_csv(path: Path) -> list[dict[str, str]]:
opener = gzip.open if path.suffix == ".gz" else open
with opener(path, "rt", newline="", encoding="utf-8") as f:
return list(csv.DictReader(f))
def metric_from_counts(tp: np.ndarray, fp: np.ndarray, fn: np.ndarray) -> dict[str, float]:
tp_sum = float(tp.sum())
fp_sum = float(fp.sum())
fn_sum = float(fn.sum())
precision = tp_sum / (tp_sum + fp_sum) if tp_sum + fp_sum else 0.0
recall = tp_sum / (tp_sum + fn_sum) if tp_sum + fn_sum else 0.0
f1 = 2.0 * precision * recall / (precision + recall) if precision + recall else 0.0
return {"precision": precision, "recall": recall, "f1": f1}
def percentile_ci(values: np.ndarray, alpha: float = 0.05) -> tuple[float, float]:
return (float(np.percentile(values, 100 * alpha / 2)), float(np.percentile(values, 100 * (1 - alpha / 2))))
def phase_arrays(rows: list[dict[str, str]]):
by_cond_phase: dict[tuple[str, str], dict[int, tuple[int, int, int]]] = defaultdict(dict)
sample_ids = set()
for row in rows:
sid = int(row["sample_id"])
sample_ids.add(sid)
by_cond_phase[(row["condition"], row["phase"])][sid] = (
int(row["tp"]),
int(row["fp"]),
int(row["fn"]),
)
ordered_ids = np.array(sorted(sample_ids), dtype=int)
out = {}
for key, vals in by_cond_phase.items():
arr = np.array([vals[int(sid)] for sid in ordered_ids], dtype=np.int64)
out[key] = {"tp": arr[:, 0], "fp": arr[:, 1], "fn": arr[:, 2]}
return ordered_ids, out
def bootstrap_phase(input_csv: Path, output_csv: Path, summary_txt: Path, n_bootstrap: int, seed: int) -> None:
rows = read_csv(input_csv)
if not rows:
raise RuntimeError(f"No rows in {input_csv}")
sample_ids, arrays = phase_arrays(rows)
n = len(sample_ids)
rng = np.random.default_rng(seed)
idx = rng.integers(0, n, size=(n_bootstrap, n), endpoint=False)
conditions = ["full", "snr5", "snr10"]
phases = ["P", "S"]
estimates: dict[tuple[str, str], dict[str, float]] = {}
boot: dict[tuple[str, str], dict[str, np.ndarray]] = {}
for cond in conditions:
for phase in phases:
arr = arrays[(cond, phase)]
estimates[(cond, phase)] = metric_from_counts(arr["tp"], arr["fp"], arr["fn"])
boot[(cond, phase)] = {}
for b in range(n_bootstrap):
m = metric_from_counts(arr["tp"][idx[b]], arr["fp"][idx[b]], arr["fn"][idx[b]])
for metric, value in m.items():
boot[(cond, phase)].setdefault(metric, np.empty(n_bootstrap, dtype=float))[b] = value
output_csv.parent.mkdir(parents=True, exist_ok=True)
with output_csv.open("w", newline="", encoding="utf-8") as f:
fieldnames = ["task", "condition", "metric", "estimate", "ci_low", "ci_high", "diff_vs_full", "diff_ci_low", "diff_ci_high"]
writer = csv.DictWriter(f, fieldnames=fieldnames)
writer.writeheader()
for cond in conditions:
for phase in phases:
for metric in ["precision", "recall", "f1"]:
values = boot[(cond, phase)][metric]
lo, hi = percentile_ci(values)
full_est = estimates[("full", phase)][metric]
diff = estimates[(cond, phase)][metric] - full_est
if cond == "full":
dlo = dhi = 0.0
else:
diff_values = values - boot[("full", phase)][metric]
dlo, dhi = percentile_ci(diff_values)
writer.writerow(
{
"task": "phase_picking",
"condition": cond,
"metric": f"{phase}_{metric}",
"estimate": estimates[(cond, phase)][metric],
"ci_low": lo,
"ci_high": hi,
"diff_vs_full": diff,
"diff_ci_low": dlo,
"diff_ci_high": dhi,
}
)
mean_est = 0.5 * (estimates[(cond, "P")]["f1"] + estimates[(cond, "S")]["f1"])
mean_boot = 0.5 * (boot[(cond, "P")]["f1"] + boot[(cond, "S")]["f1"])
lo, hi = percentile_ci(mean_boot)
diff_values = mean_boot - 0.5 * (boot[("full", "P")]["f1"] + boot[("full", "S")]["f1"])
dlo, dhi = (0.0, 0.0) if cond == "full" else percentile_ci(diff_values)
writer.writerow(
{
"task": "phase_picking",
"condition": cond,
"metric": "mean_f1",
"estimate": mean_est,
"ci_low": lo,
"ci_high": hi,
"diff_vs_full": mean_est - 0.5 * (estimates[("full", "P")]["f1"] + estimates[("full", "S")]["f1"]),
"diff_ci_low": dlo,
"diff_ci_high": dhi,
}
)
summary_txt.parent.mkdir(parents=True, exist_ok=True)
summary_txt.write_text(
f"Phase-picking paired bootstrap: {n_bootstrap} resamples over {n} deterministic test windows. "
f"Corpus-level TP/FP/FN were recomputed for each resample; per-window F1 was not averaged.\n",
encoding="utf-8",
)
def dispersion_arrays(rows: list[dict[str, str]]):
by_cond: dict[str, dict[int, tuple[float, float, float]]] = defaultdict(dict)
sample_ids = set()
for row in rows:
sid = int(row["sample_id"])
sample_ids.add(sid)
by_cond[row["condition"]][sid] = (
float(row["abs_error_sum"]),
float(row["squared_error_sum"]),
float(row["valid_period_count"]),
)
ordered_ids = np.array(sorted(sample_ids), dtype=int)
out = {}
for cond, vals in by_cond.items():
arr = np.array([vals[int(sid)] for sid in ordered_ids], dtype=float)
out[cond] = {"abs": arr[:, 0], "sq": arr[:, 1], "n": arr[:, 2]}
return ordered_ids, out
def disp_metrics(abs_sum: np.ndarray, sq_sum: np.ndarray, valid_n: np.ndarray) -> dict[str, float]:
denom = float(valid_n.sum())
mae = float(abs_sum.sum() / denom) if denom else 0.0
rmse = float(math.sqrt(sq_sum.sum() / denom)) if denom else 0.0
return {"mae": mae, "rmse": rmse}
def bootstrap_dispersion(input_csv: Path, output_csv: Path, summary_txt: Path, n_bootstrap: int, seed: int) -> None:
rows = read_csv(input_csv)
if not rows:
raise RuntimeError(f"No rows in {input_csv}")
sample_ids, arrays = dispersion_arrays(rows)
n = len(sample_ids)
rng = np.random.default_rng(seed)
idx = rng.integers(0, n, size=(n_bootstrap, n), endpoint=False)
conditions = ["full", "snr_q1", "snr_q2"]
estimates = {cond: disp_metrics(arrays[cond]["abs"], arrays[cond]["sq"], arrays[cond]["n"]) for cond in conditions}
boot = {cond: {"mae": np.empty(n_bootstrap), "rmse": np.empty(n_bootstrap)} for cond in conditions}
for cond in conditions:
arr = arrays[cond]
for b in range(n_bootstrap):
m = disp_metrics(arr["abs"][idx[b]], arr["sq"][idx[b]], arr["n"][idx[b]])
boot[cond]["mae"][b] = m["mae"]
boot[cond]["rmse"][b] = m["rmse"]
output_csv.parent.mkdir(parents=True, exist_ok=True)
with output_csv.open("w", newline="", encoding="utf-8") as f:
writer = csv.DictWriter(f, fieldnames=["task", "condition", "metric", "estimate", "ci_low", "ci_high", "diff_vs_full", "diff_ci_low", "diff_ci_high"])
writer.writeheader()
for cond in conditions:
for metric in ["mae", "rmse"]:
lo, hi = percentile_ci(boot[cond][metric])
diff = estimates[cond][metric] - estimates["full"][metric]
if cond == "full":
dlo = dhi = 0.0
else:
dlo, dhi = percentile_ci(boot[cond][metric] - boot["full"][metric])
writer.writerow(
{
"task": "dispersion",
"condition": cond,
"metric": metric,
"estimate": estimates[cond][metric],
"ci_low": lo,
"ci_high": hi,
"diff_vs_full": diff,
"diff_ci_low": dlo,
"diff_ci_high": dhi,
}
)
summary_txt.parent.mkdir(parents=True, exist_ok=True)
summary_txt.write_text(f"Dispersion paired bootstrap: {n_bootstrap} resamples over {n} test samples.\n", encoding="utf-8")
def main() -> None:
parser = argparse.ArgumentParser(description=__doc__)
parser.add_argument("--revision-dir", type=Path, default=DEFAULT_REVISION_DIR)
parser.add_argument("--n-bootstrap", type=int, default=10000)
parser.add_argument("--seed", type=int, default=20260609)
args = parser.parse_args()
tables = args.revision_dir / "tables"
bootstrap_phase(
args.revision_dir / "phase_picking" / "phase_per_window_outputs.csv.gz",
tables / "phase_bootstrap_ci.csv",
tables / "phase_bootstrap_summary.txt",
args.n_bootstrap,
args.seed,
)
bootstrap_dispersion(
args.revision_dir / "dispersion" / "dispersion_per_sample_metrics.csv.gz",
tables / "dispersion_bootstrap_ci.csv",
tables / "dispersion_bootstrap_summary.txt",
args.n_bootstrap,
args.seed,
)
(tables / "bootstrap_summary_for_manuscript.txt").write_text(
(tables / "phase_bootstrap_summary.txt").read_text(encoding="utf-8")
+ (tables / "dispersion_bootstrap_summary.txt").read_text(encoding="utf-8"),
encoding="utf-8",
)
if __name__ == "__main__":
main()