Spaces:
Running
Running
Replace the 2025 Space with the revised NTv3 release
#4
by ybornachot - opened
- .gitattributes +5 -0
- README.md +190 -53
- assets/ntv3_overview.png +3 -0
- assets/output_annotation.png +0 -0
- assets/skill_report_hbe1_4kb_annotation.png +3 -0
- assets/skill_report_hbe1_cagesal_top_hs.png +3 -0
- assets/skill_report_hbe1_hs4_saliency_cage_r848.png +0 -0
- assets/skill_workflow_v6b.png +3 -0
- index.html +84 -19
- notebooks_pipelines/01_functional_track_prediction.ipynb +0 -0
- notebooks_pipelines/02_model_interpretation.ipynb +0 -0
- notebooks_tutorials/00_quickstart_inference.ipynb +0 -0
- notebooks_tutorials/01_tracks_prediction.ipynb +0 -0
- notebooks_tutorials/02_fine_tuning_bigwig_tracks.ipynb +0 -0
- notebooks_tutorials/02_fine_tuning_pretrained_model_biwig.ipynb +0 -0
- notebooks_tutorials/03_fine_tuning_posttrained_model_biwig.ipynb +0 -0
- notebooks_tutorials/03a_genome_annotation_training.ipynb +0 -0
- notebooks_tutorials/03b_genome_annotation_decoding.ipynb +0 -0
- notebooks_tutorials/03c_genome_annotation_evaluation.ipynb +0 -0
- notebooks_tutorials/04_fine_tuning_pretrained_model_annotation.ipynb +0 -0
- notebooks_tutorials/04_model_interpretation.ipynb +1202 -0
- notebooks_tutorials/05_diffusion_fine_tuning.ipynb +941 -0
- notebooks_tutorials/05_model_interpretation.ipynb +0 -0
- notebooks_tutorials/06_NTv3_generative_training.ipynb +0 -0
- notebooks_tutorials/06_plant_promoter_generation.ipynb +0 -0
- notebooks_tutorials/07_enhancer_generation.ipynb +0 -0
- src/genome_annotation/__init__.py +1 -0
- src/genome_annotation/analysis.py +594 -0
- src/genome_annotation/benchmark.py +194 -0
- src/genome_annotation/data.py +165 -0
- src/genome_annotation/decoding.py +338 -0
- src/genome_annotation/evaluation.py +672 -0
- src/genome_annotation/inference.py +255 -0
- src/genome_annotation/metrics.py +143 -0
- src/genome_annotation/model.py +118 -0
- src/genome_annotation/plotting.py +710 -0
- src/genome_annotation/proteins.py +150 -0
- tabs/demo.html +0 -33
- tabs/home.html +181 -163
- tabs/initial_release.html +263 -0
- tabs/skill.html +148 -0
.gitattributes
CHANGED
|
@@ -35,6 +35,7 @@ saved_model/**/* filter=lfs diff=lfs merge=lfs -text
|
|
| 35 |
*tfevents* filter=lfs diff=lfs merge=lfs -text
|
| 36 |
assets/paper_summary.jpg filter=lfs diff=lfs merge=lfs -text
|
| 37 |
assets/paper_summary.png filter=lfs diff=lfs merge=lfs -text
|
|
|
|
| 38 |
assets/output_tracks.png filter=lfs diff=lfs merge=lfs -text
|
| 39 |
notebooks_pipelines/bigwig_outputs/K562_DNAse.bw filter=lfs diff=lfs merge=lfs -text
|
| 40 |
notebooks_pipelines/bigwig_outputs/K562_H3k4me3.bw filter=lfs diff=lfs merge=lfs -text
|
|
@@ -45,3 +46,7 @@ notebooks_pipelines/bigwig_outputs/HepG2_H3k4me3.bw filter=lfs diff=lfs merge=lf
|
|
| 45 |
notebooks_pipelines/bigwig_outputs/HepG2_RNA_seq.bw filter=lfs diff=lfs merge=lfs -text
|
| 46 |
notebooks_pipelines/bigwig_outputs/K562_CTCF.bw filter=lfs diff=lfs merge=lfs -text
|
| 47 |
assets/saliency_example.png filter=lfs diff=lfs merge=lfs -text
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 35 |
*tfevents* filter=lfs diff=lfs merge=lfs -text
|
| 36 |
assets/paper_summary.jpg filter=lfs diff=lfs merge=lfs -text
|
| 37 |
assets/paper_summary.png filter=lfs diff=lfs merge=lfs -text
|
| 38 |
+
assets/ntv3_overview.png filter=lfs diff=lfs merge=lfs -text
|
| 39 |
assets/output_tracks.png filter=lfs diff=lfs merge=lfs -text
|
| 40 |
notebooks_pipelines/bigwig_outputs/K562_DNAse.bw filter=lfs diff=lfs merge=lfs -text
|
| 41 |
notebooks_pipelines/bigwig_outputs/K562_H3k4me3.bw filter=lfs diff=lfs merge=lfs -text
|
|
|
|
| 46 |
notebooks_pipelines/bigwig_outputs/HepG2_RNA_seq.bw filter=lfs diff=lfs merge=lfs -text
|
| 47 |
notebooks_pipelines/bigwig_outputs/K562_CTCF.bw filter=lfs diff=lfs merge=lfs -text
|
| 48 |
assets/saliency_example.png filter=lfs diff=lfs merge=lfs -text
|
| 49 |
+
assets/skill_workflow_v6b.png filter=lfs diff=lfs merge=lfs -text
|
| 50 |
+
assets/skill_report_hbe1_cagesal_top_hs.png filter=lfs diff=lfs merge=lfs -text
|
| 51 |
+
assets/skill_report_hbe1_hs4_saliency_cage_r848.png filter=lfs diff=lfs merge=lfs -text
|
| 52 |
+
assets/skill_report_hbe1_4kb_annotation.png filter=lfs diff=lfs merge=lfs -text
|
README.md
CHANGED
|
@@ -1,5 +1,5 @@
|
|
| 1 |
---
|
| 2 |
-
title: NTv3
|
| 3 |
emoji: 🧬
|
| 4 |
colorFrom: indigo
|
| 5 |
colorTo: blue
|
|
@@ -7,26 +7,71 @@ sdk: static
|
|
| 7 |
pinned: false
|
| 8 |
---
|
| 9 |
|
| 10 |
-
# 🧬 NTv3
|
| 11 |
|
| 12 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 13 |
|
| 14 |
## 📖 About NTv3
|
| 15 |
|
| 16 |
-
NTv3
|
|
|
|
|
|
|
|
|
|
| 17 |
|
| 18 |
-
|
| 19 |
|
| 20 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 21 |
|
| 22 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 23 |
|
| 24 |
-
|
| 25 |
-
|
| 26 |
-
|
| 27 |
-
|
| 28 |
-
|
| 29 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 30 |
|
| 31 |
## 📦 Install
|
| 32 |
|
|
@@ -34,68 +79,158 @@ Notebooks live in `./notebooks/`:
|
|
| 34 |
pip install torch transformers accelerate safetensors huggingface_hub numpy
|
| 35 |
```
|
| 36 |
|
| 37 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 38 |
|
| 39 |
```python
|
| 40 |
-
|
|
|
|
| 41 |
|
| 42 |
-
|
| 43 |
-
tok = AutoTokenizer.from_pretrained(repo, trust_remote_code=True)
|
| 44 |
-
model = AutoModelForMaskedLM.from_pretrained(repo, trust_remote_code=True)
|
| 45 |
|
| 46 |
-
|
| 47 |
-
|
| 48 |
|
| 49 |
-
|
| 50 |
-
|
| 51 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 52 |
```
|
| 53 |
|
| 54 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
| 55 |
|
| 56 |
-
|
|
|
|
| 57 |
|
| 58 |
```python
|
| 59 |
-
from transformers import
|
| 60 |
-
|
| 61 |
-
|
| 62 |
-
|
| 63 |
-
|
| 64 |
-
|
| 65 |
-
|
| 66 |
-
|
| 67 |
-
# Run track prediction
|
| 68 |
-
out = pipe(
|
| 69 |
-
{
|
| 70 |
-
"chrom": "chr19",
|
| 71 |
-
"start": 6_700_000,
|
| 72 |
-
"end": 6_831_072,
|
| 73 |
-
"species": "human"
|
| 74 |
-
}
|
| 75 |
)
|
| 76 |
|
| 77 |
-
print(out.
|
| 78 |
-
print(out.
|
| 79 |
-
print(out.
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 80 |
```
|
| 81 |
|
| 82 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 83 |
|
| 84 |
-
**
|
| 85 |
|
| 86 |
-
**
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 87 |
|
| 88 |
## 🔗 Links
|
| 89 |
|
| 90 |
-
-
|
| 91 |
-
-
|
| 92 |
-
- **
|
|
|
|
|
|
|
|
|
|
|
|
|
| 93 |
|
| 94 |
## 📝 Citation
|
| 95 |
|
|
|
|
|
|
|
| 96 |
```bibtex
|
| 97 |
@article{ntv3,
|
| 98 |
-
title = {
|
| 99 |
author = {…},
|
| 100 |
journal = {…},
|
| 101 |
year = {…}
|
|
@@ -104,6 +239,8 @@ print(out.mlm_logits.shape) # MLM logits: (B, L, V = 11)
|
|
| 104 |
|
| 105 |
## 📜 License
|
| 106 |
|
| 107 |
-
**
|
|
|
|
|
|
|
| 108 |
|
| 109 |
-
**
|
|
|
|
| 1 |
---
|
| 2 |
+
title: NTv3 Foundation Models for Genomics
|
| 3 |
emoji: 🧬
|
| 4 |
colorFrom: indigo
|
| 5 |
colorTo: blue
|
|
|
|
| 7 |
pinned: false
|
| 8 |
---
|
| 9 |
|
| 10 |
+
# 🧬 NTv3: Foundation Models for Genomics
|
| 11 |
|
| 12 |
+
A genomic foundation model that reads up to 1 Mb of DNA at single-base resolution. One architecture, three
|
| 13 |
+
capabilities: **functional-track prediction**, **gene annotation** and **regulatory sequence design**. Of the
|
| 14 |
+
paper's design models, the plant promoter one is released as an example of the generative models. This Space is the
|
| 15 |
+
companion hub, with runnable PyTorch notebooks.
|
| 16 |
+
|
| 17 |
+
> **Release status.** This Space is being prepared and items still to be settled are marked `TBC` throughout.
|
| 18 |
+
> Looking for the 2025 models? See [Initial release (2025)](#-initial-release-2025) below.
|
| 19 |
|
| 20 |
## 📖 About NTv3
|
| 21 |
|
| 22 |
+
NTv3 reads DNA at single-base resolution across up to **1 Mb** of context in one pass. One architecture supports three
|
| 23 |
+
capabilities: **functional-track prediction**, **gene annotation** and **regulatory sequence design**. The backbone is
|
| 24 |
+
U-Net-style: a convolutional tower that reads the sequence, a Transformer tower that carries long-range context, and a
|
| 25 |
+
deconvolutional tower that returns predictions at base resolution.
|
| 26 |
|
| 27 |
+
They are acquired through a staged recipe, each stage building on the one before:
|
| 28 |
|
| 29 |
+
1. **Pre-training**, self-supervised. Masked-token prediction over unlabeled genomes from the OpenGenome2 corpus -> **NTv3-pretrained**.
|
| 30 |
+
2. **Tracks post-training**, supervised multi-task. 16,262 functional tracks across 15 species -> **NTv3-tracks**.
|
| 31 |
+
3. **Annotation fine-tuning**, supervised single-task. Reference gene annotations -> one **NTv3-annotate** model
|
| 32 |
+
per clade (mammals, plants, insects).
|
| 33 |
+
4. **Generative post-training**, masked diffusion on MPRA activity datasets -> **NTv3-generative**. The plant
|
| 34 |
+
promoter model is released as an example; the plant enhancer and fly enhancer models are not.
|
| 35 |
+
|
| 36 |
+
## 🧩 What it does
|
| 37 |
+
|
| 38 |
+
| Capability | Detail |
|
| 39 |
+
| --- | --- |
|
| 40 |
+
| Reads any genome | 1 Mb context, nucleotide resolution, base-pair-resolution prediction |
|
| 41 |
+
| Predicts functional tracks | 16,262 tracks, 15 species: 9,603 gene expression, 3,115 histone marks, 2,193 TF binding, 1,351 accessibility |
|
| 42 |
+
| Annotates genes | DNA alone to per-base posteriors over intergenic, intron, CDS and splice states, decoded to gene models on both strands |
|
| 43 |
+
| Designs regulatory DNA | Conditional generation of plant promoters, plant enhancers and fly enhancers, validated by measured activity |
|
| 44 |
+
|
| 45 |
+
## 🤖 The NTv3 models
|
| 46 |
+
|
| 47 |
+
Each capability is released as its own checkpoint, all sharing the same architecture and the same pretrained
|
| 48 |
+
representations, at **700M parameters**.
|
| 49 |
|
| 50 |
+
| Model | Stage | What it is for | Repository |
|
| 51 |
+
| --- | --- | --- | --- |
|
| 52 |
+
| NTv3-pretrained | Pretrained | Embeddings, masked-token prediction, your own fine-tuning | [`InstaDeepAI/NTv3_pretrained`](https://huggingface.co/InstaDeepAI/NTv3_pretrained) |
|
| 53 |
+
| NTv3-tracks | Tracks post-training | Functional-track prediction from 16 kb to 1 Mb inputs, and the backbone for downstream fine-tuning | [`InstaDeepAI/NTv3_tracks`](https://huggingface.co/InstaDeepAI/NTv3_tracks) |
|
| 54 |
+
| NTv3-tracks-1mb | Tracks post-training | The 1 Mb specialist behind the paper's results; 1 Mb inputs only | [`InstaDeepAI/NTv3_tracks_1mb`](https://huggingface.co/InstaDeepAI/NTv3_tracks_1mb) |
|
| 55 |
+
| NTv3-annotate | Annotation fine-tuning | *Ab initio* gene prediction, one model per clade | [`_mammals`](https://huggingface.co/InstaDeepAI/NTv3_annot_mammals) / [`_plants`](https://huggingface.co/InstaDeepAI/NTv3_annot_plants) / [`_insects`](https://huggingface.co/InstaDeepAI/NTv3_annot_insects) |
|
| 56 |
+
| NTv3-generative-plant-promoter | Generative post-training | Example generative model used in the paper: designs plant core promoters at a requested strength, and is what notebook 06 runs | [`InstaDeepAI/NTv3_generative_plant_promoter`](https://huggingface.co/InstaDeepAI/NTv3_generative_plant_promoter) |
|
| 57 |
|
| 58 |
+
Of the paper's design models, only the plant promoter one is released, as an example; the plant enhancer and fly
|
| 59 |
+
enhancer models are not. It is the model notebook 06 runs, and a worked example of the recipe notebook 05 teaches.
|
| 60 |
+
Read its model card before quoting anything from it: it is not species-aware.
|
| 61 |
+
|
| 62 |
+
**700M** is the figure quoted throughout, counted from the published weights rather than the internal registry, which
|
| 63 |
+
still calls the pretrained checkpoint 650M. Exactly: 703,349,146 for pretrained, 711,256,680 for tracks,
|
| 64 |
+
703,446,690 for each annotate clade, and 721,379,955 for the plant promoter example, which carries three condition
|
| 65 |
+
tables and conditioning layers in its trunk.
|
| 66 |
+
|
| 67 |
+
The [NTv3 collection](https://huggingface.co/collections/InstaDeepAI/ntv3) is the canonical index
|
| 68 |
+
of what is downloadable today.
|
| 69 |
+
|
| 70 |
+
## 🔐 Access
|
| 71 |
+
|
| 72 |
+
The models are gated: request access once on any of their model pages, and one approval, reviewed manually, covers
|
| 73 |
+
all of them. Then log in with `hf auth login`, or `notebook_login()` in a notebook. The datasets and this Space are
|
| 74 |
+
open.
|
| 75 |
|
| 76 |
## 📦 Install
|
| 77 |
|
|
|
|
| 79 |
pip install torch transformers accelerate safetensors huggingface_hub numpy
|
| 80 |
```
|
| 81 |
|
| 82 |
+
Works on **transformers 4.57.1 and 5.17.0**, which now agree exactly. Two faults that broke 5.x are fixed: the rotary
|
| 83 |
+
frequencies are rebuilt per call rather than restored from a buffer that 5.x zeroed, and the annotation checkpoints
|
| 84 |
+
store `residual_scale` as the scalar the model declares. Re-measured on chr19:6,700,000-6,716,384 across both
|
| 85 |
+
versions, the predictions are identical to the bit. Older 5.x releases may still carry the zeroed-buffer behaviour,
|
| 86 |
+
which fails silently, so pin `transformers>=4.57.1` and check against the fixtures in each repo if you stray.
|
| 87 |
+
|
| 88 |
+
Models use custom code, so pass `trust_remote_code=True`. Input sequence length must be a multiple of 128.
|
| 89 |
+
|
| 90 |
+
## 🚀 Quickstart with NTv3-pretrained
|
| 91 |
+
|
| 92 |
+
Both checkpoints are registered under `AutoModel`, and the forward takes the token tensor positionally rather than
|
| 93 |
+
the tokenizer dict, so `model(**batch)` will not work. The forward returns a plain `dict`, not a `ModelOutput`, so
|
| 94 |
+
read results with `out["logits"]` rather than `out.logits`.
|
| 95 |
|
| 96 |
```python
|
| 97 |
+
import torch
|
| 98 |
+
from transformers import AutoModel, AutoTokenizer
|
| 99 |
|
| 100 |
+
model_name = "InstaDeepAI/NTv3_pretrained"
|
|
|
|
|
|
|
| 101 |
|
| 102 |
+
tok = AutoTokenizer.from_pretrained(model_name, trust_remote_code=True)
|
| 103 |
+
model = AutoModel.from_pretrained(model_name, trust_remote_code=True).eval()
|
| 104 |
|
| 105 |
+
# Batch only equal-length sequences: padding is not masked out
|
| 106 |
+
batch = tok(["ACGT" * 32, "TTGA" * 32], add_special_tokens=False, return_tensors="pt")
|
| 107 |
+
|
| 108 |
+
with torch.no_grad():
|
| 109 |
+
out = model(batch["input_ids"])
|
| 110 |
+
|
| 111 |
+
print(out["logits"].shape) # (B, L, V = 11)
|
| 112 |
+
print(out["embedding"].shape) # (B, L, 1024)
|
| 113 |
```
|
| 114 |
|
| 115 |
+
Input length must be a multiple of 128, and **padded positions are not masked out of attention**. The same sequence
|
| 116 |
+
run alone and run padded to a longer length gives different values at its real positions, so batch only sequences of
|
| 117 |
+
equal length and run ragged ones separately.
|
| 118 |
+
|
| 119 |
+
## 📊 NTv3-tracks: functional-track prediction
|
| 120 |
|
| 121 |
+
The `ntv3-tracks` pipeline takes a genomic region and the tracks you want, fetches the sequence, conditions on the
|
| 122 |
+
species, predicts and plots.
|
| 123 |
|
| 124 |
```python
|
| 125 |
+
from transformers import pipeline
|
| 126 |
+
|
| 127 |
+
ntv3_tracks = pipeline("ntv3-tracks", model="InstaDeepAI/NTv3_tracks", trust_remote_code=True)
|
| 128 |
+
|
| 129 |
+
out = ntv3_tracks(
|
| 130 |
+
{"chrom": "chr19", "start": 6_700_000, "end": 6_732_768, "species": "human"},
|
| 131 |
+
plot=True,
|
| 132 |
+
tracks_to_plot={"K562 RNA-seq": "ENCSR056HPM", "K562 CTCF": "ENCSR000AKO"},
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 133 |
)
|
| 134 |
|
| 135 |
+
print(out.tracks.shape) # (12288, 2), the central 37.5 % of the window
|
| 136 |
+
print(out.coordinates) # (6710240, 6722528), the span actually predicted
|
| 137 |
+
print(out.track("ENCSR000AKO").shape) # (12288,): one track
|
| 138 |
+
```
|
| 139 |
+
|
| 140 |
+
Naming the tracks computes only those channels. The head is as wide as the largest species panel (hg38, 7,362
|
| 141 |
+
tracks), so a full forward at 1 Mb materialises about 11 GB, while a named subset is bit-identical and a few MB.
|
| 142 |
+
Species, assemblies and track order all come from the model's own `config.track_metadata`.
|
| 143 |
+
|
| 144 |
+
For the plain forward, which is where to start if you want to fine-tune rather than predict, `NTv3_tracks` takes a
|
| 145 |
+
species token alongside the sequence, and its nucleotide vocabulary is 16 tokens rather than the 11 of the pretrained
|
| 146 |
+
model, so it reads soft-masked (lowercase) sequence directly. Notebook 01 covers it.
|
| 147 |
+
|
| 148 |
+
The 2025 `ntv3-interpret` pipeline is not being carried over. Notebook 04 shows saliency and attention with a plain
|
| 149 |
+
`AutoModel` forward instead.
|
| 150 |
+
|
| 151 |
+
## 🏷️ NTv3-annotate: genome annotation
|
| 152 |
+
|
| 153 |
+
```python
|
| 154 |
+
import torch
|
| 155 |
+
from transformers import AutoModel, AutoTokenizer
|
| 156 |
+
|
| 157 |
+
CLADE = "mammals" # or "plants", or "insects"
|
| 158 |
+
repo = f"InstaDeepAI/NTv3_annot_{CLADE}"
|
| 159 |
+
|
| 160 |
+
model = AutoModel.from_pretrained(repo, trust_remote_code=True).eval()
|
| 161 |
+
tok = AutoTokenizer.from_pretrained(repo, trust_remote_code=True)
|
| 162 |
+
batch = tok("ACGT" * 256, add_special_tokens=False, return_tensors="pt")
|
| 163 |
+
|
| 164 |
+
# Annotation is not conditioned on species, so all three clades take an empty list
|
| 165 |
+
with torch.no_grad():
|
| 166 |
+
out = model(batch["input_ids"], conditions_tokens=[])
|
| 167 |
+
|
| 168 |
+
print(out["bed_tracks_logits"].shape) # (1, 768, 1, 15); 768 = 1024 * 0.75
|
| 169 |
```
|
| 170 |
|
| 171 |
+
The output covers the central 75% of the window, the crop the model is served at. Logits alone are not an annotation:
|
| 172 |
+
notebook 03b decodes them into gene models (GFF3) with the HMM whose specification `config.json` carries under
|
| 173 |
+
`annotation`, and notebook 03c scores an annotation the way the benchmark does.
|
| 174 |
+
|
| 175 |
+
## 📓 Notebooks
|
| 176 |
+
|
| 177 |
+
The notebooks cover the workflows end to end, in `notebooks_tutorials/`:
|
| 178 |
+
|
| 179 |
+
| # | Notebook | File | Status |
|
| 180 |
+
| --- | --- | --- | --- |
|
| 181 |
+
| 00 | 🚀 Quickstart inference | [`00_quickstart_inference.ipynb`](https://huggingface.co/spaces/InstaDeepAI/ntv3/blob/main/notebooks_tutorials/00_quickstart_inference.ipynb) | ✅ |
|
| 182 |
+
| 01 | 📊 Tracks prediction | [`01_tracks_prediction.ipynb`](https://huggingface.co/spaces/InstaDeepAI/ntv3/blob/main/notebooks_tutorials/01_tracks_prediction.ipynb) | ✅ |
|
| 183 |
+
| 02 | 🎯 Fine-tune NTv3 on bigwig tracks | [`02_fine_tuning_bigwig_tracks.ipynb`](https://huggingface.co/spaces/InstaDeepAI/ntv3/blob/main/notebooks_tutorials/02_fine_tuning_bigwig_tracks.ipynb) | ✅ |
|
| 184 |
+
| 03a | 🏷️ Train a gene-annotation model | [`03a_genome_annotation_training.ipynb`](https://huggingface.co/spaces/InstaDeepAI/ntv3/blob/main/notebooks_tutorials/03a_genome_annotation_training.ipynb) | ✅ |
|
| 185 |
+
| 03b | 🏷️ From per-base predictions to gene models | [`03b_genome_annotation_decoding.ipynb`](https://huggingface.co/spaces/InstaDeepAI/ntv3/blob/main/notebooks_tutorials/03b_genome_annotation_decoding.ipynb) | ✅ |
|
| 186 |
+
| 03c | 🏷️ Evaluate a gene annotation against a reference | [`03c_genome_annotation_evaluation.ipynb`](https://huggingface.co/spaces/InstaDeepAI/ntv3/blob/main/notebooks_tutorials/03c_genome_annotation_evaluation.ipynb) | ✅ |
|
| 187 |
+
| 04 | 🔍 Model interpretation | [`04_model_interpretation.ipynb`](https://huggingface.co/spaces/InstaDeepAI/ntv3/blob/main/notebooks_tutorials/04_model_interpretation.ipynb) | ✅ |
|
| 188 |
+
| 05 | 🧪 Fine-tuning NTv3 into a diffusion model | [`05_diffusion_fine_tuning.ipynb`](https://huggingface.co/spaces/InstaDeepAI/ntv3/blob/main/notebooks_tutorials/05_diffusion_fine_tuning.ipynb) | ✅ |
|
| 189 |
+
| 06 | 🌱 Generating plant promoter sequences | [`06_plant_promoter_generation.ipynb`](https://huggingface.co/spaces/InstaDeepAI/ntv3/blob/main/notebooks_tutorials/06_plant_promoter_generation.ipynb) | ✅ |
|
| 190 |
+
|
| 191 |
+
All run against the published checkpoints.
|
| 192 |
+
|
| 193 |
+
## 🗄️ Initial release (2025)
|
| 194 |
+
|
| 195 |
+
The initial-release repositories are **retained unchanged** for reproducibility and backward compatibility. Existing
|
| 196 |
+
model IDs, files and interfaces continue to work. If you are reproducing published results or already have a working
|
| 197 |
+
pipeline, stay on these.
|
| 198 |
+
|
| 199 |
+
**Pretrained:** `NTv3_8M_pre`, `NTv3_100M_pre`, `NTv3_650M_pre`
|
| 200 |
|
| 201 |
+
**Post-trained:** `NTv3_100M_post`, `NTv3_650M_post`
|
| 202 |
|
| 203 |
+
**Context-length variants:** `NTv3_8M_pre_8kb`, `NTv3_100M_pre_8kb`, `NTv3_650M_pre_8kb`, `NTv3_100M_post_131kb`,
|
| 204 |
+
`NTv3_650M_post_131kb`
|
| 205 |
+
|
| 206 |
+
**Architecture ablations:** `NTv3_5downsample_pre`, `NTv3_5downsample_pre_8kb`, `NTv3_5downsample_post`,
|
| 207 |
+
`NTv3_5downsample_post_131kb`
|
| 208 |
+
|
| 209 |
+
**Generative:** `NTv3_generative`
|
| 210 |
+
|
| 211 |
+
All under the `InstaDeepAI/` namespace. Note that some earlier materials wrote the post-trained checkpoints as
|
| 212 |
+
`NTv3_100M_pos` / `NTv3_650M_pos`; the correct identifiers end in `_post`.
|
| 213 |
+
|
| 214 |
+
These models were trained with a *joint* post-training objective over tracks and annotation labels, so numbers quoted
|
| 215 |
+
in their materials do not always match the current paper. Where they differ, the current paper is authoritative.
|
| 216 |
|
| 217 |
## 🔗 Links
|
| 218 |
|
| 219 |
+
- 📄 **Paper:** preprint `TBC`
|
| 220 |
+
- 💻 **JAX model code:** [github.com/instadeepai/nucleotide-transformer](https://github.com/instadeepai/nucleotide-transformer)
|
| 221 |
+
- 🎯 **Model collection:** [NTv3 collection](https://huggingface.co/collections/InstaDeepAI/ntv3)
|
| 222 |
+
- 📚 **Benchmark dataset:** [`InstaDeepAI/NTv3_benchmark_dataset_revised`](https://huggingface.co/datasets/InstaDeepAI/NTv3_benchmark_dataset_revised)
|
| 223 |
+
- 🏷️ **Annotation dataset:** [`InstaDeepAI/NTv3_annot_dataset`](https://huggingface.co/datasets/InstaDeepAI/NTv3_annot_dataset)
|
| 224 |
+
- 🏆 **Benchmark leaderboard:** [`InstaDeepAI/ntv3_benchmark_revised`](https://huggingface.co/spaces/InstaDeepAI/ntv3_benchmark_revised)
|
| 225 |
+
- 🚀 **Interactive demo:** [`InstaDeepAI/ntv3_tracks`](https://huggingface.co/spaces/InstaDeepAI/ntv3_tracks)
|
| 226 |
|
| 227 |
## 📝 Citation
|
| 228 |
|
| 229 |
+
`TBC`. To be added on publication.
|
| 230 |
+
|
| 231 |
```bibtex
|
| 232 |
@article{ntv3,
|
| 233 |
+
title = {…},
|
| 234 |
author = {…},
|
| 235 |
journal = {…},
|
| 236 |
year = {…}
|
|
|
|
| 239 |
|
| 240 |
## 📜 License
|
| 241 |
|
| 242 |
+
**Model weights:** released under the InstaDeep Open Model Licence, which permits non-commercial use only and also
|
| 243 |
+
places conditions on the **outputs** of the models. See the `MODEL-LICENSE.md` shipped in each model repository for the
|
| 244 |
+
authoritative terms.
|
| 245 |
|
| 246 |
+
**Code and notebooks in this Space:** `TBC`
|
assets/ntv3_overview.png
ADDED
|
Git LFS Details
|
assets/output_annotation.png
ADDED
|
assets/skill_report_hbe1_4kb_annotation.png
ADDED
|
Git LFS Details
|
assets/skill_report_hbe1_cagesal_top_hs.png
ADDED
|
Git LFS Details
|
assets/skill_report_hbe1_hs4_saliency_cage_r848.png
ADDED
|
assets/skill_workflow_v6b.png
ADDED
|
Git LFS Details
|
index.html
CHANGED
|
@@ -3,8 +3,8 @@
|
|
| 3 |
<head>
|
| 4 |
<meta charset="utf-8" />
|
| 5 |
<meta name="viewport" content="width=device-width,initial-scale=1" />
|
| 6 |
-
<title>NTv3
|
| 7 |
-
<meta name="description" content="NTv3
|
| 8 |
<link href="https://cdnjs.cloudflare.com/ajax/libs/prism/1.29.0/themes/prism-tomorrow.min.css" rel="stylesheet" />
|
| 9 |
<style>
|
| 10 |
:root {
|
|
@@ -198,6 +198,51 @@
|
|
| 198 |
display: block;
|
| 199 |
border-radius: 12px;
|
| 200 |
}
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 201 |
.footer { margin-top: 22px; color: var(--muted); font-size: 13px; }
|
| 202 |
|
| 203 |
/* Tab navigation styles */
|
|
@@ -244,6 +289,7 @@
|
|
| 244 |
|
| 245 |
@media (max-width: 860px) {
|
| 246 |
.card { grid-column: span 12; }
|
|
|
|
| 247 |
h1 { font-size: 28px; }
|
| 248 |
.tab-button {
|
| 249 |
padding: 10px 16px;
|
|
@@ -256,16 +302,19 @@
|
|
| 256 |
<body>
|
| 257 |
<div class="wrap">
|
| 258 |
<div class="hero">
|
| 259 |
-
<h1>🧬 NTv3
|
| 260 |
<p>
|
| 261 |
-
|
|
|
|
|
|
|
| 262 |
</p>
|
| 263 |
|
| 264 |
<div class="pillrow">
|
| 265 |
-
<span class="pill">
|
| 266 |
-
|
| 267 |
-
|
| 268 |
-
<span class="pill">
|
|
|
|
| 269 |
<span class="pill">📓 Torch notebooks</span>
|
| 270 |
</div>
|
| 271 |
</div>
|
|
@@ -273,7 +322,8 @@
|
|
| 273 |
<!-- Tab Navigation -->
|
| 274 |
<div class="tabs">
|
| 275 |
<button class="tab-button active" data-tab="home">🏠 Home</button>
|
| 276 |
-
<button class="tab-button" data-tab="
|
|
|
|
| 277 |
</div>
|
| 278 |
|
| 279 |
<!-- Home Tab (Content loaded from tabs/home.html) -->
|
|
@@ -281,18 +331,18 @@
|
|
| 281 |
<!-- Content will be loaded dynamically -->
|
| 282 |
</div>
|
| 283 |
|
| 284 |
-
<!--
|
| 285 |
-
<div id="
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 286 |
<!-- Content will be loaded dynamically -->
|
| 287 |
</div>
|
| 288 |
-
|
| 289 |
-
<!-- <div class="paper-summary">
|
| 290 |
-
<h2>📄 A foundational model for joint sequence-function multi-species modeling at scale for long-range genomic prediction</h2>
|
| 291 |
-
<img src="assets/paper_summary.png" alt="NTv3 Paper Summary" />
|
| 292 |
-
</div> -->
|
| 293 |
|
| 294 |
<p class="footer">
|
| 295 |
-
© instadeep-ai
|
| 296 |
</p>
|
| 297 |
</div>
|
| 298 |
<script src="https://cdnjs.cloudflare.com/ajax/libs/prism/1.29.0/components/prism-core.min.js"></script>
|
|
@@ -301,8 +351,8 @@
|
|
| 301 |
// Tab content mapping
|
| 302 |
const tabFiles = {
|
| 303 |
'home': 'tabs/home.html',
|
| 304 |
-
'
|
| 305 |
-
'
|
| 306 |
};
|
| 307 |
|
| 308 |
// Cache for loaded tab content
|
|
@@ -371,6 +421,21 @@
|
|
| 371 |
showTab(activeTab.id);
|
| 372 |
}
|
| 373 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 374 |
tabButtons.forEach(button => {
|
| 375 |
button.addEventListener('click', async () => {
|
| 376 |
const targetTab = button.getAttribute('data-tab');
|
|
|
|
| 3 |
<head>
|
| 4 |
<meta charset="utf-8" />
|
| 5 |
<meta name="viewport" content="width=device-width,initial-scale=1" />
|
| 6 |
+
<title>NTv3: Foundation Models for Genomics</title>
|
| 7 |
+
<meta name="description" content="NTv3 reads up to 1 Mb of DNA at single-base resolution. One architecture, three capabilities: functional-track prediction, gene annotation and regulatory sequence design." />
|
| 8 |
<link href="https://cdnjs.cloudflare.com/ajax/libs/prism/1.29.0/themes/prism-tomorrow.min.css" rel="stylesheet" />
|
| 9 |
<style>
|
| 10 |
:root {
|
|
|
|
| 198 |
display: block;
|
| 199 |
border-radius: 12px;
|
| 200 |
}
|
| 201 |
+
/* Placeholder marker for content pending release.
|
| 202 |
+
Every unresolved item on this Space is tagged with one of these,
|
| 203 |
+
so they can be found by searching the page for "TBC". */
|
| 204 |
+
.todo {
|
| 205 |
+
display: inline-block;
|
| 206 |
+
font-family: var(--mono);
|
| 207 |
+
font-size: 11px;
|
| 208 |
+
font-weight: 600;
|
| 209 |
+
letter-spacing: 0.04em;
|
| 210 |
+
padding: 2px 7px;
|
| 211 |
+
margin-left: 6px;
|
| 212 |
+
border-radius: 6px;
|
| 213 |
+
border: 1px solid rgba(251, 191, 36, 0.45);
|
| 214 |
+
background: rgba(251, 191, 36, 0.12);
|
| 215 |
+
color: #fcd34d;
|
| 216 |
+
vertical-align: middle;
|
| 217 |
+
}
|
| 218 |
+
.notice {
|
| 219 |
+
margin-top: 18px;
|
| 220 |
+
padding: 16px 18px;
|
| 221 |
+
border: 1px solid rgba(251, 191, 36, 0.35);
|
| 222 |
+
background: rgba(251, 191, 36, 0.07);
|
| 223 |
+
border-radius: 14px;
|
| 224 |
+
color: var(--muted);
|
| 225 |
+
font-size: 14px;
|
| 226 |
+
line-height: 1.6;
|
| 227 |
+
}
|
| 228 |
+
.notice strong { color: var(--text); }
|
| 229 |
+
.legacy-banner {
|
| 230 |
+
margin-top: 18px;
|
| 231 |
+
padding: 14px 18px;
|
| 232 |
+
border: 1px solid var(--border);
|
| 233 |
+
background: rgba(255, 255, 255, 0.04);
|
| 234 |
+
border-radius: 14px;
|
| 235 |
+
color: var(--muted);
|
| 236 |
+
font-size: 14px;
|
| 237 |
+
line-height: 1.6;
|
| 238 |
+
}
|
| 239 |
+
.capability-grid { margin-top: 12px; }
|
| 240 |
+
/* Keep model names such as NTv3-pretrained on one line rather than
|
| 241 |
+
breaking at the hyphen. */
|
| 242 |
+
.capability-grid td:first-child {
|
| 243 |
+
color: var(--text);
|
| 244 |
+
white-space: nowrap;
|
| 245 |
+
}
|
| 246 |
.footer { margin-top: 22px; color: var(--muted); font-size: 13px; }
|
| 247 |
|
| 248 |
/* Tab navigation styles */
|
|
|
|
| 289 |
|
| 290 |
@media (max-width: 860px) {
|
| 291 |
.card { grid-column: span 12; }
|
| 292 |
+
.card-stack { grid-column: span 12; }
|
| 293 |
h1 { font-size: 28px; }
|
| 294 |
.tab-button {
|
| 295 |
padding: 10px 16px;
|
|
|
|
| 302 |
<body>
|
| 303 |
<div class="wrap">
|
| 304 |
<div class="hero">
|
| 305 |
+
<h1>🧬 NTv3: Foundation Models for Genomics</h1>
|
| 306 |
<p>
|
| 307 |
+
A genomic foundation model that reads up to 1 Mb of DNA at single-base resolution. One architecture, three
|
| 308 |
+
capabilities: <strong>functional-track prediction</strong>, <strong>gene annotation</strong> and
|
| 309 |
+
<strong>regulatory sequence design</strong>. This Space is the companion hub, with runnable PyTorch notebooks.
|
| 310 |
</p>
|
| 311 |
|
| 312 |
<div class="pillrow">
|
| 313 |
+
<span class="pill">🧬 1 Mb context, single-base resolution</span>
|
| 314 |
+
<span class="pill">🌍 Multi-species</span>
|
| 315 |
+
<span class="pill">📊 Tracks</span>
|
| 316 |
+
<span class="pill">🏷️ Annotate</span>
|
| 317 |
+
<span class="pill">✏️ Design</span>
|
| 318 |
<span class="pill">📓 Torch notebooks</span>
|
| 319 |
</div>
|
| 320 |
</div>
|
|
|
|
| 322 |
<!-- Tab Navigation -->
|
| 323 |
<div class="tabs">
|
| 324 |
<button class="tab-button active" data-tab="home">🏠 Home</button>
|
| 325 |
+
<button class="tab-button" data-tab="skill">🤖 Claude Skill</button>
|
| 326 |
+
<button class="tab-button" data-tab="initial">🗄️ Initial release (2025)</button>
|
| 327 |
</div>
|
| 328 |
|
| 329 |
<!-- Home Tab (Content loaded from tabs/home.html) -->
|
|
|
|
| 331 |
<!-- Content will be loaded dynamically -->
|
| 332 |
</div>
|
| 333 |
|
| 334 |
+
<!-- Claude Skill Tab (Content loaded from tabs/skill.html) -->
|
| 335 |
+
<div id="skill" class="tab-content">
|
| 336 |
+
<!-- Content will be loaded dynamically -->
|
| 337 |
+
</div>
|
| 338 |
+
|
| 339 |
+
<!-- Initial-release Tab (Content loaded from tabs/initial_release.html) -->
|
| 340 |
+
<div id="initial" class="tab-content">
|
| 341 |
<!-- Content will be loaded dynamically -->
|
| 342 |
</div>
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 343 |
|
| 344 |
<p class="footer">
|
| 345 |
+
© instadeep-ai. NTv3 companion Space. Model weights are released under the InstaDeep Open Model Licence; see each model repository.
|
| 346 |
</p>
|
| 347 |
</div>
|
| 348 |
<script src="https://cdnjs.cloudflare.com/ajax/libs/prism/1.29.0/components/prism-core.min.js"></script>
|
|
|
|
| 351 |
// Tab content mapping
|
| 352 |
const tabFiles = {
|
| 353 |
'home': 'tabs/home.html',
|
| 354 |
+
'skill': 'tabs/skill.html',
|
| 355 |
+
'initial': 'tabs/initial_release.html',
|
| 356 |
};
|
| 357 |
|
| 358 |
// Cache for loaded tab content
|
|
|
|
| 421 |
showTab(activeTab.id);
|
| 422 |
}
|
| 423 |
|
| 424 |
+
// In-page links that switch tabs, e.g. <a data-tab-link="initial">.
|
| 425 |
+
// Delegated, because tab content is injected after load.
|
| 426 |
+
document.addEventListener('click', (event) => {
|
| 427 |
+
const link = event.target.closest('[data-tab-link]');
|
| 428 |
+
if (!link) return;
|
| 429 |
+
event.preventDefault();
|
| 430 |
+
const targetButton = document.querySelector(
|
| 431 |
+
`.tab-button[data-tab="${link.getAttribute('data-tab-link')}"]`
|
| 432 |
+
);
|
| 433 |
+
if (targetButton) {
|
| 434 |
+
targetButton.click();
|
| 435 |
+
window.scrollTo({ top: 0, behavior: 'smooth' });
|
| 436 |
+
}
|
| 437 |
+
});
|
| 438 |
+
|
| 439 |
tabButtons.forEach(button => {
|
| 440 |
button.addEventListener('click', async () => {
|
| 441 |
const targetTab = button.getAttribute('data-tab');
|
notebooks_pipelines/01_functional_track_prediction.ipynb
DELETED
|
The diff for this file is too large to render.
See raw diff
|
|
|
notebooks_pipelines/02_model_interpretation.ipynb
DELETED
|
The diff for this file is too large to render.
See raw diff
|
|
|
notebooks_tutorials/00_quickstart_inference.ipynb
CHANGED
|
The diff for this file is too large to render.
See raw diff
|
|
|
notebooks_tutorials/01_tracks_prediction.ipynb
CHANGED
|
The diff for this file is too large to render.
See raw diff
|
|
|
notebooks_tutorials/02_fine_tuning_bigwig_tracks.ipynb
ADDED
|
The diff for this file is too large to render.
See raw diff
|
|
|
notebooks_tutorials/02_fine_tuning_pretrained_model_biwig.ipynb
DELETED
|
The diff for this file is too large to render.
See raw diff
|
|
|
notebooks_tutorials/03_fine_tuning_posttrained_model_biwig.ipynb
DELETED
|
The diff for this file is too large to render.
See raw diff
|
|
|
notebooks_tutorials/03a_genome_annotation_training.ipynb
ADDED
|
The diff for this file is too large to render.
See raw diff
|
|
|
notebooks_tutorials/03b_genome_annotation_decoding.ipynb
ADDED
|
The diff for this file is too large to render.
See raw diff
|
|
|
notebooks_tutorials/03c_genome_annotation_evaluation.ipynb
ADDED
|
The diff for this file is too large to render.
See raw diff
|
|
|
notebooks_tutorials/04_fine_tuning_pretrained_model_annotation.ipynb
DELETED
|
The diff for this file is too large to render.
See raw diff
|
|
|
notebooks_tutorials/04_model_interpretation.ipynb
ADDED
|
@@ -0,0 +1,1202 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
{
|
| 2 |
+
"cells": [
|
| 3 |
+
{
|
| 4 |
+
"cell_type": "markdown",
|
| 5 |
+
"metadata": {
|
| 6 |
+
"id": "BXtGC0FwDK7i"
|
| 7 |
+
},
|
| 8 |
+
"source": [
|
| 9 |
+
"# 🧬 NTv3 Multi-Scale Interpretation Tutorial\n",
|
| 10 |
+
"\n",
|
| 11 |
+
"This notebook uses [`InstaDeepAI/NTv3_tracks`](https://huggingface.co/InstaDeepAI/NTv3_tracks), the NTv3 model post-trained on functional tracks, for multi-scale interpretation of regulatory logic, enabling analysis from individual nucleotides to enhancer elements and full gene loci.\n",
|
| 12 |
+
"\n",
|
| 13 |
+
"> 📝 **Note for Google Colab users:** This notebook is compatible with Colab! For faster inference, make sure to enable GPU: Runtime → Change runtime type → GPU (T4 or better recommended).\n",
|
| 14 |
+
"\n",
|
| 15 |
+
"## 📋 Overview\n",
|
| 16 |
+
"\n",
|
| 17 |
+
"Given a genomic window from the **human genome (hg38)**, we demonstrate three complementary interpretability modalities:\n",
|
| 18 |
+
"\n",
|
| 19 |
+
"1. **Predicted functional tracks** - Regulatory activity across thousands of assays\n",
|
| 20 |
+
"2. **Transformer attention maps** - How the model integrates information across long genomic distances\n",
|
| 21 |
+
"3. **Gradient-based saliency maps** - Sequence positions most influential for predictions\n",
|
| 22 |
+
"\n",
|
| 23 |
+
"> 💡 **Note:** Functional tracks are predicted only for the center 37.5% of the input sequence.\n",
|
| 24 |
+
"\n",
|
| 25 |
+
"`NTv3_tracks` predicts functional tracks, not gene annotations. Gene annotation is served by the separate NTv3-annotate checkpoints.\n",
|
| 26 |
+
"\n",
|
| 27 |
+
"## 📚 Notebook Structure\n",
|
| 28 |
+
"\n",
|
| 29 |
+
"1. **Setup**: Install dependencies and configure the analysis\n",
|
| 30 |
+
"2. **Data Loading**: Fetch the genomic sequence from UCSC\n",
|
| 31 |
+
"3. **Model Loading**: Load the NTv3 tracks model, its tokenizer and track metadata\n",
|
| 32 |
+
"4. **Inference**: Run the model to generate predictions and extract attention\n",
|
| 33 |
+
"5. **Functional Tracks**: Visualize predicted regulatory tracks\n",
|
| 34 |
+
"6. **Attention Maps**: Analyze long-range information\n",
|
| 35 |
+
"7. **Saliency Maps**: Compute gradient-based sequence attributions\n"
|
| 36 |
+
]
|
| 37 |
+
},
|
| 38 |
+
{
|
| 39 |
+
"cell_type": "markdown",
|
| 40 |
+
"metadata": {
|
| 41 |
+
"id": "MS_mIORoDK7k"
|
| 42 |
+
},
|
| 43 |
+
"source": [
|
| 44 |
+
"## 0) 📦 Imports + Setup\n"
|
| 45 |
+
]
|
| 46 |
+
},
|
| 47 |
+
{
|
| 48 |
+
"cell_type": "code",
|
| 49 |
+
"execution_count": null,
|
| 50 |
+
"metadata": {
|
| 51 |
+
"id": "vKTwnGhmVYFu"
|
| 52 |
+
},
|
| 53 |
+
"outputs": [],
|
| 54 |
+
"source": [
|
| 55 |
+
"# The NTv3 models are gated: request access once on any model page, then log in.\n",
|
| 56 |
+
"from huggingface_hub import get_token, notebook_login\n",
|
| 57 |
+
"\n",
|
| 58 |
+
"if get_token() is None:\n",
|
| 59 |
+
" notebook_login()\n"
|
| 60 |
+
]
|
| 61 |
+
},
|
| 62 |
+
{
|
| 63 |
+
"cell_type": "code",
|
| 64 |
+
"execution_count": null,
|
| 65 |
+
"metadata": {
|
| 66 |
+
"id": "JRc8918_DK7l"
|
| 67 |
+
},
|
| 68 |
+
"outputs": [],
|
| 69 |
+
"source": [
|
| 70 |
+
"# Colab ships torch and numpy. Upgrading them breaks the preinstalled scipy.\n",
|
| 71 |
+
"!pip -q install --upgrade 'transformers>=5.17,<6'\n",
|
| 72 |
+
"!pip -q install fast-logomaker\n"
|
| 73 |
+
]
|
| 74 |
+
},
|
| 75 |
+
{
|
| 76 |
+
"cell_type": "code",
|
| 77 |
+
"execution_count": null,
|
| 78 |
+
"metadata": {
|
| 79 |
+
"id": "qdXtceUiDK7l"
|
| 80 |
+
},
|
| 81 |
+
"outputs": [],
|
| 82 |
+
"source": [
|
| 83 |
+
"import os\n",
|
| 84 |
+
"import math\n",
|
| 85 |
+
"import warnings\n",
|
| 86 |
+
"import logging\n",
|
| 87 |
+
"\n",
|
| 88 |
+
"import torch\n",
|
| 89 |
+
"import torch.nn.functional as F\n",
|
| 90 |
+
"from transformers import AutoModel, AutoTokenizer\n",
|
| 91 |
+
"\n",
|
| 92 |
+
"import requests\n",
|
| 93 |
+
"import matplotlib.pyplot as plt\n",
|
| 94 |
+
"import numpy as np\n",
|
| 95 |
+
"import seaborn as sns\n",
|
| 96 |
+
"\n",
|
| 97 |
+
"# Device setup\n",
|
| 98 |
+
"device = \"cuda\" if torch.cuda.is_available() else \"cpu\"\n",
|
| 99 |
+
"if device == \"cuda\" and torch.cuda.get_device_capability(0)[0] >= 8:\n",
|
| 100 |
+
" dtype = torch.bfloat16\n",
|
| 101 |
+
"else:\n",
|
| 102 |
+
" dtype = torch.float16\n",
|
| 103 |
+
"print(f\"Device: {device}, dtype: {dtype}\")"
|
| 104 |
+
]
|
| 105 |
+
},
|
| 106 |
+
{
|
| 107 |
+
"cell_type": "markdown",
|
| 108 |
+
"metadata": {
|
| 109 |
+
"id": "gOCdTnRADK7m"
|
| 110 |
+
},
|
| 111 |
+
"source": [
|
| 112 |
+
"## 1) 📦 Configuration\n",
|
| 113 |
+
"\n",
|
| 114 |
+
"Set your NTv3 model, species and genomic window here. Coordinates are in the assembly of the chosen species (hg38 for human, the default), and the tracks you can pick come from that assembly's track list.\n",
|
| 115 |
+
"\n",
|
| 116 |
+
"> 💡 **Note:** For a T4 GPU, we recommend using an input sequence length of 32kb or shorter.\n",
|
| 117 |
+
"\n",
|
| 118 |
+
"> The knobs are `species`, `assembly`, `chrom`, `center_position` and `sequence_length` in the cell below. `assembly` must be the one `SPECIES_TO_ASSEMBLY` gives for `species`, or empty to use it; a mismatch raises an error. `start` and `end` are derived from `center_position` and `sequence_length`. The tracks to plot are chosen later, in `tracks_to_plot` (section 5) and `saliency_track_id` (section 7).\n"
|
| 119 |
+
]
|
| 120 |
+
},
|
| 121 |
+
{
|
| 122 |
+
"cell_type": "code",
|
| 123 |
+
"execution_count": null,
|
| 124 |
+
"metadata": {
|
| 125 |
+
"id": "iBmudDzIDK7m"
|
| 126 |
+
},
|
| 127 |
+
"outputs": [],
|
| 128 |
+
"source": [
|
| 129 |
+
"# -----------------------------\n",
|
| 130 |
+
"# User Configuration\n",
|
| 131 |
+
"# -----------------------------\n",
|
| 132 |
+
"model_name = \"InstaDeepAI/NTv3_tracks\"\n",
|
| 133 |
+
"\n",
|
| 134 |
+
"# Species and its assembly; leave assembly empty to use the species' assembly\n",
|
| 135 |
+
"species = \"human\" #@param [\"human\", \"mouse\", \"drosophila\", \"arabidopsis\", \"maize\", \"rice\", \"soybean\", \"caenorhabditis_elegans\", \"zebrafish\", \"chicken\", \"horse\", \"sheep\"]\n",
|
| 136 |
+
"assembly = \"hg38\" #@param [\"\", \"hg38\", \"mm10\", \"dm6\", \"TAIR10\", \"Zm-B73-REFERENCE-NAM-5.0\", \"IRGSP-1.0\", \"Glycine_max_v2.1\", \"WBcel235\", \"GRCz11\", \"GRCg6a\", \"EquCab3.0\", \"Oar_v3.1\"]\n",
|
| 137 |
+
"\n",
|
| 138 |
+
"# Genomic window (must be multiple of 128 due to model downsampling)\n",
|
| 139 |
+
"chrom = \"chr11\" #@param {type:\"string\"}\n",
|
| 140 |
+
"# Center of region of interest (HBE1 locus)\n",
|
| 141 |
+
"center_position = 5269945 #@param {type:\"integer\"}\n",
|
| 142 |
+
"# use 131kb window on A100, 32_768 on T4 GPU\n",
|
| 143 |
+
"sequence_length = 32768 #@param [16384, 32768, 65536, 131072] {type:\"raw\"}\n",
|
| 144 |
+
"assert sequence_length % 128 == 0, \"sequence_length must be a multiple of 128\"\n",
|
| 145 |
+
"\n",
|
| 146 |
+
"# Calculate start/end from center\n",
|
| 147 |
+
"start = center_position - sequence_length // 2\n",
|
| 148 |
+
"end = center_position + sequence_length // 2\n",
|
| 149 |
+
"\n",
|
| 150 |
+
"# Analysis parameters\n",
|
| 151 |
+
"token_resolution = 128 # bp per token\n",
|
| 152 |
+
"\n",
|
| 153 |
+
"# Track lists are keyed by assembly, so each species maps to one assembly.\n",
|
| 154 |
+
"# The names match metadata[\"bigwigs_per_file_assembly\"], printed once the model loads.\n",
|
| 155 |
+
"SPECIES_TO_ASSEMBLY = {\n",
|
| 156 |
+
" \"human\": \"hg38\",\n",
|
| 157 |
+
" \"mouse\": \"mm10\",\n",
|
| 158 |
+
" \"drosophila\": \"dm6\",\n",
|
| 159 |
+
" \"arabidopsis\": \"TAIR10\",\n",
|
| 160 |
+
" \"maize\": \"Zm-B73-REFERENCE-NAM-5.0\",\n",
|
| 161 |
+
" \"rice\": \"IRGSP-1.0\",\n",
|
| 162 |
+
" \"soybean\": \"Glycine_max_v2.1\",\n",
|
| 163 |
+
" \"caenorhabditis_elegans\": \"WBcel235\",\n",
|
| 164 |
+
" \"zebrafish\": \"GRCz11\",\n",
|
| 165 |
+
" \"chicken\": \"GRCg6a\",\n",
|
| 166 |
+
" \"horse\": \"EquCab3.0\",\n",
|
| 167 |
+
" \"sheep\": \"Oar_v3.1\",\n",
|
| 168 |
+
"}\n",
|
| 169 |
+
"if species not in SPECIES_TO_ASSEMBLY:\n",
|
| 170 |
+
" raise ValueError(f\"Unknown species {species!r}. Choose one of {list(SPECIES_TO_ASSEMBLY)}\")\n",
|
| 171 |
+
"if not assembly:\n",
|
| 172 |
+
" assembly = SPECIES_TO_ASSEMBLY[species]\n",
|
| 173 |
+
"elif assembly != SPECIES_TO_ASSEMBLY[species]:\n",
|
| 174 |
+
" raise ValueError(\n",
|
| 175 |
+
" f\"assembly={assembly!r} does not match species={species!r}, \"\n",
|
| 176 |
+
" f\"whose assembly is {SPECIES_TO_ASSEMBLY[species]!r}\"\n",
|
| 177 |
+
" )\n",
|
| 178 |
+
"\n",
|
| 179 |
+
"print(f\"Model: {model_name}\")\n",
|
| 180 |
+
"print(f\"Region: {chrom}:{start:,}-{end:,} ({sequence_length:,} bp)\")\n",
|
| 181 |
+
"print(f\"Center: {center_position:,}\")\n",
|
| 182 |
+
"print(f\"Species: {species} -> Assembly: {assembly}\")"
|
| 183 |
+
]
|
| 184 |
+
},
|
| 185 |
+
{
|
| 186 |
+
"cell_type": "markdown",
|
| 187 |
+
"metadata": {
|
| 188 |
+
"id": "yytbImqlDK7m"
|
| 189 |
+
},
|
| 190 |
+
"source": [
|
| 191 |
+
"## 2) 📥 Fetch Chromosome Sequence\n"
|
| 192 |
+
]
|
| 193 |
+
},
|
| 194 |
+
{
|
| 195 |
+
"cell_type": "code",
|
| 196 |
+
"execution_count": null,
|
| 197 |
+
"metadata": {
|
| 198 |
+
"id": "cqXIi7mYDK7m"
|
| 199 |
+
},
|
| 200 |
+
"outputs": [],
|
| 201 |
+
"source": [
|
| 202 |
+
"# Get the sequence from the UCSC API\n",
|
| 203 |
+
"url = (\n",
|
| 204 |
+
" f\"https://api.genome.ucsc.edu/getData/sequence?\"\n",
|
| 205 |
+
" f\"genome={assembly};chrom={chrom};start={start};end={end}\"\n",
|
| 206 |
+
")\n",
|
| 207 |
+
"seq = requests.get(url, timeout=60).json()[\"dna\"]\n",
|
| 208 |
+
"print(f\"Original sequence length: {len(seq):,} bp\")\n",
|
| 209 |
+
"\n",
|
| 210 |
+
"# Crop to multiple of 128 (ensures clean tokenization)\n",
|
| 211 |
+
"seq = seq[:int(len(seq) // 128) * 128]\n",
|
| 212 |
+
"n_tokens = len(seq) / 128\n",
|
| 213 |
+
"print(\n",
|
| 214 |
+
" f\"Cropped sequence length: {len(seq):,} bp \"\n",
|
| 215 |
+
" f\"({n_tokens:.0f} transformer tokens)\"\n",
|
| 216 |
+
")\n",
|
| 217 |
+
"\n",
|
| 218 |
+
"# UCSC returns soft-masked sequence (repeats in lowercase). NTv3_tracks was trained\n",
|
| 219 |
+
"# on soft-masked genomes, so we keep the case as it is.\n",
|
| 220 |
+
"soft_masked_fraction = sum(b.islower() for b in seq) / len(seq)\n",
|
| 221 |
+
"print(f\"Soft-masked (lowercase) fraction: {soft_masked_fraction:.1%}\")\n",
|
| 222 |
+
"print(f\"First 100 bp: {seq[:100]}\")\n"
|
| 223 |
+
]
|
| 224 |
+
},
|
| 225 |
+
{
|
| 226 |
+
"cell_type": "markdown",
|
| 227 |
+
"metadata": {
|
| 228 |
+
"id": "jtJMea-1DK7m"
|
| 229 |
+
},
|
| 230 |
+
"source": [
|
| 231 |
+
"## 3) 🤖 Load NTv3 Model + Tokenizer\n",
|
| 232 |
+
"\n",
|
| 233 |
+
"The model uses custom code, so we pass `trust_remote_code=True`. The species vocabulary and the track order ship with the model config, under `track_metadata`."
|
| 234 |
+
]
|
| 235 |
+
},
|
| 236 |
+
{
|
| 237 |
+
"cell_type": "code",
|
| 238 |
+
"execution_count": null,
|
| 239 |
+
"metadata": {
|
| 240 |
+
"id": "L8RFTmRaDK7m"
|
| 241 |
+
},
|
| 242 |
+
"outputs": [],
|
| 243 |
+
"source": [
|
| 244 |
+
"# Load model\n",
|
| 245 |
+
"print(\"Loading model...\")\n",
|
| 246 |
+
"model = AutoModel.from_pretrained(\n",
|
| 247 |
+
" model_name,\n",
|
| 248 |
+
" trust_remote_code=True,\n",
|
| 249 |
+
").to(device)\n",
|
| 250 |
+
"model.eval()\n",
|
| 251 |
+
"\n",
|
| 252 |
+
"# Load tokenizer\n",
|
| 253 |
+
"print(\"Loading tokenizer...\")\n",
|
| 254 |
+
"tokenizer = AutoTokenizer.from_pretrained(\n",
|
| 255 |
+
" model_name, trust_remote_code=True\n",
|
| 256 |
+
")\n",
|
| 257 |
+
"\n",
|
| 258 |
+
"# Species ids and track order ship with the model config\n",
|
| 259 |
+
"metadata = model.config.track_metadata\n",
|
| 260 |
+
"species_vocab = metadata[\"species_vocabulary\"]\n",
|
| 261 |
+
"\n",
|
| 262 |
+
"print(f\"\\n✅ Model loaded successfully\")\n",
|
| 263 |
+
"supported_species = [s for s in species_vocab if not s.startswith(\"<\")]\n",
|
| 264 |
+
"print(f\" Supported species: {supported_species}\")\n",
|
| 265 |
+
"assemblies = list(metadata[\"bigwigs_per_file_assembly\"])\n",
|
| 266 |
+
"print(f\" Available assemblies: {assemblies}\")\n",
|
| 267 |
+
"if species not in supported_species:\n",
|
| 268 |
+
" raise ValueError(f\"Species {species!r} is not supported by {model_name}\")\n",
|
| 269 |
+
"if assembly not in assemblies:\n",
|
| 270 |
+
" raise ValueError(f\"Assembly {assembly!r} has no tracks in {model_name}\")\n",
|
| 271 |
+
"n_bigwigs = len(metadata[\"bigwigs_per_file_assembly\"][assembly])\n",
|
| 272 |
+
"print(f\" Number of {assembly} BigWig tracks: {n_bigwigs}\")\n"
|
| 273 |
+
]
|
| 274 |
+
},
|
| 275 |
+
{
|
| 276 |
+
"cell_type": "code",
|
| 277 |
+
"execution_count": null,
|
| 278 |
+
"metadata": {
|
| 279 |
+
"id": "_XZ3xXIMDK7n"
|
| 280 |
+
},
|
| 281 |
+
"outputs": [],
|
| 282 |
+
"source": [
|
| 283 |
+
"# Inspect available outputs: channel i of the output is track i of this list\n",
|
| 284 |
+
"bigwig_names = metadata[\"bigwigs_per_file_assembly\"][assembly]\n",
|
| 285 |
+
"print(\n",
|
| 286 |
+
" f\"{len(bigwig_names)} functional tracks for {assembly}. \"\n",
|
| 287 |
+
" f\"First 10: {bigwig_names[:10]}\"\n",
|
| 288 |
+
")\n"
|
| 289 |
+
]
|
| 290 |
+
},
|
| 291 |
+
{
|
| 292 |
+
"cell_type": "markdown",
|
| 293 |
+
"metadata": {
|
| 294 |
+
"id": "rFdiD_xZDK7n"
|
| 295 |
+
},
|
| 296 |
+
"source": [
|
| 297 |
+
"## 4) ⚡ Run Inference\n",
|
| 298 |
+
"\n",
|
| 299 |
+
"Following the manuscript methods: The functional-track module produces per-base predictions for all species-matched regulatory assays. Supervision is applied only to the central 37.5% of the input sequence.\n",
|
| 300 |
+
"\n",
|
| 301 |
+
"We pass:\n",
|
| 302 |
+
"- the tokenized DNA window, positionally: the forward takes the token tensor, not the tokenizer dict\n",
|
| 303 |
+
"- `conditions_tokens`: the species id, as a list with one `(batch,)` tensor\n",
|
| 304 |
+
"\n",
|
| 305 |
+
"`bigwig_tracks_logits` comes back on the forward output. The trunk uses scaled-dot-product attention and does not return weights, so the maps below are computed from each attention block.\n"
|
| 306 |
+
]
|
| 307 |
+
},
|
| 308 |
+
{
|
| 309 |
+
"cell_type": "code",
|
| 310 |
+
"execution_count": null,
|
| 311 |
+
"metadata": {
|
| 312 |
+
"id": "bAidYEvWDK7n"
|
| 313 |
+
},
|
| 314 |
+
"outputs": [],
|
| 315 |
+
"source": [
|
| 316 |
+
"# Tokenize DNA sequence (its length is already a multiple of 128)\n",
|
| 317 |
+
"batch = tokenizer([seq], add_special_tokens=False, return_tensors=\"pt\")\n",
|
| 318 |
+
"input_ids = batch[\"input_ids\"].to(device)\n",
|
| 319 |
+
"# Characters outside the alphabet (e.g. IUPAC codes) become <unk> without changing the length\n",
|
| 320 |
+
"assert tokenizer.unk_token_id not in input_ids, \"the sequence contains characters outside the alphabet\"\n",
|
| 321 |
+
"\n",
|
| 322 |
+
"# Encode species\n",
|
| 323 |
+
"species_ids = torch.tensor([species_vocab[species]], device=device)\n",
|
| 324 |
+
"\n",
|
| 325 |
+
"print(f\"Input IDs shape: {input_ids.shape}\")\n",
|
| 326 |
+
"print(f\"Species IDs shape: {species_ids.shape}\")\n"
|
| 327 |
+
]
|
| 328 |
+
},
|
| 329 |
+
{
|
| 330 |
+
"cell_type": "code",
|
| 331 |
+
"execution_count": null,
|
| 332 |
+
"metadata": {
|
| 333 |
+
"id": "yWDD1Z0zDK7n"
|
| 334 |
+
},
|
| 335 |
+
"outputs": [],
|
| 336 |
+
"source": [
|
| 337 |
+
"# Run inference. Attention weights are not a forward argument on this model.\n",
|
| 338 |
+
"import importlib\n",
|
| 339 |
+
"\n",
|
| 340 |
+
"print(\"Running forward pass...\")\n",
|
| 341 |
+
"blocks = model.model.mha_blocks\n",
|
| 342 |
+
"modeling = importlib.import_module(type(blocks[0]).__module__)\n",
|
| 343 |
+
"captured = []\n",
|
| 344 |
+
"originals = [block.forward for block in blocks]\n",
|
| 345 |
+
"\n",
|
| 346 |
+
"def _capturing_forward(block, original):\n",
|
| 347 |
+
" def forward(x, attention_mask=None, conditions=None, conditions_masks=None):\n",
|
| 348 |
+
" batch, seq_len, _ = x.shape\n",
|
| 349 |
+
" if block.conditioned:\n",
|
| 350 |
+
" hidden = block.norm_in(x, conditions, conditions_masks)\n",
|
| 351 |
+
" else:\n",
|
| 352 |
+
" hidden = block.norm_in(x)\n",
|
| 353 |
+
" query = block.norm_q(\n",
|
| 354 |
+
" block.q_layer(hidden).view(batch, seq_len, block.attention_heads, -1)\n",
|
| 355 |
+
" )\n",
|
| 356 |
+
" key = block.norm_k(\n",
|
| 357 |
+
" block.k_layer(hidden).view(batch, seq_len, block.num_kv_heads, -1)\n",
|
| 358 |
+
" )\n",
|
| 359 |
+
" inv_freq = modeling.build_rope_inv_freq(block.key_size, device=x.device)\n",
|
| 360 |
+
" query = modeling.apply_rope(query, inv_freq)\n",
|
| 361 |
+
" key = modeling.apply_rope(key, inv_freq)\n",
|
| 362 |
+
" group = block.attention_heads // block.num_kv_heads\n",
|
| 363 |
+
" query = query.transpose(1, 2)\n",
|
| 364 |
+
" key = key.transpose(1, 2).repeat_interleave(group, dim=1)\n",
|
| 365 |
+
" scale = 1.0 / math.sqrt(block.key_size)\n",
|
| 366 |
+
" logits = torch.matmul(query.float(), key.float().transpose(-2, -1)) * scale\n",
|
| 367 |
+
" if attention_mask is not None:\n",
|
| 368 |
+
" logits = logits.masked_fill(~attention_mask, float(\"-inf\"))\n",
|
| 369 |
+
" weights = torch.softmax(logits, dim=-1)\n",
|
| 370 |
+
" captured.append(weights[0].cpu().numpy())\n",
|
| 371 |
+
" return original(\n",
|
| 372 |
+
" x,\n",
|
| 373 |
+
" attention_mask=attention_mask,\n",
|
| 374 |
+
" conditions=conditions,\n",
|
| 375 |
+
" conditions_masks=conditions_masks,\n",
|
| 376 |
+
" )\n",
|
| 377 |
+
"\n",
|
| 378 |
+
" return forward\n",
|
| 379 |
+
"\n",
|
| 380 |
+
"for block, original in zip(blocks, originals):\n",
|
| 381 |
+
" block.forward = _capturing_forward(block, original)\n",
|
| 382 |
+
"try:\n",
|
| 383 |
+
" with torch.no_grad():\n",
|
| 384 |
+
" outputs = model(input_ids, conditions_tokens=[species_ids])\n",
|
| 385 |
+
"finally:\n",
|
| 386 |
+
" for block, original in zip(blocks, originals):\n",
|
| 387 |
+
" block.forward = original\n",
|
| 388 |
+
"\n",
|
| 389 |
+
"# [batch, 0.375 * seq_len, num_tracks]\n",
|
| 390 |
+
"bigwig_logits = outputs[\"bigwig_tracks_logits\"]\n",
|
| 391 |
+
"# (layers, heads, blocks, blocks), one 128 bp block per position\n",
|
| 392 |
+
"attention_maps = np.stack(captured, axis=0)\n",
|
| 393 |
+
"\n",
|
| 394 |
+
"print(\"\\n✅ Forward pass completed\")\n",
|
| 395 |
+
"print(f\" BigWig logits: {tuple(bigwig_logits.shape)}\")\n",
|
| 396 |
+
"print(f\" Attention maps: {attention_maps.shape} (layers, heads, seq, seq)\")\n"
|
| 397 |
+
]
|
| 398 |
+
},
|
| 399 |
+
{
|
| 400 |
+
"cell_type": "markdown",
|
| 401 |
+
"metadata": {
|
| 402 |
+
"id": "WpKxn1juDK7n"
|
| 403 |
+
},
|
| 404 |
+
"source": [
|
| 405 |
+
"## 5) 📊 Visualize Functional Tracks\n",
|
| 406 |
+
"\n",
|
| 407 |
+
"Following the manuscript methods: Functional-track predictions are obtained directly from the model output tensor. Predictions are center-cropped to the supervised region (49,152 bp for 131 kb inputs).\n",
|
| 408 |
+
"\n",
|
| 409 |
+
"Metadata for all available tracks can be downloaded [here](https://huggingface.co/spaces/InstaDeepAI/ntv3_tracks/blob/main/data/functional_tracks_metadata.csv)."
|
| 410 |
+
]
|
| 411 |
+
},
|
| 412 |
+
{
|
| 413 |
+
"cell_type": "code",
|
| 414 |
+
"execution_count": null,
|
| 415 |
+
"metadata": {
|
| 416 |
+
"id": "u3pzwP7VDK7n"
|
| 417 |
+
},
|
| 418 |
+
"outputs": [],
|
| 419 |
+
"source": [
|
| 420 |
+
"# Select functional tracks to plot\n",
|
| 421 |
+
"tracks_to_plot = {\n",
|
| 422 |
+
" \"K562 RNA-seq\": \"ENCSR056HPM\",\n",
|
| 423 |
+
" \"K562 CAGE\": \"CNhs11250_P\",\n",
|
| 424 |
+
" \"K562 DNAse\": \"ENCSR921NMD\",\n",
|
| 425 |
+
" \"K562 GATA1 ChIP-seq\": \"ENCSR000EFT\"\n",
|
| 426 |
+
"}\n",
|
| 427 |
+
"\n",
|
| 428 |
+
"# Every track must exist for this assembly\n",
|
| 429 |
+
"missing_tracks = [v for v in tracks_to_plot.values() if v not in bigwig_names]\n",
|
| 430 |
+
"if missing_tracks:\n",
|
| 431 |
+
" raise ValueError(f\"Tracks not available for {assembly}: {missing_tracks}\")\n",
|
| 432 |
+
"\n",
|
| 433 |
+
"print(f\"Plotting {len(tracks_to_plot)} functional tracks\")\n"
|
| 434 |
+
]
|
| 435 |
+
},
|
| 436 |
+
{
|
| 437 |
+
"cell_type": "code",
|
| 438 |
+
"execution_count": null,
|
| 439 |
+
"metadata": {
|
| 440 |
+
"id": "gtF2KGoQDK7n"
|
| 441 |
+
},
|
| 442 |
+
"outputs": [],
|
| 443 |
+
"source": [
|
| 444 |
+
"def plot_tracks(tracks, start_pos, end_pos, chrom_name, assembly_name,\n",
|
| 445 |
+
" height=1.0):\n",
|
| 446 |
+
" \"\"\"Plot multiple tracks in a stacked view.\n",
|
| 447 |
+
"\n",
|
| 448 |
+
" Args:\n",
|
| 449 |
+
" tracks: Dictionary mapping track names to 1D arrays of values.\n",
|
| 450 |
+
" start_pos: Genomic start position for x-axis.\n",
|
| 451 |
+
" end_pos: Genomic end position for x-axis.\n",
|
| 452 |
+
" chrom_name: Chromosome name for labeling.\n",
|
| 453 |
+
" assembly_name: Assembly name for labeling.\n",
|
| 454 |
+
" height: Height multiplier for each track subplot.\n",
|
| 455 |
+
"\n",
|
| 456 |
+
" Returns:\n",
|
| 457 |
+
" matplotlib.figure.Figure: The created figure object.\n",
|
| 458 |
+
" \"\"\"\n",
|
| 459 |
+
" n_tracks = len(tracks)\n",
|
| 460 |
+
" fig, axes = plt.subplots(\n",
|
| 461 |
+
" n_tracks, 1, figsize=(16, height * n_tracks), sharex=True\n",
|
| 462 |
+
" )\n",
|
| 463 |
+
" if n_tracks == 1:\n",
|
| 464 |
+
" axes = [axes]\n",
|
| 465 |
+
"\n",
|
| 466 |
+
" for ax, (title, y) in zip(axes, tracks.items()):\n",
|
| 467 |
+
" x_positions = np.linspace(start_pos, end_pos, num=len(y))\n",
|
| 468 |
+
" ax.fill_between(x_positions, y, alpha=0.7)\n",
|
| 469 |
+
" ax.set_title(title, fontsize=8)\n",
|
| 470 |
+
" ax.yaxis.set_label_coords(-0.01, 0.5)\n",
|
| 471 |
+
" sns.despine(ax=ax, top=True, right=True, bottom=True)\n",
|
| 472 |
+
" ax.tick_params(axis='y', labelsize=7)\n",
|
| 473 |
+
"\n",
|
| 474 |
+
" xlabel = f\"{chrom_name}:{start_pos:,}-{end_pos:,} ({assembly_name})\"\n",
|
| 475 |
+
" axes[-1].set_xlabel(xlabel, fontsize=9)\n",
|
| 476 |
+
" plt.tight_layout()\n",
|
| 477 |
+
" return fig\n",
|
| 478 |
+
"\n",
|
| 479 |
+
"# Extract and plot functional tracks\n",
|
| 480 |
+
"bigwig = bigwig_logits.detach().float().cpu().numpy()[0]\n",
|
| 481 |
+
"bigwig_tracks = {}\n",
|
| 482 |
+
"for track_name, track_id in tracks_to_plot.items():\n",
|
| 483 |
+
" if bigwig_names and track_id in bigwig_names:\n",
|
| 484 |
+
" track_idx = bigwig_names.index(track_id)\n",
|
| 485 |
+
" bigwig_tracks[track_name] = bigwig[:, track_idx]\n",
|
| 486 |
+
"\n",
|
| 487 |
+
"if not bigwig_tracks:\n",
|
| 488 |
+
" print(\"⚠️ No tracks to plot (track names may not be available)\")\n",
|
| 489 |
+
"else:\n",
|
| 490 |
+
" # Calculate prediction region (center 37.5%)\n",
|
| 491 |
+
" window_len = end - start\n",
|
| 492 |
+
" prediction_start = start + int(window_len * 0.3125)\n",
|
| 493 |
+
" prediction_end = prediction_start + int(window_len * 0.375)\n",
|
| 494 |
+
"\n",
|
| 495 |
+
" fig = plot_tracks(\n",
|
| 496 |
+
" bigwig_tracks, prediction_start, prediction_end, chrom, assembly\n",
|
| 497 |
+
" )\n",
|
| 498 |
+
" plt.suptitle(\"Predicted Functional Tracks\", fontsize=11, y=1.02)\n",
|
| 499 |
+
" plt.show()\n",
|
| 500 |
+
"\n"
|
| 501 |
+
]
|
| 502 |
+
},
|
| 503 |
+
{
|
| 504 |
+
"cell_type": "markdown",
|
| 505 |
+
"metadata": {},
|
| 506 |
+
"source": [
|
| 507 |
+
"> **How to read this plot.** The x axis is the genomic coordinate on `chrom` in `assembly`. It covers the predicted span, the center 37.5% of the input window, not the full input. Each panel is one track at 1 bp resolution, on its own y scale. A high value means the model predicts a strong signal for that assay at that position.\n"
|
| 508 |
+
]
|
| 509 |
+
},
|
| 510 |
+
{
|
| 511 |
+
"cell_type": "markdown",
|
| 512 |
+
"metadata": {
|
| 513 |
+
"id": "iqyiyLPzDK7o"
|
| 514 |
+
},
|
| 515 |
+
"source": [
|
| 516 |
+
"## 6) 🔗 Analyze Attention Maps\n",
|
| 517 |
+
"\n",
|
| 518 |
+
"Following the manuscript methods: Transformer self-attention reveals how NTv3 integrates information across distal regions. We extract multi-head self-attention weights and average them across heads. Matrices are symmetrized as (A + A^T)/2.\n"
|
| 519 |
+
]
|
| 520 |
+
},
|
| 521 |
+
{
|
| 522 |
+
"cell_type": "code",
|
| 523 |
+
"execution_count": null,
|
| 524 |
+
"metadata": {
|
| 525 |
+
"id": "StvwdIcUDK7o"
|
| 526 |
+
},
|
| 527 |
+
"outputs": [],
|
| 528 |
+
"source": [
|
| 529 |
+
"# Extract attention from last layer\n",
|
| 530 |
+
"layer_index = -1 # Last layer\n",
|
| 531 |
+
"# [heads, seq_len, seq_len]\n",
|
| 532 |
+
"attention_last_layer = attention_maps[layer_index]\n",
|
| 533 |
+
"\n",
|
| 534 |
+
"# Average over heads and symmetrize\n",
|
| 535 |
+
"# [seq_len, seq_len]\n",
|
| 536 |
+
"attention_mean = attention_last_layer.mean(axis=0)\n",
|
| 537 |
+
"attention_mean = 0.5 * (attention_mean + attention_mean.T) # Symmetrize\n",
|
| 538 |
+
"\n",
|
| 539 |
+
"print(f\"Attention map shape: {attention_mean.shape}\")\n",
|
| 540 |
+
"attn_min = attention_mean.min()\n",
|
| 541 |
+
"attn_max = attention_mean.max()\n",
|
| 542 |
+
"print(f\"Attention value range: [{attn_min:.6f}, {attn_max:.6f}]\")\n"
|
| 543 |
+
]
|
| 544 |
+
},
|
| 545 |
+
{
|
| 546 |
+
"cell_type": "code",
|
| 547 |
+
"execution_count": null,
|
| 548 |
+
"metadata": {
|
| 549 |
+
"id": "qRxVY-6VDK7o"
|
| 550 |
+
},
|
| 551 |
+
"outputs": [],
|
| 552 |
+
"source": [
|
| 553 |
+
"# ============================================================\n",
|
| 554 |
+
"# Pyramid-style Attention Map Plotting (45-degree rotated triangular)\n",
|
| 555 |
+
"# ============================================================\n",
|
| 556 |
+
"from matplotlib.colors import TwoSlopeNorm\n",
|
| 557 |
+
"\n",
|
| 558 |
+
"# Global settings\n",
|
| 559 |
+
"LINE_WIDTH = 0.5\n",
|
| 560 |
+
"\n",
|
| 561 |
+
"def _get_45deg_mesh(mat):\n",
|
| 562 |
+
" \"\"\"Create 45-degree rotated mesh for triangular attention visualization.\n",
|
| 563 |
+
"\n",
|
| 564 |
+
" Args:\n",
|
| 565 |
+
" mat: Input matrix to create mesh for.\n",
|
| 566 |
+
"\n",
|
| 567 |
+
" Returns:\n",
|
| 568 |
+
" tuple: (X, Y) mesh coordinates rotated 45 degrees.\n",
|
| 569 |
+
" \"\"\"\n",
|
| 570 |
+
" theta = -np.pi / 4\n",
|
| 571 |
+
" R = np.array([\n",
|
| 572 |
+
" [np.cos(theta), -np.sin(theta)],\n",
|
| 573 |
+
" [np.sin(theta), np.cos(theta)]\n",
|
| 574 |
+
" ])\n",
|
| 575 |
+
" K = len(mat) + 1\n",
|
| 576 |
+
" g = np.arange(0, K) - 0.5\n",
|
| 577 |
+
" X = np.tile(g[:, None], (1, K))\n",
|
| 578 |
+
" Y = np.tile(g[None, :], (K, 1))\n",
|
| 579 |
+
" xy = np.array([X.ravel(), Y.ravel()])\n",
|
| 580 |
+
" xy_rot = R @ xy\n",
|
| 581 |
+
" return xy_rot[0, :].reshape(K, K), xy_rot[1, :].reshape(K, K).T\n",
|
| 582 |
+
"\n",
|
| 583 |
+
"def plot_attention_panel(mat, filename=None, *, cmap=\"Blues\", vmin=0.0001,\n",
|
| 584 |
+
" vmax=0.005, colorbar=True, dpi=600, figsize=(10, 10),\n",
|
| 585 |
+
" variant_id=\"\", show_titles=True, show_xlabel=True,\n",
|
| 586 |
+
" show_xticks=True, show_yticks=False,\n",
|
| 587 |
+
" positions='TOKEN', token_resolution=128, show=True):\n",
|
| 588 |
+
" \"\"\"Plot triangular attention heatmap with 45-degree rotation (pyramid style).\n",
|
| 589 |
+
"\n",
|
| 590 |
+
" Args:\n",
|
| 591 |
+
" mat: Attention matrix to plot.\n",
|
| 592 |
+
" filename: Output filename. If None and show=True, displays plot.\n",
|
| 593 |
+
" cmap: Colormap name. Defaults to \"Blues\".\n",
|
| 594 |
+
" vmin: Minimum value for color scale. Defaults to 0.0001.\n",
|
| 595 |
+
" vmax: Maximum value for color scale. Defaults to 0.005.\n",
|
| 596 |
+
" colorbar: Whether to show colorbar. Defaults to True.\n",
|
| 597 |
+
" dpi: Figure DPI. Defaults to 600.\n",
|
| 598 |
+
" figsize: Figure size tuple. Defaults to (10, 10).\n",
|
| 599 |
+
" variant_id: Title for the plot. Defaults to \"\".\n",
|
| 600 |
+
" show_titles: Whether to show title. Defaults to True.\n",
|
| 601 |
+
" show_xlabel: Whether to show x-axis label. Defaults to True.\n",
|
| 602 |
+
" show_xticks: Whether to show x-axis ticks. Defaults to True.\n",
|
| 603 |
+
" show_yticks: Whether to show y-axis ticks. Defaults to False.\n",
|
| 604 |
+
" positions: 'TOKEN' or 'BP' for position units. Defaults to 'TOKEN'.\n",
|
| 605 |
+
" token_resolution: Base pairs per token. Defaults to 128.\n",
|
| 606 |
+
" show: Whether to display plot. Defaults to True.\n",
|
| 607 |
+
"\n",
|
| 608 |
+
" Returns:\n",
|
| 609 |
+
" matplotlib.figure.Figure: The created figure object.\n",
|
| 610 |
+
" \"\"\"\n",
|
| 611 |
+
" # Symmetrize matrix\n",
|
| 612 |
+
" mat = 0.5 * (mat + mat.T)\n",
|
| 613 |
+
"\n",
|
| 614 |
+
" # Mask lower triangle\n",
|
| 615 |
+
" mat[np.tril_indices_from(mat, k=-1)] = np.nan\n",
|
| 616 |
+
" n = mat.shape[0]\n",
|
| 617 |
+
" X, Y = _get_45deg_mesh(mat)\n",
|
| 618 |
+
"\n",
|
| 619 |
+
" # Coordinate normalization\n",
|
| 620 |
+
" C = 1\n",
|
| 621 |
+
" half_pixel_diag = 1 / (2*C)\n",
|
| 622 |
+
" pixel_side = 1 / (C * np.sqrt(2))\n",
|
| 623 |
+
" X = X * pixel_side + half_pixel_diag\n",
|
| 624 |
+
" Y = Y * pixel_side\n",
|
| 625 |
+
" Y = -Y # Flip Y\n",
|
| 626 |
+
"\n",
|
| 627 |
+
" # Clean up coordinates\n",
|
| 628 |
+
" X = np.nan_to_num(X, nan=0.0, posinf=1.0, neginf=-1.0)\n",
|
| 629 |
+
" Y = np.nan_to_num(Y, nan=0.0, posinf=1.0, neginf=-1.0)\n",
|
| 630 |
+
"\n",
|
| 631 |
+
" # Set up normalization\n",
|
| 632 |
+
" vcenter = (vmin + vmax) / 2\n",
|
| 633 |
+
" norm = TwoSlopeNorm(vmin=vmin, vcenter=vcenter, vmax=vmax)\n",
|
| 634 |
+
"\n",
|
| 635 |
+
" fig, ax = plt.subplots(figsize=figsize, dpi=dpi)\n",
|
| 636 |
+
" im = ax.pcolormesh(X, Y, mat, cmap=cmap, norm=norm, shading=\"auto\")\n",
|
| 637 |
+
"\n",
|
| 638 |
+
" # Set axis properties\n",
|
| 639 |
+
" ax.set_aspect(\"equal\")\n",
|
| 640 |
+
"\n",
|
| 641 |
+
" # Calculate coordinate extents\n",
|
| 642 |
+
" finite_mask = np.isfinite(mat)\n",
|
| 643 |
+
" if finite_mask.any():\n",
|
| 644 |
+
" vertex_mask = np.zeros_like(X, dtype=bool)\n",
|
| 645 |
+
" vertex_mask[:-1, :-1] |= finite_mask\n",
|
| 646 |
+
" vertex_mask[:-1, 1:] |= finite_mask\n",
|
| 647 |
+
" vertex_mask[1:, :-1] |= finite_mask\n",
|
| 648 |
+
" vertex_mask[1:, 1:] |= finite_mask\n",
|
| 649 |
+
"\n",
|
| 650 |
+
" valid_x = X[vertex_mask]\n",
|
| 651 |
+
" valid_y = Y[vertex_mask]\n",
|
| 652 |
+
" x_min, x_max = np.nanmin(valid_x), np.nanmax(valid_x)\n",
|
| 653 |
+
" y_min, y_max = np.nanmin(valid_y), np.nanmax(valid_y)\n",
|
| 654 |
+
" else:\n",
|
| 655 |
+
" x_min, x_max = np.nanmin(X), np.nanmax(X)\n",
|
| 656 |
+
" y_min, y_max = np.nanmin(Y), np.nanmax(Y)\n",
|
| 657 |
+
"\n",
|
| 658 |
+
" triangle_bottom_y = y_min\n",
|
| 659 |
+
" triangle_top_y = y_max\n",
|
| 660 |
+
"\n",
|
| 661 |
+
" xlim_pad = 0.1\n",
|
| 662 |
+
" ylim_pad = 0.1\n",
|
| 663 |
+
" y_range = (y_max - y_min) if y_max > y_min else 1.0\n",
|
| 664 |
+
" bottom_pad = 0.001 * y_range\n",
|
| 665 |
+
"\n",
|
| 666 |
+
" ax.set_xlim(x_min - xlim_pad, x_max + xlim_pad)\n",
|
| 667 |
+
" ax.set_ylim(y_min - bottom_pad, y_max + ylim_pad)\n",
|
| 668 |
+
"\n",
|
| 669 |
+
" # Add colorbar\n",
|
| 670 |
+
" if colorbar:\n",
|
| 671 |
+
" pyramid_y_min = y_min\n",
|
| 672 |
+
" pyramid_y_max = y_max\n",
|
| 673 |
+
"\n",
|
| 674 |
+
" cb = plt.colorbar(im, ax=ax, shrink=1.0, aspect=20, pad=0.1)\n",
|
| 675 |
+
" cb.ax.tick_params(width=LINE_WIDTH*0.8, length=3, labelsize=7, direction=\"in\")\n",
|
| 676 |
+
" cb.outline.set_linewidth(LINE_WIDTH*0.8)\n",
|
| 677 |
+
"\n",
|
| 678 |
+
" ax_pos = ax.get_position()\n",
|
| 679 |
+
" axes_y_min_data = y_min - bottom_pad\n",
|
| 680 |
+
" axes_y_max_data = y_max + ylim_pad\n",
|
| 681 |
+
" axes_height_data = axes_y_max_data - axes_y_min_data\n",
|
| 682 |
+
"\n",
|
| 683 |
+
" if axes_height_data > 0:\n",
|
| 684 |
+
" pyramid_bottom_frac = (\n",
|
| 685 |
+
" (pyramid_y_min - axes_y_min_data) / axes_height_data\n",
|
| 686 |
+
" )\n",
|
| 687 |
+
" pyramid_top_frac = (\n",
|
| 688 |
+
" (pyramid_y_max - axes_y_min_data) / axes_height_data\n",
|
| 689 |
+
" )\n",
|
| 690 |
+
" else:\n",
|
| 691 |
+
" pyramid_bottom_frac = 0\n",
|
| 692 |
+
" pyramid_top_frac = 1\n",
|
| 693 |
+
"\n",
|
| 694 |
+
" cb_pos = cb.ax.get_position()\n",
|
| 695 |
+
" new_y0 = ax_pos.y0 + ax_pos.height * pyramid_bottom_frac\n",
|
| 696 |
+
" new_height = ax_pos.height * (pyramid_top_frac - pyramid_bottom_frac)\n",
|
| 697 |
+
" cb.ax.set_position(\n",
|
| 698 |
+
" [cb_pos.x0, new_y0, cb_pos.width, new_height]\n",
|
| 699 |
+
" )\n",
|
| 700 |
+
"\n",
|
| 701 |
+
" # Format axes\n",
|
| 702 |
+
" use_bp_display = positions.upper() == 'BP'\n",
|
| 703 |
+
"\n",
|
| 704 |
+
" ax.tick_params(width=LINE_WIDTH, length=3, labelsize=7)\n",
|
| 705 |
+
" for sp in (\"top\", \"right\", \"left\"):\n",
|
| 706 |
+
" ax.spines[sp].set_visible(False)\n",
|
| 707 |
+
" ax.spines[\"bottom\"].set_linewidth(LINE_WIDTH)\n",
|
| 708 |
+
" ax.spines[\"bottom\"].set_position(('data', triangle_bottom_y))\n",
|
| 709 |
+
" ax.xaxis.set_ticks_position('bottom')\n",
|
| 710 |
+
" ax.xaxis.set_label_position('bottom')\n",
|
| 711 |
+
"\n",
|
| 712 |
+
" if show_xticks and mat.size > 0:\n",
|
| 713 |
+
" n_tokens = mat.shape[0]\n",
|
| 714 |
+
" tick_positions = [0, n_tokens - 1]\n",
|
| 715 |
+
" if use_bp_display:\n",
|
| 716 |
+
" max_bp = (n_tokens - 1) * token_resolution\n",
|
| 717 |
+
" tick_labels = ['0', str(max_bp)]\n",
|
| 718 |
+
" else:\n",
|
| 719 |
+
" tick_labels = ['0', str(n_tokens - 1)]\n",
|
| 720 |
+
" ax.set_xticks(tick_positions)\n",
|
| 721 |
+
" ax.set_xticklabels(tick_labels)\n",
|
| 722 |
+
" else:\n",
|
| 723 |
+
" ax.set_xticks([])\n",
|
| 724 |
+
"\n",
|
| 725 |
+
" if not show_yticks:\n",
|
| 726 |
+
" ax.set_yticks([])\n",
|
| 727 |
+
"\n",
|
| 728 |
+
" if use_bp_display:\n",
|
| 729 |
+
" xlabel_text = \"Position (base pairs)\"\n",
|
| 730 |
+
" else:\n",
|
| 731 |
+
" xlabel_text = \"Token position\"\n",
|
| 732 |
+
" if show_xlabel:\n",
|
| 733 |
+
" ax.set_xlabel(xlabel_text, fontsize=8)\n",
|
| 734 |
+
"\n",
|
| 735 |
+
" if show_titles and variant_id:\n",
|
| 736 |
+
" ax.set_title(variant_id, fontsize=9, pad=10)\n",
|
| 737 |
+
"\n",
|
| 738 |
+
" ax.set_facecolor(\"white\")\n",
|
| 739 |
+
"\n",
|
| 740 |
+
" if filename:\n",
|
| 741 |
+
" dirname = os.path.dirname(filename)\n",
|
| 742 |
+
" if dirname:\n",
|
| 743 |
+
" os.makedirs(dirname, exist_ok=True)\n",
|
| 744 |
+
" plt.savefig(\n",
|
| 745 |
+
" filename, dpi=600, bbox_inches=\"tight\", facecolor='white'\n",
|
| 746 |
+
" )\n",
|
| 747 |
+
" print(f\"✅ Saved attention plot: {filename}\")\n",
|
| 748 |
+
" if not show:\n",
|
| 749 |
+
" plt.close(fig)\n",
|
| 750 |
+
" elif show:\n",
|
| 751 |
+
" plt.show()\n",
|
| 752 |
+
" else:\n",
|
| 753 |
+
" plt.close(fig)\n",
|
| 754 |
+
"\n",
|
| 755 |
+
" return fig\n",
|
| 756 |
+
"\n",
|
| 757 |
+
"print(\"✅ Pyramid-style attention plotting function defined\")"
|
| 758 |
+
]
|
| 759 |
+
},
|
| 760 |
+
{
|
| 761 |
+
"cell_type": "code",
|
| 762 |
+
"execution_count": null,
|
| 763 |
+
"metadata": {
|
| 764 |
+
"id": "qeziBpBmDK7o"
|
| 765 |
+
},
|
| 766 |
+
"outputs": [],
|
| 767 |
+
"source": [
|
| 768 |
+
"# Plot pyramid-style attention map (cropped to center window)\n",
|
| 769 |
+
"LAYER_INDEX = layer_index\n",
|
| 770 |
+
"TOKEN_RESOLUTION = token_resolution\n",
|
| 771 |
+
"window_size = min(1024, len(attention_mean)) # tokens to show\n",
|
| 772 |
+
"\n",
|
| 773 |
+
"# Crop to center window\n",
|
| 774 |
+
"n_tokens_full = len(attention_mean)\n",
|
| 775 |
+
"start_crop = (n_tokens_full - window_size) // 2\n",
|
| 776 |
+
"end_crop = start_crop + window_size\n",
|
| 777 |
+
"attention_mean_cropped = attention_mean[\n",
|
| 778 |
+
" start_crop:end_crop, start_crop:end_crop\n",
|
| 779 |
+
"].copy()\n",
|
| 780 |
+
"\n",
|
| 781 |
+
"print(f\"Plotting attention map:\")\n",
|
| 782 |
+
"print(f\" Layer: {LAYER_INDEX}\")\n",
|
| 783 |
+
"print(\n",
|
| 784 |
+
" f\" Window: {window_size} tokens \"\n",
|
| 785 |
+
" f\"(cropped from {n_tokens_full} total)\"\n",
|
| 786 |
+
")\n",
|
| 787 |
+
"attn_crop_min = attention_mean_cropped.min()\n",
|
| 788 |
+
"attn_crop_max = attention_mean_cropped.max()\n",
|
| 789 |
+
"print(f\" Value range: [{attn_crop_min:.6f}, {attn_crop_max:.6f}]\")\n",
|
| 790 |
+
"\n",
|
| 791 |
+
"# Plot with pyramid style using shared utility\n",
|
| 792 |
+
"title = f\"Attention Map - Layer {LAYER_INDEX} (Head Average)\"\n",
|
| 793 |
+
"fig = plot_attention_panel(\n",
|
| 794 |
+
" attention_mean_cropped,\n",
|
| 795 |
+
" cmap=\"Blues\",\n",
|
| 796 |
+
" vmin=0.0001,\n",
|
| 797 |
+
" vmax=0.005,\n",
|
| 798 |
+
" colorbar=True,\n",
|
| 799 |
+
" figsize=(10, 10),\n",
|
| 800 |
+
" dpi=150,\n",
|
| 801 |
+
" show_xlabel=True,\n",
|
| 802 |
+
" show_xticks=True,\n",
|
| 803 |
+
" positions='TOKEN',\n",
|
| 804 |
+
" token_resolution=TOKEN_RESOLUTION,\n",
|
| 805 |
+
" show=True,\n",
|
| 806 |
+
")\n"
|
| 807 |
+
]
|
| 808 |
+
},
|
| 809 |
+
{
|
| 810 |
+
"cell_type": "markdown",
|
| 811 |
+
"metadata": {},
|
| 812 |
+
"source": [
|
| 813 |
+
"> **How to read this plot.** The x axis is token position across the input window, one token per `token_resolution` bp (128 bp), up to 1,024 tokens around the center, so it covers the full input rather than only the predicted span. Each pixel is the last-layer attention between two tokens, averaged over heads and symmetrized. The map is the upper triangle rotated by 45°: the diagonal runs along the base and holds each token with itself and its neighbours, the local context. A pixel sits above the midpoint of its two tokens, higher the farther apart they are, so dark blobs high above the base are long-range interactions. Darker means more attention, and the colour scale is clipped at `vmax`, so the strongest pairs saturate."
|
| 814 |
+
]
|
| 815 |
+
},
|
| 816 |
+
{
|
| 817 |
+
"cell_type": "markdown",
|
| 818 |
+
"metadata": {
|
| 819 |
+
"id": "J7jJHp0uDK7o"
|
| 820 |
+
},
|
| 821 |
+
"source": [
|
| 822 |
+
"## 7) 🎯 Compute Saliency Maps\n",
|
| 823 |
+
"\n",
|
| 824 |
+
"Following the manuscript methods: Gradient-based attribution provides a first-order, nucleotide-resolved estimate of how NTv3 predictions depend on the underlying DNA sequence. We differentiate model outputs with respect to the one-hot encoded DNA input using a differentiable embedding override: a forward hook replaces the token embedding lookup with `one_hot @ embedding.weight`.\n",
|
| 825 |
+
"\n",
|
| 826 |
+
"For the selected functional track(s), we aggregate logits over sequence positions in a window and apply a log transform.\n"
|
| 827 |
+
]
|
| 828 |
+
},
|
| 829 |
+
{
|
| 830 |
+
"cell_type": "code",
|
| 831 |
+
"execution_count": null,
|
| 832 |
+
"metadata": {
|
| 833 |
+
"id": "FhAzknM4DK7o"
|
| 834 |
+
},
|
| 835 |
+
"outputs": [],
|
| 836 |
+
"source": [
|
| 837 |
+
"def find_token_embedding(model, vocab_size):\n",
|
| 838 |
+
" \"\"\"Return the embedding layer that maps DNA tokens to vectors.\n",
|
| 839 |
+
"\n",
|
| 840 |
+
" Args:\n",
|
| 841 |
+
" model: The model to search.\n",
|
| 842 |
+
" vocab_size: Size of the DNA token vocabulary.\n",
|
| 843 |
+
"\n",
|
| 844 |
+
" Returns:\n",
|
| 845 |
+
" torch.nn.Embedding: The token embedding layer.\n",
|
| 846 |
+
" \"\"\"\n",
|
| 847 |
+
" try:\n",
|
| 848 |
+
" embedding = model.get_input_embeddings()\n",
|
| 849 |
+
" except (AttributeError, NotImplementedError):\n",
|
| 850 |
+
" embedding = None\n",
|
| 851 |
+
" if isinstance(embedding, torch.nn.Embedding):\n",
|
| 852 |
+
" return embedding\n",
|
| 853 |
+
"\n",
|
| 854 |
+
" # Fall back to the only embedding table sized to the DNA vocabulary\n",
|
| 855 |
+
" candidates = [\n",
|
| 856 |
+
" module for module in model.modules()\n",
|
| 857 |
+
" if isinstance(module, torch.nn.Embedding)\n",
|
| 858 |
+
" and module.num_embeddings == vocab_size\n",
|
| 859 |
+
" ]\n",
|
| 860 |
+
" if len(candidates) != 1:\n",
|
| 861 |
+
" raise ValueError(\n",
|
| 862 |
+
" f\"Expected one token embedding of size {vocab_size}, \"\n",
|
| 863 |
+
" f\"found {len(candidates)}. \"\n",
|
| 864 |
+
" \"Saliency computation requires access to embedding weights.\"\n",
|
| 865 |
+
" )\n",
|
| 866 |
+
" return candidates[0]\n",
|
| 867 |
+
"\n",
|
| 868 |
+
"\n",
|
| 869 |
+
"class SaliencyComputer:\n",
|
| 870 |
+
" \"\"\"Compute gradient-based saliency maps for NTv3_tracks.\"\"\"\n",
|
| 871 |
+
"\n",
|
| 872 |
+
" def __init__(self, model, tokenizer, track_indices, species=\"human\",\n",
|
| 873 |
+
" device=None, target_window_bp=None):\n",
|
| 874 |
+
" \"\"\"Initialize saliency computer.\n",
|
| 875 |
+
"\n",
|
| 876 |
+
" Args:\n",
|
| 877 |
+
" model: The model to compute saliency for.\n",
|
| 878 |
+
" tokenizer: Tokenizer for input sequences.\n",
|
| 879 |
+
" track_indices: List of track indices to compute saliency for.\n",
|
| 880 |
+
" species: Species name. Defaults to \"human\".\n",
|
| 881 |
+
" device: Device to use. If None, auto-detects. Defaults to None.\n",
|
| 882 |
+
" target_window_bp: Optional (start, end) tuple in bp along the\n",
|
| 883 |
+
" output axis to aggregate over. Indices are relative to the\n",
|
| 884 |
+
" output (0 = first predicted position). If None (default),\n",
|
| 885 |
+
" sums over all output positions. The output is at 1 bp\n",
|
| 886 |
+
" resolution.\n",
|
| 887 |
+
" \"\"\"\n",
|
| 888 |
+
" # Auto-detect device if not provided\n",
|
| 889 |
+
" if device is None:\n",
|
| 890 |
+
" device = \"cuda\" if torch.cuda.is_available() else \"cpu\"\n",
|
| 891 |
+
"\n",
|
| 892 |
+
" self.model = model\n",
|
| 893 |
+
" self.tokenizer = tokenizer\n",
|
| 894 |
+
" # Only these channels of the track head are computed\n",
|
| 895 |
+
" self.track_indices = torch.tensor(track_indices, device=device)\n",
|
| 896 |
+
" self.species = species\n",
|
| 897 |
+
" self.device = device\n",
|
| 898 |
+
" self.target_window_bp = target_window_bp\n",
|
| 899 |
+
" self.embedding_layer = find_token_embedding(model, len(tokenizer))\n",
|
| 900 |
+
"\n",
|
| 901 |
+
" # Species id from the vocabulary shipped with the model config\n",
|
| 902 |
+
" species_vocab = model.config.track_metadata[\"species_vocabulary\"]\n",
|
| 903 |
+
" self.species_ids = torch.tensor([species_vocab[species]], device=device)\n",
|
| 904 |
+
"\n",
|
| 905 |
+
" def compute_saliency(self, sequence):\n",
|
| 906 |
+
" \"\"\"Compute saliency map for given sequence.\n",
|
| 907 |
+
"\n",
|
| 908 |
+
" Args:\n",
|
| 909 |
+
" sequence: DNA sequence string to compute saliency for. Its\n",
|
| 910 |
+
" length must be a multiple of 128.\n",
|
| 911 |
+
"\n",
|
| 912 |
+
" Returns:\n",
|
| 913 |
+
" tuple: (gradients, one_hot_sequence) where gradients is the\n",
|
| 914 |
+
" saliency scores and one_hot_sequence is the one-hot encoded\n",
|
| 915 |
+
" input sequence, both (positions, vocab_size).\n",
|
| 916 |
+
" \"\"\"\n",
|
| 917 |
+
" # Tokenize\n",
|
| 918 |
+
" batch = self.tokenizer(\n",
|
| 919 |
+
" [sequence], add_special_tokens=False, return_tensors=\"pt\"\n",
|
| 920 |
+
" )\n",
|
| 921 |
+
" token_ids = batch[\"input_ids\"].to(self.device)\n",
|
| 922 |
+
"\n",
|
| 923 |
+
" # Create one-hot encoding\n",
|
| 924 |
+
" one_hot = F.one_hot(\n",
|
| 925 |
+
" token_ids[0], num_classes=self.embedding_layer.num_embeddings\n",
|
| 926 |
+
" ).float()\n",
|
| 927 |
+
" one_hot.requires_grad_(True)\n",
|
| 928 |
+
"\n",
|
| 929 |
+
" # Replace the embedding lookup by one_hot @ weight, which is differentiable\n",
|
| 930 |
+
" def embed_one_hot(module, inputs, output):\n",
|
| 931 |
+
" return (one_hot @ module.weight).unsqueeze(0).to(output.dtype)\n",
|
| 932 |
+
"\n",
|
| 933 |
+
" # Forward pass with gradient tracking; the token tensor is passed positionally\n",
|
| 934 |
+
" handle = self.embedding_layer.register_forward_hook(embed_one_hot)\n",
|
| 935 |
+
" try:\n",
|
| 936 |
+
" outputs = self.model(\n",
|
| 937 |
+
" token_ids,\n",
|
| 938 |
+
" conditions_tokens=[self.species_ids],\n",
|
| 939 |
+
" track_indices=self.track_indices,\n",
|
| 940 |
+
" skip_lm_head=True,\n",
|
| 941 |
+
" )\n",
|
| 942 |
+
" finally:\n",
|
| 943 |
+
" handle.remove()\n",
|
| 944 |
+
"\n",
|
| 945 |
+
" # [batch, positions, len(track_indices)], read from the returned dict\n",
|
| 946 |
+
" logits = outputs[\"bigwig_tracks_logits\"]\n",
|
| 947 |
+
"\n",
|
| 948 |
+
" # Optionally slice output positions (output is at 1 bp resolution)\n",
|
| 949 |
+
" if self.target_window_bp is not None:\n",
|
| 950 |
+
" start, end = self.target_window_bp\n",
|
| 951 |
+
" seq_len = logits.shape[1]\n",
|
| 952 |
+
" start = max(0, min(start, seq_len))\n",
|
| 953 |
+
" end = max(start, min(end, seq_len))\n",
|
| 954 |
+
" logits = logits[:, start:end, :]\n",
|
| 955 |
+
"\n",
|
| 956 |
+
" score = torch.log(logits.sum() + 1e-6)\n",
|
| 957 |
+
"\n",
|
| 958 |
+
" # Backward pass\n",
|
| 959 |
+
" score.backward()\n",
|
| 960 |
+
"\n",
|
| 961 |
+
" gradients = one_hot.grad.cpu().detach().numpy()\n",
|
| 962 |
+
" one_hot_np = one_hot.detach().cpu().numpy()\n",
|
| 963 |
+
"\n",
|
| 964 |
+
" return gradients, one_hot_np\n"
|
| 965 |
+
]
|
| 966 |
+
},
|
| 967 |
+
{
|
| 968 |
+
"cell_type": "code",
|
| 969 |
+
"execution_count": null,
|
| 970 |
+
"metadata": {
|
| 971 |
+
"id": "_YwKWAj-DK7o"
|
| 972 |
+
},
|
| 973 |
+
"outputs": [],
|
| 974 |
+
"source": [
|
| 975 |
+
"# Select track for saliency computation\n",
|
| 976 |
+
"# The track id must exist for this assembly\n",
|
| 977 |
+
"saliency_track_id = \"ENCSR000EFT\" # K562 GATA1 ChIP-seq\n",
|
| 978 |
+
"\n",
|
| 979 |
+
"# Resolve track index\n",
|
| 980 |
+
"if saliency_track_id not in bigwig_names:\n",
|
| 981 |
+
" raise ValueError(f\"Track not available for {assembly}: {saliency_track_id!r}\")\n",
|
| 982 |
+
"saliency_track_idx = bigwig_names.index(saliency_track_id)\n",
|
| 983 |
+
"print(\n",
|
| 984 |
+
" f\"Computing saliency for track: {saliency_track_id} \"\n",
|
| 985 |
+
" f\"(index {saliency_track_idx})\"\n",
|
| 986 |
+
")\n",
|
| 987 |
+
"\n",
|
| 988 |
+
"# Use shorter sequence for saliency (faster computation)\n",
|
| 989 |
+
"saliency_seq_length = len(seq) // 2\n",
|
| 990 |
+
"seq_start = (len(seq) - saliency_seq_length) // 2\n",
|
| 991 |
+
"seq_end = seq_start + saliency_seq_length\n",
|
| 992 |
+
"saliency_seq = seq[seq_start:seq_end]\n",
|
| 993 |
+
"\n",
|
| 994 |
+
"print(\n",
|
| 995 |
+
" f\"Saliency sequence: {len(saliency_seq):,} bp \"\n",
|
| 996 |
+
" f\"(center crop of full sequence)\"\n",
|
| 997 |
+
")\n"
|
| 998 |
+
]
|
| 999 |
+
},
|
| 1000 |
+
{
|
| 1001 |
+
"cell_type": "code",
|
| 1002 |
+
"execution_count": null,
|
| 1003 |
+
"metadata": {
|
| 1004 |
+
"id": "G44_fw_bDK7o"
|
| 1005 |
+
},
|
| 1006 |
+
"outputs": [],
|
| 1007 |
+
"source": [
|
| 1008 |
+
"# Define target window: center +/- 1024 bp of the output axis\n",
|
| 1009 |
+
"promoter_window_bp = 1024\n",
|
| 1010 |
+
"output_len = int(len(saliency_seq) * 0.375) # predicted center span at 1 bp resolution\n",
|
| 1011 |
+
"center = output_len // 2\n",
|
| 1012 |
+
"target_window_bp = (center - promoter_window_bp, center + promoter_window_bp + 1)\n",
|
| 1013 |
+
"\n",
|
| 1014 |
+
"# Initialize saliency computer\n",
|
| 1015 |
+
"saliency_computer = SaliencyComputer(\n",
|
| 1016 |
+
" model=model,\n",
|
| 1017 |
+
" tokenizer=tokenizer,\n",
|
| 1018 |
+
" track_indices=[saliency_track_idx],\n",
|
| 1019 |
+
" species=species,\n",
|
| 1020 |
+
" device=device,\n",
|
| 1021 |
+
" target_window_bp=target_window_bp,\n",
|
| 1022 |
+
")\n",
|
| 1023 |
+
"\n",
|
| 1024 |
+
"# Compute saliency\n",
|
| 1025 |
+
"print(\"Computing saliency (this may take a moment)...\")\n",
|
| 1026 |
+
"saliency_scores, one_hot_sequence = saliency_computer.compute_saliency(\n",
|
| 1027 |
+
" saliency_seq\n",
|
| 1028 |
+
")\n",
|
| 1029 |
+
"\n",
|
| 1030 |
+
"print(f\"\\n✅ Saliency computed\")\n",
|
| 1031 |
+
"print(f\" Shape: {saliency_scores.shape}\")\n",
|
| 1032 |
+
"sal_min = saliency_scores.min()\n",
|
| 1033 |
+
"sal_max = saliency_scores.max()\n",
|
| 1034 |
+
"print(f\" Value range: [{sal_min:.6f}, {sal_max:.6f}]\")\n"
|
| 1035 |
+
]
|
| 1036 |
+
},
|
| 1037 |
+
{
|
| 1038 |
+
"cell_type": "code",
|
| 1039 |
+
"execution_count": null,
|
| 1040 |
+
"metadata": {
|
| 1041 |
+
"id": "A-eR1tK9DK7o"
|
| 1042 |
+
},
|
| 1043 |
+
"outputs": [],
|
| 1044 |
+
"source": [
|
| 1045 |
+
"# Nucleotide channels in logo order (A, T, C, G), upper case and soft-masked\n",
|
| 1046 |
+
"upper_ids = tokenizer(\"ATCG\", add_special_tokens=False)[\"input_ids\"]\n",
|
| 1047 |
+
"lower_ids = tokenizer(\"atcg\", add_special_tokens=False)[\"input_ids\"]\n",
|
| 1048 |
+
"assert tokenizer.unk_token_id not in upper_ids + lower_ids\n",
|
| 1049 |
+
"\n",
|
| 1050 |
+
"# Read each position from the channels of its own case: (positions, 4)\n",
|
| 1051 |
+
"soft_masked = np.array([base.islower() for base in saliency_seq])\n",
|
| 1052 |
+
"channels = np.where(soft_masked[:, None], lower_ids, upper_ids)\n",
|
| 1053 |
+
"gradients_acgt = np.take_along_axis(saliency_scores, channels, axis=1)\n",
|
| 1054 |
+
"one_hot_acgt = np.take_along_axis(one_hot_sequence, channels, axis=1)\n",
|
| 1055 |
+
"\n",
|
| 1056 |
+
"# Apply gradient correction (subtract mean over the four nucleotides at each position)\n",
|
| 1057 |
+
"saliency_corrected = gradients_acgt - np.mean(\n",
|
| 1058 |
+
" gradients_acgt, axis=1, keepdims=True\n",
|
| 1059 |
+
")\n",
|
| 1060 |
+
"\n",
|
| 1061 |
+
"# Calculate genomic positions\n",
|
| 1062 |
+
"saliency_start = start + seq_start\n"
|
| 1063 |
+
]
|
| 1064 |
+
},
|
| 1065 |
+
{
|
| 1066 |
+
"cell_type": "code",
|
| 1067 |
+
"execution_count": null,
|
| 1068 |
+
"metadata": {
|
| 1069 |
+
"id": "mU9TdySBDK7o"
|
| 1070 |
+
},
|
| 1071 |
+
"outputs": [],
|
| 1072 |
+
"source": [
|
| 1073 |
+
"# Plot saliency sequence logo\n",
|
| 1074 |
+
"from fast_logomaker import FastLogo\n",
|
| 1075 |
+
"\n",
|
| 1076 |
+
"# Nucleotide color scheme\n",
|
| 1077 |
+
"NUCLEOTIDE_COLORS = {\n",
|
| 1078 |
+
" 'A': '#00b050', # green\n",
|
| 1079 |
+
" 'C': '#3973fc', # blue\n",
|
| 1080 |
+
" 'G': '#fdb932', # yellow/gold\n",
|
| 1081 |
+
" 'T': '#ff554d' # red\n",
|
| 1082 |
+
"}\n",
|
| 1083 |
+
"\n",
|
| 1084 |
+
"# True = contribution mode (multiply by one-hot), False = hypothetical mode\n",
|
| 1085 |
+
"CONTRIBUTION_MODE = True\n",
|
| 1086 |
+
"LOGO_WINDOW_SIZE = 128 # Number of positions to show in logo\n",
|
| 1087 |
+
"\n",
|
| 1088 |
+
"# Saliency is at nucleotide resolution. Channels are already A, T, C, G.\n",
|
| 1089 |
+
"n_saliency = len(saliency_corrected)\n",
|
| 1090 |
+
"cropped_start = (n_saliency - LOGO_WINDOW_SIZE) // 2\n",
|
| 1091 |
+
"cropped_end = cropped_start + LOGO_WINDOW_SIZE\n",
|
| 1092 |
+
"window_start_bp = saliency_start + cropped_start\n",
|
| 1093 |
+
"window_end_bp = saliency_start + cropped_end\n",
|
| 1094 |
+
"\n",
|
| 1095 |
+
"print(f\"Plotting saliency sequence logo...\")\n",
|
| 1096 |
+
"print(f\" Contribution mode: {CONTRIBUTION_MODE}\")\n",
|
| 1097 |
+
"print(f\" Window: {window_start_bp:,} - {window_end_bp:,} bp\")\n",
|
| 1098 |
+
"\n",
|
| 1099 |
+
"saliency_acgt = saliency_corrected\n",
|
| 1100 |
+
"if CONTRIBUTION_MODE:\n",
|
| 1101 |
+
" saliency_acgt = saliency_acgt * one_hot_acgt\n",
|
| 1102 |
+
"\n",
|
| 1103 |
+
"window_saliency = saliency_acgt[cropped_start:cropped_end]\n",
|
| 1104 |
+
"print(f\" Window size: {len(window_saliency)} positions\")\n",
|
| 1105 |
+
"\n",
|
| 1106 |
+
"logo_processor = FastLogo(\n",
|
| 1107 |
+
" window_saliency[np.newaxis, :, :],\n",
|
| 1108 |
+
" alphabet=['A', 'T', 'C', 'G'],\n",
|
| 1109 |
+
" font_name='DejaVu Sans',\n",
|
| 1110 |
+
" font_weight='bold',\n",
|
| 1111 |
+
" color_scheme=NUCLEOTIDE_COLORS,\n",
|
| 1112 |
+
" fade_below=0.0,\n",
|
| 1113 |
+
" shade_below=0.0,\n",
|
| 1114 |
+
" width=0.9,\n",
|
| 1115 |
+
" figsize=(14, 3),\n",
|
| 1116 |
+
" center_values=not CONTRIBUTION_MODE,\n",
|
| 1117 |
+
" border=False,\n",
|
| 1118 |
+
" fixed_ylim=True,\n",
|
| 1119 |
+
" batch_size=1,\n",
|
| 1120 |
+
")\n",
|
| 1121 |
+
"logo_processor.process_all()\n",
|
| 1122 |
+
"fig, ax = logo_processor.draw_single(0, border=False)\n",
|
| 1123 |
+
"logo_title = f'{chrom}:{window_start_bp:,}-{window_end_bp:,}'\n",
|
| 1124 |
+
"ax.set_title(logo_title, fontsize=11)\n",
|
| 1125 |
+
"\n",
|
| 1126 |
+
"n_positions = len(window_saliency)\n",
|
| 1127 |
+
"tick_positions = [0, n_positions // 4, n_positions // 2,\n",
|
| 1128 |
+
" 3 * n_positions // 4, n_positions - 1]\n",
|
| 1129 |
+
"tick_labels = [f'{window_start_bp + pos:,}' for pos in tick_positions]\n",
|
| 1130 |
+
"ax.set_xticks(tick_positions)\n",
|
| 1131 |
+
"ax.set_xticklabels(tick_labels, fontsize=8)\n",
|
| 1132 |
+
"ax.set_xlabel('Genomic Position', fontsize=9)\n",
|
| 1133 |
+
"\n",
|
| 1134 |
+
"plt.yticks([])\n",
|
| 1135 |
+
"plt.tight_layout()\n",
|
| 1136 |
+
"plt.show()\n",
|
| 1137 |
+
"print(\"\\n✅ Saliency logo plotted\")\n"
|
| 1138 |
+
]
|
| 1139 |
+
},
|
| 1140 |
+
{
|
| 1141 |
+
"cell_type": "markdown",
|
| 1142 |
+
"metadata": {},
|
| 1143 |
+
"source": [
|
| 1144 |
+
"> **How to read this plot.** The x axis is the genomic coordinate on `chrom`, for the central logo window of the saliency sequence. The score is the log of the predicted `saliency_track_id` signal summed over `target_window_bp`, ±`promoter_window_bp` bp (1,024 bp) around the center of the predicted span, not over the whole span. The logo window sits inside it. In contribution mode, each letter is the reference base and its height is the gradient after subtracting the mean over the four bases. A tall letter above the axis means that base raises the predicted signal. A letter below the axis means it lowers it. Saliency ranks positions. It does not predict the effect of a mutation.\n"
|
| 1145 |
+
]
|
| 1146 |
+
},
|
| 1147 |
+
{
|
| 1148 |
+
"cell_type": "markdown",
|
| 1149 |
+
"metadata": {
|
| 1150 |
+
"id": "JF7UOTNHDK7o"
|
| 1151 |
+
},
|
| 1152 |
+
"source": [
|
| 1153 |
+
"## 📝 Summary\n",
|
| 1154 |
+
"\n",
|
| 1155 |
+
"This notebook demonstrated three complementary interpretability modalities of the NTv3 tracks model:\n",
|
| 1156 |
+
"\n",
|
| 1157 |
+
"- ✅ **Functional tracks**: Predicted regulatory activity across thousands of assays\n",
|
| 1158 |
+
"- ✅ **Attention maps**: Long-range information integration patterns\n",
|
| 1159 |
+
"- ✅ **Saliency maps**: Gradient-based sequence attributions\n",
|
| 1160 |
+
"\n",
|
| 1161 |
+
"### 💡 Next Steps\n",
|
| 1162 |
+
"\n",
|
| 1163 |
+
"- Try different genomic regions by changing `chrom`, `center_position`, `sequence_length`\n",
|
| 1164 |
+
"- Try another species by changing `species`; `assembly` must be its entry in `SPECIES_TO_ASSEMBLY`\n",
|
| 1165 |
+
"- Explore different tracks by modifying `tracks_to_plot` and `saliency_track_id`; the ids must be in that assembly's `bigwig_names`\n",
|
| 1166 |
+
"- Compare attention patterns across different layers\n",
|
| 1167 |
+
"- Compute saliency for different track combinations\n",
|
| 1168 |
+
"\n",
|
| 1169 |
+
"### 📚 Additional Resources\n",
|
| 1170 |
+
"\n",
|
| 1171 |
+
"- For track prediction tutorials, see: [01_tracks_prediction.ipynb](https://huggingface.co/spaces/InstaDeepAI/ntv3/blob/main/notebooks_tutorials/01_tracks_prediction.ipynb)\n"
|
| 1172 |
+
]
|
| 1173 |
+
}
|
| 1174 |
+
],
|
| 1175 |
+
"metadata": {
|
| 1176 |
+
"accelerator": "GPU",
|
| 1177 |
+
"colab": {
|
| 1178 |
+
"gpuType": "T4",
|
| 1179 |
+
"machine_shape": "hm",
|
| 1180 |
+
"provenance": []
|
| 1181 |
+
},
|
| 1182 |
+
"kernelspec": {
|
| 1183 |
+
"display_name": "Python 3 (ipykernel)",
|
| 1184 |
+
"language": "python",
|
| 1185 |
+
"name": "python3"
|
| 1186 |
+
},
|
| 1187 |
+
"language_info": {
|
| 1188 |
+
"codemirror_mode": {
|
| 1189 |
+
"name": "ipython",
|
| 1190 |
+
"version": 3
|
| 1191 |
+
},
|
| 1192 |
+
"file_extension": ".py",
|
| 1193 |
+
"mimetype": "text/x-python",
|
| 1194 |
+
"name": "python",
|
| 1195 |
+
"nbconvert_exporter": "python",
|
| 1196 |
+
"pygments_lexer": "ipython3",
|
| 1197 |
+
"version": "3.13.15"
|
| 1198 |
+
}
|
| 1199 |
+
},
|
| 1200 |
+
"nbformat": 4,
|
| 1201 |
+
"nbformat_minor": 0
|
| 1202 |
+
}
|
notebooks_tutorials/05_diffusion_fine_tuning.ipynb
ADDED
|
@@ -0,0 +1,941 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
{
|
| 2 |
+
"cells": [
|
| 3 |
+
{
|
| 4 |
+
"cell_type": "markdown",
|
| 5 |
+
"id": "b55e2d88",
|
| 6 |
+
"metadata": {},
|
| 7 |
+
"source": [
|
| 8 |
+
"# 🧬 Training a discrete diffusion model on biological sequences\n",
|
| 9 |
+
"\n",
|
| 10 |
+
"This notebook demonstrates how to **fine-tune NTv3** for controllable enhancer sequence generation using **Masked Diffusion Language Modeling (MDLM)** ([paper](https://arxiv.org/pdf/2406.07524)).\n",
|
| 11 |
+
"\n",
|
| 12 |
+
"---\n",
|
| 13 |
+
"\n",
|
| 14 |
+
"## 🎯 What You'll Learn\n",
|
| 15 |
+
"\n",
|
| 16 |
+
"This tutorial covers:\n",
|
| 17 |
+
"1. How to extend the post-trained [`InstaDeepAI/NTv3_tracks_1mb`](https://huggingface.co/InstaDeepAI/NTv3_tracks_1mb) model with new conditioning signals\n",
|
| 18 |
+
"2. The fundamentals of Masked Diffusion Language Modeling (MDLM) for DNA generation\n",
|
| 19 |
+
"3. Best practices for training generative DNA models with EMA stabilization\n",
|
| 20 |
+
"\n",
|
| 21 |
+
"---\n",
|
| 22 |
+
"\n",
|
| 23 |
+
"## 🧪 Background: Generative DNA Modeling\n",
|
| 24 |
+
"\n",
|
| 25 |
+
"### What is MDLM?\n",
|
| 26 |
+
"\n",
|
| 27 |
+
"**Masked Diffusion Language Modeling (MDLM)** is a diffusion-based approach adapted for discrete sequences. Unlike continuous diffusion models that add Gaussian noise, MDLM progressively masks tokens:\n",
|
| 28 |
+
"\n",
|
| 29 |
+
"- **Forward process**: Gradually replace tokens with `<mask>` according to a noise schedule\n",
|
| 30 |
+
"- **Reverse process**: Model learns to predict original tokens from masked sequences\n",
|
| 31 |
+
"- **Sampling**: Start from fully masked sequence and iteratively unmask\n",
|
| 32 |
+
"\n",
|
| 33 |
+
"This approach combines the strengths of diffusion models (iterative refinement, stable training) with the discrete nature of DNA sequences.\n",
|
| 34 |
+
"\n",
|
| 35 |
+
"### Why Conditional Generation?\n",
|
| 36 |
+
"\n",
|
| 37 |
+
"We want to generate enhancer sequences with **specific properties**:\n",
|
| 38 |
+
"- **Activity level**: Control how \"strong\" the enhancer should be (low → high activity)\n",
|
| 39 |
+
"- **Sequence context**: Provided by the input sequences, allow the enhancer design to consider promoter-enhancer interaction.\n",
|
| 40 |
+
"\n",
|
| 41 |
+
"By conditioning on these attributes, we can generate enhancers that are both biologically realistic and functionally targeted.\n",
|
| 42 |
+
"\n",
|
| 43 |
+
"---\n",
|
| 44 |
+
"\n",
|
| 45 |
+
"## 📊 Overview\n",
|
| 46 |
+
"\n",
|
| 47 |
+
"We fine-tune **NTv3_tracks** to generate *Drosophila* enhancer sequences conditioned on:\n",
|
| 48 |
+
"\n",
|
| 49 |
+
"| Condition | Source | Training |\n",
|
| 50 |
+
"|-----------|--------|----------|\n",
|
| 51 |
+
"| **Species** | NTv3_tracks species embeddings | Frozen |\n",
|
| 52 |
+
"| **Activity Level** | New embeddings (bins 0-4) | Trainable |\n",
|
| 53 |
+
"\n",
|
| 54 |
+
"The training uses MDLM-style diffusion with:\n",
|
| 55 |
+
"- **Linear noise schedule** for sequence masking (uniform sampling of noise levels)\n",
|
| 56 |
+
"- **Classifier-free guidance** through activity dropout (10% of samples)\n",
|
| 57 |
+
"- **Exponential Moving Average (EMA)** for stable inference\n",
|
| 58 |
+
"\n",
|
| 59 |
+
"[Notebook 06](https://huggingface.co/spaces/InstaDeepAI/ntv3/blob/main/notebooks_tutorials/06_plant_promoter_generation.ipynb) is the other half of this workflow: it generates sequences with a released model trained by exactly this recipe, on a plant promoter dataset.\n",
|
| 60 |
+
"\n",
|
| 61 |
+
"---\n",
|
| 62 |
+
"\n",
|
| 63 |
+
"## 📚 Notebook Structure\n",
|
| 64 |
+
"\n",
|
| 65 |
+
"1. **Setup**: Load data and base model from HuggingFace\n",
|
| 66 |
+
"2. **Model Architecture**: Add activity conditioning to NTv3\n",
|
| 67 |
+
"3. **Training Components**: MDLM masking, loss functions, EMA\n",
|
| 68 |
+
"4. **Training Loop**: Full training pipeline with LR scheduling\n",
|
| 69 |
+
"5. **Dataset**: Prepare enhancer sequences with activity labels\n",
|
| 70 |
+
"6. **Run Training**: Execute training on enhancer dataset\n",
|
| 71 |
+
"\n",
|
| 72 |
+
"---\n",
|
| 73 |
+
"\n",
|
| 74 |
+
"## 💻 Hardware Requirements\n",
|
| 75 |
+
"`NTv3_tracks` has ≈700M parameters, and the whole model is fine-tuned, with an EMA copy of it kept alongside. At the batch size of 32 used here, training peaks at **44.8 GiB of GPU memory**, measured on an H100: it needs an **80 GB GPU** (H100, or A100 80 GB) and does **not** fit a 40 GB card. Lower `batch_size` for a smaller GPU.\n",
|
| 76 |
+
"The hyperparameters are set for a full training run: at ≈0.76 s per step on an H100, 500,000 steps is about **four days**. For a quick demo, lower `num_steps` (and `warmup_steps` and `log_every` with it).\n",
|
| 77 |
+
"\n",
|
| 78 |
+
"Because of that, the cells below ship **without the outputs of a full run**. Only the cheap ones are filled in: the dataset size and the parameter counts. Run the notebook to get the rest.\n",
|
| 79 |
+
"\n",
|
| 80 |
+
"> 📝 **Note for Google Colab users:** This notebook is compatible with Colab! Enable a GPU: Runtime → Change runtime type → GPU.\n",
|
| 81 |
+
"> The Colab T4 is far too small for this: see the memory figure above."
|
| 82 |
+
]
|
| 83 |
+
},
|
| 84 |
+
{
|
| 85 |
+
"cell_type": "markdown",
|
| 86 |
+
"id": "d5df997b",
|
| 87 |
+
"metadata": {},
|
| 88 |
+
"source": [
|
| 89 |
+
"## 0) 📦 Setup\n",
|
| 90 |
+
"\n",
|
| 91 |
+
"First, we install and import the required libraries and authenticate with HuggingFace to access the pre-trained model and dataset.\n",
|
| 92 |
+
"\n",
|
| 93 |
+
"### Dependencies\n",
|
| 94 |
+
"\n",
|
| 95 |
+
"| Library | Purpose |\n",
|
| 96 |
+
"|---------|---------|\n",
|
| 97 |
+
"| `torch` | Deep learning framework |\n",
|
| 98 |
+
"| `transformers` | Load the NTv3_tracks model and tokenizer |\n",
|
| 99 |
+
"| `datasets` | Load the enhancer generation dataset from HuggingFace |\n",
|
| 100 |
+
"| `huggingface_hub` | Authentication for gated models |"
|
| 101 |
+
]
|
| 102 |
+
},
|
| 103 |
+
{
|
| 104 |
+
"cell_type": "code",
|
| 105 |
+
"execution_count": null,
|
| 106 |
+
"id": "51af2d66",
|
| 107 |
+
"metadata": {},
|
| 108 |
+
"outputs": [],
|
| 109 |
+
"source": [
|
| 110 |
+
"# Colab ships torch; reinstalling it there breaks the runtime. Elsewhere, also install torch\n",
|
| 111 |
+
"!pip -q install --upgrade 'transformers>=5.17,<6' datasets"
|
| 112 |
+
]
|
| 113 |
+
},
|
| 114 |
+
{
|
| 115 |
+
"cell_type": "code",
|
| 116 |
+
"execution_count": null,
|
| 117 |
+
"id": "58abcba5",
|
| 118 |
+
"metadata": {},
|
| 119 |
+
"outputs": [],
|
| 120 |
+
"source": [
|
| 121 |
+
"# The NTv3 models are gated: request access once on any model page, then log in.\n",
|
| 122 |
+
"from huggingface_hub import get_token, notebook_login\n",
|
| 123 |
+
"\n",
|
| 124 |
+
"if get_token() is None:\n",
|
| 125 |
+
" notebook_login()"
|
| 126 |
+
]
|
| 127 |
+
},
|
| 128 |
+
{
|
| 129 |
+
"cell_type": "code",
|
| 130 |
+
"execution_count": null,
|
| 131 |
+
"id": "c504c424",
|
| 132 |
+
"metadata": {},
|
| 133 |
+
"outputs": [],
|
| 134 |
+
"source": [
|
| 135 |
+
"import copy\n",
|
| 136 |
+
"from typing import Any\n",
|
| 137 |
+
"\n",
|
| 138 |
+
"import numpy as np\n",
|
| 139 |
+
"import torch\n",
|
| 140 |
+
"import torch.nn as nn\n",
|
| 141 |
+
"import torch.nn.functional as F\n",
|
| 142 |
+
"from datasets import load_dataset\n",
|
| 143 |
+
"from torch.optim import AdamW\n",
|
| 144 |
+
"from torch.optim.lr_scheduler import LambdaLR\n",
|
| 145 |
+
"from torch.utils.data import DataLoader, Dataset\n",
|
| 146 |
+
"from tqdm.auto import tqdm\n",
|
| 147 |
+
"from transformers import AutoConfig, AutoModel, AutoTokenizer, PreTrainedTokenizer"
|
| 148 |
+
]
|
| 149 |
+
},
|
| 150 |
+
{
|
| 151 |
+
"cell_type": "code",
|
| 152 |
+
"execution_count": 4,
|
| 153 |
+
"id": "46692f2e",
|
| 154 |
+
"metadata": {},
|
| 155 |
+
"outputs": [
|
| 156 |
+
{
|
| 157 |
+
"name": "stdout",
|
| 158 |
+
"output_type": "stream",
|
| 159 |
+
"text": [
|
| 160 |
+
"Dataset size: 804592 samples\n"
|
| 161 |
+
]
|
| 162 |
+
}
|
| 163 |
+
],
|
| 164 |
+
"source": [
|
| 165 |
+
"# Load training dataset and tokenizer\n",
|
| 166 |
+
"dataset = load_dataset(\"InstaDeepAI/NTv3_enhancer_generation\", split=\"train\")\n",
|
| 167 |
+
"print(f\"Dataset size: {len(dataset)} samples\")\n",
|
| 168 |
+
"\n",
|
| 169 |
+
"model_name = \"InstaDeepAI/NTv3_tracks_1mb\"\n",
|
| 170 |
+
"tokenizer = AutoTokenizer.from_pretrained(model_name, trust_remote_code=True)"
|
| 171 |
+
]
|
| 172 |
+
},
|
| 173 |
+
{
|
| 174 |
+
"cell_type": "markdown",
|
| 175 |
+
"id": "8797d6f6",
|
| 176 |
+
"metadata": {},
|
| 177 |
+
"source": [
|
| 178 |
+
"### 📊 Understanding the Training Data\n",
|
| 179 |
+
"\n",
|
| 180 |
+
"The dataset `InstaDeepAI/NTv3_enhancer_generation` contains enhancer sequences with measured activity levels from STARR-seq experiments:\n",
|
| 181 |
+
"\n",
|
| 182 |
+
"| Field | Description |\n",
|
| 183 |
+
"|-------|-------------|\n",
|
| 184 |
+
"| `full_sequence` | Complete 4,096 bp construct sequence (promoter + enhancer + reporter) |\n",
|
| 185 |
+
"| `enhancer_sequence` | The 249 bp enhancer inserted in the construct |\n",
|
| 186 |
+
"| `discrete_label` | Activity bin (0-4): 0 = low activity, 4 = high activity |\n",
|
| 187 |
+
"| `activity` | Measured activity, before binning |\n",
|
| 188 |
+
"| `promoter` | Promoter type used (DSCP or RpS12) |\n",
|
| 189 |
+
"\n",
|
| 190 |
+
"**Why Activity Bins?**\n",
|
| 191 |
+
"Continuous activity values are discretized into 5 bins for conditioning. This makes the conditioning signal more robust and enables discrete classifier-free guidance during generation.\n",
|
| 192 |
+
"\n",
|
| 193 |
+
"**Enhancer Region Masking**\n",
|
| 194 |
+
"During training, we only mask the enhancer region (not the promoter/reporter), as this is the region we want the model to learn to generate. The promoter context is provided as input but not predicted.\n",
|
| 195 |
+
"\n",
|
| 196 |
+
"---\n",
|
| 197 |
+
"\n",
|
| 198 |
+
"## 1) 🏗️ Model Architecture\n",
|
| 199 |
+
"\n",
|
| 200 |
+
"### How NTv3 Conditioning Works\n",
|
| 201 |
+
"\n",
|
| 202 |
+
"NTv3 supports **discrete conditioning** through learned embedding tables. For each condition:\n",
|
| 203 |
+
"1. An embedding table maps condition values (e.g., species ID, activity bin) to vectors\n",
|
| 204 |
+
"2. These vectors modulate the model's hidden states through FiLM layers (a learned scale and shift) after its normalization layers\n",
|
| 205 |
+
"3. A prediction head predicts the condition from the sequence\n",
|
| 206 |
+
"\n",
|
| 207 |
+
"`NTv3_tracks` is conditioned on species in its track-prediction head only: its trunk, which the language-model head reads, is not conditioned. To generate, the conditions have to reach that trunk.\n",
|
| 208 |
+
"\n",
|
| 209 |
+
"### Extending with New Conditions\n",
|
| 210 |
+
"\n",
|
| 211 |
+
"We extend the model with a **new activity condition**, and switch on conditioning in the trunk:\n",
|
| 212 |
+
"- **Species** (index 0): Uses the `NTv3_tracks` embeddings → **frozen**\n",
|
| 213 |
+
"- **Activity** (index 1): New embeddings initialized randomly → **trainable**\n",
|
| 214 |
+
"- **Trunk FiLM layers**: New, one per condition, **initialized at zero**, so the extended model starts out predicting exactly what `NTv3_tracks` predicts\n",
|
| 215 |
+
"- **Track head**: Dropped, generation only needs the language-model head\n",
|
| 216 |
+
"\n",
|
| 217 |
+
"All the sequences here are from *Drosophila melanogaster*, so the species condition is constant: its FiLM layers are frozen with it and stay at zero. It is the activity condition the model learns to follow."
|
| 218 |
+
]
|
| 219 |
+
},
|
| 220 |
+
{
|
| 221 |
+
"cell_type": "code",
|
| 222 |
+
"execution_count": null,
|
| 223 |
+
"id": "8fd164f1",
|
| 224 |
+
"metadata": {},
|
| 225 |
+
"outputs": [],
|
| 226 |
+
"source": [
|
| 227 |
+
"def build_generative_config(base_model_name: str, num_activity_bins: int = 5) -> AutoConfig:\n",
|
| 228 |
+
" \"\"\"NTv3_tracks config with an activity condition and FiLM conditioning in the trunk.\"\"\"\n",
|
| 229 |
+
" config = AutoConfig.from_pretrained(base_model_name, trust_remote_code=True)\n",
|
| 230 |
+
" ntv3 = dict(config.ntv3)\n",
|
| 231 |
+
" cond_dim = ntv3[\"species_embed_dim\"]\n",
|
| 232 |
+
" ntv3.update(\n",
|
| 233 |
+
" conditions_names=[\"species\", \"activity\"],\n",
|
| 234 |
+
" conditions_vocab_size=[ntv3[\"conditions_vocab_size\"][0], num_activity_bins],\n",
|
| 235 |
+
" # One FiLM input per condition, which switches trunk conditioning on\n",
|
| 236 |
+
" conditions_dims=[cond_dim, cond_dim],\n",
|
| 237 |
+
" enable_trunk_conditioning=True,\n",
|
| 238 |
+
" # Generation only needs the language-model head\n",
|
| 239 |
+
" number_bigwig_tracks=0,\n",
|
| 240 |
+
" bigwigs_per_file_assembly={},\n",
|
| 241 |
+
" )\n",
|
| 242 |
+
" config.ntv3 = ntv3\n",
|
| 243 |
+
" return config\n",
|
| 244 |
+
"\n",
|
| 245 |
+
"\n",
|
| 246 |
+
"def zero_film_layers(model: nn.Module) -> None:\n",
|
| 247 |
+
" \"\"\"Zeroes every FiLM layer, so the new conditions start as a no-op.\"\"\"\n",
|
| 248 |
+
" for name, module in model.named_modules():\n",
|
| 249 |
+
" if name.endswith(\"modulation_layers\"):\n",
|
| 250 |
+
" for layer in module:\n",
|
| 251 |
+
" nn.init.zeros_(layer.weight)\n",
|
| 252 |
+
" nn.init.zeros_(layer.bias)\n",
|
| 253 |
+
"\n",
|
| 254 |
+
"\n",
|
| 255 |
+
"def build_generative_model(base_model_name: str, num_activity_bins: int = 5) -> nn.Module:\n",
|
| 256 |
+
" \"\"\"NTv3 with activity conditioning, initialised from the NTv3_tracks weights.\"\"\"\n",
|
| 257 |
+
" config = build_generative_config(base_model_name, num_activity_bins)\n",
|
| 258 |
+
" model = AutoModel.from_config(config, trust_remote_code=True)\n",
|
| 259 |
+
" base_model = AutoModel.from_pretrained(base_model_name, trust_remote_code=True)\n",
|
| 260 |
+
" missing, unexpected = model.load_state_dict(base_model.state_dict(), strict=False)\n",
|
| 261 |
+
" del base_model\n",
|
| 262 |
+
"\n",
|
| 263 |
+
" # Only the new conditioning layers are left to initialise, and only the track head is dropped\n",
|
| 264 |
+
" new_layers = (\"modulation_layers\", \"conditions_embed_layers.1.\", \"conditions_heads.1.\")\n",
|
| 265 |
+
" assert all(any(key in k for key in new_layers) for k in missing), missing\n",
|
| 266 |
+
" assert all(k.startswith(\"model.bigwig_head.\") for k in unexpected), unexpected\n",
|
| 267 |
+
" zero_film_layers(model)\n",
|
| 268 |
+
" return model"
|
| 269 |
+
]
|
| 270 |
+
},
|
| 271 |
+
{
|
| 272 |
+
"cell_type": "code",
|
| 273 |
+
"execution_count": 6,
|
| 274 |
+
"id": "6f5143f5",
|
| 275 |
+
"metadata": {},
|
| 276 |
+
"outputs": [
|
| 277 |
+
{
|
| 278 |
+
"name": "stdout",
|
| 279 |
+
"output_type": "stream",
|
| 280 |
+
"text": [
|
| 281 |
+
"Trainable parameters: 709,345,544 / 715,370,033 (99.2%)\n"
|
| 282 |
+
]
|
| 283 |
+
}
|
| 284 |
+
],
|
| 285 |
+
"source": [
|
| 286 |
+
"# Build the generative model and freeze the species conditioning\n",
|
| 287 |
+
"generation_model = build_generative_model(model_name, num_activity_bins=5)\n",
|
| 288 |
+
"generation_config = generation_model.config\n",
|
| 289 |
+
"\n",
|
| 290 |
+
"# Freeze species conditioning layers (index 0) - only train activity (index 1)\n",
|
| 291 |
+
"species_params = (\"modulation_layers.0.\", \"conditions_embed_layers.0.\", \"conditions_heads.0.\")\n",
|
| 292 |
+
"for name, param in generation_model.named_parameters():\n",
|
| 293 |
+
" if any(x in name for x in species_params):\n",
|
| 294 |
+
" param.requires_grad = False\n",
|
| 295 |
+
"\n",
|
| 296 |
+
"trainable_params = sum(p.numel() for p in generation_model.parameters() if p.requires_grad)\n",
|
| 297 |
+
"total_params = sum(p.numel() for p in generation_model.parameters())\n",
|
| 298 |
+
"print(f\"Trainable parameters: {trainable_params:,} / {total_params:,} ({100*trainable_params/total_params:.1f}%)\")"
|
| 299 |
+
]
|
| 300 |
+
},
|
| 301 |
+
{
|
| 302 |
+
"cell_type": "markdown",
|
| 303 |
+
"id": "0bc4e00d",
|
| 304 |
+
"metadata": {},
|
| 305 |
+
"source": [
|
| 306 |
+
"## 2) 🔧 Training Components\n",
|
| 307 |
+
"\n",
|
| 308 |
+
"This section defines the core training components for MDLM training.\n",
|
| 309 |
+
"\n",
|
| 310 |
+
"---\n",
|
| 311 |
+
"\n",
|
| 312 |
+
"### MDLM Training Strategy\n",
|
| 313 |
+
"\n",
|
| 314 |
+
"The model is trained to predict masked tokens using:\n",
|
| 315 |
+
"\n",
|
| 316 |
+
"| Component | Strategy |\n",
|
| 317 |
+
"|-----------|----------|\n",
|
| 318 |
+
"| **Sequence loss** | MDLM-style masking with linear noise schedule, weighted CE loss |\n",
|
| 319 |
+
"| **Activity loss** | Dropout-style masking (10%), CE loss on masked samples only |\n",
|
| 320 |
+
"| **Species** | Frozen (input conditioning only, no loss) |\n",
|
| 321 |
+
"\n",
|
| 322 |
+
"> 💡 **Key insight:** Activity is masked during training so the model learns to predict it from the sequence, also enabling classifier-free guidance at inference. The species logits are not trained here and should not be used.\n",
|
| 323 |
+
"\n",
|
| 324 |
+
"---\n",
|
| 325 |
+
"\n",
|
| 326 |
+
"### The Noise Schedule\n",
|
| 327 |
+
"\n",
|
| 328 |
+
"In MDLM, the **noise level** $t \\in (0, 1]$ determines what fraction of tokens to mask:\n",
|
| 329 |
+
"- $t = 0$: No masking (original sequence)\n",
|
| 330 |
+
"- $t = 1$: All tokens masked\n",
|
| 331 |
+
"\n",
|
| 332 |
+
"During training, we sample $t$ uniformly for each sequence. The loss is weighted by $1/t$ (MDLM weighting) to account for the varying difficulty of different noise levels.\n",
|
| 333 |
+
"\n",
|
| 334 |
+
"### Classifier-Free Guidance (CFG)\n",
|
| 335 |
+
"\n",
|
| 336 |
+
"To enable CFG at inference time, we need the model to work both **with** and **without** the activity condition:\n",
|
| 337 |
+
"- During training: Randomly drop the activity condition with 10% probability\n",
|
| 338 |
+
"- The model learns: $P(\\text{sequence})$ and $P(\\text{sequence} | \\text{activity})$\n",
|
| 339 |
+
"- At inference: Use both predictions to amplify the conditioning signal\n",
|
| 340 |
+
"\n",
|
| 341 |
+
"---\n",
|
| 342 |
+
"\n",
|
| 343 |
+
"### Masking Function\n",
|
| 344 |
+
"\n",
|
| 345 |
+
"The `mask_by_noise_level` function implements the forward diffusion process:"
|
| 346 |
+
]
|
| 347 |
+
},
|
| 348 |
+
{
|
| 349 |
+
"cell_type": "code",
|
| 350 |
+
"execution_count": null,
|
| 351 |
+
"id": "d17ec297",
|
| 352 |
+
"metadata": {},
|
| 353 |
+
"outputs": [],
|
| 354 |
+
"source": [
|
| 355 |
+
"def mask_by_noise_level(\n",
|
| 356 |
+
" tokens: torch.Tensor,\n",
|
| 357 |
+
" noise_level: torch.Tensor,\n",
|
| 358 |
+
" mask_token_id: int,\n",
|
| 359 |
+
" pad_token_id: int,\n",
|
| 360 |
+
" mask_vector: torch.Tensor | None = None,\n",
|
| 361 |
+
") -> tuple[torch.Tensor, torch.Tensor]:\n",
|
| 362 |
+
" \"\"\"\n",
|
| 363 |
+
" Mask tokens with probability = noise_level (MDLM diffusion-style).\n",
|
| 364 |
+
"\n",
|
| 365 |
+
" Returns:\n",
|
| 366 |
+
" masked_tokens: (B, L) with some tokens replaced by mask_token_id\n",
|
| 367 |
+
" targets: (B, L) original tokens where masked, pad_token_id elsewhere\n",
|
| 368 |
+
" \"\"\"\n",
|
| 369 |
+
" B, L = tokens.shape\n",
|
| 370 |
+
"\n",
|
| 371 |
+
" # Mask each position with probability = noise_level\n",
|
| 372 |
+
" mask = torch.rand(B, L, device=tokens.device) < noise_level.view(-1, 1)\n",
|
| 373 |
+
" mask = mask & (tokens != pad_token_id) # Don't mask padding\n",
|
| 374 |
+
"\n",
|
| 375 |
+
" # Only mask positions specified by mask_vector (if provided)\n",
|
| 376 |
+
" if mask_vector is not None:\n",
|
| 377 |
+
" mask = mask & mask_vector\n",
|
| 378 |
+
"\n",
|
| 379 |
+
" masked_tokens = torch.where(mask, mask_token_id, tokens)\n",
|
| 380 |
+
" targets = torch.where(mask, tokens, pad_token_id)\n",
|
| 381 |
+
" return masked_tokens, targets\n",
|
| 382 |
+
"\n",
|
| 383 |
+
"\n",
|
| 384 |
+
"def compute_sequence_loss(\n",
|
| 385 |
+
" logits: torch.Tensor,\n",
|
| 386 |
+
" targets: torch.Tensor,\n",
|
| 387 |
+
" weights: torch.Tensor,\n",
|
| 388 |
+
" pad_token_id: int,\n",
|
| 389 |
+
" input_tokens: torch.Tensor,\n",
|
| 390 |
+
") -> torch.Tensor:\n",
|
| 391 |
+
" \"\"\"MDLM weighted cross-entropy loss, normalized by all non-padding tokens.\"\"\"\n",
|
| 392 |
+
" B, L, V = logits.shape\n",
|
| 393 |
+
" sequence_mask = targets != pad_token_id\n",
|
| 394 |
+
" loss_denominator_mask = input_tokens != pad_token_id\n",
|
| 395 |
+
"\n",
|
| 396 |
+
" ce = F.cross_entropy(\n",
|
| 397 |
+
" logits.reshape(-1, V),\n",
|
| 398 |
+
" targets.reshape(-1),\n",
|
| 399 |
+
" reduction=\"none\",\n",
|
| 400 |
+
" ignore_index=pad_token_id,\n",
|
| 401 |
+
" ).view(B, L)\n",
|
| 402 |
+
"\n",
|
| 403 |
+
" weighted_ce = ce * weights.unsqueeze(1)\n",
|
| 404 |
+
" running_sum_loss = (weighted_ce * sequence_mask).sum(dim=1)\n",
|
| 405 |
+
" per_sample_loss = running_sum_loss / loss_denominator_mask.sum(dim=1).clamp(min=1)\n",
|
| 406 |
+
" return per_sample_loss.mean()\n",
|
| 407 |
+
"\n",
|
| 408 |
+
"\n",
|
| 409 |
+
"def compute_activity_loss(\n",
|
| 410 |
+
" logits: torch.Tensor,\n",
|
| 411 |
+
" targets: torch.Tensor,\n",
|
| 412 |
+
" mask: torch.Tensor,\n",
|
| 413 |
+
") -> torch.Tensor:\n",
|
| 414 |
+
" \"\"\"Cross-entropy loss for activity prediction (only on masked samples).\"\"\"\n",
|
| 415 |
+
" if not mask.any():\n",
|
| 416 |
+
" return torch.tensor(0.0, device=logits.device)\n",
|
| 417 |
+
" return F.cross_entropy(logits[mask], targets[mask])"
|
| 418 |
+
]
|
| 419 |
+
},
|
| 420 |
+
{
|
| 421 |
+
"cell_type": "markdown",
|
| 422 |
+
"id": "c54481cb",
|
| 423 |
+
"metadata": {},
|
| 424 |
+
"source": [
|
| 425 |
+
"### Exponential Moving Average (EMA)\n",
|
| 426 |
+
"\n",
|
| 427 |
+
"EMA maintains a smoothed version of model weights throughout training:\n",
|
| 428 |
+
"\n",
|
| 429 |
+
"$$\\theta_{\\text{EMA}} = \\beta \\cdot \\theta_{\\text{EMA}} + (1 - \\beta) \\cdot \\theta_{\\text{current}}$$\n",
|
| 430 |
+
"\n",
|
| 431 |
+
"where $\\beta = 0.999$ (decay rate).\n",
|
| 432 |
+
"\n",
|
| 433 |
+
"**Why use EMA for generation?**\n",
|
| 434 |
+
"- Training weights can be noisy due to stochastic gradient descent\n",
|
| 435 |
+
"- EMA provides an \"averaged\" model that's more stable for inference\n",
|
| 436 |
+
"- Particularly important for generative models where output quality matters\n",
|
| 437 |
+
"- Adapted from existing literature\n",
|
| 438 |
+
"\n",
|
| 439 |
+
"At inference time, we use the **EMA model** instead of the training model. This has a large impact on the sequence generation quality.\n",
|
| 440 |
+
"\n",
|
| 441 |
+
"NTv3's normalization layers also keep running statistics (buffers, not parameters), updated in training mode. The EMA model copies them from the training model at each step."
|
| 442 |
+
]
|
| 443 |
+
},
|
| 444 |
+
{
|
| 445 |
+
"cell_type": "code",
|
| 446 |
+
"execution_count": null,
|
| 447 |
+
"id": "0d982575",
|
| 448 |
+
"metadata": {},
|
| 449 |
+
"outputs": [],
|
| 450 |
+
"source": [
|
| 451 |
+
"class EMAModel:\n",
|
| 452 |
+
" \"\"\"\n",
|
| 453 |
+
" Exponential Moving Average of model parameters.\n",
|
| 454 |
+
" Formula: ema_param = decay * ema_param + (1 - decay) * current_param\n",
|
| 455 |
+
" \"\"\"\n",
|
| 456 |
+
"\n",
|
| 457 |
+
" def __init__(self, model: nn.Module, decay: float = 0.999):\n",
|
| 458 |
+
" self.decay = decay\n",
|
| 459 |
+
" self.ema_model = copy.deepcopy(model)\n",
|
| 460 |
+
" self.ema_model.eval()\n",
|
| 461 |
+
" for param in self.ema_model.parameters():\n",
|
| 462 |
+
" param.requires_grad = False\n",
|
| 463 |
+
"\n",
|
| 464 |
+
" @torch.no_grad()\n",
|
| 465 |
+
" def update(self, model: nn.Module):\n",
|
| 466 |
+
" \"\"\"Update EMA parameters after each training step.\"\"\"\n",
|
| 467 |
+
" for ema_param, model_param in zip(self.ema_model.parameters(), model.parameters()):\n",
|
| 468 |
+
" ema_param.data.mul_(self.decay).add_(model_param.data, alpha=1.0 - self.decay)\n",
|
| 469 |
+
" # Running normalization statistics are copied, not averaged\n",
|
| 470 |
+
" for ema_buffer, model_buffer in zip(self.ema_model.buffers(), model.buffers()):\n",
|
| 471 |
+
" ema_buffer.copy_(model_buffer)\n",
|
| 472 |
+
"\n",
|
| 473 |
+
" def get_model(self) -> nn.Module:\n",
|
| 474 |
+
" return self.ema_model\n",
|
| 475 |
+
"\n",
|
| 476 |
+
" def state_dict(self) -> dict[str, Any]:\n",
|
| 477 |
+
" return self.ema_model.state_dict()\n",
|
| 478 |
+
"\n",
|
| 479 |
+
" def load_state_dict(self, state_dict: dict[str, Any]):\n",
|
| 480 |
+
" self.ema_model.load_state_dict(state_dict)"
|
| 481 |
+
]
|
| 482 |
+
},
|
| 483 |
+
{
|
| 484 |
+
"cell_type": "markdown",
|
| 485 |
+
"id": "e84a3c8c",
|
| 486 |
+
"metadata": {},
|
| 487 |
+
"source": [
|
| 488 |
+
"### Training Step\n",
|
| 489 |
+
"\n",
|
| 490 |
+
"The `train_step` function implements a single MDLM training iteration:\n",
|
| 491 |
+
"\n",
|
| 492 |
+
"1. **Sample noise level**: Random $t \\sim \\text{Uniform}(\\epsilon, 1)$ for each sequence\n",
|
| 493 |
+
"2. **Mask tokens**: Replace tokens with `<mask>` with probability $t$\n",
|
| 494 |
+
"3. **Apply condition dropout**: Mask activity with 10% probability (for CFG)\n",
|
| 495 |
+
"4. **Forward pass**: Model predicts original tokens and activity\n",
|
| 496 |
+
"5. **Compute losses**: Weighted sequence loss + activity prediction loss\n",
|
| 497 |
+
"6. **Track metrics**: Accuracy for both sequence and activity predictions"
|
| 498 |
+
]
|
| 499 |
+
},
|
| 500 |
+
{
|
| 501 |
+
"cell_type": "code",
|
| 502 |
+
"execution_count": null,
|
| 503 |
+
"id": "61e1b48d",
|
| 504 |
+
"metadata": {},
|
| 505 |
+
"outputs": [],
|
| 506 |
+
"source": [
|
| 507 |
+
"def train_step(\n",
|
| 508 |
+
" model: nn.Module,\n",
|
| 509 |
+
" tokens: torch.Tensor,\n",
|
| 510 |
+
" activity: torch.Tensor,\n",
|
| 511 |
+
" species_ids: torch.Tensor,\n",
|
| 512 |
+
" mask_vector: torch.Tensor | None,\n",
|
| 513 |
+
" mask_token_id: int,\n",
|
| 514 |
+
" pad_token_id: int,\n",
|
| 515 |
+
" activity_masking_prob: float = 0.1,\n",
|
| 516 |
+
" activity_loss_weight: float = 1.0,\n",
|
| 517 |
+
") -> dict[str, Any]:\n",
|
| 518 |
+
" \"\"\"Single MDLM training step with sequence and activity losses.\"\"\"\n",
|
| 519 |
+
" B = tokens.shape[0]\n",
|
| 520 |
+
" device = tokens.device\n",
|
| 521 |
+
"\n",
|
| 522 |
+
" # 1. Sample noise level (linear schedule, avoid t=0 for stability)\n",
|
| 523 |
+
" eps = 0.001\n",
|
| 524 |
+
" t = torch.rand(B, device=device) * (1 - eps) + eps\n",
|
| 525 |
+
" seq_weights = (1.0 / t).clamp(max=100.0) # MDLM weight = 1/t\n",
|
| 526 |
+
"\n",
|
| 527 |
+
" # 2. Mask sequence tokens\n",
|
| 528 |
+
" masked_tokens, seq_targets = mask_by_noise_level(\n",
|
| 529 |
+
" tokens, t, mask_token_id, pad_token_id, mask_vector\n",
|
| 530 |
+
" )\n",
|
| 531 |
+
"\n",
|
| 532 |
+
" # 3. Condition masking (dropout for classifier-free guidance)\n",
|
| 533 |
+
" species_mask = torch.ones(B, dtype=torch.bool, device=device) # Always use species\n",
|
| 534 |
+
" activity_mask = torch.rand(B, device=device) >= activity_masking_prob # 10% dropout\n",
|
| 535 |
+
"\n",
|
| 536 |
+
" # 4. Forward pass\n",
|
| 537 |
+
" outputs = model(\n",
|
| 538 |
+
" masked_tokens,\n",
|
| 539 |
+
" conditions_tokens=[species_ids, activity],\n",
|
| 540 |
+
" conditions_masks=[species_mask, activity_mask],\n",
|
| 541 |
+
" )\n",
|
| 542 |
+
"\n",
|
| 543 |
+
" # 5. Compute losses\n",
|
| 544 |
+
" seq_loss = compute_sequence_loss(\n",
|
| 545 |
+
" outputs[\"logits\"], seq_targets, seq_weights, pad_token_id, tokens\n",
|
| 546 |
+
" )\n",
|
| 547 |
+
" activity_loss = compute_activity_loss(\n",
|
| 548 |
+
" outputs[\"activity_logits\"], activity, ~activity_mask\n",
|
| 549 |
+
" )\n",
|
| 550 |
+
" total_loss = seq_loss + activity_loss_weight * activity_loss\n",
|
| 551 |
+
"\n",
|
| 552 |
+
" # 6. Compute metrics\n",
|
| 553 |
+
" seq_mask = seq_targets != pad_token_id\n",
|
| 554 |
+
" masked_tokens_count = seq_mask.sum().item()\n",
|
| 555 |
+
" total_tokens_count = (tokens != pad_token_id).sum().item()\n",
|
| 556 |
+
" masked_activity_count = (~activity_mask).sum().item()\n",
|
| 557 |
+
"\n",
|
| 558 |
+
" with torch.no_grad():\n",
|
| 559 |
+
" seq_preds = outputs[\"logits\"].argmax(dim=-1)\n",
|
| 560 |
+
" seq_correct = (seq_preds == seq_targets) & seq_mask\n",
|
| 561 |
+
" seq_accuracy = seq_correct.sum().item() / max(1, masked_tokens_count)\n",
|
| 562 |
+
"\n",
|
| 563 |
+
" if masked_activity_count > 0:\n",
|
| 564 |
+
" act_preds = outputs[\"activity_logits\"].argmax(dim=-1)\n",
|
| 565 |
+
" act_correct = (act_preds == activity) & (~activity_mask)\n",
|
| 566 |
+
" act_accuracy = act_correct.sum().item() / masked_activity_count\n",
|
| 567 |
+
" else:\n",
|
| 568 |
+
" act_accuracy = 0.0\n",
|
| 569 |
+
"\n",
|
| 570 |
+
" return {\n",
|
| 571 |
+
" \"loss\": total_loss,\n",
|
| 572 |
+
" \"seq_loss\": seq_loss.item(),\n",
|
| 573 |
+
" \"activity_loss\": activity_loss.item(),\n",
|
| 574 |
+
" \"seq_accuracy\": seq_accuracy,\n",
|
| 575 |
+
" \"act_accuracy\": act_accuracy,\n",
|
| 576 |
+
" \"masked_tokens\": masked_tokens_count,\n",
|
| 577 |
+
" \"total_tokens\": total_tokens_count,\n",
|
| 578 |
+
" \"masked_activity\": masked_activity_count,\n",
|
| 579 |
+
" \"batch_size\": B,\n",
|
| 580 |
+
" }"
|
| 581 |
+
]
|
| 582 |
+
},
|
| 583 |
+
{
|
| 584 |
+
"cell_type": "markdown",
|
| 585 |
+
"id": "b48b24a7",
|
| 586 |
+
"metadata": {},
|
| 587 |
+
"source": [
|
| 588 |
+
"## 3) 🔄 Training Loop\n",
|
| 589 |
+
"\n",
|
| 590 |
+
"This section implements the main training loop with learning rate scheduling.\n",
|
| 591 |
+
"\n",
|
| 592 |
+
"---\n",
|
| 593 |
+
"\n",
|
| 594 |
+
"### Learning Rate Schedule\n",
|
| 595 |
+
"\n",
|
| 596 |
+
"We use a **modified square-root decay** schedule with linear warmup:\n",
|
| 597 |
+
"\n",
|
| 598 |
+
"1. **Warmup phase**: Linear increase from `initial_lr` to `peak_lr`\n",
|
| 599 |
+
"2. **Decay phase**: Power-law decay: $\\text{LR}(t) = \\text{peak\\_lr} \\times \\left(\\frac{\\text{warmup\\_steps}}{t}\\right)^\\alpha$\n",
|
| 600 |
+
"\n",
|
| 601 |
+
"This schedule provides:\n",
|
| 602 |
+
"- Stable early training (warmup prevents large initial updates)\n",
|
| 603 |
+
"- Gradual refinement (decay allows fine-tuning without overshooting)\n",
|
| 604 |
+
"\n",
|
| 605 |
+
"### Training Configuration\n",
|
| 606 |
+
"\n",
|
| 607 |
+
"| Parameter | Recommended Value | Description |\n",
|
| 608 |
+
"|-----------|------------------|-------------|\n",
|
| 609 |
+
"| `num_steps` | 500,000+ | Dataset dependent convergence time |\n",
|
| 610 |
+
"| `lr` | 1e-4 | Peak learning rate |\n",
|
| 611 |
+
"| `warmup_steps` | 25,000 | ≈5% of total steps |\n",
|
| 612 |
+
"| `ema_decay` | 0.999 | Higher = smoother EMA |\n",
|
| 613 |
+
"| `activity_masking_prob` | 0.1 | Activity conditioning dropout rate |"
|
| 614 |
+
]
|
| 615 |
+
},
|
| 616 |
+
{
|
| 617 |
+
"cell_type": "code",
|
| 618 |
+
"execution_count": null,
|
| 619 |
+
"id": "67f9a841",
|
| 620 |
+
"metadata": {},
|
| 621 |
+
"outputs": [],
|
| 622 |
+
"source": [
|
| 623 |
+
"def get_modified_sqrt_decay_scheduler(\n",
|
| 624 |
+
" optimizer: torch.optim.Optimizer,\n",
|
| 625 |
+
" warmup_steps: int,\n",
|
| 626 |
+
" total_steps: int,\n",
|
| 627 |
+
" initial_lr: float,\n",
|
| 628 |
+
" peak_lr: float,\n",
|
| 629 |
+
" final_ratio: float = 0.5,\n",
|
| 630 |
+
") -> LambdaLR:\n",
|
| 631 |
+
" \"\"\"Linear warmup → power-law decay scheduler.\"\"\"\n",
|
| 632 |
+
" alpha = np.log(1 / final_ratio) / np.log(total_steps / max(1, warmup_steps))\n",
|
| 633 |
+
"\n",
|
| 634 |
+
" def lr_lambda(step):\n",
|
| 635 |
+
" if step < warmup_steps:\n",
|
| 636 |
+
" return (initial_lr + (peak_lr - initial_lr) * step / max(1, warmup_steps)) / peak_lr\n",
|
| 637 |
+
" return (warmup_steps / (step + 1)) ** alpha\n",
|
| 638 |
+
"\n",
|
| 639 |
+
" return LambdaLR(optimizer, lr_lambda)\n",
|
| 640 |
+
"\n",
|
| 641 |
+
"\n",
|
| 642 |
+
"def train_mdlm(\n",
|
| 643 |
+
" model: nn.Module,\n",
|
| 644 |
+
" train_dataloader: DataLoader,\n",
|
| 645 |
+
" tokenizer: PreTrainedTokenizer,\n",
|
| 646 |
+
" num_steps: int = 10000,\n",
|
| 647 |
+
" lr: float = 5e-5,\n",
|
| 648 |
+
" initial_lr: float = 0.0,\n",
|
| 649 |
+
" device: str = \"cuda\",\n",
|
| 650 |
+
" activity_masking_prob: float = 0.1,\n",
|
| 651 |
+
" activity_loss_weight: float = 1.0,\n",
|
| 652 |
+
" warmup_steps: int = 0,\n",
|
| 653 |
+
" scheduler_type: str | None = None,\n",
|
| 654 |
+
" final_lr_ratio: float = 0.5,\n",
|
| 655 |
+
" log_every: int = 100,\n",
|
| 656 |
+
" ema_decay: float = 0.999,\n",
|
| 657 |
+
" use_ema: bool = True,\n",
|
| 658 |
+
") -> tuple[nn.Module, nn.Module]:\n",
|
| 659 |
+
" \"\"\"MDLM training loop. Returns (trained_model, ema_model).\"\"\"\n",
|
| 660 |
+
" model = model.to(device)\n",
|
| 661 |
+
" optimizer = AdamW(filter(lambda p: p.requires_grad, model.parameters()), lr=lr)\n",
|
| 662 |
+
"\n",
|
| 663 |
+
" ema = EMAModel(model, decay=ema_decay) if use_ema else None\n",
|
| 664 |
+
" scheduler = get_modified_sqrt_decay_scheduler(\n",
|
| 665 |
+
" optimizer, warmup_steps, num_steps, initial_lr, lr, final_lr_ratio\n",
|
| 666 |
+
" ) if scheduler_type == \"modified_sqrt\" else None\n",
|
| 667 |
+
"\n",
|
| 668 |
+
" mask_token_id = tokenizer.mask_token_id\n",
|
| 669 |
+
" pad_token_id = tokenizer.pad_token_id\n",
|
| 670 |
+
"\n",
|
| 671 |
+
" model.train()\n",
|
| 672 |
+
" train_iter = iter(train_dataloader)\n",
|
| 673 |
+
" metrics = {\"loss\": 0, \"seq\": 0, \"act\": 0, \"seq_acc\": 0, \"act_acc\": 0,\n",
|
| 674 |
+
" \"masked\": 0, \"total\": 0, \"masked_act\": 0, \"batch\": 0}\n",
|
| 675 |
+
"\n",
|
| 676 |
+
" pbar = tqdm(range(1, num_steps + 1), desc=\"Training\")\n",
|
| 677 |
+
" for step in pbar:\n",
|
| 678 |
+
" try:\n",
|
| 679 |
+
" batch = next(train_iter)\n",
|
| 680 |
+
" except StopIteration:\n",
|
| 681 |
+
" train_iter = iter(train_dataloader)\n",
|
| 682 |
+
" batch = next(train_iter)\n",
|
| 683 |
+
"\n",
|
| 684 |
+
" tokens = batch[\"input_ids\"].to(device)\n",
|
| 685 |
+
" species_ids = batch[\"species_ids\"].to(device)\n",
|
| 686 |
+
" activity = batch[\"activity_labels\"].to(device)\n",
|
| 687 |
+
" mask_vector = batch[\"masking_vector\"].to(device)\n",
|
| 688 |
+
"\n",
|
| 689 |
+
" losses = train_step(\n",
|
| 690 |
+
" model, tokens, activity, species_ids, mask_vector,\n",
|
| 691 |
+
" mask_token_id, pad_token_id, activity_masking_prob, activity_loss_weight\n",
|
| 692 |
+
" )\n",
|
| 693 |
+
"\n",
|
| 694 |
+
" optimizer.zero_grad()\n",
|
| 695 |
+
" losses[\"loss\"].backward()\n",
|
| 696 |
+
" optimizer.step()\n",
|
| 697 |
+
"\n",
|
| 698 |
+
" if ema: ema.update(model)\n",
|
| 699 |
+
" if scheduler: scheduler.step()\n",
|
| 700 |
+
"\n",
|
| 701 |
+
" # Track metrics\n",
|
| 702 |
+
" metrics[\"loss\"] += losses[\"loss\"].item()\n",
|
| 703 |
+
" metrics[\"seq\"] += losses[\"seq_loss\"]\n",
|
| 704 |
+
" metrics[\"act\"] += losses[\"activity_loss\"]\n",
|
| 705 |
+
" metrics[\"seq_acc\"] += losses[\"seq_accuracy\"]\n",
|
| 706 |
+
" metrics[\"act_acc\"] += losses[\"act_accuracy\"] * losses[\"masked_activity\"]\n",
|
| 707 |
+
" metrics[\"masked\"] += losses[\"masked_tokens\"]\n",
|
| 708 |
+
" metrics[\"total\"] += losses[\"total_tokens\"]\n",
|
| 709 |
+
" metrics[\"masked_act\"] += losses[\"masked_activity\"]\n",
|
| 710 |
+
" metrics[\"batch\"] += losses[\"batch_size\"]\n",
|
| 711 |
+
"\n",
|
| 712 |
+
" if step % log_every == 0:\n",
|
| 713 |
+
" avg_act_acc = metrics[\"act_acc\"] / max(1, metrics[\"masked_act\"])\n",
|
| 714 |
+
" tqdm.write(\n",
|
| 715 |
+
" f\"Step {step:5d} | loss: {metrics['loss']/log_every:.4f} | \"\n",
|
| 716 |
+
" f\"seq_loss: {metrics['seq']/log_every:.4f} | seq_acc: {metrics['seq_acc']/log_every:.3f} | \"\n",
|
| 717 |
+
" f\"act_loss: {metrics['act']/log_every:.4f} | act_acc: {avg_act_acc:.3f} | \"\n",
|
| 718 |
+
" f\"lr: {optimizer.param_groups[0]['lr']:.2e}\"\n",
|
| 719 |
+
" )\n",
|
| 720 |
+
" metrics = {k: 0 for k in metrics}\n",
|
| 721 |
+
"\n",
|
| 722 |
+
" return (model, ema.get_model()) if use_ema else (model, model)"
|
| 723 |
+
]
|
| 724 |
+
},
|
| 725 |
+
{
|
| 726 |
+
"cell_type": "markdown",
|
| 727 |
+
"id": "263802a8",
|
| 728 |
+
"metadata": {},
|
| 729 |
+
"source": [
|
| 730 |
+
"## 4) 📊 Dataset\n",
|
| 731 |
+
"\n",
|
| 732 |
+
"This section defines the PyTorch Dataset class for loading enhancer sequences.\n",
|
| 733 |
+
"\n",
|
| 734 |
+
"---\n",
|
| 735 |
+
"\n",
|
| 736 |
+
"### Dataset Structure\n",
|
| 737 |
+
"\n",
|
| 738 |
+
"The `EnhancerDataset` class handles:\n",
|
| 739 |
+
"1. **Tokenization**: Convert DNA sequences to token IDs using the NTv3 tokenizer\n",
|
| 740 |
+
"2. **Activity labels**: Extract discrete activity bins (0-4)\n",
|
| 741 |
+
"3. **Masking vectors**: Create binary masks indicating which positions to train on\n",
|
| 742 |
+
"\n",
|
| 743 |
+
"### Enhancer Insertion Positions\n",
|
| 744 |
+
"\n",
|
| 745 |
+
"Different promoter types have the enhancer inserted at different positions:\n",
|
| 746 |
+
"\n",
|
| 747 |
+
"| Promoter | Enhancer Start | Enhancer End | Length |\n",
|
| 748 |
+
"|----------|---------------|--------------|--------|\n",
|
| 749 |
+
"| DSCP | 1018 | 1267 | 249 bp |\n",
|
| 750 |
+
"| RpS12 | 968 | 1217 | 249 bp |\n",
|
| 751 |
+
"\n",
|
| 752 |
+
"The `masking_vector` ensures we only apply MDLM loss to the enhancer region, not the flanking promoter/reporter sequences.\n",
|
| 753 |
+
"\n",
|
| 754 |
+
"The constructs are 4,096 bp long, a multiple of 128 as NTv3 requires, and in upper case: `NTv3_tracks` reads soft-masked (lower-case) bases as different tokens."
|
| 755 |
+
]
|
| 756 |
+
},
|
| 757 |
+
{
|
| 758 |
+
"cell_type": "code",
|
| 759 |
+
"execution_count": null,
|
| 760 |
+
"id": "95e60646",
|
| 761 |
+
"metadata": {},
|
| 762 |
+
"outputs": [],
|
| 763 |
+
"source": [
|
| 764 |
+
"class EnhancerDataset(Dataset):\n",
|
| 765 |
+
" \"\"\"Dataset for enhancer generation training.\"\"\"\n",
|
| 766 |
+
"\n",
|
| 767 |
+
" # Enhancer insertion positions for each promoter type\n",
|
| 768 |
+
" PROMOTER_INSERTIONS = {\n",
|
| 769 |
+
" \"DSCP\": [1018, 1267],\n",
|
| 770 |
+
" \"RpS12\": [968, 1217],\n",
|
| 771 |
+
" }\n",
|
| 772 |
+
"\n",
|
| 773 |
+
" def __init__(self, hf_dataset, tokenizer: PreTrainedTokenizer, species_id: int):\n",
|
| 774 |
+
" self.data = hf_dataset\n",
|
| 775 |
+
" self.tokenizer = tokenizer\n",
|
| 776 |
+
" self.species_id = species_id\n",
|
| 777 |
+
"\n",
|
| 778 |
+
" def __len__(self) -> int:\n",
|
| 779 |
+
" return len(self.data)\n",
|
| 780 |
+
"\n",
|
| 781 |
+
" def __getitem__(self, idx: int) -> dict[str, Any]:\n",
|
| 782 |
+
" item = self.data[idx]\n",
|
| 783 |
+
"\n",
|
| 784 |
+
" # Tokenize sequence\n",
|
| 785 |
+
" tokens = self.tokenizer(\n",
|
| 786 |
+
" item[\"full_sequence\"], add_special_tokens=False, return_tensors=\"pt\"\n",
|
| 787 |
+
" )[\"input_ids\"].squeeze(0)\n",
|
| 788 |
+
"\n",
|
| 789 |
+
" # Activity label (0-4)\n",
|
| 790 |
+
" activity = torch.tensor(item.get(\"discrete_label\", 0), dtype=torch.long)\n",
|
| 791 |
+
"\n",
|
| 792 |
+
" # Create masking vector for enhancer region only\n",
|
| 793 |
+
" promoter_type = item.get(\"promoter\")\n",
|
| 794 |
+
" mask_interval = self.PROMOTER_INSERTIONS[promoter_type]\n",
|
| 795 |
+
" masking_vector = torch.zeros(len(tokens), dtype=torch.bool)\n",
|
| 796 |
+
" masking_vector[mask_interval[0]:mask_interval[1]] = True\n",
|
| 797 |
+
"\n",
|
| 798 |
+
" return {\n",
|
| 799 |
+
" \"input_ids\": tokens,\n",
|
| 800 |
+
" \"species_ids\": self.species_id,\n",
|
| 801 |
+
" \"activity_labels\": activity,\n",
|
| 802 |
+
" \"masking_vector\": masking_vector,\n",
|
| 803 |
+
" }"
|
| 804 |
+
]
|
| 805 |
+
},
|
| 806 |
+
{
|
| 807 |
+
"cell_type": "markdown",
|
| 808 |
+
"id": "e119379f",
|
| 809 |
+
"metadata": {},
|
| 810 |
+
"source": [
|
| 811 |
+
"## 5) 🚀 Run Training\n",
|
| 812 |
+
"\n",
|
| 813 |
+
"Now we instantiate the dataset and run the training loop.\n",
|
| 814 |
+
"\n",
|
| 815 |
+
"---\n",
|
| 816 |
+
"\n",
|
| 817 |
+
"### Training Parameters\n",
|
| 818 |
+
"\n",
|
| 819 |
+
"The configuration below is set for a **full training run**. For a quick demo, decrease `num_steps` and adjust the batch size to your GPU memory:"
|
| 820 |
+
]
|
| 821 |
+
},
|
| 822 |
+
{
|
| 823 |
+
"cell_type": "code",
|
| 824 |
+
"execution_count": 12,
|
| 825 |
+
"id": "41f2a2ea",
|
| 826 |
+
"metadata": {},
|
| 827 |
+
"outputs": [
|
| 828 |
+
{
|
| 829 |
+
"name": "stdout",
|
| 830 |
+
"output_type": "stream",
|
| 831 |
+
"text": [
|
| 832 |
+
"Training on 804592 samples\n"
|
| 833 |
+
]
|
| 834 |
+
}
|
| 835 |
+
],
|
| 836 |
+
"source": [
|
| 837 |
+
"# Create dataset and dataloader\n",
|
| 838 |
+
"species_id = generation_config.track_metadata[\"species_vocabulary\"][\"drosophila\"]\n",
|
| 839 |
+
"train_dataset = EnhancerDataset(dataset, tokenizer, species_id)\n",
|
| 840 |
+
"train_loader = DataLoader(train_dataset, batch_size=32, shuffle=True, num_workers=0)\n",
|
| 841 |
+
"\n",
|
| 842 |
+
"print(f\"Training on {len(train_dataset)} samples\")"
|
| 843 |
+
]
|
| 844 |
+
},
|
| 845 |
+
{
|
| 846 |
+
"cell_type": "markdown",
|
| 847 |
+
"id": "44497419",
|
| 848 |
+
"metadata": {},
|
| 849 |
+
"source": [
|
| 850 |
+
"### Understanding Training Metrics\n",
|
| 851 |
+
"\n",
|
| 852 |
+
"During training, monitor these metrics:\n",
|
| 853 |
+
"\n",
|
| 854 |
+
"| Metric | What to Watch |\n",
|
| 855 |
+
"|--------|---------------|\n",
|
| 856 |
+
"| `loss` | Total loss (seq + activity) |\n",
|
| 857 |
+
"| `seq_loss` | Sequence prediction loss |\n",
|
| 858 |
+
"| `seq_acc` | Token prediction accuracy |\n",
|
| 859 |
+
"| `act_loss` | Activity prediction loss |\n",
|
| 860 |
+
"| `act_acc` | Activity classification accuracy |\n",
|
| 861 |
+
"\n",
|
| 862 |
+
"**Interpreting Results:**\n",
|
| 863 |
+
"- **seq_acc** measures how well the model predicts masked DNA tokens\n",
|
| 864 |
+
"- **act_acc** measures whether the model learns the activity-sequence relationship. Read it against the **majority-class baseline, not against zero**: bin 1 holds ≈52% of this dataset, so an `act_acc` at or below ≈0.52 means the head has not learned anything yet. Early in training that is what you should expect to see.\n",
|
| 865 |
+
"- The activity conditioning is what makes generation steerable, so it is the metric to watch over a long run"
|
| 866 |
+
]
|
| 867 |
+
},
|
| 868 |
+
{
|
| 869 |
+
"cell_type": "code",
|
| 870 |
+
"execution_count": null,
|
| 871 |
+
"id": "6098e7ca",
|
| 872 |
+
"metadata": {},
|
| 873 |
+
"outputs": [],
|
| 874 |
+
"source": [
|
| 875 |
+
"# Train the model\n",
|
| 876 |
+
"device = \"cuda\" if torch.cuda.is_available() else \"cpu\"\n",
|
| 877 |
+
"print(f\"Training on: {device}\")\n",
|
| 878 |
+
"\n",
|
| 879 |
+
"trained_model, ema_model = train_mdlm(\n",
|
| 880 |
+
" model=generation_model,\n",
|
| 881 |
+
" train_dataloader=train_loader,\n",
|
| 882 |
+
" tokenizer=tokenizer,\n",
|
| 883 |
+
" num_steps=500_000, # Decrease for a quick demo\n",
|
| 884 |
+
" lr=1e-4, # Peak LR\n",
|
| 885 |
+
" initial_lr=5e-5, # Starting LR for warmup\n",
|
| 886 |
+
" device=device,\n",
|
| 887 |
+
" activity_masking_prob=0.1, # 10% activity dropout for CFG\n",
|
| 888 |
+
" activity_loss_weight=0.1,\n",
|
| 889 |
+
" warmup_steps=25_000, # Decrease with num_steps\n",
|
| 890 |
+
" scheduler_type=\"modified_sqrt\",\n",
|
| 891 |
+
" final_lr_ratio=0.5,\n",
|
| 892 |
+
" log_every=2000, # Decrease for a quick demo\n",
|
| 893 |
+
" ema_decay=0.999,\n",
|
| 894 |
+
" use_ema=True,\n",
|
| 895 |
+
")\n",
|
| 896 |
+
"\n",
|
| 897 |
+
"# Use EMA model for generation (smoother, more stable)\n",
|
| 898 |
+
"generation_model = ema_model\n",
|
| 899 |
+
"print(\"Training complete!\")"
|
| 900 |
+
]
|
| 901 |
+
},
|
| 902 |
+
{
|
| 903 |
+
"cell_type": "markdown",
|
| 904 |
+
"id": "98df6da7",
|
| 905 |
+
"metadata": {},
|
| 906 |
+
"source": [
|
| 907 |
+
"### Saving Checkpoints\n",
|
| 908 |
+
"\n",
|
| 909 |
+
"After training, save both the training model and the EMA model. **Use the EMA model for generation** as it produces smoother, more stable outputs.\n",
|
| 910 |
+
"\n",
|
| 911 |
+
"To generate with the model you just trained, take the sampler of [notebook 06](https://huggingface.co/spaces/InstaDeepAI/ntv3/blob/main/notebooks_tutorials/06_plant_promoter_generation.ipynb) and point it at `ntv3_generative_finetuned_ema.pt`, with this notebook's conditions and the enhancer construct in place of the plant promoter one."
|
| 912 |
+
]
|
| 913 |
+
},
|
| 914 |
+
{
|
| 915 |
+
"cell_type": "code",
|
| 916 |
+
"execution_count": null,
|
| 917 |
+
"id": "d8e7ab96",
|
| 918 |
+
"metadata": {},
|
| 919 |
+
"outputs": [],
|
| 920 |
+
"source": [
|
| 921 |
+
"# Save checkpoints\n",
|
| 922 |
+
"torch.save(trained_model.state_dict(), \"ntv3_generative_finetuned.pt\")\n",
|
| 923 |
+
"torch.save(ema_model.state_dict(), \"ntv3_generative_finetuned_ema.pt\")\n",
|
| 924 |
+
"print(\"Checkpoints saved!\")"
|
| 925 |
+
]
|
| 926 |
+
}
|
| 927 |
+
],
|
| 928 |
+
"metadata": {
|
| 929 |
+
"accelerator": "GPU",
|
| 930 |
+
"kernelspec": {
|
| 931 |
+
"display_name": "Python 3",
|
| 932 |
+
"language": "python",
|
| 933 |
+
"name": "python3"
|
| 934 |
+
},
|
| 935 |
+
"language_info": {
|
| 936 |
+
"name": "python"
|
| 937 |
+
}
|
| 938 |
+
},
|
| 939 |
+
"nbformat": 4,
|
| 940 |
+
"nbformat_minor": 5
|
| 941 |
+
}
|
notebooks_tutorials/05_model_interpretation.ipynb
DELETED
|
The diff for this file is too large to render.
See raw diff
|
|
|
notebooks_tutorials/06_NTv3_generative_training.ipynb
DELETED
|
The diff for this file is too large to render.
See raw diff
|
|
|
notebooks_tutorials/06_plant_promoter_generation.ipynb
ADDED
|
The diff for this file is too large to render.
See raw diff
|
|
|
notebooks_tutorials/07_enhancer_generation.ipynb
DELETED
|
The diff for this file is too large to render.
See raw diff
|
|
|
src/genome_annotation/__init__.py
ADDED
|
@@ -0,0 +1 @@
|
|
|
|
|
|
|
| 1 |
+
"""Helpers of the NTv3 genome-annotation tutorial notebook."""
|
src/genome_annotation/analysis.py
ADDED
|
@@ -0,0 +1,594 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""Error analyses of a predicted annotation, as in the NTv3 annotation paper.
|
| 2 |
+
|
| 3 |
+
Everything works on ``evaluation.ScoringSets``, i.e. after the benchmark filters.
|
| 4 |
+
A locus is one reference gene; a prediction overlaps it when the two share coding
|
| 5 |
+
sequence on the same strand (a prediction inside an intron does not).
|
| 6 |
+
"""
|
| 7 |
+
|
| 8 |
+
import bisect
|
| 9 |
+
from collections import defaultdict
|
| 10 |
+
from collections.abc import Iterable
|
| 11 |
+
from dataclasses import dataclass
|
| 12 |
+
|
| 13 |
+
from .evaluation import CdsKey, Gene
|
| 14 |
+
|
| 15 |
+
LOCUS_CLASSES = ("correct", "boundary", "missed", "fragmented")
|
| 16 |
+
EXON_CLASSES = ("exact", "shifted_one", "shifted_both", "missed")
|
| 17 |
+
POSITIONS = ("first", "internal", "last", "single")
|
| 18 |
+
|
| 19 |
+
# Inclusive bins unless noted.
|
| 20 |
+
EXON_COUNT_BINS = ((1, 1), (2, 3), (4, 6), (7, 10), (11, 20), (21, 10**9))
|
| 21 |
+
SPAN_BINS = ( # half-open, bp of CDS envelope
|
| 22 |
+
(0, 5_000),
|
| 23 |
+
(5_000, 20_000),
|
| 24 |
+
(20_000, 50_000),
|
| 25 |
+
(50_000, 131_072),
|
| 26 |
+
(131_072, 500_000),
|
| 27 |
+
(500_000, 10**12),
|
| 28 |
+
)
|
| 29 |
+
EXON_LENGTH_BINS = (
|
| 30 |
+
(1, 50),
|
| 31 |
+
(51, 100),
|
| 32 |
+
(101, 200),
|
| 33 |
+
(201, 500),
|
| 34 |
+
(501, 1_000),
|
| 35 |
+
(1_001, 10**9),
|
| 36 |
+
)
|
| 37 |
+
WRONG_EXON_BUCKETS = ((0, 0), (1, 1), (2, 2), (3, 3), (4, 5), (6, 10**9))
|
| 38 |
+
OFFSET_BINS = (
|
| 39 |
+
(1, 3),
|
| 40 |
+
(4, 6),
|
| 41 |
+
(7, 12),
|
| 42 |
+
(13, 25),
|
| 43 |
+
(26, 100),
|
| 44 |
+
(101, 1_000),
|
| 45 |
+
(1_001, 10**9),
|
| 46 |
+
)
|
| 47 |
+
MERGE_SIZE_BINS = ((2, 2), (3, 3), (4, 5), (6, 10**9))
|
| 48 |
+
|
| 49 |
+
|
| 50 |
+
def bin_label(lo: int, hi: int) -> str:
|
| 51 |
+
"""``"4-6"``, ``"1"``, or ``"21+"`` for the open-ended bin."""
|
| 52 |
+
if lo == hi:
|
| 53 |
+
return str(lo)
|
| 54 |
+
return f"{lo}+" if hi >= 10**9 else f"{lo}-{hi}"
|
| 55 |
+
|
| 56 |
+
|
| 57 |
+
def _bin_index(value: int, bins: tuple[tuple[int, int], ...]) -> int:
|
| 58 |
+
return next(
|
| 59 |
+
(i for i, (lo, hi) in enumerate(bins) if lo <= value <= hi), len(bins) - 1
|
| 60 |
+
)
|
| 61 |
+
|
| 62 |
+
|
| 63 |
+
def _half_open_bin_index(value: int, bins: tuple[tuple[int, int], ...]) -> int:
|
| 64 |
+
return next(
|
| 65 |
+
(i for i, (lo, hi) in enumerate(bins) if lo <= value < hi), len(bins) - 1
|
| 66 |
+
)
|
| 67 |
+
|
| 68 |
+
|
| 69 |
+
def cds_span(gene: Gene) -> tuple[int, int]:
|
| 70 |
+
"""CDS envelope; the gene row would include UTRs the predictions lack."""
|
| 71 |
+
return min(k[1] for k in gene.cds_keys), max(k[2] for k in gene.cds_keys)
|
| 72 |
+
|
| 73 |
+
|
| 74 |
+
def _boundary(key: CdsKey) -> tuple[str, int, int, str]:
|
| 75 |
+
return key[:4]
|
| 76 |
+
|
| 77 |
+
|
| 78 |
+
def _ordered_cds(gene: Gene) -> list[CdsKey]:
|
| 79 |
+
"""CDS in transcription order, 5' to 3'."""
|
| 80 |
+
return sorted(gene.cds_keys, key=lambda k: k[1], reverse=gene.strand == "-")
|
| 81 |
+
|
| 82 |
+
|
| 83 |
+
def _position(index: int, total: int) -> str:
|
| 84 |
+
if total == 1:
|
| 85 |
+
return "single"
|
| 86 |
+
if index == 0:
|
| 87 |
+
return "first"
|
| 88 |
+
return "last" if index == total - 1 else "internal"
|
| 89 |
+
|
| 90 |
+
|
| 91 |
+
class CdsIndex:
|
| 92 |
+
"""Same-strand overlap lookup over individual CDS, returning gene IDs."""
|
| 93 |
+
|
| 94 |
+
def __init__(self, genes: dict[str, Gene]) -> None:
|
| 95 |
+
buckets: dict[tuple[str, str], list[tuple[int, int, str]]] = defaultdict(list)
|
| 96 |
+
for gid, gene in genes.items():
|
| 97 |
+
for _, start, end, strand, _ in gene.cds_keys:
|
| 98 |
+
buckets[(gene.chrom, strand)].append((start, end, gid))
|
| 99 |
+
self._items: dict[tuple[str, str], list[tuple[int, int, str]]] = {}
|
| 100 |
+
self._starts: dict[tuple[str, str], list[int]] = {}
|
| 101 |
+
self._max_end: dict[tuple[str, str], list[int]] = {}
|
| 102 |
+
for key, items in buckets.items():
|
| 103 |
+
items.sort()
|
| 104 |
+
self._items[key] = items
|
| 105 |
+
self._starts[key] = [item[0] for item in items]
|
| 106 |
+
running, best = [], 0
|
| 107 |
+
for _, end, _ in items:
|
| 108 |
+
best = max(best, end)
|
| 109 |
+
running.append(best)
|
| 110 |
+
self._max_end[key] = running
|
| 111 |
+
|
| 112 |
+
def overlapping(self, chrom: str, strand: str, start: int, end: int) -> set[str]:
|
| 113 |
+
"""IDs of genes with a CDS overlapping [start, end] on this strand."""
|
| 114 |
+
items = self._items.get((chrom, strand))
|
| 115 |
+
if items is None:
|
| 116 |
+
return set()
|
| 117 |
+
max_end = self._max_end[(chrom, strand)]
|
| 118 |
+
hits: set[str] = set()
|
| 119 |
+
for i in range(
|
| 120 |
+
bisect.bisect_right(self._starts[(chrom, strand)], end) - 1, -1, -1
|
| 121 |
+
):
|
| 122 |
+
if max_end[i] < start:
|
| 123 |
+
break
|
| 124 |
+
if items[i][1] >= start:
|
| 125 |
+
hits.add(items[i][2])
|
| 126 |
+
return hits
|
| 127 |
+
|
| 128 |
+
def overlapping_gene(self, gene: Gene) -> set[str]:
|
| 129 |
+
"""IDs of indexed genes sharing coding sequence with ``gene``."""
|
| 130 |
+
hits: set[str] = set()
|
| 131 |
+
for _, start, end, strand, _ in gene.cds_keys:
|
| 132 |
+
hits |= self.overlapping(gene.chrom, strand, start, end)
|
| 133 |
+
return hits
|
| 134 |
+
|
| 135 |
+
|
| 136 |
+
@dataclass(frozen=True)
|
| 137 |
+
class LocusOutcome:
|
| 138 |
+
"""One reference gene, classified against the predictions."""
|
| 139 |
+
|
| 140 |
+
gene_id: str
|
| 141 |
+
chrom: str
|
| 142 |
+
strand: str
|
| 143 |
+
start: int
|
| 144 |
+
end: int
|
| 145 |
+
n_cds: int
|
| 146 |
+
cds_length: int
|
| 147 |
+
outcome: str
|
| 148 |
+
n_overlapping: int
|
| 149 |
+
|
| 150 |
+
|
| 151 |
+
def classify_loci(
|
| 152 |
+
ref_genes: dict[str, Gene], pred_genes: dict[str, Gene]
|
| 153 |
+
) -> tuple[list[LocusOutcome], list[str]]:
|
| 154 |
+
"""Classifies every reference locus; also returns the invented predictions.
|
| 155 |
+
|
| 156 |
+
``correct``: its exact CDS set is predicted. Otherwise by the number of
|
| 157 |
+
overlapping predictions: 0 ``missed``, 1 ``boundary``, 2+ ``fragmented``.
|
| 158 |
+
Invented: a prediction overlapping no reference gene.
|
| 159 |
+
"""
|
| 160 |
+
pred_structures = {frozenset(g.cds_keys) for g in pred_genes.values()}
|
| 161 |
+
pred_index, ref_index = CdsIndex(pred_genes), CdsIndex(ref_genes)
|
| 162 |
+
outcomes = []
|
| 163 |
+
for gid, gene in ref_genes.items():
|
| 164 |
+
if not gene.cds_keys:
|
| 165 |
+
continue
|
| 166 |
+
hits = pred_index.overlapping_gene(gene)
|
| 167 |
+
if frozenset(gene.cds_keys) in pred_structures:
|
| 168 |
+
outcome = "correct"
|
| 169 |
+
else:
|
| 170 |
+
outcome = (
|
| 171 |
+
("missed", "boundary")[len(hits)] if len(hits) < 2 else "fragmented"
|
| 172 |
+
)
|
| 173 |
+
outcomes.append(
|
| 174 |
+
LocusOutcome(
|
| 175 |
+
gid,
|
| 176 |
+
gene.chrom,
|
| 177 |
+
gene.strand,
|
| 178 |
+
*cds_span(gene),
|
| 179 |
+
len(gene.cds_keys),
|
| 180 |
+
sum(k[2] - k[1] + 1 for k in gene.cds_keys),
|
| 181 |
+
outcome,
|
| 182 |
+
len(hits),
|
| 183 |
+
)
|
| 184 |
+
)
|
| 185 |
+
invented = [
|
| 186 |
+
gid
|
| 187 |
+
for gid, gene in pred_genes.items()
|
| 188 |
+
if gene.cds_keys and not ref_index.overlapping_gene(gene)
|
| 189 |
+
]
|
| 190 |
+
return outcomes, invented
|
| 191 |
+
|
| 192 |
+
|
| 193 |
+
def summarise_loci(
|
| 194 |
+
outcomes: list[LocusOutcome], invented: list[str], n_pred: int
|
| 195 |
+
) -> dict[str, float]:
|
| 196 |
+
"""Counts per class; precision here is correct loci over predictions."""
|
| 197 |
+
counts = dict.fromkeys(LOCUS_CLASSES, 0)
|
| 198 |
+
for outcome in outcomes:
|
| 199 |
+
counts[outcome.outcome] += 1
|
| 200 |
+
n_loci = len(outcomes)
|
| 201 |
+
recall = 100.0 * counts["correct"] / n_loci if n_loci else 0.0
|
| 202 |
+
precision = 100.0 * counts["correct"] / n_pred if n_pred else 0.0
|
| 203 |
+
total = recall + precision
|
| 204 |
+
return {
|
| 205 |
+
**counts,
|
| 206 |
+
"n_loci": n_loci,
|
| 207 |
+
"n_pred": n_pred,
|
| 208 |
+
"invented": len(invented),
|
| 209 |
+
"recall": recall,
|
| 210 |
+
"precision": precision,
|
| 211 |
+
"gene_f1": 2 * recall * precision / total if total else 0.0,
|
| 212 |
+
}
|
| 213 |
+
|
| 214 |
+
|
| 215 |
+
def recovery_by_covariate(outcomes: list[LocusOutcome]) -> dict[str, object]:
|
| 216 |
+
"""Loci and outcomes per exon-count and CDS-span bin."""
|
| 217 |
+
exon_total = [0] * len(EXON_COUNT_BINS)
|
| 218 |
+
exon_correct = [0] * len(EXON_COUNT_BINS)
|
| 219 |
+
span_total = [0] * len(SPAN_BINS)
|
| 220 |
+
span_correct = [0] * len(SPAN_BINS)
|
| 221 |
+
span_outcome = {cls: [0] * len(SPAN_BINS) for cls in LOCUS_CLASSES}
|
| 222 |
+
for outcome in outcomes:
|
| 223 |
+
i = _bin_index(outcome.n_cds, EXON_COUNT_BINS)
|
| 224 |
+
j = _half_open_bin_index(outcome.end - outcome.start + 1, SPAN_BINS)
|
| 225 |
+
exon_total[i] += 1
|
| 226 |
+
span_total[j] += 1
|
| 227 |
+
span_outcome[outcome.outcome][j] += 1
|
| 228 |
+
if outcome.outcome == "correct":
|
| 229 |
+
exon_correct[i] += 1
|
| 230 |
+
span_correct[j] += 1
|
| 231 |
+
return {
|
| 232 |
+
"exon_count_bins": [bin_label(lo, hi) for lo, hi in EXON_COUNT_BINS],
|
| 233 |
+
"exon_total": exon_total,
|
| 234 |
+
"exon_correct": exon_correct,
|
| 235 |
+
"span_bins": [f"{lo}-{hi}" for lo, hi in SPAN_BINS],
|
| 236 |
+
"span_total": span_total,
|
| 237 |
+
"span_correct": span_correct,
|
| 238 |
+
"span_outcome": span_outcome,
|
| 239 |
+
}
|
| 240 |
+
|
| 241 |
+
|
| 242 |
+
def _overlapping_cds(
|
| 243 |
+
pred_genes: dict[str, Gene],
|
| 244 |
+
pred_index: CdsIndex,
|
| 245 |
+
chrom: str,
|
| 246 |
+
strand: str,
|
| 247 |
+
start: int,
|
| 248 |
+
end: int,
|
| 249 |
+
) -> list[CdsKey]:
|
| 250 |
+
return [
|
| 251 |
+
k
|
| 252 |
+
for gid in sorted(pred_index.overlapping(chrom, strand, start, end))
|
| 253 |
+
for k in pred_genes[gid].cds_keys
|
| 254 |
+
if k[0] == chrom and k[3] == strand and k[1] <= end and k[2] >= start
|
| 255 |
+
]
|
| 256 |
+
|
| 257 |
+
|
| 258 |
+
def _best_overlap(candidates: list[CdsKey], start: int, end: int) -> CdsKey:
|
| 259 |
+
"""Largest overlap; ties broken by coordinate so the result is deterministic."""
|
| 260 |
+
return max(
|
| 261 |
+
candidates, key=lambda k: (min(end, k[2]) - max(start, k[1]), -k[1], -k[2])
|
| 262 |
+
)
|
| 263 |
+
|
| 264 |
+
|
| 265 |
+
def exon_taxonomy(
|
| 266 |
+
ref_genes: dict[str, Gene],
|
| 267 |
+
pred_genes: dict[str, Gene],
|
| 268 |
+
locus_outcome: dict[str, str],
|
| 269 |
+
) -> dict[str, object]:
|
| 270 |
+
"""Classifies every reference CDS (phase ignored), by position and length.
|
| 271 |
+
|
| 272 |
+
``exact``: some predicted CDS has both boundaries. Otherwise the most-overlapping
|
| 273 |
+
same-strand predicted CDS has one (``shifted_one``) or both (``shifted_both``)
|
| 274 |
+
boundaries wrong, or nothing overlaps (``missed``).
|
| 275 |
+
"""
|
| 276 |
+
pred_index = CdsIndex(pred_genes)
|
| 277 |
+
pred_boundaries = {_boundary(k) for g in pred_genes.values() for k in g.cds_keys}
|
| 278 |
+
by_position = {pos: dict.fromkeys(EXON_CLASSES, 0) for pos in POSITIONS}
|
| 279 |
+
by_length = [dict.fromkeys(EXON_CLASSES, 0) for _ in EXON_LENGTH_BINS]
|
| 280 |
+
ends = dict.fromkeys(("five_prime", "three_prime", "both"), 0)
|
| 281 |
+
wrong_per_failed_gene = [0] * len(WRONG_EXON_BUCKETS)
|
| 282 |
+
n_ref_cds = 0
|
| 283 |
+
|
| 284 |
+
for gid, gene in ref_genes.items():
|
| 285 |
+
ordered = _ordered_cds(gene)
|
| 286 |
+
wrong = 0
|
| 287 |
+
for index, key in enumerate(ordered):
|
| 288 |
+
chrom, start, end, strand, _ = key
|
| 289 |
+
n_ref_cds += 1
|
| 290 |
+
position = _position(index, len(ordered))
|
| 291 |
+
length_bin = by_length[_bin_index(end - start + 1, EXON_LENGTH_BINS)]
|
| 292 |
+
if _boundary(key) in pred_boundaries:
|
| 293 |
+
klass = "exact"
|
| 294 |
+
else:
|
| 295 |
+
wrong += 1
|
| 296 |
+
candidates = _overlapping_cds(
|
| 297 |
+
pred_genes, pred_index, chrom, strand, start, end
|
| 298 |
+
)
|
| 299 |
+
if not candidates:
|
| 300 |
+
klass = "missed"
|
| 301 |
+
else:
|
| 302 |
+
best = _best_overlap(candidates, start, end)
|
| 303 |
+
start_wrong, end_wrong = best[1] != start, best[2] != end
|
| 304 |
+
klass = (
|
| 305 |
+
"shifted_both" if start_wrong and end_wrong else "shifted_one"
|
| 306 |
+
)
|
| 307 |
+
# Transcript orientation: a minus-strand start is the 3' end.
|
| 308 |
+
if start_wrong and end_wrong:
|
| 309 |
+
ends["both"] += 1
|
| 310 |
+
elif start_wrong:
|
| 311 |
+
ends["five_prime" if strand == "+" else "three_prime"] += 1
|
| 312 |
+
else:
|
| 313 |
+
ends["three_prime" if strand == "+" else "five_prime"] += 1
|
| 314 |
+
by_position[position][klass] += 1
|
| 315 |
+
length_bin[klass] += 1
|
| 316 |
+
if locus_outcome.get(gid) != "correct":
|
| 317 |
+
wrong_per_failed_gene[_bin_index(wrong, WRONG_EXON_BUCKETS)] += 1
|
| 318 |
+
|
| 319 |
+
ref_boundaries = {_boundary(k) for g in ref_genes.values() for k in g.cds_keys}
|
| 320 |
+
ref_index = CdsIndex(ref_genes)
|
| 321 |
+
pred_keys = [k for g in pred_genes.values() for k in g.cds_keys]
|
| 322 |
+
invented = sum(
|
| 323 |
+
1
|
| 324 |
+
for k in pred_keys
|
| 325 |
+
if _boundary(k) not in ref_boundaries
|
| 326 |
+
and not ref_index.overlapping(k[0], k[3], k[1], k[2])
|
| 327 |
+
)
|
| 328 |
+
totals = {
|
| 329 |
+
klass: sum(c[klass] for c in by_position.values()) for klass in EXON_CLASSES
|
| 330 |
+
}
|
| 331 |
+
return {
|
| 332 |
+
"n_ref_cds": n_ref_cds,
|
| 333 |
+
"n_pred_cds": len(pred_keys),
|
| 334 |
+
"totals": totals,
|
| 335 |
+
"by_position": by_position,
|
| 336 |
+
"exon_length_bins": [bin_label(lo, hi) for lo, hi in EXON_LENGTH_BINS],
|
| 337 |
+
"by_length": by_length,
|
| 338 |
+
"ends": ends,
|
| 339 |
+
"invented": invented,
|
| 340 |
+
"wrong_exon_buckets": [bin_label(lo, hi) for lo, hi in WRONG_EXON_BUCKETS],
|
| 341 |
+
"wrong_exons_per_failed_gene": wrong_per_failed_gene,
|
| 342 |
+
}
|
| 343 |
+
|
| 344 |
+
|
| 345 |
+
def boundary_offsets(
|
| 346 |
+
ref_genes: dict[str, Gene], pred_genes: dict[str, Gene]
|
| 347 |
+
) -> dict[str, object]:
|
| 348 |
+
"""How far off shifted boundaries are, over reference CDS a prediction overlaps."""
|
| 349 |
+
pred_index = CdsIndex(pred_genes)
|
| 350 |
+
aligned = dict.fromkeys(POSITIONS, 0)
|
| 351 |
+
shifted = dict.fromkeys(POSITIONS, 0)
|
| 352 |
+
offsets = [0] * len(OFFSET_BINS)
|
| 353 |
+
length_delta_mod3 = {0: 0, 1: 0, 2: 0}
|
| 354 |
+
for gene in ref_genes.values():
|
| 355 |
+
ordered = _ordered_cds(gene)
|
| 356 |
+
for index, (chrom, start, end, strand, _) in enumerate(ordered):
|
| 357 |
+
candidates = _overlapping_cds(
|
| 358 |
+
pred_genes, pred_index, chrom, strand, start, end
|
| 359 |
+
)
|
| 360 |
+
if not candidates:
|
| 361 |
+
continue
|
| 362 |
+
best = _best_overlap(candidates, start, end)
|
| 363 |
+
position = _position(index, len(ordered))
|
| 364 |
+
aligned[position] += 1
|
| 365 |
+
if best[1] == start and best[2] == end:
|
| 366 |
+
continue
|
| 367 |
+
shifted[position] += 1
|
| 368 |
+
for offset in (abs(best[1] - start), abs(best[2] - end)):
|
| 369 |
+
if offset:
|
| 370 |
+
offsets[_bin_index(offset, OFFSET_BINS)] += 1
|
| 371 |
+
length_delta_mod3[((best[2] - best[1]) - (end - start)) % 3] += 1
|
| 372 |
+
return {
|
| 373 |
+
"aligned": aligned,
|
| 374 |
+
"shifted": shifted,
|
| 375 |
+
"offset_bins": [bin_label(lo, hi) for lo, hi in OFFSET_BINS],
|
| 376 |
+
"offsets": offsets,
|
| 377 |
+
"length_delta_mod3": length_delta_mod3,
|
| 378 |
+
}
|
| 379 |
+
|
| 380 |
+
|
| 381 |
+
def failure_anatomy(
|
| 382 |
+
ref_genes: dict[str, Gene],
|
| 383 |
+
pred_genes: dict[str, Gene],
|
| 384 |
+
locus_outcome: dict[str, str],
|
| 385 |
+
) -> dict[str, object]:
|
| 386 |
+
"""Wrong exons per failed gene, by gene size, and where a lone wrong exon sits."""
|
| 387 |
+
boundaries = {_boundary(k) for g in pred_genes.values() for k in g.cds_keys}
|
| 388 |
+
grid = [[0] * len(WRONG_EXON_BUCKETS) for _ in EXON_COUNT_BINS]
|
| 389 |
+
observed = dict.fromkeys(POSITIONS, 0)
|
| 390 |
+
expected = dict.fromkeys(POSITIONS, 0.0)
|
| 391 |
+
convertible = [0] * len(WRONG_EXON_BUCKETS)
|
| 392 |
+
for gid, gene in ref_genes.items():
|
| 393 |
+
if locus_outcome.get(gid) == "correct":
|
| 394 |
+
continue
|
| 395 |
+
ordered = _ordered_cds(gene)
|
| 396 |
+
n = len(ordered)
|
| 397 |
+
wrong = [
|
| 398 |
+
_position(i, n)
|
| 399 |
+
for i, k in enumerate(ordered)
|
| 400 |
+
if _boundary(k) not in boundaries
|
| 401 |
+
]
|
| 402 |
+
bucket = _bin_index(len(wrong), WRONG_EXON_BUCKETS)
|
| 403 |
+
grid[_bin_index(n, EXON_COUNT_BINS)][bucket] += 1
|
| 404 |
+
for i in range(bucket, len(convertible)):
|
| 405 |
+
convertible[i] += 1
|
| 406 |
+
if len(wrong) == 1:
|
| 407 |
+
observed[wrong[0]] += 1
|
| 408 |
+
if n == 1:
|
| 409 |
+
expected["single"] += 1.0
|
| 410 |
+
else: # Uniform over the gene's exons.
|
| 411 |
+
expected["first"] += 1.0 / n
|
| 412 |
+
expected["last"] += 1.0 / n
|
| 413 |
+
expected["internal"] += (n - 2) / n
|
| 414 |
+
return {
|
| 415 |
+
"exon_count_bins": [bin_label(lo, hi) for lo, hi in EXON_COUNT_BINS],
|
| 416 |
+
"wrong_exon_buckets": [bin_label(lo, hi) for lo, hi in WRONG_EXON_BUCKETS],
|
| 417 |
+
"wrong_by_gene_size": grid,
|
| 418 |
+
"cumulative_convertible": convertible,
|
| 419 |
+
"single_wrong_observed": observed,
|
| 420 |
+
"single_wrong_expected": {k: round(v, 2) for k, v in expected.items()},
|
| 421 |
+
}
|
| 422 |
+
|
| 423 |
+
|
| 424 |
+
def independence_check(
|
| 425 |
+
ref_genes: dict[str, Gene],
|
| 426 |
+
pred_genes: dict[str, Gene],
|
| 427 |
+
locus_outcome: dict[str, str],
|
| 428 |
+
) -> dict[str, object]:
|
| 429 |
+
"""Observed gene recovery vs. independent exon errors, per exon-count bin.
|
| 430 |
+
|
| 431 |
+
Predicted recovery of an n-exon gene is P(first) * P(internal)^(n-2) * P(last),
|
| 432 |
+
with per-position exact rates measured on the same predictions.
|
| 433 |
+
"""
|
| 434 |
+
boundaries = {_boundary(k) for g in pred_genes.values() for k in g.cds_keys}
|
| 435 |
+
hit: dict[str, int] = defaultdict(int)
|
| 436 |
+
total: dict[str, int] = defaultdict(int)
|
| 437 |
+
for gene in ref_genes.values():
|
| 438 |
+
ordered = _ordered_cds(gene)
|
| 439 |
+
for index, key in enumerate(ordered):
|
| 440 |
+
position = _position(index, len(ordered))
|
| 441 |
+
total[position] += 1
|
| 442 |
+
hit[position] += _boundary(key) in boundaries
|
| 443 |
+
rate = {p: hit[p] / total[p] for p in total}
|
| 444 |
+
|
| 445 |
+
def predicted(n: int) -> float:
|
| 446 |
+
if n == 1:
|
| 447 |
+
return rate.get("single", 0.0)
|
| 448 |
+
return (
|
| 449 |
+
rate.get("first", 0.0)
|
| 450 |
+
* rate.get("last", 0.0)
|
| 451 |
+
* rate.get("internal", 0.0) ** (n - 2)
|
| 452 |
+
)
|
| 453 |
+
|
| 454 |
+
observed = [0] * len(EXON_COUNT_BINS)
|
| 455 |
+
n_genes = [0] * len(EXON_COUNT_BINS)
|
| 456 |
+
expected = [0.0] * len(EXON_COUNT_BINS)
|
| 457 |
+
for gid, gene in ref_genes.items():
|
| 458 |
+
n = len(gene.cds_keys)
|
| 459 |
+
i = _bin_index(n, EXON_COUNT_BINS)
|
| 460 |
+
n_genes[i] += 1
|
| 461 |
+
expected[i] += predicted(n)
|
| 462 |
+
observed[i] += locus_outcome.get(gid) == "correct"
|
| 463 |
+
return {
|
| 464 |
+
"exon_count_bins": [bin_label(lo, hi) for lo, hi in EXON_COUNT_BINS],
|
| 465 |
+
"n_genes": n_genes,
|
| 466 |
+
"observed": observed,
|
| 467 |
+
"predicted": [round(v, 3) for v in expected],
|
| 468 |
+
"position_rate": {p: round(v, 6) for p, v in sorted(rate.items())},
|
| 469 |
+
}
|
| 470 |
+
|
| 471 |
+
|
| 472 |
+
def intron_chain(gene: Gene) -> tuple[object, ...]:
|
| 473 |
+
"""Internal CDS boundaries (gffcompare's intron chain); phase ignored."""
|
| 474 |
+
ordered = sorted(gene.cds_keys, key=lambda k: (k[1], k[2]))
|
| 475 |
+
introns = tuple((ordered[i][2], ordered[i + 1][1]) for i in range(len(ordered) - 1))
|
| 476 |
+
return (gene.chrom, gene.strand, introns)
|
| 477 |
+
|
| 478 |
+
|
| 479 |
+
def _rates(
|
| 480 |
+
tp_ref: int, n_ref: int, tp_pred: int, n_pred: int, prefix: str
|
| 481 |
+
) -> dict[str, float]:
|
| 482 |
+
recall = 100.0 * tp_ref / n_ref if n_ref else 0.0
|
| 483 |
+
precision = 100.0 * tp_pred / n_pred if n_pred else 0.0
|
| 484 |
+
total = recall + precision
|
| 485 |
+
return {
|
| 486 |
+
f"{prefix}_recall": recall,
|
| 487 |
+
f"{prefix}_precision": precision,
|
| 488 |
+
f"{prefix}_f1": 2 * recall * precision / total if total else 0.0,
|
| 489 |
+
}
|
| 490 |
+
|
| 491 |
+
|
| 492 |
+
def _chain_counts(
|
| 493 |
+
refs: list[Gene], preds: list[Gene], chains: bool
|
| 494 |
+
) -> dict[str, int]:
|
| 495 |
+
ref_exact = {frozenset(g.cds_keys) for g in refs}
|
| 496 |
+
pred_exact = {frozenset(g.cds_keys) for g in preds}
|
| 497 |
+
counts = {
|
| 498 |
+
"n_ref": len(refs),
|
| 499 |
+
"n_pred": len(preds),
|
| 500 |
+
"exact_tp_ref": sum(frozenset(g.cds_keys) in pred_exact for g in refs),
|
| 501 |
+
"exact_tp_pred": sum(frozenset(g.cds_keys) in ref_exact for g in preds),
|
| 502 |
+
}
|
| 503 |
+
if chains:
|
| 504 |
+
ref_chains = {intron_chain(g) for g in refs}
|
| 505 |
+
pred_chains = {intron_chain(g) for g in preds}
|
| 506 |
+
counts["chain_tp_ref"] = sum(intron_chain(g) in pred_chains for g in refs)
|
| 507 |
+
counts["chain_tp_pred"] = sum(intron_chain(g) in ref_chains for g in preds)
|
| 508 |
+
else: # Single-exon genes have no chain; keep the exact tally so rollups add up.
|
| 509 |
+
counts["chain_tp_ref"] = counts["exact_tp_ref"]
|
| 510 |
+
counts["chain_tp_pred"] = counts["exact_tp_pred"]
|
| 511 |
+
return counts
|
| 512 |
+
|
| 513 |
+
|
| 514 |
+
def _with_rates(counts: dict[str, int]) -> dict[str, float]:
|
| 515 |
+
return {
|
| 516 |
+
**counts,
|
| 517 |
+
**_rates(
|
| 518 |
+
counts["exact_tp_ref"],
|
| 519 |
+
counts["n_ref"],
|
| 520 |
+
counts["exact_tp_pred"],
|
| 521 |
+
counts["n_pred"],
|
| 522 |
+
"exact",
|
| 523 |
+
),
|
| 524 |
+
**_rates(
|
| 525 |
+
counts["chain_tp_ref"],
|
| 526 |
+
counts["n_ref"],
|
| 527 |
+
counts["chain_tp_pred"],
|
| 528 |
+
counts["n_pred"],
|
| 529 |
+
"chain",
|
| 530 |
+
),
|
| 531 |
+
}
|
| 532 |
+
|
| 533 |
+
|
| 534 |
+
def measure_chains(
|
| 535 |
+
ref_genes: dict[str, Gene], pred_genes: dict[str, Gene]
|
| 536 |
+
) -> dict[str, object]:
|
| 537 |
+
"""Exact-gene and intron-chain matching, for multi- and single-exon genes."""
|
| 538 |
+
|
| 539 |
+
def split(genes: Iterable[Gene]) -> tuple[list[Gene], list[Gene]]:
|
| 540 |
+
genes = [g for g in genes if g.cds_keys]
|
| 541 |
+
return [g for g in genes if len(g.cds_keys) > 1], [
|
| 542 |
+
g for g in genes if len(g.cds_keys) == 1
|
| 543 |
+
]
|
| 544 |
+
|
| 545 |
+
ref_multi, ref_single = split(ref_genes.values())
|
| 546 |
+
pred_multi, pred_single = split(pred_genes.values())
|
| 547 |
+
multi = _chain_counts(ref_multi, pred_multi, chains=True)
|
| 548 |
+
single = _chain_counts(ref_single, pred_single, chains=False)
|
| 549 |
+
combined = {key: multi[key] + single[key] for key in multi}
|
| 550 |
+
return {
|
| 551 |
+
"multi_exon": _with_rates(multi),
|
| 552 |
+
"single_exon": _with_rates(single),
|
| 553 |
+
"all": _with_rates(combined),
|
| 554 |
+
}
|
| 555 |
+
|
| 556 |
+
|
| 557 |
+
def measure_merges(
|
| 558 |
+
ref_genes: dict[str, Gene],
|
| 559 |
+
pred_genes: dict[str, Gene],
|
| 560 |
+
outcomes: list[LocusOutcome],
|
| 561 |
+
) -> dict[str, object]:
|
| 562 |
+
"""Predictions overlapping 2+ reference loci, and what they absorb."""
|
| 563 |
+
ref_index = CdsIndex(ref_genes)
|
| 564 |
+
outcome_of = {o.gene_id: o.outcome for o in outcomes}
|
| 565 |
+
sizes = dict.fromkeys((bin_label(lo, hi) for lo, hi in MERGE_SIZE_BINS), 0)
|
| 566 |
+
merging, largest = 0, 0
|
| 567 |
+
absorbed: set[str] = set()
|
| 568 |
+
for gene in pred_genes.values():
|
| 569 |
+
if not gene.cds_keys:
|
| 570 |
+
continue
|
| 571 |
+
hits = ref_index.overlapping_gene(gene)
|
| 572 |
+
if len(hits) < 2:
|
| 573 |
+
continue
|
| 574 |
+
merging += 1
|
| 575 |
+
lo, hi = MERGE_SIZE_BINS[_bin_index(len(hits), MERGE_SIZE_BINS)]
|
| 576 |
+
sizes[bin_label(lo, hi)] += 1
|
| 577 |
+
largest = max(largest, len(hits))
|
| 578 |
+
absorbed |= hits
|
| 579 |
+
absorbed_outcomes: dict[str, int] = defaultdict(int)
|
| 580 |
+
for gid in absorbed:
|
| 581 |
+
absorbed_outcomes[outcome_of.get(gid, "unscored")] += 1
|
| 582 |
+
return {
|
| 583 |
+
"n_pred": sum(1 for g in pred_genes.values() if g.cds_keys),
|
| 584 |
+
"n_ref": len(outcomes),
|
| 585 |
+
"merging_predictions": merging,
|
| 586 |
+
"absorbed_loci": len(absorbed),
|
| 587 |
+
"merge_sizes": sizes,
|
| 588 |
+
"largest_merge": largest,
|
| 589 |
+
"boundary_loci": sum(o.outcome == "boundary" for o in outcomes),
|
| 590 |
+
"boundary_from_merge": sum(
|
| 591 |
+
o.outcome == "boundary" and o.gene_id in absorbed for o in outcomes
|
| 592 |
+
),
|
| 593 |
+
"absorbed_outcomes": dict(sorted(absorbed_outcomes.items())),
|
| 594 |
+
}
|
src/genome_annotation/benchmark.py
ADDED
|
@@ -0,0 +1,194 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""The NTv3 annotation benchmark: test species, released files and published scores."""
|
| 2 |
+
|
| 3 |
+
from dataclasses import dataclass
|
| 4 |
+
|
| 5 |
+
DATASET_REPO = "InstaDeepAI/NTv3_annot_dataset"
|
| 6 |
+
|
| 7 |
+
|
| 8 |
+
@dataclass(frozen=True)
|
| 9 |
+
class TestSpecies:
|
| 10 |
+
"""One held-out species of the benchmark."""
|
| 11 |
+
|
| 12 |
+
name: str
|
| 13 |
+
clade: str
|
| 14 |
+
assembly: str
|
| 15 |
+
released_gff: str
|
| 16 |
+
gene_f1: float
|
| 17 |
+
cds_f1: float
|
| 18 |
+
|
| 19 |
+
@property
|
| 20 |
+
def model_repo(self) -> str:
|
| 21 |
+
"""Hugging Face repo of the clade model, which also holds its predictions."""
|
| 22 |
+
return f"InstaDeepAI/NTv3_annot_{self.clade}"
|
| 23 |
+
|
| 24 |
+
@property
|
| 25 |
+
def dataset_dir(self) -> str:
|
| 26 |
+
"""Folder of the species in ``DATASET_REPO``."""
|
| 27 |
+
return f"{self.clade}/test_species/{self.assembly}"
|
| 28 |
+
|
| 29 |
+
@property
|
| 30 |
+
def reference_gff(self) -> str:
|
| 31 |
+
"""Benchmark reference in ``DATASET_REPO``."""
|
| 32 |
+
return f"{self.dataset_dir}/reference_longest_isoform.gff3.gz"
|
| 33 |
+
|
| 34 |
+
@property
|
| 35 |
+
def genome_fasta(self) -> str:
|
| 36 |
+
"""Soft-masked genome in ``DATASET_REPO`` (bgzipped, indexed)."""
|
| 37 |
+
return f"{self.dataset_dir}/genome.fa.gz"
|
| 38 |
+
|
| 39 |
+
|
| 40 |
+
def _species(
|
| 41 |
+
name: str, clade: str, assembly: str, tag: str, gene_f1: float, cds_f1: float
|
| 42 |
+
) -> TestSpecies:
|
| 43 |
+
return TestSpecies(
|
| 44 |
+
name,
|
| 45 |
+
clade,
|
| 46 |
+
assembly,
|
| 47 |
+
f"annot_results/ntv3_{name}_{tag}.gff3.gz",
|
| 48 |
+
gene_f1,
|
| 49 |
+
cds_f1,
|
| 50 |
+
)
|
| 51 |
+
|
| 52 |
+
|
| 53 |
+
# Gene / CDS F1 (%) of the released NTv3 GFF3s, whole genome, as in the paper.
|
| 54 |
+
TEST_SPECIES = {
|
| 55 |
+
s.name: s
|
| 56 |
+
for s in (
|
| 57 |
+
_species(
|
| 58 |
+
"bos_taurus",
|
| 59 |
+
"mammals",
|
| 60 |
+
"GCF_002263795.3_ARS-UCD2.0",
|
| 61 |
+
"GCF_002263795.3",
|
| 62 |
+
58.06,
|
| 63 |
+
90.60,
|
| 64 |
+
),
|
| 65 |
+
_species(
|
| 66 |
+
"delphinapterus_leucas",
|
| 67 |
+
"mammals",
|
| 68 |
+
"GCF_002288925.2_ASM228892v3",
|
| 69 |
+
"GCF_002288925.2",
|
| 70 |
+
54.66,
|
| 71 |
+
89.68,
|
| 72 |
+
),
|
| 73 |
+
_species(
|
| 74 |
+
"brachypodium_distachyon",
|
| 75 |
+
"plants",
|
| 76 |
+
"GCF_000005505.3_Brachypodium_distachyon_v3.0",
|
| 77 |
+
"GCF_000005505.3",
|
| 78 |
+
71.89,
|
| 79 |
+
89.68,
|
| 80 |
+
),
|
| 81 |
+
_species(
|
| 82 |
+
"eschscholzia_californica",
|
| 83 |
+
"plants",
|
| 84 |
+
"EcalifornicavarOrangeKingHAP1v1.1",
|
| 85 |
+
"OrangeKingHAP1v1.1",
|
| 86 |
+
73.17,
|
| 87 |
+
91.07,
|
| 88 |
+
),
|
| 89 |
+
_species(
|
| 90 |
+
"freycinetia_multiflora",
|
| 91 |
+
"plants",
|
| 92 |
+
"FmultifloravarABG20040013HAP1v1.1",
|
| 93 |
+
"ABG20040013HAP1v1.1",
|
| 94 |
+
68.36,
|
| 95 |
+
88.73,
|
| 96 |
+
),
|
| 97 |
+
_species(
|
| 98 |
+
"medicago_truncatula",
|
| 99 |
+
"plants",
|
| 100 |
+
"GCF_003473485.1_MtrunA17r5.0-ANR",
|
| 101 |
+
"GCF_003473485.1",
|
| 102 |
+
72.63,
|
| 103 |
+
89.33,
|
| 104 |
+
),
|
| 105 |
+
_species(
|
| 106 |
+
"mimulus_guttatus",
|
| 107 |
+
"plants",
|
| 108 |
+
"MguttatusvarIM62v3.1",
|
| 109 |
+
"IM62v3.1",
|
| 110 |
+
80.73,
|
| 111 |
+
93.54,
|
| 112 |
+
),
|
| 113 |
+
_species(
|
| 114 |
+
"bombyx_mori",
|
| 115 |
+
"insects",
|
| 116 |
+
"GCF_030269925.1_ASM3026992v2",
|
| 117 |
+
"GCF_030269925.1",
|
| 118 |
+
56.53,
|
| 119 |
+
85.19,
|
| 120 |
+
),
|
| 121 |
+
_species(
|
| 122 |
+
"cataglyphis_hispanica",
|
| 123 |
+
"insects",
|
| 124 |
+
"GCF_021464435.1_ULB_Chis1_1.0",
|
| 125 |
+
"GCF_021464435.1",
|
| 126 |
+
63.94,
|
| 127 |
+
87.85,
|
| 128 |
+
),
|
| 129 |
+
_species(
|
| 130 |
+
"colias_croceus",
|
| 131 |
+
"insects",
|
| 132 |
+
"GCF_905220415.1_ilColCroc2.1",
|
| 133 |
+
"GCF_905220415.1",
|
| 134 |
+
62.65,
|
| 135 |
+
88.13,
|
| 136 |
+
),
|
| 137 |
+
_species(
|
| 138 |
+
"danaus_plexippus",
|
| 139 |
+
"insects",
|
| 140 |
+
"GCF_018135715.1_MEX_DaPlex",
|
| 141 |
+
"GCF_018135715.1",
|
| 142 |
+
65.68,
|
| 143 |
+
89.38,
|
| 144 |
+
),
|
| 145 |
+
_species(
|
| 146 |
+
"leptidea_sinapis",
|
| 147 |
+
"insects",
|
| 148 |
+
"GCF_905404315.1_ilLepSina1.1",
|
| 149 |
+
"GCF_905404315.1",
|
| 150 |
+
55.00,
|
| 151 |
+
81.99,
|
| 152 |
+
),
|
| 153 |
+
_species(
|
| 154 |
+
"nymphalis_io",
|
| 155 |
+
"insects",
|
| 156 |
+
"GCF_905147045.1_ilAglIoxx1.1",
|
| 157 |
+
"GCF_905147045.1",
|
| 158 |
+
66.02,
|
| 159 |
+
89.19,
|
| 160 |
+
),
|
| 161 |
+
_species(
|
| 162 |
+
"osmia_bicornis",
|
| 163 |
+
"insects",
|
| 164 |
+
"GCF_907164935.1_iOsmBic2.1",
|
| 165 |
+
"GCF_907164935.1",
|
| 166 |
+
58.75,
|
| 167 |
+
86.26,
|
| 168 |
+
),
|
| 169 |
+
_species(
|
| 170 |
+
"tribolium_castaneum",
|
| 171 |
+
"insects",
|
| 172 |
+
"GCF_031307605.1_icTriCast1.1",
|
| 173 |
+
"GCF_031307605.1",
|
| 174 |
+
53.05,
|
| 175 |
+
80.60,
|
| 176 |
+
),
|
| 177 |
+
_species(
|
| 178 |
+
"vanessa_cardui",
|
| 179 |
+
"insects",
|
| 180 |
+
"GCF_905220365.1_ilVanCard2.1",
|
| 181 |
+
"GCF_905220365.1",
|
| 182 |
+
64.37,
|
| 183 |
+
88.77,
|
| 184 |
+
),
|
| 185 |
+
_species(
|
| 186 |
+
"zerene_cesonia",
|
| 187 |
+
"insects",
|
| 188 |
+
"GCF_012273895.1_Zerene_cesonia_1.1",
|
| 189 |
+
"GCF_012273895.1",
|
| 190 |
+
61.31,
|
| 191 |
+
85.14,
|
| 192 |
+
),
|
| 193 |
+
)
|
| 194 |
+
}
|
src/genome_annotation/data.py
ADDED
|
@@ -0,0 +1,165 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""Training windows of (soft-masked tokens, 15-class labels) from FASTA + label BED."""
|
| 2 |
+
|
| 3 |
+
from collections.abc import Iterator
|
| 4 |
+
from pathlib import Path
|
| 5 |
+
|
| 6 |
+
import numpy as np
|
| 7 |
+
import pandas as pd
|
| 8 |
+
import pyfaidx
|
| 9 |
+
import torch
|
| 10 |
+
|
| 11 |
+
from genome_annotation.decoding import CLASS_INDEX
|
| 12 |
+
|
| 13 |
+
IGNORE_INDEX = -1
|
| 14 |
+
_MASK_LABELS = ("mask_short_intron", "mask_frame_error")
|
| 15 |
+
_LABEL_TO_CLASS = CLASS_INDEX | dict.fromkeys(_MASK_LABELS, IGNORE_INDEX)
|
| 16 |
+
# Where rows overlap, the label later in this order wins (masks last).
|
| 17 |
+
_PAINT_ORDER = {label: i for i, label in enumerate(_LABEL_TO_CLASS)}
|
| 18 |
+
_CDS_P0 = CLASS_INDEX["CDS_p0"]
|
| 19 |
+
_COMPLEMENT = str.maketrans("ACGTNacgtn", "TGCANtgcan")
|
| 20 |
+
|
| 21 |
+
|
| 22 |
+
def reverse_complement(sequence: str) -> str:
|
| 23 |
+
"""Reverse-complements a DNA string, keeping soft-masking."""
|
| 24 |
+
return sequence.translate(_COMPLEMENT)[::-1]
|
| 25 |
+
|
| 26 |
+
|
| 27 |
+
class Tokenizer:
|
| 28 |
+
"""Maps DNA strings to NTv3 token ids, one token per base.
|
| 29 |
+
|
| 30 |
+
A lookup-table equivalent of the model's own (character-level) tokenizer, fast
|
| 31 |
+
enough for long windows: ACGTN and their soft-masked lowercase get their own
|
| 32 |
+
ids, anything else (IUPAC codes, ...) is ``<unk>``.
|
| 33 |
+
"""
|
| 34 |
+
|
| 35 |
+
def __init__(self, vocab: dict[str, int]) -> None:
|
| 36 |
+
self.lut = np.full(256, vocab["<unk>"], dtype=np.int64)
|
| 37 |
+
for base in "ACGTNacgtn":
|
| 38 |
+
self.lut[ord(base)] = vocab[base]
|
| 39 |
+
|
| 40 |
+
def __call__(self, sequence: str) -> np.ndarray:
|
| 41 |
+
return self.lut[np.frombuffer(sequence.encode("ascii"), dtype=np.uint8)]
|
| 42 |
+
|
| 43 |
+
|
| 44 |
+
class LabelTrack:
|
| 45 |
+
"""Per-base 15-class labels of one assembly, read from its training BED.
|
| 46 |
+
|
| 47 |
+
Each BED row labels an interval of one strand. A CDS row carries the codon
|
| 48 |
+
position of its 5' base, which cycles 0, 1, 2 along the transcript.
|
| 49 |
+
"""
|
| 50 |
+
|
| 51 |
+
def __init__(self, bed_path: str | Path) -> None:
|
| 52 |
+
bed = pd.read_csv(
|
| 53 |
+
bed_path,
|
| 54 |
+
sep="\t",
|
| 55 |
+
header=None,
|
| 56 |
+
names=["chrom", "start", "end", "label", "score", "strand"],
|
| 57 |
+
dtype={"chrom": str},
|
| 58 |
+
)
|
| 59 |
+
bed["cls"] = bed["label"].map(_LABEL_TO_CLASS).astype(np.int64)
|
| 60 |
+
bed["order"] = bed["label"].map(_PAINT_ORDER).astype(np.int64)
|
| 61 |
+
self.rows: dict[tuple[str, str], tuple[np.ndarray, ...]] = {}
|
| 62 |
+
for (chrom, strand), group in bed.groupby(["chrom", "strand"]):
|
| 63 |
+
group = group.sort_values("start")
|
| 64 |
+
self.rows[(chrom, strand)] = tuple(
|
| 65 |
+
group[col].to_numpy() for col in ("start", "end", "cls", "order")
|
| 66 |
+
)
|
| 67 |
+
|
| 68 |
+
def strand_labels(
|
| 69 |
+
self, chrom: str, strand: str, start: int, end: int
|
| 70 |
+
) -> np.ndarray:
|
| 71 |
+
"""(end - start,) labels of one strand, in genomic order."""
|
| 72 |
+
labels = np.zeros(end - start, dtype=np.int64)
|
| 73 |
+
if (chrom, strand) not in self.rows:
|
| 74 |
+
return labels
|
| 75 |
+
starts, ends, classes, order = self.rows[(chrom, strand)]
|
| 76 |
+
# Rows are sorted by start; a row overlaps the window iff it starts before
|
| 77 |
+
# its end and ends after its start (ends are not sorted where rows overlap).
|
| 78 |
+
hi = np.searchsorted(starts, end, side="left")
|
| 79 |
+
candidates = np.flatnonzero(ends[:hi] > start)
|
| 80 |
+
candidates = candidates[np.argsort(order[candidates], kind="stable")]
|
| 81 |
+
for row_start, row_end, cls in zip(
|
| 82 |
+
starts[candidates], ends[candidates], classes[candidates], strict=True
|
| 83 |
+
):
|
| 84 |
+
s, e = max(start, row_start), min(end, row_end)
|
| 85 |
+
if _CDS_P0 <= cls < _CDS_P0 + 3:
|
| 86 |
+
frame = cls - _CDS_P0
|
| 87 |
+
if strand == "+":
|
| 88 |
+
offsets = np.arange(s - row_start, e - row_start)
|
| 89 |
+
else:
|
| 90 |
+
offsets = np.arange(row_end - 1 - s, row_end - 1 - e, -1)
|
| 91 |
+
labels[s - start : e - start] = _CDS_P0 + (frame + offsets) % 3
|
| 92 |
+
else:
|
| 93 |
+
labels[s - start : e - start] = cls
|
| 94 |
+
return labels
|
| 95 |
+
|
| 96 |
+
def window(self, chrom: str, start: int, end: int, reverse: bool) -> np.ndarray:
|
| 97 |
+
"""Labels of the strand read 5'->3' in the window's orientation."""
|
| 98 |
+
if reverse:
|
| 99 |
+
return self.strand_labels(chrom, "-", start, end)[::-1].copy()
|
| 100 |
+
return self.strand_labels(chrom, "+", start, end)
|
| 101 |
+
|
| 102 |
+
|
| 103 |
+
class Assembly:
|
| 104 |
+
"""A genome and its labels, stored as ``<dir>/genome.fa.gz`` and ``labels.bed.gz``."""
|
| 105 |
+
|
| 106 |
+
def __init__(self, directory: str | Path) -> None:
|
| 107 |
+
self.directory = Path(directory)
|
| 108 |
+
self.name = self.directory.name
|
| 109 |
+
self.labels = LabelTrack(self.directory / "labels.bed.gz")
|
| 110 |
+
self._fasta: pyfaidx.Fasta | None = None
|
| 111 |
+
with pyfaidx.Fasta(str(self.directory / "genome.fa.gz")) as fasta:
|
| 112 |
+
self.lengths = {name: len(record) for name, record in fasta.items()}
|
| 113 |
+
|
| 114 |
+
@property
|
| 115 |
+
def fasta(self) -> pyfaidx.Fasta:
|
| 116 |
+
# Opened lazily so that each dataloader worker gets its own file handle.
|
| 117 |
+
if self._fasta is None:
|
| 118 |
+
self._fasta = pyfaidx.Fasta(str(self.directory / "genome.fa.gz"))
|
| 119 |
+
return self._fasta
|
| 120 |
+
|
| 121 |
+
def sequence(self, chrom: str, start: int, end: int) -> str:
|
| 122 |
+
return str(self.fasta[chrom][start:end])
|
| 123 |
+
|
| 124 |
+
|
| 125 |
+
class TrainingWindows(torch.utils.data.IterableDataset):
|
| 126 |
+
"""Endless random windows: species uniform, position uniform within the species.
|
| 127 |
+
|
| 128 |
+
Each window is reverse-complemented with probability 1/2, together with its
|
| 129 |
+
labels, so the single-strand head learns genes on both strands.
|
| 130 |
+
"""
|
| 131 |
+
|
| 132 |
+
def __init__(
|
| 133 |
+
self,
|
| 134 |
+
assemblies: list[Assembly],
|
| 135 |
+
tokenizer: Tokenizer,
|
| 136 |
+
window: int,
|
| 137 |
+
seed: int = 0,
|
| 138 |
+
) -> None:
|
| 139 |
+
self.assemblies = assemblies
|
| 140 |
+
self.tokenizer = tokenizer
|
| 141 |
+
self.window = window
|
| 142 |
+
self.seed = seed
|
| 143 |
+
|
| 144 |
+
def __iter__(self) -> Iterator[tuple[torch.Tensor, torch.Tensor]]:
|
| 145 |
+
info = torch.utils.data.get_worker_info()
|
| 146 |
+
rng = np.random.default_rng([self.seed, info.id if info else 0])
|
| 147 |
+
contigs = [
|
| 148 |
+
[(c, n) for c, n in a.lengths.items() if n >= self.window]
|
| 149 |
+
for a in self.assemblies
|
| 150 |
+
]
|
| 151 |
+
while True:
|
| 152 |
+
index = rng.integers(len(self.assemblies))
|
| 153 |
+
assembly = self.assemblies[index]
|
| 154 |
+
sizes = np.array([n - self.window + 1 for _, n in contigs[index]])
|
| 155 |
+
flat = rng.integers(sizes.sum())
|
| 156 |
+
contig = int(np.searchsorted(np.cumsum(sizes), flat, side="right"))
|
| 157 |
+
chrom = contigs[index][contig][0]
|
| 158 |
+
start = int(flat - (np.cumsum(sizes)[contig] - sizes[contig]))
|
| 159 |
+
end = start + self.window
|
| 160 |
+
reverse = bool(rng.integers(2))
|
| 161 |
+
sequence = assembly.sequence(chrom, start, end)
|
| 162 |
+
if reverse:
|
| 163 |
+
sequence = reverse_complement(sequence)
|
| 164 |
+
labels = assembly.labels.window(chrom, start, end, reverse)
|
| 165 |
+
yield torch.from_numpy(self.tokenizer(sequence)), torch.from_numpy(labels)
|
src/genome_annotation/decoding.py
ADDED
|
@@ -0,0 +1,338 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""Constrained Viterbi decoding of 15-class annotation logits into GFF3 genes.
|
| 2 |
+
|
| 3 |
+
A simplified port of the NTv3 genome-annotation decoder: the same 15-state
|
| 4 |
+
Tiberius-style HMM and start / splice / stop motif constraints, without the U12
|
| 5 |
+
(AT..AC) intron lane.
|
| 6 |
+
"""
|
| 7 |
+
|
| 8 |
+
import gzip
|
| 9 |
+
import itertools
|
| 10 |
+
from collections.abc import Callable
|
| 11 |
+
from pathlib import Path
|
| 12 |
+
|
| 13 |
+
import numba
|
| 14 |
+
import numpy as np
|
| 15 |
+
|
| 16 |
+
CLASSES: tuple[str, ...] = (
|
| 17 |
+
"intergenic",
|
| 18 |
+
"intron_p0",
|
| 19 |
+
"intron_p1",
|
| 20 |
+
"intron_p2",
|
| 21 |
+
"CDS_p0",
|
| 22 |
+
"CDS_p1",
|
| 23 |
+
"CDS_p2",
|
| 24 |
+
"splice_acceptor_p0",
|
| 25 |
+
"splice_acceptor_p1",
|
| 26 |
+
"splice_acceptor_p2",
|
| 27 |
+
"splice_donor_p0",
|
| 28 |
+
"splice_donor_p1",
|
| 29 |
+
"splice_donor_p2",
|
| 30 |
+
"start_codon",
|
| 31 |
+
"stop_codon",
|
| 32 |
+
)
|
| 33 |
+
NUM_CLASSES = len(CLASSES)
|
| 34 |
+
CLASS_INDEX: dict[str, int] = {name: i for i, name in enumerate(CLASSES)}
|
| 35 |
+
|
| 36 |
+
# Tiberius mean lengths: intergenic ~10 kb, intron ~4.5 kb, exon ~200 bp.
|
| 37 |
+
_P_IR = 1 / 10_000
|
| 38 |
+
_P_I = 1 / 4_500
|
| 39 |
+
_P_E = 1 / 200
|
| 40 |
+
|
| 41 |
+
# Allowed transitions per state; the self-loop takes the remaining mass. Start,
|
| 42 |
+
# stop and splice states last exactly 1 bp.
|
| 43 |
+
TRANSITIONS: dict[str, dict[str, float]] = {
|
| 44 |
+
"intergenic": {"start_codon": _P_IR},
|
| 45 |
+
"intron_p0": {"splice_acceptor_p1": _P_I},
|
| 46 |
+
"intron_p1": {"splice_acceptor_p2": _P_I},
|
| 47 |
+
"intron_p2": {"splice_acceptor_p0": _P_I},
|
| 48 |
+
"CDS_p0": {"CDS_p1": 1 - _P_E, "splice_donor_p1": _P_E},
|
| 49 |
+
"CDS_p1": {"CDS_p2": 1 - 2 * _P_E, "splice_donor_p2": _P_E, "stop_codon": _P_E},
|
| 50 |
+
"CDS_p2": {"CDS_p0": 1 - _P_E, "splice_donor_p0": _P_E},
|
| 51 |
+
"splice_acceptor_p0": {"CDS_p1": 1.0},
|
| 52 |
+
"splice_acceptor_p1": {"CDS_p2": 1.0},
|
| 53 |
+
"splice_acceptor_p2": {"CDS_p0": 1.0},
|
| 54 |
+
"splice_donor_p0": {"intron_p0": 1.0},
|
| 55 |
+
"splice_donor_p1": {"intron_p1": 1.0},
|
| 56 |
+
"splice_donor_p2": {"intron_p2": 1.0},
|
| 57 |
+
"start_codon": {"CDS_p1": 1.0},
|
| 58 |
+
"stop_codon": {"intergenic": 1.0},
|
| 59 |
+
}
|
| 60 |
+
|
| 61 |
+
|
| 62 |
+
def build_log_transitions() -> np.ndarray:
|
| 63 |
+
"""Returns the (15, 15) log transition matrix of the HMM."""
|
| 64 |
+
matrix = np.zeros((NUM_CLASSES, NUM_CLASSES), dtype=np.float64)
|
| 65 |
+
for src, dests in TRANSITIONS.items():
|
| 66 |
+
i = CLASS_INDEX[src]
|
| 67 |
+
matrix[i, i] = max(0.0, 1.0 - sum(dests.values()))
|
| 68 |
+
for dst, prob in dests.items():
|
| 69 |
+
matrix[i, CLASS_INDEX[dst]] = prob
|
| 70 |
+
with np.errstate(divide="ignore"):
|
| 71 |
+
return np.log(matrix.astype(np.float32))
|
| 72 |
+
|
| 73 |
+
|
| 74 |
+
LOG_TRANSITIONS = build_log_transitions()
|
| 75 |
+
|
| 76 |
+
# Base codes: A=0, C=1, G=2, T=3, anything else (N, IUPAC) = 4.
|
| 77 |
+
A, C, G, T, N = 0, 1, 2, 3, 4
|
| 78 |
+
_BASE_CODE_LUT = np.full(256, N, dtype=np.uint8)
|
| 79 |
+
for _base, _code in zip("ACGT", (A, C, G, T), strict=True):
|
| 80 |
+
_BASE_CODE_LUT[ord(_base)] = _code
|
| 81 |
+
_BASE_CODE_LUT[ord(_base.lower())] = _code
|
| 82 |
+
|
| 83 |
+
|
| 84 |
+
def encode_bases(sequence: str) -> np.ndarray:
|
| 85 |
+
"""Maps a DNA string to case-insensitive base codes."""
|
| 86 |
+
return _BASE_CODE_LUT[np.frombuffer(sequence.encode("ascii"), dtype=np.uint8)]
|
| 87 |
+
|
| 88 |
+
|
| 89 |
+
def reverse_complement_codes(codes: np.ndarray) -> np.ndarray:
|
| 90 |
+
"""Reverse-complements an array of base codes."""
|
| 91 |
+
return np.where(codes == N, N, T - codes)[::-1].astype(np.uint8)
|
| 92 |
+
|
| 93 |
+
|
| 94 |
+
# (offsets relative to the state position, bases, log-score) per accepted motif.
|
| 95 |
+
# Scores follow Tiberius: a slight TAG preference, and GT >> GC donors.
|
| 96 |
+
_MOTIFS: dict[str, list[tuple[tuple[int, ...], tuple[int, ...], float]]] = {
|
| 97 |
+
"start": [((0, 1, 2), (A, T, G), 0.0)],
|
| 98 |
+
"stop": [
|
| 99 |
+
((-2, -1, 0), (T, A, G), float(np.log(np.float32(0.34)))),
|
| 100 |
+
((-2, -1, 0), (T, A, A), float(np.log(np.float32(0.33)))),
|
| 101 |
+
((-2, -1, 0), (T, G, A), float(np.log(np.float32(0.33)))),
|
| 102 |
+
],
|
| 103 |
+
"donor": [
|
| 104 |
+
((1, 2), (G, T), float(np.log(np.float32(0.99)))),
|
| 105 |
+
((1, 2), (G, C), float(np.log(np.float32(0.01)))),
|
| 106 |
+
],
|
| 107 |
+
"acceptor": [((-2, -1), (A, G), 0.0)],
|
| 108 |
+
}
|
| 109 |
+
_MOTIF_STATES: dict[str, tuple[str, ...]] = {
|
| 110 |
+
"start": ("start_codon",),
|
| 111 |
+
"stop": ("stop_codon",),
|
| 112 |
+
"donor": ("splice_donor_p0", "splice_donor_p1", "splice_donor_p2"),
|
| 113 |
+
"acceptor": ("splice_acceptor_p0", "splice_acceptor_p1", "splice_acceptor_p2"),
|
| 114 |
+
}
|
| 115 |
+
|
| 116 |
+
|
| 117 |
+
def _motif_log_mask(
|
| 118 |
+
codes: np.ndarray, rules: list[tuple[tuple[int, ...], tuple[int, ...], float]]
|
| 119 |
+
) -> np.ndarray:
|
| 120 |
+
"""Per-position log-score of one motif: -inf if no rule matches, 0 near an N."""
|
| 121 |
+
length = len(codes)
|
| 122 |
+
padded = np.pad(codes, (2, 2), constant_values=N)
|
| 123 |
+
offsets = {off for rule_offsets, _, _ in rules for off in rule_offsets}
|
| 124 |
+
|
| 125 |
+
def shifted(off: int) -> np.ndarray:
|
| 126 |
+
return padded[2 + off : 2 + off + length]
|
| 127 |
+
|
| 128 |
+
has_n = np.zeros(length, dtype=bool)
|
| 129 |
+
for off in offsets:
|
| 130 |
+
has_n |= shifted(off) == N
|
| 131 |
+
score = np.full(length, -np.inf, dtype=np.float32)
|
| 132 |
+
for rule_offsets, bases, log_score in rules:
|
| 133 |
+
match = np.ones(length, dtype=bool)
|
| 134 |
+
for off, base in zip(rule_offsets, bases, strict=True):
|
| 135 |
+
match &= shifted(off) == base
|
| 136 |
+
score[match] = log_score
|
| 137 |
+
score[has_n] = 0.0
|
| 138 |
+
# Motifs whose context falls outside the window are forbidden.
|
| 139 |
+
pos = np.arange(length)
|
| 140 |
+
score[(pos < -min(offsets)) | (pos >= length - max(offsets))] = -np.inf
|
| 141 |
+
return score
|
| 142 |
+
|
| 143 |
+
|
| 144 |
+
def constraint_log_mask(codes: np.ndarray) -> np.ndarray:
|
| 145 |
+
"""(L, 15) additive log-mask vetoing start/stop/splice states off their motif."""
|
| 146 |
+
mask = np.zeros((len(codes), NUM_CLASSES), dtype=np.float32)
|
| 147 |
+
for motif, rules in _MOTIFS.items():
|
| 148 |
+
column = _motif_log_mask(codes, rules)
|
| 149 |
+
for state in _MOTIF_STATES[motif]:
|
| 150 |
+
mask[:, CLASS_INDEX[state]] = column
|
| 151 |
+
return mask
|
| 152 |
+
|
| 153 |
+
|
| 154 |
+
@numba.njit(cache=True)
|
| 155 |
+
def _viterbi(log_emissions: np.ndarray, log_transitions: np.ndarray) -> np.ndarray:
|
| 156 |
+
length, num_states = log_emissions.shape
|
| 157 |
+
backpointers = np.zeros((length, num_states), dtype=np.int8)
|
| 158 |
+
scores = log_emissions[0].copy()
|
| 159 |
+
stepped = np.empty(num_states, dtype=np.float32)
|
| 160 |
+
for t in range(1, length):
|
| 161 |
+
for j in range(num_states):
|
| 162 |
+
best = -np.inf
|
| 163 |
+
best_i = 0
|
| 164 |
+
for i in range(num_states):
|
| 165 |
+
value = scores[i] + log_transitions[i, j]
|
| 166 |
+
if value > best:
|
| 167 |
+
best = value
|
| 168 |
+
best_i = i
|
| 169 |
+
stepped[j] = best + log_emissions[t, j]
|
| 170 |
+
backpointers[t, j] = best_i
|
| 171 |
+
# Rescale by the running max so scores stay bounded; argmaxes are unchanged.
|
| 172 |
+
offset = max(stepped.max(), np.float32(-1e30))
|
| 173 |
+
for j in range(num_states):
|
| 174 |
+
scores[j] = stepped[j] - offset
|
| 175 |
+
path = np.empty(length, dtype=np.int8)
|
| 176 |
+
path[-1] = np.argmax(scores)
|
| 177 |
+
for t in range(length - 1, 0, -1):
|
| 178 |
+
path[t - 1] = backpointers[t, path[t]]
|
| 179 |
+
return path
|
| 180 |
+
|
| 181 |
+
|
| 182 |
+
def viterbi(
|
| 183 |
+
log_emissions: np.ndarray, constrain_codes: np.ndarray | None
|
| 184 |
+
) -> np.ndarray:
|
| 185 |
+
"""Most likely state path of one strand, read 5'->3'.
|
| 186 |
+
|
| 187 |
+
Args:
|
| 188 |
+
log_emissions: (L, 15) log-probabilities in transcription direction.
|
| 189 |
+
constrain_codes: Base codes in the same direction, or None to decode
|
| 190 |
+
without motif constraints.
|
| 191 |
+
|
| 192 |
+
Returns:
|
| 193 |
+
(L,) int8 class indices.
|
| 194 |
+
"""
|
| 195 |
+
log_emissions = np.asarray(log_emissions, dtype=np.float32)
|
| 196 |
+
if constrain_codes is not None:
|
| 197 |
+
log_emissions = log_emissions + constraint_log_mask(constrain_codes)
|
| 198 |
+
return _viterbi(np.ascontiguousarray(log_emissions), LOG_TRANSITIONS)
|
| 199 |
+
|
| 200 |
+
|
| 201 |
+
def tile_windows(length: int, window: int) -> list[tuple[int, int]]:
|
| 202 |
+
"""(window start, first bp the window owns) covering [0, length) end to end.
|
| 203 |
+
|
| 204 |
+
The last window is pulled back to end on the final base; the bp it shares
|
| 205 |
+
with the previous window stay with the previous one.
|
| 206 |
+
"""
|
| 207 |
+
if length < window:
|
| 208 |
+
raise ValueError(f"Sequence of {length} bp is shorter than one window.")
|
| 209 |
+
starts = list(range(0, length - window + 1, window))
|
| 210 |
+
tiles = [(s, 0) for s in starts]
|
| 211 |
+
if starts[-1] + window < length:
|
| 212 |
+
last = length - window
|
| 213 |
+
tiles.append((last, starts[-1] + window - last))
|
| 214 |
+
return tiles
|
| 215 |
+
|
| 216 |
+
|
| 217 |
+
def annotate_sequence(
|
| 218 |
+
sequence: str,
|
| 219 |
+
predict: Callable[[str], tuple[np.ndarray, np.ndarray]],
|
| 220 |
+
window: int,
|
| 221 |
+
constrained: bool = True,
|
| 222 |
+
) -> tuple[np.ndarray, np.ndarray]:
|
| 223 |
+
"""Decodes both strands of a chromosome window by window.
|
| 224 |
+
|
| 225 |
+
Args:
|
| 226 |
+
sequence: Chromosome sequence (soft-masking allowed).
|
| 227 |
+
predict: Maps a window's sequence to (L, 15) log-probabilities for the
|
| 228 |
+
window itself and for its reverse complement.
|
| 229 |
+
window: Window length in bp.
|
| 230 |
+
constrained: Whether to apply the motif constraints.
|
| 231 |
+
|
| 232 |
+
Returns:
|
| 233 |
+
Plus- and minus-strand state paths in genomic order, each (len(sequence),).
|
| 234 |
+
"""
|
| 235 |
+
codes = encode_bases(sequence)
|
| 236 |
+
plus = np.zeros(len(sequence), dtype=np.int8)
|
| 237 |
+
minus = np.zeros(len(sequence), dtype=np.int8)
|
| 238 |
+
for start, keep in tile_windows(len(sequence), window):
|
| 239 |
+
end = start + window
|
| 240 |
+
log_probs_plus, log_probs_rc = predict(sequence[start:end])
|
| 241 |
+
window_codes = codes[start:end]
|
| 242 |
+
rc_codes = reverse_complement_codes(window_codes)
|
| 243 |
+
states_plus = viterbi(log_probs_plus, window_codes if constrained else None)
|
| 244 |
+
states_rc = viterbi(log_probs_rc, rc_codes if constrained else None)
|
| 245 |
+
plus[start + keep : end] = states_plus[keep:]
|
| 246 |
+
minus[start + keep : end] = states_rc[::-1][keep:]
|
| 247 |
+
return plus, minus
|
| 248 |
+
|
| 249 |
+
|
| 250 |
+
_INTRON_STATES = {CLASS_INDEX[f"intron_p{p}"] for p in range(3)}
|
| 251 |
+
# Codon position at the 5' end of a CDS run, which sets its GFF3 phase.
|
| 252 |
+
_CODON_POSITION: dict[int, int] = {
|
| 253 |
+
CLASS_INDEX[f"{kind}_p{p}"]: p
|
| 254 |
+
for kind in ("CDS", "splice_acceptor", "splice_donor")
|
| 255 |
+
for p in range(3)
|
| 256 |
+
} | {CLASS_INDEX["start_codon"]: 0, CLASS_INDEX["stop_codon"]: 0}
|
| 257 |
+
_STOP_CODONS = {(T, A, A), (T, A, G), (T, G, A)}
|
| 258 |
+
|
| 259 |
+
|
| 260 |
+
def _has_internal_stop(
|
| 261 |
+
gene_states: np.ndarray, gene_codes: np.ndarray, strand: str
|
| 262 |
+
) -> bool:
|
| 263 |
+
"""Whether the spliced CDS holds an in-frame stop before its last codon."""
|
| 264 |
+
is_intron = np.isin(gene_states, list(_INTRON_STATES))
|
| 265 |
+
cds = gene_codes[~is_intron]
|
| 266 |
+
if strand == "-":
|
| 267 |
+
cds = reverse_complement_codes(cds)
|
| 268 |
+
codons = cds[: len(cds) // 3 * 3].reshape(-1, 3)[:-1]
|
| 269 |
+
return any(tuple(int(b) for b in codon) in _STOP_CODONS for codon in codons)
|
| 270 |
+
|
| 271 |
+
|
| 272 |
+
def find_genes(
|
| 273 |
+
states: np.ndarray,
|
| 274 |
+
strand: str,
|
| 275 |
+
chrom: str,
|
| 276 |
+
codes: np.ndarray | None = None,
|
| 277 |
+
first_gene_id: int = 0,
|
| 278 |
+
) -> list[str]:
|
| 279 |
+
"""Turns one strand's state path into GFF3 gene/CDS/intron lines.
|
| 280 |
+
|
| 281 |
+
A gene is a maximal run of non-intergenic states. Genes with an internal
|
| 282 |
+
in-frame stop codon are dropped when ``codes`` is given.
|
| 283 |
+
|
| 284 |
+
Args:
|
| 285 |
+
states: (L,) class indices of one strand, in genomic order.
|
| 286 |
+
strand: "+" or "-".
|
| 287 |
+
chrom: Sequence name for the GFF3 seqid column.
|
| 288 |
+
codes: Optional base codes of the chromosome, in genomic order.
|
| 289 |
+
first_gene_id: Genes are numbered from first_gene_id + 1.
|
| 290 |
+
|
| 291 |
+
Returns:
|
| 292 |
+
GFF3 lines, each gene followed by its sub-features.
|
| 293 |
+
"""
|
| 294 |
+
is_genic = np.concatenate([[False], states != CLASS_INDEX["intergenic"], [False]])
|
| 295 |
+
edges = np.flatnonzero(is_genic[1:] != is_genic[:-1])
|
| 296 |
+
feature_type = np.where(
|
| 297 |
+
np.isin(states, list(_INTRON_STATES)), 2, np.where(states == 0, 0, 1)
|
| 298 |
+
)
|
| 299 |
+
type_changes = np.flatnonzero(feature_type[1:] != feature_type[:-1]) + 1
|
| 300 |
+
lines: list[str] = []
|
| 301 |
+
gene_id = first_gene_id
|
| 302 |
+
for gene_start, gene_end in zip(edges[::2], edges[1::2], strict=True):
|
| 303 |
+
gene_states = states[gene_start:gene_end]
|
| 304 |
+
if codes is not None and _has_internal_stop(
|
| 305 |
+
gene_states, codes[gene_start:gene_end], strand
|
| 306 |
+
):
|
| 307 |
+
continue
|
| 308 |
+
gene_id += 1
|
| 309 |
+
gid = f"gene_{gene_id}_{'p' if strand == '+' else 'm'}"
|
| 310 |
+
lines.append(
|
| 311 |
+
f"{chrom}\tNTv3\tgene\t{gene_start + 1}\t{gene_end}\t.\t{strand}\t.\tID={gid}"
|
| 312 |
+
)
|
| 313 |
+
lo = np.searchsorted(type_changes, gene_start, "right")
|
| 314 |
+
hi = np.searchsorted(type_changes, gene_end, "left")
|
| 315 |
+
bounds = [gene_start, *type_changes[lo:hi].tolist(), gene_end]
|
| 316 |
+
for feat_start, feat_end in itertools.pairwise(bounds):
|
| 317 |
+
is_cds = feature_type[feat_start] == 1
|
| 318 |
+
phase = "."
|
| 319 |
+
if is_cds:
|
| 320 |
+
five_prime = feat_start if strand == "+" else feat_end - 1
|
| 321 |
+
phase = str((3 - _CODON_POSITION[int(states[five_prime])]) % 3)
|
| 322 |
+
kind = "CDS" if is_cds else "intron"
|
| 323 |
+
lines.append(
|
| 324 |
+
f"{chrom}\tNTv3\t{kind}\t{feat_start + 1}\t{feat_end}\t.\t{strand}"
|
| 325 |
+
f"\t{phase}\tID={gid}_{kind}_{feat_start + 1};Parent={gid}"
|
| 326 |
+
)
|
| 327 |
+
return lines
|
| 328 |
+
|
| 329 |
+
|
| 330 |
+
def write_gff3(path: str | Path, lines: list[str]) -> None:
|
| 331 |
+
"""Writes GFF3 lines sorted by (seqid, start), gzipped if path ends in .gz."""
|
| 332 |
+
ordered = sorted(
|
| 333 |
+
lines, key=lambda line: (line.split("\t")[0], int(line.split("\t")[3]))
|
| 334 |
+
)
|
| 335 |
+
text = "##gff-version 3\n" + "\n".join(ordered) + "\n"
|
| 336 |
+
opener = gzip.open if str(path).endswith(".gz") else open
|
| 337 |
+
with opener(path, "wt") as handle:
|
| 338 |
+
handle.write(text)
|
src/genome_annotation/evaluation.py
ADDED
|
@@ -0,0 +1,672 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""Scores a predicted GFF3 against a reference the way the NTv3 benchmark did.
|
| 2 |
+
|
| 3 |
+
Tiberius / BRAKER-style (Gabriel et al. 2024): a CDS is correct if chromosome,
|
| 4 |
+
start, end, strand and phase all match; a gene is correct if its whole set of CDS
|
| 5 |
+
matches a reference gene. Reference genes are restricted to those the NTv3
|
| 6 |
+
training labels keep, predicted genes below 201 nt of CDS are dropped, and
|
| 7 |
+
predicted CDS in pseudogene regions away from reference genes are ignored.
|
| 8 |
+
|
| 9 |
+
Standard library only, so it runs anywhere the notebook does.
|
| 10 |
+
"""
|
| 11 |
+
|
| 12 |
+
import bisect
|
| 13 |
+
import gzip
|
| 14 |
+
import re
|
| 15 |
+
from collections import defaultdict
|
| 16 |
+
from collections.abc import Callable, Iterable, Iterator, Mapping
|
| 17 |
+
from dataclasses import dataclass, field
|
| 18 |
+
from pathlib import Path
|
| 19 |
+
from typing import IO, NamedTuple
|
| 20 |
+
|
| 21 |
+
CdsKey = tuple[str, int, int, str, str] # (chrom, start, end, strand, phase), 1-based
|
| 22 |
+
Intervals = dict[str, list[tuple[int, int]]]
|
| 23 |
+
|
| 24 |
+
# GENCODE keeps pseudogenes as generic rows with a pseudogene ``gene_type``.
|
| 25 |
+
_PSEUDOGENE_GENE_TYPE = re.compile(r"(?:^|;)gene_type=[^;]*pseudogene")
|
| 26 |
+
|
| 27 |
+
LEVELS = {
|
| 28 |
+
"CDS": "Exon",
|
| 29 |
+
"Gene": "Gene",
|
| 30 |
+
"CDS_no_phase": "Exon (no phase)",
|
| 31 |
+
"Gene_no_phase": "Gene (no phase)",
|
| 32 |
+
}
|
| 33 |
+
|
| 34 |
+
|
| 35 |
+
# ---------------------------------------------------------------------------
|
| 36 |
+
# GFF reading
|
| 37 |
+
# ---------------------------------------------------------------------------
|
| 38 |
+
|
| 39 |
+
|
| 40 |
+
class GffRow(NamedTuple):
|
| 41 |
+
"""One GFF3 feature row (1-based closed coordinates)."""
|
| 42 |
+
|
| 43 |
+
seqid: str
|
| 44 |
+
type: str
|
| 45 |
+
start: int
|
| 46 |
+
end: int
|
| 47 |
+
strand: str
|
| 48 |
+
phase: str
|
| 49 |
+
attribute: str
|
| 50 |
+
|
| 51 |
+
def attributes(self) -> dict[str, str]:
|
| 52 |
+
"""Column 9 as ``{tag: value}``."""
|
| 53 |
+
out = {}
|
| 54 |
+
for item in self.attribute.split(";"):
|
| 55 |
+
if "=" in item:
|
| 56 |
+
key, value = item.strip().split("=", 1)
|
| 57 |
+
out[key.strip()] = value.strip()
|
| 58 |
+
return out
|
| 59 |
+
|
| 60 |
+
def get_id(self) -> str | None:
|
| 61 |
+
"""First ``ID=`` value."""
|
| 62 |
+
for item in self.attribute.split(";"):
|
| 63 |
+
item = item.strip()
|
| 64 |
+
if item.startswith("ID="):
|
| 65 |
+
return item[3:].split(",")[0].strip() or None
|
| 66 |
+
return None
|
| 67 |
+
|
| 68 |
+
def get_parents(self) -> list[str]:
|
| 69 |
+
"""All ``Parent=`` values."""
|
| 70 |
+
for item in self.attribute.split(";"):
|
| 71 |
+
item = item.strip()
|
| 72 |
+
if item.startswith("Parent="):
|
| 73 |
+
return [v.strip() for v in item[7:].split(",") if v.strip()]
|
| 74 |
+
return []
|
| 75 |
+
|
| 76 |
+
|
| 77 |
+
def open_text(path: str | Path) -> IO[str]:
|
| 78 |
+
"""Opens a plain or gzipped text file."""
|
| 79 |
+
if str(path).endswith(".gz"):
|
| 80 |
+
return gzip.open(path, "rt", encoding="utf-8")
|
| 81 |
+
return open(path, encoding="utf-8")
|
| 82 |
+
|
| 83 |
+
|
| 84 |
+
def iter_gff(
|
| 85 |
+
path: str | Path, chromosomes: Iterable[str] | None = None
|
| 86 |
+
) -> Iterator[GffRow]:
|
| 87 |
+
"""Yields feature rows, skipping comments and malformed lines."""
|
| 88 |
+
keep = set(chromosomes) if chromosomes is not None else None
|
| 89 |
+
with open_text(path) as handle:
|
| 90 |
+
for line in handle:
|
| 91 |
+
if not line.strip() or line.startswith("#"):
|
| 92 |
+
continue
|
| 93 |
+
cols = line.rstrip("\n").split("\t")
|
| 94 |
+
if len(cols) < 9 or (keep is not None and cols[0] not in keep):
|
| 95 |
+
continue
|
| 96 |
+
try:
|
| 97 |
+
yield GffRow(cols[0], cols[2], int(cols[3]), int(cols[4]), *cols[6:9])
|
| 98 |
+
except ValueError:
|
| 99 |
+
continue
|
| 100 |
+
|
| 101 |
+
|
| 102 |
+
def merge_intervals(intervals: Iterable[tuple[int, int]]) -> list[tuple[int, int]]:
|
| 103 |
+
"""Merges overlapping or adjacent 1-based closed intervals."""
|
| 104 |
+
merged: list[tuple[int, int]] = []
|
| 105 |
+
for start, end in sorted(intervals):
|
| 106 |
+
if merged and start <= merged[-1][1] + 1:
|
| 107 |
+
merged[-1] = (merged[-1][0], max(merged[-1][1], end))
|
| 108 |
+
else:
|
| 109 |
+
merged.append((start, end))
|
| 110 |
+
return merged
|
| 111 |
+
|
| 112 |
+
|
| 113 |
+
# ---------------------------------------------------------------------------
|
| 114 |
+
# Reference preparation
|
| 115 |
+
# ---------------------------------------------------------------------------
|
| 116 |
+
|
| 117 |
+
|
| 118 |
+
def write_longest_isoform(
|
| 119 |
+
source: str | Path, output: str | Path, keep_pseudogenes: bool = False
|
| 120 |
+
) -> None:
|
| 121 |
+
"""Keeps each protein-coding gene with its longest-CDS transcript.
|
| 122 |
+
|
| 123 |
+
Turns a raw multi-isoform annotation (e.g. an NCBI ``*_genomic.gff.gz``) into
|
| 124 |
+
the single-isoform reference the benchmark scores against. Rows must be in
|
| 125 |
+
the usual gene -> transcript -> exon/CDS order.
|
| 126 |
+
|
| 127 |
+
Args:
|
| 128 |
+
source: Raw GFF3, plain or gzipped.
|
| 129 |
+
output: Destination GFF3, gzipped if it ends in ``.gz``.
|
| 130 |
+
keep_pseudogenes: Also copy ``pseudogene`` rows, which turns on
|
| 131 |
+
pseudogene filtering. The benchmark references carried none outside
|
| 132 |
+
hg38, so leave off to reproduce it.
|
| 133 |
+
"""
|
| 134 |
+
opener = gzip.open if str(output).endswith(".gz") else open
|
| 135 |
+
transcripts: dict[str, list[str]] = {}
|
| 136 |
+
lengths: dict[str, int] = {}
|
| 137 |
+
gene_line: str | None = None
|
| 138 |
+
gene_id: str | None = None
|
| 139 |
+
transcript_id: str | None = None
|
| 140 |
+
keep_gene = False
|
| 141 |
+
|
| 142 |
+
def flush(out: IO[str]) -> None:
|
| 143 |
+
if transcripts and gene_line is not None:
|
| 144 |
+
longest = max(transcripts, key=lambda tid: lengths[tid])
|
| 145 |
+
out.write(gene_line)
|
| 146 |
+
out.writelines(transcripts[longest])
|
| 147 |
+
|
| 148 |
+
with open_text(source) as src, opener(output, "wt", encoding="utf-8") as out:
|
| 149 |
+
out.write("##gff-version 3\n")
|
| 150 |
+
for line in src:
|
| 151 |
+
if not line.strip() or line.startswith("#"):
|
| 152 |
+
continue
|
| 153 |
+
cols = line.rstrip("\n").split("\t")
|
| 154 |
+
if len(cols) < 9:
|
| 155 |
+
continue
|
| 156 |
+
row = GffRow(cols[0], cols[2], int(cols[3]), int(cols[4]), *cols[6:9])
|
| 157 |
+
if row.type in ("gene", "pseudogene"):
|
| 158 |
+
flush(out)
|
| 159 |
+
if row.type == "pseudogene" and keep_pseudogenes:
|
| 160 |
+
out.write(line)
|
| 161 |
+
attrs = dict(
|
| 162 |
+
item.split("=", 1)
|
| 163 |
+
for item in row.attribute.split(";")
|
| 164 |
+
if "=" in item
|
| 165 |
+
)
|
| 166 |
+
biotype = attrs.get("gene_type") or attrs.get("gene_biotype")
|
| 167 |
+
keep_gene = biotype == "protein_coding" or not biotype
|
| 168 |
+
transcripts, lengths = {}, {}
|
| 169 |
+
gene_id, gene_line = row.get_id(), line
|
| 170 |
+
if not keep_gene:
|
| 171 |
+
continue
|
| 172 |
+
if row.type in ("mRNA", "transcript"):
|
| 173 |
+
parents = row.get_parents()
|
| 174 |
+
if parents and gene_id is not None and parents[0] == gene_id:
|
| 175 |
+
transcript_id = row.get_id()
|
| 176 |
+
if transcript_id is not None:
|
| 177 |
+
transcripts[transcript_id] = [line]
|
| 178 |
+
lengths[transcript_id] = 0
|
| 179 |
+
elif (
|
| 180 |
+
row.type in ("exon", "CDS")
|
| 181 |
+
and transcript_id is not None
|
| 182 |
+
and transcript_id in transcripts
|
| 183 |
+
):
|
| 184 |
+
# Children join the last transcript seen, as in the pipeline.
|
| 185 |
+
transcripts[transcript_id].append(line)
|
| 186 |
+
if row.type == "CDS":
|
| 187 |
+
lengths[transcript_id] += row.end - row.start + 1
|
| 188 |
+
flush(out)
|
| 189 |
+
|
| 190 |
+
|
| 191 |
+
@dataclass
|
| 192 |
+
class _CodingGene:
|
| 193 |
+
gene_id: str
|
| 194 |
+
name: str
|
| 195 |
+
chrom: str
|
| 196 |
+
strand: str
|
| 197 |
+
cds: list[tuple[int, int]] = field(default_factory=list)
|
| 198 |
+
|
| 199 |
+
|
| 200 |
+
def _parse_coding_genes(
|
| 201 |
+
path: str | Path, chromosomes: Iterable[str] | None = None
|
| 202 |
+
) -> dict[str, _CodingGene]:
|
| 203 |
+
genes: dict[str, _CodingGene] = {}
|
| 204 |
+
transcript_to_gene: dict[str, str] = {}
|
| 205 |
+
cds_by_transcript: dict[str, list[tuple[int, int]]] = defaultdict(list)
|
| 206 |
+
for row in iter_gff(path, chromosomes):
|
| 207 |
+
if row.type == "gene":
|
| 208 |
+
attrs = row.attributes()
|
| 209 |
+
biotype = attrs.get("gene_type") or attrs.get("gene_biotype")
|
| 210 |
+
gene_id = row.get_id()
|
| 211 |
+
if (biotype == "protein_coding" or not biotype) and gene_id:
|
| 212 |
+
if row.strand in ("+", "-"):
|
| 213 |
+
name = attrs.get("Name") or gene_id.removeprefix("gene-")
|
| 214 |
+
genes[gene_id] = _CodingGene(gene_id, name, row.seqid, row.strand)
|
| 215 |
+
elif row.type in ("mRNA", "transcript"):
|
| 216 |
+
parents, tid = row.get_parents(), row.get_id()
|
| 217 |
+
if parents and tid:
|
| 218 |
+
transcript_to_gene[tid] = parents[0]
|
| 219 |
+
elif row.type == "CDS" and row.phase != ".":
|
| 220 |
+
parents = row.get_parents()
|
| 221 |
+
if parents:
|
| 222 |
+
cds_by_transcript[parents[0]].append((row.start, row.end))
|
| 223 |
+
for tid, gid in transcript_to_gene.items():
|
| 224 |
+
if gid in genes:
|
| 225 |
+
genes[gid].cds.extend(cds_by_transcript.get(tid, []))
|
| 226 |
+
for gene in genes.values():
|
| 227 |
+
gene.cds.sort()
|
| 228 |
+
return genes
|
| 229 |
+
|
| 230 |
+
|
| 231 |
+
def _intervals_overlap(a: list[tuple[int, int]], b: list[tuple[int, int]]) -> bool:
|
| 232 |
+
i = j = 0
|
| 233 |
+
while i < len(a) and j < len(b):
|
| 234 |
+
if a[i][0] <= b[j][1] and b[j][0] <= a[i][1]:
|
| 235 |
+
return True
|
| 236 |
+
if a[i][1] < b[j][1]:
|
| 237 |
+
i += 1
|
| 238 |
+
else:
|
| 239 |
+
j += 1
|
| 240 |
+
return False
|
| 241 |
+
|
| 242 |
+
|
| 243 |
+
def _cluster_by_span(
|
| 244 |
+
gene_ids: list[str], genes: dict[str, _CodingGene]
|
| 245 |
+
) -> list[list[str]]:
|
| 246 |
+
ordered = sorted(gene_ids, key=lambda g: (genes[g].cds[0][0], genes[g].cds[-1][1]))
|
| 247 |
+
clusters: list[list[str]] = []
|
| 248 |
+
end = -1
|
| 249 |
+
for gid in ordered:
|
| 250 |
+
if clusters and genes[gid].cds[0][0] <= end:
|
| 251 |
+
clusters[-1].append(gid)
|
| 252 |
+
end = max(end, genes[gid].cds[-1][1])
|
| 253 |
+
else:
|
| 254 |
+
clusters.append([gid])
|
| 255 |
+
end = genes[gid].cds[-1][1]
|
| 256 |
+
return clusters
|
| 257 |
+
|
| 258 |
+
|
| 259 |
+
def _is_readthrough(name: str, other_names: set[str]) -> bool:
|
| 260 |
+
parts = name.split("-")
|
| 261 |
+
return any(
|
| 262 |
+
"-".join(parts[:i]) in other_names and "-".join(parts[i:]) in other_names
|
| 263 |
+
for i in range(1, len(parts))
|
| 264 |
+
)
|
| 265 |
+
|
| 266 |
+
|
| 267 |
+
def _cds_length(gene: _CodingGene) -> int:
|
| 268 |
+
return sum(end - start + 1 for start, end in gene.cds)
|
| 269 |
+
|
| 270 |
+
|
| 271 |
+
def _conflicting_gene_ids(genes: dict[str, _CodingGene]) -> set[str]:
|
| 272 |
+
"""Readthrough fusions, then the shorter of two genes sharing CDS bases."""
|
| 273 |
+
by_strand: dict[tuple[str, str], list[str]] = defaultdict(list)
|
| 274 |
+
for gid, gene in genes.items():
|
| 275 |
+
by_strand[(gene.chrom, gene.strand)].append(gid)
|
| 276 |
+
conflicts: set[str] = set()
|
| 277 |
+
for gids in by_strand.values():
|
| 278 |
+
for cluster in _cluster_by_span(gids, genes):
|
| 279 |
+
if len(cluster) < 2:
|
| 280 |
+
continue
|
| 281 |
+
names = {gid: genes[gid].name for gid in cluster}
|
| 282 |
+
all_names = set(names.values())
|
| 283 |
+
remaining = []
|
| 284 |
+
for gid in cluster:
|
| 285 |
+
if _is_readthrough(names[gid], all_names - {names[gid]}):
|
| 286 |
+
conflicts.add(gid)
|
| 287 |
+
else:
|
| 288 |
+
remaining.append(gid)
|
| 289 |
+
kept: list[str] = []
|
| 290 |
+
for gid in remaining:
|
| 291 |
+
clash = next(
|
| 292 |
+
(
|
| 293 |
+
other
|
| 294 |
+
for other in kept
|
| 295 |
+
if genes[gid].cds[0][0] <= genes[other].cds[-1][1]
|
| 296 |
+
and _intervals_overlap(genes[other].cds, genes[gid].cds)
|
| 297 |
+
),
|
| 298 |
+
None,
|
| 299 |
+
)
|
| 300 |
+
if clash is None:
|
| 301 |
+
kept.append(gid)
|
| 302 |
+
elif _cds_length(genes[gid]) > _cds_length(genes[clash]):
|
| 303 |
+
conflicts.add(clash)
|
| 304 |
+
kept.remove(clash)
|
| 305 |
+
kept.append(gid)
|
| 306 |
+
else:
|
| 307 |
+
conflicts.add(gid)
|
| 308 |
+
return conflicts
|
| 309 |
+
|
| 310 |
+
|
| 311 |
+
def scorable_reference_genes(
|
| 312 |
+
path: str | Path, chromosomes: Iterable[str] | None = None
|
| 313 |
+
) -> set[str]:
|
| 314 |
+
"""IDs of the reference genes the benchmark scores.
|
| 315 |
+
|
| 316 |
+
Drops genes with no CDS, overlapping CDS, readthrough fusions and CDS shared
|
| 317 |
+
with a longer gene, then keeps the longest gene of every same-strand cluster
|
| 318 |
+
of overlapping CDS spans (so a gene nested in another's intron goes too).
|
| 319 |
+
"""
|
| 320 |
+
genes = {
|
| 321 |
+
gid: gene
|
| 322 |
+
for gid, gene in _parse_coding_genes(path, chromosomes).items()
|
| 323 |
+
if gene.cds
|
| 324 |
+
and all(b[0] > a[1] for a, b in zip(gene.cds, gene.cds[1:], strict=False))
|
| 325 |
+
}
|
| 326 |
+
conflicts = _conflicting_gene_ids(genes)
|
| 327 |
+
genes = {gid: gene for gid, gene in genes.items() if gid not in conflicts}
|
| 328 |
+
by_strand: dict[tuple[str, str], list[str]] = defaultdict(list)
|
| 329 |
+
for gid, gene in genes.items():
|
| 330 |
+
by_strand[(gene.chrom, gene.strand)].append(gid)
|
| 331 |
+
return {
|
| 332 |
+
max(cluster, key=lambda g: (_cds_length(genes[g]), g))
|
| 333 |
+
for gids in by_strand.values()
|
| 334 |
+
for cluster in _cluster_by_span(gids, genes)
|
| 335 |
+
}
|
| 336 |
+
|
| 337 |
+
|
| 338 |
+
# ---------------------------------------------------------------------------
|
| 339 |
+
# Scoring
|
| 340 |
+
# ---------------------------------------------------------------------------
|
| 341 |
+
|
| 342 |
+
|
| 343 |
+
@dataclass
|
| 344 |
+
class Gene:
|
| 345 |
+
"""A gene reduced to what the scorer compares."""
|
| 346 |
+
|
| 347 |
+
gene_id: str
|
| 348 |
+
chrom: str
|
| 349 |
+
strand: str
|
| 350 |
+
cds_keys: list[CdsKey] = field(default_factory=list)
|
| 351 |
+
|
| 352 |
+
|
| 353 |
+
def parse_cds_and_genes(
|
| 354 |
+
path: str | Path, chromosomes: Iterable[str] | None = None
|
| 355 |
+
) -> tuple[set[CdsKey], dict[str, Gene]]:
|
| 356 |
+
"""All CDS keys, and each gene's CDS keys (CDS reach genes via transcripts)."""
|
| 357 |
+
cds: set[CdsKey] = set()
|
| 358 |
+
genes: dict[str, Gene] = {}
|
| 359 |
+
transcript_to_gene: dict[str, str] = {}
|
| 360 |
+
for row in iter_gff(path, chromosomes):
|
| 361 |
+
kind = row.type.lower()
|
| 362 |
+
if kind in ("gene", "pseudogene"):
|
| 363 |
+
gid = row.get_id()
|
| 364 |
+
if gid and gid not in genes:
|
| 365 |
+
genes[gid] = Gene(gid, row.seqid, row.strand)
|
| 366 |
+
elif kind in ("mrna", "transcript"):
|
| 367 |
+
tid, parents = row.get_id(), row.get_parents()
|
| 368 |
+
if tid and parents:
|
| 369 |
+
transcript_to_gene[tid] = parents[0]
|
| 370 |
+
elif kind == "cds":
|
| 371 |
+
parents = row.get_parents()
|
| 372 |
+
parent = parents[0] if parents else ""
|
| 373 |
+
gid = transcript_to_gene.get(parent, parent)
|
| 374 |
+
key = (row.seqid, row.start, row.end, row.strand, row.phase)
|
| 375 |
+
cds.add(key)
|
| 376 |
+
if gid:
|
| 377 |
+
genes.setdefault(gid, Gene(gid, row.seqid, row.strand))
|
| 378 |
+
genes[gid].cds_keys.append(key)
|
| 379 |
+
return cds, genes
|
| 380 |
+
|
| 381 |
+
|
| 382 |
+
def parse_pseudogene_regions(
|
| 383 |
+
path: str | Path, chromosomes: Iterable[str] | None = None
|
| 384 |
+
) -> list[tuple[str, int, int]]:
|
| 385 |
+
"""``pseudogene`` rows, or rows whose ``gene_type`` is a pseudogene (GENCODE)."""
|
| 386 |
+
return [
|
| 387 |
+
(row.seqid, row.start, row.end)
|
| 388 |
+
for row in iter_gff(path, chromosomes)
|
| 389 |
+
if row.type == "pseudogene" or _PSEUDOGENE_GENE_TYPE.search(row.attribute)
|
| 390 |
+
]
|
| 391 |
+
|
| 392 |
+
|
| 393 |
+
def _interval_index(regions: Iterable[tuple[str, int, int]]) -> Intervals:
|
| 394 |
+
by_chrom: dict[str, list[tuple[int, int]]] = defaultdict(list)
|
| 395 |
+
for chrom, start, end in regions:
|
| 396 |
+
by_chrom[chrom].append((start, end))
|
| 397 |
+
return {chrom: merge_intervals(ivs) for chrom, ivs in by_chrom.items()}
|
| 398 |
+
|
| 399 |
+
|
| 400 |
+
def _overlaps_any(chrom: str, start: int, end: int, index: Intervals) -> bool:
|
| 401 |
+
intervals = index.get(chrom)
|
| 402 |
+
if not intervals:
|
| 403 |
+
return False
|
| 404 |
+
i = bisect.bisect_left(intervals, (end + 1,)) - 1
|
| 405 |
+
return i >= 0 and intervals[i][1] >= start
|
| 406 |
+
|
| 407 |
+
|
| 408 |
+
@dataclass
|
| 409 |
+
class ScoringSets:
|
| 410 |
+
"""Reference and prediction after every benchmark filter."""
|
| 411 |
+
|
| 412 |
+
ref_cds: set[CdsKey]
|
| 413 |
+
ref_genes: dict[str, Gene]
|
| 414 |
+
pred_cds: set[CdsKey]
|
| 415 |
+
pred_genes: dict[str, Gene]
|
| 416 |
+
|
| 417 |
+
|
| 418 |
+
def prepare_scoring_sets(
|
| 419 |
+
reference: str | Path,
|
| 420 |
+
prediction: str | Path,
|
| 421 |
+
pseudogenes: str | Path | None = None,
|
| 422 |
+
chromosomes: Iterable[str] | None = None,
|
| 423 |
+
min_pred_gene_cds_length: int = 201,
|
| 424 |
+
full_reference: bool = False,
|
| 425 |
+
) -> ScoringSets:
|
| 426 |
+
"""Parses both files and applies the benchmark filters.
|
| 427 |
+
|
| 428 |
+
Args:
|
| 429 |
+
reference: Single-isoform reference GFF3.
|
| 430 |
+
prediction: Predicted GFF3.
|
| 431 |
+
pseudogenes: GFF3 to read pseudogene regions from; the benchmark passed
|
| 432 |
+
the reference itself.
|
| 433 |
+
chromosomes: Seqids to score; None scores the whole reference, so a
|
| 434 |
+
contig the prediction skips counts as missed.
|
| 435 |
+
min_pred_gene_cds_length: Predicted genes with less CDS (nt) are dropped.
|
| 436 |
+
full_reference: Score every reference gene with a CDS instead of the
|
| 437 |
+
ones NTv3 training labels keep.
|
| 438 |
+
|
| 439 |
+
Returns:
|
| 440 |
+
The filtered CDS and gene sets.
|
| 441 |
+
"""
|
| 442 |
+
chroms = sorted(set(chromosomes)) if chromosomes is not None else None
|
| 443 |
+
ref_cds, ref_genes = parse_cds_and_genes(reference, chroms)
|
| 444 |
+
if full_reference:
|
| 445 |
+
ref_genes = {gid: gene for gid, gene in ref_genes.items() if gene.cds_keys}
|
| 446 |
+
else:
|
| 447 |
+
keep = scorable_reference_genes(reference, chroms)
|
| 448 |
+
ref_genes = {gid: gene for gid, gene in ref_genes.items() if gid in keep}
|
| 449 |
+
ref_cds = {key for gene in ref_genes.values() for key in gene.cds_keys}
|
| 450 |
+
|
| 451 |
+
pred_cds, pred_genes = parse_cds_and_genes(prediction, chroms)
|
| 452 |
+
if min_pred_gene_cds_length > 0:
|
| 453 |
+
short = {
|
| 454 |
+
gid
|
| 455 |
+
for gid, gene in pred_genes.items()
|
| 456 |
+
if sum(end - start + 1 for _, start, end, _, _ in gene.cds_keys)
|
| 457 |
+
< min_pred_gene_cds_length
|
| 458 |
+
}
|
| 459 |
+
pred_cds -= {key for gid in short for key in pred_genes[gid].cds_keys}
|
| 460 |
+
pred_genes = {gid: g for gid, g in pred_genes.items() if gid not in short}
|
| 461 |
+
|
| 462 |
+
if pseudogenes is not None:
|
| 463 |
+
ref_index = _interval_index((c, s, e) for c, s, e, _, _ in ref_cds)
|
| 464 |
+
away_from_genes = _interval_index(
|
| 465 |
+
region
|
| 466 |
+
for region in parse_pseudogene_regions(pseudogenes, chroms)
|
| 467 |
+
if not _overlaps_any(*region, ref_index)
|
| 468 |
+
)
|
| 469 |
+
pred_cds = {k for k in pred_cds if not _overlaps_any(*k[:3], away_from_genes)}
|
| 470 |
+
pred_genes = {
|
| 471 |
+
gid: Gene(gid, g.chrom, g.strand, kept)
|
| 472 |
+
for gid, g in pred_genes.items()
|
| 473 |
+
if (kept := [k for k in g.cds_keys if k in pred_cds])
|
| 474 |
+
}
|
| 475 |
+
return ScoringSets(ref_cds, ref_genes, pred_cds, pred_genes)
|
| 476 |
+
|
| 477 |
+
|
| 478 |
+
def restrict_to_region(
|
| 479 |
+
sets: ScoringSets, chrom: str, start: int, end: int
|
| 480 |
+
) -> ScoringSets:
|
| 481 |
+
"""Keeps the genes whose CDS lie inside [start, end) (1-based, closed ends).
|
| 482 |
+
|
| 483 |
+
Genes cut by the region edges are dropped on both sides, so neither the
|
| 484 |
+
reference nor the prediction is penalised for what lies outside.
|
| 485 |
+
"""
|
| 486 |
+
|
| 487 |
+
def inside(gene: Gene) -> bool:
|
| 488 |
+
return gene.chrom == chrom and all(
|
| 489 |
+
start <= k[1] and k[2] <= end for k in gene.cds_keys
|
| 490 |
+
)
|
| 491 |
+
|
| 492 |
+
ref_genes = {g: x for g, x in sets.ref_genes.items() if inside(x)}
|
| 493 |
+
pred_genes = {g: x for g, x in sets.pred_genes.items() if inside(x)}
|
| 494 |
+
return ScoringSets(
|
| 495 |
+
{k for x in ref_genes.values() for k in x.cds_keys},
|
| 496 |
+
ref_genes,
|
| 497 |
+
{k for x in pred_genes.values() for k in x.cds_keys},
|
| 498 |
+
pred_genes,
|
| 499 |
+
)
|
| 500 |
+
|
| 501 |
+
|
| 502 |
+
def _prf(ref_tp: int, n_ref: int, pred_tp: int, n_pred: int) -> dict[str, float]:
|
| 503 |
+
recall = ref_tp / n_ref if n_ref else 0.0
|
| 504 |
+
precision = pred_tp / n_pred if n_pred else 0.0
|
| 505 |
+
f1 = 2 * recall * precision / (recall + precision) if recall + precision else 0.0
|
| 506 |
+
return {
|
| 507 |
+
"Ref_TP": ref_tp,
|
| 508 |
+
"Ref_Total": n_ref,
|
| 509 |
+
"Pred_TP": pred_tp,
|
| 510 |
+
"Pred_Total": n_pred,
|
| 511 |
+
"Recall": recall,
|
| 512 |
+
"Precision": precision,
|
| 513 |
+
"F1": f1,
|
| 514 |
+
}
|
| 515 |
+
|
| 516 |
+
|
| 517 |
+
def _no_phase(keys: Iterable[CdsKey]) -> frozenset[tuple[str, int, int, str]]:
|
| 518 |
+
return frozenset(key[:4] for key in keys)
|
| 519 |
+
|
| 520 |
+
|
| 521 |
+
def _cds_scores(ref: set, pred: set) -> dict[str, float]:
|
| 522 |
+
tp = len(ref & pred)
|
| 523 |
+
scores = _prf(tp, len(ref), tp, len(pred))
|
| 524 |
+
return {
|
| 525 |
+
"TP": tp,
|
| 526 |
+
"FN": len(ref) - tp,
|
| 527 |
+
"FP": len(pred) - tp,
|
| 528 |
+
**{k: scores[k] for k in ("Recall", "Precision", "F1")},
|
| 529 |
+
}
|
| 530 |
+
|
| 531 |
+
|
| 532 |
+
def _gene_scores(
|
| 533 |
+
ref_genes: Iterable[Gene],
|
| 534 |
+
pred_genes: Iterable[Gene],
|
| 535 |
+
structure: Callable[[Gene], frozenset],
|
| 536 |
+
all_ref: set[frozenset],
|
| 537 |
+
all_pred: set[frozenset],
|
| 538 |
+
) -> dict[str, float]:
|
| 539 |
+
ref = [structure(g) for g in ref_genes]
|
| 540 |
+
pred = [structure(g) for g in pred_genes]
|
| 541 |
+
return _prf(
|
| 542 |
+
sum(s in all_pred for s in ref),
|
| 543 |
+
len(ref),
|
| 544 |
+
sum(s in all_ref for s in pred),
|
| 545 |
+
len(pred),
|
| 546 |
+
)
|
| 547 |
+
|
| 548 |
+
|
| 549 |
+
def score_sets(sets: ScoringSets) -> dict[str, dict[str, float]]:
|
| 550 |
+
"""CDS- and gene-level scores, with and without phase (fractions, not %)."""
|
| 551 |
+
return _score(sets, sets.ref_cds, sets.pred_cds, sets.ref_genes, sets.pred_genes)
|
| 552 |
+
|
| 553 |
+
|
| 554 |
+
def _score(
|
| 555 |
+
sets: ScoringSets,
|
| 556 |
+
ref_cds: set[CdsKey],
|
| 557 |
+
pred_cds: set[CdsKey],
|
| 558 |
+
ref_genes: dict[str, Gene],
|
| 559 |
+
pred_genes: dict[str, Gene],
|
| 560 |
+
) -> dict[str, dict[str, float]]:
|
| 561 |
+
with_phase: Callable[[Gene], frozenset] = lambda g: frozenset(g.cds_keys) # noqa: E731
|
| 562 |
+
without_phase: Callable[[Gene], frozenset] = lambda g: _no_phase(g.cds_keys) # noqa: E731
|
| 563 |
+
results = {"CDS": _cds_scores(ref_cds, pred_cds)}
|
| 564 |
+
results["Gene"] = _gene_scores(
|
| 565 |
+
ref_genes.values(),
|
| 566 |
+
pred_genes.values(),
|
| 567 |
+
with_phase,
|
| 568 |
+
{with_phase(g) for g in sets.ref_genes.values()},
|
| 569 |
+
{with_phase(g) for g in sets.pred_genes.values()},
|
| 570 |
+
)
|
| 571 |
+
results["CDS_no_phase"] = _cds_scores(
|
| 572 |
+
{key[:4] for key in ref_cds}, {key[:4] for key in pred_cds}
|
| 573 |
+
)
|
| 574 |
+
results["Gene_no_phase"] = _gene_scores(
|
| 575 |
+
ref_genes.values(),
|
| 576 |
+
pred_genes.values(),
|
| 577 |
+
without_phase,
|
| 578 |
+
{without_phase(g) for g in sets.ref_genes.values()},
|
| 579 |
+
{without_phase(g) for g in sets.pred_genes.values()},
|
| 580 |
+
)
|
| 581 |
+
return results
|
| 582 |
+
|
| 583 |
+
|
| 584 |
+
def evaluate(
|
| 585 |
+
reference: str | Path,
|
| 586 |
+
prediction: str | Path,
|
| 587 |
+
pseudogenes: str | Path | None = None,
|
| 588 |
+
chromosomes: Iterable[str] | None = None,
|
| 589 |
+
min_pred_gene_cds_length: int = 201,
|
| 590 |
+
full_reference: bool = False,
|
| 591 |
+
) -> dict[str, dict[str, float]]:
|
| 592 |
+
"""Benchmark scores of one prediction; see ``prepare_scoring_sets`` for args."""
|
| 593 |
+
return score_sets(
|
| 594 |
+
prepare_scoring_sets(
|
| 595 |
+
reference,
|
| 596 |
+
prediction,
|
| 597 |
+
pseudogenes,
|
| 598 |
+
chromosomes,
|
| 599 |
+
min_pred_gene_cds_length,
|
| 600 |
+
full_reference,
|
| 601 |
+
)
|
| 602 |
+
)
|
| 603 |
+
|
| 604 |
+
|
| 605 |
+
def score_per_contig(sets: ScoringSets) -> dict[str, dict[str, dict[str, float]]]:
|
| 606 |
+
"""Scores per seqid, matched against the whole genome (counts add up)."""
|
| 607 |
+
chroms = sorted(
|
| 608 |
+
{g.chrom for g in sets.ref_genes.values()}
|
| 609 |
+
| {g.chrom for g in sets.pred_genes.values()}
|
| 610 |
+
)
|
| 611 |
+
return {
|
| 612 |
+
chrom: _score(
|
| 613 |
+
sets,
|
| 614 |
+
{k for k in sets.ref_cds if k[0] == chrom},
|
| 615 |
+
{k for k in sets.pred_cds if k[0] == chrom},
|
| 616 |
+
{gid: g for gid, g in sets.ref_genes.items() if g.chrom == chrom},
|
| 617 |
+
{gid: g for gid, g in sets.pred_genes.items() if g.chrom == chrom},
|
| 618 |
+
)
|
| 619 |
+
for chrom in chroms
|
| 620 |
+
}
|
| 621 |
+
|
| 622 |
+
|
| 623 |
+
def compare_seqids(reference: str | Path, prediction: str | Path) -> dict[str, list]:
|
| 624 |
+
"""Seqids with CDS in each file, to catch naming mismatches (``chr1`` vs ``1``)."""
|
| 625 |
+
ref = {key[0] for key in parse_cds_and_genes(reference)[0]}
|
| 626 |
+
pred = {key[0] for key in parse_cds_and_genes(prediction)[0]}
|
| 627 |
+
return {
|
| 628 |
+
"shared": sorted(ref & pred),
|
| 629 |
+
"reference_only": sorted(ref - pred),
|
| 630 |
+
"prediction_only": sorted(pred - ref),
|
| 631 |
+
}
|
| 632 |
+
|
| 633 |
+
|
| 634 |
+
def rename_seqids(
|
| 635 |
+
source: str | Path,
|
| 636 |
+
output: str | Path,
|
| 637 |
+
rename: Mapping[str, str] | Callable[[str], str],
|
| 638 |
+
) -> None:
|
| 639 |
+
"""Writes a copy of a GFF3 with column 1 (and ``##sequence-region``) renamed.
|
| 640 |
+
|
| 641 |
+
Args:
|
| 642 |
+
source: GFF3 to rename, plain or gzipped.
|
| 643 |
+
output: Destination, gzipped if it ends in ``.gz``.
|
| 644 |
+
rename: Old to new seqid (unlisted ones are kept), or a function.
|
| 645 |
+
"""
|
| 646 |
+
if callable(rename):
|
| 647 |
+
new = rename
|
| 648 |
+
else:
|
| 649 |
+
mapping = dict(rename)
|
| 650 |
+
|
| 651 |
+
def new(seqid: str) -> str:
|
| 652 |
+
return mapping.get(seqid, seqid)
|
| 653 |
+
|
| 654 |
+
opener = gzip.open if str(output).endswith(".gz") else open
|
| 655 |
+
with open_text(source) as src, opener(output, "wt", encoding="utf-8") as out:
|
| 656 |
+
for line in src:
|
| 657 |
+
if line.startswith("##sequence-region"):
|
| 658 |
+
tag, seqid, *rest = line.split()
|
| 659 |
+
line = " ".join([tag, str(new(seqid)), *rest]) + "\n"
|
| 660 |
+
elif line.strip() and not line.startswith("#"):
|
| 661 |
+
seqid, rest = line.split("\t", 1)
|
| 662 |
+
line = f"{new(seqid)}\t{rest}"
|
| 663 |
+
out.write(line)
|
| 664 |
+
|
| 665 |
+
|
| 666 |
+
def format_summary(results: dict[str, dict[str, float]]) -> list[str]:
|
| 667 |
+
"""One line per level, in %."""
|
| 668 |
+
return [
|
| 669 |
+
f"{LEVELS.get(level, level):>16s}: Recall {100 * m['Recall']:6.2f}% "
|
| 670 |
+
f"Precision {100 * m['Precision']:6.2f}% F1 {100 * m['F1']:6.2f}%"
|
| 671 |
+
for level, m in results.items()
|
| 672 |
+
]
|
src/genome_annotation/inference.py
ADDED
|
@@ -0,0 +1,255 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""Annotates a genomic region with a released NTv3 model, tiled as the pipeline does.
|
| 2 |
+
|
| 3 |
+
Each window of ``sequence_length`` bp predicts its central ``keep`` fraction; the
|
| 4 |
+
kept regions form a grid anchored at the contig's first base, the first window
|
| 5 |
+
reaching ``crop`` bp ahead of the contig (read as N) and the last one shifted back
|
| 6 |
+
onto its final base. Each kept region is decoded on its own and the state paths
|
| 7 |
+
are stitched, the overlap of the shifted last region going to the earlier window.
|
| 8 |
+
"""
|
| 9 |
+
|
| 10 |
+
from collections import defaultdict
|
| 11 |
+
from dataclasses import dataclass
|
| 12 |
+
|
| 13 |
+
import numpy as np
|
| 14 |
+
import torch
|
| 15 |
+
from transformers import AutoModel
|
| 16 |
+
|
| 17 |
+
from . import decoding
|
| 18 |
+
from .data import Tokenizer, reverse_complement
|
| 19 |
+
from .model import CODE_REVISION, predict_log_probs
|
| 20 |
+
|
| 21 |
+
DECODERS = ("argmax", "hmm", "hmm_constrained")
|
| 22 |
+
|
| 23 |
+
|
| 24 |
+
def load_released(clade: str, device: torch.device) -> torch.nn.Module:
|
| 25 |
+
"""``InstaDeepAI/NTv3_annot_<clade>`` (mammals, plants or insects), in eval mode."""
|
| 26 |
+
model = AutoModel.from_pretrained(
|
| 27 |
+
f"InstaDeepAI/NTv3_annot_{clade}",
|
| 28 |
+
trust_remote_code=True,
|
| 29 |
+
code_revision=CODE_REVISION,
|
| 30 |
+
)
|
| 31 |
+
return model.to(device).eval()
|
| 32 |
+
|
| 33 |
+
|
| 34 |
+
@dataclass(frozen=True)
|
| 35 |
+
class Window:
|
| 36 |
+
"""One model input and the contig bp its decode is kept for (0-based, half-open)."""
|
| 37 |
+
|
| 38 |
+
start: int
|
| 39 |
+
keep_start: int
|
| 40 |
+
keep_stop: int
|
| 41 |
+
|
| 42 |
+
|
| 43 |
+
def crop_of(sequence_length: int, keep: float) -> int:
|
| 44 |
+
"""Context bp the head does not predict, on each side of a window."""
|
| 45 |
+
return (sequence_length - int(sequence_length * keep)) // 2
|
| 46 |
+
|
| 47 |
+
|
| 48 |
+
def plan_windows(contig_length: int, sequence_length: int, keep: float) -> list[Window]:
|
| 49 |
+
"""The pipeline's windows over a whole contig, kept regions disjoint and covering it."""
|
| 50 |
+
crop = crop_of(sequence_length, keep)
|
| 51 |
+
stride = sequence_length - 2 * crop
|
| 52 |
+
last_start = max(contig_length - stride, 0)
|
| 53 |
+
windows: list[Window] = []
|
| 54 |
+
covered = 0
|
| 55 |
+
for grid in range(0, contig_length, stride):
|
| 56 |
+
keep_start = min(grid, last_start)
|
| 57 |
+
keep_stop = min(keep_start + stride, contig_length)
|
| 58 |
+
windows.append(Window(keep_start - crop, max(keep_start, covered), keep_stop))
|
| 59 |
+
covered = keep_stop
|
| 60 |
+
return windows
|
| 61 |
+
|
| 62 |
+
|
| 63 |
+
def windows_for_region(
|
| 64 |
+
contig_length: int, start: int, end: int, sequence_length: int, keep: float
|
| 65 |
+
) -> list[Window]:
|
| 66 |
+
"""The pipeline windows whose kept region overlaps [start, end)."""
|
| 67 |
+
return [
|
| 68 |
+
w
|
| 69 |
+
for w in plan_windows(contig_length, sequence_length, keep)
|
| 70 |
+
if w.keep_start < end and w.keep_stop > start
|
| 71 |
+
]
|
| 72 |
+
|
| 73 |
+
|
| 74 |
+
def window_sequence(contig: str, window: Window, sequence_length: int) -> str:
|
| 75 |
+
"""The window's bases, N-padded where it reaches past either contig end."""
|
| 76 |
+
lo, hi = max(window.start, 0), min(window.start + sequence_length, len(contig))
|
| 77 |
+
return (
|
| 78 |
+
"N" * (lo - window.start)
|
| 79 |
+
+ contig[lo:hi]
|
| 80 |
+
+ "N" * (window.start + sequence_length - hi)
|
| 81 |
+
)
|
| 82 |
+
|
| 83 |
+
|
| 84 |
+
def predict_window(
|
| 85 |
+
model: torch.nn.Module,
|
| 86 |
+
tokenizer: Tokenizer,
|
| 87 |
+
sequence: str,
|
| 88 |
+
device: torch.device,
|
| 89 |
+
dtype: torch.dtype,
|
| 90 |
+
one_strand_at_a_time: bool = False,
|
| 91 |
+
) -> tuple[np.ndarray, np.ndarray]:
|
| 92 |
+
"""Log-probabilities of the window's kept region, for it and its reverse complement.
|
| 93 |
+
|
| 94 |
+
``one_strand_at_a_time`` halves peak memory (about 31 GB instead of 57 GB for a
|
| 95 |
+
1 Mb window in bfloat16).
|
| 96 |
+
|
| 97 |
+
Returns:
|
| 98 |
+
(kept, 15) for the plus strand in genomic order, and (kept, 15) for the
|
| 99 |
+
minus strand in reverse-complement order (5' to 3' of the minus strand).
|
| 100 |
+
"""
|
| 101 |
+
tokens = np.stack([tokenizer(sequence), tokenizer(reverse_complement(sequence))])
|
| 102 |
+
if one_strand_at_a_time:
|
| 103 |
+
plus, rc = (predict_log_probs(model, t[None], device, dtype)[0] for t in tokens)
|
| 104 |
+
return plus, rc
|
| 105 |
+
log_probs = predict_log_probs(model, tokens, device, dtype)
|
| 106 |
+
return log_probs[0], log_probs[1]
|
| 107 |
+
|
| 108 |
+
|
| 109 |
+
def decode(log_probs: np.ndarray, codes: np.ndarray, decoder: str) -> np.ndarray:
|
| 110 |
+
"""State path of one strand's kept region (both in the strand's own orientation)."""
|
| 111 |
+
if decoder == "argmax":
|
| 112 |
+
return log_probs.argmax(-1).astype(np.int8)
|
| 113 |
+
return decoding.viterbi(log_probs, codes if decoder == "hmm_constrained" else None)
|
| 114 |
+
|
| 115 |
+
|
| 116 |
+
@dataclass
|
| 117 |
+
class RegionPrediction:
|
| 118 |
+
"""Model outputs of every window covering a region.
|
| 119 |
+
|
| 120 |
+
``per_window`` holds, for each window, the log-probabilities of its whole kept
|
| 121 |
+
region: plus strand in genomic order, minus strand in its own 5'->3' order.
|
| 122 |
+
"""
|
| 123 |
+
|
| 124 |
+
chrom: str
|
| 125 |
+
start: int
|
| 126 |
+
end: int
|
| 127 |
+
windows: list[Window]
|
| 128 |
+
per_window: list[tuple[np.ndarray, np.ndarray]]
|
| 129 |
+
|
| 130 |
+
def stitched(self, strand: str) -> np.ndarray:
|
| 131 |
+
"""(end - start, 15) log-probabilities of one strand ("+"/"-"), genomic order."""
|
| 132 |
+
out = np.zeros((self.end - self.start, decoding.NUM_CLASSES), np.float32)
|
| 133 |
+
for window, (plus, rc) in zip(self.windows, self.per_window, strict=True):
|
| 134 |
+
values = plus if strand == "+" else rc[::-1]
|
| 135 |
+
skip = window.keep_start - (window.keep_stop - len(plus))
|
| 136 |
+
out[window.keep_start - self.start : window.keep_stop - self.start] = (
|
| 137 |
+
values[skip:]
|
| 138 |
+
)
|
| 139 |
+
return out
|
| 140 |
+
|
| 141 |
+
|
| 142 |
+
def predict_region(
|
| 143 |
+
model: torch.nn.Module,
|
| 144 |
+
tokenizer: Tokenizer,
|
| 145 |
+
contig: str,
|
| 146 |
+
chrom: str,
|
| 147 |
+
start: int,
|
| 148 |
+
end: int,
|
| 149 |
+
sequence_length: int,
|
| 150 |
+
keep: float,
|
| 151 |
+
device: torch.device,
|
| 152 |
+
dtype: torch.dtype,
|
| 153 |
+
one_strand_at_a_time: bool = False,
|
| 154 |
+
) -> RegionPrediction:
|
| 155 |
+
"""Runs the model on every pipeline window covering [start, end) of a contig.
|
| 156 |
+
|
| 157 |
+
The region is widened to whole kept regions, so decoding it reproduces the
|
| 158 |
+
pipeline's decode of those bp exactly.
|
| 159 |
+
"""
|
| 160 |
+
windows = windows_for_region(len(contig), start, end, sequence_length, keep)
|
| 161 |
+
crop = crop_of(sequence_length, keep)
|
| 162 |
+
per_window = []
|
| 163 |
+
for window in windows:
|
| 164 |
+
plus, rc = predict_window(
|
| 165 |
+
model,
|
| 166 |
+
tokenizer,
|
| 167 |
+
window_sequence(contig, window, sequence_length),
|
| 168 |
+
device,
|
| 169 |
+
dtype,
|
| 170 |
+
one_strand_at_a_time,
|
| 171 |
+
)
|
| 172 |
+
n = min(window.start + crop + len(plus), len(contig)) - (window.start + crop)
|
| 173 |
+
per_window.append((plus[:n], rc[len(rc) - n :]))
|
| 174 |
+
return RegionPrediction(
|
| 175 |
+
chrom, windows[0].keep_start, windows[-1].keep_stop, windows, per_window
|
| 176 |
+
)
|
| 177 |
+
|
| 178 |
+
|
| 179 |
+
def decode_region(
|
| 180 |
+
prediction: RegionPrediction, contig: str, decoder: str
|
| 181 |
+
) -> tuple[np.ndarray, np.ndarray]:
|
| 182 |
+
"""Plus- and minus-strand state paths over the region, in genomic order.
|
| 183 |
+
|
| 184 |
+
Each window is decoded over its whole kept region, as in the pipeline; the
|
| 185 |
+
shifted last window then drops the bp its predecessor already decoded.
|
| 186 |
+
"""
|
| 187 |
+
codes = decoding.encode_bases(contig)
|
| 188 |
+
n = prediction.end - prediction.start
|
| 189 |
+
plus, minus = np.zeros(n, np.int8), np.zeros(n, np.int8)
|
| 190 |
+
for window, (lp_plus, lp_rc) in zip(
|
| 191 |
+
prediction.windows, prediction.per_window, strict=True
|
| 192 |
+
):
|
| 193 |
+
full_start = window.keep_stop - len(lp_plus)
|
| 194 |
+
window_codes = codes[full_start : window.keep_stop]
|
| 195 |
+
states_plus = decode(lp_plus, window_codes, decoder)
|
| 196 |
+
states_minus = decode(
|
| 197 |
+
lp_rc, decoding.reverse_complement_codes(window_codes), decoder
|
| 198 |
+
)[::-1]
|
| 199 |
+
skip = window.keep_start - full_start
|
| 200 |
+
out = slice(
|
| 201 |
+
window.keep_start - prediction.start, window.keep_stop - prediction.start
|
| 202 |
+
)
|
| 203 |
+
plus[out] = states_plus[skip:]
|
| 204 |
+
minus[out] = states_minus[skip:]
|
| 205 |
+
return plus, minus
|
| 206 |
+
|
| 207 |
+
|
| 208 |
+
def region_genes(
|
| 209 |
+
plus: np.ndarray,
|
| 210 |
+
minus: np.ndarray,
|
| 211 |
+
contig: str,
|
| 212 |
+
chrom: str,
|
| 213 |
+
start: int,
|
| 214 |
+
filters: bool = True,
|
| 215 |
+
min_cds_length: int = 201,
|
| 216 |
+
) -> list[str]:
|
| 217 |
+
"""GFF3 lines of both strands, in contig coordinates.
|
| 218 |
+
|
| 219 |
+
With ``filters``, genes with an internal in-frame stop or less than
|
| 220 |
+
``min_cds_length`` bp of CDS are dropped, as in the released annotations.
|
| 221 |
+
"""
|
| 222 |
+
codes = (
|
| 223 |
+
decoding.encode_bases(contig[start : start + len(plus)]) if filters else None
|
| 224 |
+
)
|
| 225 |
+
lines = decoding.find_genes(plus, "+", chrom, codes)
|
| 226 |
+
lines += decoding.find_genes(minus, "-", chrom, codes)
|
| 227 |
+
lines = [_shift(line, start) for line in lines]
|
| 228 |
+
return drop_short_genes(lines, min_cds_length) if filters else lines
|
| 229 |
+
|
| 230 |
+
|
| 231 |
+
def _shift(line: str, offset: int) -> str:
|
| 232 |
+
cols = line.split("\t")
|
| 233 |
+
cols[3], cols[4] = str(int(cols[3]) + offset), str(int(cols[4]) + offset)
|
| 234 |
+
if "Parent=" in cols[8]: # sub-feature IDs carry their start
|
| 235 |
+
parent = cols[8].split("Parent=")[1]
|
| 236 |
+
cols[8] = f"ID={parent}_{cols[2]}_{cols[3]};Parent={parent}"
|
| 237 |
+
return "\t".join(cols)
|
| 238 |
+
|
| 239 |
+
|
| 240 |
+
def drop_short_genes(lines: list[str], min_cds_length: int) -> list[str]:
|
| 241 |
+
"""Drops ``find_genes`` genes with less than ``min_cds_length`` bp of CDS."""
|
| 242 |
+
cds_length: dict[str, int] = defaultdict(int)
|
| 243 |
+
for line in lines:
|
| 244 |
+
cols = line.split("\t")
|
| 245 |
+
if cols[2] == "CDS":
|
| 246 |
+
parent = cols[8].split("Parent=")[1]
|
| 247 |
+
cds_length[parent] += int(cols[4]) - int(cols[3]) + 1
|
| 248 |
+
|
| 249 |
+
def gene_of(line: str) -> str:
|
| 250 |
+
attributes = line.split("\t")[8]
|
| 251 |
+
if "Parent=" in attributes:
|
| 252 |
+
return attributes.split("Parent=")[1]
|
| 253 |
+
return attributes.split("ID=")[1].split(";")[0]
|
| 254 |
+
|
| 255 |
+
return [line for line in lines if cds_length[gene_of(line)] >= min_cds_length]
|
src/genome_annotation/metrics.py
ADDED
|
@@ -0,0 +1,143 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""Gene- and CDS-level accuracy of a predicted GFF3 (Tiberius / BRAKER style).
|
| 2 |
+
|
| 3 |
+
A CDS is correct if its chromosome, start, end, strand and phase all match the
|
| 4 |
+
reference; a gene is correct if its whole set of CDS matches a reference gene.
|
| 5 |
+
"""
|
| 6 |
+
|
| 7 |
+
import bisect
|
| 8 |
+
import gzip
|
| 9 |
+
from collections import defaultdict
|
| 10 |
+
from pathlib import Path
|
| 11 |
+
|
| 12 |
+
import numpy as np
|
| 13 |
+
|
| 14 |
+
CdsKey = tuple[str, int, int, str, str] # (chrom, start, end, strand, phase), 1-based
|
| 15 |
+
|
| 16 |
+
|
| 17 |
+
def _open_text(path: str | Path):
|
| 18 |
+
return gzip.open(path, "rt") if str(path).endswith(".gz") else open(path)
|
| 19 |
+
|
| 20 |
+
|
| 21 |
+
def _attribute(attributes: str, key: str) -> str | None:
|
| 22 |
+
for field in attributes.split(";"):
|
| 23 |
+
if field.startswith(f"{key}="):
|
| 24 |
+
return field[len(key) + 1 :]
|
| 25 |
+
return None
|
| 26 |
+
|
| 27 |
+
|
| 28 |
+
def parse_genes(
|
| 29 |
+
path: str | Path, chromosomes: set[str] | None = None
|
| 30 |
+
) -> dict[str, list[CdsKey]]:
|
| 31 |
+
"""Maps each gene ID to its CDS keys; CDS reach genes directly or via mRNAs."""
|
| 32 |
+
transcript_to_gene: dict[str, str] = {}
|
| 33 |
+
genes: dict[str, list[CdsKey]] = defaultdict(list)
|
| 34 |
+
with _open_text(path) as handle:
|
| 35 |
+
for line in handle:
|
| 36 |
+
if line.startswith("#"):
|
| 37 |
+
continue
|
| 38 |
+
cols = line.rstrip("\n").split("\t")
|
| 39 |
+
if len(cols) < 9 or (chromosomes and cols[0] not in chromosomes):
|
| 40 |
+
continue
|
| 41 |
+
kind = cols[2].lower()
|
| 42 |
+
if kind in ("mrna", "transcript"):
|
| 43 |
+
tid, parent = _attribute(cols[8], "ID"), _attribute(cols[8], "Parent")
|
| 44 |
+
if tid and parent:
|
| 45 |
+
transcript_to_gene[tid] = parent.split(",")[0]
|
| 46 |
+
elif kind == "cds":
|
| 47 |
+
parent = (_attribute(cols[8], "Parent") or "").split(",")[0]
|
| 48 |
+
gene = transcript_to_gene.get(parent, parent)
|
| 49 |
+
genes[gene].append(
|
| 50 |
+
(cols[0], int(cols[3]), int(cols[4]), cols[6], cols[7])
|
| 51 |
+
)
|
| 52 |
+
return dict(genes)
|
| 53 |
+
|
| 54 |
+
|
| 55 |
+
def read_regions(path: str | Path) -> dict[str, list[tuple[int, int]]]:
|
| 56 |
+
"""Reads a BED of regions into sorted, merged 1-based closed intervals."""
|
| 57 |
+
regions: dict[str, list[tuple[int, int]]] = defaultdict(list)
|
| 58 |
+
with _open_text(path) as handle:
|
| 59 |
+
for line in handle:
|
| 60 |
+
chrom, start, end = line.split("\t")[:3]
|
| 61 |
+
regions[chrom].append((int(start) + 1, int(end)))
|
| 62 |
+
merged: dict[str, list[tuple[int, int]]] = {}
|
| 63 |
+
for chrom, intervals in regions.items():
|
| 64 |
+
out: list[tuple[int, int]] = []
|
| 65 |
+
for start, end in sorted(intervals):
|
| 66 |
+
if out and start <= out[-1][1] + 1:
|
| 67 |
+
out[-1] = (out[-1][0], max(out[-1][1], end))
|
| 68 |
+
else:
|
| 69 |
+
out.append((start, end))
|
| 70 |
+
merged[chrom] = out
|
| 71 |
+
return merged
|
| 72 |
+
|
| 73 |
+
|
| 74 |
+
def _overlaps(regions: dict[str, list[tuple[int, int]]], key: CdsKey) -> bool:
|
| 75 |
+
intervals = regions.get(key[0])
|
| 76 |
+
if not intervals:
|
| 77 |
+
return False
|
| 78 |
+
i = bisect.bisect_left(intervals, (key[2] + 1,)) - 1
|
| 79 |
+
return i >= 0 and intervals[i][1] >= key[1]
|
| 80 |
+
|
| 81 |
+
|
| 82 |
+
def _prf(
|
| 83 |
+
tp_recall: int, n_ref: int, tp_precision: int, n_pred: int
|
| 84 |
+
) -> dict[str, float]:
|
| 85 |
+
recall = tp_recall / n_ref if n_ref else 0.0
|
| 86 |
+
precision = tp_precision / n_pred if n_pred else 0.0
|
| 87 |
+
f1 = 2 * recall * precision / (recall + precision) if recall + precision else 0.0
|
| 88 |
+
return {"Precision": 100 * precision, "Recall": 100 * recall, "F1": 100 * f1}
|
| 89 |
+
|
| 90 |
+
|
| 91 |
+
def evaluate_gff(
|
| 92 |
+
reference: dict[str, list[CdsKey]],
|
| 93 |
+
prediction: dict[str, list[CdsKey]],
|
| 94 |
+
pseudogenes: dict[str, list[tuple[int, int]]] | None = None,
|
| 95 |
+
min_cds_length: int = 201,
|
| 96 |
+
) -> dict[str, dict[str, float]]:
|
| 97 |
+
"""CDS- and gene-level precision, recall and F1 (in %).
|
| 98 |
+
|
| 99 |
+
Args:
|
| 100 |
+
reference: Reference genes, as returned by ``parse_genes``.
|
| 101 |
+
prediction: Predicted genes, restricted to the reference's chromosomes.
|
| 102 |
+
pseudogenes: Pseudogene regions away from reference CDS; predicted CDS
|
| 103 |
+
overlapping them are ignored.
|
| 104 |
+
min_cds_length: Predicted genes with less CDS than this (bp) are ignored.
|
| 105 |
+
|
| 106 |
+
Returns:
|
| 107 |
+
{"CDS": {...}, "Gene": {...}} with Precision, Recall and F1.
|
| 108 |
+
"""
|
| 109 |
+
ref_cds = {key for keys in reference.values() for key in keys}
|
| 110 |
+
kept = {
|
| 111 |
+
gid: keys
|
| 112 |
+
for gid, keys in prediction.items()
|
| 113 |
+
if sum(end - start + 1 for _, start, end, _, _ in keys) >= min_cds_length
|
| 114 |
+
}
|
| 115 |
+
dropped = {
|
| 116 |
+
key for gid, keys in prediction.items() if gid not in kept for key in keys
|
| 117 |
+
}
|
| 118 |
+
pred_cds = {key for keys in prediction.values() for key in keys} - dropped
|
| 119 |
+
if pseudogenes:
|
| 120 |
+
pred_cds = {key for key in pred_cds if not _overlaps(pseudogenes, key)}
|
| 121 |
+
kept = {gid: [k for k in keys if k in pred_cds] for gid, keys in kept.items()}
|
| 122 |
+
kept = {gid: keys for gid, keys in kept.items() if keys}
|
| 123 |
+
|
| 124 |
+
tp = len(ref_cds & pred_cds)
|
| 125 |
+
ref_structures = [frozenset(keys) for keys in reference.values()]
|
| 126 |
+
pred_structures = [frozenset(keys) for keys in kept.values()]
|
| 127 |
+
ref_set, pred_set = set(ref_structures), set(pred_structures)
|
| 128 |
+
return {
|
| 129 |
+
"CDS": _prf(tp, len(ref_cds), tp, len(pred_cds)),
|
| 130 |
+
"Gene": _prf(
|
| 131 |
+
sum(s in pred_set for s in ref_structures),
|
| 132 |
+
len(ref_structures),
|
| 133 |
+
sum(s in ref_set for s in pred_structures),
|
| 134 |
+
len(pred_structures),
|
| 135 |
+
),
|
| 136 |
+
}
|
| 137 |
+
|
| 138 |
+
|
| 139 |
+
def per_class_f1(confusion: np.ndarray) -> np.ndarray:
|
| 140 |
+
"""Per-class F1 from a (true, predicted) confusion matrix."""
|
| 141 |
+
tp = np.diag(confusion).astype(np.float64)
|
| 142 |
+
denominator = confusion.sum(axis=0) + confusion.sum(axis=1)
|
| 143 |
+
return np.divide(2 * tp, denominator, out=np.zeros_like(tp), where=denominator > 0)
|
src/genome_annotation/model.py
ADDED
|
@@ -0,0 +1,118 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""NTv3 annotation model warm-started from NTv3-tracks, its loss and its inference."""
|
| 2 |
+
|
| 3 |
+
import functools
|
| 4 |
+
|
| 5 |
+
import numpy as np
|
| 6 |
+
import torch
|
| 7 |
+
from torch.utils.checkpoint import checkpoint
|
| 8 |
+
from transformers import AutoConfig, AutoModel
|
| 9 |
+
|
| 10 |
+
ANNOTATION_REPO = "InstaDeepAI/NTv3_annot_plants"
|
| 11 |
+
TRACKS_REPO = "InstaDeepAI/NTv3_tracks_1mb"
|
| 12 |
+
# Revision of the modelling code (hosted in InstaDeepAI/NTv3_pretrained), pinned
|
| 13 |
+
# because batch-norm running statistics depend on it.
|
| 14 |
+
CODE_REVISION = "ea80a2df95bb605f33e1077b652da3c36c1dad56"
|
| 15 |
+
|
| 16 |
+
# Which parts of the network are trained; the rest stays at the NTv3-tracks weights.
|
| 17 |
+
TRAINABLE_PARTS = {
|
| 18 |
+
"all": ("",),
|
| 19 |
+
"decoder": ("model.decoder_blocks.", "model.bed_head."),
|
| 20 |
+
"head": ("model.bed_head.",),
|
| 21 |
+
}
|
| 22 |
+
|
| 23 |
+
|
| 24 |
+
def set_training(model: torch.nn.Module, training: bool) -> torch.nn.Module:
|
| 25 |
+
"""Switches the trained batch norms between batch statistics and running ones.
|
| 26 |
+
|
| 27 |
+
In training mode a batch norm normalises with the window's own statistics and
|
| 28 |
+
updates its running ones. Everything else stays in eval mode: NTv3 has no
|
| 29 |
+
dropout, and batch norms of frozen blocks keep their NTv3-tracks statistics.
|
| 30 |
+
"""
|
| 31 |
+
model.eval()
|
| 32 |
+
if training and model.batch_norm == "batch":
|
| 33 |
+
for module in model.modules():
|
| 34 |
+
if hasattr(module, "var_ema") and module.scale.requires_grad:
|
| 35 |
+
module.train()
|
| 36 |
+
return model
|
| 37 |
+
|
| 38 |
+
|
| 39 |
+
def load_model(
|
| 40 |
+
trainable: str = "all",
|
| 41 |
+
gradient_checkpointing: bool = False,
|
| 42 |
+
batch_norm: str = "frozen",
|
| 43 |
+
) -> torch.nn.Module:
|
| 44 |
+
"""NTv3-tracks trunk under a new 15-class, single-strand annotation head.
|
| 45 |
+
|
| 46 |
+
The architecture (and head size) comes from the released plant annotation
|
| 47 |
+
model's config; every trunk weight comes from NTv3-tracks.
|
| 48 |
+
|
| 49 |
+
Args:
|
| 50 |
+
trainable: "all", "decoder" (U-Net decoder + head) or "head".
|
| 51 |
+
gradient_checkpointing: Recompute block activations in the backward pass.
|
| 52 |
+
batch_norm: "frozen" keeps the NTv3-tracks running statistics; "batch"
|
| 53 |
+
normalises trained blocks with per-window statistics during training
|
| 54 |
+
and updates their running statistics, as our training pipeline does.
|
| 55 |
+
"""
|
| 56 |
+
if batch_norm == "batch" and gradient_checkpointing:
|
| 57 |
+
# Recomputing a block in the backward pass would update its statistics twice.
|
| 58 |
+
raise ValueError("batch_norm='batch' does not support gradient checkpointing.")
|
| 59 |
+
config = AutoConfig.from_pretrained(
|
| 60 |
+
ANNOTATION_REPO, trust_remote_code=True, code_revision=CODE_REVISION
|
| 61 |
+
)
|
| 62 |
+
config.ntv3["keep_target_center_fraction"] = 1.0
|
| 63 |
+
model = AutoModel.from_pretrained(
|
| 64 |
+
TRACKS_REPO, config=config, trust_remote_code=True, code_revision=CODE_REVISION
|
| 65 |
+
)
|
| 66 |
+
prefixes = TRAINABLE_PARTS[trainable]
|
| 67 |
+
for name, param in model.named_parameters():
|
| 68 |
+
param.requires_grad = name.startswith(prefixes)
|
| 69 |
+
if gradient_checkpointing:
|
| 70 |
+
trunk = model.model
|
| 71 |
+
for block in [
|
| 72 |
+
*trunk.encoder_blocks,
|
| 73 |
+
*trunk.mha_blocks,
|
| 74 |
+
*trunk.mlp_blocks,
|
| 75 |
+
*trunk.decoder_blocks,
|
| 76 |
+
]:
|
| 77 |
+
block.forward = functools.partial(
|
| 78 |
+
checkpoint, block.forward, use_reentrant=False
|
| 79 |
+
)
|
| 80 |
+
model.batch_norm = batch_norm
|
| 81 |
+
return model.eval()
|
| 82 |
+
|
| 83 |
+
|
| 84 |
+
def logits(model: torch.nn.Module, tokens: torch.Tensor) -> torch.Tensor:
|
| 85 |
+
"""(batch, length) tokens -> (batch, length, 15) annotation logits."""
|
| 86 |
+
out = model(tokens, conditions_tokens=[], skip_lm_head=True)
|
| 87 |
+
return out["bed_tracks_logits"][:, :, 0, :]
|
| 88 |
+
|
| 89 |
+
|
| 90 |
+
def focal_loss(
|
| 91 |
+
logits: torch.Tensor,
|
| 92 |
+
labels: torch.Tensor,
|
| 93 |
+
class_weights: torch.Tensor,
|
| 94 |
+
gamma: float = 2.0,
|
| 95 |
+
) -> torch.Tensor:
|
| 96 |
+
"""Class-weighted focal loss averaged over labelled positions.
|
| 97 |
+
|
| 98 |
+
Each position is weighted by the larger of its true and predicted class
|
| 99 |
+
weights, so false positives of rare classes cost as much as misses.
|
| 100 |
+
"""
|
| 101 |
+
log_probs = torch.log_softmax(logits.float(), dim=-1)
|
| 102 |
+
valid = labels >= 0
|
| 103 |
+
target = labels.clamp(min=0)
|
| 104 |
+
log_p = log_probs.gather(-1, target[..., None])[..., 0]
|
| 105 |
+
loss = -((1 - log_p.exp()) ** gamma) * log_p
|
| 106 |
+
weight = torch.maximum(class_weights[target], class_weights[log_probs.argmax(-1)])
|
| 107 |
+
return (loss * weight * valid).sum() / valid.sum().clamp(min=1)
|
| 108 |
+
|
| 109 |
+
|
| 110 |
+
@torch.no_grad()
|
| 111 |
+
def predict_log_probs(
|
| 112 |
+
model: torch.nn.Module, tokens: np.ndarray, device: torch.device, dtype: torch.dtype
|
| 113 |
+
) -> np.ndarray:
|
| 114 |
+
"""(batch, length) tokens -> (batch, length, 15) float32 log-probabilities."""
|
| 115 |
+
batch = torch.from_numpy(tokens).to(device)
|
| 116 |
+
with torch.autocast(device.type, dtype=dtype, enabled=dtype != torch.float32):
|
| 117 |
+
out = logits(model, batch)
|
| 118 |
+
return torch.log_softmax(out.float(), dim=-1).cpu().numpy()
|
src/genome_annotation/plotting.py
ADDED
|
@@ -0,0 +1,710 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""Figures of the annotation notebooks (matplotlib, one call per figure)."""
|
| 2 |
+
|
| 3 |
+
import itertools
|
| 4 |
+
from collections.abc import Mapping, Sequence
|
| 5 |
+
from typing import Any
|
| 6 |
+
|
| 7 |
+
import matplotlib.pyplot as plt
|
| 8 |
+
import numpy as np
|
| 9 |
+
from matplotlib.axes import Axes
|
| 10 |
+
from matplotlib.colors import to_rgb
|
| 11 |
+
from matplotlib.figure import Figure
|
| 12 |
+
from matplotlib.patches import Rectangle
|
| 13 |
+
|
| 14 |
+
from . import decoding
|
| 15 |
+
from .analysis import cds_span
|
| 16 |
+
from .benchmark import TestSpecies
|
| 17 |
+
from .evaluation import Gene
|
| 18 |
+
|
| 19 |
+
# Paper hues; the gold is darkened to sit in the readable lightness band. `correct`
|
| 20 |
+
# and `invented` are deliberate neutrals so the three error classes carry the colour.
|
| 21 |
+
CLASS_COLORS = {
|
| 22 |
+
"correct": "#b3b3b3",
|
| 23 |
+
"boundary": "#d99a00",
|
| 24 |
+
"missed": "#b428a0",
|
| 25 |
+
"fragmented": "#ff554d",
|
| 26 |
+
"invented": "#4b5563",
|
| 27 |
+
}
|
| 28 |
+
EXON_COLORS = {
|
| 29 |
+
"exact": CLASS_COLORS["correct"],
|
| 30 |
+
"shifted_one": CLASS_COLORS["boundary"],
|
| 31 |
+
"shifted_both": CLASS_COLORS["fragmented"],
|
| 32 |
+
"missed": CLASS_COLORS["missed"],
|
| 33 |
+
}
|
| 34 |
+
NTV3_COLOR = "#3973fc"
|
| 35 |
+
OTHER_COLOR = "#808080"
|
| 36 |
+
INK, MUTED = "#1f2328", "#6b7280"
|
| 37 |
+
BAR_HEIGHT = 0.6
|
| 38 |
+
|
| 39 |
+
|
| 40 |
+
def _style(ax: Axes) -> None:
|
| 41 |
+
for side in ("top", "right"):
|
| 42 |
+
ax.spines[side].set_visible(False)
|
| 43 |
+
for side in ("left", "bottom"):
|
| 44 |
+
ax.spines[side].set_color(MUTED)
|
| 45 |
+
ax.tick_params(colors=MUTED, labelcolor=INK)
|
| 46 |
+
ax.grid(axis="x", color="#e5e7eb", linewidth=0.8)
|
| 47 |
+
ax.set_axisbelow(True)
|
| 48 |
+
|
| 49 |
+
|
| 50 |
+
def _text_color(background: str) -> str:
|
| 51 |
+
r, g, b = to_rgb(background)
|
| 52 |
+
return "white" if 0.2126 * r + 0.7152 * g + 0.0722 * b < 0.45 else INK
|
| 53 |
+
|
| 54 |
+
|
| 55 |
+
def _stacked_rows(
|
| 56 |
+
ax: Axes,
|
| 57 |
+
rows: Sequence[str],
|
| 58 |
+
shares: Sequence[Mapping[str, float]],
|
| 59 |
+
colors: Mapping[str, str],
|
| 60 |
+
) -> None:
|
| 61 |
+
"""Horizontal 100% bars, one per row, labelled inside segments wide enough."""
|
| 62 |
+
for y, share in enumerate(shares):
|
| 63 |
+
left = 0.0
|
| 64 |
+
for klass, color in colors.items():
|
| 65 |
+
width = share.get(klass, 0.0)
|
| 66 |
+
ax.barh(
|
| 67 |
+
y,
|
| 68 |
+
width,
|
| 69 |
+
left=left,
|
| 70 |
+
height=BAR_HEIGHT,
|
| 71 |
+
color=color,
|
| 72 |
+
edgecolor="white",
|
| 73 |
+
linewidth=2,
|
| 74 |
+
)
|
| 75 |
+
if width >= 6:
|
| 76 |
+
ax.text(
|
| 77 |
+
left + width / 2,
|
| 78 |
+
y,
|
| 79 |
+
f"{width:.0f}%",
|
| 80 |
+
ha="center",
|
| 81 |
+
va="center",
|
| 82 |
+
fontsize=8,
|
| 83 |
+
color=_text_color(color),
|
| 84 |
+
)
|
| 85 |
+
left += width
|
| 86 |
+
ax.set_yticks(range(len(rows)), rows)
|
| 87 |
+
ax.invert_yaxis()
|
| 88 |
+
ax.set_xlim(0, 100)
|
| 89 |
+
ax.set_xlabel("share (%)")
|
| 90 |
+
handles = [Rectangle((0, 0), 1, 1, color=c) for c in colors.values()]
|
| 91 |
+
labels = [k.replace("_", " ") for k in colors]
|
| 92 |
+
ax.legend(
|
| 93 |
+
handles,
|
| 94 |
+
labels,
|
| 95 |
+
ncol=len(colors),
|
| 96 |
+
frameon=False,
|
| 97 |
+
loc="lower left",
|
| 98 |
+
bbox_to_anchor=(0, 1.0),
|
| 99 |
+
fontsize=8,
|
| 100 |
+
)
|
| 101 |
+
_style(ax)
|
| 102 |
+
|
| 103 |
+
|
| 104 |
+
def _percent(counts: Mapping[str, int], keys: Sequence[str]) -> dict[str, float]:
|
| 105 |
+
total = sum(counts[k] for k in keys)
|
| 106 |
+
return {k: 100.0 * counts[k] / total if total else 0.0 for k in keys}
|
| 107 |
+
|
| 108 |
+
|
| 109 |
+
def plot_scores(
|
| 110 |
+
results: Mapping[str, Mapping[str, Mapping[str, float]]],
|
| 111 |
+
species: TestSpecies | None = None,
|
| 112 |
+
) -> Figure:
|
| 113 |
+
"""Gene and exon F1 of each annotation, with the published NTv3 score as a tick.
|
| 114 |
+
|
| 115 |
+
Args:
|
| 116 |
+
results: Annotation label to ``evaluation.evaluate`` output.
|
| 117 |
+
species: Benchmark species, to mark the paper's NTv3 numbers.
|
| 118 |
+
"""
|
| 119 |
+
fig, axes = plt.subplots(1, 2, figsize=(9, 0.6 + 0.5 * len(results)), sharey=True)
|
| 120 |
+
labels = list(results)
|
| 121 |
+
for ax, (level, title) in zip(
|
| 122 |
+
axes, (("Gene", "Gene F1 (%)"), ("CDS", "Exon (CDS) F1 (%)")), strict=True
|
| 123 |
+
):
|
| 124 |
+
values = [100 * results[label][level]["F1"] for label in labels]
|
| 125 |
+
colors = [NTV3_COLOR if "NTv3" in label else OTHER_COLOR for label in labels]
|
| 126 |
+
ax.barh(labels, values, height=BAR_HEIGHT, color=colors)
|
| 127 |
+
for y, value in enumerate(values):
|
| 128 |
+
ax.text(value + 1, y, f"{value:.2f}", va="center", fontsize=9, color=INK)
|
| 129 |
+
if species is not None:
|
| 130 |
+
paper = species.gene_f1 if level == "Gene" else species.cds_f1
|
| 131 |
+
ax.axvline(paper, color=INK, linestyle=":", linewidth=1, label="paper")
|
| 132 |
+
ax.legend(
|
| 133 |
+
frameon=False, fontsize=8, loc="lower right", bbox_to_anchor=(1, 1)
|
| 134 |
+
)
|
| 135 |
+
ax.set_xlim(0, 110)
|
| 136 |
+
ax.set_title(title, loc="left", fontsize=10, color=INK)
|
| 137 |
+
_style(ax)
|
| 138 |
+
axes[0].invert_yaxis()
|
| 139 |
+
fig.tight_layout()
|
| 140 |
+
return fig
|
| 141 |
+
|
| 142 |
+
|
| 143 |
+
def plot_locus_composition(summaries: Mapping[str, Mapping[str, float]]) -> Figure:
|
| 144 |
+
"""Where every reference gene ends up, and the share of invented predictions."""
|
| 145 |
+
fig, (ax, side) = plt.subplots(
|
| 146 |
+
1,
|
| 147 |
+
2,
|
| 148 |
+
figsize=(10, 1.2 + 0.6 * len(summaries)),
|
| 149 |
+
gridspec_kw={"width_ratios": (5, 1)},
|
| 150 |
+
sharey=True,
|
| 151 |
+
)
|
| 152 |
+
classes = ("correct", "boundary", "missed", "fragmented")
|
| 153 |
+
shares = [
|
| 154 |
+
_percent({k: int(s[k]) for k in classes}, classes) for s in summaries.values()
|
| 155 |
+
]
|
| 156 |
+
_stacked_rows(ax, list(summaries), shares, {k: CLASS_COLORS[k] for k in classes})
|
| 157 |
+
ax.set_xlabel("reference genes (%)")
|
| 158 |
+
for y, summary in enumerate(summaries.values()):
|
| 159 |
+
share = 100 * summary["invented"] / summary["n_pred"]
|
| 160 |
+
side.barh(y, share, height=BAR_HEIGHT, color=CLASS_COLORS["invented"])
|
| 161 |
+
side.text(share, y, f" {share:.1f}%", va="center", fontsize=8, color=INK)
|
| 162 |
+
side.set_xlim(0, max(10, 1.4 * side.get_xlim()[1]))
|
| 163 |
+
side.set_xlabel("invented\n(% of predictions)")
|
| 164 |
+
_style(side)
|
| 165 |
+
fig.tight_layout()
|
| 166 |
+
return fig
|
| 167 |
+
|
| 168 |
+
|
| 169 |
+
def plot_recovery_by_size(recovery: Mapping[str, Any]) -> Figure:
|
| 170 |
+
"""Locus outcome by CDS span, and exact recovery by exon count."""
|
| 171 |
+
fig, (left, right) = plt.subplots(1, 2, figsize=(11, 3.6))
|
| 172 |
+
span_bins = [_short_span(label) for label in recovery["span_bins"]]
|
| 173 |
+
totals = recovery["span_total"]
|
| 174 |
+
outcome = recovery["span_outcome"]
|
| 175 |
+
shares = [
|
| 176 |
+
_percent({k: outcome[k][i] for k in outcome}, list(outcome))
|
| 177 |
+
for i in range(len(span_bins))
|
| 178 |
+
]
|
| 179 |
+
rows = [f"{b} (n={n:,})" for b, n in zip(span_bins, totals, strict=True)]
|
| 180 |
+
_stacked_rows(left, rows, shares, {k: CLASS_COLORS[k] for k in outcome})
|
| 181 |
+
left.set_title("by CDS span", loc="left", fontsize=10, color=INK, pad=22)
|
| 182 |
+
|
| 183 |
+
bins = recovery["exon_count_bins"]
|
| 184 |
+
total = recovery["exon_total"]
|
| 185 |
+
correct = recovery["exon_correct"]
|
| 186 |
+
rate = [100 * c / t if t else 0.0 for c, t in zip(correct, total, strict=True)]
|
| 187 |
+
right.bar(bins, rate, color=NTV3_COLOR, width=0.6)
|
| 188 |
+
for x, (value, n) in enumerate(zip(rate, total, strict=True)):
|
| 189 |
+
right.text(x, value + 1, f"{value:.0f}%\nn={n:,}", ha="center", fontsize=8)
|
| 190 |
+
right.set_ylim(0, 110)
|
| 191 |
+
right.set_xlabel("coding exons per gene")
|
| 192 |
+
right.set_ylabel("genes recovered exactly (%)")
|
| 193 |
+
right.set_title("by exon count", loc="left", fontsize=10, color=INK)
|
| 194 |
+
_style(right)
|
| 195 |
+
right.grid(axis="x", visible=False)
|
| 196 |
+
right.grid(axis="y", color="#e5e7eb", linewidth=0.8)
|
| 197 |
+
fig.tight_layout()
|
| 198 |
+
return fig
|
| 199 |
+
|
| 200 |
+
|
| 201 |
+
def _short_span(label: str) -> str:
|
| 202 |
+
lo, hi = (int(x) for x in label.split("-"))
|
| 203 |
+
fmt = lambda v: f"{v / 1e6:.0f} Mb" if v >= 1e6 else f"{v / 1e3:.0f} kb" # noqa: E731
|
| 204 |
+
return (
|
| 205 |
+
f">{fmt(lo)}" if hi >= 10**12 else f"{fmt(lo)}-{fmt(hi)}".replace("0 kb-", "0-")
|
| 206 |
+
)
|
| 207 |
+
|
| 208 |
+
|
| 209 |
+
def plot_exon_taxonomy(exons: Mapping[str, Any]) -> Figure:
|
| 210 |
+
"""Reference exons: exact, one or both boundaries wrong, or missed."""
|
| 211 |
+
by_position = exons["by_position"]
|
| 212 |
+
by_length = exons["by_length"]
|
| 213 |
+
classes = list(EXON_COLORS)
|
| 214 |
+
fig, (top, bottom) = plt.subplots(2, 1, figsize=(9, 6.6))
|
| 215 |
+
positions = [
|
| 216 |
+
p
|
| 217 |
+
for p in ("first", "internal", "last", "single")
|
| 218 |
+
if sum(by_position[p].values())
|
| 219 |
+
]
|
| 220 |
+
totals = exons["totals"]
|
| 221 |
+
_stacked_rows(
|
| 222 |
+
top,
|
| 223 |
+
[f"all exons (n={sum(totals.values()):,})"]
|
| 224 |
+
+ [f"{p} (n={sum(by_position[p].values()):,})" for p in positions],
|
| 225 |
+
[_percent(totals, classes)]
|
| 226 |
+
+ [_percent(by_position[p], classes) for p in positions],
|
| 227 |
+
EXON_COLORS,
|
| 228 |
+
)
|
| 229 |
+
top.set_title("by position in the gene", loc="left", fontsize=10, color=INK, pad=22)
|
| 230 |
+
lengths = exons["exon_length_bins"]
|
| 231 |
+
_stacked_rows(
|
| 232 |
+
bottom,
|
| 233 |
+
[
|
| 234 |
+
f"{b.replace('+', '+ ')} bp (n={sum(c.values()):,})"
|
| 235 |
+
for b, c in zip(lengths, by_length, strict=True)
|
| 236 |
+
],
|
| 237 |
+
[_percent(c, classes) for c in by_length],
|
| 238 |
+
EXON_COLORS,
|
| 239 |
+
)
|
| 240 |
+
bottom.set_title("by exon length", loc="left", fontsize=10, color=INK, pad=22)
|
| 241 |
+
fig.tight_layout()
|
| 242 |
+
return fig
|
| 243 |
+
|
| 244 |
+
|
| 245 |
+
def plot_failure_anatomy(anatomy: Mapping[str, Any]) -> Figure:
|
| 246 |
+
"""How many exons are wrong in each failed gene, and which one when it is one."""
|
| 247 |
+
grid = anatomy["wrong_by_gene_size"]
|
| 248 |
+
sizes = anatomy["exon_count_bins"]
|
| 249 |
+
buckets = anatomy["wrong_exon_buckets"]
|
| 250 |
+
fig, (left, right) = plt.subplots(1, 2, figsize=(11, 3.6))
|
| 251 |
+
ramp = ["#c3d5fc", "#7ea4fc", "#3973fc", "#1d56d6", "#004697", "#002a5c"]
|
| 252 |
+
shares = [
|
| 253 |
+
{
|
| 254 |
+
b: 100 * c / sum(row) if sum(row) else 0.0
|
| 255 |
+
for b, c in zip(buckets, row, strict=True)
|
| 256 |
+
}
|
| 257 |
+
for row in grid
|
| 258 |
+
]
|
| 259 |
+
rows = [
|
| 260 |
+
f"{s} exon{'' if s == '1' else 's'} (n={sum(r):,})"
|
| 261 |
+
for s, r in zip(sizes, grid, strict=True)
|
| 262 |
+
]
|
| 263 |
+
_stacked_rows(left, rows, shares, dict(zip(buckets, ramp, strict=False)))
|
| 264 |
+
left.set_title(
|
| 265 |
+
"wrong exons per failed gene", loc="left", fontsize=10, color=INK, pad=22
|
| 266 |
+
)
|
| 267 |
+
|
| 268 |
+
observed = anatomy["single_wrong_observed"]
|
| 269 |
+
expected = anatomy["single_wrong_expected"]
|
| 270 |
+
positions = list(observed)
|
| 271 |
+
x = range(len(positions))
|
| 272 |
+
right.bar(
|
| 273 |
+
[i - 0.2 for i in x],
|
| 274 |
+
[observed[p] for p in positions],
|
| 275 |
+
width=0.4,
|
| 276 |
+
edgecolor="white",
|
| 277 |
+
linewidth=2,
|
| 278 |
+
color=NTV3_COLOR,
|
| 279 |
+
label="observed",
|
| 280 |
+
)
|
| 281 |
+
right.bar(
|
| 282 |
+
[i + 0.2 for i in x],
|
| 283 |
+
[expected[p] for p in positions],
|
| 284 |
+
width=0.4,
|
| 285 |
+
edgecolor="white",
|
| 286 |
+
linewidth=2,
|
| 287 |
+
color=OTHER_COLOR,
|
| 288 |
+
label="if uniform",
|
| 289 |
+
)
|
| 290 |
+
right.set_xticks(list(x), positions)
|
| 291 |
+
right.set_ylabel("failed genes")
|
| 292 |
+
right.set_title(
|
| 293 |
+
"position of the single wrong exon", loc="left", fontsize=10, color=INK
|
| 294 |
+
)
|
| 295 |
+
right.legend(frameon=False, fontsize=8)
|
| 296 |
+
_style(right)
|
| 297 |
+
right.grid(axis="x", visible=False)
|
| 298 |
+
fig.tight_layout()
|
| 299 |
+
return fig
|
| 300 |
+
|
| 301 |
+
|
| 302 |
+
def plot_independence(check: Mapping[str, Any]) -> Figure:
|
| 303 |
+
"""Observed gene recovery vs. what independent exon errors would give."""
|
| 304 |
+
bins = check["exon_count_bins"]
|
| 305 |
+
n = check["n_genes"]
|
| 306 |
+
observed = [
|
| 307 |
+
100 * o / t if t else 0 for o, t in zip(check["observed"], n, strict=True)
|
| 308 |
+
]
|
| 309 |
+
predicted = [
|
| 310 |
+
100 * p / t if t else 0 for p, t in zip(check["predicted"], n, strict=True)
|
| 311 |
+
]
|
| 312 |
+
fig, ax = plt.subplots(figsize=(6, 3.4))
|
| 313 |
+
ax.plot(bins, observed, marker="o", color=NTV3_COLOR, linewidth=2, label="observed")
|
| 314 |
+
ax.plot(
|
| 315 |
+
bins,
|
| 316 |
+
predicted,
|
| 317 |
+
marker="o",
|
| 318 |
+
color=OTHER_COLOR,
|
| 319 |
+
linewidth=2,
|
| 320 |
+
linestyle="--",
|
| 321 |
+
label="independent exons",
|
| 322 |
+
)
|
| 323 |
+
ax.set_ylim(0, 100)
|
| 324 |
+
ax.set_xlabel("coding exons per gene")
|
| 325 |
+
ax.set_ylabel("genes recovered exactly (%)")
|
| 326 |
+
ax.legend(frameon=False, fontsize=8)
|
| 327 |
+
_style(ax)
|
| 328 |
+
ax.grid(axis="y", color="#e5e7eb", linewidth=0.8)
|
| 329 |
+
fig.tight_layout()
|
| 330 |
+
return fig
|
| 331 |
+
|
| 332 |
+
|
| 333 |
+
def plot_boundary_offsets(offsets: Mapping[str, Any]) -> Figure:
|
| 334 |
+
"""How far a wrong exon boundary is from the reference one."""
|
| 335 |
+
bins = offsets["offset_bins"]
|
| 336 |
+
counts = offsets["offsets"]
|
| 337 |
+
total = sum(counts)
|
| 338 |
+
fig, ax = plt.subplots(figsize=(7, 3.2))
|
| 339 |
+
shares = [100 * c / total if total else 0 for c in counts]
|
| 340 |
+
ax.bar([f"{b} bp" for b in bins], shares, color=NTV3_COLOR, width=0.6)
|
| 341 |
+
for x, value in enumerate(shares):
|
| 342 |
+
ax.text(x, value + 0.5, f"{value:.0f}%", ha="center", fontsize=8)
|
| 343 |
+
ax.set_ylabel("shifted boundaries (%)")
|
| 344 |
+
ax.set_xlabel("distance to the reference boundary")
|
| 345 |
+
_style(ax)
|
| 346 |
+
ax.grid(axis="x", visible=False)
|
| 347 |
+
fig.tight_layout()
|
| 348 |
+
return fig
|
| 349 |
+
|
| 350 |
+
|
| 351 |
+
def plot_structure(chains: Mapping[str, Mapping[str, float]]) -> Figure:
|
| 352 |
+
"""Exact gene F1 vs. intron-chain F1 (outer ends forgiven), by gene type."""
|
| 353 |
+
groups = [
|
| 354 |
+
("multi_exon", "multi-exon"),
|
| 355 |
+
("single_exon", "single-exon"),
|
| 356 |
+
("all", "all"),
|
| 357 |
+
]
|
| 358 |
+
fig, ax = plt.subplots(figsize=(6.5, 3.2))
|
| 359 |
+
for i, (key, _) in enumerate(groups):
|
| 360 |
+
for offset, (metric, color, label) in zip(
|
| 361 |
+
(-0.2, 0.2),
|
| 362 |
+
(
|
| 363 |
+
("exact_f1", NTV3_COLOR, "exact structure"),
|
| 364 |
+
("chain_f1", "#004697", "intron chain"),
|
| 365 |
+
),
|
| 366 |
+
strict=True,
|
| 367 |
+
):
|
| 368 |
+
value = chains[key][metric]
|
| 369 |
+
ax.bar(
|
| 370 |
+
i + offset,
|
| 371 |
+
value,
|
| 372 |
+
width=0.4,
|
| 373 |
+
edgecolor="white",
|
| 374 |
+
linewidth=2,
|
| 375 |
+
color=color,
|
| 376 |
+
label=label if i == 0 else None,
|
| 377 |
+
)
|
| 378 |
+
ax.text(i + offset, value + 1, f"{value:.1f}", ha="center", fontsize=8)
|
| 379 |
+
ax.set_xticks(range(len(groups)), [label for _, label in groups])
|
| 380 |
+
ax.set_ylim(0, 110)
|
| 381 |
+
ax.set_ylabel("gene F1 (%)")
|
| 382 |
+
ax.legend(frameon=False, fontsize=8, ncol=2, loc="upper left")
|
| 383 |
+
_style(ax)
|
| 384 |
+
ax.grid(axis="x", visible=False)
|
| 385 |
+
fig.tight_layout()
|
| 386 |
+
return fig
|
| 387 |
+
|
| 388 |
+
|
| 389 |
+
def plot_proteins(counts: Mapping[str, Any]) -> Figure:
|
| 390 |
+
"""Identity of each reference protein's best predicted counterpart."""
|
| 391 |
+
histogram = counts["identity_histogram"]
|
| 392 |
+
total = sum(histogram.values())
|
| 393 |
+
fig, ax = plt.subplots(figsize=(6.5, 3.2))
|
| 394 |
+
labels = list(histogram)
|
| 395 |
+
shares = [100 * histogram[k] / total if total else 0 for k in labels]
|
| 396 |
+
ax.bar([f"{k}%" for k in labels], shares, color=NTV3_COLOR, width=0.6)
|
| 397 |
+
for x, value in enumerate(shares):
|
| 398 |
+
ax.text(x, value + 1, f"{value:.0f}%", ha="center", fontsize=8)
|
| 399 |
+
ax.set_ylabel("reference proteins (%)")
|
| 400 |
+
ax.set_xlabel("identity to the best overlapping prediction")
|
| 401 |
+
_style(ax)
|
| 402 |
+
ax.grid(axis="x", visible=False)
|
| 403 |
+
fig.tight_layout()
|
| 404 |
+
return fig
|
| 405 |
+
|
| 406 |
+
|
| 407 |
+
def plot_failed_gene_repair(
|
| 408 |
+
exons: Mapping[str, Any], anatomy: Mapping[str, Any], summary: Mapping[str, Any]
|
| 409 |
+
) -> Figure:
|
| 410 |
+
"""Wrong exons per failed gene, and the genes a fix of at most k exons would add."""
|
| 411 |
+
buckets = exons["wrong_exon_buckets"]
|
| 412 |
+
per_gene = exons["wrong_exons_per_failed_gene"]
|
| 413 |
+
fig, (left, right) = plt.subplots(1, 2, figsize=(11, 3.4))
|
| 414 |
+
failed = sum(per_gene)
|
| 415 |
+
shares = [100 * n / failed if failed else 0 for n in per_gene]
|
| 416 |
+
left.bar(buckets, shares, color=NTV3_COLOR, width=0.6)
|
| 417 |
+
for x, value in enumerate(shares):
|
| 418 |
+
left.text(x, value + 1, f"{value:.0f}%", ha="center", fontsize=8)
|
| 419 |
+
left.set_xlabel("wrong exons in the gene")
|
| 420 |
+
left.set_ylabel("failed genes (%)")
|
| 421 |
+
left.set_title("how wrong a failed gene is", loc="left", fontsize=10, color=INK)
|
| 422 |
+
n_loci = summary["n_loci"]
|
| 423 |
+
recall = 100 * summary["correct"] / n_loci
|
| 424 |
+
gained = [100 * c / n_loci for c in anatomy["cumulative_convertible"]]
|
| 425 |
+
right.bar(
|
| 426 |
+
buckets,
|
| 427 |
+
[recall] * len(buckets),
|
| 428 |
+
color=OTHER_COLOR,
|
| 429 |
+
width=0.6,
|
| 430 |
+
label="recovered",
|
| 431 |
+
)
|
| 432 |
+
right.bar(
|
| 433 |
+
buckets,
|
| 434 |
+
gained,
|
| 435 |
+
bottom=recall,
|
| 436 |
+
color=NTV3_COLOR,
|
| 437 |
+
width=0.6,
|
| 438 |
+
label="after the fix",
|
| 439 |
+
)
|
| 440 |
+
for x, value in enumerate(gained):
|
| 441 |
+
right.text(
|
| 442 |
+
x, recall + value + 1, f"{recall + value:.0f}%", ha="center", fontsize=8
|
| 443 |
+
)
|
| 444 |
+
right.set_ylim(0, 105)
|
| 445 |
+
right.set_xlabel("fix at most this many exons per failed gene")
|
| 446 |
+
right.set_ylabel("reference genes exact (%)")
|
| 447 |
+
right.set_title(
|
| 448 |
+
"what fixing a few exons would buy", loc="left", fontsize=10, color=INK
|
| 449 |
+
)
|
| 450 |
+
right.legend(frameon=False, fontsize=8, loc="lower right")
|
| 451 |
+
for ax in (left, right):
|
| 452 |
+
_style(ax)
|
| 453 |
+
ax.grid(axis="x", visible=False)
|
| 454 |
+
fig.tight_layout()
|
| 455 |
+
return fig
|
| 456 |
+
|
| 457 |
+
|
| 458 |
+
def plot_structure_and_merges(
|
| 459 |
+
chains: Mapping[str, Mapping[str, float]], merges: Mapping[str, Any]
|
| 460 |
+
) -> Figure:
|
| 461 |
+
"""Exact vs. intron-chain gene F1, and how often one prediction merges loci."""
|
| 462 |
+
fig, (left, right) = plt.subplots(1, 2, figsize=(10, 3.2))
|
| 463 |
+
values = [chains["all"]["exact_f1"], chains["all"]["chain_f1"]]
|
| 464 |
+
left.bar(
|
| 465 |
+
["exact structure", "intron chain"],
|
| 466 |
+
values,
|
| 467 |
+
color=[NTV3_COLOR, "#004697"],
|
| 468 |
+
width=0.5,
|
| 469 |
+
)
|
| 470 |
+
for x, value in enumerate(values):
|
| 471 |
+
left.text(x, value + 1, f"{value:.1f}", ha="center", fontsize=9)
|
| 472 |
+
left.set_ylim(0, 105)
|
| 473 |
+
left.set_ylabel("gene F1 (%)")
|
| 474 |
+
shares = [
|
| 475 |
+
100 * merges["absorbed_loci"] / merges["n_ref"],
|
| 476 |
+
100 * merges["merging_predictions"] / merges["n_pred"],
|
| 477 |
+
]
|
| 478 |
+
right.barh(
|
| 479 |
+
["reference genes\nabsorbed in a merge", "predictions that\nmerge 2+ genes"],
|
| 480 |
+
shares,
|
| 481 |
+
color=CLASS_COLORS["fragmented"],
|
| 482 |
+
height=0.5,
|
| 483 |
+
)
|
| 484 |
+
for y, value in enumerate(shares):
|
| 485 |
+
right.text(value, y, f" {value:.1f}%", va="center", fontsize=9)
|
| 486 |
+
right.set_xlim(0, max(5, 1.3 * max(shares)))
|
| 487 |
+
right.set_xlabel("%")
|
| 488 |
+
right.invert_yaxis()
|
| 489 |
+
for ax in (left, right):
|
| 490 |
+
_style(ax)
|
| 491 |
+
left.grid(axis="x", visible=False)
|
| 492 |
+
fig.tight_layout()
|
| 493 |
+
return fig
|
| 494 |
+
|
| 495 |
+
|
| 496 |
+
def plot_protein_recovery(counts: Mapping[str, Any]) -> Figure:
|
| 497 |
+
"""Reference proteins recovered at each identity, and well-formed predicted ORFs."""
|
| 498 |
+
fig, (left, right) = plt.subplots(
|
| 499 |
+
1, 2, figsize=(10, 3.2), gridspec_kw={"width_ratios": (3, 1)}
|
| 500 |
+
)
|
| 501 |
+
n_ref = counts["n_reference"]
|
| 502 |
+
thresholds = list(counts["recovered"])
|
| 503 |
+
shares = [100 * counts["recovered"][t] / n_ref for t in thresholds]
|
| 504 |
+
left.bar([f">= {t}%" for t in thresholds], shares, color=NTV3_COLOR, width=0.6)
|
| 505 |
+
for x, value in enumerate(shares):
|
| 506 |
+
left.text(x, value + 1, f"{value:.1f}%", ha="center", fontsize=8)
|
| 507 |
+
left.set_ylim(0, 105)
|
| 508 |
+
left.set_xlabel("identity to the best overlapping prediction")
|
| 509 |
+
left.set_ylabel("reference proteins (%)")
|
| 510 |
+
well = 100 * counts["well_formed"] / counts["n_predicted"]
|
| 511 |
+
right.bar(["well-formed\nORFs"], [well], color=NTV3_COLOR, width=0.5)
|
| 512 |
+
right.text(0, well + 1, f"{well:.2f}%", ha="center", fontsize=8)
|
| 513 |
+
right.set_ylim(0, 105)
|
| 514 |
+
right.set_ylabel("predicted proteins (%)")
|
| 515 |
+
for ax in (left, right):
|
| 516 |
+
_style(ax)
|
| 517 |
+
ax.grid(axis="x", visible=False)
|
| 518 |
+
fig.tight_layout()
|
| 519 |
+
return fig
|
| 520 |
+
|
| 521 |
+
|
| 522 |
+
def plot_locus(
|
| 523 |
+
reference: Gene,
|
| 524 |
+
predictions: Sequence[Gene],
|
| 525 |
+
title: str = "",
|
| 526 |
+
pad_fraction: float = 0.04,
|
| 527 |
+
) -> Figure:
|
| 528 |
+
"""Gene models of one reference locus and the predictions overlapping it."""
|
| 529 |
+
genes = [reference, *predictions]
|
| 530 |
+
spans = [cds_span(g) for g in genes if g.cds_keys]
|
| 531 |
+
start, end = min(s for s, _ in spans), max(e for _, e in spans)
|
| 532 |
+
pad = max(1, int((end - start) * pad_fraction))
|
| 533 |
+
fig, ax = plt.subplots(figsize=(10, 0.8 + 0.5 * len(genes)))
|
| 534 |
+
ref_boundaries = {k[:4] for k in reference.cds_keys}
|
| 535 |
+
for y, gene in enumerate(genes):
|
| 536 |
+
lo, hi = cds_span(gene)
|
| 537 |
+
ax.plot([lo, hi], [y, y], color=MUTED, linewidth=1)
|
| 538 |
+
for key in gene.cds_keys:
|
| 539 |
+
good = y == 0 or key[:4] in ref_boundaries
|
| 540 |
+
color = (
|
| 541 |
+
(OTHER_COLOR if y == 0 else NTV3_COLOR)
|
| 542 |
+
if good
|
| 543 |
+
else CLASS_COLORS["fragmented"]
|
| 544 |
+
)
|
| 545 |
+
ax.add_patch(
|
| 546 |
+
Rectangle((key[1], y - 0.25), key[2] - key[1] + 1, 0.5, color=color)
|
| 547 |
+
)
|
| 548 |
+
ax.set_yticks(range(len(genes)), ["reference", *[g.gene_id for g in predictions]])
|
| 549 |
+
ax.invert_yaxis()
|
| 550 |
+
ax.set_xlim(start - pad, end + pad)
|
| 551 |
+
ax.xaxis.set_major_formatter(lambda x, _: f"{x:,.0f}")
|
| 552 |
+
strand = "forward" if reference.strand == "+" else "reverse"
|
| 553 |
+
ax.set_xlabel(f"{reference.chrom} ({strand} strand), bp")
|
| 554 |
+
ax.set_title(title or reference.gene_id, loc="left", fontsize=10, color=INK)
|
| 555 |
+
_style(ax)
|
| 556 |
+
fig.tight_layout()
|
| 557 |
+
return fig
|
| 558 |
+
|
| 559 |
+
|
| 560 |
+
# --- Decoding walkthrough (03b) ---------------------------------------------------
|
| 561 |
+
|
| 562 |
+
_INTRON = np.array([c.startswith("intron") for c in decoding.CLASSES])
|
| 563 |
+
_EXONIC = ~_INTRON & (np.arange(decoding.NUM_CLASSES) != 0)
|
| 564 |
+
GROUP_COLORS = {"intergenic": "#e5e7eb", "exon": NTV3_COLOR, "intron": "#c3d5fc"}
|
| 565 |
+
|
| 566 |
+
|
| 567 |
+
def plot_transitions() -> Figure:
|
| 568 |
+
"""The HMM's allowed transitions and their probabilities (log scale)."""
|
| 569 |
+
probs = np.exp(decoding.LOG_TRANSITIONS)
|
| 570 |
+
fig, ax = plt.subplots(figsize=(7, 6))
|
| 571 |
+
shown = np.where(probs > 0, np.log10(np.maximum(probs, 1e-12)), np.nan)
|
| 572 |
+
image = ax.imshow(shown, cmap="Blues", vmin=-5, vmax=0)
|
| 573 |
+
ax.set_xticks(
|
| 574 |
+
range(decoding.NUM_CLASSES), decoding.CLASSES, rotation=90, fontsize=8
|
| 575 |
+
)
|
| 576 |
+
ax.set_yticks(range(decoding.NUM_CLASSES), decoding.CLASSES, fontsize=8)
|
| 577 |
+
ax.set_xlabel("to")
|
| 578 |
+
ax.set_ylabel("from")
|
| 579 |
+
fig.colorbar(image, ax=ax, label="log10 probability (blank: forbidden)")
|
| 580 |
+
fig.tight_layout()
|
| 581 |
+
return fig
|
| 582 |
+
|
| 583 |
+
|
| 584 |
+
def _feature_runs(
|
| 585 |
+
states: np.ndarray,
|
| 586 |
+
) -> tuple[list[tuple[int, int]], list[tuple[int, int]]]:
|
| 587 |
+
"""(exon runs, intron runs) of a state path, as half-open index ranges."""
|
| 588 |
+
kind = np.where(_INTRON[states], 2, np.where(_EXONIC[states], 1, 0))
|
| 589 |
+
edges = np.flatnonzero(np.diff(np.concatenate([[-1], kind, [-1]])))
|
| 590 |
+
runs = list(itertools.pairwise(edges))
|
| 591 |
+
return (
|
| 592 |
+
[(a, b) for a, b in runs if kind[a] == 1],
|
| 593 |
+
[(a, b) for a, b in runs if kind[a] == 2],
|
| 594 |
+
)
|
| 595 |
+
|
| 596 |
+
|
| 597 |
+
def plot_tracks(
|
| 598 |
+
tracks: Mapping[str, np.ndarray],
|
| 599 |
+
region_start: int,
|
| 600 |
+
zoom: tuple[int, int],
|
| 601 |
+
reference: Sequence[Gene] = (),
|
| 602 |
+
title: str = "",
|
| 603 |
+
mark_errors: bool = False,
|
| 604 |
+
) -> Figure:
|
| 605 |
+
"""Gene structure of each state path over ``zoom`` (contig bp), reference on top.
|
| 606 |
+
|
| 607 |
+
Args:
|
| 608 |
+
tracks: Label to one strand's state path over the region, genomic order.
|
| 609 |
+
region_start: Contig bp of the paths' first position.
|
| 610 |
+
zoom: Contig bp range to draw.
|
| 611 |
+
reference: Reference genes of that strand to draw on the first row.
|
| 612 |
+
title: Axes title.
|
| 613 |
+
mark_errors: Draw predicted exons whose boundaries match no reference exon
|
| 614 |
+
in red.
|
| 615 |
+
"""
|
| 616 |
+
ref_exons = {(k[1], k[2]) for gene in reference for k in gene.cds_keys}
|
| 617 |
+
rows = ["reference", *tracks] if reference else list(tracks)
|
| 618 |
+
fig, ax = plt.subplots(figsize=(11, 0.9 + 0.45 * len(rows)))
|
| 619 |
+
lo, hi = zoom
|
| 620 |
+
y = 0
|
| 621 |
+
if reference:
|
| 622 |
+
for gene in reference:
|
| 623 |
+
s, e = cds_span(gene)
|
| 624 |
+
if e < lo or s > hi:
|
| 625 |
+
continue
|
| 626 |
+
ax.plot([s, e], [y, y], color=MUTED, linewidth=1)
|
| 627 |
+
for key in gene.cds_keys:
|
| 628 |
+
ax.add_patch(
|
| 629 |
+
Rectangle(
|
| 630 |
+
(key[1], y - 0.25), key[2] - key[1] + 1, 0.5, color=OTHER_COLOR
|
| 631 |
+
)
|
| 632 |
+
)
|
| 633 |
+
y += 1
|
| 634 |
+
for states in tracks.values():
|
| 635 |
+
window = states[lo - region_start : hi - region_start]
|
| 636 |
+
exons, introns = _feature_runs(window)
|
| 637 |
+
for a, b in introns:
|
| 638 |
+
ax.plot([lo + a, lo + b], [y, y], color=MUTED, linewidth=1)
|
| 639 |
+
for a, b in exons:
|
| 640 |
+
wrong = mark_errors and (lo + a + 1, lo + b) not in ref_exons
|
| 641 |
+
color = CLASS_COLORS["fragmented"] if wrong else NTV3_COLOR
|
| 642 |
+
ax.add_patch(Rectangle((lo + a, y - 0.25), b - a, 0.5, color=color))
|
| 643 |
+
y += 1
|
| 644 |
+
ax.set_yticks(range(len(rows)), rows)
|
| 645 |
+
ax.set_ylim(len(rows) - 0.5, -0.5)
|
| 646 |
+
ax.set_xlim(lo, hi)
|
| 647 |
+
ax.xaxis.set_major_formatter(lambda x, _: f"{x:,.0f}")
|
| 648 |
+
ax.set_title(title, loc="left", fontsize=10, color=INK)
|
| 649 |
+
_style(ax)
|
| 650 |
+
fig.tight_layout()
|
| 651 |
+
return fig
|
| 652 |
+
|
| 653 |
+
|
| 654 |
+
def plot_class_probabilities(
|
| 655 |
+
log_probs: np.ndarray, region_start: int, zoom: tuple[int, int], title: str = ""
|
| 656 |
+
) -> Figure:
|
| 657 |
+
"""Probability of intergenic / exon / intron along ``zoom``, stacked to 1."""
|
| 658 |
+
lo, hi = zoom
|
| 659 |
+
probs = np.exp(log_probs[lo - region_start : hi - region_start])
|
| 660 |
+
groups = {
|
| 661 |
+
"intergenic": probs[:, 0],
|
| 662 |
+
"exon": probs[:, _EXONIC].sum(1),
|
| 663 |
+
"intron": probs[:, _INTRON].sum(1),
|
| 664 |
+
}
|
| 665 |
+
fig, ax = plt.subplots(figsize=(11, 2.4))
|
| 666 |
+
x = np.arange(lo, hi)
|
| 667 |
+
ax.stackplot(
|
| 668 |
+
x, *groups.values(), colors=list(GROUP_COLORS.values()), labels=list(groups)
|
| 669 |
+
)
|
| 670 |
+
ax.set_xlim(lo, hi)
|
| 671 |
+
ax.set_ylim(0, 1)
|
| 672 |
+
ax.set_ylabel("probability")
|
| 673 |
+
ax.xaxis.set_major_formatter(lambda v, _: f"{v:,.0f}")
|
| 674 |
+
ax.legend(
|
| 675 |
+
frameon=False, fontsize=8, ncol=3, loc="lower left", bbox_to_anchor=(0, 1)
|
| 676 |
+
)
|
| 677 |
+
ax.set_title(title, loc="right", fontsize=10, color=INK)
|
| 678 |
+
_style(ax)
|
| 679 |
+
fig.tight_layout()
|
| 680 |
+
return fig
|
| 681 |
+
|
| 682 |
+
|
| 683 |
+
def plot_edge_drift(
|
| 684 |
+
distance_kb: np.ndarray, drift: np.ndarray, crop_kb: float
|
| 685 |
+
) -> Figure:
|
| 686 |
+
"""How far predictions move from their full-context values near a window edge.
|
| 687 |
+
|
| 688 |
+
Args:
|
| 689 |
+
distance_kb: Bin centres, kb from the window edge.
|
| 690 |
+
drift: Mean total-variation distance to the full-context prediction.
|
| 691 |
+
crop_kb: Context margin the pipeline discards, drawn as a line.
|
| 692 |
+
"""
|
| 693 |
+
fig, ax = plt.subplots(figsize=(7.5, 3.2))
|
| 694 |
+
ax.axvspan(
|
| 695 |
+
distance_kb.min() / 2, crop_kb, color="#eef2ff", label="discarded margin"
|
| 696 |
+
)
|
| 697 |
+
ax.plot(distance_kb, drift, color=NTV3_COLOR, linewidth=2, marker="o", markersize=5)
|
| 698 |
+
ax.set_xscale("log")
|
| 699 |
+
ax.set_xlim(distance_kb.min() / 2, distance_kb.max() * 1.5)
|
| 700 |
+
ticks = [1, 4, 16, 64, round(crop_kb)]
|
| 701 |
+
ax.set_xticks(ticks, [f"{t:g}" for t in ticks])
|
| 702 |
+
ax.minorticks_off()
|
| 703 |
+
ax.set_ylim(0, None)
|
| 704 |
+
ax.set_xlabel("distance to the window edge (kb)")
|
| 705 |
+
ax.set_ylabel("change in predicted\nprobabilities (TV)")
|
| 706 |
+
ax.legend(frameon=False, fontsize=8)
|
| 707 |
+
_style(ax)
|
| 708 |
+
ax.grid(axis="y", color="#e5e7eb", linewidth=0.8)
|
| 709 |
+
fig.tight_layout()
|
| 710 |
+
return fig
|
src/genome_annotation/proteins.py
ADDED
|
@@ -0,0 +1,150 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""Protein-level checks of a predicted annotation (needs the genome FASTA).
|
| 2 |
+
|
| 3 |
+
Translates every gene, checks predicted ORFs (start with M, no internal stop), and
|
| 4 |
+
scores each reference protein by the identity of its best overlapping prediction.
|
| 5 |
+
"""
|
| 6 |
+
|
| 7 |
+
import dataclasses
|
| 8 |
+
from collections.abc import Callable, Iterable, Mapping
|
| 9 |
+
from dataclasses import dataclass, field
|
| 10 |
+
|
| 11 |
+
from Bio.Align import PairwiseAligner
|
| 12 |
+
from Bio.Seq import Seq
|
| 13 |
+
|
| 14 |
+
from .analysis import CdsIndex
|
| 15 |
+
from .evaluation import Gene
|
| 16 |
+
|
| 17 |
+
IDENTITY_THRESHOLDS = (100.0, 95.0, 90.0, 50.0)
|
| 18 |
+
IDENTITY_BINS = ("100", "95-100", "90-95", "50-90", "<50")
|
| 19 |
+
# Shorter proteins are fragments, not scored for ORF shape.
|
| 20 |
+
MIN_PROTEIN_LENGTH = 20
|
| 21 |
+
|
| 22 |
+
|
| 23 |
+
@dataclass
|
| 24 |
+
class ProteinCounts:
|
| 25 |
+
"""Protein-level tallies; ORF fields cover predictions of 20+ residues."""
|
| 26 |
+
|
| 27 |
+
n_reference: int = 0
|
| 28 |
+
recovered: dict[str, int] = field(
|
| 29 |
+
default_factory=lambda: {f"{t:g}": 0 for t in IDENTITY_THRESHOLDS}
|
| 30 |
+
)
|
| 31 |
+
no_counterpart: int = 0
|
| 32 |
+
n_predicted: int = 0
|
| 33 |
+
starts_with_methionine: int = 0
|
| 34 |
+
no_internal_stop: int = 0
|
| 35 |
+
well_formed: int = 0
|
| 36 |
+
identity_histogram: dict[str, int] = field(
|
| 37 |
+
default_factory=lambda: dict.fromkeys(IDENTITY_BINS, 0)
|
| 38 |
+
)
|
| 39 |
+
|
| 40 |
+
|
| 41 |
+
def translate_gene(gene: Gene, sequence: str) -> str:
|
| 42 |
+
"""Protein encoded by a gene's CDS on its (uppercase) chromosome sequence.
|
| 43 |
+
|
| 44 |
+
CDS are joined in ascending genomic order and reverse-complemented once on the
|
| 45 |
+
minus strand; the phase of the transcript's first CDS is honoured, a trailing
|
| 46 |
+
partial codon and one terminal stop are dropped.
|
| 47 |
+
"""
|
| 48 |
+
ascending = sorted(gene.cds_keys, key=lambda k: k[1])
|
| 49 |
+
coding = "".join(sequence[start - 1 : end] for _, start, end, _, _ in ascending)
|
| 50 |
+
if gene.strand == "-":
|
| 51 |
+
coding = str(Seq(coding).reverse_complement())
|
| 52 |
+
phase = (ascending[-1] if gene.strand == "-" else ascending[0])[4]
|
| 53 |
+
coding = coding[int(phase) if phase.isdigit() else 0 :]
|
| 54 |
+
coding = coding[: len(coding) - len(coding) % 3]
|
| 55 |
+
if not coding:
|
| 56 |
+
return ""
|
| 57 |
+
protein = str(Seq(coding).translate())
|
| 58 |
+
return protein[:-1] if protein.endswith("*") else protein
|
| 59 |
+
|
| 60 |
+
|
| 61 |
+
def identity(a: str, b: str, aligner: PairwiseAligner) -> float:
|
| 62 |
+
"""Percent identity of a global alignment, over the longer protein."""
|
| 63 |
+
if a == b:
|
| 64 |
+
return 100.0
|
| 65 |
+
if not a or not b:
|
| 66 |
+
return 0.0
|
| 67 |
+
alignment = aligner.align(a, b)[0]
|
| 68 |
+
matches = sum(
|
| 69 |
+
x == y and x != "-" for x, y in zip(alignment[0], alignment[1], strict=True)
|
| 70 |
+
)
|
| 71 |
+
return 100.0 * matches / max(len(a), len(b))
|
| 72 |
+
|
| 73 |
+
|
| 74 |
+
def _identity_bin(value: float) -> str:
|
| 75 |
+
for threshold, label in zip(IDENTITY_THRESHOLDS, IDENTITY_BINS, strict=False):
|
| 76 |
+
if value >= threshold:
|
| 77 |
+
return label
|
| 78 |
+
return "<50"
|
| 79 |
+
|
| 80 |
+
|
| 81 |
+
def measure_proteins(
|
| 82 |
+
ref_genes: dict[str, Gene],
|
| 83 |
+
pred_genes: dict[str, Gene],
|
| 84 |
+
genome: Mapping[str, str] | Callable[[str], str],
|
| 85 |
+
chromosomes: Iterable[str] | None = None,
|
| 86 |
+
) -> dict[str, object]:
|
| 87 |
+
"""ORF shape of the predictions and identity recovery of the reference.
|
| 88 |
+
|
| 89 |
+
Args:
|
| 90 |
+
ref_genes: Reference genes, e.g. ``ScoringSets.ref_genes``.
|
| 91 |
+
pred_genes: Predicted genes.
|
| 92 |
+
genome: Chromosome name to sequence, or a function returning it (e.g. over a
|
| 93 |
+
``pyfaidx.Fasta``); read one chromosome at a time.
|
| 94 |
+
chromosomes: Chromosomes to measure; defaults to every one with a gene.
|
| 95 |
+
|
| 96 |
+
Returns:
|
| 97 |
+
``ProteinCounts`` as a dict.
|
| 98 |
+
"""
|
| 99 |
+
fetch = genome if callable(genome) else genome.__getitem__
|
| 100 |
+
if chromosomes is None:
|
| 101 |
+
chromosomes = sorted(
|
| 102 |
+
{g.chrom for g in ref_genes.values()}
|
| 103 |
+
| {g.chrom for g in pred_genes.values()}
|
| 104 |
+
)
|
| 105 |
+
aligner = PairwiseAligner(scoring="blastp")
|
| 106 |
+
aligner.mode = "global"
|
| 107 |
+
counts = ProteinCounts()
|
| 108 |
+
|
| 109 |
+
for chrom in chromosomes:
|
| 110 |
+
chrom_ref = {
|
| 111 |
+
g: x for g, x in ref_genes.items() if x.chrom == chrom and x.cds_keys
|
| 112 |
+
}
|
| 113 |
+
chrom_pred = {
|
| 114 |
+
g: x for g, x in pred_genes.items() if x.chrom == chrom and x.cds_keys
|
| 115 |
+
}
|
| 116 |
+
if not chrom_ref and not chrom_pred:
|
| 117 |
+
continue
|
| 118 |
+
sequence = str(fetch(chrom)).upper()
|
| 119 |
+
ref_proteins = {g: translate_gene(x, sequence) for g, x in chrom_ref.items()}
|
| 120 |
+
pred_proteins = {g: translate_gene(x, sequence) for g, x in chrom_pred.items()}
|
| 121 |
+
|
| 122 |
+
for protein in pred_proteins.values():
|
| 123 |
+
if len(protein) < MIN_PROTEIN_LENGTH:
|
| 124 |
+
continue
|
| 125 |
+
starts, clean = protein.startswith("M"), "*" not in protein
|
| 126 |
+
counts.n_predicted += 1
|
| 127 |
+
counts.starts_with_methionine += starts
|
| 128 |
+
counts.no_internal_stop += clean
|
| 129 |
+
counts.well_formed += starts and clean
|
| 130 |
+
|
| 131 |
+
index = CdsIndex(chrom_pred)
|
| 132 |
+
for gid, gene in chrom_ref.items():
|
| 133 |
+
reference = ref_proteins[gid]
|
| 134 |
+
if not reference:
|
| 135 |
+
continue
|
| 136 |
+
counts.n_reference += 1
|
| 137 |
+
candidates = [
|
| 138 |
+
pred_proteins[other]
|
| 139 |
+
for other in index.overlapping_gene(gene)
|
| 140 |
+
if pred_proteins.get(other)
|
| 141 |
+
]
|
| 142 |
+
if not candidates:
|
| 143 |
+
counts.no_counterpart += 1
|
| 144 |
+
counts.identity_histogram["<50"] += 1
|
| 145 |
+
continue
|
| 146 |
+
best = max(identity(reference, c, aligner) for c in candidates)
|
| 147 |
+
for threshold in IDENTITY_THRESHOLDS:
|
| 148 |
+
counts.recovered[f"{threshold:g}"] += best >= threshold
|
| 149 |
+
counts.identity_histogram[_identity_bin(best)] += 1
|
| 150 |
+
return dataclasses.asdict(counts)
|
tabs/demo.html
DELETED
|
@@ -1,33 +0,0 @@
|
|
| 1 |
-
<div class="grid">
|
| 2 |
-
<div class="card" style="grid-column: span 12; text-align: center; padding: 60px 40px;">
|
| 3 |
-
<h2 style="margin-bottom: 20px;">🚀 NTv3 Live Demo</h2>
|
| 4 |
-
<p style="margin-bottom: 30px; color: var(--muted); line-height: 1.7;">
|
| 5 |
-
Try out the NTv3 model interactively in this live demo. Predict functional tracks and genome annotation directly in your browser. Click the button below to open the interactive demo in a new window.
|
| 6 |
-
</p>
|
| 7 |
-
<a
|
| 8 |
-
href="https://huggingface.co/spaces/InstaDeepAI/ntv3_tracks"
|
| 9 |
-
target="_blank"
|
| 10 |
-
rel="noopener noreferrer"
|
| 11 |
-
style="
|
| 12 |
-
display: inline-block;
|
| 13 |
-
padding: 16px 32px;
|
| 14 |
-
background: var(--link);
|
| 15 |
-
color: white;
|
| 16 |
-
text-decoration: none;
|
| 17 |
-
border-radius: 12px;
|
| 18 |
-
font-weight: 600;
|
| 19 |
-
font-size: 16px;
|
| 20 |
-
transition: all 0.2s ease;
|
| 21 |
-
box-shadow: 0 4px 12px rgba(125, 211, 252, 0.3);
|
| 22 |
-
"
|
| 23 |
-
onmouseover="this.style.background='#60b8e8'; this.style.transform='translateY(-2px)'; this.style.boxShadow='0 6px 16px rgba(125, 211, 252, 0.4)';"
|
| 24 |
-
onmouseout="this.style.background='var(--link)'; this.style.transform='translateY(0)'; this.style.boxShadow='0 4px 12px rgba(125, 211, 252, 0.3)';"
|
| 25 |
-
>
|
| 26 |
-
🚀 Open Live Demo →
|
| 27 |
-
</a>
|
| 28 |
-
<p style="margin-top: 30px; font-size: 13px; color: var(--muted);">
|
| 29 |
-
The demo will open in a new tab. Make sure pop-ups are enabled for this site.
|
| 30 |
-
</p>
|
| 31 |
-
</div>
|
| 32 |
-
</div>
|
| 33 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
tabs/home.html
CHANGED
|
@@ -1,78 +1,101 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
<div class="summary">
|
| 2 |
<h2>📖 About NTv3</h2>
|
| 3 |
<p>
|
| 4 |
-
NTv3 is a
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 5 |
</p>
|
| 6 |
<p>
|
| 7 |
-
|
|
|
|
| 8 |
</p>
|
| 9 |
</div>
|
| 10 |
|
| 11 |
<div class="paper-summary">
|
| 12 |
-
<
|
| 13 |
-
<img src="assets/paper_summary.png" alt="NTv3 Paper Summary" />
|
| 14 |
</div>
|
| 15 |
|
| 16 |
<div class="why-ntv3">
|
| 17 |
-
<h2>✨
|
| 18 |
<ul>
|
| 19 |
-
<li>📏 <strong>1 Mb
|
| 20 |
-
<li>
|
| 21 |
-
<li>
|
| 22 |
-
<li>
|
| 23 |
-
<li>
|
| 24 |
</ul>
|
| 25 |
</div>
|
| 26 |
|
| 27 |
<div class="grid">
|
| 28 |
-
<div class="card">
|
| 29 |
-
<h2>🤖
|
| 30 |
-
<
|
| 31 |
-
<
|
| 32 |
-
|
| 33 |
-
|
| 34 |
-
|
| 35 |
-
|
| 36 |
-
|
| 37 |
-
</
|
| 38 |
-
|
| 39 |
-
|
| 40 |
-
|
| 41 |
-
|
| 42 |
-
</div>
|
| 43 |
-
</li>
|
| 44 |
-
</ul>
|
| 45 |
-
<table>
|
| 46 |
<thead>
|
| 47 |
<tr>
|
| 48 |
<th>Model</th>
|
| 49 |
-
<th>
|
| 50 |
-
<th>
|
| 51 |
-
<th>
|
| 52 |
-
<th>Usage</th>
|
| 53 |
</tr>
|
| 54 |
</thead>
|
| 55 |
<tbody>
|
| 56 |
<tr>
|
| 57 |
-
<td><strong>NTv3-
|
| 58 |
-
<td>
|
| 59 |
-
<td>
|
| 60 |
-
<td>
|
| 61 |
-
<td>Embeddings, light inference</td>
|
| 62 |
</tr>
|
| 63 |
<tr>
|
| 64 |
-
<td><strong>NTv3-
|
| 65 |
-
<td>
|
| 66 |
-
<td>
|
| 67 |
-
<td>
|
| 68 |
-
|
|
|
|
|
|
|
| 69 |
</tr>
|
| 70 |
<tr>
|
| 71 |
-
<td><strong>NTv3-
|
| 72 |
-
<td>
|
| 73 |
-
<td><
|
| 74 |
-
<td>
|
| 75 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 76 |
</tr>
|
| 77 |
</tbody>
|
| 78 |
</table>
|
|
@@ -80,152 +103,147 @@
|
|
| 80 |
|
| 81 |
<div class="card-stack">
|
| 82 |
<div class="card">
|
| 83 |
-
<h2>📓
|
|
|
|
|
|
|
|
|
|
| 84 |
<ul>
|
| 85 |
-
<li><a href="https://huggingface.co/spaces/InstaDeepAI/ntv3/blob/main/notebooks_tutorials/00_quickstart_inference.ipynb" target="_blank" rel="noopener noreferrer">
|
| 86 |
-
<li><a href="https://huggingface.co/spaces/InstaDeepAI/ntv3/blob/main/notebooks_tutorials/01_tracks_prediction.ipynb" target="_blank" rel="noopener noreferrer">
|
| 87 |
-
<li><a href="https://huggingface.co/spaces/InstaDeepAI/ntv3/blob/main/notebooks_tutorials/
|
| 88 |
-
<li>
|
| 89 |
-
|
| 90 |
-
|
| 91 |
-
|
| 92 |
-
|
| 93 |
-
|
| 94 |
-
|
| 95 |
-
|
| 96 |
-
|
| 97 |
-
|
| 98 |
-
<li><a href="https://huggingface.co/spaces/InstaDeepAI/ntv3/blob/main/notebooks_pipelines/01_functional_track_prediction.ipynb" target="_blank" rel="noopener noreferrer">🎯 01 — Generate bigwig predictions for certain tracks</a></li>
|
| 99 |
-
<li><a href="https://huggingface.co/spaces/InstaDeepAI/ntv3/blob/main/notebooks_pipelines/02_model_interpretation.ipynb" target="_blank" rel="noopener noreferrer">🔍 02 — Interpret a given genomic region</a></li>
|
| 100 |
</ul>
|
| 101 |
</div>
|
| 102 |
<div class="card">
|
| 103 |
<h2>🔗 Links</h2>
|
| 104 |
<ul>
|
| 105 |
-
<li>
|
| 106 |
<li><a href="https://github.com/instadeepai/nucleotide-transformer" target="_blank" rel="noopener noreferrer">💻 JAX model code (GitHub)</a></li>
|
| 107 |
-
<li><a href="https://huggingface.co/collections/InstaDeepAI/
|
| 108 |
-
<li><a href="https://huggingface.co/
|
| 109 |
-
<li><a href="https://huggingface.co/
|
|
|
|
| 110 |
</ul>
|
| 111 |
</div>
|
| 112 |
</div>
|
| 113 |
|
| 114 |
<div class="card-stack">
|
| 115 |
<div class="card">
|
| 116 |
-
<h2>🤖
|
| 117 |
-
<p>
|
| 118 |
-
<div class="code"><pre><code class="language-python">
|
|
|
|
| 119 |
|
| 120 |
-
model_name = "InstaDeepAI/
|
| 121 |
|
| 122 |
-
|
| 123 |
-
model = AutoModelForMaskedLM.from_pretrained(model_name, trust_remote_code=True)
|
| 124 |
tok = AutoTokenizer.from_pretrained(model_name, trust_remote_code=True)
|
| 125 |
|
| 126 |
-
#
|
| 127 |
-
|
|
|
|
|
|
|
| 128 |
|
| 129 |
-
#
|
| 130 |
-
|
|
|
|
| 131 |
|
| 132 |
-
#
|
| 133 |
-
print(out.
|
| 134 |
</code></pre></div>
|
| 135 |
-
<p>Model embeddings can be used for fine-tuning on downstream tasks.</p>
|
| 136 |
</div>
|
| 137 |
<div class="card">
|
| 138 |
-
<h2>🔍
|
| 139 |
-
<p>
|
| 140 |
-
<
|
| 141 |
-
import torch
|
| 142 |
-
import matplotlib.pyplot as plt
|
| 143 |
-
|
| 144 |
-
model_name = "InstaDeepAI/NTv3_650M_post"
|
| 145 |
-
|
| 146 |
-
# Build interpretation pipeline
|
| 147 |
-
ntv3_interpret = pipeline(
|
| 148 |
-
"ntv3-interpret",
|
| 149 |
-
model=model_name,
|
| 150 |
-
trust_remote_code=True,
|
| 151 |
-
device=0 if torch.cuda.is_available() else -1,
|
| 152 |
-
)
|
| 153 |
-
|
| 154 |
-
# Run interpretation on a given genomic region with tracks, annotations, attention, and saliency
|
| 155 |
-
result = ntv3_interpret(
|
| 156 |
-
{"chrom": "chr11", "start": 5_253_561, "end": 5_286_329, "species": "human"},
|
| 157 |
-
output_attention=True,
|
| 158 |
-
output_saliency=True,
|
| 159 |
-
saliency_track_id="ENCSR000EFT", # K562 GATA1 ChIP-seq
|
| 160 |
-
plot=True, # plot predictons on tracks and annotations
|
| 161 |
-
tracks_to_plot={"K562 RNA-seq": "ENCSR056HPM", "K562 GATA1": "ENCSR000EFT"},
|
| 162 |
-
elements_to_plot=["exon", "promoter_Tissue_specific"],
|
| 163 |
-
)
|
| 164 |
-
|
| 165 |
-
# Access attention map results
|
| 166 |
-
result.plot_attention() # attention map (last layer)
|
| 167 |
-
plt.show()
|
| 168 |
-
|
| 169 |
-
# Access saliency scores results
|
| 170 |
-
result.plot_saliency(window_size=128)
|
| 171 |
-
plt.show()
|
| 172 |
-
</code></pre></div>
|
| 173 |
-
<img src="assets/saliency_example.png" alt="Output tracks visualization" style="max-width: 100%; margin-top: 20px;" />
|
| 174 |
</div>
|
| 175 |
</div>
|
| 176 |
-
|
| 177 |
-
<div class="card">
|
| 178 |
-
<h2>💻 Use a post-trained model</h2>
|
| 179 |
-
<p>Here is a quick example of how to use the post-trained NTv3 650M model to predict tracks for a human genomic window.</p>
|
| 180 |
-
<div class="code"><pre><code class="language-python">from transformers import pipeline
|
| 181 |
-
import torch
|
| 182 |
|
| 183 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 184 |
|
| 185 |
-
ntv3_tracks = pipeline(
|
| 186 |
-
"ntv3-tracks",
|
| 187 |
-
model=model_name,
|
| 188 |
-
trust_remote_code=True,
|
| 189 |
-
device=0 if torch.cuda.is_available() else -1,
|
| 190 |
-
)
|
| 191 |
|
| 192 |
-
# Run track prediction
|
| 193 |
out = ntv3_tracks(
|
| 194 |
-
|
| 195 |
-
|
| 196 |
-
|
| 197 |
-
|
| 198 |
-
|
| 199 |
-
|
|
|
|
|
|
|
| 200 |
)
|
| 201 |
|
| 202 |
-
#
|
| 203 |
-
#
|
| 204 |
-
print("
|
| 205 |
-
|
| 206 |
-
|
| 207 |
-
# Language model logits for whole sequence over vocabulary
|
| 208 |
-
print("language model logits:", tuple(out.mlm_logits.shape))</code></pre></div>
|
| 209 |
-
<p>Predictions can also be plotted for a subset of functional tracks and genomic elements:</p>
|
| 210 |
-
<div class="code"><pre><code class="language-python">tracks_to_plot = {
|
| 211 |
-
"K562 RNA-seq": "ENCSR056HPM",
|
| 212 |
-
"K562 DNAse": "ENCSR921NMD",
|
| 213 |
-
"K562 H3k4me3": "ENCSR000DWD",
|
| 214 |
-
"K562 CTCF": "ENCSR000AKO",
|
| 215 |
-
"HepG2 RNA-seq": "ENCSR561FEE_P",
|
| 216 |
-
"HepG2 DNAse": "ENCSR000EJV",
|
| 217 |
-
"HepG2 H3k4me3": "ENCSR000AMP",
|
| 218 |
-
"HepG2 CTCF": "ENCSR000BIE",
|
| 219 |
-
}
|
| 220 |
-
elements_to_plot = ["protein_coding_gene", "exon", "intron", "splice_donor", "splice_acceptor"]
|
| 221 |
|
| 222 |
-
|
| 223 |
-
|
| 224 |
-
|
| 225 |
-
|
| 226 |
-
|
| 227 |
-
|
| 228 |
-
<
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 229 |
</div>
|
| 230 |
</div>
|
| 231 |
-
|
|
|
|
| 1 |
+
<div class="legacy-banner">
|
| 2 |
+
🗄️ Looking for the 2025 NTv3 models? They are listed under the
|
| 3 |
+
<a href="#" data-tab-link="initial">Initial release (2025)</a> tab, with their notebooks.
|
| 4 |
+
</div>
|
| 5 |
+
|
| 6 |
<div class="summary">
|
| 7 |
<h2>📖 About NTv3</h2>
|
| 8 |
<p>
|
| 9 |
+
NTv3 is a genomic foundation model that reads DNA at single-base resolution across up to <strong>1 Mb</strong> of
|
| 10 |
+
context in one pass. One architecture supports three capabilities: <strong>functional-track prediction</strong>,
|
| 11 |
+
<strong>gene annotation</strong> and <strong>regulatory sequence design</strong>. The backbone is U-Net-style: a
|
| 12 |
+
convolutional tower that reads the sequence, a Transformer tower that carries long-range context, and a
|
| 13 |
+
deconvolutional tower that returns predictions at base resolution.
|
| 14 |
+
</p>
|
| 15 |
+
<p>
|
| 16 |
+
The three capabilities are acquired in stages, each building on the one before. NTv3 is first
|
| 17 |
+
<strong>pretrained</strong> by masked-token prediction on unlabeled genomes from the OpenGenome2 corpus. It is then
|
| 18 |
+
<strong>post-trained on functional tracks</strong>, a supervised multi-task stage over 16,262 tracks from 15 species,
|
| 19 |
+
which gives the first capability. The other two are built from those same functional representations:
|
| 20 |
+
<strong>annotation fine-tuning</strong> on reference gene annotations, and <strong>generative post-training</strong>
|
| 21 |
+
by masked diffusion on MPRA activity datasets. The checkpoints below are the pretrained backbone, tracks and
|
| 22 |
+
annotation, plus the plant promoter model of the paper as an example of the generative ones.
|
| 23 |
</p>
|
| 24 |
<p>
|
| 25 |
+
Because it is one backbone and one set of learned representations throughout, what NTv3 learns about sequence
|
| 26 |
+
function carries across tasks and across species rather than being relearned for each.
|
| 27 |
</p>
|
| 28 |
</div>
|
| 29 |
|
| 30 |
<div class="paper-summary">
|
| 31 |
+
<img src="assets/ntv3_overview.png" alt="NTv3 overview: pre-training, tracks post-training, annotation fine-tuning and generative post-training, with the four resulting capabilities" />
|
|
|
|
| 32 |
</div>
|
| 33 |
|
| 34 |
<div class="why-ntv3">
|
| 35 |
+
<h2>✨ What NTv3 does</h2>
|
| 36 |
<ul>
|
| 37 |
+
<li>📏 <strong>Reads any genome.</strong> 1 Mb of context at nucleotide resolution, with a U-Net design that keeps very long sequences tractable on GPU.</li>
|
| 38 |
+
<li>📊 <strong>Predicts functional tracks.</strong> 16,262 tracks across 15 species: 9,603 gene expression, 3,115 histone marks, 2,193 TF binding, 1,351 accessibility.</li>
|
| 39 |
+
<li>🏷️ <strong>Annotates genes from DNA alone.</strong> Per-base posteriors over intergenic, intron, CDS and splice states, decoded into gene models on both strands. One model per clade: mammals, plants and insects.</li>
|
| 40 |
+
<li>✏️ <strong>Designs regulatory DNA.</strong> Conditional generation of plant promoters, plant enhancers and fly enhancers at requested activity levels, with activity measured experimentally.</li>
|
| 41 |
+
<li>🌍 <strong>Generalizes across species</strong>, including to genomes held out of post-training entirely.</li>
|
| 42 |
</ul>
|
| 43 |
</div>
|
| 44 |
|
| 45 |
<div class="grid">
|
| 46 |
+
<div class="card" style="grid-column: span 12;">
|
| 47 |
+
<h2>🤖 The NTv3 models</h2>
|
| 48 |
+
<p>
|
| 49 |
+
NTv3 is a family rather than a single model. One <strong>700M-parameter</strong> backbone is trained once on
|
| 50 |
+
unlabeled genomes, then specialised into a checkpoint per capability, so each one inherits what the others
|
| 51 |
+
learned about sequence rather than starting over.
|
| 52 |
+
</p>
|
| 53 |
+
<p>
|
| 54 |
+
Start from <strong>NTv3-pretrained</strong> for embeddings, masked-token prediction, or your own fine-tuning on
|
| 55 |
+
a task we have not covered. Reach for <strong>NTv3-tracks</strong> to predict functional signal, chromatin
|
| 56 |
+
accessibility, histone marks, transcription factor binding and expression, anywhere in a supported genome. Use
|
| 57 |
+
<strong>NTv3-annotate</strong> to call genes from sequence alone, choosing the model for your clade.
|
| 58 |
+
</p>
|
| 59 |
+
<table class="capability-grid">
|
|
|
|
|
|
|
|
|
|
|
|
|
| 60 |
<thead>
|
| 61 |
<tr>
|
| 62 |
<th>Model</th>
|
| 63 |
+
<th>Stage</th>
|
| 64 |
+
<th>What it is for</th>
|
| 65 |
+
<th>Repository</th>
|
|
|
|
| 66 |
</tr>
|
| 67 |
</thead>
|
| 68 |
<tbody>
|
| 69 |
<tr>
|
| 70 |
+
<td><strong>NTv3-pretrained</strong></td>
|
| 71 |
+
<td>Pretrained</td>
|
| 72 |
+
<td>Embeddings, masked-token prediction, starting point for your own fine-tuning</td>
|
| 73 |
+
<td><a href="https://huggingface.co/InstaDeepAI/NTv3_pretrained" target="_blank" rel="noopener noreferrer"><code>InstaDeepAI/NTv3_pretrained</code></a></td>
|
|
|
|
| 74 |
</tr>
|
| 75 |
<tr>
|
| 76 |
+
<td><strong>NTv3-tracks</strong></td>
|
| 77 |
+
<td>Tracks post-training</td>
|
| 78 |
+
<td>Functional-track prediction, and the backbone for downstream fine-tuning. <code>NTv3_tracks_1mb</code> is the 1 Mb specialist; 1 Mb inputs only</td>
|
| 79 |
+
<td>
|
| 80 |
+
<a href="https://huggingface.co/InstaDeepAI/NTv3_tracks" target="_blank" rel="noopener noreferrer"><code>InstaDeepAI/NTv3_tracks</code></a><br />
|
| 81 |
+
<a href="https://huggingface.co/InstaDeepAI/NTv3_tracks_1mb" target="_blank" rel="noopener noreferrer"><code>InstaDeepAI/NTv3_tracks_1mb</code></a>
|
| 82 |
+
</td>
|
| 83 |
</tr>
|
| 84 |
<tr>
|
| 85 |
+
<td><strong>NTv3-annotate</strong></td>
|
| 86 |
+
<td>Annotation fine-tuning</td>
|
| 87 |
+
<td><em>Ab initio</em> gene prediction, one model per clade</td>
|
| 88 |
+
<td>
|
| 89 |
+
<a href="https://huggingface.co/InstaDeepAI/NTv3_annot_mammals" target="_blank" rel="noopener noreferrer"><code>InstaDeepAI/NTv3_annot_mammals</code></a><br />
|
| 90 |
+
<a href="https://huggingface.co/InstaDeepAI/NTv3_annot_plants" target="_blank" rel="noopener noreferrer"><code>InstaDeepAI/NTv3_annot_plants</code></a><br />
|
| 91 |
+
<a href="https://huggingface.co/InstaDeepAI/NTv3_annot_insects" target="_blank" rel="noopener noreferrer"><code>InstaDeepAI/NTv3_annot_insects</code></a>
|
| 92 |
+
</td>
|
| 93 |
+
</tr>
|
| 94 |
+
<tr>
|
| 95 |
+
<td><strong>NTv3-generative-plant-promoter</strong></td>
|
| 96 |
+
<td>Generative post-training</td>
|
| 97 |
+
<td>Example generative model used in the paper: designs plant core promoters at a requested strength, and is what notebook 06 runs</td>
|
| 98 |
+
<td><a href="https://huggingface.co/InstaDeepAI/NTv3_generative_plant_promoter" target="_blank" rel="noopener noreferrer"><code>InstaDeepAI/NTv3_generative_plant_promoter</code></a></td>
|
| 99 |
</tr>
|
| 100 |
</tbody>
|
| 101 |
</table>
|
|
|
|
| 103 |
|
| 104 |
<div class="card-stack">
|
| 105 |
<div class="card">
|
| 106 |
+
<h2>📓 Notebooks</h2>
|
| 107 |
+
<p>
|
| 108 |
+
The notebooks cover the workflows end to end. All run against the published checkpoints and are linked.
|
| 109 |
+
</p>
|
| 110 |
<ul>
|
| 111 |
+
<li>🚀 <strong>00 · Quickstart inference</strong><br /><a href="https://huggingface.co/spaces/InstaDeepAI/ntv3/blob/main/notebooks_tutorials/00_quickstart_inference.ipynb" target="_blank" rel="noopener noreferrer"><code>00_quickstart_inference.ipynb</code></a></li>
|
| 112 |
+
<li>📊 <strong>01 · Tracks prediction</strong><br /><a href="https://huggingface.co/spaces/InstaDeepAI/ntv3/blob/main/notebooks_tutorials/01_tracks_prediction.ipynb" target="_blank" rel="noopener noreferrer"><code>01_tracks_prediction.ipynb</code></a></li>
|
| 113 |
+
<li>🎯 <strong>02 · Fine-tune NTv3 on bigwig tracks</strong><br /><a href="https://huggingface.co/spaces/InstaDeepAI/ntv3/blob/main/notebooks_tutorials/02_fine_tuning_bigwig_tracks.ipynb" target="_blank" rel="noopener noreferrer"><code>02_fine_tuning_bigwig_tracks.ipynb</code></a></li>
|
| 114 |
+
<li>🏷️ <strong>03 · Genome annotation with NTv3</strong>
|
| 115 |
+
<ul>
|
| 116 |
+
<li><strong>03a · Train a gene-annotation model</strong><br /><a href="https://huggingface.co/spaces/InstaDeepAI/ntv3/blob/main/notebooks_tutorials/03a_genome_annotation_training.ipynb" target="_blank" rel="noopener noreferrer"><code>03a_genome_annotation_training.ipynb</code></a></li>
|
| 117 |
+
<li><strong>03b · From per-base predictions to gene models</strong><br /><a href="https://huggingface.co/spaces/InstaDeepAI/ntv3/blob/main/notebooks_tutorials/03b_genome_annotation_decoding.ipynb" target="_blank" rel="noopener noreferrer"><code>03b_genome_annotation_decoding.ipynb</code></a></li>
|
| 118 |
+
<li><strong>03c · Evaluate a gene annotation against a reference</strong><br /><a href="https://huggingface.co/spaces/InstaDeepAI/ntv3/blob/main/notebooks_tutorials/03c_genome_annotation_evaluation.ipynb" target="_blank" rel="noopener noreferrer"><code>03c_genome_annotation_evaluation.ipynb</code></a></li>
|
| 119 |
+
</ul>
|
| 120 |
+
</li>
|
| 121 |
+
<li>🔍 <strong>04 · Model interpretation</strong><br /><a href="https://huggingface.co/spaces/InstaDeepAI/ntv3/blob/main/notebooks_tutorials/04_model_interpretation.ipynb" target="_blank" rel="noopener noreferrer"><code>04_model_interpretation.ipynb</code></a></li>
|
| 122 |
+
<li>🧪 <strong>05 · Fine-tuning NTv3 into a diffusion model</strong><br /><a href="https://huggingface.co/spaces/InstaDeepAI/ntv3/blob/main/notebooks_tutorials/05_diffusion_fine_tuning.ipynb" target="_blank" rel="noopener noreferrer"><code>05_diffusion_fine_tuning.ipynb</code></a></li>
|
| 123 |
+
<li>🌱 <strong>06 · Generating plant promoter sequences</strong><br /><a href="https://huggingface.co/spaces/InstaDeepAI/ntv3/blob/main/notebooks_tutorials/06_plant_promoter_generation.ipynb" target="_blank" rel="noopener noreferrer"><code>06_plant_promoter_generation.ipynb</code></a></li>
|
|
|
|
|
|
|
| 124 |
</ul>
|
| 125 |
</div>
|
| 126 |
<div class="card">
|
| 127 |
<h2>🔗 Links</h2>
|
| 128 |
<ul>
|
| 129 |
+
<li>📄 Paper <span class="todo">link TBC</span></li>
|
| 130 |
<li><a href="https://github.com/instadeepai/nucleotide-transformer" target="_blank" rel="noopener noreferrer">💻 JAX model code (GitHub)</a></li>
|
| 131 |
+
<li><a href="https://huggingface.co/collections/InstaDeepAI/ntv3" target="_blank" rel="noopener noreferrer">🎯 HF model collection</a></li>
|
| 132 |
+
<li><a href="https://huggingface.co/datasets/InstaDeepAI/NTv3_benchmark_dataset_revised" target="_blank" rel="noopener noreferrer">📚 NTv3 benchmark dataset</a></li>
|
| 133 |
+
<li><a href="https://huggingface.co/datasets/InstaDeepAI/NTv3_annot_dataset" target="_blank" rel="noopener noreferrer">🏷️ NTv3 annotation dataset</a></li>
|
| 134 |
+
<li><a href="https://huggingface.co/spaces/InstaDeepAI/ntv3_benchmark_revised" target="_blank" rel="noopener noreferrer">🏆 NTv3 benchmark leaderboard</a></li>
|
| 135 |
</ul>
|
| 136 |
</div>
|
| 137 |
</div>
|
| 138 |
|
| 139 |
<div class="card-stack">
|
| 140 |
<div class="card">
|
| 141 |
+
<h2>🤖 NTv3-pretrained</h2>
|
| 142 |
+
<p>Embeddings and masked-token prediction, the starting point for your own fine-tuning.</p>
|
| 143 |
+
<div class="code"><pre><code class="language-python">import torch
|
| 144 |
+
from transformers import AutoModel, AutoTokenizer
|
| 145 |
|
| 146 |
+
model_name = "InstaDeepAI/NTv3_pretrained"
|
| 147 |
|
| 148 |
+
model = AutoModel.from_pretrained(model_name, trust_remote_code=True).eval()
|
|
|
|
| 149 |
tok = AutoTokenizer.from_pretrained(model_name, trust_remote_code=True)
|
| 150 |
|
| 151 |
+
# Input length must be a multiple of 128. Batch only equal-length sequences:
|
| 152 |
+
# padding is NOT masked out, so a padded sequence gets different values than
|
| 153 |
+
# the same sequence run on its own.
|
| 154 |
+
batch = tok(["ACGT" * 32, "TTGA" * 32], add_special_tokens=False, return_tensors="pt")
|
| 155 |
|
| 156 |
+
# The forward takes the token tensor positionally, not the tokenizer dict
|
| 157 |
+
with torch.no_grad():
|
| 158 |
+
out = model(batch["input_ids"])
|
| 159 |
|
| 160 |
+
print(out["logits"].shape) # (B, L, V = 11)
|
| 161 |
+
print(out["embedding"].shape) # (B, L, 1024)
|
| 162 |
</code></pre></div>
|
|
|
|
| 163 |
</div>
|
| 164 |
<div class="card">
|
| 165 |
+
<h2>🔍 NTv3-tracks: model interpretation</h2>
|
| 166 |
+
<p>Multi-scale analysis of a genomic region: predicted tracks, attention and saliency.</p>
|
| 167 |
+
<img src="assets/saliency_example.png" alt="Saliency example" style="max-width: 100%; margin-top: 20px;" />
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 168 |
</div>
|
| 169 |
</div>
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 170 |
|
| 171 |
+
<div class="card" style="grid-column: span 12;">
|
| 172 |
+
<h2>📊 NTv3-tracks: predict functional tracks</h2>
|
| 173 |
+
<p>
|
| 174 |
+
Give the pipeline a genomic region and the tracks you want, and it fetches the sequence, conditions on the
|
| 175 |
+
species, predicts and plots.
|
| 176 |
+
</p>
|
| 177 |
+
<div class="code"><pre><code class="language-python">from transformers import pipeline
|
| 178 |
|
| 179 |
+
ntv3_tracks = pipeline("ntv3-tracks", model="InstaDeepAI/NTv3_tracks", trust_remote_code=True)
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 180 |
|
|
|
|
| 181 |
out = ntv3_tracks(
|
| 182 |
+
{"chrom": "chr19", "start": 6_700_000, "end": 6_732_768, "species": "human"},
|
| 183 |
+
plot=True,
|
| 184 |
+
tracks_to_plot={
|
| 185 |
+
"K562 RNA-seq": "ENCSR056HPM",
|
| 186 |
+
"K562 DNAse": "ENCSR921NMD",
|
| 187 |
+
"K562 H3k4me3": "ENCSR000DWD",
|
| 188 |
+
"K562 CTCF": "ENCSR000AKO",
|
| 189 |
+
},
|
| 190 |
)
|
| 191 |
|
| 192 |
+
print(out.tracks.shape) # (12288, 4): the central 37.5 % of the window
|
| 193 |
+
print(out.coordinates) # (6710240, 6722528), the span actually predicted
|
| 194 |
+
print(out.track("ENCSR000AKO").shape) # (12288,): one track</code></pre></div>
|
| 195 |
+
<img src="assets/output_tracks.png" alt="Predicted tracks over a human genomic window" style="max-width: 100%; margin-top: 20px;" />
|
| 196 |
+
</div>
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 197 |
|
| 198 |
+
<div class="card" style="grid-column: span 12;">
|
| 199 |
+
<h2>🏷️ NTv3-annotate: end-to-end FASTA annotation pipeline</h2>
|
| 200 |
+
<p>
|
| 201 |
+
Turns a genome FASTA into its predicted gene annotation (GFF3): the production pipeline tiles every chromosome
|
| 202 |
+
into 1 Mb windows, runs the clade model and decodes the gene models. Here is a single-chromosome example on a
|
| 203 |
+
test species.
|
| 204 |
+
</p>
|
| 205 |
+
<div class="code"><pre><code class="language-bash"># 1. Get the code
|
| 206 |
+
git clone git@github.com:instadeepai/nucleotide-transformer-private.git
|
| 207 |
+
cd nucleotide-transformer-private
|
| 208 |
+
|
| 209 |
+
# 2. Install with GPU support, then check that JAX sees the GPU
|
| 210 |
+
python3 -m venv .venv && source .venv/bin/activate
|
| 211 |
+
pip install '.[gpu]'
|
| 212 |
+
python -c "import jax; print(jax.devices())" # should list a CudaDevice
|
| 213 |
+
|
| 214 |
+
# 3. Get chromosome 31 of the monarch butterfly (2.8 Mb, soft-masked)
|
| 215 |
+
NCBI=https://ftp.ncbi.nlm.nih.gov/genomes/all/GCF/018/135/715/GCF_018135715.1_MEX_DaPlex
|
| 216 |
+
curl -sSfLO $NCBI/GCF_018135715.1_MEX_DaPlex_genomic.fna.gz
|
| 217 |
+
gunzip GCF_018135715.1_MEX_DaPlex_genomic.fna.gz
|
| 218 |
+
faidx GCF_018135715.1_MEX_DaPlex_genomic.fna NC_083558.1 > danaus_chr31.fa
|
| 219 |
+
|
| 220 |
+
# 4. Annotate it with the insect model (weights download on the first run)
|
| 221 |
+
export HF_TOKEN=hf_... # needed while the model repos are private
|
| 222 |
+
ntv3-annotate --genome danaus_chr31.fa --model_cfg insects --out danaus_chr31.gff3
|
| 223 |
+
|
| 224 |
+
# 5. Look at one predicted gene: a gene line, then its exons (CDS) and introns
|
| 225 |
+
awk '$4 >= 2334916 && $5 <= 2337940' danaus_chr31.gff3 | cut -f 1,3-5,7
|
| 226 |
+
# NC_083558.1 gene 2334916 2337940 -
|
| 227 |
+
# NC_083558.1 CDS 2334916 2334969 -
|
| 228 |
+
# NC_083558.1 intron 2334970 2335582 -
|
| 229 |
+
# NC_083558.1 CDS 2335583 2335753 -
|
| 230 |
+
# NC_083558.1 intron 2335754 2336418 -
|
| 231 |
+
# NC_083558.1 CDS 2336419 2336565 -
|
| 232 |
+
# NC_083558.1 intron 2336566 2336820 -
|
| 233 |
+
# NC_083558.1 CDS 2336821 2336932 -
|
| 234 |
+
# NC_083558.1 intron 2336933 2337785 -
|
| 235 |
+
# NC_083558.1 CDS 2337786 2337940 -</code></pre></div>
|
| 236 |
+
<img src="assets/output_annotation.png" alt="Predicted gene models on monarch butterfly chromosome 31 against the RefSeq reference" style="max-width: 100%; margin-top: 20px;" />
|
| 237 |
+
<p>
|
| 238 |
+
The predicted genes (blue) over 34 kb of chromosome 31, against the RefSeq reference (grey). Four genes match the
|
| 239 |
+
reference exon for exon, including the one printed above; the rightmost reference gene is missed.
|
| 240 |
+
</p>
|
| 241 |
+
<div class="notice">
|
| 242 |
+
Needs an <strong>A100-40GB or larger</strong> GPU. For the other clades, use
|
| 243 |
+
<code style="white-space: nowrap;">--model_cfg mammals</code> or
|
| 244 |
+
<code style="white-space: nowrap;">--model_cfg plants</code> with a genome of that clade. Notebook
|
| 245 |
+
<strong>03b</strong> explains how the gene models are decoded, and notebook <strong>03c</strong> scores a GFF3
|
| 246 |
+
against the benchmark reference.
|
| 247 |
+
</div>
|
| 248 |
</div>
|
| 249 |
</div>
|
|
|
tabs/initial_release.html
ADDED
|
@@ -0,0 +1,263 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
<div class="notice">
|
| 2 |
+
🗄️ <strong>This page describes the initial NTv3 release, from 2025.</strong> Those repositories are retained unchanged for
|
| 3 |
+
reproducibility and backward compatibility. Existing model IDs, files and interfaces continue to work exactly as they
|
| 4 |
+
did, and nothing on this page is being withdrawn. If you are starting something new, prefer the current models on the
|
| 5 |
+
<strong>Home</strong> tab; if you are reproducing published results or already have a working pipeline, stay here.
|
| 6 |
+
</div>
|
| 7 |
+
|
| 8 |
+
<div class="summary">
|
| 9 |
+
<h2>📖 About the initial release (2025)</h2>
|
| 10 |
+
<p>
|
| 11 |
+
The initial release described NTv3 as a single U-Net-style backbone pretrained on the OpenGenome2 corpus and then
|
| 12 |
+
post-trained with a <em>joint</em> objective over functional tracks and annotation labels. It shipped three model sizes
|
| 13 |
+
(8M, 100M, 650M), each in pretrained and post-trained form, plus context-length and architecture ablation variants.
|
| 14 |
+
</p>
|
| 15 |
+
<p>
|
| 16 |
+
The current release changes that structure: tracks post-training is its own stage, and annotation becomes a separate
|
| 17 |
+
per-clade fine-tune rather than a head trained jointly. Numbers quoted in these materials therefore do not always
|
| 18 |
+
match the current paper. Where the two differ, the current paper is authoritative.
|
| 19 |
+
</p>
|
| 20 |
+
</div>
|
| 21 |
+
|
| 22 |
+
<div class="paper-summary">
|
| 23 |
+
<img src="assets/paper_summary.png" alt="NTv3 initial-release overview figure" />
|
| 24 |
+
</div>
|
| 25 |
+
|
| 26 |
+
<div class="grid">
|
| 27 |
+
<div class="card" style="grid-column: span 12;">
|
| 28 |
+
<h2>🤖 Initial-release checkpoints (2025)</h2>
|
| 29 |
+
<p>
|
| 30 |
+
All of the following are public on the Hub and unchanged. Sizes are the parameter counts as published.
|
| 31 |
+
</p>
|
| 32 |
+
<table>
|
| 33 |
+
<thead>
|
| 34 |
+
<tr>
|
| 35 |
+
<th>Model</th>
|
| 36 |
+
<th>Size</th>
|
| 37 |
+
<th>Pretrained</th>
|
| 38 |
+
<th>Post-trained</th>
|
| 39 |
+
<th>Usage</th>
|
| 40 |
+
</tr>
|
| 41 |
+
</thead>
|
| 42 |
+
<tbody>
|
| 43 |
+
<tr>
|
| 44 |
+
<td><strong>NTv3-8M</strong></td>
|
| 45 |
+
<td>8M params</td>
|
| 46 |
+
<td><span class="checkmark">✅</span></td>
|
| 47 |
+
<td>❌</td>
|
| 48 |
+
<td>Embeddings, light inference</td>
|
| 49 |
+
</tr>
|
| 50 |
+
<tr>
|
| 51 |
+
<td><strong>NTv3-100M</strong></td>
|
| 52 |
+
<td>100M params</td>
|
| 53 |
+
<td><span class="checkmark">✅</span></td>
|
| 54 |
+
<td><span class="checkmark">✅</span></td>
|
| 55 |
+
<td>Embeddings, tracks, annotation</td>
|
| 56 |
+
</tr>
|
| 57 |
+
<tr>
|
| 58 |
+
<td><strong>NTv3-650M</strong></td>
|
| 59 |
+
<td>650M params</td>
|
| 60 |
+
<td><span class="checkmark">✅</span></td>
|
| 61 |
+
<td><span class="checkmark">✅</span></td>
|
| 62 |
+
<td>Embeddings, tracks, annotation, best accuracy</td>
|
| 63 |
+
</tr>
|
| 64 |
+
</tbody>
|
| 65 |
+
</table>
|
| 66 |
+
|
| 67 |
+
<h2 style="margin-top: 22px;">📦 Pretrained</h2>
|
| 68 |
+
<ul>
|
| 69 |
+
<li><a href="https://huggingface.co/InstaDeepAI/NTv3_8M_pre" target="_blank" rel="noopener noreferrer"><code>InstaDeepAI/NTv3_8M_pre</code></a></li>
|
| 70 |
+
<li><a href="https://huggingface.co/InstaDeepAI/NTv3_100M_pre" target="_blank" rel="noopener noreferrer"><code>InstaDeepAI/NTv3_100M_pre</code></a></li>
|
| 71 |
+
<li><a href="https://huggingface.co/InstaDeepAI/NTv3_650M_pre" target="_blank" rel="noopener noreferrer"><code>InstaDeepAI/NTv3_650M_pre</code></a></li>
|
| 72 |
+
</ul>
|
| 73 |
+
|
| 74 |
+
<h2 style="margin-top: 18px;">🎯 Post-trained</h2>
|
| 75 |
+
<ul>
|
| 76 |
+
<li><a href="https://huggingface.co/InstaDeepAI/NTv3_100M_post" target="_blank" rel="noopener noreferrer"><code>InstaDeepAI/NTv3_100M_post</code></a></li>
|
| 77 |
+
<li><a href="https://huggingface.co/InstaDeepAI/NTv3_650M_post" target="_blank" rel="noopener noreferrer"><code>InstaDeepAI/NTv3_650M_post</code></a></li>
|
| 78 |
+
</ul>
|
| 79 |
+
|
| 80 |
+
<h2 style="margin-top: 18px;">📏 Context-length variants</h2>
|
| 81 |
+
<ul>
|
| 82 |
+
<li><a href="https://huggingface.co/InstaDeepAI/NTv3_8M_pre_8kb" target="_blank" rel="noopener noreferrer"><code>InstaDeepAI/NTv3_8M_pre_8kb</code></a></li>
|
| 83 |
+
<li><a href="https://huggingface.co/InstaDeepAI/NTv3_100M_pre_8kb" target="_blank" rel="noopener noreferrer"><code>InstaDeepAI/NTv3_100M_pre_8kb</code></a></li>
|
| 84 |
+
<li><a href="https://huggingface.co/InstaDeepAI/NTv3_650M_pre_8kb" target="_blank" rel="noopener noreferrer"><code>InstaDeepAI/NTv3_650M_pre_8kb</code></a></li>
|
| 85 |
+
<li><a href="https://huggingface.co/InstaDeepAI/NTv3_100M_post_131kb" target="_blank" rel="noopener noreferrer"><code>InstaDeepAI/NTv3_100M_post_131kb</code></a></li>
|
| 86 |
+
<li><a href="https://huggingface.co/InstaDeepAI/NTv3_650M_post_131kb" target="_blank" rel="noopener noreferrer"><code>InstaDeepAI/NTv3_650M_post_131kb</code></a></li>
|
| 87 |
+
</ul>
|
| 88 |
+
|
| 89 |
+
<h2 style="margin-top: 18px;">🔬 Architecture ablations (five-downsample variants)</h2>
|
| 90 |
+
<ul>
|
| 91 |
+
<li><a href="https://huggingface.co/InstaDeepAI/NTv3_5downsample_pre" target="_blank" rel="noopener noreferrer"><code>InstaDeepAI/NTv3_5downsample_pre</code></a></li>
|
| 92 |
+
<li><a href="https://huggingface.co/InstaDeepAI/NTv3_5downsample_pre_8kb" target="_blank" rel="noopener noreferrer"><code>InstaDeepAI/NTv3_5downsample_pre_8kb</code></a></li>
|
| 93 |
+
<li><a href="https://huggingface.co/InstaDeepAI/NTv3_5downsample_post" target="_blank" rel="noopener noreferrer"><code>InstaDeepAI/NTv3_5downsample_post</code></a></li>
|
| 94 |
+
<li><a href="https://huggingface.co/InstaDeepAI/NTv3_5downsample_post_131kb" target="_blank" rel="noopener noreferrer"><code>InstaDeepAI/NTv3_5downsample_post_131kb</code></a></li>
|
| 95 |
+
</ul>
|
| 96 |
+
|
| 97 |
+
<h2 style="margin-top: 18px;">🧪 Generative</h2>
|
| 98 |
+
<ul>
|
| 99 |
+
<li><a href="https://huggingface.co/InstaDeepAI/NTv3_generative" target="_blank" rel="noopener noreferrer"><code>InstaDeepAI/NTv3_generative</code></a></li>
|
| 100 |
+
</ul>
|
| 101 |
+
|
| 102 |
+
<div class="notice">
|
| 103 |
+
<strong>Naming note.</strong> Some earlier materials referred to the post-trained checkpoints as
|
| 104 |
+
<code>NTv3_100M_pos</code> and <code>NTv3_650M_pos</code>. The correct identifiers end in <code>_post</code>. If a
|
| 105 |
+
script of yours 404s, that is the likely cause.
|
| 106 |
+
</div>
|
| 107 |
+
</div>
|
| 108 |
+
|
| 109 |
+
<div class="card-stack">
|
| 110 |
+
<div class="card">
|
| 111 |
+
<h2>📓 Notebooks for the initial release (2025)</h2>
|
| 112 |
+
<p>These notebooks were written and run against the initial-release checkpoints.</p>
|
| 113 |
+
<ul>
|
| 114 |
+
<li><a href="https://huggingface.co/spaces/InstaDeepAI/ntv3/blob/initial-release/notebooks_tutorials/00_quickstart_inference.ipynb" target="_blank" rel="noopener noreferrer">🚀 00 · Quickstart inference</a></li>
|
| 115 |
+
<li><a href="https://huggingface.co/spaces/InstaDeepAI/ntv3/blob/initial-release/notebooks_tutorials/01_tracks_prediction.ipynb" target="_blank" rel="noopener noreferrer">📊 01 · Tracks prediction</a></li>
|
| 116 |
+
<li><a href="https://huggingface.co/spaces/InstaDeepAI/ntv3/blob/initial-release/notebooks_tutorials/02_fine_tuning_pretrained_model_biwig.ipynb" target="_blank" rel="noopener noreferrer">🎯 02 · Fine-tune a pretrained model on bigwig tracks</a></li>
|
| 117 |
+
<li><a href="https://huggingface.co/spaces/InstaDeepAI/ntv3/blob/initial-release/notebooks_tutorials/03_fine_tuning_posttrained_model_biwig.ipynb" target="_blank" rel="noopener noreferrer">🎯 03 · Fine-tune a post-trained model on bigwig tracks</a></li>
|
| 118 |
+
<li><a href="https://huggingface.co/spaces/InstaDeepAI/ntv3/blob/initial-release/notebooks_tutorials/04_fine_tuning_pretrained_model_annotation.ipynb" target="_blank" rel="noopener noreferrer">🏷️ 04 · Fine-tune a pretrained model on annotations</a></li>
|
| 119 |
+
<li><a href="https://huggingface.co/spaces/InstaDeepAI/ntv3/blob/initial-release/notebooks_tutorials/05_model_interpretation.ipynb" target="_blank" rel="noopener noreferrer">🔍 05 · Model interpretation</a></li>
|
| 120 |
+
<li><a href="https://huggingface.co/spaces/InstaDeepAI/ntv3/blob/initial-release/notebooks_tutorials/06_NTv3_generative_training.ipynb" target="_blank" rel="noopener noreferrer">🧪 06 · Fine-tuning NTv3 into a diffusion model</a></li>
|
| 121 |
+
<li><a href="https://huggingface.co/spaces/InstaDeepAI/ntv3/blob/initial-release/notebooks_tutorials/07_enhancer_generation.ipynb" target="_blank" rel="noopener noreferrer">🪰 07 · Generating enhancer sequences</a></li>
|
| 122 |
+
</ul>
|
| 123 |
+
<p style="margin-top: 12px;">Pipeline notebooks:</p>
|
| 124 |
+
<ul>
|
| 125 |
+
<li><a href="https://huggingface.co/spaces/InstaDeepAI/ntv3/blob/initial-release/notebooks_pipelines/01_functional_track_prediction.ipynb" target="_blank" rel="noopener noreferrer">🎯 01 · Generate bigwig predictions for given tracks</a></li>
|
| 126 |
+
<li><a href="https://huggingface.co/spaces/InstaDeepAI/ntv3/blob/initial-release/notebooks_pipelines/02_model_interpretation.ipynb" target="_blank" rel="noopener noreferrer">🔍 02 · Interpret a given genomic region</a></li>
|
| 127 |
+
</ul>
|
| 128 |
+
</div>
|
| 129 |
+
<div class="card">
|
| 130 |
+
<h2>🔗 Initial-release links (2025)</h2>
|
| 131 |
+
<ul>
|
| 132 |
+
<li><a href="https://www.biorxiv.org/content/10.64898/2025.12.22.695963v1" target="_blank" rel="noopener noreferrer">📄 Initial preprint</a></li>
|
| 133 |
+
<li><a href="https://huggingface.co/spaces/InstaDeepAI/ntv3/tree/initial-release" target="_blank" rel="noopener noreferrer">🏠 Original NTv3 Space (files at tag <code>initial-release</code>)</a></li>
|
| 134 |
+
<li><a href="https://github.com/instadeepai/nucleotide-transformer/tree/ntv3-initial-release" target="_blank" rel="noopener noreferrer">💻 JAX model code (GitHub, tag <code>ntv3-initial-release</code>)</a></li>
|
| 135 |
+
<li><a href="https://huggingface.co/collections/InstaDeepAI/nucleotide-transformer-v3" target="_blank" rel="noopener noreferrer">🎯 HF model collection (2025)</a></li>
|
| 136 |
+
<li><a href="https://huggingface.co/datasets/InstaDeepAI/NTv3_benchmark_dataset" target="_blank" rel="noopener noreferrer">📚 NTv3 benchmark dataset</a></li>
|
| 137 |
+
<li><a href="https://huggingface.co/datasets/InstaDeepAI/NTv3_enhancer_generation" target="_blank" rel="noopener noreferrer">📚 NTv3 enhancer generation dataset</a></li>
|
| 138 |
+
<li><a href="https://huggingface.co/spaces/InstaDeepAI/ntv3_benchmark" target="_blank" rel="noopener noreferrer">🏆 NTv3 benchmark leaderboard (2025)</a></li>
|
| 139 |
+
<li><a href="https://huggingface.co/spaces/InstaDeepAI/ntv3_tracks" target="_blank" rel="noopener noreferrer">🚀 Interactive tracks demo</a></li>
|
| 140 |
+
</ul>
|
| 141 |
+
</div>
|
| 142 |
+
</div>
|
| 143 |
+
|
| 144 |
+
<div class="card">
|
| 145 |
+
<h2>🤖 Load a pre-trained model</h2>
|
| 146 |
+
<p>Here is an example of how to load and use a pre-trained NTv3 model.</p>
|
| 147 |
+
<div class="code"><pre><code class="language-python">from transformers import AutoTokenizer, AutoModelForMaskedLM
|
| 148 |
+
|
| 149 |
+
model_name = "InstaDeepAI/NTv3_650M_pre"
|
| 150 |
+
|
| 151 |
+
model = AutoModelForMaskedLM.from_pretrained(model_name, trust_remote_code=True)
|
| 152 |
+
tok = AutoTokenizer.from_pretrained(model_name, trust_remote_code=True)
|
| 153 |
+
|
| 154 |
+
batch = tok(["ATCGNATCG", "ACGT"], add_special_tokens=False, padding=True,
|
| 155 |
+
pad_to_multiple_of=128, return_tensors="pt")
|
| 156 |
+
|
| 157 |
+
out = model(**batch)
|
| 158 |
+
print(out.logits.shape) # (B, L, V = 11)
|
| 159 |
+
</code></pre></div>
|
| 160 |
+
<p>
|
| 161 |
+
To pin a specific version rather than tracking the branch, pass a tag:
|
| 162 |
+
<code>from_pretrained(model_name, revision="v1.0", trust_remote_code=True)</code>
|
| 163 |
+
</p>
|
| 164 |
+
</div>
|
| 165 |
+
|
| 166 |
+
<div class="card">
|
| 167 |
+
<h2>🔍 Model interpretation</h2>
|
| 168 |
+
<p>
|
| 169 |
+
Here is an example of how to use the interpretation pipeline on the NTv3 post-trained model for multi-scale
|
| 170 |
+
analysis of DNA sequences:
|
| 171 |
+
</p>
|
| 172 |
+
<div class="code"><pre><code class="language-python">from transformers import pipeline
|
| 173 |
+
import torch
|
| 174 |
+
import matplotlib.pyplot as plt
|
| 175 |
+
|
| 176 |
+
model_name = "InstaDeepAI/NTv3_650M_post"
|
| 177 |
+
|
| 178 |
+
# Build interpretation pipeline
|
| 179 |
+
ntv3_interpret = pipeline(
|
| 180 |
+
"ntv3-interpret",
|
| 181 |
+
model=model_name,
|
| 182 |
+
trust_remote_code=True,
|
| 183 |
+
device=0 if torch.cuda.is_available() else -1,
|
| 184 |
+
)
|
| 185 |
+
|
| 186 |
+
# Run interpretation on a given genomic region with tracks, annotations, attention, and saliency
|
| 187 |
+
result = ntv3_interpret(
|
| 188 |
+
{"chrom": "chr11", "start": 5_253_561, "end": 5_286_329, "species": "human"},
|
| 189 |
+
output_attention=True,
|
| 190 |
+
output_saliency=True,
|
| 191 |
+
saliency_track_id="ENCSR000EFT", # K562 GATA1 ChIP-seq
|
| 192 |
+
plot=True, # plot predictions on tracks and annotations
|
| 193 |
+
tracks_to_plot={"K562 RNA-seq": "ENCSR056HPM", "K562 GATA1": "ENCSR000EFT"},
|
| 194 |
+
elements_to_plot=["exon", "promoter_Tissue_specific"],
|
| 195 |
+
)
|
| 196 |
+
|
| 197 |
+
# Access attention map results
|
| 198 |
+
result.plot_attention() # attention map (last layer)
|
| 199 |
+
plt.show()
|
| 200 |
+
|
| 201 |
+
# Access saliency scores results
|
| 202 |
+
result.plot_saliency(window_size=128)
|
| 203 |
+
plt.show()</code></pre></div>
|
| 204 |
+
<img src="assets/saliency_example.png" alt="Saliency example" style="max-width: 100%; margin-top: 20px;" />
|
| 205 |
+
</div>
|
| 206 |
+
|
| 207 |
+
<div class="card" style="grid-column: span 12;">
|
| 208 |
+
<h2>💻 Use a post-trained model</h2>
|
| 209 |
+
<p>
|
| 210 |
+
Here is a quick example of how to use the post-trained NTv3 650M model to predict tracks for a human genomic
|
| 211 |
+
window.
|
| 212 |
+
</p>
|
| 213 |
+
<div class="code"><pre><code class="language-python">from transformers import pipeline
|
| 214 |
+
import torch
|
| 215 |
+
|
| 216 |
+
model_name = "InstaDeepAI/NTv3_650M_post"
|
| 217 |
+
|
| 218 |
+
ntv3_tracks = pipeline(
|
| 219 |
+
"ntv3-tracks",
|
| 220 |
+
model=model_name,
|
| 221 |
+
trust_remote_code=True,
|
| 222 |
+
device=0 if torch.cuda.is_available() else -1,
|
| 223 |
+
)
|
| 224 |
+
|
| 225 |
+
# Run track prediction
|
| 226 |
+
out = ntv3_tracks(
|
| 227 |
+
{
|
| 228 |
+
"chrom": "chr19",
|
| 229 |
+
"start": 6_700_000,
|
| 230 |
+
"end": 6_831_072,
|
| 231 |
+
"species": "human"
|
| 232 |
+
}
|
| 233 |
+
)
|
| 234 |
+
|
| 235 |
+
# Print output shapes
|
| 236 |
+
# 7k human tracks over 37.5 % center region of the input sequence
|
| 237 |
+
print("bigwig_tracks_logits:", tuple(out.bigwig_tracks_logits.shape))
|
| 238 |
+
# Location of 21 genomic elements over 37.5 % center region of the input sequence
|
| 239 |
+
print("bed_tracks_logits:", tuple(out.bed_tracks_logits.shape))
|
| 240 |
+
# Language model logits for whole sequence over vocabulary
|
| 241 |
+
print("language model logits:", tuple(out.mlm_logits.shape))</code></pre></div>
|
| 242 |
+
<p>Predictions can be plotted for a subset of tracks and genomic elements:</p>
|
| 243 |
+
<div class="code"><pre><code class="language-python">tracks_to_plot = {
|
| 244 |
+
"K562 RNA-seq": "ENCSR056HPM",
|
| 245 |
+
"K562 DNAse": "ENCSR921NMD",
|
| 246 |
+
"K562 H3k4me3": "ENCSR000DWD",
|
| 247 |
+
"K562 CTCF": "ENCSR000AKO",
|
| 248 |
+
"HepG2 RNA-seq": "ENCSR561FEE_P",
|
| 249 |
+
"HepG2 DNAse": "ENCSR000EJV",
|
| 250 |
+
"HepG2 H3k4me3": "ENCSR000AMP",
|
| 251 |
+
"HepG2 CTCF": "ENCSR000BIE",
|
| 252 |
+
}
|
| 253 |
+
elements_to_plot = ["protein_coding_gene", "exon", "intron", "splice_donor", "splice_acceptor"]
|
| 254 |
+
|
| 255 |
+
out = ntv3_tracks(
|
| 256 |
+
{"chrom": "chr19", "start": 6_700_000, "end": 6_831_072, "species": "human"},
|
| 257 |
+
plot=True,
|
| 258 |
+
tracks_to_plot=tracks_to_plot,
|
| 259 |
+
elements_to_plot=elements_to_plot,
|
| 260 |
+
)</code></pre></div>
|
| 261 |
+
<img src="assets/output_tracks.png" alt="Predicted tracks over a human genomic window" style="max-width: 100%; margin-top: 20px;" />
|
| 262 |
+
</div>
|
| 263 |
+
</div>
|
tabs/skill.html
ADDED
|
@@ -0,0 +1,148 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
<div class="summary">
|
| 2 |
+
<h2>🤖 NTv3 in Claude Code</h2>
|
| 3 |
+
<p>The NTv3 Claude Skill provides a natural-language interface to NTv3 models and genomic analysis tools. It combines model outputs with external genomic data and annotations to support analysis and interpretation of genomic regions.</p><p>Current analyses include functional-track prediction, saliency (gradient-based attribution), single-nucleotide variant comparison and gene annotation, with further interpretation methods and tools planned as the skill develops.</p>
|
| 4 |
+
<p>Give the agent a gene or region and the analysis you want to run. It asks for any missing details, prepares an analysis plan and waits for your approval before running it.</p>
|
| 5 |
+
</div>
|
| 6 |
+
<div class="grid">
|
| 7 |
+
<div class="card" style="grid-column: span 12;">
|
| 8 |
+
<h2>🧭 How it works</h2>
|
| 9 |
+
<p>The agent retrieves the reference sequence, runs the selected analyses and gathers relevant external data. Results are assembled into a locus-level report with an interactive genome browser, delivered as a Claude artifact. A Python script supports rerunning the analysis, and an optional notebook provides access to the saved results.</p><p style="margin-top: 12px;">An evidence audit checks references to analysis files and literature and screens report claims for predefined forms of overstatement. Its decisions are recorded alongside the results.</p>
|
| 10 |
+
<a href="assets/skill_workflow_v6b.png" rel="noopener noreferrer" target="_blank"><img alt="NTv3 skill workflow: the natural-language request 'Predict K562 CAGE and DNase at HBE1 and show which bases drive the prediction', the LLM agent that clarifies inputs and resolves a plan, user approval, then NTv3 track prediction, gradient attribution and single-variant comparison with public ENCODE and UCSC context, an evidence audit, and a locus-level report published as a Claude artifact" src="assets/skill_workflow_v6b.png" style="max-width: 100%; margin-top: 20px; border-radius: 12px;"/></a>
|
| 11 |
+
</div>
|
| 12 |
+
<div class="card" style="grid-column: span 12;">
|
| 13 |
+
<h2>🖥️ Example: the HBE1 locus in K562</h2>
|
| 14 |
+
<style>
|
| 15 |
+
/* Skill-run transcript (matches supp figure v10a, panel b). Scoped to .skill-chat. */
|
| 16 |
+
.skill-chat {
|
| 17 |
+
margin-top: 12px;
|
| 18 |
+
padding: 14px 16px;
|
| 19 |
+
border: 1px solid var(--border);
|
| 20 |
+
border-radius: 14px;
|
| 21 |
+
background: rgba(125, 211, 252, 0.06);
|
| 22 |
+
font-size: 14px;
|
| 23 |
+
line-height: 1.55;
|
| 24 |
+
color: var(--text);
|
| 25 |
+
display: grid;
|
| 26 |
+
grid-template-columns: 4.6em minmax(0, 1fr);
|
| 27 |
+
column-gap: 12px;
|
| 28 |
+
row-gap: 10px;
|
| 29 |
+
overflow-wrap: anywhere;
|
| 30 |
+
}
|
| 31 |
+
.skill-chat .who { color: var(--muted); font-weight: 600; }
|
| 32 |
+
.skill-chat .turn { min-width: 0; }
|
| 33 |
+
.skill-chat .pair { display: inline-block; max-width: 100%; margin-right: 0.35em; }
|
| 34 |
+
.skill-chat .sep { color: var(--muted); margin-left: 0.15em; }
|
| 35 |
+
.skill-chat .q { font-weight: 600; }
|
| 36 |
+
.skill-chat .key { color: var(--muted); }
|
| 37 |
+
.skill-chat .plan {
|
| 38 |
+
padding: 8px 10px;
|
| 39 |
+
border: 1px solid rgba(125, 211, 252, 0.35);
|
| 40 |
+
border-radius: 10px;
|
| 41 |
+
background: rgba(0, 0, 0, 0.18);
|
| 42 |
+
}
|
| 43 |
+
.skill-chat .arrow { color: var(--muted); margin: 0 0.35em; }
|
| 44 |
+
.skill-prompt pre, .skill-prompt code[class*="language-"] { white-space: pre-wrap; overflow-wrap: anywhere; }
|
| 45 |
+
@media (max-width: 520px) {
|
| 46 |
+
.skill-chat { font-size: 13px; padding: 12px; column-gap: 8px; grid-template-columns: 3.9em minmax(0, 1fr); }
|
| 47 |
+
}
|
| 48 |
+
</style>
|
| 49 |
+
<div aria-label="Skill run transcript" class="skill-chat" role="group">
|
| 50 |
+
<div class="who">User</div>
|
| 51 |
+
<div class="turn">Predict K562 CAGE and DNase at <em>HBE1</em> and show which bases drive the prediction.</div>
|
| 52 |
+
<div class="who">Agent</div>
|
| 53 |
+
<div class="turn">
|
| 54 |
+
<span class="pair"><span class="q">Species?</span> human <span class="sep">·</span></span>
|
| 55 |
+
<span class="pair"><span class="q">Assembly?</span> hg38 <span class="sep">·</span></span>
|
| 56 |
+
<span class="pair"><span class="q">Tracks?</span> CAGE, DNase <span class="sep">·</span></span>
|
| 57 |
+
<span class="pair"><span class="q">Window length?</span> 131,072 bp <span class="sep">·</span></span>
|
| 58 |
+
<span class="pair"><span class="q">Browser lanes?</span> GENCODE (knownGene), ClinVar (clinvarMain)</span>
|
| 59 |
+
</div>
|
| 60 |
+
<div class="who">Plan</div>
|
| 61 |
+
<div class="turn plan">
|
| 62 |
+
<span class="pair"><span class="key">Window</span> chr11:5,204,408–5,335,480 (131,072 bp, GPU, centered on the <em>HBE1</em> TSS)</span><br/>
|
| 63 |
+
<span class="pair"><span class="key">Prediction region</span> 49,152 bp <span class="sep">·</span></span>
|
| 64 |
+
<span class="pair"><span class="key">Tracks</span> CAGE CNhs11250_P; DNase ENCSR921NMD</span><br/>
|
| 65 |
+
<span class="pair"><span class="key">Measured signal</span> ENCODE ENCSR921NMD (DNase) <span class="sep">·</span></span>
|
| 66 |
+
<span class="pair"><span class="key">Attribution</span> CAGE <span class="sep">·</span></span>
|
| 67 |
+
<span class="pair"><span class="key">Evidence audit</span> on</span>
|
| 68 |
+
</div>
|
| 69 |
+
<div class="who">User</div>
|
| 70 |
+
<div class="turn">
|
| 71 |
+
<strong>Approve</strong><span class="arrow">→</span><span class="who">Agent</span>
|
| 72 |
+
lookup → predict + saliency → evidence audit → locus-level report (Claude artifact)
|
| 73 |
+
</div>
|
| 74 |
+
</div>
|
| 75 |
+
<p style="margin-top: 14px;">Here, the skill uses NTv3_tracks to predict CAGE and DNase signal in K562 cells around <em>HBE1</em>. The 131,072-bp input is centered on the transcription start site; predictions cover the central 49,152 bp, including the β-globin locus control region.</p>
|
| 76 |
+
<p style="margin-top: 10px;">The report brings together predicted CAGE and DNase tracks, measured DNase signal from ENCODE (ENCSR921NMD), and GENCODE and ClinVar annotations. CAGE attributions are shown below the signal tracks. Each lane has its own scale, so compare peak shapes rather than heights. The <em>HBE1</em> TSS line and the HS1–HS5 labels of the locus control region were added for this figure.</p>
|
| 77 |
+
<a href="assets/skill_report_hbe1_cagesal_top_hs.png" rel="noopener noreferrer" target="_blank"><img alt="NTv3 skill report browser for HBE1 in K562 over the 49,152 bp predicted crop, with its controls row, range line and ideogram: GENCODE and ClinVar lanes, the NTv3 predicted CAGE track, the measured and predicted DNase tracks, and the CAGE saliency, with the HBE1 TSS and HS1 to HS5 marked" src="assets/skill_report_hbe1_cagesal_top_hs.png" style="max-width: 100%; margin-top: 20px; border-radius: 12px;"/></a>
|
| 78 |
+
<p style="margin-top: 16px;">Zooming in on HS4 shows the CAGE attributions at base resolution. Letter heights show the gradient × input of each base for the mean predicted CAGE over the 49,152-bp prediction region. The GATA- and AP-1-like boxes were added for this figure.</p>
|
| 79 |
+
<a href="assets/skill_report_hbe1_hs4_saliency_cage_r848.png" rel="noopener noreferrer" target="_blank"><img alt="HS4, chr11:5,288,225-5,288,345 (120 bp), Saliency · CAGE lane: the saliency letters show a GATA site pair and an AP-1 site" src="assets/skill_report_hbe1_hs4_saliency_cage_r848.png" style="max-width: 100%; margin-top: 20px; border-radius: 12px; background: #fff;"/></a>
|
| 80 |
+
<p style="margin-top: 6px; font-size: 13px;">HS4 · chr11:5,288,225–5,288,345 (hg38) · CAGE attribution</p>
|
| 81 |
+
</div>
|
| 82 |
+
<div class="card" style="grid-column: span 12;">
|
| 83 |
+
<h2>🎯 Current capabilities</h2>
|
| 84 |
+
<table class="capability-grid">
|
| 85 |
+
<thead>
|
| 86 |
+
<tr>
|
| 87 |
+
<th>Analysis</th>
|
| 88 |
+
<th>What the skill does</th>
|
| 89 |
+
</tr>
|
| 90 |
+
</thead>
|
| 91 |
+
<tbody>
|
| 92 |
+
<tr>
|
| 93 |
+
<td><strong>Predict</strong></td>
|
| 94 |
+
<td>Predict functional tracks for a gene or genomic region, with the species and tracks selected for the analysis.</td>
|
| 95 |
+
</tr>
|
| 96 |
+
<tr>
|
| 97 |
+
<td><strong>Saliency</strong></td>
|
| 98 |
+
<td>Show which bases drive a selected prediction, as gradient × input at base resolution.</td>
|
| 99 |
+
</tr>
|
| 100 |
+
<tr>
|
| 101 |
+
<td><strong>Compare</strong></td>
|
| 102 |
+
<td>Predict each allele of a single-nucleotide variant and report the change in each selected track (alternative − reference).</td>
|
| 103 |
+
</tr>
|
| 104 |
+
<tr>
|
| 105 |
+
<td><strong>Annotate</strong></td>
|
| 106 |
+
<td>Predict gene structures for a FASTA, or for the 1 Mb around a Predict run, with the clade-specific NTv3 annotation models.</td>
|
| 107 |
+
</tr>
|
| 108 |
+
<tr>
|
| 109 |
+
<td><strong>Explain</strong></td>
|
| 110 |
+
<td>Show which model, window, crop and head produced each number, read from the run's saved files.</td>
|
| 111 |
+
</tr>
|
| 112 |
+
</tbody>
|
| 113 |
+
</table>
|
| 114 |
+
<p style="margin-top: 12px;"><strong>Scope:</strong> human (hg38) and mouse (mm10) for Predict, Saliency and Compare; any mammal, plant or insect FASTA for Annotate.</p>
|
| 115 |
+
<div class="notice">The <strong>Home</strong> tab describes the models and provides notebooks for running the analyses directly.</div>
|
| 116 |
+
</div>
|
| 117 |
+
<div class="card" style="grid-column: span 12;">
|
| 118 |
+
<h2>🚀 Run the skill</h2>
|
| 119 |
+
<p>The skill requires Claude Code, Python 3.11 with uv, and network access to UCSC, ENCODE and Hugging Face. Install it from the <a href="https://github.com/instadeepai/nucleotide-transformer" rel="noopener noreferrer" target="_blank">NTv3 repository</a> (its README has the details):</p>
|
| 120 |
+
<div class="code"><pre><code class="language-bash">git clone https://github.com/instadeepai/nucleotide-transformer.git
|
| 121 |
+
cd nucleotide-transformer
|
| 122 |
+
"$(uv python find 3.11)" -m venv .venv
|
| 123 |
+
.venv/bin/pip install -e . # on a GPU host: .venv/bin/pip install -e '.[gpu]'
|
| 124 |
+
uv sync --project ntv3_skills --python 3.11 --frozen
|
| 125 |
+
ntv3_skills/.venv/bin/ntv3 doctor</code></pre></div>
|
| 126 |
+
<p style="margin-top: 12px;"><code>ntv3 doctor</code> finds the Python that runs the model and says whether it sees a GPU; the skill picks the device from it.</p>
|
| 127 |
+
<div class="notice"><strong>GPU or CPU:</strong> with the <code>gpu</code> install on a GPU host, the skill uses the GPU for everything: windows up to 1,048,576 bp, such as the 131,072-bp example above, and Annotate (about 25 GB of GPU memory per 1 Mb window). On a laptop CPU, Predict, Saliency and Compare also run; from 16,384 bp the plan warns that it is slow and needs a lot of memory. On either device, Saliency above 131,072 bp comes with a warning that it may run out of memory.</div>
|
| 128 |
+
<p style="margin-top: 12px;">Open Claude Code in the repository directory and try a request that runs on any laptop:</p>
|
| 129 |
+
<div class="code skill-prompt"><pre><code class="language-text">/ntv3 HBE1 at chr11 5266144 5270240 on hg38 in K562. Predict K562 CAGE with a 4,096 bp window and the GENCODE and ClinVar lanes.</code></pre></div>
|
| 130 |
+
<p style="margin-top: 12px;">The agent asks for any missing details and presents the analysis plan for approval. Once approved, NTv3_tracks predicts K562 CAGE over the central 1,536 bp of the window, at the 3′ end of <em>HBE1</em>, next to the measured ENCODE CAGE signal. Running Annotate on the 1 Mb around this run adds the NTv3 gene model, which matches the three coding exons of <em>HBE1</em> in GENCODE:</p>
|
| 131 |
+
<a href="assets/skill_report_hbe1_4kb_annotation.png" rel="noopener noreferrer" target="_blank"><img alt="NTv3 skill report browser for the 4,096 bp HBE1 window in K562: GENCODE V50, the NTv3 annotation lane with a three-exon minus-strand gene model matching HBE1, ClinVar, the measured CAGE signal and the NTv3 predicted CAGE track" src="assets/skill_report_hbe1_4kb_annotation.png" style="max-width: 100%; margin-top: 16px; border-radius: 12px; background: #fff;"/></a>
|
| 132 |
+
<p style="margin-top: 6px; font-size: 13px;">Report browser for the request above, after Annotate with the mammals model · chr11:5,266,144–5,270,240 (hg38). Thin GENCODE blocks are UTRs; NTv3 predicts coding exons only.</p>
|
| 133 |
+
<p style="margin-top: 16px;">More requests to try:</p>
|
| 134 |
+
<div class="code skill-prompt"><pre><code class="language-text"># Saliency
|
| 135 |
+
/ntv3 Predict K562 CAGE at HBE1 with a 4,096 bp window and show which bases drive it.
|
| 136 |
+
# Compare (rs334, the HBB sickle-cell variant)
|
| 137 |
+
/ntv3 Compare chr11 5227002 T A on hg38 in K562: CAGE with a 4,096 bp window.
|
| 138 |
+
# The 131 kb example above (GPU)
|
| 139 |
+
/ntv3 Predict K562 CAGE and DNase at HBE1 with a 131,072 bp window and show which bases drive the prediction.
|
| 140 |
+
# Annotate a genome (GPU)
|
| 141 |
+
/ntv3 Annotate /path/to/genome.fa with the mammals model.
|
| 142 |
+
# Annotate the 1 Mb around a Predict run (GPU)
|
| 143 |
+
/ntv3 Annotate the 1 Mb around my HBE1 run.</code></pre></div>
|
| 144 |
+
<p style="margin-top: 12px;">The skill's tools write only to <code>results/</code> in the repository and upload nothing; the report reaches you as a Claude artifact, and every model download is listed in the plan you approve.</p>
|
| 145 |
+
<h2 style="margin-top: 20px;">🔗 Links</h2>
|
| 146 |
+
<ul><li>Analysis notebooks: <strong>Home</strong> tab</li><li><a href="https://github.com/instadeepai/nucleotide-transformer" rel="noopener noreferrer" target="_blank">NTv3 model code (GitHub)</a></li><li><a href="https://huggingface.co/collections/InstaDeepAI/ntv3" rel="noopener noreferrer" target="_blank">NTv3 model collection</a></li></ul>
|
| 147 |
+
</div>
|
| 148 |
+
</div>
|