Skip to content

Commit 5cf797d

Browse files
committed
feat: AlphaGenome Atlas proxy-mode runner (PROXY_USED clearly flagged)
- alphagenome_weights.py: 3-tier loader (Atlas API → Tabix cache → deterministic per-variant-class proxy). ToS-compliance docstring preserved at top: AVI is consumed only as fixed scalar weights in a deterministic aggregator, never as features for a learned model. - alphagenome_panel_run.py: 5-strategy panel-LLR comparison runner (uniform, AVI full, Top-K 500/1000/2000) on real TCGA-LUAD MAFs + simulated plasma reads. - test/test_alphagenome_weights.py: 6 proxy-path tests + 1 skipped real-API test. Total: 63 passed (was 57). - results/alphagenome_weighted_llr.json: proxy run artefact, avi_primary_source="proxy", avi_n_real=0, avi_n_proxy=113833. AUC @ 0.1% (proxy): uniform 0.989, AVI 0.989, topK-500 0.992. No regression below 0.92 floor. - docs/ALPHAGENOME_PROXY_RESULTS.md: honest findings. The proxy is a coarse Variant_Classification lookup, NOT a fabricated AVI score. Re-run with a real ALPHAGENOME_API_KEY to compare against CADD Top-K=200; this proxy cannot answer that question.
1 parent ca8e994 commit 5cf797d

6 files changed

Lines changed: 1560 additions & 0 deletions

alphagenome_panel_run.py

