pytransdecoder 0.2.1__tar.gz → 0.3.0__tar.gz

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 (81) hide show
  1. {pytransdecoder-0.2.1/pytransdecoder.egg-info → pytransdecoder-0.3.0}/PKG-INFO +5 -1
  2. {pytransdecoder-0.2.1 → pytransdecoder-0.3.0}/README.md +3 -0
  3. {pytransdecoder-0.2.1 → pytransdecoder-0.3.0}/pyproject.toml +2 -1
  4. {pytransdecoder-0.2.1 → pytransdecoder-0.3.0}/pytransdecoder/__init__.py +1 -1
  5. {pytransdecoder-0.2.1 → pytransdecoder-0.3.0}/pytransdecoder/cli.py +5 -0
  6. {pytransdecoder-0.2.1 → pytransdecoder-0.3.0}/pytransdecoder/core/base_freqs.py +6 -5
  7. {pytransdecoder-0.2.1 → pytransdecoder-0.3.0}/pytransdecoder/core/decoys.py +3 -5
  8. pytransdecoder-0.3.0/pytransdecoder/core/markov_fast.py +107 -0
  9. {pytransdecoder-0.2.1 → pytransdecoder-0.3.0}/pytransdecoder/core/orf_finder.py +39 -21
  10. pytransdecoder-0.3.0/pytransdecoder/core/parallel.py +133 -0
  11. {pytransdecoder-0.2.1 → pytransdecoder-0.3.0}/pytransdecoder/core/prng.py +24 -1
  12. pytransdecoder-0.3.0/pytransdecoder/core/sequence.py +32 -0
  13. {pytransdecoder-0.2.1 → pytransdecoder-0.3.0}/pytransdecoder/core/translator.py +25 -3
  14. {pytransdecoder-0.2.1 → pytransdecoder-0.3.0}/pytransdecoder/predict.py +73 -130
  15. {pytransdecoder-0.2.1 → pytransdecoder-0.3.0/pytransdecoder.egg-info}/PKG-INFO +5 -1
  16. {pytransdecoder-0.2.1 → pytransdecoder-0.3.0}/pytransdecoder.egg-info/SOURCES.txt +6 -1
  17. {pytransdecoder-0.2.1 → pytransdecoder-0.3.0}/pytransdecoder.egg-info/requires.txt +1 -0
  18. {pytransdecoder-0.2.1 → pytransdecoder-0.3.0}/tests/test_decoys.py +14 -0
  19. pytransdecoder-0.3.0/tests/test_markov_fast.py +51 -0
  20. pytransdecoder-0.3.0/tests/test_parallel.py +51 -0
  21. pytransdecoder-0.3.0/tests/test_translator_memo.py +31 -0
  22. pytransdecoder-0.2.1/pytransdecoder/core/sequence.py +0 -19
  23. {pytransdecoder-0.2.1 → pytransdecoder-0.3.0}/LICENSE +0 -0
  24. {pytransdecoder-0.2.1 → pytransdecoder-0.3.0}/pytransdecoder/__main__.py +0 -0
  25. {pytransdecoder-0.2.1 → pytransdecoder-0.3.0}/pytransdecoder/core/__init__.py +0 -0
  26. {pytransdecoder-0.2.1 → pytransdecoder-0.3.0}/pytransdecoder/core/fasta.py +0 -0
  27. {pytransdecoder-0.2.1 → pytransdecoder-0.3.0}/pytransdecoder/core/gene_model.py +0 -0
  28. {pytransdecoder-0.2.1 → pytransdecoder-0.3.0}/pytransdecoder/core/gff3_parser.py +0 -0
  29. {pytransdecoder-0.2.1 → pytransdecoder-0.3.0}/pytransdecoder/core/homology_starts.py +0 -0
  30. {pytransdecoder-0.2.1 → pytransdecoder-0.3.0}/pytransdecoder/core/kozak.py +0 -0
  31. {pytransdecoder-0.2.1 → pytransdecoder-0.3.0}/pytransdecoder/core/models.py +0 -0
  32. {pytransdecoder-0.2.1 → pytransdecoder-0.3.0}/pytransdecoder/core/presets.py +0 -0
  33. {pytransdecoder-0.2.1 → pytransdecoder-0.3.0}/pytransdecoder/core/pwm.py +0 -0
  34. {pytransdecoder-0.2.1 → pytransdecoder-0.3.0}/pytransdecoder/core/writers.py +0 -0
  35. {pytransdecoder-0.2.1 → pytransdecoder-0.3.0}/pytransdecoder/genome.py +0 -0
  36. {pytransdecoder-0.2.1 → pytransdecoder-0.3.0}/pytransdecoder/longorfs.py +0 -0
  37. {pytransdecoder-0.2.1 → pytransdecoder-0.3.0}/pytransdecoder/presets/apicomplexan/hexamer.scores.gz +0 -0
  38. {pytransdecoder-0.2.1 → pytransdecoder-0.3.0}/pytransdecoder/presets/apicomplexan/kozak.tsv +0 -0
  39. {pytransdecoder-0.2.1 → pytransdecoder-0.3.0}/pytransdecoder/presets/apicomplexan/preset.json +0 -0
  40. {pytransdecoder-0.2.1 → pytransdecoder-0.3.0}/pytransdecoder/presets/apicomplexan/thresholds.tsv.gz +0 -0
  41. {pytransdecoder-0.2.1 → pytransdecoder-0.3.0}/pytransdecoder/presets/apicomplexan/training_orfs.cds.gz +0 -0
  42. {pytransdecoder-0.2.1 → pytransdecoder-0.3.0}/pytransdecoder/presets/ciliate/hexamer.scores.gz +0 -0
  43. {pytransdecoder-0.2.1 → pytransdecoder-0.3.0}/pytransdecoder/presets/ciliate/kozak.tsv +0 -0
  44. {pytransdecoder-0.2.1 → pytransdecoder-0.3.0}/pytransdecoder/presets/ciliate/preset.json +0 -0
  45. {pytransdecoder-0.2.1 → pytransdecoder-0.3.0}/pytransdecoder/presets/ciliate/thresholds.tsv.gz +0 -0
  46. {pytransdecoder-0.2.1 → pytransdecoder-0.3.0}/pytransdecoder/presets/ciliate/training_orfs.cds.gz +0 -0
  47. {pytransdecoder-0.2.1 → pytransdecoder-0.3.0}/pytransdecoder/presets/fungus/hexamer.scores.gz +0 -0
  48. {pytransdecoder-0.2.1 → pytransdecoder-0.3.0}/pytransdecoder/presets/fungus/kozak.tsv +0 -0
  49. {pytransdecoder-0.2.1 → pytransdecoder-0.3.0}/pytransdecoder/presets/fungus/preset.json +0 -0
  50. {pytransdecoder-0.2.1 → pytransdecoder-0.3.0}/pytransdecoder/presets/fungus/thresholds.tsv.gz +0 -0
  51. {pytransdecoder-0.2.1 → pytransdecoder-0.3.0}/pytransdecoder/presets/fungus/training_orfs.cds.gz +0 -0
  52. {pytransdecoder-0.2.1 → pytransdecoder-0.3.0}/pytransdecoder/presets/green_alga/hexamer.scores.gz +0 -0
  53. {pytransdecoder-0.2.1 → pytransdecoder-0.3.0}/pytransdecoder/presets/green_alga/kozak.tsv +0 -0
  54. {pytransdecoder-0.2.1 → pytransdecoder-0.3.0}/pytransdecoder/presets/green_alga/preset.json +0 -0
  55. {pytransdecoder-0.2.1 → pytransdecoder-0.3.0}/pytransdecoder/presets/green_alga/thresholds.tsv.gz +0 -0
  56. {pytransdecoder-0.2.1 → pytransdecoder-0.3.0}/pytransdecoder/presets/green_alga/training_orfs.cds.gz +0 -0
  57. {pytransdecoder-0.2.1 → pytransdecoder-0.3.0}/pytransdecoder/presets/insect/hexamer.scores.gz +0 -0
  58. {pytransdecoder-0.2.1 → pytransdecoder-0.3.0}/pytransdecoder/presets/insect/kozak.tsv +0 -0
  59. {pytransdecoder-0.2.1 → pytransdecoder-0.3.0}/pytransdecoder/presets/insect/preset.json +0 -0
  60. {pytransdecoder-0.2.1 → pytransdecoder-0.3.0}/pytransdecoder/presets/insect/thresholds.tsv.gz +0 -0
  61. {pytransdecoder-0.2.1 → pytransdecoder-0.3.0}/pytransdecoder/presets/insect/training_orfs.cds.gz +0 -0
  62. {pytransdecoder-0.2.1 → pytransdecoder-0.3.0}/pytransdecoder/presets/mammal/hexamer.scores.gz +0 -0
  63. {pytransdecoder-0.2.1 → pytransdecoder-0.3.0}/pytransdecoder/presets/mammal/kozak.tsv +0 -0
  64. {pytransdecoder-0.2.1 → pytransdecoder-0.3.0}/pytransdecoder/presets/mammal/preset.json +0 -0
  65. {pytransdecoder-0.2.1 → pytransdecoder-0.3.0}/pytransdecoder/presets/mammal/thresholds.tsv.gz +0 -0
  66. {pytransdecoder-0.2.1 → pytransdecoder-0.3.0}/pytransdecoder/presets/mammal/training_orfs.cds.gz +0 -0
  67. {pytransdecoder-0.2.1 → pytransdecoder-0.3.0}/pytransdecoder/presets/plant/hexamer.scores.gz +0 -0
  68. {pytransdecoder-0.2.1 → pytransdecoder-0.3.0}/pytransdecoder/presets/plant/kozak.tsv +0 -0
  69. {pytransdecoder-0.2.1 → pytransdecoder-0.3.0}/pytransdecoder/presets/plant/preset.json +0 -0
  70. {pytransdecoder-0.2.1 → pytransdecoder-0.3.0}/pytransdecoder/presets/plant/thresholds.tsv.gz +0 -0
  71. {pytransdecoder-0.2.1 → pytransdecoder-0.3.0}/pytransdecoder/presets/plant/training_orfs.cds.gz +0 -0
  72. {pytransdecoder-0.2.1 → pytransdecoder-0.3.0}/pytransdecoder.egg-info/dependency_links.txt +0 -0
  73. {pytransdecoder-0.2.1 → pytransdecoder-0.3.0}/pytransdecoder.egg-info/entry_points.txt +0 -0
  74. {pytransdecoder-0.2.1 → pytransdecoder-0.3.0}/pytransdecoder.egg-info/top_level.txt +0 -0
  75. {pytransdecoder-0.2.1 → pytransdecoder-0.3.0}/setup.cfg +0 -0
  76. {pytransdecoder-0.2.1 → pytransdecoder-0.3.0}/setup.py +0 -0
  77. {pytransdecoder-0.2.1 → pytransdecoder-0.3.0}/tests/test_basic.py +0 -0
  78. {pytransdecoder-0.2.1 → pytransdecoder-0.3.0}/tests/test_fasta.py +0 -0
  79. {pytransdecoder-0.2.1 → pytransdecoder-0.3.0}/tests/test_genome_bed.py +0 -0
  80. {pytransdecoder-0.2.1 → pytransdecoder-0.3.0}/tests/test_homology_starts.py +0 -0
  81. {pytransdecoder-0.2.1 → pytransdecoder-0.3.0}/tests/test_qvalue_modes.py +0 -0
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: pytransdecoder
3
- Version: 0.2.1
3
+ Version: 0.3.0
4
4
  Summary: TransDecoder, the original Perl version ported to Python by AI/LLMs with new features integrated
