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