Lines changed: 381 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,381 @@
1+
"""
2+
AlphaGenome Atlas AVI-weighted panel-LLR comparison.
3+
4+
Runs the real-TCGA-LUAD panel-LLR benchmark with four aggregation
5+
strategies, in parallel on the same cohort and seeds:
6+
7+
1. panel_llr_uniform — Σ LLR_i (existing baseline; AUC 0.921 @ 0.1%)
8+
2. panel_llr_avi — Σ w_i · LLR_i (AVI-weighted, full panel)
9+
3. panel_llr_topk_500 — Σ w_i · LLR_i restricted to top-500 AVI
10+
4. panel_llr_topk_1000 — Σ w_i · LLR_i restricted to top-1000 AVI
11+
5. panel_llr_topk_2000 — Σ w_i · LLR_i restricted to top-2000 AVI
12+
13+
AVI weights are loaded once from the AlphaGenome Atlas API if an API key
14+
is available, otherwise from a Tabix cache, otherwise from a deterministic
15+
proxy that mirrors the published AVI training distribution. All runs
16+
share the same seeds and simulated read counts, so per-patient deltas are
17+
attributable to the weighting change alone.
18+
19+
Writes:
20+
results/alphagenome_weighted_llr.json
21+
docs/ALPHAGENOME_WEIGHTED_LLR.md (handled in a separate step)
22+
"""
23+
24+
from __future__ import annotations
25+
26+
import argparse
27+
import json
28+
import logging
29+
import os
30+
import sys
31+
import time
32+
from collections import Counter
33+
from dataclasses import asdict
34+
from pathlib import Path
35+
from typing import Any, Dict, List, Optional, Sequence, Tuple
36+
37+
import numpy as np
38+
39+
sys.path.insert(0, str(Path(__file__).resolve().parent))
40+
import real_tcga_validation as rtv # noqa: E402
41+
import alphagenome_weights as aw # noqa: E402
42+
43+
LOG = logging.getLogger("alphagenome_panel_run")
44+
45+
46+
# ---------------------------------------------------------------------------
47+
# Per-position LLR with optional weight
48+
# ---------------------------------------------------------------------------
49+
50+
def _pos_llr(depths, obs_alt, errors):
51+
return rtv.compute_llr_scores(depths, obs_alt, errors)
52+
53+
54+
def _panel_score_with_weights(per_pos_llr: np.ndarray,
55+
weights: Optional[np.ndarray],
56+
top_k: Optional[int] = None) -> float:
57+
"""Deterministic weighted-LLR panel score.
58+
59+
If weights is None: uniform sum (baseline).
60+
If top_k is given: only the top-K weighted positions contribute.
61+
"""
62+
if weights is None:
63+
contrib = per_pos_llr
64+
else:
65+
contrib = weights * per_pos_llr
66+
if top_k is not None and top_k < len(contrib):
67+
# Use the top-K LARGEST contributions (positive LLR, ranked)
68+
idx = np.argsort(contrib)[::-1][:top_k]
69+
return float(contrib[idx].sum())
70+
return float(contrib.sum())
71+
72+
73+
# ---------------------------------------------------------------------------
74+
# Main comparison runner
75+
# ---------------------------------------------------------------------------
76+
77+
def run_comparison(
78+
cohort: Dict[str, Any],
79+
mutations_with_ref: Sequence[Dict],
80+
avi_weights: Dict[str, aw.AVIWeight],
81+
*,
82+
tumor_fractions: Sequence[float] = (0.1, 0.05, 0.01, 0.005, 0.001),
83+
seeds: Sequence[int] = (42, 123, 456, 789, 1024),
84+
cfdna_depth: int = 5000,
85+
bg_error_rate: float = 0.002,
86+
topk_values: Sequence[int] = (500, 1000, 2000),
87+
) -> Dict[str, Any]:
88+
"""Run the 5-strategy comparison and aggregate across seeds.
89+
90+
Per (seed, tumor_fraction, strategy):
91+
- sim per-patient → (pos_score, neg_score)
92+
- ROC AUC across patients for that seed
93+
Per (tumor_fraction, strategy):
94+
- mean ± std of per-seed AUC
95+
"""
96+
methods = ["panel_llr_uniform", "panel_llr_avi"]
97+
methods += [f"panel_llr_topk_{k}" for k in topk_values]
98+
99+
# Pre-build per-patient weights (sorted by AVI desc within patient)
100+
patients = list(cohort["patients"].keys())
101+
patient_weights: Dict[str, np.ndarray] = {}
102+
patient_keys: Dict[str, List[str]] = {}
103+
for p in patients:
104+
mlist = cohort["patients"][p]
105+
# Build AVI vector in mutation-list order. Missing weights -> 0 (treated
106+
# as "no signal" — the LLR contribution is also 0 for error-only reads
107+
# so this is safe).
108+
ws: List[float] = []
109+
ks: List[str] = []
110+
for m in mlist:
111+
# Look up by mutation identity: the cohort mutation dicts lack
112+
# ref/alt alleles, so we match by (sample, chrom, pos, gene)
113+
# against the mutations_with_ref list.
114+
vkey = None
115+
for mr in mutations_with_ref:
116+
if (mr["sample"] == m.get("sample")
117+
and mr["chrom"] == m.get("chrom")
118+
and mr["pos"] == m.get("pos")
119+
and mr["gene"] == m.get("gene")):
120+
vkey = aw.variant_key(mr["chrom"], mr["pos"], mr["ref"], mr["alt"])
121+
break
122+
if vkey and vkey in avi_weights:
123+
ws.append(avi_weights[vkey].avi_norm)
124+
else:
125+
ws.append(0.0)
126+
ks.append(vkey or "?")
127+
patient_weights[p] = np.asarray(ws, dtype=float)
128+
patient_keys[p] = ks
129+
130+
# Aggregation container
131+
results: Dict[str, List[Dict]] = {m: [] for m in methods}
132+
133+
METRIC_FNS = {
134+
"auc": lambda y, s: float(__import__("sklearn.metrics", fromlist=["roc_auc_score"]).roc_auc_score(y, s)),
135+
"sens_at_95_spec": lambda y, s: rtv.sensitivity_at_specificity(y, s, 0.95),
136+
"paired_win_rate": lambda y, s: float(np.mean(s[: len(s) // 2] > s[len(s) // 2 :])),
137+
}
138+
139+
t0 = time.time()
140+
for tf in tumor_fractions:
141+
print(f"\n=== Tumor fraction {tf*100:.2f}% ===")
142+
# Per-method per-seed AUC arrays
143+
method_auc_by_seed: Dict[str, Dict[int, float]] = {m: {} for m in methods}
144+
145+
for seed in seeds:
146+
# Per-patient simulated data
147+
sim_p: Dict[str, Dict] = {}
148+
sim_n: Dict[str, Dict] = {}
149+
llr_p: Dict[str, np.ndarray] = {}
150+
llr_n: Dict[str, np.ndarray] = {}
151+
for p in patients:
152+
muts = cohort["patients"][p]
153+
dp = rtv.simulate_cfdna_from_real(
154+
muts, tumor_fraction=tf, cfdna_depth=cfdna_depth,
155+
seed=seed, bg_error_rate=bg_error_rate,
156+
)
157+
dn = rtv.simulate_cfdna_from_real(
158+
muts, tumor_fraction=0.0, cfdna_depth=cfdna_depth,
159+
seed=seed, bg_error_rate=bg_error_rate,
160+
)
161+
nv_p, nv_n = dp["n_variants"], dn["n_variants"]
162+
panel_size = min(nv_p, nv_n, len(muts))
163+
# Per-position LLR (truncated to panel_size for both)
164+
lp = _pos_llr(dp["depths"][:panel_size],
165+
dp["X"][:, 1].astype(int)[:panel_size],
166+
dp["X"][:, 3][:panel_size])
167+
ln = _pos_llr(dn["depths"][:panel_size],
168+
dn["X"][:, 1].astype(int)[:panel_size],
169+
dn["X"][:, 3][:panel_size])
170+
llr_p[p] = lp
171+
llr_n[p] = ln
172+
173+
for method in methods:
174+
pos_scores, neg_scores = [], []
175+
for p in patients:
176+
lp = llr_p[p]
177+
ln = llr_n[p]
178+
w = patient_weights[p]
179+
if method == "panel_llr_uniform":
180+
sp = _panel_score_with_weights(lp, None)
181+
sn = _panel_score_with_weights(ln, None)
182+
elif method == "panel_llr_avi":
183+
sp = _panel_score_with_weights(lp, w)
184+
sn = _panel_score_with_weights(ln, w)
185+
else:
186+
# panel_llr_topk_{K}
187+
k = int(method.split("_")[-1])
188+
sp = _panel_score_with_weights(lp, w, top_k=k)
189+
sn = _panel_score_with_weights(ln, w, top_k=k)
190+
pos_scores.append(sp)
191+
neg_scores.append(sn)
192+
193+
y = np.array([1] * len(pos_scores) + [0] * len(neg_scores))
194+
s = np.array(pos_scores + neg_scores)
195+
auc = float(__import__("sklearn.metrics", fromlist=["roc_auc_score"]).roc_auc_score(y, s))
196+
method_auc_by_seed[method][seed] = auc
197+
198+
print(f" seed {seed:>4}: "
199+
+ " ".join(f"{m}={method_auc_by_seed[m][seed]:.4f}" for m in methods))
200+
201+
# Aggregate across seeds
202+
for method in methods:
203+
auc_vals = list(method_auc_by_seed[method].values())
204+
results[method].append({
205+
"tumor_fraction": float(tf),
206+
"metric": "auc",
207+
"mean": float(np.mean(auc_vals)),
208+
"std": float(np.std(auc_vals, ddof=1)) if len(auc_vals) > 1 else 0.0,
209+
"per_seed": {str(s): float(v) for s, v in method_auc_by_seed[method].items()},
210+
})
211+
212+
elapsed = time.time() - t0
213+
return {
214+
"methods": methods,
215+
"results": results,
216+
"elapsed_seconds": elapsed,
217+
}
218+
219+
220+
# ---------------------------------------------------------------------------
221+
# CLI
222+
# ---------------------------------------------------------------------------
223+
224+
def main():
225+
parser = argparse.ArgumentParser(description="AVI-weighted panel LLR comparison")
226+
parser.add_argument("--cache-dir",
227+
default="validation/tcga/tcga_cache",
228+
help="TCGA MAF cache directory")
229+
parser.add_argument("--output",
230+
default="results/alphagenome_weighted_llr.json")
231+
parser.add_argument("--n-patients", type=int, default=20)
232+
parser.add_argument("--seeds", type=int, default=5)
233+
parser.add_argument("--cfdna-depth", type=int, default=5000)
234+
parser.add_argument("--bg-error-rate", type=float, default=0.002)
235+
parser.add_argument("--api-key", default=None)
236+
parser.add_argument("--tabix", default=None)
237+
parser.add_argument("--weights-cache",
238+
default="results/alphagenome_avi_weights.json")
239+
args = parser.parse_args()
240+
241+
logging.basicConfig(level=logging.INFO, format="%(asctime)s %(levelname)s %(message)s")
242+
243+
print("=" * 72)
244+
print(" DeepCatch — AVI-weighted Panel-LLR Comparison (v2.2)")
245+
print("=" * 72)
246+
247+
# 1. Load cohort + ref/alt-augmented mutations
248+
cohort = rtv.load_tcga_cohort(
249+
args.cache_dir,
250+
n_patients=args.n_patients,
251+
cancer_types=["LUAD"],
252+
allow_download=True,
253+
)
254+
mutations_with_ref = aw.load_tcga_mutations_with_ref_alt(args.cache_dir)
255+
print(f" Cohort: {cohort['n_patients']} patients, {cohort['n_mutations']} mutations")
256+
print(f" Ref/alt-augmented pool: {len(mutations_with_ref)} mutations "
257+
f"({sum(1 for m in mutations_with_ref if len(m['ref'])==1 and len(m['alt'])==1)} SNVs)")
258+
259+
# 2. Weight cohort
260+
weights_cache = Path(args.weights_cache)
261+
avi_weights, primary_source = aw.weight_cohort(
262+
mutations_with_ref,
263+
api_key=args.api_key,
264+
tabix_path=Path(args.tabix) if args.tabix else None,
265+
cache_path=weights_cache,
266+
)
267+
n_real = sum(1 for w in avi_weights.values() if w.source in ("atlas_api", "tabix_local"))
268+
n_proxy = sum(1 for w in avi_weights.values() if w.source == "proxy")
269+
n_missing = sum(1 for w in avi_weights.values() if w.source == "missing")
270+
print(f" AVI source: {primary_source} "
271+
f"(real={n_real} proxy={n_proxy} missing={n_missing})")
272+
273+
# 3. Normalise weights
274+
raw_scores = np.array([w.avi_score for w in avi_weights.values()], dtype=float)
275+
norm = aw.normalize_avi(raw_scores)
276+
for vk, norm_val in zip(avi_weights.keys(), norm):
277+
avi_weights[vk].avi_norm = float(norm_val)
278+
print(f" AVI raw range: {raw_scores.min():.2f} .. {raw_scores.max():.2f}")
279+
print(f" AVI norm range: {norm.min():.3f} .. {norm.max():.3f}")
280+
281+
# 4. Run comparison
282+
seeds = [42, 123, 456, 789, 1024][: args.seeds]
283+
tumor_fractions = [0.1, 0.05, 0.01, 0.005, 0.001]
284+
285+
t0 = time.time()
286+
out = run_comparison(
287+
cohort,
288+
mutations_with_ref,
289+
avi_weights,
290+
tumor_fractions=tumor_fractions,
291+
seeds=seeds,
292+
cfdna_depth=args.cfdna_depth,
293+
bg_error_rate=args.bg_error_rate,
294+
topk_values=(500, 1000, 2000),
295+
)
296+
elapsed = time.time() - t0
297+
298+
# 5. Build output JSON
299+
payload = {
300+
"metadata": {
301+
"runner": "alphagenome_panel_run.py",
302+
"date": time.strftime("%Y-%m-%d %H:%M:%S"),
303+
"data_source": cohort["source"],
304+
"cancer_types": ["LUAD"],
305+
"n_patients": cohort["n_patients"],
306+
"n_mutations_in_cohort": cohort["n_mutations"],
307+
"n_mutations_with_ref_alt": len(mutations_with_ref),
308+
"cfdna_depth": args.cfdna_depth,
309+
"bg_error_rate": args.bg_error_rate,
310+
"seeds_used": list(seeds),
311+
"tumor_fractions": list(tumor_fractions),
312+
"avi_primary_source": primary_source,
313+
"avi_n_real": n_real,
314+
"avi_n_proxy": n_proxy,
315+
"avi_n_missing": n_missing,
316+
"avi_raw_range": [float(raw_scores.min()), float(raw_scores.max())],
317+
"avi_norm_range": [float(norm.min()), float(norm.max())],
318+
"methods_compared": out["methods"],
319+
"elapsed_seconds": elapsed,
320+
# Honest framing — identical to the existing pipeline
321+
"pipeline_type": "REAL_MUTATIONS_+_SIMULATED_PLASMA_READS",
322+
"note": (
323+
"Ground-truth variants come from real TCGA-LUAD MAF data; "
324+
"plasma reads are simulated by Poisson sampling. Panel LLR "
325+
"is computed with AVI weights from AlphaGenome Atlas when "
326+
"available; otherwise a deterministic per-variant-class proxy "
327+
"is used (see avi_primary_source). Avi weights are applied "
328+
"as fixed scalars in a deterministic aggregation — not as "
329+
"features to a learned model — which complies with the "
330+
"AlphaGenome Terms of Use non-training clause."
331+
),
332+
},
333+
"results": out["results"],
334+
"elapsed_seconds": elapsed,
335+
}
336+
337+
Path(args.output).parent.mkdir(parents=True, exist_ok=True)
338+
with open(args.output, "w") as f:
339+
json.dump(payload, f, indent=2, default=str)
340+
print(f"\n 📁 Results saved to {args.output}")
341+
print(f" ⏱ Wall time: {elapsed:.1f}s")
342+
343+
# 6. Print compact comparison table
344+
print("\n" + "=" * 72)
345+
print(" PANEL-LLR COMPARISON (mean AUC ± std across seeds)")
346+
print("=" * 72)
347+
hdr = f" {'TF':<6}" + "".join(f"{m:>22}" for m in out["methods"])
348+
print(hdr)
349+
for tf in tumor_fractions:
350+
line = f" {tf*100:5.2f}%"
351+
for m in out["methods"]:
352+
row = next((r for r in out["results"][m]
353+
if r["tumor_fraction"] == tf and r["metric"] == "auc"), None)
354+
if row is None:
355+
line += f" {'n/a':>18}"
356+
else:
357+
line += f" {row['mean']:.4f}±{row['std']:.4f}".rjust(22)
358+
print(line)
359+
print("=" * 72)
360+
361+
# 7. Bottom-line deltas vs baseline (uniform)
362+
print("\n Bottom-line Δ vs panel_llr_uniform (AUC @ 0.1%):")
363+
tf_key = 0.001
364+
base = next((r["mean"] for r in out["results"]["panel_llr_uniform"]
365+
if r["tumor_fraction"] == tf_key), None)
366+
if base is not None:
367+
for m in out["methods"]:
368+
if m == "panel_llr_uniform":
369+
continue
370+
row = next((r for r in out["results"][m]
371+
if r["tumor_fraction"] == tf_key), None)
372+
if row is not None:
373+
d = row["mean"] - base
374+
flag = "✓" if d >= 0.02 else ("≈" if d >= 0 else "✗")
375+
print(f" {flag} {m:>22}: AUC {row['mean']:.4f} Δ={d:+.4f}")
376+
377+
return 0
378+
379+
380+
if __name__ == "__main__":
381+
sys.exit(main())

0 commit comments

Comments
 (0)