5
5
  Author: Brian Haas, ported to python by AI/LLMs
6
6
  License: BSD-3-Clause
@@ -10,6 +10,7 @@ Requires-Python: >=3.8
10
10
  Description-Content-Type: text/markdown
11
11
  License-File: LICENSE
12
12
  Requires-Dist: biopython>=1.81
13
+ Requires-Dist: numpy>=1.21
13
14
  Requires-Dist: tqdm>=4.65
14
15
  Provides-Extra: dev
15
16
  Requires-Dist: pytest>=7.4; extra == "dev"
@@ -178,6 +179,8 @@ pyTransdecoder -t transcripts.fasta --pfam-search-db Pfam-A.hmm
178
179
  --decoy-seed INT Seed of the decoy shuffles (default: 42)
179
180
  --preset NAME|DIR Models and thresholds from a preset instead of the input
180
181
  (recommended below ~1,000 transcripts or for mostly partial sets)
182
+ --cpu INT Worker processes for scoring candidate and decoy ORFs
183
+ (default: 1); the output does not depend on it
181
184
  -G, --genetic-code TEXT Genetic code (default: Standard)
182
185
  -v, --verbose Verbose output
183
186
  ```
@@ -302,6 +305,7 @@ diff ../TransDecoder/test.fasta.transdecoder_dir/longest_orfs.pep \
302
305
 
303
306
  - Python 3.8+
304
307
  - BioPython >= 1.81
308
+ - NumPy >= 1.21
305
309
  - tqdm >= 4.65
306
310
 
307
311
  **Note**: Ported by Claude.io Sonnet 4.5 under guidance by bhaas. Jan 24, 2026.
@@ -158,6 +158,8 @@ pyTransdecoder -t transcripts.fasta --pfam-search-db Pfam-A.hmm
158
158
  --decoy-seed INT Seed of the decoy shuffles (default: 42)
159
159
  --preset NAME|DIR Models and thresholds from a preset instead of the input
160
160
  (recommended below ~1,000 transcripts or for mostly partial sets)
161
+ --cpu INT Worker processes for scoring candidate and decoy ORFs
162
+ (default: 1); the output does not depend on it
161
163
  -G, --genetic-code TEXT Genetic code (default: Standard)
162
164
  -v, --verbose Verbose output
163
165
  ```
