File size: 11,252 Bytes
0fff343
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
"""Multi-seed HNSC/HPV stability report.

Runs the same HNSC/HPV genetic-programming pipeline the Lab launches
across a small grid of seeds, then reports (a) per-seed held-out AUROC
+ permutation p + GSE65858 transfer AUROC + p + gene coverage, (b) a
gene-recurrence tally across the winners with each symbol tagged by
its HNSC reference set (p16 / cell_cycle), and (c) held-out and
transfer AUROC ranges across seeds.

Read-only orchestration; no engine / API / airgap change. `scripts/`
is biology-aware β€” the same layer that owns `run_h2.py` β€” so this
file is allowed to import `airgap.reveal` and the named-side
validation helpers.

Airgap discipline
-----------------
Only each seed's WINNER's own opaque IDs are ever revealed (via the
bounded ``airgap.reveal`` β€” the same call ``/evaluate`` uses). The
sealed map is never dumped and the GSE65858 gene list never crosses
back into the engine. GSE65858 lives on the named (reveal) side; the
engine only sees TCGA HNSC via the anonymised matrix.

Usage
-----
    python -m scripts.multiseed_hpv
    python -m scripts.multiseed_hpv --seeds 1 3 7
    python -m scripts.multiseed_hpv --generations 30 --population 300
"""

from __future__ import annotations

import argparse
import sys
import time
from typing import Sequence

from airgap import reveal
from api.app import (
    COHERENCE_DEFAULT_WEIGHT,
    REFERENCE_SETS_BY_DATASET,
    _prepare_lab_data,
)
from engine_v2 import run_v2_pipeline
from engine_v2.fitness import objective_from_spec as v2_objective_from_spec
from validate.transfer_gse65858 import transfer_score


DEFAULT_SEEDS = [1, 3, 7, 11, 13, 17, 23, 29]

# Same numbers the Lab writes into RunParamsModel via DEFAULT_PARAMS.
DEFAULT_GENERATIONS = 60
DEFAULT_POPULATION = 300
DEFAULT_GENES_PER_SET = 8
DEFAULT_LAMBDA = 0.005
DEFAULT_PERMUTATIONS = 200
DEFAULT_PREFILTER_N: int | None = None  # Match the Lab's "off" default.


def _tag_reference(sym: str) -> str:
    """Return the HNSC reference-set name that contains ``sym``, or ``-``.
    ``p16`` wins over ``cell_cycle`` if a symbol appears in both.
    """
    hnsc = REFERENCE_SETS_BY_DATASET.get("hnsc", {})
    for name in ("p16", "cell_cycle"):
        if sym in hnsc.get(name, ()):
            return name
    return "-"


def _fmt(v: float | None, digits: int = 3) -> str:
    if v is None:
        return "β€”"
    try:
        f = float(v)
    except (TypeError, ValueError):
        return "β€”"
    return f"{f:.{digits}f}" if f == f and f not in (float("inf"), float("-inf")) else "β€”"


def _run_one(
    M, y, clinical, extra_labels,
    *,
    seed: int,
    generations: int,
    population: int,
    genes_per_set: int,
    lambda_size: float,
    permutations: int,
    prefilter_n: int | None,
    coherence_weight: float,
    confounders: tuple[str, ...],
    transfer_perms: int,
) -> dict:
    """Run the pipeline for one seed and score the winner on GSE65858.
    Returns a summary dict."""
    objective_v2 = v2_objective_from_spec({"target": "hpv", "metric": "auroc"})
    t0 = time.time()
    _log, result = run_v2_pipeline(
        M, y,
        objective=objective_v2,
        seed=seed,
        prefilter_n=prefilter_n,
        population_size=population,
        n_generations=generations,
        n_permutations=permutations,
        lambda_size=lambda_size,
        max_genes_per_set=genes_per_set,
        clinical=clinical,
        extra_labels=extra_labels,
        coherence_weight=coherence_weight,
        confounders=confounders,
    )
    elapsed = time.time() - t0

    winning = result.get("winning", {}) or {}
    gene_ids: list[str] = list(winning.get("gene_ids") or [])
    # Bounded reveal β€” same discipline as /evaluate. Only this seed's
    # winner's opaque IDs are ever translated; the sealed map is
    # never dumped.
    symbols: list[str] = reveal(gene_ids) if gene_ids else []
    symbols = [s for s in symbols if isinstance(s, str) and s]

    if symbols:
        try:
            transfer = transfer_score(
                symbols, n_permutations=transfer_perms, seed=seed,
            )
        except FileNotFoundError:
            transfer = None
    else:
        transfer = None

    return {
        "seed": seed,
        "elapsed_s": round(elapsed, 1),
        "holdout": winning.get("holdout_score"),
        "permutation_p": winning.get("permutation_p"),
        "gene_ids": gene_ids,
        "symbols": symbols,
        "transfer": transfer,
    }


