"""Transfer test on the GSE65858 independent HPV cohort. Score a WINNER's revealed gene symbols on GSE65858 (an INDEPENDENT HPV validation cohort from GEO; Illumina HumanHT-12 v4 microarray) and return the orientation-agnostic AUROC + a permutation p-value. Airgap discipline ----------------- GSE65858 is NAMED-SIDE ONLY. It lives in `validate/`, is keyed by real gene symbols, is NEVER anonymised, and MUST NEVER be passed to `engine`/`engine_v2`. The only thing that crosses from the blind side to this cohort is the winning program's REVEALED gene symbols (the bounded reveal `/evaluate` already uses). This module never sends any gene NAMES back to the blind side. The function is pure and testable — no network, no engine import. Tests use a synthetic GSE65858-shaped fixture. """ from __future__ import annotations import math from dataclasses import dataclass from pathlib import Path from typing import Sequence import numpy as np import pandas as pd from data_pipeline import schema # --------------------------------------------------------------------------- # Cohort loading (cached per process — the cohort doesn't change between # calls). Lazy so importing this module is cheap. # --------------------------------------------------------------------------- _COHORT_CACHE: dict = {} @dataclass class _Cohort: expression: pd.DataFrame # symbols × samples hpv: pd.Series # per-sample, values in {"HPV+", "HPV-"} def _load_cohort(processed_dir: Path | None = None) -> _Cohort: """Load the processed GSE65858 cohort. Uses ``schema.GSE65858_PROCESSED_DIR`` by default. Raises FileNotFoundError with a clear message if the parquets aren't built yet.""" pd_dir = Path(processed_dir) if processed_dir else schema.GSE65858_PROCESSED_DIR key = str(pd_dir.resolve()) if key in _COHORT_CACHE: return _COHORT_CACHE[key] expr_path = pd_dir / "expression.parquet" clin_path = pd_dir / "clinical.parquet" if not expr_path.exists() or not clin_path.exists(): raise FileNotFoundError( f"GSE65858 processed parquets not found in {pd_dir}. " "Run: python -m data_pipeline.download_gse65858 && " "python -m data_pipeline.build_gse65858" ) expr = pd.read_parquet(expr_path) clin = pd.read_parquet(clin_path) if "sample_id" not in clin.columns: raise ValueError( f"{clin_path.name} missing 'sample_id' column." ) hpv = ( clin.set_index("sample_id")["hpv_status"] .reindex(expr.columns) ) coh = _Cohort(expression=expr, hpv=hpv) _COHORT_CACHE[key] = coh return coh # --------------------------------------------------------------------------- # Orientation-agnostic AUROC via the rank-sum formula. Same shape as # validate/hpv_rank._auroc_per_column, but for a single 1-D score. # --------------------------------------------------------------------------- def _omni_auroc(score: np.ndarray, y: np.ndarray) -> float: """orientation-agnostic AUROC of a 1-D score vs a binary label. Returns 0.5 for degenerate inputs (no variance, single class). """ if score.size == 0 or y.size == 0 or score.size != y.size: return 0.5 finite = np.isfinite(score) & np.isfinite(y) if int(finite.sum()) < 2: return 0.5 s = score[finite].astype(float) yy = y[finite].astype(int) n_pos = int((yy == 1).sum()) n_neg = int((yy == 0).sum()) if n_pos == 0 or n_neg == 0: return 0.5 ranks = pd.Series(s).rank(method="average").to_numpy() S_pos = ranks[yy == 1].sum() auroc = (S_pos - n_pos * (n_pos + 1) / 2.0) / (n_pos * n_neg) omni = max(auroc, 1.0 - auroc) if not np.isfinite(omni): return 0.5 return float(omni) def _finite_or_none(x: float) -> float | None: if x is None: return None try: f = float(x) except (TypeError, ValueError): return None return f if math.isfinite(f) else None # --------------------------------------------------------------------------- # Public API. # --------------------------------------------------------------------------- def transfer_score( symbols: Sequence[str], *, n_permutations: int = 1000, seed: int = 0, processed_dir: Path | None = None, ) -> dict: """Score the winner's gene symbols on GSE65858. Aggregation matches the module-ranking "Combined AUROC" (per-patient mean of the found genes) BUT standardises each found gene within GSE65858 first — the cross-platform fix so TCGA RNA-seq scale vs Illumina array intensity doesn't drown the signal. Parameters ---------- symbols : Sequence[str] Winner's revealed gene symbols. This is the ONLY input from the blind side. n_permutations : int, default 1000 How many label shuffles form the null distribution. seed : int, default 0 RNG seed for the permutation null. processed_dir : Path, optional Override the cohort location (used by tests). Returns ------- dict ``{ auroc, p, n, n_pos, n_neg, n_found, n_missing, found_symbols, missing_symbols, }``. All numeric fields are finite-guarded (``None`` if non-finite / not computable). Symbols are the ONLY gene NAMES in the payload — same discipline as ``/evaluate``. """ coh = _load_cohort(processed_dir) dedup_symbols = list(dict.fromkeys(str(s) for s in symbols if s)) found = [s for s in dedup_symbols if s in coh.expression.index] missing = [s for s in dedup_symbols if s not in coh.expression.index] # Restrict to called-HPV samples with complete expression for the # found genes. called = coh.hpv.isin([ schema.GSE65858_HPV_POS_LABEL, schema.GSE65858_HPV_NEG_LABEL, ]) kept_samples = coh.expression.columns[called.fillna(False).values] if len(found) == 0 or len(kept_samples) == 0: return { "auroc": None, "p": None, "n": int(len(kept_samples)), "n_pos": int((coh.hpv.reindex(kept_samples) == schema.GSE65858_HPV_POS_LABEL).sum()), "n_neg": int((coh.hpv.reindex(kept_samples) == schema.GSE65858_HPV_NEG_LABEL).sum()), "n_found": int(len(found)), "n_missing": int(len(missing)), "found_symbols": found, "missing_symbols": missing, } sub = coh.expression.loc[found, kept_samples] # Drop samples with any NaN across the found genes (rare, defensive). keep_mask = ~sub.isna().any(axis=0) kept_samples = kept_samples[keep_mask.values] sub = sub.loc[:, kept_samples] if sub.shape[1] < 2: return { "auroc": None, "p": None, "n": int(sub.shape[1]), "n_pos": 0, "n_neg": 0, "n_found": int(len(found)), "n_missing": int(len(missing)), "found_symbols": found, "missing_symbols": missing, } # Standardise each found gene ACROSS the kept patients (z-score). # Cross-platform fix: TCGA RNA-seq and Illumina array intensity live # on different absolute scales; z-scoring per gene removes the # scale mismatch so the per-patient mean is comparable. mu = sub.mean(axis=1).to_numpy() sd = sub.std(axis=1, ddof=0).to_numpy() sd_safe = np.where(sd > 0, sd, 1.0) z = (sub.to_numpy() - mu[:, None]) / sd_safe[:, None] # Per-patient mean across the standardised found genes. score = z.mean(axis=0) y_ser = coh.hpv.reindex(kept_samples) y = (y_ser == schema.GSE65858_HPV_POS_LABEL).astype(int).to_numpy() n = int(len(y)) n_pos = int((y == 1).sum()) n_neg = int((y == 0).sum()) if n_pos == 0 or n_neg == 0: return { "auroc": None, "p": None, "n": n, "n_pos": n_pos, "n_neg": n_neg, "n_found": int(len(found)), "n_missing": int(len(missing)), "found_symbols": found, "missing_symbols": missing, } auroc = _omni_auroc(score, y) # Permutation null: shuffle y, recompute AUROC on the SAME score. rng = np.random.default_rng(int(seed)) hits = 0 n_perm = max(1, int(n_permutations)) y_shuf = y.copy() for _ in range(n_perm): rng.shuffle(y_shuf) null_auroc = _omni_auroc(score, y_shuf) if null_auroc >= auroc: hits += 1 # Standard permutation-p with a +1 numerator + denominator to avoid # p = 0 with finite null samples. p = (hits + 1) / (n_perm + 1) return { "auroc": _finite_or_none(auroc), "p": _finite_or_none(p), "n": n, "n_pos": n_pos, "n_neg": n_neg, "n_found": int(len(found)), "n_missing": int(len(missing)), "found_symbols": found, "missing_symbols": missing, }