@@ -282,6 +284,7 @@ diff ../TransDecoder/test.fasta.transdecoder_dir/longest_orfs.pep \
282
284
 
283
285
  - Python 3.8+
284
286
  - BioPython >= 1.81
287
+ - NumPy >= 1.21
285
288
  - tqdm >= 4.65
286
289
 
287
290
  **Note**: Ported by Claude.io Sonnet 4.5 under guidance by bhaas. Jan 24, 2026.
@@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta"
4
4
 
5
5
  [project]
6
6
  name = "pytransdecoder"
7
- version = "0.2.1"
7
+ version = "0.3.0"
8
8
  description = "TransDecoder, the original Perl version ported to Python by AI/LLMs with new features integrated"
9
9
  readme = "README.md"
10
10
  requires-python = ">=3.8"
@@ -14,6 +14,7 @@ authors = [
14
14
  ]
15
15
  dependencies = [
16
16
  "biopython>=1.81",
17
+ "numpy>=1.21",
17
18
  "tqdm>=4.65",
18
19
  ]
19
20
 
@@ -4,4 +4,4 @@ PyTransDecoder - Python port of TransDecoder
4
4
  Identifies candidate protein-coding regions within transcript sequences.
5
5
  """
6
6
 
7
- __version__ = "0.2.1"
7
+ __version__ = "0.3.0"
@@ -106,6 +106,9 @@ def _add_selection_args(parser):
106
106
  "(~20 Mb of decoy sequence, at most 50)")
107
107
  parser.add_argument('--decoy-seed', type=int, default=DEFAULT_DECOY_SEED, dest='decoy_seed',
108
108
  help="Seed of the decoy shuffles (default: %(default)s)")
109
+ parser.add_argument('--cpu', type=int, default=1, dest='cpu',
110
+ help="Worker processes for scoring candidate and decoy ORFs; results do not depend "
111
+ "on it (default: %(default)s)")
109
112
  from .core.presets import bundled_presets
110
113
  parser.add_argument('--preset', default=None,
111
114
  help="Use a preset's coding model, start model and q-value thresholds instead of "
@@ -330,6 +333,7 @@ def predict_cmd(args):
330
333
  decoy_seed=args.decoy_seed,
331
334
  preset=args.preset,
332
335
  homology_start_assist=args.homology_start_assist,
336
+ cpu=args.cpu,
333
337
  )
334
338
  predictor.run()
335
339
  except Exception as e:
@@ -607,6 +611,7 @@ def pipeline_cmd(args):
607
611
  decoy_seed=args.decoy_seed,
608
612
  preset=args.preset,
609
613
  homology_start_assist=args.homology_start_assist,
614
+ cpu=args.cpu,
610
615
  )
611
616
  predictor.run()
612
617
  except Exception as e:
@@ -19,11 +19,12 @@ def compute_base_counts(fasta_file: str, top_strand_only: bool = False) -> Dict[
19
19
  complement = {'A': 'T', 'T': 'A', 'G': 'C', 'C': 'G'}
20
20
  counts = {base: 0 for base in 'ACGT'}
21
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
22
+ seq = str(record.seq).upper()
23
+ for base in 'ACGT':
24
+ k = seq.count(base)
25
+ counts[base] += k
26
+ if not top_strand_only:
27
+ counts[complement[base]] += k
27
28
  return counts
28
29
 
29
30
 
@@ -55,13 +55,11 @@ def dinucleotide_shuffle(seq: str, rng: Xoshiro128ss) -> str:
55
55
  if v in last:
56
56
  out.append(last[v])
57
57
  order[v] = out
58
- pos = defaultdict(int)
58
+ nxt_of = {v: iter(o).__next__ for v, o in order.items()} # each edge list consumed in order
59
59
  res, u = [seq[0]], seq[0]
60
60
  for _ in range(n - 1):
61
- w = order[u][pos[u]]
62
- pos[u] += 1
63
- res.append(w)
64
- u = w
61
+ u = nxt_of[u]()
62
+ res.append(u)
65
63
  return "".join(res)
66
64
 
67
65
 
@@ -0,0 +1,107 @@
1
+ """
2
+ Framed 5th-order Markov scoring with NumPy, bit-identical to the reference loop
3
+ (core/parallel.score_markov).
4
+
5
+ The reference adds, for i = 0 .. n-1, the score of the framed k-mer ending at i
6
+ (k = min(i, 5) + 1, frame i % 3) to a running total that starts at 0.0, and stops
7
+ before a terminal in-frame stop codon. Here the per-position values are gathered
8
+ with array lookups, and the total is formed by np.add.accumulate over
9
+ [0.0, v0, v1, ...], which performs the same IEEE additions in the same order.
10
+ Positions 0-4 (k-mers shorter than 6) use the dict, as in the reference.
11
+
12
+ Only score tables whose keys are all ^[ACGT]{1,6}-[012]$ can be compiled; for any
13
+ other table, compile() returns None and callers use the reference loop.
14
+ """
15
+
16
+ import re
17
+ from typing import Dict, Optional, Sequence, Tuple
18
+
19
+ import numpy as np
20
+
21
+ _KEY = re.compile(r"^[ACGT]{1,6}-[012]$")
22
+ _CODE = np.full(256, 4, dtype=np.int64)
23
+ for _i, _b in enumerate("ACGT"):
24
+ _CODE[ord(_b)] = _i
25
+ _POW = (1024, 256, 64, 16, 4, 1)
26
+ INVALID = 4096 # index of the zero slot: 6-mers with a non-ACGT base
27
+
28
+
29
+ class MarkovTable:
30
+ """Hexamer scores of one model, as a (3, 4097) array plus the dict for short k-mers."""
31
+
32
+ def __init__(self, scores: Dict[str, float], stop_codons):
33
+ self.scores = scores
34
+ self.stop_codons = stop_codons
35
+ tbl = np.zeros((3, INVALID + 1), dtype=np.float64)
36
+ for key, v in scores.items():
37
+ kmer, fr = key[:-2], int(key[-1])
38
+ if len(kmer) == 6:
39
+ idx = 0
40
+ for b, p in zip(kmer, _POW):
41
+ idx += "ACGT".index(b) * p
42
+ tbl[fr, idx] = v
43
+ self.tbl = tbl
44
+
45
+ # -- helpers -------------------------------------------------------------------
46
+ @staticmethod
47
+ def _hexamer_index(seq: str) -> np.ndarray:
48
+ """idx[t] = base-4 index of seq[t:t+6], or INVALID if it contains a non-ACGT base."""
49
+ c = _CODE[np.frombuffer(seq.encode("latin-1"), dtype=np.uint8)]
50
+ n = len(c)
51
+ bad = c == 4
52
+ cc = np.where(bad, 0, c)
53
+ m = n - 5
54
+ idx = cc[0:m] * 1024
55
+ badw = bad[0:m].copy()
56
+ for j in range(1, 6):
57
+ idx += cc[j:m + j] * _POW[j]
58
+ badw |= bad[j:m + j]
59
+ idx[badw] = INVALID
60
+ return idx
61
+
62
+ def _score_offsets(self, seq: str, idx: Optional[np.ndarray], offsets: Sequence[int]) -> Tuple[float, ...]:
63
+ """Reference score of seq[k:] for each k in offsets (idx: _hexamer_index(seq) or None)."""
64
+ n_full = len(seq)
65
+ get = self.scores.get
66
+ tail_stop = seq[n_full - 3:] in self.stop_codons
67
+ out = []
68
+ for k in offsets:
69
+ n = n_full - k
70
+ if n < 6:
71
+ out.append(0.0)
72
+ continue
73
+ m = n - 3 if (n % 3 == 0 and tail_stop) else n # positions scored: 0 .. m-1
74
+ ms = min(m, 5)
75
+ vals = np.empty(m + 1, dtype=np.float64)
76
+ vals[0] = 0.0
77
+ for i in range(ms):
78
+ vals[i + 1] = get(f"{seq[k:k + i + 1]}-{i % 3}", 0.0)
79
+ if m > 5:
80
+ # positions i = 5 .. m-1 of seq[k:], i.e. 6-mers seq[k+i-5 : k+i+1] = idx[k+i-5]
81
+ t = idx[k:k + m - 5]
82
+ fr = (np.arange(5, m)) % 3
83
+ vals[6:] = self.tbl[fr, t]
84
+ out.append(float(np.add.accumulate(vals)[-1]))
85
+ return tuple(out)
86
+
87
+ # -- public --------------------------------------------------------------------
88
+ def score(self, seq: str) -> float:
89
+ if len(seq) < 6:
90
+ return 0.0
91
+ return self._score_offsets(seq, self._hexamer_index(seq), (0,))[0]
92
+
93
+ def forward_frames(self, seq: str) -> Tuple[float, float, float]:
94
+ """Scores of seq, seq[1:], seq[2:]."""
95
+ idx = self._hexamer_index(seq) if len(seq) >= 6 else None
96
+ return self._score_offsets(seq, idx, (0, 1, 2))
97
+
98
+ def six_frames(self, seq: str, rev: str) -> Tuple[float, ...]:
99
+ """Scores of seq, seq[1:], seq[2:], rev, rev[1:], rev[2:] (rev = reverse complement)."""
100
+ return self.forward_frames(seq) + self.forward_frames(rev)
101
+
102
+
103
+ def compile_table(scores: Dict[str, float], stop_codons) -> Optional[MarkovTable]:
104
+ """MarkovTable for a score dict, or None if any key is outside ^[ACGT]{1,6}-[012]$."""
105
+ if not all(_KEY.match(k) for k in scores):
106
+ return None
107
+ return MarkovTable(scores, stop_codons)
@@ -4,6 +4,8 @@ ORF (Open Reading Frame) finder
4
4
  Replicates the core algorithm from Longest_orf.pm
5
5
  """
