GrimSqueaker commited on
Commit
30b7e77
·
verified ·
1 Parent(s): df4c10a

Initial deploy: MAGI variant interpreter (gradio_app)

Browse files
.gitattributes CHANGED
@@ -33,3 +33,5 @@ saved_model/**/* filter=lfs diff=lfs merge=lfs -text
33
  *.zip filter=lfs diff=lfs merge=lfs -text
34
  *.zst filter=lfs diff=lfs merge=lfs -text
35
  *tfevents* filter=lfs diff=lfs merge=lfs -text
 
 
 
33
  *.zip filter=lfs diff=lfs merge=lfs -text
34
  *.zst filter=lfs diff=lfs merge=lfs -text
35
  *tfevents* filter=lfs diff=lfs merge=lfs -text
36
+ data/MANE_processed.csv filter=lfs diff=lfs merge=lfs -text
37
+ data/Promoter_processed.csv filter=lfs diff=lfs merge=lfs -text
.gitignore ADDED
@@ -0,0 +1,92 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ # Large data files
2
+ hg38.fa
3
+ hg38.fa.gz
4
+ hg38.fa.fai
5
+ *.fasta
6
+ *.fa.gz
7
+ *.fai
8
+
9
+ # Model cache
10
+ models/
11
+ checkpoints/
12
+ *.pt
13
+ *.pth
14
+ *.safetensors
15
+
16
+ # Python cache
17
+ __pycache__/
18
+ *.py[cod]
19
+ *$py.class
20
+ *.so
21
+ .Python
22
+ build/
23
+ develop-eggs/
24
+ dist/
25
+ downloads/
26
+ eggs/
27
+ .eggs/
28
+ lib/
29
+ lib64/
30
+ parts/
31
+ sdist/
32
+ var/
33
+ wheels/
34
+ *.egg-info/
35
+ .installed.cfg
36
+ *.egg
37
+
38
+ # Virtual environments
39
+ venv/
40
+ env/
41
+ ENV/
42
+ env.bak/
43
+ venv.bak/
44
+ .venv/
45
+
46
+ # IDE
47
+ .vscode/
48
+ .idea/
49
+ *.swp
50
+ *.swo
51
+ *~
52
+ .DS_Store
53
+
54
+ # Jupyter
55
+ .ipynb_checkpoints/
56
+ *.ipynb
57
+
58
+ # Testing
59
+ .pytest_cache/
60
+ .coverage
61
+ htmlcov/
62
+ .tox/
63
+
64
+ # Logs
65
+ *.log
66
+ logs/
67
+ flagged/ # Gradio flagged data
68
+
69
+ # Temporary files
70
+ tmp/
71
+ temp/
72
+ *.tmp
73
+
74
+ # Output files (results from app)
75
+ outputs/
76
+ results/
77
+ *.csv.bak
78
+ *.csv~
79
+ last_variant_result.csv
80
+ batch_results.csv
81
+ last_variant_tracks.png
82
+ *.png
83
+
84
+ # System files
85
+ .DS_Store
86
+ Thumbs.db
87
+ desktop.ini
88
+
89
+ # HuggingFace Spaces
90
+ .gradio/
91
+ ./data/hg38.fa
92
+ ./data/MANE_processed.csv
README.md CHANGED
@@ -1,13 +1,190 @@
1
  ---
2
- title: MAGI
3
- emoji: 🐨
4
- colorFrom: indigo
5
- colorTo: red
6
  sdk: gradio
7
- sdk_version: 6.15.2
8
- python_version: '3.13'
9
  app_file: app.py
10
  pinned: false
 
 
11
  ---
12
 
13
- Check out the configuration reference at https://huggingface.co/docs/hub/spaces-config-reference
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
  ---
2
+ title: MAGI Variant Interpreter
3
+ emoji: 🧬
4
+ colorFrom: purple
5
+ colorTo: blue
6
  sdk: gradio
7
+ sdk_version: 5.0.0
 
8
  app_file: app.py
9
  pinned: false
10
+ license: mit
11
+ hardware: zero-a10
12
  ---
13
 
