Spaces:
Running
Running
Initial deploy: MAGI variant interpreter (gradio_app)
Browse files- .gitattributes +2 -0
- .gitignore +92 -0
- README.md +184 -7
- SETUP_GUIDE.md +195 -0
- analysis.py +747 -0
- annotation.py +429 -0
- app.py +1146 -0
- data/MANE_processed.csv +3 -0
- data/MANE_processed.parquet +3 -0
- data/Promoter_processed.csv +3 -0
- data/Promoter_processed.parquet +3 -0
- data/functional_tracks_metadata_human.csv +0 -0
- data/magi_baseline_stats.csv +0 -0
- download_hg38.py +66 -0
- examples/example_variants.csv +6 -0
- inference.py +895 -0
- interpretation.py +253 -0
- requirements.txt +9 -0
- test_installation.py +253 -0
- tracks.py +455 -0
- validate_examples.py +234 -0
.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:
|
| 5 |
-
colorTo:
|
| 6 |
sdk: gradio
|
| 7 |
-
sdk_version:
|
| 8 |
-
python_version: '3.13'
|
| 9 |
app_file: app.py
|
| 10 |
pinned: false
|
|
|
|
|
|
|
| 11 |
---
|
| 12 |
|
| 13 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 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)
|