6
6
 
7
+ import bisect
8
+ import re
7
9
  from typing import List, Dict, Tuple
8
10
  from .models import ORF
9
11
  from .translator import Translator
@@ -54,6 +56,9 @@ class ORFFinder:
54
56
  self.translator = Translator(genetic_code)
55
57
  self.stop_codons = set(self.translator.get_stop_codons())
56
58
  self.start_codons = set(self.translator.get_start_codons(allow_non_met_starts))
59
+ # overlapping matches of any stop / start codon (positions in all three frames)
60
+ self._stop_re = re.compile("(?=(" + "|".join(sorted(map(re.escape, self.stop_codons))) + "))")
61
+ self._start_re = re.compile("(?=(" + "|".join(sorted(map(re.escape, self.start_codons))) + "))")
57
62
 
58
63
  def find_all_orfs(self, sequence: str, seq_id: str) -> List[ORF]:
59
64
  """
@@ -91,13 +96,21 @@ class ORFFinder:
91
96
  sequence = sequence.upper()
92
97
  orfs = []
93
98
 
99
+ # Codon positions in all frames from one scan each (ascending), split by frame
100
+ stop_pos = [[], [], []]
101
+ for m in self._stop_re.finditer(sequence):
102
+ stop_pos[m.start() % 3].append(m.start())
103
+ start_pos = [[], [], []]
104
+ for m in self._start_re.finditer(sequence):
105
+ start_pos[m.start() % 3].append(m.start())
106
+
94
107
  # Find stops and starts for each frame
95
108
  for frame in range(3):
96
109
  # Find all stop codons in this frame
97
- stops, real_stops = self._find_stops_in_frame(sequence, frame)
110
+ stops, real_stops = self._find_stops_in_frame(sequence, frame, stop_pos[frame])
98
111
 
99
112
  # Find all start codons in this frame (passing real_stops to avoid starts at stop positions)
100
- starts = self._find_starts_in_frame(sequence, frame, real_stops)
113
+ starts = self._find_starts_in_frame(sequence, frame, real_stops, start_pos[frame])
101
114
 
102
115
  # Build ORFs from start-stop pairs
103
116
  orfs.extend(
@@ -108,7 +121,7 @@ class ORFFinder:
108
121
 
109
122
  return orfs
110
123
 
111
- def _find_stops_in_frame(self, sequence: str, frame: int) -> Tuple[List[int], set]:
124
+ def _find_stops_in_frame(self, sequence: str, frame: int, positions: List[int] = None) -> Tuple[List[int], set]:
112
125
  """