def main(argv: Sequence[str] | None = None) -> int:
    ap = argparse.ArgumentParser(
        description=(
            "Run HNSC/HPV across several seeds and report per-seed stability "
            "+ gene recurrence. Read-only; no engine / API / airgap change."
        ),
    )
    ap.add_argument(
        "--seeds", type=int, nargs="+", default=None,
        help=f"Seeds to run (default {DEFAULT_SEEDS}).",
    )
    ap.add_argument("--generations", type=int, default=DEFAULT_GENERATIONS)
    ap.add_argument("--population", type=int, default=DEFAULT_POPULATION)
    ap.add_argument("--genes-per-set", type=int, default=DEFAULT_GENES_PER_SET)
    ap.add_argument("--lambda-size", type=float, default=DEFAULT_LAMBDA)
    ap.add_argument("--permutations", type=int, default=DEFAULT_PERMUTATIONS)
    ap.add_argument(
        "--prefilter-n", type=int, default=DEFAULT_PREFILTER_N,
        help=(
            "Prefilter top-N (default: off, matches the Lab). Pass an int to "
            "narrow the initial pool."
        ),
    )
    ap.add_argument(
        "--transfer-perms", type=int, default=500,
        help="Permutation-null size for the GSE65858 transfer AUROC.",
    )
    ap.add_argument(
        "--no-coherence", action="store_true",
        help="Skip the coherence prior (default: on, matches the Lab).",
    )
    args = ap.parse_args(argv or sys.argv[1:])

    seeds = list(args.seeds) if args.seeds else list(DEFAULT_SEEDS)

    coherence_weight = 0.0 if args.no_coherence else COHERENCE_DEFAULT_WEIGHT

    print("=" * 78)
    print("Multi-seed HNSC/HPV stability report")
    print("=" * 78)
    print(f"Seeds              : {seeds}")
    print(f"Generations Γ— pop  : {args.generations} Γ— {args.population}")
    print(f"Prefilter top-N    : {args.prefilter_n if args.prefilter_n else 'off (all genes)'}")
    print(f"Coherence prior    : {'ON' if coherence_weight > 0 else 'OFF'} (weight {coherence_weight})")
    print(f"Permutations (GP)  : {args.permutations}")
    print(f"Permutations (xfer): {args.transfer_perms}")
    print(
        "Airgap             : only each winner's own genes are revealed "
        "(bounded reveal via airgap.reveal β€” same discipline as /evaluate)."
    )
    print(
        "                     The sealed map is never dumped; the GSE65858 "
        "gene list never crosses back into the engine."
    )
    print("=" * 78)

    # Load HNSC HPV cohort ONCE and reuse across seeds β€” the anonymised
    # M / y / clinical / extra_labels are seed-independent.
    print("\nLoading HNSC HPV cohort (anonymised matrix + clinical) …")
    M, y, clinical, extra_labels = _prepare_lab_data("hpv", "hnsc")
    print(f"  M: {M.shape[0]} patients Γ— {M.shape[1]} opaque columns")
    print(f"  y: HPV+ {int((y == 1).sum())} Β· HPVβˆ’ {int((y == 0).sum())}")
    if clinical is not None:
        cols = list(clinical.columns)
        print(f"  clinical columns: {cols}")
        # Match the API worker's HNSC confounder set.
        extras = [c for c in ("sex", "race") if c in cols]
        confounders = ("stage", "age", *extras)
    else:
        confounders = ("stage", "age")
    print(f"  Effect confounders: {confounders}")

    # ----- Per-seed runs -----------------------------------------------------
    rows: list[dict] = []
    for i, seed in enumerate(seeds, start=1):
        print(f"\n[{i}/{len(seeds)}] seed={seed} β€” running …", flush=True)
        row = _run_one(
            M, y, clinical, extra_labels,
            seed=seed,
            generations=args.generations,
            population=args.population,
            genes_per_set=args.genes_per_set,
            lambda_size=args.lambda_size,
            permutations=args.permutations,
            prefilter_n=args.prefilter_n,
            coherence_weight=coherence_weight,
            confounders=confounders,
            transfer_perms=args.transfer_perms,
        )
        symbols_short = ", ".join(row["symbols"][:6])
        if len(row["symbols"]) > 6:
            symbols_short += f", … (+{len(row['symbols']) - 6})"
        print(
            f"       held-out AUROC={_fmt(row['holdout'])}  "
            f"p={_fmt(row['permutation_p'])}  "
            f"elapsed={row['elapsed_s']:.1f}s  "
            f"winner=[{symbols_short}]"
        )
        rows.append(row)

    # ----- Per-seed table ----------------------------------------------------
    print("\n" + "-" * 78)
    print("PER-SEED SUMMARY")
    print("-" * 78)
    header = (
        f"{'seed':>5}  {'held-out':>9}  {'GP p':>6}  "
        f"{'xfer AUROC':>10}  {'xfer p':>7}  {'genes found':>12}"
    )
    print(header)
    print("-" * len(header))
    for r in rows:
        t = r["transfer"] or {}
        found = int(t.get("n_found", 0)) if t else 0
        total_req = (
            int(t.get("n_found", 0)) + int(t.get("n_missing", 0)) if t else 0
        )
        if total_req == 0:
            total_req = len(r["symbols"])
        print(
            f"{r['seed']:>5}  {_fmt(r['holdout']):>9}  "
            f"{_fmt(r['permutation_p']):>6}  "
            f"{_fmt(t.get('auroc') if t else None):>10}  "
            f"{_fmt(t.get('p') if t else None):>7}  "
            f"{found:>4} / {total_req:<5}"
        )

    # ----- Range summary -----------------------------------------------------
    def _range(vs: list[float | None]) -> str:
        ok = [v for v in vs if v is not None]
        if not ok:
            return "β€”"
        return f"{min(ok):.3f}–{max(ok):.3f}"

    holdouts = [r["holdout"] for r in rows]
    xfer_aurocs = [
        (r["transfer"] or {}).get("auroc") if r["transfer"] else None
        for r in rows
    ]
    print("\n" + "-" * 78)
    print("RANGES ACROSS SEEDS")
    print("-" * 78)
    print(f"  held-out AUROC : {_range(holdouts)}")
    print(f"  transfer AUROC : {_range(xfer_aurocs)}")

    # ----- Gene recurrence tally --------------------------------------------
    tally: dict[str, int] = {}
    for r in rows:
        for sym in dict.fromkeys(r["symbols"]):  # dedup within a winner
            tally[sym] = tally.get(sym, 0) + 1
    print("\n" + "-" * 78)
    print("GENE RECURRENCE ACROSS WINNERS")
    print(f"  ({len(seeds)} seeds; symbols listed by seeds they appear in, desc)")
    print("-" * 78)
    if not tally:
        print("  (no winners had revealed symbols β€” nothing to tally)")
    else:
        header = f"  {'symbol':<14} {'seeds':>6}  {'reference':<11}"
        print(header)
        for sym, count in sorted(
            tally.items(),
            key=lambda kv: (-kv[1], kv[0]),
        ):
            tag = _tag_reference(sym)
            print(f"  {sym:<14} {count:>4} / {len(seeds):<3} {tag:<11}")

    print("\n" + "=" * 78)
    print("Done.")
    return 0


if __name__ == "__main__":
    sys.exit(main())