pytransdecoder 0.2.1__tar.gz → 0.4.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 (83) hide show
  1. {pytransdecoder-0.2.1/pytransdecoder.egg-info → pytransdecoder-0.4.0}/PKG-INFO +7 -1
  2. {pytransdecoder-0.2.1 → pytransdecoder-0.4.0}/README.md +5 -0
  3. {pytransdecoder-0.2.1 → pytransdecoder-0.4.0}/pyproject.toml +2 -1
  4. {pytransdecoder-0.2.1 → pytransdecoder-0.4.0}/pytransdecoder/__init__.py +1 -1
  5. {pytransdecoder-0.2.1 → pytransdecoder-0.4.0}/pytransdecoder/cli.py +13 -2
  6. {pytransdecoder-0.2.1 → pytransdecoder-0.4.0}/pytransdecoder/core/base_freqs.py +6 -5
  7. {pytransdecoder-0.2.1 → pytransdecoder-0.4.0}/pytransdecoder/core/decoys.py +3 -5
  8. pytransdecoder-0.4.0/pytransdecoder/core/markov_fast.py +107 -0
  9. {pytransdecoder-0.2.1 → pytransdecoder-0.4.0}/pytransdecoder/core/orf_finder.py +39 -21
  10. pytransdecoder-0.4.0/pytransdecoder/core/parallel.py +133 -0
  11. {pytransdecoder-0.2.1 → pytransdecoder-0.4.0}/pytransdecoder/core/prng.py +24 -1
  12. pytransdecoder-0.4.0/pytransdecoder/core/sequence.py +32 -0
  13. {pytransdecoder-0.2.1 → pytransdecoder-0.4.0}/pytransdecoder/core/translator.py +25 -3
  14. {pytransdecoder-0.2.1 → pytransdecoder-0.4.0}/pytransdecoder/core/writers.py +11 -11
  15. pytransdecoder-0.4.0/pytransdecoder/longorfs.py +247 -0
  16. {pytransdecoder-0.2.1 → pytransdecoder-0.4.0}/pytransdecoder/predict.py +73 -130
  17. {pytransdecoder-0.2.1 → pytransdecoder-0.4.0/pytransdecoder.egg-info}/PKG-INFO +7 -1
  18. {pytransdecoder-0.2.1 → pytransdecoder-0.4.0}/pytransdecoder.egg-info/SOURCES.txt +7 -1
  19. {pytransdecoder-0.2.1 → pytransdecoder-0.4.0}/pytransdecoder.egg-info/requires.txt +1 -0
  20. {pytransdecoder-0.2.1 → pytransdecoder-0.4.0}/tests/test_decoys.py +14 -0
  21. pytransdecoder-0.4.0/tests/test_longorfs_parallel.py +85 -0
  22. pytransdecoder-0.4.0/tests/test_markov_fast.py +51 -0
  23. pytransdecoder-0.4.0/tests/test_parallel.py +51 -0
  24. pytransdecoder-0.4.0/tests/test_translator_memo.py +31 -0
  25. pytransdecoder-0.2.1/pytransdecoder/core/sequence.py +0 -19
  26. pytransdecoder-0.2.1/pytransdecoder/longorfs.py +0 -180
  27. {pytransdecoder-0.2.1 → pytransdecoder-0.4.0}/LICENSE +0 -0
  28. {pytransdecoder-0.2.1 → pytransdecoder-0.4.0}/pytransdecoder/__main__.py +0 -0
  29. {pytransdecoder-0.2.1 → pytransdecoder-0.4.0}/pytransdecoder/core/__init__.py +0 -0
  30. {pytransdecoder-0.2.1 → pytransdecoder-0.4.0}/pytransdecoder/core/fasta.py +0 -0
  31. {pytransdecoder-0.2.1 → pytransdecoder-0.4.0}/pytransdecoder/core/gene_model.py +0 -0
  32. {pytransdecoder-0.2.1 → pytransdecoder-0.4.0}/pytransdecoder/core/gff3_parser.py +0 -0
  33. {pytransdecoder-0.2.1 → pytransdecoder-0.4.0}/pytransdecoder/core/homology_starts.py +0 -0
  34. {pytransdecoder-0.2.1 → pytransdecoder-0.4.0}/pytransdecoder/core/kozak.py +0 -0
  35. {pytransdecoder-0.2.1 → pytransdecoder-0.4.0}/pytransdecoder/core/models.py +0 -0
  36. {pytransdecoder-0.2.1 → pytransdecoder-0.4.0}/pytransdecoder/core/presets.py +0 -0
  37. {pytransdecoder-0.2.1 → pytransdecoder-0.4.0}/pytransdecoder/core/pwm.py +0 -0
  38. {pytransdecoder-0.2.1 → pytransdecoder-0.4.0}/pytransdecoder/genome.py +0 -0
  39. {pytransdecoder-0.2.1 → pytransdecoder-0.4.0}/pytransdecoder/presets/apicomplexan/hexamer.scores.gz +0 -0
  40. {pytransdecoder-0.2.1 → pytransdecoder-0.4.0}/pytransdecoder/presets/apicomplexan/kozak.tsv +0 -0
  41. {pytransdecoder-0.2.1 → pytransdecoder-0.4.0}/pytransdecoder/presets/apicomplexan/preset.json +0 -0
  42. {pytransdecoder-0.2.1 → pytransdecoder-0.4.0}/pytransdecoder/presets/apicomplexan/thresholds.tsv.gz +0 -0
  43. {pytransdecoder-0.2.1 → pytransdecoder-0.4.0}/pytransdecoder/presets/apicomplexan/training_orfs.cds.gz +0 -0
  44. {pytransdecoder-0.2.1 → pytransdecoder-0.4.0}/pytransdecoder/presets/ciliate/hexamer.scores.gz +0 -0
  45. {pytransdecoder-0.2.1 → pytransdecoder-0.4.0}/pytransdecoder/presets/ciliate/kozak.tsv +0 -0
  46. {pytransdecoder-0.2.1 → pytransdecoder-0.4.0}/pytransdecoder/presets/ciliate/preset.json +0 -0
  47. {pytransdecoder-0.2.1 → pytransdecoder-0.4.0}/pytransdecoder/presets/ciliate/thresholds.tsv.gz +0 -0
  48. {pytransdecoder-0.2.1 → pytransdecoder-0.4.0}/pytransdecoder/presets/ciliate/training_orfs.cds.gz +0 -0
  49. {pytransdecoder-0.2.1 → pytransdecoder-0.4.0}/pytransdecoder/presets/fungus/hexamer.scores.gz +0 -0
  50. {pytransdecoder-0.2.1 → pytransdecoder-0.4.0}/pytransdecoder/presets/fungus/kozak.tsv +0 -0
  51. {pytransdecoder-0.2.1 → pytransdecoder-0.4.0}/pytransdecoder/presets/fungus/preset.json +0 -0
  52. {pytransdecoder-0.2.1 → pytransdecoder-0.4.0}/pytransdecoder/presets/fungus/thresholds.tsv.gz +0 -0
  53. {pytransdecoder-0.2.1 → pytransdecoder-0.4.0}/pytransdecoder/presets/fungus/training_orfs.cds.gz +0 -0
  54. {pytransdecoder-0.2.1 → pytransdecoder-0.4.0}/pytransdecoder/presets/green_alga/hexamer.scores.gz +0 -0
  55. {pytransdecoder-0.2.1 → pytransdecoder-0.4.0}/pytransdecoder/presets/green_alga/kozak.tsv +0 -0
  56. {pytransdecoder-0.2.1 → pytransdecoder-0.4.0}/pytransdecoder/presets/green_alga/preset.json +0 -0
  57. {pytransdecoder-0.2.1 → pytransdecoder-0.4.0}/pytransdecoder/presets/green_alga/thresholds.tsv.gz +0 -0
  58. {pytransdecoder-0.2.1 → pytransdecoder-0.4.0}/pytransdecoder/presets/green_alga/training_orfs.cds.gz +0 -0
  59. {pytransdecoder-0.2.1 → pytransdecoder-0.4.0}/pytransdecoder/presets/insect/hexamer.scores.gz +0 -0
  60. {pytransdecoder-0.2.1 → pytransdecoder-0.4.0}/pytransdecoder/presets/insect/kozak.tsv +0 -0
  61. {pytransdecoder-0.2.1 → pytransdecoder-0.4.0}/pytransdecoder/presets/insect/preset.json +0 -0
  62. {pytransdecoder-0.2.1 → pytransdecoder-0.4.0}/pytransdecoder/presets/insect/thresholds.tsv.gz +0 -0
  63. {pytransdecoder-0.2.1 → pytransdecoder-0.4.0}/pytransdecoder/presets/insect/training_orfs.cds.gz +0 -0
  64. {pytransdecoder-0.2.1 → pytransdecoder-0.4.0}/pytransdecoder/presets/mammal/hexamer.scores.gz +0 -0
  65. {pytransdecoder-0.2.1 → pytransdecoder-0.4.0}/pytransdecoder/presets/mammal/kozak.tsv +0 -0
  66. {pytransdecoder-0.2.1 → pytransdecoder-0.4.0}/pytransdecoder/presets/mammal/preset.json +0 -0
  67. {pytransdecoder-0.2.1 → pytransdecoder-0.4.0}/pytransdecoder/presets/mammal/thresholds.tsv.gz +0 -0
  68. {pytransdecoder-0.2.1 → pytransdecoder-0.4.0}/pytransdecoder/presets/mammal/training_orfs.cds.gz +0 -0
  69. {pytransdecoder-0.2.1 → pytransdecoder-0.4.0}/pytransdecoder/presets/plant/hexamer.scores.gz +0 -0
  70. {pytransdecoder-0.2.1 → pytransdecoder-0.4.0}/pytransdecoder/presets/plant/kozak.tsv +0 -0
  71. {pytransdecoder-0.2.1 → pytransdecoder-0.4.0}/pytransdecoder/presets/plant/preset.json +0 -0
  72. {pytransdecoder-0.2.1 → pytransdecoder-0.4.0}/pytransdecoder/presets/plant/thresholds.tsv.gz +0 -0
  73. {pytransdecoder-0.2.1 → pytransdecoder-0.4.0}/pytransdecoder/presets/plant/training_orfs.cds.gz +0 -0
  74. {pytransdecoder-0.2.1 → pytransdecoder-0.4.0}/pytransdecoder.egg-info/dependency_links.txt +0 -0
  75. {pytransdecoder-0.2.1 → pytransdecoder-0.4.0}/pytransdecoder.egg-info/entry_points.txt +0 -0
  76. {pytransdecoder-0.2.1 → pytransdecoder-0.4.0}/pytransdecoder.egg-info/top_level.txt +0 -0
  77. {pytransdecoder-0.2.1 → pytransdecoder-0.4.0}/setup.cfg +0 -0
  78. {pytransdecoder-0.2.1 → pytransdecoder-0.4.0}/setup.py +0 -0
  79. {pytransdecoder-0.2.1 → pytransdecoder-0.4.0}/tests/test_basic.py +0 -0
  80. {pytransdecoder-0.2.1 → pytransdecoder-0.4.0}/tests/test_fasta.py +0 -0
  81. {pytransdecoder-0.2.1 → pytransdecoder-0.4.0}/tests/test_genome_bed.py +0 -0
  82. {pytransdecoder-0.2.1 → pytransdecoder-0.4.0}/tests/test_homology_starts.py +0 -0
  83. {pytransdecoder-0.2.1 → pytransdecoder-0.4.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.4.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"
@@ -80,6 +81,7 @@ This creates a directory `transcripts.fasta.transdecoder_dir/` with:
80
81
  -O, --output-dir PATH Output directory
81
82
  --gene-trans-map PATH Gene-to-transcript mapping file
82
83
  --complete-orfs-only Only output complete ORFs
84
+ --cpu INT Worker processes (default: 1); the output does not depend on it
83
85
  -v, --verbose Verbose output
84
86
  ```
85
87
 
@@ -178,6 +180,9 @@ pyTransdecoder -t transcripts.fasta --pfam-search-db Pfam-A.hmm
178
180
  --decoy-seed INT Seed of the decoy shuffles (default: 42)
179
181
  --preset NAME|DIR Models and thresholds from a preset instead of the input
180
182
  (recommended below ~1,000 transcripts or for mostly partial sets)
183
+ --cpu INT Worker processes for scoring candidate and decoy ORFs, and in
184
+ pyTransdecoder also for LongOrfs (default: 1); the output does
185
+ not depend on it
181
186
  -G, --genetic-code TEXT Genetic code (default: Standard)
182
187
  -v, --verbose Verbose output
183
188
  ```
@@ -302,6 +307,7 @@ diff ../TransDecoder/test.fasta.transdecoder_dir/longest_orfs.pep \
302
307
 
303
308
  - Python 3.8+
304
309
  - BioPython >= 1.81
310
+ - NumPy >= 1.21
305
311
  - tqdm >= 4.65
306
312
 
307
313
  **Note**: Ported by Claude.io Sonnet 4.5 under guidance by bhaas. Jan 24, 2026.
@@ -60,6 +60,7 @@ This creates a directory `transcripts.fasta.transdecoder_dir/` with:
60
60
  -O, --output-dir PATH Output directory
61
61
  --gene-trans-map PATH Gene-to-transcript mapping file
62
62
  --complete-orfs-only Only output complete ORFs
63
+ --cpu INT Worker processes (default: 1); the output does not depend on it
63
64
  -v, --verbose Verbose output
64
65
  ```
65
66
 
@@ -158,6 +159,9 @@ pyTransdecoder -t transcripts.fasta --pfam-search-db Pfam-A.hmm
158
159
  --decoy-seed INT Seed of the decoy shuffles (default: 42)
159
160
  --preset NAME|DIR Models and thresholds from a preset instead of the input
160
161
  (recommended below ~1,000 transcripts or for mostly partial sets)
162
+ --cpu INT Worker processes for scoring candidate and decoy ORFs, and in
163
+ pyTransdecoder also for LongOrfs (default: 1); the output does
164
+ not depend on it
161
165
  -G, --genetic-code TEXT Genetic code (default: Standard)
162
166
  -v, --verbose Verbose output
163
167
  ```
@@ -282,6 +286,7 @@ diff ../TransDecoder/test.fasta.transdecoder_dir/longest_orfs.pep \
282
286
 
283
287
  - Python 3.8+
284
288
  - BioPython >= 1.81
289
+ - NumPy >= 1.21
285
290
  - tqdm >= 4.65
286
291
 
287
292
  **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.4.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.4.0"
@@ -80,6 +80,10 @@ Example:
80
80
  help='Show version and exit'
81
81
  )
