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.
Files changed (63) hide show
  1. pytransdecoder/__init__.py +7 -0
  2. pytransdecoder/__main__.py +6 -0
  3. pytransdecoder/cli.py +781 -0
  4. pytransdecoder/core/__init__.py +1 -0
  5. pytransdecoder/core/base_freqs.py +71 -0
  6. pytransdecoder/core/decoys.py +113 -0
  7. pytransdecoder/core/fasta.py +55 -0
  8. pytransdecoder/core/gene_model.py +143 -0
  9. pytransdecoder/core/gff3_parser.py +58 -0
  10. pytransdecoder/core/homology_starts.py +54 -0
  11. pytransdecoder/core/kozak.py +180 -0
  12. pytransdecoder/core/models.py +123 -0
  13. pytransdecoder/core/orf_finder.py +317 -0
  14. pytransdecoder/core/presets.py +152 -0
  15. pytransdecoder/core/prng.py +54 -0
  16. pytransdecoder/core/pwm.py +174 -0
  17. pytransdecoder/core/sequence.py +19 -0
  18. pytransdecoder/core/translator.py +143 -0
  19. pytransdecoder/core/writers.py +69 -0
  20. pytransdecoder/genome.py +338 -0
  21. pytransdecoder/longorfs.py +180 -0
  22. pytransdecoder/predict.py +1621 -0
  23. pytransdecoder/presets/apicomplexan/hexamer.scores.gz +0 -0
  24. pytransdecoder/presets/apicomplexan/kozak.tsv +9 -0
  25. pytransdecoder/presets/apicomplexan/preset.json +20 -0
  26. pytransdecoder/presets/apicomplexan/thresholds.tsv.gz +0 -0
  27. pytransdecoder/presets/apicomplexan/training_orfs.cds.gz +0 -0
  28. pytransdecoder/presets/ciliate/hexamer.scores.gz +0 -0
  29. pytransdecoder/presets/ciliate/kozak.tsv +9 -0
  30. pytransdecoder/presets/ciliate/preset.json +20 -0
  31. pytransdecoder/presets/ciliate/thresholds.tsv.gz +0 -0
  32. pytransdecoder/presets/ciliate/training_orfs.cds.gz +0 -0
  33. pytransdecoder/presets/fungus/hexamer.scores.gz +0 -0
  34. pytransdecoder/presets/fungus/kozak.tsv +9 -0
  35. pytransdecoder/presets/fungus/preset.json +20 -0
  36. pytransdecoder/presets/fungus/thresholds.tsv.gz +0 -0
  37. pytransdecoder/presets/fungus/training_orfs.cds.gz +0 -0
  38. pytransdecoder/presets/green_alga/hexamer.scores.gz +0 -0
  39. pytransdecoder/presets/green_alga/kozak.tsv +9 -0
  40. pytransdecoder/presets/green_alga/preset.json +20 -0
  41. pytransdecoder/presets/green_alga/thresholds.tsv.gz +0 -0
  42. pytransdecoder/presets/green_alga/training_orfs.cds.gz +0 -0
  43. pytransdecoder/presets/insect/hexamer.scores.gz +0 -0
  44. pytransdecoder/presets/insect/kozak.tsv +9 -0
  45. pytransdecoder/presets/insect/preset.json +20 -0
  46. pytransdecoder/presets/insect/thresholds.tsv.gz +0 -0
  47. pytransdecoder/presets/insect/training_orfs.cds.gz +0 -0
  48. pytransdecoder/presets/mammal/hexamer.scores.gz +0 -0
  49. pytransdecoder/presets/mammal/kozak.tsv +9 -0
  50. pytransdecoder/presets/mammal/preset.json +20 -0
  51. pytransdecoder/presets/mammal/thresholds.tsv.gz +0 -0
  52. pytransdecoder/presets/mammal/training_orfs.cds.gz +0 -0
  53. pytransdecoder/presets/plant/hexamer.scores.gz +0 -0
  54. pytransdecoder/presets/plant/kozak.tsv +9 -0
  55. pytransdecoder/presets/plant/preset.json +20 -0
  56. pytransdecoder/presets/plant/thresholds.tsv.gz +0 -0
  57. pytransdecoder/presets/plant/training_orfs.cds.gz +0 -0
  58. pytransdecoder-0.2.1.dist-info/METADATA +307 -0
  59. pytransdecoder-0.2.1.dist-info/RECORD +63 -0
  60. pytransdecoder-0.2.1.dist-info/WHEEL +5 -0
  61. pytransdecoder-0.2.1.dist-info/entry_points.txt +3 -0
  62. pytransdecoder-0.2.1.dist-info/licenses/LICENSE +28 -0
  63. 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")