ybornachot commited on
Commit
3e211d2
·
1 Parent(s): c5c1974

Replace the 2025 Space with the revised NTv3 release (#4)

Browse files

- Replace the 2025 Space with the revised NTv3 release (a6af7a757e95fd80e6af34a772ff1e571bb11813)

Files changed (41) hide show
  1. .gitattributes +5 -0
  2. README.md +190 -53
  3. assets/ntv3_overview.png +3 -0
  4. assets/output_annotation.png +0 -0
  5. assets/skill_report_hbe1_4kb_annotation.png +3 -0
  6. assets/skill_report_hbe1_cagesal_top_hs.png +3 -0
  7. assets/skill_report_hbe1_hs4_saliency_cage_r848.png +0 -0
  8. assets/skill_workflow_v6b.png +3 -0
  9. index.html +84 -19
  10. notebooks_pipelines/01_functional_track_prediction.ipynb +0 -0
  11. notebooks_pipelines/02_model_interpretation.ipynb +0 -0
  12. notebooks_tutorials/00_quickstart_inference.ipynb +0 -0
  13. notebooks_tutorials/01_tracks_prediction.ipynb +0 -0
  14. notebooks_tutorials/02_fine_tuning_bigwig_tracks.ipynb +0 -0
  15. notebooks_tutorials/02_fine_tuning_pretrained_model_biwig.ipynb +0 -0
  16. notebooks_tutorials/03_fine_tuning_posttrained_model_biwig.ipynb +0 -0
  17. notebooks_tutorials/03a_genome_annotation_training.ipynb +0 -0
  18. notebooks_tutorials/03b_genome_annotation_decoding.ipynb +0 -0
  19. notebooks_tutorials/03c_genome_annotation_evaluation.ipynb +0 -0
  20. notebooks_tutorials/04_fine_tuning_pretrained_model_annotation.ipynb +0 -0
  21. notebooks_tutorials/04_model_interpretation.ipynb +1202 -0
  22. notebooks_tutorials/05_diffusion_fine_tuning.ipynb +941 -0
  23. notebooks_tutorials/05_model_interpretation.ipynb +0 -0
  24. notebooks_tutorials/06_NTv3_generative_training.ipynb +0 -0
  25. notebooks_tutorials/06_plant_promoter_generation.ipynb +0 -0
  26. notebooks_tutorials/07_enhancer_generation.ipynb +0 -0
  27. src/genome_annotation/__init__.py +1 -0
  28. src/genome_annotation/analysis.py +594 -0
  29. src/genome_annotation/benchmark.py +194 -0
  30. src/genome_annotation/data.py +165 -0
  31. src/genome_annotation/decoding.py +338 -0
  32. src/genome_annotation/evaluation.py +672 -0
  33. src/genome_annotation/inference.py +255 -0
  34. src/genome_annotation/metrics.py +143 -0
  35. src/genome_annotation/model.py +118 -0
  36. src/genome_annotation/plotting.py +710 -0
  37. src/genome_annotation/proteins.py +150 -0
  38. tabs/demo.html +0 -33
  39. tabs/home.html +181 -163
  40. tabs/initial_release.html +263 -0
  41. 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 — Foundation Models for Long-Range Genomics
3
  emoji: 🧬
4
  colorFrom: indigo
5
  colorTo: blue
@@ -7,26 +7,71 @@ sdk: static
7
  pinned: false
8
  ---
9
 
10
- # 🧬 NTv3 — Foundation Models for Long-Range Genomics
11
 
12
- This Space is the companion hub for NTv3 checkpoints on the Hugging Face Hub. It provides PyTorch notebooks and minimal examples for inference, sequence-to-function prediction (functional tracks), genome annotation, fine-tuning, model interpretation and sequence generation.
 
 
 
 
 
 
13
 
14
  ## 📖 About NTv3
15
 
16
- NTv3 is a multi-species genomic foundation model family that unifies representation learning, functional-track prediction, genome annotation, and controllable sequence generation within a single U-Net-style backbone. It models up to 1 Mb of DNA at single-base resolution, using a conv–Transformer–deconv architecture that efficiently captures both local motifs and long-range regulatory dependencies. NTv3 is first pretrained on ~9T base pairs from the OpenGenome2 corpus spanning >128k species using masked language modeling, and then post-trained with a joint objective on ~16k functional tracks and annotation labels across 24 animal and plant species, enabling state-of-the-art cross-species functional prediction and base-resolution genome annotation.
 
 
 
17
 
18
- Beyond prediction, NTv3 can be fine-tuned into a controllable generative model via masked-diffusion language modeling, allowing targeted design of regulatory sequences (for example, enhancers with specified activity and promoter selectivity) that have been validated experimentally.
19
 
20
- ## 📓 Notebooks
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
21
 
22
- Notebooks live in `./notebooks/`:
 
 
 
 
 
 
23
 
24
- - 🚀 `00_quickstart_inference.ipynb` — load a checkpoint + run inference
25
- - 📊 `01_tracks_prediction.ipynb` — sequence → functional tracks (+ plotting)
26
- - 🏷️ `02_genome_annotation_segmentation.ipynb` — sequence → annotation
27
- - 🎯 `03_finetune_head.ipynb` — fine-tune on bigwig tracks
28
- - 🔍 `04_model_interpretation.ipynb` — interpretation of post-trained model
29
- - 🧪 `05_sequence_generation.ipynb` — fine-tune NTv3 to generate enhancer sequences
 
 
 
 
 
 
 
 
 
 
 
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
- ## 🤖 Load a pre-trained model
 
 
 
 
 
 
 
 
 
 
 
 
38
 
39
  ```python
40
- from transformers import AutoTokenizer, AutoModelForMaskedLM
 
41
 
42
- repo = "InstaDeepAI/NTv3_650M_pre"
43
- tok = AutoTokenizer.from_pretrained(repo, trust_remote_code=True)
44
- model = AutoModelForMaskedLM.from_pretrained(repo, trust_remote_code=True)
45
 
46
- batch = tok(["ATCGNATCG", "ACGT"], add_special_tokens=False, padding=True, pad_to_multiple_of=128, return_tensors="pt")
47
- out = model(**batch, output_hidden_states=True, output_attentions=True)
48
 
49
- print(out.logits.shape) # (B, L, V = 11)
50
- print(len(out.hidden_states)) # convs + transformers + deconvs
51
- print(len(out.attentions)) # equals transformer layers = 12
 
 
 
 
 
52
  ```
53
 
54
- ## 💻 Pipelines
 
 
 
 
55
 
56
- Here is a quick example of how to use the post-trained NTv3 650M model on a human genomic window.
 
57
 
58
  ```python
59
- from transformers import AutoConfig
60
-
61
- model_name = "InstaDeepAI/NTv3_100M"
62
-
63
- # Load track prediction pipeline
64
- cfg = AutoConfig.from_pretrained(model_name, trust_remote_code=True, force_download=True)
65
- pipe = cfg.load_tracks_pipeline(model_name, device="auto") # or "cpu"/"cuda"/"mps"
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.bigwig_tracks_logits.shape) # functional track predictions
78
- print(out.bed_tracks_logits.shape) # genome annotation predictions
79
- print(out.mlm_logits.shape) # MLM logits: (B, L, V = 11)
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
80
  ```
81
 
82
- ## 🤖 Checkpoints
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
83
 
84
- **📦 Pre-trained:** `InstaDeepAI/NTv3_8M_pre`, `InstaDeepAI/NTv3_100M_pre`, `InstaDeepAI/NTv3_650M_pre`
85
 
86
- **🎯 Post-trained:** `InstaDeepAI/NTv3_100M`, `InstaDeepAI/NTv3_650M`
 
 
 
 
 
 
 
 
 
 
 
 
87
 
88
  ## 🔗 Links
89
 
90
- - **📄 Paper:** (add link)
91
- - **💻 JAX research code (GitHub):** [https://github.com/instadeepai/nucleotide-transformer](https://github.com/instadeepai/nucleotide-transformer)
92
- - **🏆 NTv3 benchmark leaderboard: (add link)**
 
 
 
 
93
 
94
  ## 📝 Citation
95
 
 
 
96
  ```bibtex
97
  @article{ntv3,
98
- title = {A foundational model for joint sequence-function multi-species modeling at scale for long-range genomic prediction},
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
- **Code & notebooks in this Space:** (choose and add, e.g., Apache-2.0)
 
 
108
 
109
- **Model weights:** see the license specified in each model repository
 
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

  • SHA256: ef5e59ffcbf9ef9d2dac26f0b153bef0dc6926c2d862a2441fd4743705e1683b
  • Pointer size: 131 Bytes
  • Size of remote file: 814 kB
assets/output_annotation.png ADDED
assets/skill_report_hbe1_4kb_annotation.png ADDED

Git LFS Details

  • SHA256: f3dcea164e781bbc4dd3603f0f9fda801daf844f13a34c32fa376dbb39180e84
  • Pointer size: 131 Bytes
  • Size of remote file: 100 kB
assets/skill_report_hbe1_cagesal_top_hs.png ADDED

Git LFS Details

  • SHA256: c3f9223cb9d4318f5151a2e2c7d28f870264242c120b775d8b788a74e7afde4c
  • Pointer size: 131 Bytes
  • Size of remote file: 269 kB
assets/skill_report_hbe1_hs4_saliency_cage_r848.png ADDED
assets/skill_workflow_v6b.png ADDED

Git LFS Details

  • SHA256: da918ecc7b3bdd957143281bddbb12667cc9e8467570210b9ab7fac7b37589a8
  • Pointer size: 131 Bytes
  • Size of remote file: 408 kB
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 — Next-Gen Foundation Models for Genomics</title>
7
- <meta name="description" content="NTv3 companion hub: PyTorch notebooks for inference, fine-tuning, interpretation, and sequence generation on NTv3 models hosted on Hugging Face." />
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 — Next-Gen Foundation Models for Genomics</h1>
260
  <p>
261
- This Space is the companion hub for <strong>NTv3</strong> models: runnable notebooks for inference, fine-tuning, interpretation, and sequence generation.
 
 
262
  </p>
263
 
264
  <div class="pillrow">
265
- <span class="pill">🤖 Foundation Models</span>
266
- <span class="pill">🧬 Long-context genomics</span>
267
- <span class="pill">🌍 Multi-species</span>
268
- <span class="pill">⚡ Inference • Fine-tune • Interpret • Generate</span>
 
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="demo">🚀 Live Demo</button>
 
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
- <!-- Live Demo Tab (Content loaded from tabs/demo.html) -->
285
- <div id="demo" class="tab-content">
 
 
 
 
 
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 — NTv3 companion Space.
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
- 'demo': 'tabs/demo.html',
305
- 'functional_tracks': 'tabs/functional_tracks.html',
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 multi-species genomic foundation model family that unifies representation learning, functional-track prediction, genome annotation, and controllable sequence generation within a single U-Net-style backbone. It models up to 1 Mb of DNA at single-base resolution, using a conv–Transformer–deconv architecture that efficiently captures both local motifs and long-range regulatory dependencies. NTv3 is first pretrained on ~9T base pairs from the OpenGenome2 corpus spanning >128k species using masked language modeling, and then post-trained with a joint objective on ~16k functional tracks and annotation labels across 24 animal and plant species, enabling state-of-the-art cross-species functional prediction and base-resolution genome annotation.
 
 
 
 
 
 
 
 
 
 
 
 
 
5
  </p>
6
  <p>
7
- NTv3 also acts as a controllable generative model via masked-diffusion language modeling, allowing targeted design of regulatory sequences (for example, enhancers with specified activity and promoter selectivity) that have been validated experimentally.
 
8
  </p>
9
  </div>
10
 
11
  <div class="paper-summary">
12
- <!-- <h2>📄 A foundational model for joint sequence-function multi-species modeling at scale for long-range genomic prediction</h2> -->
13
- <img src="assets/paper_summary.png" alt="NTv3 Paper Summary" />
14
  </div>
15
 
16
  <div class="why-ntv3">
17
- <h2>✨ Why NTv3?</h2>
18
  <ul>
19
- <li>📏 <strong>1 Mb long context at nucleotide resolution</strong> — ~100× longer than typical genomics models.</li>
20
- <li>🏗️ <strong>Unified architecture</strong> for: masked language modeling, functional-track prediction, genome annotation, and sequence generation.</li>
21
- <li>🌍 <strong>Cross-species generalization</strong> across 24 animals + plants with a shared conditioned representation space.</li>
22
- <li>⚡ <strong>U-Net–style architecture</strong> improves stability and GPU efficiency on very long sequences.</li>
23
- <li>🎯 <strong>Controllable generative modeling</strong>, enabling targeted enhancer/promoter engineering validated by experimental assays.</li>
24
  </ul>
25
  </div>
26
 
27
  <div class="grid">
28
- <div class="card">
29
- <h2>🤖 Models (see <a href="https://huggingface.co/collections/InstaDeepAI/nucleotide-transformer-v3" target="_blank" rel="noopener noreferrer">collection</a>)</h2>
30
- <ul>
31
- <li>📦 Pretrained checkpoints:
32
- <div style="margin-top: 8px; margin-left: 0;">
33
- <div><a href="https://huggingface.co/InstaDeepAI/NTv3_8M_pre" target="_blank" rel="noopener noreferrer"><code>InstaDeepAI/NTv3_8M_pre</code></a></div>
34
- <div><a href="https://huggingface.co/InstaDeepAI/NTv3_100M_pre" target="_blank" rel="noopener noreferrer"><code>InstaDeepAI/NTv3_100M_pre</code></a></div>
35
- <div><a href="https://huggingface.co/InstaDeepAI/NTv3_650M_pre" target="_blank" rel="noopener noreferrer"><code>InstaDeepAI/NTv3_650M_pre</code></a></div>
36
- </div>
37
- </li>
38
- <li>🎯 Post-trained checkpoints:
39
- <div style="margin-top: 8px; margin-left: 0;">
40
- <div><a href="https://huggingface.co/InstaDeepAI/NTv3_100M_pos" target="_blank" rel="noopener noreferrer"><code>InstaDeepAI/NTv3_100M_pos</code></a></div>
41
- <div><a href="https://huggingface.co/InstaDeepAI/NTv3_650M_pos" target="_blank" rel="noopener noreferrer"><code>InstaDeepAI/NTv3_650M_pos</code></a></div>
42
- </div>
43
- </li>
44
- </ul>
45
- <table>
46
  <thead>
47
  <tr>
48
  <th>Model</th>
49
- <th>Size</th>
50
- <th>Pre-training</th>
51
- <th>Post-training</th>
52
- <th>Usage</th>
53
  </tr>
54
  </thead>
55
  <tbody>
56
  <tr>
57
- <td><strong>NTv3-8M</strong></td>
58
- <td>8M params</td>
59
- <td><span class="checkmark">✅</span></td>
60
- <td>❌</td>
61
- <td>Embeddings, light inference</td>
62
  </tr>
63
  <tr>
64
- <td><strong>NTv3-100M</strong></td>
65
- <td>100M params</td>
66
- <td><span class="checkmark">✅</span></td>
67
- <td><span class="checkmark">✅</span></td>
68
- <td>Embeddings, tracks, annotation</td>
 
 
69
  </tr>
70
  <tr>
71
- <td><strong>NTv3-650M</strong></td>
72
- <td>650M params</td>
73
- <td><span class="checkmark">✅</span></td>
74
- <td><span class="checkmark">✅</span></td>
75
- <td>Embeddings, tracks, annotation, best accuracy</td>
 
 
 
 
 
 
 
 
 
76
  </tr>
77
  </tbody>
78
  </table>
@@ -80,152 +103,147 @@
80
 
81
  <div class="card-stack">
82
  <div class="card">
83
- <h2>📓 Tutorial notebooks (browse <a href="https://huggingface.co/spaces/InstaDeepAI/ntv3/tree/main/notebooks_tutorials" target="_blank" rel="noopener noreferrer">folder</a>)</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">🚀 00 — Quickstart inference</a></li>
86
- <li><a href="https://huggingface.co/spaces/InstaDeepAI/ntv3/blob/main/notebooks_tutorials/01_tracks_prediction.ipynb" target="_blank" rel="noopener noreferrer">📊 01 — Tracks prediction</a></li>
87
- <li><a href="https://huggingface.co/spaces/InstaDeepAI/ntv3/blob/main/notebooks_tutorials/02_fine_tuning_pretrained_model_biwig.ipynb" target="_blank" rel="noopener noreferrer">🎯 02 — Fine-tune a pre-trained model on bigwig tracks</a></li>
88
- <li><a href="https://huggingface.co/spaces/InstaDeepAI/ntv3/blob/main/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>
89
- <li><a href="https://huggingface.co/spaces/InstaDeepAI/ntv3/blob/main/notebooks_tutorials/04_fine_tuning_pretrained_model_annotation.ipynb" target="_blank" rel="noopener noreferrer">🏷️ 04 — Fine-tune a pre-trained model on annotations</a></li>
90
- <li><a href="https://huggingface.co/spaces/InstaDeepAI/ntv3/blob/main/notebooks_tutorials/05_model_interpretation.ipynb" target="_blank" rel="noopener noreferrer">🔍 05 — Model interpretation</a></li>
91
- <li><a href="https://huggingface.co/spaces/InstaDeepAI/ntv3/blob/main/notebooks_tutorials/06_NTv3_generative_training.ipynb" target="_blank" rel="noopener noreferrer">🧪 06 — Fine-tuning NTv3 into a diffusion model</a></li>
92
- <li><a href="https://huggingface.co/spaces/InstaDeepAI/ntv3/blob/main/notebooks_tutorials/07_enhancer_generation.ipynb" target="_blank" rel="noopener noreferrer">🪰 07 — Generating enhancer sequences</a></li>
93
- </ul>
94
- </div>
95
- <div class="card">
96
- <h2>📓 Pipeline notebooks (browse <a href="https://huggingface.co/spaces/InstaDeepAI/ntv3/tree/main/notebooks_pipelines" target="_blank" rel="noopener noreferrer">folder</a>)</h2>
97
- <ul>
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><a href="https://www.biorxiv.org/content/10.64898/2025.12.22.695963v1" target="_blank" rel="noopener noreferrer">📄 Paper</a></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/nucleotide-transformer-v3" target="_blank" rel="noopener noreferrer">🎯 HF Model Collection (all NTv3 models)</a></li>
108
- <li><a href="https://huggingface.co/spaces/InstaDeepAI/ntv3/tree/main/notebooks_tutorials" target="_blank" rel="noopener noreferrer">📚 Tutorial </a> and <a href="https://huggingface.co/spaces/InstaDeepAI/ntv3/tree/main/notebooks_pipelines" target="_blank" rel="noopener noreferrer">🔧 Pipeline</a> notebooks</li>
109
- <li><a href="https://huggingface.co/spaces/InstaDeepAI/ntv3_benchmark" target="_blank" rel="noopener noreferrer">🏆 NTv3 benchmark leaderboard</a></li>
 
110
  </ul>
111
  </div>
112
  </div>
113
 
114
  <div class="card-stack">
115
  <div class="card">
116
- <h2>🤖 Load a pre-trained model</h2>
117
- <p>Here is an example of how to load and use a pre-trained NTv3 model.</p>
118
- <div class="code"><pre><code class="language-python">from transformers import AutoTokenizer, AutoModelForMaskedLM
 
119
 
120
- model_name = "InstaDeepAI/NTv3_650M_pre"
121
 
122
- # Load model and tokenizer
123
- model = AutoModelForMaskedLM.from_pretrained(model_name, trust_remote_code=True)
124
  tok = AutoTokenizer.from_pretrained(model_name, trust_remote_code=True)
125
 
126
- # Tokenize input sequences
127
- batch = tok(["ATCGNATCG", "ACGT"], add_special_tokens=False, padding=True, pad_to_multiple_of=128, return_tensors="pt")
 
 
128
 
129
- # Run model
130
- out = model(**batch)
 
131
 
132
- # Print output shapes
133
- print(out.logits.shape) # (B, L, V = 11)
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>🔍 Model interpretation</h2>
139
- <p>Here is an example of how to use the interpretation pipeline on the NTv3 post-trained model for multi-scale analysis of DNA sequences:</p>
140
- <div class="code"><pre><code class="language-python">from transformers import pipeline
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
- model_name = "InstaDeepAI/NTv3_650M_pos"
 
 
 
 
 
 
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
- "chrom": "chr19",
196
- "start": 6_700_000,
197
- "end": 6_831_072,
198
- "species": "human"
199
- }
 
 
200
  )
201
 
202
- # Print output shapes
203
- # 7k human tracks over 37.5 % center region of the input sequence
204
- print("bigwig_tracks_logits:", tuple(out.bigwig_tracks_logits.shape))
205
- # Location of 21 genomic elements over 37.5 % center region of the input sequence
206
- print("bed_tracks_logits:", tuple(out.bed_tracks_logits.shape))
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
- out = ntv3_tracks(
223
- {"chrom": "chr19", "start": 6_700_000, "end": 6_831_072, "species": "human"},
224
- plot=True,
225
- tracks_to_plot=tracks_to_plot,
226
- elements_to_plot=elements_to_plot,
227
- )</code></pre></div>
228
- <img src="assets/output_tracks.png" alt="Output tracks visualization" style="max-width: 100%; margin-top: 20px;" />
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
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>