Code / src/anomaly_atlas/stats/multiple_testing.py
src/anomaly_atlas/stats/multiple_testing.py
69 lines
# =============================================================================
# Project : anomaly-atlas
# File : src/anomaly_atlas/stats/multiple_testing.py
# Purpose : FDR/Bonferroni corrections (White RC / SPA / DSR arrive in expF)
# Author : Simon-Pierre Boucher
# Contact : contact@spboucher.ai
# Data src : hfmarketdata.io (sole data source)
# Created : 2026-08-12
# Modified : 2026-08-12
# Platform : macOS / Apple Silicon (arm64)
# License : All rights reserved (research code)
# =============================================================================
"""Multiple-testing corrections for the scan stage (Level-0 triage).
Scans use Benjamini–Hochberg FDR (the right error rate when a controlled
fraction of false leads into the next stage is acceptable). Level-1
promotion uses SPA/StepM against artifact nulls — implemented with expF.
Validated on synthetic ground truth (§8.1 gate).
"""
from __future__ import annotations
import numpy as np
def benjamini_hochberg(pvals: np.ndarray, alpha: float = 0.05) -> np.ndarray:
"""BH step-up FDR procedure. Returns a boolean rejection mask.
NaN p-values are never rejected but still COUNT toward m (conservative:
an unevaluable test is a spent test, not a free one).
"""
p = np.asarray(pvals, dtype=float)
m = len(p)
if m == 0:
return np.zeros(0, dtype=bool)
finite = np.where(np.isfinite(p))[0]
reject = np.zeros(m, dtype=bool)
if len(finite) == 0:
return reject
order = finite[np.argsort(p[finite])]
thresholds = alpha * (np.arange(1, len(order) + 1) / m)
passed = np.where(p[order] <= thresholds)[0]
if len(passed):
reject[order[: passed.max() + 1]] = True
return reject
def bonferroni(pvals: np.ndarray, alpha: float = 0.05) -> np.ndarray:
"""Bonferroni FWE mask (reported alongside FDR for reference)."""
p = np.asarray(pvals, dtype=float)
return np.isfinite(p) & (p <= alpha / max(len(p), 1))
def bootstrap_pvalue(samples: np.ndarray, null_value: float = 0.0) -> float:
"""Two-sided percentile-bootstrap p-value of a statistic vs a null value.
p = 2 * min(P(boot <= null), P(boot >= null)), with the +1/(B+1)
correction so p is never exactly 0. Triage-grade inference for scans —
Level-1 promotion re-tests with SPA machinery.
"""
s = np.asarray(samples, dtype=float)
s = s[np.isfinite(s)]
b = len(s)
if b < 50:
return float("nan")
lo = (np.sum(s <= null_value) + 1) / (b + 1)
hi = (np.sum(s >= null_value) + 1) / (b + 1)
return float(min(1.0, 2.0 * min(lo, hi)))