82
82
 
83
+ parser.add_argument('--cpu', type=int, default=1, dest='cpu',
84
+ help="Worker processes for ORF extraction; the output does not depend on it "
85
+ "(default: %(default)s)")
86
+
83
87
  parser.set_defaults(func=longorfs_cmd)
84
88
  return parser
85
89
 
@@ -106,6 +110,9 @@ def _add_selection_args(parser):
106
110
  "(~20 Mb of decoy sequence, at most 50)")
107
111
  parser.add_argument('--decoy-seed', type=int, default=DEFAULT_DECOY_SEED, dest='decoy_seed',
108
112
  help="Seed of the decoy shuffles (default: %(default)s)")
113
+ parser.add_argument('--cpu', type=int, default=1, dest='cpu',
114
+ help="Worker processes for scoring candidate and decoy ORFs (pyTransdecoder: also for "
115
+ "extracting ORFs); results do not depend on it (default: %(default)s)")
109
116
  from .core.presets import bundled_presets
110
117
  parser.add_argument('--preset', default=None,
111
118
  help="Use a preset's coding model, start model and q-value thresholds instead of "
@@ -276,7 +283,8 @@ def longorfs_cmd(args):
276
283
  output_dir=args.output_dir,
277
284
  gene_trans_map_file=args.gene_trans_map,
278
285
  complete_orfs_only=args.complete_orfs_only,
279
- verbose=args.verbose
286
+ verbose=args.verbose,
287
+ cpu=args.cpu
280
288
  )
281
289
  except Exception as e:
282
290
  print(f"Error: {e}", file=sys.stderr)
@@ -330,6 +338,7 @@ def predict_cmd(args):
330
338
  decoy_seed=args.decoy_seed,
331
339
  preset=args.preset,
332
340
  homology_start_assist=args.homology_start_assist,
341
+ cpu=args.cpu,
333
342
  )
334
343
  predictor.run()
335
344
  except Exception as e:
@@ -492,7 +501,8 @@ def pipeline_cmd(args):
492
501
  output_dir=args.output_dir,
493
502
  gene_trans_map_file=args.gene_trans_map,
494
503
  complete_orfs_only=args.complete_orfs_only,
495
- verbose=args.verbose
504
+ verbose=args.verbose,
505
+ cpu=args.cpu
496
506
  )
497
507
  except Exception as e:
498
508
  print(f"Error during LongOrfs phase: {e}", file=sys.stderr)
@@ -607,6 +617,7 @@ def pipeline_cmd(args):
607
617
  decoy_seed=args.decoy_seed,
608
618
  preset=args.preset,
609
619
  homology_start_assist=args.homology_start_assist,
620
+ cpu=args.cpu,
610
621
  )
611
622
  predictor.run()
612
623
  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