pytransdecoder 0.2.1__py3-none-any.whl
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- pytransdecoder/__init__.py +7 -0
- pytransdecoder/__main__.py +6 -0
- pytransdecoder/cli.py +781 -0
- pytransdecoder/core/__init__.py +1 -0
- pytransdecoder/core/base_freqs.py +71 -0
- pytransdecoder/core/decoys.py +113 -0
- pytransdecoder/core/fasta.py +55 -0
- pytransdecoder/core/gene_model.py +143 -0
- pytransdecoder/core/gff3_parser.py +58 -0
- pytransdecoder/core/homology_starts.py +54 -0
- pytransdecoder/core/kozak.py +180 -0
- pytransdecoder/core/models.py +123 -0
- pytransdecoder/core/orf_finder.py +317 -0
- pytransdecoder/core/presets.py +152 -0
- pytransdecoder/core/prng.py +54 -0
- pytransdecoder/core/pwm.py +174 -0
- pytransdecoder/core/sequence.py +19 -0
- pytransdecoder/core/translator.py +143 -0
- pytransdecoder/core/writers.py +69 -0
- pytransdecoder/genome.py +338 -0
- pytransdecoder/longorfs.py +180 -0
- pytransdecoder/predict.py +1621 -0
- pytransdecoder/presets/apicomplexan/hexamer.scores.gz +0 -0
- pytransdecoder/presets/apicomplexan/kozak.tsv +9 -0
- pytransdecoder/presets/apicomplexan/preset.json +20 -0
- pytransdecoder/presets/apicomplexan/thresholds.tsv.gz +0 -0
- pytransdecoder/presets/apicomplexan/training_orfs.cds.gz +0 -0
- pytransdecoder/presets/ciliate/hexamer.scores.gz +0 -0
- pytransdecoder/presets/ciliate/kozak.tsv +9 -0
- pytransdecoder/presets/ciliate/preset.json +20 -0
- pytransdecoder/presets/ciliate/thresholds.tsv.gz +0 -0
- pytransdecoder/presets/ciliate/training_orfs.cds.gz +0 -0
- pytransdecoder/presets/fungus/hexamer.scores.gz +0 -0
- pytransdecoder/presets/fungus/kozak.tsv +9 -0
- pytransdecoder/presets/fungus/preset.json +20 -0
- pytransdecoder/presets/fungus/thresholds.tsv.gz +0 -0
- pytransdecoder/presets/fungus/training_orfs.cds.gz +0 -0
- pytransdecoder/presets/green_alga/hexamer.scores.gz +0 -0
- pytransdecoder/presets/green_alga/kozak.tsv +9 -0
- pytransdecoder/presets/green_alga/preset.json +20 -0
- pytransdecoder/presets/green_alga/thresholds.tsv.gz +0 -0
- pytransdecoder/presets/green_alga/training_orfs.cds.gz +0 -0
- pytransdecoder/presets/insect/hexamer.scores.gz +0 -0
- pytransdecoder/presets/insect/kozak.tsv +9 -0
- pytransdecoder/presets/insect/preset.json +20 -0
- pytransdecoder/presets/insect/thresholds.tsv.gz +0 -0
- pytransdecoder/presets/insect/training_orfs.cds.gz +0 -0
- pytransdecoder/presets/mammal/hexamer.scores.gz +0 -0
- pytransdecoder/presets/mammal/kozak.tsv +9 -0
- pytransdecoder/presets/mammal/preset.json +20 -0
- pytransdecoder/presets/mammal/thresholds.tsv.gz +0 -0
- pytransdecoder/presets/mammal/training_orfs.cds.gz +0 -0
- pytransdecoder/presets/plant/hexamer.scores.gz +0 -0
- pytransdecoder/presets/plant/kozak.tsv +9 -0
- pytransdecoder/presets/plant/preset.json +20 -0
- pytransdecoder/presets/plant/thresholds.tsv.gz +0 -0
- pytransdecoder/presets/plant/training_orfs.cds.gz +0 -0
- pytransdecoder-0.2.1.dist-info/METADATA +307 -0
- pytransdecoder-0.2.1.dist-info/RECORD +63 -0
- pytransdecoder-0.2.1.dist-info/WHEEL +5 -0
- pytransdecoder-0.2.1.dist-info/entry_points.txt +3 -0
- pytransdecoder-0.2.1.dist-info/licenses/LICENSE +28 -0
- pytransdecoder-0.2.1.dist-info/top_level.txt +1 -0
|
@@ -0,0 +1 @@
|
|
|
1
|
+
"""Core modules for PyTransDecoder"""
|
|
@@ -0,0 +1,71 @@
|
|
|
1
|
+
"""
|
|
2
|
+
Base frequency calculation
|
|
3
|
+
|
|
4
|
+
Computes nucleotide frequencies for background model.
|
|
5
|
+
Replaces compute_base_probs.pl
|
|
6
|
+
"""
|
|
7
|
+
|
|
8
|
+
from typing import Dict
|
|
9
|
+
from .fasta import read_fasta
|
|
10
|
+
|
|
11
|
+
|
|
12
|
+
def compute_base_counts(fasta_file: str, top_strand_only: bool = False) -> Dict[str, int]:
|
|
13
|
+
"""
|
|
14
|
+
Count nucleotides in a FASTA file, as compute_base_probs.pl does.
|
|
15
|
+
|
|
16
|
+
Unless top_strand_only, each base also increments its complement so that
|
|
17
|
+
the counts reflect both strands (A=T, C=G).
|
|
18
|
+
"""
|
|
19
|
+
complement = {'A': 'T', 'T': 'A', 'G': 'C', 'C': 'G'}
|
|
20
|
+
counts = {base: 0 for base in 'ACGT'}
|
|
21
|
+
for record in read_fasta(fasta_file):
|
|
22
|
+
for base in str(record.seq).upper():
|
|
23
|
+
if base in counts:
|
|
24
|
+
counts[base] += 1
|
|
25
|
+
if not top_strand_only:
|
|
26
|
+
counts[complement[base]] += 1
|
|
27
|
+
return counts
|
|
28
|
+
|
|
29
|
+
|
|
30
|
+
def compute_base_frequencies(fasta_file: str, top_strand_only: bool = False) -> Dict[str, float]:
|
|
31
|
+
"""Nucleotide frequencies from a FASTA file (see compute_base_counts)."""
|
|
32
|
+
counts = compute_base_counts(fasta_file, top_strand_only)
|
|
33
|
+
total = sum(counts.values())
|
|
34
|
+
if total == 0:
|
|
35
|
+
return {base: 0.25 for base in 'ACGT'}
|
|
36
|
+
return {base: counts[base] / total for base in 'ACGT'}
|
|
37
|
+
|
|
38
|
+
|
|
39
|
+
def write_base_frequencies(counts: Dict[str, int], output_file: str):
|
|
40
|
+
"""
|
|
41
|
+
Write base_freqs.dat in TransDecoder's format: base, count, ratio (3 decimals).
|
|
42
|
+
|
|
43
|
+
The rounded ratio is what both implementations read back as the background
|
|
44
|
+
model, so it is written exactly as compute_base_probs.pl does.
|
|
45
|
+
"""
|
|
46
|
+
total = sum(counts.values())
|
|
47
|
+
with open(output_file, 'w') as f:
|
|
48
|
+
for base in 'ACGT':
|
|
49
|
+
ratio = counts[base] / total if total else 0.25
|
|
50
|
+
f.write(f"{base}\t{counts[base]}\t{ratio:.3f}\n")
|
|
51
|
+
|
|
52
|
+
|
|
53
|
+
def read_base_frequencies(input_file: str) -> Dict[str, float]:
|
|
54
|
+
"""Read background base frequencies from base_freqs.dat (base, count, ratio)."""
|
|
55
|
+
frequencies = {}
|
|
56
|
+
with open(input_file) as f:
|
|
57
|
+
for line in f:
|
|
58
|
+
parts = line.split()
|
|
59
|
+
if len(parts) == 3:
|
|
60
|
+
frequencies[parts[0]] = float(parts[2])
|
|
61
|
+
elif len(parts) == 2: # files written by PyTransDecoder <= 0.1.0
|
|
62
|
+
frequencies[parts[0]] = float(parts[1])
|
|
63
|
+
missing = set('ACGT') - set(frequencies)
|
|
64
|
+
if missing:
|
|
65
|
+
raise ValueError(f"{input_file}: missing base frequencies for {sorted(missing)}")
|
|
66
|
+
return frequencies
|
|
67
|
+
|
|
68
|
+
|
|
69
|
+
def calculate_gc_content(frequencies: Dict[str, float]) -> float:
|
|
70
|
+
"""Calculate GC content from base frequencies"""
|
|
71
|
+
return frequencies.get('G', 0) + frequencies.get('C', 0)
|
|
@@ -0,0 +1,113 @@
|
|
|
1
|
+
"""
|
|
2
|
+
Decoy ORFs from shuffled transcripts, for target-decoy FDR estimates of candidate ORFs (experimental).
|
|
3
|
+
|
|
4
|
+
Each transcript is shuffled preserving its dinucleotide counts (Altschul & Erickson 1985; the random
|
|
5
|
+
Eulerian path is drawn with Wilson's algorithm for the last-exit tree), so decoys keep the transcript's
|
|
6
|
+
length and base composition. Random numbers come from core/prng.py, so the Perl implementation
|
|
7
|
+
(PerlLib/Decoys.pm) produces the same decoys. Decoy ORFs are found with the same ORF finder, genetic code, minimum
|
|
8
|
+
length and strand setting as LongOrfs and scored with the run's trained Markov model.
|
|
9
|
+
|
|
10
|
+
Within strata of ORF length (log-spaced bins), transcript length (log2 bins) and ORF type (complete vs
|
|
11
|
+
partial), a target ORF with frame contrast c has
|
|
12
|
+
FDR(c) = decoys with contrast >= c / (shuffles x targets with contrast >= c)
|
|
13
|
+
and its q-value is the lowest FDR of any cutoff that accepts it. Strata without decoys give q = 0.
|
|
14
|
+
"""
|
|
15
|
+
import bisect
|
|
16
|
+
import math
|
|
17
|
+
from collections import defaultdict
|
|
18
|
+
from typing import Dict, Hashable, Iterable, List, Tuple
|
|
19
|
+
|
|
20
|
+
from pytransdecoder.core.prng import Xoshiro128ss
|
|
21
|
+
|
|
22
|
+
LENGTH_BIN_RATIO = 1.1
|
|
23
|
+
LENGTH_BIN_START = 50 # codons
|
|
24
|
+
TX_BIN_START = 500 # nt
|
|
25
|
+
STRATA = ("len", "len_tx", "len_tx_type")
|
|
26
|
+
|
|
27
|
+
|
|
28
|
+
def dinucleotide_shuffle(seq: str, rng: Xoshiro128ss) -> str:
|
|
29
|
+
"""Random sequence with the same first base, last base and dinucleotide counts as seq."""
|
|
30
|
+
n = len(seq)
|
|
31
|
+
if n < 3:
|
|
32
|
+
return seq
|
|
33
|
+
edges = defaultdict(list)
|
|
34
|
+
for a, b in zip(seq, seq[1:]):
|
|
35
|
+
edges[a].append(b)
|
|
36
|
+
end = seq[-1]
|
|
37
|
+
# random last-exit tree rooted at the final base (Wilson's loop-erased random walks)
|
|
38
|
+
in_tree, nxt, last = {end}, {}, {}
|
|
39
|
+
for v in sorted(edges):
|
|
40
|
+
u = v
|
|
41
|
+
while u not in in_tree:
|
|
42
|
+
nxt[u] = edges[u][rng.below(len(edges[u]))]
|
|
43
|
+
u = nxt[u]
|
|
44
|
+
u = v
|
|
45
|
+
while u not in in_tree:
|
|
46
|
+
in_tree.add(u)
|
|
47
|
+
last[u] = nxt[u]
|
|
48
|
+
u = nxt[u]
|
|
49
|
+
order = {}
|
|
50
|
+
for v in sorted(edges):
|
|
51
|
+
out = edges[v][:]
|
|
52
|
+
if v in last:
|
|
53
|
+
out.remove(last[v])
|
|
54
|
+
rng.shuffle(out)
|
|
55
|
+
if v in last:
|
|
56
|
+
out.append(last[v])
|
|
57
|
+
order[v] = out
|
|
58
|
+
pos = defaultdict(int)
|
|
59
|
+
res, u = [seq[0]], seq[0]
|
|
60
|
+
for _ in range(n - 1):
|
|
61
|
+
w = order[u][pos[u]]
|
|
62
|
+
pos[u] += 1
|
|
63
|
+
res.append(w)
|
|
64
|
+
u = w
|
|
65
|
+
return "".join(res)
|
|
66
|
+
|
|
67
|
+
|
|
68
|
+
def _length_bin(codons: int) -> int:
|
|
69
|
+
"""Largest k with codons >= LENGTH_BIN_START * LENGTH_BIN_RATIO**k (powers by repeated
|
|
70
|
+
multiplication, so the Perl port bins identically); -1 below LENGTH_BIN_START."""
|
|
71
|
+
if codons < LENGTH_BIN_START:
|
|
72
|
+
return -1
|
|
73
|
+
k, edge = 0, LENGTH_BIN_START * LENGTH_BIN_RATIO
|
|
74
|
+
while codons >= edge:
|
|
75
|
+
k += 1
|
|
76
|
+
edge *= LENGTH_BIN_RATIO
|
|
77
|
+
return k
|
|
78
|
+
|
|
79
|
+
|
|
80
|
+
def stratum(codons: int, tx_len: int, complete: bool, strata: str = "len_tx_type") -> Tuple:
|
|
81
|
+
"""Stratum key: ORF-length bin, then (len_tx) transcript-length bin (floor(log2(tx_len / 500)) + 1,
|
|
82
|
+
0 below 500 nt), then (len_tx_type) ORF type."""
|
|
83
|
+
k = (_length_bin(codons),)
|
|
84
|
+
if strata in ("len_tx", "len_tx_type"):
|
|
85
|
+
k += ((tx_len // TX_BIN_START).bit_length() if tx_len >= TX_BIN_START else 0,)
|
|
86
|
+
if strata == "len_tx_type":
|
|
87
|
+
k += (bool(complete),)
|
|
88
|
+
return k
|
|
89
|
+
|
|
90
|
+
|
|
91
|
+
def qvalues(targets: Dict[str, Tuple[Hashable, float]], decoys: Iterable[Tuple[Hashable, float]],
|
|
92
|
+
n_shuffles: int) -> Dict[str, float]:
|
|
93
|
+
"""q-value per target ORF id from (stratum, contrast) of targets and decoys (eligible ORFs only)."""
|
|
94
|
+
tby, dby = defaultdict(list), defaultdict(list)
|
|
95
|
+
for orf_id, (k, c) in targets.items():
|
|
96
|
+
tby[k].append(c)
|
|
97
|
+
for k, c in decoys:
|
|
98
|
+
dby[k].append(c)
|
|
99
|
+
q_of = {}
|
|
100
|
+
for k, tc in tby.items():
|
|
101
|
+
tc = sorted(tc)
|
|
102
|
+
dc = sorted(dby.get(k, ()))
|
|
103
|
+
q, run_min = [], math.inf
|
|
104
|
+
for i, c in enumerate(tc):
|
|
105
|
+
fdr = (len(dc) - bisect.bisect_left(dc, c)) / n_shuffles / (len(tc) - i)
|
|
106
|
+
run_min = min(run_min, fdr)
|
|
107
|
+
q.append(run_min)
|
|
108
|
+
q_of[k] = (tc, q)
|
|
109
|
+
out = {}
|
|
110
|
+
for orf_id, (k, c) in targets.items():
|
|
111
|
+
tc, q = q_of[k]
|
|
112
|
+
out[orf_id] = q[bisect.bisect_left(tc, c)]
|
|
113
|
+
return out
|
|
@@ -0,0 +1,55 @@
|
|
|
1
|
+
"""
|
|
2
|
+
FASTA reading shared by all PyTransDecoder steps.
|
|
3
|
+
|
|
4
|
+
Text before the first '>' header line (e.g. ';' comment lines, as in FAIR Header Reference
|
|
5
|
+
files) is skipped with a warning, as Perl TransDecoder's Fasta_reader does. Biopython's "fasta"
|
|
6
|
+
parser rejects such files, and older Perl versions read the text as an extra sequence.
|
|
7
|
+
"""
|
|
8
|
+
|
|
9
|
+
from __future__ import annotations
|
|
10
|
+
|
|
11
|
+
import logging
|
|
12
|
+
from pathlib import Path
|
|
13
|
+
from typing import IO, Iterator, Union
|
|
14
|
+
|
|
15
|
+
from Bio.Seq import Seq
|
|
16
|
+
from Bio.SeqRecord import SeqRecord
|
|
17
|
+
|
|
18
|
+
logger = logging.getLogger(__name__)
|
|
19
|
+
|
|
20
|
+
|
|
21
|
+
def read_fasta(source: Union[str, Path, IO[str]]) -> Iterator[SeqRecord]:
|
|
22
|
+
"""Yield SeqRecords (id = first word of the header, description = whole header) from a
|
|
23
|
+
path or an open text handle. Whitespace inside sequences is removed."""
|
|
24
|
+
if isinstance(source, (str, Path)):
|
|
25
|
+
with open(source) as handle:
|
|
26
|
+
yield from _parse(handle, str(source))
|
|
27
|
+
else:
|
|
28
|
+
yield from _parse(source, getattr(source, "name", "FASTA input"))
|
|
29
|
+
|
|
30
|
+
|
|
31
|
+
def _parse(handle: IO[str], label: str) -> Iterator[SeqRecord]:
|
|
32
|
+
header = None
|
|
33
|
+
chunks = []
|
|
34
|
+
skipped_text = False
|
|
35
|
+
for line in handle:
|
|
36
|
+
if line.startswith(">"):
|
|
37
|
+
if header is not None:
|
|
38
|
+
yield _record(header, chunks)
|
|
39
|
+
elif skipped_text:
|
|
40
|
+
logger.warning("%s: skipped text before the first '>' header line", label)
|
|
41
|
+
header, chunks = line[1:].rstrip("\r\n"), []
|
|
42
|
+
elif header is None:
|
|
43
|
+
skipped_text = skipped_text or bool(line.strip())
|
|
44
|
+
else:
|
|
45
|
+
chunks.append("".join(line.split()))
|
|
46
|
+
if header is None and skipped_text:
|
|
47
|
+
logger.warning("%s: no '>' header line found; no sequences read", label)
|
|
48
|
+
if header is not None:
|
|
49
|
+
yield _record(header, chunks)
|
|
50
|
+
|
|
51
|
+
|
|
52
|
+
def _record(header: str, chunks) -> SeqRecord:
|
|
53
|
+
words = header.split()
|
|
54
|
+
seq_id = words[0] if words else ""
|
|
55
|
+
return SeqRecord(Seq("".join(chunks)), id=seq_id, name=seq_id, description=header)
|
|
@@ -0,0 +1,143 @@
|
|
|
1
|
+
"""
|
|
2
|
+
Coding gene models (exons + a CDS range) and their GFF3 / BED representation.
|
|
3
|
+
|
|
4
|
+
Used for TransDecoder's transcript-level outputs (one exon spanning the transcript) and for
|
|
5
|
+
ORFs propagated to the genome through transcript structures. The GFF3 layout follows Perl
|
|
6
|
+
TransDecoder's Gene_obj::to_GFF3_format: gene, then per isoform mRNA, five_prime_UTR, exon (+
|
|
7
|
+
CDS) in transcription order, three_prime_UTR; a blank line after each gene.
|
|
8
|
+
"""
|
|
9
|
+
|
|
10
|
+
from __future__ import annotations
|
|
11
|
+
|
|
12
|
+
from dataclasses import dataclass, field
|
|
13
|
+
from typing import List, Optional, Sequence, Tuple
|
|
14
|
+
|
|
15
|
+
Segment = Tuple[int, int]
|
|
16
|
+
|
|
17
|
+
|
|
18
|
+
@dataclass
|
|
19
|
+
class CodingModel:
|
|
20
|
+
"""One transcript isoform with a CDS. Coordinates are 1-based inclusive on `seqid`;
|
|
21
|
+
exons are (lend, rend) with lend <= rend; cds_lend/cds_rend bound the CDS (incl. stop)."""
|
|
22
|
+
seqid: str
|
|
23
|
+
strand: str
|
|
24
|
+
exons: List[Segment]
|
|
25
|
+
cds_lend: int
|
|
26
|
+
cds_rend: int
|
|
27
|
+
model_id: str
|
|
28
|
+
name: str = ""
|
|
29
|
+
extra_attrs: str = "" # appended to the mRNA attributes (e.g. "score=...;...")
|
|
30
|
+
|
|
31
|
+
def __post_init__(self):
|
|
32
|
+
if self.strand not in ("+", "-"):
|
|
33
|
+
raise ValueError(f"strand must be + or -, not {self.strand!r}")
|
|
34
|
+
self.exons = sorted((min(a, b), max(a, b)) for a, b in self.exons)
|
|
35
|
+
self.cds_lend, self.cds_rend = sorted((self.cds_lend, self.cds_rend))
|
|
36
|
+
|
|
37
|
+
# -- ordering helpers -------------------------------------------------------------
|
|
38
|
+
def _in_tx_order(self, segments: Sequence[Segment]) -> List[Segment]:
|
|
39
|
+
segs = sorted(segments)
|
|
40
|
+
return segs if self.strand == "+" else segs[::-1]
|
|
41
|
+
|
|
42
|
+
def exons_tx_order(self) -> List[Segment]:
|
|
43
|
+
return self._in_tx_order(self.exons)
|
|
44
|
+
|
|
45
|
+
def cds_segments(self) -> List[Segment]:
|
|
46
|
+
"""CDS pieces per exon, in transcription order."""
|
|
47
|
+
segs = [(max(a, self.cds_lend), min(b, self.cds_rend)) for a, b in self.exons
|
|
48
|
+
if b >= self.cds_lend and a <= self.cds_rend]
|
|
49
|
+
if not segs:
|
|
50
|
+
raise ValueError(f"{self.model_id}: CDS {self.cds_lend}-{self.cds_rend} lies outside its exons")
|
|
51
|
+
return self._in_tx_order(segs)
|
|
52
|
+
|
|
53
|
+
def _utr(self, left: bool) -> List[Segment]:
|
|
54
|
+
if left:
|
|
55
|
+
segs = [(a, min(b, self.cds_lend - 1)) for a, b in self.exons if a < self.cds_lend]
|
|
56
|
+
else:
|
|
57
|
+
segs = [(max(a, self.cds_rend + 1), b) for a, b in self.exons if b > self.cds_rend]
|
|
58
|
+
return self._in_tx_order(segs)
|
|
59
|
+
|
|
60
|
+
def utr5(self) -> List[Segment]:
|
|
61
|
+
return _min_utr(self._utr(left=(self.strand == "+")))
|
|
62
|
+
|
|
63
|
+
def utr3(self) -> List[Segment]:
|
|
64
|
+
return _min_utr(self._utr(left=(self.strand == "-")))
|
|
65
|
+
|
|
66
|
+
def span(self) -> Segment:
|
|
67
|
+
return self.exons[0][0], self.exons[-1][1]
|
|
68
|
+
|
|
69
|
+
# -- output -----------------------------------------------------------------------
|
|
70
|
+
def gff3_lines(self, gene_id: str, source: str) -> List[str]:
|
|
71
|
+
lend, rend = self.span()
|
|
72
|
+
name = _quoted(self.name)
|
|
73
|
+
attrs = f"ID={self.model_id};Parent={gene_id}" + (f";Name={name}" if name else "") + \
|
|
74
|
+
(f";{self.extra_attrs}" if self.extra_attrs else "")
|
|
75
|
+
lines = [_row(self.seqid, source, "mRNA", lend, rend, self.strand, ".", attrs)]
|
|
76
|
+
for i, (a, b) in enumerate(self.utr5(), 1):
|
|
77
|
+
lines.append(_row(self.seqid, source, "five_prime_UTR", a, b, self.strand, ".",
|
|
78
|
+
f"ID={self.model_id}.utr5p{i};Parent={self.model_id}"))
|
|
79
|
+
cds = dict()
|
|
80
|
+
cds_len = 0
|
|
81
|
+
for a, b in self.cds_segments():
|
|
82
|
+
# GFF3 phase: bases to skip from this segment's 5' end to reach a codon start
|
|
83
|
+
cds[(a, b)] = (3 - cds_len % 3) % 3
|
|
84
|
+
cds_len += b - a + 1
|
|
85
|
+
for i, (ea, eb) in enumerate(self.exons_tx_order(), 1):
|
|
86
|
+
lines.append(_row(self.seqid, source, "exon", ea, eb, self.strand, ".",
|
|
87
|
+
f"ID={self.model_id}.exon{i};Parent={self.model_id}"))
|
|
88
|
+
for (a, b), phase in cds.items():
|
|
89
|
+
if ea <= a and b <= eb:
|
|
90
|
+
lines.append(_row(self.seqid, source, "CDS", a, b, self.strand, str(phase),
|
|
91
|
+
f"ID=cds.{self.model_id};Parent={self.model_id}"))
|
|
92
|
+
for i, (a, b) in enumerate(self.utr3(), 1):
|
|
93
|
+
lines.append(_row(self.seqid, source, "three_prime_UTR", a, b, self.strand, ".",
|
|
94
|
+
f"ID={self.model_id}.utr3p{i};Parent={self.model_id}"))
|
|
95
|
+
return lines
|
|
96
|
+
|
|
97
|
+
def bed12(self, name: str) -> str:
|
|
98
|
+
"""BED12 line (as Perl gff3_file_to_bed.pl: thickStart/End = CDS span)."""
|
|
99
|
+
lend, rend = self.span()
|
|
100
|
+
sizes = ",".join(str(b - a + 1) for a, b in self.exons)
|
|
101
|
+
starts = ",".join(str(a - lend) for a, _ in self.exons)
|
|
102
|
+
return "\t".join(map(str, [self.seqid, lend - 1, rend, name, 0, self.strand,
|
|
103
|
+
self.cds_lend - 1, self.cds_rend, 0, len(self.exons), sizes, starts]))
|
|
104
|
+
|
|
105
|
+
|
|
106
|
+
@dataclass
|
|
107
|
+
class CodingGene:
|
|
108
|
+
gene_id: str
|
|
109
|
+
models: List[CodingModel] = field(default_factory=list)
|
|
110
|
+
name: str = ""
|
|
111
|
+
|
|
112
|
+
def gff3_text(self, source: str = "transdecoder") -> str:
|
|
113
|
+
m0 = self.models[0]
|
|
114
|
+
lend = min(m.span()[0] for m in self.models)
|
|
115
|
+
rend = max(m.span()[1] for m in self.models)
|
|
116
|
+
name = _quoted(self.name if self.name else m0.name)
|
|
117
|
+
gene_attrs = f"ID={_quote_id(self.gene_id)}" + (f";Name={name}" if name else "")
|
|
118
|
+
lines = [_row(m0.seqid, source, "gene", lend, rend, m0.strand, ".", gene_attrs)]
|
|
119
|
+
for m in self.models:
|
|
120
|
+
lines += m.gff3_lines(_quote_id(self.gene_id), source)
|
|
121
|
+
return "\n".join(lines) + "\n\n"
|
|
122
|
+
|
|
123
|
+
|
|
124
|
+
def _min_utr(segments: List[Segment]) -> List[Segment]:
|
|
125
|
+
# as Perl Gene_obj::has_5prime_UTR / has_3prime_UTR: a UTR needs more than 2 nt in total,
|
|
126
|
+
# so the 1-2 nt left by a partial ORF's frame offset is not reported
|
|
127
|
+
return segments if sum(b - a + 1 for a, b in segments) > 2 else []
|
|
128
|
+
|
|
129
|
+
|
|
130
|
+
def _row(seqid, source, ftype, a, b, strand, phase, attrs) -> str:
|
|
131
|
+
return f"{seqid}\t{source}\t{ftype}\t{a}\t{b}\t.\t{strand}\t{phase}\t{attrs}"
|
|
132
|
+
|
|
133
|
+
|
|
134
|
+
def _quoted(name: Optional[str]) -> str:
|
|
135
|
+
if not name:
|
|
136
|
+
return ""
|
|
137
|
+
if name[0] in "'\"" and name[-1] in "'\"":
|
|
138
|
+
return name
|
|
139
|
+
return f'"{name}"'
|
|
140
|
+
|
|
141
|
+
|
|
142
|
+
def _quote_id(identifier: str) -> str:
|
|
143
|
+
return f'"{identifier}"' if ";" in identifier else identifier
|
|
@@ -0,0 +1,58 @@
|
|
|
1
|
+
"""
|
|
2
|
+
Simple GFF3 parser for TransDecoder
|
|
3
|
+
|
|
4
|
+
Parses GFF3 files generated by LongOrfs phase.
|
|
5
|
+
"""
|
|
6
|
+
|
|
7
|
+
from typing import Generator, Dict, Any
|
|
8
|
+
|
|
9
|
+
|
|
10
|
+
def parse_gff3(gff3_file: str) -> Generator[Dict[str, Any], None, None]:
|
|
11
|
+
"""
|
|
12
|
+
Parse a GFF3 file
|
|
13
|
+
|
|
14
|
+
Args:
|
|
15
|
+
gff3_file: Path to GFF3 file
|
|
16
|
+
|
|
17
|
+
Yields:
|
|
18
|
+
Dictionary for each GFF3 feature with keys:
|
|
19
|
+
- seqid: sequence ID
|
|
20
|
+
- source: source
|
|
21
|
+
- type: feature type
|
|
22
|
+
- start: start position (1-based)
|
|
23
|
+
- end: end position (1-based)
|
|
24
|
+
- score: score
|
|
25
|
+
- strand: strand
|
|
26
|
+
- phase: phase
|
|
27
|
+
- attributes: dict of attributes
|
|
28
|
+
"""
|
|
29
|
+
with open(gff3_file) as f:
|
|
30
|
+
for line in f:
|
|
31
|
+
line = line.strip()
|
|
32
|
+
|
|
33
|
+
# Skip empty lines and comments
|
|
34
|
+
if not line or line.startswith('#'):
|
|
35
|
+
continue
|
|
36
|
+
|
|
37
|
+
parts = line.split('\t')
|
|
38
|
+
if len(parts) != 9:
|
|
39
|
+
continue
|
|
40
|
+
|
|
41
|
+
# Parse attributes
|
|
42
|
+
attributes = {}
|
|
43
|
+
for attr in parts[8].split(';'):
|
|
44
|
+
if '=' in attr:
|
|
45
|
+
key, value = attr.split('=', 1)
|
|
46
|
+
attributes[key] = value.split(',')
|
|
47
|
+
|
|
48
|
+
yield {
|
|
49
|
+
'seqid': parts[0],
|
|
50
|
+
'source': parts[1],
|
|
51
|
+
'type': parts[2],
|
|
52
|
+
'start': int(parts[3]),
|
|
53
|
+
'end': int(parts[4]),
|
|
54
|
+
'score': parts[5],
|
|
55
|
+
'strand': parts[6],
|
|
56
|
+
'phase': parts[7],
|
|
57
|
+
'attributes': attributes
|
|
58
|
+
}
|
|
@@ -0,0 +1,54 @@
|
|
|
1
|
+
"""
|
|
2
|
+
Homology-assisted start refinement (used only when BLASTP and/or Pfam hits are supplied to Predict).
|
|
3
|
+
|
|
4
|
+
For an ORF that is open at its 5' end and has a first in-frame ATG at peptide position a (1-based
|
|
5
|
+
position in the longest-ORF peptide, whose position 1 is the first whole codon at the open end):
|
|
6
|
+
|
|
7
|
+
R2 (BLASTP, trim): the best hit's subject N-terminus is aligned (sstart <= R2_MAX_SSTART) and maps
|
|
8
|
+
to within R2_MAX_OFFSET aa of the ATG: |qstart - (sstart - 1) - a| <= R2_MAX_OFFSET
|
|
9
|
+
R1 (BLASTP, keep): the best hit's alignment starts >= R1_MIN_UPSTREAM aa upstream of the ATG
|
|
10
|
+
(qstart <= a - R1_MIN_UPSTREAM) and the subject continues further N-terminal
|
|
11
|
+
(sstart > 1)
|
|
12
|
+
R3 (Pfam, keep): a Pfam domain envelope starts >= R3_MIN_UPSTREAM aa upstream of the ATG
|
|
13
|
+
(env_from <= a - R3_MIN_UPSTREAM)
|
|
14
|
+
|
|
15
|
+
R2 calls trim-to-ATG; R1 or R3 call keep-5'-open. When both a trim and a keep call fire, or none
|
|
16
|
+
fires, the ab initio coding-vs-UTR rule decides. Thresholds: TransDecoderPaper evidence E33 step 3
|
|
17
|
+
(fixed before evaluation on simulated Trinity assemblies and the cross-species benchmark).
|
|
18
|
+
"""
|
|
19
|
+
from typing import List, Optional, Tuple
|
|
20
|
+
|
|
21
|
+
R2_MAX_SSTART = 3
|
|
22
|
+
R2_MAX_OFFSET = 5
|
|
23
|
+
R1_MIN_UPSTREAM = 5
|
|
24
|
+
R3_MIN_UPSTREAM = 1
|
|
25
|
+
|
|
26
|
+
|
|
27
|
+
def homology_start_call(atg_aa: int, blast: Optional[Tuple[int, int]] = None,
|
|
28
|
+
pfam_env_from: Optional[int] = None) -> Tuple[List[str], Optional[str]]:
|
|
29
|
+
"""
|
|
30
|
+
Args:
|
|
31
|
+
atg_aa: 1-based peptide position of the first in-frame ATG
|
|
32
|
+
blast: (qstart, sstart) of the ORF's best BLASTP hit, or None (no hit / no BLASTP input)
|
|
33
|
+
pfam_env_from: smallest envelope start of the ORF's Pfam domains, or None
|
|
34
|
+
Returns:
|
|
35
|
+
(rules fired, call) with call 'trim', 'keep', 'conflict' or None (no rule fired)
|
|
36
|
+
"""
|
|
37
|
+
fired = []
|
|
38
|
+
if blast is not None:
|
|
39
|
+
qstart, sstart = blast
|
|
40
|
+
if sstart <= R2_MAX_SSTART and abs(qstart - (sstart - 1) - atg_aa) <= R2_MAX_OFFSET:
|
|
41
|
+
fired.append("R2")
|
|
42
|
+
if qstart <= atg_aa - R1_MIN_UPSTREAM and sstart > 1:
|
|
43
|
+
fired.append("R1")
|
|
44
|
+
if pfam_env_from is not None and pfam_env_from <= atg_aa - R3_MIN_UPSTREAM:
|
|
45
|
+
fired.append("R3")
|
|
46
|
+
trim = "R2" in fired
|
|
47
|
+
keep = "R1" in fired or "R3" in fired
|
|
48
|
+
if trim and keep:
|
|
49
|
+
return fired, "conflict"
|
|
50
|
+
if trim:
|
|
51
|
+
return fired, "trim"
|
|
52
|
+
if keep:
|
|
53
|
+
return fired, "keep"
|
|
54
|
+
return fired, None
|
|
@@ -0,0 +1,180 @@
|
|
|
1
|
+
"""
|
|
2
|
+
Start-codon (Kozak) context model used by start codon refinement.
|
|
3
|
+
|
|
4
|
+
Positions -6..-1 and +4..+5 around an ATG (A of ATG = +1; the ATG itself is not scored).
|
|
5
|
+
Log2-odds (bits, +1 pseudocounts) of training ORF start contexts vs in-frame ATGs inside the
|
|
6
|
+
training ORFs: +1 bit = twice as start-like as an average internal ATG. Same model and file formats as Perl TransDecoder (PerlLib/Kozak_model.pm,
|
|
7
|
+
util/train_kozak_model.pl).
|
|
8
|
+
"""
|
|
9
|
+
|
|
10
|
+
from __future__ import annotations
|
|
11
|
+
|
|
12
|
+
import math
|
|
13
|
+
from pathlib import Path
|
|
14
|
+
from typing import Dict, List, Optional, Sequence
|
|
15
|
+
|
|
16
|
+
UPSTREAM = 6
|
|
17
|
+
DOWNSTREAM = 2
|
|
18
|
+
BASES = ("A", "C", "G", "T")
|
|
19
|
+
MIN_TRAINING_STARTS = 50
|
|
20
|
+
|
|
21
|
+
Counts = List[Dict[str, int]]
|
|
22
|
+
Model = List[Dict[str, float]]
|
|
23
|
+
|
|
24
|
+
|
|
25
|
+
def position_labels() -> List[str]:
|
|
26
|
+
return [str(k) for k in range(-UPSTREAM, 0)] + [f"+{k}" for k in range(4, 4 + DOWNSTREAM)]
|
|
27
|
+
|
|
28
|
+
|
|
29
|
+
def window(sequence: str, atg_index: int) -> List[Optional[str]]:
|
|
30
|
+
"""Bases at -6..-1 and +4..+5 around the ATG at atg_index (None outside the sequence)."""
|
|
31
|
+
positions = list(range(atg_index - UPSTREAM, atg_index)) + \
|
|
32
|
+
list(range(atg_index + 3, atg_index + 3 + DOWNSTREAM))
|
|
33
|
+
return [sequence[j] if 0 <= j < len(sequence) and sequence[j] in "ACGT" else None
|
|
34
|
+
for j in positions]
|
|
35
|
+
|
|
36
|
+
|
|
37
|
+
def new_counts() -> Counts:
|
|
38
|
+
return [dict.fromkeys(BASES, 0) for _ in range(UPSTREAM + DOWNSTREAM)]
|
|
39
|
+
|
|
40
|
+
|
|
41
|
+
def add_site(counts: Counts, sequence: str, atg_index: int) -> None:
|
|
42
|
+
for k, base in enumerate(window(sequence, atg_index)):
|
|
43
|
+
if base:
|
|
44
|
+
counts[k][base] += 1
|
|
45
|
+
|
|
46
|
+
|
|
47
|
+
def log_odds_model(pos_counts: Counts, neg_counts: Counts) -> Model:
|
|
48
|
+
model = []
|
|
49
|
+
for pc, nc in zip(pos_counts, neg_counts):
|
|
50
|
+
pt, nt = sum(pc.values()) + 4, sum(nc.values()) + 4
|
|
51
|
+
model.append({b: math.log2(((pc[b] + 1) / pt) / ((nc[b] + 1) / nt)) for b in BASES})
|
|
52
|
+
return model
|
|
53
|
+
|
|
54
|
+
|
|
55
|
+
def score(model: Model, sequence: str, atg_index: int) -> float:
|
|
56
|
+
return sum(model[k][b] for k, b in enumerate(window(sequence, atg_index)) if b)
|
|
57
|
+
|
|
58
|
+
|
|
59
|
+
def write_model(model: Model, num_starts: int, filename: Path) -> None:
|
|
60
|
+
with open(filename, "w") as out:
|
|
61
|
+
out.write("#position\tA\tC\tG\tT (log2-odds in bits, start vs internal in-frame ATG; "
|
|
62
|
+
f"{num_starts} training starts)\n")
|
|
63
|
+
for label, row in zip(position_labels(), model):
|
|
64
|
+
out.write(label + "\t" + "\t".join(f"{row[b]:.3f}" for b in BASES) + "\n")
|
|
65
|
+
|
|
66
|
+
|
|
67
|
+
def read_model(filename: Path) -> Model:
|
|
68
|
+
model = []
|
|
69
|
+
with open(filename) as handle:
|
|
70
|
+
for line in handle:
|
|
71
|
+
if line.startswith("#"):
|
|
72
|
+
continue
|
|
73
|
+
parts = line.rstrip("\n").split("\t")
|
|
74
|
+
if len(parts) == 5:
|
|
75
|
+
model.append({b: float(v) for b, v in zip(BASES, parts[1:])})
|
|
76
|
+
if len(model) != UPSTREAM + DOWNSTREAM:
|
|
77
|
+
raise ValueError(f"{filename} does not have {UPSTREAM + DOWNSTREAM} positions")
|
|
78
|
+
return model
|
|
79
|
+
|
|
80
|
+
|
|
81
|
+
def write_counts(pos_counts: Counts, neg_counts: Counts, filename: Path) -> None:
|
|
82
|
+
with open(filename, "w") as out:
|
|
83
|
+
out.write("\t".join(["#position"] + [f"start_{b}" for b in BASES] +
|
|
84
|
+
[f"internal_{b}" for b in BASES]) + "\n")
|
|
85
|
+
for label, pc, nc in zip(position_labels(), pos_counts, neg_counts):
|
|
86
|
+
out.write("\t".join([label] + [str(pc[b]) for b in BASES] + [str(nc[b]) for b in BASES]) + "\n")
|
|
87
|
+
|
|
88
|
+
|
|
89
|
+
def _nice_ceiling(value: float) -> float:
|
|
90
|
+
for step in (0.25, 0.5, 1, 1.5, 2, 3, 4, 5, 6, 8, 10):
|
|
91
|
+
if value <= step:
|
|
92
|
+
return step
|
|
93
|
+
return int(value) + 1
|
|
94
|
+
|
|
95
|
+
|
|
96
|
+
def _fmt(v: float) -> str:
|
|
97
|
+
return f"{v:g}"
|
|
98
|
+
|
|
99
|
+
|
|
100
|
+
def write_logo_svg(pos_counts: Counts, model: Model, num_starts: int, filename: Path) -> None:
|
|
101
|
+
"""
|
|
102
|
+
Two panels over -6..-1, ATG, +4..+5 (the ATG itself is labeled, not drawn):
|
|
103
|
+
top: information content (bits) of the training start contexts
|
|
104
|
+
bottom: log2-odds vs internal in-frame ATGs, positive above and negative below the axis
|
|
105
|
+
Both y scales fit the data (at least 0.5 bits / 1 log2 unit).
|
|
106
|
+
"""
|
|
107
|
+
color = {"A": "#109648", "C": "#255C99", "G": "#F7B32B", "T": "#D62839"}
|
|
108
|
+
glyph_w = {"A": 72, "C": 72, "G": 78, "T": 61} # bold Helvetica/Arial at 100px; cap height ~72px
|
|
109
|
+
labels = position_labels()
|
|
110
|
+
cols = labels[:UPSTREAM] + ["A", "T", "G"] + labels[UPSTREAM:]
|
|
111
|
+
colw, left, top_h, bot_h, gap = 40, 56, 150, 220, 56
|
|
112
|
+
|
|
113
|
+
ic_stack, up_stack, dn_stack = [], [], []
|
|
114
|
+
max_ic, max_lo = 0.5, 1.0
|
|
115
|
+
for k in range(UPSTREAM + DOWNSTREAM):
|
|
116
|
+
n = sum(pos_counts[k].values())
|
|
117
|
+
f = {b: (pos_counts[k][b] + 0.5) / (n + 2) for b in BASES}
|
|
118
|
+
ic = 2 + sum(f[b] * math.log2(f[b]) for b in BASES)
|
|
119
|
+
ic_stack.append([(b, f[b] * ic) for b in sorted(BASES, key=lambda b: f[b])])
|
|
120
|
+
max_ic = max(max_ic, ic)
|
|
121
|
+
lo = model[k]
|
|
122
|
+
up = [(b, lo[b]) for b in sorted(BASES, key=lambda b: lo[b]) if lo[b] > 0]
|
|
123
|
+
dn = [(b, -lo[b]) for b in sorted(BASES, key=lambda b: -lo[b]) if lo[b] < 0]
|
|
124
|
+
up_stack.append(up)
|
|
125
|
+
dn_stack.append(dn)
|
|
126
|
+
max_lo = max(max_lo, sum(h for _, h in up), sum(h for _, h in dn))
|
|
127
|
+
ic_top, lo_top = _nice_ceiling(max_ic), _nice_ceiling(max_lo)
|
|
128
|
+
|
|
129
|
+
top_y0 = 40 + top_h
|
|
130
|
+
bot_axis = top_y0 + gap + bot_h / 2
|
|
131
|
+
height = top_y0 + gap + bot_h + 40
|
|
132
|
+
width = left + len(cols) * colw + 20
|
|
133
|
+
ic_px, lo_px = top_h / ic_top, (bot_h / 2) / lo_top
|
|
134
|
+
|
|
135
|
+
svg = [f'<svg xmlns="http://www.w3.org/2000/svg" width="{width}" height="{_fmt(height)}" '
|
|
136
|
+
'font-family="Helvetica,Arial,sans-serif">',
|
|
137
|
+
'<rect width="100%" height="100%" fill="white"/>',
|
|
138
|
+
f'<text x="{left}" y="22" font-size="14">Start-codon context of {num_starts} training ORF starts (bits)</text>',
|
|
139
|
+
f'<text x="{left}" y="{_fmt(top_y0 + gap - 14)}" font-size="14">Log2-odds vs in-frame ATGs inside the training ORFs</text>']
|
|
140
|
+
|
|
141
|
+
def letter(b: str, x: float, y_base: float, h: float) -> None:
|
|
142
|
+
if h < 0.5:
|
|
143
|
+
return
|
|
144
|
+
svg.append(f'<text x="0" y="0" font-size="100" font-weight="bold" fill="{color[b]}" '
|
|
145
|
+
f'transform="translate({x + 2:.1f},{y_base:.1f}) scale({(colw - 4) / glyph_w[b]:.4f},{h / 72:.4f})">{b}</text>')
|
|
146
|
+
|
|
147
|
+
for c, label in enumerate(cols):
|
|
148
|
+
x = left + c * colw
|
|
149
|
+
is_atg = UPSTREAM <= c < UPSTREAM + 3
|
|
150
|
+
style = ' font-weight="bold" fill="#888888"' if is_atg else ""
|
|
151
|
+
svg.append(f'<text x="{x + colw / 2:.1f}" y="{height - 14:.1f}" font-size="{16 if is_atg else 12}" '
|
|
152
|
+
f'text-anchor="middle"{style}>{label}</text>')
|
|
153
|
+
if is_atg:
|
|
154
|
+
continue
|
|
155
|
+
k = c if c < UPSTREAM else c - 3
|
|
156
|
+
y = top_y0
|
|
157
|
+
for b, v in ic_stack[k]:
|
|
158
|
+
letter(b, x, y, v * ic_px)
|
|
159
|
+
y -= v * ic_px
|
|
160
|
+
y = bot_axis
|
|
161
|
+
for b, v in up_stack[k]:
|
|
162
|
+
letter(b, x, y, v * lo_px)
|
|
163
|
+
y -= v * lo_px
|
|
164
|
+
y = bot_axis
|
|
165
|
+
for b, v in dn_stack[k]:
|
|
166
|
+
y += v * lo_px
|
|
167
|
+
letter(b, x, y, v * lo_px)
|
|
168
|
+
|
|
169
|
+
xr = left + len(cols) * colw
|
|
170
|
+
svg.append(f'<line x1="{left}" y1="{_fmt(top_y0)}" x2="{xr}" y2="{_fmt(top_y0)}" stroke="black"/>')
|
|
171
|
+
svg.append(f'<line x1="{left}" y1="{_fmt(bot_axis)}" x2="{xr}" y2="{_fmt(bot_axis)}" stroke="black"/>')
|
|
172
|
+
svg.append(f'<line x1="{left - 4}" y1="{top_y0 - top_h:.1f}" x2="{left - 4}" y2="{top_y0:.1f}" stroke="black"/>')
|
|
173
|
+
svg.append(f'<line x1="{left - 4}" y1="{bot_axis - bot_h / 2:.1f}" x2="{left - 4}" y2="{bot_axis + bot_h / 2:.1f}" stroke="black"/>')
|
|
174
|
+
for text, y in [(0, top_y0), (_fmt(ic_top / 2), top_y0 - top_h / 2), (_fmt(ic_top), top_y0 - top_h),
|
|
175
|
+
(f"+{_fmt(lo_top)}", bot_axis - bot_h / 2), (0, bot_axis), (f"-{_fmt(lo_top)}", bot_axis + bot_h / 2)]:
|
|
176
|
+
svg.append(f'<text x="{left - 8}" y="{y + 4:.1f}" font-size="11" text-anchor="end">{text}</text>')
|
|
177
|
+
svg.append("</svg>")
|
|
178
|
+
|
|
179
|
+
with open(filename, "w") as out:
|
|
180
|
+
out.write("\n".join(svg) + "\n")
|