113
126
  Find all stop codon positions in a specific frame.
114
127
 
@@ -117,14 +130,10 @@ class ORFFinder:
117
130
  - all_stops: List of all stop positions (real + implicit)
118
131
  - real_stop_set: Set of positions that are actual stop codons
119
132
  """
120
- stops = []
121
- real_stops = set()
122
-
123
- for i in range(frame, len(sequence) - 2, 3):
124
- codon = sequence[i:i+3]
125
- if codon in self.stop_codons:
126
- stops.append(i)
127
- real_stops.add(i)
133
+ if positions is None: # stop codons at frame, frame+3, ... (ascending)
134
+ positions = [i for i in range(frame, len(sequence) - 2, 3) if sequence[i:i+3] in self.stop_codons]
135
+ stops = list(positions)
136
+ real_stops = set(positions)
128
137
 
129
138
  # Add implicit stops at end for 3' partials (NOT real stop codons)
130
139
  # Perl adds seq_length, seq_length-1, seq_length-2 as implicit stops
@@ -137,7 +146,8 @@ class ORFFinder:
137
146
 
138
147
  return stops, real_stops
139
148
 
140
- def _find_starts_in_frame(self, sequence: str, frame: int, real_stops: set) -> List[int]:
149
+ def _find_starts_in_frame(self, sequence: str, frame: int, real_stops: set,
150
+ positions: List[int] = None) -> List[int]:
141
151
  """
