Token Classification
Transformers
Safetensors
generanno
dna
genomics
biology
genome-annotation
custom_code
Instructions to use HuggingFaceBio/Carbon-A-1.2B with libraries, inference providers, notebooks, and local apps. Follow these links to get started.
- Libraries
- Transformers
How to use HuggingFaceBio/Carbon-A-1.2B with Transformers:
# Use a pipeline as a high-level helper from transformers import pipeline pipe = pipeline("token-classification", model="HuggingFaceBio/Carbon-A-1.2B", trust_remote_code=True)# pip install -U transformers accelerate # Load model directly from transformers import AutoModelForTokenClassification model = AutoModelForTokenClassification.from_pretrained("HuggingFaceBio/Carbon-A-1.2B", trust_remote_code=True, device_map="auto") - Notebooks
- Google Colab
- Kaggle
Add the Carbon-A annotation pipeline and rewrite the model card around it
Browse files- INFERENCE.md +0 -86
- README.md +43 -95
- annotation_pipeline/README.md +258 -0
- annotation_pipeline/codon_tables/GCF_000189635.1.json +18 -0
- annotation_pipeline/codon_tables/standard.json +18 -0
- annotation_pipeline/run_annotation.sh +340 -0
- annotation_pipeline/run_busco.sh +147 -0
- annotation_pipeline/run_evaluation.sh +113 -0
- annotation_pipeline/src/busco_evaluation.py +212 -0
- annotation_pipeline/src/confidence_network.py +232 -0
- annotation_pipeline/src/constants.py +141 -0
- annotation_pipeline/src/evaluation.py +1093 -0
- annotation_pipeline/src/model_inference.py +1067 -0
- annotation_pipeline/src/post_processing.py +511 -0
- annotation_pipeline/src/tcn.py +136 -0
- annotation_pipeline/src/transition_network.py +141 -0
- annotation_pipeline/src/utils.py +791 -0
- annotation_pipeline/src/viterbi.py +940 -0
- annotation_pipeline/src/viterbi_backends/__init__.py +0 -0
- annotation_pipeline/src/viterbi_backends/decode_cpu.py +112 -0
- annotation_pipeline/src/viterbi_backends/decode_gpu.py +167 -0
- annotation_pipeline/src/viterbi_backends/viterbi_cpu.cpp +806 -0
- annotation_pipeline/src/viterbi_backends/viterbi_gpu.cu +1607 -0
- annotation_pipeline/weights/confidence_network.safetensors +3 -0
- annotation_pipeline/weights/transition_network.safetensors +3 -0
- genbank-inference-check.json +0 -33
- infer_genbank.py +0 -123
- infer_packed.py +0 -116
- inference-check.json +0 -27
- load_data.py +0 -52
- provenance.json +1 -6
- requirements.txt +15 -3
INFERENCE.md
DELETED
|
@@ -1,86 +0,0 @@
|
|
| 1 |
-
# Carbon-A inference guide
|
| 2 |
-
|
| 3 |
-
Examples, output formats, and functional checks for Carbon-A. For model facts and a quick start, see the [model card](README.md).
|
| 4 |
-
|
| 5 |
-
## Run inference on a GenBank file
|
| 6 |
-
|
| 7 |
-
Use `infer_genbank.py` for a local eukaryotic GenBank flat file (`.gb`, `.gbff`, or `.gz`). It reads the DNA sequence; existing feature annotations are not used as model inputs. Install the repository's `requirements.txt` and authenticate with `hf auth login`, then run:
|
| 8 |
-
|
| 9 |
-
```bash
|
| 10 |
-
python infer_genbank.py genome.gbff.gz --out-dir genome-predictions
|
| 11 |
-
```
|
| 12 |
-
|
| 13 |
-
For a FASTA file downloaded from GenBank:
|
| 14 |
-
|
| 15 |
-
```bash
|
| 16 |
-
python infer_genbank.py genome.fna.gz --format fasta --out-dir genome-predictions
|
| 17 |
-
```
|
| 18 |
-
|
| 19 |
-
Both commands process every sequence record in the file. Use `--revision <model-commit>` to pin the model, or `--threshold 0.7` to change the binary-call threshold (default 0.5).
|
| 20 |
-
|
| 21 |
-
The script uses 98,304-base windows with a 49,152-base stride and averages CDS probabilities where windows overlap. The last window may be shorter. No BOS/EOS tokens are added. Short records are supported, but the training data used full-length 98,304-base windows. This example uses overlap averaging; it does not reproduce any separate production genome-annotation pipeline.
|
| 22 |
-
|
| 23 |
-
Outputs are one compressed NumPy file per record, named with its accession and record name, plus `summary.json`. Each file contains forward/reverse CDS probabilities and calls, a combined `cds_label_any` track, a `valid` mask, and the number of contributing windows per base (`window_count`). Array position `i` corresponds to record base `i + 1` on **both** strands; the reverse track is not reversed. These are coding-occupancy predictions, not GFF gene or transcript models.
|
| 24 |
-
|
| 25 |
-
Any 6-mer containing an ambiguous nucleotide is excluded from the output. An incomplete final 6-mer is padded with `N` and also excluded. Unscored positions have probability `NaN`, call `-1`, and `valid=False`. Record sequences must be present in the file; annotation-only records with undefined sequence cannot be used. The example retains per-base arrays for one record in memory, so large chromosomes require more host RAM.
|
| 26 |
-
|
| 27 |
-
```python
|
| 28 |
-
import json
|
| 29 |
-
from pathlib import Path
|
| 30 |
-
import numpy as np
|
| 31 |
-
|
| 32 |
-
out = Path("genome-predictions")
|
| 33 |
-
record = json.loads((out / "summary.json").read_text())["records"][0]
|
| 34 |
-
with np.load(out / record["file"]) as prediction:
|
| 35 |
-
plus = prediction["cds_probability_plus"]
|
| 36 |
-
minus = prediction["cds_probability_minus"]
|
| 37 |
-
coding = prediction["cds_label_any"] # 0, 1, or -1 (unscored)
|
| 38 |
-
valid = prediction["valid"]
|
| 39 |
-
print(record["accession"], plus.shape, int(valid.sum()))
|
| 40 |
-
```
|
| 41 |
-
|
| 42 |
-
## Run inference on packed data
|
| 43 |
-
|
| 44 |
-
Use Python 3.10+ and a CUDA-enabled PyTorch installation. Install the requirements and authenticate with your Hugging Face account:
|
| 45 |
-
|
| 46 |
-
```bash
|
| 47 |
-
pip install -r requirements.txt
|
| 48 |
-
hf auth login
|
| 49 |
-
python infer_packed.py --num-rows 1 --out-dir predictions
|
| 50 |
-
```
|
| 51 |
-
|
| 52 |
-
Run these commands from a downloaded copy of this repository. The script downloads a small evaluation HDF5 file when `--h5` is omitted, loads the model from this repository, and predicts one **full-length** row. Model files are downloaded once and cached. The script uses PyTorch SDPA; a separate FlashAttention package is not required.
|
| 53 |
-
|
| 54 |
-
For a local training or evaluation shard:
|
| 55 |
-
|
| 56 |
-
```bash
|
| 57 |
-
python infer_packed.py --h5 /path/to/shard.h5 --start-row 0 --num-rows 2 --batch-size 1 --out-dir predictions-local
|
| 58 |
-
```
|
| 59 |
-
|
| 60 |
-
For repeatable runs, pass `--revision <model-commit>`. Default precision is BF16 on supported GPUs, otherwise FP16; CPU uses FP32 and requires `--device cpu`. Full-length inference on CPU is slow.
|
| 61 |
-
|
| 62 |
-
### Output
|
| 63 |
-
|
| 64 |
-
Each row produces an `.npz` file with arrays of 98,304 positions:
|
| 65 |
-
|
| 66 |
-
- `cds_probability_plus`, `cds_probability_minus`: CDS probabilities.
|
| 67 |
-
- `cds_label_plus`, `cds_label_minus`: calls at `--threshold` (default 0.5).
|
| 68 |
-
- `cds_label_any`: 1 if either strand passes the threshold.
|
| 69 |
-
- `valid_plus`, `valid_minus`: positions eligible for label comparison.
|
| 70 |
-
- `reference_plus`, `reference_minus`: packed RefSeq targets, converted with `label != 0`.
|
| 71 |
-
|
| 72 |
-
Predicted calls are `-1` at excluded special-token positions; references use `-100`. Probabilities are retained at these positions, so use the validity masks when comparing predictions with annotations. A JSON sidecar records the model revision, input row indices, precision, threshold, and output paths. Coordinates are relative to the packed window; the HDF5 rows do not supply genomic accession/start metadata.
|
| 73 |
-
|
| 74 |
-
### Loading details
|
| 75 |
-
|
| 76 |
-
`load_data.py` reads the already-tokenized inputs without adding BOS/EOS tokens. Raw signed labels are converted to binary with `!= 0`, concatenated forward then reverse, and masked at special token positions. Its token IDs match the model tokenizer. To tokenize raw DNA separately, use uppercase sequence and `add_special_tokens=False`; this script itself accepts packed HDF5 only.
|
| 77 |
-
|
| 78 |
-
The model requires `trust_remote_code=True` to load its custom architecture. It supports Transformers 4.56 and later, including 5.x. Apache-2.0 notices in the model code are retained.
|
| 79 |
-
|
| 80 |
-
## Inference check
|
| 81 |
-
|
| 82 |
-
On 2026-10-02, the published model and script at commit `d5a62f1bbf483426314525fcff8695847f9fcdc4` were downloaded from the Hub and run on rows 0–1 of `test/fungi1/GCF_000002945.2.h5`, fetched from the data bucket by the script. The run passed on one NVIDIA H100 80 GB using BF16, PyTorch 2.7.1+cu128, Transformers 4.56.0, and batch size 2. Each row covered all 98,304 bases, with finite two-strand predictions and checked threshold/validity-mask outputs. This is a functional inference check, not an accuracy benchmark. See `inference-check.json` for the check summary.
|
| 83 |
-
|
| 84 |
-
### GenBank file inference check
|
| 85 |
-
|
| 86 |
-
On 2026-10-02, `infer_genbank.py` at commit `d429145e77373827ac93cc03ff04dcbdfab5723f` passed an end-to-end test on an NVIDIA H100 using BF16. The input included the complete 230,218-base yeast chromosome I record `NC_001133.9` (four overlapping windows), plus synthetic short and ambiguous-sequence edge cases. Gzipped GenBank and FASTA inputs produced identical predictions. Saved probabilities matched an independent window-by-window calculation and overlap average; coordinates, thresholded calls, invalid-position masks, multiple records, and overwrite refusal were checked. No script changes were required. See `genbank-inference-check.json`. This check establishes that the example runs correctly; it is not an accuracy benchmark.
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
README.md
CHANGED
|
@@ -1,6 +1,6 @@
|
|
| 1 |
---
|
| 2 |
library_name: transformers
|
| 3 |
-
license:
|
| 4 |
language:
|
| 5 |
- dna
|
| 6 |
pipeline_tag: token-classification
|
|
@@ -17,134 +17,82 @@ tags:
|
|
| 17 |
|
| 18 |
A DNA annotation model from the **Carbon** family.
|
| 19 |
|
| 20 |
-
**Carbon-A predicts protein-coding sequence (CDS) at single-base resolution in eukaryotic genomes.** It produces separate forward- and reverse-strand probabilities, preserving coding regions that overlap on opposite strands.
|
| 21 |
|
| 22 |
## Facts
|
| 23 |
|
| 24 |
- **1.2B-parameter DNA annotation model** with two strand-specific classification heads.
|
| 25 |
- **Tokenizer:** non-overlapping 6-mers; each DNA token represents six bases.
|
| 26 |
-
- **Sequence length:** 16,384 tokens (**98,304 bp**).
|
| 27 |
-
- **Inputs:** genomic DNA. The
|
| 28 |
-
- **Outputs:** per-base probabilities for non-coding and CDS on each strand
|
| 29 |
-
- **Training data:** RefSeq GCF assemblies from mammals, other vertebrates, invertebrates, plants, fungi, and protozoa.
|
| 30 |
-
- **Transformers interface:** `AutoModelForTokenClassification` with `trust_remote_code=True`; Transformers 4.56 or later, including 5.x.
|
| 31 |
-
- **Inference:**
|
| 32 |
|
| 33 |
## How to use
|
| 34 |
|
| 35 |
-
|
|
|
|
|
|
|
| 36 |
|
| 37 |
```bash
|
| 38 |
-
|
| 39 |
-
hf auth login
|
| 40 |
-
hf download HuggingFaceBio/Carbon-A-1.2B --exclude model.safetensors --local-dir Carbon-A-1.2B
|
| 41 |
-
cd Carbon-A-1.2B
|
| 42 |
-
pip install -r requirements.txt
|
| 43 |
```
|
| 44 |
|
| 45 |
-
|
| 46 |
-
|
| 47 |
-
Full-length 98,304-base windows rely on PyTorch's fused SDPA kernels (FlashAttention or memory-efficient attention), which recent NVIDIA GPUs provide. Without them, PyTorch falls back to a kernel that materializes 16,384 × 16,384 attention matrices and needs more than 32 GB of additional GPU memory.
|
| 48 |
-
|
| 49 |
-
### Use from Python
|
| 50 |
-
|
| 51 |
-
```python
|
| 52 |
-
import torch
|
| 53 |
-
from transformers import AutoModelForTokenClassification, AutoTokenizer
|
| 54 |
-
|
| 55 |
-
model_id = "HuggingFaceBio/Carbon-A-1.2B"
|
| 56 |
-
tokenizer = AutoTokenizer.from_pretrained(model_id, trust_remote_code=True)
|
| 57 |
-
model = AutoModelForTokenClassification.from_pretrained(
|
| 58 |
-
model_id, trust_remote_code=True, dtype=torch.bfloat16, attn_implementation="sdpa"
|
| 59 |
-
).to("cuda").eval()
|
| 60 |
|
| 61 |
-
|
| 62 |
-
|
| 63 |
-
|
| 64 |
-
|
| 65 |
-
|
| 66 |
-
logits = model(**inputs).logits # (1, 2 * len(sequence), 2)
|
| 67 |
-
cds_plus, cds_minus = logits.float().softmax(-1)[0, :, 1].reshape(2, -1).cpu()
|
| 68 |
```
|
| 69 |
|
| 70 |
-
|
| 71 |
-
|
| 72 |
-
### Annotate a GenBank or FASTA file
|
| 73 |
|
| 74 |
-
|
| 75 |
|
| 76 |
```bash
|
| 77 |
-
|
| 78 |
-
python infer_genbank.py genome.gbff.gz --out-dir predictions
|
| 79 |
-
|
| 80 |
-
# FASTA input.
|
| 81 |
-
python infer_genbank.py genome.fna.gz --format fasta --out-dir predictions-fasta
|
| 82 |
```
|
| 83 |
|
| 84 |
-
|
| 85 |
|
| 86 |
-
|
|
|
|
|
|
|
| 87 |
|
| 88 |
-
|
| 89 |
-
import json
|
| 90 |
-
from pathlib import Path
|
| 91 |
-
import numpy as np
|
| 92 |
|
| 93 |
-
|
| 94 |
-
record = json.loads((out / "summary.json").read_text())["records"][0]
|
| 95 |
-
with np.load(out / record["file"]) as pred:
|
| 96 |
-
plus = pred["cds_probability_plus"]
|
| 97 |
-
minus = pred["cds_probability_minus"]
|
| 98 |
-
coding = pred["cds_label_any"] # 1 if either strand passes the threshold
|
| 99 |
-
valid = pred["valid"]
|
| 100 |
-
```
|
| 101 |
|
| 102 |
-
|
| 103 |
|
| 104 |
-
###
|
| 105 |
|
| 106 |
-
The
|
| 107 |
|
| 108 |
```bash
|
| 109 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
| 110 |
```
|
| 111 |
|
| 112 |
-
|
| 113 |
|
| 114 |
```bash
|
| 115 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
| 116 |
```
|
| 117 |
|
| 118 |
-
|
| 119 |
-
|
| 120 |
-
## Training data
|
| 121 |
-
|
| 122 |
-
Examples were constructed from paired **RefSeq GBFF annotations and FASTA sequences**, using GCF-accessioned assemblies. Records at least 98,304 bp long were eligible. Domain-specific sliding windows increase exposure to less abundant groups, particularly fungi and protozoa.
|
| 123 |
-
|
| 124 |
-
CDS intervals are unioned across isoforms independently on each strand. Windows without annotated CDS are retained, exposing the model to both coding and non-coding genomic context.
|
| 125 |
-
|
| 126 |
-
The **[packed-data bucket](https://huggingface.co/buckets/HuggingFaceBio/Carbon-A-training-data)** contains training shards, the original evaluation panel, a PyTorch loader, and reuse instructions. Stored labels are converted to binary with `label != 0`; the loader handles signed values and special-token masking.
|
| 127 |
-
|
| 128 |
-
## Evaluation
|
| 129 |
-
|
| 130 |
-
The inference examples have passed functional checks on an **NVIDIA H100**, using BF16, PyTorch 2.7.1, and Transformers 4.56.0:
|
| 131 |
-
|
| 132 |
-
- Two full-length packed evaluation windows, including strand outputs and thresholded calls.
|
| 133 |
-
- The complete **230,218-base yeast chromosome I** record, processed in four overlapping windows.
|
| 134 |
-
- Identical predictions when the same sequence is supplied as GenBank or FASTA.
|
| 135 |
-
- Independent checks of overlap averaging, coordinates, ambiguous bases, and short records.
|
| 136 |
-
|
| 137 |
-
These checks verify the inference code. **They do not measure agreement with reference CDS annotations.** Details are recorded in [the packed-data check](inference-check.json) and [the GenBank check](genbank-inference-check.json).
|
| 138 |
-
|
| 139 |
-
## Limitations
|
| 140 |
-
|
| 141 |
-
- Predictions represent **CDS occupancy**. Transcript paths, alternative isoforms, and complete gene structures are not resolved.
|
| 142 |
-
- The model is intended for **eukaryotic sequence**. Bacteria and viruses are outside its training scope.
|
| 143 |
-
- The GenBank/FASTA example excludes any 6-mer containing an ambiguous nucleotide, including an incomplete final 6-mer padded with `N`.
|
| 144 |
-
- Shorter records can be processed, but training used full-length 98,304-base windows.
|
| 145 |
-
- The supplied evaluation panel was used during training; it is not a newly held-out test set.
|
| 146 |
-
- Full-length inference on CPU is slow. The file example holds per-base arrays for one record in memory, so large chromosomes need more host RAM.
|
| 147 |
|
| 148 |
## License
|
| 149 |
|
| 150 |
-
|
|
|
|
| 1 |
---
|
| 2 |
library_name: transformers
|
| 3 |
+
license: apache-2.0
|
| 4 |
language:
|
| 5 |
- dna
|
| 6 |
pipeline_tag: token-classification
|
|
|
|
| 17 |
|
| 18 |
A DNA annotation model from the **Carbon** family.
|
| 19 |
|
| 20 |
+
**Carbon-A predicts protein-coding sequence (CDS) at single-base resolution in eukaryotic genomes.** It produces separate forward- and reverse-strand probabilities, preserving coding regions that overlap on opposite strands. The [annotation pipeline](annotation_pipeline/README.md) in this repository turns these probabilities into gene models with a confidence score per gene.
|
| 21 |
|
| 22 |
## Facts
|
| 23 |
|
| 24 |
- **1.2B-parameter DNA annotation model** with two strand-specific classification heads.
|
| 25 |
- **Tokenizer:** non-overlapping 6-mers; each DNA token represents six bases.
|
| 26 |
+
- **Sequence length:** 16,384 tokens (**98,304 bp**). The annotation pipeline reads longer records in windows that overlap by 1,024 tokens (6,144 bp).
|
| 27 |
+
- **Inputs:** genomic DNA. The annotation pipeline reads FASTA and GenBank files, optionally gzip-compressed.
|
| 28 |
+
- **Outputs:** per-base probabilities for non-coding and CDS on each strand. The annotation pipeline turns them into gene models with a confidence score per gene.
|
| 29 |
+
- **Training data:** 2,055 RefSeq GCF assemblies (2,047 species) from mammals, other vertebrates, invertebrates, plants, fungi, and protozoa.
|
| 30 |
+
- **Transformers interface:** `AutoModelForTokenClassification` with `trust_remote_code=True`; Transformers 4.56 or later, including 5.x. FlashAttention-2 needs Transformers 4.x.
|
| 31 |
+
- **Inference:** BF16 with FlashAttention-2, the setting of all reported results. PyTorch SDPA (`--attn-implementation sdpa`) needs no FlashAttention installation, but its probabilities are not bit-identical.
|
| 32 |
|
| 33 |
## How to use
|
| 34 |
|
| 35 |
+
### Install
|
| 36 |
+
|
| 37 |
+
The annotation pipeline runs on Linux. It needs Python 3.10 or later, a CUDA build of PyTorch, and a C++ compiler with OpenMP. If you do not have a suitable compiler, we recommend GCC, for example on Debian or Ubuntu:
|
| 38 |
|
| 39 |
```bash
|
| 40 |
+
sudo apt install build-essential
|
|
|
|
|
|
|
|
|
|
|
|
|
| 41 |
```
|
| 42 |
|
| 43 |
+
Then download this repository and install its requirements:
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 44 |
|
| 45 |
+
```bash
|
| 46 |
+
pip install -U huggingface_hub
|
| 47 |
+
hf download HuggingFaceBio/Carbon-A-1.2B --exclude model.safetensors --local-dir Carbon-A-1.2B
|
| 48 |
+
pip install -r Carbon-A-1.2B/requirements.txt
|
| 49 |
+
pip install --no-build-isolation flash-attn
|
|
|
|
|
|
|
| 50 |
```
|
| 51 |
|
| 52 |
+
The weights (4.6 GB) are downloaded to the Hugging Face cache on first use.
|
|
|
|
|
|
|
| 53 |
|
| 54 |
+
### Annotate a genome
|
| 55 |
|
| 56 |
```bash
|
| 57 |
+
bash Carbon-A-1.2B/annotation_pipeline/run_annotation.sh --input genome.fna.gz
|
|
|
|
|
|
|
|
|
|
|
|
|
| 58 |
```
|
| 59 |
|
| 60 |
+
The input is a FASTA or GenBank file, optionally gzip-compressed. Carbon-A reads it in 98,304-bp windows that overlap by 6,144 bp and averages the overlapping predictions. The pipeline then decodes the gene structures and writes the results to `results/<accession>/`:
|
| 61 |
|
| 62 |
+
- `prediction.gff`: genes, mRNAs, and CDS. Every gene carries `confidence`, the probability that its structure is exactly right.
|
| 63 |
+
- `prediction.fna` and `prediction.faa`: the CDS and protein sequences.
|
| 64 |
+
- `*.raw_probs.parquet`: the per-base CDS probabilities.
|
| 65 |
|
| 66 |
+
All genes are written. The reported results count the genes with a confidence of at least 0.1.
|
|
|
|
|
|
|
|
|
|
| 67 |
|
| 68 |
+
The standard genetic code is used by default. For another code, pass `--codon-table`. *Tetrahymena thermophila* (`GCF_000189635.1`) uses translation table 6 automatically. A likely codon-table mismatch is detected automatically: the pipeline adds a warning to `results/WARNINGS.txt` and renames the result directory to `results/<accession>_WARNING`.
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 69 |
|
| 70 |
+
The [pipeline README](annotation_pipeline/README.md) lists all options. It also describes the evaluation against a reference annotation and BUSCO.
|
| 71 |
|
| 72 |
+
### Test on a benchmark genome
|
| 73 |
|
| 74 |
+
The benchmark genomes and their NCBI annotations are in the [data bucket](https://huggingface.co/buckets/HuggingFaceBio/Carbon-A-training-data) under `eval/`. To test the pipeline on yeast, download its genome and annotation:
|
| 75 |
|
| 76 |
```bash
|
| 77 |
+
url=https://huggingface.co/buckets/HuggingFaceBio/Carbon-A-training-data/resolve/eval/seen/GCF_000146045.2
|
| 78 |
+
mkdir -p GCF_000146045.2
|
| 79 |
+
for file in GCF_000146045.2_R64_genomic.fna genomic.gff cds_from_genomic.fna sequence_report.jsonl; do
|
| 80 |
+
curl -fsSL -o GCF_000146045.2/$file $url/$file
|
| 81 |
+
done
|
| 82 |
```
|
| 83 |
|
| 84 |
+
Annotate the genome and compare the result with the NCBI annotation:
|
| 85 |
|
| 86 |
```bash
|
| 87 |
+
bash Carbon-A-1.2B/annotation_pipeline/run_annotation.sh \
|
| 88 |
+
--input GCF_000146045.2/GCF_000146045.2_R64_genomic.fna
|
| 89 |
+
bash Carbon-A-1.2B/annotation_pipeline/run_evaluation.sh \
|
| 90 |
+
--prediction-prefix results/GCF_000146045.2/prediction \
|
| 91 |
+
--genome-dir GCF_000146045.2
|
| 92 |
```
|
| 93 |
|
| 94 |
+
`results/GCF_000146045.2/evaluation_metrics.json` then reports a nucleotide F1 of 0.998, an exon F1 of 0.943, and a gene F1 of 0.953.
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 95 |
|
| 96 |
## License
|
| 97 |
|
| 98 |
+
Apache 2.0.
|
annotation_pipeline/README.md
ADDED
|
@@ -0,0 +1,258 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
# Carbon-A annotation pipeline
|
| 2 |
+
|
| 3 |
+
This directory annotates protein-coding genes in eukaryotic genomes with
|
| 4 |
+
Carbon-A: it runs the model over a genome and turns its per-nucleotide coding
|
| 5 |
+
probabilities into gene annotations. It also contains reference-based
|
| 6 |
+
evaluation and BUSCO evaluation.
|
| 7 |
+
|
| 8 |
+
## Pipeline
|
| 9 |
+
|
| 10 |
+
Annotation runs four stages for each genome:
|
| 11 |
+
|
| 12 |
+
1. **Genome-scale inference.** Carbon-A assigns every nucleotide a
|
| 13 |
+
strand-specific coding probability (`src/model_inference.py`).
|
| 14 |
+
2. **Transition network.** A small temporal convolutional network reads each
|
| 15 |
+
strand's coding-probability track and predicts, at every boundary between
|
| 16 |
+
adjacent nucleotides, a distribution over remaining in the current segment,
|
| 17 |
+
entering a gene, leaving a gene, entering an intron, and leaving an intron
|
| 18 |
+
(`src/transition_network.py`).
|
| 19 |
+
3. **Viterbi algorithm.** The coding probabilities, the transition probabilities,
|
| 20 |
+
and the gene grammar are combined into the highest-scoring labeling of each
|
| 21 |
+
strand into intergenic, exon, and intron bases. A gene opens at a start codon
|
| 22 |
+
and closes at the first in-frame stop codon of the selected genetic code, an
|
| 23 |
+
intron opens at a donor site and closes at an acceptor site of a permitted
|
| 24 |
+
splice class (GT-AG, GC-AG, AT-AC), and the reading frame continues across
|
| 25 |
+
each intron. Genes truncated by a record edge are kept and marked incomplete
|
| 26 |
+
(`src/viterbi.py`, native Viterbi backends in `src/viterbi_backends/`).
|
| 27 |
+
4. **Confidence network.** For every complete gene, a second network reads the
|
| 28 |
+
coding probabilities together with the decoded roles of the whole strand,
|
| 29 |
+
neighbouring genes included, and returns the probability that the gene
|
| 30 |
+
structure is exactly right (`src/confidence_network.py`).
|
| 31 |
+
|
| 32 |
+
The trained weights of both networks are in `weights/`. `src/post_processing.py`
|
| 33 |
+
drives the three post-processing stages and writes the outputs; the module
|
| 34 |
+
docstring of `src/viterbi.py` documents the 30 states, the transition
|
| 35 |
+
rules, and where each transition probability enters the Viterbi score.
|
| 36 |
+
|
| 37 |
+
## Layout
|
| 38 |
+
|
| 39 |
+
- `run_annotation.sh` runs model inference and post-processing for one genome
|
| 40 |
+
file.
|
| 41 |
+
- `run_evaluation.sh` compares one annotation with an NCBI reference annotation.
|
| 42 |
+
- `run_busco.sh` runs BUSCO on the predicted proteins of one annotation.
|
| 43 |
+
- `src/` contains the Python implementation and the CPU/GPU Viterbi backend sources.
|
| 44 |
+
- `src/constants.py` centralizes cross-module data contracts and pipeline
|
| 45 |
+
parameters.
|
| 46 |
+
- `weights/` contains the transition network and confidence network weights.
|
| 47 |
+
- `codon_tables/` contains the standard and accession-specific translation tables.
|
| 48 |
+
|
| 49 |
+
## Installation
|
| 50 |
+
|
| 51 |
+
The pipeline supports Linux and Python 3.10 or later. Download this repository
|
| 52 |
+
without the model weights, install a PyTorch build compatible with the local
|
| 53 |
+
CUDA environment, then install the requirements of the repository and
|
| 54 |
+
FlashAttention:
|
| 55 |
+
|
| 56 |
+
```bash
|
| 57 |
+
pip install -U "huggingface_hub>=0.34"
|
| 58 |
+
hf download HuggingFaceBio/Carbon-A-1.2B --exclude model.safetensors --local-dir Carbon-A-1.2B
|
| 59 |
+
pip install -r Carbon-A-1.2B/requirements.txt
|
| 60 |
+
pip install --no-build-isolation flash-attn
|
| 61 |
+
```
|
| 62 |
+
|
| 63 |
+
Model inference loads Carbon-A with `from_pretrained`, which downloads the
|
| 64 |
+
weights (4.6 GB) to the Hugging Face cache on first use. It runs in bfloat16
|
| 65 |
+
with FlashAttention-2, the setting of all reported results. The requirements
|
| 66 |
+
keep Transformers below version 5, which does not load the model with
|
| 67 |
+
FlashAttention-2.
|
| 68 |
+
`--attn-implementation sdpa` runs without the `flash-attn` package, but its
|
| 69 |
+
probabilities are not bit-identical with those of FlashAttention-2.
|
| 70 |
+
|
| 71 |
+
Two optional command-line tools are not installed by `requirements.txt`:
|
| 72 |
+
|
| 73 |
+
- The NCBI Datasets CLI (`datasets`) downloads genomes and reference
|
| 74 |
+
annotations from NCBI.
|
| 75 |
+
- The BUSCO CLI (`busco`) is required only by `run_busco.sh`.
|
| 76 |
+
|
| 77 |
+
The two networks run with PyTorch on the GPU when one is available, otherwise on
|
| 78 |
+
the CPU. The GPU Viterbi backend requires a CUDA toolkit; the CPU backend
|
| 79 |
+
requires a C++17 compiler with OpenMP support. Both are built on first use. Use
|
| 80 |
+
`--viterbi-backend numba` when neither native backend can be built.
|
| 81 |
+
|
| 82 |
+
## Annotation
|
| 83 |
+
|
| 84 |
+
The input is a genome FASTA or GenBank file, optionally gzip-compressed. Run the
|
| 85 |
+
scripts with `bash`, because `hf download` does not keep their executable bit.
|
| 86 |
+
For example, for the yeast reference genome from NCBI:
|
| 87 |
+
|
| 88 |
+
```bash
|
| 89 |
+
datasets download genome accession GCF_000146045.2 --include genome,gff3,cds,seq-report
|
| 90 |
+
unzip ncbi_dataset.zip
|
| 91 |
+
bash Carbon-A-1.2B/annotation_pipeline/run_annotation.sh \
|
| 92 |
+
--input ncbi_dataset/data/GCF_000146045.2/GCF_000146045.2_R64_genomic.fna
|
| 93 |
+
```
|
| 94 |
+
|
| 95 |
+
The results are written below the current directory:
|
| 96 |
+
|
| 97 |
+
```text
|
| 98 |
+
results/<accession>/
|
| 99 |
+
*.raw_probs.parquet
|
| 100 |
+
prediction.gff
|
| 101 |
+
prediction.fna
|
| 102 |
+
prediction.faa
|
| 103 |
+
```
|
| 104 |
+
|
| 105 |
+
The accession is `--accession` when given, otherwise the NCBI assembly accession
|
| 106 |
+
that begins the file name (`GCF_`/`GCA_`), otherwise the file name without its
|
| 107 |
+
extensions; `--output-dir` selects another directory. Model inference uses every
|
| 108 |
+
visible GPU unless `--gpu-count` is given. `--model` selects another model, a
|
| 109 |
+
Hub id or a local directory (default: `CARBON_A_MODEL`, otherwise
|
| 110 |
+
`HuggingFaceBio/Carbon-A-1.2B`), and `--revision` a branch, tag, or commit of a
|
| 111 |
+
model on the Hub (default: the latest). `run_annotation.sh --help` lists every
|
| 112 |
+
option.
|
| 113 |
+
|
| 114 |
+
When the codon-table check described below fails, the post-processing itself
|
| 115 |
+
appends the warning to `results/WARNINGS.txt` (time, accession, message with
|
| 116 |
+
the stop codons used) and renames the directory to
|
| 117 |
+
`results/<accession>_WARNING`, whether it runs through the script or on its
|
| 118 |
+
own, so one file and the directory names show every genome whose table is in
|
| 119 |
+
doubt. Rerunning the accession with the right `--codon-table` (usually with
|
| 120 |
+
`--skip-inference`) continues in the renamed directory, renames it back once
|
| 121 |
+
the check passes, and records that in `results/WARNINGS.txt` too.
|
| 122 |
+
|
| 123 |
+
Every gene row of `prediction.gff` and every FASTA header carries
|
| 124 |
+
`confidence=<score>`, the confidence network's probability that the gene
|
| 125 |
+
structure is exactly right, or `confidence=NA` for genes truncated by a record
|
| 126 |
+
edge. All genes are written; the gene-level results count the genes whose
|
| 127 |
+
confidence reaches 0.1 (see Reference evaluation).
|
| 128 |
+
|
| 129 |
+
`run_annotation.sh` does not run reference evaluation or BUSCO. By default,
|
| 130 |
+
post-processing excludes records shorter than 100,000 bp and spliced CDS
|
| 131 |
+
models shorter than 96 bp. Set `--min-record-length 0` or `--min-cds-length 0`
|
| 132 |
+
to disable the corresponding filter. Use `--skip-inference` to rerun only
|
| 133 |
+
post-processing from an existing raw-probability Parquet file.
|
| 134 |
+
|
| 135 |
+
Post-processing options: `--transition-network FILE` and
|
| 136 |
+
`--confidence-network FILE` select other weight files and `--device DEVICE`
|
| 137 |
+
picks the torch device for the two networks (default: `cuda` when available).
|
| 138 |
+
On CUDA the networks run under bfloat16 autocast, the precision used for all
|
| 139 |
+
reported results; on CPU they run in float32. `--viterbi-backend` selects the
|
| 140 |
+
Viterbi backend. `src/post_processing.py` can also be run directly on a
|
| 141 |
+
raw-probability Parquet file.
|
| 142 |
+
|
| 143 |
+
`GCF_000189635.1` (Tetrahymena thermophila) automatically uses NCBI translation
|
| 144 |
+
table 6. Other accessions use the standard translation table unless an
|
| 145 |
+
accession-specific table is present in `codon_tables/` or supplied with
|
| 146 |
+
`--codon-table`. After scoring, the post-processing checks the codon table with
|
| 147 |
+
the confidences themselves: a table that does not match the genome splits genes
|
| 148 |
+
at codons the genome reads through or fuses genes at stop codons it lacks, and
|
| 149 |
+
the confidence network gives such genes almost no confidence. When at least 100
|
| 150 |
+
complete genes were scored and fewer than half of them reach confidence 0.1,
|
| 151 |
+
it warns that the table probably does not match. With the shipped network, 17
|
| 152 |
+
genomes decoded with their own table keep 67% (hagfish) to 98% of their
|
| 153 |
+
complete genes at or above 0.1, Tetrahymena decoded with the standard table
|
| 154 |
+
keeps 1.4%, and yeast decoded with translation table 6 keeps 36%. A table that
|
| 155 |
+
only drops a rarely used stop codon escapes the check (Dictyostelium decoded
|
| 156 |
+
without TGA keeps 94%). The warning goes to stderr, to `WARNINGS.txt` in the
|
| 157 |
+
parent of the output directory, and into the output directory's name, as
|
| 158 |
+
described above. The summary line reports the number of complete genes at or
|
| 159 |
+
above 0.1 and the median confidence.
|
| 160 |
+
|
| 161 |
+
## Reference evaluation
|
| 162 |
+
|
| 163 |
+
Compare an annotation with the NCBI reference annotation of the same assembly:
|
| 164 |
+
|
| 165 |
+
```bash
|
| 166 |
+
bash Carbon-A-1.2B/annotation_pipeline/run_evaluation.sh \
|
| 167 |
+
--prediction-prefix results/GCF_000146045.2/prediction \
|
| 168 |
+
--genome-dir ncbi_dataset/data/GCF_000146045.2
|
| 169 |
+
```
|
| 170 |
+
|
| 171 |
+
The reference directory provides `genomic.gff` and `cds_from_genomic.fna`. When
|
| 172 |
+
it also provides `sequence_report.jsonl`, evaluation prefers assembled
|
| 173 |
+
chromosomes, then assembled linkage groups, then chromosome scaffolds; without
|
| 174 |
+
the report every record is evaluated. The metrics are written beside the
|
| 175 |
+
predictions, to `results/<accession>/evaluation_metrics.json`, unless
|
| 176 |
+
`--output-json` is given.
|
| 177 |
+
|
| 178 |
+
The benchmark genomes are also available in this layout, with their NCBI
|
| 179 |
+
reference annotations, under `eval/` in the
|
| 180 |
+
[data bucket](https://huggingface.co/buckets/HuggingFaceBio/Carbon-A-training-data);
|
| 181 |
+
the model card shows how to test the pipeline on one of them.
|
| 182 |
+
|
| 183 |
+
Gene and protein metrics count the predicted genes whose confidence reaches
|
| 184 |
+
`--confidence-threshold` (default `0.1`). Genes with `confidence=NA` (genes
|
| 185 |
+
truncated by a record edge) are always counted, and `0` counts every predicted
|
| 186 |
+
gene. Exon, boundary, and nucleotide metrics describe the whole annotation.
|
| 187 |
+
`prediction.*` is never modified. The translation table is
|
| 188 |
+
`codon_tables/<accession>.json` when present, where the accession is the name
|
| 189 |
+
of the reference directory, otherwise the standard table; `--codon-table`
|
| 190 |
+
overrides it.
|
| 191 |
+
|
| 192 |
+
The reported levels are defined as follows:
|
| 193 |
+
|
| 194 |
+
- Nucleotide metrics measure CDS base overlap.
|
| 195 |
+
- Exon metrics require exact merged CDS intervals from the selected gene model.
|
| 196 |
+
- Gene metrics require a one-to-one exact CDS sequence match on the same genomic
|
| 197 |
+
record.
|
| 198 |
+
- Protein metrics retain all exact gene matches, then translate unmatched
|
| 199 |
+
predicted and reference CDS sequences with the same translation table and add
|
| 200 |
+
one-to-one exact protein matches on the same record. This allows synonymous
|
| 201 |
+
CDS differences to match while preserving confirmed CDS matches with partial
|
| 202 |
+
frame annotation differences. NCBI `protein.faa` is not used.
|
| 203 |
+
|
| 204 |
+
`confidence_mean` is the mean confidence of the counted genes.
|
| 205 |
+
`confidence_exact_match_auroc` and `confidence_exact_match_auprc` treat each
|
| 206 |
+
counted gene as one sample and its CDS-level exact-match status as the binary
|
| 207 |
+
label. Tied scores are processed at the same cutoff. AUPRC is
|
| 208 |
+
non-interpolated average precision. Both values are `NA` when only one label
|
| 209 |
+
class is present.
|
| 210 |
+
|
| 211 |
+
## BUSCO evaluation
|
| 212 |
+
|
| 213 |
+
BUSCO must be installed separately and available as `busco`, or supplied with
|
| 214 |
+
`--busco-executable`. It runs offline, so download the lineage dataset first:
|
| 215 |
+
|
| 216 |
+
```bash
|
| 217 |
+
busco --download saccharomycetaceae_odb12.2
|
| 218 |
+
bash Carbon-A-1.2B/annotation_pipeline/run_busco.sh \
|
| 219 |
+
--faa results/GCF_000146045.2/prediction.faa \
|
| 220 |
+
--lineage-path busco_downloads/lineages/saccharomycetaceae_odb12.2
|
| 221 |
+
```
|
| 222 |
+
|
| 223 |
+
The lineage directory must keep the dataset's name: the script takes the
|
| 224 |
+
lineage name from it and checks it against BUSCO's summary. BUSCO runs in
|
| 225 |
+
protein mode on the proteins in `prediction.faa`. Its working directory is
|
| 226 |
+
temporary; only this compact output is retained, beside the proteins unless
|
| 227 |
+
`--output-json` is given:
|
| 228 |
+
|
| 229 |
+
```text
|
| 230 |
+
results/<accession>/busco_metrics.json
|
| 231 |
+
```
|
| 232 |
+
|
| 233 |
+
The accession recorded with the metrics is `--accession`, otherwise the name of
|
| 234 |
+
the directory containing the proteins without a `_WARNING` suffix. `--workers`
|
| 235 |
+
is passed to BUSCO as its CPU count (default 8). An existing
|
| 236 |
+
`busco_metrics.json` is kept unless `--force` is supplied.
|
| 237 |
+
|
| 238 |
+
The BUSCO JSON records the lineage, its total number of marker genes, and the
|
| 239 |
+
Complete, Single-copy, Duplicated, Fragmented, and Missing percentages. The
|
| 240 |
+
marker count gives important context: scores from a small lineage dataset are
|
| 241 |
+
more sensitive to the state of each individual marker.
|
| 242 |
+
|
| 243 |
+
## Results layout
|
| 244 |
+
|
| 245 |
+
After all stages, each genome uses this layout:
|
| 246 |
+
|
| 247 |
+
```text
|
| 248 |
+
results/<accession>/
|
| 249 |
+
*.raw_probs.parquet
|
| 250 |
+
prediction.gff
|
| 251 |
+
prediction.fna
|
| 252 |
+
prediction.faa
|
| 253 |
+
evaluation_metrics.json
|
| 254 |
+
busco_metrics.json
|
| 255 |
+
```
|
| 256 |
+
|
| 257 |
+
Both JSON files record the accession, the generation time, and their values
|
| 258 |
+
under `metrics`.
|
annotation_pipeline/codon_tables/GCF_000189635.1.json
ADDED
|
@@ -0,0 +1,18 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
{
|
| 2 |
+
"AAA": "K", "AAT": "N", "AAC": "N", "AAG": "K",
|
| 3 |
+
"ATA": "I", "ATT": "I", "ATC": "I", "ATG": "M",
|
| 4 |
+
"ACA": "T", "ACT": "T", "ACC": "T", "ACG": "T",
|
| 5 |
+
"AGA": "R", "AGT": "S", "AGC": "S", "AGG": "R",
|
| 6 |
+
"TAA": "Q", "TAT": "Y", "TAC": "Y", "TAG": "Q",
|
| 7 |
+
"TTA": "L", "TTT": "F", "TTC": "F", "TTG": "L",
|
| 8 |
+
"TCA": "S", "TCT": "S", "TCC": "S", "TCG": "S",
|
| 9 |
+
"TGA": "*", "TGT": "C", "TGC": "C", "TGG": "W",
|
| 10 |
+
"CAA": "Q", "CAT": "H", "CAC": "H", "CAG": "Q",
|
| 11 |
+
"CTA": "L", "CTT": "L", "CTC": "L", "CTG": "L",
|
| 12 |
+
"CCA": "P", "CCT": "P", "CCC": "P", "CCG": "P",
|
| 13 |
+
"CGA": "R", "CGT": "R", "CGC": "R", "CGG": "R",
|
| 14 |
+
"GAA": "E", "GAT": "D", "GAC": "D", "GAG": "E",
|
| 15 |
+
"GTA": "V", "GTT": "V", "GTC": "V", "GTG": "V",
|
| 16 |
+
"GCA": "A", "GCT": "A", "GCC": "A", "GCG": "A",
|
| 17 |
+
"GGA": "G", "GGT": "G", "GGC": "G", "GGG": "G"
|
| 18 |
+
}
|
annotation_pipeline/codon_tables/standard.json
ADDED
|
@@ -0,0 +1,18 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
{
|
| 2 |
+
"AAA": "K", "AAT": "N", "AAC": "N", "AAG": "K",
|
| 3 |
+
"ATA": "I", "ATT": "I", "ATC": "I", "ATG": "M",
|
| 4 |
+
"ACA": "T", "ACT": "T", "ACC": "T", "ACG": "T",
|
| 5 |
+
"AGA": "R", "AGT": "S", "AGC": "S", "AGG": "R",
|
| 6 |
+
"TAA": "*", "TAT": "Y", "TAC": "Y", "TAG": "*",
|
| 7 |
+
"TTA": "L", "TTT": "F", "TTC": "F", "TTG": "L",
|
| 8 |
+
"TCA": "S", "TCT": "S", "TCC": "S", "TCG": "S",
|
| 9 |
+
"TGA": "*", "TGT": "C", "TGC": "C", "TGG": "W",
|
| 10 |
+
"CAA": "Q", "CAT": "H", "CAC": "H", "CAG": "Q",
|
| 11 |
+
"CTA": "L", "CTT": "L", "CTC": "L", "CTG": "L",
|
| 12 |
+
"CCA": "P", "CCT": "P", "CCC": "P", "CCG": "P",
|
| 13 |
+
"CGA": "R", "CGT": "R", "CGC": "R", "CGG": "R",
|
| 14 |
+
"GAA": "E", "GAT": "D", "GAC": "D", "GAG": "E",
|
| 15 |
+
"GTA": "V", "GTT": "V", "GTC": "V", "GTG": "V",
|
| 16 |
+
"GCA": "A", "GCT": "A", "GCC": "A", "GCG": "A",
|
| 17 |
+
"GGA": "G", "GGT": "G", "GGC": "G", "GGG": "G"
|
| 18 |
+
}
|
annotation_pipeline/run_annotation.sh
ADDED
|
@@ -0,0 +1,340 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
#!/usr/bin/env bash
|
| 2 |
+
# Run model inference and post-processing for one genome file.
|
| 3 |
+
set -euo pipefail
|
| 4 |
+
shopt -s nullglob
|
| 5 |
+
|
| 6 |
+
ROOT_DIR="$(cd -- "$(dirname -- "${BASH_SOURCE[0]}")" && pwd)"
|
| 7 |
+
# Results go below the current directory, never into the pipeline directory.
|
| 8 |
+
RESULTS_DIR="results"
|
| 9 |
+
CODON_TABLE_DIR="$ROOT_DIR/codon_tables"
|
| 10 |
+
# post_processing.py renames results/<accession> to results/<accession>_WARNING
|
| 11 |
+
# and appends the warning to results/WARNINGS.txt when the codon-table check
|
| 12 |
+
# fails (constants.WARNING_DIRECTORY_SUFFIX).
|
| 13 |
+
WARNING_SUFFIX="_WARNING"
|
| 14 |
+
|
| 15 |
+
INPUT=""
|
| 16 |
+
ACCESSION=""
|
| 17 |
+
MODEL="${CARBON_A_MODEL:-}"
|
| 18 |
+
REVISION=""
|
| 19 |
+
OUTPUT_DIR=""
|
| 20 |
+
CODON_TABLE=""
|
| 21 |
+
VITERBI_BACKEND="auto"
|
| 22 |
+
|
| 23 |
+
GPU_COUNT="-1"
|
| 24 |
+
BATCH_SIZE="8"
|
| 25 |
+
DISPATCH_CHUNK_SIZE="4"
|
| 26 |
+
ATTN_IMPLEMENTATION="flash_attention_2"
|
| 27 |
+
SKIP_INFERENCE=0
|
| 28 |
+
|
| 29 |
+
TRANSITION_NETWORK=""
|
| 30 |
+
CONFIDENCE_NETWORK=""
|
| 31 |
+
DEVICE=""
|
| 32 |
+
MIN_RECORD_LENGTH="100000"
|
| 33 |
+
MIN_CDS_LENGTH="96"
|
| 34 |
+
MAX_BOUNDARY_INTRON_LENGTH="20000"
|
| 35 |
+
CHUNK_MBP="500"
|
| 36 |
+
|
| 37 |
+
PREDICTION_PREFIX=""
|
| 38 |
+
RAW_PROBS=""
|
| 39 |
+
|
| 40 |
+
usage() {
|
| 41 |
+
local status="${1:-2}"
|
| 42 |
+
cat >&2 <<EOF
|
| 43 |
+
Usage:
|
| 44 |
+
$(basename "$0") --input FILE [options]
|
| 45 |
+
|
| 46 |
+
Input and output:
|
| 47 |
+
--input FILE Genome FASTA or GenBank file, optionally gzip-compressed.
|
| 48 |
+
--accession ACCESSION Result identifier. Default: the NCBI assembly accession
|
| 49 |
+
that starts the file name (GCF_/GCA_), otherwise the
|
| 50 |
+
file name without its extension.
|
| 51 |
+
--model MODEL Model id on the Hugging Face Hub or local model directory.
|
| 52 |
+
Default: CARBON_A_MODEL, otherwise HuggingFaceBio/Carbon-A-1.2B.
|
| 53 |
+
--revision REVISION Model revision on the Hub (branch, tag, or commit);
|
| 54 |
+
default: the latest.
|
| 55 |
+
--output-dir DIR Default: results/<accession> in the current directory, or
|
| 56 |
+
results/<accession>_WARNING when a failed codon-table
|
| 57 |
+
check renamed it and only that exists.
|
| 58 |
+
--codon-table FILE Default: accession-specific table, then standard.
|
| 59 |
+
|
| 60 |
+
Inference:
|
| 61 |
+
--gpu-count N GPUs to use; -1 selects all available GPUs (default: -1).
|
| 62 |
+
--batch-size N Inference batch size (default: 8).
|
| 63 |
+
--dispatch-chunk-size N Per-GPU dispatch chunk size (default: 4).
|
| 64 |
+
--attn-implementation NAME flash_attention_2 (default; used for all reported results)
|
| 65 |
+
or sdpa (needs no flash-attn package; not bit-identical).
|
| 66 |
+
--skip-inference Reuse the newest *.raw_probs.parquet in --output-dir.
|
| 67 |
+
|
| 68 |
+
Post-processing:
|
| 69 |
+
--viterbi-backend NAME Viterbi backend: auto, gpu, cpu, or numba (default: auto).
|
| 70 |
+
--transition-network FILE Transition network weights
|
| 71 |
+
(default: weights/transition_network.safetensors).
|
| 72 |
+
--confidence-network FILE Confidence network weights
|
| 73 |
+
(default: weights/confidence_network.safetensors).
|
| 74 |
+
--device DEVICE Torch device for the two networks (default: cuda if available).
|
| 75 |
+
--min-record-length N Minimum FASTA record length (default: 100000).
|
| 76 |
+
--min-cds-length N Minimum spliced CDS length (default: 96).
|
| 77 |
+
--max-boundary-intron-length N
|
| 78 |
+
Maximum boundary intron length (default: 20000).
|
| 79 |
+
--chunk-mbp N Post-processing chunk size in Mbp (default: 500).
|
| 80 |
+
|
| 81 |
+
-h, --help Show this help message.
|
| 82 |
+
EOF
|
| 83 |
+
exit "$status"
|
| 84 |
+
}
|
| 85 |
+
|
| 86 |
+
die() {
|
| 87 |
+
printf 'Error: %s\n' "$*" >&2
|
| 88 |
+
exit 1
|
| 89 |
+
}
|
| 90 |
+
|
| 91 |
+
require_value() {
|
| 92 |
+
local option="$1"
|
| 93 |
+
local argument_count="$2"
|
| 94 |
+
(( argument_count >= 2 )) || die "$option requires a value"
|
| 95 |
+
}
|
| 96 |
+
|
| 97 |
+
parse_args() {
|
| 98 |
+
while (($#)); do
|
| 99 |
+
case "$1" in
|
| 100 |
+
--input)
|
| 101 |
+
require_value "$1" "$#"
|
| 102 |
+
INPUT="$2"
|
| 103 |
+
shift 2
|
| 104 |
+
;;
|
| 105 |
+
--accession)
|
| 106 |
+
require_value "$1" "$#"
|
| 107 |
+
ACCESSION="$2"
|
| 108 |
+
shift 2
|
| 109 |
+
;;
|
| 110 |
+
--model|--model_name)
|
| 111 |
+
require_value "$1" "$#"
|
| 112 |
+
MODEL="$2"
|
| 113 |
+
shift 2
|
| 114 |
+
;;
|
| 115 |
+
--output-dir|--output_path)
|
| 116 |
+
require_value "$1" "$#"
|
| 117 |
+
OUTPUT_DIR="$2"
|
| 118 |
+
shift 2
|
| 119 |
+
;;
|
| 120 |
+
--codon-table|--codon_table)
|
| 121 |
+
require_value "$1" "$#"
|
| 122 |
+
CODON_TABLE="$2"
|
| 123 |
+
shift 2
|
| 124 |
+
;;
|
| 125 |
+
--viterbi-backend|--viterbi_backend)
|
| 126 |
+
require_value "$1" "$#"
|
| 127 |
+
VITERBI_BACKEND="$2"
|
| 128 |
+
shift 2
|
| 129 |
+
;;
|
| 130 |
+
--transition-network|--transition_network)
|
| 131 |
+
require_value "$1" "$#"
|
| 132 |
+
TRANSITION_NETWORK="$2"
|
| 133 |
+
shift 2
|
| 134 |
+
;;
|
| 135 |
+
--confidence-network|--confidence_network)
|
| 136 |
+
require_value "$1" "$#"
|
| 137 |
+
CONFIDENCE_NETWORK="$2"
|
| 138 |
+
shift 2
|
| 139 |
+
;;
|
| 140 |
+
--device)
|
| 141 |
+
require_value "$1" "$#"
|
| 142 |
+
DEVICE="$2"
|
| 143 |
+
shift 2
|
| 144 |
+
;;
|
| 145 |
+
--min-record-length|--min_record_length)
|
| 146 |
+
require_value "$1" "$#"
|
| 147 |
+
MIN_RECORD_LENGTH="$2"
|
| 148 |
+
shift 2
|
| 149 |
+
;;
|
| 150 |
+
--min-cds-length|--min_cds_length)
|
| 151 |
+
require_value "$1" "$#"
|
| 152 |
+
MIN_CDS_LENGTH="$2"
|
| 153 |
+
shift 2
|
| 154 |
+
;;
|
| 155 |
+
--max-boundary-intron-length|--max_boundary_intron_length)
|
| 156 |
+
require_value "$1" "$#"
|
| 157 |
+
MAX_BOUNDARY_INTRON_LENGTH="$2"
|
| 158 |
+
shift 2
|
| 159 |
+
;;
|
| 160 |
+
--chunk-mbp|--chunk_mbp)
|
| 161 |
+
require_value "$1" "$#"
|
| 162 |
+
CHUNK_MBP="$2"
|
| 163 |
+
shift 2
|
| 164 |
+
;;
|
| 165 |
+
--batch-size|--batch_size)
|
| 166 |
+
require_value "$1" "$#"
|
| 167 |
+
BATCH_SIZE="$2"
|
| 168 |
+
shift 2
|
| 169 |
+
;;
|
| 170 |
+
--dispatch-chunk-size|--dispatch_chunk_size)
|
| 171 |
+
require_value "$1" "$#"
|
| 172 |
+
DISPATCH_CHUNK_SIZE="$2"
|
| 173 |
+
shift 2
|
| 174 |
+
;;
|
| 175 |
+
--gpu-count|--gpu_count)
|
| 176 |
+
require_value "$1" "$#"
|
| 177 |
+
GPU_COUNT="$2"
|
| 178 |
+
shift 2
|
| 179 |
+
;;
|
| 180 |
+
--revision)
|
| 181 |
+
require_value "$1" "$#"
|
| 182 |
+
REVISION="$2"
|
| 183 |
+
shift 2
|
| 184 |
+
;;
|
| 185 |
+
--attn-implementation|--attn_implementation)
|
| 186 |
+
require_value "$1" "$#"
|
| 187 |
+
ATTN_IMPLEMENTATION="$2"
|
| 188 |
+
shift 2
|
| 189 |
+
;;
|
| 190 |
+
--skip-inference)
|
| 191 |
+
SKIP_INFERENCE=1
|
| 192 |
+
shift
|
| 193 |
+
;;
|
| 194 |
+
-h|--help)
|
| 195 |
+
usage 0
|
| 196 |
+
;;
|
| 197 |
+
*)
|
| 198 |
+
printf 'Unknown argument: %s\n\n' "$1" >&2
|
| 199 |
+
usage 2
|
| 200 |
+
;;
|
| 201 |
+
esac
|
| 202 |
+
done
|
| 203 |
+
}
|
| 204 |
+
|
| 205 |
+
accession_from_input() {
|
| 206 |
+
local filename
|
| 207 |
+
filename="$(basename -- "$INPUT")"
|
| 208 |
+
|
| 209 |
+
# NCBI genome files start with the assembly accession, as in
|
| 210 |
+
# GCF_000146045.2_R64_genomic.fna.
|
| 211 |
+
if [[ "$filename" =~ ^(GC[AF]_[0-9]+\.[0-9]+)_ ]]; then
|
| 212 |
+
printf '%s\n' "${BASH_REMATCH[1]}"
|
| 213 |
+
return
|
| 214 |
+
fi
|
| 215 |
+
|
| 216 |
+
filename="${filename%.gz}"
|
| 217 |
+
case "$filename" in
|
| 218 |
+
*.fasta) printf '%s\n' "${filename%.fasta}" ;;
|
| 219 |
+
*.fna) printf '%s\n' "${filename%.fna}" ;;
|
| 220 |
+
*.fa) printf '%s\n' "${filename%.fa}" ;;
|
| 221 |
+
*.gbff) printf '%s\n' "${filename%.gbff}" ;;
|
| 222 |
+
*.gbk) printf '%s\n' "${filename%.gbk}" ;;
|
| 223 |
+
*.gb) printf '%s\n' "${filename%.gb}" ;;
|
| 224 |
+
*) printf '%s\n' "$filename" ;;
|
| 225 |
+
esac
|
| 226 |
+
}
|
| 227 |
+
|
| 228 |
+
resolve_input() {
|
| 229 |
+
[[ -f "$INPUT" ]] || die "input file does not exist: $INPUT"
|
| 230 |
+
[[ -n "$ACCESSION" ]] || ACCESSION="$(accession_from_input)"
|
| 231 |
+
}
|
| 232 |
+
|
| 233 |
+
resolve_paths() {
|
| 234 |
+
[[ "$ACCESSION" =~ ^[A-Za-z0-9][A-Za-z0-9._-]*$ ]] || \
|
| 235 |
+
die "invalid accession: $ACCESSION"
|
| 236 |
+
|
| 237 |
+
if [[ -z "$OUTPUT_DIR" ]]; then
|
| 238 |
+
OUTPUT_DIR="$RESULTS_DIR/$ACCESSION"
|
| 239 |
+
# A rerun (typically --skip-inference with the right --codon-table)
|
| 240 |
+
# continues in the directory a failed codon-table check renamed.
|
| 241 |
+
if [[ ! -d "$OUTPUT_DIR" && -d "$OUTPUT_DIR$WARNING_SUFFIX" ]]; then
|
| 242 |
+
OUTPUT_DIR="$OUTPUT_DIR$WARNING_SUFFIX"
|
| 243 |
+
fi
|
| 244 |
+
fi
|
| 245 |
+
PREDICTION_PREFIX="$OUTPUT_DIR/prediction"
|
| 246 |
+
|
| 247 |
+
if [[ -z "$CODON_TABLE" ]]; then
|
| 248 |
+
if [[ -f "$CODON_TABLE_DIR/$ACCESSION.json" ]]; then
|
| 249 |
+
CODON_TABLE="$CODON_TABLE_DIR/$ACCESSION.json"
|
| 250 |
+
else
|
| 251 |
+
CODON_TABLE="$CODON_TABLE_DIR/standard.json"
|
| 252 |
+
fi
|
| 253 |
+
fi
|
| 254 |
+
|
| 255 |
+
[[ -f "$CODON_TABLE" ]] || die "codon table does not exist: $CODON_TABLE"
|
| 256 |
+
|
| 257 |
+
mkdir -p -- "$OUTPUT_DIR"
|
| 258 |
+
}
|
| 259 |
+
|
| 260 |
+
run_inference() {
|
| 261 |
+
(( SKIP_INFERENCE )) && return
|
| 262 |
+
|
| 263 |
+
local inference_args=(
|
| 264 |
+
--input "$INPUT"
|
| 265 |
+
--output_path "$OUTPUT_DIR"
|
| 266 |
+
--gpu_count "$GPU_COUNT"
|
| 267 |
+
--batch_size "$BATCH_SIZE"
|
| 268 |
+
--dispatch_chunk_size "$DISPATCH_CHUNK_SIZE"
|
| 269 |
+
--attn_implementation "$ATTN_IMPLEMENTATION"
|
| 270 |
+
)
|
| 271 |
+
[[ -n "$MODEL" ]] && inference_args+=(--model_name "$MODEL")
|
| 272 |
+
[[ -n "$REVISION" ]] && inference_args+=(--revision "$REVISION")
|
| 273 |
+
|
| 274 |
+
echo "[annotation] inference: $ACCESSION"
|
| 275 |
+
python "$ROOT_DIR/src/model_inference.py" "${inference_args[@]}"
|
| 276 |
+
}
|
| 277 |
+
|
| 278 |
+
select_raw_probs() {
|
| 279 |
+
local candidates=("$OUTPUT_DIR"/*.raw_probs.parquet)
|
| 280 |
+
(( ${#candidates[@]} > 0 )) || \
|
| 281 |
+
die "no raw-probability Parquet found in $OUTPUT_DIR"
|
| 282 |
+
|
| 283 |
+
RAW_PROBS="${candidates[0]}"
|
| 284 |
+
local candidate
|
| 285 |
+
for candidate in "${candidates[@]:1}"; do
|
| 286 |
+
[[ "$candidate" -nt "$RAW_PROBS" ]] && RAW_PROBS="$candidate"
|
| 287 |
+
done
|
| 288 |
+
|
| 289 |
+
if (( ${#candidates[@]} > 1 )); then
|
| 290 |
+
echo "[annotation] found ${#candidates[@]} probability files; using $(basename -- "$RAW_PROBS")"
|
| 291 |
+
fi
|
| 292 |
+
}
|
| 293 |
+
|
| 294 |
+
run_postprocessing() {
|
| 295 |
+
local postprocess_args=(
|
| 296 |
+
--input "$RAW_PROBS"
|
| 297 |
+
--output "$PREDICTION_PREFIX"
|
| 298 |
+
--viterbi_backend "$VITERBI_BACKEND"
|
| 299 |
+
--codon_table "$CODON_TABLE"
|
| 300 |
+
--max_boundary_intron_length "$MAX_BOUNDARY_INTRON_LENGTH"
|
| 301 |
+
--chunk_mbp "$CHUNK_MBP"
|
| 302 |
+
--min_record_length "$MIN_RECORD_LENGTH"
|
| 303 |
+
--min_cds_length "$MIN_CDS_LENGTH"
|
| 304 |
+
)
|
| 305 |
+
[[ -n "$TRANSITION_NETWORK" ]] && postprocess_args+=(--transition_network "$TRANSITION_NETWORK")
|
| 306 |
+
[[ -n "$CONFIDENCE_NETWORK" ]] && postprocess_args+=(--confidence_network "$CONFIDENCE_NETWORK")
|
| 307 |
+
[[ -n "$DEVICE" ]] && postprocess_args+=(--device "$DEVICE")
|
| 308 |
+
|
| 309 |
+
echo "[annotation] post-processing: $ACCESSION"
|
| 310 |
+
python "$ROOT_DIR/src/post_processing.py" "${postprocess_args[@]}"
|
| 311 |
+
}
|
| 312 |
+
|
| 313 |
+
# post_processing.py records a failed codon-table check itself: it appends the
|
| 314 |
+
# warning to WARNINGS.txt beside the result directories and renames the output
|
| 315 |
+
# directory with the warning suffix (and back when a later run passes), so the
|
| 316 |
+
# final report has to look the directory up again.
|
| 317 |
+
locate_output_dir() {
|
| 318 |
+
local clean_dir="${OUTPUT_DIR%"$WARNING_SUFFIX"}"
|
| 319 |
+
if [[ -d "$clean_dir" ]]; then
|
| 320 |
+
OUTPUT_DIR="$clean_dir"
|
| 321 |
+
elif [[ -d "$clean_dir$WARNING_SUFFIX" ]]; then
|
| 322 |
+
OUTPUT_DIR="$clean_dir$WARNING_SUFFIX"
|
| 323 |
+
fi
|
| 324 |
+
}
|
| 325 |
+
|
| 326 |
+
main() {
|
| 327 |
+
parse_args "$@"
|
| 328 |
+
[[ -n "$INPUT" ]] || usage 2
|
| 329 |
+
|
| 330 |
+
resolve_input
|
| 331 |
+
resolve_paths
|
| 332 |
+
run_inference
|
| 333 |
+
select_raw_probs
|
| 334 |
+
run_postprocessing
|
| 335 |
+
locate_output_dir
|
| 336 |
+
|
| 337 |
+
echo "[annotation] complete: $OUTPUT_DIR"
|
| 338 |
+
}
|
| 339 |
+
|
| 340 |
+
main "$@"
|
annotation_pipeline/run_busco.sh
ADDED
|
@@ -0,0 +1,147 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
#!/usr/bin/env bash
|
| 2 |
+
# Run BUSCO on the predicted proteins of one annotation and retain only busco_metrics.json.
|
| 3 |
+
set -euo pipefail
|
| 4 |
+
|
| 5 |
+
ROOT_DIR="$(cd -- "$(dirname -- "${BASH_SOURCE[0]}")" && pwd)"
|
| 6 |
+
# A failed codon-table check renames results/<accession> with this suffix
|
| 7 |
+
# (constants.WARNING_DIRECTORY_SUFFIX); the default accession drops it.
|
| 8 |
+
WARNING_SUFFIX="_WARNING"
|
| 9 |
+
|
| 10 |
+
BUSCO_EXECUTABLE="${BUSCO_EXECUTABLE:-busco}"
|
| 11 |
+
|
| 12 |
+
FAA=""
|
| 13 |
+
LINEAGE_PATH=""
|
| 14 |
+
ACCESSION=""
|
| 15 |
+
OUTPUT_JSON=""
|
| 16 |
+
FORCE=0
|
| 17 |
+
WORKERS="8"
|
| 18 |
+
TEMPORARY_DIRECTORY=""
|
| 19 |
+
|
| 20 |
+
usage() {
|
| 21 |
+
local status="${1:-2}"
|
| 22 |
+
cat >&2 <<EOF
|
| 23 |
+
Usage:
|
| 24 |
+
$(basename "$0") --faa FILE --lineage-path DIR [options]
|
| 25 |
+
|
| 26 |
+
Input and output:
|
| 27 |
+
--faa FILE Predicted proteins, for example
|
| 28 |
+
results/<accession>/prediction.faa.
|
| 29 |
+
--lineage-path DIR Local BUSCO lineage dataset, for example
|
| 30 |
+
busco_downloads/lineages/saccharomycetes_odb12.
|
| 31 |
+
--accession ACCESSION Name recorded in the metrics (default: the
|
| 32 |
+
directory containing --faa).
|
| 33 |
+
--output-json FILE Default: busco_metrics.json beside --faa.
|
| 34 |
+
--force Replace an existing output JSON.
|
| 35 |
+
|
| 36 |
+
BUSCO configuration:
|
| 37 |
+
--busco-executable FILE Default: BUSCO_EXECUTABLE or busco from PATH.
|
| 38 |
+
--workers N CPU workers passed to BUSCO (default: 8).
|
| 39 |
+
--temporary-directory DIR Parent directory for temporary BUSCO files.
|
| 40 |
+
|
| 41 |
+
-h, --help Show this help message.
|
| 42 |
+
EOF
|
| 43 |
+
exit "$status"
|
| 44 |
+
}
|
| 45 |
+
|
| 46 |
+
die() {
|
| 47 |
+
printf 'Error: %s\n' "$*" >&2
|
| 48 |
+
exit 1
|
| 49 |
+
}
|
| 50 |
+
|
| 51 |
+
require_value() {
|
| 52 |
+
local option="$1"
|
| 53 |
+
local argument_count="$2"
|
| 54 |
+
(( argument_count >= 2 )) || die "$option requires a value"
|
| 55 |
+
}
|
| 56 |
+
|
| 57 |
+
parse_args() {
|
| 58 |
+
while (($#)); do
|
| 59 |
+
case "$1" in
|
| 60 |
+
--faa)
|
| 61 |
+
require_value "$1" "$#"
|
| 62 |
+
FAA="$2"
|
| 63 |
+
shift 2
|
| 64 |
+
;;
|
| 65 |
+
--lineage-path)
|
| 66 |
+
require_value "$1" "$#"
|
| 67 |
+
LINEAGE_PATH="$2"
|
| 68 |
+
shift 2
|
| 69 |
+
;;
|
| 70 |
+
--accession)
|
| 71 |
+
require_value "$1" "$#"
|
| 72 |
+
ACCESSION="$2"
|
| 73 |
+
shift 2
|
| 74 |
+
;;
|
| 75 |
+
--output-json)
|
| 76 |
+
require_value "$1" "$#"
|
| 77 |
+
OUTPUT_JSON="$2"
|
| 78 |
+
shift 2
|
| 79 |
+
;;
|
| 80 |
+
--busco-executable)
|
| 81 |
+
require_value "$1" "$#"
|
| 82 |
+
BUSCO_EXECUTABLE="$2"
|
| 83 |
+
shift 2
|
| 84 |
+
;;
|
| 85 |
+
--workers)
|
| 86 |
+
require_value "$1" "$#"
|
| 87 |
+
WORKERS="$2"
|
| 88 |
+
shift 2
|
| 89 |
+
;;
|
| 90 |
+
--temporary-directory)
|
| 91 |
+
require_value "$1" "$#"
|
| 92 |
+
TEMPORARY_DIRECTORY="$2"
|
| 93 |
+
shift 2
|
| 94 |
+
;;
|
| 95 |
+
--force)
|
| 96 |
+
FORCE=1
|
| 97 |
+
shift
|
| 98 |
+
;;
|
| 99 |
+
-h|--help)
|
| 100 |
+
usage 0
|
| 101 |
+
;;
|
| 102 |
+
*)
|
| 103 |
+
printf 'Unknown argument: %s\n\n' "$1" >&2
|
| 104 |
+
usage 2
|
| 105 |
+
;;
|
| 106 |
+
esac
|
| 107 |
+
done
|
| 108 |
+
}
|
| 109 |
+
|
| 110 |
+
main() {
|
| 111 |
+
parse_args "$@"
|
| 112 |
+
[[ -n "$FAA" && -n "$LINEAGE_PATH" ]] || usage 2
|
| 113 |
+
[[ "$WORKERS" =~ ^[1-9][0-9]*$ ]] || die "--workers must be a positive integer"
|
| 114 |
+
[[ -s "$FAA" ]] || die "missing protein FASTA: $FAA"
|
| 115 |
+
[[ -d "$LINEAGE_PATH" ]] || die "lineage directory does not exist: $LINEAGE_PATH"
|
| 116 |
+
|
| 117 |
+
if [[ -z "$ACCESSION" ]]; then
|
| 118 |
+
ACCESSION="$(basename -- "$(dirname -- "$FAA")")"
|
| 119 |
+
ACCESSION="${ACCESSION%"$WARNING_SUFFIX"}"
|
| 120 |
+
fi
|
| 121 |
+
local lineage_name
|
| 122 |
+
lineage_name="$(basename -- "$LINEAGE_PATH")"
|
| 123 |
+
local output_json="${OUTPUT_JSON:-$(dirname -- "$FAA")/busco_metrics.json}"
|
| 124 |
+
|
| 125 |
+
if [[ -s "$output_json" ]] && (( ! FORCE )); then
|
| 126 |
+
echo "[busco] skipped $ACCESSION ($output_json exists; use --force)"
|
| 127 |
+
return 0
|
| 128 |
+
fi
|
| 129 |
+
|
| 130 |
+
local busco_args=(
|
| 131 |
+
--accession "$ACCESSION"
|
| 132 |
+
--faa "$FAA"
|
| 133 |
+
--lineage "$lineage_name"
|
| 134 |
+
--lineage-path "$LINEAGE_PATH"
|
| 135 |
+
--busco-executable "$BUSCO_EXECUTABLE"
|
| 136 |
+
--workers "$WORKERS"
|
| 137 |
+
--output-json "$output_json"
|
| 138 |
+
)
|
| 139 |
+
if [[ -n "$TEMPORARY_DIRECTORY" ]]; then
|
| 140 |
+
busco_args+=(--temporary-directory "$TEMPORARY_DIRECTORY")
|
| 141 |
+
fi
|
| 142 |
+
|
| 143 |
+
echo "[busco] running $ACCESSION ($lineage_name)"
|
| 144 |
+
python "$ROOT_DIR/src/busco_evaluation.py" "${busco_args[@]}"
|
| 145 |
+
}
|
| 146 |
+
|
| 147 |
+
main "$@"
|
annotation_pipeline/run_evaluation.sh
ADDED
|
@@ -0,0 +1,113 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
#!/usr/bin/env bash
|
| 2 |
+
# Write the reference metrics of one annotation to evaluation_metrics.json.
|
| 3 |
+
set -euo pipefail
|
| 4 |
+
|
| 5 |
+
ROOT_DIR="$(cd -- "$(dirname -- "${BASH_SOURCE[0]}")" && pwd)"
|
| 6 |
+
|
| 7 |
+
PREDICTION_PREFIX=""
|
| 8 |
+
GENOME_DIR=""
|
| 9 |
+
OUTPUT_JSON=""
|
| 10 |
+
CODON_TABLE=""
|
| 11 |
+
CONFIDENCE_THRESHOLD="0.1"
|
| 12 |
+
|
| 13 |
+
usage() {
|
| 14 |
+
local status="${1:-2}"
|
| 15 |
+
cat >&2 <<EOF
|
| 16 |
+
Usage:
|
| 17 |
+
$(basename "$0") --prediction-prefix PREFIX --genome-dir DIR [options]
|
| 18 |
+
|
| 19 |
+
Input and output:
|
| 20 |
+
--prediction-prefix PREFIX Prediction files without .gff/.fna suffixes,
|
| 21 |
+
for example results/<accession>/prediction.
|
| 22 |
+
--genome-dir DIR NCBI genome directory containing the reference
|
| 23 |
+
genomic.gff and cds_from_genomic.fna; an optional
|
| 24 |
+
sequence_report.jsonl enables nuclear-record selection.
|
| 25 |
+
--output-json FILE Default: evaluation_metrics.json beside the predictions.
|
| 26 |
+
--codon-table FILE Translation table JSON (default: the
|
| 27 |
+
accession-specific table, then standard).
|
| 28 |
+
|
| 29 |
+
Metrics:
|
| 30 |
+
--confidence-threshold VALUE Gene-level confidence threshold (default: 0.1;
|
| 31 |
+
0 counts every predicted gene).
|
| 32 |
+
|
| 33 |
+
-h, --help Show this help message.
|
| 34 |
+
EOF
|
| 35 |
+
exit "$status"
|
| 36 |
+
}
|
| 37 |
+
|
| 38 |
+
die() {
|
| 39 |
+
printf 'Error: %s\n' "$*" >&2
|
| 40 |
+
exit 1
|
| 41 |
+
}
|
| 42 |
+
|
| 43 |
+
require_value() {
|
| 44 |
+
local option="$1"
|
| 45 |
+
local argument_count="$2"
|
| 46 |
+
(( argument_count >= 2 )) || die "$option requires a value"
|
| 47 |
+
}
|
| 48 |
+
|
| 49 |
+
parse_args() {
|
| 50 |
+
while (($#)); do
|
| 51 |
+
case "$1" in
|
| 52 |
+
--prediction-prefix|--pred)
|
| 53 |
+
require_value "$1" "$#"
|
| 54 |
+
PREDICTION_PREFIX="$2"
|
| 55 |
+
shift 2
|
| 56 |
+
;;
|
| 57 |
+
--genome-dir|--genome_dir)
|
| 58 |
+
require_value "$1" "$#"
|
| 59 |
+
GENOME_DIR="$2"
|
| 60 |
+
shift 2
|
| 61 |
+
;;
|
| 62 |
+
--output-json)
|
| 63 |
+
require_value "$1" "$#"
|
| 64 |
+
OUTPUT_JSON="$2"
|
| 65 |
+
shift 2
|
| 66 |
+
;;
|
| 67 |
+
--codon-table|--codon_table)
|
| 68 |
+
require_value "$1" "$#"
|
| 69 |
+
CODON_TABLE="$2"
|
| 70 |
+
shift 2
|
| 71 |
+
;;
|
| 72 |
+
--confidence-threshold)
|
| 73 |
+
require_value "$1" "$#"
|
| 74 |
+
CONFIDENCE_THRESHOLD="$2"
|
| 75 |
+
shift 2
|
| 76 |
+
;;
|
| 77 |
+
-h|--help)
|
| 78 |
+
usage 0
|
| 79 |
+
;;
|
| 80 |
+
*)
|
| 81 |
+
printf 'Unknown argument: %s\n\n' "$1" >&2
|
| 82 |
+
usage 2
|
| 83 |
+
;;
|
| 84 |
+
esac
|
| 85 |
+
done
|
| 86 |
+
}
|
| 87 |
+
|
| 88 |
+
main() {
|
| 89 |
+
parse_args "$@"
|
| 90 |
+
[[ -n "$PREDICTION_PREFIX" && -n "$GENOME_DIR" ]] || usage 2
|
| 91 |
+
|
| 92 |
+
[[ -s "$PREDICTION_PREFIX.gff" && -s "$PREDICTION_PREFIX.fna" ]] || \
|
| 93 |
+
die "missing prediction GFF/FNA: $PREDICTION_PREFIX.gff, $PREDICTION_PREFIX.fna"
|
| 94 |
+
[[ -s "$GENOME_DIR/genomic.gff" && -s "$GENOME_DIR/cds_from_genomic.fna" ]] || \
|
| 95 |
+
die "missing reference genomic.gff or cds_from_genomic.fna in $GENOME_DIR"
|
| 96 |
+
|
| 97 |
+
local output_json="${OUTPUT_JSON:-$(dirname -- "$PREDICTION_PREFIX")/evaluation_metrics.json}"
|
| 98 |
+
local evaluation_args=(
|
| 99 |
+
--pred "$PREDICTION_PREFIX"
|
| 100 |
+
--genome_dir "$GENOME_DIR"
|
| 101 |
+
--confidence-threshold "$CONFIDENCE_THRESHOLD"
|
| 102 |
+
--output-json "$output_json"
|
| 103 |
+
)
|
| 104 |
+
if [[ -n "$CODON_TABLE" ]]; then
|
| 105 |
+
[[ -f "$CODON_TABLE" ]] || die "codon table does not exist: $CODON_TABLE"
|
| 106 |
+
evaluation_args+=(--codon-table "$CODON_TABLE")
|
| 107 |
+
fi
|
| 108 |
+
|
| 109 |
+
echo "[evaluation] running $PREDICTION_PREFIX"
|
| 110 |
+
python "$ROOT_DIR/src/evaluation.py" "${evaluation_args[@]}"
|
| 111 |
+
}
|
| 112 |
+
|
| 113 |
+
main "$@"
|
annotation_pipeline/src/busco_evaluation.py
ADDED
|
@@ -0,0 +1,212 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""Run BUSCO on the predicted proteins and retain only summary JSON."""
|
| 2 |
+
|
| 3 |
+
import argparse
|
| 4 |
+
from datetime import datetime, timezone
|
| 5 |
+
import json
|
| 6 |
+
from pathlib import Path
|
| 7 |
+
import shutil
|
| 8 |
+
import subprocess
|
| 9 |
+
import tempfile
|
| 10 |
+
|
| 11 |
+
from evaluation import iter_fasta_records
|
| 12 |
+
|
| 13 |
+
|
| 14 |
+
BUSCO_RESULT_FIELDS = {
|
| 15 |
+
"busco_complete_percent": "Complete percentage",
|
| 16 |
+
"busco_complete_count": "Complete BUSCOs",
|
| 17 |
+
"busco_single_copy_percent": "Single copy percentage",
|
| 18 |
+
"busco_single_copy_count": "Single copy BUSCOs",
|
| 19 |
+
"busco_duplicated_percent": "Multi copy percentage",
|
| 20 |
+
"busco_duplicated_count": "Multi copy BUSCOs",
|
| 21 |
+
"busco_fragmented_percent": "Fragmented percentage",
|
| 22 |
+
"busco_fragmented_count": "Fragmented BUSCOs",
|
| 23 |
+
"busco_missing_percent": "Missing percentage",
|
| 24 |
+
"busco_missing_count": "Missing BUSCOs",
|
| 25 |
+
}
|
| 26 |
+
|
| 27 |
+
|
| 28 |
+
def parse_busco_summary(path, lineage):
|
| 29 |
+
"""Extract stable key metrics from a BUSCO short_summary.json file."""
|
| 30 |
+
with Path(path).open(encoding="utf-8") as handle:
|
| 31 |
+
summary = json.load(handle)
|
| 32 |
+
results = summary["results"]
|
| 33 |
+
lineage_data = summary["lineage_dataset"]
|
| 34 |
+
versions = summary["versions"]
|
| 35 |
+
if lineage_data.get("name") != lineage:
|
| 36 |
+
raise ValueError(
|
| 37 |
+
f"BUSCO summary lineage is {lineage_data.get('name')!r}, "
|
| 38 |
+
f"expected {lineage!r}"
|
| 39 |
+
)
|
| 40 |
+
metrics = {
|
| 41 |
+
"busco_lineage": lineage,
|
| 42 |
+
"busco_marker_count": int(results["n_markers"]),
|
| 43 |
+
"busco_version": str(versions["busco"]),
|
| 44 |
+
}
|
| 45 |
+
for output_name, summary_name in BUSCO_RESULT_FIELDS.items():
|
| 46 |
+
metrics[output_name] = results[summary_name]
|
| 47 |
+
lineage_markers = int(lineage_data["number_of_buscos"])
|
| 48 |
+
if metrics["busco_marker_count"] != lineage_markers:
|
| 49 |
+
raise ValueError(
|
| 50 |
+
f"BUSCO marker count mismatch: results={metrics['busco_marker_count']}, "
|
| 51 |
+
f"lineage={lineage_markers}"
|
| 52 |
+
)
|
| 53 |
+
return metrics
|
| 54 |
+
|
| 55 |
+
|
| 56 |
+
def run_busco_command(
|
| 57 |
+
executable,
|
| 58 |
+
input_faa,
|
| 59 |
+
lineage_path,
|
| 60 |
+
lineage,
|
| 61 |
+
output_directory,
|
| 62 |
+
workers,
|
| 63 |
+
):
|
| 64 |
+
"""Run BUSCO once and return parsed summary metrics."""
|
| 65 |
+
command = [
|
| 66 |
+
str(executable),
|
| 67 |
+
"-i",
|
| 68 |
+
str(input_faa),
|
| 69 |
+
"-l",
|
| 70 |
+
str(lineage_path),
|
| 71 |
+
"-o",
|
| 72 |
+
"busco",
|
| 73 |
+
"--out_path",
|
| 74 |
+
str(output_directory),
|
| 75 |
+
"-m",
|
| 76 |
+
"proteins",
|
| 77 |
+
"--offline",
|
| 78 |
+
"--opt-out-run-stats",
|
| 79 |
+
"-c",
|
| 80 |
+
str(workers),
|
| 81 |
+
]
|
| 82 |
+
log_path = output_directory.parent / f"{output_directory.name}.log"
|
| 83 |
+
with log_path.open("w", encoding="utf-8") as log_handle:
|
| 84 |
+
completed = subprocess.run(
|
| 85 |
+
command,
|
| 86 |
+
stdout=log_handle,
|
| 87 |
+
stderr=subprocess.STDOUT,
|
| 88 |
+
check=False,
|
| 89 |
+
)
|
| 90 |
+
if completed.returncode != 0:
|
| 91 |
+
log_lines = log_path.read_text(encoding="utf-8", errors="replace").splitlines()
|
| 92 |
+
log_tail = "\n".join(log_lines[-40:])
|
| 93 |
+
raise RuntimeError(
|
| 94 |
+
f"BUSCO failed with exit code {completed.returncode}:\n{log_tail}"
|
| 95 |
+
)
|
| 96 |
+
|
| 97 |
+
summary_path = (
|
| 98 |
+
output_directory
|
| 99 |
+
/ "busco"
|
| 100 |
+
/ f"run_{lineage}"
|
| 101 |
+
/ "short_summary.json"
|
| 102 |
+
)
|
| 103 |
+
if not summary_path.is_file():
|
| 104 |
+
raise RuntimeError(f"BUSCO did not create expected summary: {summary_path}")
|
| 105 |
+
return parse_busco_summary(summary_path, lineage)
|
| 106 |
+
|
| 107 |
+
|
| 108 |
+
def write_json_atomic(path, payload):
|
| 109 |
+
output_path = Path(path)
|
| 110 |
+
output_path.parent.mkdir(parents=True, exist_ok=True)
|
| 111 |
+
temporary_path = output_path.with_name(output_path.name + ".tmp")
|
| 112 |
+
with temporary_path.open("w", encoding="utf-8") as handle:
|
| 113 |
+
json.dump(payload, handle, indent=2, sort_keys=True, allow_nan=False)
|
| 114 |
+
handle.write("\n")
|
| 115 |
+
temporary_path.replace(output_path)
|
| 116 |
+
|
| 117 |
+
|
| 118 |
+
def count_proteins(path):
|
| 119 |
+
"""Return the number of protein records, rejecting duplicate gene IDs."""
|
| 120 |
+
gene_ids = set()
|
| 121 |
+
for header, _ in iter_fasta_records(path):
|
| 122 |
+
gene_id = header.split(None, 1)[0]
|
| 123 |
+
if gene_id in gene_ids:
|
| 124 |
+
raise ValueError(f"duplicate protein FASTA gene ID {gene_id!r}")
|
| 125 |
+
gene_ids.add(gene_id)
|
| 126 |
+
if not gene_ids:
|
| 127 |
+
raise ValueError(f"{path} contains no proteins")
|
| 128 |
+
return len(gene_ids)
|
| 129 |
+
|
| 130 |
+
|
| 131 |
+
def run_busco(
|
| 132 |
+
accession,
|
| 133 |
+
prediction_faa,
|
| 134 |
+
lineage,
|
| 135 |
+
lineage_path,
|
| 136 |
+
busco_executable,
|
| 137 |
+
workers,
|
| 138 |
+
output_json,
|
| 139 |
+
temporary_directory=None,
|
| 140 |
+
):
|
| 141 |
+
"""Run BUSCO on the predicted proteins and write one summary JSON."""
|
| 142 |
+
protein_count = count_proteins(prediction_faa)
|
| 143 |
+
with tempfile.TemporaryDirectory(
|
| 144 |
+
prefix=f"busco_{accession}_",
|
| 145 |
+
dir=temporary_directory,
|
| 146 |
+
) as work_directory:
|
| 147 |
+
metrics = run_busco_command(
|
| 148 |
+
busco_executable,
|
| 149 |
+
prediction_faa,
|
| 150 |
+
lineage_path,
|
| 151 |
+
lineage,
|
| 152 |
+
Path(work_directory) / "run",
|
| 153 |
+
workers,
|
| 154 |
+
)
|
| 155 |
+
metrics["predicted_proteins"] = protein_count
|
| 156 |
+
print(
|
| 157 |
+
f"[busco] {accession} lineage={lineage} proteins={protein_count} "
|
| 158 |
+
f"complete={metrics['busco_complete_percent']:.1f}%",
|
| 159 |
+
flush=True,
|
| 160 |
+
)
|
| 161 |
+
|
| 162 |
+
payload = {
|
| 163 |
+
"accession": accession,
|
| 164 |
+
"generated_at": datetime.now(timezone.utc).isoformat(),
|
| 165 |
+
"lineage": lineage,
|
| 166 |
+
"metrics": metrics,
|
| 167 |
+
}
|
| 168 |
+
write_json_atomic(output_json, payload)
|
| 169 |
+
print(f"[busco] wrote {output_json}")
|
| 170 |
+
return payload
|
| 171 |
+
|
| 172 |
+
|
| 173 |
+
def parse_args():
|
| 174 |
+
parser = argparse.ArgumentParser(description=__doc__)
|
| 175 |
+
parser.add_argument("--accession", required=True)
|
| 176 |
+
parser.add_argument("--faa", required=True, type=Path)
|
| 177 |
+
parser.add_argument("--lineage", required=True)
|
| 178 |
+
parser.add_argument("--lineage-path", required=True, type=Path)
|
| 179 |
+
parser.add_argument("--busco-executable", default="busco")
|
| 180 |
+
parser.add_argument("--workers", type=int, default=8)
|
| 181 |
+
parser.add_argument("--output-json", required=True, type=Path)
|
| 182 |
+
parser.add_argument("--temporary-directory", type=Path)
|
| 183 |
+
return parser.parse_args()
|
| 184 |
+
|
| 185 |
+
|
| 186 |
+
def main():
|
| 187 |
+
args = parse_args()
|
| 188 |
+
if args.workers < 1:
|
| 189 |
+
raise SystemExit("--workers must be at least 1")
|
| 190 |
+
if shutil.which(args.busco_executable) is None:
|
| 191 |
+
raise SystemExit(f"BUSCO executable not found: {args.busco_executable}")
|
| 192 |
+
if not args.faa.is_file():
|
| 193 |
+
raise SystemExit(f"prediction FAA not found: {args.faa}")
|
| 194 |
+
if not args.lineage_path.is_dir():
|
| 195 |
+
raise SystemExit(f"lineage directory not found: {args.lineage_path}")
|
| 196 |
+
if args.temporary_directory is not None:
|
| 197 |
+
args.temporary_directory.mkdir(parents=True, exist_ok=True)
|
| 198 |
+
|
| 199 |
+
return run_busco(
|
| 200 |
+
accession=args.accession,
|
| 201 |
+
prediction_faa=args.faa,
|
| 202 |
+
lineage=args.lineage,
|
| 203 |
+
lineage_path=args.lineage_path,
|
| 204 |
+
busco_executable=args.busco_executable,
|
| 205 |
+
workers=args.workers,
|
| 206 |
+
output_json=args.output_json,
|
| 207 |
+
temporary_directory=args.temporary_directory,
|
| 208 |
+
)
|
| 209 |
+
|
| 210 |
+
|
| 211 |
+
if __name__ == "__main__":
|
| 212 |
+
main()
|
annotation_pipeline/src/confidence_network.py
ADDED
|
@@ -0,0 +1,232 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""Confidence network: one confidence score per decoded gene.
|
| 2 |
+
|
| 3 |
+
The confidence network estimates, for every complete gene reconstructed by
|
| 4 |
+
the Viterbi algorithm, the probability that its structure is exactly right.
|
| 5 |
+
It reads the whole strand the gene lies on: the coding-probability track, and
|
| 6 |
+
the role the Viterbi algorithm gave every base, exon, intron, or intergenic,
|
| 7 |
+
neighbouring genes included. The two are combined into the log-odds that each
|
| 8 |
+
base's role is right (the coding log-odds at exon bases, the non-coding
|
| 9 |
+
log-odds elsewhere) plus the one-hot roles; a TCN with the same architecture
|
| 10 |
+
as the transition network's (tcn.py) turns these four tracks into one feature
|
| 11 |
+
vector per base. For each scored gene, the feature vectors at its exon bases
|
| 12 |
+
are pooled by two attention heads and an MLP maps the pooled vectors to the
|
| 13 |
+
confidence logit; the sigmoid of the logit is the confidence.
|
| 14 |
+
|
| 15 |
+
``inference`` does this for a strand of any length without holding the whole
|
| 16 |
+
strand on the device: a feature vector depends only on the strand within the
|
| 17 |
+
network's context radius of its base, so only the windows of that radius
|
| 18 |
+
around the scored exons are read, concatenated and processed in chunks the
|
| 19 |
+
way the transition network reads a record (tcn.chunked_inference). Only
|
| 20 |
+
complete genes are scored; a gene truncated by a record edge has no complete
|
| 21 |
+
structure to judge, but its decoded bases still give roles to the strand for
|
| 22 |
+
its neighbours.
|
| 23 |
+
"""
|
| 24 |
+
|
| 25 |
+
import numpy as np
|
| 26 |
+
import torch
|
| 27 |
+
from torch import nn
|
| 28 |
+
|
| 29 |
+
from constants import NETWORK_CHUNK_BP
|
| 30 |
+
from tcn import TCN, chunked_inference, coding_log_odds, load_weights
|
| 31 |
+
|
| 32 |
+
CONFIDENCE_NETWORK_TYPE = "confidence_network"
|
| 33 |
+
|
| 34 |
+
# Roles of the bases in the decoded structure, in channel order.
|
| 35 |
+
ROLE_NAMES = ("exon", "intron", "intergenic")
|
| 36 |
+
ROLE_EXON, ROLE_INTRON, ROLE_INTERGENIC = range(len(ROLE_NAMES))
|
| 37 |
+
# Input tracks in channel order: the log-odds that the decoded role is right,
|
| 38 |
+
# then one indicator track per role.
|
| 39 |
+
INPUT_NAMES = ("role_log_odds",) + ROLE_NAMES
|
| 40 |
+
|
| 41 |
+
|
| 42 |
+
def input_tracks(probabilities, roles):
|
| 43 |
+
"""Return the ``[batch, inputs, bases]`` network input from coding probabilities and roles.
|
| 44 |
+
|
| 45 |
+
The first track is the log-odds that each base's decoded role is right:
|
| 46 |
+
the coding log-odds at exon bases and their negative, the non-coding
|
| 47 |
+
log-odds, at intron and intergenic bases. The remaining tracks are the
|
| 48 |
+
one-hot roles.
|
| 49 |
+
"""
|
| 50 |
+
if probabilities.ndim != 2 or roles.shape != probabilities.shape:
|
| 51 |
+
raise ValueError("probabilities and roles must both have shape [batch, bases]")
|
| 52 |
+
log_odds = coding_log_odds(probabilities)
|
| 53 |
+
role_tracks = nn.functional.one_hot(roles, num_classes=len(ROLE_NAMES)).to(log_odds.dtype)
|
| 54 |
+
sign = role_tracks[..., ROLE_EXON] * 2 - 1
|
| 55 |
+
return torch.cat(((log_odds * sign)[:, None, :], role_tracks.transpose(1, 2)), dim=1)
|
| 56 |
+
|
| 57 |
+
|
| 58 |
+
class ConfidenceNetwork(nn.Module):
|
| 59 |
+
"""Score decoded genes from the strand's coding probabilities and decoded roles."""
|
| 60 |
+
|
| 61 |
+
def __init__(
|
| 62 |
+
self, channels=32, dilation_count=8,
|
| 63 |
+
attention_heads=2, attention_channels=32, readout_channels=32,
|
| 64 |
+
):
|
| 65 |
+
super().__init__()
|
| 66 |
+
self.attention_heads = int(attention_heads)
|
| 67 |
+
self.encoder = TCN(len(INPUT_NAMES), channels, dilation_count)
|
| 68 |
+
self.context_radius = self.encoder.context_radius
|
| 69 |
+
# One attention logit per head and exon base, from that base's features.
|
| 70 |
+
self.attention = nn.Sequential(
|
| 71 |
+
nn.Linear(self.encoder.channels, attention_channels, bias=False),
|
| 72 |
+
nn.GELU(),
|
| 73 |
+
nn.Linear(attention_channels, self.attention_heads, bias=False),
|
| 74 |
+
)
|
| 75 |
+
# The pooled vectors of all heads, concatenated, give the confidence logit.
|
| 76 |
+
self.readout = nn.Sequential(
|
| 77 |
+
nn.Linear(self.attention_heads * self.encoder.channels, readout_channels),
|
| 78 |
+
nn.GELU(),
|
| 79 |
+
nn.Linear(readout_channels, 1),
|
| 80 |
+
)
|
| 81 |
+
|
| 82 |
+
def encode(self, probabilities, roles):
|
| 83 |
+
"""Return the ``[bases, channels]`` features of a strand, or a chunk of one, given as ``[bases]`` tensors."""
|
| 84 |
+
return self.encoder(input_tracks(probabilities[None, :], roles[None, :]))[0]
|
| 85 |
+
|
| 86 |
+
def gene_logits(self, exon_features):
|
| 87 |
+
"""Return one confidence logit per gene from the features at its exon bases.
|
| 88 |
+
|
| 89 |
+
``exon_features`` holds one ``[exon_bases, channels]`` tensor per gene.
|
| 90 |
+
Each head gives every exon base an attention logit; the softmax over
|
| 91 |
+
the bases of one gene weights their feature vectors into one pooled
|
| 92 |
+
vector per head. Genes are pooled side by side with segment sums, so
|
| 93 |
+
no gene is padded to the length of another.
|
| 94 |
+
"""
|
| 95 |
+
exon_features = tuple(exon_features)
|
| 96 |
+
if not exon_features:
|
| 97 |
+
raise ValueError("at least one gene is required")
|
| 98 |
+
for features in exon_features:
|
| 99 |
+
if features.ndim != 2 or not len(features):
|
| 100 |
+
raise ValueError("a gene must contain at least one exon base")
|
| 101 |
+
|
| 102 |
+
lengths = torch.as_tensor(
|
| 103 |
+
[len(features) for features in exon_features],
|
| 104 |
+
dtype=torch.long, device=exon_features[0].device,
|
| 105 |
+
)
|
| 106 |
+
features = torch.cat(exon_features)
|
| 107 |
+
# The gene every base belongs to, repeated for every head.
|
| 108 |
+
gene_of_base = torch.repeat_interleave(torch.arange(len(lengths), device=lengths.device), lengths)
|
| 109 |
+
gene_of_base_per_head = gene_of_base[:, None].expand(-1, self.attention_heads)
|
| 110 |
+
|
| 111 |
+
# Softmax within each gene: subtract the gene's maximum logit, exponentiate,
|
| 112 |
+
# divide by the gene's sum.
|
| 113 |
+
attention_logits = self.attention(features)
|
| 114 |
+
maximum = features.new_full((len(lengths), self.attention_heads), -torch.inf)
|
| 115 |
+
maximum.scatter_reduce_(0, gene_of_base_per_head, attention_logits, reduce="amax", include_self=True)
|
| 116 |
+
attention = torch.exp(attention_logits - maximum[gene_of_base])
|
| 117 |
+
normalization = features.new_zeros(len(lengths), self.attention_heads)
|
| 118 |
+
normalization.scatter_add_(0, gene_of_base_per_head, attention)
|
| 119 |
+
attention = attention / normalization[gene_of_base]
|
| 120 |
+
|
| 121 |
+
# Attention-weighted sum of the feature vectors of each gene, per head.
|
| 122 |
+
pooled = features.new_zeros(len(lengths), self.attention_heads, features.shape[1])
|
| 123 |
+
for head in range(self.attention_heads):
|
| 124 |
+
pooled[:, head].index_add_(0, gene_of_base, attention[:, head, None] * features)
|
| 125 |
+
return self.readout(pooled.flatten(1)).squeeze(1)
|
| 126 |
+
|
| 127 |
+
@torch.inference_mode()
|
| 128 |
+
def inference(self, probabilities, exons_by_gene, scored, chunk_bp=NETWORK_CHUNK_BP):
|
| 129 |
+
"""Return the confidence of each scored gene of one strand.
|
| 130 |
+
|
| 131 |
+
``probabilities`` is the strand's coding-probability track in decoder
|
| 132 |
+
orientation; ``exons_by_gene`` holds the inclusive exon intervals, in
|
| 133 |
+
transcript order, of every gene decoded on the strand; ``scored`` lists
|
| 134 |
+
the positions in ``exons_by_gene`` of the complete genes to score.
|
| 135 |
+
Every decoded gene gives roles to the strand. The confidences come back
|
| 136 |
+
as a float64 array in the order of ``scored``.
|
| 137 |
+
"""
|
| 138 |
+
scored = list(scored)
|
| 139 |
+
if not scored:
|
| 140 |
+
return np.zeros(0, dtype=np.float64)
|
| 141 |
+
device = next(self.parameters()).device
|
| 142 |
+
probabilities = np.asarray(probabilities)
|
| 143 |
+
roles = strand_roles(exons_by_gene, len(probabilities))
|
| 144 |
+
scored_exons = [exons_by_gene[index] for index in scored]
|
| 145 |
+
|
| 146 |
+
# 1. Keep only the bases a scored exon can see: the window of one
|
| 147 |
+
# context radius around every scored exon, merged, in strand order.
|
| 148 |
+
windows = exon_windows(scored_exons, len(probabilities), self.context_radius)
|
| 149 |
+
window_probabilities = np.concatenate([probabilities[start:stop] for start, stop in windows])
|
| 150 |
+
window_roles = np.concatenate([roles[start:stop] for start, stop in windows])
|
| 151 |
+
# 2. Where each gene's exon bases lie in the concatenated windows, in transcript order.
|
| 152 |
+
positions_by_gene = [
|
| 153 |
+
positions_in_windows(exon_positions(oriented_exons), windows) for oriented_exons in scored_exons
|
| 154 |
+
]
|
| 155 |
+
# 3. Encode the concatenated windows in chunks; keep the features at the exon bases.
|
| 156 |
+
features = chunked_inference(
|
| 157 |
+
self.encode, self.context_radius, (window_probabilities, window_roles), device, chunk_bp,
|
| 158 |
+
)
|
| 159 |
+
exon_features = [torch.as_tensor(features[positions]).to(device) for positions in positions_by_gene]
|
| 160 |
+
# 4. Pool each gene's exon features into its confidence.
|
| 161 |
+
logits = self.gene_logits(exon_features).to(torch.float64)
|
| 162 |
+
return torch.sigmoid(logits).cpu().numpy()
|
| 163 |
+
|
| 164 |
+
|
| 165 |
+
def load_confidence_network(path, device):
|
| 166 |
+
"""Load the confidence network weights onto ``device`` in evaluation mode."""
|
| 167 |
+
checkpoint = load_weights(path)
|
| 168 |
+
if checkpoint.get("model_type") != CONFIDENCE_NETWORK_TYPE:
|
| 169 |
+
raise ValueError(f"{path}: not a confidence network checkpoint")
|
| 170 |
+
if tuple(checkpoint.get("input_names", ())) != INPUT_NAMES:
|
| 171 |
+
raise ValueError(f"{path}: unexpected input tracks")
|
| 172 |
+
if tuple(checkpoint.get("role_names", ())) != ROLE_NAMES:
|
| 173 |
+
raise ValueError(f"{path}: unexpected roles")
|
| 174 |
+
network = ConfidenceNetwork(
|
| 175 |
+
int(checkpoint["channels"]), int(checkpoint["dilation_count"]),
|
| 176 |
+
int(checkpoint["attention_heads"]), int(checkpoint["attention_channels"]),
|
| 177 |
+
int(checkpoint["readout_channels"]),
|
| 178 |
+
)
|
| 179 |
+
network.load_state_dict(checkpoint["state_dict"])
|
| 180 |
+
return network.to(device).eval()
|
| 181 |
+
|
| 182 |
+
|
| 183 |
+
def strand_roles(exons_by_gene, record_length):
|
| 184 |
+
"""Return the role of every base of a strand from the oriented exons of its decoded genes.
|
| 185 |
+
|
| 186 |
+
A base inside a gene's span is intron unless some gene has an exon there;
|
| 187 |
+
every other base is intergenic. Every decoded gene counts, complete or not.
|
| 188 |
+
"""
|
| 189 |
+
roles = np.full(record_length, ROLE_INTERGENIC, dtype=np.int64)
|
| 190 |
+
for oriented_exons in exons_by_gene:
|
| 191 |
+
roles[oriented_exons[0][0]:oriented_exons[-1][1] + 1] = ROLE_INTRON
|
| 192 |
+
for oriented_exons in exons_by_gene:
|
| 193 |
+
for exon_start, exon_stop_inclusive in oriented_exons:
|
| 194 |
+
roles[exon_start:exon_stop_inclusive + 1] = ROLE_EXON
|
| 195 |
+
return roles
|
| 196 |
+
|
| 197 |
+
|
| 198 |
+
def exon_positions(oriented_exons):
|
| 199 |
+
"""Return the strand positions of a gene's exon bases, in transcript order."""
|
| 200 |
+
return np.concatenate([
|
| 201 |
+
np.arange(exon_start, exon_stop_inclusive + 1) for exon_start, exon_stop_inclusive in oriented_exons
|
| 202 |
+
])
|
| 203 |
+
|
| 204 |
+
|
| 205 |
+
def exon_windows(scored_exons, record_length, context_radius):
|
| 206 |
+
"""Return the half-open windows of the strand within ``context_radius`` of a scored exon.
|
| 207 |
+
|
| 208 |
+
Every exon is widened by the radius on both sides and clipped to the
|
| 209 |
+
record; overlapping or touching windows are merged. Sorted along the
|
| 210 |
+
strand. Reading these windows and nothing else gives every exon base its
|
| 211 |
+
full context.
|
| 212 |
+
"""
|
| 213 |
+
widened = sorted(
|
| 214 |
+
(max(0, exon_start - context_radius), min(record_length, exon_stop_inclusive + 1 + context_radius))
|
| 215 |
+
for oriented_exons in scored_exons
|
| 216 |
+
for exon_start, exon_stop_inclusive in oriented_exons
|
| 217 |
+
)
|
| 218 |
+
windows = []
|
| 219 |
+
for start, stop in widened:
|
| 220 |
+
if windows and start <= windows[-1][1]:
|
| 221 |
+
windows[-1][1] = max(windows[-1][1], stop)
|
| 222 |
+
else:
|
| 223 |
+
windows.append([start, stop])
|
| 224 |
+
return [(start, stop) for start, stop in windows]
|
| 225 |
+
|
| 226 |
+
|
| 227 |
+
def positions_in_windows(positions, windows):
|
| 228 |
+
"""Map sorted strand positions inside ``windows`` to positions in the concatenated windows."""
|
| 229 |
+
window_starts = np.asarray([start for start, _ in windows], dtype=np.int64)
|
| 230 |
+
concatenated_starts = np.concatenate(([0], np.cumsum([stop - start for start, stop in windows])[:-1]))
|
| 231 |
+
window = np.searchsorted(window_starts, positions, side="right") - 1
|
| 232 |
+
return positions - window_starts[window] + concatenated_starts[window]
|
annotation_pipeline/src/constants.py
ADDED
|
@@ -0,0 +1,141 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""Shared constants for the Carbon-A annotation pipeline.
|
| 2 |
+
|
| 3 |
+
Keep cross-module data contracts and tunable pipeline parameters here. Private
|
| 4 |
+
implementation details, such as decoder state ids and evaluation regexes, stay
|
| 5 |
+
with the modules that own them.
|
| 6 |
+
"""
|
| 7 |
+
|
| 8 |
+
import math
|
| 9 |
+
from pathlib import Path
|
| 10 |
+
|
| 11 |
+
|
| 12 |
+
PROJECT_DIRECTORY = Path(__file__).resolve().parent.parent
|
| 13 |
+
|
| 14 |
+
|
| 15 |
+
# Model inference and output schema. The model reads windows of
|
| 16 |
+
# CONTEXT_LENGTH tokens (six bases each); consecutive windows of a long record
|
| 17 |
+
# overlap by OVERLAP_LENGTH tokens and overlapping probabilities are averaged.
|
| 18 |
+
MODEL_NAME = "HuggingFaceBio/Carbon-A-1.2B"
|
| 19 |
+
CONTEXT_LENGTH = 16384
|
| 20 |
+
OVERLAP_LENGTH = 1024
|
| 21 |
+
HEAD_NAMES = ("positive_strand", "negative_strand")
|
| 22 |
+
RECORD_ID_COLUMN = "record_name"
|
| 23 |
+
SEQUENCE_COLUMN = "sequence"
|
| 24 |
+
POSITIVE_STRAND_PROBABILITY_COLUMN = "pred_prob_positive_strand_cds"
|
| 25 |
+
NEGATIVE_STRAND_PROBABILITY_COLUMN = "pred_prob_negative_strand_cds"
|
| 26 |
+
|
| 27 |
+
|
| 28 |
+
# Sequence alphabet and biological constraints.
|
| 29 |
+
BASE_A = 0
|
| 30 |
+
BASE_T = 1
|
| 31 |
+
BASE_C = 2
|
| 32 |
+
BASE_G = 3
|
| 33 |
+
BASE_N = 4
|
| 34 |
+
BASE_VALUES = {
|
| 35 |
+
"A": BASE_A,
|
| 36 |
+
"T": BASE_T,
|
| 37 |
+
"C": BASE_C,
|
| 38 |
+
"G": BASE_G,
|
| 39 |
+
"N": BASE_N,
|
| 40 |
+
}
|
| 41 |
+
COMPLEMENT_BASES = {
|
| 42 |
+
"A": "T",
|
| 43 |
+
"T": "A",
|
| 44 |
+
"C": "G",
|
| 45 |
+
"G": "C",
|
| 46 |
+
"N": "N",
|
| 47 |
+
}
|
| 48 |
+
START_CODONS = ("ATG",)
|
| 49 |
+
STOP_CODONS = ("TAA", "TAG", "TGA")
|
| 50 |
+
CODON_BASES = "ATCG"
|
| 51 |
+
AMINO_ACID_SYMBOLS = frozenset("ACDEFGHIKLMNPQRSTVWY*")
|
| 52 |
+
NON_NUCLEAR_ASSEMBLY_UNIT = "non-nuclear"
|
| 53 |
+
|
| 54 |
+
|
| 55 |
+
# Viterbi and post-processing parameters.
|
| 56 |
+
# Keep emission probabilities finite before taking logarithms.
|
| 57 |
+
MIN_PROB = 1.0e-6
|
| 58 |
+
MAX_PROB = 1.0 - MIN_PROB
|
| 59 |
+
|
| 60 |
+
# Output gene models must contain at least 32 codons. A complete donor and
|
| 61 |
+
# acceptor occupy four distinct intron bases.
|
| 62 |
+
MIN_CDS_LENGTH_BP = 96
|
| 63 |
+
MIN_INTERNAL_INTRON_LENGTH_BP = 4
|
| 64 |
+
|
| 65 |
+
# Codon-table check. A codon table that does not match the genome splits genes
|
| 66 |
+
# at codons the genome reads through or fuses genes at stop codons it lacks,
|
| 67 |
+
# and the confidence network gives such genes almost no confidence. The check
|
| 68 |
+
# warns when fewer than CODON_TABLE_WARNING_FRACTION of the complete genes
|
| 69 |
+
# reach CONFIDENCE_THRESHOLD. With the shipped network, 17 genomes decoded
|
| 70 |
+
# with their own table keep 67% (hagfish) to 98% of their complete genes at or
|
| 71 |
+
# above the threshold; Tetrahymena decoded with the standard table keeps 1.4%
|
| 72 |
+
# and yeast decoded with table 6 keeps 36%. A table that only drops a rarely
|
| 73 |
+
# used stop codon escapes the check (Dictyostelium without TGA: 94%). Fewer
|
| 74 |
+
# than CODON_TABLE_WARNING_MIN_GENES complete genes are too few to judge.
|
| 75 |
+
CODON_TABLE_WARNING_FRACTION = 0.5
|
| 76 |
+
CODON_TABLE_WARNING_MIN_GENES = 100
|
| 77 |
+
# A failed check is recorded where a batch run will see it: appended to this
|
| 78 |
+
# file in the parent of the output directory, and in the output directory's
|
| 79 |
+
# name (run_*.sh share the suffix).
|
| 80 |
+
WARNINGS_FILE_NAME = "WARNINGS.txt"
|
| 81 |
+
WARNING_DIRECTORY_SUFFIX = "_WARNING"
|
| 82 |
+
|
| 83 |
+
# Intron classes are scored relative to the 99% GT-AG baseline.
|
| 84 |
+
GT_AG_INTRON_LOG_PRIOR = math.log(0.990 / 0.99)
|
| 85 |
+
GC_AG_INTRON_LOG_PRIOR = math.log(0.009 / 0.99)
|
| 86 |
+
AT_AC_INTRON_LOG_PRIOR = math.log(0.001 / 0.99)
|
| 87 |
+
|
| 88 |
+
# The two networks of the annotation pipeline. The transition network reads
|
| 89 |
+
# the coding-probability track and scores the four permitted transitions at
|
| 90 |
+
# every base; the confidence network reads the track together with the
|
| 91 |
+
# decoded roles of the whole strand and scores every complete gene. Both
|
| 92 |
+
# read coding probabilities clipped to NETWORK_INPUT_MIN_PROB as log-odds.
|
| 93 |
+
WEIGHTS_DIRECTORY = PROJECT_DIRECTORY / "weights"
|
| 94 |
+
TRANSITION_NETWORK_PATH = WEIGHTS_DIRECTORY / "transition_network.safetensors"
|
| 95 |
+
CONFIDENCE_NETWORK_PATH = WEIGHTS_DIRECTORY / "confidence_network.safetensors"
|
| 96 |
+
# Per-base transition probability tracks handed to the Viterbi backends, in
|
| 97 |
+
# this column order. They are stored as log-odds against staying in the
|
| 98 |
+
# backends' fixed-point units; the native backends and the Numba reference
|
| 99 |
+
# share the layout.
|
| 100 |
+
TRANSITION_NAMES = ("enter_gene", "leave_gene", "enter_intron", "leave_intron")
|
| 101 |
+
TRANSITION_COUNT = len(TRANSITION_NAMES)
|
| 102 |
+
ENTER_GENE, LEAVE_GENE, ENTER_INTRON, LEAVE_INTRON = range(TRANSITION_COUNT)
|
| 103 |
+
NETWORK_INPUT_MIN_PROB = 1.0e-4
|
| 104 |
+
# Both networks read long tracks in chunks of this many bases, each evaluated
|
| 105 |
+
# with the network's full context on both sides (tcn.chunked_inference), so a
|
| 106 |
+
# chromosome never has to fit in GPU memory at once. (The post-processing
|
| 107 |
+
# driver separately groups whole records into host-side chunks, chunk_mbp.)
|
| 108 |
+
NETWORK_CHUNK_BP = 262_144
|
| 109 |
+
# Native Viterbi implementations accumulate int32 fixed-point scores.
|
| 110 |
+
# Transition probabilities are exchanged as log-odds in the same units: nats
|
| 111 |
+
# multiplied by this scale and rounded.
|
| 112 |
+
FIXED_POINT_SCORE_SCALE = 16384.0
|
| 113 |
+
|
| 114 |
+
# Boundary introns model genes truncated at a record edge. A finite limit
|
| 115 |
+
# prevents an incomplete path from hiding a long region as one intron.
|
| 116 |
+
MAX_BOUNDARY_INTRON_LENGTH_BP = 20_000
|
| 117 |
+
|
| 118 |
+
# Records shorter than this are skipped. Host-side chunks can exceed their
|
| 119 |
+
# target size because a sequence record is never split.
|
| 120 |
+
MIN_RECORD_LENGTH_BP = 100_000
|
| 121 |
+
CHUNK_SIZE_MBP = 25
|
| 122 |
+
PARQUET_BATCH_ROWS = 1
|
| 123 |
+
CODON_TABLE_PATH = PROJECT_DIRECTORY / "codon_tables" / "standard.json"
|
| 124 |
+
|
| 125 |
+
|
| 126 |
+
# GPU Viterbi backend memory and batching parameters.
|
| 127 |
+
GPU_SEGMENT_LENGTH = 2000
|
| 128 |
+
GPU_MEMORY_FRACTION = 0.70
|
| 129 |
+
GPU_MIN_BATCH_BASES = 20_000_000
|
| 130 |
+
# The CUDA preprocessing kernel indexes packed bases with int32. Record offsets
|
| 131 |
+
# remain int64, so this limits a sub-batch rather than the input file.
|
| 132 |
+
GPU_MAX_BATCH_BASES = (1 << 31) - 1
|
| 133 |
+
# Covers packed inputs, per-base scores and paths, the four int32 transition
|
| 134 |
+
# score tracks, segment matrices, and allocation headroom while CUDA windows
|
| 135 |
+
# the backpointer array internally.
|
| 136 |
+
GPU_ESTIMATED_BYTES_PER_BASE = 34
|
| 137 |
+
|
| 138 |
+
|
| 139 |
+
# Gene-level results count the predicted genes whose confidence reaches this
|
| 140 |
+
# threshold; genes without a confidence are always counted.
|
| 141 |
+
CONFIDENCE_THRESHOLD = 0.1
|
annotation_pipeline/src/evaluation.py
ADDED
|
@@ -0,0 +1,1093 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""Evaluate predicted CDS annotations against an NCBI reference assembly.
|
| 2 |
+
|
| 3 |
+
When ``sequence_report.jsonl`` is available, evaluation prefers assembled
|
| 4 |
+
chromosomes, then assembled linkage groups, then chromosome scaffolds; without
|
| 5 |
+
the report every record is evaluated. Six complementary metrics are reported:
|
| 6 |
+
|
| 7 |
+
* gene: one-to-one exact CDS-sequence matches on the same genomic record;
|
| 8 |
+
* protein: gene exact matches plus additional one-to-one exact matches after
|
| 9 |
+
translating unmatched predicted and reference CDS with the same codon table;
|
| 10 |
+
* exon: exact merged CDS intervals from the longest CDS isoform per gene;
|
| 11 |
+
* 5' boundary and 3' boundary: individual CDS boundaries in transcript direction;
|
| 12 |
+
* nucleotide: base-pair overlap against those reference intervals.
|
| 13 |
+
|
| 14 |
+
Gene and protein metrics count the predicted genes whose ``confidence``
|
| 15 |
+
reaches the confidence threshold (default 0.1); genes without a confidence are
|
| 16 |
+
always counted, and a threshold of 0 counts every predicted gene. Exon,
|
| 17 |
+
boundary, and nucleotide metrics describe the whole predicted annotation.
|
| 18 |
+
Evaluation also reports AUROC, non-interpolated AUPRC, and point-biserial
|
| 19 |
+
correlation of the confidence against the one-to-one gene exact-match label,
|
| 20 |
+
together with exact and non-exact group means.
|
| 21 |
+
|
| 22 |
+
The 5' and 3' boundary metrics follow transcript direction, so their meanings are
|
| 23 |
+
reversed on the negative genomic strand. Interval arithmetic keeps memory
|
| 24 |
+
proportional to the number of CDS intervals rather than genome length.
|
| 25 |
+
|
| 26 |
+
Example:
|
| 27 |
+
|
| 28 |
+
python src/evaluation.py --pred results/GCF_000146045.2/prediction \
|
| 29 |
+
--genome_dir ncbi_dataset/data/GCF_000146045.2
|
| 30 |
+
"""
|
| 31 |
+
|
| 32 |
+
import argparse
|
| 33 |
+
from collections import defaultdict
|
| 34 |
+
from datetime import datetime, timezone
|
| 35 |
+
import json
|
| 36 |
+
import math
|
| 37 |
+
import os
|
| 38 |
+
from pathlib import Path
|
| 39 |
+
import re
|
| 40 |
+
import sys
|
| 41 |
+
|
| 42 |
+
|
| 43 |
+
sys.path.insert(0, os.path.dirname(os.path.abspath(__file__)))
|
| 44 |
+
from constants import CONFIDENCE_THRESHOLD
|
| 45 |
+
from utils import load_codon_table, load_nuclear_evaluation_accessions
|
| 46 |
+
|
| 47 |
+
|
| 48 |
+
STRANDS = ("+", "-")
|
| 49 |
+
REFERENCE_CDS_FEATURES = frozenset(("mRNA", "transcript", "CDS"))
|
| 50 |
+
METRIC_NAMES = (
|
| 51 |
+
"gene",
|
| 52 |
+
"protein",
|
| 53 |
+
"exon",
|
| 54 |
+
"five_prime_boundary",
|
| 55 |
+
"three_prime_boundary",
|
| 56 |
+
"nucleotide",
|
| 57 |
+
)
|
| 58 |
+
INTERVAL_METRIC_NAMES = (
|
| 59 |
+
"exon",
|
| 60 |
+
"five_prime_boundary",
|
| 61 |
+
"three_prime_boundary",
|
| 62 |
+
"nucleotide",
|
| 63 |
+
)
|
| 64 |
+
REFERENCE_RECORD_PATTERN = re.compile(r"^lcl\|(.+?)_cds_")
|
| 65 |
+
PREDICTED_RECORD_PATTERN = re.compile(r"(?:^|\s)record_id=(\S+)")
|
| 66 |
+
PREDICTED_FRAME_PATTERN = re.compile(r"(?:^|\s)phase=([012])(?:\s|$)")
|
| 67 |
+
REFERENCE_FRAME_PATTERN = re.compile(r"\[frame=([123])\]")
|
| 68 |
+
GENE_ID_PATTERN = re.compile(
|
| 69 |
+
r"\[db_xref=(?:[^,\]]+,)*GeneID:(\d+)(?:,|\])"
|
| 70 |
+
)
|
| 71 |
+
LOCUS_TAG_PATTERN = re.compile(r"\[locus_tag=([^\]]+)\]")
|
| 72 |
+
GENE_NAME_PATTERN = re.compile(r"\[gene=([^\]]+)\]")
|
| 73 |
+
CONFIDENCE_NAME = "confidence"
|
| 74 |
+
|
| 75 |
+
|
| 76 |
+
def iter_gff_rows(path):
|
| 77 |
+
"""Yield the nine tab-separated fields from each GFF3 feature row."""
|
| 78 |
+
with open(path, encoding="utf-8") as handle:
|
| 79 |
+
for line_number, line in enumerate(handle, 1):
|
| 80 |
+
if line.startswith("#") or line.isspace():
|
| 81 |
+
continue
|
| 82 |
+
fields = line.rstrip("\n").split("\t")
|
| 83 |
+
if len(fields) != 9:
|
| 84 |
+
raise ValueError(
|
| 85 |
+
f"{path}:{line_number}: expected 9 GFF3 fields, "
|
| 86 |
+
f"found {len(fields)}"
|
| 87 |
+
)
|
| 88 |
+
yield fields
|
| 89 |
+
|
| 90 |
+
|
| 91 |
+
def gff_attribute_value(attribute_text, name):
|
| 92 |
+
"""Return one GFF3 attribute without parsing unrelated attributes."""
|
| 93 |
+
prefix = name + "="
|
| 94 |
+
if attribute_text.startswith(prefix):
|
| 95 |
+
value_start = len(prefix)
|
| 96 |
+
else:
|
| 97 |
+
marker = ";" + prefix
|
| 98 |
+
marker_start = attribute_text.find(marker)
|
| 99 |
+
if marker_start == -1:
|
| 100 |
+
return None
|
| 101 |
+
value_start = marker_start + len(marker)
|
| 102 |
+
|
| 103 |
+
value_end = attribute_text.find(";", value_start)
|
| 104 |
+
if value_end == -1:
|
| 105 |
+
return attribute_text[value_start:]
|
| 106 |
+
return attribute_text[value_start:value_end]
|
| 107 |
+
|
| 108 |
+
|
| 109 |
+
def iter_selected_gff_features(path, feature_types, retained_records=None):
|
| 110 |
+
"""Yield selected GFF3 features, optionally restricted by record id.
|
| 111 |
+
|
| 112 |
+
NCBI GFF files contain many gene, exon, and regulatory rows that CDS
|
| 113 |
+
evaluation never uses. Reading the first three columns lets those rows be
|
| 114 |
+
rejected before splitting their long attribute columns.
|
| 115 |
+
"""
|
| 116 |
+
with open(path, encoding="utf-8") as handle:
|
| 117 |
+
for line_number, line in enumerate(handle, 1):
|
| 118 |
+
if line.startswith("#") or line.isspace():
|
| 119 |
+
continue
|
| 120 |
+
|
| 121 |
+
leading_fields = line.split("\t", 3)
|
| 122 |
+
if len(leading_fields) != 4:
|
| 123 |
+
raise ValueError(
|
| 124 |
+
f"{path}:{line_number}: expected 9 GFF3 fields, "
|
| 125 |
+
f"found {len(leading_fields)}"
|
| 126 |
+
)
|
| 127 |
+
record_id, _, feature_type, remaining_text = leading_fields
|
| 128 |
+
if feature_type not in feature_types:
|
| 129 |
+
continue
|
| 130 |
+
if retained_records is not None and record_id not in retained_records:
|
| 131 |
+
continue
|
| 132 |
+
|
| 133 |
+
remaining_fields = remaining_text.rstrip("\n").split("\t", 5)
|
| 134 |
+
if len(remaining_fields) != 6:
|
| 135 |
+
raise ValueError(
|
| 136 |
+
f"{path}:{line_number}: expected 9 GFF3 fields, "
|
| 137 |
+
f"found {3 + len(remaining_fields)}"
|
| 138 |
+
)
|
| 139 |
+
start, end, _, strand, _, attribute_text = remaining_fields
|
| 140 |
+
yield record_id, feature_type, start, end, strand, attribute_text
|
| 141 |
+
|
| 142 |
+
|
| 143 |
+
def parse_predicted_cds_tracks(path):
|
| 144 |
+
"""Return predicted CDS intervals keyed by ``(record_id, strand)``."""
|
| 145 |
+
tracks = defaultdict(list)
|
| 146 |
+
for fields in iter_gff_rows(path):
|
| 147 |
+
record_id, _, feature_type, start, end, _, strand, _, _ = fields
|
| 148 |
+
if feature_type != "CDS":
|
| 149 |
+
continue
|
| 150 |
+
if strand not in STRANDS:
|
| 151 |
+
raise ValueError(f"unsupported CDS strand {strand!r} in {path}")
|
| 152 |
+
tracks[(record_id, strand)].append((int(start) - 1, int(end) - 1))
|
| 153 |
+
return dict(tracks)
|
| 154 |
+
|
| 155 |
+
|
| 156 |
+
def parse_reference_cds_tracks(path, retained_records=None):
|
| 157 |
+
"""Return CDS intervals from the longest CDS isoform of each reference gene."""
|
| 158 |
+
transcript_gene = {}
|
| 159 |
+
transcript_cds = defaultdict(list)
|
| 160 |
+
|
| 161 |
+
for feature in iter_selected_gff_features(
|
| 162 |
+
path,
|
| 163 |
+
REFERENCE_CDS_FEATURES,
|
| 164 |
+
retained_records,
|
| 165 |
+
):
|
| 166 |
+
record_id, feature_type, start, end, strand, attribute_text = feature
|
| 167 |
+
if feature_type in ("mRNA", "transcript"):
|
| 168 |
+
transcript_id = gff_attribute_value(attribute_text, "ID")
|
| 169 |
+
parent = gff_attribute_value(attribute_text, "Parent")
|
| 170 |
+
if transcript_id and parent:
|
| 171 |
+
transcript_gene[(record_id, transcript_id)] = parent.split(",", 1)[0]
|
| 172 |
+
continue
|
| 173 |
+
|
| 174 |
+
if strand not in STRANDS:
|
| 175 |
+
raise ValueError(f"unsupported CDS strand {strand!r} in {path}")
|
| 176 |
+
parent = gff_attribute_value(attribute_text, "Parent")
|
| 177 |
+
for transcript_id in (parent or "").split(","):
|
| 178 |
+
if transcript_id:
|
| 179 |
+
transcript_cds[(record_id, transcript_id)].append(
|
| 180 |
+
(int(start) - 1, int(end) - 1, strand)
|
| 181 |
+
)
|
| 182 |
+
|
| 183 |
+
transcripts_by_gene = defaultdict(dict)
|
| 184 |
+
for (record_id, transcript_id), intervals in transcript_cds.items():
|
| 185 |
+
gene_id = transcript_gene.get((record_id, transcript_id), transcript_id)
|
| 186 |
+
transcripts_by_gene[(record_id, gene_id)][transcript_id] = intervals
|
| 187 |
+
|
| 188 |
+
tracks = defaultdict(list)
|
| 189 |
+
for (record_id, _), transcripts in transcripts_by_gene.items():
|
| 190 |
+
longest_transcript = max(
|
| 191 |
+
transcripts,
|
| 192 |
+
key=lambda transcript_id: sum(
|
| 193 |
+
end - start + 1
|
| 194 |
+
for start, end, _ in transcripts[transcript_id]
|
| 195 |
+
),
|
| 196 |
+
)
|
| 197 |
+
intervals = transcripts[longest_transcript]
|
| 198 |
+
# NCBI trans-spliced transcripts can contain CDS pieces on both strands.
|
| 199 |
+
# Each piece remains on its annotated genomic strand for interval scoring.
|
| 200 |
+
for start, end, strand in intervals:
|
| 201 |
+
tracks[(record_id, strand)].append((start, end))
|
| 202 |
+
return dict(tracks)
|
| 203 |
+
|
| 204 |
+
|
| 205 |
+
def iter_fasta_records(path):
|
| 206 |
+
"""Yield ``(header_without_marker, sequence)`` from a FASTA file."""
|
| 207 |
+
header = None
|
| 208 |
+
sequence_lines = []
|
| 209 |
+
with open(path, encoding="utf-8") as handle:
|
| 210 |
+
for line in handle:
|
| 211 |
+
if line.startswith(">"):
|
| 212 |
+
if header is not None:
|
| 213 |
+
yield header, "".join(sequence_lines)
|
| 214 |
+
header = line[1:].strip()
|
| 215 |
+
sequence_lines = []
|
| 216 |
+
else:
|
| 217 |
+
sequence_lines.append(line.strip())
|
| 218 |
+
if header is not None:
|
| 219 |
+
yield header, "".join(sequence_lines)
|
| 220 |
+
|
| 221 |
+
|
| 222 |
+
def parse_predicted_cds_fasta(path):
|
| 223 |
+
"""Return every predicted CDS as ``(record_id, sequence)``.
|
| 224 |
+
|
| 225 |
+
A list is intentional: two identical predictions remain two predictions and
|
| 226 |
+
therefore participate independently in precision accounting.
|
| 227 |
+
"""
|
| 228 |
+
return [
|
| 229 |
+
(record_id, sequence)
|
| 230 |
+
for _, record_id, sequence in parse_predicted_cds_records(path)
|
| 231 |
+
]
|
| 232 |
+
|
| 233 |
+
|
| 234 |
+
def parse_predicted_cds_records(path):
|
| 235 |
+
"""Return predicted CDS records as ``(gene_id, record_id, sequence)``."""
|
| 236 |
+
return [
|
| 237 |
+
(gene_id, record_id, sequence)
|
| 238 |
+
for gene_id, record_id, sequence, _ in (
|
| 239 |
+
parse_predicted_cds_records_with_frames(path)
|
| 240 |
+
)
|
| 241 |
+
]
|
| 242 |
+
|
| 243 |
+
|
| 244 |
+
def parse_predicted_cds_records_with_frames(path):
|
| 245 |
+
"""Return predicted CDS records with their zero-based translation frame."""
|
| 246 |
+
predictions = []
|
| 247 |
+
for header, sequence in iter_fasta_records(path):
|
| 248 |
+
record_match = PREDICTED_RECORD_PATTERN.search(header)
|
| 249 |
+
if record_match is None:
|
| 250 |
+
raise ValueError(f"predicted FASTA header lacks record_id: {header}")
|
| 251 |
+
frame_match = PREDICTED_FRAME_PATTERN.search(header)
|
| 252 |
+
frame = int(frame_match.group(1)) if frame_match else 0
|
| 253 |
+
gene_id = header.split(None, 1)[0]
|
| 254 |
+
predictions.append((
|
| 255 |
+
gene_id,
|
| 256 |
+
record_match.group(1),
|
| 257 |
+
sequence.upper(),
|
| 258 |
+
frame,
|
| 259 |
+
))
|
| 260 |
+
return predictions
|
| 261 |
+
|
| 262 |
+
|
| 263 |
+
def translate_cds_for_evaluation(sequence, frame, codon_to_amino_acid):
|
| 264 |
+
"""Translate one CDS for exact-match evaluation.
|
| 265 |
+
|
| 266 |
+
Incomplete trailing codons are ignored, ambiguous codons become ``X``, and
|
| 267 |
+
one terminal stop is removed. Internal stops remain in the sequence so an
|
| 268 |
+
unusual reference CDS can still be compared instead of aborting evaluation.
|
| 269 |
+
"""
|
| 270 |
+
sequence = sequence.upper()
|
| 271 |
+
codon_end = len(sequence) - ((len(sequence) - frame) % 3)
|
| 272 |
+
protein = "".join(
|
| 273 |
+
codon_to_amino_acid.get(sequence[start:start + 3], "X")
|
| 274 |
+
for start in range(frame, codon_end, 3)
|
| 275 |
+
)
|
| 276 |
+
if protein.endswith("*"):
|
| 277 |
+
protein = protein[:-1]
|
| 278 |
+
return protein
|
| 279 |
+
|
| 280 |
+
|
| 281 |
+
def parse_predicted_translated_proteins(path, codon_to_amino_acid):
|
| 282 |
+
"""Translate predicted CDS records as ``(gene_id, record_id, protein)``."""
|
| 283 |
+
return [
|
| 284 |
+
(
|
| 285 |
+
gene_id,
|
| 286 |
+
record_id,
|
| 287 |
+
translate_cds_for_evaluation(sequence, frame, codon_to_amino_acid),
|
| 288 |
+
)
|
| 289 |
+
for gene_id, record_id, sequence, frame in (
|
| 290 |
+
parse_predicted_cds_records_with_frames(path)
|
| 291 |
+
)
|
| 292 |
+
]
|
| 293 |
+
|
| 294 |
+
|
| 295 |
+
def parse_predicted_gene_scores(path, score_name=CONFIDENCE_NAME):
|
| 296 |
+
"""Return one optional gene-level score keyed by predicted GFF3 gene ID."""
|
| 297 |
+
scores = {}
|
| 298 |
+
for fields in iter_gff_rows(path):
|
| 299 |
+
_, _, feature_type, _, _, _, _, _, attribute_text = fields
|
| 300 |
+
if feature_type != "gene":
|
| 301 |
+
continue
|
| 302 |
+
gene_id = gff_attribute_value(attribute_text, "ID")
|
| 303 |
+
if gene_id is None:
|
| 304 |
+
raise ValueError(
|
| 305 |
+
f"predicted GFF3 gene lacks an ID in {path}"
|
| 306 |
+
)
|
| 307 |
+
if gene_id in scores:
|
| 308 |
+
raise ValueError(f"duplicate predicted gene ID {gene_id!r} in {path}")
|
| 309 |
+
score_text = gff_attribute_value(attribute_text, score_name)
|
| 310 |
+
if score_text is None:
|
| 311 |
+
scores[gene_id] = None
|
| 312 |
+
continue
|
| 313 |
+
if score_text in ("NA", "."):
|
| 314 |
+
scores[gene_id] = None
|
| 315 |
+
continue
|
| 316 |
+
try:
|
| 317 |
+
score = float(score_text)
|
| 318 |
+
except ValueError as exc:
|
| 319 |
+
raise ValueError(
|
| 320 |
+
f"invalid {score_name}={score_text!r} for {gene_id!r} in {path}"
|
| 321 |
+
) from exc
|
| 322 |
+
if not math.isfinite(score) or not 0.0 <= score <= 1.0:
|
| 323 |
+
raise ValueError(
|
| 324 |
+
f"{score_name} for {gene_id!r} must be between 0 and 1, "
|
| 325 |
+
f"found {score_text!r}"
|
| 326 |
+
)
|
| 327 |
+
scores[gene_id] = score
|
| 328 |
+
return scores
|
| 329 |
+
|
| 330 |
+
|
| 331 |
+
def reference_gene_id(header):
|
| 332 |
+
"""Return the most stable gene identifier available in an NCBI CDS header."""
|
| 333 |
+
match = GENE_ID_PATTERN.search(header)
|
| 334 |
+
if match:
|
| 335 |
+
return "GeneID:" + match.group(1)
|
| 336 |
+
match = LOCUS_TAG_PATTERN.search(header)
|
| 337 |
+
if match:
|
| 338 |
+
return "locus_tag:" + match.group(1)
|
| 339 |
+
match = GENE_NAME_PATTERN.search(header)
|
| 340 |
+
if match:
|
| 341 |
+
return "gene:" + match.group(1)
|
| 342 |
+
return "cds:" + header.split()[0]
|
| 343 |
+
|
| 344 |
+
|
| 345 |
+
def parse_reference_cds_fasta(path):
|
| 346 |
+
"""Return reference isoform sequences keyed by ``(record_id, gene_id)``."""
|
| 347 |
+
return {
|
| 348 |
+
gene_key: [sequence for sequence, _ in isoforms]
|
| 349 |
+
for gene_key, isoforms in parse_reference_cds_fasta_with_frames(path).items()
|
| 350 |
+
}
|
| 351 |
+
|
| 352 |
+
|
| 353 |
+
def parse_reference_cds_fasta_with_frames(path):
|
| 354 |
+
"""Return reference CDS and zero-based frame by genomic record and gene."""
|
| 355 |
+
genes = defaultdict(list)
|
| 356 |
+
for header, sequence in iter_fasta_records(path):
|
| 357 |
+
record_match = REFERENCE_RECORD_PATTERN.search(header)
|
| 358 |
+
if record_match is None:
|
| 359 |
+
raise ValueError(f"unrecognized NCBI CDS FASTA header: {header}")
|
| 360 |
+
frame_match = REFERENCE_FRAME_PATTERN.search(header)
|
| 361 |
+
frame = int(frame_match.group(1)) - 1 if frame_match else 0
|
| 362 |
+
key = (record_match.group(1), reference_gene_id(header))
|
| 363 |
+
genes[key].append((sequence.upper(), frame))
|
| 364 |
+
return dict(genes)
|
| 365 |
+
|
| 366 |
+
|
| 367 |
+
def parse_reference_translated_proteins(path, codon_to_amino_acid):
|
| 368 |
+
"""Translate reference CDS isoforms by ``(record_id, gene_id)``."""
|
| 369 |
+
return {
|
| 370 |
+
gene_key: [
|
| 371 |
+
translate_cds_for_evaluation(sequence, frame, codon_to_amino_acid)
|
| 372 |
+
for sequence, frame in isoforms
|
| 373 |
+
]
|
| 374 |
+
for gene_key, isoforms in parse_reference_cds_fasta_with_frames(path).items()
|
| 375 |
+
}
|
| 376 |
+
|
| 377 |
+
|
| 378 |
+
def merge_intervals(intervals):
|
| 379 |
+
"""Return the union of inclusive intervals in coordinate order."""
|
| 380 |
+
if not intervals:
|
| 381 |
+
return []
|
| 382 |
+
ordered = sorted(intervals)
|
| 383 |
+
if any(start > end for start, end in ordered):
|
| 384 |
+
raise ValueError(f"invalid interval list: {ordered}")
|
| 385 |
+
merged = [list(ordered[0])]
|
| 386 |
+
for start, end in ordered[1:]:
|
| 387 |
+
previous = merged[-1]
|
| 388 |
+
if start <= previous[1] + 1:
|
| 389 |
+
previous[1] = max(previous[1], end)
|
| 390 |
+
else:
|
| 391 |
+
merged.append([start, end])
|
| 392 |
+
return [(start, end) for start, end in merged]
|
| 393 |
+
|
| 394 |
+
|
| 395 |
+
def merge_tracks(tracks):
|
| 396 |
+
"""Merge overlapping or adjacent CDS intervals independently per track."""
|
| 397 |
+
return {track: merge_intervals(intervals) for track, intervals in tracks.items()}
|
| 398 |
+
|
| 399 |
+
|
| 400 |
+
def interval_overlap_length(left, right):
|
| 401 |
+
"""Return the number of bases covered by both merged interval lists."""
|
| 402 |
+
overlap = 0
|
| 403 |
+
left_index = 0
|
| 404 |
+
right_index = 0
|
| 405 |
+
while left_index < len(left) and right_index < len(right):
|
| 406 |
+
left_start, left_end = left[left_index]
|
| 407 |
+
right_start, right_end = right[right_index]
|
| 408 |
+
overlap += max(
|
| 409 |
+
0,
|
| 410 |
+
min(left_end, right_end) - max(left_start, right_start) + 1,
|
| 411 |
+
)
|
| 412 |
+
if left_end < right_end:
|
| 413 |
+
left_index += 1
|
| 414 |
+
else:
|
| 415 |
+
right_index += 1
|
| 416 |
+
return overlap
|
| 417 |
+
|
| 418 |
+
|
| 419 |
+
def add_set_metric_counts(counts, metric_name, predicted_items, reference_items):
|
| 420 |
+
"""Accumulate TP/FP/FN counts for two sets."""
|
| 421 |
+
true_positives = len(predicted_items & reference_items)
|
| 422 |
+
counts[f"{metric_name}_tp"] += true_positives
|
| 423 |
+
counts[f"{metric_name}_fp"] += len(predicted_items) - true_positives
|
| 424 |
+
counts[f"{metric_name}_fn"] += len(reference_items) - true_positives
|
| 425 |
+
|
| 426 |
+
|
| 427 |
+
def interval_metric_counts(predicted_tracks, reference_tracks):
|
| 428 |
+
"""Count exon, boundary, and nucleotide matches."""
|
| 429 |
+
predicted_tracks = merge_tracks(predicted_tracks)
|
| 430 |
+
reference_tracks = merge_tracks(reference_tracks)
|
| 431 |
+
counts = defaultdict(int)
|
| 432 |
+
for metric_name in INTERVAL_METRIC_NAMES:
|
| 433 |
+
for suffix in ("tp", "fp", "fn"):
|
| 434 |
+
counts[f"{metric_name}_{suffix}"] = 0
|
| 435 |
+
|
| 436 |
+
for track in set(predicted_tracks) | set(reference_tracks):
|
| 437 |
+
_, strand = track
|
| 438 |
+
predicted_exons = predicted_tracks.get(track, ())
|
| 439 |
+
reference_exons = reference_tracks.get(track, ())
|
| 440 |
+
|
| 441 |
+
predicted_bp = sum(end - start + 1 for start, end in predicted_exons)
|
| 442 |
+
reference_bp = sum(end - start + 1 for start, end in reference_exons)
|
| 443 |
+
overlap_bp = interval_overlap_length(predicted_exons, reference_exons)
|
| 444 |
+
counts["nucleotide_tp"] += overlap_bp
|
| 445 |
+
counts["nucleotide_fp"] += predicted_bp - overlap_bp
|
| 446 |
+
counts["nucleotide_fn"] += reference_bp - overlap_bp
|
| 447 |
+
|
| 448 |
+
predicted_left_boundaries = {start for start, _ in predicted_exons}
|
| 449 |
+
predicted_right_boundaries = {end for _, end in predicted_exons}
|
| 450 |
+
reference_left_boundaries = {start for start, _ in reference_exons}
|
| 451 |
+
reference_right_boundaries = {end for _, end in reference_exons}
|
| 452 |
+
|
| 453 |
+
if strand == "+":
|
| 454 |
+
predicted_five_prime_boundaries = predicted_left_boundaries
|
| 455 |
+
predicted_three_prime_boundaries = predicted_right_boundaries
|
| 456 |
+
reference_five_prime_boundaries = reference_left_boundaries
|
| 457 |
+
reference_three_prime_boundaries = reference_right_boundaries
|
| 458 |
+
else:
|
| 459 |
+
predicted_five_prime_boundaries = predicted_right_boundaries
|
| 460 |
+
predicted_three_prime_boundaries = predicted_left_boundaries
|
| 461 |
+
reference_five_prime_boundaries = reference_right_boundaries
|
| 462 |
+
reference_three_prime_boundaries = reference_left_boundaries
|
| 463 |
+
|
| 464 |
+
add_set_metric_counts(
|
| 465 |
+
counts,
|
| 466 |
+
"five_prime_boundary",
|
| 467 |
+
predicted_five_prime_boundaries,
|
| 468 |
+
reference_five_prime_boundaries,
|
| 469 |
+
)
|
| 470 |
+
add_set_metric_counts(
|
| 471 |
+
counts,
|
| 472 |
+
"three_prime_boundary",
|
| 473 |
+
predicted_three_prime_boundaries,
|
| 474 |
+
reference_three_prime_boundaries,
|
| 475 |
+
)
|
| 476 |
+
add_set_metric_counts(
|
| 477 |
+
counts,
|
| 478 |
+
"exon",
|
| 479 |
+
set(predicted_exons),
|
| 480 |
+
set(reference_exons),
|
| 481 |
+
)
|
| 482 |
+
|
| 483 |
+
return dict(counts)
|
| 484 |
+
|
| 485 |
+
|
| 486 |
+
def gene_exact_match_assignments(predicted_cds, reference_genes):
|
| 487 |
+
"""Return one-to-one exact matches keyed by prediction index.
|
| 488 |
+
|
| 489 |
+
A reference gene may match any one of its annotated isoforms. The augmenting
|
| 490 |
+
path prevents one prediction from recalling several genes with an identical
|
| 491 |
+
CDS and prevents duplicate predictions from matching the same gene twice.
|
| 492 |
+
"""
|
| 493 |
+
candidate_genes = defaultdict(list)
|
| 494 |
+
for gene_key, isoforms in reference_genes.items():
|
| 495 |
+
record_id, _ = gene_key
|
| 496 |
+
for sequence in set(isoforms):
|
| 497 |
+
candidate_genes[(record_id, sequence)].append(gene_key)
|
| 498 |
+
|
| 499 |
+
matched_prediction_by_gene = {}
|
| 500 |
+
matched_gene_by_prediction = {}
|
| 501 |
+
|
| 502 |
+
def match_prediction(start_prediction):
|
| 503 |
+
"""Find and apply one augmenting path without recursive calls."""
|
| 504 |
+
queued_predictions = [start_prediction]
|
| 505 |
+
visited_predictions = {start_prediction}
|
| 506 |
+
visited_genes = set()
|
| 507 |
+
parent_prediction_by_gene = {}
|
| 508 |
+
queue_index = 0
|
| 509 |
+
|
| 510 |
+
while queue_index < len(queued_predictions):
|
| 511 |
+
prediction_index = queued_predictions[queue_index]
|
| 512 |
+
queue_index += 1
|
| 513 |
+
prediction = predicted_cds[prediction_index]
|
| 514 |
+
|
| 515 |
+
for gene_key in candidate_genes.get(prediction, ()):
|
| 516 |
+
if gene_key in visited_genes:
|
| 517 |
+
continue
|
| 518 |
+
visited_genes.add(gene_key)
|
| 519 |
+
parent_prediction_by_gene[gene_key] = prediction_index
|
| 520 |
+
|
| 521 |
+
previous_prediction = matched_prediction_by_gene.get(gene_key)
|
| 522 |
+
if previous_prediction is None:
|
| 523 |
+
current_gene = gene_key
|
| 524 |
+
while current_gene is not None:
|
| 525 |
+
current_prediction = parent_prediction_by_gene[current_gene]
|
| 526 |
+
previous_gene = matched_gene_by_prediction.get(
|
| 527 |
+
current_prediction
|
| 528 |
+
)
|
| 529 |
+
matched_prediction_by_gene[current_gene] = current_prediction
|
| 530 |
+
matched_gene_by_prediction[current_prediction] = current_gene
|
| 531 |
+
current_gene = previous_gene
|
| 532 |
+
return True
|
| 533 |
+
|
| 534 |
+
if previous_prediction not in visited_predictions:
|
| 535 |
+
visited_predictions.add(previous_prediction)
|
| 536 |
+
queued_predictions.append(previous_prediction)
|
| 537 |
+
return False
|
| 538 |
+
|
| 539 |
+
for prediction_index in range(len(predicted_cds)):
|
| 540 |
+
match_prediction(prediction_index)
|
| 541 |
+
return matched_gene_by_prediction
|
| 542 |
+
|
| 543 |
+
|
| 544 |
+
def gene_metric_counts(
|
| 545 |
+
predicted_cds,
|
| 546 |
+
reference_genes,
|
| 547 |
+
matched_gene_by_prediction=None,
|
| 548 |
+
):
|
| 549 |
+
"""Count one-to-one exact CDS matches between predictions and genes."""
|
| 550 |
+
if matched_gene_by_prediction is None:
|
| 551 |
+
matched_gene_by_prediction = gene_exact_match_assignments(
|
| 552 |
+
predicted_cds,
|
| 553 |
+
reference_genes,
|
| 554 |
+
)
|
| 555 |
+
matched_genes = len(matched_gene_by_prediction)
|
| 556 |
+
return {
|
| 557 |
+
"gene_tp": matched_genes,
|
| 558 |
+
"gene_fp": len(predicted_cds) - matched_genes,
|
| 559 |
+
"gene_fn": len(reference_genes) - matched_genes,
|
| 560 |
+
"predicted_genes": len(predicted_cds),
|
| 561 |
+
"reference_genes": len(reference_genes),
|
| 562 |
+
}
|
| 563 |
+
|
| 564 |
+
|
| 565 |
+
def protein_metric_counts(
|
| 566 |
+
predicted_proteins,
|
| 567 |
+
reference_proteins,
|
| 568 |
+
inherited_gene_matches=None,
|
| 569 |
+
):
|
| 570 |
+
"""Count protein matches, retaining every exact CDS gene match.
|
| 571 |
+
|
| 572 |
+
Translation frames can be uncertain for partial CDS. Exact nucleotide CDS
|
| 573 |
+
matches supplied in ``inherited_gene_matches`` therefore remain matches at
|
| 574 |
+
protein level even if their independently annotated frames differ. Protein
|
| 575 |
+
matching is then applied only to the still-unmatched predictions and genes,
|
| 576 |
+
allowing synonymous CDS differences to add matches without double counting.
|
| 577 |
+
"""
|
| 578 |
+
if inherited_gene_matches is None:
|
| 579 |
+
matched_gene_by_prediction = gene_exact_match_assignments(
|
| 580 |
+
predicted_proteins,
|
| 581 |
+
reference_proteins,
|
| 582 |
+
)
|
| 583 |
+
else:
|
| 584 |
+
matched_gene_by_prediction = dict(inherited_gene_matches)
|
| 585 |
+
matched_reference_genes = set(matched_gene_by_prediction.values())
|
| 586 |
+
if len(matched_reference_genes) != len(matched_gene_by_prediction):
|
| 587 |
+
raise ValueError("inherited gene matches must be one-to-one")
|
| 588 |
+
if any(
|
| 589 |
+
prediction_index < 0 or prediction_index >= len(predicted_proteins)
|
| 590 |
+
for prediction_index in matched_gene_by_prediction
|
| 591 |
+
):
|
| 592 |
+
raise ValueError("inherited gene match has an invalid prediction index")
|
| 593 |
+
if not matched_reference_genes.issubset(reference_proteins):
|
| 594 |
+
raise ValueError("inherited gene match is absent from protein references")
|
| 595 |
+
|
| 596 |
+
unmatched_prediction_indices = [
|
| 597 |
+
prediction_index
|
| 598 |
+
for prediction_index in range(len(predicted_proteins))
|
| 599 |
+
if prediction_index not in matched_gene_by_prediction
|
| 600 |
+
]
|
| 601 |
+
unmatched_reference_proteins = {
|
| 602 |
+
gene_key: isoforms
|
| 603 |
+
for gene_key, isoforms in reference_proteins.items()
|
| 604 |
+
if gene_key not in matched_reference_genes
|
| 605 |
+
}
|
| 606 |
+
additional_matches = gene_exact_match_assignments(
|
| 607 |
+
[
|
| 608 |
+
predicted_proteins[prediction_index]
|
| 609 |
+
for prediction_index in unmatched_prediction_indices
|
| 610 |
+
],
|
| 611 |
+
unmatched_reference_proteins,
|
| 612 |
+
)
|
| 613 |
+
matched_gene_by_prediction.update({
|
| 614 |
+
unmatched_prediction_indices[local_prediction_index]: gene_key
|
| 615 |
+
for local_prediction_index, gene_key in additional_matches.items()
|
| 616 |
+
})
|
| 617 |
+
|
| 618 |
+
matched_genes = len(matched_gene_by_prediction)
|
| 619 |
+
return {
|
| 620 |
+
"protein_tp": matched_genes,
|
| 621 |
+
"protein_fp": len(predicted_proteins) - matched_genes,
|
| 622 |
+
"protein_fn": len(reference_proteins) - matched_genes,
|
| 623 |
+
"predicted_proteins": len(predicted_proteins),
|
| 624 |
+
"reference_proteins": len(reference_proteins),
|
| 625 |
+
}
|
| 626 |
+
|
| 627 |
+
|
| 628 |
+
def pearson_correlation(left_values, right_values):
|
| 629 |
+
"""Return Pearson correlation, or ``None`` when either side is constant."""
|
| 630 |
+
if len(left_values) != len(right_values):
|
| 631 |
+
raise ValueError("correlation vectors must have the same length")
|
| 632 |
+
if len(left_values) < 2:
|
| 633 |
+
return None
|
| 634 |
+
|
| 635 |
+
left_mean = math.fsum(left_values) / len(left_values)
|
| 636 |
+
right_mean = math.fsum(right_values) / len(right_values)
|
| 637 |
+
left_deviations = [value - left_mean for value in left_values]
|
| 638 |
+
right_deviations = [value - right_mean for value in right_values]
|
| 639 |
+
left_sum_squares = math.fsum(value * value for value in left_deviations)
|
| 640 |
+
right_sum_squares = math.fsum(value * value for value in right_deviations)
|
| 641 |
+
if left_sum_squares == 0.0 or right_sum_squares == 0.0:
|
| 642 |
+
return None
|
| 643 |
+
|
| 644 |
+
cross_product = math.fsum(
|
| 645 |
+
left * right
|
| 646 |
+
for left, right in zip(left_deviations, right_deviations)
|
| 647 |
+
)
|
| 648 |
+
correlation = cross_product / math.sqrt(
|
| 649 |
+
left_sum_squares * right_sum_squares
|
| 650 |
+
)
|
| 651 |
+
return max(-1.0, min(1.0, correlation))
|
| 652 |
+
|
| 653 |
+
|
| 654 |
+
def binary_ranking_auroc_auprc(scores, labels):
|
| 655 |
+
"""Return tie-aware AUROC and stepwise AUPRC for binary labels.
|
| 656 |
+
|
| 657 |
+
Score ties are processed as a group. This gives tied positive/negative pairs
|
| 658 |
+
half credit in AUROC and prevents input order from affecting either metric.
|
| 659 |
+
AUPRC uses the average-precision (non-interpolated step) convention. Both
|
| 660 |
+
metrics are undefined when either class is absent.
|
| 661 |
+
"""
|
| 662 |
+
if len(scores) != len(labels):
|
| 663 |
+
raise ValueError("ranking score and label vectors must have the same length")
|
| 664 |
+
if any(label not in (0, 1, False, True) for label in labels):
|
| 665 |
+
raise ValueError("ranking labels must be binary")
|
| 666 |
+
|
| 667 |
+
positive_count = sum(bool(label) for label in labels)
|
| 668 |
+
negative_count = len(labels) - positive_count
|
| 669 |
+
if positive_count == 0 or negative_count == 0:
|
| 670 |
+
return None, None
|
| 671 |
+
|
| 672 |
+
ranked = sorted(
|
| 673 |
+
zip(scores, (bool(label) for label in labels)),
|
| 674 |
+
key=lambda item: item[0],
|
| 675 |
+
reverse=True,
|
| 676 |
+
)
|
| 677 |
+
true_positives = 0
|
| 678 |
+
false_positives = 0
|
| 679 |
+
previous_recall = 0.0
|
| 680 |
+
auroc_area = 0.0
|
| 681 |
+
auprc_area = 0.0
|
| 682 |
+
group_start = 0
|
| 683 |
+
while group_start < len(ranked):
|
| 684 |
+
score = ranked[group_start][0]
|
| 685 |
+
group_end = group_start
|
| 686 |
+
group_positives = 0
|
| 687 |
+
while group_end < len(ranked) and ranked[group_end][0] == score:
|
| 688 |
+
group_positives += int(ranked[group_end][1])
|
| 689 |
+
group_end += 1
|
| 690 |
+
group_negatives = group_end - group_start - group_positives
|
| 691 |
+
|
| 692 |
+
next_true_positives = true_positives + group_positives
|
| 693 |
+
next_false_positives = false_positives + group_negatives
|
| 694 |
+
false_positive_rate_increment = group_negatives / negative_count
|
| 695 |
+
auroc_area += false_positive_rate_increment * (
|
| 696 |
+
true_positives + next_true_positives
|
| 697 |
+
) / (2.0 * positive_count)
|
| 698 |
+
|
| 699 |
+
recall = next_true_positives / positive_count
|
| 700 |
+
precision = next_true_positives / (
|
| 701 |
+
next_true_positives + next_false_positives
|
| 702 |
+
)
|
| 703 |
+
auprc_area += (recall - previous_recall) * precision
|
| 704 |
+
|
| 705 |
+
true_positives = next_true_positives
|
| 706 |
+
false_positives = next_false_positives
|
| 707 |
+
previous_recall = recall
|
| 708 |
+
group_start = group_end
|
| 709 |
+
|
| 710 |
+
return auroc_area, auprc_area
|
| 711 |
+
|
| 712 |
+
|
| 713 |
+
def confidence_exact_match_metrics(
|
| 714 |
+
confidence_values,
|
| 715 |
+
matched_gene_by_prediction,
|
| 716 |
+
):
|
| 717 |
+
"""Summarize association between confidence and exact gene matches."""
|
| 718 |
+
scored_values = []
|
| 719 |
+
exact_labels = []
|
| 720 |
+
exact_scores = []
|
| 721 |
+
nonexact_scores = []
|
| 722 |
+
for prediction_index, score in enumerate(confidence_values):
|
| 723 |
+
if score is None:
|
| 724 |
+
continue
|
| 725 |
+
is_exact = prediction_index in matched_gene_by_prediction
|
| 726 |
+
scored_values.append(score)
|
| 727 |
+
exact_labels.append(float(is_exact))
|
| 728 |
+
if is_exact:
|
| 729 |
+
exact_scores.append(score)
|
| 730 |
+
else:
|
| 731 |
+
nonexact_scores.append(score)
|
| 732 |
+
|
| 733 |
+
auroc, auprc = binary_ranking_auroc_auprc(scored_values, exact_labels)
|
| 734 |
+
return {
|
| 735 |
+
"confidence_scored_genes": len(scored_values),
|
| 736 |
+
"confidence_missing_genes": (
|
| 737 |
+
len(confidence_values) - len(scored_values)
|
| 738 |
+
),
|
| 739 |
+
"confidence_mean": (
|
| 740 |
+
math.fsum(scored_values) / len(scored_values)
|
| 741 |
+
if scored_values
|
| 742 |
+
else None
|
| 743 |
+
),
|
| 744 |
+
"confidence_exact_genes": len(exact_scores),
|
| 745 |
+
"confidence_nonexact_genes": len(nonexact_scores),
|
| 746 |
+
"confidence_exact_mean": (
|
| 747 |
+
math.fsum(exact_scores) / len(exact_scores)
|
| 748 |
+
if exact_scores
|
| 749 |
+
else None
|
| 750 |
+
),
|
| 751 |
+
"confidence_nonexact_mean": (
|
| 752 |
+
math.fsum(nonexact_scores) / len(nonexact_scores)
|
| 753 |
+
if nonexact_scores
|
| 754 |
+
else None
|
| 755 |
+
),
|
| 756 |
+
"confidence_exact_match_correlation": pearson_correlation(
|
| 757 |
+
scored_values,
|
| 758 |
+
exact_labels,
|
| 759 |
+
),
|
| 760 |
+
"confidence_exact_match_auroc": auroc,
|
| 761 |
+
"confidence_exact_match_auprc": auprc,
|
| 762 |
+
}
|
| 763 |
+
|
| 764 |
+
|
| 765 |
+
def precision_recall_f1(true_positives, false_positives, false_negatives):
|
| 766 |
+
"""Return precision, recall, and F1 for integer match counts."""
|
| 767 |
+
precision_denominator = true_positives + false_positives
|
| 768 |
+
recall_denominator = true_positives + false_negatives
|
| 769 |
+
precision = (
|
| 770 |
+
true_positives / precision_denominator
|
| 771 |
+
if precision_denominator
|
| 772 |
+
else 0.0
|
| 773 |
+
)
|
| 774 |
+
recall = (
|
| 775 |
+
true_positives / recall_denominator
|
| 776 |
+
if recall_denominator
|
| 777 |
+
else 0.0
|
| 778 |
+
)
|
| 779 |
+
f1 = (
|
| 780 |
+
2 * precision * recall / (precision + recall)
|
| 781 |
+
if precision + recall
|
| 782 |
+
else 0.0
|
| 783 |
+
)
|
| 784 |
+
return precision, recall, f1
|
| 785 |
+
|
| 786 |
+
|
| 787 |
+
def add_metric_rates(metrics):
|
| 788 |
+
"""Add precision, recall, and F1 values for every metric count group."""
|
| 789 |
+
for metric_name in METRIC_NAMES:
|
| 790 |
+
precision, recall, f1 = precision_recall_f1(
|
| 791 |
+
metrics.get(f"{metric_name}_tp", 0),
|
| 792 |
+
metrics.get(f"{metric_name}_fp", 0),
|
| 793 |
+
metrics.get(f"{metric_name}_fn", 0),
|
| 794 |
+
)
|
| 795 |
+
metrics[f"{metric_name}_precision"] = precision
|
| 796 |
+
metrics[f"{metric_name}_recall"] = recall
|
| 797 |
+
metrics[f"{metric_name}_f1"] = f1
|
| 798 |
+
|
| 799 |
+
|
| 800 |
+
def filter_tracks(tracks, retained_records):
|
| 801 |
+
"""Restrict interval tracks to a record set, or return all tracks for None."""
|
| 802 |
+
if retained_records is None:
|
| 803 |
+
return tracks
|
| 804 |
+
return {
|
| 805 |
+
track: intervals
|
| 806 |
+
for track, intervals in tracks.items()
|
| 807 |
+
if track[0] in retained_records
|
| 808 |
+
}
|
| 809 |
+
|
| 810 |
+
|
| 811 |
+
def filter_gene_inputs(predicted_cds, reference_genes, retained_records):
|
| 812 |
+
"""Restrict gene-level inputs to a record set."""
|
| 813 |
+
if retained_records is None:
|
| 814 |
+
return predicted_cds, reference_genes
|
| 815 |
+
return (
|
| 816 |
+
[
|
| 817 |
+
prediction
|
| 818 |
+
for prediction in predicted_cds
|
| 819 |
+
if prediction[0] in retained_records
|
| 820 |
+
],
|
| 821 |
+
{
|
| 822 |
+
gene_key: isoforms
|
| 823 |
+
for gene_key, isoforms in reference_genes.items()
|
| 824 |
+
if gene_key[0] in retained_records
|
| 825 |
+
},
|
| 826 |
+
)
|
| 827 |
+
|
| 828 |
+
|
| 829 |
+
def required_file(directory, filename):
|
| 830 |
+
"""Return a required file path with a useful error when it is absent."""
|
| 831 |
+
path = Path(directory) / filename
|
| 832 |
+
if not path.is_file():
|
| 833 |
+
raise FileNotFoundError(f"required evaluation file not found: {path}")
|
| 834 |
+
return path
|
| 835 |
+
|
| 836 |
+
|
| 837 |
+
def default_codon_table_path(genome_dir):
|
| 838 |
+
"""Return an accession-specific codon table when one is bundled."""
|
| 839 |
+
codon_table_directory = Path(__file__).resolve().parent.parent / "codon_tables"
|
| 840 |
+
accession_path = codon_table_directory / f"{Path(genome_dir).name}.json"
|
| 841 |
+
if accession_path.is_file():
|
| 842 |
+
return accession_path
|
| 843 |
+
return codon_table_directory / "standard.json"
|
| 844 |
+
|
| 845 |
+
|
| 846 |
+
def evaluate_prediction(
|
| 847 |
+
prediction_prefix,
|
| 848 |
+
genome_dir,
|
| 849 |
+
confidence_threshold=CONFIDENCE_THRESHOLD,
|
| 850 |
+
codon_table_path=None,
|
| 851 |
+
):
|
| 852 |
+
"""Load one prediction/reference pair and return count and rate metrics.
|
| 853 |
+
|
| 854 |
+
Gene and protein metrics count the predicted genes whose confidence is at
|
| 855 |
+
least ``confidence_threshold``; genes without a confidence
|
| 856 |
+
(``confidence=NA``) are always counted, and ``0`` counts every predicted
|
| 857 |
+
gene. Exon, boundary, and nucleotide metrics use every predicted CDS
|
| 858 |
+
interval. Without ``codon_table_path`` the translation table is
|
| 859 |
+
``codon_tables/<accession>.json`` when the genome directory is named after
|
| 860 |
+
an accession with a bundled table, otherwise the standard table.
|
| 861 |
+
"""
|
| 862 |
+
prediction_prefix = Path(prediction_prefix)
|
| 863 |
+
genome_dir = Path(genome_dir)
|
| 864 |
+
if (
|
| 865 |
+
not math.isfinite(confidence_threshold)
|
| 866 |
+
or not 0.0 <= confidence_threshold <= 1.0
|
| 867 |
+
):
|
| 868 |
+
raise ValueError("confidence_threshold must be between 0 and 1")
|
| 869 |
+
codon_table_path = (
|
| 870 |
+
Path(codon_table_path)
|
| 871 |
+
if codon_table_path is not None
|
| 872 |
+
else default_codon_table_path(genome_dir)
|
| 873 |
+
)
|
| 874 |
+
codon_to_amino_acid = load_codon_table(codon_table_path)
|
| 875 |
+
retained_records = load_nuclear_evaluation_accessions(genome_dir)
|
| 876 |
+
|
| 877 |
+
predicted_gff_path = str(prediction_prefix) + ".gff"
|
| 878 |
+
predicted_gene_scores = parse_predicted_gene_scores(predicted_gff_path)
|
| 879 |
+
predicted_tracks = parse_predicted_cds_tracks(predicted_gff_path)
|
| 880 |
+
predicted_cds_frame_records = [
|
| 881 |
+
prediction
|
| 882 |
+
for prediction in parse_predicted_cds_records_with_frames(
|
| 883 |
+
str(prediction_prefix) + ".fna"
|
| 884 |
+
)
|
| 885 |
+
if confidence_threshold <= 0.0
|
| 886 |
+
or predicted_gene_scores.get(prediction[0]) is None
|
| 887 |
+
or predicted_gene_scores[prediction[0]] >= confidence_threshold
|
| 888 |
+
]
|
| 889 |
+
reference_tracks = parse_reference_cds_tracks(
|
| 890 |
+
required_file(genome_dir, "genomic.gff"),
|
| 891 |
+
retained_records,
|
| 892 |
+
)
|
| 893 |
+
reference_cds_path = required_file(genome_dir, "cds_from_genomic.fna")
|
| 894 |
+
reference_genes = parse_reference_cds_fasta(reference_cds_path)
|
| 895 |
+
reference_proteins = parse_reference_translated_proteins(
|
| 896 |
+
reference_cds_path,
|
| 897 |
+
codon_to_amino_acid,
|
| 898 |
+
)
|
| 899 |
+
|
| 900 |
+
predicted_tracks = filter_tracks(predicted_tracks, retained_records)
|
| 901 |
+
reference_tracks = filter_tracks(reference_tracks, retained_records)
|
| 902 |
+
if retained_records is not None:
|
| 903 |
+
predicted_cds_frame_records = [
|
| 904 |
+
prediction
|
| 905 |
+
for prediction in predicted_cds_frame_records
|
| 906 |
+
if prediction[1] in retained_records
|
| 907 |
+
]
|
| 908 |
+
predicted_cds = [
|
| 909 |
+
(record_id, sequence)
|
| 910 |
+
for _, record_id, sequence, _ in predicted_cds_frame_records
|
| 911 |
+
]
|
| 912 |
+
predicted_proteins = [
|
| 913 |
+
(record_id, translate_cds_for_evaluation(sequence, frame, codon_to_amino_acid))
|
| 914 |
+
for _, record_id, sequence, frame in predicted_cds_frame_records
|
| 915 |
+
]
|
| 916 |
+
_, reference_genes = filter_gene_inputs([], reference_genes, retained_records)
|
| 917 |
+
_, reference_proteins = filter_gene_inputs([], reference_proteins, retained_records)
|
| 918 |
+
|
| 919 |
+
matched_gene_by_prediction = gene_exact_match_assignments(
|
| 920 |
+
predicted_cds,
|
| 921 |
+
reference_genes,
|
| 922 |
+
)
|
| 923 |
+
metrics = gene_metric_counts(
|
| 924 |
+
predicted_cds,
|
| 925 |
+
reference_genes,
|
| 926 |
+
matched_gene_by_prediction,
|
| 927 |
+
)
|
| 928 |
+
metrics.update(protein_metric_counts(
|
| 929 |
+
predicted_proteins,
|
| 930 |
+
reference_proteins,
|
| 931 |
+
matched_gene_by_prediction,
|
| 932 |
+
))
|
| 933 |
+
metrics.update(confidence_exact_match_metrics(
|
| 934 |
+
[
|
| 935 |
+
predicted_gene_scores.get(gene_id)
|
| 936 |
+
for gene_id, _, _, _ in predicted_cds_frame_records
|
| 937 |
+
],
|
| 938 |
+
matched_gene_by_prediction,
|
| 939 |
+
))
|
| 940 |
+
metrics.update(interval_metric_counts(predicted_tracks, reference_tracks))
|
| 941 |
+
add_metric_rates(metrics)
|
| 942 |
+
|
| 943 |
+
metrics["genome"] = genome_dir.name
|
| 944 |
+
metrics["codon_table"] = codon_table_path.name
|
| 945 |
+
metrics["confidence_threshold"] = confidence_threshold
|
| 946 |
+
metrics["records"] = len(
|
| 947 |
+
{record_id for record_id, _ in predicted_tracks}
|
| 948 |
+
| {record_id for record_id, _ in reference_tracks}
|
| 949 |
+
)
|
| 950 |
+
metrics["record_filter"] = (
|
| 951 |
+
"all (no sequence report)" if retained_records is None else "nuclear"
|
| 952 |
+
)
|
| 953 |
+
return metrics
|
| 954 |
+
|
| 955 |
+
|
| 956 |
+
def write_evaluation_metrics_json(path, metrics):
|
| 957 |
+
"""Atomically write the evaluation metrics of one accession."""
|
| 958 |
+
output_path = Path(path)
|
| 959 |
+
output_path.parent.mkdir(parents=True, exist_ok=True)
|
| 960 |
+
payload = {
|
| 961 |
+
"accession": metrics["genome"],
|
| 962 |
+
"generated_at": datetime.now(timezone.utc).isoformat(),
|
| 963 |
+
"metrics": metrics,
|
| 964 |
+
}
|
| 965 |
+
temporary_path = output_path.with_name(output_path.name + ".tmp")
|
| 966 |
+
with temporary_path.open("w", encoding="utf-8") as handle:
|
| 967 |
+
json.dump(payload, handle, indent=2, sort_keys=True, allow_nan=False)
|
| 968 |
+
handle.write("\n")
|
| 969 |
+
temporary_path.replace(output_path)
|
| 970 |
+
|
| 971 |
+
|
| 972 |
+
def print_evaluation_metrics(metrics):
|
| 973 |
+
"""Print a concise summary of one evaluation."""
|
| 974 |
+
print(
|
| 975 |
+
f"[evaluation] {metrics['genome']} filter={metrics['record_filter']} "
|
| 976 |
+
f"codon_table={metrics['codon_table']} "
|
| 977 |
+
f"confidence_threshold={metrics['confidence_threshold']:.2f} "
|
| 978 |
+
f"({metrics['records']} records, "
|
| 979 |
+
f"{metrics['reference_genes']} reference genes, "
|
| 980 |
+
f"{metrics['predicted_genes']} predictions)"
|
| 981 |
+
)
|
| 982 |
+
for label, metric_name in (
|
| 983 |
+
("gene", "gene"),
|
| 984 |
+
("protein", "protein"),
|
| 985 |
+
("exon", "exon"),
|
| 986 |
+
("5' boundary", "five_prime_boundary"),
|
| 987 |
+
("3' boundary", "three_prime_boundary"),
|
| 988 |
+
("nucleotide", "nucleotide"),
|
| 989 |
+
):
|
| 990 |
+
print(
|
| 991 |
+
f" {label:<11} P/R/F1: "
|
| 992 |
+
f"{metrics[f'{metric_name}_precision']:.4f} / "
|
| 993 |
+
f"{metrics[f'{metric_name}_recall']:.4f} / "
|
| 994 |
+
f"{metrics[f'{metric_name}_f1']:.4f}"
|
| 995 |
+
)
|
| 996 |
+
scored_genes = metrics.get("confidence_scored_genes")
|
| 997 |
+
if scored_genes:
|
| 998 |
+
correlation = metrics["confidence_exact_match_correlation"]
|
| 999 |
+
correlation_text = "NA" if correlation is None else f"{correlation:.4f}"
|
| 1000 |
+
auroc = metrics["confidence_exact_match_auroc"]
|
| 1001 |
+
auprc = metrics["confidence_exact_match_auprc"]
|
| 1002 |
+
auroc_text = "NA" if auroc is None else f"{auroc:.4f}"
|
| 1003 |
+
auprc_text = "NA" if auprc is None else f"{auprc:.4f}"
|
| 1004 |
+
exact_mean = metrics["confidence_exact_mean"]
|
| 1005 |
+
nonexact_mean = metrics["confidence_nonexact_mean"]
|
| 1006 |
+
exact_mean_text = "NA" if exact_mean is None else f"{exact_mean:.4f}"
|
| 1007 |
+
nonexact_mean_text = (
|
| 1008 |
+
"NA" if nonexact_mean is None else f"{nonexact_mean:.4f}"
|
| 1009 |
+
)
|
| 1010 |
+
print(
|
| 1011 |
+
" confidence vs gene exact match: "
|
| 1012 |
+
f"AUROC/AUPRC={auroc_text}/{auprc_text}; "
|
| 1013 |
+
f"point-biserial r={correlation_text}; "
|
| 1014 |
+
f"mean exact/non-exact={exact_mean_text}/{nonexact_mean_text}; "
|
| 1015 |
+
f"exact/non-exact={metrics['confidence_exact_genes']}/"
|
| 1016 |
+
f"{metrics['confidence_nonexact_genes']}; "
|
| 1017 |
+
f"scored/missing={scored_genes}/"
|
| 1018 |
+
f"{metrics['confidence_missing_genes']}"
|
| 1019 |
+
)
|
| 1020 |
+
elif scored_genes == 0:
|
| 1021 |
+
print(" confidence vs gene exact match: unavailable (no scores)")
|
| 1022 |
+
|
| 1023 |
+
|
| 1024 |
+
def parse_args():
|
| 1025 |
+
"""Parse command-line arguments."""
|
| 1026 |
+
parser = argparse.ArgumentParser(
|
| 1027 |
+
description=__doc__,
|
| 1028 |
+
formatter_class=argparse.RawDescriptionHelpFormatter,
|
| 1029 |
+
)
|
| 1030 |
+
parser.add_argument(
|
| 1031 |
+
"--pred",
|
| 1032 |
+
required=True,
|
| 1033 |
+
help="prediction prefix; reads <prefix>.gff and <prefix>.fna",
|
| 1034 |
+
)
|
| 1035 |
+
parser.add_argument(
|
| 1036 |
+
"--genome_dir",
|
| 1037 |
+
required=True,
|
| 1038 |
+
help=(
|
| 1039 |
+
"NCBI directory containing genomic.gff and cds_from_genomic.fna; "
|
| 1040 |
+
"optional sequence_report.jsonl enables nuclear-record selection"
|
| 1041 |
+
),
|
| 1042 |
+
)
|
| 1043 |
+
parser.add_argument(
|
| 1044 |
+
"--codon-table",
|
| 1045 |
+
default=None,
|
| 1046 |
+
help=(
|
| 1047 |
+
"JSON translation table for predicted and reference CDS; defaults "
|
| 1048 |
+
"to codon_tables/<accession>.json when present, otherwise "
|
| 1049 |
+
"codon_tables/standard.json"
|
| 1050 |
+
),
|
| 1051 |
+
)
|
| 1052 |
+
parser.add_argument(
|
| 1053 |
+
"--confidence-threshold",
|
| 1054 |
+
type=float,
|
| 1055 |
+
default=CONFIDENCE_THRESHOLD,
|
| 1056 |
+
help=(
|
| 1057 |
+
"gene-level confidence threshold: gene and protein metrics count "
|
| 1058 |
+
"predicted genes with confidence at or above this value "
|
| 1059 |
+
f"(default {CONFIDENCE_THRESHOLD}); genes without a confidence are "
|
| 1060 |
+
"always counted and 0 counts every gene"
|
| 1061 |
+
),
|
| 1062 |
+
)
|
| 1063 |
+
parser.add_argument(
|
| 1064 |
+
"--output-json",
|
| 1065 |
+
help="atomically write the metrics to this JSON file",
|
| 1066 |
+
)
|
| 1067 |
+
return parser.parse_args()
|
| 1068 |
+
|
| 1069 |
+
|
| 1070 |
+
def main():
|
| 1071 |
+
"""Command-line entry point."""
|
| 1072 |
+
args = parse_args()
|
| 1073 |
+
if (
|
| 1074 |
+
not math.isfinite(args.confidence_threshold)
|
| 1075 |
+
or not 0.0 <= args.confidence_threshold <= 1.0
|
| 1076 |
+
):
|
| 1077 |
+
raise SystemExit("--confidence-threshold must be between 0 and 1")
|
| 1078 |
+
|
| 1079 |
+
metrics = evaluate_prediction(
|
| 1080 |
+
args.pred,
|
| 1081 |
+
args.genome_dir,
|
| 1082 |
+
confidence_threshold=args.confidence_threshold,
|
| 1083 |
+
codon_table_path=args.codon_table,
|
| 1084 |
+
)
|
| 1085 |
+
print_evaluation_metrics(metrics)
|
| 1086 |
+
if args.output_json:
|
| 1087 |
+
write_evaluation_metrics_json(args.output_json, metrics)
|
| 1088 |
+
print(f"[evaluation] wrote {args.output_json}")
|
| 1089 |
+
return metrics
|
| 1090 |
+
|
| 1091 |
+
|
| 1092 |
+
if __name__ == "__main__":
|
| 1093 |
+
main()
|
annotation_pipeline/src/model_inference.py
ADDED
|
@@ -0,0 +1,1067 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
import os
|
| 2 |
+
import argparse
|
| 3 |
+
import datetime
|
| 4 |
+
import gzip
|
| 5 |
+
import signal
|
| 6 |
+
import threading
|
| 7 |
+
import queue
|
| 8 |
+
import time
|
| 9 |
+
from typing import Any, Dict, IO, List, Optional, Sequence, Tuple
|
| 10 |
+
|
| 11 |
+
import numpy as np
|
| 12 |
+
import pandas as pd
|
| 13 |
+
|
| 14 |
+
try:
|
| 15 |
+
import pyarrow as pa
|
| 16 |
+
import pyarrow.parquet as pq
|
| 17 |
+
|
| 18 |
+
HAS_PYARROW = True
|
| 19 |
+
except ImportError:
|
| 20 |
+
pa = None
|
| 21 |
+
pq = None
|
| 22 |
+
HAS_PYARROW = False
|
| 23 |
+
import torch
|
| 24 |
+
import torch.multiprocessing as mp
|
| 25 |
+
from tqdm import tqdm
|
| 26 |
+
from transformers import (
|
| 27 |
+
AutoModelForTokenClassification,
|
| 28 |
+
AutoTokenizer,
|
| 29 |
+
PreTrainedModel,
|
| 30 |
+
PreTrainedTokenizer,
|
| 31 |
+
)
|
| 32 |
+
|
| 33 |
+
from constants import (
|
| 34 |
+
CONTEXT_LENGTH,
|
| 35 |
+
HEAD_NAMES,
|
| 36 |
+
MODEL_NAME,
|
| 37 |
+
OVERLAP_LENGTH,
|
| 38 |
+
)
|
| 39 |
+
|
| 40 |
+
FASTA_SUFFIXES = (".fasta", ".fa", ".fna", ".ffn", ".faa", ".fas")
|
| 41 |
+
GENBANK_SUFFIXES = (".gb", ".gbk", ".gbff")
|
| 42 |
+
|
| 43 |
+
|
| 44 |
+
def parse_arguments() -> argparse.Namespace:
|
| 45 |
+
parser = argparse.ArgumentParser(
|
| 46 |
+
description="Pure model inference for CDS annotation. Outputs raw softmax probabilities to parquet.",
|
| 47 |
+
)
|
| 48 |
+
|
| 49 |
+
parser.add_argument(
|
| 50 |
+
"--input",
|
| 51 |
+
type=str,
|
| 52 |
+
nargs="+",
|
| 53 |
+
required=True,
|
| 54 |
+
help="Genome FASTA or GenBank files (optionally gzip-compressed) or directories",
|
| 55 |
+
)
|
| 56 |
+
parser.add_argument(
|
| 57 |
+
"--output_path", type=str, default="./annotation_results", help="Output directory"
|
| 58 |
+
)
|
| 59 |
+
|
| 60 |
+
parser.add_argument(
|
| 61 |
+
"--model_name",
|
| 62 |
+
type=str,
|
| 63 |
+
default=MODEL_NAME,
|
| 64 |
+
help="Hugging Face model id or local model directory",
|
| 65 |
+
)
|
| 66 |
+
parser.add_argument(
|
| 67 |
+
"--revision",
|
| 68 |
+
type=str,
|
| 69 |
+
default=None,
|
| 70 |
+
help=(
|
| 71 |
+
"Model revision on the Hugging Face Hub (branch, tag, or commit); it "
|
| 72 |
+
"applies to the weights and the model code. Default: the latest revision."
|
| 73 |
+
),
|
| 74 |
+
)
|
| 75 |
+
parser.add_argument(
|
| 76 |
+
"--batch_size", type=int, default=8, help="Batch size for inference"
|
| 77 |
+
)
|
| 78 |
+
parser.add_argument(
|
| 79 |
+
"--dispatch_chunk_size",
|
| 80 |
+
type=int,
|
| 81 |
+
default=4,
|
| 82 |
+
help=(
|
| 83 |
+
"Number of sequences per dynamic scheduler task. Smaller values improve "
|
| 84 |
+
"load balancing/work stealing; larger values improve batching locality."
|
| 85 |
+
),
|
| 86 |
+
)
|
| 87 |
+
parser.add_argument(
|
| 88 |
+
"--gpu_count", type=int, default=-1, help="Number of GPUs to use (-1 for all)"
|
| 89 |
+
)
|
| 90 |
+
parser.add_argument(
|
| 91 |
+
"--attn_implementation",
|
| 92 |
+
type=str,
|
| 93 |
+
choices=["eager", "sdpa", "flash_attention_2"],
|
| 94 |
+
default="flash_attention_2",
|
| 95 |
+
help=(
|
| 96 |
+
"Attention implementation. flash_attention_2 (default) was used for all "
|
| 97 |
+
"reported results; sdpa needs no flash-attn package, but its "
|
| 98 |
+
"probabilities are not bit-identical."
|
| 99 |
+
),
|
| 100 |
+
)
|
| 101 |
+
|
| 102 |
+
parser.add_argument(
|
| 103 |
+
"--context_length",
|
| 104 |
+
type=int,
|
| 105 |
+
default=CONTEXT_LENGTH,
|
| 106 |
+
help="Context length in tokens",
|
| 107 |
+
)
|
| 108 |
+
parser.add_argument(
|
| 109 |
+
"--overlap_length",
|
| 110 |
+
type=int,
|
| 111 |
+
default=OVERLAP_LENGTH,
|
| 112 |
+
help="Overlap length in tokens",
|
| 113 |
+
)
|
| 114 |
+
|
| 115 |
+
parser.add_argument(
|
| 116 |
+
"--prob_dtype",
|
| 117 |
+
type=str,
|
| 118 |
+
choices=["float16", "float32"],
|
| 119 |
+
default="float16",
|
| 120 |
+
help="Data type for saved CDS probabilities in parquet",
|
| 121 |
+
)
|
| 122 |
+
parser.add_argument(
|
| 123 |
+
"--save_non_coding_prob",
|
| 124 |
+
action="store_true",
|
| 125 |
+
help=(
|
| 126 |
+
"Also save non-coding probability. By default only P(CDS) is stored, which is lossless for binary softmax "
|
| 127 |
+
"because P(non_coding)=1-P(CDS)."
|
| 128 |
+
),
|
| 129 |
+
)
|
| 130 |
+
parser.add_argument(
|
| 131 |
+
"--limit", type=int, default=None, help="Limit number of sequences (debug)"
|
| 132 |
+
)
|
| 133 |
+
|
| 134 |
+
return parser.parse_args()
|
| 135 |
+
|
| 136 |
+
|
| 137 |
+
def open_text(path: str, errors: str = "strict") -> IO[str]:
|
| 138 |
+
"""Open a text file, decompressing it when the name ends with .gz."""
|
| 139 |
+
if path.lower().endswith(".gz"):
|
| 140 |
+
return gzip.open(path, "rt", encoding="utf-8", errors=errors)
|
| 141 |
+
return open(path, "r", encoding="utf-8", errors=errors)
|
| 142 |
+
|
| 143 |
+
|
| 144 |
+
def strip_gzip_suffix(name: str) -> str:
|
| 145 |
+
return name[: -len(".gz")] if name.lower().endswith(".gz") else name
|
| 146 |
+
|
| 147 |
+
|
| 148 |
+
def parse_fasta_from_stream(stream: IO[str]) -> List[Tuple[str, str]]:
|
| 149 |
+
records: List[Tuple[str, str]] = []
|
| 150 |
+
header: Optional[str] = None
|
| 151 |
+
seq_lines: List[str] = []
|
| 152 |
+
for line in stream:
|
| 153 |
+
line = line.strip()
|
| 154 |
+
if line.startswith(">"):
|
| 155 |
+
if header is not None:
|
| 156 |
+
records.append((header, "".join(seq_lines).upper()))
|
| 157 |
+
header, seq_lines = line, []
|
| 158 |
+
else:
|
| 159 |
+
seq_lines.append(line)
|
| 160 |
+
if header is not None:
|
| 161 |
+
records.append((header, "".join(seq_lines).upper()))
|
| 162 |
+
return records
|
| 163 |
+
|
| 164 |
+
|
| 165 |
+
def read_fasta(path: str) -> List[Tuple[str, str]]:
|
| 166 |
+
with open_text(path) as f:
|
| 167 |
+
records = parse_fasta_from_stream(f)
|
| 168 |
+
print(f"✅ Read {len(records)} sequences from local file: {path}")
|
| 169 |
+
return records
|
| 170 |
+
|
| 171 |
+
|
| 172 |
+
def read_genbank(path: str) -> List[Tuple[str, str]]:
|
| 173 |
+
"""Read the sequence of every GenBank record; the record name is its accession.version."""
|
| 174 |
+
from Bio import SeqIO
|
| 175 |
+
from Bio.Seq import UndefinedSequenceError
|
| 176 |
+
|
| 177 |
+
records: List[Tuple[str, str]] = []
|
| 178 |
+
with open_text(path) as f:
|
| 179 |
+
for record in SeqIO.parse(f, "genbank"):
|
| 180 |
+
try:
|
| 181 |
+
sequence = str(record.seq).upper()
|
| 182 |
+
except UndefinedSequenceError:
|
| 183 |
+
raise ValueError(
|
| 184 |
+
f"GenBank record {record.id} in '{path}' contains no sequence"
|
| 185 |
+
) from None
|
| 186 |
+
records.append((f">{record.id}", sequence))
|
| 187 |
+
print(f"✅ Read {len(records)} sequences from GenBank file: {path}")
|
| 188 |
+
return records
|
| 189 |
+
|
| 190 |
+
|
| 191 |
+
def sniff_input_format(path: str, lines_to_check: int = 50) -> Optional[str]:
|
| 192 |
+
with open_text(path, errors="ignore") as f:
|
| 193 |
+
for _ in range(lines_to_check):
|
| 194 |
+
line = f.readline()
|
| 195 |
+
if not line:
|
| 196 |
+
break
|
| 197 |
+
line = line.strip()
|
| 198 |
+
if line:
|
| 199 |
+
if line.startswith(">"):
|
| 200 |
+
return "fasta"
|
| 201 |
+
if line.startswith("LOCUS"):
|
| 202 |
+
return "genbank"
|
| 203 |
+
return None
|
| 204 |
+
return None
|
| 205 |
+
|
| 206 |
+
|
| 207 |
+
def detect_input_format(path: str) -> str:
|
| 208 |
+
name = strip_gzip_suffix(path.lower())
|
| 209 |
+
if name.endswith(FASTA_SUFFIXES):
|
| 210 |
+
return "fasta"
|
| 211 |
+
if name.endswith(GENBANK_SUFFIXES):
|
| 212 |
+
return "genbank"
|
| 213 |
+
input_format = sniff_input_format(path)
|
| 214 |
+
if input_format is None:
|
| 215 |
+
raise ValueError(
|
| 216 |
+
f"Unsupported input format for '{path}'. Please provide a FASTA or GenBank file."
|
| 217 |
+
)
|
| 218 |
+
return input_format
|
| 219 |
+
|
| 220 |
+
|
| 221 |
+
def resolve_input_paths(input_items: Sequence[str]) -> List[str]:
|
| 222 |
+
supported_suffixes = FASTA_SUFFIXES + GENBANK_SUFFIXES
|
| 223 |
+
resolved: List[str] = []
|
| 224 |
+
for item in input_items:
|
| 225 |
+
if os.path.isdir(item):
|
| 226 |
+
files_in_dir: List[str] = []
|
| 227 |
+
for root, _, files in os.walk(item):
|
| 228 |
+
for name in files:
|
| 229 |
+
if strip_gzip_suffix(name.lower()).endswith(supported_suffixes):
|
| 230 |
+
files_in_dir.append(os.path.join(root, name))
|
| 231 |
+
resolved.extend(sorted(files_in_dir))
|
| 232 |
+
continue
|
| 233 |
+
|
| 234 |
+
resolved.append(item)
|
| 235 |
+
return resolved
|
| 236 |
+
|
| 237 |
+
|
| 238 |
+
def get_record_name(header: str) -> str:
|
| 239 |
+
text = header[1:] if header.startswith(">") else header
|
| 240 |
+
text = text.strip()
|
| 241 |
+
return text.split()[0] if text else "record"
|
| 242 |
+
|
| 243 |
+
|
| 244 |
+
def pyarrow_list_array_from_ndarrays(
|
| 245 |
+
arrays: Sequence[np.ndarray], value_type: "pa.DataType"
|
| 246 |
+
) -> "pa.Array":
|
| 247 |
+
assert HAS_PYARROW and pa is not None
|
| 248 |
+
return pa.array(
|
| 249 |
+
[pa.array(np.asarray(arr), type=value_type) for arr in arrays],
|
| 250 |
+
type=pa.list_(value_type),
|
| 251 |
+
)
|
| 252 |
+
|
| 253 |
+
|
| 254 |
+
def write_prob_parquet(
|
| 255 |
+
records: Sequence[Tuple[str, str]],
|
| 256 |
+
probs_per_head: Sequence[Sequence[np.ndarray]],
|
| 257 |
+
head_names: Sequence[str],
|
| 258 |
+
output_path: str,
|
| 259 |
+
prob_dtype: str,
|
| 260 |
+
save_non_coding_prob: bool,
|
| 261 |
+
) -> None:
|
| 262 |
+
if not probs_per_head:
|
| 263 |
+
raise ValueError("No probabilities to write.")
|
| 264 |
+
|
| 265 |
+
os.makedirs(os.path.dirname(output_path) or ".", exist_ok=True)
|
| 266 |
+
np_dtype = np.float16 if prob_dtype == "float16" else np.float32
|
| 267 |
+
|
| 268 |
+
record_names = [get_record_name(h) for h, _ in records]
|
| 269 |
+
sequences = [seq for _, seq in records]
|
| 270 |
+
|
| 271 |
+
if HAS_PYARROW and pa is not None and pq is not None:
|
| 272 |
+
value_type = pa.float16() if prob_dtype == "float16" else pa.float32()
|
| 273 |
+
cols: Dict[str, Any] = {}
|
| 274 |
+
cols["record_name"] = pa.array(record_names, type=pa.string())
|
| 275 |
+
cols["sequence"] = pa.array(sequences, type=pa.string())
|
| 276 |
+
|
| 277 |
+
for h, head_name in enumerate(head_names):
|
| 278 |
+
cds_arrays = [np.asarray(arr, dtype=np_dtype) for arr in probs_per_head[h]]
|
| 279 |
+
cols[f"pred_prob_{head_name}_cds"] = pyarrow_list_array_from_ndarrays(
|
| 280 |
+
cds_arrays, value_type
|
| 281 |
+
)
|
| 282 |
+
if save_non_coding_prob:
|
| 283 |
+
nc_arrays = [
|
| 284 |
+
np.asarray(1.0 - arr, dtype=np_dtype) for arr in cds_arrays
|
| 285 |
+
]
|
| 286 |
+
cols[f"pred_prob_{head_name}_non_coding"] = (
|
| 287 |
+
pyarrow_list_array_from_ndarrays(nc_arrays, value_type)
|
| 288 |
+
)
|
| 289 |
+
|
| 290 |
+
table = pa.table(cols)
|
| 291 |
+
pq.write_table(table, output_path, compression="zstd")
|
| 292 |
+
else:
|
| 293 |
+
data: List[Dict[str, Any]] = []
|
| 294 |
+
for i in range(len(records)):
|
| 295 |
+
row: Dict[str, Any] = {
|
| 296 |
+
"record_name": record_names[i],
|
| 297 |
+
"sequence": sequences[i],
|
| 298 |
+
}
|
| 299 |
+
for h, head_name in enumerate(head_names):
|
| 300 |
+
row[f"pred_prob_{head_name}_cds"] = np.asarray(
|
| 301 |
+
probs_per_head[h][i], dtype=np_dtype
|
| 302 |
+
).tolist()
|
| 303 |
+
if save_non_coding_prob:
|
| 304 |
+
row[f"pred_prob_{head_name}_non_coding"] = np.asarray(
|
| 305 |
+
1.0 - probs_per_head[h][i], dtype=np_dtype
|
| 306 |
+
).tolist()
|
| 307 |
+
data.append(row)
|
| 308 |
+
df = pd.DataFrame(data)
|
| 309 |
+
df.to_parquet(output_path, index=False)
|
| 310 |
+
|
| 311 |
+
print(f"✅ Raw probability parquet written to {output_path}")
|
| 312 |
+
|
| 313 |
+
|
| 314 |
+
def setup_model_for_gpu(
|
| 315 |
+
model_name: str,
|
| 316 |
+
gpu_id: int,
|
| 317 |
+
dtype_str: str,
|
| 318 |
+
attn_implementation: Optional[str] = None,
|
| 319 |
+
revision: Optional[str] = None,
|
| 320 |
+
) -> Tuple[PreTrainedModel, PreTrainedTokenizer, torch.device]:
|
| 321 |
+
revision_note = f" (revision {revision})" if revision else ""
|
| 322 |
+
print(f"🤗 Loading model on GPU {gpu_id}: {model_name}{revision_note}")
|
| 323 |
+
start_time = time.time()
|
| 324 |
+
|
| 325 |
+
tokenizer = AutoTokenizer.from_pretrained(
|
| 326 |
+
model_name, revision=revision, trust_remote_code=True
|
| 327 |
+
)
|
| 328 |
+
model_kwargs: Dict[str, Any] = {
|
| 329 |
+
"dtype": getattr(torch, dtype_str),
|
| 330 |
+
"revision": revision,
|
| 331 |
+
"trust_remote_code": True,
|
| 332 |
+
}
|
| 333 |
+
if attn_implementation is not None:
|
| 334 |
+
model_kwargs["attn_implementation"] = attn_implementation
|
| 335 |
+
|
| 336 |
+
model = AutoModelForTokenClassification.from_pretrained(
|
| 337 |
+
model_name,
|
| 338 |
+
**model_kwargs,
|
| 339 |
+
|
| 340 |
+
)
|
| 341 |
+
|
| 342 |
+
device = torch.device(f"cuda:{gpu_id}" if gpu_id >= 0 else "cpu")
|
| 343 |
+
model.to(device)
|
| 344 |
+
model.eval()
|
| 345 |
+
|
| 346 |
+
total_params = sum(p.numel() for p in model.parameters())
|
| 347 |
+
effective_attn = getattr(model.config, "_attn_implementation", None)
|
| 348 |
+
first_attn_class = None
|
| 349 |
+
base_model = getattr(model, "model", None)
|
| 350 |
+
layers = getattr(base_model, "layers", None)
|
| 351 |
+
if layers:
|
| 352 |
+
first_attn = getattr(layers[0], "self_attn", None)
|
| 353 |
+
if first_attn is not None:
|
| 354 |
+
first_attn_class = type(first_attn).__name__
|
| 355 |
+
print(f"📊 Device {device} model size: {total_params / 1e6:.1f}M parameters")
|
| 356 |
+
print(
|
| 357 |
+
f"🧠 Attention implementation: requested={attn_implementation or 'default'} "
|
| 358 |
+
f"effective={effective_attn} first_layer={first_attn_class}"
|
| 359 |
+
)
|
| 360 |
+
print(f"⏱️ Model loading completed in {time.time() - start_time:.2f} seconds")
|
| 361 |
+
return model, tokenizer, device
|
| 362 |
+
|
| 363 |
+
|
| 364 |
+
def distribute_sequences_to_gpus(
|
| 365 |
+
records: Sequence[Tuple[str, str]],
|
| 366 |
+
gpu_count: int,
|
| 367 |
+
input_ids_per_record: Optional[Sequence[np.ndarray]] = None,
|
| 368 |
+
) -> Tuple[List[List[Tuple[str, str]]], Optional[List[List[np.ndarray]]]]:
|
| 369 |
+
total = len(records)
|
| 370 |
+
# Use contiguous, workload-weighted partitioning (by sequence length) so that
|
| 371 |
+
# global record order is preserved while reducing multi-GPU stragglers.
|
| 372 |
+
weights = [max(1, len(seq)) for _, seq in records]
|
| 373 |
+
prefix = [0]
|
| 374 |
+
for w in weights:
|
| 375 |
+
prefix.append(prefix[-1] + w)
|
| 376 |
+
|
| 377 |
+
def range_weight(start: int, end: int) -> int:
|
| 378 |
+
return prefix[end] - prefix[start]
|
| 379 |
+
|
| 380 |
+
gpu_sequences: List[List[Tuple[str, str]]] = []
|
| 381 |
+
gpu_input_ids: Optional[List[List[np.ndarray]]] = (
|
| 382 |
+
[] if input_ids_per_record is not None else None
|
| 383 |
+
)
|
| 384 |
+
|
| 385 |
+
cursor = 0
|
| 386 |
+
for gpu_id in range(gpu_count):
|
| 387 |
+
remaining_gpus = gpu_count - gpu_id
|
| 388 |
+
remaining_records = total - cursor
|
| 389 |
+
|
| 390 |
+
if remaining_records <= 0:
|
| 391 |
+
end = cursor
|
| 392 |
+
elif remaining_gpus == 1:
|
| 393 |
+
end = total
|
| 394 |
+
else:
|
| 395 |
+
max_end = total - (remaining_gpus - 1) # leave >=1 record per later GPU
|
| 396 |
+
remaining_weight = range_weight(cursor, total)
|
| 397 |
+
target = remaining_weight / remaining_gpus
|
| 398 |
+
|
| 399 |
+
end = cursor
|
| 400 |
+
accum = 0
|
| 401 |
+
while end < max_end:
|
| 402 |
+
next_w = weights[end]
|
| 403 |
+
if end > cursor and accum >= target:
|
| 404 |
+
break
|
| 405 |
+
accum += next_w
|
| 406 |
+
end += 1
|
| 407 |
+
|
| 408 |
+
if end > cursor + 1:
|
| 409 |
+
with_last = abs(accum - target)
|
| 410 |
+
without_last = abs((accum - weights[end - 1]) - target)
|
| 411 |
+
if without_last <= with_last:
|
| 412 |
+
end -= 1
|
| 413 |
+
|
| 414 |
+
chunk = list(records[cursor:end])
|
| 415 |
+
gpu_sequences.append(chunk)
|
| 416 |
+
if gpu_input_ids is not None:
|
| 417 |
+
gpu_input_ids.append(list(input_ids_per_record[cursor:end]))
|
| 418 |
+
|
| 419 |
+
assigned_bp = range_weight(cursor, end)
|
| 420 |
+
print(
|
| 421 |
+
f"📋 GPU {gpu_id} assigned {len(chunk)} sequences (~{assigned_bp} bp)"
|
| 422 |
+
)
|
| 423 |
+
cursor = end
|
| 424 |
+
|
| 425 |
+
return gpu_sequences, gpu_input_ids
|
| 426 |
+
|
| 427 |
+
|
| 428 |
+
@torch.no_grad()
|
| 429 |
+
def process_sequences_on_gpu(
|
| 430 |
+
sequences: Sequence[Tuple[str, str]],
|
| 431 |
+
model: PreTrainedModel,
|
| 432 |
+
tokenizer: PreTrainedTokenizer,
|
| 433 |
+
device: torch.device,
|
| 434 |
+
max_length: int,
|
| 435 |
+
overlap_length: int,
|
| 436 |
+
micro_batch_size: int,
|
| 437 |
+
progress_event_queue: mp.Queue,
|
| 438 |
+
) -> List[List[np.ndarray]]:
|
| 439 |
+
pad_id = tokenizer.pad_token_id
|
| 440 |
+
num_heads = getattr(model, "num_prediction_heads", 1)
|
| 441 |
+
tokenizer_k = getattr(tokenizer, "k", 1) or 1
|
| 442 |
+
|
| 443 |
+
max_char_length = max_length * tokenizer_k
|
| 444 |
+
overlap_char_length = overlap_length * tokenizer_k
|
| 445 |
+
num_sequences = len(sequences)
|
| 446 |
+
|
| 447 |
+
prob_per_head: List[List[np.ndarray]] = [
|
| 448 |
+
[np.empty((0,), dtype=np.float32) for _ in range(num_sequences)]
|
| 449 |
+
for _ in range(num_heads)
|
| 450 |
+
]
|
| 451 |
+
|
| 452 |
+
ChunkTask = Tuple[int, int, int, List[int], List[int]]
|
| 453 |
+
seq_cds_sums: List[Optional[List[torch.Tensor]]] = [None] * num_sequences
|
| 454 |
+
seq_overlap_cnt: List[Optional[torch.Tensor]] = [None] * num_sequences
|
| 455 |
+
remaining_chunks = [0] * num_sequences
|
| 456 |
+
full_pending: List[ChunkTask] = []
|
| 457 |
+
padded_pending: List[ChunkTask] = []
|
| 458 |
+
|
| 459 |
+
def windows_for_sequence(seq_len: int) -> List[Tuple[int, int]]:
|
| 460 |
+
windows: List[Tuple[int, int]] = []
|
| 461 |
+
if seq_len < max_char_length:
|
| 462 |
+
main_len = (seq_len // tokenizer_k) * tokenizer_k
|
| 463 |
+
if main_len:
|
| 464 |
+
windows.append((0, main_len))
|
| 465 |
+
tail_offset = seq_len % tokenizer_k
|
| 466 |
+
if tail_offset:
|
| 467 |
+
windows.append((tail_offset, seq_len - tail_offset))
|
| 468 |
+
else:
|
| 469 |
+
seq_start = 0
|
| 470 |
+
seq_work_len = seq_len
|
| 471 |
+
while True:
|
| 472 |
+
char_length = min(max_char_length, seq_work_len)
|
| 473 |
+
windows.append((seq_start, char_length))
|
| 474 |
+
if char_length == seq_work_len:
|
| 475 |
+
break
|
| 476 |
+
if (
|
| 477 |
+
seq_work_len - max_char_length + overlap_char_length
|
| 478 |
+
< max_char_length
|
| 479 |
+
):
|
| 480 |
+
seq_start = seq_len - max_char_length
|
| 481 |
+
seq_work_len = max_char_length
|
| 482 |
+
else:
|
| 483 |
+
step_chars = max_char_length - overlap_char_length
|
| 484 |
+
seq_start += step_chars
|
| 485 |
+
seq_work_len -= step_chars
|
| 486 |
+
return windows
|
| 487 |
+
|
| 488 |
+
def finalize_sequence(seq_idx: int) -> None:
|
| 489 |
+
counts = seq_overlap_cnt[seq_idx]
|
| 490 |
+
sums = seq_cds_sums[seq_idx]
|
| 491 |
+
if counts is None or sums is None:
|
| 492 |
+
raise RuntimeError(f"Missing accumulation buffers for sequence {seq_idx}")
|
| 493 |
+
if torch.any(counts == 0):
|
| 494 |
+
raise RuntimeError(f"Sequence {seq_idx} has uncovered base positions")
|
| 495 |
+
|
| 496 |
+
denom = counts.to(dtype=torch.float32)
|
| 497 |
+
for h in range(num_heads):
|
| 498 |
+
prob_per_head[h][seq_idx] = (
|
| 499 |
+
(sums[h] / denom).numpy().astype(np.float32, copy=False)
|
| 500 |
+
)
|
| 501 |
+
seq_overlap_cnt[seq_idx] = None
|
| 502 |
+
seq_cds_sums[seq_idx] = None
|
| 503 |
+
progress_event_queue.put("infer")
|
| 504 |
+
|
| 505 |
+
def run_batch(batch: Sequence[ChunkTask]) -> None:
|
| 506 |
+
if not batch:
|
| 507 |
+
return
|
| 508 |
+
inp = torch.tensor([task[3] for task in batch], dtype=torch.long).to(device)
|
| 509 |
+
att = torch.tensor([task[4] for task in batch], dtype=torch.long).to(device)
|
| 510 |
+
|
| 511 |
+
# Count total tokens in this batch (sum of attention mask)
|
| 512 |
+
total_tokens = att.sum().item()
|
| 513 |
+
|
| 514 |
+
# Full-window batches carry all-ones masks. GENERanno's token-
|
| 515 |
+
# classification head needs the mask for output sizing, and the base
|
| 516 |
+
# model drops all-ones masks internally so FA2 can avoid padding-mask
|
| 517 |
+
# fallback. Padded windows are kept in separate masked batches.
|
| 518 |
+
logits = model(input_ids=inp, attention_mask=att).logits
|
| 519 |
+
probs = logits.softmax(dim=-1).cpu()
|
| 520 |
+
|
| 521 |
+
for local_i, (seq_idx, char_start, char_length, _, mask) in enumerate(batch):
|
| 522 |
+
valid_len = int(sum(mask)) * tokenizer_k
|
| 523 |
+
if valid_len != char_length:
|
| 524 |
+
raise RuntimeError(
|
| 525 |
+
f"Chunk length mismatch: valid_len={valid_len}, "
|
| 526 |
+
f"char_length={char_length}"
|
| 527 |
+
)
|
| 528 |
+
total_pred_len = valid_len * num_heads
|
| 529 |
+
chunk_probs_all_heads = probs[local_i, :total_pred_len]
|
| 530 |
+
chunk_probs_all_heads = chunk_probs_all_heads.view(
|
| 531 |
+
num_heads, valid_len, -1
|
| 532 |
+
)
|
| 533 |
+
|
| 534 |
+
counts = seq_overlap_cnt[seq_idx]
|
| 535 |
+
sums = seq_cds_sums[seq_idx]
|
| 536 |
+
if counts is None or sums is None:
|
| 537 |
+
raise RuntimeError(
|
| 538 |
+
f"Missing accumulation buffers for sequence {seq_idx}"
|
| 539 |
+
)
|
| 540 |
+
counts[char_start : char_start + char_length] += 1
|
| 541 |
+
for h in range(num_heads):
|
| 542 |
+
sums[h][char_start : char_start + char_length] += (
|
| 543 |
+
chunk_probs_all_heads[h, :, 1].float()
|
| 544 |
+
)
|
| 545 |
+
|
| 546 |
+
remaining_chunks[seq_idx] -= 1
|
| 547 |
+
if remaining_chunks[seq_idx] == 0:
|
| 548 |
+
finalize_sequence(seq_idx)
|
| 549 |
+
|
| 550 |
+
def flush_ready(pending: List[ChunkTask]) -> None:
|
| 551 |
+
while len(pending) >= micro_batch_size:
|
| 552 |
+
batch = pending[:micro_batch_size]
|
| 553 |
+
del pending[:micro_batch_size]
|
| 554 |
+
run_batch(batch)
|
| 555 |
+
|
| 556 |
+
def flush_all(pending: List[ChunkTask]) -> None:
|
| 557 |
+
while pending:
|
| 558 |
+
batch = pending[:micro_batch_size]
|
| 559 |
+
del pending[:micro_batch_size]
|
| 560 |
+
run_batch(batch)
|
| 561 |
+
|
| 562 |
+
for seq_idx, (_, seq) in enumerate(sequences):
|
| 563 |
+
if len(seq) < tokenizer_k:
|
| 564 |
+
for h in range(num_heads):
|
| 565 |
+
prob_per_head[h][seq_idx] = np.zeros((len(seq),), dtype=np.float32)
|
| 566 |
+
progress_event_queue.put("infer")
|
| 567 |
+
continue
|
| 568 |
+
|
| 569 |
+
windows = windows_for_sequence(len(seq))
|
| 570 |
+
if not windows:
|
| 571 |
+
for h in range(num_heads):
|
| 572 |
+
prob_per_head[h][seq_idx] = np.zeros((len(seq),), dtype=np.float32)
|
| 573 |
+
progress_event_queue.put("infer")
|
| 574 |
+
continue
|
| 575 |
+
|
| 576 |
+
remaining_chunks[seq_idx] = len(windows)
|
| 577 |
+
seq_overlap_cnt[seq_idx] = torch.zeros(len(seq), dtype=torch.long)
|
| 578 |
+
seq_cds_sums[seq_idx] = [
|
| 579 |
+
torch.zeros(len(seq), dtype=torch.float32) for _ in range(num_heads)
|
| 580 |
+
]
|
| 581 |
+
|
| 582 |
+
for char_start, char_length in windows:
|
| 583 |
+
chrs = seq[char_start : char_start + char_length]
|
| 584 |
+
chunk_seq = tokenizer(chrs, add_special_tokens=False)["input_ids"]
|
| 585 |
+
|
| 586 |
+
if len(chunk_seq) > max_length:
|
| 587 |
+
raise ValueError(
|
| 588 |
+
f"Chunk has {len(chunk_seq)} tokens, exceeding max_length={max_length}"
|
| 589 |
+
)
|
| 590 |
+
valid_token_len = len(chunk_seq)
|
| 591 |
+
if valid_token_len * tokenizer_k != char_length:
|
| 592 |
+
raise RuntimeError(
|
| 593 |
+
f"Tokenized chunk length mismatch: {valid_token_len} tokens "
|
| 594 |
+
f"for {char_length} bp"
|
| 595 |
+
)
|
| 596 |
+
pad_len = max_length - valid_token_len
|
| 597 |
+
mask = [1] * valid_token_len + [0] * pad_len
|
| 598 |
+
task: ChunkTask = (
|
| 599 |
+
seq_idx,
|
| 600 |
+
char_start,
|
| 601 |
+
char_length,
|
| 602 |
+
chunk_seq + [pad_id] * pad_len,
|
| 603 |
+
mask,
|
| 604 |
+
)
|
| 605 |
+
if valid_token_len == max_length:
|
| 606 |
+
full_pending.append(task)
|
| 607 |
+
flush_ready(full_pending)
|
| 608 |
+
else:
|
| 609 |
+
padded_pending.append(task)
|
| 610 |
+
flush_ready(padded_pending)
|
| 611 |
+
|
| 612 |
+
flush_all(full_pending)
|
| 613 |
+
flush_all(padded_pending)
|
| 614 |
+
|
| 615 |
+
return prob_per_head
|
| 616 |
+
|
| 617 |
+
|
| 618 |
+
def persistent_worker_process(
|
| 619 |
+
gpu_id: int,
|
| 620 |
+
model_name: str,
|
| 621 |
+
revision: Optional[str],
|
| 622 |
+
dtype_str: str,
|
| 623 |
+
attn_implementation: Optional[str],
|
| 624 |
+
max_length: int,
|
| 625 |
+
overlap_length: int,
|
| 626 |
+
micro_batch_size: int,
|
| 627 |
+
task_queue: mp.Queue,
|
| 628 |
+
result_queue: mp.Queue,
|
| 629 |
+
progress_event_queue: mp.Queue,
|
| 630 |
+
) -> None:
|
| 631 |
+
job_id = -1
|
| 632 |
+
task_id = -1
|
| 633 |
+
try:
|
| 634 |
+
model: Optional[PreTrainedModel] = None
|
| 635 |
+
tokenizer: Optional[PreTrainedTokenizer] = None
|
| 636 |
+
device: Optional[torch.device] = None
|
| 637 |
+
|
| 638 |
+
while True:
|
| 639 |
+
task_item = task_queue.get()
|
| 640 |
+
if task_item is None:
|
| 641 |
+
break
|
| 642 |
+
|
| 643 |
+
job_id, task_id, task_seq_indices, sequences = task_item
|
| 644 |
+
if model is None:
|
| 645 |
+
model, tokenizer, device = setup_model_for_gpu(
|
| 646 |
+
model_name, gpu_id, dtype_str, attn_implementation, revision
|
| 647 |
+
)
|
| 648 |
+
assert tokenizer is not None and device is not None and model is not None
|
| 649 |
+
gpu_results = process_sequences_on_gpu(
|
| 650 |
+
sequences=sequences,
|
| 651 |
+
model=model,
|
| 652 |
+
tokenizer=tokenizer,
|
| 653 |
+
device=device,
|
| 654 |
+
max_length=max_length,
|
| 655 |
+
overlap_length=overlap_length,
|
| 656 |
+
micro_batch_size=micro_batch_size,
|
| 657 |
+
progress_event_queue=progress_event_queue,
|
| 658 |
+
)
|
| 659 |
+
result_queue.put(
|
| 660 |
+
("ok", job_id, gpu_id, task_id, task_seq_indices, gpu_results)
|
| 661 |
+
)
|
| 662 |
+
except KeyboardInterrupt:
|
| 663 |
+
pass
|
| 664 |
+
except Exception as e:
|
| 665 |
+
print(f"❌ Error in persistent GPU {gpu_id} worker: {e}")
|
| 666 |
+
result_queue.put(("error", job_id, gpu_id, task_id, str(e)))
|
| 667 |
+
|
| 668 |
+
|
| 669 |
+
def create_persistent_worker_runtime(
|
| 670 |
+
model_name: str,
|
| 671 |
+
revision: Optional[str],
|
| 672 |
+
dtype_str: str,
|
| 673 |
+
attn_implementation: Optional[str],
|
| 674 |
+
gpu_count: int,
|
| 675 |
+
max_length: int,
|
| 676 |
+
overlap_length: int,
|
| 677 |
+
micro_batch_size: int,
|
| 678 |
+
) -> Dict[str, Any]:
|
| 679 |
+
task_queue = mp.Queue()
|
| 680 |
+
result_queue = mp.Queue()
|
| 681 |
+
progress_event_queue = mp.Queue()
|
| 682 |
+
|
| 683 |
+
processes: List[mp.Process] = []
|
| 684 |
+
for gpu_id in range(gpu_count):
|
| 685 |
+
p = mp.Process(
|
| 686 |
+
target=persistent_worker_process,
|
| 687 |
+
args=(
|
| 688 |
+
gpu_id,
|
| 689 |
+
model_name,
|
| 690 |
+
revision,
|
| 691 |
+
dtype_str,
|
| 692 |
+
attn_implementation,
|
| 693 |
+
max_length,
|
| 694 |
+
overlap_length,
|
| 695 |
+
micro_batch_size,
|
| 696 |
+
task_queue,
|
| 697 |
+
result_queue,
|
| 698 |
+
progress_event_queue,
|
| 699 |
+
),
|
| 700 |
+
)
|
| 701 |
+
p.start()
|
| 702 |
+
processes.append(p)
|
| 703 |
+
|
| 704 |
+
return {
|
| 705 |
+
"gpu_count": gpu_count,
|
| 706 |
+
"task_queue": task_queue,
|
| 707 |
+
"result_queue": result_queue,
|
| 708 |
+
"progress_event_queue": progress_event_queue,
|
| 709 |
+
"processes": processes,
|
| 710 |
+
"next_job_id": 0,
|
| 711 |
+
}
|
| 712 |
+
|
| 713 |
+
|
| 714 |
+
def shutdown_persistent_worker_runtime(
|
| 715 |
+
runtime: Dict[str, Any], interrupted: bool = False
|
| 716 |
+
) -> None:
|
| 717 |
+
task_queue = runtime["task_queue"]
|
| 718 |
+
result_queue = runtime["result_queue"]
|
| 719 |
+
progress_event_queue = runtime["progress_event_queue"]
|
| 720 |
+
processes = runtime["processes"]
|
| 721 |
+
|
| 722 |
+
if interrupted:
|
| 723 |
+
for p in processes:
|
| 724 |
+
if p.is_alive() and p.pid is not None:
|
| 725 |
+
os.kill(p.pid, signal.SIGINT)
|
| 726 |
+
else:
|
| 727 |
+
for _ in processes:
|
| 728 |
+
task_queue.put(None)
|
| 729 |
+
|
| 730 |
+
deadline = time.monotonic() + 30
|
| 731 |
+
for p in processes:
|
| 732 |
+
p.join(timeout=max(0, deadline - time.monotonic()))
|
| 733 |
+
for p in processes:
|
| 734 |
+
if p.is_alive():
|
| 735 |
+
p.terminate()
|
| 736 |
+
p.join()
|
| 737 |
+
|
| 738 |
+
task_queue.close()
|
| 739 |
+
# Tasks left behind by a worker that failed (for example to load the
|
| 740 |
+
# model) can no longer be read; waiting for them to be sent would hang.
|
| 741 |
+
task_queue.cancel_join_thread()
|
| 742 |
+
result_queue.close()
|
| 743 |
+
result_queue.join_thread()
|
| 744 |
+
progress_event_queue.close()
|
| 745 |
+
progress_event_queue.join_thread()
|
| 746 |
+
|
| 747 |
+
|
| 748 |
+
def build_dynamic_sequence_tasks(
|
| 749 |
+
records: Sequence[Tuple[str, str]],
|
| 750 |
+
dispatch_chunk_size: int,
|
| 751 |
+
) -> List[Tuple[int, List[int], List[Tuple[str, str]]]]:
|
| 752 |
+
if dispatch_chunk_size <= 0:
|
| 753 |
+
raise ValueError(
|
| 754 |
+
f"--dispatch_chunk_size must be a positive integer, got {dispatch_chunk_size}"
|
| 755 |
+
)
|
| 756 |
+
|
| 757 |
+
# Longest-first scheduling reduces end-of-job stragglers when sequence sizes vary.
|
| 758 |
+
ordered_indices = sorted(range(len(records)), key=lambda i: len(records[i][1]), reverse=True)
|
| 759 |
+
tasks: List[Tuple[int, List[int], List[Tuple[str, str]]]] = []
|
| 760 |
+
for task_id, start in enumerate(range(0, len(ordered_indices), dispatch_chunk_size)):
|
| 761 |
+
seq_indices = ordered_indices[start : start + dispatch_chunk_size]
|
| 762 |
+
seq_chunk = [records[i] for i in seq_indices]
|
| 763 |
+
tasks.append((task_id, seq_indices, seq_chunk))
|
| 764 |
+
return tasks
|
| 765 |
+
|
| 766 |
+
|
| 767 |
+
def infer_records(
|
| 768 |
+
records: Sequence[Tuple[str, str]],
|
| 769 |
+
model_name: str,
|
| 770 |
+
dtype_str: str,
|
| 771 |
+
gpu_count: int,
|
| 772 |
+
max_length: int,
|
| 773 |
+
overlap_length: int,
|
| 774 |
+
micro_batch_size: int,
|
| 775 |
+
dispatch_chunk_size: int,
|
| 776 |
+
persistent_runtime: Optional[Dict[str, Any]] = None,
|
| 777 |
+
attn_implementation: Optional[str] = None,
|
| 778 |
+
revision: Optional[str] = None,
|
| 779 |
+
) -> List[List[np.ndarray]]:
|
| 780 |
+
owns_runtime = False
|
| 781 |
+
runtime = persistent_runtime
|
| 782 |
+
if runtime is None:
|
| 783 |
+
print(f"🚀 Starting persistent GPU workers ({gpu_count} GPUs)")
|
| 784 |
+
runtime = create_persistent_worker_runtime(
|
| 785 |
+
model_name=model_name,
|
| 786 |
+
revision=revision,
|
| 787 |
+
dtype_str=dtype_str,
|
| 788 |
+
attn_implementation=attn_implementation,
|
| 789 |
+
gpu_count=gpu_count,
|
| 790 |
+
max_length=max_length,
|
| 791 |
+
overlap_length=overlap_length,
|
| 792 |
+
micro_batch_size=micro_batch_size,
|
| 793 |
+
)
|
| 794 |
+
owns_runtime = True
|
| 795 |
+
else:
|
| 796 |
+
print(f"🚀 Reusing persistent GPU workers ({gpu_count} GPUs)")
|
| 797 |
+
|
| 798 |
+
if int(runtime["gpu_count"]) != gpu_count:
|
| 799 |
+
raise ValueError(
|
| 800 |
+
f"persistent runtime gpu_count={runtime['gpu_count']} does not match requested gpu_count={gpu_count}"
|
| 801 |
+
)
|
| 802 |
+
|
| 803 |
+
interrupted = False
|
| 804 |
+
try:
|
| 805 |
+
tasks = build_dynamic_sequence_tasks(
|
| 806 |
+
records=records,
|
| 807 |
+
dispatch_chunk_size=dispatch_chunk_size,
|
| 808 |
+
)
|
| 809 |
+
print(
|
| 810 |
+
f"📊 Dynamic scheduling {len(records)} sequences across {gpu_count} GPUs "
|
| 811 |
+
f"using {len(tasks)} task(s) (chunk_size={dispatch_chunk_size})"
|
| 812 |
+
)
|
| 813 |
+
|
| 814 |
+
result_queue = runtime["result_queue"]
|
| 815 |
+
progress_event_queue = runtime["progress_event_queue"]
|
| 816 |
+
task_queue = runtime["task_queue"]
|
| 817 |
+
|
| 818 |
+
job_id = runtime["next_job_id"]
|
| 819 |
+
runtime["next_job_id"] = job_id + 1
|
| 820 |
+
for task_id, seq_indices, seq_chunk in tasks:
|
| 821 |
+
task_queue.put((job_id, task_id, seq_indices, seq_chunk))
|
| 822 |
+
|
| 823 |
+
total_sequences = len(records)
|
| 824 |
+
num_heads = 0
|
| 825 |
+
all_probs_per_head: List[List[Optional[np.ndarray]]] = []
|
| 826 |
+
expected_results = len(tasks)
|
| 827 |
+
received_results = 0
|
| 828 |
+
|
| 829 |
+
def progress_worker() -> None:
|
| 830 |
+
infer_pbar = tqdm(
|
| 831 |
+
total=total_sequences,
|
| 832 |
+
desc="Inference",
|
| 833 |
+
unit="seq",
|
| 834 |
+
position=0,
|
| 835 |
+
leave=True,
|
| 836 |
+
dynamic_ncols=True,
|
| 837 |
+
miniters=1,
|
| 838 |
+
mininterval=0.0,
|
| 839 |
+
)
|
| 840 |
+
while True:
|
| 841 |
+
event_type = progress_event_queue.get()
|
| 842 |
+
if event_type == "stop":
|
| 843 |
+
break
|
| 844 |
+
if event_type == "infer":
|
| 845 |
+
infer_pbar.update(1)
|
| 846 |
+
infer_pbar.close()
|
| 847 |
+
|
| 848 |
+
progress_thread = threading.Thread(target=progress_worker, daemon=True)
|
| 849 |
+
progress_thread.start()
|
| 850 |
+
try:
|
| 851 |
+
while received_results < expected_results:
|
| 852 |
+
try:
|
| 853 |
+
result = result_queue.get(
|
| 854 |
+
timeout=30
|
| 855 |
+
)
|
| 856 |
+
except queue.Empty:
|
| 857 |
+
dead_workers = [
|
| 858 |
+
i
|
| 859 |
+
for i, p in enumerate(runtime["processes"])
|
| 860 |
+
if not p.is_alive()
|
| 861 |
+
]
|
| 862 |
+
if dead_workers:
|
| 863 |
+
raise RuntimeError(
|
| 864 |
+
f"Worker process(es) on GPU {dead_workers} died unexpectedly, "
|
| 865 |
+
f"exit codes: {[runtime['processes'][i].exitcode for i in dead_workers]}"
|
| 866 |
+
)
|
| 867 |
+
continue
|
| 868 |
+
state = result[0]
|
| 869 |
+
result_job_id = result[1]
|
| 870 |
+
result_gpu_id = result[2]
|
| 871 |
+
if state == "error":
|
| 872 |
+
error_task_id = result[3]
|
| 873 |
+
payload = result[4]
|
| 874 |
+
raise RuntimeError(
|
| 875 |
+
f"Worker error on GPU {result_gpu_id} (task {error_task_id}): {payload}"
|
| 876 |
+
)
|
| 877 |
+
if result_job_id != job_id:
|
| 878 |
+
continue
|
| 879 |
+
_, _, _, result_task_id, task_seq_indices, task_probs = result
|
| 880 |
+
if num_heads == 0:
|
| 881 |
+
num_heads = len(task_probs)
|
| 882 |
+
all_probs_per_head = [
|
| 883 |
+
[None] * total_sequences for _ in range(num_heads)
|
| 884 |
+
]
|
| 885 |
+
if len(task_probs) != num_heads:
|
| 886 |
+
raise RuntimeError(
|
| 887 |
+
f"Inconsistent number of heads from task {result_task_id}: "
|
| 888 |
+
f"expected {num_heads}, got {len(task_probs)}"
|
| 889 |
+
)
|
| 890 |
+
for local_i, seq_idx in enumerate(task_seq_indices):
|
| 891 |
+
for head_idx in range(num_heads):
|
| 892 |
+
all_probs_per_head[head_idx][seq_idx] = task_probs[head_idx][
|
| 893 |
+
local_i
|
| 894 |
+
]
|
| 895 |
+
received_results += 1
|
| 896 |
+
except KeyboardInterrupt:
|
| 897 |
+
interrupted = True
|
| 898 |
+
raise
|
| 899 |
+
finally:
|
| 900 |
+
progress_event_queue.put("stop")
|
| 901 |
+
progress_thread.join()
|
| 902 |
+
|
| 903 |
+
print("🔗 Combining results from all GPUs...")
|
| 904 |
+
finalized_probs: List[List[np.ndarray]] = []
|
| 905 |
+
for head_idx in range(num_heads):
|
| 906 |
+
head_probs = all_probs_per_head[head_idx]
|
| 907 |
+
if any(p is None for p in head_probs):
|
| 908 |
+
missing = sum(1 for p in head_probs if p is None)
|
| 909 |
+
raise RuntimeError(
|
| 910 |
+
f"Missing probabilities for {missing} sequence(s) in head {head_idx}"
|
| 911 |
+
)
|
| 912 |
+
finalized_probs.append([p for p in head_probs if p is not None])
|
| 913 |
+
|
| 914 |
+
print(
|
| 915 |
+
f"✅ Successfully processed {len(records)} sequences across {gpu_count} GPUs"
|
| 916 |
+
)
|
| 917 |
+
return finalized_probs
|
| 918 |
+
finally:
|
| 919 |
+
if owns_runtime:
|
| 920 |
+
shutdown_persistent_worker_runtime(runtime, interrupted=interrupted)
|
| 921 |
+
|
| 922 |
+
|
| 923 |
+
def display_progress_header() -> None:
|
| 924 |
+
print("\n" + "=" * 80)
|
| 925 |
+
print("🧬 CDS RAW MODEL INFERENCE PIPELINE 🧬")
|
| 926 |
+
print("=" * 80 + "\n")
|
| 927 |
+
|
| 928 |
+
|
| 929 |
+
def read_input_records(
|
| 930 |
+
input_path: str,
|
| 931 |
+
limit: Optional[int],
|
| 932 |
+
) -> List[Tuple[str, str]]:
|
| 933 |
+
print("🔄 Reading input file...")
|
| 934 |
+
if detect_input_format(input_path) == "genbank":
|
| 935 |
+
records = read_genbank(input_path)
|
| 936 |
+
else:
|
| 937 |
+
records = read_fasta(input_path)
|
| 938 |
+
if limit is not None:
|
| 939 |
+
records = records[:limit]
|
| 940 |
+
return records
|
| 941 |
+
|
| 942 |
+
|
| 943 |
+
def make_output_parquet_path(
|
| 944 |
+
output_dir: str, input_path: str, run_timestamp: str
|
| 945 |
+
) -> str:
|
| 946 |
+
input_filename = strip_gzip_suffix(os.path.basename(input_path))
|
| 947 |
+
base_input_name = os.path.splitext(input_filename)[0]
|
| 948 |
+
return os.path.join(
|
| 949 |
+
output_dir, f"{base_input_name}_{run_timestamp}.raw_probs.parquet"
|
| 950 |
+
)
|
| 951 |
+
|
| 952 |
+
|
| 953 |
+
def main() -> None:
|
| 954 |
+
display_progress_header()
|
| 955 |
+
total_start_time = time.time()
|
| 956 |
+
args = parse_arguments()
|
| 957 |
+
|
| 958 |
+
# Every reported result was computed with bfloat16 weights, which
|
| 959 |
+
# FlashAttention-2 also requires.
|
| 960 |
+
dtype_str = "bfloat16"
|
| 961 |
+
print(f"📊 Using model dtype: {dtype_str}")
|
| 962 |
+
print(f"💾 Saving probability dtype: {args.prob_dtype}")
|
| 963 |
+
if not args.save_non_coding_prob:
|
| 964 |
+
print(
|
| 965 |
+
"ℹ️ Saving only P(CDS) per head; P(non_coding)=1-P(CDS) for binary softmax."
|
| 966 |
+
)
|
| 967 |
+
|
| 968 |
+
available_gpus = torch.cuda.device_count()
|
| 969 |
+
if available_gpus <= 0:
|
| 970 |
+
raise RuntimeError("No CUDA devices available.")
|
| 971 |
+
if args.gpu_count == -1:
|
| 972 |
+
gpu_count = available_gpus
|
| 973 |
+
elif args.gpu_count <= 0:
|
| 974 |
+
raise ValueError(
|
| 975 |
+
f"--gpu_count must be -1 (all GPUs) or a positive integer, got {args.gpu_count}"
|
| 976 |
+
)
|
| 977 |
+
else:
|
| 978 |
+
gpu_count = min(args.gpu_count, available_gpus)
|
| 979 |
+
print(f"🎯 Using {gpu_count} GPU(s) out of {available_gpus} available")
|
| 980 |
+
|
| 981 |
+
if args.overlap_length >= args.context_length:
|
| 982 |
+
raise ValueError(
|
| 983 |
+
f"--overlap_length ({args.overlap_length}) must be strictly less than "
|
| 984 |
+
f"--context_length ({args.context_length})"
|
| 985 |
+
)
|
| 986 |
+
|
| 987 |
+
input_paths = resolve_input_paths(args.input)
|
| 988 |
+
if not input_paths:
|
| 989 |
+
raise ValueError("No valid input files resolved from --input.")
|
| 990 |
+
print(f"📁 Resolved {len(input_paths)} input file(s)")
|
| 991 |
+
|
| 992 |
+
os.makedirs(args.output_path, exist_ok=True)
|
| 993 |
+
run_timestamp = datetime.datetime.now().strftime("%Y%m%d_%H%M%S")
|
| 994 |
+
|
| 995 |
+
persistent_runtime: Optional[Dict[str, Any]] = None
|
| 996 |
+
interrupted = False
|
| 997 |
+
try:
|
| 998 |
+
persistent_runtime = create_persistent_worker_runtime(
|
| 999 |
+
model_name=args.model_name,
|
| 1000 |
+
revision=args.revision,
|
| 1001 |
+
dtype_str=dtype_str,
|
| 1002 |
+
attn_implementation=args.attn_implementation,
|
| 1003 |
+
gpu_count=gpu_count,
|
| 1004 |
+
max_length=args.context_length,
|
| 1005 |
+
overlap_length=args.overlap_length,
|
| 1006 |
+
micro_batch_size=args.batch_size,
|
| 1007 |
+
)
|
| 1008 |
+
|
| 1009 |
+
for input_idx, input_path in enumerate(input_paths, start=1):
|
| 1010 |
+
print(f"\n{'=' * 80}")
|
| 1011 |
+
print(f"📂 Processing input {input_idx}/{len(input_paths)}: {input_path}")
|
| 1012 |
+
print(f"{'=' * 80}")
|
| 1013 |
+
|
| 1014 |
+
records = read_input_records(input_path, limit=args.limit)
|
| 1015 |
+
if not records:
|
| 1016 |
+
print("⚠️ No sequences found, skipping.")
|
| 1017 |
+
continue
|
| 1018 |
+
|
| 1019 |
+
probs_per_head = infer_records(
|
| 1020 |
+
records=records,
|
| 1021 |
+
model_name=args.model_name,
|
| 1022 |
+
dtype_str=dtype_str,
|
| 1023 |
+
gpu_count=gpu_count,
|
| 1024 |
+
max_length=args.context_length,
|
| 1025 |
+
overlap_length=args.overlap_length,
|
| 1026 |
+
micro_batch_size=args.batch_size,
|
| 1027 |
+
dispatch_chunk_size=args.dispatch_chunk_size,
|
| 1028 |
+
persistent_runtime=persistent_runtime,
|
| 1029 |
+
attn_implementation=args.attn_implementation,
|
| 1030 |
+
revision=args.revision,
|
| 1031 |
+
)
|
| 1032 |
+
|
| 1033 |
+
head_names = (
|
| 1034 |
+
HEAD_NAMES
|
| 1035 |
+
if len(probs_per_head) == 2
|
| 1036 |
+
else [f"head_{i}" for i in range(len(probs_per_head))]
|
| 1037 |
+
)
|
| 1038 |
+
output_parquet_path = make_output_parquet_path(
|
| 1039 |
+
args.output_path, input_path, run_timestamp
|
| 1040 |
+
)
|
| 1041 |
+
write_prob_parquet(
|
| 1042 |
+
records=records,
|
| 1043 |
+
probs_per_head=probs_per_head,
|
| 1044 |
+
head_names=head_names,
|
| 1045 |
+
output_path=output_parquet_path,
|
| 1046 |
+
prob_dtype=args.prob_dtype,
|
| 1047 |
+
save_non_coding_prob=args.save_non_coding_prob,
|
| 1048 |
+
)
|
| 1049 |
+
except KeyboardInterrupt:
|
| 1050 |
+
interrupted = True
|
| 1051 |
+
raise
|
| 1052 |
+
finally:
|
| 1053 |
+
if persistent_runtime is not None:
|
| 1054 |
+
print("🛑 Shutting down persistent GPU workers...")
|
| 1055 |
+
shutdown_persistent_worker_runtime(
|
| 1056 |
+
persistent_runtime, interrupted=interrupted
|
| 1057 |
+
)
|
| 1058 |
+
|
| 1059 |
+
total_time = time.time() - total_start_time
|
| 1060 |
+
minutes, seconds = divmod(total_time, 60)
|
| 1061 |
+
print(f"\n⏱️ Total execution time: {int(minutes)}m {seconds:.2f}s")
|
| 1062 |
+
print("✨ Completed successfully! ✨\n")
|
| 1063 |
+
|
| 1064 |
+
|
| 1065 |
+
if __name__ == "__main__":
|
| 1066 |
+
mp.set_start_method("spawn", force=True)
|
| 1067 |
+
main()
|
annotation_pipeline/src/post_processing.py
ADDED
|
@@ -0,0 +1,511 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""Post-process Carbon-A coding probabilities into GFF3, CDS FASTA, and protein FASTA.
|
| 2 |
+
|
| 3 |
+
Post-processing runs three stages on each strand of each record, in
|
| 4 |
+
transcript orientation:
|
| 5 |
+
|
| 6 |
+
1. The transition network (transition_network.py) reads the coding
|
| 7 |
+
probability track and scores the four permitted transitions at every
|
| 8 |
+
base: enter gene, leave gene, enter intron, leave intron.
|
| 9 |
+
2. The Viterbi algorithm (viterbi.py) combines the coding probabilities,
|
| 10 |
+
the transition probabilities, and the gene grammar into the highest-scoring
|
| 11 |
+
labeling of the strand into intergenic, exon, and intron bases. Runs of
|
| 12 |
+
exon bases form CDS intervals, intervals separated by an intron belong
|
| 13 |
+
to one gene, and the reading frame gives each interval its phase.
|
| 14 |
+
3. The confidence network (confidence_network.py) reads the coding
|
| 15 |
+
probabilities together with the decoded roles of the whole strand,
|
| 16 |
+
neighbouring genes included, and returns, for every complete gene, the
|
| 17 |
+
probability that its structure is exactly right.
|
| 18 |
+
|
| 19 |
+
The score of a labeling is the sum over bases of log P(CDS) for exon bases and
|
| 20 |
+
log(1 - P(CDS)) for all other bases, plus the log-odds of the transition at
|
| 21 |
+
every base where a segment begins or a gene closes, plus a fixed log-prior for
|
| 22 |
+
each intron's splice class. The grammar decides which labelings are admissible:
|
| 23 |
+
a gene opens at an ATG start codon and closes at the first in-frame stop codon
|
| 24 |
+
of the selected genetic code, an intron opens at a donor site and closes at an
|
| 25 |
+
acceptor site of a permitted class, and the reading frame continues across
|
| 26 |
+
each intron. The implementation expands the three labels into 30 states so
|
| 27 |
+
that every condition can be checked from local context.
|
| 28 |
+
|
| 29 |
+
Output filtering:
|
| 30 |
+
|
| 31 |
+
FASTA records shorter than min_record_length are skipped before sequence
|
| 32 |
+
encoding and Viterbi decoding. The production default is 100,000 bp;
|
| 33 |
+
zero disables this record-level filter.
|
| 34 |
+
|
| 35 |
+
Gene models whose whole spliced CDS is shorter than ``min_cds_length`` are
|
| 36 |
+
omitted after decoding. The production default is
|
| 37 |
+
``MIN_CDS_LENGTH_BP`` (96 bp); zero disables this gene-level filter.
|
| 38 |
+
This output filter does not constrain individual exon lengths or alter the
|
| 39 |
+
Viterbi path.
|
| 40 |
+
|
| 41 |
+
Gene confidence:
|
| 42 |
+
|
| 43 |
+
Every complete gene carries ``confidence``, the confidence network's
|
| 44 |
+
estimate of the probability that its structure is exactly correct, in its
|
| 45 |
+
GFF3 gene attributes and FASTA headers. Genes truncated by a record edge
|
| 46 |
+
are not scored and carry ``confidence=NA``. All genes are written;
|
| 47 |
+
filtering at a threshold is left to downstream tools such as
|
| 48 |
+
evaluation.py, whose gene-level results use a threshold of 0.1.
|
| 49 |
+
"""
|
| 50 |
+
|
| 51 |
+
import argparse
|
| 52 |
+
import os
|
| 53 |
+
import sys
|
| 54 |
+
import time
|
| 55 |
+
from concurrent.futures import ThreadPoolExecutor
|
| 56 |
+
|
| 57 |
+
import numpy as np
|
| 58 |
+
import torch
|
| 59 |
+
|
| 60 |
+
sys.path.insert(0, os.path.dirname(os.path.abspath(__file__)))
|
| 61 |
+
from constants import (
|
| 62 |
+
CHUNK_SIZE_MBP,
|
| 63 |
+
CODON_TABLE_PATH,
|
| 64 |
+
CONFIDENCE_THRESHOLD,
|
| 65 |
+
CONFIDENCE_NETWORK_PATH,
|
| 66 |
+
MAX_BOUNDARY_INTRON_LENGTH_BP,
|
| 67 |
+
MIN_CDS_LENGTH_BP,
|
| 68 |
+
MIN_RECORD_LENGTH_BP,
|
| 69 |
+
NEGATIVE_STRAND_PROBABILITY_COLUMN,
|
| 70 |
+
PARQUET_BATCH_ROWS,
|
| 71 |
+
POSITIVE_STRAND_PROBABILITY_COLUMN,
|
| 72 |
+
RECORD_ID_COLUMN,
|
| 73 |
+
SEQUENCE_COLUMN,
|
| 74 |
+
TRANSITION_NETWORK_PATH,
|
| 75 |
+
WARNING_DIRECTORY_SUFFIX,
|
| 76 |
+
WARNINGS_FILE_NAME,
|
| 77 |
+
)
|
| 78 |
+
from utils import (
|
| 79 |
+
load_codon_table, stop_codons_from_table,
|
| 80 |
+
probability_vector_from_parquet,
|
| 81 |
+
validate_max_boundary_intron_length,
|
| 82 |
+
encode_both_strands,
|
| 83 |
+
assemble_gene_model,
|
| 84 |
+
codon_table_mismatch,
|
| 85 |
+
write_gff3, write_cds_fasta, write_protein_fasta,
|
| 86 |
+
)
|
| 87 |
+
from confidence_network import load_confidence_network
|
| 88 |
+
from transition_network import load_transition_network
|
| 89 |
+
from viterbi import choose_viterbi_backend, decode_paths, iter_gene_exons_with_states
|
| 90 |
+
|
| 91 |
+
|
| 92 |
+
def default_output_prefix(input_path):
|
| 93 |
+
"""Return the default output prefix for one raw-probability parquet file."""
|
| 94 |
+
if input_path.endswith(".raw_probs.parquet"):
|
| 95 |
+
return input_path[:-len(".raw_probs.parquet")] + ".predicted"
|
| 96 |
+
return os.path.splitext(input_path)[0] + ".predicted"
|
| 97 |
+
|
| 98 |
+
|
| 99 |
+
def iter_probability_chunks(
|
| 100 |
+
parquet_file, record_id_col, sequence_col,
|
| 101 |
+
positive_col, negative_col, max_chunk_bp,
|
| 102 |
+
min_record_length=0, filter_stats=None,
|
| 103 |
+
):
|
| 104 |
+
"""Read and encode strand tracks in approximately ``max_chunk_bp`` chunks.
|
| 105 |
+
|
| 106 |
+
Each record yields two tracks in transcript orientation, ``(coding
|
| 107 |
+
probabilities, encoded bases)`` for the forward strand and for the reverse
|
| 108 |
+
complement, together with a ``(record_id, sequence, strand, length)``
|
| 109 |
+
context. A sequence record is never split, and each yielded base count
|
| 110 |
+
includes both tracks.
|
| 111 |
+
"""
|
| 112 |
+
required_columns = [record_id_col, sequence_col, positive_col, negative_col]
|
| 113 |
+
tracks = []
|
| 114 |
+
track_contexts = []
|
| 115 |
+
chunk_bp = 0
|
| 116 |
+
|
| 117 |
+
for batch in parquet_file.iter_batches(batch_size=PARQUET_BATCH_ROWS, columns=required_columns):
|
| 118 |
+
columns_by_name = {
|
| 119 |
+
column_name: batch.column(column_index)
|
| 120 |
+
for column_index, column_name in enumerate(batch.schema.names)
|
| 121 |
+
}
|
| 122 |
+
record_ids = columns_by_name[record_id_col]
|
| 123 |
+
sequences = columns_by_name[sequence_col]
|
| 124 |
+
positive_probabilities = columns_by_name[positive_col]
|
| 125 |
+
negative_probabilities = columns_by_name[negative_col]
|
| 126 |
+
|
| 127 |
+
for row_index in range(batch.num_rows):
|
| 128 |
+
sequence = sequences[row_index].as_py().upper()
|
| 129 |
+
record_length = len(sequence)
|
| 130 |
+
record_id = record_ids[row_index].as_py()
|
| 131 |
+
if record_length < min_record_length:
|
| 132 |
+
if filter_stats is not None:
|
| 133 |
+
filter_stats["record_count"] += 1
|
| 134 |
+
filter_stats["bp"] += record_length
|
| 135 |
+
continue
|
| 136 |
+
positive = probability_vector_from_parquet(positive_probabilities[row_index])
|
| 137 |
+
negative = probability_vector_from_parquet(negative_probabilities[row_index])
|
| 138 |
+
if len(positive) < record_length or len(negative) < record_length:
|
| 139 |
+
raise ValueError(
|
| 140 |
+
f"record {record_id!r} has {record_length} bases but "
|
| 141 |
+
f"probability lengths {len(positive)} and {len(negative)}"
|
| 142 |
+
)
|
| 143 |
+
|
| 144 |
+
positive = positive[:record_length]
|
| 145 |
+
negative = negative[:record_length]
|
| 146 |
+
forward_bases, reverse_bases = encode_both_strands(sequence)
|
| 147 |
+
tracks.append((positive, forward_bases))
|
| 148 |
+
track_contexts.append((record_id, sequence, "+", record_length))
|
| 149 |
+
tracks.append((np.ascontiguousarray(negative[::-1]), reverse_bases))
|
| 150 |
+
track_contexts.append((record_id, sequence, "-", record_length))
|
| 151 |
+
chunk_bp += 2 * record_length
|
| 152 |
+
|
| 153 |
+
if chunk_bp >= max_chunk_bp:
|
| 154 |
+
yield tracks, track_contexts, chunk_bp
|
| 155 |
+
tracks = []
|
| 156 |
+
track_contexts = []
|
| 157 |
+
chunk_bp = 0
|
| 158 |
+
|
| 159 |
+
if tracks:
|
| 160 |
+
yield tracks, track_contexts, chunk_bp
|
| 161 |
+
|
| 162 |
+
|
| 163 |
+
def iter_prefetched_chunks(chunks):
|
| 164 |
+
"""Prepare one chunk ahead while the caller decodes the current chunk.
|
| 165 |
+
|
| 166 |
+
The one-item lookahead overlaps Parquet decoding with GPU/CPU work while
|
| 167 |
+
keeping memory use bounded to one current and one prepared chunk.
|
| 168 |
+
"""
|
| 169 |
+
chunk_iterator = iter(chunks)
|
| 170 |
+
with ThreadPoolExecutor(max_workers=1) as executor:
|
| 171 |
+
next_chunk = executor.submit(next, chunk_iterator, None)
|
| 172 |
+
while True:
|
| 173 |
+
chunk = next_chunk.result()
|
| 174 |
+
if chunk is None:
|
| 175 |
+
return
|
| 176 |
+
next_chunk = executor.submit(next, chunk_iterator, None)
|
| 177 |
+
yield chunk
|
| 178 |
+
|
| 179 |
+
|
| 180 |
+
def decode_chunk_genes(
|
| 181 |
+
tracks, track_contexts, transition_probabilities, viterbi_backend, stop_codons,
|
| 182 |
+
max_boundary_intron_length, min_cds_length, confidence_network,
|
| 183 |
+
):
|
| 184 |
+
"""Decode one chunk under one stop-codon set and score its complete genes.
|
| 185 |
+
|
| 186 |
+
Returns ``(chunk_genes, short_gene_count, viterbi_seconds,
|
| 187 |
+
confidence_seconds)``; each entry of ``chunk_genes`` is ``(gene,
|
| 188 |
+
probabilities, oriented_exons)``.
|
| 189 |
+
"""
|
| 190 |
+
stage_started = time.perf_counter()
|
| 191 |
+
state_paths = decode_paths(
|
| 192 |
+
tracks,
|
| 193 |
+
transition_probabilities,
|
| 194 |
+
viterbi_backend,
|
| 195 |
+
stop_taa="TAA" in stop_codons,
|
| 196 |
+
stop_tag="TAG" in stop_codons,
|
| 197 |
+
stop_tga="TGA" in stop_codons,
|
| 198 |
+
max_boundary_intron_length=max_boundary_intron_length,
|
| 199 |
+
)
|
| 200 |
+
viterbi_seconds = time.perf_counter() - stage_started
|
| 201 |
+
if len(state_paths) != len(track_contexts):
|
| 202 |
+
raise RuntimeError(
|
| 203 |
+
f"{viterbi_backend} Viterbi backend returned {len(state_paths)} paths "
|
| 204 |
+
f"for {len(track_contexts)} tracks"
|
| 205 |
+
)
|
| 206 |
+
|
| 207 |
+
# Gene models of the chunk, each with the oriented track and exons it
|
| 208 |
+
# was decoded from, grouped by strand: the confidence network reads each
|
| 209 |
+
# strand's decoded structure whole.
|
| 210 |
+
chunk_genes = []
|
| 211 |
+
strand_gene_indices = []
|
| 212 |
+
short_gene_count = 0
|
| 213 |
+
for state_path, track_context, (probabilities, _) in zip(state_paths, track_contexts, tracks):
|
| 214 |
+
record_id, sequence, strand, record_length = track_context
|
| 215 |
+
if len(state_path) != record_length:
|
| 216 |
+
raise RuntimeError(
|
| 217 |
+
f"{viterbi_backend} Viterbi backend returned path length {len(state_path)} "
|
| 218 |
+
f"for record {record_id!r} {strand} with {record_length} bases"
|
| 219 |
+
)
|
| 220 |
+
gene_indices = []
|
| 221 |
+
for oriented_exons, first_cds_state, last_cds_state in iter_gene_exons_with_states(state_path):
|
| 222 |
+
gene = assemble_gene_model(
|
| 223 |
+
record_id, sequence, oriented_exons, strand, record_length,
|
| 224 |
+
first_cds_state, last_cds_state,
|
| 225 |
+
stop_codons,
|
| 226 |
+
)
|
| 227 |
+
if gene is None:
|
| 228 |
+
continue
|
| 229 |
+
if len(gene["cds"]) < min_cds_length:
|
| 230 |
+
short_gene_count += 1
|
| 231 |
+
continue
|
| 232 |
+
gene["confidence"] = None
|
| 233 |
+
gene_indices.append(len(chunk_genes))
|
| 234 |
+
chunk_genes.append((gene, probabilities, oriented_exons))
|
| 235 |
+
strand_gene_indices.append((probabilities, gene_indices))
|
| 236 |
+
|
| 237 |
+
# Confidence of every complete gene of the chunk, strand by strand. Every
|
| 238 |
+
# written gene gives roles to its strand; only the complete ones are scored.
|
| 239 |
+
stage_started = time.perf_counter()
|
| 240 |
+
for probabilities, gene_indices in strand_gene_indices:
|
| 241 |
+
scored = [
|
| 242 |
+
position for position, gene_index in enumerate(gene_indices)
|
| 243 |
+
if chunk_genes[gene_index][0]["complete"]
|
| 244 |
+
]
|
| 245 |
+
if not scored:
|
| 246 |
+
continue
|
| 247 |
+
confidences = confidence_network.inference(
|
| 248 |
+
probabilities, [chunk_genes[gene_index][2] for gene_index in gene_indices], scored,
|
| 249 |
+
)
|
| 250 |
+
for position, confidence in zip(scored, confidences):
|
| 251 |
+
chunk_genes[gene_indices[position]][0]["confidence"] = float(confidence)
|
| 252 |
+
confidence_seconds = time.perf_counter() - stage_started
|
| 253 |
+
return chunk_genes, short_gene_count, viterbi_seconds, confidence_seconds
|
| 254 |
+
|
| 255 |
+
|
| 256 |
+
def record_codon_table_warning(output_prefix, warning, stop_codons):
|
| 257 |
+
"""Record the outcome of the codon-table check where a batch run will see it.
|
| 258 |
+
|
| 259 |
+
A failed check (``warning`` is the message) is appended to
|
| 260 |
+
``WARNINGS_FILE_NAME`` in the parent of the output directory, one line per
|
| 261 |
+
run with the time and the directory name, and the output directory is
|
| 262 |
+
renamed with ``WARNING_DIRECTORY_SUFFIX``, so a batch run shows every
|
| 263 |
+
doubtful genome in one file and in the directory names. A passed check in
|
| 264 |
+
a directory that carries the suffix, typically a rerun with the right
|
| 265 |
+
codon table, renames it back and records that too. Returns the output
|
| 266 |
+
prefix after any rename. The directory is left alone when the new name is
|
| 267 |
+
taken or when it is the working directory.
|
| 268 |
+
"""
|
| 269 |
+
output_dir = os.path.dirname(os.path.abspath(output_prefix))
|
| 270 |
+
clean_dir = output_dir[:-len(WARNING_DIRECTORY_SUFFIX)] if output_dir.endswith(WARNING_DIRECTORY_SUFFIX) else output_dir
|
| 271 |
+
flagged_dir = clean_dir + WARNING_DIRECTORY_SUFFIX
|
| 272 |
+
if warning is not None:
|
| 273 |
+
entry = warning
|
| 274 |
+
target = flagged_dir
|
| 275 |
+
elif output_dir == flagged_dir:
|
| 276 |
+
entry = f"cleared: the codon-table check passed with stops={'+'.join(stop_codons)}"
|
| 277 |
+
target = clean_dir
|
| 278 |
+
else:
|
| 279 |
+
return output_prefix
|
| 280 |
+
|
| 281 |
+
with open(os.path.join(os.path.dirname(clean_dir), WARNINGS_FILE_NAME), "a") as handle:
|
| 282 |
+
handle.write(f"{time.strftime('%Y-%m-%dT%H:%M:%SZ', time.gmtime())}\t{os.path.basename(clean_dir)}\t{entry}\n")
|
| 283 |
+
if target == output_dir:
|
| 284 |
+
return output_prefix
|
| 285 |
+
if os.path.exists(target) or output_dir == os.getcwd():
|
| 286 |
+
print(
|
| 287 |
+
f"[post_processing] leaving {output_dir} in place: {target} exists or it is the working directory",
|
| 288 |
+
file=sys.stderr, flush=True,
|
| 289 |
+
)
|
| 290 |
+
return output_prefix
|
| 291 |
+
os.rename(output_dir, target)
|
| 292 |
+
return os.path.join(target, os.path.basename(output_prefix))
|
| 293 |
+
|
| 294 |
+
|
| 295 |
+
def post_process_probability_file(
|
| 296 |
+
input_path, output_prefix, viterbi_backend, codon_to_amino_acid,
|
| 297 |
+
transition_network, confidence_network,
|
| 298 |
+
max_boundary_intron_length, chunk_mbp,
|
| 299 |
+
min_record_length=MIN_RECORD_LENGTH_BP,
|
| 300 |
+
min_cds_length=MIN_CDS_LENGTH_BP,
|
| 301 |
+
):
|
| 302 |
+
"""Post-process one raw-probability parquet into GFF3, CDS FASTA, and protein FASTA.
|
| 303 |
+
|
| 304 |
+
``viterbi_backend`` is the name returned by ``choose_viterbi_backend``.
|
| 305 |
+
When the confidences of the complete genes contradict the codon table
|
| 306 |
+
(utils.codon_table_mismatch), the warning is printed and recorded in the
|
| 307 |
+
directory layout by ``record_codon_table_warning``.
|
| 308 |
+
"""
|
| 309 |
+
import pyarrow.parquet as pq
|
| 310 |
+
|
| 311 |
+
file_started = time.perf_counter()
|
| 312 |
+
stop_codons = tuple(stop_codons_from_table(codon_to_amino_acid))
|
| 313 |
+
parquet_file = pq.ParquetFile(input_path)
|
| 314 |
+
required_columns = {
|
| 315 |
+
RECORD_ID_COLUMN,
|
| 316 |
+
SEQUENCE_COLUMN,
|
| 317 |
+
POSITIVE_STRAND_PROBABILITY_COLUMN,
|
| 318 |
+
NEGATIVE_STRAND_PROBABILITY_COLUMN,
|
| 319 |
+
}
|
| 320 |
+
missing_columns = required_columns - set(parquet_file.schema_arrow.names)
|
| 321 |
+
if missing_columns:
|
| 322 |
+
raise ValueError(
|
| 323 |
+
"raw probability parquet is missing columns: "
|
| 324 |
+
+ ", ".join(sorted(missing_columns))
|
| 325 |
+
)
|
| 326 |
+
|
| 327 |
+
genes = []
|
| 328 |
+
short_gene_count = 0
|
| 329 |
+
total_bp = 0
|
| 330 |
+
transition_seconds = 0.0
|
| 331 |
+
viterbi_seconds = 0.0
|
| 332 |
+
confidence_seconds = 0.0
|
| 333 |
+
filter_stats = {"record_count": 0, "bp": 0}
|
| 334 |
+
|
| 335 |
+
input_chunks = iter_probability_chunks(
|
| 336 |
+
parquet_file, RECORD_ID_COLUMN, SEQUENCE_COLUMN,
|
| 337 |
+
POSITIVE_STRAND_PROBABILITY_COLUMN, NEGATIVE_STRAND_PROBABILITY_COLUMN,
|
| 338 |
+
chunk_mbp * 1_000_000,
|
| 339 |
+
min_record_length=min_record_length,
|
| 340 |
+
filter_stats=filter_stats,
|
| 341 |
+
)
|
| 342 |
+
for tracks, track_contexts, chunk_bp in iter_prefetched_chunks(input_chunks):
|
| 343 |
+
# Stage 1: transition probabilities for every oriented track of the chunk.
|
| 344 |
+
stage_started = time.perf_counter()
|
| 345 |
+
transition_probabilities = [
|
| 346 |
+
transition_network.inference(probabilities)
|
| 347 |
+
for probabilities, _ in tracks
|
| 348 |
+
]
|
| 349 |
+
transition_seconds += time.perf_counter() - stage_started
|
| 350 |
+
|
| 351 |
+
# Stages 2 and 3: the Viterbi algorithm, gene models, and their confidences.
|
| 352 |
+
chunk_genes, chunk_short_gene_count, chunk_viterbi_seconds, chunk_confidence_seconds = decode_chunk_genes(
|
| 353 |
+
tracks, track_contexts, transition_probabilities, viterbi_backend, stop_codons,
|
| 354 |
+
max_boundary_intron_length, min_cds_length, confidence_network,
|
| 355 |
+
)
|
| 356 |
+
short_gene_count += chunk_short_gene_count
|
| 357 |
+
viterbi_seconds += chunk_viterbi_seconds
|
| 358 |
+
confidence_seconds += chunk_confidence_seconds
|
| 359 |
+
|
| 360 |
+
genes.extend(gene for gene, _, _ in chunk_genes)
|
| 361 |
+
total_bp += chunk_bp
|
| 362 |
+
|
| 363 |
+
genes.sort(key=lambda gene: (gene["record_id"], gene["exons"][0][0]))
|
| 364 |
+
write_gff3(genes, output_prefix + ".gff")
|
| 365 |
+
write_cds_fasta(genes, output_prefix + ".fna")
|
| 366 |
+
protein_count = write_protein_fasta(genes, output_prefix + ".faa", codon_to_amino_acid)
|
| 367 |
+
|
| 368 |
+
total_seconds = time.perf_counter() - file_started
|
| 369 |
+
rate = total_bp / viterbi_seconds / 1e6 if viterbi_seconds else 0.0
|
| 370 |
+
confidences = sorted(gene["confidence"] for gene in genes if gene["confidence"] is not None)
|
| 371 |
+
scored_gene_count = len(confidences)
|
| 372 |
+
median_confidence = confidences[len(confidences) // 2] if confidences else float("nan")
|
| 373 |
+
confident_gene_count = sum(confidence >= CONFIDENCE_THRESHOLD for confidence in confidences)
|
| 374 |
+
warning = None
|
| 375 |
+
if codon_table_mismatch(confidences):
|
| 376 |
+
warning = (
|
| 377 |
+
f"WARNING: only {confident_gene_count} of {scored_gene_count} complete genes "
|
| 378 |
+
f"({confident_gene_count / scored_gene_count:.0%}) reach confidence {CONFIDENCE_THRESHOLD} "
|
| 379 |
+
f"with stops={'+'.join(stop_codons)}. "
|
| 380 |
+
f"The codon table probably does not match this genome; check --codon_table."
|
| 381 |
+
)
|
| 382 |
+
print(f"[post_processing] {warning}", file=sys.stderr, flush=True)
|
| 383 |
+
output_prefix = record_codon_table_warning(output_prefix, warning, stop_codons)
|
| 384 |
+
print(
|
| 385 |
+
f"[post_processing] viterbi_backend={viterbi_backend} stops={'+'.join(stop_codons)} "
|
| 386 |
+
f"max_boundary_intron_length={max_boundary_intron_length} "
|
| 387 |
+
f"min_record_length={min_record_length} "
|
| 388 |
+
f"{os.path.basename(input_path)}: {total_bp / 1e6:.0f} Mbp; "
|
| 389 |
+
f"transition network {transition_seconds:.1f}s; "
|
| 390 |
+
f"Viterbi {viterbi_seconds:.1f}s ({rate:.0f} Mbp/s); "
|
| 391 |
+
f"confidence network {confidence_seconds:.1f}s "
|
| 392 |
+
f"({scored_gene_count} genes scored, {confident_gene_count} at or above {CONFIDENCE_THRESHOLD}, "
|
| 393 |
+
f"median confidence {median_confidence:.3f}); "
|
| 394 |
+
f"{total_seconds:.1f}s end to end -> {len(genes)} genes "
|
| 395 |
+
f"({short_gene_count} shorter than {min_cds_length} bp omitted) "
|
| 396 |
+
f"({filter_stats['record_count']} records / "
|
| 397 |
+
f"{filter_stats['bp'] / 1e6:.1f} Mbp shorter than "
|
| 398 |
+
f"{min_record_length} bp skipped) "
|
| 399 |
+
f"-> {output_prefix}.gff/.fna/.faa ({protein_count} proteins)",
|
| 400 |
+
flush=True,
|
| 401 |
+
)
|
| 402 |
+
|
| 403 |
+
|
| 404 |
+
def parse_args():
|
| 405 |
+
"""Parse command-line arguments."""
|
| 406 |
+
parser = argparse.ArgumentParser(description=__doc__, formatter_class=argparse.RawDescriptionHelpFormatter)
|
| 407 |
+
parser.add_argument("--input", required=True, help="one *.raw_probs.parquet file")
|
| 408 |
+
parser.add_argument("--output", default=None, help="output prefix; defaults to <input>.predicted")
|
| 409 |
+
parser.add_argument(
|
| 410 |
+
"--viterbi_backend",
|
| 411 |
+
choices=["auto", "gpu", "cpu", "numba"],
|
| 412 |
+
default="auto",
|
| 413 |
+
help="Viterbi backend: gpu (CUDA), cpu (C++/OpenMP), or numba (reference); auto prefers gpu",
|
| 414 |
+
)
|
| 415 |
+
parser.add_argument(
|
| 416 |
+
"--codon_table",
|
| 417 |
+
default=str(CODON_TABLE_PATH),
|
| 418 |
+
help=(
|
| 419 |
+
"JSON object mapping all 64 ATCG codons to amino acids; '*' marks "
|
| 420 |
+
"decoder stop codons"
|
| 421 |
+
),
|
| 422 |
+
)
|
| 423 |
+
parser.add_argument(
|
| 424 |
+
"--transition_network",
|
| 425 |
+
default=str(TRANSITION_NETWORK_PATH),
|
| 426 |
+
help="transition network weights (default: weights/transition_network.safetensors)",
|
| 427 |
+
)
|
| 428 |
+
parser.add_argument(
|
| 429 |
+
"--confidence_network",
|
| 430 |
+
default=str(CONFIDENCE_NETWORK_PATH),
|
| 431 |
+
help="confidence network weights (default: weights/confidence_network.safetensors)",
|
| 432 |
+
)
|
| 433 |
+
parser.add_argument(
|
| 434 |
+
"--device",
|
| 435 |
+
default=None,
|
| 436 |
+
help="torch device for the two networks (default: cuda if available, else cpu)",
|
| 437 |
+
)
|
| 438 |
+
parser.add_argument(
|
| 439 |
+
"--max_boundary_intron_length",
|
| 440 |
+
type=int,
|
| 441 |
+
default=MAX_BOUNDARY_INTRON_LENGTH_BP,
|
| 442 |
+
help=(
|
| 443 |
+
"maximum visible boundary-intron length in bp; -1 is unlimited; "
|
| 444 |
+
"0 disables boundary introns, leaving only exon-truncated incomplete genes"
|
| 445 |
+
),
|
| 446 |
+
)
|
| 447 |
+
parser.add_argument(
|
| 448 |
+
"--chunk_mbp",
|
| 449 |
+
type=int,
|
| 450 |
+
default=CHUNK_SIZE_MBP,
|
| 451 |
+
help="target host-side chunk size in megabases; sequence records are never split",
|
| 452 |
+
)
|
| 453 |
+
parser.add_argument(
|
| 454 |
+
"--min_record_length",
|
| 455 |
+
type=int,
|
| 456 |
+
default=MIN_RECORD_LENGTH_BP,
|
| 457 |
+
help=(
|
| 458 |
+
"minimum input FASTA record length in bp; shorter records are skipped "
|
| 459 |
+
"before decoding (0 disables)"
|
| 460 |
+
),
|
| 461 |
+
)
|
| 462 |
+
parser.add_argument(
|
| 463 |
+
"--min_cds_length",
|
| 464 |
+
type=int,
|
| 465 |
+
default=MIN_CDS_LENGTH_BP,
|
| 466 |
+
help=(
|
| 467 |
+
"minimum spliced CDS length in bp for output gene models; "
|
| 468 |
+
"shorter models are omitted (0 disables)"
|
| 469 |
+
),
|
| 470 |
+
)
|
| 471 |
+
args = parser.parse_args()
|
| 472 |
+
try:
|
| 473 |
+
args.max_boundary_intron_length = validate_max_boundary_intron_length(
|
| 474 |
+
args.max_boundary_intron_length
|
| 475 |
+
)
|
| 476 |
+
except (TypeError, ValueError) as exc:
|
| 477 |
+
parser.error(str(exc))
|
| 478 |
+
if args.min_record_length < 0:
|
| 479 |
+
parser.error("--min_record_length must be non-negative")
|
| 480 |
+
if args.min_cds_length < 0:
|
| 481 |
+
parser.error("--min_cds_length must be non-negative")
|
| 482 |
+
return args
|
| 483 |
+
|
| 484 |
+
|
| 485 |
+
def main():
|
| 486 |
+
"""Command-line entry point."""
|
| 487 |
+
args = parse_args()
|
| 488 |
+
try:
|
| 489 |
+
codon_to_amino_acid = load_codon_table(args.codon_table)
|
| 490 |
+
except OSError as exc:
|
| 491 |
+
raise SystemExit(f"error: cannot read codon table {args.codon_table}: {exc}") from exc
|
| 492 |
+
except ValueError as exc:
|
| 493 |
+
raise SystemExit(f"error: {exc}") from exc
|
| 494 |
+
|
| 495 |
+
output_prefix = args.output or default_output_prefix(args.input)
|
| 496 |
+
viterbi_backend = choose_viterbi_backend(args.viterbi_backend)
|
| 497 |
+
device = torch.device(args.device or ("cuda" if torch.cuda.is_available() else "cpu"))
|
| 498 |
+
transition_network = load_transition_network(args.transition_network, device)
|
| 499 |
+
confidence_network = load_confidence_network(args.confidence_network, device)
|
| 500 |
+
post_process_probability_file(
|
| 501 |
+
args.input, output_prefix, viterbi_backend, codon_to_amino_acid,
|
| 502 |
+
transition_network, confidence_network,
|
| 503 |
+
max_boundary_intron_length=args.max_boundary_intron_length,
|
| 504 |
+
chunk_mbp=args.chunk_mbp,
|
| 505 |
+
min_record_length=args.min_record_length,
|
| 506 |
+
min_cds_length=args.min_cds_length,
|
| 507 |
+
)
|
| 508 |
+
|
| 509 |
+
|
| 510 |
+
if __name__ == "__main__":
|
| 511 |
+
main()
|
annotation_pipeline/src/tcn.py
ADDED
|
@@ -0,0 +1,136 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""Temporal convolutional network shared by the transition and confidence networks.
|
| 2 |
+
|
| 3 |
+
Both networks of the post-processing pipeline read per-base tracks and need
|
| 4 |
+
one contextual feature vector per base. They use the same architecture: a
|
| 5 |
+
width-5 input convolution, a stack of residual blocks with depthwise dilated
|
| 6 |
+
convolutions (dilations 1, 2, 4, ...), and a final LayerNorm. With eight
|
| 7 |
+
blocks the receptive radius is 2 + 2 * (1 + 2 + ... + 128) = 512 bases.
|
| 8 |
+
|
| 9 |
+
Both networks take the coding-probability track itself as input and turn it
|
| 10 |
+
into log-odds internally (``coding_log_odds``). Both read tracks far longer
|
| 11 |
+
than GPU memory allows in one pass; ``chunked_inference`` runs a network
|
| 12 |
+
over a track in chunks with the network's full context on both sides, so the
|
| 13 |
+
outputs equal those of one pass over the whole track. Both store their weights
|
| 14 |
+
as safetensors files, read by ``load_weights``.
|
| 15 |
+
"""
|
| 16 |
+
|
| 17 |
+
import json
|
| 18 |
+
|
| 19 |
+
import numpy as np
|
| 20 |
+
import torch
|
| 21 |
+
from safetensors import safe_open
|
| 22 |
+
from torch import nn
|
| 23 |
+
|
| 24 |
+
from constants import NETWORK_CHUNK_BP, NETWORK_INPUT_MIN_PROB
|
| 25 |
+
|
| 26 |
+
|
| 27 |
+
class ResidualDilatedBlock(nn.Module):
|
| 28 |
+
"""Add one depthwise dilated-convolution update to the features.
|
| 29 |
+
|
| 30 |
+
Each block widens the receptive field by 2 * dilation bases on both sides:
|
| 31 |
+
a depthwise width-5 dilated convolution mixes nearby positions, LayerNorm
|
| 32 |
+
and GELU shape the update, and a pointwise convolution mixes channels
|
| 33 |
+
before the residual sum.
|
| 34 |
+
"""
|
| 35 |
+
|
| 36 |
+
def __init__(self, channels, dilation):
|
| 37 |
+
super().__init__()
|
| 38 |
+
self.depthwise = nn.Conv1d(
|
| 39 |
+
channels, channels, kernel_size=5,
|
| 40 |
+
padding=2 * dilation, dilation=dilation, groups=channels,
|
| 41 |
+
)
|
| 42 |
+
self.normalization = nn.LayerNorm(channels)
|
| 43 |
+
self.pointwise = nn.Conv1d(channels, channels, kernel_size=1)
|
| 44 |
+
self.activation = nn.GELU()
|
| 45 |
+
|
| 46 |
+
def forward(self, features):
|
| 47 |
+
update = self.depthwise(features)
|
| 48 |
+
update = self.normalization(update.transpose(1, 2)).transpose(1, 2)
|
| 49 |
+
update = self.activation(update)
|
| 50 |
+
return features + self.pointwise(update)
|
| 51 |
+
|
| 52 |
+
|
| 53 |
+
class TCN(nn.Module):
|
| 54 |
+
"""Contextual per-base features from a few input tracks."""
|
| 55 |
+
|
| 56 |
+
def __init__(self, input_channels, channels, dilation_count):
|
| 57 |
+
super().__init__()
|
| 58 |
+
self.channels = int(channels)
|
| 59 |
+
self.dilation_count = int(dilation_count)
|
| 60 |
+
if self.dilation_count < 1:
|
| 61 |
+
raise ValueError("at least one dilated block is required")
|
| 62 |
+
# Block k dilates by 2 ** k, so the receptive radius doubles with every block.
|
| 63 |
+
self.dilations = tuple(2 ** index for index in range(self.dilation_count))
|
| 64 |
+
self.context_radius = 2 + 2 * sum(self.dilations)
|
| 65 |
+
|
| 66 |
+
self.input_projection = nn.Conv1d(input_channels, self.channels, kernel_size=5, padding=2)
|
| 67 |
+
self.blocks = nn.Sequential(*[
|
| 68 |
+
ResidualDilatedBlock(self.channels, dilation) for dilation in self.dilations
|
| 69 |
+
])
|
| 70 |
+
self.output_normalization = nn.LayerNorm(self.channels)
|
| 71 |
+
|
| 72 |
+
def forward(self, input_tracks):
|
| 73 |
+
"""Map ``[batch, input_channels, bases]`` to ``[batch, bases, channels]``."""
|
| 74 |
+
features = self.blocks(self.input_projection(input_tracks))
|
| 75 |
+
return self.output_normalization(features.transpose(1, 2))
|
| 76 |
+
|
| 77 |
+
|
| 78 |
+
def coding_log_odds(probabilities):
|
| 79 |
+
"""Return the float32 log-odds of a tensor of coding probabilities.
|
| 80 |
+
|
| 81 |
+
Probabilities are clipped to ``[NETWORK_INPUT_MIN_PROB, 1 - NETWORK_INPUT_MIN_PROB]``
|
| 82 |
+
first, so a probability of exactly 0 or 1 stays finite.
|
| 83 |
+
"""
|
| 84 |
+
return torch.logit(probabilities.float().clamp(NETWORK_INPUT_MIN_PROB, 1.0 - NETWORK_INPUT_MIN_PROB))
|
| 85 |
+
|
| 86 |
+
|
| 87 |
+
def chunked_inference(network, context_radius, tracks, device, chunk_bp=NETWORK_CHUNK_BP):
|
| 88 |
+
"""Run a per-base network over long tracks, one chunk at a time.
|
| 89 |
+
|
| 90 |
+
``tracks`` are per-base arrays of one common length in decoder
|
| 91 |
+
orientation. ``network(*chunks)`` receives corresponding chunks of them as
|
| 92 |
+
tensors on ``device`` and returns one output row per base; each output
|
| 93 |
+
depends on the inputs within ``context_radius`` bases of its base. The
|
| 94 |
+
tracks are processed in chunks of at most ``chunk_bp`` bases, each
|
| 95 |
+
evaluated with ``context_radius`` extra bases on both sides, clipped to
|
| 96 |
+
the tracks, so the outputs equal those of one pass over the whole tracks
|
| 97 |
+
while only one chunk is ever on the device. Returns the float32
|
| 98 |
+
``[bases, ...]`` outputs as one CPU array. On CUDA the network runs under
|
| 99 |
+
bfloat16 autocast, the precision used for all reported results; on CPU it
|
| 100 |
+
runs in float32.
|
| 101 |
+
"""
|
| 102 |
+
tracks = [np.asarray(track) for track in tracks]
|
| 103 |
+
if not tracks or any(track.ndim != 1 for track in tracks) or len({len(track) for track in tracks}) != 1:
|
| 104 |
+
raise ValueError("tracks must be one or more vectors of one common length")
|
| 105 |
+
length = len(tracks[0])
|
| 106 |
+
if length == 0:
|
| 107 |
+
raise ValueError("tracks must be nonempty")
|
| 108 |
+
if chunk_bp < 1:
|
| 109 |
+
raise ValueError("chunk size must be positive")
|
| 110 |
+
|
| 111 |
+
outputs = None
|
| 112 |
+
for start in range(0, length, chunk_bp):
|
| 113 |
+
stop = min(start + chunk_bp, length)
|
| 114 |
+
window_start = max(0, start - context_radius)
|
| 115 |
+
window_stop = min(length, stop + context_radius)
|
| 116 |
+
with torch.autocast(device_type=device.type, dtype=torch.bfloat16, enabled=device.type == "cuda"):
|
| 117 |
+
window_outputs = network(*(
|
| 118 |
+
torch.as_tensor(track[window_start:window_stop]).to(device) for track in tracks
|
| 119 |
+
))
|
| 120 |
+
chunk_outputs = window_outputs[start - window_start:stop - window_start].float().cpu().numpy()
|
| 121 |
+
if outputs is None:
|
| 122 |
+
outputs = np.empty((length,) + chunk_outputs.shape[1:], dtype=np.float32)
|
| 123 |
+
outputs[start:stop] = chunk_outputs
|
| 124 |
+
return outputs
|
| 125 |
+
|
| 126 |
+
|
| 127 |
+
def load_weights(path):
|
| 128 |
+
"""Read a network's weights file: its metadata fields plus ``state_dict``.
|
| 129 |
+
|
| 130 |
+
The weights are a safetensors file whose metadata values are JSON-encoded,
|
| 131 |
+
such as the network type, its sizes, and the names of its inputs or outputs.
|
| 132 |
+
"""
|
| 133 |
+
with safe_open(path, framework="pt") as weights:
|
| 134 |
+
checkpoint = {key: json.loads(value) for key, value in (weights.metadata() or {}).items()}
|
| 135 |
+
checkpoint["state_dict"] = {name: weights.get_tensor(name) for name in weights.keys()}
|
| 136 |
+
return checkpoint
|
annotation_pipeline/src/transition_network.py
ADDED
|
@@ -0,0 +1,141 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""Transition network: transition probabilities between adjacent bases.
|
| 2 |
+
|
| 3 |
+
The transition network is a small temporal convolutional network (TCN) that
|
| 4 |
+
reads one strand's coding-probability track and predicts what happens at
|
| 5 |
+
every boundary between adjacent bases and at both ends of the record. An
|
| 6 |
+
L-base record has L + 1 such boundaries; boundary j lies between bases j - 1
|
| 7 |
+
and j. At each boundary the network outputs a distribution over five
|
| 8 |
+
possibilities, staying or one of the four transitions:
|
| 9 |
+
|
| 10 |
+
stay remain in the current segment
|
| 11 |
+
enter_gene a gene begins with base j
|
| 12 |
+
leave_gene a gene ends with base j - 1
|
| 13 |
+
enter_intron an intron begins with base j
|
| 14 |
+
leave_intron an intron ends and an exon resumes with base j
|
| 15 |
+
|
| 16 |
+
The Viterbi algorithm (viterbi.py) adds the log-probability of the
|
| 17 |
+
possibility it selects at every boundary to the path score. Because every path
|
| 18 |
+
selects exactly one per boundary, subtracting log P(stay) at each
|
| 19 |
+
boundary changes the score of every path by the same constant, so the decoders
|
| 20 |
+
receive each transition as its log-odds relative to staying, and staying costs
|
| 21 |
+
nothing. The four log-odds are attached to the base where the new segment
|
| 22 |
+
begins or, for leaving a gene, to the base that closes it:
|
| 23 |
+
|
| 24 |
+
enter_gene(i) = log P(enter_gene | boundary i) - log P(stay | boundary i)
|
| 25 |
+
leave_gene(i) = log P(leave_gene | boundary i + 1) - log P(stay | boundary i + 1)
|
| 26 |
+
enter_intron(i) = log P(enter_intron | boundary i) - log P(stay | boundary i)
|
| 27 |
+
leave_intron(i) = log P(leave_intron | boundary i) - log P(stay | boundary i)
|
| 28 |
+
|
| 29 |
+
Records are scored in chunks read with the network's full context on both
|
| 30 |
+
sides (tcn.chunked_inference), so a chromosome never has to fit in GPU memory
|
| 31 |
+
at once. The scores leave this module in nats; the Viterbi implementations
|
| 32 |
+
quantize them to their own fixed-point units (utils.prepare_transition_probabilities).
|
| 33 |
+
"""
|
| 34 |
+
|
| 35 |
+
import numpy as np
|
| 36 |
+
import torch
|
| 37 |
+
from torch import nn
|
| 38 |
+
from torch.nn import functional as F
|
| 39 |
+
|
| 40 |
+
from constants import (
|
| 41 |
+
ENTER_GENE,
|
| 42 |
+
ENTER_INTRON,
|
| 43 |
+
LEAVE_GENE,
|
| 44 |
+
LEAVE_INTRON,
|
| 45 |
+
NETWORK_CHUNK_BP,
|
| 46 |
+
TRANSITION_COUNT,
|
| 47 |
+
TRANSITION_NAMES,
|
| 48 |
+
)
|
| 49 |
+
from tcn import TCN, chunked_inference, coding_log_odds, load_weights
|
| 50 |
+
|
| 51 |
+
TRANSITION_NETWORK_TYPE = "transition_network"
|
| 52 |
+
|
| 53 |
+
# Columns of the network's boundary logits: staying, then the four transitions
|
| 54 |
+
# in constants.TRANSITION_NAMES order.
|
| 55 |
+
STAY = 0
|
| 56 |
+
TRANSITION_LOGIT_NAMES = ("stay",) + TRANSITION_NAMES
|
| 57 |
+
|
| 58 |
+
|
| 59 |
+
class TransitionNetwork(nn.Module):
|
| 60 |
+
"""Predict the five-way transition distribution at every boundary.
|
| 61 |
+
|
| 62 |
+
The TCN reads the coding probabilities, as log-odds, alone. A width-two
|
| 63 |
+
readout then combines the features of the two bases beside each boundary,
|
| 64 |
+
so L input bases yield L + 1 distributions; the boundaries before
|
| 65 |
+
the first and after the last base see zero features on their outer side.
|
| 66 |
+
The context radius is one base larger than the TCN's because a boundary
|
| 67 |
+
reads both neighbours.
|
| 68 |
+
"""
|
| 69 |
+
|
| 70 |
+
def __init__(self, channels=32, dilation_count=8):
|
| 71 |
+
super().__init__()
|
| 72 |
+
self.encoder = TCN(1, channels, dilation_count)
|
| 73 |
+
self.context_radius = self.encoder.context_radius + 1
|
| 74 |
+
self.transition_readout = nn.Conv1d(self.encoder.channels, len(TRANSITION_LOGIT_NAMES), kernel_size=2)
|
| 75 |
+
|
| 76 |
+
def transition_logits(self, probabilities):
|
| 77 |
+
"""Return ``[batch, bases + 1, 5]`` boundary logits over ``TRANSITION_LOGIT_NAMES`` from ``[batch, bases]`` probabilities."""
|
| 78 |
+
if probabilities.ndim != 2:
|
| 79 |
+
raise ValueError("probabilities must have shape [batch, bases]")
|
| 80 |
+
features = self.encoder(coding_log_odds(probabilities)[:, None, :]).transpose(1, 2)
|
| 81 |
+
return self.transition_readout(F.pad(features, (1, 1))).transpose(1, 2)
|
| 82 |
+
|
| 83 |
+
def forward(self, probabilities):
|
| 84 |
+
"""Return the four per-base transition tracks, in nats, from ``[batch, bases]`` probabilities."""
|
| 85 |
+
return transition_log_odds_from_logits(self.transition_logits(probabilities))
|
| 86 |
+
|
| 87 |
+
@torch.inference_mode()
|
| 88 |
+
def inference(self, probabilities, chunk_bp=NETWORK_CHUNK_BP):
|
| 89 |
+
"""Return the transition probabilities of one oriented coding-probability track.
|
| 90 |
+
|
| 91 |
+
The result is a float32 ``(bases, 4)`` array in ``TRANSITION_NAMES``
|
| 92 |
+
column order, each value the transition's log-odds against staying, in
|
| 93 |
+
nats. The track is processed in ``chunk_bp`` chunks with the network's
|
| 94 |
+
full context on both sides (tcn.chunked_inference), so the values equal
|
| 95 |
+
those of scoring the whole record at once.
|
| 96 |
+
"""
|
| 97 |
+
probabilities = np.asarray(probabilities, dtype=np.float32)
|
| 98 |
+
if probabilities.ndim != 1 or len(probabilities) == 0:
|
| 99 |
+
raise ValueError("probabilities must be a nonempty vector")
|
| 100 |
+
|
| 101 |
+
def network(chunk):
|
| 102 |
+
return self(chunk[None, :])[0]
|
| 103 |
+
|
| 104 |
+
return chunked_inference(
|
| 105 |
+
network, self.context_radius, (probabilities,), next(self.parameters()).device, chunk_bp,
|
| 106 |
+
)
|
| 107 |
+
|
| 108 |
+
|
| 109 |
+
def transition_log_odds_from_logits(transition_logits):
|
| 110 |
+
"""Map ``[batch, bases + 1, 5]`` boundary logits to ``[batch, bases, 4]`` tracks.
|
| 111 |
+
|
| 112 |
+
Each track is the transition's log-odds relative to ``stay`` at the same
|
| 113 |
+
boundary, placed on the base described in the module docstring: the
|
| 114 |
+
boundary before the base for entering a gene or an intron and for leaving
|
| 115 |
+
an intron, the boundary after the base for leaving a gene.
|
| 116 |
+
"""
|
| 117 |
+
if transition_logits.ndim != 3 or transition_logits.shape[2] != len(TRANSITION_LOGIT_NAMES):
|
| 118 |
+
raise ValueError("transition logits must have shape [batch, bases + 1, 5]")
|
| 119 |
+
if transition_logits.shape[1] < 2:
|
| 120 |
+
raise ValueError("transition logits must describe at least one base")
|
| 121 |
+
|
| 122 |
+
# Log-odds against staying; the transition columns follow the stay column.
|
| 123 |
+
relative = (transition_logits - transition_logits[:, :, STAY:STAY + 1])[:, :, STAY + 1:]
|
| 124 |
+
tracks = relative.new_zeros(relative.shape[0], relative.shape[1] - 1, TRANSITION_COUNT)
|
| 125 |
+
tracks[:, :, ENTER_GENE] = relative[:, :-1, ENTER_GENE]
|
| 126 |
+
tracks[:, :, LEAVE_GENE] = relative[:, 1:, LEAVE_GENE]
|
| 127 |
+
tracks[:, :, ENTER_INTRON] = relative[:, :-1, ENTER_INTRON]
|
| 128 |
+
tracks[:, :, LEAVE_INTRON] = relative[:, :-1, LEAVE_INTRON]
|
| 129 |
+
return tracks
|
| 130 |
+
|
| 131 |
+
|
| 132 |
+
def load_transition_network(path, device):
|
| 133 |
+
"""Load the transition network weights onto ``device`` in evaluation mode."""
|
| 134 |
+
checkpoint = load_weights(path)
|
| 135 |
+
if checkpoint.get("model_type") != TRANSITION_NETWORK_TYPE:
|
| 136 |
+
raise ValueError(f"{path}: not a transition network checkpoint")
|
| 137 |
+
if tuple(checkpoint.get("outcome_names", TRANSITION_LOGIT_NAMES)) != TRANSITION_LOGIT_NAMES:
|
| 138 |
+
raise ValueError(f"{path}: unexpected transition logits")
|
| 139 |
+
network = TransitionNetwork(int(checkpoint["channels"]), int(checkpoint["dilation_count"]))
|
| 140 |
+
network.load_state_dict(checkpoint["state_dict"])
|
| 141 |
+
return network.to(device).eval()
|
annotation_pipeline/src/utils.py
ADDED
|
@@ -0,0 +1,791 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""Utility functions shared by the Carbon-A annotation pipeline.
|
| 2 |
+
|
| 3 |
+
The functions in this module do not import numba, torch, or Viterbi backends.
|
| 4 |
+
They are intentionally plain NumPy/Python helpers used by post-processing,
|
| 5 |
+
evaluation, and the CPU/GPU Viterbi backends.
|
| 6 |
+
"""
|
| 7 |
+
|
| 8 |
+
import json
|
| 9 |
+
import operator
|
| 10 |
+
from pathlib import Path
|
| 11 |
+
|
| 12 |
+
import numpy as np
|
| 13 |
+
|
| 14 |
+
from constants import (
|
| 15 |
+
AMINO_ACID_SYMBOLS,
|
| 16 |
+
BASE_A,
|
| 17 |
+
BASE_C,
|
| 18 |
+
BASE_G,
|
| 19 |
+
BASE_N,
|
| 20 |
+
BASE_T,
|
| 21 |
+
BASE_VALUES,
|
| 22 |
+
CODON_BASES,
|
| 23 |
+
CODON_TABLE_WARNING_MIN_GENES,
|
| 24 |
+
CODON_TABLE_WARNING_FRACTION,
|
| 25 |
+
CONFIDENCE_THRESHOLD,
|
| 26 |
+
COMPLEMENT_BASES,
|
| 27 |
+
FIXED_POINT_SCORE_SCALE,
|
| 28 |
+
MIN_INTERNAL_INTRON_LENGTH_BP,
|
| 29 |
+
NEGATIVE_STRAND_PROBABILITY_COLUMN,
|
| 30 |
+
NON_NUCLEAR_ASSEMBLY_UNIT,
|
| 31 |
+
POSITIVE_STRAND_PROBABILITY_COLUMN,
|
| 32 |
+
RECORD_ID_COLUMN,
|
| 33 |
+
SEQUENCE_COLUMN,
|
| 34 |
+
START_CODONS,
|
| 35 |
+
STOP_CODONS,
|
| 36 |
+
TRANSITION_COUNT,
|
| 37 |
+
)
|
| 38 |
+
|
| 39 |
+
_BASE_ENCODING_VALUES = bytearray([BASE_N] * 256)
|
| 40 |
+
_COMPLEMENT_ENCODING_VALUES = bytearray([BASE_N] * 256)
|
| 41 |
+
for _base, _base_value in BASE_VALUES.items():
|
| 42 |
+
_BASE_ENCODING_VALUES[ord(_base)] = _base_value
|
| 43 |
+
_BASE_ENCODING_VALUES[ord(_base.lower())] = _base_value
|
| 44 |
+
|
| 45 |
+
_complement_value = BASE_VALUES[COMPLEMENT_BASES[_base]]
|
| 46 |
+
_COMPLEMENT_ENCODING_VALUES[ord(_base)] = _complement_value
|
| 47 |
+
_COMPLEMENT_ENCODING_VALUES[ord(_base.lower())] = _complement_value
|
| 48 |
+
|
| 49 |
+
BASE_ENCODING_TABLE = bytes(_BASE_ENCODING_VALUES)
|
| 50 |
+
COMPLEMENT_ENCODING_TABLE = bytes(_COMPLEMENT_ENCODING_VALUES)
|
| 51 |
+
REVERSE_COMPLEMENT_TRANSLATION = str.maketrans("ATCGNatcgn", "TAGCNtagcn")
|
| 52 |
+
|
| 53 |
+
def validate_max_boundary_intron_length(value):
|
| 54 |
+
"""Return a boundary-intron limit of -1 or a nonnegative integer.
|
| 55 |
+
|
| 56 |
+
``-1`` leaves boundary introns unlimited, ``0`` disables both boundary
|
| 57 |
+
intron states, and a positive value sets their maximum visible length in
|
| 58 |
+
bases. With a limit of ``0``, incomplete genes can only result from a record
|
| 59 |
+
boundary truncating an exon.
|
| 60 |
+
"""
|
| 61 |
+
try:
|
| 62 |
+
value = operator.index(value)
|
| 63 |
+
except TypeError as exc:
|
| 64 |
+
raise TypeError("max_boundary_intron_length must be an integer") from exc
|
| 65 |
+
if value < -1:
|
| 66 |
+
raise ValueError(
|
| 67 |
+
"max_boundary_intron_length must be -1 or a nonnegative integer"
|
| 68 |
+
)
|
| 69 |
+
return value
|
| 70 |
+
|
| 71 |
+
|
| 72 |
+
def validate_reconstruction_batch_size(value):
|
| 73 |
+
"""Return a reconstruction batch size in bases, or ``None`` for auto sizing."""
|
| 74 |
+
if value is None:
|
| 75 |
+
return None
|
| 76 |
+
try:
|
| 77 |
+
value = operator.index(value)
|
| 78 |
+
except TypeError as exc:
|
| 79 |
+
raise TypeError("reconstruction_batch_size must be an integer or None") from exc
|
| 80 |
+
if value <= 0:
|
| 81 |
+
raise ValueError("reconstruction_batch_size must be positive or None")
|
| 82 |
+
return value
|
| 83 |
+
|
| 84 |
+
|
| 85 |
+
# Codon tables are flat JSON objects: all 64 concrete codons map to one-letter
|
| 86 |
+
# amino acid symbols, and "*" selects the decoder's enabled stop codons.
|
| 87 |
+
ALL_CODONS = frozenset(
|
| 88 |
+
first + second + third
|
| 89 |
+
for first in CODON_BASES
|
| 90 |
+
for second in CODON_BASES
|
| 91 |
+
for third in CODON_BASES
|
| 92 |
+
)
|
| 93 |
+
def validate_codon_table(codon_to_amino_acid):
|
| 94 |
+
"""Validate and return a copy of a complete codon-to-amino-acid mapping."""
|
| 95 |
+
if not isinstance(codon_to_amino_acid, dict):
|
| 96 |
+
raise ValueError("codon table JSON must contain one object")
|
| 97 |
+
|
| 98 |
+
supplied_codons = set(codon_to_amino_acid)
|
| 99 |
+
missing_codons = sorted(ALL_CODONS - supplied_codons)
|
| 100 |
+
unexpected_codons = sorted(supplied_codons - ALL_CODONS)
|
| 101 |
+
if missing_codons or unexpected_codons:
|
| 102 |
+
details = []
|
| 103 |
+
if missing_codons:
|
| 104 |
+
details.append("missing " + ",".join(missing_codons))
|
| 105 |
+
if unexpected_codons:
|
| 106 |
+
details.append("unexpected " + ",".join(unexpected_codons))
|
| 107 |
+
raise ValueError(
|
| 108 |
+
"codon table must contain all 64 ATCG codons: "
|
| 109 |
+
+ "; ".join(details)
|
| 110 |
+
)
|
| 111 |
+
|
| 112 |
+
invalid_amino_acids = {
|
| 113 |
+
codon: amino_acid
|
| 114 |
+
for codon, amino_acid in codon_to_amino_acid.items()
|
| 115 |
+
if not isinstance(amino_acid, str)
|
| 116 |
+
or len(amino_acid) != 1
|
| 117 |
+
or amino_acid not in AMINO_ACID_SYMBOLS
|
| 118 |
+
}
|
| 119 |
+
if invalid_amino_acids:
|
| 120 |
+
formatted_values = ", ".join(
|
| 121 |
+
f"{codon}={amino_acid!r}"
|
| 122 |
+
for codon, amino_acid in sorted(invalid_amino_acids.items())
|
| 123 |
+
)
|
| 124 |
+
raise ValueError(
|
| 125 |
+
"amino acids must use the 20 standard one-letter symbols or '*': "
|
| 126 |
+
+ formatted_values
|
| 127 |
+
)
|
| 128 |
+
|
| 129 |
+
unsupported_stop_codons = sorted(
|
| 130 |
+
codon
|
| 131 |
+
for codon, amino_acid in codon_to_amino_acid.items()
|
| 132 |
+
if amino_acid == "*" and codon not in STOP_CODONS
|
| 133 |
+
)
|
| 134 |
+
if unsupported_stop_codons:
|
| 135 |
+
raise ValueError(
|
| 136 |
+
"decoder stop codons must be a subset of TAA,TAG,TGA: "
|
| 137 |
+
+ ",".join(unsupported_stop_codons)
|
| 138 |
+
)
|
| 139 |
+
|
| 140 |
+
return dict(codon_to_amino_acid)
|
| 141 |
+
|
| 142 |
+
|
| 143 |
+
def load_codon_table(path):
|
| 144 |
+
"""Load a codon-to-amino-acid mapping from a JSON file."""
|
| 145 |
+
path = Path(path)
|
| 146 |
+
try:
|
| 147 |
+
with path.open(encoding="utf-8") as handle:
|
| 148 |
+
codon_to_amino_acid = json.load(handle)
|
| 149 |
+
except json.JSONDecodeError as exc:
|
| 150 |
+
raise ValueError(
|
| 151 |
+
f"invalid codon table JSON at {path}:{exc.lineno}:{exc.colno}: {exc.msg}"
|
| 152 |
+
) from exc
|
| 153 |
+
return validate_codon_table(codon_to_amino_acid)
|
| 154 |
+
|
| 155 |
+
|
| 156 |
+
def stop_codons_from_table(codon_to_amino_acid):
|
| 157 |
+
"""Return supported stop codons whose amino-acid symbol is ``*``."""
|
| 158 |
+
codon_to_amino_acid = validate_codon_table(codon_to_amino_acid)
|
| 159 |
+
return tuple(
|
| 160 |
+
codon
|
| 161 |
+
for codon in STOP_CODONS
|
| 162 |
+
if codon_to_amino_acid[codon] == "*"
|
| 163 |
+
)
|
| 164 |
+
|
| 165 |
+
|
| 166 |
+
# Translation uses a five-value alphabet: A/T/C/G plus one value shared by all
|
| 167 |
+
# ambiguous bases. Three values form a base-5 codon index into this table.
|
| 168 |
+
TRANSLATION_BASE_COUNT = 5
|
| 169 |
+
AMBIGUOUS_TRANSLATION_BASE = BASE_N
|
| 170 |
+
TRANSLATION_BASE_TO_VALUE = np.full(
|
| 171 |
+
256,
|
| 172 |
+
AMBIGUOUS_TRANSLATION_BASE,
|
| 173 |
+
dtype=np.uint8,
|
| 174 |
+
)
|
| 175 |
+
for _base, _base_value in BASE_VALUES.items():
|
| 176 |
+
TRANSLATION_BASE_TO_VALUE[ord(_base)] = _base_value
|
| 177 |
+
TRANSLATION_BASE_TO_VALUE[ord(_base.lower())] = _base_value
|
| 178 |
+
|
| 179 |
+
|
| 180 |
+
def build_translation_lookup(codon_to_amino_acid):
|
| 181 |
+
"""Build the compact lookup array used by vectorized protein translation."""
|
| 182 |
+
codon_to_amino_acid = validate_codon_table(codon_to_amino_acid)
|
| 183 |
+
lookup = np.full(
|
| 184 |
+
TRANSLATION_BASE_COUNT ** 3,
|
| 185 |
+
ord("X"),
|
| 186 |
+
dtype=np.uint8,
|
| 187 |
+
)
|
| 188 |
+
for codon, amino_acid in codon_to_amino_acid.items():
|
| 189 |
+
first_base, second_base, third_base = (
|
| 190 |
+
BASE_VALUES[base] for base in codon
|
| 191 |
+
)
|
| 192 |
+
codon_index = (
|
| 193 |
+
first_base * TRANSLATION_BASE_COUNT * TRANSLATION_BASE_COUNT
|
| 194 |
+
+ second_base * TRANSLATION_BASE_COUNT
|
| 195 |
+
+ third_base
|
| 196 |
+
)
|
| 197 |
+
lookup[codon_index] = ord(amino_acid)
|
| 198 |
+
return lookup
|
| 199 |
+
|
| 200 |
+
|
| 201 |
+
# Decoder state schema. The active sequence-conditioned decoder uses 30 states.
|
| 202 |
+
PREFIX_TYPE_COUNT = 6
|
| 203 |
+
STATE_COUNT = 30
|
| 204 |
+
|
| 205 |
+
# Prefix type ids carried by exon and intron states. Codon phase is the number
|
| 206 |
+
# of CDS bases already consumed in the current codon. Prefix types retain the
|
| 207 |
+
# additional bases needed to recognize a standard stop on the next emission.
|
| 208 |
+
PREFIX_TYPE_0 = 0 # codon phase 0; codon boundary, no open prefix
|
| 209 |
+
PREFIX_TYPE_1 = 1 # codon phase 1; one-base prefix T
|
| 210 |
+
PREFIX_TYPE_2 = 2 # codon phase 1; one-base prefix other
|
| 211 |
+
PREFIX_TYPE_3 = 3 # codon phase 2; two-base prefix TA
|
| 212 |
+
PREFIX_TYPE_4 = 4 # codon phase 2; two-base prefix TG
|
| 213 |
+
PREFIX_TYPE_5 = 5 # codon phase 2; two-base prefix other
|
| 214 |
+
|
| 215 |
+
# Numeric state ids. Keep the layout grouped by biological role rather than by
|
| 216 |
+
# historical patches; lookup tables below make prefix-type mapping explicit.
|
| 217 |
+
INTERGENIC = 0
|
| 218 |
+
START_A = 1
|
| 219 |
+
START_AT = 2
|
| 220 |
+
EXON_PREFIX_TYPE_0 = 3
|
| 221 |
+
EXON_PREFIX_TYPE_1 = 4
|
| 222 |
+
EXON_PREFIX_TYPE_2 = 5
|
| 223 |
+
EXON_PREFIX_TYPE_3 = 6
|
| 224 |
+
EXON_PREFIX_TYPE_4 = 7
|
| 225 |
+
EXON_PREFIX_TYPE_5 = 8
|
| 226 |
+
STOP = 9
|
| 227 |
+
GT_GC_AG_INTRON_FIRST_PREFIX_TYPE_0 = 10
|
| 228 |
+
GT_GC_AG_INTRON_FIRST_PREFIX_TYPE_1 = 11
|
| 229 |
+
GT_GC_AG_INTRON_FIRST_PREFIX_TYPE_2 = 12
|
| 230 |
+
GT_GC_AG_INTRON_FIRST_PREFIX_TYPE_3 = 13
|
| 231 |
+
GT_GC_AG_INTRON_FIRST_PREFIX_TYPE_4 = 14
|
| 232 |
+
GT_GC_AG_INTRON_FIRST_PREFIX_TYPE_5 = 15
|
| 233 |
+
GT_GC_AG_INTRON_READY_PREFIX_TYPE_0 = 16
|
| 234 |
+
GT_GC_AG_INTRON_READY_PREFIX_TYPE_1 = 17
|
| 235 |
+
GT_GC_AG_INTRON_READY_PREFIX_TYPE_2 = 18
|
| 236 |
+
GT_GC_AG_INTRON_READY_PREFIX_TYPE_3 = 19
|
| 237 |
+
GT_GC_AG_INTRON_READY_PREFIX_TYPE_4 = 20
|
| 238 |
+
GT_GC_AG_INTRON_READY_PREFIX_TYPE_5 = 21
|
| 239 |
+
AT_AC_INTRON_PREFIX_TYPE_0 = 22
|
| 240 |
+
AT_AC_INTRON_PREFIX_TYPE_1 = 23
|
| 241 |
+
AT_AC_INTRON_PREFIX_TYPE_2 = 24
|
| 242 |
+
AT_AC_INTRON_PREFIX_TYPE_3 = 25
|
| 243 |
+
AT_AC_INTRON_PREFIX_TYPE_4 = 26
|
| 244 |
+
AT_AC_INTRON_PREFIX_TYPE_5 = 27
|
| 245 |
+
LEADING_BOUNDARY_INTRON = 28
|
| 246 |
+
TRAILING_BOUNDARY_INTRON = 29
|
| 247 |
+
|
| 248 |
+
EXON_STATE_BY_PREFIX_TYPE = np.array((
|
| 249 |
+
EXON_PREFIX_TYPE_0,
|
| 250 |
+
EXON_PREFIX_TYPE_1,
|
| 251 |
+
EXON_PREFIX_TYPE_2,
|
| 252 |
+
EXON_PREFIX_TYPE_3,
|
| 253 |
+
EXON_PREFIX_TYPE_4,
|
| 254 |
+
EXON_PREFIX_TYPE_5,
|
| 255 |
+
), dtype=np.int64)
|
| 256 |
+
|
| 257 |
+
GT_GC_AG_INTRON_FIRST_STATE_BY_PREFIX_TYPE = np.array((
|
| 258 |
+
GT_GC_AG_INTRON_FIRST_PREFIX_TYPE_0,
|
| 259 |
+
GT_GC_AG_INTRON_FIRST_PREFIX_TYPE_1,
|
| 260 |
+
GT_GC_AG_INTRON_FIRST_PREFIX_TYPE_2,
|
| 261 |
+
GT_GC_AG_INTRON_FIRST_PREFIX_TYPE_3,
|
| 262 |
+
GT_GC_AG_INTRON_FIRST_PREFIX_TYPE_4,
|
| 263 |
+
GT_GC_AG_INTRON_FIRST_PREFIX_TYPE_5,
|
| 264 |
+
), dtype=np.int64)
|
| 265 |
+
|
| 266 |
+
GT_GC_AG_INTRON_READY_STATE_BY_PREFIX_TYPE = np.array((
|
| 267 |
+
GT_GC_AG_INTRON_READY_PREFIX_TYPE_0,
|
| 268 |
+
GT_GC_AG_INTRON_READY_PREFIX_TYPE_1,
|
| 269 |
+
GT_GC_AG_INTRON_READY_PREFIX_TYPE_2,
|
| 270 |
+
GT_GC_AG_INTRON_READY_PREFIX_TYPE_3,
|
| 271 |
+
GT_GC_AG_INTRON_READY_PREFIX_TYPE_4,
|
| 272 |
+
GT_GC_AG_INTRON_READY_PREFIX_TYPE_5,
|
| 273 |
+
), dtype=np.int64)
|
| 274 |
+
|
| 275 |
+
AT_AC_INTRON_STATE_BY_PREFIX_TYPE = np.array((
|
| 276 |
+
AT_AC_INTRON_PREFIX_TYPE_0,
|
| 277 |
+
AT_AC_INTRON_PREFIX_TYPE_1,
|
| 278 |
+
AT_AC_INTRON_PREFIX_TYPE_2,
|
| 279 |
+
AT_AC_INTRON_PREFIX_TYPE_3,
|
| 280 |
+
AT_AC_INTRON_PREFIX_TYPE_4,
|
| 281 |
+
AT_AC_INTRON_PREFIX_TYPE_5,
|
| 282 |
+
), dtype=np.int64)
|
| 283 |
+
|
| 284 |
+
|
| 285 |
+
# Unlike codon phase above, GFF3 phase is the number of visible bases to skip at
|
| 286 |
+
# the beginning of a CDS feature before reaching the first complete codon. A
|
| 287 |
+
# boundary-truncated gene's first state retains enough information to recover it.
|
| 288 |
+
INITIAL_CDS_PHASE_BY_STATE = {
|
| 289 |
+
START_A: 0,
|
| 290 |
+
START_AT: 2,
|
| 291 |
+
EXON_PREFIX_TYPE_0: 1,
|
| 292 |
+
EXON_PREFIX_TYPE_1: 0,
|
| 293 |
+
EXON_PREFIX_TYPE_2: 0,
|
| 294 |
+
EXON_PREFIX_TYPE_3: 2,
|
| 295 |
+
EXON_PREFIX_TYPE_4: 2,
|
| 296 |
+
EXON_PREFIX_TYPE_5: 2,
|
| 297 |
+
STOP: 1,
|
| 298 |
+
}
|
| 299 |
+
|
| 300 |
+
|
| 301 |
+
def initial_cds_phase_from_state(state):
|
| 302 |
+
"""Return the initial GFF3 phase encoded by the first visible CDS state."""
|
| 303 |
+
try:
|
| 304 |
+
return INITIAL_CDS_PHASE_BY_STATE[int(state)]
|
| 305 |
+
except KeyError as exc:
|
| 306 |
+
raise ValueError(f"state {state} does not emit CDS") from exc
|
| 307 |
+
|
| 308 |
+
|
| 309 |
+
def reverse_complement(sequence):
|
| 310 |
+
"""Return the reverse complement string for a DNA sequence."""
|
| 311 |
+
return sequence.translate(REVERSE_COMPLEMENT_TRANSLATION)[::-1]
|
| 312 |
+
|
| 313 |
+
|
| 314 |
+
def encode_both_strands(sequence):
|
| 315 |
+
"""Encode both strands as A/T/C/G/N = 0/1/2/3/4."""
|
| 316 |
+
sequence_bytes = sequence.encode("ascii")
|
| 317 |
+
forward_bases = np.frombuffer(
|
| 318 |
+
sequence_bytes.translate(BASE_ENCODING_TABLE),
|
| 319 |
+
dtype=np.int8,
|
| 320 |
+
)
|
| 321 |
+
reverse_bases = np.frombuffer(
|
| 322 |
+
sequence_bytes.translate(COMPLEMENT_ENCODING_TABLE)[::-1],
|
| 323 |
+
dtype=np.int8,
|
| 324 |
+
)
|
| 325 |
+
return forward_bases, reverse_bases
|
| 326 |
+
|
| 327 |
+
|
| 328 |
+
def probability_vector_from_parquet(value):
|
| 329 |
+
"""Convert one PyArrow list cell to a 1D float16 probability array."""
|
| 330 |
+
if not hasattr(value, "values"):
|
| 331 |
+
raise TypeError("probability column must contain PyArrow list values")
|
| 332 |
+
probabilities = np.asarray(value.values, dtype=np.float16)
|
| 333 |
+
|
| 334 |
+
if probabilities.ndim != 1:
|
| 335 |
+
raise ValueError(f"probability vector must be 1D, got {probabilities.shape}")
|
| 336 |
+
return probabilities
|
| 337 |
+
|
| 338 |
+
|
| 339 |
+
def prepare_decode_tasks(tasks):
|
| 340 |
+
"""Normalize decoder tasks and reject invalid shapes or lengths."""
|
| 341 |
+
prepared_tasks = []
|
| 342 |
+
for task_index, (probabilities, encoded_bases) in enumerate(tasks):
|
| 343 |
+
probability_array = np.ascontiguousarray(probabilities, dtype=np.float16)
|
| 344 |
+
base_array = np.ascontiguousarray(encoded_bases, dtype=np.int8)
|
| 345 |
+
if probability_array.ndim != 1:
|
| 346 |
+
raise ValueError(
|
| 347 |
+
f"decoder task {task_index} probabilities must be 1D, "
|
| 348 |
+
f"got {probability_array.shape}"
|
| 349 |
+
)
|
| 350 |
+
if base_array.ndim != 1:
|
| 351 |
+
raise ValueError(
|
| 352 |
+
f"decoder task {task_index} encoded bases must be 1D, "
|
| 353 |
+
f"got {base_array.shape}"
|
| 354 |
+
)
|
| 355 |
+
if len(probability_array) != len(base_array):
|
| 356 |
+
raise ValueError(
|
| 357 |
+
f"decoder task {task_index} has {len(probability_array)} "
|
| 358 |
+
f"probabilities but {len(base_array)} encoded bases"
|
| 359 |
+
)
|
| 360 |
+
if len(probability_array) == 0:
|
| 361 |
+
raise ValueError(f"decoder task {task_index} is empty")
|
| 362 |
+
prepared_tasks.append((probability_array, base_array))
|
| 363 |
+
return prepared_tasks
|
| 364 |
+
|
| 365 |
+
|
| 366 |
+
def prepare_transition_probabilities(transition_probabilities, prepared_tasks):
|
| 367 |
+
"""Quantize one ``(bases, 4)`` transition-probability array per task for the decoders.
|
| 368 |
+
|
| 369 |
+
The columns follow ``TRANSITION_NAMES``: enter gene, leave gene, enter
|
| 370 |
+
intron, leave intron. Each value is the transition network's log-odds
|
| 371 |
+
against staying, in nats, aligned base by base with the task's
|
| 372 |
+
probabilities. The Viterbi implementations accumulate int32 fixed-point
|
| 373 |
+
scores, so the values come back rounded to ``FIXED_POINT_SCORE_SCALE``
|
| 374 |
+
units as contiguous int32 arrays.
|
| 375 |
+
"""
|
| 376 |
+
transition_probabilities = list(transition_probabilities)
|
| 377 |
+
if len(transition_probabilities) != len(prepared_tasks):
|
| 378 |
+
raise ValueError(
|
| 379 |
+
f"{len(transition_probabilities)} transition-probability arrays for "
|
| 380 |
+
f"{len(prepared_tasks)} decoder tasks"
|
| 381 |
+
)
|
| 382 |
+
limits = np.iinfo(np.int32)
|
| 383 |
+
prepared_scores = []
|
| 384 |
+
for task_index, (log_odds, (probabilities, _)) in enumerate(
|
| 385 |
+
zip(transition_probabilities, prepared_tasks)
|
| 386 |
+
):
|
| 387 |
+
scaled = np.rint(np.asarray(log_odds, dtype=np.float64) * FIXED_POINT_SCORE_SCALE)
|
| 388 |
+
if scaled.shape != (len(probabilities), TRANSITION_COUNT):
|
| 389 |
+
raise ValueError(
|
| 390 |
+
f"decoder task {task_index} transition probabilities must have shape "
|
| 391 |
+
f"{(len(probabilities), TRANSITION_COUNT)}, got {scaled.shape}"
|
| 392 |
+
)
|
| 393 |
+
if not np.all(np.isfinite(scaled)) or scaled.min() < limits.min or scaled.max() > limits.max:
|
| 394 |
+
raise OverflowError(f"decoder task {task_index} transition log-odds exceed the int32 fixed-point range")
|
| 395 |
+
prepared_scores.append(np.ascontiguousarray(scaled, dtype=np.int32))
|
| 396 |
+
return prepared_scores
|
| 397 |
+
|
| 398 |
+
|
| 399 |
+
def build_cds_sequence(genome_sequence, transcript_order_exons, strand):
|
| 400 |
+
"""Build a CDS string from genomic exon intervals in transcript order."""
|
| 401 |
+
if not transcript_order_exons:
|
| 402 |
+
return ""
|
| 403 |
+
if strand == "+":
|
| 404 |
+
return "".join(
|
| 405 |
+
genome_sequence[start:end + 1] for start, end in transcript_order_exons
|
| 406 |
+
)
|
| 407 |
+
|
| 408 |
+
genomic_order_sequence = "".join(
|
| 409 |
+
genome_sequence[start:end + 1]
|
| 410 |
+
for start, end in reversed(transcript_order_exons)
|
| 411 |
+
)
|
| 412 |
+
return reverse_complement(genomic_order_sequence)
|
| 413 |
+
|
| 414 |
+
|
| 415 |
+
def compute_gff_cds_phases(transcript_order_exons, initial_cds_phase=0):
|
| 416 |
+
"""Return GFF3 phases, preserving the first visible codon's phase."""
|
| 417 |
+
if initial_cds_phase not in (0, 1, 2):
|
| 418 |
+
raise ValueError("initial CDS phase must be 0, 1, or 2")
|
| 419 |
+
|
| 420 |
+
phases = []
|
| 421 |
+
consumed_bases = 0
|
| 422 |
+
for start, end in transcript_order_exons:
|
| 423 |
+
phase = (initial_cds_phase - consumed_bases) % 3
|
| 424 |
+
phases.append(phase)
|
| 425 |
+
consumed_bases += end - start + 1
|
| 426 |
+
return phases
|
| 427 |
+
|
| 428 |
+
|
| 429 |
+
def has_internal_stop_codon(
|
| 430 |
+
cds_sequence,
|
| 431 |
+
translation_frame,
|
| 432 |
+
stop_codons=STOP_CODONS,
|
| 433 |
+
):
|
| 434 |
+
"""Return whether a translated CDS contains a non-terminal stop codon.
|
| 435 |
+
|
| 436 |
+
This is a diagnostic check: the decoder grammar should normally make the
|
| 437 |
+
result false. A stop is terminal only when its third base is the final CDS
|
| 438 |
+
base; a stop before an incomplete trailing codon is still internal.
|
| 439 |
+
"""
|
| 440 |
+
if translation_frame not in (0, 1, 2):
|
| 441 |
+
raise ValueError("translation frame must be 0, 1, or 2")
|
| 442 |
+
|
| 443 |
+
codon_end = len(cds_sequence) - (
|
| 444 |
+
(len(cds_sequence) - translation_frame) % 3
|
| 445 |
+
)
|
| 446 |
+
for codon_start in range(translation_frame, codon_end, 3):
|
| 447 |
+
codon = cds_sequence[codon_start:codon_start + 3].upper()
|
| 448 |
+
if codon in stop_codons and codon_start + 3 < len(cds_sequence):
|
| 449 |
+
return True
|
| 450 |
+
return False
|
| 451 |
+
|
| 452 |
+
|
| 453 |
+
def classify_cds_completion(
|
| 454 |
+
cds_sequence,
|
| 455 |
+
has_start_codon,
|
| 456 |
+
has_stop_codon,
|
| 457 |
+
has_short_intron=False,
|
| 458 |
+
stop_codons=STOP_CODONS,
|
| 459 |
+
):
|
| 460 |
+
"""Return ``(complete, reason)`` for a reconstructed CDS sequence.
|
| 461 |
+
|
| 462 |
+
Reasons are comma-separated so boundary-truncated predictions can state
|
| 463 |
+
every visible problem inside one GFF3 attribute, for example
|
| 464 |
+
``no_start,no_stop``. ``short_intron``
|
| 465 |
+
records a decoder path with fewer than four bases between adjacent exons.
|
| 466 |
+
Start and stop presence comes from the decoded states, then the sequence
|
| 467 |
+
is checked as a consistency guard.
|
| 468 |
+
"""
|
| 469 |
+
reasons = []
|
| 470 |
+
if len(cds_sequence) < 6:
|
| 471 |
+
reasons.append("too_short")
|
| 472 |
+
if len(cds_sequence) % 3 != 0:
|
| 473 |
+
reasons.append("not_multiple_of_3")
|
| 474 |
+
if has_short_intron:
|
| 475 |
+
reasons.append("short_intron")
|
| 476 |
+
if (
|
| 477 |
+
not has_start_codon
|
| 478 |
+
or len(cds_sequence) < 3
|
| 479 |
+
or cds_sequence[:3] not in START_CODONS
|
| 480 |
+
):
|
| 481 |
+
reasons.append("no_start")
|
| 482 |
+
if (
|
| 483 |
+
not has_stop_codon
|
| 484 |
+
or len(cds_sequence) < 3
|
| 485 |
+
or cds_sequence[-3:] not in stop_codons
|
| 486 |
+
):
|
| 487 |
+
reasons.append("no_stop")
|
| 488 |
+
|
| 489 |
+
if reasons:
|
| 490 |
+
# Comma-separated: a semicolon would end the GFF3 attribute.
|
| 491 |
+
return False, ",".join(reasons)
|
| 492 |
+
return True, None
|
| 493 |
+
|
| 494 |
+
|
| 495 |
+
def codon_table_mismatch(
|
| 496 |
+
confidences, threshold=CONFIDENCE_THRESHOLD,
|
| 497 |
+
fraction=CODON_TABLE_WARNING_FRACTION, min_genes=CODON_TABLE_WARNING_MIN_GENES,
|
| 498 |
+
):
|
| 499 |
+
"""Return whether the confidences of the complete genes contradict the codon table.
|
| 500 |
+
|
| 501 |
+
A codon table that does not match the genome splits genes at codons the
|
| 502 |
+
genome reads through or fuses genes at stop codons it lacks, and the
|
| 503 |
+
confidence network gives such genes almost no confidence. The table is
|
| 504 |
+
contradicted when at least ``min_genes`` complete genes were scored and
|
| 505 |
+
fewer than ``fraction`` of them reach ``threshold``.
|
| 506 |
+
"""
|
| 507 |
+
confidences = np.asarray(list(confidences), dtype=np.float64)
|
| 508 |
+
if len(confidences) < min_genes:
|
| 509 |
+
return False
|
| 510 |
+
return bool(np.mean(confidences >= threshold) < fraction)
|
| 511 |
+
|
| 512 |
+
|
| 513 |
+
def has_short_internal_intron(exons):
|
| 514 |
+
"""Return whether adjacent exons leave an internal intron under four bases."""
|
| 515 |
+
for exon_index in range(1, len(exons)):
|
| 516 |
+
previous_exon = exons[exon_index - 1]
|
| 517 |
+
next_exon = exons[exon_index]
|
| 518 |
+
if previous_exon[0] < next_exon[0]:
|
| 519 |
+
intron_length = next_exon[0] - previous_exon[1] - 1
|
| 520 |
+
else:
|
| 521 |
+
intron_length = previous_exon[0] - next_exon[1] - 1
|
| 522 |
+
if 0 < intron_length < MIN_INTERNAL_INTRON_LENGTH_BP:
|
| 523 |
+
return True
|
| 524 |
+
return False
|
| 525 |
+
|
| 526 |
+
|
| 527 |
+
def translate_cds_sequence(
|
| 528 |
+
cds_sequence,
|
| 529 |
+
frame,
|
| 530 |
+
amino_acid_by_codon_index,
|
| 531 |
+
):
|
| 532 |
+
"""Translate one CDS sequence with a prepared codon-table lookup.
|
| 533 |
+
|
| 534 |
+
A single stop at the final complete codon is removed from the protein.
|
| 535 |
+
Any earlier stop indicates an invalid decoded CDS and raises an assertion
|
| 536 |
+
instead of silently truncating the output. An incomplete trailing codon is
|
| 537 |
+
ignored unless it follows a stop, in which case that stop is internal.
|
| 538 |
+
"""
|
| 539 |
+
codon_end = len(cds_sequence) - ((len(cds_sequence) - frame) % 3)
|
| 540 |
+
if codon_end <= frame:
|
| 541 |
+
return ""
|
| 542 |
+
|
| 543 |
+
sequence_bytes = np.frombuffer(cds_sequence.encode("ascii"), dtype=np.uint8)
|
| 544 |
+
base_values = TRANSLATION_BASE_TO_VALUE[
|
| 545 |
+
sequence_bytes[frame:codon_end]
|
| 546 |
+
].reshape(-1, 3)
|
| 547 |
+
codon_indices = (
|
| 548 |
+
base_values[:, 0] * TRANSLATION_BASE_COUNT * TRANSLATION_BASE_COUNT
|
| 549 |
+
+ base_values[:, 1] * TRANSLATION_BASE_COUNT
|
| 550 |
+
+ base_values[:, 2]
|
| 551 |
+
)
|
| 552 |
+
amino_acids = amino_acid_by_codon_index[codon_indices]
|
| 553 |
+
|
| 554 |
+
stop_positions = np.flatnonzero(amino_acids == ord("*"))
|
| 555 |
+
if stop_positions.size:
|
| 556 |
+
only_terminal_stop = (
|
| 557 |
+
stop_positions.size == 1
|
| 558 |
+
and stop_positions[0] == len(amino_acids) - 1
|
| 559 |
+
and codon_end == len(cds_sequence)
|
| 560 |
+
)
|
| 561 |
+
if not only_terminal_stop:
|
| 562 |
+
stop_index = int(stop_positions[0])
|
| 563 |
+
codon_start = frame + stop_index * 3
|
| 564 |
+
stop_codon = cds_sequence[codon_start:codon_start + 3].upper()
|
| 565 |
+
raise AssertionError(
|
| 566 |
+
"protein translation encountered an internal stop codon: "
|
| 567 |
+
f"codon={stop_codon}, cds_offset={codon_start}"
|
| 568 |
+
)
|
| 569 |
+
amino_acids = amino_acids[:-1]
|
| 570 |
+
return amino_acids.tobytes().decode("ascii")
|
| 571 |
+
|
| 572 |
+
|
| 573 |
+
def assemble_gene_model(
|
| 574 |
+
record_id,
|
| 575 |
+
genome_sequence,
|
| 576 |
+
oriented_exons,
|
| 577 |
+
strand,
|
| 578 |
+
record_length,
|
| 579 |
+
first_cds_state,
|
| 580 |
+
last_cds_state,
|
| 581 |
+
stop_codons=STOP_CODONS,
|
| 582 |
+
):
|
| 583 |
+
"""Build one output gene from its exons and boundary CDS states."""
|
| 584 |
+
initial_cds_phase = initial_cds_phase_from_state(first_cds_state)
|
| 585 |
+
if strand == "+":
|
| 586 |
+
transcript_order_exons = [
|
| 587 |
+
(int(start), int(end)) for start, end in oriented_exons
|
| 588 |
+
]
|
| 589 |
+
else:
|
| 590 |
+
transcript_order_exons = [
|
| 591 |
+
(record_length - 1 - int(end), record_length - 1 - int(start))
|
| 592 |
+
for start, end in oriented_exons
|
| 593 |
+
]
|
| 594 |
+
|
| 595 |
+
cds_sequence = build_cds_sequence(genome_sequence, transcript_order_exons, strand)
|
| 596 |
+
if not cds_sequence:
|
| 597 |
+
return None
|
| 598 |
+
|
| 599 |
+
transcript_order_phases = compute_gff_cds_phases(
|
| 600 |
+
transcript_order_exons,
|
| 601 |
+
initial_cds_phase,
|
| 602 |
+
)
|
| 603 |
+
genomic_order = sorted(
|
| 604 |
+
zip(transcript_order_exons, transcript_order_phases),
|
| 605 |
+
key=lambda exon_and_phase: exon_and_phase[0][0],
|
| 606 |
+
)
|
| 607 |
+
|
| 608 |
+
assert not has_internal_stop_codon(
|
| 609 |
+
cds_sequence,
|
| 610 |
+
initial_cds_phase,
|
| 611 |
+
stop_codons,
|
| 612 |
+
), (
|
| 613 |
+
"decoder produced an internal stop codon: "
|
| 614 |
+
f"record_id={record_id!r}, strand={strand}, "
|
| 615 |
+
f"phase={initial_cds_phase}, exons={transcript_order_exons}"
|
| 616 |
+
)
|
| 617 |
+
complete, incomplete_reason = classify_cds_completion(
|
| 618 |
+
cds_sequence,
|
| 619 |
+
first_cds_state == START_A,
|
| 620 |
+
last_cds_state == STOP,
|
| 621 |
+
has_short_intron=has_short_internal_intron(transcript_order_exons),
|
| 622 |
+
stop_codons=stop_codons,
|
| 623 |
+
)
|
| 624 |
+
return {
|
| 625 |
+
"record_id": record_id,
|
| 626 |
+
"strand": strand,
|
| 627 |
+
"cds": cds_sequence,
|
| 628 |
+
"translation_frame": initial_cds_phase,
|
| 629 |
+
"complete": complete,
|
| 630 |
+
"incomplete_reason": incomplete_reason,
|
| 631 |
+
"exons": [exon for exon, phase in genomic_order],
|
| 632 |
+
"phases": [phase for exon, phase in genomic_order],
|
| 633 |
+
}
|
| 634 |
+
|
| 635 |
+
|
| 636 |
+
def format_gene_status(gene, separator):
|
| 637 |
+
"""Format shared complete/incomplete metadata for GFF3 or FASTA."""
|
| 638 |
+
status = "complete" if gene["complete"] else "incomplete"
|
| 639 |
+
fields = [f"status={status}"]
|
| 640 |
+
if not gene["complete"]:
|
| 641 |
+
reason = gene.get("incomplete_reason") or "unknown"
|
| 642 |
+
fields.append(f"incomplete_reason={reason}")
|
| 643 |
+
return separator.join(fields)
|
| 644 |
+
|
| 645 |
+
|
| 646 |
+
def format_gene_confidence(gene):
|
| 647 |
+
"""Format the per-gene confidence for GFF3 attributes or FASTA headers.
|
| 648 |
+
|
| 649 |
+
Complete genes carry the confidence model's probability that the decoded
|
| 650 |
+
gene is exactly right. Incomplete genes are not scored and are written as
|
| 651 |
+
``NA`` so that a confidence filter keeps them.
|
| 652 |
+
"""
|
| 653 |
+
value = gene.get("confidence")
|
| 654 |
+
formatted_value = "NA" if value is None else f"{value:.6f}"
|
| 655 |
+
return f"confidence={formatted_value}"
|
| 656 |
+
|
| 657 |
+
|
| 658 |
+
def write_gff3(genes, output_path, source="Carbon-A"):
|
| 659 |
+
"""Write gene, mRNA, and CDS features in GFF3 format."""
|
| 660 |
+
with open(output_path, "w", encoding="utf-8", newline="\n") as handle:
|
| 661 |
+
handle.write("##gff-version 3\n")
|
| 662 |
+
for gene_number, gene in enumerate(genes, 1):
|
| 663 |
+
gene_id = f"gene{gene_number:06d}"
|
| 664 |
+
transcript_id = f"mrna{gene_number:06d}"
|
| 665 |
+
gene_start = gene["exons"][0][0] + 1
|
| 666 |
+
gene_end = gene["exons"][-1][1] + 1
|
| 667 |
+
status_attributes = format_gene_status(gene, separator=";")
|
| 668 |
+
status_attributes += ";" + format_gene_confidence(gene)
|
| 669 |
+
|
| 670 |
+
handle.write(
|
| 671 |
+
f"{gene['record_id']}\t{source}\tgene\t{gene_start}\t{gene_end}\t.\t"
|
| 672 |
+
f"{gene['strand']}\t.\tID={gene_id};{status_attributes}\n"
|
| 673 |
+
)
|
| 674 |
+
handle.write(
|
| 675 |
+
f"{gene['record_id']}\t{source}\tmRNA\t{gene_start}\t{gene_end}\t.\t"
|
| 676 |
+
f"{gene['strand']}\t.\tID={transcript_id};Parent={gene_id}\n"
|
| 677 |
+
)
|
| 678 |
+
|
| 679 |
+
for (start, end), phase in zip(gene["exons"], gene["phases"]):
|
| 680 |
+
handle.write(
|
| 681 |
+
f"{gene['record_id']}\t{source}\tCDS\t{start + 1}\t{end + 1}\t.\t"
|
| 682 |
+
f"{gene['strand']}\t{phase}\tID={transcript_id}.cds;Parent={transcript_id}\n"
|
| 683 |
+
)
|
| 684 |
+
|
| 685 |
+
|
| 686 |
+
def write_cds_fasta(genes, output_path):
|
| 687 |
+
"""Write predicted CDS sequences in FASTA format."""
|
| 688 |
+
with open(output_path, "w", encoding="utf-8", newline="\n") as handle:
|
| 689 |
+
for gene_number, gene in enumerate(genes, 1):
|
| 690 |
+
gene_start = gene["exons"][0][0] + 1
|
| 691 |
+
gene_end = gene["exons"][-1][1] + 1
|
| 692 |
+
header = (
|
| 693 |
+
f">gene{gene_number:06d} record_id={gene['record_id']} "
|
| 694 |
+
f"strand={gene['strand']} loc={gene_start}-{gene_end} "
|
| 695 |
+
f"phase={gene['translation_frame']} "
|
| 696 |
+
f"{format_gene_status(gene, separator=' ')}"
|
| 697 |
+
)
|
| 698 |
+
header += " " + format_gene_confidence(gene)
|
| 699 |
+
|
| 700 |
+
handle.write(header + "\n")
|
| 701 |
+
cds_sequence = gene["cds"]
|
| 702 |
+
for line_start in range(0, len(cds_sequence), 80):
|
| 703 |
+
handle.write(cds_sequence[line_start:line_start + 80] + "\n")
|
| 704 |
+
|
| 705 |
+
|
| 706 |
+
def write_protein_fasta(
|
| 707 |
+
genes,
|
| 708 |
+
output_path,
|
| 709 |
+
codon_to_amino_acid,
|
| 710 |
+
):
|
| 711 |
+
"""Write translated protein sequences in FASTA format.
|
| 712 |
+
|
| 713 |
+
Returns the number of non-empty protein records written. Incomplete CDS
|
| 714 |
+
records keep their reason in the header so downstream filters can decide
|
| 715 |
+
whether to use them.
|
| 716 |
+
"""
|
| 717 |
+
amino_acid_by_codon_index = build_translation_lookup(codon_to_amino_acid)
|
| 718 |
+
written_count = 0
|
| 719 |
+
with open(output_path, "w", encoding="utf-8", newline="\n") as handle:
|
| 720 |
+
for gene_number, gene in enumerate(genes, 1):
|
| 721 |
+
cds_sequence = gene["cds"]
|
| 722 |
+
protein_sequence = translate_cds_sequence(
|
| 723 |
+
cds_sequence,
|
| 724 |
+
gene["translation_frame"],
|
| 725 |
+
amino_acid_by_codon_index,
|
| 726 |
+
)
|
| 727 |
+
if not protein_sequence:
|
| 728 |
+
continue
|
| 729 |
+
|
| 730 |
+
gene_start = gene["exons"][0][0] + 1
|
| 731 |
+
gene_end = gene["exons"][-1][1] + 1
|
| 732 |
+
header = (
|
| 733 |
+
f">gene{gene_number:06d} record_id={gene['record_id']} "
|
| 734 |
+
f"strand={gene['strand']} loc={gene_start}-{gene_end} "
|
| 735 |
+
f"cds_len={len(cds_sequence)} aa_len={len(protein_sequence)} "
|
| 736 |
+
f"{format_gene_status(gene, separator=' ')}"
|
| 737 |
+
)
|
| 738 |
+
header += " " + format_gene_confidence(gene)
|
| 739 |
+
|
| 740 |
+
handle.write(header + "\n")
|
| 741 |
+
for line_start in range(0, len(protein_sequence), 80):
|
| 742 |
+
handle.write(protein_sequence[line_start:line_start + 80] + "\n")
|
| 743 |
+
written_count += 1
|
| 744 |
+
|
| 745 |
+
return written_count
|
| 746 |
+
|
| 747 |
+
|
| 748 |
+
def load_nuclear_evaluation_accessions(genome_dir):
|
| 749 |
+
"""Return the record accessions used for nuclear-gene evaluation.
|
| 750 |
+
|
| 751 |
+
Prefer assembled chromosomes. If none exist, use assembled linkage groups;
|
| 752 |
+
if those are also absent, use chromosome scaffolds. ``None`` means that no
|
| 753 |
+
sequence report is available for filtering.
|
| 754 |
+
"""
|
| 755 |
+
sequence_report_path = Path(genome_dir) / "sequence_report.jsonl"
|
| 756 |
+
if not sequence_report_path.exists():
|
| 757 |
+
return None
|
| 758 |
+
|
| 759 |
+
assembled_chromosome_accessions = set()
|
| 760 |
+
assembled_linkage_group_accessions = set()
|
| 761 |
+
chromosome_scaffold_accessions = set()
|
| 762 |
+
with sequence_report_path.open(encoding="utf-8") as handle:
|
| 763 |
+
for line in handle:
|
| 764 |
+
line = line.strip()
|
| 765 |
+
if not line:
|
| 766 |
+
continue
|
| 767 |
+
|
| 768 |
+
row = json.loads(line)
|
| 769 |
+
accession = row.get("refseqAccession") or row.get("genbankAccession")
|
| 770 |
+
if not accession:
|
| 771 |
+
continue
|
| 772 |
+
|
| 773 |
+
assembly_unit = row.get("assemblyUnit")
|
| 774 |
+
location_type = row.get("assignedMoleculeLocationType")
|
| 775 |
+
if assembly_unit == NON_NUCLEAR_ASSEMBLY_UNIT:
|
| 776 |
+
continue
|
| 777 |
+
|
| 778 |
+
role = row.get("role")
|
| 779 |
+
if role == "assembled-molecule":
|
| 780 |
+
if location_type == "Chromosome":
|
| 781 |
+
assembled_chromosome_accessions.add(accession)
|
| 782 |
+
elif location_type == "Linkage Group":
|
| 783 |
+
assembled_linkage_group_accessions.add(accession)
|
| 784 |
+
elif location_type == "Chromosome" and (role or "").endswith("-scaffold"):
|
| 785 |
+
chromosome_scaffold_accessions.add(accession)
|
| 786 |
+
|
| 787 |
+
return (
|
| 788 |
+
assembled_chromosome_accessions
|
| 789 |
+
or assembled_linkage_group_accessions
|
| 790 |
+
or chromosome_scaffold_accessions
|
| 791 |
+
)
|
annotation_pipeline/src/viterbi.py
ADDED
|
@@ -0,0 +1,940 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""Viterbi algorithm: the highest-scoring gene labeling of one strand.
|
| 2 |
+
|
| 3 |
+
The algorithm labels every base of a strand, in transcript orientation, as
|
| 4 |
+
intergenic, exon, or intron. The score of a labeling is the sum over bases of
|
| 5 |
+
log P(CDS) for exon bases and log(1 - P(CDS)) for all other bases, plus the
|
| 6 |
+
log-probability of the transition the transition network assigns at every
|
| 7 |
+
base where a segment begins or a gene closes, plus a fixed log-prior for each
|
| 8 |
+
intron's splice class. The implementation adds each transition's log-odds
|
| 9 |
+
against staying instead of its log-probability; the two differ by the sum of
|
| 10 |
+
log P(stay) over all boundaries, which is the same for every labeling. The gene
|
| 11 |
+
grammar decides which labelings are admissible: a gene opens at an ATG start
|
| 12 |
+
codon and closes at the first in-frame stop codon of the selected genetic
|
| 13 |
+
code, an intron opens at a donor site and closes at an acceptor site of a
|
| 14 |
+
permitted class, and the reading frame continues across each intron. The
|
| 15 |
+
implementation expands the three labels into 30 states so that every
|
| 16 |
+
condition can be checked from local context.
|
| 17 |
+
|
| 18 |
+
This module holds the readable Numba reference implementation, the
|
| 19 |
+
interpretation of a decoded state path as genes and exons, and the choice of
|
| 20 |
+
Viterbi backend: the equivalent native implementations in viterbi_backends/
|
| 21 |
+
(C++/OpenMP and CUDA) return identical paths far faster.
|
| 22 |
+
|
| 23 |
+
State design:
|
| 24 |
+
|
| 25 |
+
To recognize stop codons, the decoder only needs to remember which stop
|
| 26 |
+
prefix, if any, has already been seen inside the current codon. That memory
|
| 27 |
+
is stored as one of six prefix types. Codon phase here means how many CDS
|
| 28 |
+
bases, 0, 1, or 2, have been consumed in the current codon:
|
| 29 |
+
|
| 30 |
+
prefix type 0: codon phase 0; codon boundary, no open prefix
|
| 31 |
+
prefix type 1: codon phase 1; one-base prefix T
|
| 32 |
+
prefix type 2: codon phase 1; one-base prefix other
|
| 33 |
+
prefix type 3: codon phase 2; prefix TA, so A/G completes TAA/TAG
|
| 34 |
+
prefix type 4: codon phase 2; prefix TG, so A completes TGA
|
| 35 |
+
prefix type 5: codon phase 2; other prefix, so no stop can be completed
|
| 36 |
+
|
| 37 |
+
GFF3 phase uses a different convention: it records how many visible bases
|
| 38 |
+
to skip before the next complete codon, rather than bases already consumed.
|
| 39 |
+
|
| 40 |
+
Exon states carry the current prefix type while emitting coding bases.
|
| 41 |
+
Intron states carry the prefix type from the exon before the donor, so the
|
| 42 |
+
acceptor resumes CDS in the same frame. GT-AG and GC-AG share one intron
|
| 43 |
+
state table because both exit at AG, but each uses its own intron prior.
|
| 44 |
+
AT-AC uses a separate state table because it exits at AC.
|
| 45 |
+
|
| 46 |
+
The active state ids are defined in utils.py:
|
| 47 |
+
|
| 48 |
+
0 INTERGENIC non-coding outside genes
|
| 49 |
+
1 START_A first base of start codon ATG
|
| 50 |
+
2 START_AT first two bases of start codon ATG
|
| 51 |
+
3 EXON_PREFIX_TYPE_0 CDS, carries prefix type 0
|
| 52 |
+
4 EXON_PREFIX_TYPE_1 CDS, carries prefix type 1
|
| 53 |
+
5 EXON_PREFIX_TYPE_2 CDS, carries prefix type 2
|
| 54 |
+
6 EXON_PREFIX_TYPE_3 CDS, carries prefix type 3
|
| 55 |
+
7 EXON_PREFIX_TYPE_4 CDS, carries prefix type 4
|
| 56 |
+
8 EXON_PREFIX_TYPE_5 CDS, carries prefix type 5
|
| 57 |
+
9 STOP terminal in-frame stop codon
|
| 58 |
+
10 GT_GC_AG_INTRON_FIRST_PREFIX_TYPE_0 first GT/GC-AG intron base, carries prefix type 0
|
| 59 |
+
11 GT_GC_AG_INTRON_FIRST_PREFIX_TYPE_1 first GT/GC-AG intron base, carries prefix type 1
|
| 60 |
+
12 GT_GC_AG_INTRON_FIRST_PREFIX_TYPE_2 first GT/GC-AG intron base, carries prefix type 2
|
| 61 |
+
13 GT_GC_AG_INTRON_FIRST_PREFIX_TYPE_3 first GT/GC-AG intron base, carries prefix type 3
|
| 62 |
+
14 GT_GC_AG_INTRON_FIRST_PREFIX_TYPE_4 first GT/GC-AG intron base, carries prefix type 4
|
| 63 |
+
15 GT_GC_AG_INTRON_FIRST_PREFIX_TYPE_5 first GT/GC-AG intron base, carries prefix type 5
|
| 64 |
+
16 GT_GC_AG_INTRON_READY_PREFIX_TYPE_0 later GT/GC-AG intron base, carries prefix type 0
|
| 65 |
+
17 GT_GC_AG_INTRON_READY_PREFIX_TYPE_1 later GT/GC-AG intron base, carries prefix type 1
|
| 66 |
+
18 GT_GC_AG_INTRON_READY_PREFIX_TYPE_2 later GT/GC-AG intron base, carries prefix type 2
|
| 67 |
+
19 GT_GC_AG_INTRON_READY_PREFIX_TYPE_3 later GT/GC-AG intron base, carries prefix type 3
|
| 68 |
+
20 GT_GC_AG_INTRON_READY_PREFIX_TYPE_4 later GT/GC-AG intron base, carries prefix type 4
|
| 69 |
+
21 GT_GC_AG_INTRON_READY_PREFIX_TYPE_5 later GT/GC-AG intron base, carries prefix type 5
|
| 70 |
+
22 AT_AC_INTRON_PREFIX_TYPE_0 AT-AC intron, carries prefix type 0
|
| 71 |
+
23 AT_AC_INTRON_PREFIX_TYPE_1 AT-AC intron, carries prefix type 1
|
| 72 |
+
24 AT_AC_INTRON_PREFIX_TYPE_2 AT-AC intron, carries prefix type 2
|
| 73 |
+
25 AT_AC_INTRON_PREFIX_TYPE_3 AT-AC intron, carries prefix type 3
|
| 74 |
+
26 AT_AC_INTRON_PREFIX_TYPE_4 AT-AC intron, carries prefix type 4
|
| 75 |
+
27 AT_AC_INTRON_PREFIX_TYPE_5 AT-AC intron, carries prefix type 5
|
| 76 |
+
28 LEADING_BOUNDARY_INTRON intron extending from the left record boundary
|
| 77 |
+
29 TRAILING_BOUNDARY_INTRON intron extending to the right record boundary
|
| 78 |
+
|
| 79 |
+
Transition rules:
|
| 80 |
+
|
| 81 |
+
INTERGENIC self-loops or starts a start codon ATG. START_A -> START_AT ->
|
| 82 |
+
exon prefix type 0. Exons consume coding bases and update prefix type;
|
| 83 |
+
selected stops enter STOP. STOP exits to INTERGENIC. Exons may enter
|
| 84 |
+
GT-AG/GC-AG introns at G followed by T/C, or AT-AC introns at A followed
|
| 85 |
+
by T. GT/GC-AG introns enter FIRST, then must consume one more intron base
|
| 86 |
+
before becoming READY; only READY can exit after AG. This prevents exon A
|
| 87 |
+
plus intron G in an AGT/AGC boundary from being misread as an acceptor.
|
| 88 |
+
AT-AC can use one state table because AT cannot overlap AC in the same way.
|
| 89 |
+
|
| 90 |
+
Opening an intron adds the explicit log prior for its splice class. The
|
| 91 |
+
supported frequencies are 0.990 for GT-AG, 0.009 for GC-AG, and 0.001 for
|
| 92 |
+
AT-AC, each divided by the 0.99 GT-AG baseline before taking the logarithm.
|
| 93 |
+
The GT-AG prior is therefore exactly zero, but remains an explicit decoder
|
| 94 |
+
parameter in the Numba, CPU, and CUDA implementations.
|
| 95 |
+
|
| 96 |
+
Except for the two boundary-intron guards described below, a sequence base
|
| 97 |
+
N is a free wildcard. When a transition needs its identity, N may satisfy
|
| 98 |
+
any A, T, C, or G requirement without changing the score. Coding N bases
|
| 99 |
+
branch into every compatible prefix state, so subsequent stop-codon
|
| 100 |
+
decisions remain consistent with the selected branch.
|
| 101 |
+
|
| 102 |
+
Splice-signal matching deliberately does not commit N to one concrete base.
|
| 103 |
+
The same N can therefore satisfy overlapping donor and acceptor checks with
|
| 104 |
+
different interpretations, allowing an internal intron shorter than the
|
| 105 |
+
four bases needed to contain both motifs. No such intron occurred in the
|
| 106 |
+
26-species validation set. Such a path must be supported by the transition
|
| 107 |
+
scores at both ends of the intron and still pays the splice-class prior. If
|
| 108 |
+
it wins, the model strongly prefers those bases to be non-coding, while the
|
| 109 |
+
ambiguous sequence provides too little evidence for a reliable repair. The path is retained and its gene is reported as
|
| 110 |
+
incomplete with reason short_intron instead of expanding the decoder state
|
| 111 |
+
space solely for this rare condition.
|
| 112 |
+
|
| 113 |
+
The transition network's four probabilities enter the score as log-odds
|
| 114 |
+
against staying, charged on the base where the new segment begins or, for
|
| 115 |
+
leaving a gene, on the base that closes it:
|
| 116 |
+
|
| 117 |
+
INTERGENIC -> START_A enter gene first start-codon base
|
| 118 |
+
any state -> STOP leave gene last stop-codon base
|
| 119 |
+
exon -> ordinary/trailing intron enter intron first intron base
|
| 120 |
+
ordinary/leading intron -> exon leave intron first exon base after the intron
|
| 121 |
+
|
| 122 |
+
Every other state change adds nothing. The first base is initialized
|
| 123 |
+
the same way: a start codon beginning there is charged enter gene and a
|
| 124 |
+
stop codon completed there is charged leave gene, mirroring the right
|
| 125 |
+
edge, where a stop codon completed at the last base is charged leave gene.
|
| 126 |
+
When an intron exit completes a stop codon that the intron split, the base
|
| 127 |
+
closes the gene and is charged leave gene.
|
| 128 |
+
|
| 129 |
+
The two boundary-intron states represent genes truncated inside an intron.
|
| 130 |
+
LEADING_BOUNDARY_INTRON is the only intron state allowed at the first base.
|
| 131 |
+
Any prefix type may be chosen when LEADING_BOUNDARY_INTRON exits, so the
|
| 132 |
+
state does not need to carry one while it remains intronic. If a concrete
|
| 133 |
+
AG or AC acceptor is immediately followed by a concrete ATG, the boundary
|
| 134 |
+
state may remain intronic but cannot exit there. Any path that treats this
|
| 135 |
+
ATG as CDS must therefore use the normal start-codon states instead of
|
| 136 |
+
borrowing an unknown phase.
|
| 137 |
+
|
| 138 |
+
TRAILING_BOUNDARY_INTRON can be entered from any exon at a legal donor and
|
| 139 |
+
can only self-loop to the record end. Entry is forbidden when the three
|
| 140 |
+
concrete bases immediately before a concrete donor form an enabled stop
|
| 141 |
+
codon, regardless of the path's carried prefix type. This prevents a path
|
| 142 |
+
from choosing another phase merely to hide a terminal stop. These two
|
| 143 |
+
boundary-only guards require every base in the acceptor/codon or
|
| 144 |
+
codon/donor pattern to be present and different from N. If any base is
|
| 145 |
+
missing or N, the hard guard does not apply and model scores choose between
|
| 146 |
+
the otherwise legal paths. N remains a wildcard for the underlying splice
|
| 147 |
+
and ordinary start/stop transitions. An ordinary intron must be entered
|
| 148 |
+
from an exon at a visible donor and must exit to an exon at a visible
|
| 149 |
+
acceptor. Only a boundary-intron state may represent an intron that crosses
|
| 150 |
+
a record edge.
|
| 151 |
+
|
| 152 |
+
max_boundary_intron_length limits how many consecutive bases either
|
| 153 |
+
boundary-intron state may emit. The leading state can only extend that far
|
| 154 |
+
from the left edge; the trailing state can only be entered within that
|
| 155 |
+
distance of the right edge. The production default is 20,000 bp. Zero
|
| 156 |
+
disables both boundary-intron states, so incomplete genes can then only arise
|
| 157 |
+
when a record edge truncates an exon. A value of -1 leaves boundary-intron
|
| 158 |
+
lengths unlimited. Exon-truncated incomplete genes remain legal in every mode.
|
| 159 |
+
|
| 160 |
+
Record boundaries:
|
| 161 |
+
|
| 162 |
+
A record may begin or end inside a gene. Missing motif bases outside the
|
| 163 |
+
record are treated as unknown: they may complete a boundary-spanning
|
| 164 |
+
start, stop, donor, or acceptor. This relaxation applies only where context
|
| 165 |
+
is unavailable; every internal transition still requires its full motif.
|
| 166 |
+
"""
|
| 167 |
+
|
| 168 |
+
import importlib
|
| 169 |
+
import os
|
| 170 |
+
import sys
|
| 171 |
+
import warnings
|
| 172 |
+
|
| 173 |
+
import numpy as np
|
| 174 |
+
from numba import njit
|
| 175 |
+
|
| 176 |
+
sys.path.insert(0, os.path.dirname(os.path.abspath(__file__)))
|
| 177 |
+
from constants import (
|
| 178 |
+
AT_AC_INTRON_LOG_PRIOR,
|
| 179 |
+
BASE_A,
|
| 180 |
+
BASE_C,
|
| 181 |
+
BASE_G,
|
| 182 |
+
BASE_N,
|
| 183 |
+
BASE_T,
|
| 184 |
+
ENTER_GENE,
|
| 185 |
+
ENTER_INTRON,
|
| 186 |
+
FIXED_POINT_SCORE_SCALE,
|
| 187 |
+
GC_AG_INTRON_LOG_PRIOR,
|
| 188 |
+
GT_AG_INTRON_LOG_PRIOR,
|
| 189 |
+
LEAVE_GENE,
|
| 190 |
+
LEAVE_INTRON,
|
| 191 |
+
MAX_BOUNDARY_INTRON_LENGTH_BP,
|
| 192 |
+
MAX_PROB,
|
| 193 |
+
MIN_PROB,
|
| 194 |
+
)
|
| 195 |
+
from utils import (
|
| 196 |
+
PREFIX_TYPE_0, PREFIX_TYPE_1, PREFIX_TYPE_2,
|
| 197 |
+
PREFIX_TYPE_3, PREFIX_TYPE_4, PREFIX_TYPE_5, PREFIX_TYPE_COUNT,
|
| 198 |
+
STATE_COUNT, INTERGENIC, START_A, START_AT, EXON_PREFIX_TYPE_0, STOP,
|
| 199 |
+
LEADING_BOUNDARY_INTRON, TRAILING_BOUNDARY_INTRON,
|
| 200 |
+
EXON_STATE_BY_PREFIX_TYPE,
|
| 201 |
+
GT_GC_AG_INTRON_FIRST_STATE_BY_PREFIX_TYPE,
|
| 202 |
+
GT_GC_AG_INTRON_READY_STATE_BY_PREFIX_TYPE,
|
| 203 |
+
AT_AC_INTRON_STATE_BY_PREFIX_TYPE,
|
| 204 |
+
prepare_decode_tasks, prepare_transition_probabilities,
|
| 205 |
+
validate_max_boundary_intron_length,
|
| 206 |
+
)
|
| 207 |
+
|
| 208 |
+
|
| 209 |
+
@njit(cache=True, inline="always")
|
| 210 |
+
def keep_best_transition(next_scores, predecessors, target_state, candidate_score, source_state):
|
| 211 |
+
"""Apply one Viterbi max update for a candidate transition."""
|
| 212 |
+
# Standard Viterbi max update: keep the best score and remember where it
|
| 213 |
+
# came from so backtrace can reconstruct the path.
|
| 214 |
+
if candidate_score > next_scores[target_state]:
|
| 215 |
+
next_scores[target_state] = candidate_score
|
| 216 |
+
predecessors[target_state] = source_state
|
| 217 |
+
|
| 218 |
+
|
| 219 |
+
@njit(cache=True, inline="always")
|
| 220 |
+
def base_matches(observed_base, expected_base):
|
| 221 |
+
"""Return whether a concrete base or the N wildcard matches a base."""
|
| 222 |
+
return observed_base == expected_base or observed_base == BASE_N
|
| 223 |
+
|
| 224 |
+
|
| 225 |
+
@njit(cache=True, inline="always")
|
| 226 |
+
def is_exact_enabled_stop_codon(
|
| 227 |
+
first_base, second_base, third_base,
|
| 228 |
+
stop_taa, stop_tag, stop_tga,
|
| 229 |
+
):
|
| 230 |
+
"""Return whether three concrete bases are an enabled stop codon."""
|
| 231 |
+
if first_base != BASE_T:
|
| 232 |
+
return False
|
| 233 |
+
if second_base == BASE_A:
|
| 234 |
+
if third_base == BASE_A and stop_taa:
|
| 235 |
+
return True
|
| 236 |
+
if third_base == BASE_G and stop_tag:
|
| 237 |
+
return True
|
| 238 |
+
return second_base == BASE_G and third_base == BASE_A and stop_tga
|
| 239 |
+
|
| 240 |
+
|
| 241 |
+
@njit(cache=True, inline="always")
|
| 242 |
+
def next_coding_state_after_base(prefix_type, base, stop_taa, stop_tag, stop_tga):
|
| 243 |
+
"""Return the exon/STOP state after emitting one coding base."""
|
| 244 |
+
# Prefix type 0 means the previous coding base ended a codon. A new T can
|
| 245 |
+
# still become TAA/TAG/TGA; every other base cannot start a stop codon.
|
| 246 |
+
if prefix_type == PREFIX_TYPE_0:
|
| 247 |
+
if base == BASE_T:
|
| 248 |
+
return EXON_STATE_BY_PREFIX_TYPE[PREFIX_TYPE_1]
|
| 249 |
+
return EXON_STATE_BY_PREFIX_TYPE[PREFIX_TYPE_2]
|
| 250 |
+
|
| 251 |
+
# Prefix type 1 means the current codon prefix is exactly T.
|
| 252 |
+
if prefix_type == PREFIX_TYPE_1:
|
| 253 |
+
if base == BASE_A:
|
| 254 |
+
return EXON_STATE_BY_PREFIX_TYPE[PREFIX_TYPE_3]
|
| 255 |
+
if base == BASE_G:
|
| 256 |
+
return EXON_STATE_BY_PREFIX_TYPE[PREFIX_TYPE_4]
|
| 257 |
+
return EXON_STATE_BY_PREFIX_TYPE[PREFIX_TYPE_5]
|
| 258 |
+
|
| 259 |
+
# Prefix type 2 is a one-base prefix other than T, so the second base cannot
|
| 260 |
+
# create a possible standard stop prefix.
|
| 261 |
+
if prefix_type == PREFIX_TYPE_2:
|
| 262 |
+
return EXON_STATE_BY_PREFIX_TYPE[PREFIX_TYPE_5]
|
| 263 |
+
|
| 264 |
+
# Prefix type 3 is TA. A or G completes TAA/TAG if those stops are enabled.
|
| 265 |
+
if prefix_type == PREFIX_TYPE_3:
|
| 266 |
+
if base == BASE_A and stop_taa:
|
| 267 |
+
return STOP
|
| 268 |
+
if base == BASE_G and stop_tag:
|
| 269 |
+
return STOP
|
| 270 |
+
return EXON_PREFIX_TYPE_0
|
| 271 |
+
|
| 272 |
+
# Prefix type 4 is TG. A completes TGA if that stop is enabled.
|
| 273 |
+
if prefix_type == PREFIX_TYPE_4:
|
| 274 |
+
if base == BASE_A and stop_tga:
|
| 275 |
+
return STOP
|
| 276 |
+
return EXON_PREFIX_TYPE_0
|
| 277 |
+
|
| 278 |
+
return EXON_PREFIX_TYPE_0
|
| 279 |
+
|
| 280 |
+
|
| 281 |
+
@njit(cache=True, inline="always")
|
| 282 |
+
def emit_coding_base(
|
| 283 |
+
current_scores, next_scores, predecessors,
|
| 284 |
+
source_state, prefix_type, coding_score,
|
| 285 |
+
leave_intron_log_odds, leave_gene_log_odds,
|
| 286 |
+
base, stop_taa, stop_tag, stop_tga,
|
| 287 |
+
):
|
| 288 |
+
"""Emit the current base as CDS from an exon or intron-acceptor state.
|
| 289 |
+
|
| 290 |
+
``leave_intron_log_odds`` is the log-odds of returning to an exon;
|
| 291 |
+
it is zero when the source state is already an exon. A base that completes
|
| 292 |
+
an enabled stop codon closes the gene and is charged ``leave_gene_log_odds``
|
| 293 |
+
instead.
|
| 294 |
+
"""
|
| 295 |
+
# Used both by normal exon continuation and by intron acceptor exits. In
|
| 296 |
+
# both cases the current base is emitted as CDS and updates prefix type.
|
| 297 |
+
source_score = current_scores[source_state]
|
| 298 |
+
if source_score == -np.inf:
|
| 299 |
+
return
|
| 300 |
+
|
| 301 |
+
base_score = source_score + coding_score
|
| 302 |
+
if base == BASE_N:
|
| 303 |
+
# Resolve N independently along every legal A/T/C/G branch. The
|
| 304 |
+
# selected prefix state remembers the consequence of that choice.
|
| 305 |
+
for resolved_base in (BASE_A, BASE_T, BASE_C, BASE_G):
|
| 306 |
+
target_state = next_coding_state_after_base(
|
| 307 |
+
prefix_type, resolved_base, stop_taa, stop_tag, stop_tga
|
| 308 |
+
)
|
| 309 |
+
transition_log_odds = leave_gene_log_odds if target_state == STOP else leave_intron_log_odds
|
| 310 |
+
keep_best_transition(
|
| 311 |
+
next_scores, predecessors, target_state,
|
| 312 |
+
base_score + transition_log_odds, source_state,
|
| 313 |
+
)
|
| 314 |
+
else:
|
| 315 |
+
target_state = next_coding_state_after_base(
|
| 316 |
+
prefix_type, base, stop_taa, stop_tag, stop_tga
|
| 317 |
+
)
|
| 318 |
+
transition_log_odds = leave_gene_log_odds if target_state == STOP else leave_intron_log_odds
|
| 319 |
+
keep_best_transition(
|
| 320 |
+
next_scores, predecessors, target_state,
|
| 321 |
+
base_score + transition_log_odds, source_state,
|
| 322 |
+
)
|
| 323 |
+
|
| 324 |
+
|
| 325 |
+
@njit(cache=True)
|
| 326 |
+
def initialize_first_base_scores(
|
| 327 |
+
coding_scores, noncoding_scores, transition_probabilities, bases, record_length,
|
| 328 |
+
stop_taa, stop_tag, stop_tga, boundary_introns_enabled,
|
| 329 |
+
current_scores,
|
| 330 |
+
):
|
| 331 |
+
"""Initialize first-position state scores, allowing truncated genes at record edges.
|
| 332 |
+
|
| 333 |
+
Transitions across the left record edge are charged like interior ones:
|
| 334 |
+
a start codon beginning at the first base enters the gene at boundary 0
|
| 335 |
+
(``enter_gene[0]``), and a stop codon completed at the first base leaves
|
| 336 |
+
it at boundary 1 (``leave_gene[0]``). Every other first-base state stays
|
| 337 |
+
in whatever the record began with and is charged nothing.
|
| 338 |
+
"""
|
| 339 |
+
# The first base is not forced to be intergenic. A record may start in the
|
| 340 |
+
# middle of a gene or intron, so initialize every start state that is
|
| 341 |
+
# compatible with this observed base and an unknown left context.
|
| 342 |
+
# That is why several exon prefix types and the leading boundary-intron
|
| 343 |
+
# state can be reachable at position 0.
|
| 344 |
+
for state in range(STATE_COUNT):
|
| 345 |
+
# -np.inf means this state has no legal path ending here.
|
| 346 |
+
current_scores[state] = -np.inf
|
| 347 |
+
|
| 348 |
+
base = bases[0]
|
| 349 |
+
coding_score = coding_scores[0]
|
| 350 |
+
noncoding_score = noncoding_scores[0]
|
| 351 |
+
|
| 352 |
+
current_scores[INTERGENIC] = noncoding_score
|
| 353 |
+
|
| 354 |
+
# These exon initial states enumerate all prefix types compatible with one
|
| 355 |
+
# observed base and unknown bases to the left of the record boundary. They
|
| 356 |
+
# remain legal when boundary introns are disabled, allowing a record to
|
| 357 |
+
# begin inside an exon.
|
| 358 |
+
current_scores[EXON_STATE_BY_PREFIX_TYPE[PREFIX_TYPE_0]] = coding_score
|
| 359 |
+
current_scores[EXON_STATE_BY_PREFIX_TYPE[PREFIX_TYPE_5]] = coding_score
|
| 360 |
+
if base == BASE_N:
|
| 361 |
+
for prefix_type in range(PREFIX_TYPE_COUNT):
|
| 362 |
+
current_scores[EXON_STATE_BY_PREFIX_TYPE[prefix_type]] = coding_score
|
| 363 |
+
else:
|
| 364 |
+
one_base_prefix_type = PREFIX_TYPE_1 if base == BASE_T else PREFIX_TYPE_2
|
| 365 |
+
current_scores[EXON_STATE_BY_PREFIX_TYPE[one_base_prefix_type]] = coding_score
|
| 366 |
+
if base == BASE_A:
|
| 367 |
+
current_scores[EXON_STATE_BY_PREFIX_TYPE[PREFIX_TYPE_3]] = coding_score
|
| 368 |
+
elif base == BASE_G:
|
| 369 |
+
current_scores[EXON_STATE_BY_PREFIX_TYPE[PREFIX_TYPE_4]] = coding_score
|
| 370 |
+
|
| 371 |
+
# The record may begin with the last observed base of a stop codon whose
|
| 372 |
+
# first two bases lie outside the left boundary.
|
| 373 |
+
can_end_stop = (
|
| 374 |
+
base_matches(base, BASE_A) and (stop_taa or stop_tga)
|
| 375 |
+
) or (base_matches(base, BASE_G) and stop_tag)
|
| 376 |
+
if can_end_stop:
|
| 377 |
+
current_scores[STOP] = coding_score + transition_probabilities[0, LEAVE_GENE]
|
| 378 |
+
|
| 379 |
+
if boundary_introns_enabled:
|
| 380 |
+
# A record that starts inside an intron has no observable preceding
|
| 381 |
+
# exon, donor class, or codon phase. Ordinary intron states remain
|
| 382 |
+
# reserved for visible donors.
|
| 383 |
+
current_scores[LEADING_BOUNDARY_INTRON] = noncoding_score
|
| 384 |
+
|
| 385 |
+
# Missing right-edge bases are unknown, so an ATG truncated by the record
|
| 386 |
+
# boundary remains legal.
|
| 387 |
+
second_start_base_matches = record_length == 1 or base_matches(bases[1], BASE_T)
|
| 388 |
+
third_start_base_matches = record_length <= 2 or base_matches(bases[2], BASE_G)
|
| 389 |
+
has_atg_start = (
|
| 390 |
+
base_matches(base, BASE_A)
|
| 391 |
+
and second_start_base_matches
|
| 392 |
+
and third_start_base_matches
|
| 393 |
+
)
|
| 394 |
+
if has_atg_start:
|
| 395 |
+
current_scores[START_A] = coding_score + transition_probabilities[0, ENTER_GENE]
|
| 396 |
+
|
| 397 |
+
|
| 398 |
+
@njit(cache=True)
|
| 399 |
+
def run_viterbi_forward(
|
| 400 |
+
coding_scores, noncoding_scores, transition_probabilities, bases, record_length,
|
| 401 |
+
stop_taa, stop_tag, stop_tga,
|
| 402 |
+
gt_ag_intron_log_prior, gc_ag_intron_log_prior, at_ac_intron_log_prior,
|
| 403 |
+
max_boundary_intron_length,
|
| 404 |
+
current_scores, backpointers,
|
| 405 |
+
):
|
| 406 |
+
"""Run the source-state Viterbi forward pass and fill backpointers.
|
| 407 |
+
|
| 408 |
+
``transition_probabilities`` is the ``(record_length, 4)`` array of transition
|
| 409 |
+
network scores in nats, in ``TRANSITION_NAMES`` column order.
|
| 410 |
+
"""
|
| 411 |
+
initialize_first_base_scores(
|
| 412 |
+
coding_scores, noncoding_scores, transition_probabilities, bases, record_length,
|
| 413 |
+
stop_taa, stop_tag, stop_tga, max_boundary_intron_length != 0,
|
| 414 |
+
current_scores,
|
| 415 |
+
)
|
| 416 |
+
next_scores = np.empty(STATE_COUNT, np.float64)
|
| 417 |
+
predecessors = np.empty(STATE_COUNT, np.int8)
|
| 418 |
+
|
| 419 |
+
for position in range(1, record_length):
|
| 420 |
+
base = bases[position]
|
| 421 |
+
coding_score = coding_scores[position]
|
| 422 |
+
noncoding_score = noncoding_scores[position]
|
| 423 |
+
enter_gene_log_odds = transition_probabilities[position, ENTER_GENE]
|
| 424 |
+
leave_gene_log_odds = transition_probabilities[position, LEAVE_GENE]
|
| 425 |
+
enter_intron_log_odds = transition_probabilities[position, ENTER_INTRON]
|
| 426 |
+
leave_intron_log_odds = transition_probabilities[position, LEAVE_INTRON]
|
| 427 |
+
|
| 428 |
+
# Donor tests look at the current and next base. At the final base the
|
| 429 |
+
# unavailable next base may complete a donor outside the record.
|
| 430 |
+
next_base_is_missing = position + 1 == record_length
|
| 431 |
+
next_base = -1 if next_base_is_missing else bases[position + 1]
|
| 432 |
+
has_gt_ag_donor = base_matches(base, BASE_G) and (
|
| 433 |
+
next_base_is_missing or base_matches(next_base, BASE_T)
|
| 434 |
+
)
|
| 435 |
+
has_gc_ag_donor = base_matches(base, BASE_G) and (
|
| 436 |
+
next_base_is_missing or base_matches(next_base, BASE_C)
|
| 437 |
+
)
|
| 438 |
+
|
| 439 |
+
# Acceptor tests look behind the current base. At position 1, an A just
|
| 440 |
+
# outside the record may combine with position 0 G/C to complete AG/AC.
|
| 441 |
+
acceptor_first_base_matches = position == 1 or base_matches(bases[position - 2], BASE_A)
|
| 442 |
+
has_ag_acceptor = acceptor_first_base_matches and base_matches(bases[position - 1], BASE_G)
|
| 443 |
+
has_ac_acceptor = acceptor_first_base_matches and base_matches(bases[position - 1], BASE_C)
|
| 444 |
+
|
| 445 |
+
third_start_base_matches = position + 2 >= record_length or base_matches(
|
| 446 |
+
bases[position + 2], BASE_G
|
| 447 |
+
)
|
| 448 |
+
has_atg_start = (
|
| 449 |
+
base_matches(base, BASE_A)
|
| 450 |
+
and (next_base_is_missing or base_matches(next_base, BASE_T))
|
| 451 |
+
and third_start_base_matches
|
| 452 |
+
)
|
| 453 |
+
has_exact_acceptor_followed_by_atg = (
|
| 454 |
+
position >= 2
|
| 455 |
+
and position + 2 < record_length
|
| 456 |
+
and bases[position - 2] == BASE_A
|
| 457 |
+
and (
|
| 458 |
+
bases[position - 1] == BASE_G
|
| 459 |
+
or bases[position - 1] == BASE_C
|
| 460 |
+
)
|
| 461 |
+
and base == BASE_A
|
| 462 |
+
and next_base == BASE_T
|
| 463 |
+
and bases[position + 2] == BASE_G
|
| 464 |
+
)
|
| 465 |
+
has_at_ac_donor = base_matches(base, BASE_A) and (
|
| 466 |
+
next_base_is_missing or base_matches(next_base, BASE_T)
|
| 467 |
+
)
|
| 468 |
+
|
| 469 |
+
# Boundary-intron guards require exact five-base patterns. N and
|
| 470 |
+
# missing record-edge context still participate in ordinary motif
|
| 471 |
+
# matching, but never provide enough evidence for either hard guard.
|
| 472 |
+
has_exact_enabled_stop_followed_by_donor = False
|
| 473 |
+
has_any_donor = has_gt_ag_donor or has_gc_ag_donor or has_at_ac_donor
|
| 474 |
+
has_exact_donor = (
|
| 475 |
+
position + 1 < record_length
|
| 476 |
+
and (
|
| 477 |
+
(base == BASE_G and (next_base == BASE_T or next_base == BASE_C))
|
| 478 |
+
or (base == BASE_A and next_base == BASE_T)
|
| 479 |
+
)
|
| 480 |
+
)
|
| 481 |
+
if has_exact_donor and position >= 3:
|
| 482 |
+
has_exact_enabled_stop_followed_by_donor = is_exact_enabled_stop_codon(
|
| 483 |
+
bases[position - 3], bases[position - 2], bases[position - 1],
|
| 484 |
+
stop_taa, stop_tag, stop_tga,
|
| 485 |
+
)
|
| 486 |
+
|
| 487 |
+
# A leading self-loop here makes its run position + 1 bases long.
|
| 488 |
+
# Entering the trailing state here emits record_length - position bases
|
| 489 |
+
# through the right edge. No boundary counter state is needed.
|
| 490 |
+
boundary_introns_enabled = max_boundary_intron_length != 0
|
| 491 |
+
boundary_intron_length_is_unlimited = max_boundary_intron_length == -1
|
| 492 |
+
can_extend_leading_boundary_intron = (
|
| 493 |
+
boundary_introns_enabled
|
| 494 |
+
and (
|
| 495 |
+
boundary_intron_length_is_unlimited
|
| 496 |
+
or position < max_boundary_intron_length
|
| 497 |
+
)
|
| 498 |
+
)
|
| 499 |
+
can_enter_trailing_boundary_intron = (
|
| 500 |
+
boundary_introns_enabled
|
| 501 |
+
and (
|
| 502 |
+
boundary_intron_length_is_unlimited
|
| 503 |
+
or record_length - position <= max_boundary_intron_length
|
| 504 |
+
)
|
| 505 |
+
)
|
| 506 |
+
|
| 507 |
+
for state in range(STATE_COUNT):
|
| 508 |
+
# Reset the next column to unreachable before source states brush
|
| 509 |
+
# their legal transitions into it.
|
| 510 |
+
next_scores[state] = -np.inf
|
| 511 |
+
predecessors[state] = 0
|
| 512 |
+
|
| 513 |
+
# 1. Intergenic stays intergenic or starts a start codon ATG.
|
| 514 |
+
if current_scores[INTERGENIC] != -np.inf:
|
| 515 |
+
keep_best_transition(
|
| 516 |
+
next_scores, predecessors, INTERGENIC,
|
| 517 |
+
current_scores[INTERGENIC] + noncoding_score,
|
| 518 |
+
INTERGENIC,
|
| 519 |
+
)
|
| 520 |
+
if has_atg_start:
|
| 521 |
+
keep_best_transition(
|
| 522 |
+
next_scores, predecessors, START_A,
|
| 523 |
+
current_scores[INTERGENIC] + coding_score + enter_gene_log_odds,
|
| 524 |
+
INTERGENIC,
|
| 525 |
+
)
|
| 526 |
+
|
| 527 |
+
# 2. Stop exits to intergenic.
|
| 528 |
+
if current_scores[STOP] != -np.inf:
|
| 529 |
+
keep_best_transition(
|
| 530 |
+
next_scores, predecessors, INTERGENIC,
|
| 531 |
+
current_scores[STOP] + noncoding_score,
|
| 532 |
+
STOP,
|
| 533 |
+
)
|
| 534 |
+
|
| 535 |
+
# 3. Start codon ATG consumes T and G, then enters exon prefix type 0.
|
| 536 |
+
if current_scores[START_A] != -np.inf:
|
| 537 |
+
keep_best_transition(
|
| 538 |
+
next_scores, predecessors, START_AT,
|
| 539 |
+
current_scores[START_A] + coding_score, START_A,
|
| 540 |
+
)
|
| 541 |
+
if current_scores[START_AT] != -np.inf:
|
| 542 |
+
keep_best_transition(
|
| 543 |
+
next_scores, predecessors, EXON_PREFIX_TYPE_0,
|
| 544 |
+
current_scores[START_AT] + coding_score,
|
| 545 |
+
START_AT,
|
| 546 |
+
)
|
| 547 |
+
|
| 548 |
+
# 4. A leading boundary intron self-loops or exits at either acceptor
|
| 549 |
+
# class. It carries no prefix type because any prefix type may be chosen
|
| 550 |
+
# when it exits. It cannot exit where an acceptor is immediately followed
|
| 551 |
+
# by an exact, fully visible ATG; that ATG must use the normal
|
| 552 |
+
# start-codon states. Any N in the acceptor or ATG leaves both paths
|
| 553 |
+
# available for the model scores to resolve.
|
| 554 |
+
if current_scores[LEADING_BOUNDARY_INTRON] != -np.inf:
|
| 555 |
+
if can_extend_leading_boundary_intron:
|
| 556 |
+
keep_best_transition(
|
| 557 |
+
next_scores, predecessors,
|
| 558 |
+
LEADING_BOUNDARY_INTRON,
|
| 559 |
+
current_scores[LEADING_BOUNDARY_INTRON] + noncoding_score,
|
| 560 |
+
LEADING_BOUNDARY_INTRON,
|
| 561 |
+
)
|
| 562 |
+
can_exit_leading_intron = (
|
| 563 |
+
(has_ag_acceptor or has_ac_acceptor)
|
| 564 |
+
and not has_exact_acceptor_followed_by_atg
|
| 565 |
+
)
|
| 566 |
+
if can_exit_leading_intron:
|
| 567 |
+
for prefix_type in range(PREFIX_TYPE_COUNT):
|
| 568 |
+
emit_coding_base(
|
| 569 |
+
current_scores, next_scores, predecessors,
|
| 570 |
+
LEADING_BOUNDARY_INTRON, prefix_type,
|
| 571 |
+
coding_score, leave_intron_log_odds, leave_gene_log_odds,
|
| 572 |
+
base, stop_taa, stop_tag, stop_tga,
|
| 573 |
+
)
|
| 574 |
+
|
| 575 |
+
# 5. Exons emit coding bases, open an ordinary intron that must later
|
| 576 |
+
# return to an exon, or commit to an intron that reaches the right edge.
|
| 577 |
+
for prefix_type in range(PREFIX_TYPE_COUNT):
|
| 578 |
+
exon_state = EXON_STATE_BY_PREFIX_TYPE[prefix_type]
|
| 579 |
+
exon_score = current_scores[exon_state]
|
| 580 |
+
if exon_score == -np.inf:
|
| 581 |
+
continue
|
| 582 |
+
|
| 583 |
+
# Continue the exon by emitting this base as coding. This may enter
|
| 584 |
+
# STOP when the prefix type plus current base forms an enabled stop.
|
| 585 |
+
emit_coding_base(
|
| 586 |
+
current_scores, next_scores, predecessors,
|
| 587 |
+
exon_state, prefix_type,
|
| 588 |
+
coding_score, 0.0, leave_gene_log_odds,
|
| 589 |
+
base, stop_taa, stop_tag, stop_tga,
|
| 590 |
+
)
|
| 591 |
+
|
| 592 |
+
# GT-AG and GC-AG share states because both wait for AG after entry.
|
| 593 |
+
# Each class uses its explicit log prior; the production GT-AG
|
| 594 |
+
# prior happens to be zero. Entry targets FIRST so the next base
|
| 595 |
+
# cannot immediately be treated as an exon base after a fake AG
|
| 596 |
+
# acceptor spanning the exon/intron boundary.
|
| 597 |
+
if has_gt_ag_donor:
|
| 598 |
+
keep_best_transition(
|
| 599 |
+
next_scores, predecessors,
|
| 600 |
+
GT_GC_AG_INTRON_FIRST_STATE_BY_PREFIX_TYPE[prefix_type],
|
| 601 |
+
exon_score + noncoding_score + gt_ag_intron_log_prior + enter_intron_log_odds,
|
| 602 |
+
exon_state,
|
| 603 |
+
)
|
| 604 |
+
if has_gc_ag_donor:
|
| 605 |
+
keep_best_transition(
|
| 606 |
+
next_scores, predecessors,
|
| 607 |
+
GT_GC_AG_INTRON_FIRST_STATE_BY_PREFIX_TYPE[prefix_type],
|
| 608 |
+
exon_score + noncoding_score + gc_ag_intron_log_prior + enter_intron_log_odds,
|
| 609 |
+
exon_state,
|
| 610 |
+
)
|
| 611 |
+
|
| 612 |
+
# U12 AT-AC introns are represented separately because they exit at
|
| 613 |
+
# AC instead of AG.
|
| 614 |
+
if has_at_ac_donor:
|
| 615 |
+
keep_best_transition(
|
| 616 |
+
next_scores, predecessors,
|
| 617 |
+
AT_AC_INTRON_STATE_BY_PREFIX_TYPE[prefix_type],
|
| 618 |
+
exon_score + noncoding_score + at_ac_intron_log_prior + enter_intron_log_odds,
|
| 619 |
+
exon_state,
|
| 620 |
+
)
|
| 621 |
+
|
| 622 |
+
if (
|
| 623 |
+
has_any_donor
|
| 624 |
+
and not has_exact_enabled_stop_followed_by_donor
|
| 625 |
+
and can_enter_trailing_boundary_intron
|
| 626 |
+
):
|
| 627 |
+
if has_gt_ag_donor:
|
| 628 |
+
keep_best_transition(
|
| 629 |
+
next_scores, predecessors,
|
| 630 |
+
TRAILING_BOUNDARY_INTRON,
|
| 631 |
+
exon_score + noncoding_score + gt_ag_intron_log_prior + enter_intron_log_odds,
|
| 632 |
+
exon_state,
|
| 633 |
+
)
|
| 634 |
+
if has_gc_ag_donor:
|
| 635 |
+
keep_best_transition(
|
| 636 |
+
next_scores, predecessors,
|
| 637 |
+
TRAILING_BOUNDARY_INTRON,
|
| 638 |
+
exon_score + noncoding_score + gc_ag_intron_log_prior + enter_intron_log_odds,
|
| 639 |
+
exon_state,
|
| 640 |
+
)
|
| 641 |
+
if has_at_ac_donor:
|
| 642 |
+
keep_best_transition(
|
| 643 |
+
next_scores, predecessors,
|
| 644 |
+
TRAILING_BOUNDARY_INTRON,
|
| 645 |
+
exon_score + noncoding_score + at_ac_intron_log_prior + enter_intron_log_odds,
|
| 646 |
+
exon_state,
|
| 647 |
+
)
|
| 648 |
+
|
| 649 |
+
# 6. GT-AG/GC-AG FIRST states must consume one more intron base before
|
| 650 |
+
# acceptor exit is allowed.
|
| 651 |
+
for prefix_type in range(PREFIX_TYPE_COUNT):
|
| 652 |
+
intron_state = GT_GC_AG_INTRON_FIRST_STATE_BY_PREFIX_TYPE[prefix_type]
|
| 653 |
+
if current_scores[intron_state] == -np.inf:
|
| 654 |
+
continue
|
| 655 |
+
keep_best_transition(
|
| 656 |
+
next_scores, predecessors,
|
| 657 |
+
GT_GC_AG_INTRON_READY_STATE_BY_PREFIX_TYPE[prefix_type],
|
| 658 |
+
current_scores[intron_state] + noncoding_score,
|
| 659 |
+
intron_state,
|
| 660 |
+
)
|
| 661 |
+
|
| 662 |
+
# 7. GT-AG/GC-AG READY states self-loop or exit at AG.
|
| 663 |
+
for prefix_type in range(PREFIX_TYPE_COUNT):
|
| 664 |
+
intron_state = GT_GC_AG_INTRON_READY_STATE_BY_PREFIX_TYPE[prefix_type]
|
| 665 |
+
if current_scores[intron_state] == -np.inf:
|
| 666 |
+
continue
|
| 667 |
+
# Staying in an intron emits the current base as non-coding.
|
| 668 |
+
keep_best_transition(
|
| 669 |
+
next_scores, predecessors, intron_state,
|
| 670 |
+
current_scores[intron_state] + noncoding_score,
|
| 671 |
+
intron_state,
|
| 672 |
+
)
|
| 673 |
+
if has_ag_acceptor:
|
| 674 |
+
# Exiting at AG emits the current base as CDS and resumes with
|
| 675 |
+
# the carried prefix type.
|
| 676 |
+
emit_coding_base(
|
| 677 |
+
current_scores, next_scores, predecessors,
|
| 678 |
+
intron_state, prefix_type,
|
| 679 |
+
coding_score, leave_intron_log_odds, leave_gene_log_odds,
|
| 680 |
+
base, stop_taa, stop_tag, stop_tga,
|
| 681 |
+
)
|
| 682 |
+
|
| 683 |
+
# 8. AT-AC introns self-loop or exit at AC. AT cannot overlap AC the
|
| 684 |
+
# way GT/GC can overlap AG, so one state table is enough.
|
| 685 |
+
for prefix_type in range(PREFIX_TYPE_COUNT):
|
| 686 |
+
intron_state = AT_AC_INTRON_STATE_BY_PREFIX_TYPE[prefix_type]
|
| 687 |
+
if current_scores[intron_state] == -np.inf:
|
| 688 |
+
continue
|
| 689 |
+
# Same as GT/GC-AG introns, but the acceptor motif is AC.
|
| 690 |
+
keep_best_transition(
|
| 691 |
+
next_scores, predecessors, intron_state,
|
| 692 |
+
current_scores[intron_state] + noncoding_score,
|
| 693 |
+
intron_state,
|
| 694 |
+
)
|
| 695 |
+
if has_ac_acceptor:
|
| 696 |
+
emit_coding_base(
|
| 697 |
+
current_scores, next_scores, predecessors,
|
| 698 |
+
intron_state, prefix_type,
|
| 699 |
+
coding_score, leave_intron_log_odds, leave_gene_log_odds,
|
| 700 |
+
base, stop_taa, stop_tag, stop_tga,
|
| 701 |
+
)
|
| 702 |
+
|
| 703 |
+
# 9. A trailing boundary intron never exits back to an exon.
|
| 704 |
+
if current_scores[TRAILING_BOUNDARY_INTRON] != -np.inf:
|
| 705 |
+
keep_best_transition(
|
| 706 |
+
next_scores, predecessors,
|
| 707 |
+
TRAILING_BOUNDARY_INTRON,
|
| 708 |
+
current_scores[TRAILING_BOUNDARY_INTRON] + noncoding_score,
|
| 709 |
+
TRAILING_BOUNDARY_INTRON,
|
| 710 |
+
)
|
| 711 |
+
|
| 712 |
+
for state in range(STATE_COUNT):
|
| 713 |
+
current_scores[state] = next_scores[state]
|
| 714 |
+
backpointers[position - 1, state] = predecessors[state]
|
| 715 |
+
|
| 716 |
+
|
| 717 |
+
@njit(cache=True)
|
| 718 |
+
def best_final_state(current_scores):
|
| 719 |
+
"""Return the best state that may legally end a record.
|
| 720 |
+
|
| 721 |
+
Ordinary introns must return to an exon. Only TRAILING_BOUNDARY_INTRON may
|
| 722 |
+
represent an intron that continues beyond the right record boundary. Exon
|
| 723 |
+
states may end at the boundary to represent an exon-truncated gene. Thus,
|
| 724 |
+
when boundary introns are disabled, exon truncation is the only way a
|
| 725 |
+
decoded gene can remain incomplete.
|
| 726 |
+
"""
|
| 727 |
+
best_state = INTERGENIC
|
| 728 |
+
for state in range(START_A, STOP + 1):
|
| 729 |
+
if current_scores[state] > current_scores[best_state]:
|
| 730 |
+
best_state = state
|
| 731 |
+
if current_scores[TRAILING_BOUNDARY_INTRON] > current_scores[best_state]:
|
| 732 |
+
best_state = TRAILING_BOUNDARY_INTRON
|
| 733 |
+
return best_state
|
| 734 |
+
|
| 735 |
+
|
| 736 |
+
@njit(cache=True, nogil=True)
|
| 737 |
+
def decode_one_track(
|
| 738 |
+
coding_scores, noncoding_scores, transition_probabilities, bases, record_length,
|
| 739 |
+
stop_taa, stop_tag, stop_tga,
|
| 740 |
+
gt_ag_intron_log_prior, gc_ag_intron_log_prior, at_ac_intron_log_prior,
|
| 741 |
+
max_boundary_intron_length,
|
| 742 |
+
path,
|
| 743 |
+
):
|
| 744 |
+
"""Decode one oriented probability track into a per-base state path."""
|
| 745 |
+
current_scores = np.empty(STATE_COUNT, np.float64)
|
| 746 |
+
backpointers = np.empty((record_length, STATE_COUNT), np.int8)
|
| 747 |
+
run_viterbi_forward(
|
| 748 |
+
coding_scores, noncoding_scores, transition_probabilities, bases, record_length,
|
| 749 |
+
stop_taa, stop_tag, stop_tga,
|
| 750 |
+
gt_ag_intron_log_prior, gc_ag_intron_log_prior, at_ac_intron_log_prior,
|
| 751 |
+
max_boundary_intron_length,
|
| 752 |
+
current_scores, backpointers,
|
| 753 |
+
)
|
| 754 |
+
|
| 755 |
+
state = best_final_state(current_scores)
|
| 756 |
+
for position in range(record_length - 1, 0, -1):
|
| 757 |
+
path[position] = state
|
| 758 |
+
state = backpointers[position - 1, state]
|
| 759 |
+
path[0] = state
|
| 760 |
+
|
| 761 |
+
|
| 762 |
+
def decode_paths_with_numba(
|
| 763 |
+
tasks,
|
| 764 |
+
transition_probabilities,
|
| 765 |
+
stop_taa=True, stop_tag=True, stop_tga=True,
|
| 766 |
+
gt_ag_intron_log_prior=GT_AG_INTRON_LOG_PRIOR,
|
| 767 |
+
gc_ag_intron_log_prior=GC_AG_INTRON_LOG_PRIOR,
|
| 768 |
+
at_ac_intron_log_prior=AT_AC_INTRON_LOG_PRIOR,
|
| 769 |
+
max_boundary_intron_length=MAX_BOUNDARY_INTRON_LENGTH_BP,
|
| 770 |
+
):
|
| 771 |
+
"""Decode ``[(probabilities, encoded_bases), ...]`` with the reference Numba decoder.
|
| 772 |
+
|
| 773 |
+
``transition_probabilities`` holds one ``(bases, 4)`` array per task with
|
| 774 |
+
each transition's log-odds against staying, in nats. They are quantized to
|
| 775 |
+
the backends' int32 fixed point (``prepare_transition_probabilities``) and
|
| 776 |
+
divided by ``FIXED_POINT_SCORE_SCALE`` again, so the reference sees exactly
|
| 777 |
+
the values the native backends accumulate.
|
| 778 |
+
"""
|
| 779 |
+
max_boundary_intron_length = validate_max_boundary_intron_length(max_boundary_intron_length)
|
| 780 |
+
tasks = prepare_decode_tasks(tasks)
|
| 781 |
+
transition_probabilities = prepare_transition_probabilities(transition_probabilities, tasks)
|
| 782 |
+
paths = []
|
| 783 |
+
for (probabilities, encoded_bases), fixed_point_scores in zip(tasks, transition_probabilities):
|
| 784 |
+
coding_probabilities = probabilities.astype(np.float64)
|
| 785 |
+
np.clip(coding_probabilities, MIN_PROB, MAX_PROB, out=coding_probabilities)
|
| 786 |
+
record_length = coding_probabilities.shape[0]
|
| 787 |
+
|
| 788 |
+
path = np.empty(record_length, np.int8)
|
| 789 |
+
if record_length:
|
| 790 |
+
decode_one_track(
|
| 791 |
+
np.log(coding_probabilities), np.log(1.0 - coding_probabilities),
|
| 792 |
+
fixed_point_scores.astype(np.float64) / FIXED_POINT_SCORE_SCALE,
|
| 793 |
+
np.ascontiguousarray(encoded_bases, np.int8), record_length,
|
| 794 |
+
bool(stop_taa), bool(stop_tag), bool(stop_tga),
|
| 795 |
+
float(gt_ag_intron_log_prior), float(gc_ag_intron_log_prior),
|
| 796 |
+
float(at_ac_intron_log_prior),
|
| 797 |
+
int(max_boundary_intron_length),
|
| 798 |
+
path,
|
| 799 |
+
)
|
| 800 |
+
paths.append(path)
|
| 801 |
+
return paths
|
| 802 |
+
|
| 803 |
+
|
| 804 |
+
@njit(cache=True, inline="always")
|
| 805 |
+
def state_emits_cds(state):
|
| 806 |
+
"""Return True for states that emit CDS bases in the decoded path."""
|
| 807 |
+
return START_A <= state <= STOP
|
| 808 |
+
|
| 809 |
+
|
| 810 |
+
@njit(cache=True)
|
| 811 |
+
def find_gene_exon_intervals(state_path):
|
| 812 |
+
"""Return exon starts, exon ends, gene ids, and gene count for one path.
|
| 813 |
+
|
| 814 |
+
This scans the path directly instead of building full-length boolean,
|
| 815 |
+
diff, and cumsum arrays. Intergenic bases between two exons start a new
|
| 816 |
+
gene; intron bases keep neighboring exons in the same gene.
|
| 817 |
+
"""
|
| 818 |
+
# First pass: count how many exon intervals and genes will be emitted, so
|
| 819 |
+
# the second pass can fill fixed-size arrays without Python list growth.
|
| 820 |
+
exon_count = 0
|
| 821 |
+
gene_count = 0
|
| 822 |
+
inside_exon = False
|
| 823 |
+
seen_exon = False
|
| 824 |
+
intergenic_after_previous_exon = False
|
| 825 |
+
|
| 826 |
+
for position in range(state_path.shape[0]):
|
| 827 |
+
state = state_path[position]
|
| 828 |
+
if state_emits_cds(state):
|
| 829 |
+
if not inside_exon:
|
| 830 |
+
exon_count += 1
|
| 831 |
+
if not seen_exon:
|
| 832 |
+
gene_count = 1
|
| 833 |
+
seen_exon = True
|
| 834 |
+
elif intergenic_after_previous_exon:
|
| 835 |
+
gene_count += 1
|
| 836 |
+
intergenic_after_previous_exon = False
|
| 837 |
+
inside_exon = True
|
| 838 |
+
else:
|
| 839 |
+
inside_exon = False
|
| 840 |
+
if seen_exon and state == INTERGENIC:
|
| 841 |
+
intergenic_after_previous_exon = True
|
| 842 |
+
|
| 843 |
+
exon_starts = np.empty(exon_count, np.int64)
|
| 844 |
+
exon_ends = np.empty(exon_count, np.int64)
|
| 845 |
+
gene_ids = np.empty(exon_count, np.int64)
|
| 846 |
+
|
| 847 |
+
# Second pass: record exon intervals and assign each exon to a gene.
|
| 848 |
+
exon_index = 0
|
| 849 |
+
current_gene = -1
|
| 850 |
+
inside_exon = False
|
| 851 |
+
seen_exon = False
|
| 852 |
+
intergenic_after_previous_exon = False
|
| 853 |
+
|
| 854 |
+
for position in range(state_path.shape[0]):
|
| 855 |
+
state = state_path[position]
|
| 856 |
+
if state_emits_cds(state):
|
| 857 |
+
if not inside_exon:
|
| 858 |
+
if not seen_exon:
|
| 859 |
+
current_gene = 0
|
| 860 |
+
seen_exon = True
|
| 861 |
+
elif intergenic_after_previous_exon:
|
| 862 |
+
current_gene += 1
|
| 863 |
+
exon_starts[exon_index] = position
|
| 864 |
+
gene_ids[exon_index] = current_gene
|
| 865 |
+
intergenic_after_previous_exon = False
|
| 866 |
+
inside_exon = True
|
| 867 |
+
else:
|
| 868 |
+
if inside_exon:
|
| 869 |
+
exon_ends[exon_index] = position - 1
|
| 870 |
+
exon_index += 1
|
| 871 |
+
inside_exon = False
|
| 872 |
+
if seen_exon and state == INTERGENIC:
|
| 873 |
+
intergenic_after_previous_exon = True
|
| 874 |
+
|
| 875 |
+
if inside_exon:
|
| 876 |
+
exon_ends[exon_index] = state_path.shape[0] - 1
|
| 877 |
+
|
| 878 |
+
return exon_starts, exon_ends, gene_ids, gene_count
|
| 879 |
+
|
| 880 |
+
|
| 881 |
+
def iter_gene_exons_with_states(state_path):
|
| 882 |
+
"""Yield each gene's oriented exons and first/last CDS states."""
|
| 883 |
+
exon_starts, exon_ends, gene_ids, gene_count = find_gene_exon_intervals(state_path)
|
| 884 |
+
exon_index = 0
|
| 885 |
+
for gene_id in range(gene_count):
|
| 886 |
+
gene_exons = []
|
| 887 |
+
while exon_index < len(gene_ids) and gene_ids[exon_index] == gene_id:
|
| 888 |
+
gene_exons.append((int(exon_starts[exon_index]), int(exon_ends[exon_index])))
|
| 889 |
+
exon_index += 1
|
| 890 |
+
first_cds_state = int(state_path[gene_exons[0][0]])
|
| 891 |
+
last_cds_state = int(state_path[gene_exons[-1][1]])
|
| 892 |
+
yield gene_exons, first_cds_state, last_cds_state
|
| 893 |
+
|
| 894 |
+
|
| 895 |
+
def choose_viterbi_backend(requested_backend):
|
| 896 |
+
"""Return the name of the Viterbi backend to use: ``gpu``, ``cpu``, or ``numba``.
|
| 897 |
+
|
| 898 |
+
``auto`` prefers the GPU backend, then the CPU backend, then the Numba
|
| 899 |
+
reference; ``gpu``, ``cpu``, and ``numba`` select one directly (``cpu``
|
| 900 |
+
falls back to the reference when the extension cannot be built).
|
| 901 |
+
"""
|
| 902 |
+
order = {
|
| 903 |
+
"auto": ("gpu", "cpu", "numba"),
|
| 904 |
+
"gpu": ("gpu",),
|
| 905 |
+
"cpu": ("cpu", "numba"),
|
| 906 |
+
"numba": ("numba",),
|
| 907 |
+
}[requested_backend]
|
| 908 |
+
|
| 909 |
+
reasons = []
|
| 910 |
+
for backend in order:
|
| 911 |
+
if backend == "numba":
|
| 912 |
+
if requested_backend in ("auto", "cpu") and reasons:
|
| 913 |
+
warnings.warn(
|
| 914 |
+
"CPU/GPU Viterbi backend unavailable "
|
| 915 |
+
f"({'; '.join(reasons)}); using the Numba reference."
|
| 916 |
+
)
|
| 917 |
+
return "numba"
|
| 918 |
+
|
| 919 |
+
try:
|
| 920 |
+
module = importlib.import_module(f"viterbi_backends.decode_{backend}")
|
| 921 |
+
ok, reason = module.available()
|
| 922 |
+
if ok:
|
| 923 |
+
return backend
|
| 924 |
+
reasons.append(f"{backend}: {reason}")
|
| 925 |
+
except Exception as exc:
|
| 926 |
+
reasons.append(f"{backend}: {type(exc).__name__}: {exc}")
|
| 927 |
+
|
| 928 |
+
raise RuntimeError("no Viterbi backend available: " + "; ".join(reasons))
|
| 929 |
+
|
| 930 |
+
|
| 931 |
+
def decode_paths(tasks, transition_probabilities, viterbi_backend, **options):
|
| 932 |
+
"""Run the Viterbi algorithm on ``viterbi_backend`` (``gpu``, ``cpu``, or ``numba``).
|
| 933 |
+
|
| 934 |
+
Every backend takes the arguments of ``decode_paths_with_numba`` and
|
| 935 |
+
returns identical state paths.
|
| 936 |
+
"""
|
| 937 |
+
if viterbi_backend == "numba":
|
| 938 |
+
return decode_paths_with_numba(tasks, transition_probabilities, **options)
|
| 939 |
+
module = importlib.import_module(f"viterbi_backends.decode_{viterbi_backend}")
|
| 940 |
+
return module.decode_paths(tasks, transition_probabilities, **options)
|
annotation_pipeline/src/viterbi_backends/__init__.py
ADDED
|
File without changes
|
annotation_pipeline/src/viterbi_backends/decode_cpu.py
ADDED
|
@@ -0,0 +1,112 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""CPU Viterbi backend: the C++/OpenMP implementation of the algorithm in viterbi.py.
|
| 2 |
+
|
| 3 |
+
The C++ extension is JIT-compiled by torch.utils.cpp_extension.load on first
|
| 4 |
+
use and cached under ~/.cache/torch_extensions. Tasks are
|
| 5 |
+
``(P(CDS), encoded_bases)`` pairs; every task is accompanied by a
|
| 6 |
+
``(bases, 4)`` array holding the transition network's log-odds against staying
|
| 7 |
+
for the four permitted transitions, in nats, quantized here to the kernel's
|
| 8 |
+
int32 fixed point (``prepare_transition_probabilities``). Stop
|
| 9 |
+
codons, all three intron-class priors, and the boundary-intron length limit
|
| 10 |
+
are explicit across every Viterbi backend.
|
| 11 |
+
"""
|
| 12 |
+
import os
|
| 13 |
+
import sys
|
| 14 |
+
|
| 15 |
+
import numpy as np
|
| 16 |
+
|
| 17 |
+
sys.path.insert(0, os.path.dirname(os.path.dirname(os.path.abspath(__file__))))
|
| 18 |
+
from constants import (
|
| 19 |
+
AT_AC_INTRON_LOG_PRIOR,
|
| 20 |
+
GC_AG_INTRON_LOG_PRIOR,
|
| 21 |
+
GT_AG_INTRON_LOG_PRIOR,
|
| 22 |
+
MAX_BOUNDARY_INTRON_LENGTH_BP,
|
| 23 |
+
)
|
| 24 |
+
from utils import (
|
| 25 |
+
prepare_decode_tasks,
|
| 26 |
+
prepare_transition_probabilities,
|
| 27 |
+
validate_max_boundary_intron_length,
|
| 28 |
+
validate_reconstruction_batch_size,
|
| 29 |
+
)
|
| 30 |
+
|
| 31 |
+
_HERE = os.path.dirname(os.path.abspath(__file__))
|
| 32 |
+
_SRC = os.path.join(_HERE, "viterbi_cpu.cpp")
|
| 33 |
+
_MOD = None
|
| 34 |
+
|
| 35 |
+
|
| 36 |
+
def _load():
|
| 37 |
+
"""Build or reuse the cached C++ extension."""
|
| 38 |
+
global _MOD
|
| 39 |
+
if _MOD is not None:
|
| 40 |
+
return _MOD
|
| 41 |
+
from torch.utils.cpp_extension import load
|
| 42 |
+
|
| 43 |
+
# Do not use -march=native: the extension must run on CPUs older than
|
| 44 |
+
# the machine that first builds it, including CPUs without AVX-512.
|
| 45 |
+
_MOD = load(
|
| 46 |
+
name="carbon_a_viterbi_cpu", sources=[_SRC],
|
| 47 |
+
extra_cflags=["-O3", "-fopenmp"],
|
| 48 |
+
extra_ldflags=["-fopenmp"],
|
| 49 |
+
verbose=False,
|
| 50 |
+
)
|
| 51 |
+
return _MOD
|
| 52 |
+
|
| 53 |
+
|
| 54 |
+
def available():
|
| 55 |
+
"""Return whether the CPU extension can be loaded."""
|
| 56 |
+
try:
|
| 57 |
+
_load()
|
| 58 |
+
return True, "ok"
|
| 59 |
+
except Exception as exc:
|
| 60 |
+
return False, f"{type(exc).__name__}: {exc}"
|
| 61 |
+
|
| 62 |
+
|
| 63 |
+
def decode_paths(
|
| 64 |
+
tasks,
|
| 65 |
+
transition_probabilities,
|
| 66 |
+
stop_taa=True, stop_tag=True, stop_tga=True,
|
| 67 |
+
gt_ag_intron_log_prior=GT_AG_INTRON_LOG_PRIOR,
|
| 68 |
+
gc_ag_intron_log_prior=GC_AG_INTRON_LOG_PRIOR,
|
| 69 |
+
at_ac_intron_log_prior=AT_AC_INTRON_LOG_PRIOR,
|
| 70 |
+
max_boundary_intron_length=MAX_BOUNDARY_INTRON_LENGTH_BP,
|
| 71 |
+
reconstruction_batch_size=None,
|
| 72 |
+
):
|
| 73 |
+
"""Decode probability/base tasks with the CPU Viterbi backend.
|
| 74 |
+
|
| 75 |
+
``transition_probabilities`` holds one ``(bases, 4)`` array per task of
|
| 76 |
+
nat-valued log-odds in ``TRANSITION_NAMES`` column order.
|
| 77 |
+
``reconstruction_batch_size`` is the number of bases processed together
|
| 78 |
+
during final path reconstruction. ``None`` uses the backend default.
|
| 79 |
+
"""
|
| 80 |
+
max_boundary_intron_length = validate_max_boundary_intron_length(max_boundary_intron_length)
|
| 81 |
+
reconstruction_batch_size = validate_reconstruction_batch_size(reconstruction_batch_size)
|
| 82 |
+
tasks = prepare_decode_tasks(tasks)
|
| 83 |
+
transition_probabilities = prepare_transition_probabilities(transition_probabilities, tasks)
|
| 84 |
+
if not tasks:
|
| 85 |
+
return []
|
| 86 |
+
module = _load()
|
| 87 |
+
record_offsets = np.zeros(len(tasks) + 1, np.int64)
|
| 88 |
+
for index, (_, encoded_bases) in enumerate(tasks):
|
| 89 |
+
record_offsets[index + 1] = record_offsets[index] + len(encoded_bases)
|
| 90 |
+
|
| 91 |
+
probability_bits = np.ascontiguousarray(
|
| 92 |
+
np.concatenate([np.asarray(probabilities, np.float16) for probabilities, _ in tasks])
|
| 93 |
+
).view(np.uint16)
|
| 94 |
+
encoded_bases = np.ascontiguousarray(
|
| 95 |
+
np.concatenate([np.asarray(bases, np.int8) for _, bases in tasks]),
|
| 96 |
+
dtype=np.int8,
|
| 97 |
+
)
|
| 98 |
+
packed_transition_probabilities = np.ascontiguousarray(np.concatenate(transition_probabilities), dtype=np.int32)
|
| 99 |
+
combined_path = np.asarray(
|
| 100 |
+
module.decode(
|
| 101 |
+
probability_bits, encoded_bases, packed_transition_probabilities, record_offsets,
|
| 102 |
+
int(stop_taa), int(stop_tag), int(stop_tga),
|
| 103 |
+
float(gt_ag_intron_log_prior), float(gc_ag_intron_log_prior),
|
| 104 |
+
float(at_ac_intron_log_prior),
|
| 105 |
+
int(max_boundary_intron_length),
|
| 106 |
+
0 if reconstruction_batch_size is None else reconstruction_batch_size,
|
| 107 |
+
)
|
| 108 |
+
)
|
| 109 |
+
return [
|
| 110 |
+
combined_path[int(record_offsets[index]):int(record_offsets[index + 1])]
|
| 111 |
+
for index in range(len(tasks))
|
| 112 |
+
]
|
annotation_pipeline/src/viterbi_backends/decode_gpu.py
ADDED
|
@@ -0,0 +1,167 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""GPU Viterbi backend: the CUDA implementation of the algorithm in viterbi.py.
|
| 2 |
+
|
| 3 |
+
The CUDA extension is JIT-compiled by torch.utils.cpp_extension.load on first
|
| 4 |
+
use. Tasks are ``(P(CDS), encoded_bases)`` pairs; every task is accompanied by
|
| 5 |
+
a ``(bases, 4)`` array holding the transition network's log-odds against
|
| 6 |
+
staying for the four permitted transitions, in nats, quantized here to the
|
| 7 |
+
kernel's int32 fixed point (``prepare_transition_probabilities``). Stop
|
| 8 |
+
codons, all three intron-class priors, and the boundary-intron length limit
|
| 9 |
+
are explicit across every Viterbi backend.
|
| 10 |
+
|
| 11 |
+
decode_paths packs tasks into sub-batches sized by available VRAM. The kernel
|
| 12 |
+
windows the backpointer array internally, so a single long chromosome can be
|
| 13 |
+
decoded without materializing a genome-sized backpointer buffer.
|
| 14 |
+
"""
|
| 15 |
+
import os
|
| 16 |
+
import sys
|
| 17 |
+
|
| 18 |
+
import numpy as np
|
| 19 |
+
|
| 20 |
+
sys.path.insert(0, os.path.dirname(os.path.dirname(os.path.abspath(__file__))))
|
| 21 |
+
from constants import (
|
| 22 |
+
AT_AC_INTRON_LOG_PRIOR,
|
| 23 |
+
GC_AG_INTRON_LOG_PRIOR,
|
| 24 |
+
GPU_ESTIMATED_BYTES_PER_BASE,
|
| 25 |
+
GPU_MAX_BATCH_BASES,
|
| 26 |
+
GPU_MEMORY_FRACTION,
|
| 27 |
+
GPU_MIN_BATCH_BASES,
|
| 28 |
+
GPU_SEGMENT_LENGTH,
|
| 29 |
+
GT_AG_INTRON_LOG_PRIOR,
|
| 30 |
+
MAX_BOUNDARY_INTRON_LENGTH_BP,
|
| 31 |
+
)
|
| 32 |
+
from utils import (
|
| 33 |
+
prepare_decode_tasks,
|
| 34 |
+
prepare_transition_probabilities,
|
| 35 |
+
validate_max_boundary_intron_length,
|
| 36 |
+
validate_reconstruction_batch_size,
|
| 37 |
+
)
|
| 38 |
+
|
| 39 |
+
_HERE = os.path.dirname(os.path.abspath(__file__))
|
| 40 |
+
_SRC = os.path.join(_HERE, "viterbi_gpu.cu")
|
| 41 |
+
_MOD = None
|
| 42 |
+
|
| 43 |
+
|
| 44 |
+
def _load():
|
| 45 |
+
"""Build or reuse the cached CUDA extension."""
|
| 46 |
+
global _MOD
|
| 47 |
+
if _MOD is not None:
|
| 48 |
+
return _MOD
|
| 49 |
+
from torch.utils.cpp_extension import load
|
| 50 |
+
|
| 51 |
+
_MOD = load(
|
| 52 |
+
name="carbon_a_viterbi_gpu", sources=[_SRC],
|
| 53 |
+
with_cuda=True, extra_cuda_cflags=["-O3"], verbose=False,
|
| 54 |
+
)
|
| 55 |
+
return _MOD
|
| 56 |
+
|
| 57 |
+
|
| 58 |
+
def available():
|
| 59 |
+
"""Return whether CUDA and the GPU extension are available."""
|
| 60 |
+
try:
|
| 61 |
+
import torch
|
| 62 |
+
if not torch.cuda.is_available():
|
| 63 |
+
return False, "no CUDA device"
|
| 64 |
+
_load()
|
| 65 |
+
return True, "ok"
|
| 66 |
+
except Exception as exc:
|
| 67 |
+
return False, f"{type(exc).__name__}: {exc}"
|
| 68 |
+
|
| 69 |
+
|
| 70 |
+
def batch_base_budget(free_bytes):
|
| 71 |
+
"""Return a VRAM-based sub-batch size that also fits CUDA int32 indexing."""
|
| 72 |
+
memory_budget = max(
|
| 73 |
+
GPU_MIN_BATCH_BASES,
|
| 74 |
+
int(free_bytes * GPU_MEMORY_FRACTION / GPU_ESTIMATED_BYTES_PER_BASE),
|
| 75 |
+
)
|
| 76 |
+
return min(memory_budget, GPU_MAX_BATCH_BASES)
|
| 77 |
+
|
| 78 |
+
|
| 79 |
+
def _decode_batch(module, tasks, transition_probabilities, reconstruction_batch_size):
|
| 80 |
+
"""Pack and decode one GPU sub-batch."""
|
| 81 |
+
record_offsets = np.zeros(len(tasks) + 1, np.int64)
|
| 82 |
+
for index, (_, encoded_bases) in enumerate(tasks):
|
| 83 |
+
record_offsets[index + 1] = record_offsets[index] + len(encoded_bases)
|
| 84 |
+
|
| 85 |
+
probability_bits = np.ascontiguousarray(
|
| 86 |
+
np.concatenate([np.asarray(probabilities, np.float16) for probabilities, _ in tasks])
|
| 87 |
+
).view(np.uint16)
|
| 88 |
+
encoded_bases = np.ascontiguousarray(
|
| 89 |
+
np.concatenate([np.asarray(bases, np.int8) for _, bases in tasks]),
|
| 90 |
+
dtype=np.int8,
|
| 91 |
+
)
|
| 92 |
+
packed_transition_probabilities = np.ascontiguousarray(np.concatenate(transition_probabilities), dtype=np.int32)
|
| 93 |
+
combined_path = np.asarray(
|
| 94 |
+
module.decode(
|
| 95 |
+
probability_bits, encoded_bases, packed_transition_probabilities, record_offsets,
|
| 96 |
+
GPU_SEGMENT_LENGTH,
|
| 97 |
+
0 if reconstruction_batch_size is None else reconstruction_batch_size,
|
| 98 |
+
)
|
| 99 |
+
)
|
| 100 |
+
return [
|
| 101 |
+
combined_path[int(record_offsets[index]):int(record_offsets[index + 1])]
|
| 102 |
+
for index in range(len(tasks))
|
| 103 |
+
]
|
| 104 |
+
|
| 105 |
+
|
| 106 |
+
def decode_paths(
|
| 107 |
+
tasks,
|
| 108 |
+
transition_probabilities,
|
| 109 |
+
stop_taa=True, stop_tag=True, stop_tga=True,
|
| 110 |
+
gt_ag_intron_log_prior=GT_AG_INTRON_LOG_PRIOR,
|
| 111 |
+
gc_ag_intron_log_prior=GC_AG_INTRON_LOG_PRIOR,
|
| 112 |
+
at_ac_intron_log_prior=AT_AC_INTRON_LOG_PRIOR,
|
| 113 |
+
max_boundary_intron_length=MAX_BOUNDARY_INTRON_LENGTH_BP,
|
| 114 |
+
reconstruction_batch_size=None,
|
| 115 |
+
):
|
| 116 |
+
"""Decode probability/base tasks with the GPU Viterbi backend.
|
| 117 |
+
|
| 118 |
+
``transition_probabilities`` holds one ``(bases, 4)`` array per task of
|
| 119 |
+
nat-valued log-odds in ``TRANSITION_NAMES`` column order.
|
| 120 |
+
``reconstruction_batch_size`` is the number of bases processed together
|
| 121 |
+
during final path reconstruction. ``None`` lets CUDA size it from VRAM.
|
| 122 |
+
"""
|
| 123 |
+
max_boundary_intron_length = validate_max_boundary_intron_length(max_boundary_intron_length)
|
| 124 |
+
reconstruction_batch_size = validate_reconstruction_batch_size(reconstruction_batch_size)
|
| 125 |
+
tasks = prepare_decode_tasks(tasks)
|
| 126 |
+
transition_probabilities = prepare_transition_probabilities(transition_probabilities, tasks)
|
| 127 |
+
if not tasks:
|
| 128 |
+
return []
|
| 129 |
+
for _, encoded_bases in tasks:
|
| 130 |
+
if len(encoded_bases) > GPU_MAX_BATCH_BASES:
|
| 131 |
+
raise ValueError(
|
| 132 |
+
f"one GPU Viterbi backend record has {len(encoded_bases)} bases; "
|
| 133 |
+
f"the maximum supported length is {GPU_MAX_BATCH_BASES}"
|
| 134 |
+
)
|
| 135 |
+
import torch
|
| 136 |
+
|
| 137 |
+
module = _load()
|
| 138 |
+
module.init(
|
| 139 |
+
int(stop_taa), int(stop_tag), int(stop_tga),
|
| 140 |
+
float(gt_ag_intron_log_prior), float(gc_ag_intron_log_prior),
|
| 141 |
+
float(at_ac_intron_log_prior),
|
| 142 |
+
int(max_boundary_intron_length),
|
| 143 |
+
)
|
| 144 |
+
|
| 145 |
+
free_bytes = torch.cuda.mem_get_info()[0]
|
| 146 |
+
base_budget = batch_base_budget(free_bytes)
|
| 147 |
+
|
| 148 |
+
decoded_paths = []
|
| 149 |
+
batch_start = 0
|
| 150 |
+
while batch_start < len(tasks):
|
| 151 |
+
batch_end = batch_start
|
| 152 |
+
batch_bases = 0
|
| 153 |
+
while batch_end < len(tasks):
|
| 154 |
+
next_record_bases = len(tasks[batch_end][1])
|
| 155 |
+
if batch_end > batch_start and batch_bases + next_record_bases > base_budget:
|
| 156 |
+
break
|
| 157 |
+
batch_bases += next_record_bases
|
| 158 |
+
batch_end += 1
|
| 159 |
+
|
| 160 |
+
decoded_paths.extend(
|
| 161 |
+
_decode_batch(
|
| 162 |
+
module, tasks[batch_start:batch_end],
|
| 163 |
+
transition_probabilities[batch_start:batch_end], reconstruction_batch_size,
|
| 164 |
+
)
|
| 165 |
+
)
|
| 166 |
+
batch_start = batch_end
|
| 167 |
+
return decoded_paths
|
annotation_pipeline/src/viterbi_backends/viterbi_cpu.cpp
ADDED
|
@@ -0,0 +1,806 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
// viterbi_cpu.cpp -- CPU Viterbi backend: C++/OpenMP implementation of the algorithm in viterbi.py.
|
| 2 |
+
//
|
| 3 |
+
// This file implements the same 30-state decoder grammar as viterbi.py:
|
| 4 |
+
// - six exon prefix types for stop-codon detection;
|
| 5 |
+
// - GT/GC-AG introns enter FIRST, then move to READY before AG exit;
|
| 6 |
+
// - AT-AC introns use one state table because AT cannot overlap AC;
|
| 7 |
+
// - Python supplies explicit GT-AG, GC-AG, and AT-AC log priors;
|
| 8 |
+
// - N freely matches any concrete base in codons and splice signals;
|
| 9 |
+
// - dedicated leading/trailing states represent introns crossing a record edge;
|
| 10 |
+
// - the transition network supplies the probabilities of the four permitted
|
| 11 |
+
// transitions per base (enter gene, leave gene, enter intron, leave
|
| 12 |
+
// intron) as log-odds in the decoder's fixed-point units;
|
| 13 |
+
// - stop read-through is disabled.
|
| 14 |
+
//
|
| 15 |
+
// Per-base log-probability and transition increments are int32 fixed-point
|
| 16 |
+
// values from a fp16 log LUT. Accumulated path scores use int64 and are kept
|
| 17 |
+
// unnormalized, so this implementation does not discard a state just because
|
| 18 |
+
// it temporarily falls behind the current best state. Long records keep score
|
| 19 |
+
// checkpoints at reconstruction-batch boundaries, so backpointer memory does
|
| 20 |
+
// not grow with the complete record length.
|
| 21 |
+
#include <cstdint>
|
| 22 |
+
#include <cmath>
|
| 23 |
+
#include <cstring>
|
| 24 |
+
#include <stdexcept>
|
| 25 |
+
#include <vector>
|
| 26 |
+
#include <omp.h>
|
| 27 |
+
|
| 28 |
+
using AccumulatedScore = int64_t;
|
| 29 |
+
using StepScore = int32_t;
|
| 30 |
+
static const double SCORE_SCALE = 16384.0;
|
| 31 |
+
static inline StepScore score_from_nats(double nats) {
|
| 32 |
+
return static_cast<StepScore>(std::llround(nats * SCORE_SCALE));
|
| 33 |
+
}
|
| 34 |
+
// Fixed-point code uses this sentinel in place of IEEE -inf.
|
| 35 |
+
static const AccumulatedScore UNREACHABLE_ACCUMULATED_SCORE = -4'000'000'000'000'000'000LL;
|
| 36 |
+
static const int64_t DEFAULT_RECONSTRUCTION_BATCH_SIZE = 8'000'000;
|
| 37 |
+
|
| 38 |
+
static const int STATE_COUNT = 30;
|
| 39 |
+
static const int PREFIX_TYPE_COUNT = 6;
|
| 40 |
+
static const int INTERGENIC = 0;
|
| 41 |
+
static const int START_A = 1;
|
| 42 |
+
static const int START_AT = 2;
|
| 43 |
+
static const int EXON_PREFIX_TYPE_0 = 3;
|
| 44 |
+
static const int STOP = 9;
|
| 45 |
+
static const int GT_GC_AG_INTRON_FIRST_PREFIX_TYPE_0 = 10;
|
| 46 |
+
static const int GT_GC_AG_INTRON_READY_PREFIX_TYPE_0 = 16;
|
| 47 |
+
static const int AT_AC_INTRON_PREFIX_TYPE_0 = 22;
|
| 48 |
+
static const int LEADING_BOUNDARY_INTRON = 28;
|
| 49 |
+
static const int TRAILING_BOUNDARY_INTRON = 29;
|
| 50 |
+
|
| 51 |
+
// State layout matches viterbi.py and viterbi_gpu.cu:
|
| 52 |
+
// 0 intergenic; 1..2 ATG start; 3..8 exon prefix types; 9 stop;
|
| 53 |
+
// 10..15 GT/GC-AG FIRST; 16..21 GT/GC-AG READY; 22..27 AT-AC;
|
| 54 |
+
// 28 leading boundary intron; 29 trailing boundary intron.
|
| 55 |
+
|
| 56 |
+
static const int BASE_A = 0;
|
| 57 |
+
static const int BASE_T = 1;
|
| 58 |
+
static const int BASE_C = 2;
|
| 59 |
+
static const int BASE_G = 3;
|
| 60 |
+
static const int BASE_N = 4;
|
| 61 |
+
|
| 62 |
+
// Transition probability tracks per base, in the column order of constants.py.
|
| 63 |
+
// They arrive from Python as log-odds against staying, already quantized to
|
| 64 |
+
// the decoder's fixed-point units, so they add directly to path scores.
|
| 65 |
+
static const int ENTER_GENE = 0;
|
| 66 |
+
static const int LEAVE_GENE = 1;
|
| 67 |
+
static const int ENTER_INTRON = 2;
|
| 68 |
+
static const int LEAVE_INTRON = 3;
|
| 69 |
+
static const int TRANSITION_COUNT = 4;
|
| 70 |
+
|
| 71 |
+
static inline bool is_exon_state(int state) {
|
| 72 |
+
return state >= EXON_PREFIX_TYPE_0
|
| 73 |
+
&& state < EXON_PREFIX_TYPE_0 + PREFIX_TYPE_COUNT;
|
| 74 |
+
}
|
| 75 |
+
|
| 76 |
+
static inline bool is_gt_gc_intron_entry_state(int state) {
|
| 77 |
+
return state >= GT_GC_AG_INTRON_FIRST_PREFIX_TYPE_0
|
| 78 |
+
&& state < GT_GC_AG_INTRON_READY_PREFIX_TYPE_0;
|
| 79 |
+
}
|
| 80 |
+
|
| 81 |
+
static inline bool is_gt_gc_acceptor_state(int state) {
|
| 82 |
+
return state >= GT_GC_AG_INTRON_READY_PREFIX_TYPE_0
|
| 83 |
+
&& state < AT_AC_INTRON_PREFIX_TYPE_0;
|
| 84 |
+
}
|
| 85 |
+
|
| 86 |
+
static inline bool is_at_ac_intron_state(int state) {
|
| 87 |
+
return state >= AT_AC_INTRON_PREFIX_TYPE_0
|
| 88 |
+
&& state < LEADING_BOUNDARY_INTRON;
|
| 89 |
+
}
|
| 90 |
+
|
| 91 |
+
// Transition log-odds of one legal state change. Each value is attached to the
|
| 92 |
+
// base where the new segment begins: the first start-codon base (enter gene),
|
| 93 |
+
// the last stop-codon base (leave gene, charged on the base that closes the
|
| 94 |
+
// gene), the first intron base (enter intron), or the first exon base after an
|
| 95 |
+
// intron (leave intron). Introns that cross a record edge are entered and left
|
| 96 |
+
// the same way.
|
| 97 |
+
static inline StepScore transition_log_odds(
|
| 98 |
+
int source_state, int target_state, const StepScore* position_transition_probabilities
|
| 99 |
+
) {
|
| 100 |
+
if (source_state == INTERGENIC && target_state == START_A) {
|
| 101 |
+
return position_transition_probabilities[ENTER_GENE];
|
| 102 |
+
}
|
| 103 |
+
// Leaving the gene takes precedence when an intron exit completes a stop
|
| 104 |
+
// codon that was split by the intron.
|
| 105 |
+
if (target_state == STOP) return position_transition_probabilities[LEAVE_GENE];
|
| 106 |
+
if (is_exon_state(source_state)
|
| 107 |
+
&& (is_gt_gc_intron_entry_state(target_state)
|
| 108 |
+
|| is_at_ac_intron_state(target_state)
|
| 109 |
+
|| target_state == TRAILING_BOUNDARY_INTRON)) {
|
| 110 |
+
return position_transition_probabilities[ENTER_INTRON];
|
| 111 |
+
}
|
| 112 |
+
if ((is_gt_gc_acceptor_state(source_state)
|
| 113 |
+
|| is_at_ac_intron_state(source_state)
|
| 114 |
+
|| source_state == LEADING_BOUNDARY_INTRON)
|
| 115 |
+
&& is_exon_state(target_state)) {
|
| 116 |
+
return position_transition_probabilities[LEAVE_INTRON];
|
| 117 |
+
}
|
| 118 |
+
return StepScore(0);
|
| 119 |
+
}
|
| 120 |
+
|
| 121 |
+
static inline bool base_matches(int observed_base, int expected_base) {
|
| 122 |
+
return observed_base == expected_base || observed_base == BASE_N;
|
| 123 |
+
}
|
| 124 |
+
|
| 125 |
+
static inline bool is_exact_enabled_stop_codon(
|
| 126 |
+
int first_base, int second_base, int third_base,
|
| 127 |
+
bool stop_taa, bool stop_tag, bool stop_tga
|
| 128 |
+
) {
|
| 129 |
+
if (first_base != BASE_T) return false;
|
| 130 |
+
if (second_base == BASE_A) {
|
| 131 |
+
if (third_base == BASE_A && stop_taa) return true;
|
| 132 |
+
if (third_base == BASE_G && stop_tag) return true;
|
| 133 |
+
}
|
| 134 |
+
return second_base == BASE_G
|
| 135 |
+
&& third_base == BASE_A
|
| 136 |
+
&& stop_tga;
|
| 137 |
+
}
|
| 138 |
+
|
| 139 |
+
static StepScore coding_score_lookup[65536], noncoding_score_lookup[65536];
|
| 140 |
+
|
| 141 |
+
static double half_bits_to_double(uint16_t half_bits) {
|
| 142 |
+
uint32_t sign = static_cast<uint32_t>(half_bits & 0x8000) << 16;
|
| 143 |
+
uint32_t exponent = (half_bits >> 10) & 0x1F;
|
| 144 |
+
uint32_t mantissa = half_bits & 0x3FF;
|
| 145 |
+
uint32_t float_bits;
|
| 146 |
+
if (exponent == 0) {
|
| 147 |
+
if (mantissa == 0) {
|
| 148 |
+
float_bits = sign;
|
| 149 |
+
} else {
|
| 150 |
+
int normalization_shift = -1;
|
| 151 |
+
do {
|
| 152 |
+
normalization_shift++;
|
| 153 |
+
mantissa <<= 1;
|
| 154 |
+
} while (!(mantissa & 0x400));
|
| 155 |
+
float_bits = sign
|
| 156 |
+
| (static_cast<uint32_t>(127 - 15 - normalization_shift) << 23)
|
| 157 |
+
| ((mantissa & 0x3FF) << 13);
|
| 158 |
+
}
|
| 159 |
+
} else if (exponent == 0x1F) {
|
| 160 |
+
float_bits = sign | 0x7F800000u | (mantissa << 13);
|
| 161 |
+
} else {
|
| 162 |
+
float_bits = sign | ((exponent + (127 - 15)) << 23) | (mantissa << 13);
|
| 163 |
+
}
|
| 164 |
+
float value;
|
| 165 |
+
memcpy(&value, &float_bits, sizeof(value));
|
| 166 |
+
return static_cast<double>(value);
|
| 167 |
+
}
|
| 168 |
+
|
| 169 |
+
static bool build_log_score_lookup() {
|
| 170 |
+
for (int index = 0; index < 65536; index++) {
|
| 171 |
+
double probability = half_bits_to_double(static_cast<uint16_t>(index));
|
| 172 |
+
if (probability < 1e-6) probability = 1e-6;
|
| 173 |
+
else if (probability > 1.0 - 1e-6) probability = 1.0 - 1e-6;
|
| 174 |
+
coding_score_lookup[index] = score_from_nats(log(probability));
|
| 175 |
+
noncoding_score_lookup[index] = score_from_nats(log(1.0 - probability));
|
| 176 |
+
}
|
| 177 |
+
return true;
|
| 178 |
+
}
|
| 179 |
+
|
| 180 |
+
static inline int exon_state_for_prefix(int prefix_type) {
|
| 181 |
+
return EXON_PREFIX_TYPE_0 + prefix_type;
|
| 182 |
+
}
|
| 183 |
+
|
| 184 |
+
static inline int next_coding_state_after_base(
|
| 185 |
+
int prefix_type, int base,
|
| 186 |
+
bool stop_taa, bool stop_tag, bool stop_tga
|
| 187 |
+
) {
|
| 188 |
+
if (prefix_type == 0) return base == BASE_T ? exon_state_for_prefix(1) : exon_state_for_prefix(2);
|
| 189 |
+
if (prefix_type == 1) {
|
| 190 |
+
if (base == BASE_A) return exon_state_for_prefix(3);
|
| 191 |
+
if (base == BASE_G) return exon_state_for_prefix(4);
|
| 192 |
+
return exon_state_for_prefix(5);
|
| 193 |
+
}
|
| 194 |
+
if (prefix_type == 2) return exon_state_for_prefix(5);
|
| 195 |
+
if (prefix_type == 3) {
|
| 196 |
+
if (base == BASE_A && stop_taa) return STOP;
|
| 197 |
+
if (base == BASE_G && stop_tag) return STOP;
|
| 198 |
+
return exon_state_for_prefix(0);
|
| 199 |
+
}
|
| 200 |
+
if (prefix_type == 4) {
|
| 201 |
+
if (base == BASE_A && stop_tga) return STOP;
|
| 202 |
+
return exon_state_for_prefix(0);
|
| 203 |
+
}
|
| 204 |
+
return exon_state_for_prefix(0);
|
| 205 |
+
}
|
| 206 |
+
|
| 207 |
+
static inline void keep_best_transition(
|
| 208 |
+
AccumulatedScore* next_scores, int8_t* predecessors,
|
| 209 |
+
int target_state, AccumulatedScore candidate_score, int source_state,
|
| 210 |
+
const StepScore* position_transition_probabilities
|
| 211 |
+
) {
|
| 212 |
+
candidate_score += transition_log_odds(
|
| 213 |
+
source_state, target_state, position_transition_probabilities
|
| 214 |
+
);
|
| 215 |
+
if (candidate_score > next_scores[target_state]) {
|
| 216 |
+
next_scores[target_state] = candidate_score;
|
| 217 |
+
predecessors[target_state] = static_cast<int8_t>(source_state);
|
| 218 |
+
}
|
| 219 |
+
}
|
| 220 |
+
|
| 221 |
+
static inline void emit_coding_base(
|
| 222 |
+
const AccumulatedScore* current_scores, AccumulatedScore* next_scores,
|
| 223 |
+
int8_t* predecessors, int source_state, int prefix_type,
|
| 224 |
+
StepScore coding_score, const StepScore* position_transition_probabilities, int base,
|
| 225 |
+
bool stop_taa, bool stop_tag, bool stop_tga
|
| 226 |
+
) {
|
| 227 |
+
AccumulatedScore source_score = current_scores[source_state];
|
| 228 |
+
if (source_score == UNREACHABLE_ACCUMULATED_SCORE) return;
|
| 229 |
+
AccumulatedScore candidate_score = source_score + coding_score;
|
| 230 |
+
if (base == BASE_N) {
|
| 231 |
+
for (int resolved_base = BASE_A; resolved_base <= BASE_G; resolved_base++) {
|
| 232 |
+
int target_state = next_coding_state_after_base(
|
| 233 |
+
prefix_type, resolved_base, stop_taa, stop_tag, stop_tga
|
| 234 |
+
);
|
| 235 |
+
keep_best_transition(
|
| 236 |
+
next_scores, predecessors, target_state, candidate_score, source_state,
|
| 237 |
+
position_transition_probabilities
|
| 238 |
+
);
|
| 239 |
+
}
|
| 240 |
+
} else {
|
| 241 |
+
int target_state = next_coding_state_after_base(
|
| 242 |
+
prefix_type, base, stop_taa, stop_tag, stop_tga
|
| 243 |
+
);
|
| 244 |
+
keep_best_transition(
|
| 245 |
+
next_scores, predecessors, target_state, candidate_score, source_state,
|
| 246 |
+
position_transition_probabilities
|
| 247 |
+
);
|
| 248 |
+
}
|
| 249 |
+
}
|
| 250 |
+
|
| 251 |
+
// Transitions across the left record edge are charged like interior ones: a
|
| 252 |
+
// start codon beginning at the first base is charged enter_gene[0] and a stop
|
| 253 |
+
// codon completed there is charged leave_gene[0]; every other first-base state
|
| 254 |
+
// is charged nothing.
|
| 255 |
+
static void initialize_first_base_scores(
|
| 256 |
+
const uint16_t* probability_bits, const StepScore* transition_probabilities,
|
| 257 |
+
const int8_t* bases, int record_length,
|
| 258 |
+
bool stop_taa, bool stop_tag, bool stop_tga,
|
| 259 |
+
bool boundary_introns_enabled, AccumulatedScore* current_scores
|
| 260 |
+
) {
|
| 261 |
+
StepScore coding_score = coding_score_lookup[probability_bits[0]];
|
| 262 |
+
StepScore noncoding_score = noncoding_score_lookup[probability_bits[0]];
|
| 263 |
+
int base = bases[0];
|
| 264 |
+
|
| 265 |
+
for (int state = 0; state < STATE_COUNT; state++) {
|
| 266 |
+
current_scores[state] = UNREACHABLE_ACCUMULATED_SCORE;
|
| 267 |
+
}
|
| 268 |
+
|
| 269 |
+
current_scores[INTERGENIC] = noncoding_score;
|
| 270 |
+
current_scores[exon_state_for_prefix(0)] = coding_score;
|
| 271 |
+
current_scores[exon_state_for_prefix(5)] = coding_score;
|
| 272 |
+
if (base == BASE_N) {
|
| 273 |
+
for (int prefix_type = 0; prefix_type < PREFIX_TYPE_COUNT; prefix_type++) {
|
| 274 |
+
current_scores[exon_state_for_prefix(prefix_type)] = coding_score;
|
| 275 |
+
}
|
| 276 |
+
} else {
|
| 277 |
+
int one_base_prefix_type = base == BASE_T ? 1 : 2;
|
| 278 |
+
current_scores[exon_state_for_prefix(one_base_prefix_type)] = coding_score;
|
| 279 |
+
if (base == BASE_A) current_scores[exon_state_for_prefix(3)] = coding_score;
|
| 280 |
+
else if (base == BASE_G) current_scores[exon_state_for_prefix(4)] = coding_score;
|
| 281 |
+
}
|
| 282 |
+
if ((base_matches(base, BASE_A) && (stop_taa || stop_tga))
|
| 283 |
+
|| (base_matches(base, BASE_G) && stop_tag)) {
|
| 284 |
+
current_scores[STOP] = static_cast<AccumulatedScore>(coding_score)
|
| 285 |
+
+ transition_probabilities[LEAVE_GENE];
|
| 286 |
+
}
|
| 287 |
+
|
| 288 |
+
if (boundary_introns_enabled) {
|
| 289 |
+
current_scores[LEADING_BOUNDARY_INTRON] = noncoding_score;
|
| 290 |
+
}
|
| 291 |
+
|
| 292 |
+
bool second_start_base_matches = record_length == 1 || base_matches(bases[1], BASE_T);
|
| 293 |
+
bool third_start_base_matches = record_length <= 2 || base_matches(bases[2], BASE_G);
|
| 294 |
+
bool has_atg_start = base_matches(base, BASE_A)
|
| 295 |
+
&& second_start_base_matches
|
| 296 |
+
&& third_start_base_matches;
|
| 297 |
+
if (has_atg_start) {
|
| 298 |
+
current_scores[START_A] = static_cast<AccumulatedScore>(coding_score)
|
| 299 |
+
+ transition_probabilities[ENTER_GENE];
|
| 300 |
+
}
|
| 301 |
+
}
|
| 302 |
+
|
| 303 |
+
static void run_viterbi_forward(
|
| 304 |
+
const uint16_t* probability_bits, const int8_t* bases,
|
| 305 |
+
const StepScore* transition_probabilities, int record_length,
|
| 306 |
+
int segment_start, int segment_end, bool is_record_start,
|
| 307 |
+
StepScore gt_ag_intron_score, StepScore gc_ag_intron_score, StepScore at_ac_intron_score,
|
| 308 |
+
int max_boundary_intron_length,
|
| 309 |
+
bool stop_taa, bool stop_tag, bool stop_tga,
|
| 310 |
+
int8_t* backpointers, AccumulatedScore* output_scores,
|
| 311 |
+
const AccumulatedScore* input_scores = nullptr
|
| 312 |
+
) {
|
| 313 |
+
AccumulatedScore current_buffer[STATE_COUNT];
|
| 314 |
+
AccumulatedScore next_buffer[STATE_COUNT];
|
| 315 |
+
AccumulatedScore* current_scores = current_buffer;
|
| 316 |
+
AccumulatedScore* next_scores = next_buffer;
|
| 317 |
+
|
| 318 |
+
if (is_record_start) {
|
| 319 |
+
initialize_first_base_scores(
|
| 320 |
+
probability_bits, transition_probabilities, bases, record_length,
|
| 321 |
+
stop_taa, stop_tag, stop_tga,
|
| 322 |
+
max_boundary_intron_length != 0,
|
| 323 |
+
current_scores
|
| 324 |
+
);
|
| 325 |
+
} else {
|
| 326 |
+
for (int state = 0; state < STATE_COUNT; state++) {
|
| 327 |
+
current_scores[state] = input_scores[state];
|
| 328 |
+
}
|
| 329 |
+
}
|
| 330 |
+
|
| 331 |
+
for (int position = segment_start + 1; position <= segment_end; position++) {
|
| 332 |
+
int base = bases[position];
|
| 333 |
+
StepScore coding_score = coding_score_lookup[probability_bits[position]];
|
| 334 |
+
StepScore noncoding_score = noncoding_score_lookup[probability_bits[position]];
|
| 335 |
+
const StepScore* position_transition_probabilities =
|
| 336 |
+
transition_probabilities + static_cast<size_t>(position) * TRANSITION_COUNT;
|
| 337 |
+
|
| 338 |
+
// Missing motif bases at record edges are unknown. Internal positions
|
| 339 |
+
// still require the complete start, donor, or acceptor motif.
|
| 340 |
+
bool next_base_is_missing = position + 1 == record_length;
|
| 341 |
+
int next_base = next_base_is_missing ? -1 : bases[position + 1];
|
| 342 |
+
bool has_gt_ag_donor = base_matches(base, BASE_G)
|
| 343 |
+
&& (next_base_is_missing || base_matches(next_base, BASE_T));
|
| 344 |
+
bool has_gc_ag_donor = base_matches(base, BASE_G)
|
| 345 |
+
&& (next_base_is_missing || base_matches(next_base, BASE_C));
|
| 346 |
+
bool acceptor_first_base_matches = position == 1
|
| 347 |
+
|| base_matches(bases[position - 2], BASE_A);
|
| 348 |
+
bool has_ag_acceptor = acceptor_first_base_matches
|
| 349 |
+
&& base_matches(bases[position - 1], BASE_G);
|
| 350 |
+
bool has_ac_acceptor = acceptor_first_base_matches
|
| 351 |
+
&& base_matches(bases[position - 1], BASE_C);
|
| 352 |
+
bool third_start_base_matches = position + 2 >= record_length
|
| 353 |
+
|| base_matches(bases[position + 2], BASE_G);
|
| 354 |
+
bool has_atg_start = base_matches(base, BASE_A)
|
| 355 |
+
&& (next_base_is_missing || base_matches(next_base, BASE_T))
|
| 356 |
+
&& third_start_base_matches;
|
| 357 |
+
// Boundary guards require exact five-base patterns. N and missing
|
| 358 |
+
// edge context remain valid for ordinary motif matching only.
|
| 359 |
+
bool has_exact_acceptor_followed_by_atg = position >= 2
|
| 360 |
+
&& position + 2 < record_length
|
| 361 |
+
&& bases[position - 2] == BASE_A
|
| 362 |
+
&& (bases[position - 1] == BASE_G || bases[position - 1] == BASE_C)
|
| 363 |
+
&& base == BASE_A
|
| 364 |
+
&& next_base == BASE_T
|
| 365 |
+
&& bases[position + 2] == BASE_G;
|
| 366 |
+
bool has_at_ac_donor = base_matches(base, BASE_A)
|
| 367 |
+
&& (next_base_is_missing || base_matches(next_base, BASE_T));
|
| 368 |
+
bool has_any_donor = has_gt_ag_donor || has_gc_ag_donor || has_at_ac_donor;
|
| 369 |
+
bool has_exact_donor = position + 1 < record_length
|
| 370 |
+
&& ((base == BASE_G && (next_base == BASE_T || next_base == BASE_C))
|
| 371 |
+
|| (base == BASE_A && next_base == BASE_T));
|
| 372 |
+
bool has_exact_enabled_stop_followed_by_donor = false;
|
| 373 |
+
if (has_exact_donor && position >= 3) {
|
| 374 |
+
has_exact_enabled_stop_followed_by_donor = is_exact_enabled_stop_codon(
|
| 375 |
+
bases[position - 3], bases[position - 2], bases[position - 1],
|
| 376 |
+
stop_taa, stop_tag, stop_tga
|
| 377 |
+
);
|
| 378 |
+
}
|
| 379 |
+
// A leading self-loop into position makes its run position + 1 bases.
|
| 380 |
+
// A trailing entry here emits record_length - position bases through
|
| 381 |
+
// the right edge, so both limits follow directly from coordinates.
|
| 382 |
+
bool boundary_introns_enabled = max_boundary_intron_length != 0;
|
| 383 |
+
bool boundary_intron_length_is_unlimited = max_boundary_intron_length == -1;
|
| 384 |
+
bool can_extend_leading_boundary_intron =
|
| 385 |
+
boundary_introns_enabled
|
| 386 |
+
&& (boundary_intron_length_is_unlimited
|
| 387 |
+
|| position < max_boundary_intron_length);
|
| 388 |
+
bool can_enter_trailing_boundary_intron =
|
| 389 |
+
boundary_introns_enabled
|
| 390 |
+
&& (boundary_intron_length_is_unlimited
|
| 391 |
+
|| record_length - position <= max_boundary_intron_length);
|
| 392 |
+
|
| 393 |
+
int8_t local_predecessors[STATE_COUNT];
|
| 394 |
+
int8_t* predecessors = backpointers
|
| 395 |
+
? &backpointers[
|
| 396 |
+
static_cast<size_t>(position - segment_start - 1) * STATE_COUNT
|
| 397 |
+
]
|
| 398 |
+
: local_predecessors;
|
| 399 |
+
for (int state = 0; state < STATE_COUNT; state++) {
|
| 400 |
+
next_scores[state] = UNREACHABLE_ACCUMULATED_SCORE;
|
| 401 |
+
predecessors[state] = 0;
|
| 402 |
+
}
|
| 403 |
+
|
| 404 |
+
// Intergenic, stop, and start-codon transitions.
|
| 405 |
+
if (current_scores[INTERGENIC] != UNREACHABLE_ACCUMULATED_SCORE) {
|
| 406 |
+
keep_best_transition(
|
| 407 |
+
next_scores, predecessors, INTERGENIC,
|
| 408 |
+
current_scores[INTERGENIC] + noncoding_score, INTERGENIC,
|
| 409 |
+
position_transition_probabilities
|
| 410 |
+
);
|
| 411 |
+
if (has_atg_start) {
|
| 412 |
+
keep_best_transition(
|
| 413 |
+
next_scores, predecessors, START_A,
|
| 414 |
+
current_scores[INTERGENIC] + coding_score,
|
| 415 |
+
INTERGENIC,
|
| 416 |
+
position_transition_probabilities
|
| 417 |
+
);
|
| 418 |
+
}
|
| 419 |
+
}
|
| 420 |
+
|
| 421 |
+
if (current_scores[STOP] != UNREACHABLE_ACCUMULATED_SCORE) {
|
| 422 |
+
keep_best_transition(
|
| 423 |
+
next_scores, predecessors, INTERGENIC,
|
| 424 |
+
current_scores[STOP] + noncoding_score, STOP,
|
| 425 |
+
position_transition_probabilities
|
| 426 |
+
);
|
| 427 |
+
}
|
| 428 |
+
|
| 429 |
+
if (current_scores[START_A] != UNREACHABLE_ACCUMULATED_SCORE) {
|
| 430 |
+
keep_best_transition(
|
| 431 |
+
next_scores, predecessors, START_AT,
|
| 432 |
+
current_scores[START_A] + coding_score, START_A,
|
| 433 |
+
position_transition_probabilities
|
| 434 |
+
);
|
| 435 |
+
}
|
| 436 |
+
if (current_scores[START_AT] != UNREACHABLE_ACCUMULATED_SCORE) {
|
| 437 |
+
keep_best_transition(
|
| 438 |
+
next_scores, predecessors, exon_state_for_prefix(0),
|
| 439 |
+
current_scores[START_AT] + coding_score, START_AT,
|
| 440 |
+
position_transition_probabilities
|
| 441 |
+
);
|
| 442 |
+
}
|
| 443 |
+
|
| 444 |
+
// Every prefix type is allowed when the leading boundary intron exits,
|
| 445 |
+
// so the state carries none. Only an exact acceptor followed by an
|
| 446 |
+
// exact ATG activates the guard; N leaves both paths available.
|
| 447 |
+
if (current_scores[LEADING_BOUNDARY_INTRON] != UNREACHABLE_ACCUMULATED_SCORE) {
|
| 448 |
+
if (can_extend_leading_boundary_intron) {
|
| 449 |
+
keep_best_transition(
|
| 450 |
+
next_scores, predecessors, LEADING_BOUNDARY_INTRON,
|
| 451 |
+
current_scores[LEADING_BOUNDARY_INTRON] + noncoding_score,
|
| 452 |
+
LEADING_BOUNDARY_INTRON,
|
| 453 |
+
position_transition_probabilities
|
| 454 |
+
);
|
| 455 |
+
}
|
| 456 |
+
bool can_exit_leading_intron =
|
| 457 |
+
(has_ag_acceptor || has_ac_acceptor)
|
| 458 |
+
&& !has_exact_acceptor_followed_by_atg;
|
| 459 |
+
if (can_exit_leading_intron) {
|
| 460 |
+
for (int prefix_type = 0; prefix_type < PREFIX_TYPE_COUNT; prefix_type++) {
|
| 461 |
+
emit_coding_base(
|
| 462 |
+
current_scores, next_scores, predecessors,
|
| 463 |
+
LEADING_BOUNDARY_INTRON, prefix_type,
|
| 464 |
+
coding_score, position_transition_probabilities,
|
| 465 |
+
base, stop_taa, stop_tag, stop_tga
|
| 466 |
+
);
|
| 467 |
+
}
|
| 468 |
+
}
|
| 469 |
+
}
|
| 470 |
+
|
| 471 |
+
// Exons emit coding bases, open an ordinary internal intron, or enter
|
| 472 |
+
// the trailing state when the intron continues beyond the record.
|
| 473 |
+
for (int prefix_type = 0; prefix_type < PREFIX_TYPE_COUNT; prefix_type++) {
|
| 474 |
+
int exon_state = exon_state_for_prefix(prefix_type);
|
| 475 |
+
AccumulatedScore exon_score = current_scores[exon_state];
|
| 476 |
+
if (exon_score == UNREACHABLE_ACCUMULATED_SCORE) continue;
|
| 477 |
+
|
| 478 |
+
emit_coding_base(
|
| 479 |
+
current_scores, next_scores, predecessors,
|
| 480 |
+
exon_state, prefix_type,
|
| 481 |
+
coding_score, position_transition_probabilities,
|
| 482 |
+
base, stop_taa, stop_tag, stop_tga
|
| 483 |
+
);
|
| 484 |
+
|
| 485 |
+
if (has_gt_ag_donor) {
|
| 486 |
+
keep_best_transition(
|
| 487 |
+
next_scores, predecessors,
|
| 488 |
+
GT_GC_AG_INTRON_FIRST_PREFIX_TYPE_0 + prefix_type,
|
| 489 |
+
exon_score + noncoding_score + gt_ag_intron_score,
|
| 490 |
+
exon_state,
|
| 491 |
+
position_transition_probabilities
|
| 492 |
+
);
|
| 493 |
+
}
|
| 494 |
+
if (has_gc_ag_donor) {
|
| 495 |
+
keep_best_transition(
|
| 496 |
+
next_scores, predecessors,
|
| 497 |
+
GT_GC_AG_INTRON_FIRST_PREFIX_TYPE_0 + prefix_type,
|
| 498 |
+
exon_score + noncoding_score + gc_ag_intron_score,
|
| 499 |
+
exon_state,
|
| 500 |
+
position_transition_probabilities
|
| 501 |
+
);
|
| 502 |
+
}
|
| 503 |
+
if (has_at_ac_donor) {
|
| 504 |
+
keep_best_transition(
|
| 505 |
+
next_scores, predecessors, AT_AC_INTRON_PREFIX_TYPE_0 + prefix_type,
|
| 506 |
+
exon_score + noncoding_score + at_ac_intron_score,
|
| 507 |
+
exon_state,
|
| 508 |
+
position_transition_probabilities
|
| 509 |
+
);
|
| 510 |
+
}
|
| 511 |
+
if (has_any_donor
|
| 512 |
+
&& !has_exact_enabled_stop_followed_by_donor
|
| 513 |
+
&& can_enter_trailing_boundary_intron) {
|
| 514 |
+
if (has_gt_ag_donor) {
|
| 515 |
+
keep_best_transition(
|
| 516 |
+
next_scores, predecessors, TRAILING_BOUNDARY_INTRON,
|
| 517 |
+
exon_score + noncoding_score + gt_ag_intron_score,
|
| 518 |
+
exon_state,
|
| 519 |
+
position_transition_probabilities
|
| 520 |
+
);
|
| 521 |
+
}
|
| 522 |
+
if (has_gc_ag_donor) {
|
| 523 |
+
keep_best_transition(
|
| 524 |
+
next_scores, predecessors, TRAILING_BOUNDARY_INTRON,
|
| 525 |
+
exon_score + noncoding_score + gc_ag_intron_score,
|
| 526 |
+
exon_state,
|
| 527 |
+
position_transition_probabilities
|
| 528 |
+
);
|
| 529 |
+
}
|
| 530 |
+
if (has_at_ac_donor) {
|
| 531 |
+
keep_best_transition(
|
| 532 |
+
next_scores, predecessors, TRAILING_BOUNDARY_INTRON,
|
| 533 |
+
exon_score + noncoding_score + at_ac_intron_score,
|
| 534 |
+
exon_state,
|
| 535 |
+
position_transition_probabilities
|
| 536 |
+
);
|
| 537 |
+
}
|
| 538 |
+
}
|
| 539 |
+
}
|
| 540 |
+
|
| 541 |
+
// FIRST consumes one additional base before AG exit is legal.
|
| 542 |
+
for (int prefix_type = 0; prefix_type < PREFIX_TYPE_COUNT; prefix_type++) {
|
| 543 |
+
int intron_state = GT_GC_AG_INTRON_FIRST_PREFIX_TYPE_0 + prefix_type;
|
| 544 |
+
if (current_scores[intron_state] == UNREACHABLE_ACCUMULATED_SCORE) continue;
|
| 545 |
+
keep_best_transition(
|
| 546 |
+
next_scores, predecessors,
|
| 547 |
+
GT_GC_AG_INTRON_READY_PREFIX_TYPE_0 + prefix_type,
|
| 548 |
+
current_scores[intron_state] + noncoding_score, intron_state,
|
| 549 |
+
position_transition_probabilities
|
| 550 |
+
);
|
| 551 |
+
}
|
| 552 |
+
|
| 553 |
+
// READY self-loops or exits a GT/GC-AG intron after AG.
|
| 554 |
+
for (int prefix_type = 0; prefix_type < PREFIX_TYPE_COUNT; prefix_type++) {
|
| 555 |
+
int intron_state = GT_GC_AG_INTRON_READY_PREFIX_TYPE_0 + prefix_type;
|
| 556 |
+
if (current_scores[intron_state] == UNREACHABLE_ACCUMULATED_SCORE) continue;
|
| 557 |
+
keep_best_transition(
|
| 558 |
+
next_scores, predecessors, intron_state,
|
| 559 |
+
current_scores[intron_state] + noncoding_score, intron_state,
|
| 560 |
+
position_transition_probabilities
|
| 561 |
+
);
|
| 562 |
+
if (has_ag_acceptor) {
|
| 563 |
+
emit_coding_base(
|
| 564 |
+
current_scores, next_scores, predecessors,
|
| 565 |
+
intron_state, prefix_type,
|
| 566 |
+
coding_score, position_transition_probabilities,
|
| 567 |
+
base, stop_taa, stop_tag, stop_tga
|
| 568 |
+
);
|
| 569 |
+
}
|
| 570 |
+
}
|
| 571 |
+
|
| 572 |
+
// AT-AC introns self-loop or exit after AC.
|
| 573 |
+
for (int prefix_type = 0; prefix_type < PREFIX_TYPE_COUNT; prefix_type++) {
|
| 574 |
+
int intron_state = AT_AC_INTRON_PREFIX_TYPE_0 + prefix_type;
|
| 575 |
+
if (current_scores[intron_state] == UNREACHABLE_ACCUMULATED_SCORE) continue;
|
| 576 |
+
keep_best_transition(
|
| 577 |
+
next_scores, predecessors, intron_state,
|
| 578 |
+
current_scores[intron_state] + noncoding_score, intron_state,
|
| 579 |
+
position_transition_probabilities
|
| 580 |
+
);
|
| 581 |
+
if (has_ac_acceptor) {
|
| 582 |
+
emit_coding_base(
|
| 583 |
+
current_scores, next_scores, predecessors,
|
| 584 |
+
intron_state, prefix_type,
|
| 585 |
+
coding_score, position_transition_probabilities,
|
| 586 |
+
base, stop_taa, stop_tag, stop_tga
|
| 587 |
+
);
|
| 588 |
+
}
|
| 589 |
+
}
|
| 590 |
+
|
| 591 |
+
// A trailing boundary intron cannot return to an exon.
|
| 592 |
+
if (current_scores[TRAILING_BOUNDARY_INTRON] != UNREACHABLE_ACCUMULATED_SCORE) {
|
| 593 |
+
keep_best_transition(
|
| 594 |
+
next_scores, predecessors, TRAILING_BOUNDARY_INTRON,
|
| 595 |
+
current_scores[TRAILING_BOUNDARY_INTRON] + noncoding_score,
|
| 596 |
+
TRAILING_BOUNDARY_INTRON,
|
| 597 |
+
position_transition_probabilities
|
| 598 |
+
);
|
| 599 |
+
}
|
| 600 |
+
|
| 601 |
+
AccumulatedScore* previous_scores = current_scores;
|
| 602 |
+
current_scores = next_scores;
|
| 603 |
+
next_scores = previous_scores;
|
| 604 |
+
}
|
| 605 |
+
|
| 606 |
+
if (output_scores) {
|
| 607 |
+
for (int state = 0; state < STATE_COUNT; state++) {
|
| 608 |
+
output_scores[state] = current_scores[state];
|
| 609 |
+
}
|
| 610 |
+
}
|
| 611 |
+
}
|
| 612 |
+
|
| 613 |
+
static int best_final_state(const AccumulatedScore* current_scores) {
|
| 614 |
+
int best_state = INTERGENIC;
|
| 615 |
+
for (int state = START_A; state <= STOP; state++) {
|
| 616 |
+
if (current_scores[state] > current_scores[best_state]) best_state = state;
|
| 617 |
+
}
|
| 618 |
+
if (current_scores[TRAILING_BOUNDARY_INTRON] > current_scores[best_state]) {
|
| 619 |
+
best_state = TRAILING_BOUNDARY_INTRON;
|
| 620 |
+
}
|
| 621 |
+
return best_state;
|
| 622 |
+
}
|
| 623 |
+
|
| 624 |
+
static void decode_one_track(
|
| 625 |
+
const uint16_t* probability_bits, const int8_t* bases,
|
| 626 |
+
const StepScore* transition_probabilities, int record_length,
|
| 627 |
+
StepScore gt_ag_intron_score, StepScore gc_ag_intron_score, StepScore at_ac_intron_score,
|
| 628 |
+
int max_boundary_intron_length,
|
| 629 |
+
bool stop_taa, bool stop_tag, bool stop_tga,
|
| 630 |
+
int64_t reconstruction_batch_size, int8_t* path,
|
| 631 |
+
std::vector<int8_t>& backpointer_buffer,
|
| 632 |
+
std::vector<AccumulatedScore>& checkpoints
|
| 633 |
+
) {
|
| 634 |
+
if (record_length <= 0) return;
|
| 635 |
+
|
| 636 |
+
if (static_cast<int64_t>(record_length) <= reconstruction_batch_size) {
|
| 637 |
+
backpointer_buffer.assign(static_cast<size_t>(record_length) * STATE_COUNT, 0);
|
| 638 |
+
AccumulatedScore final_scores[STATE_COUNT];
|
| 639 |
+
run_viterbi_forward(
|
| 640 |
+
probability_bits, bases, transition_probabilities, record_length, 0, record_length - 1, true,
|
| 641 |
+
gt_ag_intron_score, gc_ag_intron_score, at_ac_intron_score,
|
| 642 |
+
max_boundary_intron_length,
|
| 643 |
+
stop_taa, stop_tag, stop_tga,
|
| 644 |
+
backpointer_buffer.data(), final_scores
|
| 645 |
+
);
|
| 646 |
+
|
| 647 |
+
int state = best_final_state(final_scores);
|
| 648 |
+
for (int64_t position = record_length - 1; position >= 1; position--) {
|
| 649 |
+
path[position] = static_cast<int8_t>(state);
|
| 650 |
+
state = static_cast<int>(
|
| 651 |
+
backpointer_buffer[static_cast<size_t>(position - 1) * STATE_COUNT + state]
|
| 652 |
+
);
|
| 653 |
+
}
|
| 654 |
+
path[0] = static_cast<int8_t>(state);
|
| 655 |
+
return;
|
| 656 |
+
}
|
| 657 |
+
|
| 658 |
+
int64_t window_count = (
|
| 659 |
+
record_length - 1 + reconstruction_batch_size - 1
|
| 660 |
+
) / reconstruction_batch_size;
|
| 661 |
+
if (window_count < 1) window_count = 1;
|
| 662 |
+
checkpoints.assign(static_cast<size_t>(window_count + 1) * STATE_COUNT, 0);
|
| 663 |
+
|
| 664 |
+
// First pass keeps only the score vector at each window boundary.
|
| 665 |
+
AccumulatedScore current_scores[STATE_COUNT];
|
| 666 |
+
initialize_first_base_scores(
|
| 667 |
+
probability_bits, transition_probabilities, bases, record_length,
|
| 668 |
+
stop_taa, stop_tag, stop_tga,
|
| 669 |
+
max_boundary_intron_length != 0,
|
| 670 |
+
current_scores
|
| 671 |
+
);
|
| 672 |
+
for (int64_t window = 0; window < window_count; window++) {
|
| 673 |
+
for (int state = 0; state < STATE_COUNT; state++) {
|
| 674 |
+
checkpoints[static_cast<size_t>(window) * STATE_COUNT + state] =
|
| 675 |
+
current_scores[state];
|
| 676 |
+
}
|
| 677 |
+
int64_t segment_start = window * reconstruction_batch_size;
|
| 678 |
+
int64_t segment_end = segment_start + reconstruction_batch_size;
|
| 679 |
+
if (segment_end > record_length - 1) segment_end = record_length - 1;
|
| 680 |
+
run_viterbi_forward(
|
| 681 |
+
probability_bits, bases, transition_probabilities, record_length,
|
| 682 |
+
static_cast<int>(segment_start), static_cast<int>(segment_end), window == 0,
|
| 683 |
+
gt_ag_intron_score, gc_ag_intron_score, at_ac_intron_score,
|
| 684 |
+
max_boundary_intron_length,
|
| 685 |
+
stop_taa, stop_tag, stop_tga,
|
| 686 |
+
nullptr, current_scores, current_scores
|
| 687 |
+
);
|
| 688 |
+
}
|
| 689 |
+
for (int state = 0; state < STATE_COUNT; state++) {
|
| 690 |
+
checkpoints[static_cast<size_t>(window_count) * STATE_COUNT + state] =
|
| 691 |
+
current_scores[state];
|
| 692 |
+
}
|
| 693 |
+
|
| 694 |
+
int state = best_final_state(current_scores);
|
| 695 |
+
backpointer_buffer.assign(static_cast<size_t>(reconstruction_batch_size) * STATE_COUNT, 0);
|
| 696 |
+
// Recompute one window at a time and immediately backtrace it.
|
| 697 |
+
for (int64_t window = window_count - 1; window >= 0; window--) {
|
| 698 |
+
int64_t segment_start = window * reconstruction_batch_size;
|
| 699 |
+
int64_t segment_end = segment_start + reconstruction_batch_size;
|
| 700 |
+
if (segment_end > record_length - 1) segment_end = record_length - 1;
|
| 701 |
+
run_viterbi_forward(
|
| 702 |
+
probability_bits, bases, transition_probabilities, record_length,
|
| 703 |
+
static_cast<int>(segment_start), static_cast<int>(segment_end), window == 0,
|
| 704 |
+
gt_ag_intron_score, gc_ag_intron_score, at_ac_intron_score,
|
| 705 |
+
max_boundary_intron_length,
|
| 706 |
+
stop_taa, stop_tag, stop_tga,
|
| 707 |
+
backpointer_buffer.data(), nullptr,
|
| 708 |
+
&checkpoints[static_cast<size_t>(window) * STATE_COUNT]
|
| 709 |
+
);
|
| 710 |
+
for (int64_t position = segment_end; position >= segment_start + 1; position--) {
|
| 711 |
+
path[position] = static_cast<int8_t>(state);
|
| 712 |
+
state = static_cast<int>(backpointer_buffer[
|
| 713 |
+
static_cast<size_t>(position - segment_start - 1) * STATE_COUNT + state
|
| 714 |
+
]);
|
| 715 |
+
}
|
| 716 |
+
}
|
| 717 |
+
path[0] = static_cast<int8_t>(state);
|
| 718 |
+
}
|
| 719 |
+
|
| 720 |
+
extern "C" void decode_batch_on_cpu(
|
| 721 |
+
const uint16_t* probability_bits, const int8_t* bases,
|
| 722 |
+
const StepScore* transition_probabilities,
|
| 723 |
+
const int64_t* record_offsets, int record_count,
|
| 724 |
+
int stop_taa, int stop_tag, int stop_tga,
|
| 725 |
+
double gt_ag_intron_log_prior, double gc_ag_intron_log_prior,
|
| 726 |
+
double at_ac_intron_log_prior,
|
| 727 |
+
int max_boundary_intron_length,
|
| 728 |
+
int64_t requested_reconstruction_batch_size, int8_t* output_paths
|
| 729 |
+
) {
|
| 730 |
+
static const bool score_lookup_ready = build_log_score_lookup();
|
| 731 |
+
(void)score_lookup_ready;
|
| 732 |
+
|
| 733 |
+
const StepScore gt_ag_intron_score = score_from_nats(gt_ag_intron_log_prior);
|
| 734 |
+
const StepScore gc_ag_intron_score = score_from_nats(gc_ag_intron_log_prior);
|
| 735 |
+
const StepScore at_ac_intron_score = score_from_nats(at_ac_intron_log_prior);
|
| 736 |
+
int64_t reconstruction_batch_size = requested_reconstruction_batch_size == 0
|
| 737 |
+
? DEFAULT_RECONSTRUCTION_BATCH_SIZE
|
| 738 |
+
: requested_reconstruction_batch_size;
|
| 739 |
+
|
| 740 |
+
#pragma omp parallel
|
| 741 |
+
{
|
| 742 |
+
std::vector<int8_t> backpointer_buffer;
|
| 743 |
+
std::vector<AccumulatedScore> checkpoints;
|
| 744 |
+
#pragma omp for schedule(dynamic, 1)
|
| 745 |
+
for (int record = 0; record < record_count; record++) {
|
| 746 |
+
int64_t offset = record_offsets[record];
|
| 747 |
+
int record_length = static_cast<int>(record_offsets[record + 1] - offset);
|
| 748 |
+
decode_one_track(
|
| 749 |
+
probability_bits + offset, bases + offset,
|
| 750 |
+
transition_probabilities + static_cast<size_t>(offset) * TRANSITION_COUNT,
|
| 751 |
+
record_length,
|
| 752 |
+
gt_ag_intron_score, gc_ag_intron_score, at_ac_intron_score,
|
| 753 |
+
max_boundary_intron_length,
|
| 754 |
+
stop_taa != 0, stop_tag != 0, stop_tga != 0,
|
| 755 |
+
reconstruction_batch_size, output_paths + offset,
|
| 756 |
+
backpointer_buffer, checkpoints
|
| 757 |
+
);
|
| 758 |
+
}
|
| 759 |
+
}
|
| 760 |
+
}
|
| 761 |
+
|
| 762 |
+
#ifdef TORCH_EXTENSION_NAME
|
| 763 |
+
#include <torch/extension.h>
|
| 764 |
+
#include <pybind11/numpy.h>
|
| 765 |
+
namespace py = pybind11;
|
| 766 |
+
|
| 767 |
+
static py::array_t<int8_t> decode_packed_paths(
|
| 768 |
+
py::array_t<unsigned short> probability_array, py::array_t<int8_t> encoded_bases,
|
| 769 |
+
py::array_t<StepScore> transition_probabilities,
|
| 770 |
+
py::array_t<int64_t> record_offsets_array,
|
| 771 |
+
int stop_taa, int stop_tag, int stop_tga,
|
| 772 |
+
double gt_ag_intron_log_prior, double gc_ag_intron_log_prior,
|
| 773 |
+
double at_ac_intron_log_prior,
|
| 774 |
+
int max_boundary_intron_length,
|
| 775 |
+
int64_t reconstruction_batch_size
|
| 776 |
+
) {
|
| 777 |
+
if (transition_probabilities.size() != encoded_bases.size() * TRANSITION_COUNT) {
|
| 778 |
+
throw std::invalid_argument(
|
| 779 |
+
"transition probabilities must contain four values per encoded base"
|
| 780 |
+
);
|
| 781 |
+
}
|
| 782 |
+
int record_count = static_cast<int>(record_offsets_array.size()) - 1;
|
| 783 |
+
auto state_paths = py::array_t<int8_t>(encoded_bases.size());
|
| 784 |
+
const uint16_t* probability_bits = probability_array.data();
|
| 785 |
+
const int8_t* base_data = encoded_bases.data();
|
| 786 |
+
const StepScore* transition_probability_data = transition_probabilities.data();
|
| 787 |
+
const int64_t* record_offsets = record_offsets_array.data();
|
| 788 |
+
int8_t* output_paths = state_paths.mutable_data();
|
| 789 |
+
{
|
| 790 |
+
// Let the Python reader prepare the next input chunk during CPU work.
|
| 791 |
+
py::gil_scoped_release release;
|
| 792 |
+
decode_batch_on_cpu(
|
| 793 |
+
probability_bits, base_data, transition_probability_data, record_offsets, record_count,
|
| 794 |
+
stop_taa, stop_tag, stop_tga,
|
| 795 |
+
gt_ag_intron_log_prior, gc_ag_intron_log_prior, at_ac_intron_log_prior,
|
| 796 |
+
max_boundary_intron_length,
|
| 797 |
+
reconstruction_batch_size, output_paths
|
| 798 |
+
);
|
| 799 |
+
}
|
| 800 |
+
return state_paths;
|
| 801 |
+
}
|
| 802 |
+
|
| 803 |
+
PYBIND11_MODULE(TORCH_EXTENSION_NAME, m) {
|
| 804 |
+
m.def("decode", &decode_packed_paths, "Decode a batch with the 30-state CDS decoder");
|
| 805 |
+
}
|
| 806 |
+
#endif
|
annotation_pipeline/src/viterbi_backends/viterbi_gpu.cu
ADDED
|
@@ -0,0 +1,1607 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
// viterbi_gpu.cu -- GPU Viterbi backend: CUDA implementation of the algorithm in viterbi.py.
|
| 2 |
+
//
|
| 3 |
+
// The active decoder has 30 states. Backpointer columns use a 32-byte stride so
|
| 4 |
+
// one warp can map one lane to one state while lanes 30..31 remain padding.
|
| 5 |
+
// The kernels use the same transition grammar as viterbi.py:
|
| 6 |
+
// - exon states carry one of six stop-prefix types;
|
| 7 |
+
// - GT/GC-AG introns enter FIRST, then move to READY before AG exit;
|
| 8 |
+
// - AT-AC introns use one state table and exit at AC;
|
| 9 |
+
// - Python supplies explicit GT-AG, GC-AG, and AT-AC log priors;
|
| 10 |
+
// - N freely matches any concrete base in codons and splice signals;
|
| 11 |
+
// - dedicated leading/trailing states represent introns crossing a record edge;
|
| 12 |
+
// - stop read-through is disabled.
|
| 13 |
+
//
|
| 14 |
+
// Transition probabilities: the transition network supplies, per base, the
|
| 15 |
+
// probabilities of the four permitted transitions (enter gene, leave gene,
|
| 16 |
+
// enter intron, leave intron) as log-odds against staying in the decoder's
|
| 17 |
+
// fixed-point units. The grammar still decides which state changes are legal;
|
| 18 |
+
// the transition probabilities re-rank the legal paths.
|
| 19 |
+
//
|
| 20 |
+
// Decoder stages:
|
| 21 |
+
// 1. Convert probabilities to fixed-point coding/non-coding log scores.
|
| 22 |
+
// 2. Split records into bounded segments. For each possible entry state,
|
| 23 |
+
// build an int32 matrix containing the best score to every exit state.
|
| 24 |
+
// 3. Combine segment matrices in int64 and save the exact 30-state score
|
| 25 |
+
// vector at every segment entry while selecting each record's end state.
|
| 26 |
+
// 4. Propagate each segment's entry origins with those global scores, then
|
| 27 |
+
// trace the selected segment boundaries.
|
| 28 |
+
// 5. Restart each selected segment path at score 0, recompute int32
|
| 29 |
+
// backpointers, and write the final path in bounded memory windows.
|
| 30 |
+
#include <cstdint>
|
| 31 |
+
#include <cmath>
|
| 32 |
+
#include <initializer_list>
|
| 33 |
+
#include <stdexcept>
|
| 34 |
+
#include <string>
|
| 35 |
+
#include <vector>
|
| 36 |
+
#include <cuda_runtime.h>
|
| 37 |
+
#include <cuda_fp16.h>
|
| 38 |
+
|
| 39 |
+
using StateScore = int32_t;
|
| 40 |
+
using AccumulatedScore = int64_t;
|
| 41 |
+
static constexpr double SCORE_SCALE = 16384.0;
|
| 42 |
+
// A segment starts at zero and is short enough that every raw step score
|
| 43 |
+
// remains above this int32 sentinel. Only scores accumulated across segment
|
| 44 |
+
// boundaries need int64; reconstruction restarts the chosen segment path at 0.
|
| 45 |
+
static constexpr StateScore UNREACHABLE_STATE_SCORE = -2000000000;
|
| 46 |
+
static constexpr AccumulatedScore UNREACHABLE_ACCUMULATED_SCORE = -4000000000000000000LL;
|
| 47 |
+
static constexpr int64_t RAW_SEGMENT_SCORE_LIMIT = 1800000000LL;
|
| 48 |
+
|
| 49 |
+
__device__ __host__ __forceinline__ StateScore score_from_nats(double nats) {
|
| 50 |
+
return static_cast<StateScore>(llround(nats * SCORE_SCALE));
|
| 51 |
+
}
|
| 52 |
+
|
| 53 |
+
// Set during decoder initialization and used on the host to cap segment length
|
| 54 |
+
// before any int32 segment kernel is launched.
|
| 55 |
+
static int64_t maximum_absolute_step_score = 1;
|
| 56 |
+
|
| 57 |
+
static const int STATE_COUNT = 30;
|
| 58 |
+
static const int BACKPOINTER_STRIDE = 32;
|
| 59 |
+
static const int PREFIX_TYPE_COUNT = 6;
|
| 60 |
+
static const int INTERGENIC = 0;
|
| 61 |
+
static const int START_A = 1;
|
| 62 |
+
static const int START_AT = 2;
|
| 63 |
+
static const int EXON_PREFIX_TYPE_0 = 3;
|
| 64 |
+
static const int STOP = 9;
|
| 65 |
+
static const int GT_GC_AG_INTRON_FIRST_PREFIX_TYPE_0 = 10;
|
| 66 |
+
static const int GT_GC_AG_INTRON_READY_PREFIX_TYPE_0 = 16;
|
| 67 |
+
static const int AT_AC_INTRON_PREFIX_TYPE_0 = 22;
|
| 68 |
+
static const int LEADING_BOUNDARY_INTRON = 28;
|
| 69 |
+
static const int TRAILING_BOUNDARY_INTRON = 29;
|
| 70 |
+
|
| 71 |
+
// Transition probability tracks per base, in the column order of constants.py.
|
| 72 |
+
static const int ENTER_GENE = 0;
|
| 73 |
+
static const int LEAVE_GENE = 1;
|
| 74 |
+
static const int ENTER_INTRON = 2;
|
| 75 |
+
static const int LEAVE_INTRON = 3;
|
| 76 |
+
static const int TRANSITION_COUNT = 4;
|
| 77 |
+
|
| 78 |
+
__device__ __host__ __forceinline__ size_t segment_transition_index(
|
| 79 |
+
int segment, int exit_state, int entry_state
|
| 80 |
+
) {
|
| 81 |
+
return (static_cast<size_t>(segment) * STATE_COUNT + exit_state) * STATE_COUNT
|
| 82 |
+
+ entry_state;
|
| 83 |
+
}
|
| 84 |
+
|
| 85 |
+
__device__ __host__ __forceinline__ size_t segment_state_index(int segment, int state) {
|
| 86 |
+
return static_cast<size_t>(segment) * STATE_COUNT + state;
|
| 87 |
+
}
|
| 88 |
+
|
| 89 |
+
// State layout:
|
| 90 |
+
// 0 intergenic
|
| 91 |
+
// 1..2 first one/two bases of an ATG start codon
|
| 92 |
+
// 3..8 exon states carrying prefix type 0..5
|
| 93 |
+
// 9 completed in-frame stop codon
|
| 94 |
+
// 10..15 first GT/GC-AG intron base, carrying prefix type 0..5
|
| 95 |
+
// 16..21 GT/GC-AG intron ready to accept AG, carrying prefix type 0..5
|
| 96 |
+
// 22..27 AT-AC intron, carrying prefix type 0..5
|
| 97 |
+
// 28 intron extending from the left record boundary
|
| 98 |
+
// 29 intron extending to the right record boundary
|
| 99 |
+
|
| 100 |
+
static const int BASE_A = 0;
|
| 101 |
+
static const int BASE_T = 1;
|
| 102 |
+
static const int BASE_C = 2;
|
| 103 |
+
static const int BASE_G = 3;
|
| 104 |
+
static const int BASE_N = 4;
|
| 105 |
+
|
| 106 |
+
__device__ __forceinline__ bool base_matches(
|
| 107 |
+
int observed_base, int expected_base
|
| 108 |
+
) {
|
| 109 |
+
return observed_base == expected_base || observed_base == BASE_N;
|
| 110 |
+
}
|
| 111 |
+
|
| 112 |
+
__constant__ StateScore d_gt_ag_intron_score;
|
| 113 |
+
__constant__ StateScore d_gc_ag_intron_score;
|
| 114 |
+
__constant__ StateScore d_at_ac_intron_score;
|
| 115 |
+
__constant__ int d_stop_taa_enabled;
|
| 116 |
+
__constant__ int d_stop_tag_enabled;
|
| 117 |
+
__constant__ int d_stop_tga_enabled;
|
| 118 |
+
__constant__ int d_max_boundary_intron_length;
|
| 119 |
+
__device__ __forceinline__ bool is_exact_enabled_stop_codon(
|
| 120 |
+
int first_base, int second_base, int third_base
|
| 121 |
+
) {
|
| 122 |
+
if (first_base != BASE_T) return false;
|
| 123 |
+
if (second_base == BASE_A) {
|
| 124 |
+
if (third_base == BASE_A && d_stop_taa_enabled) return true;
|
| 125 |
+
if (third_base == BASE_G && d_stop_tag_enabled) return true;
|
| 126 |
+
}
|
| 127 |
+
return second_base == BASE_G
|
| 128 |
+
&& third_base == BASE_A
|
| 129 |
+
&& d_stop_tga_enabled;
|
| 130 |
+
}
|
| 131 |
+
|
| 132 |
+
__device__ __forceinline__ int exon_state_for_prefix(int prefix_type) {
|
| 133 |
+
return EXON_PREFIX_TYPE_0 + prefix_type;
|
| 134 |
+
}
|
| 135 |
+
|
| 136 |
+
struct PositionSignals {
|
| 137 |
+
int base;
|
| 138 |
+
StateScore coding_score;
|
| 139 |
+
StateScore noncoding_score;
|
| 140 |
+
bool has_gt_ag_donor;
|
| 141 |
+
bool has_gc_ag_donor;
|
| 142 |
+
bool has_at_ac_donor;
|
| 143 |
+
bool has_ag_acceptor;
|
| 144 |
+
bool has_ac_acceptor;
|
| 145 |
+
bool has_atg_start;
|
| 146 |
+
bool has_exact_acceptor_followed_by_atg;
|
| 147 |
+
bool has_exact_enabled_stop_followed_by_donor;
|
| 148 |
+
bool can_extend_leading_boundary_intron;
|
| 149 |
+
bool can_enter_trailing_boundary_intron;
|
| 150 |
+
bool completes_stop_from_prefix_type_3;
|
| 151 |
+
bool completes_stop_from_prefix_type_4;
|
| 152 |
+
StateScore enter_gene_log_odds;
|
| 153 |
+
StateScore leave_gene_log_odds;
|
| 154 |
+
StateScore enter_intron_log_odds;
|
| 155 |
+
StateScore leave_intron_log_odds;
|
| 156 |
+
};
|
| 157 |
+
|
| 158 |
+
__device__ __forceinline__ bool is_exon_state(int state) {
|
| 159 |
+
return state >= EXON_PREFIX_TYPE_0
|
| 160 |
+
&& state < EXON_PREFIX_TYPE_0 + PREFIX_TYPE_COUNT;
|
| 161 |
+
}
|
| 162 |
+
|
| 163 |
+
__device__ __forceinline__ bool is_gt_gc_intron_entry_state(int state) {
|
| 164 |
+
return state >= GT_GC_AG_INTRON_FIRST_PREFIX_TYPE_0
|
| 165 |
+
&& state < GT_GC_AG_INTRON_READY_PREFIX_TYPE_0;
|
| 166 |
+
}
|
| 167 |
+
|
| 168 |
+
__device__ __forceinline__ bool is_gt_gc_acceptor_state(int state) {
|
| 169 |
+
return state >= GT_GC_AG_INTRON_READY_PREFIX_TYPE_0
|
| 170 |
+
&& state < AT_AC_INTRON_PREFIX_TYPE_0;
|
| 171 |
+
}
|
| 172 |
+
|
| 173 |
+
__device__ __forceinline__ bool is_at_ac_intron_state(int state) {
|
| 174 |
+
return state >= AT_AC_INTRON_PREFIX_TYPE_0
|
| 175 |
+
&& state < LEADING_BOUNDARY_INTRON;
|
| 176 |
+
}
|
| 177 |
+
|
| 178 |
+
// Transition log-odds of one legal state change. Each value is attached to the
|
| 179 |
+
// base where the new segment begins: the first start-codon base (enter gene),
|
| 180 |
+
// the last stop-codon base (leave gene, charged on the base that closes the
|
| 181 |
+
// gene), the first intron base (enter intron), or the first exon base after an
|
| 182 |
+
// intron (leave intron). Introns that cross a record edge are entered and left
|
| 183 |
+
// the same way.
|
| 184 |
+
__device__ __forceinline__ StateScore transition_log_odds(
|
| 185 |
+
int source_state, int target_state, const PositionSignals& signals
|
| 186 |
+
) {
|
| 187 |
+
if (source_state == INTERGENIC && target_state == START_A) {
|
| 188 |
+
return signals.enter_gene_log_odds;
|
| 189 |
+
}
|
| 190 |
+
// Leaving the gene takes precedence when an intron exit completes a stop
|
| 191 |
+
// codon that was split by the intron.
|
| 192 |
+
if (target_state == STOP) return signals.leave_gene_log_odds;
|
| 193 |
+
if (
|
| 194 |
+
is_exon_state(source_state)
|
| 195 |
+
&& (is_gt_gc_intron_entry_state(target_state)
|
| 196 |
+
|| is_at_ac_intron_state(target_state)
|
| 197 |
+
|| target_state == TRAILING_BOUNDARY_INTRON)
|
| 198 |
+
) {
|
| 199 |
+
return signals.enter_intron_log_odds;
|
| 200 |
+
}
|
| 201 |
+
if (
|
| 202 |
+
(is_gt_gc_acceptor_state(source_state)
|
| 203 |
+
|| is_at_ac_intron_state(source_state)
|
| 204 |
+
|| source_state == LEADING_BOUNDARY_INTRON)
|
| 205 |
+
&& is_exon_state(target_state)
|
| 206 |
+
) {
|
| 207 |
+
return signals.leave_intron_log_odds;
|
| 208 |
+
}
|
| 209 |
+
return StateScore(0);
|
| 210 |
+
}
|
| 211 |
+
|
| 212 |
+
__device__ __forceinline__ StateScore best_gt_gc_ag_intron_score(
|
| 213 |
+
const PositionSignals& signals
|
| 214 |
+
) {
|
| 215 |
+
StateScore best_score = UNREACHABLE_STATE_SCORE;
|
| 216 |
+
if (signals.has_gt_ag_donor) best_score = d_gt_ag_intron_score;
|
| 217 |
+
if (signals.has_gc_ag_donor && d_gc_ag_intron_score > best_score) {
|
| 218 |
+
best_score = d_gc_ag_intron_score;
|
| 219 |
+
}
|
| 220 |
+
return best_score;
|
| 221 |
+
}
|
| 222 |
+
|
| 223 |
+
__device__ __forceinline__ StateScore best_intron_score_for_donor(
|
| 224 |
+
const PositionSignals& signals
|
| 225 |
+
) {
|
| 226 |
+
StateScore best_score = best_gt_gc_ag_intron_score(signals);
|
| 227 |
+
if (signals.has_at_ac_donor && d_at_ac_intron_score > best_score) {
|
| 228 |
+
best_score = d_at_ac_intron_score;
|
| 229 |
+
}
|
| 230 |
+
return best_score;
|
| 231 |
+
}
|
| 232 |
+
|
| 233 |
+
template <typename Score>
|
| 234 |
+
struct TransitionChoice {
|
| 235 |
+
Score score;
|
| 236 |
+
int source_state;
|
| 237 |
+
};
|
| 238 |
+
|
| 239 |
+
__device__ __forceinline__ bool is_unreachable(StateScore score) {
|
| 240 |
+
return score == UNREACHABLE_STATE_SCORE;
|
| 241 |
+
}
|
| 242 |
+
|
| 243 |
+
__device__ __forceinline__ bool is_unreachable(AccumulatedScore score) {
|
| 244 |
+
return score == UNREACHABLE_ACCUMULATED_SCORE;
|
| 245 |
+
}
|
| 246 |
+
|
| 247 |
+
__device__ __forceinline__ StateScore unreachable_score(StateScore) {
|
| 248 |
+
return UNREACHABLE_STATE_SCORE;
|
| 249 |
+
}
|
| 250 |
+
|
| 251 |
+
__device__ __forceinline__ AccumulatedScore unreachable_score(AccumulatedScore) {
|
| 252 |
+
return UNREACHABLE_ACCUMULATED_SCORE;
|
| 253 |
+
}
|
| 254 |
+
|
| 255 |
+
__device__ __forceinline__ int next_coding_state_after_base(
|
| 256 |
+
int prefix_type, int base,
|
| 257 |
+
bool completes_stop_from_prefix_type_3,
|
| 258 |
+
bool completes_stop_from_prefix_type_4
|
| 259 |
+
) {
|
| 260 |
+
if (prefix_type == 0) return exon_state_for_prefix(base == BASE_T ? 1 : 2);
|
| 261 |
+
if (prefix_type == 1) {
|
| 262 |
+
if (base == BASE_A) return exon_state_for_prefix(3);
|
| 263 |
+
if (base == BASE_G) return exon_state_for_prefix(4);
|
| 264 |
+
return exon_state_for_prefix(5);
|
| 265 |
+
}
|
| 266 |
+
if (prefix_type == 2) return exon_state_for_prefix(5);
|
| 267 |
+
if (prefix_type == 3) {
|
| 268 |
+
if (completes_stop_from_prefix_type_3) return STOP;
|
| 269 |
+
return exon_state_for_prefix(0);
|
| 270 |
+
}
|
| 271 |
+
if (prefix_type == 4) {
|
| 272 |
+
if (completes_stop_from_prefix_type_4) return STOP;
|
| 273 |
+
return exon_state_for_prefix(0);
|
| 274 |
+
}
|
| 275 |
+
return exon_state_for_prefix(0);
|
| 276 |
+
}
|
| 277 |
+
|
| 278 |
+
__device__ __forceinline__ int next_coding_state_after_base(
|
| 279 |
+
int prefix_type, const PositionSignals& signals
|
| 280 |
+
) {
|
| 281 |
+
return next_coding_state_after_base(
|
| 282 |
+
prefix_type, signals.base,
|
| 283 |
+
signals.completes_stop_from_prefix_type_3, signals.completes_stop_from_prefix_type_4
|
| 284 |
+
);
|
| 285 |
+
}
|
| 286 |
+
|
| 287 |
+
__device__ __forceinline__ int next_coding_state_after_resolving_n(
|
| 288 |
+
int prefix_type, int resolved_base
|
| 289 |
+
) {
|
| 290 |
+
bool completes_stop_from_prefix_type_3 =
|
| 291 |
+
(resolved_base == BASE_A && d_stop_taa_enabled)
|
| 292 |
+
|| (resolved_base == BASE_G && d_stop_tag_enabled);
|
| 293 |
+
bool completes_stop_from_prefix_type_4 =
|
| 294 |
+
resolved_base == BASE_A && d_stop_tga_enabled;
|
| 295 |
+
return next_coding_state_after_base(
|
| 296 |
+
prefix_type, resolved_base,
|
| 297 |
+
completes_stop_from_prefix_type_3, completes_stop_from_prefix_type_4
|
| 298 |
+
);
|
| 299 |
+
}
|
| 300 |
+
|
| 301 |
+
__device__ __forceinline__ bool coding_base_can_reach_state(
|
| 302 |
+
int prefix_type, int target_state, const PositionSignals& signals
|
| 303 |
+
) {
|
| 304 |
+
if (signals.base != BASE_N) {
|
| 305 |
+
return target_state == next_coding_state_after_base(prefix_type, signals);
|
| 306 |
+
}
|
| 307 |
+
for (int resolved_base = BASE_A; resolved_base <= BASE_G; resolved_base++) {
|
| 308 |
+
if (target_state == next_coding_state_after_resolving_n(
|
| 309 |
+
prefix_type, resolved_base
|
| 310 |
+
)) {
|
| 311 |
+
return true;
|
| 312 |
+
}
|
| 313 |
+
}
|
| 314 |
+
return false;
|
| 315 |
+
}
|
| 316 |
+
|
| 317 |
+
__device__ __forceinline__ PositionSignals compute_position_signals(
|
| 318 |
+
const StateScore* coding_scores, const StateScore* noncoding_scores,
|
| 319 |
+
const StateScore* transition_probabilities, const int8_t* bases,
|
| 320 |
+
int record_length, int position
|
| 321 |
+
) {
|
| 322 |
+
// position is always at least 1. At a record edge, unavailable motif bases
|
| 323 |
+
// are unknown rather than evidence that a start, donor, or acceptor is
|
| 324 |
+
// impossible. Internal positions still require the complete motif.
|
| 325 |
+
int base = bases[position];
|
| 326 |
+
bool next_base_is_missing = position + 1 == record_length;
|
| 327 |
+
int next_base = next_base_is_missing ? -1 : bases[position + 1];
|
| 328 |
+
bool has_gt_ag_donor = base_matches(base, BASE_G)
|
| 329 |
+
&& (next_base_is_missing || base_matches(next_base, BASE_T));
|
| 330 |
+
bool has_gc_ag_donor = base_matches(base, BASE_G)
|
| 331 |
+
&& (next_base_is_missing || base_matches(next_base, BASE_C));
|
| 332 |
+
bool has_at_ac_donor = base_matches(base, BASE_A)
|
| 333 |
+
&& (next_base_is_missing || base_matches(next_base, BASE_T));
|
| 334 |
+
bool acceptor_first_base_matches = position == 1
|
| 335 |
+
|| base_matches(bases[position - 2], BASE_A);
|
| 336 |
+
bool has_ag_acceptor = acceptor_first_base_matches
|
| 337 |
+
&& base_matches(bases[position - 1], BASE_G);
|
| 338 |
+
bool has_ac_acceptor = acceptor_first_base_matches
|
| 339 |
+
&& base_matches(bases[position - 1], BASE_C);
|
| 340 |
+
bool third_start_base_matches = position + 2 >= record_length
|
| 341 |
+
|| base_matches(bases[position + 2], BASE_G);
|
| 342 |
+
bool has_atg_start = base_matches(base, BASE_A)
|
| 343 |
+
&& (next_base_is_missing || base_matches(next_base, BASE_T))
|
| 344 |
+
&& third_start_base_matches;
|
| 345 |
+
// Boundary guards require exact five-base patterns. N and missing edge
|
| 346 |
+
// context remain valid for ordinary motif matching only.
|
| 347 |
+
bool has_exact_acceptor_followed_by_atg = position >= 2
|
| 348 |
+
&& position + 2 < record_length
|
| 349 |
+
&& bases[position - 2] == BASE_A
|
| 350 |
+
&& (bases[position - 1] == BASE_G || bases[position - 1] == BASE_C)
|
| 351 |
+
&& base == BASE_A
|
| 352 |
+
&& next_base == BASE_T
|
| 353 |
+
&& bases[position + 2] == BASE_G;
|
| 354 |
+
bool has_exact_donor = position + 1 < record_length
|
| 355 |
+
&& ((base == BASE_G && (next_base == BASE_T || next_base == BASE_C))
|
| 356 |
+
|| (base == BASE_A && next_base == BASE_T));
|
| 357 |
+
bool has_exact_enabled_stop_followed_by_donor = false;
|
| 358 |
+
if (has_exact_donor && position >= 3) {
|
| 359 |
+
has_exact_enabled_stop_followed_by_donor = is_exact_enabled_stop_codon(
|
| 360 |
+
bases[position - 3], bases[position - 2], bases[position - 1]
|
| 361 |
+
);
|
| 362 |
+
}
|
| 363 |
+
bool completes_stop_from_prefix_type_3 =
|
| 364 |
+
(base_matches(base, BASE_A) && d_stop_taa_enabled)
|
| 365 |
+
|| (base_matches(base, BASE_G) && d_stop_tag_enabled);
|
| 366 |
+
bool completes_stop_from_prefix_type_4 =
|
| 367 |
+
base_matches(base, BASE_A) && d_stop_tga_enabled;
|
| 368 |
+
// A leading self-loop into position makes its run position + 1 bases.
|
| 369 |
+
// A trailing entry here emits record_length - position bases through the
|
| 370 |
+
// right edge, so neither boundary needs a counter state.
|
| 371 |
+
bool boundary_introns_enabled = d_max_boundary_intron_length != 0;
|
| 372 |
+
bool boundary_intron_length_is_unlimited = d_max_boundary_intron_length == -1;
|
| 373 |
+
bool can_extend_leading_boundary_intron =
|
| 374 |
+
boundary_introns_enabled
|
| 375 |
+
&& (boundary_intron_length_is_unlimited
|
| 376 |
+
|| position < d_max_boundary_intron_length);
|
| 377 |
+
bool can_enter_trailing_boundary_intron =
|
| 378 |
+
boundary_introns_enabled
|
| 379 |
+
&& (boundary_intron_length_is_unlimited
|
| 380 |
+
|| record_length - position <= d_max_boundary_intron_length);
|
| 381 |
+
const StateScore* position_scores =
|
| 382 |
+
transition_probabilities + static_cast<size_t>(position) * TRANSITION_COUNT;
|
| 383 |
+
return {
|
| 384 |
+
base, coding_scores[position], noncoding_scores[position],
|
| 385 |
+
has_gt_ag_donor, has_gc_ag_donor, has_at_ac_donor,
|
| 386 |
+
has_ag_acceptor, has_ac_acceptor, has_atg_start,
|
| 387 |
+
has_exact_acceptor_followed_by_atg,
|
| 388 |
+
has_exact_enabled_stop_followed_by_donor,
|
| 389 |
+
can_extend_leading_boundary_intron,
|
| 390 |
+
can_enter_trailing_boundary_intron,
|
| 391 |
+
completes_stop_from_prefix_type_3, completes_stop_from_prefix_type_4,
|
| 392 |
+
position_scores[ENTER_GENE],
|
| 393 |
+
position_scores[LEAVE_GENE],
|
| 394 |
+
position_scores[ENTER_INTRON],
|
| 395 |
+
position_scores[LEAVE_INTRON]
|
| 396 |
+
};
|
| 397 |
+
}
|
| 398 |
+
|
| 399 |
+
__device__ __forceinline__ void keep_best_transition(
|
| 400 |
+
StateScore* next_scores, int8_t* predecessors,
|
| 401 |
+
int target_state, StateScore candidate_score, int source_state,
|
| 402 |
+
const PositionSignals& signals
|
| 403 |
+
) {
|
| 404 |
+
candidate_score += transition_log_odds(
|
| 405 |
+
source_state, target_state, signals
|
| 406 |
+
);
|
| 407 |
+
if (candidate_score > next_scores[target_state]) {
|
| 408 |
+
next_scores[target_state] = candidate_score;
|
| 409 |
+
if (predecessors) predecessors[target_state] = static_cast<int8_t>(source_state);
|
| 410 |
+
}
|
| 411 |
+
}
|
| 412 |
+
|
| 413 |
+
__device__ __forceinline__ void emit_coding_base(
|
| 414 |
+
const StateScore* current_scores, StateScore* next_scores, int8_t* predecessors,
|
| 415 |
+
int source_state, int prefix_type, StateScore coding_score,
|
| 416 |
+
const PositionSignals& signals
|
| 417 |
+
) {
|
| 418 |
+
StateScore source_score = current_scores[source_state];
|
| 419 |
+
if (source_score == UNREACHABLE_STATE_SCORE) return;
|
| 420 |
+
StateScore candidate_score = source_score + coding_score;
|
| 421 |
+
|
| 422 |
+
if (signals.base == BASE_N) {
|
| 423 |
+
for (int resolved_base = BASE_A; resolved_base <= BASE_G; resolved_base++) {
|
| 424 |
+
int target_state = next_coding_state_after_resolving_n(
|
| 425 |
+
prefix_type, resolved_base
|
| 426 |
+
);
|
| 427 |
+
keep_best_transition(
|
| 428 |
+
next_scores, predecessors, target_state, candidate_score,
|
| 429 |
+
source_state, signals
|
| 430 |
+
);
|
| 431 |
+
}
|
| 432 |
+
} else {
|
| 433 |
+
int target_state = next_coding_state_after_base(prefix_type, signals);
|
| 434 |
+
keep_best_transition(
|
| 435 |
+
next_scores, predecessors, target_state, candidate_score,
|
| 436 |
+
source_state, signals
|
| 437 |
+
);
|
| 438 |
+
}
|
| 439 |
+
}
|
| 440 |
+
|
| 441 |
+
template <typename Score>
|
| 442 |
+
__device__ __forceinline__ void consider_predecessor(
|
| 443 |
+
TransitionChoice<Score>& best, const Score* current_scores,
|
| 444 |
+
int source_state, int target_state, StateScore added_score,
|
| 445 |
+
const PositionSignals& signals
|
| 446 |
+
) {
|
| 447 |
+
Score source_score = current_scores[source_state];
|
| 448 |
+
if (is_unreachable(source_score)) return;
|
| 449 |
+
|
| 450 |
+
Score candidate_score = source_score + static_cast<Score>(
|
| 451 |
+
added_score + transition_log_odds(
|
| 452 |
+
source_state, target_state, signals
|
| 453 |
+
)
|
| 454 |
+
);
|
| 455 |
+
if (candidate_score > best.score) {
|
| 456 |
+
best.score = candidate_score;
|
| 457 |
+
best.source_state = source_state;
|
| 458 |
+
}
|
| 459 |
+
}
|
| 460 |
+
|
| 461 |
+
// A warp assigns one target state to each lane. Gathering only that target's
|
| 462 |
+
// legal predecessors prevents every lane from repeating all source updates.
|
| 463 |
+
template <typename Score>
|
| 464 |
+
__device__ __forceinline__ TransitionChoice<Score> best_transition_to_state(
|
| 465 |
+
int target_state, const Score* current_scores,
|
| 466 |
+
const PositionSignals& signals
|
| 467 |
+
) {
|
| 468 |
+
TransitionChoice<Score> best = {unreachable_score(Score{}), 0};
|
| 469 |
+
|
| 470 |
+
if (target_state == INTERGENIC) {
|
| 471 |
+
consider_predecessor(
|
| 472 |
+
best, current_scores, INTERGENIC, target_state,
|
| 473 |
+
signals.noncoding_score, signals
|
| 474 |
+
);
|
| 475 |
+
consider_predecessor(
|
| 476 |
+
best, current_scores, STOP, target_state,
|
| 477 |
+
signals.noncoding_score, signals
|
| 478 |
+
);
|
| 479 |
+
return best;
|
| 480 |
+
}
|
| 481 |
+
if (target_state == START_A) {
|
| 482 |
+
if (signals.has_atg_start) {
|
| 483 |
+
consider_predecessor(
|
| 484 |
+
best, current_scores, INTERGENIC, target_state,
|
| 485 |
+
signals.coding_score, signals
|
| 486 |
+
);
|
| 487 |
+
}
|
| 488 |
+
return best;
|
| 489 |
+
}
|
| 490 |
+
if (target_state == START_AT) {
|
| 491 |
+
consider_predecessor(
|
| 492 |
+
best, current_scores, START_A, target_state,
|
| 493 |
+
signals.coding_score, signals
|
| 494 |
+
);
|
| 495 |
+
return best;
|
| 496 |
+
}
|
| 497 |
+
|
| 498 |
+
bool is_exon_or_stop = target_state == STOP || (
|
| 499 |
+
target_state >= EXON_PREFIX_TYPE_0
|
| 500 |
+
&& target_state < EXON_PREFIX_TYPE_0 + PREFIX_TYPE_COUNT
|
| 501 |
+
);
|
| 502 |
+
if (is_exon_or_stop) {
|
| 503 |
+
if (target_state == exon_state_for_prefix(0)) {
|
| 504 |
+
consider_predecessor(
|
| 505 |
+
best, current_scores, START_AT, target_state,
|
| 506 |
+
signals.coding_score, signals
|
| 507 |
+
);
|
| 508 |
+
}
|
| 509 |
+
// Every prefix type is allowed when the leading boundary intron exits,
|
| 510 |
+
// so the boundary state does not need to carry one.
|
| 511 |
+
if ((signals.has_ag_acceptor || signals.has_ac_acceptor)
|
| 512 |
+
&& !signals.has_exact_acceptor_followed_by_atg) {
|
| 513 |
+
#pragma unroll
|
| 514 |
+
for (int prefix_type = 0; prefix_type < PREFIX_TYPE_COUNT; prefix_type++) {
|
| 515 |
+
if (coding_base_can_reach_state(prefix_type, target_state, signals)) {
|
| 516 |
+
consider_predecessor(
|
| 517 |
+
best, current_scores, LEADING_BOUNDARY_INTRON,
|
| 518 |
+
target_state, signals.coding_score,
|
| 519 |
+
signals
|
| 520 |
+
);
|
| 521 |
+
}
|
| 522 |
+
}
|
| 523 |
+
}
|
| 524 |
+
#pragma unroll
|
| 525 |
+
for (int prefix_type = 0; prefix_type < PREFIX_TYPE_COUNT; prefix_type++) {
|
| 526 |
+
if (coding_base_can_reach_state(prefix_type, target_state, signals)) {
|
| 527 |
+
consider_predecessor(
|
| 528 |
+
best, current_scores, exon_state_for_prefix(prefix_type),
|
| 529 |
+
target_state, signals.coding_score, signals
|
| 530 |
+
);
|
| 531 |
+
}
|
| 532 |
+
}
|
| 533 |
+
if (signals.has_ag_acceptor) {
|
| 534 |
+
#pragma unroll
|
| 535 |
+
for (int prefix_type = 0; prefix_type < PREFIX_TYPE_COUNT; prefix_type++) {
|
| 536 |
+
if (coding_base_can_reach_state(prefix_type, target_state, signals)) {
|
| 537 |
+
consider_predecessor(
|
| 538 |
+
best, current_scores,
|
| 539 |
+
GT_GC_AG_INTRON_READY_PREFIX_TYPE_0 + prefix_type,
|
| 540 |
+
target_state, signals.coding_score,
|
| 541 |
+
signals
|
| 542 |
+
);
|
| 543 |
+
}
|
| 544 |
+
}
|
| 545 |
+
}
|
| 546 |
+
if (signals.has_ac_acceptor) {
|
| 547 |
+
#pragma unroll
|
| 548 |
+
for (int prefix_type = 0; prefix_type < PREFIX_TYPE_COUNT; prefix_type++) {
|
| 549 |
+
if (coding_base_can_reach_state(prefix_type, target_state, signals)) {
|
| 550 |
+
consider_predecessor(
|
| 551 |
+
best, current_scores, AT_AC_INTRON_PREFIX_TYPE_0 + prefix_type,
|
| 552 |
+
target_state, signals.coding_score,
|
| 553 |
+
signals
|
| 554 |
+
);
|
| 555 |
+
}
|
| 556 |
+
}
|
| 557 |
+
}
|
| 558 |
+
return best;
|
| 559 |
+
}
|
| 560 |
+
|
| 561 |
+
if (target_state >= GT_GC_AG_INTRON_FIRST_PREFIX_TYPE_0
|
| 562 |
+
&& target_state < GT_GC_AG_INTRON_READY_PREFIX_TYPE_0) {
|
| 563 |
+
if (signals.has_gt_ag_donor || signals.has_gc_ag_donor) {
|
| 564 |
+
int prefix_type = target_state - GT_GC_AG_INTRON_FIRST_PREFIX_TYPE_0;
|
| 565 |
+
StateScore intron_score = best_gt_gc_ag_intron_score(signals);
|
| 566 |
+
consider_predecessor(
|
| 567 |
+
best, current_scores, exon_state_for_prefix(prefix_type),
|
| 568 |
+
target_state,
|
| 569 |
+
signals.noncoding_score + intron_score,
|
| 570 |
+
signals
|
| 571 |
+
);
|
| 572 |
+
}
|
| 573 |
+
return best;
|
| 574 |
+
}
|
| 575 |
+
|
| 576 |
+
if (target_state >= GT_GC_AG_INTRON_READY_PREFIX_TYPE_0
|
| 577 |
+
&& target_state < AT_AC_INTRON_PREFIX_TYPE_0) {
|
| 578 |
+
int prefix_type = target_state - GT_GC_AG_INTRON_READY_PREFIX_TYPE_0;
|
| 579 |
+
consider_predecessor(
|
| 580 |
+
best, current_scores, GT_GC_AG_INTRON_FIRST_PREFIX_TYPE_0 + prefix_type,
|
| 581 |
+
target_state, signals.noncoding_score, signals
|
| 582 |
+
);
|
| 583 |
+
consider_predecessor(
|
| 584 |
+
best, current_scores, target_state, target_state,
|
| 585 |
+
signals.noncoding_score, signals
|
| 586 |
+
);
|
| 587 |
+
return best;
|
| 588 |
+
}
|
| 589 |
+
|
| 590 |
+
if (target_state >= AT_AC_INTRON_PREFIX_TYPE_0
|
| 591 |
+
&& target_state < LEADING_BOUNDARY_INTRON) {
|
| 592 |
+
int prefix_type = target_state - AT_AC_INTRON_PREFIX_TYPE_0;
|
| 593 |
+
if (signals.has_at_ac_donor) {
|
| 594 |
+
consider_predecessor(
|
| 595 |
+
best, current_scores, exon_state_for_prefix(prefix_type),
|
| 596 |
+
target_state,
|
| 597 |
+
signals.noncoding_score + d_at_ac_intron_score,
|
| 598 |
+
signals
|
| 599 |
+
);
|
| 600 |
+
}
|
| 601 |
+
consider_predecessor(
|
| 602 |
+
best, current_scores, target_state, target_state,
|
| 603 |
+
signals.noncoding_score, signals
|
| 604 |
+
);
|
| 605 |
+
return best;
|
| 606 |
+
}
|
| 607 |
+
|
| 608 |
+
if (target_state == LEADING_BOUNDARY_INTRON) {
|
| 609 |
+
if (signals.can_extend_leading_boundary_intron) {
|
| 610 |
+
consider_predecessor(
|
| 611 |
+
best, current_scores, LEADING_BOUNDARY_INTRON,
|
| 612 |
+
target_state, signals.noncoding_score, signals
|
| 613 |
+
);
|
| 614 |
+
}
|
| 615 |
+
return best;
|
| 616 |
+
}
|
| 617 |
+
|
| 618 |
+
if (target_state == TRAILING_BOUNDARY_INTRON) {
|
| 619 |
+
bool has_any_donor = signals.has_gt_ag_donor
|
| 620 |
+
|| signals.has_gc_ag_donor
|
| 621 |
+
|| signals.has_at_ac_donor;
|
| 622 |
+
if (has_any_donor
|
| 623 |
+
&& !signals.has_exact_enabled_stop_followed_by_donor
|
| 624 |
+
&& signals.can_enter_trailing_boundary_intron) {
|
| 625 |
+
StateScore intron_score = best_intron_score_for_donor(signals);
|
| 626 |
+
StateScore added_score =
|
| 627 |
+
signals.noncoding_score + intron_score;
|
| 628 |
+
#pragma unroll
|
| 629 |
+
for (int prefix_type = 0; prefix_type < PREFIX_TYPE_COUNT; prefix_type++) {
|
| 630 |
+
consider_predecessor(
|
| 631 |
+
best, current_scores, exon_state_for_prefix(prefix_type),
|
| 632 |
+
target_state, added_score, signals
|
| 633 |
+
);
|
| 634 |
+
}
|
| 635 |
+
}
|
| 636 |
+
consider_predecessor(
|
| 637 |
+
best, current_scores, TRAILING_BOUNDARY_INTRON, target_state,
|
| 638 |
+
signals.noncoding_score, signals
|
| 639 |
+
);
|
| 640 |
+
return best;
|
| 641 |
+
}
|
| 642 |
+
|
| 643 |
+
return best;
|
| 644 |
+
}
|
| 645 |
+
|
| 646 |
+
// A scalar matrix thread owns all 30 target scores, so it is faster and clearer
|
| 647 |
+
// to brush transitions from each reachable source state. The warp path above
|
| 648 |
+
// uses target-oriented gathering because each lane owns only one target.
|
| 649 |
+
__device__ inline void advance_one_column(
|
| 650 |
+
const StateScore* current_scores, StateScore* next_scores, int8_t* predecessors,
|
| 651 |
+
const StateScore* coding_scores, const StateScore* noncoding_scores,
|
| 652 |
+
const StateScore* transition_probabilities, const int8_t* bases,
|
| 653 |
+
int record_length, int position
|
| 654 |
+
) {
|
| 655 |
+
PositionSignals signals = compute_position_signals(
|
| 656 |
+
coding_scores, noncoding_scores, transition_probabilities,
|
| 657 |
+
bases, record_length, position
|
| 658 |
+
);
|
| 659 |
+
|
| 660 |
+
#pragma unroll
|
| 661 |
+
for (int state = 0; state < STATE_COUNT; state++) {
|
| 662 |
+
next_scores[state] = UNREACHABLE_STATE_SCORE;
|
| 663 |
+
if (predecessors) predecessors[state] = 0;
|
| 664 |
+
}
|
| 665 |
+
|
| 666 |
+
// Intergenic, stop, and start-codon transitions.
|
| 667 |
+
StateScore source_score = current_scores[INTERGENIC];
|
| 668 |
+
if (source_score != UNREACHABLE_STATE_SCORE) {
|
| 669 |
+
keep_best_transition(
|
| 670 |
+
next_scores, predecessors, INTERGENIC,
|
| 671 |
+
source_score + signals.noncoding_score, INTERGENIC, signals
|
| 672 |
+
);
|
| 673 |
+
if (signals.has_atg_start) {
|
| 674 |
+
keep_best_transition(
|
| 675 |
+
next_scores, predecessors, START_A,
|
| 676 |
+
source_score + signals.coding_score,
|
| 677 |
+
INTERGENIC, signals
|
| 678 |
+
);
|
| 679 |
+
}
|
| 680 |
+
}
|
| 681 |
+
|
| 682 |
+
source_score = current_scores[STOP];
|
| 683 |
+
if (source_score != UNREACHABLE_STATE_SCORE) {
|
| 684 |
+
keep_best_transition(
|
| 685 |
+
next_scores, predecessors, INTERGENIC,
|
| 686 |
+
source_score + signals.noncoding_score,
|
| 687 |
+
STOP, signals
|
| 688 |
+
);
|
| 689 |
+
}
|
| 690 |
+
|
| 691 |
+
source_score = current_scores[START_A];
|
| 692 |
+
if (source_score != UNREACHABLE_STATE_SCORE) {
|
| 693 |
+
keep_best_transition(
|
| 694 |
+
next_scores, predecessors, START_AT,
|
| 695 |
+
source_score + signals.coding_score, START_A, signals
|
| 696 |
+
);
|
| 697 |
+
}
|
| 698 |
+
source_score = current_scores[START_AT];
|
| 699 |
+
if (source_score != UNREACHABLE_STATE_SCORE) {
|
| 700 |
+
keep_best_transition(
|
| 701 |
+
next_scores, predecessors, exon_state_for_prefix(0),
|
| 702 |
+
source_score + signals.coding_score, START_AT, signals
|
| 703 |
+
);
|
| 704 |
+
}
|
| 705 |
+
|
| 706 |
+
// Every prefix type is allowed when the leading boundary intron exits, so
|
| 707 |
+
// the state carries none. Only an exact acceptor followed by an exact ATG
|
| 708 |
+
// activates the guard; N leaves both paths available.
|
| 709 |
+
source_score = current_scores[LEADING_BOUNDARY_INTRON];
|
| 710 |
+
if (source_score != UNREACHABLE_STATE_SCORE) {
|
| 711 |
+
if (signals.can_extend_leading_boundary_intron) {
|
| 712 |
+
keep_best_transition(
|
| 713 |
+
next_scores, predecessors,
|
| 714 |
+
LEADING_BOUNDARY_INTRON, source_score + signals.noncoding_score,
|
| 715 |
+
LEADING_BOUNDARY_INTRON, signals
|
| 716 |
+
);
|
| 717 |
+
}
|
| 718 |
+
bool can_exit_leading_intron =
|
| 719 |
+
(signals.has_ag_acceptor || signals.has_ac_acceptor)
|
| 720 |
+
&& !signals.has_exact_acceptor_followed_by_atg;
|
| 721 |
+
if (can_exit_leading_intron) {
|
| 722 |
+
#pragma unroll
|
| 723 |
+
for (int prefix_type = 0; prefix_type < PREFIX_TYPE_COUNT; prefix_type++) {
|
| 724 |
+
emit_coding_base(
|
| 725 |
+
current_scores, next_scores, predecessors,
|
| 726 |
+
LEADING_BOUNDARY_INTRON, prefix_type,
|
| 727 |
+
signals.coding_score,
|
| 728 |
+
signals
|
| 729 |
+
);
|
| 730 |
+
}
|
| 731 |
+
}
|
| 732 |
+
}
|
| 733 |
+
|
| 734 |
+
// Exons emit coding bases, open an ordinary internal intron, or enter the
|
| 735 |
+
// trailing state when an intron continues beyond the record.
|
| 736 |
+
bool has_gt_gc_ag_donor = signals.has_gt_ag_donor || signals.has_gc_ag_donor;
|
| 737 |
+
StateScore gt_gc_ag_intron_score = best_gt_gc_ag_intron_score(signals);
|
| 738 |
+
StateScore any_intron_score = best_intron_score_for_donor(signals);
|
| 739 |
+
bool can_enter_trailing =
|
| 740 |
+
(has_gt_gc_ag_donor || signals.has_at_ac_donor)
|
| 741 |
+
&& !signals.has_exact_enabled_stop_followed_by_donor
|
| 742 |
+
&& signals.can_enter_trailing_boundary_intron;
|
| 743 |
+
#pragma unroll
|
| 744 |
+
for (int prefix_type = 0; prefix_type < PREFIX_TYPE_COUNT; prefix_type++) {
|
| 745 |
+
int exon_state = exon_state_for_prefix(prefix_type);
|
| 746 |
+
source_score = current_scores[exon_state];
|
| 747 |
+
if (source_score == UNREACHABLE_STATE_SCORE) continue;
|
| 748 |
+
|
| 749 |
+
emit_coding_base(
|
| 750 |
+
current_scores, next_scores, predecessors,
|
| 751 |
+
exon_state, prefix_type, signals.coding_score, signals
|
| 752 |
+
);
|
| 753 |
+
if (has_gt_gc_ag_donor) {
|
| 754 |
+
keep_best_transition(
|
| 755 |
+
next_scores, predecessors,
|
| 756 |
+
GT_GC_AG_INTRON_FIRST_PREFIX_TYPE_0 + prefix_type,
|
| 757 |
+
source_score + signals.noncoding_score + gt_gc_ag_intron_score,
|
| 758 |
+
exon_state, signals
|
| 759 |
+
);
|
| 760 |
+
}
|
| 761 |
+
if (signals.has_at_ac_donor) {
|
| 762 |
+
keep_best_transition(
|
| 763 |
+
next_scores, predecessors,
|
| 764 |
+
AT_AC_INTRON_PREFIX_TYPE_0 + prefix_type,
|
| 765 |
+
source_score + signals.noncoding_score + d_at_ac_intron_score,
|
| 766 |
+
exon_state, signals
|
| 767 |
+
);
|
| 768 |
+
}
|
| 769 |
+
if (can_enter_trailing) {
|
| 770 |
+
keep_best_transition(
|
| 771 |
+
next_scores, predecessors,
|
| 772 |
+
TRAILING_BOUNDARY_INTRON,
|
| 773 |
+
source_score + signals.noncoding_score + any_intron_score,
|
| 774 |
+
exon_state, signals
|
| 775 |
+
);
|
| 776 |
+
}
|
| 777 |
+
}
|
| 778 |
+
|
| 779 |
+
// FIRST must consume one additional intron base before AG exit is legal.
|
| 780 |
+
#pragma unroll
|
| 781 |
+
for (int prefix_type = 0; prefix_type < PREFIX_TYPE_COUNT; prefix_type++) {
|
| 782 |
+
int first_state = GT_GC_AG_INTRON_FIRST_PREFIX_TYPE_0 + prefix_type;
|
| 783 |
+
source_score = current_scores[first_state];
|
| 784 |
+
if (source_score != UNREACHABLE_STATE_SCORE) {
|
| 785 |
+
keep_best_transition(
|
| 786 |
+
next_scores, predecessors,
|
| 787 |
+
GT_GC_AG_INTRON_READY_PREFIX_TYPE_0 + prefix_type,
|
| 788 |
+
source_score + signals.noncoding_score,
|
| 789 |
+
first_state, signals
|
| 790 |
+
);
|
| 791 |
+
}
|
| 792 |
+
}
|
| 793 |
+
|
| 794 |
+
// A trailing boundary intron cannot return to an exon.
|
| 795 |
+
source_score = current_scores[TRAILING_BOUNDARY_INTRON];
|
| 796 |
+
if (source_score != UNREACHABLE_STATE_SCORE) {
|
| 797 |
+
keep_best_transition(
|
| 798 |
+
next_scores, predecessors,
|
| 799 |
+
TRAILING_BOUNDARY_INTRON, source_score + signals.noncoding_score,
|
| 800 |
+
TRAILING_BOUNDARY_INTRON, signals
|
| 801 |
+
);
|
| 802 |
+
}
|
| 803 |
+
|
| 804 |
+
// READY self-loops or exits a GT/GC-AG intron after an AG acceptor.
|
| 805 |
+
#pragma unroll
|
| 806 |
+
for (int prefix_type = 0; prefix_type < PREFIX_TYPE_COUNT; prefix_type++) {
|
| 807 |
+
int intron_state = GT_GC_AG_INTRON_READY_PREFIX_TYPE_0 + prefix_type;
|
| 808 |
+
source_score = current_scores[intron_state];
|
| 809 |
+
if (source_score == UNREACHABLE_STATE_SCORE) continue;
|
| 810 |
+
|
| 811 |
+
keep_best_transition(
|
| 812 |
+
next_scores, predecessors, intron_state,
|
| 813 |
+
source_score + signals.noncoding_score, intron_state, signals
|
| 814 |
+
);
|
| 815 |
+
if (signals.has_ag_acceptor) {
|
| 816 |
+
emit_coding_base(
|
| 817 |
+
current_scores, next_scores, predecessors,
|
| 818 |
+
intron_state, prefix_type, signals.coding_score,
|
| 819 |
+
signals
|
| 820 |
+
);
|
| 821 |
+
}
|
| 822 |
+
}
|
| 823 |
+
|
| 824 |
+
// AT-AC introns self-loop or exit after AC; no FIRST state is required.
|
| 825 |
+
#pragma unroll
|
| 826 |
+
for (int prefix_type = 0; prefix_type < PREFIX_TYPE_COUNT; prefix_type++) {
|
| 827 |
+
int intron_state = AT_AC_INTRON_PREFIX_TYPE_0 + prefix_type;
|
| 828 |
+
source_score = current_scores[intron_state];
|
| 829 |
+
if (source_score == UNREACHABLE_STATE_SCORE) continue;
|
| 830 |
+
|
| 831 |
+
keep_best_transition(
|
| 832 |
+
next_scores, predecessors, intron_state,
|
| 833 |
+
source_score + signals.noncoding_score, intron_state, signals
|
| 834 |
+
);
|
| 835 |
+
if (signals.has_ac_acceptor) {
|
| 836 |
+
emit_coding_base(
|
| 837 |
+
current_scores, next_scores, predecessors,
|
| 838 |
+
intron_state, prefix_type, signals.coding_score,
|
| 839 |
+
signals
|
| 840 |
+
);
|
| 841 |
+
}
|
| 842 |
+
}
|
| 843 |
+
}
|
| 844 |
+
|
| 845 |
+
// Transitions across the left record edge are charged like interior ones: a
|
| 846 |
+
// start codon beginning at the first base is charged enter_gene[0] and a stop
|
| 847 |
+
// codon completed there is charged leave_gene[0]; every other first-base state
|
| 848 |
+
// is charged nothing.
|
| 849 |
+
__device__ __forceinline__ StateScore first_base_score_for_state(
|
| 850 |
+
int state, const int8_t* bases, int record_length,
|
| 851 |
+
StateScore first_coding_score, StateScore first_noncoding_score,
|
| 852 |
+
StateScore first_enter_gene_score, StateScore first_leave_gene_score
|
| 853 |
+
) {
|
| 854 |
+
int base = bases[0];
|
| 855 |
+
bool second_start_base_matches = record_length == 1
|
| 856 |
+
|| base_matches(bases[1], BASE_T);
|
| 857 |
+
bool third_start_base_matches = record_length <= 2
|
| 858 |
+
|| base_matches(bases[2], BASE_G);
|
| 859 |
+
bool has_atg_start = base_matches(base, BASE_A)
|
| 860 |
+
&& second_start_base_matches
|
| 861 |
+
&& third_start_base_matches;
|
| 862 |
+
|
| 863 |
+
if (state == INTERGENIC) return first_noncoding_score;
|
| 864 |
+
if (state == START_A && has_atg_start) return first_coding_score + first_enter_gene_score;
|
| 865 |
+
if (state == STOP) {
|
| 866 |
+
bool can_end_stop = (
|
| 867 |
+
base_matches(base, BASE_A)
|
| 868 |
+
&& (d_stop_taa_enabled || d_stop_tga_enabled)
|
| 869 |
+
) || (base_matches(base, BASE_G) && d_stop_tag_enabled);
|
| 870 |
+
return can_end_stop ? first_coding_score + first_leave_gene_score : UNREACHABLE_STATE_SCORE;
|
| 871 |
+
}
|
| 872 |
+
if (base == BASE_N
|
| 873 |
+
&& state >= EXON_PREFIX_TYPE_0
|
| 874 |
+
&& state < EXON_PREFIX_TYPE_0 + PREFIX_TYPE_COUNT) {
|
| 875 |
+
return first_coding_score;
|
| 876 |
+
}
|
| 877 |
+
if (state == exon_state_for_prefix(0) || state == exon_state_for_prefix(5)) {
|
| 878 |
+
return first_coding_score;
|
| 879 |
+
}
|
| 880 |
+
if (state == exon_state_for_prefix(1) && base == BASE_T) return first_coding_score;
|
| 881 |
+
if (state == exon_state_for_prefix(2) && base != BASE_T) return first_coding_score;
|
| 882 |
+
if (state == exon_state_for_prefix(3) && base == BASE_A) return first_coding_score;
|
| 883 |
+
if (state == exon_state_for_prefix(4) && base == BASE_G) return first_coding_score;
|
| 884 |
+
if (state == LEADING_BOUNDARY_INTRON && d_max_boundary_intron_length != 0) {
|
| 885 |
+
return first_noncoding_score;
|
| 886 |
+
}
|
| 887 |
+
return UNREACHABLE_STATE_SCORE;
|
| 888 |
+
}
|
| 889 |
+
|
| 890 |
+
__device__ inline void decode_segment_from_one_entry_state(
|
| 891 |
+
const StateScore* coding_scores, const StateScore* noncoding_scores,
|
| 892 |
+
const StateScore* transition_probabilities, const int8_t* bases, int record_length,
|
| 893 |
+
int segment_start, int segment_end, int entry_state,
|
| 894 |
+
StateScore* output_scores
|
| 895 |
+
) {
|
| 896 |
+
StateScore current_scores[STATE_COUNT], next_scores[STATE_COUNT];
|
| 897 |
+
#pragma unroll
|
| 898 |
+
for (int state = 0; state < STATE_COUNT; state++) {
|
| 899 |
+
current_scores[state] = UNREACHABLE_STATE_SCORE;
|
| 900 |
+
}
|
| 901 |
+
current_scores[entry_state] = StateScore(0);
|
| 902 |
+
|
| 903 |
+
for (int position = segment_start + 1; position <= segment_end; position++) {
|
| 904 |
+
advance_one_column(
|
| 905 |
+
current_scores, next_scores, nullptr,
|
| 906 |
+
coding_scores, noncoding_scores, transition_probabilities,
|
| 907 |
+
bases, record_length, position
|
| 908 |
+
);
|
| 909 |
+
#pragma unroll
|
| 910 |
+
for (int state = 0; state < STATE_COUNT; state++) {
|
| 911 |
+
current_scores[state] = next_scores[state];
|
| 912 |
+
}
|
| 913 |
+
}
|
| 914 |
+
|
| 915 |
+
#pragma unroll
|
| 916 |
+
for (int state = 0; state < STATE_COUNT; state++) {
|
| 917 |
+
output_scores[state] = current_scores[state];
|
| 918 |
+
}
|
| 919 |
+
}
|
| 920 |
+
|
| 921 |
+
__device__ StateScore d_coding_score_lookup[65536], d_noncoding_score_lookup[65536];
|
| 922 |
+
|
| 923 |
+
__global__ void build_log_score_lookup() {
|
| 924 |
+
int idx = blockIdx.x * blockDim.x + threadIdx.x;
|
| 925 |
+
if (idx >= 65536) return;
|
| 926 |
+
double p = (double)__half2float(__ushort_as_half((unsigned short)idx));
|
| 927 |
+
if (p < 1e-6) p = 1e-6;
|
| 928 |
+
else if (p > 1.0 - 1e-6) p = 1.0 - 1e-6;
|
| 929 |
+
d_coding_score_lookup[idx] = score_from_nats(log(p));
|
| 930 |
+
d_noncoding_score_lookup[idx] = score_from_nats(log(1.0 - p));
|
| 931 |
+
}
|
| 932 |
+
|
| 933 |
+
__global__ void convert_probabilities_to_log_scores(
|
| 934 |
+
const __half* probabilities, StateScore* coding_scores,
|
| 935 |
+
StateScore* noncoding_scores, int base_count
|
| 936 |
+
) {
|
| 937 |
+
int base_index = blockIdx.x * blockDim.x + threadIdx.x;
|
| 938 |
+
if (base_index >= base_count) return;
|
| 939 |
+
unsigned short probability_bits = __half_as_ushort(probabilities[base_index]);
|
| 940 |
+
coding_scores[base_index] = d_coding_score_lookup[probability_bits];
|
| 941 |
+
noncoding_scores[base_index] = d_noncoding_score_lookup[probability_bits];
|
| 942 |
+
}
|
| 943 |
+
|
| 944 |
+
static StateScore configured_step_score(double nats, const char* parameter_name) {
|
| 945 |
+
double scaled_score = nats * SCORE_SCALE;
|
| 946 |
+
if (!std::isfinite(scaled_score)
|
| 947 |
+
|| scaled_score <= -RAW_SEGMENT_SCORE_LIMIT
|
| 948 |
+
|| scaled_score >= RAW_SEGMENT_SCORE_LIMIT) {
|
| 949 |
+
throw std::invalid_argument(
|
| 950 |
+
std::string(parameter_name) + " is outside the supported fixed-point range"
|
| 951 |
+
);
|
| 952 |
+
}
|
| 953 |
+
return static_cast<StateScore>(llround(scaled_score));
|
| 954 |
+
}
|
| 955 |
+
|
| 956 |
+
extern "C" void initialize_gpu_decoder(
|
| 957 |
+
int stop_taa, int stop_tag, int stop_tga,
|
| 958 |
+
double gt_ag_intron_log_prior, double gc_ag_intron_log_prior,
|
| 959 |
+
double at_ac_intron_log_prior, int max_boundary_intron_length
|
| 960 |
+
) {
|
| 961 |
+
StateScore gt_ag_intron_score = configured_step_score(
|
| 962 |
+
gt_ag_intron_log_prior, "gt_ag_intron_log_prior"
|
| 963 |
+
);
|
| 964 |
+
StateScore gc_ag_intron_score = configured_step_score(
|
| 965 |
+
gc_ag_intron_log_prior, "gc_ag_intron_log_prior"
|
| 966 |
+
);
|
| 967 |
+
StateScore at_ac_intron_score = configured_step_score(
|
| 968 |
+
at_ac_intron_log_prior, "at_ac_intron_log_prior"
|
| 969 |
+
);
|
| 970 |
+
// Every step emits one base and adds at most one intron prior; transition
|
| 971 |
+
// scores are bounded separately per batch. This bound lets the host shorten
|
| 972 |
+
// risky segments before their raw int32 scores are computed.
|
| 973 |
+
StateScore minimum_emission_score = score_from_nats(log(1e-6));
|
| 974 |
+
StateScore maximum_emission_score = score_from_nats(log(1.0 - 1e-6));
|
| 975 |
+
StateScore intron_scores[] = {gt_ag_intron_score, gc_ag_intron_score, at_ac_intron_score};
|
| 976 |
+
maximum_absolute_step_score = 1;
|
| 977 |
+
auto include_step_score = [&](int64_t score) {
|
| 978 |
+
int64_t magnitude = score < 0 ? -score : score;
|
| 979 |
+
if (magnitude >= RAW_SEGMENT_SCORE_LIMIT) {
|
| 980 |
+
throw std::invalid_argument(
|
| 981 |
+
"configured priors cannot be represented in one int32 segment step"
|
| 982 |
+
);
|
| 983 |
+
}
|
| 984 |
+
if (magnitude > maximum_absolute_step_score) {
|
| 985 |
+
maximum_absolute_step_score = magnitude;
|
| 986 |
+
}
|
| 987 |
+
};
|
| 988 |
+
for (StateScore emission_score : {minimum_emission_score, maximum_emission_score}) {
|
| 989 |
+
include_step_score(emission_score);
|
| 990 |
+
for (StateScore intron_score : intron_scores) {
|
| 991 |
+
include_step_score(static_cast<int64_t>(emission_score) + intron_score);
|
| 992 |
+
}
|
| 993 |
+
}
|
| 994 |
+
|
| 995 |
+
cudaMemcpyToSymbol(d_gt_ag_intron_score, >_ag_intron_score, sizeof(StateScore));
|
| 996 |
+
cudaMemcpyToSymbol(d_gc_ag_intron_score, &gc_ag_intron_score, sizeof(StateScore));
|
| 997 |
+
cudaMemcpyToSymbol(d_at_ac_intron_score, &at_ac_intron_score, sizeof(StateScore));
|
| 998 |
+
cudaMemcpyToSymbol(d_stop_taa_enabled, &stop_taa, sizeof(int));
|
| 999 |
+
cudaMemcpyToSymbol(d_stop_tag_enabled, &stop_tag, sizeof(int));
|
| 1000 |
+
cudaMemcpyToSymbol(d_stop_tga_enabled, &stop_tga, sizeof(int));
|
| 1001 |
+
cudaMemcpyToSymbol(d_max_boundary_intron_length, &max_boundary_intron_length, sizeof(int));
|
| 1002 |
+
build_log_score_lookup<<<256, 256>>>();
|
| 1003 |
+
}
|
| 1004 |
+
|
| 1005 |
+
__global__ void build_segment_transition_matrices(
|
| 1006 |
+
const StateScore* coding_scores, const StateScore* noncoding_scores,
|
| 1007 |
+
const StateScore* transition_probabilities, const int8_t* bases,
|
| 1008 |
+
const int64_t* record_offsets,
|
| 1009 |
+
const int* record_lengths,
|
| 1010 |
+
const int64_t* segment_starts, const int64_t* segment_ends,
|
| 1011 |
+
int segment_count, StateScore* transition_matrices
|
| 1012 |
+
) {
|
| 1013 |
+
// One task decodes a segment from one possible entry state. Its 30 final
|
| 1014 |
+
// scores form one column of the segment's max-plus transition matrix.
|
| 1015 |
+
int task_index = blockIdx.x * blockDim.x + threadIdx.x;
|
| 1016 |
+
int segment = task_index / STATE_COUNT;
|
| 1017 |
+
int entry_state = task_index % STATE_COUNT;
|
| 1018 |
+
if (segment >= segment_count) return;
|
| 1019 |
+
|
| 1020 |
+
int64_t offset = record_offsets[segment];
|
| 1021 |
+
int segment_start = static_cast<int>(segment_starts[segment] - offset);
|
| 1022 |
+
int segment_end = static_cast<int>(segment_ends[segment] - offset);
|
| 1023 |
+
StateScore output_scores[STATE_COUNT];
|
| 1024 |
+
decode_segment_from_one_entry_state(
|
| 1025 |
+
coding_scores + offset, noncoding_scores + offset,
|
| 1026 |
+
transition_probabilities + offset * TRANSITION_COUNT, bases + offset,
|
| 1027 |
+
record_lengths[segment], segment_start, segment_end,
|
| 1028 |
+
entry_state, output_scores
|
| 1029 |
+
);
|
| 1030 |
+
#pragma unroll
|
| 1031 |
+
for (int exit_state = 0; exit_state < STATE_COUNT; exit_state++) {
|
| 1032 |
+
transition_matrices[
|
| 1033 |
+
segment_transition_index(segment, exit_state, entry_state)
|
| 1034 |
+
] = output_scores[exit_state];
|
| 1035 |
+
}
|
| 1036 |
+
}
|
| 1037 |
+
|
| 1038 |
+
__global__ void recompute_selected_segment_backpointers(
|
| 1039 |
+
const StateScore* coding_scores, const StateScore* noncoding_scores,
|
| 1040 |
+
const StateScore* transition_probabilities, const int8_t* bases,
|
| 1041 |
+
const int64_t* segment_record_offsets,
|
| 1042 |
+
const int* segment_record_lengths,
|
| 1043 |
+
const int64_t* segment_starts, const int64_t* segment_ends,
|
| 1044 |
+
const int8_t* segment_entry_states,
|
| 1045 |
+
int segment_count, int64_t backpointer_origin,
|
| 1046 |
+
int8_t* backpointers
|
| 1047 |
+
) {
|
| 1048 |
+
int warp_in_block = threadIdx.x >> 5;
|
| 1049 |
+
int segment = blockIdx.x * 4 + warp_in_block;
|
| 1050 |
+
if (segment >= segment_count) return;
|
| 1051 |
+
|
| 1052 |
+
int target_state = threadIdx.x & 31;
|
| 1053 |
+
int64_t offset = segment_record_offsets[segment];
|
| 1054 |
+
int record_length = segment_record_lengths[segment];
|
| 1055 |
+
int segment_start = static_cast<int>(segment_starts[segment] - offset);
|
| 1056 |
+
int segment_end = static_cast<int>(segment_ends[segment] - offset);
|
| 1057 |
+
const StateScore* record_coding_scores = coding_scores + offset;
|
| 1058 |
+
const StateScore* record_noncoding_scores = noncoding_scores + offset;
|
| 1059 |
+
const StateScore* record_transition_probabilities =
|
| 1060 |
+
transition_probabilities + offset * TRANSITION_COUNT;
|
| 1061 |
+
const int8_t* record_bases = bases + offset;
|
| 1062 |
+
|
| 1063 |
+
int entry_state = static_cast<int>(segment_entry_states[segment]);
|
| 1064 |
+
__shared__ StateScore shared_scores[4][STATE_COUNT];
|
| 1065 |
+
StateScore* current_scores = shared_scores[warp_in_block];
|
| 1066 |
+
if (target_state < STATE_COUNT) {
|
| 1067 |
+
current_scores[target_state] = target_state == entry_state
|
| 1068 |
+
? StateScore(0)
|
| 1069 |
+
: UNREACHABLE_STATE_SCORE;
|
| 1070 |
+
}
|
| 1071 |
+
__syncwarp();
|
| 1072 |
+
|
| 1073 |
+
for (int position = segment_start + 1; position <= segment_end; position++) {
|
| 1074 |
+
PositionSignals signals = compute_position_signals(
|
| 1075 |
+
record_coding_scores, record_noncoding_scores,
|
| 1076 |
+
record_transition_probabilities, record_bases, record_length, position
|
| 1077 |
+
);
|
| 1078 |
+
TransitionChoice<StateScore> best = {UNREACHABLE_STATE_SCORE, 0};
|
| 1079 |
+
if (target_state < STATE_COUNT) {
|
| 1080 |
+
best = best_transition_to_state(target_state, current_scores, signals);
|
| 1081 |
+
}
|
| 1082 |
+
|
| 1083 |
+
if (target_state < STATE_COUNT) {
|
| 1084 |
+
current_scores[target_state] = best.score;
|
| 1085 |
+
backpointers[
|
| 1086 |
+
static_cast<size_t>(offset + position - 1 - backpointer_origin)
|
| 1087 |
+
* BACKPOINTER_STRIDE
|
| 1088 |
+
+ target_state
|
| 1089 |
+
] =
|
| 1090 |
+
static_cast<int8_t>(best.source_state);
|
| 1091 |
+
}
|
| 1092 |
+
__syncwarp();
|
| 1093 |
+
}
|
| 1094 |
+
}
|
| 1095 |
+
|
| 1096 |
+
__global__ void write_selected_segment_paths(
|
| 1097 |
+
const int64_t* segment_starts, const int64_t* segment_ends,
|
| 1098 |
+
const int* segment_exit_states, int segment_count,
|
| 1099 |
+
int64_t backpointer_origin, const int8_t* backpointers,
|
| 1100 |
+
int8_t* paths
|
| 1101 |
+
) {
|
| 1102 |
+
int segment = blockIdx.x * blockDim.x + threadIdx.x;
|
| 1103 |
+
if (segment >= segment_count) return;
|
| 1104 |
+
|
| 1105 |
+
int64_t segment_start = segment_starts[segment];
|
| 1106 |
+
int64_t segment_end = segment_ends[segment];
|
| 1107 |
+
int current_state = segment_exit_states[segment];
|
| 1108 |
+
for (int64_t position = segment_end; position > segment_start; position--) {
|
| 1109 |
+
paths[position] = static_cast<int8_t>(current_state);
|
| 1110 |
+
current_state = static_cast<int>(
|
| 1111 |
+
backpointers[
|
| 1112 |
+
static_cast<size_t>(position - 1 - backpointer_origin) * BACKPOINTER_STRIDE
|
| 1113 |
+
+ current_state
|
| 1114 |
+
]
|
| 1115 |
+
);
|
| 1116 |
+
}
|
| 1117 |
+
paths[segment_start] = static_cast<int8_t>(current_state);
|
| 1118 |
+
}
|
| 1119 |
+
|
| 1120 |
+
__global__ void combine_segment_scores(
|
| 1121 |
+
const StateScore* coding_scores, const StateScore* noncoding_scores,
|
| 1122 |
+
const StateScore* transition_probabilities, const int8_t* bases,
|
| 1123 |
+
const int64_t* record_offsets, const int* record_lengths,
|
| 1124 |
+
const StateScore* transition_matrices,
|
| 1125 |
+
const int* record_first_segments, const int* record_segment_counts, int record_count,
|
| 1126 |
+
AccumulatedScore* segment_entry_scores, int* record_end_states
|
| 1127 |
+
) {
|
| 1128 |
+
// One warp handles one record. Lane n owns state n and repeatedly applies
|
| 1129 |
+
// the record's segment matrices. Scores crossing segment boundaries use
|
| 1130 |
+
// int64, so arbitrarily long records do not overflow.
|
| 1131 |
+
int record = blockIdx.x;
|
| 1132 |
+
if (record >= record_count) return;
|
| 1133 |
+
int state = threadIdx.x;
|
| 1134 |
+
int segment_count = record_segment_counts[record];
|
| 1135 |
+
int first_segment = record_first_segments[record];
|
| 1136 |
+
int64_t offset = record_offsets[record];
|
| 1137 |
+
|
| 1138 |
+
__shared__ AccumulatedScore score_buffers[2][STATE_COUNT];
|
| 1139 |
+
AccumulatedScore* current_scores = score_buffers[0];
|
| 1140 |
+
AccumulatedScore* next_scores = score_buffers[1];
|
| 1141 |
+
if (state < STATE_COUNT) {
|
| 1142 |
+
StateScore initial_score = first_base_score_for_state(
|
| 1143 |
+
state, bases + offset, record_lengths[record],
|
| 1144 |
+
coding_scores[offset], noncoding_scores[offset],
|
| 1145 |
+
transition_probabilities[offset * TRANSITION_COUNT + ENTER_GENE],
|
| 1146 |
+
transition_probabilities[offset * TRANSITION_COUNT + LEAVE_GENE]
|
| 1147 |
+
);
|
| 1148 |
+
current_scores[state] = initial_score == UNREACHABLE_STATE_SCORE
|
| 1149 |
+
? UNREACHABLE_ACCUMULATED_SCORE
|
| 1150 |
+
: static_cast<AccumulatedScore>(initial_score);
|
| 1151 |
+
}
|
| 1152 |
+
__syncthreads();
|
| 1153 |
+
|
| 1154 |
+
for (int local_segment = 0; local_segment < segment_count; local_segment++) {
|
| 1155 |
+
int segment = first_segment + local_segment;
|
| 1156 |
+
if (state < STATE_COUNT) {
|
| 1157 |
+
segment_entry_scores[segment_state_index(segment, state)] = current_scores[state];
|
| 1158 |
+
|
| 1159 |
+
const StateScore* matrix = transition_matrices
|
| 1160 |
+
+ segment_transition_index(segment, state, 0);
|
| 1161 |
+
AccumulatedScore best_score = UNREACHABLE_ACCUMULATED_SCORE;
|
| 1162 |
+
#pragma unroll
|
| 1163 |
+
for (int entry_state = 0; entry_state < STATE_COUNT; entry_state++) {
|
| 1164 |
+
AccumulatedScore score_before_segment = current_scores[entry_state];
|
| 1165 |
+
StateScore score_inside_segment = matrix[entry_state];
|
| 1166 |
+
if (score_before_segment == UNREACHABLE_ACCUMULATED_SCORE
|
| 1167 |
+
|| score_inside_segment == UNREACHABLE_STATE_SCORE) {
|
| 1168 |
+
continue;
|
| 1169 |
+
}
|
| 1170 |
+
AccumulatedScore candidate_score =
|
| 1171 |
+
score_before_segment
|
| 1172 |
+
+ static_cast<AccumulatedScore>(score_inside_segment);
|
| 1173 |
+
if (candidate_score > best_score) best_score = candidate_score;
|
| 1174 |
+
}
|
| 1175 |
+
next_scores[state] = best_score;
|
| 1176 |
+
}
|
| 1177 |
+
__syncthreads();
|
| 1178 |
+
AccumulatedScore* previous_scores = current_scores;
|
| 1179 |
+
current_scores = next_scores;
|
| 1180 |
+
next_scores = previous_scores;
|
| 1181 |
+
}
|
| 1182 |
+
|
| 1183 |
+
if (state == 0) {
|
| 1184 |
+
int best_end_state = INTERGENIC;
|
| 1185 |
+
AccumulatedScore best_score = current_scores[INTERGENIC];
|
| 1186 |
+
for (int candidate = START_A; candidate <= STOP; candidate++) {
|
| 1187 |
+
if (current_scores[candidate] > best_score) {
|
| 1188 |
+
best_score = current_scores[candidate];
|
| 1189 |
+
best_end_state = candidate;
|
| 1190 |
+
}
|
| 1191 |
+
}
|
| 1192 |
+
if (current_scores[TRAILING_BOUNDARY_INTRON] > best_score) {
|
| 1193 |
+
best_end_state = TRAILING_BOUNDARY_INTRON;
|
| 1194 |
+
}
|
| 1195 |
+
record_end_states[record] = best_end_state;
|
| 1196 |
+
}
|
| 1197 |
+
}
|
| 1198 |
+
|
| 1199 |
+
__global__ void find_segment_entry_origins(
|
| 1200 |
+
const StateScore* coding_scores, const StateScore* noncoding_scores,
|
| 1201 |
+
const StateScore* transition_probabilities, const int8_t* bases,
|
| 1202 |
+
const int64_t* segment_record_offsets,
|
| 1203 |
+
const int* segment_record_lengths,
|
| 1204 |
+
const int64_t* segment_starts, const int64_t* segment_ends,
|
| 1205 |
+
const AccumulatedScore* segment_entry_scores,
|
| 1206 |
+
int segment_count, int8_t* segment_entry_origins
|
| 1207 |
+
) {
|
| 1208 |
+
// A segment starts with the exact 30-state score vector produced by the
|
| 1209 |
+
// preceding segments. Alongside each score, carry only the segment entry
|
| 1210 |
+
// state that produced it. This preserves strict-greater tie order without
|
| 1211 |
+
// storing another per-base backpointer table.
|
| 1212 |
+
int warp_in_block = threadIdx.x >> 5;
|
| 1213 |
+
int segment = blockIdx.x * 4 + warp_in_block;
|
| 1214 |
+
if (segment >= segment_count) return;
|
| 1215 |
+
|
| 1216 |
+
int target_state = threadIdx.x & 31;
|
| 1217 |
+
int64_t record_offset = segment_record_offsets[segment];
|
| 1218 |
+
int record_length = segment_record_lengths[segment];
|
| 1219 |
+
int segment_start = static_cast<int>(segment_starts[segment] - record_offset);
|
| 1220 |
+
int segment_end = static_cast<int>(segment_ends[segment] - record_offset);
|
| 1221 |
+
const StateScore* record_coding_scores = coding_scores + record_offset;
|
| 1222 |
+
const StateScore* record_noncoding_scores = noncoding_scores + record_offset;
|
| 1223 |
+
const StateScore* record_transition_probabilities =
|
| 1224 |
+
transition_probabilities + record_offset * TRANSITION_COUNT;
|
| 1225 |
+
const int8_t* record_bases = bases + record_offset;
|
| 1226 |
+
|
| 1227 |
+
__shared__ AccumulatedScore shared_scores[4][STATE_COUNT];
|
| 1228 |
+
__shared__ int8_t shared_entry_origins[4][STATE_COUNT];
|
| 1229 |
+
AccumulatedScore* current_scores = shared_scores[warp_in_block];
|
| 1230 |
+
int8_t* entry_origins = shared_entry_origins[warp_in_block];
|
| 1231 |
+
if (target_state < STATE_COUNT) {
|
| 1232 |
+
current_scores[target_state] =
|
| 1233 |
+
segment_entry_scores[segment_state_index(segment, target_state)];
|
| 1234 |
+
entry_origins[target_state] = static_cast<int8_t>(target_state);
|
| 1235 |
+
}
|
| 1236 |
+
__syncwarp();
|
| 1237 |
+
|
| 1238 |
+
for (int position = segment_start + 1; position <= segment_end; position++) {
|
| 1239 |
+
PositionSignals signals = compute_position_signals(
|
| 1240 |
+
record_coding_scores, record_noncoding_scores,
|
| 1241 |
+
record_transition_probabilities, record_bases, record_length, position
|
| 1242 |
+
);
|
| 1243 |
+
TransitionChoice<AccumulatedScore> best = {UNREACHABLE_ACCUMULATED_SCORE, 0};
|
| 1244 |
+
int8_t best_entry_origin = 0;
|
| 1245 |
+
if (target_state < STATE_COUNT) {
|
| 1246 |
+
best = best_transition_to_state(target_state, current_scores, signals);
|
| 1247 |
+
if (!is_unreachable(best.score)) {
|
| 1248 |
+
best_entry_origin = entry_origins[best.source_state];
|
| 1249 |
+
}
|
| 1250 |
+
}
|
| 1251 |
+
|
| 1252 |
+
if (target_state < STATE_COUNT) {
|
| 1253 |
+
current_scores[target_state] = best.score;
|
| 1254 |
+
entry_origins[target_state] = best_entry_origin;
|
| 1255 |
+
}
|
| 1256 |
+
__syncwarp();
|
| 1257 |
+
}
|
| 1258 |
+
|
| 1259 |
+
if (target_state < STATE_COUNT) {
|
| 1260 |
+
segment_entry_origins[segment_state_index(segment, target_state)] =
|
| 1261 |
+
entry_origins[target_state];
|
| 1262 |
+
}
|
| 1263 |
+
}
|
| 1264 |
+
|
| 1265 |
+
__global__ void trace_selected_segment_boundaries(
|
| 1266 |
+
const int* record_first_segments, const int* record_segment_counts,
|
| 1267 |
+
const int* record_end_states,
|
| 1268 |
+
const int8_t* segment_entry_origins,
|
| 1269 |
+
int record_count, int8_t* segment_entry_states, int* segment_exit_states
|
| 1270 |
+
) {
|
| 1271 |
+
// Follow the exact segment-origin table from the selected record end state.
|
| 1272 |
+
int record = blockIdx.x * blockDim.x + threadIdx.x;
|
| 1273 |
+
if (record >= record_count) return;
|
| 1274 |
+
|
| 1275 |
+
int first_segment = record_first_segments[record];
|
| 1276 |
+
int segment_count = record_segment_counts[record];
|
| 1277 |
+
int current_exit_state = record_end_states[record];
|
| 1278 |
+
for (int local_segment = segment_count - 1; local_segment >= 0; local_segment--) {
|
| 1279 |
+
int segment = first_segment + local_segment;
|
| 1280 |
+
segment_exit_states[segment] = current_exit_state;
|
| 1281 |
+
current_exit_state = static_cast<int>(
|
| 1282 |
+
segment_entry_origins[
|
| 1283 |
+
segment_state_index(segment, current_exit_state)
|
| 1284 |
+
]
|
| 1285 |
+
);
|
| 1286 |
+
segment_entry_states[segment] = static_cast<int8_t>(current_exit_state);
|
| 1287 |
+
}
|
| 1288 |
+
}
|
| 1289 |
+
|
| 1290 |
+
// Reused device buffers grow as needed and remain allocated between batches.
|
| 1291 |
+
static __half* d_probabilities = nullptr;
|
| 1292 |
+
static StateScore* d_coding_scores = nullptr;
|
| 1293 |
+
static StateScore* d_noncoding_scores = nullptr;
|
| 1294 |
+
static StateScore* d_transition_probabilities = nullptr;
|
| 1295 |
+
static StateScore* d_transition_matrices = nullptr;
|
| 1296 |
+
static AccumulatedScore* d_segment_entry_scores = nullptr;
|
| 1297 |
+
static int8_t* d_bases = nullptr;
|
| 1298 |
+
static int8_t* d_backpointers = nullptr;
|
| 1299 |
+
static int8_t* d_paths = nullptr;
|
| 1300 |
+
static int8_t* d_segment_entry_origins = nullptr;
|
| 1301 |
+
static int8_t* d_segment_entry_states = nullptr;
|
| 1302 |
+
static int64_t* d_record_offsets = nullptr;
|
| 1303 |
+
static int64_t* d_segment_starts = nullptr;
|
| 1304 |
+
static int64_t* d_segment_ends = nullptr;
|
| 1305 |
+
static int64_t* d_segment_record_offsets = nullptr;
|
| 1306 |
+
static int* d_record_lengths = nullptr;
|
| 1307 |
+
static int* d_segment_exit_states = nullptr;
|
| 1308 |
+
static int* d_segment_record_lengths = nullptr;
|
| 1309 |
+
static int* d_record_end_states = nullptr;
|
| 1310 |
+
static int* d_record_first_segments = nullptr;
|
| 1311 |
+
static int* d_record_segment_counts = nullptr;
|
| 1312 |
+
|
| 1313 |
+
static int64_t allocated_base_capacity = 0;
|
| 1314 |
+
static int64_t allocated_segment_capacity = 0;
|
| 1315 |
+
static int64_t allocated_record_capacity = 0;
|
| 1316 |
+
static int64_t allocated_backpointer_capacity = 0;
|
| 1317 |
+
|
| 1318 |
+
template <typename T>
|
| 1319 |
+
static void reallocate_device_buffer(T*& buffer, size_t element_count) {
|
| 1320 |
+
if (buffer) cudaFree(buffer);
|
| 1321 |
+
cudaMalloc(reinterpret_cast<void**>(&buffer), element_count * sizeof(T));
|
| 1322 |
+
}
|
| 1323 |
+
|
| 1324 |
+
template <typename T>
|
| 1325 |
+
static void copy_to_device(T* destination, const T* source, size_t element_count) {
|
| 1326 |
+
cudaMemcpy(destination, source, element_count * sizeof(T), cudaMemcpyHostToDevice);
|
| 1327 |
+
}
|
| 1328 |
+
|
| 1329 |
+
extern "C" void decode_batch_on_gpu(
|
| 1330 |
+
const unsigned short* probability_bits, const int8_t* input_bases,
|
| 1331 |
+
const StateScore* input_transition_probabilities,
|
| 1332 |
+
const int64_t* input_record_offsets, int record_count,
|
| 1333 |
+
int requested_segment_length,
|
| 1334 |
+
int64_t requested_reconstruction_batch_size,
|
| 1335 |
+
int8_t* paths
|
| 1336 |
+
) {
|
| 1337 |
+
const __half* probabilities = reinterpret_cast<const __half*>(probability_bits);
|
| 1338 |
+
if (record_count <= 0) return;
|
| 1339 |
+
|
| 1340 |
+
// One step can add at most one transition log-odds value, so the largest
|
| 1341 |
+
// magnitude widens the per-step bound that sizes the int32 segments.
|
| 1342 |
+
int64_t largest_transition_log_odds = 0;
|
| 1343 |
+
int64_t total_bases = input_record_offsets[record_count];
|
| 1344 |
+
for (int64_t value = 0; value < total_bases * TRANSITION_COUNT; value++) {
|
| 1345 |
+
int64_t magnitude = std::abs(static_cast<int64_t>(input_transition_probabilities[value]));
|
| 1346 |
+
if (magnitude > largest_transition_log_odds) largest_transition_log_odds = magnitude;
|
| 1347 |
+
}
|
| 1348 |
+
int segment_length = requested_segment_length < 1 ? 2000 : requested_segment_length;
|
| 1349 |
+
int64_t maximum_batch_step_score =
|
| 1350 |
+
maximum_absolute_step_score + largest_transition_log_odds;
|
| 1351 |
+
if (maximum_batch_step_score >= RAW_SEGMENT_SCORE_LIMIT) {
|
| 1352 |
+
throw std::invalid_argument(
|
| 1353 |
+
"combined decoder and transition probabilities exceed the int32 step range"
|
| 1354 |
+
);
|
| 1355 |
+
}
|
| 1356 |
+
int maximum_safe_segment_length = static_cast<int>(
|
| 1357 |
+
RAW_SEGMENT_SCORE_LIMIT / maximum_batch_step_score
|
| 1358 |
+
);
|
| 1359 |
+
if (segment_length > maximum_safe_segment_length) {
|
| 1360 |
+
segment_length = maximum_safe_segment_length;
|
| 1361 |
+
}
|
| 1362 |
+
// This is a base count for the final path-reconstruction batch, not a
|
| 1363 |
+
// segment count and not a byte count. Zero selects automatic sizing.
|
| 1364 |
+
int64_t reconstruction_batch_size = requested_reconstruction_batch_size;
|
| 1365 |
+
if (reconstruction_batch_size == 0) {
|
| 1366 |
+
size_t free_bytes = 0, total_bytes = 0;
|
| 1367 |
+
cudaMemGetInfo(&free_bytes, &total_bytes);
|
| 1368 |
+
reconstruction_batch_size = static_cast<int64_t>(
|
| 1369 |
+
free_bytes / 8 / BACKPOINTER_STRIDE
|
| 1370 |
+
);
|
| 1371 |
+
}
|
| 1372 |
+
if (reconstruction_batch_size < static_cast<int64_t>(segment_length) * 2) {
|
| 1373 |
+
reconstruction_batch_size = static_cast<int64_t>(segment_length) * 2;
|
| 1374 |
+
}
|
| 1375 |
+
int64_t required_backpointer_capacity = (
|
| 1376 |
+
total_bases < reconstruction_batch_size
|
| 1377 |
+
? total_bases
|
| 1378 |
+
: reconstruction_batch_size
|
| 1379 |
+
);
|
| 1380 |
+
|
| 1381 |
+
std::vector<int> record_lengths(record_count);
|
| 1382 |
+
std::vector<int> record_first_segments(record_count), record_segment_counts(record_count);
|
| 1383 |
+
int segment_count = 0;
|
| 1384 |
+
// All segments use the same algorithm and are distributed as evenly as
|
| 1385 |
+
// possible; there is no special segment-zero decoder.
|
| 1386 |
+
for (int record = 0; record < record_count; record++) {
|
| 1387 |
+
int64_t length = input_record_offsets[record + 1] - input_record_offsets[record];
|
| 1388 |
+
record_lengths[record] = static_cast<int>(length);
|
| 1389 |
+
int count = static_cast<int>((length - 1 + segment_length - 1) / segment_length);
|
| 1390 |
+
if (count < 1) count = 1;
|
| 1391 |
+
record_first_segments[record] = segment_count;
|
| 1392 |
+
record_segment_counts[record] = count;
|
| 1393 |
+
segment_count += count;
|
| 1394 |
+
}
|
| 1395 |
+
|
| 1396 |
+
// Persistent device buffers grow on demand and are reused by later batches.
|
| 1397 |
+
if (total_bases > allocated_base_capacity) {
|
| 1398 |
+
reallocate_device_buffer(d_probabilities, total_bases);
|
| 1399 |
+
reallocate_device_buffer(d_coding_scores, total_bases);
|
| 1400 |
+
reallocate_device_buffer(d_noncoding_scores, total_bases);
|
| 1401 |
+
reallocate_device_buffer(
|
| 1402 |
+
d_transition_probabilities,
|
| 1403 |
+
static_cast<size_t>(total_bases) * TRANSITION_COUNT
|
| 1404 |
+
);
|
| 1405 |
+
reallocate_device_buffer(d_bases, total_bases);
|
| 1406 |
+
reallocate_device_buffer(d_paths, total_bases);
|
| 1407 |
+
allocated_base_capacity = total_bases;
|
| 1408 |
+
}
|
| 1409 |
+
if (required_backpointer_capacity > allocated_backpointer_capacity) {
|
| 1410 |
+
reallocate_device_buffer(
|
| 1411 |
+
d_backpointers, static_cast<size_t>(required_backpointer_capacity) * BACKPOINTER_STRIDE
|
| 1412 |
+
);
|
| 1413 |
+
allocated_backpointer_capacity = required_backpointer_capacity;
|
| 1414 |
+
}
|
| 1415 |
+
if (record_count > allocated_record_capacity) {
|
| 1416 |
+
reallocate_device_buffer(d_record_offsets, record_count + 1);
|
| 1417 |
+
reallocate_device_buffer(d_record_lengths, record_count);
|
| 1418 |
+
reallocate_device_buffer(d_record_end_states, record_count);
|
| 1419 |
+
reallocate_device_buffer(d_record_first_segments, record_count);
|
| 1420 |
+
reallocate_device_buffer(d_record_segment_counts, record_count);
|
| 1421 |
+
allocated_record_capacity = record_count;
|
| 1422 |
+
}
|
| 1423 |
+
if (segment_count > allocated_segment_capacity) {
|
| 1424 |
+
reallocate_device_buffer(d_segment_starts, segment_count);
|
| 1425 |
+
reallocate_device_buffer(d_segment_ends, segment_count);
|
| 1426 |
+
reallocate_device_buffer(d_segment_record_offsets, segment_count);
|
| 1427 |
+
reallocate_device_buffer(d_segment_record_lengths, segment_count);
|
| 1428 |
+
reallocate_device_buffer(d_segment_exit_states, segment_count);
|
| 1429 |
+
reallocate_device_buffer(
|
| 1430 |
+
d_transition_matrices, static_cast<size_t>(segment_count) * STATE_COUNT * STATE_COUNT
|
| 1431 |
+
);
|
| 1432 |
+
reallocate_device_buffer(
|
| 1433 |
+
d_segment_entry_scores, static_cast<size_t>(segment_count) * STATE_COUNT
|
| 1434 |
+
);
|
| 1435 |
+
reallocate_device_buffer(
|
| 1436 |
+
d_segment_entry_origins, static_cast<size_t>(segment_count) * STATE_COUNT
|
| 1437 |
+
);
|
| 1438 |
+
reallocate_device_buffer(d_segment_entry_states, segment_count);
|
| 1439 |
+
allocated_segment_capacity = segment_count;
|
| 1440 |
+
}
|
| 1441 |
+
|
| 1442 |
+
// Flatten each record's segments while retaining the record-relative data
|
| 1443 |
+
// needed by matrix construction and backpointer recomputation.
|
| 1444 |
+
std::vector<int64_t> segment_starts(segment_count), segment_ends(segment_count);
|
| 1445 |
+
std::vector<int64_t> segment_record_offsets(segment_count);
|
| 1446 |
+
std::vector<int> segment_record_lengths(segment_count);
|
| 1447 |
+
for (int record = 0; record < record_count; record++) {
|
| 1448 |
+
int64_t offset = input_record_offsets[record];
|
| 1449 |
+
int length = record_lengths[record];
|
| 1450 |
+
int first_segment = record_first_segments[record];
|
| 1451 |
+
int count = record_segment_counts[record];
|
| 1452 |
+
auto boundary = [&](int boundary_index) {
|
| 1453 |
+
return static_cast<int64_t>(
|
| 1454 |
+
static_cast<int64_t>(boundary_index) * (length - 1) / count
|
| 1455 |
+
);
|
| 1456 |
+
};
|
| 1457 |
+
|
| 1458 |
+
for (int local_segment = 0; local_segment < count; local_segment++) {
|
| 1459 |
+
int segment = first_segment + local_segment;
|
| 1460 |
+
segment_starts[segment] = offset + boundary(local_segment);
|
| 1461 |
+
segment_ends[segment] = offset + boundary(local_segment + 1);
|
| 1462 |
+
segment_record_offsets[segment] = offset;
|
| 1463 |
+
segment_record_lengths[segment] = length;
|
| 1464 |
+
}
|
| 1465 |
+
}
|
| 1466 |
+
|
| 1467 |
+
// Upload packed per-base data and the compact segment layout.
|
| 1468 |
+
cudaMemcpy(d_probabilities, probability_bits, total_bases * 2, cudaMemcpyHostToDevice);
|
| 1469 |
+
copy_to_device(
|
| 1470 |
+
d_transition_probabilities, input_transition_probabilities,
|
| 1471 |
+
static_cast<size_t>(total_bases) * TRANSITION_COUNT
|
| 1472 |
+
);
|
| 1473 |
+
copy_to_device(d_bases, input_bases, total_bases);
|
| 1474 |
+
copy_to_device(d_record_offsets, input_record_offsets, record_count + 1);
|
| 1475 |
+
copy_to_device(d_record_lengths, record_lengths.data(), record_count);
|
| 1476 |
+
copy_to_device(d_record_first_segments, record_first_segments.data(), record_count);
|
| 1477 |
+
copy_to_device(d_record_segment_counts, record_segment_counts.data(), record_count);
|
| 1478 |
+
copy_to_device(d_segment_starts, segment_starts.data(), segment_count);
|
| 1479 |
+
copy_to_device(d_segment_ends, segment_ends.data(), segment_count);
|
| 1480 |
+
copy_to_device(d_segment_record_offsets, segment_record_offsets.data(), segment_count);
|
| 1481 |
+
copy_to_device(d_segment_record_lengths, segment_record_lengths.data(), segment_count);
|
| 1482 |
+
|
| 1483 |
+
// Build every segment transition matrix, then combine matrices by record.
|
| 1484 |
+
int threads = 256;
|
| 1485 |
+
int probability_blocks = static_cast<int>((total_bases + threads - 1) / threads);
|
| 1486 |
+
convert_probabilities_to_log_scores<<<probability_blocks, threads>>>(
|
| 1487 |
+
d_probabilities, d_coding_scores, d_noncoding_scores,
|
| 1488 |
+
static_cast<int>(total_bases)
|
| 1489 |
+
);
|
| 1490 |
+
int64_t matrix_threads = static_cast<int64_t>(segment_count) * STATE_COUNT;
|
| 1491 |
+
int matrix_blocks = static_cast<int>((matrix_threads + 127) / 128);
|
| 1492 |
+
build_segment_transition_matrices<<<matrix_blocks, 128>>>(
|
| 1493 |
+
d_coding_scores, d_noncoding_scores, d_transition_probabilities,
|
| 1494 |
+
d_bases, d_segment_record_offsets,
|
| 1495 |
+
d_segment_record_lengths, d_segment_starts, d_segment_ends,
|
| 1496 |
+
segment_count, d_transition_matrices
|
| 1497 |
+
);
|
| 1498 |
+
combine_segment_scores<<<record_count, 32>>>(
|
| 1499 |
+
d_coding_scores, d_noncoding_scores, d_transition_probabilities,
|
| 1500 |
+
d_bases, d_record_offsets, d_record_lengths,
|
| 1501 |
+
d_transition_matrices, d_record_first_segments,
|
| 1502 |
+
d_record_segment_counts, record_count,
|
| 1503 |
+
d_segment_entry_scores, d_record_end_states
|
| 1504 |
+
);
|
| 1505 |
+
find_segment_entry_origins<<<(segment_count + 3) / 4, 128>>>(
|
| 1506 |
+
d_coding_scores, d_noncoding_scores, d_transition_probabilities, d_bases,
|
| 1507 |
+
d_segment_record_offsets, d_segment_record_lengths,
|
| 1508 |
+
d_segment_starts, d_segment_ends,
|
| 1509 |
+
d_segment_entry_scores, segment_count,
|
| 1510 |
+
d_segment_entry_origins
|
| 1511 |
+
);
|
| 1512 |
+
|
| 1513 |
+
trace_selected_segment_boundaries<<<(record_count + 127) / 128, 128>>>(
|
| 1514 |
+
d_record_first_segments, d_record_segment_counts,
|
| 1515 |
+
d_record_end_states, d_segment_entry_origins, record_count,
|
| 1516 |
+
d_segment_entry_states, d_segment_exit_states
|
| 1517 |
+
);
|
| 1518 |
+
|
| 1519 |
+
// Selected-path reconstruction materializes backpointers in bounded
|
| 1520 |
+
// base-pair windows. Each selected segment restarts at score 0 and uses
|
| 1521 |
+
// raw int32 scores without a clamp.
|
| 1522 |
+
std::vector<int> window_first_segments;
|
| 1523 |
+
{
|
| 1524 |
+
int segment = 0;
|
| 1525 |
+
while (segment < segment_count) {
|
| 1526 |
+
int64_t window_start_bp = segment_starts[segment];
|
| 1527 |
+
int end = segment + 1;
|
| 1528 |
+
while (end < segment_count
|
| 1529 |
+
&& segment_ends[end] - window_start_bp <= required_backpointer_capacity) {
|
| 1530 |
+
end++;
|
| 1531 |
+
}
|
| 1532 |
+
window_first_segments.push_back(segment);
|
| 1533 |
+
segment = end;
|
| 1534 |
+
}
|
| 1535 |
+
window_first_segments.push_back(segment_count);
|
| 1536 |
+
}
|
| 1537 |
+
int window_count = static_cast<int>(window_first_segments.size()) - 1;
|
| 1538 |
+
|
| 1539 |
+
auto recompute_selected_backpointers = [&](int start, int end, int64_t window_start_bp) {
|
| 1540 |
+
recompute_selected_segment_backpointers<<<(end - start + 3) / 4, 128>>>(
|
| 1541 |
+
d_coding_scores, d_noncoding_scores, d_transition_probabilities,
|
| 1542 |
+
d_bases, d_segment_record_offsets + start,
|
| 1543 |
+
d_segment_record_lengths + start, d_segment_starts + start,
|
| 1544 |
+
d_segment_ends + start,
|
| 1545 |
+
d_segment_entry_states + start,
|
| 1546 |
+
end - start, window_start_bp, d_backpointers
|
| 1547 |
+
);
|
| 1548 |
+
};
|
| 1549 |
+
auto write_selected_paths = [&](int start, int end, int64_t window_start_bp) {
|
| 1550 |
+
write_selected_segment_paths<<<(end - start + 127) / 128, 128>>>(
|
| 1551 |
+
d_segment_starts + start, d_segment_ends + start,
|
| 1552 |
+
d_segment_exit_states + start, end - start,
|
| 1553 |
+
window_start_bp, d_backpointers, d_paths
|
| 1554 |
+
);
|
| 1555 |
+
};
|
| 1556 |
+
|
| 1557 |
+
for (int window = 0; window < window_count; window++) {
|
| 1558 |
+
int start = window_first_segments[window];
|
| 1559 |
+
int end = window_first_segments[window + 1];
|
| 1560 |
+
int64_t window_start_bp = segment_starts[start];
|
| 1561 |
+
recompute_selected_backpointers(start, end, window_start_bp);
|
| 1562 |
+
write_selected_paths(start, end, window_start_bp);
|
| 1563 |
+
}
|
| 1564 |
+
|
| 1565 |
+
cudaMemcpy(paths, d_paths, total_bases, cudaMemcpyDeviceToHost);
|
| 1566 |
+
cudaDeviceSynchronize();
|
| 1567 |
+
}
|
| 1568 |
+
|
| 1569 |
+
#ifdef TORCH_EXTENSION_NAME
|
| 1570 |
+
#include <torch/extension.h>
|
| 1571 |
+
#include <pybind11/numpy.h>
|
| 1572 |
+
namespace py = pybind11;
|
| 1573 |
+
|
| 1574 |
+
static py::array_t<int8_t> decode_packed_paths(
|
| 1575 |
+
py::array_t<unsigned short> probability_array, py::array_t<int8_t> encoded_bases,
|
| 1576 |
+
py::array_t<StateScore> transition_probabilities,
|
| 1577 |
+
py::array_t<int64_t> record_offsets_array,
|
| 1578 |
+
int segment_length, int64_t reconstruction_batch_size
|
| 1579 |
+
) {
|
| 1580 |
+
if (transition_probabilities.size() != encoded_bases.size() * TRANSITION_COUNT) {
|
| 1581 |
+
throw std::invalid_argument(
|
| 1582 |
+
"transition probabilities must contain four values per encoded base"
|
| 1583 |
+
);
|
| 1584 |
+
}
|
| 1585 |
+
int record_count = static_cast<int>(record_offsets_array.size()) - 1;
|
| 1586 |
+
auto state_paths = py::array_t<int8_t>(encoded_bases.size());
|
| 1587 |
+
const unsigned short* probability_bits = probability_array.data();
|
| 1588 |
+
const int8_t* base_data = encoded_bases.data();
|
| 1589 |
+
const StateScore* transition_probability_data = transition_probabilities.data();
|
| 1590 |
+
const int64_t* record_offsets = record_offsets_array.data();
|
| 1591 |
+
int8_t* output_paths = state_paths.mutable_data();
|
| 1592 |
+
{
|
| 1593 |
+
// Let the Python reader prepare the next input chunk during GPU work.
|
| 1594 |
+
py::gil_scoped_release release;
|
| 1595 |
+
decode_batch_on_gpu(
|
| 1596 |
+
probability_bits, base_data, transition_probability_data, record_offsets,
|
| 1597 |
+
record_count, segment_length, reconstruction_batch_size, output_paths
|
| 1598 |
+
);
|
| 1599 |
+
}
|
| 1600 |
+
return state_paths;
|
| 1601 |
+
}
|
| 1602 |
+
|
| 1603 |
+
PYBIND11_MODULE(TORCH_EXTENSION_NAME, m) {
|
| 1604 |
+
m.def("init", &initialize_gpu_decoder, "Set decoder stop codons, intron priors, and limits");
|
| 1605 |
+
m.def("decode", &decode_packed_paths, "Decode a batch with the 30-state CDS decoder");
|
| 1606 |
+
}
|
| 1607 |
+
#endif
|
annotation_pipeline/weights/confidence_network.safetensors
ADDED
|
@@ -0,0 +1,3 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
version https://git-lfs.github.com/spec/v1
|
| 2 |
+
oid sha256:eadd22818e30aee6e8e8d779b3b25302bd503ce42dbb82b3f9df9620c9089928
|
| 3 |
+
size 63460
|
annotation_pipeline/weights/transition_network.safetensors
ADDED
|
@@ -0,0 +1,3 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
version https://git-lfs.github.com/spec/v1
|
| 2 |
+
oid sha256:2e4d253ab067a97eaeee690c1292000d95ea649326f6bad66f4570f16fb138f9
|
| 3 |
+
size 49628
|
genbank-inference-check.json
DELETED
|
@@ -1,33 +0,0 @@
|
|
| 1 |
-
{
|
| 2 |
-
"status": "passed",
|
| 3 |
-
"model_revision": "d429145e77373827ac93cc03ff04dcbdfab5723f",
|
| 4 |
-
"gpu": "NVIDIA H100 80GB HBM3",
|
| 5 |
-
"torch": "2.7.1+cu128",
|
| 6 |
-
"dtype": "bfloat16",
|
| 7 |
-
"source_accession": "NC_001133.9",
|
| 8 |
-
"source_length": 230218,
|
| 9 |
-
"source_windows": 4,
|
| 10 |
-
"records_per_format": 3,
|
| 11 |
-
"formats": [
|
| 12 |
-
"gzipped GenBank",
|
| 13 |
-
"gzipped FASTA"
|
| 14 |
-
],
|
| 15 |
-
"checks": [
|
| 16 |
-
"published script and model downloaded",
|
| 17 |
-
"real NCBI chromosome inference",
|
| 18 |
-
"multiple records",
|
| 19 |
-
"all original coordinates retained",
|
| 20 |
-
"independent overlap-average comparison",
|
| 21 |
-
"GenBank and FASTA predictions identical",
|
| 22 |
-
"ambiguous 6-mers excluded",
|
| 23 |
-
"incomplete final 6-mer excluded",
|
| 24 |
-
"single-base record handled",
|
| 25 |
-
"thresholded strand and combined calls",
|
| 26 |
-
"overwrite refusal"
|
| 27 |
-
],
|
| 28 |
-
"scope": "Functional inference check; not an accuracy benchmark",
|
| 29 |
-
"date": "2026-10-02",
|
| 30 |
-
"transformers": "4.56.0",
|
| 31 |
-
"biopython": "1.85",
|
| 32 |
-
"slurm_job_id": "100266"
|
| 33 |
-
}
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
infer_genbank.py
DELETED
|
@@ -1,123 +0,0 @@
|
|
| 1 |
-
"""Predict per-base CDS occupancy from a GenBank/GBFF (or FASTA) file."""
|
| 2 |
-
import argparse
|
| 3 |
-
import gzip
|
| 4 |
-
import json
|
| 5 |
-
from pathlib import Path
|
| 6 |
-
import re
|
| 7 |
-
|
| 8 |
-
from Bio import SeqIO
|
| 9 |
-
import numpy as np
|
| 10 |
-
import torch
|
| 11 |
-
from transformers import AutoModelForTokenClassification, AutoTokenizer
|
| 12 |
-
|
| 13 |
-
MODEL_ID = 'HuggingFaceBio/Carbon-A-1.2B'
|
| 14 |
-
WINDOW = 98304
|
| 15 |
-
STRIDE = 49152
|
| 16 |
-
|
| 17 |
-
|
| 18 |
-
def safe_name(value):
|
| 19 |
-
return re.sub(r'[^A-Za-z0-9_.-]+', '_', value).strip('._')[:100] or 'record'
|
| 20 |
-
|
| 21 |
-
|
| 22 |
-
def predict_record(sequence, model, tokenizer, device, threshold):
|
| 23 |
-
"""Average probabilities across overlapping windows in record coordinates."""
|
| 24 |
-
sequence = sequence.upper()
|
| 25 |
-
length = len(sequence)
|
| 26 |
-
if not length:
|
| 27 |
-
raise ValueError('Cannot predict an empty sequence')
|
| 28 |
-
sums = np.zeros((2, length), dtype=np.float32)
|
| 29 |
-
counts = np.zeros(length, dtype=np.uint32)
|
| 30 |
-
windows = 0
|
| 31 |
-
with torch.inference_mode():
|
| 32 |
-
for start in range(0, length, STRIDE):
|
| 33 |
-
stop = min(start + WINDOW, length)
|
| 34 |
-
fragment = sequence[start:stop]
|
| 35 |
-
# Complete the final 6-mer with N; its positions will be unscored.
|
| 36 |
-
fragment += 'N' * (-len(fragment) % 6)
|
| 37 |
-
encoded = tokenizer(fragment, add_special_tokens=False, return_tensors='pt',
|
| 38 |
-
return_attention_mask=True)
|
| 39 |
-
ids = encoded['input_ids']
|
| 40 |
-
n_tokens = len(fragment) // 6
|
| 41 |
-
if ids.shape != (1, n_tokens):
|
| 42 |
-
raise ValueError('Tokenizer did not produce one token per six bases')
|
| 43 |
-
# Match packed-data inference: OOV tokens remain visible context;
|
| 44 |
-
# only their output positions are excluded, not the attention mask.
|
| 45 |
-
mask = torch.ones_like(ids)
|
| 46 |
-
logits = model(input_ids=ids.to(device), attention_mask=mask.to(device)).logits.float()
|
| 47 |
-
if logits.shape != (1, 2 * n_tokens * 6, 2) or not torch.isfinite(logits).all():
|
| 48 |
-
raise ValueError('Unexpected shape or nonfinite model predictions')
|
| 49 |
-
probs = logits.softmax(-1)[0, :, 1].cpu().numpy().reshape(2, -1)
|
| 50 |
-
width = stop - start
|
| 51 |
-
valid = np.repeat(ids[0].numpy() >= 32, 6)[:width]
|
| 52 |
-
sums[:, start:stop] += np.where(valid[None, :], probs[:, :width], 0)
|
| 53 |
-
counts[start:stop] += valid.astype(np.uint32)
|
| 54 |
-
windows += 1
|
| 55 |
-
if stop == length:
|
| 56 |
-
break
|
| 57 |
-
valid = counts > 0
|
| 58 |
-
np.divide(sums, counts[None, :], out=sums, where=valid[None, :])
|
| 59 |
-
sums[:, ~valid] = np.nan
|
| 60 |
-
calls = (sums >= threshold).astype(np.int8)
|
| 61 |
-
calls[:, ~valid] = -1
|
| 62 |
-
any_call = np.any(calls == 1, axis=0).astype(np.int8)
|
| 63 |
-
any_call[~valid] = -1
|
| 64 |
-
return {'cds_probability_plus': sums[0], 'cds_probability_minus': sums[1],
|
| 65 |
-
'cds_label_plus': calls[0], 'cds_label_minus': calls[1],
|
| 66 |
-
'cds_label_any': any_call, 'valid': valid, 'window_count': counts}, windows
|
| 67 |
-
|
| 68 |
-
|
| 69 |
-
def main():
|
| 70 |
-
parser = argparse.ArgumentParser(description=__doc__)
|
| 71 |
-
parser.add_argument('input', type=Path, help='GenBank/GBFF file; .gz is supported.')
|
| 72 |
-
parser.add_argument('--format', choices=['genbank', 'fasta'], default='genbank')
|
| 73 |
-
parser.add_argument('--model', default=MODEL_ID)
|
| 74 |
-
parser.add_argument('--revision', default='main', help='Use a model commit for repeatable runs.')
|
| 75 |
-
parser.add_argument('--device', choices=['cuda', 'cpu'], default='cuda')
|
| 76 |
-
parser.add_argument('--threshold', type=float, default=0.5)
|
| 77 |
-
parser.add_argument('--out-dir', type=Path, default=Path('genbank-predictions'))
|
| 78 |
-
args = parser.parse_args()
|
| 79 |
-
if not 0 <= args.threshold <= 1:
|
| 80 |
-
parser.error('Threshold must be between 0 and 1.')
|
| 81 |
-
if not args.input.is_file():
|
| 82 |
-
parser.error('Input file does not exist.')
|
| 83 |
-
if args.device == 'cuda' and not torch.cuda.is_available():
|
| 84 |
-
parser.error('CUDA is unavailable; use a GPU or explicitly select --device cpu (slow).')
|
| 85 |
-
if args.out_dir.exists() and (not args.out_dir.is_dir() or any(args.out_dir.iterdir())):
|
| 86 |
-
parser.error('Choose an empty output directory to avoid overwriting previous predictions.')
|
| 87 |
-
dtype = torch.float32
|
| 88 |
-
if args.device == 'cuda':
|
| 89 |
-
dtype = torch.bfloat16 if torch.cuda.is_bf16_supported() else torch.float16
|
| 90 |
-
tokenizer = AutoTokenizer.from_pretrained(args.model, revision=args.revision, trust_remote_code=True)
|
| 91 |
-
model = AutoModelForTokenClassification.from_pretrained(
|
| 92 |
-
args.model, revision=args.revision, trust_remote_code=True,
|
| 93 |
-
dtype=dtype, attn_implementation='sdpa',
|
| 94 |
-
).to(args.device).eval()
|
| 95 |
-
if (model.config.k, model.config.num_prediction_heads, model.config.num_labels) != (6, 2, 2):
|
| 96 |
-
raise ValueError('Expected a 6-mer, two-strand binary CDS model.')
|
| 97 |
-
args.out_dir.mkdir(parents=True, exist_ok=True)
|
| 98 |
-
summary = {'model':args.model, 'revision_requested':args.revision,
|
| 99 |
-
'revision_resolved':getattr(model.config, '_commit_hash', None),
|
| 100 |
-
'input_file':str(args.input), 'input_format':args.format,
|
| 101 |
-
'dtype':str(dtype), 'threshold':args.threshold,
|
| 102 |
-
'window_bp':WINDOW, 'stride_bp':STRIDE,
|
| 103 |
-
'coordinates':'Array index i corresponds to record base i+1 on both strands.',
|
| 104 |
-
'records':[]}
|
| 105 |
-
opener = gzip.open if args.input.suffix.lower() == '.gz' else open
|
| 106 |
-
with opener(args.input, 'rt') as handle:
|
| 107 |
-
for index, record in enumerate(SeqIO.parse(handle, args.format)):
|
| 108 |
-
if not len(record):
|
| 109 |
-
raise ValueError(f'Empty sequence in record {record.id}')
|
| 110 |
-
arrays, windows = predict_record(str(record.seq), model, tokenizer, args.device, args.threshold)
|
| 111 |
-
filename = f'{index:04d}_{safe_name(record.id)}_{safe_name(record.name)}.npz'
|
| 112 |
-
np.savez_compressed(args.out_dir / filename, **arrays)
|
| 113 |
-
summary['records'].append({'accession':record.id, 'name':record.name,
|
| 114 |
-
'length':len(record), 'windows':windows,
|
| 115 |
-
'valid_bases':int(arrays['valid'].sum()), 'file':filename})
|
| 116 |
-
print(f'Saved {filename}: {len(record):,} bases, {windows} windows', flush=True)
|
| 117 |
-
if not summary['records']:
|
| 118 |
-
raise ValueError('No sequence records found; check the input format.')
|
| 119 |
-
(args.out_dir / 'summary.json').write_text(json.dumps(summary, indent=2)+'\n')
|
| 120 |
-
|
| 121 |
-
|
| 122 |
-
if __name__ == '__main__':
|
| 123 |
-
main()
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
infer_packed.py
DELETED
|
@@ -1,116 +0,0 @@
|
|
| 1 |
-
"""Run Carbon-A on local packed HDF5 rows, or download a small example file."""
|
| 2 |
-
import argparse
|
| 3 |
-
import json
|
| 4 |
-
from pathlib import Path
|
| 5 |
-
import time
|
| 6 |
-
|
| 7 |
-
import numpy as np
|
| 8 |
-
import requests
|
| 9 |
-
import torch
|
| 10 |
-
from huggingface_hub import get_token
|
| 11 |
-
from torch.utils.data import DataLoader, Subset
|
| 12 |
-
from transformers import AutoModelForTokenClassification
|
| 13 |
-
|
| 14 |
-
from load_data import PackedAnnotationDataset
|
| 15 |
-
|
| 16 |
-
MODEL_ID = 'HuggingFaceBio/Carbon-A-1.2B'
|
| 17 |
-
EXAMPLE_URL = ('https://huggingface.co/buckets/HuggingFaceBio/Carbon-A-training-data/'
|
| 18 |
-
'resolve/test/fungi1/GCF_000002945.2.h5')
|
| 19 |
-
|
| 20 |
-
|
| 21 |
-
def download_example(path):
|
| 22 |
-
"""Stream a 29 MB evaluation file using the saved Hugging Face login."""
|
| 23 |
-
path.parent.mkdir(parents=True, exist_ok=True)
|
| 24 |
-
if not path.exists():
|
| 25 |
-
token = get_token()
|
| 26 |
-
headers = {'Authorization': f'Bearer {token}'} if token else {}
|
| 27 |
-
partial = path.with_suffix('.h5.partial')
|
| 28 |
-
with requests.get(EXAMPLE_URL, headers=headers, stream=True, timeout=(30, 120)) as response:
|
| 29 |
-
response.raise_for_status()
|
| 30 |
-
with partial.open('wb') as f:
|
| 31 |
-
for block in response.iter_content(1024 * 1024):
|
| 32 |
-
f.write(block)
|
| 33 |
-
partial.replace(path)
|
| 34 |
-
return path
|
| 35 |
-
|
| 36 |
-
|
| 37 |
-
def main():
|
| 38 |
-
parser = argparse.ArgumentParser(description=__doc__)
|
| 39 |
-
parser.add_argument('--model', default=MODEL_ID)
|
| 40 |
-
parser.add_argument('--revision', default='main', help='Pin a model commit for repeatable runs.')
|
| 41 |
-
parser.add_argument('--h5', type=Path, help='Local packed HDF5. If omitted, download the example evaluation file.')
|
| 42 |
-
parser.add_argument('--cache-dir', type=Path, default=Path('data'))
|
| 43 |
-
parser.add_argument('--start-row', type=int, default=0)
|
| 44 |
-
parser.add_argument('--num-rows', type=int, default=1)
|
| 45 |
-
parser.add_argument('--batch-size', type=int, default=1)
|
| 46 |
-
parser.add_argument('--device', choices=['cuda', 'cpu'], default='cuda')
|
| 47 |
-
parser.add_argument('--dtype', choices=['auto', 'float32', 'bfloat16', 'float16'], default='auto')
|
| 48 |
-
parser.add_argument('--threshold', type=float, default=0.5)
|
| 49 |
-
parser.add_argument('--out-dir', type=Path, default=Path('predictions'))
|
| 50 |
-
args = parser.parse_args()
|
| 51 |
-
if args.start_row < 0 or args.num_rows < 1 or args.batch_size < 1:
|
| 52 |
-
parser.error('Use a nonnegative start row and positive row/batch counts.')
|
| 53 |
-
if not 0 <= args.threshold <= 1:
|
| 54 |
-
parser.error('Threshold must be between 0 and 1.')
|
| 55 |
-
if args.device == 'cuda' and not torch.cuda.is_available():
|
| 56 |
-
parser.error('CUDA is unavailable. Run on a GPU, or explicitly select --device cpu (slow).')
|
| 57 |
-
dtype_name = args.dtype
|
| 58 |
-
if dtype_name == 'auto':
|
| 59 |
-
dtype_name = ('bfloat16' if torch.cuda.is_bf16_supported() else 'float16') if args.device == 'cuda' else 'float32'
|
| 60 |
-
dtype = getattr(torch, dtype_name)
|
| 61 |
-
path = args.h5 or download_example(args.cache_dir / 'GCF_000002945.2.h5')
|
| 62 |
-
dataset = PackedAnnotationDataset([path])
|
| 63 |
-
stop = args.start_row + args.num_rows
|
| 64 |
-
if stop > len(dataset):
|
| 65 |
-
parser.error(f'Requested rows end at {stop}; file contains {len(dataset)} rows.')
|
| 66 |
-
args.out_dir.mkdir(parents=True, exist_ok=True)
|
| 67 |
-
targets = [args.out_dir / f'{path.stem}.row-{i:06d}.npz' for i in range(args.start_row, stop)]
|
| 68 |
-
report_path = args.out_dir / f'{path.stem}.rows-{args.start_row}-{stop}.json'
|
| 69 |
-
if any(p.exists() for p in targets + [report_path]):
|
| 70 |
-
parser.error('Output already exists; choose a new output directory or row range.')
|
| 71 |
-
model = AutoModelForTokenClassification.from_pretrained(
|
| 72 |
-
args.model, revision=args.revision, trust_remote_code=True,
|
| 73 |
-
dtype=dtype, attn_implementation='sdpa',
|
| 74 |
-
).to(args.device).eval()
|
| 75 |
-
if (model.config.k, model.config.num_prediction_heads, model.config.num_labels) != (6, 2, 2):
|
| 76 |
-
raise ValueError('Expected a 6-mer, two-strand binary CDS model.')
|
| 77 |
-
loader = DataLoader(Subset(dataset, range(args.start_row, stop)), batch_size=args.batch_size)
|
| 78 |
-
reports = []
|
| 79 |
-
cursor = 0
|
| 80 |
-
started = time.monotonic()
|
| 81 |
-
with torch.inference_mode():
|
| 82 |
-
for batch in loader:
|
| 83 |
-
logits = model(input_ids=batch['input_ids'].to(args.device),
|
| 84 |
-
attention_mask=batch['attention_mask'].to(args.device)).logits.float()
|
| 85 |
-
if logits.shape[1:] != (196608, 2) or not torch.isfinite(logits).all():
|
| 86 |
-
raise ValueError(f'Unexpected or nonfinite logits: {tuple(logits.shape)}')
|
| 87 |
-
probs = logits.softmax(-1)[..., 1].cpu().numpy().reshape(-1, 2, 98304)
|
| 88 |
-
references = batch['labels'].numpy().reshape(-1, 2, 98304)
|
| 89 |
-
for prob, reference in zip(probs, references):
|
| 90 |
-
valid = reference != -100
|
| 91 |
-
calls = (prob >= args.threshold).astype(np.int8)
|
| 92 |
-
calls[~valid] = -1
|
| 93 |
-
any_valid = valid.all(axis=0)
|
| 94 |
-
any_call = np.any(calls == 1, axis=0).astype(np.int8)
|
| 95 |
-
any_call[~any_valid] = -1
|
| 96 |
-
output = targets[cursor]
|
| 97 |
-
np.savez_compressed(output, cds_probability_plus=prob[0], cds_probability_minus=prob[1],
|
| 98 |
-
cds_label_plus=calls[0], cds_label_minus=calls[1], cds_label_any=any_call,
|
| 99 |
-
valid_plus=valid[0], valid_minus=valid[1],
|
| 100 |
-
reference_plus=reference[0], reference_minus=reference[1])
|
| 101 |
-
reports.append({'row':args.start_row+cursor, 'file':output.name,
|
| 102 |
-
'bases_per_strand':98304, 'valid_bases_per_strand':valid.sum(axis=1).tolist(),
|
| 103 |
-
'cds_calls_per_strand':(calls == 1).sum(axis=1).tolist()})
|
| 104 |
-
cursor += 1
|
| 105 |
-
print(f'Saved {output}', flush=True)
|
| 106 |
-
report = {'model':args.model, 'revision_requested':args.revision,
|
| 107 |
-
'revision_resolved':getattr(model.config, '_commit_hash', None),
|
| 108 |
-
'input_file':str(path), 'device':args.device, 'dtype':dtype_name,
|
| 109 |
-
'threshold':args.threshold, 'elapsed_seconds':time.monotonic()-started,
|
| 110 |
-
'rows':reports}
|
| 111 |
-
report_path.write_text(json.dumps(report, indent=2)+'\n')
|
| 112 |
-
print(json.dumps(report, indent=2), flush=True)
|
| 113 |
-
|
| 114 |
-
|
| 115 |
-
if __name__ == '__main__':
|
| 116 |
-
main()
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
inference-check.json
DELETED
|
@@ -1,27 +0,0 @@
|
|
| 1 |
-
{
|
| 2 |
-
"status": "passed",
|
| 3 |
-
"gpu": "NVIDIA H100 80GB HBM3",
|
| 4 |
-
"torch": "2.7.1+cu128",
|
| 5 |
-
"full_length_rows": 2,
|
| 6 |
-
"batch_size": 2,
|
| 7 |
-
"bases_per_strand": 98304,
|
| 8 |
-
"checks": [
|
| 9 |
-
"published model and scripts downloaded",
|
| 10 |
-
"bucket example downloaded by inference script",
|
| 11 |
-
"finite logits and probabilities",
|
| 12 |
-
"two strand shapes",
|
| 13 |
-
"threshold calls and validity masks",
|
| 14 |
-
"combined strand calls",
|
| 15 |
-
"tokenizer IDs"
|
| 16 |
-
],
|
| 17 |
-
"tested_model_revision": "d5a62f1bbf483426314525fcff8695847f9fcdc4",
|
| 18 |
-
"transformers": "4.56.0",
|
| 19 |
-
"dtype": "bfloat16",
|
| 20 |
-
"data_bucket": "HuggingFaceBio/Carbon-A-training-data",
|
| 21 |
-
"data_path": "test/fungi1/GCF_000002945.2.h5",
|
| 22 |
-
"rows": [
|
| 23 |
-
0,
|
| 24 |
-
1
|
| 25 |
-
],
|
| 26 |
-
"scope": "Functional inference check, not an accuracy benchmark"
|
| 27 |
-
}
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
load_data.py
DELETED
|
@@ -1,52 +0,0 @@
|
|
| 1 |
-
"""Minimal loader for the released 16,384-token GENERanno annotation HDF5s."""
|
| 2 |
-
from bisect import bisect_right
|
| 3 |
-
from pathlib import Path
|
| 4 |
-
|
| 5 |
-
import h5py
|
| 6 |
-
import numpy as np
|
| 7 |
-
import torch
|
| 8 |
-
from torch.utils.data import Dataset
|
| 9 |
-
|
| 10 |
-
|
| 11 |
-
class PackedAnnotationDataset(Dataset):
|
| 12 |
-
"""Read local downloaded shards; one row = 98,304 bp with two label tracks.
|
| 13 |
-
|
| 14 |
-
No augmentation, retokenization, or context truncation is applied here.
|
| 15 |
-
Supply special IDs from the bundled tokenizer to reproduce training masking.
|
| 16 |
-
"""
|
| 17 |
-
|
| 18 |
-
def __init__(self, paths, ignored_token_ids=(0, 1, 2, 3, 4)):
|
| 19 |
-
self.paths = sorted(Path(p) for p in paths)
|
| 20 |
-
if not self.paths:
|
| 21 |
-
raise ValueError("Provide at least one downloaded .h5 file")
|
| 22 |
-
self.ignored_token_ids = np.asarray(ignored_token_ids, dtype=np.int64)
|
| 23 |
-
self.ends = []
|
| 24 |
-
total = 0
|
| 25 |
-
for path in self.paths:
|
| 26 |
-
with h5py.File(path, "r") as f:
|
| 27 |
-
n, tokens = f['input_ids'].shape
|
| 28 |
-
if tokens != 16384 or any(f[k].shape != (n, tokens * 6) for k in ('label_plus', 'label_minus')):
|
| 29 |
-
raise ValueError(f"Expected aligned 16k annotation rows in {path}")
|
| 30 |
-
total += n
|
| 31 |
-
self.ends.append(total)
|
| 32 |
-
|
| 33 |
-
def __len__(self):
|
| 34 |
-
return self.ends[-1]
|
| 35 |
-
|
| 36 |
-
def __getitem__(self, index):
|
| 37 |
-
if not 0 <= index < len(self):
|
| 38 |
-
raise IndexError(index)
|
| 39 |
-
file_index = bisect_right(self.ends, index)
|
| 40 |
-
row = index - (self.ends[file_index - 1] if file_index else 0)
|
| 41 |
-
# Open per read: no shared HDF5 handle between DataLoader workers.
|
| 42 |
-
with h5py.File(self.paths[file_index], "r") as f:
|
| 43 |
-
ids = np.asarray(f['input_ids'][row], dtype=np.int64)
|
| 44 |
-
plus = (f['label_plus'][row] != 0).astype(np.int64)
|
| 45 |
-
minus = (f['label_minus'][row] != 0).astype(np.int64)
|
| 46 |
-
labels = np.concatenate([plus, minus])
|
| 47 |
-
ignored_bases = np.repeat(np.isin(ids, self.ignored_token_ids), 6)
|
| 48 |
-
labels[np.concatenate([ignored_bases, ignored_bases])] = -100
|
| 49 |
-
return {'input_ids': torch.from_numpy(ids),
|
| 50 |
-
# Fixed-length pretokenized rows; matches the original collator.
|
| 51 |
-
'attention_mask': torch.ones(len(ids), dtype=torch.long),
|
| 52 |
-
'labels': torch.from_numpy(labels)}
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
provenance.json
CHANGED
|
@@ -37,12 +37,7 @@
|
|
| 37 |
"additional_files": [
|
| 38 |
"README.md",
|
| 39 |
"requirements.txt",
|
| 40 |
-
"
|
| 41 |
-
"infer_packed.py",
|
| 42 |
-
"infer_genbank.py",
|
| 43 |
-
"inference-check.json",
|
| 44 |
-
"genbank-inference-check.json",
|
| 45 |
-
"INFERENCE.md"
|
| 46 |
],
|
| 47 |
"modified_source_files": {
|
| 48 |
"modeling_generanno.py": {
|
|
|
|
| 37 |
"additional_files": [
|
| 38 |
"README.md",
|
| 39 |
"requirements.txt",
|
| 40 |
+
"annotation_pipeline/"
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 41 |
],
|
| 42 |
"modified_source_files": {
|
| 43 |
"modeling_generanno.py": {
|
requirements.txt
CHANGED
|
@@ -1,8 +1,20 @@
|
|
| 1 |
torch>=2.7,<3
|
| 2 |
-
|
|
|
|
|
|
|
| 3 |
huggingface_hub>=0.34
|
| 4 |
safetensors>=0.4.3
|
| 5 |
numpy>=1.24
|
| 6 |
-
h5py>=3.10
|
| 7 |
-
requests>=2.31
|
| 8 |
biopython>=1.83,<2
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
torch>=2.7,<3
|
| 2 |
+
# Below 5: Transformers 5 does not load this model with flash_attention_2, the
|
| 3 |
+
# annotation pipeline's default attention implementation.
|
| 4 |
+
transformers>=4.56,<5
|
| 5 |
huggingface_hub>=0.34
|
| 6 |
safetensors>=0.4.3
|
| 7 |
numpy>=1.24
|
|
|
|
|
|
|
| 8 |
biopython>=1.83,<2
|
| 9 |
+
|
| 10 |
+
# Annotation pipeline (annotation_pipeline/, Linux only)
|
| 11 |
+
pandas
|
| 12 |
+
pyarrow
|
| 13 |
+
tqdm
|
| 14 |
+
numba
|
| 15 |
+
ninja
|
| 16 |
+
# Its default attention implementation, flash_attention_2, also needs FlashAttention:
|
| 17 |
+
# pip install --no-build-isolation flash-attn
|
| 18 |
+
# (--attn-implementation sdpa runs without it.) The native Viterbi backends are
|
| 19 |
+
# compiled on first use: the CPU backend needs a C++17 compiler with OpenMP and
|
| 20 |
+
# the GPU backend a CUDA toolkit.
|