| """Shared statistical helpers for the retrieval-evaluation scripts. | |
| ``rand_ap`` is the average precision of a *uniformly random* ranking of ``N`` | |
| items with ``R`` relevant — the honest chance baseline for AP (whose expectation | |
| is ~prevalence, not zero). ``scripts/patch_eval`` and ``scripts/significance_audit`` | |
| each carried a byte-identical copy of this; this module is the single source of | |
| truth so the permutation baseline can never drift between them. | |
| NOTE: ``scripts/cv_eval`` deliberately keeps its own permutation routine — it | |
| draws via ``rng.permutation`` rather than ``rng.shuffle``, so swapping it for | |
| this helper would change its RNG draw sequence and perturb the already-committed | |
| ``cv_eval__*.json`` results. It is intentionally left separate. | |
| """ | |
| from __future__ import annotations | |
| import numpy as np | |
| def rand_ap(R: int, N: int, rng) -> float: | |
| """AP of a uniformly random ranking of ``N`` items with ``R`` relevant. | |
| ``rng`` is a ``numpy`` Generator/RandomState; one ``rng.shuffle`` call is | |
| consumed per invocation (callers rely on this for reproducible draws). | |
| """ | |
| rel = np.zeros(N, dtype=bool) | |
| rel[:R] = True | |
| rng.shuffle(rel) | |
| hits = np.cumsum(rel) | |
| return float((hits / np.arange(1, N + 1))[rel].sum() / R) | |
| def rank_order(scores: np.ndarray, rel: np.ndarray) -> np.ndarray: | |
| """Indices that rank ``scores`` descending, with deterministic, *pessimistic* | |
| tie-breaking: among equal scores a non-relevant item is ranked above a | |
| relevant one (so ties never inflate AP), and the order is fully reproducible. | |
| Implemented as a stable ``lexsort`` with ``-scores`` as the primary key and the | |
| relevance flag as the secondary key (0 = non-relevant sorts first within a tie). | |
| Use this everywhere AP / Recall@K is computed so results never depend on the | |
| undefined ordering of ``np.argsort`` over tied (e.g. bootstrap-duplicated) | |
| scores. | |
| """ | |
| scores = np.asarray(scores, dtype=np.float64) | |
| rel = np.asarray(rel) | |
| return np.lexsort((rel.astype(np.int8), -scores)) | |
| def perm_p_value(n_ge: int, iters: int) -> float: | |
| """Unbiased one-sided Monte-Carlo permutation p-value. | |
| ``n_ge`` = number of null draws with statistic >= observed. The observed | |
| statistic is itself one realisation of the null, so it must be counted: | |
| ``(n_ge + 1) / (iters + 1)``. This can never return an impossible ``0.0``. | |
| """ | |
| return (int(n_ge) + 1) / (int(iters) + 1) | |
| def aoi_folds(aois, n_folds: int, seed: int) -> dict: | |
| """Deterministic AOI -> fold assignment for leave-one-group-out CV. | |
| Round-robin over a seeded permutation of the *sorted* AOI ids. Shared by | |
| ``scripts.cv_eval`` and ``scripts.patch_eval`` so their k-fold partitions are | |
| provably identical (same seed -> same partition), making their cross-validated | |
| mAPs directly comparable. Folds are disjoint and cover every AOI. | |
| """ | |
| rng = np.random.default_rng(seed) | |
| perm = list(rng.permutation(sorted(aois))) | |
| return {a: i % n_folds for i, a in enumerate(perm)} | |
| def bh_fdr(pvals) -> np.ndarray: | |
| """Benjamini-Hochberg FDR-adjusted q-values for ``pvals``. | |
| Returns q-values aligned to the input order. The raw ``p * m / rank`` is not | |
| monotone in rank, so the standard step-up enforces monotonicity by taking the | |
| running minimum from the largest p downward, then caps at 1. | |
| """ | |
| p = np.asarray(pvals, dtype=np.float64) | |
| m = p.size | |
| if m == 0: | |
| return np.empty(0) | |
| order = np.argsort(p) | |
| ranked = p[order] * m / np.arange(1, m + 1) | |
| ranked = np.minimum.accumulate(ranked[::-1])[::-1] | |
| out = np.empty(m) | |
| out[order] = np.clip(ranked, 0.0, 1.0) | |
| return out | |