142
152
  Find all start codon positions in a specific frame.
143
153
 
@@ -157,10 +167,9 @@ class ORFFinder:
157
167
  starts.append(frame)
158
168
 
159
169
  # Find explicit start codons
160
- for i in range(frame, len(sequence) - 2, 3):
161
- codon = sequence[i:i+3]
162
- if codon in self.start_codons:
163
- starts.append(i)
170
+ if positions is None:
171
+ positions = [i for i in range(frame, len(sequence) - 2, 3) if sequence[i:i+3] in self.start_codons]
172
+ starts.extend(positions)
164
173
 
165
174
  return starts
166
175
 
@@ -189,6 +198,11 @@ class ORFFinder:
189
198
  """
190
199
  orfs = []
191
200
  last_used_stop = -1 # Track the last stop position used in this frame
201
+ # stops = real stop positions (ascending, all in this frame) + implicit end positions;
202
+ # every start is in this frame too, so the first listed stop after a start is the first
203
+ # real stop after it (bisect), else the first matching implicit position
204
+ n_real = len(real_stops)
205
+ real_sorted = stops[:n_real]
192
206
 
193
207
  # Iterate through starts in order
194
208
  for start_pos in starts:
@@ -199,11 +213,15 @@ class ORFFinder:
199
213
 
200
214
  # Find the first valid stop after this start (Perl's algorithm)
201
215
  valid_stop = None
202
- for stop_pos in stops:
203
- if (stop_pos > start_pos and
204
- (stop_pos - start_pos) % 3 == 0):
205
- valid_stop = stop_pos
206
- break # Take first valid stop (replicates Perl's 'last')
216
+ k = bisect.bisect_right(real_sorted, start_pos)
217
+ if k < n_real and (real_sorted[k] - start_pos) % 3 == 0:
218
+ valid_stop = real_sorted[k]
219
+ else: # the original scan; stops before index k are <= start_pos and cannot match
220
+ for stop_pos in stops[k:]:
221
+ if (stop_pos > start_pos and
222
+ (stop_pos - start_pos) % 3 == 0):
223
+ valid_stop = stop_pos
224
+ break # Take first valid stop (replicates Perl's 'last')
207
225
 
208
226
  if valid_stop is None:
209
227
  continue
@@ -0,0 +1,133 @@
1
+ """
2
+ Multi-process scoring of candidate and decoy ORFs.
3
+
4
+ Both loops are CPU-bound pure Python, so they use worker processes (threads would be serialised by the
5
+ GIL). Each worker runs the same per-ORF arithmetic as the single-process code. Work is split into chunks
6
+ in input order, and results are put back together in that order, so the output does not depend on the
7
+ number of workers.
8
+ """
9
+
10
+ from concurrent.futures import ProcessPoolExecutor
11
+ import multiprocessing as mp
12
+ from typing import Dict, FrozenSet, List, Sequence, Tuple
13
+
14
+ from pytransdecoder.core.sequence import reverse_complement
15
+
16
+ MARKOV_ORDER = 5
17
+
18
+ # per-worker state, set by _init_worker (inherited on fork, or passed on spawn)
19
+ _HEX: Dict[str, float] = {}
20
+ _STOPS: FrozenSet[str] = frozenset()
21
+ _FINDER_ARGS: dict = {}
22
+ _TABLE = None # core/markov_fast.MarkovTable of _HEX, or None (reference loop)
23
+
24
+
25
+ def score_markov(sequence: str, markov_order: int, scores: Dict[str, float], stop_codons) -> float:
26
+ """Sum of framed k-mer log-likelihoods; a terminal in-frame stop codon is not scored."""
27
+ seq_length = len(sequence)
28
+ if seq_length < markov_order + 1:
29
+ return 0.0
30
+ total_score = 0.0
31
+ for i in range(seq_length):
32
+ frame = i % 3
33
+ markov_use = min(i, markov_order)
34
+ kmer = sequence[i - markov_use:i + 1]
35
+ if i == seq_length - 3 and frame == 0:
36
+ if sequence[i:i + 3] in stop_codons:
37
+ break
38
+ total_score += scores.get(f"{kmer}-{frame}", 0.0)
39
+ return total_score
40
+
41
+
42
+ def _init_worker(hex_scores, stop_codons, finder_args):
43
+ global _HEX, _STOPS, _FINDER_ARGS, _TABLE
44
+ from pytransdecoder.core.markov_fast import compile_table
45
+ _HEX, _STOPS, _FINDER_ARGS = hex_scores, stop_codons, finder_args
46
+ _TABLE = compile_table(hex_scores, stop_codons)
47
+
48
+
49
+ def _score_six_frames(sequence: str) -> Tuple[float, ...]:
50
+ rev = reverse_complement(sequence)
51
+ if _TABLE is not None:
52
+ return _TABLE.six_frames(sequence, rev)
53
+ return tuple(score_markov(s, MARKOV_ORDER, _HEX, _STOPS)
54
+ for s in (sequence, sequence[1:], sequence[2:], rev, rev[1:], rev[2:]))
55
+
56
+
57
+ def _six_frame_chunk(seqs: Sequence[str]) -> List[Tuple[float, ...]]:
58
+ return [_score_six_frames(s) for s in seqs]
59
+
60
+
61
+ def _decoy_chunk(args):
62
+ """Decoy ORFs of a chunk of (tid, seq) in the given order: (n_decoy, [(stratum, contrast), ...])."""
63
+ from pytransdecoder.core import decoys
64
+ from pytransdecoder.core.prng import Xoshiro128ss
65
+ from pytransdecoder.core.orf_finder import ORFFinder
66
+ items, n_shuf, seed = args
67
+ finder = ORFFinder(**_FINDER_ARGS)
68
+ n_decoy, out = 0, []
69
+ for tid, seq in items:
70
+ for k in range(n_shuf):
71
+ shuf = decoys.dinucleotide_shuffle(seq, Xoshiro128ss(f"{seed}:{tid}:{k}"))
72
+ for orf in finder.find_all_orfs(shuf, f"decoy{k}_{tid}"):
73
+ n_decoy += 1
74
+ cds = orf.sequence.upper()
75
+ if _TABLE is not None:
76
+ s0, s1, s2 = (round(x, 2) for x in _TABLE.forward_frames(cds))
77
+ else:
78
+ s0 = round(score_markov(cds, MARKOV_ORDER, _HEX, _STOPS), 2)
79
+ s1 = round(score_markov(cds[1:], MARKOV_ORDER, _HEX, _STOPS), 2)
80
+ s2 = round(score_markov(cds[2:], MARKOV_ORDER, _HEX, _STOPS), 2)
81
+ c = s0 - max(s1, s2)
82
+ if s0 > 0 and c > 0:
83
+ out.append((decoys.stratum(len(cds) // 3, len(seq), orf.orf_type == 'complete',
84
+ 'len_tx_type'), c))
85
+ return n_decoy, out
86
+
87
+
88
+ def _chunks_by_size(items, sizes, n_chunks):
89
+ """Consecutive chunks of roughly equal total size (order preserved)."""
90
+ total = sum(sizes) or 1
91
+ target = total / n_chunks
92
+ chunks, cur, acc = [], [], 0
93
+ for it, sz in zip(items, sizes):
94
+ cur.append(it)
95
+ acc += sz
96
+ if acc >= target:
97
+ chunks.append(cur)
98
+ cur, acc = [], 0
99
+ if cur:
100
+ chunks.append(cur)
101
+ return chunks
102
+
103
+
104
+ def _pool(cpu, hex_scores, stop_codons, finder_args=None):
105
+ ctx = mp.get_context("fork") if "fork" in mp.get_all_start_methods() else None
106
+ return ProcessPoolExecutor(max_workers=cpu, mp_context=ctx, initializer=_init_worker,
107
+ initargs=(hex_scores, stop_codons, finder_args or {}))
108
+
109
+
110
+ def score_six_frames(seqs: List[str], hex_scores, stop_codons, cpu: int) -> List[Tuple[float, ...]]:
111
+ """Six-frame scores of each sequence, in input order."""
112
+ if cpu <= 1 or len(seqs) < 2:
113
+ _init_worker(hex_scores, stop_codons, {})
114
+ return _six_frame_chunk(seqs)
115
+ # many small chunks so slow chunks (long ORFs) do not leave workers idle
116
+ chunks = _chunks_by_size(seqs, [len(s) for s in seqs], cpu * 16)
117
+ with _pool(cpu, hex_scores, stop_codons) as ex:
118
+ return [r for part in ex.map(_six_frame_chunk, chunks) for r in part]
119
+
120
+
121
+ def decoy_scores(transcripts: List[Tuple[str, str]], n_shuf: int, seed: int, hex_scores, stop_codons,
122
+ finder_args: dict, cpu: int):
123
+ """(n_decoy, eligible decoy (stratum, contrast) list) over transcripts in the given order."""
124
+ if cpu <= 1:
125
+ _init_worker(hex_scores, stop_codons, finder_args)
126
+ return _decoy_chunk((transcripts, n_shuf, seed))
127
+ chunks = _chunks_by_size(transcripts, [len(s) for _, s in transcripts], cpu * 16)
128
+ n_decoy, out = 0, []
129
+ with _pool(cpu, hex_scores, stop_codons, finder_args) as ex:
130
+ for n, part in ex.map(_decoy_chunk, [(c, n_shuf, seed) for c in chunks]):
131
+ n_decoy += n
132
+ out.extend(part)
133
+ return n_decoy, out
@@ -48,7 +48,30 @@ class Xoshiro128ss:
48
48
  return r % n
49
49
 
50
50
  def shuffle(self, items: list) -> None:
51
- """Fisher-Yates, in place."""
51
+ """Fisher-Yates, in place: j = below(i + 1) for i = len-1 .. 1, with next32/below inlined
52
+ (same integer arithmetic, so the same stream as shuffle_reference)."""
53
+ s0, s1, s2, s3 = self.s
54
+ for i in range(len(items) - 1, 0, -1):
55
+ n = i + 1
56
+ limit = (4294967296 // n) * n
57
+ while True:
58
+ x = (s1 * 5) & M32
59
+ r = ((((x << 7) | (x >> 25)) & M32) * 9) & M32
60
+ t = (s1 << 9) & M32
61
+ s2 ^= s0
62
+ s3 ^= s1
63
+ s1 ^= s2
64
+ s0 ^= s3
65
+ s2 ^= t
66
+ s3 = ((s3 << 11) | (s3 >> 21)) & M32
67
+ if r < limit:
68
+ break
69
+ j = r % n
70
+ items[i], items[j] = items[j], items[i]
71
+ self.s[:] = [s0, s1, s2, s3]
72
+
73
+ def shuffle_reference(self, items: list) -> None:
74
+ """Fisher-Yates, in place (the form shuffle() inlines)."""
52
75
  for i in range(len(items) - 1, 0, -1):
53
76
  j = self.below(i + 1)
54
77
  items[i], items[j] = items[j], items[i]
@@ -0,0 +1,32 @@
1
+ """
2
+ Sequence utilities
3
+ """
4
+
5
+ from Bio.Seq import Seq
6
+
7
+
8
+ def reverse_complement(sequence: str) -> str:
9
+ """
10
+ Return reverse complement of a DNA/RNA sequence.
11
+
12
+ Args:
13
+ sequence: DNA or RNA sequence
14
+
15
+ Returns:
16
+ Reverse complement sequence
17
+ """
18
+ if sequence.isascii():
19
+ return sequence.translate(_rc_table())[::-1]
20
+ return str(Seq(sequence).reverse_complement())
21
+
22
+
23
+ _RC = None
24
+
25
+
26
+ def _rc_table():
27
+ """Per-character complement of ASCII, taken from Biopython (whose reverse complement maps each
28
+ character independently), so translate + reverse equals Seq.reverse_complement."""
29
+ global _RC
30
+ if _RC is None:
31
+ _RC = str.maketrans({chr(o): str(Seq(chr(o)).reverse_complement()) for o in range(128)})
32
+ return _RC
@@ -78,6 +78,9 @@ class Translator:
78
78
  self.genetic_code_name = genetic_code
79
79
  self.table_id = self.GENETIC_CODE_MAP[code_name]
80
80
  self.codon_table = CodonTable.unambiguous_dna_by_id[self.table_id]
81
+ # codon -> amino acid, filled from Biopython one codon at a time (Biopython translates
82
+ # each codon independently, so joining memoised codons gives the same protein)
83
+ self._codon_memo = {}
81
84
 
82
85
  def get_stop_codons(self) -> List[str]:
83
86
  """