14
+ ## 🧬 MAGI Variant Interpreter
15
+
16
+ ### Variant impact scoring using MAGI and the NTv3 foundation model
17
+
18
+ MAGI is a lightweight demo for reviewing variant-associated signals in a local genomic window. It uses the external NTv3 model for sequence predictions and adds MAGI scoring, annotation, ranking, and short rule-based summaries.
19
+
20
+ MAGI was developed by Dan Ofer, Stav Zok, and Michal Linial. The NTv3 model was developed by InstaDeepAI and collaborators.
21
+
22
+ ## Features
23
+
24
+ - **Single Variant Analysis**: Manual input of SNPs and indels with a compact summary
25
+ - **Batch Processing**: Upload CSV files with up to 10 variants
26
+ - **Multi-species support**: Human plus supported animals and plants via Ensembl sequence retrieval
27
+ - **Gene Annotation**: Automatic annotation using MANE Select RefSeq transcripts
28
+ - **Ranked Signal Review**:
29
+ - BED outputs from the NTv3 configuration
30
+ - Filtered BigWig context tracks
31
+ - Sequence-model metrics such as LLR, KL divergence, and embedding distances
32
+ - **Region Track View**: Zoomable probability tracks for the top disrupted BED and BigWig outputs
33
+ - **Rule-Based Signal Interpretation**: Short deterministic summary of the strongest ranked signals
34
+ - **Impact Scoring**: Quantitative metrics for variant prioritization, including MAGI `Global_z_sum_log`
35
+
36
+ ## Usage
37
+
38
+ ### Single Variant
39
+
40
+ 1. Select species and chromosome.
41
+ 2. Enter a 1-based genomic position.
42
+ Human uses GRCh38/hg38 coordinates. Non-human species use the selected species' current Ensembl assembly, and chromosome names can be bare (`1`, `X`, `MT`) or `chr`-prefixed.
43
+ 3. Enter reference and alternate alleles (for example `C` → `T` for a SNP, `ATCT` → `A` for a deletion).
44
+ 4. Click **Predict Impact**.
45
+
46
+ **Example:**
47
+
48
+ - Chromosome: `chr17`
49
+ - Position: `7675088`
50
+ - Ref: `C`
51
+ - Alt: `T`
52
+ - (TP53 pathogenic missense variant)
53
+
54
+ ### Batch Upload
55
+
56
+ Upload a CSV file with these columns:
57
+
58
+ ```csv
59
+ chrom,pos,ref,alt
60
+ chr17,7675088,C,T
61
+ chr7,117559593,ATCT,A
62
+ chr13,32332771,AGAGA,AGA
63
+ ```
64
+
65
+ **Limit:** 10 variants per batch
66
+
67
+ ## Output Interpretation
68
+
69
+ ### Impact Scores
70
+
71
+ - **MAGI `Global_z_sum_log`**: Burden score computed as `Σ log(1 + |z_j|)` across z-scored BED and BigWig delta tracks using bundled baseline statistics
72
+
73
+ - Higher values indicate broader or stronger deviation from the baseline set
74
+ - This is a ranking score, not a calibrated pathogenicity probability
75
+
76
+ - **Impact_Score_BED**: Mean of top-3 largest absolute BED element deltas
77
+
78
+ - This score is also used for the simple high/moderate/low summary tier shown in the single-variant card
79
+ - Tier thresholds: `HIGH > 0.10`, `MODERATE > 0.05`, otherwise `LOW`
80
+
81
+ - **LLR (Log-Likelihood Ratio)**: For SNPs only, log(P(ALT)/P(REF))
82
+
83
+ - Positive: Alternate allele more likely
84
+ - Negative: Reference allele more likely
85
+
86
+ - **KL Divergence**: Measures how much the predicted token distribution changes around the variant
87
+
88
+ - Higher values indicate a larger local sequence-model shift
89
+
90
+ ### Rule-Based Signal Interpretation
91
+
92
+ The single-variant view includes a short interpretation block that:
93
+
94
+ - summarizes the most disrupted BED and BigWig signals in plain language
95
+ - highlights whether the strongest evidence is coding-related, splice-related, promoter-related, or context-dependent
96
+ - adds sequence-model context from LLR and KL divergence
97
+
98
+ This panel is deterministic and uses the same ranked signals already displayed in the table and plots. It is a heuristic summary, not a calibrated pathogenicity assessment.
99
+
100
+ The summary card also surfaces the top raw BED, BigWig, and MLM signals first, using a minimum absolute magnitude threshold of 0.03.
101
+
102
+ **MAGI baseline note:** the app bundles baseline statistics under `data/magi_baseline_stats.csv`, so `Global_z_sum_log` is computed locally.
103
+
104
+ ### Functional Annotations
105
+
106
+ **Region classes:**
107
+
108
+ - `CODING`
109
+ - `CODING, SPLICE`
110
+ - `SPLICE`
111
+ - `UTR_5` / `UTR_3`
112
+ - `PROMOTER`
113
+ - `INTRONIC`
114
+ - `GENIC_OTHER`
115
+ - `OTHER`
116
+
117
+ **BigWig tracks:**
118
+
119
+ - Histone modifications: H3K4me3, H3K27ac, H3K36me3, H3K27me3, etc.
120
+ - Chromatin accessibility: ATAC-seq, DNase-seq
121
+ - Gene expression or transcription-linked assays such as CAGE
122
+
123
+ **Direction:**
124
+
125
+ - **Gain of Function (GOF)**: Δ > 0 → predicted signal increased
126
+ - **Loss of Function (LOF)**: Δ < 0 → predicted signal decreased
127
+
128
+ ### Region Classification
129
+
130
+ Variants are automatically classified into:
131
+
132
+ - `CODING`: Overlaps coding sequence
133
+ - `CODING, SPLICE`: Coding region near splice junction
134
+ - `SPLICE`: Intronic splice site (±2 bp from exon boundary)
135
+ - `UTR_5` / `UTR_3`: 5' or 3' untranslated region
136
+ - `PROMOTER`: Within 2 kb upstream of TSS
137
+ - `INTRONIC`: Intronic (not splice site)
138
+ - `GENIC_OTHER`: Within gene boundaries (other)
139
+ - `OTHER`: Intergenic
140
+
141
+ ## Data Sources
142
+
143
+ - **Model:** InstaDeepAI/NTv3_650M_post (HuggingFace)
144
+ - **Annotation:** MANE Select v1.3 (RefSeq + Ensembl)
145
+ - **BigWig Metadata:** ENCODE v3, GTEx, FANTOM5, GEO, CATLAS
146
+ - **Reference Genome:** GRCh38/hg38 (local `hg38.fa` preferred; UCSC API used as fallback when local sequence is unavailable)
147
+
148
+ ## Species Support
149
+
150
+ - **Human:** full app support, including BigWig context tracks and MANE transcript annotation
151
+ - **Non-human animals and plants:** BED outputs and sequence-model metrics via Ensembl / Ensembl Plants sequence retrieval
152
+ - **Important:** MAGI baseline z-scores are derived from human variants, so `Global_z_sum_log` is less directly comparable for non-human species
153
+
154
+ ## Runtime Behavior
155
+
156
+ - A Hugging Face token may be required to access the gated NTv3 model weights.
157
+ - No external LLM calls are made.
158
+ - No secret-management flow is used by the Gradio app.
159
+ - Ordinary external network access can still occur when downloading model assets from Hugging Face or when falling back to the UCSC sequence API.
160
+
161
+ ## Limitations
162
+
163
+ 1. Predictions are computational and require experimental validation
164
+ 2. The app uses a 32 kb local sequence window and does not model long-range chromatin effects
165
+ 3. No phasing information is used
166
+ 4. Human uses GRCh38/hg38; non-human coordinates must match the selected species assembly available through Ensembl
167
+ 5. Batch processing limited to 10 variants
168
+ 6. BigWig context tracks and MANE transcript annotation are currently human-only in this app
169
+ 7. Region Track View zoom is limited by the available NTv3 track-profile span for the current prediction
170
+
171
+ ## Citation
172
+
173
+ If you use MAGI in your research, cite the MAGI manuscript. If you rely on the underlying foundation model, also cite NTv3.
174
+ ## Links
175
+
176
+ - 📄 **Paper:** *MAGI: Mechanistic Consequences of Genetic Variants via Genomic Foundation Models* (Ofer, Zok & Linial — preprint forthcoming)
177
+ - 💻 **GitHub:** https://github.com/ddofer/magi<!--
178
+ ## License
179
+
180
+ MIT License - see LICENSE file for details -->
181
+
182
+ ## Acknowledgments
183
+
184
+ - InstaDeepAI and collaborators for developing the NTv3 model
185
+ - ENCODE, GTEx, FANTOM5 consortia for functional genomics data
186
+ - NCBI RefSeq and Ensembl for transcript annotations
187
+
188
+ ---
189
+
190
+ *Developed for research. Feedback and contributions welcome!*
SETUP_GUIDE.md ADDED
@@ -0,0 +1,195 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ # Setup and Usage Guide
2
+
3
+ ## Quick Start
4
+
5
+ MAGI is a variant interpretation app that scores genomic variants using the NTv3 foundation model.
6
+
7
+ Current runtime configuration:
8
+
9
+ - Model: `InstaDeepAI/NTv3_650M_post`
10
+ - Sequence window: `32768` bp (32 kb)
11
+ - Batch limit: `10` variants
12
+ - Reference genome: local `data/hg38.fa` when available for human, otherwise UCSC / Ensembl fallback
13
+
14
+ ## Hugging Face Spaces
15
+
16
+ 1. Create a new Space at [Hugging Face Spaces](https://huggingface.co/spaces).
17
+ 2. Select the `Gradio` SDK and `zero-a10` hardware.
18
+ 3. Upload the contents of this directory.
19
+ - **Important:** `data/hg38.fa` is ~915 MB. Do **not** commit it to the Space repo without Git LFS — instead rely on the UCSC API fallback (the app does this automatically when the file is absent).
20
+ - `data/MANE_processed.csv` (~235 MB) and `data/MANE_processed.parquet` (~24 MB) should be committed; the parquet is auto-generated from the CSV on first run.
21
+ 4. Set the `HF_TOKEN` Space secret so the app can access the gated `NTv3_650M_post` model weights.
22
+ 5. Start the Space.
23
+
24
+ Notes:
25
+
26
+ - The app downloads model weights from Hugging Face on first launch and caches them.
27
+ - If `data/hg38.fa` is not present, sequence retrieval falls back to the UCSC API automatically — no extra configuration is needed.
28
+
29
+ ## Local Installation
30
+
31
+ ### Prerequisites
32
+
33
+ - Python 3.8+
34
+ - Enough disk space for the model cache and optional local genome
35
+ - Internet access for model download and, if needed, UCSC fallback sequence retrieval
36
+ - A Hugging Face account with access to `InstaDeepAI/NTv3_650M_post`
37
+
38
+ ### Install dependencies
39
+
40
+ ```bash
41
+ cd ntv3_gradio_app
42
+ pip install -r requirements.txt
43
+ ```
44
+
45
+ ### Optional: download the local genome
46
+
47
+ Automatic:
48
+
49
+ ```bash
50
+ python download_hg38.py
51
+ ```
52
+
53
+ Manual:
54
+
55
+ ```bash
56
+ wget -c https://hgdownload.soe.ucsc.edu/goldenPath/hg38/bigZips/hg38.fa.gz
57
+ gunzip hg38.fa.gz
58
+ mv hg38.fa data/hg38.fa
59
+ ```
60
+
61
+ If the local genome is absent, the app still runs by querying UCSC for sequence windows.
62
+
63
+ ### Configure Hugging Face access
64
+
65
+ The active NTv3 model is gated. If model loading fails with an authentication error:
66
+
67
+ 1. Accept the model terms at [InstaDeepAI/NTv3_650M_post](https://huggingface.co/InstaDeepAI/NTv3_650M_post)
68
+ 2. Set `HF_TOKEN` in your environment
69
+
70
+ Example:
71
+
72
+ ```bash
73
+ export HF_TOKEN=your_token_here
74
+ ```
75
+
76
+ ### Validate installation
77
+
78
+ ```bash
79
+ python test_installation.py
80
+ ```
81
+
82
+ This checks:
83
+
84
+ - Python dependencies
85
+ - Required annotation and metadata files
86
+ - Sequence access through the local genome or UCSC fallback
87
+ - Model loading
88
+
89
+ ### Run the app
90
+
91
+ ```bash
92
+ python app.py
93
+ ```
94
+
95
+ Or:
96
+
97
+ ```bash
98
+ gradio app.py
99
+ ```
100
+
101
+ ## Input Format
102
+
103
+ ### Single variant input
104
+
105
+ - Human chromosome: `chr1`-`chr22`, `chrX`, `chrY`, `chrM`
106
+ - Non-human chromosome: bare names such as `1`, `X`, `MT` or `chr`-prefixed names
107
+ - Position: 1-based coordinate for the selected species assembly
108
+ - `ref`: reference allele
109
+ - `alt`: alternate allele
110
+
111
+ Example:
112
+
113
+ - Chromosome: `chr17`
114
+ - Position: `7675088`
115
+ - Ref: `C`
116
+ - Alt: `T`
117
+
118
+ ### Batch CSV input
119
+
120
+ Required columns:
121
+
122
+ ```csv
123
+ chrom,pos,ref,alt
124
+ chr17,7675088,C,T
125
+ chr7,117559593,ATCT,A
126
+ chr13,32340300,G,A
127
+ ```
128
+
129
+ Batch runs are limited to 10 variants.
130
+
131
+ ## Output Overview
132
+
133
+ The single-variant view includes:
134
+
135
+ - Variant summary with gene, region class, and core metrics
136
+ - Ranked BED and BigWig signals
137
+ - Region track plot
138
+ - Full BED table
139
+ - Sequence-model metrics
140
+ - Rule-based interpretation panel
141
+
142
+ Important notes:
143
+
144
+ - `Global_z_sum_log` is a MAGI ranking score, not a pathogenicity probability.
145
+ - The simple high/moderate/low tier shown in the summary card is currently based on `Impact_Score_BED`.
146
+ - The interpretation panel is heuristic and deterministic. It summarizes existing outputs; it does not add a new predictive model.
147
+
148
+ ## Troubleshooting
149
+
150
+ ### Model download or authentication failed
151
+
152
+ - Confirm that you accepted the model terms for `InstaDeepAI/NTv3_650M_post`
153
+ - Confirm that `HF_TOKEN` is set
154
+ - Retry after verifying network access to Hugging Face
155
+
156
+ ### No local genome found
157
+
158
+ - The app can run without `data/hg38.fa`
159
+ - In that case, sequence windows are requested from the UCSC API
160
+ - For faster local runs, download `hg38.fa` into `data/`
161
+
162
+ ### Slow inference
163
+
164
+ - First launch is slower because weights may need to be downloaded
165
+ - CPU inference is much slower than GPU inference
166
+ - The 650M model requires more memory than smaller NTv3 variants
167
+
168
+ ### Batch upload rejected
169
+
170
+ - Ensure the CSV contains `chrom`, `pos`, `ref`, and `alt`
171
+ - Ensure the file has no more than 10 rows
172
+
173
+ ### Coordinates look wrong
174
+
175
+ - Human predictions expect GRCh38/hg38 coordinates
176
+ - Non-human predictions expect coordinates from the selected Ensembl assembly
177
+ - Non-human chromosome names can be bare or `chr`-prefixed
178
+
179
+ ## Development Notes
180
+
181
+ Source-of-truth files for runtime behavior:
182
+
183
+ - `app.py`: UI, summaries, and batch handling
184
+ - `inference.py`: model loading, context length, and sequence retrieval
185
+ - `annotation.py`: region classes and annotation flags
186
+ - `analysis.py`: ranking and MAGI score computation
187
+ - `interpretation.py`: rule-based summary text
188
+
189
+ ## References
190
+
191
+ - NTv3 model page: [InstaDeepAI/NTv3_650M_post](https://huggingface.co/InstaDeepAI/NTv3_650M_post)
192
+ - NTv3 paper: [bioRxiv preprint](https://www.biorxiv.org/content/10.1101/2023.01.11.523679v2)
193
+ - Gradio docs: [gradio.app/docs](https://www.gradio.app/docs)
194
+
195
+ **Last updated:** March 2026
analysis.py ADDED
@@ -0,0 +1,747 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ #!/usr/bin/env python3
2
+ """
3
+ Analysis and visualization module for the MAGI Gradio app.
4
+
5
+ Adapted from elegant_solution.py for the app.
6
+
7
+ Provides:
8
+ - Impact score computation (top-K mean absolute delta)
9
+ - Feature summarization with direction labels
10
+ - Fingerprint-style plots for ranked signals
11
+ - BigWig track description mapping
12
+
13
+ Usage:
14
+ from analysis import compute_impact_scores, make_fingerprint_plot
15
+ df = compute_impact_scores(df)
16
+ fig = make_fingerprint_plot(row, bed_names, bw_names, metadata_df)
17
+ """
18
+
19
+ from dataclasses import dataclass
20
+ from pathlib import Path
21
+ from typing import List, Tuple, Optional, Dict, Any
22
+
23
+ import numpy as np
24
+ import pandas as pd
25
+ import matplotlib.pyplot as plt
26
+ import matplotlib
27
+
28
+ # Use non-interactive backend for server deployment
29
+ matplotlib.use("Agg")
30
+
31
+
32
+ BASE_DIR = Path(__file__).parent
33
+ DATA_DIR = BASE_DIR / "data"
34
+ MAGI_BASELINE_FILE = DATA_DIR / "magi_baseline_stats.csv"
35
+ _MAGI_BASELINE_CACHE: Optional[Dict[str, Dict[str, pd.Series]]] = None
36
+
37
+ MLM_SIGNAL_SPECS = [
38
+ ("LLR", "LLR", True),
39
+ ("MLM_logprob_delta", "Log-prob Δ", True),
40
+ ("MLM_Delta", "MLM Δ", True),
41
+ ("MLM_KL_mean", "KL mean", False),
42
+ ("MLM_KL_max", "KL max", False),
43
+ ("EMB_cosine_dist", "Embedding cosine dist", False),
44
+ ("EMB_l2_dist", "Embedding L2 dist", False),
45
+ ("EMB_max_pos_dist", "Embedding max-pos dist", False),
46
+ ("EMB_mean_pos_dist", "Embedding mean-pos dist", False),
47
+ ]
48
+
49
+
50
+ # ============================================================================
51
+ # DATA STRUCTURES
52
+ # ============================================================================
53
+ @dataclass
54
+ class MechanisticSummary:
55
+ """Concise summary of top disrupted features for a variant."""
56
+
57
+ variant_id: str
58
+ gene_name: str
59
+ region_class: str
60
+ top_features: List[Tuple[str, float, str]] # (name, delta, GOF/LOF)
61
+ impact_score_bed: float
62
+ llr: float
63
+ description: Optional[str] = None
64
+
65
+
66
+ # ============================================================================
67
+ # IMPACT SCORING
68
+ # ============================================================================
69
+ def identify_delta_columns(df: pd.DataFrame) -> Tuple[List[str], List[str]]:
70
+ """
71
+ Identify BED and BigWig delta columns in DataFrame.
72
+
73
+ Returns:
74
+ (bed_delta_cols, bw_delta_cols)
75
+ """
76
+ delta_bed_cols = [c for c in df.columns if c.startswith("D_BED_")]
77
+ delta_bw_cols = [c for c in df.columns if c.startswith("D_BW_")]
78
+ return delta_bed_cols, delta_bw_cols
79
+
80
+
81
+ def compute_impact_scores(
82
+ df: pd.DataFrame,
83
+ bed_k: int = 3,
84
+ bw_k: int = 10,
85
+ magi_baseline: Optional[Dict[str, Dict[str, pd.Series]]] = None,
86
+ ) -> pd.DataFrame:
87
+ """
88
+ Compute impact scores from delta columns.
89
+
90
+ Impact_Score_BED: mean of top-K largest absolute BED deltas
91
+ Impact_Score_BW: mean of top-K largest absolute BigWig deltas
92
+
93
+ Args:
94
+ df: DataFrame with D_BED_* and D_BW_* columns
95
+ bed_k: Number of top BED deltas to average
96
+ bw_k: Number of top BigWig deltas to average
97
+
98
+ Returns:
99
+ DataFrame with added Impact_Score_BED, Impact_Score_BW, and
100
+ Global_z_sum_log columns
101
+ """
102
+ df = df.copy()
103
+ bed_cols, bw_cols = identify_delta_columns(df)
104
+
105
+ # BED impact — use argpartition for O(n) top-K selection instead of O(n log n) sort
106
+ if bed_cols:
107
+ bed_matrix = np.abs(df[bed_cols].to_numpy())
108
+ k_bed = min(bed_k, bed_matrix.shape[1])
109
+ if k_bed > 0:
110
+ # argpartition gives us indices that partition the array at position k
111
+ # We want the k largest elements, so we partition at (n - k) to get them on the right
112
+ indices = np.argpartition(bed_matrix, kth=-k_bed, axis=1)[:, -k_bed:]
113
+ top_abs_bed = np.take_along_axis(bed_matrix, indices, axis=1)
114
+ df["Impact_Score_BED"] = top_abs_bed.mean(axis=1)
115
+ else:
116
+ df["Impact_Score_BED"] = np.nan
117
+ else:
118
+ df["Impact_Score_BED"] = np.nan
119
+
120
+ # BigWig impact — use argpartition for O(n) top-K selection
121
+ if bw_cols:
122
+ bw_matrix = np.abs(df[bw_cols].to_numpy())
123
+ k_bw = min(bw_k, bw_matrix.shape[1])
124
+ if k_bw > 0:
125
+ indices = np.argpartition(bw_matrix, kth=-k_bw, axis=1)[:, -k_bw:]
126
+ top_abs_bw = np.take_along_axis(bw_matrix, indices, axis=1)
127
+ df["Impact_Score_BW"] = top_abs_bw.mean(axis=1)
128
+ else:
129
+ df["Impact_Score_BW"] = np.nan
130
+ else:
131
+ df["Impact_Score_BW"] = np.nan
132
+
133
+ baseline = magi_baseline if magi_baseline is not None else load_magi_baseline()
134
+ if baseline:
135
+ df["Global_z_sum_log"] = df.apply(
136
+ lambda row: compute_global_z_sum_log(row, baseline), axis=1
137
+ )
138
+ else:
139
+ df["Global_z_sum_log"] = np.nan
140
+
141
+ return df
142
+
143
+
144
+ def _normalize_variant_type_key(
145
+ value: Optional[str],
146
+ row: Optional[pd.Series] = None,
147
+ ) -> str:
148
+ """Normalize variant-type labels for MAGI baseline lookup."""
149
+ text = (
150
+ str(value).strip().lower() if value is not None and str(value).strip() else ""
151
+ )
152
+ if text in {"snp", "snv", "single nucleotide variant"}:
153
+ return "snp"
154
+ if text in {"deletion", "del"} or ("deletion" in text and "insert" not in text):
155
+ return "deletion"
156
+ if text in {"insertion", "ins"} or "insertion" in text:
157
+ return "insertion"
158
+ if text in {"indel", "delins"} or "delins" in text:
159
+ return "indel"
160
+
161
+ if row is not None:
162
+ ref = str(row.get("ref", "") or "")
163
+ alt = str(row.get("alt", "") or "")
164
+ if len(ref) == 1 and len(alt) == 1:
165
+ return "snp"
166
+ if len(ref) > len(alt):
167
+ return "deletion"
168
+ if len(alt) > len(ref):
169
+ return "insertion"
170
+ indel_size = row.get("indel_size", np.nan)
171
+ if pd.notna(indel_size) and float(indel_size) != 0:
172
+ return "indel"
173
+
174
+ return "indel"
175
+
176
+
177
+ def load_magi_baseline(
178
+ path: Path = MAGI_BASELINE_FILE,
179
+ ) -> Dict[str, Dict[str, pd.Series]]:
180
+ """Load cached benign baseline stats used for MAGI z-scoring."""
181
+ global _MAGI_BASELINE_CACHE
182
+
183
+ if _MAGI_BASELINE_CACHE is not None:
184
+ return _MAGI_BASELINE_CACHE
185
+ if not path.exists():
186
+ _MAGI_BASELINE_CACHE = {}
187
+ return _MAGI_BASELINE_CACHE
188
+
189
+ stats_df = pd.read_csv(path)
190
+ baseline: Dict[str, Dict[str, pd.Series]] = {}
191
+ for variant_type, subset in stats_df.groupby("variant_type"):
192
+ key = str(variant_type).strip().lower()
193
+ baseline[key] = {
194
+ "mean": pd.Series(
195
+ subset["mean"].astype(float).to_numpy(),
196
+ index=subset["delta_col"].astype(str),
197
+ ),
198
+ "std": pd.Series(
199
+ subset["std"].astype(float).to_numpy(),
200
+ index=subset["delta_col"].astype(str),
201
+ ),
202
+ }
203
+
204
+ _MAGI_BASELINE_CACHE = baseline
205
+ return baseline
206
+
207
+
208
+ def compute_global_z_sum_log(
209
+ row: pd.Series,
210
+ baseline: Optional[Dict[str, Dict[str, pd.Series]]] = None,
211
+ variant_type: Optional[str] = None,
212
+ ) -> float:
213
+ """Compute MAGI `Global_z_sum_log` from BED and BigWig delta columns."""
214
+ baseline = baseline if baseline is not None else load_magi_baseline()
215
+ if not baseline:
216
+ return np.nan
217
+
218
+ variant_key = _normalize_variant_type_key(
219
+ variant_type if variant_type is not None else row.get("variant_type"),
220
+ row=row,
221
+ )
222
+ lookup_order = [variant_key]
223
+ if variant_key != "snp":
224
+ lookup_order.append("indel")
225
+
226
+ stats = None
227
+ for key in lookup_order:
228
+ stats = baseline.get(key)
229
+ if stats:
230
+ break
231
+ if not stats:
232
+ return np.nan
233
+
234
+ delta_cols = [
235
+ col
236
+ for col in row.index
237
+ if (col.startswith("D_BED_") or col.startswith("D_BW_"))
238
+ and not pd.isna(row[col])
239
+ ]
240
+ if not delta_cols:
241
+ return np.nan
242
+
243
+ mean_series = stats["mean"]
244
+ std_series = stats["std"]
245
+ present_cols = [
246
+ col
247
+ for col in delta_cols
248
+ if col in mean_series.index and col in std_series.index
249
+ ]
250
+ if not present_cols:
251
+ return np.nan
252
+
253
+ values = np.asarray([float(row[col]) for col in present_cols], dtype=np.float64)
254
+ mu = mean_series.reindex(present_cols).to_numpy(dtype=np.float64)
255
+ sigma = std_series.reindex(present_cols).to_numpy(dtype=np.float64)
256
+ sigma = np.where(np.isfinite(sigma) & (sigma >= 1e-8), sigma, 1.0)
257
+
258
+ z_scores = (values - mu) / sigma
259
+ z_scores[~np.isfinite(z_scores)] = np.nan
260
+ if not np.isfinite(z_scores).any():
261
+ return np.nan
262
+
263
+ return float(np.nansum(np.log1p(np.abs(z_scores))))
264
+
265
+
266
+ def extract_top_summary_signals(
267
+ row: pd.Series,
268
+ ranked: List[Dict[str, Any]],
269
+ min_abs_threshold: float = 0.03,
270
+ max_per_source: int = 5,
271
+ ) -> Dict[str, List[Dict[str, Any]]]:
272
+ """Extract compact top BED, BigWig, and MLM signals for the summary card."""
273
+ summary = {"bed": [], "bigwig": [], "mlm": []}
274
+
275
+ for item in ranked:
276
+ if abs(float(item.get("delta", 0.0))) < min_abs_threshold:
277
+ continue
278
+ payload = {
279
+ "label": item.get("display_name", item.get("track_id", "")),
280
+ "track_id": item.get("track_id", ""),
281
+ "delta": float(item.get("delta", np.nan)),
282
+ "ref_val": item.get("ref_val", np.nan),
283
+ "alt_val": item.get("alt_val", np.nan),
284
+ }
285
+ if item.get("track_type") == "BED" and len(summary["bed"]) < max_per_source:
286
+ summary["bed"].append(payload)
287
+ if (
288
+ item.get("track_type") == "BigWig"
289
+ and len(summary["bigwig"]) < max_per_source
290
+ ):
291
+ summary["bigwig"].append(payload)
292
+ if (
293
+ len(summary["bed"]) >= max_per_source
294
+ and len(summary["bigwig"]) >= max_per_source
295
+ ):
296
+ break
297
+
298
+ mlm_items: List[Dict[str, Any]] = []
299
+ for column, label, signed in MLM_SIGNAL_SPECS:
300
+ if column not in row.index or pd.isna(row[column]):
301
+ continue
302
+ value = float(row[column])
303
+ magnitude = abs(value)
304
+ if magnitude < min_abs_threshold:
305
+ continue
306
+ mlm_items.append(
307
+ {
308
+ "label": label,
309
+ "column": column,
310
+ "value": value,
311
+ "magnitude": magnitude,
312
+ "signed": signed,
313
+ }
314
+ )
315
+ mlm_items.sort(key=lambda item: item["magnitude"], reverse=True)
316
+ summary["mlm"] = mlm_items[:max_per_source]
317
+ return summary
318
+
319
+
320
+ def get_top_features(
321
+ row: pd.Series,
322
+ bed_cols: List[str],
323
+ bw_cols: Optional[List[str]] = None,
324
+ k: int = 10,
325
+ ) -> List[Tuple[str, float, str]]:
326
+ """
327
+ Extract top K disrupted features for a variant.
328
+
329
+ Args:
330
+ row: DataFrame row with delta columns
331
+ bed_cols: BED delta column names
332
+ bw_cols: BigWig delta column names (optional)
333
+ k: Number of features to return
334
+
335
+ Returns:
336
+ List of (feature_name, delta, direction) tuples
337
+ direction is 'GOF' (gain) or 'LOF' (loss)
338
+ """
339
+ deltas = {}
340
+
341
+ # Accumulate BED deltas
342
+ for col in bed_cols:
343
+ if col in row.index and not pd.isna(row[col]):
344
+ name = col.replace("D_BED_", "")
345
+ deltas[name] = float(row[col])
346
+
347
+ # Accumulate BigWig deltas (truncate long names)
348
+ if bw_cols:
349
+ for col in bw_cols:
350
+ if col in row.index and not pd.isna(row[col]):
351
+ name = col.replace("D_BW_", "")
352
+ if len(name) > 40:
353
+ name = name[:37] + "..."
354
+ deltas[name] = float(row[col])
355
+
356
+ # Sort by absolute value
357
+ top_items = sorted(deltas.items(), key=lambda x: abs(x[1]), reverse=True)[:k]
358
+
359
+ # Add direction
360
+ result = []
361
+ for feat_name, delta_val in top_items:
362
+ direction = "GOF" if delta_val > 0 else "LOF"
363
+ result.append((feat_name, delta_val, direction))
364
+
365
+ return result
366
+
367
+
368
+ def summarize_variant(
369
+ row: pd.Series, bed_cols: List[str], bw_cols: Optional[List[str]] = None, k: int = 5
370
+ ) -> MechanisticSummary:
371
+ """
372
+ Create a mechanistic summary for a variant.
373
+
374
+ Args:
375
+ row: DataFrame row
376
+ bed_cols: BED delta column names
377
+ bw_cols: BigWig delta column names
378
+ k: Number of top features
379
+
380
+ Returns:
381
+ MechanisticSummary object
382
+ """
383
+ top_features = get_top_features(row, bed_cols, bw_cols, k=k)
384
+
385
+ variant_id = f"{row.get('chrom', 'chr?')}:{row.get('pos', '?')} {row.get('ref', '?')}>{row.get('alt', '?')}"
386
+ gene_name = row.get("gene_name", "")
387
+ region_class = row.get("region_class", "OTHER")
388
+ impact_bed = row.get("Impact_Score_BED", np.nan)
389
+ llr = row.get("LLR", np.nan)
390
+
391
+ return MechanisticSummary(
392
+ variant_id=variant_id,
393
+ gene_name=gene_name,
394
+ region_class=region_class,
395
+ top_features=top_features,
396
+ impact_score_bed=impact_bed,
397
+ llr=llr,
398
+ )
399
+
400
+
401
+ # ============================================================================
402
+ # BIGWIG DESCRIPTION MAPPING
403
+ # ============================================================================
404
+ def get_top_bigwig_descriptions(
405
+ row: pd.Series, metadata_df: pd.DataFrame, k: int = 5
406
+ ) -> List[Tuple[str, float, str]]:
407
+ """
408
+ Get top BigWig track changes with human-readable descriptions.
409
+
410
+ Args:
411
+ row: DataFrame row with D_BW_* columns
412
+ metadata_df: Track metadata with columns [file_id, tissue, assay, experiment_target]
413
+ k: Number of tracks to return
414
+
415
+ Returns:
416
+ List of (description, delta, direction) tuples
417
+ description format: "{tissue} | {assay} | {target}"
418
+ """
419
+ bw_cols = [c for c in row.index if c.startswith("D_BW_")]
420
+
421
+ deltas = {}
422
+ for col in bw_cols:
423
+ if not pd.isna(row[col]):
424
+ track_id = col.replace("D_BW_", "")
425
+ delta = float(row[col])
426
+
427
+ # Look up metadata
428
+ meta = metadata_df[metadata_df["file_id"] == track_id]
429
+ if not meta.empty:
430
+ r = meta.iloc[0]
431
+ tissue = r.get("tissue", "")
432
+ assay = r.get("assay", "")
433
+ target = r.get("experiment_target", "")
434
+ desc = f"{tissue} | {assay}"
435
+ if pd.notna(target) and str(target).strip():
436
+ desc += f" | {target}"
437
+ else:
438
+ desc = track_id
439
+
440
+ deltas[desc] = delta
441
+
442
+ # Sort by absolute value
443
+ top_items = sorted(deltas.items(), key=lambda x: abs(x[1]), reverse=True)[:k]
444
+
445
+ result = []
446
+ for desc, delta in top_items:
447
+ direction = "Gain" if delta > 0 else "Loss"
448
+ result.append((desc, delta, direction))
449
+
450
+ return result
451
+
452
+
453
+ def _clean_part(s: str) -> str:
454
+ """Strip whitespace, trailing/leading commas, and collapse double commas."""
455
+ s = s.strip().strip(",").strip()
456
+ # collapse repeated commas with optional spaces
457
+ import re
458
+ s = re.sub(r",\s*,", ",", s)
459
+ return s
460
+
461
+
462
+ _EMPTY_TARGETS = {"", "nan", "none", "n/a", "na", "null"}
463
+
464
+
465
+ def _resolve_bw_name(
466
+ track_id: str,
467
+ metadata_df: Optional[pd.DataFrame] = None,
468
+ metadata_dict: Optional[Dict[str, Dict[str, str]]] = None,
469
+ max_len: int = 55,
470
+ ) -> str:
471
+ """Resolve BigWig track_id → human-readable display name."""
472
+ if metadata_dict:
473
+ meta = metadata_dict.get(track_id)
474
+ if meta:
475
+ parts = [
476
+ _clean_part(p)
477
+ for p in [
478
+ meta.get("tissue", ""),
479
+ meta.get("assay", ""),
480
+ meta.get("target", ""),
481
+ ]
482
+ if _clean_part(p) and p.strip().lower() not in _EMPTY_TARGETS
483
+ ]
484
+ if parts:
485
+ name = " | ".join(parts)
486
+ return name[:max_len] if len(name) > max_len else name
487
+
488
+ if metadata_df is not None:
489
+ rows = metadata_df[metadata_df["file_id"] == track_id]
490
+ if not rows.empty:
491
+ r = rows.iloc[0]
492
+ parts = [
493
+ _clean_part(str(p))
494
+ for p in [
495
+ r.get("tissue", ""),
496
+ r.get("assay", ""),
497
+ r.get("experiment_target", ""),
498
+ ]
499
+ if pd.notna(p) and _clean_part(str(p)) and str(p).strip().lower() not in _EMPTY_TARGETS
500
+ ]
501
+ if parts:
502
+ name = " | ".join(parts)
503
+ return name[:max_len] if len(name) > max_len else name
504
+
505
+ return track_id[:40]
506
+
507
+
508
+ def rank_top_disrupted_tracks(
509
+ row: pd.Series,
510
+ bed_names: List[str],
511
+ bw_names: Optional[List[str]] = None,
512
+ metadata_df: Optional[pd.DataFrame] = None,
513
+ metadata_dict: Optional[Dict[str, Dict[str, str]]] = None,
514
+ top_k: Optional[int] = None,
515
+ ) -> List[Dict]:
516
+ """
517
+ Single-source ranking of the most disrupted tracks across BED + BigWig.
518
+
519
+ Returns an ordered list (by |delta| descending) of dicts:
520
+ {track_id, display_name, delta, track_type ("BED"/"BigWig"),
521
+ ref_val, alt_val}
522
+ """
523
+ items: List[Dict] = []
524
+
525
+ for name in bed_names:
526
+ d_col = f"D_BED_{name}"
527
+ r_col = f"REF_BED_{name}"
528
+ if d_col not in row.index or pd.isna(row[d_col]):
529
+ continue
530
+ delta = float(row[d_col])
531
+ ref_v = (
532
+ float(row[r_col])
533
+ if r_col in row.index and not pd.isna(row[r_col])
534
+ else np.nan
535
+ )
536
+ items.append(
537
+ {
538
+ "track_id": name,
539
+ "display_name": name,
540
+ "delta": delta,
541
+ "track_type": "BED",
542
+ "ref_val": ref_v,
543
+ "alt_val": ref_v + delta if not np.isnan(ref_v) else np.nan,
544
+ }
545
+ )
546
+
547
+ if bw_names:
548
+ for track_id in bw_names:
549
+ d_col = f"D_BW_{track_id}"
550
+ r_col = f"REF_BW_{track_id}"
551
+ if d_col not in row.index or pd.isna(row[d_col]):
552
+ continue
553
+ delta = float(row[d_col])
554
+ ref_v = (
555
+ float(row[r_col])
556
+ if r_col in row.index and not pd.isna(row[r_col])
557
+ else np.nan
558
+ )
559
+ items.append(
560
+ {
561
+ "track_id": track_id,
562
+ "display_name": _resolve_bw_name(
563
+ track_id, metadata_df, metadata_dict
564
+ ),
565
+ "delta": delta,
566
+ "track_type": "BigWig",
567
+ "ref_val": ref_v,
568
+ "alt_val": ref_v + delta if not np.isnan(ref_v) else np.nan,
569
+ }
570
+ )
571
+
572
+ items.sort(key=lambda x: abs(x["delta"]), reverse=True)
573
+ for idx, item in enumerate(items, start=1):
574
+ item["abs_delta"] = abs(item["delta"])
575
+ item["rank"] = idx
576
+
577
+ if top_k is None:
578
+ return items
579
+ return items[:top_k]
580
+
581
+
582
+ def build_top_track_table(
583
+ ranked: List[Dict],
584
+ max_rows: int = 15,
585
+ min_rows_by_type: Optional[Dict[str, int]] = None,
586
+ ) -> pd.DataFrame:
587
+ """Build a single merged top-tracks table from the unified ranking."""
588
+ if not ranked:
589
+ return pd.DataFrame(
590
+ [
591
+ {
592
+ "Track": "No disruptions detected",
593
+ "Type": "",
594
+ "REF": "",
595
+ "ALT": "",
596
+ "Δ": "",
597
+ "Direction": "",
598
+ }
599
+ ]
600
+ )
601
+
602
+ min_rows_by_type = min_rows_by_type or {"BED": 4}
603
+ max_rows = max(max_rows, sum(min_rows_by_type.values()))
604
+
605
+ forced_keys = set()
606
+ for track_type, min_rows in min_rows_by_type.items():
607
+ count = 0
608
+ for item in ranked:
609
+ if item["track_type"] != track_type:
610
+ continue
611
+ forced_keys.add((item["track_type"], item["track_id"]))
612
+ count += 1
613
+ if count >= min_rows:
614
+ break
615
+
616
+ selected = []
617
+ selected_keys = set()
618
+ for item in ranked:
619
+ key = (item["track_type"], item["track_id"])
620
+ if key in forced_keys and key not in selected_keys:
621
+ selected.append(item)
622
+ selected_keys.add(key)
623
+
624
+ for item in ranked:
625
+ key = (item["track_type"], item["track_id"])
626
+ if key in selected_keys:
627
+ continue
628
+ if len(selected) >= max_rows:
629
+ break
630
+ selected.append(item)
631
+ selected_keys.add(key)
632
+
633
+ selected.sort(key=lambda item: item.get("rank", 10**9))
634
+
635
+ rows = []
636
+ for item in selected[:max_rows]:
637
+ ref_v = item["ref_val"]
638
+ alt_v = item["alt_val"]
639
+ rows.append(
640
+ {
641
+ "Track": item["display_name"],
642
+ "Type": item["track_type"],
643
+ "REF": f"{ref_v:.4f}" if not np.isnan(ref_v) else "N/A",
644
+ "ALT": f"{alt_v:.4f}" if not np.isnan(alt_v) else "N/A",
645
+ "Δ": f"{item['delta']:+.4f}",
646
+ "Direction": "Gain" if item["delta"] > 0 else "Loss",
647
+ }
648
+ )
649
+ return pd.DataFrame(rows)
650
+
651
+
652
+ # ============================================================================
653
+ # VISUALIZATION
654
+ # ============================================================================
655
+ def make_fingerprint_plot(
656
+ ranked: List[Dict],
657
+ top_k: int = 15,
658
+ figsize: Tuple[float, float] = (10, 6),
659
+ ) -> plt.Figure:
660
+ """
661
+ Horizontal bar chart of top disrupted features.
662
+
663
+ Args:
664
+ ranked: Pre-ranked list from rank_top_disrupted_tracks().
665
+ top_k: Number of features to show (capped by len(ranked)).
666
+ figsize: Figure size.
667
+ """
668
+ items = ranked[:top_k]
669
+
670
+ if not items:
671
+ fig, ax = plt.subplots(figsize=figsize)
672
+ ax.text(
673
+ 0.5, 0.5, "No feature data available", ha="center", va="center", fontsize=14
674
+ )
675
+ ax.set_xlim(0, 1)
676
+ ax.set_ylim(0, 1)
677
+ ax.axis("off")
678
+ return fig
679
+
680
+ names = [it["display_name"] for it in items]
681
+ vals = [it["delta"] for it in items]
682
+ colors = ["#d73027" if v > 0 else "#4575b4" for v in vals]
683
+
684
+ fig, ax = plt.subplots(figsize=figsize)
685
+ y_pos = np.arange(len(names))
686
+ ax.barh(y_pos, vals, color=colors, alpha=0.8, edgecolor="black", linewidth=0.5)
687
+ ax.axvline(0, color="black", linewidth=1.5, linestyle="-", alpha=0.7)
688
+ ax.set_yticks(y_pos)
689
+ ax.set_yticklabels(names, fontsize=9)
690
+ ax.set_xlabel("Δ Probability (Alt − Ref)", fontsize=11, fontweight="bold")
691
+ ax.set_title(
692
+ "Top Disrupted Genomic Features", fontsize=13, fontweight="bold", pad=15
693
+ )
694
+ ax.grid(True, axis="x", alpha=0.3, linestyle="--")
695
+ ax.set_axisbelow(True)
696
+
697
+ from matplotlib.patches import Patch
698
+
699
+ legend_elements = [
700
+ Patch(facecolor="#d73027", alpha=0.8, label="Gain of Function"),
701
+ Patch(facecolor="#4575b4", alpha=0.8, label="Loss of Function"),
702
+ ]
703
+ ax.legend(handles=legend_elements, loc="lower right", frameon=True, fontsize=9)
704
+
705
+ plt.tight_layout()
706
+ return fig
707
+
708
+
709
+ def format_summary_table(df: pd.DataFrame) -> pd.DataFrame:
710
+ """
711
+ Extract clinically relevant columns for display.
712
+
713
+ Args:
714
+ df: Full results DataFrame
715
+
716
+ Returns:
717
+ DataFrame with selected columns for display
718
+ """
719
+ display_cols = ["chrom", "pos", "ref", "alt"]
720
+
721
+ # Add annotation if available
722
+ if "gene_name" in df.columns:
723
+ display_cols.append("gene_name")
724
+ if "region_class" in df.columns:
725
+ display_cols.append("region_class")
726
+
727
+ # Add impact scores
728
+ if "Impact_Score_BED" in df.columns:
729
+ display_cols.append("Impact_Score_BED")
730
+ if "Impact_Score_BW" in df.columns:
731
+ display_cols.append("Impact_Score_BW")
732
+ if "Global_z_sum_log" in df.columns:
733
+ display_cols.append("Global_z_sum_log")
734
+
735
+ # Add MLM features
736
+ for col in ["LLR", "MLM_KL_mean", "MLM_KL_max"]:
737
+ if col in df.columns:
738
+ display_cols.append(col)
739
+
740
+ # Add indel size if present
741
+ if "indel_size" in df.columns:
742
+ display_cols.append("indel_size")
743
+
744
+ # Filter to available columns
745
+ available = [c for c in display_cols if c in df.columns]
746
+
747
+ return df[available].copy()
annotation.py ADDED
@@ -0,0 +1,429 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ #!/usr/bin/env python3
2
+ """
3
+ MANE Genomic Annotation Module
4
+ =======================================================
5
+ Adapted from stav_analysis_v2.py for the Gradio app.
6
+
7
+ Provides gene-level and structural annotation (exon, intron, UTR, splice, promoter)
8
+ using the MANE Select transcript dataset (RefSeq).
9
+
10
+ Usage:
11
+ from annotation import annotate_dataframe
12
+ df_annotated = annotate_dataframe(df) # adds 30+ annotation columns
13
+ """
14
+ from pathlib import Path
15
+ from typing import Dict, Set, Tuple
16
+
17
+ import pandas as pd
18
+ import numpy as np
19
+
20
+ # ============================================================================
21
+ # CONFIGURATION
22
+ # ============================================================================
23
+ BASE_DIR = Path(__file__).parent
24
+ DATA_DIR = BASE_DIR / "data"
25
+ MANE_FILE = DATA_DIR / "MANE_processed.csv"
26
+ MANE_PARQUET = DATA_DIR / "MANE_processed.parquet"
27
+ PROMOTER_FILE = DATA_DIR / "Promoter_processed.csv"
28
+ PROMOTER_PARQUET = DATA_DIR / "Promoter_processed.parquet"
29
+
30
+ # Annotation columns (27 region flags + transcript sets)
31
+ ANNOTATION_COLUMNS = [
32
+ 'gene', 'mRNA', 'mRNA_promoter', 'mRNA_exon', 'coding_sequence',
33
+ 'start_codon', 'stop_codon', 'five_prime_UTR', 'three_prime_UTR',
34
+ 'mRNA_intron', 'mRNA_splice', 'lncRNA', 'lncRNA_promoter', 'lncRNA_exon',
35
+ 'snRNA', 'snRNA_promoter', 'snRNA_exon', 'antisenseRNA',
36
+ 'antisenseRNA_promoter', 'antisenseRNA_exon', 'telomeraseRNA',
37
+ 'telomeraseRNA_promoter', 'telomeraseRNA_exon', 'RNaseMRPRNA',
38
+ 'RNaseMRPRNA_promoter', 'RNaseMRPRNA_exon', 'snoRNA', 'snoRNA_promoter',
39
+ 'snoRNA_exon', 'other'
40
+ ]
41
+
42
+ RNA_TYPES = ['lncRNA', 'snRNA', 'antisenseRNA', 'telomeraseRNA',
43
+ 'RNaseMRPRNA', 'snoRNA']
44
+
45
+ # Global cache for MANE data
46
+ _MANE_CACHE = {
47
+ "mane_by_chrom": None,
48
+ "promoter_by_chrom": None,
49
+ "mane_parent_idx": None,
50
+ }
51
+
52
+
53
+ # ============================================================================
54
+ # HELPER FUNCTIONS
55
+ # ============================================================================
56
+ def collapse_region_class(region: str) -> str:
57
+ """
58
+ Collapse a comma-separated region annotation into a high-level class.
59
+
60
+ Priority order:
61
+ CODING > SPLICE > UTR_5 > UTR_3 > PROMOTER > INTRONIC > GENIC_OTHER > OTHER
62
+
63
+ Args:
64
+ region: Comma-separated string of annotation flags
65
+
66
+ Returns:
67
+ High-level region class string
68
+ """
69
+ if not isinstance(region, str) or not region.strip():
70
+ return "OTHER"
71
+
72
+ parts = {r.strip() for r in region.split(",")}
73
+
74
+ if {"coding_sequence", "start_codon", "stop_codon"} & parts:
75
+ if "mRNA_splice" in parts:
76
+ return "CODING, SPLICE"
77
+ return "CODING"
78
+
79
+ if "mRNA_splice" in parts:
80
+ return "SPLICE"
81
+
82
+ if "five_prime_UTR" in parts:
83
+ return "UTR_5"
84
+
85
+ if "three_prime_UTR" in parts:
86
+ return "UTR_3"
87
+
88
+ if "mRNA_promoter" in parts:
89
+ return "PROMOTER"
90
+
91
+ if "mRNA_intron" in parts:
92
+ return "INTRONIC"
93
+
94
+ if parts & {"gene", "mRNA", "mRNA_exon"}:
95
+ return "GENIC_OTHER"
96
+
97
+ return "OTHER"
98
+
99
+
100
+ def preprocess(mane_raw: pd.DataFrame, promoter_raw: pd.DataFrame) -> Tuple[Dict, Dict, Dict]:
101
+ """
102
+ Pre-cast types and build per-chromosome lookup structures.
103
+
104
+ Returns:
105
+ (mane_by_chrom, promoter_by_chrom, mane_parent_idx)
106
+ """
107
+ mane = mane_raw.copy()
108
+ promoter = promoter_raw.copy()
109
+
110
+ # Cast integer columns
111
+ mane['Start'] = mane['Start'].astype(np.int64)
112
+ mane['End'] = mane['End'].astype(np.int64)
113
+ promoter['Promoter_Start'] = promoter['Promoter_Start'].astype(np.int64)
114
+ promoter['Promoter_End'] = promoter['Promoter_End'].astype(np.int64)
115
+
116
+ # Normalize chromosome naming
117
+ mane['chrom_key'] = mane['Chromosome']
118
+ promoter['chrom_key'] = promoter['Chromosome'].astype(str)
119
+
120
+ # Group by chromosome
121
+ mane_by_chrom = {k: g for k, g in mane.groupby('chrom_key')}
122
+ promoter_by_chrom = {k: g for k, g in promoter.groupby('chrom_key')}
123
+
124
+ # Build parent-indexed lookups
125
+ mane_parent_idx = {}
126
+ for chrom, df in mane_by_chrom.items():
127
+ parent_groups = {}
128
+ for parent_val, grp in df.groupby('Parent', sort=False):
129
+ parent_groups[parent_val] = grp
130
+ mane_parent_idx[chrom] = parent_groups
131
+
132
+ return mane_by_chrom, promoter_by_chrom, mane_parent_idx
133
+
134
+
135
+ def annotate_variant(
136
+ chrom: str,
137
+ pos: int,
138
+ mane_by_chrom: Dict,
139
+ promoter_by_chrom: Dict,
140
+ mane_parent_idx: Dict
141
+ ) -> Tuple[dict, Set[str], Set[str]]:
142
+ """
143
+ Annotate a single variant at the given position.
144
+
145
+ Args:
146
+ chrom: Chromosome (e.g., 'chr17')
147
+ pos: 1-based genomic position
148
+ mane_by_chrom: MANE data grouped by chromosome
149
+ promoter_by_chrom: Promoter data grouped by chromosome
150
+ mane_parent_idx: Parent-indexed MANE data
151
+
152
+ Returns:
153
+ (annotation_dict, transcript_set, promoter_transcript_set)
154
+ """
155
+ result = {col: 0 for col in ANNOTATION_COLUMNS}
156
+ transcript_set = set()
157
+ promoter_transcript_set = set()
158
+
159
+ chrom_str = str(chrom)
160
+
161
+ # --- Overlap with MANE ---
162
+ mane_df = mane_by_chrom.get(chrom_str)
163
+ if mane_df is not None and len(mane_df) > 0:
164
+ mask = (mane_df['Start'].values <= pos) & (mane_df['End'].values >= pos)
165
+ annotation = mane_df[mask]
166
+ else:
167
+ annotation = pd.DataFrame()
168
+
169
+ # --- Overlap with Promoter ---
170
+ prom_df = promoter_by_chrom.get(chrom_str)
171
+ if prom_df is not None and len(prom_df) > 0:
172
+ mask = (prom_df['Promoter_Start'].values <= pos) & (prom_df['Promoter_End'].values >= pos)
173
+ annotation_promoter = prom_df[mask]
174
+ else:
175
+ annotation_promoter = pd.DataFrame()
176
+
177
+ if annotation.empty and annotation_promoter.empty:
178
+ result['other'] = 1
179
+ return result, transcript_set, promoter_transcript_set
180
+
181
+ types = set(annotation['Feature'].unique()) if not annotation.empty else set()
182
+ types_promoter = set(annotation_promoter['Feature'].unique()) if not annotation_promoter.empty else set()
183
+
184
+ # --- gene ---
185
+ if 'gene' in types:
186
+ result['gene'] = 1
187
+
188
+ # --- mRNA ---
189
+ if 'mRNA' in types:
190
+ result['mRNA'] = 1
191
+ tids = set(annotation.loc[annotation['Feature'] == 'mRNA', 'transcript_id'].dropna())
192
+ transcript_set.update(tids)
193
+
194
+ parent_idx = mane_parent_idx.get(chrom_str, {})
195
+
196
+ for tid in tids:
197
+ rna_key = f'rna-{tid}'
198
+
199
+ # Strand
200
+ id_match = annotation[annotation['ID'] == rna_key]
201
+ if id_match.empty:
202
+ continue
203
+ strand = id_match['Strand'].iloc[0]
204
+
205
+ # Exon/CDS overlapping this position
206
+ ann_exon = annotation[(annotation['Parent'] == rna_key) & (annotation['Feature'] == 'exon')]
207
+ ann_cds = annotation[(annotation['Parent'] == rna_key) & (annotation['Feature'] == 'CDS')]
208
+
209
+ # Full transcript exons/CDS
210
+ full_exon = parent_idx.get(rna_key)
211
+ if full_exon is not None:
212
+ tr_exon = full_exon[full_exon['Feature'] == 'exon']
213
+ tr_cds = full_exon[full_exon['Feature'] == 'CDS']
214
+ else:
215
+ tr_exon = pd.DataFrame()
216
+ tr_cds = pd.DataFrame()
217
+
218
+ if not ann_cds.empty and not ann_exon.empty:
219
+ # Exon + CDS
220
+ result['mRNA_exon'] = 1
221
+ result['coding_sequence'] = 1
222
+
223
+ if not tr_cds.empty:
224
+ cds_starts = tr_cds['Start'].values
225
+ cds_ends = tr_cds['End'].values
226
+ if strand == '+':
227
+ start_1 = cds_starts.min()
228
+ if start_1 <= pos <= start_1 + 2:
229
+ result['start_codon'] = 1
230
+ stop_3 = cds_ends.max()
231
+ if stop_3 - 2 <= pos <= stop_3:
232
+ result['stop_codon'] = 1
233
+ else:
234
+ start_1 = cds_ends.max()
235
+ if start_1 - 2 <= pos <= start_1:
236
+ result['start_codon'] = 1
237
+ stop_3 = cds_starts.min()
238
+ if stop_3 <= pos <= stop_3 + 2:
239
+ result['stop_codon'] = 1
240
+
241
+ elif ann_cds.empty and not ann_exon.empty:
242
+ # UTR
243
+ result['mRNA_exon'] = 1
244
+ if not tr_exon.empty and not tr_cds.empty:
245
+ exon_starts = tr_exon['Start'].values
246
+ exon_ends = tr_exon['End'].values
247
+ cds_starts = tr_cds['Start'].values
248
+ cds_ends = tr_cds['End'].values
249
+
250
+ if strand == '+':
251
+ five_start = exon_starts.min()
252
+ five_end = cds_starts.min() - 1
253
+ if five_start <= pos <= five_end:
254
+ result['five_prime_UTR'] = 1
255
+ three_start = cds_ends.max() + 1
256
+ three_end = exon_ends.max()
257
+ if three_start <= pos <= three_end:
258
+ result['three_prime_UTR'] = 1
259
+ else:
260
+ five_start = exon_ends.max()
261
+ five_end = cds_ends.max() + 1
262
+ if five_end <= pos <= five_start:
263
+ result['five_prime_UTR'] = 1
264
+ three_start = cds_starts.min() - 1
265
+ three_end = exon_starts.min()
266
+ if three_end <= pos <= three_start:
267
+ result['three_prime_UTR'] = 1
268
+
269
+ elif ann_cds.empty and ann_exon.empty:
270
+ # Intron
271
+ result['mRNA_intron'] = 1
272
+ if not tr_exon.empty:
273
+ ex_starts = tr_exon['Start'].values
274
+ ex_ends = tr_exon['End'].values
275
+ splice_positions = np.concatenate([
276
+ ex_starts - 1, ex_starts - 2,
277
+ ex_ends + 1, ex_ends + 2
278
+ ])
279
+ if pos in splice_positions:
280
+ result['mRNA_splice'] = 1
281
+
282
+ # --- mRNA promoter ---
283
+ if 'mRNA' in types_promoter:
284
+ result['mRNA_promoter'] = 1
285
+ tids = set(annotation_promoter.loc[
286
+ annotation_promoter['Feature'] == 'mRNA', 'transcript_id'
287
+ ].dropna())
288
+ promoter_transcript_set.update(tids)
289
+
290
+ # --- Other RNA types ---
291
+ for rna in RNA_TYPES:
292
+ if rna in types:
293
+ result[rna] = 1
294
+ tids = set(annotation.loc[annotation['Feature'] == rna, 'transcript_id'].dropna())
295
+ transcript_set.update(tids)
296
+ for tid in tids:
297
+ rna_key = f'rna-{tid}'
298
+ ann_exon = annotation[(annotation['Parent'] == rna_key) & (annotation['Feature'] == 'exon')]
299
+ if not ann_exon.empty:
300
+ result[f'{rna}_exon'] = 1
301
+
302
+ if rna in types_promoter:
303
+ result[f'{rna}_promoter'] = 1
304
+ tids = set(annotation_promoter.loc[
305
+ annotation_promoter['Feature'] == rna, 'transcript_id'
306
+ ].dropna())
307
+ promoter_transcript_set.update(tids)
308
+
309
+ return result, transcript_set, promoter_transcript_set
310
+
311
+
312
+ # ============================================================================
313
+ # PUBLIC API
314
+ # ============================================================================
315
+ def _load_or_convert(csv_path: Path, parquet_path: Path) -> pd.DataFrame:
316
+ """Load from parquet if available, otherwise read CSV and cache as parquet."""
317
+ if parquet_path.exists():
318
+ return pd.read_parquet(parquet_path)
319
+ df = pd.read_csv(csv_path)
320
+ try:
321
+ df.to_parquet(parquet_path, index=False)
322
+ print(f" Cached {parquet_path.name} for faster future loads")
323
+ except Exception as exc:
324
+ print(f" ⚠️ Parquet cache write failed: {exc}")
325
+ return df
326
+
327
+
328
+ def load_mane_data():
329
+ """Load and preprocess MANE and Promoter data. Caches globally."""
330
+ if _MANE_CACHE["mane_by_chrom"] is not None:
331
+ return # Already loaded
332
+
333
+ print(f"📚 Loading MANE annotation data from {DATA_DIR}...")
334
+ mane_raw = _load_or_convert(MANE_FILE, MANE_PARQUET)
335
+ promoter_raw = _load_or_convert(PROMOTER_FILE, PROMOTER_PARQUET)
336
+
337
+ print(f" MANE: {len(mane_raw):,} features")
338
+ print(f" Promoter: {len(promoter_raw):,} features")
339
+
340
+ mane_by_chrom, promoter_by_chrom, mane_parent_idx = preprocess(mane_raw, promoter_raw)
341
+
342
+ _MANE_CACHE.update({
343
+ "mane_by_chrom": mane_by_chrom,
344
+ "promoter_by_chrom": promoter_by_chrom,
345
+ "mane_parent_idx": mane_parent_idx,
346
+ })
347
+
348
+ print(f"✅ MANE data loaded: {len(mane_by_chrom)} chromosomes indexed")
349
+
350
+
351
+ def annotate_dataframe(df: pd.DataFrame) -> pd.DataFrame:
352
+ """
353
+ Add MANE annotations to a variants DataFrame.
354
+
355
+ Args:
356
+ df: DataFrame with 'chrom' and 'pos' columns
357
+
358
+ Returns:
359
+ DataFrame with added annotation columns:
360
+ - 30 binary flags (gene, mRNA, coding_sequence, start_codon, etc.)
361
+ - 'transcript_set', 'promoter_transcript_set' (sets of transcript IDs)
362
+ - 'region' (comma-separated list of active flags)
363
+ - 'region_class' (high-level category)
364
+ - 'gene_name' (from MANE, if available)
365
+ """
366
+ # Load MANE data if not already loaded
367
+ if _MANE_CACHE["mane_by_chrom"] is None:
368
+ load_mane_data()
369
+
370
+ mane_by_chrom = _MANE_CACHE["mane_by_chrom"]
371
+ promoter_by_chrom = _MANE_CACHE["promoter_by_chrom"]
372
+ mane_parent_idx = _MANE_CACHE["mane_parent_idx"]
373
+
374
+ # Validate input
375
+ if "chrom" not in df.columns or "pos" not in df.columns:
376
+ raise ValueError("DataFrame must have 'chrom' and 'pos' columns")
377
+
378
+ df = df.copy()
379
+ chroms = df['chrom'].values
380
+ positions = df['pos'].astype(np.int64).values
381
+
382
+ all_results = []
383
+ all_tsets = []
384
+ all_ptsets = []
385
+
386
+ for i in range(len(df)):
387
+ res, tset, ptset = annotate_variant(
388
+ chroms[i], positions[i],
389
+ mane_by_chrom, promoter_by_chrom, mane_parent_idx
390
+ )
391
+ all_results.append(res)
392
+ all_tsets.append(tset)
393
+ all_ptsets.append(ptset)
394
+
395
+ # Add annotation columns
396
+ ann_df = pd.DataFrame(all_results, index=df.index)
397
+ for col in ANNOTATION_COLUMNS:
398
+ df[col] = ann_df[col].values
399
+ df['transcript_set'] = all_tsets
400
+ df['promoter_transcript_set'] = all_ptsets
401
+
402
+ # Combine into region string
403
+ df['region'] = (
404
+ df[ANNOTATION_COLUMNS]
405
+ .apply(lambda r: ','.join(r.index[r == 1]), axis=1)
406
+ )
407
+
408
+ # Collapse to high-level class
409
+ df["region_class"] = df["region"].apply(collapse_region_class)
410
+
411
+ # Extract gene name from MANE (if available)
412
+ gene_names = []
413
+ for i in range(len(df)):
414
+ chrom_str = str(chroms[i])
415
+ pos = positions[i]
416
+ mane_df = mane_by_chrom.get(chrom_str)
417
+ gene_name = ""
418
+ if mane_df is not None:
419
+ mask = (mane_df['Start'].values <= pos) & (mane_df['End'].values >= pos)
420
+ overlaps = mane_df[mask]
421
+ if not overlaps.empty and 'gene' in overlaps.columns:
422
+ genes = overlaps['gene'].dropna().unique()
423
+ if len(genes) > 0:
424
+ gene_name = genes[0]
425
+ gene_names.append(gene_name)
426
+
427
+ df['gene_name'] = gene_names
428
+
429
+ return df
app.py ADDED
@@ -0,0 +1,1146 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ #!/usr/bin/env python3
2
+ """
3
+ MAGI Variant Interpreter - Gradio Web Interface
4
+ ===============================================
5
+ Interactive web app for scoring and summarizing the predicted effects of
6
+ human genomic variants.
7
+
8
+ The app uses the external NTv3 foundation model together with MAGI scoring,
9
+ annotation, and interpretation layers.
10
+
11
+ Usage:
12
+ python app.py
13
+
14
+ For HuggingFace Spaces deployment with ZeroGPU:
15
+ Uses @spaces.GPU decorator for GPU bursting
16
+ """
17
+
18
+ import os
19
+ import warnings
20
+ from pathlib import Path
21
+
22
+ import torch
23
+ import gradio as gr
24
+ import pandas as pd
25
+ import numpy as np
26
+ import matplotlib.pyplot as plt
27
+
28
+ # Import our modules
29
+ from inference import (
30
+ CONTEXT_LEN,
31
+ predict_variants,
32
+ load_model_and_resources,
33
+ _MODEL_CACHE,
34
+ SUPPORTED_UI_SPECIES,
35
+ )
36
+ from annotation import annotate_dataframe, load_mane_data, _MANE_CACHE
37
+ from analysis import (
38
+ rank_top_disrupted_tracks,
39
+ build_top_track_table,
40
+ compute_impact_scores,
41
+ extract_top_summary_signals,
42
+ summarize_variant,
43
+ get_top_bigwig_descriptions,
44
+ make_fingerprint_plot,
45
+ identify_delta_columns,
46
+ format_summary_table,
47
+ )
48
+ from tracks import generate_region_tracks_plot, get_track_view_bounds
49
+ from interpretation import build_signal_interpretation
50
+
51
+ # Suppress warnings for cleaner UI
52
+ warnings.filterwarnings("ignore")
53
+
54
+ # ============================================================================
55
+ # CONFIGURATION
56
+ # ============================================================================
57
+ BASE_DIR = Path(__file__).parent
58
+ DATA_DIR = BASE_DIR / "data"
59
+ EXAMPLES_DIR = BASE_DIR / "examples"
60
+ METADATA_FILE = DATA_DIR / "functional_tracks_metadata_human.csv"
61
+ DEFAULT_ZOOM_BP = 128
62
+ MIN_ZOOM_BP = 8
63
+ INITIAL_ZOOM_MAX_BP = CONTEXT_LEN // 2
64
+ ANNOTATION_FLAGS = [
65
+ "gene",
66
+ "mRNA",
67
+ "mRNA_promoter",
68
+ "mRNA_exon",
69
+ "coding_sequence",
70
+ "start_codon",
71
+ "stop_codon",
72
+ "five_prime_UTR",
73
+ "three_prime_UTR",
74
+ "mRNA_intron",
75
+ "mRNA_splice",
76
+ "lncRNA",
77
+ "lncRNA_promoter",
78
+ "lncRNA_exon",
79
+ "snRNA",
80
+ "snRNA_promoter",
81
+ "snRNA_exon",
82
+ "antisenseRNA",
83
+ "antisenseRNA_promoter",
84
+ "antisenseRNA_exon",
85
+ "telomeraseRNA",
86
+ "telomeraseRNA_promoter",
87
+ "telomeraseRNA_exon",
88
+ "RNaseMRPRNA",
89
+ "RNaseMRPRNA_promoter",
90
+ "RNaseMRPRNA_exon",
91
+ "snoRNA",
92
+ "snoRNA_promoter",
93
+ "snoRNA_exon",
94
+ "other",
95
+ ]
96
+
97
+
98
+ def _export_figure_png(fig, file_name: str, dpi: int = 300):
99
+ """Persist a Matplotlib figure for figure-ready download."""
100
+ if fig is None:
101
+ return None
102
+
103
+ output_path = BASE_DIR / file_name
104
+ fig.savefig(output_path, dpi=dpi, bbox_inches="tight")
105
+ return str(output_path)
106
+
107
+
108
+ def _is_human_species(species: str) -> bool:
109
+ return str(species).strip() == "human"
110
+
111
+
112
+ def _species_label(species: str) -> str:
113
+ return SUPPORTED_UI_SPECIES.get(
114
+ str(species).strip(), str(species).replace("_", " ").title()
115
+ )
116
+
117
+
118
+ def _apply_species_annotations(results_df: pd.DataFrame, species: str) -> pd.DataFrame:
119
+ if _is_human_species(species):
120
+ return annotate_dataframe(results_df)
121
+
122
+ annotated = results_df.copy()
123
+ for col in ANNOTATION_FLAGS:
124
+ annotated[col] = 0
125
+ annotated["transcript_set"] = [set() for _ in range(len(annotated))]
126
+ annotated["promoter_transcript_set"] = [set() for _ in range(len(annotated))]
127
+ annotated["region"] = "non_human_unavailable"
128
+ annotated["region_class"] = "NON_HUMAN"
129
+ annotated["gene_name"] = "N/A (non-human)"
130
+ return annotated
131
+
132
+
133
+ def _build_zoom_slider_update(requested_zoom_bp):
134
+ """Update the zoom slider to the current variant's available track-view span."""
135
+ try:
136
+ requested_zoom_bp = max(MIN_ZOOM_BP, int(requested_zoom_bp))
137
+ except (TypeError, ValueError):
138
+ requested_zoom_bp = DEFAULT_ZOOM_BP
139
+
140
+ view_bounds = get_track_view_bounds()
141
+ max_radius = view_bounds.get("max_radius")
142
+ if max_radius is None:
143
+ return gr.update()
144
+
145
+ return gr.update(
146
+ value=min(requested_zoom_bp, max_radius),
147
+ maximum=max_radius,
148
+ info=f"Current available radius for this prediction: up to {max_radius:,} bp",
149
+ )
150
+
151
+
152
+ def predict_single_variant_ui(species, chrom, pos, ref, alt, zoom_bp=DEFAULT_ZOOM_BP):
153
+ """UI wrapper: run prediction and prepare user-facing outputs."""
154
+ result = predict_single_variant(species, chrom, pos, ref, alt, zoom_bp=zoom_bp)
155
+ # Engine returns 9 values
156
+ (
157
+ summary_md,
158
+ interpretation_md,
159
+ top_table_df,
160
+ fingerprint_fig,
161
+ region_tracks_fig,
162
+ csv_path,
163
+ bed_table_df,
164
+ mlm_md,
165
+ ranked,
166
+ ) = result
167
+
168
+ tracks_png_path = None
169
+ try:
170
+ tracks_png_path = _export_figure_png(
171
+ region_tracks_fig, "last_variant_tracks.png"
172
+ )
173
+ except Exception as exc:
174
+ print(f"\u26a0\ufe0f Track PNG export failed: {exc}")
175
+
176
+ zoom_update = gr.update()
177
+ if isinstance(ranked, list):
178
+ zoom_update = _build_zoom_slider_update(zoom_bp)
179
+
180
+ return (
181
+ summary_md,
182
+ interpretation_md,
183
+ top_table_df,
184
+ fingerprint_fig,
185
+ region_tracks_fig,
186
+ csv_path,
187
+ tracks_png_path,
188
+ bed_table_df,
189
+ mlm_md,
190
+ zoom_update,
191
+ ranked[:50]
192
+ if isinstance(ranked, list)
193
+ else ranked, # → gr.State for zoom slider
194
+ )
195
+
196
+
197
+ def _update_region_zoom(zoom_bp, ranked, track_filter="All"):
198
+ """Re-render region tracks plot when the zoom slider or filter changes."""
199
+ if not ranked:
200
+ return None, None
201
+ try:
202
+ filtered = ranked
203
+ if track_filter and track_filter != "All":
204
+ filtered = [r for r in ranked if r["track_type"] == track_filter]
205
+ if not filtered:
206
+ filtered = ranked
207
+ fig = generate_region_tracks_plot(
208
+ ranked_tracks=filtered,
209
+ max_ranked_tracks=10,
210
+ visible_radius_bp=max(int(zoom_bp), 8),
211
+ )
212
+ png_path = _export_figure_png(fig, "last_variant_tracks.png")
213
+ return fig, png_path
214
+ except Exception as exc:
215
+ print(f"\u26a0\ufe0f Zoom re-render failed: {exc}")
216
+ return None, None
217
+
218
+
219
+ def predict_single_variant_wrapper(
220
+ species, chrom, pos, ref, alt, zoom_bp=DEFAULT_ZOOM_BP
221
+ ):
222
+ """Wrapper with ZeroGPU decorator if available."""
223
+ return predict_single_variant_ui(species, chrom, pos, ref, alt, zoom_bp)
224
+
225
+
226
+ # Check if running on HuggingFace Spaces with ZeroGPU
227
+ try:
228
+ import spaces
229
+
230
+ HAS_SPACES = True
231
+ print("\u2705 Running with HuggingFace Spaces ZeroGPU support")
232
+ except ImportError:
233
+ spaces = None
234
+ HAS_SPACES = False
235
+ print("\u26a0\ufe0f Not running on HuggingFace Spaces - using local GPU/CPU")
236
+
237
+
238
+ # Apply decorators if available
239
+ if HAS_SPACES and spaces is not None:
240
+ predict_single_variant_wrapper = spaces.GPU(predict_single_variant_wrapper)
241
+
242
+ # Device selection: prefer CUDA if available, fall back to CPU
243
+ if os.environ.get("SPACES_GPU") or (HAS_SPACES and torch.cuda.is_available()):
244
+ DEVICE = "cuda"
245
+ elif torch.cuda.is_available():
246
+ DEVICE = "cuda"
247
+ else:
248
+ DEVICE = "cpu"
249
+ print(f"⚙️ Using device: {DEVICE}")
250
+
251
+ # Load metadata for BigWig track descriptions
252
+ TRACK_METADATA = pd.read_csv(METADATA_FILE)
253
+
254
+ # Pre-build metadata lookup dictionary for O(1) access
255
+ TRACK_META_DICT = {
256
+ row["file_id"]: {
257
+ "tissue": str(row.get("tissue", "") or ""),
258
+ "assay": str(row.get("assay", "") or ""),
259
+ "target": str(row.get("experiment_target", "") or ""),
260
+ }
261
+ for _, row in TRACK_METADATA.iterrows()
262
+ }
263
+
264
+
265
+ # ============================================================================
266
+ # STARTUP - LOAD MODEL AND ANNOTATION DATA
267
+ # ============================================================================
268
+ def initialize_app():
269
+ """Load model and annotation data at startup."""
270
+ print("🚀 Initializing MAGI Variant Interpreter...")
271
+ try:
272
+ load_model_and_resources(device=DEVICE)
273
+ load_mane_data()
274
+ print("✅ App ready!")
275
+ return True
276
+ except Exception as e:
277
+ print(f"❌ Model initialization failed: {e}")
278
+ import traceback
279
+
280
+ traceback.print_exc()
281
+ return False
282
+
283
+
284
+ # Initialize on import
285
+ _APP_READY = initialize_app()
286
+
287
+
288
+ # ============================================================================
289
+ # UTILITY HELPERS
290
+ # ============================================================================
291
+ def _fmt(val, fmt: str = ".4f", na: str = "N/A") -> str:
292
+ """Safely format a numeric value; return 'na' string for NaN/None."""
293
+ if val is None or (isinstance(val, float) and np.isnan(val)):
294
+ return na
295
+ try:
296
+ return format(float(val), fmt)
297
+ except (TypeError, ValueError):
298
+ return na
299
+
300
+
301
+ def _render_track_signal_block(title: str, items) -> str:
302
+ """Render compact BED/BigWig bullets for the summary card."""
303
+ if not items:
304
+ return f"**{title}**\n- none above 3% absolute change"
305
+
306
+ lines = [f"**{title}**"]
307
+ for item in items:
308
+ lines.append(
309
+ "- "
310
+ f"{item['label']}: {item['delta']:+.1%} "
311
+ f"({_fmt(item['ref_val'])} → {_fmt(item['alt_val'])})"
312
+ )
313
+ return "\n".join(lines)
314
+
315
+
316
+ def _render_mlm_signal_block(items) -> str:
317
+ """Render compact sequence-model bullets for the summary card."""
318
+ if not items:
319
+ return "**Top sequence-model signals**\n- none above 0.03 absolute magnitude"
320
+
321
+ lines = ["**Top sequence-model signals**"]
322
+ for item in items:
323
+ value_fmt = (
324
+ f"{item['value']:+.4f}"
325
+ if item.get("signed", False)
326
+ else f"{item['value']:.4f}"
327
+ )
328
+ lines.append(f"- {item['label']}: {value_fmt}")
329
+ return "\n".join(lines)
330
+
331
+
332
+ def _build_top_signal_summary(signals, include_bigwig: bool = True) -> str:
333
+ """Build the top-signal markdown block for the summary card."""
334
+ blocks = [
335
+ "#### Top Signals",
336
+ _render_track_signal_block("Top BED signals", signals.get("bed", [])),
337
+ _render_mlm_signal_block(signals.get("mlm", [])),
338
+ ]
339
+ if include_bigwig:
340
+ blocks.insert(
341
+ 2,
342
+ _render_track_signal_block("Top BigWig signals", signals.get("bigwig", [])),
343
+ )
344
+ return "\n\n".join(blocks)
345
+
346
+
347
+ def _validate_allele(allele: str) -> bool:
348
+ """Return True if allele contains only valid nucleotide characters."""
349
+ return bool(allele) and all(c in "ACGTNacgtn" for c in allele.strip())
350
+
351
+
352
+ # ============================================================================
353
+ # SINGLE VARIANT PREDICTION
354
+ # ============================================================================
355
+ def predict_single_variant(
356
+ species: str,
357
+ chrom: str,
358
+ pos: int,
359
+ ref: str,
360
+ alt: str,
361
+ zoom_bp: int = DEFAULT_ZOOM_BP,
362
+ ):
363
+ """
364
+ Predict functional impact for a single variant.
365
+
366
+ Returns:
367
+ Tuple of (summary_md, interpretation_md, top_table_df,
368
+ ranked_tracks=ranked,
369
+ max_ranked_tracks=15,
370
+ bed_table_df, mlm_md, ranked_tracks)
371
+ """
372
+ _none9 = (None,) * 9
373
+
374
+ try:
375
+ # Validate inputs
376
+ if not chrom or not ref or not alt:
377
+ return (
378
+ "❌ Error: Please provide all required fields (Chromosome, Position, Ref, Alt)",
379
+ *_none9[1:],
380
+ )
381
+
382
+ if pos <= 0:
383
+ return (
384
+ "❌ Error: Position must be a positive integer",
385
+ *_none9[1:],
386
+ )
387
+
388
+ ref_clean = ref.upper().strip()
389
+ alt_clean = alt.upper().strip()
390
+
391
+ if not _validate_allele(ref_clean):
392
+ return (
393
+ f"❌ Error: REF allele '{ref}' contains invalid characters. Use A/C/G/T/N only.",
394
+ *_none9[1:],
395
+ )
396
+ if not _validate_allele(alt_clean):
397
+ return (
398
+ f"❌ Error: ALT allele '{alt}' contains invalid characters. Use A/C/G/T/N only.",
399
+ *_none9[1:],
400
+ )
401
+
402
+ # Create input DataFrame
403
+ input_df = pd.DataFrame(
404
+ [
405
+ {
406
+ "chrom": chrom,
407
+ "pos": int(pos),
408
+ "ref": ref_clean,
409
+ "alt": alt_clean,
410
+ }
411
+ ]
412
+ )
413
+
414
+ # Run inference
415
+ species = str(species).strip()
416
+ print(f"🔬 Predicting {species} {chrom}:{pos} {ref_clean}>{alt_clean}...")
417
+ results_df = predict_variants(input_df, device=DEVICE, species=species)
418
+
419
+ # Annotate
420
+ results_df = _apply_species_annotations(results_df, species)
421
+
422
+ # Compute impact scores
423
+ results_df = compute_impact_scores(results_df)
424
+
425
+ # Extract data
426
+ row = results_df.iloc[0]
427
+
428
+ gene = row.get("gene_name", "Unknown")
429
+ region = row.get("region_class", "OTHER")
430
+ impact_bed = row.get("Impact_Score_BED", np.nan)
431
+ impact_bw = row.get("Impact_Score_BW", np.nan)
432
+ magi_score = row.get("Global_z_sum_log", np.nan)
433
+ llr = row.get("LLR", np.nan)
434
+ kl_mean = row.get("MLM_KL_mean", np.nan)
435
+ indel_size = row.get("indel_size", 0)
436
+ transcript_set = row.get("transcript_set", set())
437
+
438
+ variant_type = "Indel" if indel_size != 0 else "SNP"
439
+ indel_label = f" ({int(indel_size):+d} bp)" if indel_size != 0 else ""
440
+
441
+ if pd.isna(impact_bed):
442
+ impact_level = "**unknown**"
443
+ elif impact_bed > 0.1:
444
+ impact_level = "**high**"
445
+ elif impact_bed > 0.05:
446
+ impact_level = "**moderate**"
447
+ else:
448
+ impact_level = "**low**"
449
+
450
+ llr_note = " *(N/A for indels)*" if pd.isna(llr) else ""
451
+
452
+ transcript_str = ""
453
+ if transcript_set and len(transcript_set) > 0:
454
+ transcript_ids = ", ".join(sorted(list(transcript_set))[:3])
455
+ transcript_str = f" \n**Transcripts:** `{transcript_ids}`"
456
+
457
+ context_start = max(1, pos - CONTEXT_LEN // 2)
458
+ context_end = pos + CONTEXT_LEN // 2
459
+
460
+ # Inline genomic context tags (replaces separate Annotation Detail accordion)
461
+ active_flags = [
462
+ flag
463
+ for flag in ANNOTATION_FLAGS
464
+ if flag in row.index and row.get(flag, 0) == 1
465
+ ]
466
+ if not _is_human_species(species):
467
+ context_tags = "Transcript-level human MANE annotation is not available for this species in the web app."
468
+ elif active_flags:
469
+ context_tags = " · ".join(f"`{f}`" for f in active_flags)
470
+ else:
471
+ context_tags = "Intergenic (no annotated features)"
472
+
473
+ # === 1. Unified ranking → fingerprint, table, region tracks ===
474
+ bed_names = _MODEL_CACHE.get("bed_names", [])
475
+ bw_names_selected = _MODEL_CACHE.get("selected_bw_indices", [])
476
+ bw_names_all = _MODEL_CACHE.get("bigwig_names", [])
477
+ bw_names_filtered = (
478
+ [bw_names_all[i] for i in bw_names_selected]
479
+ if _is_human_species(species) and bw_names_selected and bw_names_all
480
+ else None
481
+ )
482
+
483
+ ranked = rank_top_disrupted_tracks(
484
+ row,
485
+ bed_names,
486
+ bw_names_filtered,
487
+ metadata_df=TRACK_METADATA,
488
+ metadata_dict=TRACK_META_DICT,
489
+ top_k=None,
490
+ )
491
+
492
+ top_table_df = build_top_track_table(
493
+ ranked,
494
+ max_rows=50,
495
+ min_rows_by_type={"BED": 5},
496
+ )
497
+
498
+ fingerprint_fig = make_fingerprint_plot(ranked, top_k=15)
499
+
500
+ interpretation_md = build_signal_interpretation(row, ranked, variant_type)
501
+
502
+ summary_signals = extract_top_summary_signals(
503
+ row,
504
+ ranked,
505
+ min_abs_threshold=0.03,
506
+ )
507
+ top_signal_md = _build_top_signal_summary(
508
+ summary_signals,
509
+ include_bigwig=_is_human_species(species),
510
+ )
511
+
512
+ species_name = _species_label(species)
513
+ bigwig_value = (
514
+ _fmt(impact_bw)
515
+ if _is_human_species(species)
516
+ else "N/A (human-only context tracks)"
517
+ )
518
+ annotation_note = (
519
+ ""
520
+ if _is_human_species(species)
521
+ else "\n**Non-human note:** BigWig context tracks and MANE transcript annotation are not available in this app for non-human species."
522
+ )
523
+
524
+ summary_md = f"""
525
+ ### Variant Summary
526
+
527
+ **Variant:** `{chrom}:{pos} {ref_clean}>{alt_clean}` ({variant_type}{indel_label})  
528
+ **Species:** {species_name} (`{species}`)  
529
+ **Gene:** {gene if gene else "Intergenic"}  
530
+ **Region:** {region}{transcript_str}  
531
+ **Genomic context:** {context_tags}
532
+
533
+ {top_signal_md}
534
+
535
+ | Metric | Value |
536
+ |--------|-------|
537
+ | MAGI score (`Global_z_sum_log`) | {_fmt(magi_score)} |
538
+ | BED Impact Score | {_fmt(impact_bed)} |
539
+ | BigWig Impact Score | {bigwig_value} |
540
+ | LLR | {_fmt(llr)}{llr_note} |
541
+ | KL Divergence (mean) | {_fmt(kl_mean)} |
542
+
543
+ **Analysis window:** `{chrom}:{context_start:,}-{context_end:,}` ({context_end - context_start:,} bp)
544
+ **BED-based summary tier:** {impact_level}. This tier reflects `Impact_Score_BED` only.{annotation_note}
545
+ """
546
+
547
+ # === 2. BED Table (all 21 elements) ===
548
+ bed_table = []
549
+ for name in bed_names:
550
+ ref_col = f"REF_BED_{name}"
551
+ delta_col = f"D_BED_{name}"
552
+ if ref_col in row.index and delta_col in row.index:
553
+ ref_val = row[ref_col]
554
+ delta_val = row[delta_col]
555
+ alt_val = (
556
+ ref_val + delta_val
557
+ if not pd.isna(ref_val) and not pd.isna(delta_val)
558
+ else np.nan
559
+ )
560
+ bed_table.append(
561
+ {
562
+ "Element": name,
563
+ "REF": f"{ref_val:.4f}" if not pd.isna(ref_val) else "N/A",
564
+ "ALT": f"{alt_val:.4f}" if not pd.isna(alt_val) else "N/A",
565
+ "Delta": f"{delta_val:+.5f}"
566
+ if not pd.isna(delta_val)
567
+ else "N/A",
568
+ }
569
+ )
570
+ bed_table_df = pd.DataFrame(bed_table)
571
+
572
+ # === 3. Sequence model metrics ===
573
+ kl_max = row.get("MLM_KL_max", np.nan)
574
+ logprob_ref = row.get("MLM_logprob_ref", np.nan)
575
+ logprob_alt = row.get("MLM_logprob_alt", np.nan)
576
+ ref_5mer = row.get("REF_5mer", "N/A") or "N/A"
577
+ alt_5mer = row.get("ALT_5mer", "N/A") or "N/A"
578
+
579
+ mlm_md = f"""
580
+ These metrics summarize how the NTv3 model responds to the alternate sequence relative to the reference:
581
+
582
+ - **Variant type:** {variant_type}{indel_label}
583
+ - **LLR (Log-Likelihood Ratio):** {_fmt(llr)}{" *(SNPs only — N/A for indels)*" if pd.isna(llr) else ""}
584
+ - **KL Divergence (mean):** {_fmt(kl_mean)}
585
+ Average KL(ALT \u2225\u2225 REF) across the evaluated span
586
+ - **KL Divergence (max):** {_fmt(kl_max)}
587
+ - **Log-prob REF:** {_fmt(logprob_ref, fmt=".4f")}
588
+ - **Log-prob ALT:** {_fmt(logprob_alt, fmt=".4f")}
589
+ - **Context (REF):** `{ref_5mer}`
590
+ - **Context (ALT):** `{alt_5mer}`
591
+ """
592
+
593
+ if abs(indel_size) > 0:
594
+ emb_cosine = row.get("EMB_cosine_dist", np.nan)
595
+ emb_l2 = row.get("EMB_l2_dist", np.nan)
596
+ emb_max = row.get("EMB_max_pos_dist", np.nan)
597
+ emb_mean = row.get("EMB_mean_pos_dist", np.nan)
598
+ mlm_md += f"""
599
+ **Embedding Distance Metrics (Indel-specific):**
600
+ - Cosine Distance: {_fmt(emb_cosine)}
601
+ - L2 Distance: {_fmt(emb_l2)}
602
+ - Max per-position dist: {_fmt(emb_max)}
603
+ - Mean per-position dist: {_fmt(emb_mean)}
604
+ """
605
+
606
+ # === 4. CSV Export ===
607
+ try:
608
+ result_csv_path = str(BASE_DIR / "last_variant_result.csv")
609
+ results_df.to_csv(result_csv_path, index=False)
610
+ except Exception as e:
611
+ print(f"⚠️ CSV export failed: {e}")
612
+ result_csv_path = None
613
+
614
+ # === 5. Region Tracks Plot (uses unified ranking) ===
615
+ try:
616
+ region_tracks_fig = generate_region_tracks_plot(
617
+ ranked_tracks=ranked,
618
+ max_ranked_tracks=10,
619
+ visible_radius_bp=max(int(zoom_bp), 8),
620
+ )
621
+ except Exception as e:
622
+ print(f"⚠️ Region tracks plot failed: {e}")
623
+ region_tracks_fig = None
624
+
625
+ return (
626
+ summary_md,
627
+ interpretation_md,
628
+ top_table_df,
629
+ fingerprint_fig,
630
+ region_tracks_fig,
631
+ result_csv_path,
632
+ bed_table_df,
633
+ mlm_md,
634
+ ranked, # stashed for slider re-renders
635
+ )
636
+
637
+ except Exception as e:
638
+ import traceback
639
+
640
+ error_msg = f"❌ **Error during prediction:**\n\n```\n{str(e)}\n```\n\n**Traceback:**\n```\n{traceback.format_exc()}\n```"
641
+ return (error_msg, None, None, None, None, None, None, None, None)
642
+
643
+
644
+ # ============================================================================
645
+ # BATCH PREDICTION
646
+ # ============================================================================
647
+ def predict_batch_wrapper(species, file):
648
+ """Wrapper with ZeroGPU decorator if available."""
649
+ return predict_batch(species, file)
650
+
651
+
652
+ # Apply batch decorator if available
653
+ if HAS_SPACES and spaces is not None:
654
+ predict_batch_wrapper = spaces.GPU(predict_batch_wrapper)
655
+
656
+
657
+ def predict_batch(species, file):
658
+ """
659
+ Predict functional impact for multiple variants from CSV.
660
+
661
+ Args:
662
+ file: Uploaded CSV file with columns [chrom, pos, ref, alt]
663
+
664
+ Returns:
665
+ Tuple of (preview_df, download_file_path)
666
+ """
667
+ try:
668
+ if file is None:
669
+ return (pd.DataFrame([{"Error": "No file uploaded"}]), None)
670
+
671
+ # Read CSV — handle both Gradio 3.x (file object) and 4.x (path string)
672
+ file_path = file if isinstance(file, str) else file.name
673
+ input_df = pd.read_csv(file_path)
674
+
675
+ # Validate columns
676
+ required = {"chrom", "pos", "ref", "alt"}
677
+ missing = required - set(input_df.columns)
678
+ if missing:
679
+ return (pd.DataFrame([{"Error": f"Missing columns: {missing}"}]), None)
680
+
681
+ # Limit batch size
682
+ if len(input_df) > 10:
683
+ return (
684
+ pd.DataFrame(
685
+ [
686
+ {
687
+ "Error": f"Batch size limited to 10 variants (received {len(input_df)})"
688
+ }
689
+ ]
690
+ ),
691
+ None,
692
+ )
693
+
694
+ species = str(species).strip()
695
+ print(f"🔬 Processing batch of {len(input_df)} variants for {species}...")
696
+
697
+ # Run inference
698
+ results_df = predict_variants(input_df, device=DEVICE, species=species)
699
+
700
+ # Annotate
701
+ results_df = _apply_species_annotations(results_df, species)
702
+
703
+ # Compute impact scores
704
+ results_df = compute_impact_scores(results_df)
705
+
706
+ # Save full results
707
+ output_path = BASE_DIR / "batch_results.csv"
708
+ results_df.to_csv(output_path, index=False)
709
+
710
+ # Create preview (summary columns)
711
+ preview_df = format_summary_table(results_df)
712
+
713
+ print(f"✅ Batch processing complete: {len(results_df)} variants")
714
+
715
+ return (preview_df, str(output_path))
716
+
717
+ except Exception as e:
718
+ import traceback
719
+
720
+ error_msg = f"Error: {str(e)}\n\n{traceback.format_exc()}"
721
+ return (pd.DataFrame([{"Error": error_msg}]), None)
722
+
723
+
724
+ # ============================================================================
725
+ # GRADIO INTERFACE
726
+ # ============================================================================
727
+ def build_interface():
728
+ """Build the Gradio interface."""
729
+
730
+ # Custom CSS
731
+ custom_css = """
732
+ .variant-header {
733
+ background: linear-gradient(135deg, #667eea 0%, #764ba2 100%);
734
+ color: white;
735
+ padding: 2rem;
736
+ border-radius: 10px;
737
+ margin-bottom: 2rem;
738
+ }
739
+ .metric-card {
740
+ background: #f8f9fa;
741
+ padding: 1rem;
742
+ border-radius: 8px;
743
+ border-left: 4px solid #667eea;
744
+ }
745
+ """
746
+
747
+ # Build status line from cached model info (safe — loads at startup)
748
+ _seq_src = _MODEL_CACHE.get("sequence_source", "ucsc")
749
+ _bed_n = len(_MODEL_CACHE.get("bed_names", []) or [])
750
+ _bw_n = len(_MODEL_CACHE.get("selected_bw_indices", []) or [])
751
+ _species_choices = [
752
+ (f"{label} ({species})", species)
753
+ for species, label in SUPPORTED_UI_SPECIES.items()
754
+ ]
755
+
756
+ if _APP_READY:
757
+ _status_line = (
758
+ "🟢 **Model ready** — "
759
+ f"Device: `{DEVICE}` • "
760
+ f"Sequence source: `{_seq_src}` • "
761
+ f"Tracks: {_bed_n} BED + {_bw_n} BigWig"
762
+ )
763
+ else:
764
+ _status_line = "🔴 **Model failed to load** — Check configuration and logs"
765
+
766
+ # Gradio 6.x: create Blocks without theme/css, then set as attributes
767
+ app = gr.Blocks(title="MAGI Variant Interpreter")
768
+ app.theme = gr.themes.Soft()
769
+ app.css = custom_css
770
+
771
+ with app:
772
+ gr.Markdown(
773
+ """
774
+ # 🧬 MAGI Variant Interpreter
775
+
776
+ **Variant interpretation demo built on the NTv3 foundation model**
777
+
778
+ MAGI combines NTv3 sequence predictions with annotation, ranking, and rule-based summaries to help review variant-associated signals in a local genomic window.
779
+
780
+ The app reports changes across:
781
+ - **Regulatory elements** (promoters, enhancers, CTCF sites)
782
+ - **Structural features** (coding sequence, splice sites, UTRs)
783
+ - **Epigenetic marks** (histone modifications, chromatin accessibility)
784
+ - **Context-dependent tracks** (for example CAGE and chromatin assays)
785
+
786
+ **Attribution:** MAGI was developed by Dan Ofer, Stav Zok, and Michal Linial.
787
+
788
+ **This web app** extends MAGI with multi-species support: 15+ animals and 6 plants can be scored via Ensembl REST APIs for on-the-fly sequence fetching. Human remains the primary supported species with full BigWig and MANE transcript annotation.
789
+ """,
790
+ elem_classes="variant-header",
791
+ )
792
+ gr.Markdown(_status_line)
793
+
794
+ with gr.Tabs():
795
+ # ===================================================================
796
+ # TAB 1: SINGLE VARIANT
797
+ # ===================================================================
798
+ with gr.Tab("🔬 Single Variant Analysis"):
799
+ gr.Markdown("""
800
+ ### Manual Variant Input
801
+ Enter a single variant for detailed scoring and interpretation.
802
+ Human defaults to **GRCh38/hg38**. For non-human species, coordinates should match the selected species assembly available through Ensembl.
803
+ """)
804
+
805
+ with gr.Row():
806
+ with gr.Column(scale=1):
807
+ species_input = gr.Dropdown(
808
+ choices=_species_choices,
809
+ label="Species",
810
+ value="human",
811
+ )
812
+ chrom_input = gr.Textbox(
813
+ label="Chromosome",
814
+ value="chr17",
815
+ max_lines=1,
816
+ )
817
+ gr.Markdown(
818
+ "*Human: `chr1`–`chr22`, `chrX`, `chrY`, `chrM`. "
819
+ "Non-human: bare names (e.g., `1`, `X`, `MT`) or with `chr` prefix. "
820
+ "Coordinates should match your species' current Ensembl assembly.*"
821
+ )
822
+ pos_input = gr.Number(
823
+ label="Position (1-based)",
824
+ value=7675088,
825
+ precision=0,
826
+ )
827
+ with gr.Column(scale=1):
828
+ ref_input = gr.Textbox(
829
+ label="Reference Allele",
830
+ value="C",
831
+ max_lines=1,
832
+ )
833
+ alt_input = gr.Textbox(
834
+ label="Alternate Allele",
835
+ value="T",
836
+ max_lines=1,
837
+ )
838
+
839
+ predict_btn = gr.Button(
840
+ "🚀 Predict Impact", variant="primary", size="lg"
841
+ )
842
+
843
+ with gr.Accordion("📋 Example Variants", open=False):
844
+ gr.Examples(
845
+ examples=[
846
+ [
847
+ "human",
848
+ "chr17",
849
+ 7675088,
850
+ "C",
851
+ "T",
852
+ ], # TP53 R175H — Pathogenic missense, most common cancer driver mutation
853
+ [
854
+ "human",
855
+ "chr7",
856
+ 117559593,
857
+ "ATCT",
858
+ "A",
859
+ ], # CFTR F508del — Pathogenic 3-bp deletion, ~70% of cystic fibrosis alleles
860
+ [
861
+ "human",
862
+ "chr13",
863
+ 32332771,
864
+ "AGAGA",
865
+ "AGA",
866
+ ], # BRCA2 c.5946delT — Pathogenic frameshift, hereditary breast/ovarian cancer
867
+ [
868
+ "human",
869
+ "chr11",
870
+ 5227002,
871
+ "T",
872
+ "A",
873
+ ], # HBB E6V (rs334) — Pathogenic missense, causes sickle cell disease
874
+ [
875
+ "human",
876
+ "chr17",
877
+ 43092418,
878
+ "T",
879
+ "C",
880
+ ], # BRCA1 c.3113A>G (rs16941) — Benign synonymous variant
881
+ ],
882
+ inputs=[
883
+ species_input,
884
+ chrom_input,
885
+ pos_input,
886
+ ref_input,
887
+ alt_input,
888
+ ],
889
+ label="Click to load pre-filled examples",
890
+ )
891
+
892
+ gr.Markdown("---")
893
+ gr.Markdown("### Results")
894
+
895
+ with gr.Row():
896
+ with gr.Column(scale=1):
897
+ summary_output = gr.Markdown()
898
+ with gr.Column(scale=1):
899
+ interpretation_output = gr.Markdown()
900
+
901
+ with gr.Row():
902
+ with gr.Column(scale=2):
903
+ top_table_output = gr.DataFrame(
904
+ label="Top ranked tracks (BED + BigWig, ordered by |Δ|)",
905
+ wrap=True,
906
+ max_height=600,
907
+ )
908
+ with gr.Column(scale=3):
909
+ fingerprint_output = gr.Plot(label="Top signals bar chart")
910
+
911
+ with gr.Accordion("📥 Download Results", open=False):
912
+ download_single_output = gr.File(label="Download CSV")
913
+ download_tracks_output = gr.File(
914
+ label="Download Track PNG (current zoom/filter)"
915
+ )
916
+
917
+ ranked_state = gr.State(value=None)
918
+
919
+ with gr.Accordion("🔬 Region Track View", open=True):
920
+ gr.Markdown(
921
+ "*Reference (dashed gray) vs alternate signal for the top disrupted tracks. "
922
+ "Red shading = gain of function, blue shading = loss of function. "
923
+ "The slider maximum updates to the available track span for the current prediction.*"
924
+ )
925
+ with gr.Row(equal_height=True):
926
+ zoom_slider = gr.Slider(
927
+ minimum=MIN_ZOOM_BP,
928
+ maximum=INITIAL_ZOOM_MAX_BP,
929
+ value=DEFAULT_ZOOM_BP,
930
+ step=8,
931
+ label="Zoom radius (bp around variant)",
932
+ info="Updates after each prediction to the exact available track radius",
933
+ scale=3,
934
+ )
935
+ track_filter_dd = gr.Dropdown(
936
+ choices=["All", "BED", "BigWig"],
937
+ value="All",
938
+ label="Track type filter",
939
+ scale=1,
940
+ )
941
+ region_tracks_output = gr.Plot(label="Region Track View")
942
+
943
+ with gr.Accordion("📊 All BED Elements", open=False):
944
+ bed_table_output = gr.DataFrame(
945
+ wrap=True,
946
+ )
947
+
948
+ with gr.Accordion("📋 Sequence Model Metrics", open=True):
949
+ mlm_output = gr.Markdown()
950
+
951
+ # Connect button
952
+ predict_btn.click(
953
+ fn=predict_single_variant_wrapper,
954
+ inputs=[
955
+ species_input,
956
+ chrom_input,
957
+ pos_input,
958
+ ref_input,
959
+ alt_input,
960
+ zoom_slider,
961
+ ],
962
+ outputs=[
963
+ summary_output,
964
+ interpretation_output,
965
+ top_table_output,
966
+ fingerprint_output,
967
+ region_tracks_output,
968
+ download_single_output,
969
+ download_tracks_output,
970
+ bed_table_output,
971
+ mlm_output,
972
+ zoom_slider,
973
+ ranked_state,
974
+ ],
975
+ )
976
+
977
+ # Zoom slider re-renders region tracks on release (not during drag)
978
+ zoom_slider.release(
979
+ fn=_update_region_zoom,
980
+ inputs=[zoom_slider, ranked_state, track_filter_dd],
981
+ outputs=[region_tracks_output, download_tracks_output],
982
+ )
983
+ track_filter_dd.change(
984
+ fn=_update_region_zoom,
985
+ inputs=[zoom_slider, ranked_state, track_filter_dd],
986
+ outputs=[region_tracks_output, download_tracks_output],
987
+ )
988
+
989
+ # ===================================================================
990
+ # TAB 2: BATCH UPLOAD
991
+ # ===================================================================
992
+ with gr.Tab("📤 Batch Analysis"):
993
+ gr.Markdown("""
994
+ ### Batch Variant Upload
995
+ Upload a CSV file with multiple variants for batch processing.
996
+
997
+ **Required columns:** `chrom`, `pos`, `ref`, `alt`
998
+ **Format:** 1-based coordinates matching the selected species assembly
999
+ **Limit:** Maximum 10 variants per batch
1000
+ **Chromosome naming:** For non-human, bare names (e.g., `1`, `X`, `MT`) or `chr`-prefixed both work.
1001
+
1002
+ **Example CSV format (human):**
1003
+ ```
1004
+ chrom,pos,ref,alt
1005
+ chr17,7675088,C,T
1006
+ chr7,117559593,ATCT,A
1007
+ chr13,32340300,G,A
1008
+ ```
1009
+
1010
+ **Example CSV format (non-human):**
1011
+ ```
1012
+ chrom,pos,ref,alt
1013
+ 2,50000000,A,T
1014
+ X,25000000,C,G
1015
+ ```
1016
+ """)
1017
+
1018
+ file_input = gr.File(
1019
+ label="Upload CSV File",
1020
+ file_types=[".csv"],
1021
+ )
1022
+
1023
+ batch_species_input = gr.Dropdown(
1024
+ choices=_species_choices,
1025
+ label="Species",
1026
+ value="human",
1027
+ )
1028
+
1029
+ batch_btn = gr.Button("🚀 Process Batch", variant="primary", size="lg")
1030
+
1031
+ gr.Markdown("---")
1032
+ gr.Markdown("### Results Preview")
1033
+
1034
+ preview_output = gr.DataFrame(
1035
+ label="Summary Table (first 10 rows)",
1036
+ wrap=True,
1037
+ )
1038
+
1039
+ download_output = gr.File(
1040
+ label="Download Full Results (CSV)",
1041
+ )
1042
+
1043
+ # Connect button
1044
+ batch_btn.click(
1045
+ fn=predict_batch_wrapper,
1046
+ inputs=[batch_species_input, file_input],
1047
+ outputs=[preview_output, download_output],
1048
+ )
1049
+
1050
+ # ===================================================================
1051
+ # TAB 3: DOCUMENTATION
1052
+ # ===================================================================
1053
+ with gr.Tab("📖 Documentation"):
1054
+ gr.Markdown("""
1055
+ ## About MAGI
1056
+
1057
+ MAGI is a variant interpretation workflow that uses a Genomic foundation model (NTv3) for sequence scoring and adds:
1058
+ - gene and region annotation from MANE Select transcripts
1059
+ - ranking of the strongest BED and BigWig changes
1060
+ - a compact rule-based text summary
1061
+ - a baseline-derived MAGI score (`Global_z_sum_log`)
1062
+
1063
+ ### Model configuration
1064
+ - Current model: `InstaDeepAI/NTv3_650M_post`
1065
+ - Current sequence window: **32 kb** (`CONTEXT_LEN = 32768`)
1066
+ - Region Track View zoom is capped by the available NTv3 track-profile span for the current prediction
1067
+ - Sequence source:
1068
+ - **Human:** local `hg38.fa` when available, otherwise UCSC REST fallback
1069
+ - **Non-human animals:** Ensembl REST API
1070
+ - **Plants:** Ensembl Plants REST API (with Ensembl REST fallback)
1071
+
1072
+ ### Output groups
1073
+
1074
+ **BED outputs**
1075
+ - Functional and structural elements supplied by the NTv3 model configuration
1076
+ - Typically include coding, splice, promoter, UTR, exon, intron, and related annotations
1077
+
1078
+ **BigWig outputs**
1079
+ - A filtered subset of assay tracks such as histone marks, chromatin accessibility, and CAGE
1080
+ - Used as contextual signals rather than direct mechanistic proof
1081
+ - Currently reported for human only in this app
1082
+
1083
+ **Sequence model metrics**
1084
+ - `LLR` for SNPs
1085
+ - `KL` divergence summaries
1086
+ - log-probability differences
1087
+ - embedding distance summaries for indels
1088
+
1089
+ ### Gene Annotation
1090
+ Variants are automatically annotated with:
1091
+ - **Gene name** (from MANE Select RefSeq transcripts)
1092
+ - **Region class:** CODING, SPLICE, UTR_5, UTR_3, PROMOTER, INTRONIC, GENIC_OTHER, OTHER
1093
+ - **Annotation flags:** overlap with coding sequence, splice sites, promoters, UTRs, and related transcript features
1094
+ - Non-human species skip MANE transcript annotation and report sequence- and BED-based outputs only
1095
+ - All supported species (animals and plants) can be scored via BED elements and MLM sequence-model features
1096
+
1097
+ ### Impact Scoring
1098
+ - **MAGI score (`Global_z_sum_log`)**: baseline-derived burden score across BED and BigWig deltas
1099
+ - **Impact_Score_BED**: mean absolute value of the top 3 BED deltas
1100
+ - **Impact_Score_BW**: mean absolute value of the top 10 BigWig deltas
1101
+
1102
+ Larger values indicate stronger deviation from the reference prediction. The summary tier shown in the Variant Summary is currently derived from `Impact_Score_BED` only.
1103
+
1104
+ Positive Δ indicates an increase in the predicted signal; negative Δ indicates a decrease.
1105
+
1106
+ ### Interpretation panel
1107
+ The rule-based interpretation panel summarizes the strongest ranked BED, BigWig, and sequence-model signals already shown elsewhere in the app. It is a compact heuristic summary, not a calibrated pathogenicity assessment.
1108
+
1109
+ ### Limitations
1110
+ 1. **Predictions are computational** and require experimental follow-up.
1111
+ 2. No phasing information is used.
1112
+ 3. **Coordinates must match the species assembly available through Ensembl:**
1113
+ - Human: GRCh38/hg38
1114
+ - Other animals: latest Ensembl assembly for that species
1115
+ - Plants: latest Ensembl Plants assembly for that species
1116
+ 4. BigWig context tracks are currently human-only in this app.
1117
+ 5. MANE transcript annotation is human-only; non-human predictions show BED-level and sequence-model features only.
1118
+ 6. Baseline z-scores (`Global_z_sum_log`, `magi_baseline_stats.csv`) are derived from human variants and may be less meaningful for non-human species. Use with caution.
1119
+
1120
+ ### Citation
1121
+ If you use MAGI in your research, please cite the MAGI manuscript. If you find this webserver useful, please mention it! If you rely on the underlying foundation model, we suggest cite NTv3.
1122
+
1123
+ ```
1124
+ Ofer, D., Zok, S., & Linial, M. (2026). MAGI: Mechanistic Interpretation of
1125
+ Genetic Variants Consequences via Genomic Foundation Models.
1126
+ ```
1127
+
1128
+ ### External model credit
1129
+ **NTv3:** Dalla-Torre, H., et al. (2025). Nucleotide Transformer: building and evaluating robust foundation models for human genomics. Nature Methods .
1130
+
1131
+ <!-- ### Contact & Source Code
1132
+ - **Model:** [InstaDeepAI/NTv3_650M_post](https://huggingface.co/InstaDeepAI/NTv3_650M_post)
1133
+ - **Paper:** [bioRxiv](https://www.biorxiv.org/content/10.1101/2023.01.11.523679v2)
1134
+ - **GitHub:** [Source repository](https://github.com/instadeepai/nucleotide-transformer) -->
1135
+
1136
+ ---
1137
+ """)
1138
+
1139
+ return app
1140
+
1141
+
1142
+ # Build app at module level for Gradio/Spaces auto-detection
1143
+ app = build_interface()
1144
+
1145
+ if __name__ == "__main__":
1146
+ app.launch()
data/MANE_processed.csv ADDED
@@ -0,0 +1,3 @@
 
 
 
 
1
+ version https://git-lfs.github.com/spec/v1
2
+ oid sha256:676a3042889bdbb362a83e93df4f744857d0eae631d688caf273fc731bd865c5
3
+ size 246259927
data/MANE_processed.parquet ADDED
@@ -0,0 +1,3 @@
 
 
 
 
1
+ version https://git-lfs.github.com/spec/v1
2
+ oid sha256:5a11043f7c63b55a88cbb123e07427328fdd94c93668f33adaa085c8a61addb7
3
+ size 24626551
data/Promoter_processed.csv ADDED
@@ -0,0 +1,3 @@
 
 
 
 
1
+ version https://git-lfs.github.com/spec/v1
2
+ oid sha256:4c4bd66cea689648115d771c5e90e140f7ab6a6555f8faf119c2c37398645f7d
3
+ size 11044412
data/Promoter_processed.parquet ADDED
@@ -0,0 +1,3 @@
 
 
 
 
1
+ version https://git-lfs.github.com/spec/v1
2
+ oid sha256:305a725c6d281f36581d7bda96054f2d7bd710be8cf5540be2beb5996af02950
3
+ size 4374214
data/functional_tracks_metadata_human.csv ADDED
The diff for this file is too large to render. See raw diff
 
data/magi_baseline_stats.csv ADDED
The diff for this file is too large to render. See raw diff
 
download_hg38.py ADDED
@@ -0,0 +1,66 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ #!/usr/bin/env python3
2
+ """
3
+ Download and extract hg38 reference genome
4
+ ==========================================
5
+ Downloads hg38.fa.gz from UCSC and extracts it to data/hg38.fa
6
+ Only runs if hg38.fa doesn't already exist (3GB download).
7
+
8
+ Usage:
9
+ python download_hg38.py
10
+ """
11
+ import os
12
+ import urllib.request
13
+ import gzip
14
+ import shutil
15
+ from pathlib import Path
16
+
17
+ # Define paths relative to this script
18
+ BASE_DIR = Path(__file__).parent
19
+ DATA_DIR = BASE_DIR / "data"
20
+ FASTA_PATH = DATA_DIR / "hg38.fa"
21
+ GZ_PATH = DATA_DIR / "hg38.fa.gz"
22
+ URL = "https://hgdownload.soe.ucsc.edu/goldenPath/hg38/bigZips/hg38.fa.gz"
23
+
24
+
25
+ def download_and_extract():
26
+ """Download and extract hg38 reference genome."""
27
+ DATA_DIR.mkdir(parents=True, exist_ok=True)
28
+
29
+ if FASTA_PATH.exists():
30
+ size_mb = FASTA_PATH.stat().st_size / (1024 * 1024)
31
+ print(f"✅ '{FASTA_PATH}' already exists ({size_mb:.0f} MB). Skipping download.")
32
+ return
33
+
34
+ print("⬇️ Downloading hg38.fa.gz from UCSC (~3 GB)...")
35
+ print(f" URL: {URL}")
36
+ print(" This may take 10-30 minutes depending on your connection.\n")
37
+
38
+ # Download with progress reporting
39
+ def reporthook(block_num, block_size, total_size):
40
+ downloaded = block_num * block_size
41
+ if total_size > 0:
42
+ percent = min(100, downloaded * 100 / total_size)
43
+ mb_downloaded = downloaded / (1024 * 1024)
44
+ mb_total = total_size / (1024 * 1024)
45
+ print(
46
+ f"\r Progress: {percent:.1f}% ({mb_downloaded:.0f}/{mb_total:.0f} MB)",
47
+ end="",
48
+ )
49
+
50
+ urllib.request.urlretrieve(URL, GZ_PATH, reporthook=reporthook)
51
+ print("\n")
52
+
53
+ print(f"📦 Extracting to {FASTA_PATH}...")
54
+ with gzip.open(GZ_PATH, "rb") as f_in:
55
+ with open(FASTA_PATH, "wb") as f_out:
56
+ shutil.copyfileobj(f_in, f_out)
57
+
58
+ print("🧹 Cleaning up compressed file...")
59
+ GZ_PATH.unlink()
60
+
61
+ size_mb = FASTA_PATH.stat().st_size / (1024 * 1024)
62
+ print(f"✅ Done! hg38.fa extracted ({size_mb:.0f} MB)\n")
63
+
64
+
65
+ if __name__ == "__main__":
66
+ download_and_extract()
examples/example_variants.csv ADDED
@@ -0,0 +1,6 @@
 
 
 
 
 
 
 
1
+ chrom,pos,ref,alt
2
+ chr17,7675088,C,T
3
+ chr7,117559593,ATCT,A
4
+ chr13,32332771,AGAGA,AGA
5
+ chr11,5227002,T,A
6
+ chr17,43092418,T,C
inference.py ADDED
@@ -0,0 +1,895 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ #!/usr/bin/env python3
2
+ """
3
+ NTv3 inference module for the MAGI Gradio app.
4
+
5
+ Adapted from the top-level inference.py pipeline for web deployment.
6
+
7
+ Key features:
8
+ - Uses local hg38.fa via pyfaidx when available
9
+ - Loads the configured NTv3 model once and caches it across requests
10
+ - Returns BED/BigWig deltas together with sequence-model metrics
11
+
12
+ Usage:
13
+ from inference import predict_variants
14
+ results_df = predict_variants(variants_df)
15
+ """
16
+
17
+ import os
18
+ import time
19
+ import warnings
20
+ from pathlib import Path
21
+ from typing import Optional, Tuple, List, Any, Dict, cast
22
+ from functools import lru_cache
23
+
24
+ import numpy as np
25
+ import pandas as pd
26
+ import torch
27
+ import torch.nn.functional as F
28
+ import requests
29
+ from pyfaidx import Fasta
30
+ from transformers import AutoModel, AutoTokenizer
31
+
32
+ # ============================================================================
33
+ # CONFIGURATION
34
+ # ============================================================================
35
+ MODEL_NAME = "InstaDeepAI/NTv3_650M_post"
36
+ CONTEXT_LEN = 32 * 1024 # 32 kb sequence window
37
+ USE_BED = True
38
+ USE_BIGWIGS = True
39
+ USE_KL_DIVERGENCE = True
40
+ USE_EMBEDDINGS = True # Useful for indels
41
+ MLM_WINDOW = 3 # ±3 positions for embedding window
42
+
43
+ # Paths (relative to this file)
44
+ BASE_DIR = Path(__file__).parent
45
+ DATA_DIR = BASE_DIR / "data"
46
+ GENOME_FILE = DATA_DIR / "hg38.fa"
47
+ GENOME_GZ_FILE = DATA_DIR / "hg38.fa.gz"
48
+ METADATA_FILE = DATA_DIR / "functional_tracks_metadata_human.csv"
49
+ UCSC_SEQUENCE_URL = "https://api.genome.ucsc.edu/getData/sequence"
50
+ ENSEMBL_SEQUENCE_URL = "https://rest.ensembl.org/sequence/region"
51
+ ENSEMBL_PLANTS_SEQUENCE_URL = "https://rest.plants.ensembl.org/sequence/region"
52
+ FORCE_UCSC = os.environ.get("NTV3_FORCE_UCSC", "0") == "1"
53
+
54
+ SUPPORTED_UI_SPECIES: Dict[str, str] = {
55
+ "human": "Human",
56
+ "mouse": "Mouse",
57
+ "rattus_norvegicus": "Rat",
58
+ "canis_lupus_familiaris": "Dog",
59
+ "felis_catus": "Cat",
60
+ "gallus_gallus": "Chicken",
61
+ "danio_rerio": "Zebrafish",
62
+ "gorilla_gorilla": "Gorilla",
63
+ "macaca_nemestrina": "Pig-tailed macaque",
64
+ "bison_bison_bison": "Bison",
65
+ "chinchilla_lanigera": "Chinchilla",
66
+ "serinus_canaria": "Canary",
67
+ "salmo_trutta": "Brown trout",
68
+ "tetraodon_nigroviridis": "Green spotted puffer",
69
+ "amphiprion_ocellaris": "Clownfish",
70
+ "arabidopsis_thaliana": "Arabidopsis (Thale cress)",
71
+ "oryza_sativa": "Rice",
72
+ "glycine_max": "Soybean",
73
+ "gossypium_hirsutum": "Cotton",
74
+ "triticum_aestivum": "Wheat",
75
+ "zea_mays": "Maize",
76
+ }
77
+
78
+ SPECIES_TO_ENSEMBL: Dict[str, str] = {
79
+ "human": "homo_sapiens",
80
+ "mouse": "mus_musculus",
81
+ "rattus_norvegicus": "rattus_norvegicus",
82
+ "canis_lupus_familiaris": "canis_lupus_familiaris",
83
+ "felis_catus": "felis_catus",
84
+ "gallus_gallus": "gallus_gallus",
85
+ "danio_rerio": "danio_rerio",
86
+ "gorilla_gorilla": "gorilla_gorilla",
87
+ "macaca_nemestrina": "macaca_nemestrina",
88
+ "bison_bison_bison": "bison_bison_bison",
89
+ "chinchilla_lanigera": "chinchilla_lanigera",
90
+ "serinus_canaria": "serinus_canaria",
91
+ "salmo_trutta": "salmo_trutta",
92
+ "tetraodon_nigroviridis": "tetraodon_nigroviridis",
93
+ "amphiprion_ocellaris": "amphiprion_ocellaris",
94
+ "arabidopsis_thaliana": "arabidopsis_thaliana",
95
+ "oryza_sativa": "oryza_sativa",
96
+ "glycine_max": "glycine_max",
97
+ "gossypium_hirsutum": "gossypium_hirsutum",
98
+ "triticum_aestivum": "triticum_aestivum",
99
+ "zea_mays": "zea_mays",
100
+ }
101
+
102
+ PLANT_SPECIES = {
103
+ "arabidopsis_thaliana",
104
+ "oryza_sativa",
105
+ "glycine_max",
106
+ "gossypium_hirsutum",
107
+ "triticum_aestivum",
108
+ "zea_mays",
109
+ }
110
+
111
+ # Global cache for model and genome
112
+ _MODEL_CACHE: Dict[str, Any] = {
113
+ "model": None,
114
+ "tokenizer": None,
115
+ "genome": None,
116
+ "bed_names": None,
117
+ "bigwig_names": None,
118
+ "selected_bw_indices": None,
119
+ "nuc_token_map": None,
120
+ "human_id": None,
121
+ "species_to_token_id": None,
122
+ "sequence_source": "ucsc",
123
+ }
124
+
125
+ # Cache for the most recent track profiles (used by tracks.py for plotting)
126
+ _LAST_TRACK_PROFILES: Dict[str, Any] = {}
127
+
128
+
129
+ # ============================================================================
130
+ # HELPER FUNCTIONS
131
+ # ============================================================================
132
+ def _normalize_ucsc_chrom(chrom: str) -> str:
133
+ """Normalize chromosome string for UCSC API (expects chr-prefixed names)."""
134
+ chrom = str(chrom).strip()
135
+ if not chrom:
136
+ return chrom
137
+ return chrom if chrom.startswith("chr") else f"chr{chrom}"
138
+
139
+
140
+ def _normalize_ensembl_chrom(chrom: str) -> str:
141
+ """Normalize chromosome string for Ensembl REST (expects no chr prefix)."""
142
+ clean = str(chrom).strip()
143
+ if clean.lower().startswith("chr"):
144
+ clean = clean[3:]
145
+ if clean.upper() == "M":
146
+ return "MT"
147
+ return clean
148
+
149
+
150
+ @lru_cache(maxsize=2048)
151
+ def _fetch_ucsc_window(chrom: str, start: int, end: int) -> Optional[str]:
152
+ """Fetch sequence window from UCSC API with in-session caching."""
153
+ try:
154
+ response = requests.get(
155
+ UCSC_SEQUENCE_URL,
156
+ params={
157
+ "genome": "hg38",
158
+ "chrom": chrom,
159
+ "start": int(start),
160
+ "end": int(end),
161
+ },
162
+ timeout=10,
163
+ )
164
+ response.raise_for_status()
165
+ payload = response.json()
166
+ dna = payload.get("dna", "")
167
+ if not dna:
168
+ return None
169
+ return str(dna).upper()
170
+ except Exception:
171
+ return None
172
+
173
+
174
+ @lru_cache(maxsize=2048)
175
+ def _fetch_ensembl_window(
176
+ species: str, chrom: str, start: int, end: int
177
+ ) -> Optional[str]:
178
+ """Fetch sequence window from Ensembl REST for non-human species.
179
+
180
+ For plant species, first tries Ensembl REST, then falls back to Ensembl Plants REST.
181
+ """
182
+ ensembl_species = SPECIES_TO_ENSEMBL.get(species)
183
+ if not ensembl_species:
184
+ return None
185
+
186
+ clean_chrom = _normalize_ensembl_chrom(chrom)
187
+ if not clean_chrom:
188
+ return None
189
+
190
+ start_1based = max(1, int(start) + 1)
191
+ end_1based = max(start_1based, int(end))
192
+
193
+ is_plant = species in PLANT_SPECIES
194
+ urls = []
195
+
196
+ if is_plant:
197
+ urls.append(
198
+ f"{ENSEMBL_PLANTS_SEQUENCE_URL}/{ensembl_species}/"
199
+ f"{clean_chrom}:{start_1based}..{end_1based}"
200
+ )
201
+ urls.append(
202
+ f"{ENSEMBL_SEQUENCE_URL}/{ensembl_species}/"
203
+ f"{clean_chrom}:{start_1based}..{end_1based}"
204
+ )
205
+ else:
206
+ urls.append(
207
+ f"{ENSEMBL_SEQUENCE_URL}/{ensembl_species}/"
208
+ f"{clean_chrom}:{start_1based}..{end_1based}"
209
+ )
210
+
211
+ for url in urls:
212
+ try:
213
+ response = requests.get(
214
+ url,
215
+ headers={"Content-Type": "text/plain", "Accept": "text/plain"},
216
+ timeout=15,
217
+ )
218
+ response.raise_for_status()
219
+ dna = response.text.strip()
220
+ if dna:
221
+ return dna.upper()
222
+ except Exception:
223
+ continue
224
+
225
+ return None
226
+
227
+
228
+ def _fetch_sequence_from_local(
229
+ genome: Fasta, chrom: str, start: int, end: int
230
+ ) -> Optional[str]:
231
+ """Fetch sequence window from local pyfaidx genome."""
232
+ query_chrom = chrom
233
+ if query_chrom not in genome:
234
+ query_chrom = (
235
+ query_chrom if query_chrom.startswith("chr") else f"chr{query_chrom}"
236
+ )
237
+ if query_chrom not in genome:
238
+ query_chrom = (
239
+ query_chrom.replace("chr", "")
240
+ if "chr" in query_chrom
241
+ else f"chr{query_chrom}"
242
+ )
243
+ if query_chrom not in genome:
244
+ return None
245
+
246
+ try:
247
+ return str(genome[query_chrom][start:end]).upper()
248
+ except Exception:
249
+ return None
250
+
251
+
252
+ def get_genomic_sequence(
253
+ genome: Optional[Fasta],
254
+ chrom: str,
255
+ pos: int,
256
+ ref: str,
257
+ alt: str,
258
+ context_len: int = 4096,
259
+ species: str = "human",
260
+ ) -> Tuple[Optional[str], Optional[str], Optional[int]]:
261
+ """
262
+ Extract Ref/Alt sequences centered at variant position.
263
+
264
+ Returns:
265
+ (ref_seq, alt_seq, variant_center_idx) or (None, None, None) on error
266
+ """
267
+ variant_idx = pos - 1 # Convert to 0-based
268
+ half = context_len // 2
269
+ start = max(0, variant_idx - half)
270
+ end = variant_idx + half
271
+
272
+ ref_seq = None
273
+ if species == "human":
274
+ if genome is not None:
275
+ ref_seq = _fetch_sequence_from_local(genome, str(chrom), start, end)
276
+
277
+ if ref_seq is None:
278
+ ucsc_chrom = _normalize_ucsc_chrom(str(chrom))
279
+ ref_seq = _fetch_ucsc_window(ucsc_chrom, start, end)
280
+ if ref_seq is None:
281
+ warnings.warn(
282
+ f"Failed to fetch sequence for {chrom}:{pos} from local genome and UCSC API"
283
+ )
284
+ return None, None, None
285
+ else:
286
+ ref_seq = _fetch_ensembl_window(species, str(chrom), start, end)
287
+ if ref_seq is None:
288
+ warnings.warn(
289
+ f"Failed to fetch sequence for {species} {chrom}:{pos} from Ensembl REST API"
290
+ )
291
+ return None, None, None
292
+
293
+ if not ref_seq:
294
+ return None, None, None
295
+
296
+ center = variant_idx - start
297
+ center = max(0, min(center, len(ref_seq) - 1))
298
+
299
+ # Validate REF allele
300
+ ref_end = min(center + len(ref), len(ref_seq))
301
+ actual_ref = ref_seq[center:ref_end]
302
+ if actual_ref.upper() != ref.upper():
303
+ warnings.warn(
304
+ f"REF mismatch at {chrom}:{pos}: expected '{ref}' but genome has '{actual_ref}'"
305
+ )
306
+
307
+ # Build ALT sequence
308
+ alt_seq = ref_seq[:center] + alt + ref_seq[ref_end:]
309
+
310
+ # Crop to equal length
311
+ target_len = min(len(ref_seq), len(alt_seq))
312
+ center = min(center, target_len - 1)
313
+
314
+ return ref_seq[:target_len], alt_seq[:target_len], center
315
+
316
+
317
+ def get_track_indices(
318
+ bigwig_names: List[str], metadata_file: Path
319
+ ) -> Tuple[List[int], List[str]]:
320
+ """Filter BigWig tracks to functional subset using metadata."""
321
+ if not metadata_file.exists():
322
+ print(f"⚠️ Metadata not found, using all {len(bigwig_names)} tracks")
323
+ return list(range(len(bigwig_names))), bigwig_names
324
+
325
+ metadata = pd.read_csv(metadata_file)
326
+
327
+ key_marks = {
328
+ "H3K4me3",
329
+ "H3K27ac",
330
+ "H3K36me3",
331
+ "H3K27me3",
332
+ "H3K9me3",
333
+ "H3K4me1",
334
+ "H3K9ac",
335
+ }
336
+ sel_idx, sel_names = [], []
337
+
338
+ for i, tid in enumerate(bigwig_names):
339
+ rows = metadata[metadata["file_id"] == tid]
340
+ if rows.empty:
341
+ continue
342
+ r = rows.iloc[0]
343
+ assay = str(r.get("assay", ""))
344
+ target = str(r.get("experiment_target", ""))
345
+ dataset = str(r.get("dataset", ""))
346
+
347
+ keep = (
348
+ (
349
+ "ChIP" in assay
350
+ and any(m in target for m in key_marks)
351
+ and dataset in ("encode_v3", "geo")
352
+ )
353
+ or (("ATAC" in assay or "DNase" in assay) and dataset == "encode_v3")
354
+ or dataset == "fantom5"
355
+ or "CAGE" in assay
356
+ or dataset == "gtex"
357
+ )
358
+ if keep:
359
+ sel_idx.append(i)
360
+ sel_names.append(tid)
361
+
362
+ print(f"📊 Selected {len(sel_idx)}/{len(bigwig_names)} BigWig tracks")
363
+ return sel_idx, sel_names
364
+
365
+
366
+ def build_nuc_token_map(tokenizer):
367
+ """Pre-compute nucleotide -> token ID mapping for LLR calculation."""
368
+ return {
369
+ nuc: tokenizer(nuc, add_special_tokens=False)["input_ids"][0]
370
+ for nuc in "ACGTN"
371
+ if tokenizer(nuc, add_special_tokens=False)["input_ids"]
372
+ }
373
+
374
+
375
+ def to_track_probabilities(track_values):
376
+ """
377
+ Convert NTv3 track logits to probabilities via sigmoid.
378
+
379
+ BED tracks: shape (B, L', 21, 2) → extract positive class [..., 1]
380
+ BigWig tracks: shape (B, L', N) → apply sigmoid directly
381
+ """
382
+ if track_values is None:
383
+ return None
384
+ if track_values.shape[-1] == 2: # Binary classification (BED)
385
+ return torch.sigmoid(track_values[..., 1])
386
+ return torch.sigmoid(track_values)
387
+
388
+
389
+ def compute_mlm_features(
390
+ out_ref,
391
+ out_alt,
392
+ ref_seq: str,
393
+ alt_seq: str,
394
+ idx: int,
395
+ variant_center: int,
396
+ ref_allele: str,
397
+ alt_allele: str,
398
+ nuc_token_map: dict,
399
+ use_kl: bool = True,
400
+ use_embeddings: bool = False,
401
+ window: int = 50,
402
+ ) -> dict:
403
+ """
404
+ Compute MLM language model features (LLR, KL divergence, log-probs, embeddings).
405
+
406
+ Returns dict with keys:
407
+ - LLR, MLM_Prior, MLM_Delta (SNPs only)
408
+ - MLM_KL_mean, MLM_KL_max
409
+ - MLM_logprob_ref, MLM_logprob_alt, MLM_logprob_delta
410
+ - REF_5mer, ALT_5mer
411
+ - EMB_* (if use_embeddings=True)
412
+ """
413
+ feat = {}
414
+ ref_logits = out_ref.logits[idx]
415
+ alt_logits = out_alt.logits[idx]
416
+ seq_len = min(ref_logits.shape[0], alt_logits.shape[0])
417
+
418
+ # Determine variant span (1 for SNP, max allele length for indel)
419
+ variant_span = max(1, len(ref_allele), len(alt_allele))
420
+ variant_center = int(max(0, min(variant_center, seq_len - 1)))
421
+
422
+ # KL window: cover exactly the variant span
423
+ kl_ws = variant_center
424
+ kl_we = min(seq_len, variant_center + variant_span)
425
+
426
+ # --- LLR (single-nucleotide substitutions only) ---
427
+ is_snp = len(ref_allele) == 1 and len(alt_allele) == 1
428
+ if is_snp and ref_allele in nuc_token_map and alt_allele in nuc_token_map:
429
+ probs = F.softmax(ref_logits[variant_center], dim=-1)
430
+ rp = float(probs[nuc_token_map[ref_allele]].cpu())
431
+ ap = float(probs[nuc_token_map[alt_allele]].cpu())
432
+ feat["LLR"] = np.log(ap / (rp + 1e-10) + 1e-10)
433
+ feat["MLM_Prior"] = rp
434
+ feat["MLM_Delta"] = ap - rp
435
+ else:
436
+ feat["LLR"] = feat["MLM_Prior"] = feat["MLM_Delta"] = np.nan
437
+
438
+ # --- Context k-mers ---
439
+ for pf, seq in [("REF", ref_seq), ("ALT", alt_seq)]:
440
+ if len(seq) >= variant_center + 3:
441
+ feat[f"{pf}_5mer"] = seq[max(0, variant_center - 2) : variant_center + 3]
442
+ else:
443
+ feat[f"{pf}_5mer"] = "NNNNN"
444
+
445
+ # --- KL divergence + log-prob ---
446
+ if use_kl:
447
+ if kl_we <= kl_ws:
448
+ feat["MLM_KL_mean"] = np.nan
449
+ feat["MLM_KL_max"] = np.nan
450
+ feat["MLM_logprob_ref"] = np.nan
451
+ feat["MLM_logprob_alt"] = np.nan
452
+ feat["MLM_logprob_delta"] = np.nan
453
+ return feat
454
+
455
+ rp_w = F.softmax(ref_logits[kl_ws:kl_we], dim=-1)
456
+ ap_w = F.softmax(alt_logits[kl_ws:kl_we], dim=-1)
457
+ kl = F.kl_div(rp_w.log(), ap_w, reduction="none", log_target=False).sum(-1)
458
+ feat["MLM_KL_mean"] = float(kl.mean().cpu())
459
+ feat["MLM_KL_max"] = float(kl.max().cpu())
460
+
461
+ # Log-probs
462
+ rlp = [
463
+ float(torch.log(rp_w[p - kl_ws, nuc_token_map[ref_seq[p]]] + 1e-10).cpu())
464
+ for p in range(kl_ws, kl_we)
465
+ if p < len(ref_seq) and ref_seq[p] in nuc_token_map
466
+ ]
467
+ alp = [
468
+ float(torch.log(ap_w[p - kl_ws, nuc_token_map[alt_seq[p]]] + 1e-10).cpu())
469
+ for p in range(kl_ws, kl_we)
470
+ if p < len(alt_seq) and alt_seq[p] in nuc_token_map
471
+ ]
472
+ feat["MLM_logprob_ref"] = np.mean(rlp) if rlp else np.nan
473
+ feat["MLM_logprob_alt"] = np.mean(alp) if alp else np.nan
474
+ feat["MLM_logprob_delta"] = feat["MLM_logprob_alt"] - feat["MLM_logprob_ref"]
475
+
476
+ # --- Embedding distances ---
477
+ if use_embeddings:
478
+ hr = getattr(out_ref, "last_hidden_state", None)
479
+ ha = getattr(out_alt, "last_hidden_state", None)
480
+ if hr is not None and ha is not None:
481
+ ws = max(0, variant_center - window)
482
+ we = min(seq_len, variant_center + window)
483
+ hr, ha = hr[idx, ws:we, :], ha[idx, ws:we, :]
484
+ hrm, ham = hr.mean(0), ha.mean(0)
485
+ feat["EMB_cosine_dist"] = float(
486
+ 1.0 - F.cosine_similarity(hrm.unsqueeze(0), ham.unsqueeze(0)).cpu()
487
+ )
488
+ feat["EMB_l2_dist"] = float(torch.norm(hrm - ham, p=2).cpu())
489
+ per_pos = torch.norm(hr - ha, p=2, dim=-1)
490
+ feat["EMB_max_pos_dist"] = float(per_pos.max().cpu())
491
+ feat["EMB_mean_pos_dist"] = float(per_pos.mean().cpu())
492
+ else:
493
+ for k in (
494
+ "EMB_cosine_dist",
495
+ "EMB_l2_dist",
496
+ "EMB_max_pos_dist",
497
+ "EMB_mean_pos_dist",
498
+ ):
499
+ feat[k] = np.nan
500
+
501
+ return feat
502
+
503
+
504
+ # ============================================================================
505
+ # MODEL LOADING AND CACHING
506
+ # ============================================================================
507
+ def load_model_and_resources(device: str = "cuda"):
508
+ """
509
+ Load model, tokenizer, genome, and metadata once at startup.
510
+ Caches everything in global _MODEL_CACHE.
511
+
512
+ Handles:
513
+ - HF_TOKEN authentication for gated models
514
+ - bf16 mixed precision when supported by GPU
515
+ """
516
+ if _MODEL_CACHE["model"] is not None:
517
+ return # Already loaded
518
+
519
+ # Get HF token from environment (needed for gated models like NTv3)
520
+ hf_token = os.environ.get("HF_TOKEN")
521
+ if not hf_token:
522
+ print("⚠️ HF_TOKEN not set; model loading may fail if the model is gated")
523
+
524
+ print(f"🧠 Loading NTv3 model '{MODEL_NAME}' on {device}...")
525
+
526
+ # Prepare bf16 kwargs if GPU supports bfloat16
527
+ bf16_kwargs = {}
528
+ if device == "cuda" and torch.cuda.is_bf16_supported():
529
+ print("💾 Enabling bf16 mixed precision to reduce memory usage")
530
+ bf16_kwargs = {
531
+ "stem_compute_dtype": "bfloat16",
532
+ "down_convolution_compute_dtype": "bfloat16",
533
+ "transformer_qkvo_compute_dtype": "bfloat16",
534
+ "transformer_ffn_compute_dtype": "bfloat16",
535
+ "up_convolution_compute_dtype": "bfloat16",
536
+ "modulation_compute_dtype": "bfloat16",
537
+ }
538
+
539
+ try:
540
+ model = (
541
+ AutoModel.from_pretrained(
542
+ MODEL_NAME, trust_remote_code=True, token=hf_token, **bf16_kwargs
543
+ )
544
+ .to(device)
545
+ .eval()
546
+ )
547
+ tokenizer = AutoTokenizer.from_pretrained(
548
+ MODEL_NAME, trust_remote_code=True, token=hf_token
549
+ )
550
+ except Exception as e:
551
+ error_msg = f"Failed to load model: {str(e)}"
552
+ if "gated" in str(e).lower() or "401" in str(e):
553
+ error_msg += "\n\nThe NTv3_650M_post model is gated. You need to:\n1. Accept the model terms at https://huggingface.co/InstaDeepAI/NTv3_650M_post\n2. Set HF_TOKEN as an environment variable with your HuggingFace API token"
554
+ raise RuntimeError(error_msg) from e
555
+
556
+ species_to_token_id = dict(getattr(model.config, "species_to_token_id", {}) or {})
557
+
558
+ # Extract human species ID
559
+ try:
560
+ human_id = model.encode_species(["human"]).item()
561
+ except (AttributeError, TypeError):
562
+ human_id = species_to_token_id.get("human", 6)
563
+
564
+ # Extract BED/BigWig names
565
+ bed_names = []
566
+ if USE_BED:
567
+ for attr in ("bed_elements_names", "bed_tracks", "bed_track_labels"):
568
+ if hasattr(model.config, attr):
569
+ bed_names = getattr(model.config, attr)
570
+ break
571
+ bw_names = []
572
+ if USE_BIGWIGS and hasattr(model.config, "bigwigs_per_species"):
573
+ bw_names = model.config.bigwigs_per_species.get("human", [])
574
+
575
+ # Filter BigWig tracks
576
+ selected_bw_indices, selected_bw_names = get_track_indices(bw_names, METADATA_FILE)
577
+
578
+ # Load genome optionally (for fast local lookups); otherwise UCSC fallback
579
+ genome = None
580
+ sequence_source = "ucsc"
581
+ if FORCE_UCSC:
582
+ print("⚠️ NTV3_FORCE_UCSC=1 set; using UCSC API for sequence retrieval")
583
+ elif GENOME_FILE.exists():
584
+ print(f"🧬 Loading local reference genome from {GENOME_FILE}...")
585
+ genome = Fasta(str(GENOME_FILE))
586
+ sequence_source = "local+ucsc-fallback"
587
+ elif GENOME_GZ_FILE.exists():
588
+ print(
589
+ f"⚠️ Found compressed genome at {GENOME_GZ_FILE}; using UCSC API (decompress to enable local fast path)"
590
+ )
591
+ else:
592
+ print("⚠️ No local hg38.fa found; using UCSC API for sequence retrieval")
593
+
594
+ # Build token map
595
+ nuc_token_map = build_nuc_token_map(tokenizer)
596
+
597
+ # Cache everything
598
+ _MODEL_CACHE.update(
599
+ {
600
+ "model": model,
601
+ "tokenizer": tokenizer,
602
+ "genome": genome,
603
+ "bed_names": bed_names,
604
+ "bigwig_names": bw_names,
605
+ "selected_bw_indices": selected_bw_indices,
606
+ "nuc_token_map": nuc_token_map,
607
+ "human_id": human_id,
608
+ "species_to_token_id": species_to_token_id,
609
+ "sequence_source": sequence_source,
610
+ }
611
+ )
612
+
613
+ print(
614
+ f"✅ Model loaded: {len(bed_names)} BED, {len(selected_bw_indices)} BigWig tracks | sequence source: {sequence_source}"
615
+ )
616
+
617
+
618
+ # ============================================================================
619
+ # INFERENCE
620
+ # ============================================================================
621
+ def predict_variants(
622
+ df: pd.DataFrame,
623
+ device: str = "cuda",
624
+ species: str = "human",
625
+ cache_profiles: bool = True,
626
+ ) -> pd.DataFrame:
627
+ """
628
+ Run NTv3 inference on variants DataFrame.
629
+
630
+ Args:
631
+ df: DataFrame with columns ['chrom', 'pos', 'ref', 'alt']
632
+ device: 'cuda' or 'cpu'
633
+
634
+ Returns:
635
+ DataFrame with original columns plus:
636
+ - D_BED_* (21 BED element deltas)
637
+ - REF_BED_* (21 BED element ref probabilities)
638
+ - D_BW_* (filtered BigWig deltas)
639
+ - REF_BW_* (filtered BigWig ref probabilities)
640
+ - LLR, MLM_Prior, MLM_Delta, MLM_KL_mean, MLM_KL_max
641
+ - MLM_logprob_ref, MLM_logprob_alt, MLM_logprob_delta
642
+ - REF_5mer, ALT_5mer
643
+ - EMB_* (if embeddings enabled)
644
+ - indel_size
645
+ """
646
+ # Load model if not already loaded
647
+ if _MODEL_CACHE["model"] is None:
648
+ load_model_and_resources(device)
649
+
650
+ model = _MODEL_CACHE.get("model")
651
+ tokenizer = _MODEL_CACHE.get("tokenizer")
652
+ genome = cast(Optional[Fasta], _MODEL_CACHE.get("genome"))
653
+ bed_names = cast(List[str], _MODEL_CACHE.get("bed_names") or [])
654
+ bigwig_names = cast(List[str], _MODEL_CACHE.get("bigwig_names") or [])
655
+ selected_bw_indices = cast(List[int], _MODEL_CACHE.get("selected_bw_indices") or [])
656
+ nuc_token_map = cast(dict, _MODEL_CACHE.get("nuc_token_map") or {})
657
+ species_to_token_id = cast(
658
+ Dict[str, int], _MODEL_CACHE.get("species_to_token_id") or {}
659
+ )
660
+
661
+ species = str(species).strip()
662
+ species_id = species_to_token_id.get(species)
663
+ active_bigwig_names = bigwig_names if species == "human" else []
664
+ active_bw_indices = selected_bw_indices if species == "human" else []
665
+
666
+ if model is None or tokenizer is None or species_id is None:
667
+ raise RuntimeError("Model resources are not initialized correctly")
668
+
669
+ # Validate input
670
+ required_cols = {"chrom", "pos", "ref", "alt"}
671
+ missing = required_cols - set(df.columns)
672
+ if missing:
673
+ raise ValueError(f"Missing required columns: {missing}")
674
+
675
+ # Clean data
676
+ df = df.copy()
677
+ df = df[df["ref"].notna() & df["alt"].notna()].reset_index(drop=True)
678
+
679
+ results = []
680
+
681
+ for idx, row in df.iterrows():
682
+ # Get sequences
683
+ ref_seq, alt_seq, vcenter = get_genomic_sequence(
684
+ genome,
685
+ row["chrom"],
686
+ row["pos"],
687
+ row["ref"],
688
+ row["alt"],
689
+ CONTEXT_LEN,
690
+ species=species,
691
+ )
692
+
693
+ if ref_seq is None or alt_seq is None or vcenter is None:
694
+ # Failed to fetch sequence - return NaN results
695
+ res = {c: row[c] for c in df.columns}
696
+ res["indel_size"] = len(str(row["alt"])) - len(str(row["ref"]))
697
+ for nm in bed_names:
698
+ res[f"REF_BED_{nm}"] = np.nan
699
+ res[f"D_BED_{nm}"] = np.nan
700
+ for gi in active_bw_indices:
701
+ res[f"REF_BW_{active_bigwig_names[gi]}"] = np.nan
702
+ res[f"D_BW_{active_bigwig_names[gi]}"] = np.nan
703
+ for k in (
704
+ "LLR",
705
+ "MLM_Prior",
706
+ "MLM_Delta",
707
+ "MLM_KL_mean",
708
+ "MLM_KL_max",
709
+ "MLM_logprob_ref",
710
+ "MLM_logprob_alt",
711
+ "MLM_logprob_delta",
712
+ "REF_5mer",
713
+ "ALT_5mer",
714
+ ):
715
+ res[k] = np.nan if k not in ("REF_5mer", "ALT_5mer") else "NNNNN"
716
+ results.append(res)
717
+ continue
718
+
719
+ # Tokenize
720
+ tok_kw = dict(
721
+ return_tensors="pt",
722
+ padding="max_length",
723
+ max_length=CONTEXT_LEN,
724
+ truncation=True,
725
+ add_special_tokens=False,
726
+ pad_to_multiple_of=128,
727
+ )
728
+ inp_r = tokenizer([ref_seq], **tok_kw).to(device)
729
+ inp_a = tokenizer([alt_seq], **tok_kw).to(device)
730
+
731
+ # Forward pass
732
+ with torch.no_grad():
733
+ sp = torch.tensor([species_id], device=device)
734
+ if USE_EMBEDDINGS:
735
+ try:
736
+ out_r = model(**inp_r, species_ids=sp, output_hidden_states=True)
737
+ out_a = model(**inp_a, species_ids=sp, output_hidden_states=True)
738
+ for o in (out_r, out_a):
739
+ if getattr(o, "last_hidden_state", None) is None and hasattr(
740
+ o, "hidden_states"
741
+ ):
742
+ o.last_hidden_state = o.hidden_states[-1]
743
+ except TypeError:
744
+ out_r = model(**inp_r, species_ids=sp)
745
+ out_a = model(**inp_a, species_ids=sp)
746
+ else:
747
+ out_r = model(**inp_r, species_ids=sp)
748
+ out_a = model(**inp_a, species_ids=sp)
749
+
750
+ # Build result
751
+ res = {c: row[c] for c in df.columns}
752
+ ref_allele = str(row["ref"])
753
+ alt_allele = str(row["alt"])
754
+ res["indel_size"] = len(alt_allele) - len(ref_allele)
755
+ variant_span = max(1, len(ref_allele), len(alt_allele))
756
+ in_len = int(inp_r["input_ids"].shape[1])
757
+
758
+ # === BED tracks ===
759
+ bed_r = getattr(out_r, "bed_tracks_logits", None)
760
+ bed_a = getattr(out_a, "bed_tracks_logits", None)
761
+ if USE_BED and bed_r is not None and bed_a is not None:
762
+ bed_r_probs = to_track_probabilities(bed_r[0])
763
+ bed_a_probs = to_track_probabilities(bed_a[0])
764
+ track_len = int(bed_r_probs.shape[0])
765
+ track_start = max(0, (in_len - track_len) // 2)
766
+ bed_pos = vcenter - track_start
767
+ if 0 <= bed_pos < track_len:
768
+ be = min(bed_pos + variant_span, track_len)
769
+ br = bed_r_probs[bed_pos:be].mean(0).float().cpu().numpy()
770
+ ba = bed_a_probs[bed_pos:be].mean(0).float().cpu().numpy()
771
+ for j, nm in enumerate(bed_names):
772
+ res[f"REF_BED_{nm}"] = float(br[j])
773
+ res[f"D_BED_{nm}"] = float(ba[j] - br[j])
774
+ else:
775
+ for nm in bed_names:
776
+ res[f"REF_BED_{nm}"] = np.nan
777
+ res[f"D_BED_{nm}"] = np.nan
778
+ else:
779
+ for nm in bed_names:
780
+ res[f"REF_BED_{nm}"] = np.nan
781
+ res[f"D_BED_{nm}"] = np.nan
782
+
783
+ # === BigWig tracks ===
784
+ bw_r = getattr(out_r, "bigwig_tracks_logits", None)
785
+ bw_a = getattr(out_a, "bigwig_tracks_logits", None)
786
+ if species == "human" and USE_BIGWIGS and bw_r is not None and bw_a is not None:
787
+ bw_r_probs = to_track_probabilities(bw_r[0])
788
+ bw_a_probs = to_track_probabilities(bw_a[0])
789
+ track_len = int(bw_r_probs.shape[0])
790
+ track_start = max(0, (in_len - track_len) // 2)
791
+ bw_pos = vcenter - track_start
792
+ if 0 <= bw_pos < track_len:
793
+ bwe = min(bw_pos + variant_span, track_len)
794
+ bwr = bw_r_probs[bw_pos:bwe].mean(0).float().cpu().numpy()
795
+ bwa = bw_a_probs[bw_pos:bwe].mean(0).float().cpu().numpy()
796
+ for gi in active_bw_indices:
797
+ res[f"REF_BW_{active_bigwig_names[gi]}"] = float(bwr[gi])
798
+ res[f"D_BW_{active_bigwig_names[gi]}"] = float(bwa[gi] - bwr[gi])
799
+ else:
800
+ for gi in active_bw_indices:
801
+ res[f"REF_BW_{active_bigwig_names[gi]}"] = np.nan
802
+ res[f"D_BW_{active_bigwig_names[gi]}"] = np.nan
803
+ else:
804
+ for gi in active_bw_indices:
805
+ res[f"REF_BW_{active_bigwig_names[gi]}"] = np.nan
806
+ res[f"D_BW_{active_bigwig_names[gi]}"] = np.nan
807
+
808
+ # === MLM features ===
809
+ res.update(
810
+ compute_mlm_features(
811
+ out_r,
812
+ out_a,
813
+ ref_seq,
814
+ alt_seq,
815
+ 0,
816
+ vcenter,
817
+ ref_allele,
818
+ alt_allele,
819
+ nuc_token_map,
820
+ use_kl=USE_KL_DIVERGENCE,
821
+ use_embeddings=USE_EMBEDDINGS,
822
+ window=MLM_WINDOW,
823
+ )
824
+ )
825
+
826
+ # === Cache full track profiles for plotting (single-variant mode only) ===
827
+ if cache_profiles:
828
+ _cache_track_profiles(
829
+ row,
830
+ vcenter,
831
+ in_len,
832
+ bed_names,
833
+ active_bigwig_names,
834
+ active_bw_indices,
835
+ bed_r,
836
+ bed_a,
837
+ bw_r,
838
+ bw_a,
839
+ )
840
+
841
+ results.append(res)
842
+
843
+ return pd.DataFrame(results)
844
+
845
+
846
+ def _cache_track_profiles(
847
+ row,
848
+ vcenter,
849
+ in_len,
850
+ bed_names,
851
+ bigwig_names,
852
+ selected_bw_indices,
853
+ bed_r_logits,
854
+ bed_a_logits,
855
+ bw_r_logits,
856
+ bw_a_logits,
857
+ ):
858
+ """Cache the most recent variant's full track-length logit profiles for plotting."""
859
+ global _LAST_TRACK_PROFILES
860
+
861
+ profiles: Dict[str, Any] = {
862
+ "chrom": str(row["chrom"]),
863
+ "pos": int(row["pos"]),
864
+ "ref": str(row["ref"]),
865
+ "alt": str(row["alt"]),
866
+ "variant_center": vcenter,
867
+ "input_len": in_len,
868
+ "bed_names": list(bed_names),
869
+ "bigwig_names": list(bigwig_names),
870
+ "selected_bw_indices": list(selected_bw_indices),
871
+ }
872
+
873
+ # BED profiles: convert to probabilities and store as numpy (L, 21)
874
+ if bed_r_logits is not None and bed_a_logits is not None:
875
+ bed_ref_probs = to_track_probabilities(bed_r_logits[0]).float().cpu().numpy()
876
+ bed_alt_probs = to_track_probabilities(bed_a_logits[0]).float().cpu().numpy()
877
+ profiles["bed_ref"] = bed_ref_probs
878
+ profiles["bed_alt"] = bed_alt_probs
879
+ profiles["bed_track_len"] = bed_ref_probs.shape[0]
880
+ profiles["bed_track_start"] = max(0, (in_len - bed_ref_probs.shape[0]) // 2)
881
+ else:
882
+ profiles["bed_ref"] = profiles["bed_alt"] = None
883
+
884
+ # BigWig profiles: convert to probabilities and store as numpy (L, T)
885
+ if bw_r_logits is not None and bw_a_logits is not None:
886
+ bw_ref_probs = to_track_probabilities(bw_r_logits[0]).float().cpu().numpy()
887
+ bw_alt_probs = to_track_probabilities(bw_a_logits[0]).float().cpu().numpy()
888
+ profiles["bw_ref"] = bw_ref_probs
889
+ profiles["bw_alt"] = bw_alt_probs
890
+ profiles["bw_track_len"] = bw_ref_probs.shape[0]
891
+ profiles["bw_track_start"] = max(0, (in_len - bw_ref_probs.shape[0]) // 2)
892
+ else:
893
+ profiles["bw_ref"] = profiles["bw_alt"] = None
894
+
895
+ _LAST_TRACK_PROFILES = profiles
interpretation.py ADDED
@@ -0,0 +1,253 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ #!/usr/bin/env python3
2
+ """
3
+ Rule-based signal interpretation for the MAGI Gradio app.
4
+
5
+ This module is intentionally self-contained. It converts already-available
6
+ single-variant outputs into a short interpretation panel without depending on
7
+ notebook code or outer-folder runtime imports.
8
+ """
9
+
10
+ from typing import Dict, List, Optional, Tuple
11
+
12
+ import numpy as np
13
+ import pandas as pd
14
+
15
+
16
+ STRONG_DELTA = 0.20
17
+ MODERATE_DELTA = 0.10
18
+ WEAK_DELTA = 0.05
19
+
20
+ LLR_STRONG_NEG = -3.0
21
+ LLR_MODERATE_NEG = -1.5
22
+ LLR_POSITIVE = 1.0
23
+
24
+ KL_STRONG = 0.30
25
+ KL_MODERATE = 0.15
26
+
27
+ BED_FEATURE_LABELS: Dict[str, Tuple[str, str]] = {
28
+ "mRNA_splice": ("splice-site recognition", "splice disruption"),
29
+ "coding_sequence": ("coding sequence identity", "coding disruption"),
30
+ "mRNA_exon": ("exonic structure", "coding or exon-level disruption"),
31
+ "start_codon": ("translation initiation", "start-codon disruption"),
32
+ "stop_codon": ("translation termination", "stop-codon disruption"),
33
+ "mRNA_promoter": ("promoter activity", "promoter-regulatory disruption"),
34
+ "five_prime_UTR": ("5' UTR regulation", "UTR-level regulation change"),
35
+ "three_prime_UTR": ("3' UTR regulation", "UTR-level regulation change"),
36
+ "mRNA_intron": ("intronic transcript context", "intronic transcript disruption"),
37
+ "gene": ("genic context", "genic structural disruption"),
38
+ "other": ("annotated genomic context", "localized genomic disruption"),
39
+ }
40
+
41
+
42
+ def _is_missing(value) -> bool:
43
+ return value is None or (isinstance(value, float) and np.isnan(value))
44
+
45
+
46
+ def _fmt_value(value, fmt: str = ".3f", na: str = "N/A") -> str:
47
+ if value is None:
48
+ return na
49
+ try:
50
+ if pd.isna(value):
51
+ return na
52
+ except TypeError:
53
+ pass
54
+ try:
55
+ return format(float(value), fmt)
56
+ except (TypeError, ValueError):
57
+ return na
58
+
59
+
60
+ def _strength_from_delta(delta: float) -> str:
61
+ magnitude = abs(delta)
62
+ if magnitude >= STRONG_DELTA:
63
+ return "strong"
64
+ if magnitude >= MODERATE_DELTA:
65
+ return "moderate"
66
+ if magnitude >= WEAK_DELTA:
67
+ return "subtle"
68
+ return "weak"
69
+
70
+
71
+ def _direction_word(delta: float) -> str:
72
+ return "gain" if delta > 0 else "loss"
73
+
74
+
75
+ def _top_ranked_by_type(ranked: List[Dict], track_type: str, k: int = 2) -> List[Dict]:
76
+ return [item for item in ranked if item.get("track_type") == track_type][:k]
77
+
78
+
79
+ def _humanize_bw_context(display_name: str) -> str:
80
+ parts = [part.strip() for part in str(display_name).split("|") if part.strip()]
81
+ if not parts:
82
+ return str(display_name)
83
+ if len(parts) == 1:
84
+ return parts[0]
85
+ if len(parts) == 2:
86
+ return f"{parts[0]} ({parts[1]})"
87
+ return f"{parts[0]} ({parts[1]}, {parts[2]})"
88
+
89
+
90
+ def _bed_mechanism(track_id: str) -> Tuple[str, str]:
91
+ return BED_FEATURE_LABELS.get(
92
+ track_id,
93
+ (track_id.replace("_", " "), "localized structural disruption"),
94
+ )
95
+
96
+
97
+ def _primary_mechanism(
98
+ ranked: List[Dict], row: pd.Series, variant_type: str
99
+ ) -> Tuple[str, str]:
100
+ bed_ranked = _top_ranked_by_type(ranked, "BED", k=5)
101
+ for item in bed_ranked:
102
+ if abs(float(item.get("delta", 0.0))) < WEAK_DELTA:
103
+ continue
104
+ label, mechanism = _bed_mechanism(str(item.get("track_id", "other")))
105
+ return mechanism, f"The strongest BED signal points to {label}."
106
+
107
+ bw_ranked = _top_ranked_by_type(ranked, "BigWig", k=3)
108
+ if bw_ranked and abs(float(bw_ranked[0].get("delta", 0.0))) >= MODERATE_DELTA:
109
+ context = _humanize_bw_context(str(bw_ranked[0].get("display_name", "track")))
110
+ return (
111
+ "context-specific regulatory change",
112
+ f"The strongest ranked context signal suggests a shift in {context}.",
113
+ )
114
+
115
+ llr = row.get("LLR", np.nan)
116
+ kl_mean = row.get("MLM_KL_mean", np.nan)
117
+ if not _is_missing(llr) and float(llr) <= LLR_MODERATE_NEG:
118
+ return (
119
+ "sequence-constraint signal",
120
+ "The sequence model ranks the alternate sequence as less likely even without a dominant BED or BigWig signal.",
121
+ )
122
+ if not _is_missing(kl_mean) and float(kl_mean) >= KL_MODERATE:
123
+ return (
124
+ "sequence-disruption signal",
125
+ "Token-level sequence distributions shift even though no single BED or BigWig track dominates.",
126
+ )
127
+
128
+ return (
129
+ "mixed or weak evidence",
130
+ f"No single {variant_type.lower()} mechanism dominates the current rule-based evidence.",
131
+ )
132
+
133
+
134
+ def _bed_evidence_lines(ranked: List[Dict]) -> List[str]:
135
+ lines: List[str] = []
136
+ for item in _top_ranked_by_type(ranked, "BED", k=2):
137
+ delta = float(item.get("delta", 0.0))
138
+ label, _ = _bed_mechanism(str(item.get("track_id", "other")))
139
+ strength = _strength_from_delta(delta)
140
+ direction = _direction_word(delta)
141
+ ref_val = _fmt_value(item.get("ref_val"))
142
+ alt_val = _fmt_value(item.get("alt_val"))
143
+ lines.append(
144
+ f"BED `{item['track_id']}` shows a {strength} {direction} in {label} "
145
+ f"(REF {ref_val} → ALT {alt_val}, Δ={delta:+.3f})."
146
+ )
147
+ return lines
148
+
149
+
150
+ def _bw_evidence_lines(ranked: List[Dict]) -> List[str]:
151
+ lines: List[str] = []
152
+ for item in _top_ranked_by_type(ranked, "BigWig", k=2):
153
+ delta = float(item.get("delta", 0.0))
154
+ strength = _strength_from_delta(delta)
155
+ direction = _direction_word(delta)
156
+ ref_val = _fmt_value(item.get("ref_val"))
157
+ alt_val = _fmt_value(item.get("alt_val"))
158
+ context = _humanize_bw_context(
159
+ str(item.get("display_name", item.get("track_id", "track")))
160
+ )
161
+ lines.append(
162
+ f"Context track `{context}` has a {strength} {direction} "
163
+ f"(REF {ref_val} → ALT {alt_val}, Δ={delta:+.3f})."
164
+ )
165
+ return lines
166
+
167
+
168
+ def _sequence_evidence_lines(row: pd.Series, variant_type: str) -> List[str]:
169
+ lines: List[str] = []
170
+
171
+ llr = row.get("LLR", np.nan)
172
+ if not _is_missing(llr):
173
+ llr = float(llr)
174
+ if llr <= LLR_STRONG_NEG:
175
+ lines.append(
176
+ f"Sequence-model evidence is strong: LLR {llr:.3f} makes the alternate sequence much less plausible than reference."
177
+ )
178
+ elif llr <= LLR_MODERATE_NEG:
179
+ lines.append(
180
+ f"Sequence-model evidence is supportive: LLR {llr:.3f} penalizes the alternate sequence."
181
+ )
182
+ elif llr >= LLR_POSITIVE:
183
+ lines.append(
184
+ f"LLR {llr:.3f} does not penalize the alternate allele, so sequence-only support is limited."
185
+ )
186
+
187
+ kl_mean = row.get("MLM_KL_mean", np.nan)
188
+ if not _is_missing(kl_mean):
189
+ kl_mean = float(kl_mean)
190
+ if kl_mean >= KL_STRONG:
191
+ lines.append(
192
+ f"Mean KL {kl_mean:.3f} indicates a pronounced redistribution of token probabilities around the variant."
193
+ )
194
+ elif kl_mean >= KL_MODERATE:
195
+ lines.append(
196
+ f"Mean KL {kl_mean:.3f} indicates moderate local sequence perturbation."
197
+ )
198
+
199
+ if variant_type == "Indel":
200
+ emb_cosine = row.get("EMB_cosine_dist", np.nan)
201
+ emb_l2 = row.get("EMB_l2_dist", np.nan)
202
+ if not _is_missing(emb_cosine) or not _is_missing(emb_l2):
203
+ lines.append(
204
+ "Indel embedding distances are available as supportive context: "
205
+ f"cosine={_fmt_value(emb_cosine)}, L2={_fmt_value(emb_l2)}."
206
+ )
207
+
208
+ return lines
209
+
210
+
211
+ def build_signal_interpretation(
212
+ row: pd.Series,
213
+ ranked: List[Dict],
214
+ variant_type: str,
215
+ ) -> str:
216
+ """Create a short deterministic interpretation panel."""
217
+ gene_name = row.get("gene_name", "Intergenic") or "Intergenic"
218
+ region_class = row.get("region_class", "OTHER")
219
+ if str(gene_name).startswith("N/A (non-human)") or str(region_class) == "NON_HUMAN":
220
+ context_anchor = "non-human BED + sequence outputs"
221
+ else:
222
+ context_anchor = f"{gene_name} / {region_class}"
223
+
224
+ if not ranked:
225
+ return (
226
+ "### Rule-Based Signal Interpretation\n\n"
227
+ "No ranked BED or BigWig signals available for rule-based interpretation.\n\n"
228
+ "**Note:** The MAGI score is computed separately from bundled baseline statistics and is shown in the Variant Summary rather than in this panel."
229
+ )
230
+
231
+ mechanism, rationale = _primary_mechanism(ranked, row, variant_type)
232
+ evidence_lines = []
233
+ evidence_lines.extend(_bed_evidence_lines(ranked))
234
+ evidence_lines.extend(_bw_evidence_lines(ranked))
235
+ evidence_lines.extend(_sequence_evidence_lines(row, variant_type))
236
+
237
+ if not evidence_lines:
238
+ evidence_lines.append(
239
+ "The current ranked outputs are weak, so this should be treated as a low-confidence summary only."
240
+ )
241
+
242
+ bullet_block = "\n".join(f"- {line}" for line in evidence_lines[:5])
243
+
244
+ return f"""
245
+ ### Rule-Based Signal Interpretation
246
+
247
+ **Primary hypothesis:** {mechanism.capitalize()}
248
+ **Context anchor:** {context_anchor}
249
+ **Why this is suggested:** {rationale}
250
+
251
+ **Top evidence**
252
+ {bullet_block}
253
+ """.strip()
requirements.txt ADDED
@@ -0,0 +1,9 @@
 
 
 
 
 
 
 
 
 
 
1
+ torch>=2.0.0
2
+ transformers>=4.55.0
3
+ gradio>=4.0.0,<7.0.0
4
+ pandas>=2.0.0
5
+ numpy>=1.24.0
6
+ matplotlib>=3.7.0
7
+ pyfaidx>=0.7.0
8
+ requests>=2.31.0
9
+ spaces>=0.19.0
test_installation.py ADDED
@@ -0,0 +1,253 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ #!/usr/bin/env python3
2
+ """
3
+ Quick validation script for the MAGI Gradio app.
4
+
5
+ The local genome is optional. If `data/hg38.fa` is absent, the app can fall back
6
+ to UCSC sequence retrieval.
7
+ """
8
+
9
+ import sys
10
+ import os
11
+ import requests
12
+
13
+
14
+ def check_dependencies():
15
+ """Check if all required packages are importable"""
16
+ print("🔍 Checking dependencies...")
17
+ required = [
18
+ ("torch", "PyTorch"),
19
+ ("transformers", "Transformers"),
20
+ ("gradio", "Gradio"),
21
+ ("pandas", "Pandas"),
22
+ ("numpy", "NumPy"),
23
+ ("matplotlib", "Matplotlib"),
24
+ ("pyfaidx", "pyfaidx"),
25
+ ]
26
+
27
+ missing = []
28
+ for module, name in required:
29
+ try:
30
+ __import__(module)
31
+ print(f" ✓ {name}")
32
+ except ImportError:
33
+ print(f" ✗ {name} (missing)")
34
+ missing.append(module)
35
+
36
+ if missing:
37
+ print(f"\n❌ Missing packages: {', '.join(missing)}")
38
+ print(f" Install with: pip install {' '.join(missing)}")
39
+ return False
40
+ else:
41
+ print("\n✅ All dependencies installed\n")
42
+ return True
43
+
44
+
45
+ def check_data_files():
46
+ """Check if required data files exist (local genome optional)."""
47
+ print("🔍 Checking data files...")
48
+
49
+ required_files = [
50
+ ("data/MANE_processed.csv", "MANE transcripts", 200_000_000), # ~235 MB
51
+ ("data/Promoter_processed.csv", "Promoter annotations", 10_000_000), # ~11 MB
52
+ (
53
+ "data/functional_tracks_metadata_human.csv",
54
+ "BigWig metadata",
55
+ 500_000,
56
+ ), # ~519 KB
57
+ ]
58
+
59
+ all_present = True
60
+ for file_path, description, min_size in required_files:
61
+ if os.path.exists(file_path):
62
+ size = os.path.getsize(file_path)
63
+ size_mb = size / 1_000_000
64
+ if size >= min_size:
65
+ print(f" ✓ {description} ({size_mb:.1f} MB)")
66
+ else:
67
+ print(
68
+ f" ⚠ {description} exists but may be corrupted ({size_mb:.1f} MB < {min_size / 1_000_000:.1f} MB expected)"
69
+ )
70
+ all_present = False
71
+ else:
72
+ print(f" ✗ {description} (missing: {file_path})")
73
+ all_present = False
74
+
75
+ local_fa = os.path.exists("data/hg38.fa")
76
+ local_fai = os.path.exists("data/hg38.fa.fai")
77
+ local_fa_gz = os.path.exists("data/hg38.fa.gz")
78
+
79
+ if local_fa:
80
+ size_mb = os.path.getsize("data/hg38.fa") / 1_000_000
81
+ print(f" ✓ Local reference genome available ({size_mb:.1f} MB)")
82
+ if local_fai:
83
+ idx_mb = os.path.getsize("data/hg38.fa.fai") / 1_000_000
84
+ print(f" ✓ Genome index available ({idx_mb:.2f} MB)")
85
+ else:
86
+ print(
87
+ " ⚠ Local genome index missing (will auto-build on first local lookup)"
88
+ )
89
+ elif local_fa_gz:
90
+ gz_mb = os.path.getsize("data/hg38.fa.gz") / 1_000_000
91
+ print(
92
+ f" ⚠ Compressed genome found ({gz_mb:.1f} MB); app will use UCSC API unless decompressed"
93
+ )
94
+ else:
95
+ print(" ⚠ Local genome not found; app will use UCSC API fallback")
96
+
97
+ if not all_present:
98
+ print("\n❌ Some data files missing or incomplete")
99
+ return False
100
+ else:
101
+ print("\n✅ Required data files present\n")
102
+ return True
103
+
104
+
105
+ def test_sequence_access():
106
+ """Test sequence retrieval path: local hg38.fa if available, otherwise UCSC API."""
107
+ print("🔍 Testing sequence access...")
108
+
109
+ def _test_ucsc_api():
110
+ response = requests.get(
111
+ "https://api.genome.ucsc.edu/getData/sequence",
112
+ params={
113
+ "genome": "hg38",
114
+ "chrom": "chr17",
115
+ "start": 7675087,
116
+ "end": 7675088,
117
+ },
118
+ timeout=10,
119
+ )
120
+ response.raise_for_status()
121
+ dna = str(response.json().get("dna", "")).upper()
122
+ if dna and dna[0] in {"A", "C", "G", "T", "N"}:
123
+ print(f" ✓ UCSC API reachable (chr17:7675088 -> {dna[0]})")
124
+ print("✅ Sequence access test passed\n")
125
+ return True
126
+ return False
127
+
128
+ if not os.path.exists("data/hg38.fa") or not os.path.exists("data/hg38.fa.fai"):
129
+ if os.path.exists("data/hg38.fa") and not os.path.exists("data/hg38.fa.fai"):
130
+ print(
131
+ " ⚠ Local hg38.fa found but index missing; validating UCSC API path to avoid long index build"
132
+ )
133
+ try:
134
+ if _test_ucsc_api():
135
+ return True
136
+ except Exception as e:
137
+ print(f" ✗ UCSC API test failed: {e}")
138
+ return False
139
+
140
+ try:
141
+ import pyfaidx
142
+
143
+ genome = pyfaidx.Fasta("data/hg38.fa")
144
+
145
+ # Test accessing chr17 (TP53 region), with the same fallback behavior as the app.
146
+ seq = None
147
+ for test_chrom in ("chr17", "17"):
148
+ if test_chrom in genome.keys():
149
+ seq = str(genome[test_chrom][7675087:7675088]).upper()
150
+ print(f" ✓ Successfully accessed {test_chrom} locally (TP53 position: {seq})")
151
+ break
152
+
153
+ if seq is None:
154
+ print(" ⚠ Local genome could not serve chr17; testing UCSC fallback instead")
155
+ genome.close()
156
+ return _test_ucsc_api()
157
+
158
+ genome.close()
159
+ print("✅ Genome access test passed\n")
160
+ return True
161
+
162
+ except Exception as e:
163
+ print(f" ✗ Error accessing genome: {e}")
164
+ return False
165
+
166
+
167
+ def test_model_loading():
168
+ """Test that we can load the configured NTv3 model."""
169
+ print("🔍 Testing model loading (this may take 30-60 seconds)...")
170
+
171
+ try:
172
+ from transformers import AutoModel, AutoTokenizer
173
+
174
+ model_name = "InstaDeepAI/NTv3_650M_post"
175
+ hf_token = os.environ.get("HF_TOKEN")
176
+ print(" ⏳ Loading tokenizer...")
177
+ tokenizer = AutoTokenizer.from_pretrained(
178
+ model_name,
179
+ trust_remote_code=True,
180
+ token=hf_token,
181
+ )
182
+
183
+ print(" ⏳ Loading model (650M configuration)...")
184
+ model = AutoModel.from_pretrained(
185
+ model_name,
186
+ trust_remote_code=True,
187
+ token=hf_token,
188
+ )
189
+
190
+ print(" ✓ Model loaded successfully")
191
+ print(f" ✓ Tokenizer vocab size: {tokenizer.vocab_size}")
192
+ print(
193
+ f" ✓ Model parameters: {sum(p.numel() for p in model.parameters()) / 1e6:.1f}M"
194
+ )
195
+
196
+ print("✅ Model loading test passed\n")
197
+ return True
198
+
199
+ except Exception as e:
200
+ print(f" ✗ Error loading model: {e}")
201
+ if "gated" in str(e).lower() or "401" in str(e):
202
+ print(" ⚠ Accept the model terms and set HF_TOKEN before retrying")
203
+ return False
204
+
205
+
206
+ def main():
207
+ """Run all validation tests"""
208
+ print("\n" + "=" * 60)
209
+ print(" MAGI Gradio App - Installation Validation")
210
+ print("=" * 60 + "\n")
211
+
212
+ # Change to app directory if needed
213
+ if os.path.exists("ntv3_gradio_app"):
214
+ os.chdir("ntv3_gradio_app")
215
+ print("📁 Working directory: ntv3_gradio_app/\n")
216
+
217
+ # Run checks
218
+ results = []
219
+ results.append(("Dependencies", check_dependencies()))
220
+ results.append(("Data Files", check_data_files()))
221
+ results.append(("Sequence Access", test_sequence_access()))
222
+
223
+ # Model test is optional (downloads ~400MB if not cached)
224
+ if all(r[1] for r in results):
225
+ try:
226
+ results.append(("Model Loading", test_model_loading()))
227
+ except KeyboardInterrupt:
228
+ print("\n⚠ Model test skipped (user interrupt)")
229
+
230
+ # Summary
231
+ print("\n" + "=" * 60)
232
+ print(" Validation Summary")
233
+ print("=" * 60)
234
+ for name, passed in results:
235
+ status = "✅ PASS" if passed else "❌ FAIL"
236
+ print(f" {name:20s} {status}")
237
+ print("=" * 60 + "\n")
238
+
239
+ if all(r[1] for r in results):
240
+ print("🎉 All tests passed! Ready to run the app:")
241
+ print(" python app.py")
242
+ print(" or")
243
+ print(" gradio app.py")
244
+ return 0
245
+ else:
246
+ print(
247
+ "⚠️ Some tests failed. Please fix the issues above before running the app."
248
+ )
249
+ return 1
250
+
251
+
252
+ if __name__ == "__main__":
253
+ sys.exit(main())
tracks.py ADDED
@@ -0,0 +1,455 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ #!/usr/bin/env python3
2
+ """
3
+ Track Visualization Module
4
+ ===========================
5
+ Generates region-level fill-between plots from cached NTv3 track profiles.
6
+
7
+ Shows the continuous predicted probability of each genomic feature across the
8
+ analysis window, highlighting the variant position and the most impacted tracks.
9
+ Unified track selection is driven by the ranked list from analysis.py to
10
+ ensure consistency with the fingerprint bar chart.
11
+
12
+ Usage:
13
+ from tracks import generate_region_tracks_plot
14
+ fig = generate_region_tracks_plot(ranked_tracks=ranked, visible_radius_bp=1000)
15
+ """
16
+
17
+ from typing import Optional, Dict, List
18
+
19
+ import numpy as np
20
+ import pandas as pd
21
+ import matplotlib
22
+ import matplotlib.pyplot as plt
23
+ from matplotlib.patches import Patch
24
+
25
+ matplotlib.use("Agg")
26
+
27
+ # LOF/GOF palette — consistent with fingerprint bar chart
28
+ _CLR_REF = "#555555" # neutral gray for reference
29
+ _CLR_GAIN = "#d73027" # red — gain of function (ALT > REF)
30
+ _CLR_LOSS = "#2166ac" # blue — loss of function (ALT < REF)
31
+ _CLR_VARIANT = "#333333" # dark gray for variant position line
32
+ _BED_BG = "#f7f7f7" # subtle background for BED group
33
+ _BW_BG = "#fffef5" # subtle warm tint for BigWig group
34
+
35
+
36
+ def get_track_view_bounds() -> Dict[str, Optional[int]]:
37
+ """Return the exact symmetric track-view bounds available for the current variant."""
38
+ from inference import _LAST_TRACK_PROFILES
39
+
40
+ profiles = _LAST_TRACK_PROFILES
41
+ if not profiles:
42
+ return {"max_radius": None, "window_start": None, "window_end": None}
43
+
44
+ pos = int(profiles["pos"])
45
+ vcenter = int(profiles["variant_center"])
46
+ candidate_radii: List[int] = []
47
+
48
+ for prefix in ("bed", "bw"):
49
+ if profiles.get(f"{prefix}_ref") is None:
50
+ continue
51
+ track_len = profiles.get(f"{prefix}_track_len")
52
+ track_start = profiles.get(f"{prefix}_track_start")
53
+ if track_len is None or track_start is None:
54
+ continue
55
+
56
+ left_radius = max(0, int(vcenter - int(track_start)))
57
+ right_radius = max(0, int(int(track_start) + int(track_len) - 1 - vcenter))
58
+ candidate_radii.append(min(left_radius, right_radius))
59
+
60
+ if not candidate_radii:
61
+ return {"max_radius": None, "window_start": None, "window_end": None}
62
+
63
+ max_radius = max(8, int(min(candidate_radii)))
64
+ return {
65
+ "max_radius": max_radius,
66
+ "window_start": pos - max_radius,
67
+ "window_end": pos + max_radius,
68
+ }
69
+
70
+
71
+ def generate_region_tracks_plot(
72
+ ranked_tracks: Optional[List[Dict]] = None,
73
+ metadata_df: Optional[pd.DataFrame] = None,
74
+ metadata_dict: Optional[Dict[str, Dict[str, str]]] = None,
75
+ top_k_bed: int = 3,
76
+ top_k_bw: int = 3,
77
+ visible_radius_bp: int = 1000,
78
+ max_ranked_tracks: int = 10,
79
+ figsize_x: float = 14.0,
80
+ row_height: float = 1.6,
81
+ ) -> Optional[plt.Figure]:
82
+ """
83
+ Generate fill-between region view from cached track profiles.
84
+
85
+ If *ranked_tracks* is provided (from analysis.rank_top_disrupted_tracks),
86
+ those exact tracks are shown (ensuring consistency with fingerprint).
87
+ Otherwise falls back to auto-selecting top-N by point delta.
88
+
89
+ Args:
90
+ ranked_tracks: Pre-ranked list of dicts with keys {track_id, track_type, display_name, delta}.
91
+ metadata_df / metadata_dict: BigWig metadata (used only for fallback auto-select).
92
+ top_k_bed / top_k_bw: Fallback auto-select counts (ignored when ranked_tracks given).
93
+ visible_radius_bp: Half-width of visible window centred on variant (bp).
94
+ max_ranked_tracks: Maximum number of ranked tracks to render.
95
+ figsize_x: Figure width in inches.
96
+ row_height: Height per subplot row.
97
+ """
98
+ from inference import _LAST_TRACK_PROFILES
99
+
100
+ profiles = _LAST_TRACK_PROFILES
101
+ if not profiles:
102
+ return None
103
+
104
+ chrom = profiles["chrom"]
105
+ pos = profiles["pos"]
106
+ ref = profiles["ref"]
107
+ alt = profiles["alt"]
108
+ vcenter = profiles["variant_center"]
109
+ bed_names = profiles["bed_names"]
110
+ bigwig_names = profiles["bigwig_names"]
111
+ selected_bw = profiles["selected_bw_indices"]
112
+
113
+ # ── Build per-track rendering list ──────────────────────────────────
114
+ all_tracks: List[dict] = []
115
+
116
+ if ranked_tracks:
117
+ # Use unified ranking — pull continuous arrays from cache
118
+ for item in ranked_tracks[: max(1, int(max_ranked_tracks))]:
119
+ tid = item["track_id"]
120
+ ttype = item["track_type"]
121
+
122
+ if ttype == "BED":
123
+ if profiles.get("bed_ref") is None:
124
+ continue
125
+ bed_ref = profiles["bed_ref"]
126
+ bed_alt = profiles["bed_alt"]
127
+ track_len = profiles["bed_track_len"]
128
+ track_start = profiles["bed_track_start"]
129
+ try:
130
+ idx = bed_names.index(tid)
131
+ except ValueError:
132
+ continue
133
+ bed_pos = vcenter - track_start
134
+ delta_at = (
135
+ float(bed_alt[bed_pos, idx] - bed_ref[bed_pos, idx])
136
+ if 0 <= bed_pos < track_len
137
+ else 0.0
138
+ )
139
+ all_tracks.append(
140
+ {
141
+ "name": item["display_name"],
142
+ "ref": bed_ref[:, idx],
143
+ "alt": bed_alt[:, idx],
144
+ "type": "BED",
145
+ "track_start": track_start,
146
+ "delta_at_variant": delta_at,
147
+ }
148
+ )
149
+
150
+ elif ttype == "BigWig":
151
+ if profiles.get("bw_ref") is None:
152
+ continue
153
+ bw_ref = profiles["bw_ref"]
154
+ bw_alt = profiles["bw_alt"]
155
+ track_len = profiles["bw_track_len"]
156
+ track_start = profiles["bw_track_start"]
157
+ try:
158
+ global_idx = bigwig_names.index(tid)
159
+ except ValueError:
160
+ continue
161
+ bw_pos = vcenter - track_start
162
+ delta_at = (
163
+ float(bw_alt[bw_pos, global_idx] - bw_ref[bw_pos, global_idx])
164
+ if 0 <= bw_pos < track_len
165
+ else 0.0
166
+ )
167
+ all_tracks.append(
168
+ {
169
+ "name": item["display_name"],
170
+ "ref": bw_ref[:, global_idx],
171
+ "alt": bw_alt[:, global_idx],
172
+ "type": "BigWig",
173
+ "track_start": track_start,
174
+ "delta_at_variant": delta_at,
175
+ }
176
+ )
177
+ else:
178
+ # Fallback: auto-select by point delta (legacy behaviour)
179
+ all_tracks = _auto_select_tracks(
180
+ profiles, metadata_df, metadata_dict, top_k_bed, top_k_bw
181
+ )
182
+
183
+ if not all_tracks:
184
+ return None
185
+
186
+ # ── Separate BED and BigWig groups for visual banding ───────────────
187
+ bed_group = [t for t in all_tracks if t["type"] == "BED"]
188
+ bw_group = [t for t in all_tracks if t["type"] == "BigWig"]
189
+ ordered = bed_group + bw_group
190
+ n_tracks = len(ordered)
191
+
192
+ fig, axes = plt.subplots(
193
+ n_tracks,
194
+ 1,
195
+ figsize=(figsize_x, row_height * n_tracks + 1.0),
196
+ sharex=True,
197
+ squeeze=False,
198
+ )
199
+ axes = axes.flatten()
200
+
201
+ genomic_origin = pos - vcenter # genomic coord at token 0
202
+ n_bed = len(bed_group)
203
+ requested_radius_bp = max(int(visible_radius_bp), 8)
204
+ view_bounds = get_track_view_bounds()
205
+ max_radius_bp = view_bounds.get("max_radius")
206
+ effective_radius_bp = (
207
+ min(requested_radius_bp, max_radius_bp)
208
+ if max_radius_bp is not None
209
+ else requested_radius_bp
210
+ )
211
+ xleft = pos - effective_radius_bp
212
+ xright = pos + effective_radius_bp
213
+
214
+ for ax_idx, track_info in enumerate(ordered):
215
+ ax = axes[ax_idx]
216
+ ts = track_info["track_start"]
217
+ arr_len = len(track_info["ref"])
218
+ x_genomic = np.arange(ts, ts + arr_len) + genomic_origin
219
+
220
+ window_mask = (x_genomic >= xleft) & (x_genomic <= xright)
221
+ if not np.any(window_mask):
222
+ nearest_idx = int(np.argmin(np.abs(x_genomic - pos)))
223
+ lo = max(0, nearest_idx - 1)
224
+ hi = min(arr_len, nearest_idx + 2)
225
+ window_mask = np.zeros(arr_len, dtype=bool)
226
+ window_mask[lo:hi] = True
227
+
228
+ x_window = x_genomic[window_mask]
229
+ ref_y = track_info["ref"][window_mask]
230
+ alt_y = track_info["alt"][window_mask]
231
+
232
+ # Subtle group background
233
+ bg = _BED_BG if ax_idx < n_bed else _BW_BG
234
+ ax.set_facecolor(bg)
235
+
236
+ # ALT coloured by delta sign at variant (draw first, behind REF)
237
+ delta = track_info["delta_at_variant"]
238
+ alt_clr = _CLR_GAIN if delta > 0 else _CLR_LOSS
239
+ ax.plot(x_window, alt_y, color=alt_clr, linewidth=1.0, alpha=0.85, label="ALT")
240
+
241
+ # Directional delta fill: red where gain, blue where loss
242
+ ax.fill_between(
243
+ x_window,
244
+ ref_y,
245
+ alt_y,
246
+ where=(alt_y > ref_y),
247
+ color=_CLR_GAIN,
248
+ alpha=0.22,
249
+ interpolate=True,
250
+ )
251
+ ax.fill_between(
252
+ x_window,
253
+ ref_y,
254
+ alt_y,
255
+ where=(alt_y <= ref_y),
256
+ color=_CLR_LOSS,
257
+ alpha=0.22,
258
+ interpolate=True,
259
+ )
260
+
261
+ # REF as gray dashed line — drawn on top so it stays visible
262
+ ax.plot(x_window, ref_y, color=_CLR_REF, linewidth=1.1, alpha=0.7,
263
+ linestyle="--", dashes=(4, 2), label="REF")
264
+
265
+ # Variant position marker
266
+ ax.axvline(pos, color=_CLR_VARIANT, linewidth=1.2, linestyle="--", alpha=0.7)
267
+
268
+ # Track label
269
+ direction = "↑ Gain" if delta > 0 else "↓ Loss"
270
+ label_txt = f"{track_info['name']} (Δ = {delta:+.4f} {direction})"
271
+ ax.set_title(label_txt, fontsize=8.5, fontweight="bold", loc="left", pad=3)
272
+ ax.set_ylabel("P", fontsize=7, labelpad=1)
273
+ ax.tick_params(axis="both", labelsize=6.5)
274
+ ax.set_ylim(bottom=0)
275
+
276
+ # Minimal Tufte-style axes — only left spine + bottom on last
277
+ ax.spines["top"].set_visible(False)
278
+ ax.spines["right"].set_visible(False)
279
+ if ax_idx < n_tracks - 1:
280
+ ax.spines["bottom"].set_visible(False)
281
+ ax.tick_params(axis="x", length=0)
282
+
283
+ # Group separator line between BED and BigWig
284
+ if n_bed > 0 and len(bw_group) > 0:
285
+ # Use the axis position of the first BigWig row to draw a thin separator
286
+ sep_ax = axes[n_bed]
287
+ sep_ax.annotate(
288
+ "",
289
+ xy=(0, 1),
290
+ xycoords="axes fraction",
291
+ xytext=(1, 1),
292
+ textcoords="axes fraction",
293
+ arrowprops=dict(arrowstyle="-", color="#aaaaaa", lw=0.8),
294
+ )
295
+
296
+ # X-axis
297
+ axes[-1].set_xlabel(f"Genomic position ({chrom})", fontsize=9)
298
+ axes[-1].ticklabel_format(axis="x", style="plain", useOffset=False)
299
+ axes[-1].set_xlim(xleft, xright)
300
+ tick_positions = np.linspace(xleft, xright, num=5)
301
+ tick_positions = np.unique(np.rint(tick_positions).astype(int))
302
+ axes[-1].set_xticks(tick_positions)
303
+ axes[-1].set_xticklabels([f"{tick:,}" for tick in tick_positions], fontsize=6.5)
304
+
305
+ # Suptitle
306
+ fig.suptitle(
307
+ f"Region Track View — {chrom}:{pos:,} {ref}>{alt}",
308
+ fontsize=11,
309
+ fontweight="bold",
310
+ y=1.0,
311
+ )
312
+
313
+ # Legend
314
+ legend_elements = [
315
+ plt.Line2D([0], [0], color=_CLR_REF, linewidth=1.2, alpha=0.7,
316
+ linestyle="--", dashes=(4, 2), label="REF"),
317
+ Patch(facecolor=_CLR_GAIN, alpha=0.3, label="Gain (ALT > REF)"),
318
+ Patch(facecolor=_CLR_LOSS, alpha=0.3, label="Loss (ALT < REF)"),
319
+ plt.Line2D(
320
+ [0], [0], color=_CLR_VARIANT, linewidth=1.2, linestyle="--", label="Variant"
321
+ ),
322
+ ]
323
+ fig.legend(
324
+ handles=legend_elements,
325
+ loc="upper right",
326
+ fontsize=7,
327
+ frameon=True,
328
+ ncol=4,
329
+ bbox_to_anchor=(0.99, 0.99),
330
+ )
331
+
332
+ window_note = (
333
+ f"Visible window: {chrom}:{xleft:,}-{xright:,} "
334
+ f"(radius {effective_radius_bp:,} bp)"
335
+ )
336
+ if effective_radius_bp != requested_radius_bp:
337
+ window_note += (
338
+ f" | requested {requested_radius_bp:,} bp, limited by available track signal"
339
+ )
340
+ fig.text(0.5, 0.016, window_note, ha="center", fontsize=7.5, color="#555555")
341
+
342
+ # Footnote: P = predicted probability
343
+ fig.text(
344
+ 0.01, 0.002,
345
+ "P = predicted probability of genomic feature",
346
+ fontsize=7, color="#777777", style="italic",
347
+ )
348
+
349
+ plt.tight_layout(rect=(0, 0.03, 1, 0.96))
350
+ return fig
351
+
352
+
353
+ # ── Fallback auto-select (preserves legacy behaviour) ──────────────────
354
+ def _auto_select_tracks(profiles, metadata_df, metadata_dict, top_k_bed, top_k_bw):
355
+ """Select top tracks by point-delta when no pre-ranked list is given."""
356
+ tracks = []
357
+ vcenter = profiles["variant_center"]
358
+ bed_names = profiles["bed_names"]
359
+ bigwig_names = profiles["bigwig_names"]
360
+ selected_bw = profiles["selected_bw_indices"]
361
+
362
+ if profiles.get("bed_ref") is not None:
363
+ bed_ref = profiles["bed_ref"]
364
+ bed_alt = profiles["bed_alt"]
365
+ track_len = profiles["bed_track_len"]
366
+ track_start = profiles["bed_track_start"]
367
+ bed_pos = vcenter - track_start
368
+ if 0 <= bed_pos < track_len:
369
+ deltas = np.abs(bed_alt[bed_pos] - bed_ref[bed_pos])
370
+ top_idx = np.argsort(deltas)[-top_k_bed:][::-1]
371
+ for idx in top_idx:
372
+ name = bed_names[idx] if idx < len(bed_names) else f"BED_{idx}"
373
+ tracks.append(
374
+ {
375
+ "name": name,
376
+ "ref": bed_ref[:, idx],
377
+ "alt": bed_alt[:, idx],
378
+ "type": "BED",
379
+ "track_start": track_start,
380
+ "delta_at_variant": float(
381
+ bed_alt[bed_pos, idx] - bed_ref[bed_pos, idx]
382
+ ),
383
+ }
384
+ )
385
+
386
+ if profiles.get("bw_ref") is not None:
387
+ bw_ref = profiles["bw_ref"]
388
+ bw_alt = profiles["bw_alt"]
389
+ track_len = profiles["bw_track_len"]
390
+ track_start = profiles["bw_track_start"]
391
+ bw_pos = vcenter - track_start
392
+ if 0 <= bw_pos < track_len and selected_bw:
393
+ sel_ref = bw_ref[bw_pos, selected_bw]
394
+ sel_alt = bw_alt[bw_pos, selected_bw]
395
+ abs_d = np.abs(sel_alt - sel_ref)
396
+ top_local = np.argsort(abs_d)[-top_k_bw:][::-1]
397
+ for li in top_local:
398
+ gi = selected_bw[li]
399
+ tid = bigwig_names[gi] if gi < len(bigwig_names) else f"BW_{gi}"
400
+ display = _resolve_track_name(tid, metadata_df, metadata_dict)
401
+ tracks.append(
402
+ {
403
+ "name": display,
404
+ "ref": bw_ref[:, gi],
405
+ "alt": bw_alt[:, gi],
406
+ "type": "BigWig",
407
+ "track_start": track_start,
408
+ "delta_at_variant": float(
409
+ bw_alt[bw_pos, gi] - bw_ref[bw_pos, gi]
410
+ ),
411
+ }
412
+ )
413
+ return tracks
414
+
415
+
416
+ def _resolve_track_name(
417
+ track_id: str,
418
+ metadata_df: Optional[pd.DataFrame] = None,
419
+ metadata_dict: Optional[Dict[str, Dict[str, str]]] = None,
420
+ ) -> str:
421
+ """Resolve a BigWig track ID to a human-readable name."""
422
+ if metadata_dict:
423
+ meta = metadata_dict.get(track_id)
424
+ if meta:
425
+ parts = [
426
+ p
427
+ for p in [
428
+ meta.get("tissue", ""),
429
+ meta.get("assay", ""),
430
+ meta.get("target", ""),
431
+ ]
432
+ if p.strip() and p != "nan"
433
+ ]
434
+ if parts:
435
+ name = " | ".join(parts)
436
+ return name[:55] if len(name) > 55 else name
437
+
438
+ if metadata_df is not None:
439
+ rows = metadata_df[metadata_df["file_id"] == track_id]
440
+ if not rows.empty:
441
+ r = rows.iloc[0]
442
+ parts = [
443
+ str(p)
444
+ for p in [
445
+ r.get("tissue", ""),
446
+ r.get("assay", ""),
447
+ r.get("experiment_target", ""),
448
+ ]
449
+ if pd.notna(p) and str(p).strip()
450
+ ]
451
+ if parts:
452
+ name = " | ".join(parts)
453
+ return name[:55] if len(name) > 55 else name
454
+
455
+ return track_id[:40]
validate_examples.py ADDED
@@ -0,0 +1,234 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ #!/usr/bin/env python3
2
+ """
3
+ Comprehensive validation of example variants:
4
+ 1. Literature accuracy
5
+ 2. Example diversity and quality
6
+ 3. Track type coverage in results
7
+ """
8
+
9
+ import sys
10
+
11
+ sys.path.insert(0, ".")
12
+
13
+ from app import predict_single_variant
14
+ import pandas as pd
15
+ from collections import defaultdict
16
+ import json
17
+
18
+ # Known variants from literature
19
+ VARIANT_REFERENCES = {
20
+ ("chr17", 7675088, "C", "T"): {
21
+ "name": "TP53 R175H",
22
+ "disease": "Cancer (tumor suppressor)",
23
+ "frequency": "~6% of cancers",
24
+ "literature": "Highly common hotspot, eliminates DNA binding domain",
25
+ "impact_expected": "HIGH",
26
+ "regions_expected": ["coding", "regulatory"],
27
+ },
28
+ ("chr7", 117559593, "ATCT", "A"): {
29
+ "name": "CFTR F508del",
30
+ "disease": "Cystic Fibrosis",
31
+ "frequency": "~70% of CF patients",
32
+ "literature": "Most common CF mutation, causes protein misfolding",
33
+ "impact_expected": "HIGH",
34
+ "regions_expected": ["coding", "structural"],
35
+ },
36
+ ("chr13", 32332771, "AGAGA", "AGA"): {
37
+ "name": "BRCA2 frameshift",
38
+ "disease": "Hereditary breast/ovarian cancer",
39
+ "frequency": "Rare, pathogenic",
40
+ "literature": "BRCA2 frameshift deletion (c.5946delT), causes loss of function",
41
+ "impact_expected": "HIGH",
42
+ "regions_expected": ["coding", "frameshift"],
43
+ },
44
+ ("chr11", 5227002, "T", "A"): {
45
+ "name": "HBB E6V",
46
+ "disease": "Sickle cell disease",
47
+ "frequency": "Common in African populations",
48
+ "literature": "Missense mutation (rs334) causing hemoglobin S polymerization",
49
+ "impact_expected": "HIGH",
50
+ "regions_expected": ["coding", "regulatory"],
51
+ },
52
+ ("chr17", 43092418, "T", "C"): {
53
+ "name": "BRCA1 synonymous",
54
+ "disease": "Benign control variant",
55
+ "frequency": "Common",
56
+ "literature": "Synonymous variant (c.3113A>G, rs16941), expected benign",
57
+ "impact_expected": "LOW",
58
+ "regions_expected": ["coding"],
59
+ },
60
+ }
61
+
62
+
63
+ def categorize_track(feature_name, feature_type):
64
+ """Categorize track type (BED or BigWig, and specific kind)"""
65
+ if feature_type == "BED":
66
+ if any(
67
+ x in feature_name.lower() for x in ["splice", "exon", "intron", "codon"]
68
+ ):
69
+ return "bed_splicing"
70
+ elif any(x in feature_name.lower() for x in ["cds", "coding"]):
71
+ return "bed_coding"
72
+ elif any(x in feature_name.lower() for x in ["promoter", "enhancer"]):
73
+ return "bed_regulatory"
74
+ elif any(x in feature_name.lower() for x in ["utr", "5utr", "3utr"]):
75
+ return "bed_utr"
76
+ else:
77
+ return "bed_other"
78
+ elif feature_type == "BigWig":
79
+ if "Histone" in feature_name:
80
+ return "bw_histone"
81
+ elif "RNA" in feature_name or "CAGE" in feature_name:
82
+ return "bw_expression"
83
+ elif "DNase" in feature_name or "ATAC" in feature_name:
84
+ return "bw_accessibility"
85
+ elif "ChIP-seq" in feature_name:
86
+ return "bw_chipseq"
87
+ else:
88
+ return "bw_other"
89
+ return "unknown"
90
+
91
+
92
+ def validate_example(chrom, pos, ref, alt):
93
+ """Validate a single example"""
94
+ key = (chrom, pos, ref, alt)
95
+ ref_data = VARIANT_REFERENCES.get(key)
96
+
97
+ if not ref_data:
98
+ return None
99
+
100
+ print("\n" + "=" * 80)
101
+ print(f"VARIANT: {ref_data['name']} ({chrom}:{pos} {ref}>{alt})")
102
+ print("=" * 80)
103
+
104
+ # Literature context
105
+ print("\n📚 LITERATURE CONTEXT:")
106
+ print(f" Disease: {ref_data['disease']}")
107
+ print(f" Frequency: {ref_data['frequency']}")
108
+ print(f" Summary: {ref_data['literature']}")
109
+ print(f" Expected Impact: {ref_data['impact_expected']}")
110
+
111
+ # Run prediction
112
+ print("\n🔬 Running prediction...")
113
+ result = predict_single_variant(chrom, pos, ref, alt)
114
+ (
115
+ summary_md,
116
+ interpretation_md,
117
+ top_table_df,
118
+ fp_fig,
119
+ rt_fig,
120
+ csv_path,
121
+ bed_df,
122
+ mlm_md,
123
+ ranked,
124
+ ) = result
125
+
126
+ # Extract impact score from summary
127
+ impact_from_summary = None
128
+ if "BED Impact Score" in summary_md:
129
+ import re
130
+
131
+ m = re.search(r"BED Impact Score\s*\|\s*([\d.]+)", summary_md)
132
+ if m:
133
+ impact_from_summary = float(m.group(1))
134
+
135
+ print("\n📊 RESULTS SUMMARY:")
136
+ gene_region = "N/A"
137
+ if "**Gene:**" in summary_md:
138
+ gene_part = summary_md.split("**Gene:**")[1]
139
+ gene_region = gene_part.split("\n")[0].strip()
140
+ print(f" Gene/Region: {gene_region}")
141
+ print(f" Top tracks: {len(top_table_df)} features")
142
+ print(f" Total ranked: {len(ranked)} tracks")
143
+ print(f" Interpretation panel present: {bool(interpretation_md)}")
144
+ print(
145
+ f" Impact Score (BED): {impact_from_summary if impact_from_summary else 'N/A'}"
146
+ )
147
+
148
+ # Track diversity analysis
149
+ print("\n🎯 TRACK DIVERSITY ANALYSIS:")
150
+
151
+ track_categories = defaultdict(int)
152
+ sources = set()
153
+
154
+ # Analyze ranked tracks
155
+ for r in ranked:
156
+ feat = r["display_name"]
157
+ ftype = r["track_type"]
158
+ category = categorize_track(feat, ftype)
159
+ track_categories[category] += 1
160
+
161
+ if "|" in feat: # BigWig with tissue info
162
+ tissue = feat.split("|")[0].strip()
163
+ sources.add(tissue)
164
+
165
+ print("\n Track Categories:")
166
+ for cat, count in sorted(track_categories.items(), key=lambda x: -x[1]):
167
+ cat_display = cat.replace("bed_", "BED: ").replace("bw_", "BigWig: ")
168
+ print(f" - {cat_display}: {count}")
169
+
170
+ print(f"\n Tissue/Cell Sources Found: {len(sources)}")
171
+ if len(sources) > 0:
172
+ for source in sorted(list(sources))[:5]: # Show first 5
173
+ print(f" - {source}")
174
+ if len(sources) > 5:
175
+ print(f" ... and {len(sources) - 5} more")
176
+
177
+ # Quality assessment
178
+ print("\n✓ VARIANT QUALITY ASSESSMENT:")
179
+
180
+ # Has diverse tracks
181
+ diversity_score = len(track_categories)
182
+ if diversity_score >= 3:
183
+ print(f" ✓ Good track diversity ({diversity_score} categories)")
184
+ else:
185
+ print(f" ⚠️ Limited track diversity ({diversity_score} categories)")
186
+
187
+ # Has gains and losses
188
+ gains = [r for r in ranked if r["delta"] > 0]
189
+ losses = [r for r in ranked if r["delta"] < 0]
190
+ if gains and losses:
191
+ print(f" ✓ Shows both gains ({len(gains)}) and losses ({len(losses)})")
192
+ elif gains:
193
+ print(" ⚠️ Only shows gains, no losses")
194
+ elif losses:
195
+ print(" ⚠️ Only shows losses, no gains")
196
+ else:
197
+ print(" ✗ No gains or losses - may not be suitable example")
198
+
199
+ # Expected impact matches actual
200
+ has_high_impacts = any(abs(r["delta"]) >= 0.1 for r in ranked)
201
+
202
+ if ref_data["impact_expected"] == "HIGH" and has_high_impacts:
203
+ print(" ✓ HIGH impact expected and observed")
204
+ elif ref_data["impact_expected"] == "MODERATE" and not has_high_impacts:
205
+ print(" ✓ MODERATE impact expected and observed (no extreme deltas)")
206
+ else:
207
+ if ref_data["impact_expected"] == "HIGH":
208
+ print(" ⚠️ HIGH impact expected but weak observed")
209
+
210
+ # Relevant to disease
211
+ print(f" ✓ Disease-relevant example ({ref_data['disease']})")
212
+
213
+ return True
214
+
215
+
216
+ # Validate all examples
217
+ examples = [
218
+ ("chr17", 7675088, "C", "T"),
219
+ ("chr7", 117559593, "ATCT", "A"),
220
+ ("chr13", 32332771, "AGAGA", "AGA"),
221
+ ("chr11", 5227002, "T", "A"),
222
+ ("chr17", 43092418, "T", "C"),
223
+ ]
224
+
225
+ print("\n" + "=" * 80)
226
+ print("COMPREHENSIVE EXAMPLE VALIDATION")
227
+ print("=" * 80)
228
+
229
+ for chrom, pos, ref, alt in examples:
230
+ validate_example(chrom, pos, ref, alt)
231
+
232
+ print("\n" + "=" * 80)
233
+ print("VALIDATION COMPLETE")
234
+ print("=" * 80)