@@ -108,15 +111,34 @@ class Translator:
108
111
 
109
112
  def translate(self, sequence: str, frame: int = 0) -> str:
110
113
  """
111
- Translate nucleotide sequence to protein.
112
-
114
+ Translate nucleotide sequence to protein (as Biopython's Seq.translate, to_stop=False;
115
+ a trailing partial codon is dropped).
116
+
113
117
  Args:
114
118
  sequence: DNA/RNA sequence
115
119
  frame: Reading frame offset (0, 1, or 2)
116
-
120
+
117
121
  Returns:
118
122
  Protein sequence (stops are represented as *)
119
123
  """
124
+ seq = sequence[frame:].upper()
125
+ n = len(seq) - len(seq) % 3
126
+ memo = self._codon_memo
127
+ try:
128
+ return ''.join([memo[seq[i:i + 3]] for i in range(0, n, 3)])
129
+ except KeyError:
130
+ pass
131
+ for i in range(0, n, 3):
132
+ codon = seq[i:i + 3]
133
+ if codon not in memo:
134
+ try:
135
+ memo[codon] = str(Seq(codon).translate(table=self.table_id, to_stop=False))
136
+ except Exception:
137
+ return self._translate_reference(sequence, frame)
138
+ return ''.join([memo[seq[i:i + 3]] for i in range(0, n, 3)])
139
+
140
+ def _translate_reference(self, sequence: str, frame: int = 0) -> str:
141
+ """Whole-sequence Biopython translation (the form translate() reproduces codon by codon)."""
120
142
  # Adjust for frame
121
143
  seq = sequence[frame:].upper()
122
144