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,1621 @@
1
+ """
2
+ TransDecoder.Predict - Final coding region predictions
3
+
4
+ This module implements the prediction phase of TransDecoder, which:
5
+ 1. Trains a Markov model on longest ORFs
6
+ 2. Scores all candidate ORFs
7
+ 3. Incorporates homology information (BLAST/Pfam)
8
+ 4. Selects best ORFs per transcript
9
+ 5. Refines start codons
10
+ 6. Generates final output files
11
+ """
12
+
13
+ import logging
14
+ import math
15
+ import re
16
+ from pathlib import Path
17
+ from typing import Optional, Dict, List, Tuple
18
+ from Bio import SeqIO
19
+
20
+ from .core.fasta import read_fasta
21
+
22
+ from pytransdecoder.core.base_freqs import read_base_frequencies
23
+
24
+ logger = logging.getLogger(__name__)
25
+
26
+ # Start refinement compares two scores for the segment before an open ORF's first in-frame ATG,
27
+ # both in bases' worth of typical coding signal (the training ORFs' median per-base frame contrast):
28
+ # coding score = the segment's frame contrast / that median
29
+ # UTR score = alpha * segment length + kozak_weight * Kozak score of the ATG (bits)
30
+ # and trims the ORF to the ATG when the UTR score is higher; otherwise it stays 5' partial.
31
+ # alpha: the fraction of typical coding signal a segment may show and still be called UTR.
32
+ # kozak_weight: bases of coding signal the ATG overrules per bit of Kozak score (17 per bit equals
33
+ # the 25 per nat previously used). Both evaluated on the 2026 benchmark and GM12878
34
+ # (TransDecoderPaper evidence E09, E13).
35
+ DEFAULT_START_CONTRAST_ALPHA = 0.4
36
+ DEFAULT_START_KOZAK_WEIGHT = 17.0
37
+
38
+ # ORF acceptance without homology support: an ORF with in-frame score > 0 and frame contrast (in-frame
39
+ # score minus the better shifted frame) > 0 is kept when its target-decoy q-value is <= the cutoff.
40
+ # Decoy ORFs come from dinucleotide-shuffled copies of the input transcripts, found with the LongOrfs
41
+ # settings and scored with the run's model; q-values are computed within strata of ORF length,
42
+ # transcript length and ORF type (core/decoys.py). ORFs at or above the GC-dependent length below are
43
+ # kept on length alone. Cutoffs: TransDecoderPaper evidence E20-E22.
44
+ DEFAULT_DECOY_Q = 0.05
45
+ PRECISE_DECOY_Q = 0.01
46
+ SENSITIVE_DECOY_Q = 0.2
47
+ DECOY_TARGET_NT = 20_000_000 # shuffles per transcript: enough for ~20 Mb of decoy sequence, at most 50
48
+ DECOY_MAX_SHUFFLES = 50
49
+ DEFAULT_DECOY_SEED = 42
50
+ # Fewer input transcripts than this: models learned from the input are unreliable (E21); use a preset
51
+ SMALL_INPUT_TRANSCRIPTS = 1000
52
+
53
+ # Stop codons the GC-dependent long-ORF length table was simulated with
54
+ STANDARD_STOP_CODONS = frozenset({"TAA", "TAG", "TGA"})
55
+
56
+
57
+ def _perl_reverse_sort(items, key):
58
+ """Longest-first ordering exactly as Perl's `reverse sort {$a <=> $b}` produces it.
59
+
60
+ Perl's sort is stable, so reversing an ascending sort places tied items in
61
+ reverse input order (unlike sorted(..., reverse=True), which keeps input order).
62
+ The training-set selection applies this ordering at each of its three steps,
63
+ and tie order determines which equal-length ORFs make the training set.
64
+ """
65
+ return list(reversed(sorted(items, key=key)))
66
+
67
+
68
+ class TransDecoderPredict:
69
+ """Main class for TransDecoder prediction phase"""
70
+
71
+ def __init__(
72
+ self,
73
+ transcripts_file: Path,
74
+ output_dir: Optional[Path] = None,
75
+ top_orfs_train: int = 500,
76
+ retain_long_orfs_mode: str = 'dynamic',
77
+ retain_long_orfs_length: int = 1000000,
78
+ retain_pfam_hits: Optional[Path] = None,
79
+ retain_blastp_hits: Optional[Path] = None,
80
+ single_best_only: bool = True,
81
+ no_refine_starts: bool = False,
82
+ genetic_code: str = "Standard",
83
+ start_contrast_alpha: float = DEFAULT_START_CONTRAST_ALPHA,
84
+ start_kozak_weight: float = DEFAULT_START_KOZAK_WEIGHT,
85
+ decoy_q: float = DEFAULT_DECOY_Q,
86
+ decoy_shuffles: int = 0,
87
+ decoy_seed: int = DEFAULT_DECOY_SEED,
88
+ preset: Optional[str] = None,
89
+ homology_start_assist: bool = True,
90
+ ):
91
+ """
92
+ Initialize TransDecoder.Predict
93
+
94
+ Args:
95
+ transcripts_file: Input transcripts FASTA file
96
+ output_dir: Output directory (defaults to current directory)
97
+ top_orfs_train: Number of top ORFs to use for training (default: 500)
98
+ retain_long_orfs_mode: 'dynamic' or 'strict' (default: dynamic)
99
+ retain_long_orfs_length: Min length to auto-retain under strict mode
100
+ retain_pfam_hits: Pfam domain hits file
101
+ retain_blastp_hits: BLASTP hits file
102
+ single_best_only: Retain only the best ORF per transcript (default); False keeps
103
+ every non-overlapping ORF that passes selection
104
+ no_refine_starts: Skip start codon refinement
105
+ genetic_code: Genetic code to use
106
+ start_contrast_alpha: Start refinement UTR score per base of the segment before the
107
+ first in-frame ATG (the fraction of typical coding signal the segment may show
108
+ and still be called UTR)
109
+ start_kozak_weight: Start refinement UTR score per bit of the ATG's Kozak score, in
110
+ bases of typical coding signal (0 uses the coding score alone)
111
+ decoy_q: keep ORFs without homology support whose target-decoy q-value is <= this
112
+ decoy_shuffles: shuffled copies per transcript (0: automatic)
113
+ decoy_seed: seed of the decoy shuffles
114
+ preset: preset name or directory: coding model, start model and q-value thresholds from
115
+ a reference transcriptome instead of the input (small or partial inputs)
116
+ homology_start_assist: when BLASTP and/or Pfam hits are supplied, let them decide whether
117
+ a 5'-open ORF keeps its open end or is trimmed to its first ATG (core/homology_starts.py);
118
+ the ab initio rule decides where no homology rule fires
119
+ """
120
+ self.transcripts_file = Path(transcripts_file)
121
+ self.output_dir = Path(output_dir) if output_dir else Path.cwd()
122
+ self.top_orfs_train = top_orfs_train
123
+ self.retain_long_orfs_mode = retain_long_orfs_mode
124
+ self.retain_long_orfs_length = retain_long_orfs_length
125
+ self.retain_pfam_hits = Path(retain_pfam_hits) if retain_pfam_hits else None
126
+ self.retain_blastp_hits = Path(retain_blastp_hits) if retain_blastp_hits else None
127
+ self.single_best_only = single_best_only
128
+ self.no_refine_starts = no_refine_starts
129
+ self.genetic_code = genetic_code
130
+ self.start_contrast_alpha = start_contrast_alpha
131
+ self.start_kozak_weight = start_kozak_weight
132
+ self.decoy_q = decoy_q
133
+ self.decoy_shuffles = decoy_shuffles
134
+ self.decoy_seed = decoy_seed
135
+ self.homology_start_assist = homology_start_assist
136
+ self.preset = None
137
+ if preset:
138
+ from pytransdecoder.core.presets import Preset, resolve
139
+ self.preset = Preset(resolve(preset))
140
+ self._coding_contrast_cache = None
141
+ # Terminal stops excluded from Markov training and scoring
142
+ from pytransdecoder.core.translator import Translator
143
+ self.stop_codons = frozenset(Translator(genetic_code).get_stop_codons())
144
+
145
+ # Derived paths
146
+ self.workdir = self.output_dir / f"{self.transcripts_file.name}.transdecoder_dir"
147
+ self.checkpoints_dir = self.workdir / "__checkpoints_TDpredict"
148
+
149
+ # Input files from LongOrfs phase
150
+ self.longest_orfs_prefix = self.workdir / "longest_orfs"
151
+ self.cds_file = self.longest_orfs_prefix.with_suffix(self.longest_orfs_prefix.suffix + ".cds")
152
+ self.gff3_file = self.longest_orfs_prefix.with_suffix(self.longest_orfs_prefix.suffix + ".gff3")
153
+ self.pep_file = self.longest_orfs_prefix.with_suffix(self.longest_orfs_prefix.suffix + ".pep")
154
+ self.base_freqs_file = self.workdir / "base_freqs.dat"
155
+
156
+ # GC% -> minimum ORF length (nt) retained regardless of coding score: the 0.999
157
+ # quantile of LongOrfs ORF lengths in random sequence of that GC content
158
+ # (TransDecoderPaper/analyses/long_orf_threshold; same table as TransDecoder.Predict)
159
+ self.gc_to_min_length = [
160
+ (25, 519), (30, 552), (35, 573), (40, 615),
161
+ (45, 663), (50, 726), (55, 804), (60, 927),
162
+ (65, 1083), (70, 1347), (75, 1746), (80, 2436)
163
+ ]
164
+
165
+ def validate_inputs(self):
166
+ """Validate that required input files exist"""
167
+ if not self.transcripts_file.exists():
168
+ raise FileNotFoundError(f"Transcripts file not found: {self.transcripts_file}")
169
+
170
+ if not self.workdir.exists():
171
+ raise FileNotFoundError(
172
+ f"Working directory not found: {self.workdir}\n"
173
+ "Please run TransDecoder.LongOrfs first!"
174
+ )
175
+
176
+ for required_file in [self.cds_file, self.gff3_file, self.pep_file]:
177
+ if not required_file.exists():
178
+ raise FileNotFoundError(
179
+ f"Required file not found: {required_file}\n"
180
+ "Please run TransDecoder.LongOrfs first!"
181
+ )
182
+
183
+ def run(self):
184
+ """Run the complete prediction pipeline"""
185
+ logger.info("Starting TransDecoder.Predict")
186
+
187
+ # Validate inputs
188
+ self.validate_inputs()
189
+
190
+ # Create checkpoints directory
191
+ self.checkpoints_dir.mkdir(exist_ok=True)
192
+
193
+ if self.preset:
194
+ self._install_preset()
195
+ else:
196
+ self._warn_if_small_input()
197
+
198
+ # Step 1: Get top longest ORFs for training
199
+ logger.info(f"Selecting top {self.top_orfs_train} longest ORFs for training...")
200
+ top_cds_file = self._select_training_orfs()
201
+
202
+ # Step 2: Calculate hexamer scores (Markov model)
203
+ logger.info("Training hexamer scoring model...")
204
+ hexamer_scores_file = self._train_hexamer_model(top_cds_file)
205
+
206
+ # Step 3: Score all ORFs
207
+ logger.info("Scoring all candidate ORFs...")
208
+ cds_scores_file = self._score_all_orfs(hexamer_scores_file)
209
+
210
+ # Step 4: Select best ORFs
211
+ logger.info("Selecting best ORFs per transcript...")
212
+ best_orfs_gff3 = self._select_best_orfs(cds_scores_file, top_cds_file, hexamer_scores_file)
213
+
214
+ # Step 5: Refine start codons (if enabled)
215
+ if not self.no_refine_starts:
216
+ logger.info("Refining start codon predictions...")
217
+ best_orfs_gff3 = self._refine_start_codons(best_orfs_gff3, top_cds_file, hexamer_scores_file)
218
+
219
+ # Step 6: Generate final outputs
220
+ logger.info("Generating final output files...")
221
+ self._generate_final_outputs(best_orfs_gff3)
222
+
223
+ logger.info("TransDecoder.Predict completed successfully!")
224
+
225
+ def _select_training_orfs(self) -> Path:
226
+ """
227
+ Select top longest ORFs for training, removing redundancy
228
+
229
+ Process:
230
+ 1. Get top N*10 longest ORFs (up to max protein length)
231
+ 2. Remove redundant/similar sequences
232
+ 3. Select top N from the non-redundant set
233
+
234
+ Returns:
235
+ Path to file containing training ORF sequences
236
+ """
237
+ checkpoint = self.checkpoints_dir / "training_orfs.ok"
238
+ output_file = self.checkpoints_dir / "top_training_orfs.cds"
239
+
240
+ if checkpoint.exists():
241
+ logger.info("Training ORFs already selected (checkpoint exists)")
242
+ return output_file
243
+
244
+ # Parameters from Perl code
245
+ red_num = self.top_orfs_train * 10
246
+ max_cds_length = 5000 # From Perl: my $max_prot_length_for_training = 5000 (CDS nucleotide length, not protein aa)
247
+
248
+ # Step 1: Get top N*10 longest ORFs (up to max CDS length)
249
+ logger.info(f"Getting top {red_num} longest ORFs (max CDS length: {max_cds_length} nt)...")
250
+ longest_orfs = []
251
+
252
+ with open(self.cds_file) as f:
253
+ for record in read_fasta(f):
254
+ seq_len = len(record.seq)
255
+
256
+ # Filter by CDS nucleotide length (not protein length)
257
+ if seq_len <= max_cds_length:
258
+ longest_orfs.append((record, seq_len))
259
+
260
+ # Sort by length (longest first)
261
+ longest_orfs = _perl_reverse_sort(longest_orfs, key=lambda x: x[1])
262
+
263
+ # Take top red_num
264
+ longest_orfs = longest_orfs[:red_num]
265
+ logger.info(f"Selected {len(longest_orfs)} ORFs for redundancy filtering")
266
+
267
+ # Step 2: Remove redundant sequences
268
+ logger.info("Removing redundant sequences...")
269
+ nr_orfs = self._exclude_similar_proteins(longest_orfs)
270
+ logger.info(f"After redundancy filtering: {len(nr_orfs)} ORFs retained "
271
+ f"({len(nr_orfs)/len(longest_orfs)*100:.1f}%)")
272
+
273
+ # Step 3: Select top N from non-redundant set
274
+ nr_orfs_sorted = _perl_reverse_sort(nr_orfs, key=lambda x: len(x.seq))
275
+ training_orfs = nr_orfs_sorted[:self.top_orfs_train]
276
+ logger.info(f"Final training set: {len(training_orfs)} ORFs")
277
+
278
+ # Write output
279
+ with open(output_file, 'w') as f:
280
+ SeqIO.write(training_orfs, f, "fasta")
281
+
282
+ checkpoint.write_text("OK")
283
+ return output_file
284
+
285
+ def _exclude_similar_proteins(self, orf_records: List[Tuple]) -> List:
286
+ """
287
+ Exclude similar proteins using k-mer profiling
288
+
289
+ Implementation of exclude_similar_proteins.pl logic:
290
+ - Uses 5-mer profiles of translated sequences
291
+ - Filters low complexity sequences (< 30% unique kmers)
292
+ - Excludes sequences with > 80% similarity to already selected sequences
293
+
294
+ Args:
295
+ orf_records: List of (SeqRecord, length) tuples
296
+
297
+ Returns:
298
+ List of non-redundant SeqRecord objects
299
+ """
300
+ from pytransdecoder.core.translator import Translator
301
+
302
+ WMER_SIZE = 5
303
+ MAX_PER_ID = 80
304
+ captured_wmers = {} # wmer -> list of protein accessions
305
+ retained_orfs = []
306
+
307
+ # Create translator
308
+ translator = Translator(self.genetic_code)
309
+
310
+ # exclude_similar_proteins.pl re-sorts its input longest-first, which reverses ties again
311
+ for record, _ in _perl_reverse_sort(orf_records, key=lambda x: x[1]):
312
+ acc = record.id
313
+ sequence = str(record.seq)
314
+
315
+ # Translate the CDS sequence
316
+ protein = translator.translate(sequence, frame=0)
317
+
318
+ # Get spaced 5-mers for this protein (spaced by WMER_SIZE)
319
+ wmers_spaced = {}
320
+ for i in range(0, len(protein) - WMER_SIZE + 1, WMER_SIZE):
321
+ wmer = protein[i:i + WMER_SIZE]
322
+ wmers_spaced[wmer] = 1
323
+
324
+ num_unique_wmers = len(wmers_spaced)
325
+ if num_unique_wmers == 0:
326
+ continue
327
+
328
+ pct_unique_wmers = num_unique_wmers / (len(protein) / WMER_SIZE) * 100
329
+
330
+ # Skip low complexity sequences
331
+ if pct_unique_wmers < 30:
332
+ logger.debug(f"Skipping {acc} as likely low complexity sequence")
333
+ continue
334
+
335
+ # Check if this protein is too similar to already captured ones
336
+ prots_seen = {}
337
+ for wmer in wmers_spaced:
338
+ if wmer in captured_wmers:
339
+ for prot_acc in captured_wmers[wmer]:
340
+ prots_seen[prot_acc] = prots_seen.get(prot_acc, 0) + 1
341
+
342
+ proxy_per_id = 0
343
+ if prots_seen:
344
+ max_seen_acc = max(prots_seen, key=prots_seen.get)
345
+ max_seen_count = prots_seen[max_seen_acc]
346
+ proxy_per_id = max_seen_count / num_unique_wmers * 100
347
+ logger.debug(f"Analyzing {acc}: max_seen={max_seen_count} in {max_seen_acc}, "
348
+ f"proxy_per_id={proxy_per_id:.2f}")
349
+
350
+ if proxy_per_id <= MAX_PER_ID:
351
+ # Keep this ORF - it's unique enough
352
+
353
+ # Recompute wmers as overlapping (not spaced) for storage
354
+ wmers_overlapping = {}
355
+ for i in range(len(protein) - WMER_SIZE + 1):
356
+ wmer = protein[i:i + WMER_SIZE]
357
+ wmers_overlapping[wmer] = 1
358
+
359
+ # Store wmers for this protein
360
+ for wmer in wmers_overlapping:
361
+ if wmer not in captured_wmers:
362
+ captured_wmers[wmer] = []
363
+ captured_wmers[wmer].append(acc)
364
+
365
+ retained_orfs.append(record)
366
+ else:
367
+ logger.debug(f"Skipping training candidate: {acc}, not unique enough "
368
+ f"(proxy_per_id={proxy_per_id:.2f})")
369
+
370
+ return retained_orfs
371
+
372
+ def _train_hexamer_model(self, training_orfs_file: Path) -> Path:
373
+ """
374
+ Train hexamer scoring model on training ORFs
375
+
376
+ This implements a Markov chain model that computes log-likelihood scores
377
+ for hexamers (6-mers) in each reading frame.
378
+
379
+ The algorithm:
380
+ 1. Count k-mers (k=1 to 6) in each frame of training ORFs
381
+ 2. Calculate Markov probabilities: P(base | k-1mer, frame)
382
+ 3. Compare to background base frequencies
383
+ 4. Output log-likelihood ratios
384
+
385
+ Args:
386
+ training_orfs_file: File containing training ORF sequences
387
+
388
+ Returns:
389
+ Path to hexamer scores file
390
+ """
391
+ checkpoint = self.checkpoints_dir / "hexamer_scores.ok"
392
+ output_file = self.workdir / "hexamer.scores"
393
+
394
+ if checkpoint.exists():
395
+ logger.info("Hexamer model already trained (checkpoint exists)")
396
+ return output_file
397
+
398
+ # Parse training ORFs and count framed k-mers
399
+ logger.info("Counting framed k-mers in training ORFs...")
400
+ framed_kmers = {} # key: "kmer-frame", value: count
401
+
402
+ with open(training_orfs_file) as f:
403
+ for record in read_fasta(f):
404
+ sequence = str(record.seq).upper()
405
+ seq_len = len(sequence)
406
+
407
+ # Count k-mers for Markov orders 0-5 (producing 1-mers to 6-mers)
408
+ for markov_order in range(6):
409
+ for i in range(markov_order, seq_len):
410
+ frame = i % 3
411
+
412
+ # Skip stop codons at the end
413
+ if (i == seq_len - 3 and frame == 0):
414
+ codon = sequence[i:i+3]
415
+ if codon in self.stop_codons:
416
+ break
417
+
418
+ # Count frame positions (needed for Markov chain)
419
+ if markov_order == 0:
420
+ key = f"FRAME-{frame}"
421
+ framed_kmers[key] = framed_kmers.get(key, 0) + 1
422
+
423
+ # Count k-mer in this frame
424
+ if i >= markov_order:
425
+ kmer = sequence[i - markov_order:i + 1]
426
+ key = f"{kmer}-{frame}"
427
+ framed_kmers[key] = framed_kmers.get(key, 0) + 1
428
+
429
+ logger.info(f"Counted {len(framed_kmers)} unique framed k-mers")
430
+
431
+ # Load background base probabilities
432
+ logger.info("Loading background base frequencies...")
433
+ if not self.base_freqs_file.exists():
434
+ raise FileNotFoundError(f"{self.base_freqs_file} not found; run LongOrfs first")
435
+ background_probs = read_base_frequencies(str(self.base_freqs_file))
436
+ logger.info(f"Background base frequencies: {background_probs}")
437
+
438
+ # Calculate log-likelihood ratios
439
+ logger.info("Computing log-likelihood scores...")
440
+ scores = []
441
+
442
+ for framed_kmer, kmer_count in sorted(framed_kmers.items()):
443
+ if '-' not in framed_kmer or framed_kmer.startswith('FRAME-'):
444
+ continue
445
+
446
+ kmer, frame_str = framed_kmer.rsplit('-', 1)
447
+ frame = int(frame_str)
448
+
449
+ # Skip k-mers with non-GATC bases
450
+ if any(c not in 'GATC' for c in kmer):
451
+ continue
452
+
453
+ kmer_length = len(kmer)
454
+
455
+ # Get k-1mer count in previous frame
456
+ kminus1_frame = (frame - 1) % 3
457
+
458
+ if kmer_length > 1:
459
+ kminus1mer = kmer[:-1]
460
+ kminus1_key = f"{kminus1mer}-{kminus1_frame}"
461
+ kminus1_count = framed_kmers.get(kminus1_key, 0)
462
+ else:
463
+ kminus1_key = f"FRAME-{kminus1_frame}"
464
+ kminus1_count = framed_kmers.get(kminus1_key, 0)
465
+
466
+ # Markov probability with pseudocounts
467
+ markov_prob = (kmer_count + 1) / (kminus1_count + 4)
468
+
469
+ # Background probability
470
+ last_base = kmer[-1]
471
+ background_prob = background_probs[last_base]
472
+
473
+ # Log-likelihood ratio
474
+ import math
475
+ loglikelihood = math.log(markov_prob / background_prob)
476
+
477
+ scores.append((framed_kmer, kmer_count, kminus1_count, loglikelihood))
478
+
479
+ # Write scores file
480
+ logger.info(f"Writing {len(scores)} hexamer scores to {output_file}")
481
+ with open(output_file, 'w') as f:
482
+ f.write("#framed_kmer\tkmer_count\tkminus1_prefix_count\tloglikelihood\n")
483
+ for framed_kmer, kmer_count, kminus1_count, loglikelihood in scores:
484
+ f.write(f"{framed_kmer}\t{kmer_count}\t{kminus1_count}\t{loglikelihood}\n")
485
+
486
+ checkpoint.write_text("OK")
487
+ return output_file
488
+
489
+ def _score_all_orfs(self, hexamer_scores_file: Path) -> Path:
490
+ """
491
+ Score all ORFs using hexamer model
492
+
493
+ Scores each ORF in all 6 reading frames using the trained hexamer model.
494
+ The score for each frame is the sum of log-likelihood scores for each
495
+ hexamer in that frame.
496
+
497
+ Args:
498
+ hexamer_scores_file: File containing hexamer log-likelihood scores
499
+
500
+ Returns:
501
+ Path to CDS scores file
502
+ """
503
+ checkpoint = self.checkpoints_dir / "cds_scores.ok"
504
+ output_file = self.workdir / "longest_orfs.cds.scores"
505
+
506
+ if checkpoint.exists():
507
+ logger.info("ORF scores already computed (checkpoint exists)")
508
+ return output_file
509
+
510
+ # Load hexamer scores
511
+ logger.info("Loading hexamer scores...")
512
+ scores = {}
513
+ with open(hexamer_scores_file) as f:
514
+ for line in f:
515
+ if line.startswith('#'):
516
+ continue
517
+ parts = line.strip().split('\t')
518
+ if len(parts) >= 4:
519
+ framed_kmer = parts[0]
520
+ loglikelihood = float(parts[3])
521
+ scores[framed_kmer] = loglikelihood
522
+
523
+ logger.info(f"Loaded {len(scores)} hexamer scores")
524
+
525
+ # Score all ORFs in all 6 frames
526
+ logger.info("Scoring all candidate ORFs...")
527
+ from pytransdecoder.core.sequence import reverse_complement
528
+
529
+ results = []
530
+ with open(self.cds_file) as f:
531
+ for record in read_fasta(f):
532
+ accession = record.id
533
+ sequence = str(record.seq).upper()
534
+
535
+ # Use Markov order 5 (hexamers = 6-mers)
536
+ markov_order = 5
537
+
538
+ # Score forward strand frames
539
+ score1 = self._score_cds_via_markov(sequence, markov_order, scores)
540
+ score2 = self._score_cds_via_markov(sequence[1:], markov_order, scores)
541
+ score3 = self._score_cds_via_markov(sequence[2:], markov_order, scores)
542
+
543
+ # Score reverse strand frames
544
+ rev_seq = reverse_complement(sequence)
545
+ score4 = self._score_cds_via_markov(rev_seq, markov_order, scores)
546
+ score5 = self._score_cds_via_markov(rev_seq[1:], markov_order, scores)
547
+ score6 = self._score_cds_via_markov(rev_seq[2:], markov_order, scores)
548
+
549
+ results.append((accession, markov_order, len(sequence),
550
+ score1, score2, score3, score4, score5, score6))
551
+
552
+ # Write scores file
553
+ logger.info(f"Writing scores for {len(results)} ORFs to {output_file}")
554
+ with open(output_file, 'w') as f:
555
+ f.write("#acc\tMarkov_order\tseq_length\tscore_1\tscore_2\tscore_3\tscore_4\tscore_5\tscore_6\n")
556
+ for row in results:
557
+ acc, order, length, s1, s2, s3, s4, s5, s6 = row
558
+ f.write(f"{acc}\t{order}\t{length}\t{s1:.2f}\t{s2:.2f}\t{s3:.2f}\t{s4:.2f}\t{s5:.2f}\t{s6:.2f}\n")
559
+
560
+ checkpoint.write_text("OK")
561
+ return output_file
562
+
563
+ def _score_cds_via_markov(self, sequence: str, markov_order: int, scores: Dict[str, float]) -> float:
564
+ """
565
+ Score a sequence using Markov model
566
+
567
+ Args:
568
+ sequence: DNA sequence to score
569
+ markov_order: Markov order (5 for hexamers)
570
+ scores: Dictionary of framed k-mer scores
571
+
572
+ Returns:
573
+ Total log-likelihood score
574
+ """
575
+ seq_length = len(sequence)
576
+
577
+ if seq_length < markov_order + 1:
578
+ return 0.0
579
+
580
+ total_score = 0.0
581
+
582
+ for i in range(seq_length):
583
+ frame = i % 3
584
+
585
+ # Use appropriate k-mer size (up to markov_order)
586
+ markov_use = min(i, markov_order)
587
+ kmer = sequence[i - markov_use:i + 1]
588
+
589
+ # Skip stop codons at the end
590
+ if i == seq_length - 3 and frame == 0:
591
+ codon = sequence[i:i+3]
592
+ if codon in self.stop_codons:
593
+ break
594
+
595
+ # Look up score
596
+ framed_kmer = f"{kmer}-{frame}"
597
+ loglikelihood = scores.get(framed_kmer, 0.0)
598
+
599
+ total_score += loglikelihood
600
+
601
+ return total_score
602
+
603
+ def _select_best_orfs(self, cds_scores_file: Path, training_orfs_file: Optional[Path] = None,
604
+ hexamer_scores_file: Optional[Path] = None) -> Path:
605
+ """
606
+ Select best ORFs per transcript based on scores and homology
607
+
608
+ Selection criteria (any of):
609
+ 1. Has BLAST hit
610
+ 2. Has Pfam hit
611
+ 3. Score[frame0] > 0 AND score[frame0] > max(score[frame1], score[frame2]) AND a
612
+ target-decoy q-value <= decoy_q (with a preset: the preset's threshold at decoy_q)
613
+ 4. Length >= min_length_auto_accept (for dynamic/strict mode)
614
+
615
+ Prioritization:
616
+ - homology_count (BLAST + Pfam hits)
617
+ - frame score[0]
618
+ - ORF length
619
+
620
+ Args:
621
+ cds_scores_file: File containing CDS scores
622
+
623
+ Returns:
624
+ Path to GFF3 file with selected ORFs
625
+ """
626
+ checkpoint = self.checkpoints_dir / "best_orfs.ok"
627
+ output_file = self.workdir / "best_candidates.gff3"
628
+
629
+ if checkpoint.exists():
630
+ logger.info("Best ORFs already selected (checkpoint exists)")
631
+ return output_file
632
+
633
+ # Parse homology data
634
+ blast_hits = {}
635
+ if self.retain_blastp_hits:
636
+ logger.info(f"Parsing BLAST hits from {self.retain_blastp_hits}")
637
+ blast_hits = self._parse_blastp_hits()
638
+
639
+ pfam_hits = {}
640
+ if self.retain_pfam_hits:
641
+ logger.info(f"Parsing Pfam hits from {self.retain_pfam_hits}")
642
+ pfam_hits = self._parse_pfam_hits()
643
+
644
+ # Parse CDS scores
645
+ logger.info(f"Parsing CDS scores from {cds_scores_file}")
646
+ cds_scores = self._parse_cds_scores(cds_scores_file)
647
+
648
+ # Calculate min length for auto-accept based on GC content
649
+ min_length_auto_accept = self._min_length_auto_accept()
650
+
651
+ if self.preset:
652
+ decoy_q = self._preset_pass(cds_scores)
653
+ min_length_auto_accept = self.preset.auto_accept_nt
654
+ logger.info("Preset long-ORF auto-accept: %s nt", min_length_auto_accept)
655
+ else:
656
+ decoy_q = self._decoy_qvalues(cds_scores, hexamer_scores_file)
657
+
658
+ # Parse GFF3 and select best ORFs
659
+ logger.info(f"Selecting best ORFs from {self.gff3_file}")
660
+ from pytransdecoder.core.gff3_parser import parse_gff3
661
+
662
+ orfs_by_transcript = {}
663
+ for orf in parse_gff3(self.gff3_file):
664
+ # one candidate per ORF: its mRNA feature carries the ORF ID used for
665
+ # scores and homology hits (gene/exon/CDS lines would otherwise enter as
666
+ # unscored duplicates and could displace the ORF in overlap filtering)
667
+ if orf['type'] != 'mRNA':
668
+ continue
669
+ transcript_id = orf['seqid']
670
+ if transcript_id not in orfs_by_transcript:
671
+ orfs_by_transcript[transcript_id] = []
672
+ orfs_by_transcript[transcript_id].append(orf)
673
+
674
+ # Select best ORFs per transcript
675
+ selected_orfs = []
676
+ for transcript_id in sorted(orfs_by_transcript.keys()):
677
+ orfs = orfs_by_transcript[transcript_id]
678
+
679
+ # Filter and score ORFs
680
+ candidates = []
681
+ for orf in orfs:
682
+ orf_id = orf['attributes'].get('ID', [''])[0]
683
+ orf_length = orf['end'] - orf['start'] + 1
684
+
685
+ # Get homology count
686
+ homology_count = 0
687
+ blast_info = ""
688
+ pfam_info = ""
689
+ if orf_id in blast_hits:
690
+ homology_count += 1
691
+ blast_info = blast_hits[orf_id]
692
+ if orf_id in pfam_hits:
693
+ homology_count += 1
694
+ pfam_info = pfam_hits[orf_id]
695
+
696
+ # Get CDS scores (6 frames)
697
+ scores = cds_scores.get(orf_id, [0.0] * 6)
698
+
699
+ # Apply selection criteria
700
+ frame_contrast = scores[0] - max(scores[1], scores[2])
701
+ meets_criteria = (homology_count > 0 or orf_length >= min_length_auto_accept or
702
+ (scores[0] > 0 and frame_contrast > 0 and
703
+ decoy_q.get(orf_id, 1.0) <= self.decoy_q))
704
+
705
+ if meets_criteria:
706
+ candidates.append({
707
+ 'orf': orf,
708
+ 'orf_id': orf_id,
709
+ 'length': orf_length,
710
+ 'homology_count': homology_count,
711
+ 'scores': scores,
712
+ 'blast_info': blast_info,
713
+ 'pfam_info': pfam_info
714
+ })
715
+
716
+ if not candidates:
717
+ continue
718
+
719
+ # Prioritize ORFs
720
+ candidates.sort(key=lambda x: (
721
+ -x['homology_count'],
722
+ -x['scores'][0],
723
+ -x['length'],
724
+ # exact ties (e.g. identical repeated ORFs): leftmost, then plus strand
725
+ min(x['orf']['start'], x['orf']['end']),
726
+ max(x['orf']['start'], x['orf']['end']),
727
+ x['orf']['strand']
728
+ ))
729
+
730
+ # Select ORFs
731
+ if self.single_best_only:
732
+ # the top candidate by homology, then coding score, then length (the priority
733
+ # used for overlap removal); ranking by length alone favours long antisense ORFs
734
+ selected_orfs.append(candidates[0])
735
+ else:
736
+ # Remove overlapping ORFs
737
+ selected_orfs.extend(self._remove_overlapping_orfs(candidates))
738
+
739
+ # Write output GFF3
740
+ logger.info(f"Writing {len(selected_orfs)} selected ORFs to {output_file}")
741
+ with open(output_file, 'w') as f:
742
+ f.write("##gff-version 3\n")
743
+ for candidate in selected_orfs:
744
+ orf = candidate['orf']
745
+
746
+ # Update attributes with score and homology info
747
+ attrs = orf['attributes'].copy()
748
+ attrs['score'] = [str(candidate['scores'][0])]
749
+ if candidate['blast_info']:
750
+ attrs['blast'] = [candidate['blast_info']]
751
+ if candidate['pfam_info']:
752
+ attrs['pfam'] = [candidate['pfam_info']]
753
+
754
+ # Write GFF3 line
755
+ attr_str = ';'.join([f"{k}={','.join(v)}" for k, v in attrs.items()])
756
+ f.write(f"{orf['seqid']}\ttransdecoder\t{orf['type']}\t{orf['start']}\t{orf['end']}\t"
757
+ f"{orf['score']}\t{orf['strand']}\t{orf['phase']}\t{attr_str}\n")
758
+
759
+ checkpoint.write_text("OK")
760
+ return output_file
761
+
762
+ def _read_blastp_first_hits(self) -> Dict[str, List[str]]:
763
+ """First (best) outfmt 6 line of each query in the BLASTP hits file, split into fields"""
764
+ first = {}
765
+ with open(self.retain_blastp_hits) as f:
766
+ for line in f:
767
+ if line.startswith('#'):
768
+ continue
769
+ parts = line.strip().split('\t')
770
+ if len(parts) >= 12 and parts[0] not in first:
771
+ first[parts[0]] = parts
772
+ return first
773
+
774
+ def _parse_blastp_hits(self) -> Dict[str, str]:
775
+ """Parse BLASTP hits file (outfmt 6 format)"""
776
+ return {query_id: f",blast:{parts[1]}|{parts[10]}|{parts[11]}"
777
+ for query_id, parts in self._read_blastp_first_hits().items()}
778
+
779
+ def _read_pfam_domains(self):
780
+ """Domains of the Pfam hits file (domtblout from hmmscan or hmmsearch), as
781
+ (orf_id, pfam_name, pfam_acc, domain_evalue, fields) in file order"""
782
+ with open(self.retain_pfam_hits) as f:
783
+ for line in f:
784
+ if line.startswith('#'):
785
+ continue
786
+ parts = line.split()
787
+ if len(parts) >= 13:
788
+ # Try hmmscan format first (pfam_acc in column 1)
789
+ if parts[1].startswith('PF'):
790
+ yield parts[3], parts[0], parts[1], parts[12], parts
791
+ # Try hmmsearch format (pfam_acc in column 4)
792
+ elif parts[4].startswith('PF'):
793
+ yield parts[0], parts[3], parts[4], parts[12], parts
794
+
795
+ def _parse_pfam_hits(self) -> Dict[str, str]:
796
+ """Parse Pfam hits file (domtblout format from hmmscan/hmmsearch)"""
797
+ hits = {}
798
+ for orf_id, pfam_name, pfam_acc, domain_evalue, _ in self._read_pfam_domains():
799
+ if orf_id not in hits:
800
+ hits[orf_id] = ""
801
+ hits[orf_id] += f",pfam:{pfam_name}|{pfam_acc}|{domain_evalue}"
802
+ return hits
803
+
804
+ def _parse_cds_scores(self, cds_scores_file: Path) -> Dict[str, List[float]]:
805
+ """Parse CDS scores file"""
806
+ scores = {}
807
+ with open(cds_scores_file) as f:
808
+ for line in f:
809
+ if line.startswith('#'):
810
+ continue
811
+ parts = line.strip().split('\t')
812
+ if len(parts) >= 9:
813
+ orf_id = parts[0]
814
+ # scores: markov_order, seq_length, score_1..6
815
+ frame_scores = [float(parts[i]) for i in range(3, 9)]
816
+ scores[orf_id] = frame_scores
817
+ return scores
818
+
819
+ def _warn_if_small_input(self):
820
+ with open(self.transcripts_file) as fh:
821
+ n = sum(1 for line in fh if line.startswith('>'))
822
+ if n < SMALL_INPUT_TRANSCRIPTS:
823
+ from pytransdecoder.core.presets import bundled_presets
824
+ logger.warning("Only %d input transcripts: coding and start models learned from fewer than %d "
825
+ "transcripts are unreliable, and noncoding sequences then often receive ORF "
826
+ "calls. Consider --preset (bundled: %s) or a preset built from a related "
827
+ "species' transcriptome (build-preset).", n, SMALL_INPUT_TRANSCRIPTS,
828
+ ", ".join(bundled_presets()) or "none installed")
829
+
830
+ def _install_preset(self):
831
+ """Use the preset's training ORFs and coding model instead of learning them from the input."""
832
+ p = self.preset
833
+ logger.info("Preset %s (%s; GC %s%%, genetic code %s): coding model, training ORFs, start model and "
834
+ "ORF thresholds from the preset", p.meta.get("name"), p.meta.get("source"),
835
+ p.meta.get("gc_pct"), p.meta.get("genetic_code"))
836
+ from pytransdecoder.core.presets import copy_plain
837
+ from pytransdecoder.core.translator import Translator
838
+ if Translator(p.meta.get("genetic_code", "universal")).table_id != Translator(self.genetic_code).table_id:
839
+ raise ValueError(f"preset {p.meta.get('name')} was built with genetic code "
840
+ f"{p.meta.get('genetic_code')}, not -G {self.genetic_code}")
841
+ copy_plain(p.training_orfs, self.checkpoints_dir / "top_training_orfs.cds")
842
+ (self.checkpoints_dir / "training_orfs.ok").write_text("OK")
843
+ copy_plain(p.hexamer_scores, self.workdir / "hexamer.scores")
844
+ (self.checkpoints_dir / "hexamer_scores.ok").write_text("OK")
845
+
846
+ def _target_strata(self, cds_scores: Dict[str, List[float]], strata: str) -> Dict[str, Tuple]:
847
+ """(stratum, frame contrast) of each eligible candidate ORF (in-frame score > 0, contrast > 0)."""
848
+ from pytransdecoder.core import decoys
849
+ with open(self.transcripts_file) as fh:
850
+ tx_len = {r.id: len(r.seq) for r in read_fasta(fh)}
851
+ info, lengths = {}, {}
852
+ with open(self.cds_file) as fh:
853
+ for r in read_fasta(fh):
854
+ lengths[r.id] = len(r.seq) // 3
855
+ with open(self.cds_file) as fh:
856
+ for line in fh:
857
+ if line.startswith('>'):
858
+ x = line[1:].split()
859
+ info[x[0]] = (x[1].split(':', 1)[1], x[2].rsplit(':', 1)[0])
860
+ targets = {}
861
+ for orf_id, sc in cds_scores.items():
862
+ c = sc[0] - max(sc[1], sc[2])
863
+ if sc[0] > 0 and c > 0 and orf_id in info:
864
+ orf_type, tid = info[orf_id]
865
+ targets[orf_id] = (decoys.stratum(lengths[orf_id], tx_len.get(tid, 0), orf_type == 'complete',
866
+ strata), c)
867
+ return targets
868
+
869
+ def _preset_pass(self, cds_scores: Dict[str, List[float]]) -> Dict[str, float]:
870
+ """Pseudo q-values from the preset thresholds: 0 when an ORF reaches its stratum's threshold."""
871
+ targets = self._target_strata(cds_scores, self.preset.strata)
872
+ out = {orf_id: (0.0 if c >= self.preset.threshold(k, self.decoy_q) else 1.0)
873
+ for orf_id, (k, c) in targets.items()}
874
+ logger.info("Preset thresholds at q %g (stored level %g): %d of %d eligible candidates pass",
875
+ self.decoy_q, self.preset.level(self.decoy_q), sum(v == 0.0 for v in out.values()), len(out))
876
+ return out
877
+
878
+ def _decoy_qvalues(self, cds_scores: Dict[str, List[float]], hexamer_scores_file: Path) -> Dict[str, float]:
879
+ """Target-decoy q-value of each eligible candidate ORF (in-frame score > 0, frame contrast > 0)."""
880
+ import json
881
+ from pytransdecoder.core import decoys
882
+ from pytransdecoder.core.prng import Xoshiro128ss
883
+ from pytransdecoder.core.orf_finder import ORFFinder
884
+ params_file = self.workdir / "longorfs.params.json"
885
+ if params_file.exists():
886
+ params = json.loads(params_file.read_text())
887
+ else: # LongOrfs from before the params file: infer its settings from its outputs
888
+ min_aa, minus, all_complete = 10 ** 9, False, True
889
+ with open(self.cds_file) as fh:
890
+ for line in fh:
891
+ if line.startswith('>'):
892
+ minus |= line.rstrip().endswith('(-)')
893
+ all_complete &= ' type:complete ' in line
894
+ with open(self.pep_file) as fh:
895
+ for _, pep in ((r.id, str(r.seq)) for r in read_fasta(fh)):
896
+ min_aa = min(min_aa, len(pep.rstrip('*')))
897
+ params = {"min_protein_length": min_aa, "genetic_code": self.genetic_code,
898
+ "strand_specific": not minus, "complete_orfs_only": all_complete}
899
+ logger.warning("No longorfs.params.json; decoy ORF settings inferred: %s", params)
900
+ finder = ORFFinder(min_protein_length=params["min_protein_length"],
901
+ allow_5prime_partial=not params["complete_orfs_only"],
902
+ allow_3prime_partial=not params["complete_orfs_only"],
903
+ forward_strand=True, reverse_strand=not params["strand_specific"],
904
+ genetic_code=params["genetic_code"],
905
+ complete_orfs_only=params["complete_orfs_only"])
906
+ hex_scores = {}
907
+ with open(hexamer_scores_file) as fh:
908
+ for line in fh:
909
+ if not line.startswith('#'):
910
+ parts = line.rstrip('\n').split('\t')
911
+ if len(parts) >= 4:
912
+ hex_scores[parts[0]] = float(parts[3])
913
+ with open(self.transcripts_file) as fh:
914
+ transcripts = {r.id: str(r.seq).upper() for r in read_fasta(fh)}
915
+ total_nt = sum(len(x) for x in transcripts.values())
916
+ n_shuf = self.decoy_shuffles or max(1, min(DECOY_MAX_SHUFFLES, -(-DECOY_TARGET_NT // max(total_nt, 1))))
917
+
918
+ def contrast(seq):
919
+ s0 = round(self._score_cds_via_markov(seq, 5, hex_scores), 2)
920
+ s1 = round(self._score_cds_via_markov(seq[1:], 5, hex_scores), 2)
921
+ s2 = round(self._score_cds_via_markov(seq[2:], 5, hex_scores), 2)
922
+ return s0, s0 - max(s1, s2)
923
+
924
+ # targets: eligible LongOrfs candidates
925
+ info = {}
926
+ with open(self.cds_file) as fh:
927
+ for line in fh:
928
+ if line.startswith('>'):
929
+ x = line[1:].split()
930
+ info[x[0]] = (x[1].split(':', 1)[1], x[2].rsplit(':', 1)[0])
931
+ lengths = {}
932
+ with open(self.cds_file) as fh:
933
+ for r in read_fasta(fh):
934
+ lengths[r.id] = len(r.seq) // 3
935
+ targets = {}
936
+ for orf_id, sc in cds_scores.items():
937
+ c = sc[0] - max(sc[1], sc[2])
938
+ if sc[0] > 0 and c > 0 and orf_id in info:
939
+ orf_type, tid = info[orf_id]
940
+ targets[orf_id] = (decoys.stratum(lengths[orf_id], len(transcripts.get(tid, '')),
941
+ orf_type == 'complete', 'len_tx_type'), c)
942
+ # decoys
943
+ decoy_scores, n_decoy = [], 0
944
+ for tid in sorted(transcripts):
945
+ seq = transcripts[tid]
946
+ for k in range(n_shuf):
947
+ shuf = decoys.dinucleotide_shuffle(seq, Xoshiro128ss(f"{self.decoy_seed}:{tid}:{k}"))
948
+ for orf in finder.find_all_orfs(shuf, f"decoy{k}_{tid}"):
949
+ n_decoy += 1
950
+ cds = orf.sequence.upper()
951
+ s0, c = contrast(cds)
952
+ if s0 > 0 and c > 0:
953
+ decoy_scores.append((decoys.stratum(len(cds) // 3, len(seq), orf.orf_type == 'complete',
954
+ 'len_tx_type'), c))
955
+ # q-values at the precision written to decoy_qvalues.tsv, which TransDecoder (Perl) selects on
956
+ q = {k: float(f"{v:.5g}") for k, v in decoys.qvalues(targets, decoy_scores, n_shuf).items()}
957
+ out = self.workdir / "decoy_qvalues.tsv"
958
+ with open(out, 'w') as fh:
959
+ fh.write("#orf_id\tstratum\tcontrast\tq\n")
960
+ for orf_id in sorted(q):
961
+ fh.write(f"{orf_id}\t{','.join(map(str, targets[orf_id][0]))}\t{targets[orf_id][1]:.2f}\t{q[orf_id]:.5g}\n")
962
+ logger.info("Decoys: %d shuffle(s) per transcript (seed %d), %d decoy ORFs (%d eligible), strata %s; "
963
+ "%d eligible candidates, %d with q <= %g (%s)", n_shuf, self.decoy_seed, n_decoy,
964
+ len(decoy_scores), 'len_tx_type', len(targets),
965
+ sum(v <= self.decoy_q for v in q.values()), self.decoy_q, out)
966
+ return q
967
+
968
+ def _min_length_auto_accept(self) -> float:
969
+ """ORF length (nt) at or above which an ORF is retained regardless of its coding score."""
970
+ if self.retain_long_orfs_mode != 'dynamic':
971
+ logger.info(f"Strict mode: min auto-accept length = {self.retain_long_orfs_length} nt")
972
+ return self.retain_long_orfs_length
973
+ if self.stop_codons != STANDARD_STOP_CODONS:
974
+ # the GC table was simulated with TAA/TAG/TGA; with fewer stops random ORFs are much
975
+ # longer (e.g. TGA-only ciliate code), so no ORF is retained on length alone
976
+ logger.info("Dynamic mode: genetic code stops %s differ from TAA/TAG/TGA; "
977
+ "no length-based auto-accept", ",".join(sorted(self.stop_codons)))
978
+ return float('inf')
979
+ min_length = self._get_min_length_by_gc()
980
+ logger.info(f"Dynamic mode: min auto-accept length = {min_length} nt")
981
+ return min_length
982
+
983
+ def _get_min_length_by_gc(self) -> float:
984
+ """
985
+ Calculate minimum ORF length based on GC content (dynamic mode)
986
+
987
+ Returns:
988
+ Minimum length threshold in nucleotides
989
+ """
990
+ # Calculate GC content from transcripts
991
+ gc_count = 0
992
+ total_count = 0
993
+
994
+ with open(self.transcripts_file) as f:
995
+ for record in read_fasta(f):
996
+ sequence = str(record.seq).upper()
997
+ gc_count += sequence.count('G') + sequence.count('C')
998
+ # as Perl's base_freqs.dat: A/C/G/T only
999
+ total_count += sum(sequence.count(b) for b in 'ACGT')
1000
+
1001
+ gc_pct = (gc_count / total_count * 100) if total_count > 0 else 50
1002
+ logger.info(f"Transcriptome GC content: {gc_pct:.1f}%")
1003
+
1004
+ # Find appropriate threshold from lookup table
1005
+ min_length = 100 # default
1006
+ for gc_threshold, length_threshold in self.gc_to_min_length:
1007
+ if gc_pct <= gc_threshold:
1008
+ min_length = length_threshold
1009
+ break
1010
+ else:
1011
+ # GC > 80%: as Perl, rely on the coding score alone (no length-based auto-accept)
1012
+ min_length = float('inf')
1013
+
1014
+ return min_length
1015
+
1016
+ def _remove_overlapping_orfs(self, candidates: List[Dict]) -> List[Dict]:
1017
+ """
1018
+ Remove overlapping ORFs, keeping higher priority ones
1019
+
1020
+ ORFs with homology support are always retained, even if they overlap.
1021
+ This matches Perl's behavior: $gene_entry->{homology_count} || ! &has_sufficient_overlap()
1022
+
1023
+ Args:
1024
+ candidates: List of candidate ORFs (already sorted by priority)
1025
+
1026
+ Returns:
1027
+ List of non-overlapping ORFs (plus all ORFs with homology)
1028
+ """
1029
+ MAX_PCT_OVERLAP = 10
1030
+
1031
+ selected = []
1032
+ for candidate in candidates:
1033
+ # Always include ORFs with homology support (Perl behavior)
1034
+ if candidate['homology_count'] > 0:
1035
+ selected.append(candidate)
1036
+ continue
1037
+
1038
+ orf = candidate['orf']
1039
+ start = orf['start']
1040
+ end = orf['end']
1041
+ strand = orf['strand']
1042
+ length = end - start + 1
1043
+
1044
+ # Check for overlap with already selected ORFs
1045
+ # NOTE: Perl checks overlap regardless of strand to avoid selecting
1046
+ # multiple ORFs at the same genomic location (even on opposite strands)
1047
+ overlaps = False
1048
+ for selected_candidate in selected:
1049
+ sel_orf = selected_candidate['orf']
1050
+ # Perl does NOT check strand - it filters overlaps regardless of strand
1051
+ # This prevents selecting ORFs on opposite strands at same location
1052
+
1053
+ sel_start = sel_orf['start']
1054
+ sel_end = sel_orf['end']
1055
+ sel_length = sel_end - sel_start + 1
1056
+
1057
+ # Calculate overlap
1058
+ overlap_start = max(start, sel_start)
1059
+ overlap_end = min(end, sel_end)
1060
+
1061
+ if overlap_start <= overlap_end:
1062
+ overlap_length = overlap_end - overlap_start + 1
1063
+ pct_overlap_curr = (overlap_length / length) * 100
1064
+ pct_overlap_sel = (overlap_length / sel_length) * 100
1065
+
1066
+ if pct_overlap_curr > MAX_PCT_OVERLAP or pct_overlap_sel > MAX_PCT_OVERLAP:
1067
+ overlaps = True
1068
+ break
1069
+
1070
+ if not overlaps:
1071
+ selected.append(candidate)
1072
+
1073
+ return selected
1074
+
1075
+ def _coding_contrast(self, training_orfs_file: Path, hexamer_scores_file: Path) -> Optional[float]:
1076
+ """Median per-base frame contrast of the training ORFs (None without training ORFs)."""
1077
+ if self._coding_contrast_cache is None:
1078
+ scores = self._load_hexamer_scores(hexamer_scores_file)
1079
+ per_base = []
1080
+ with open(training_orfs_file) as f:
1081
+ for record in read_fasta(f):
1082
+ seq = str(record.seq).upper()
1083
+ if len(seq) >= 30:
1084
+ per_base.append(self._frame_contrast(seq, scores) / len(seq))
1085
+ if not per_base:
1086
+ return None
1087
+ per_base.sort()
1088
+ mid = len(per_base) // 2
1089
+ self._coding_contrast_cache = (per_base[mid] if len(per_base) % 2
1090
+ else (per_base[mid - 1] + per_base[mid]) / 2)
1091
+ return self._coding_contrast_cache
1092
+
1093
+ def _refine_start_codons(self, gff3_file: Path, training_orfs_file: Path,
1094
+ hexamer_scores_file: Path) -> Path:
1095
+ """
1096
+ Decide, for each ORF open at its 5' end (no start codon), whether it is a true
1097
+ 5'-partial ORF or extends into a 5' UTR and should start at its first in-frame ATG.
1098
+
1099
+ Two scores are compared for the segment between the open end and the ATG, both in bases'
1100
+ worth of typical coding signal (the training ORFs' median per-base frame contrast):
1101
+ - coding score: the segment's frame contrast (coding Markov score in frame minus the
1102
+ better of the two shifted frames, as in ORF selection) divided by that median.
1103
+ Coding sequence scores about 1 per base; UTR about 0.
1104
+ - UTR score: start_contrast_alpha * segment length + start_kozak_weight * the ATG's Kozak
1105
+ score. The Kozak score is a log2-odds model (bits) of -6..-1 and +4..+5 trained on the
1106
+ training ORFs' starts against in-frame ATGs inside them.
1107
+ The ORF is trimmed to the ATG when the UTR score is higher; otherwise it stays 5' partial.
1108
+
1109
+ Returns:
1110
+ Path to the revised GFF3 file
1111
+ """
1112
+ checkpoint = self.checkpoints_dir / "refine_starts.ok"
1113
+ revised_gff3 = Path(str(gff3_file) + ".revised_starts.gff3")
1114
+
1115
+ if checkpoint.exists():
1116
+ logger.info("Start codon refinement already done (checkpoint exists)")
1117
+ return revised_gff3 if revised_gff3.exists() else gff3_file
1118
+
1119
+ scores = self._load_hexamer_scores(hexamer_scores_file)
1120
+ coding_contrast = self._coding_contrast(training_orfs_file, hexamer_scores_file)
1121
+ if coding_contrast is None:
1122
+ logger.warning("No training ORFs for start refinement; skipping")
1123
+ checkpoint.write_text("OK")
1124
+ return gff3_file
1125
+ logger.info("Training ORFs: median frame contrast %.4f per base; open 5' ends trimmed to the "
1126
+ "first ATG when %.2f x segment length + %g x Kozak bits exceeds the segment's "
1127
+ "coding score", coding_contrast, self.start_contrast_alpha, self.start_kozak_weight)
1128
+
1129
+ transcripts = self._load_transcripts()
1130
+ if self.preset:
1131
+ from pytransdecoder.core import kozak as kozak_mod
1132
+ kozak = kozak_mod.read_model(self.preset.kozak) if self.preset.kozak else None
1133
+ logger.info("Start-context model from preset: %s", self.preset.kozak)
1134
+ else:
1135
+ kozak = self._train_kozak_model(training_orfs_file, transcripts)
1136
+
1137
+ homology = self._homology_start_evidence()
1138
+ num_revised = self._revise_start_sites(
1139
+ gff3_file, revised_gff3, transcripts, scores, coding_contrast, kozak, homology
1140
+ )
1141
+ logger.info("Revised %s start codon positions", num_revised)
1142
+
1143
+ checkpoint.write_text("OK")
1144
+ return revised_gff3
1145
+
1146
+ def _homology_start_evidence(self) -> Optional[Dict[str, Dict]]:
1147
+ """BLASTP (qstart, sstart of each ORF's best hit) and Pfam (smallest envelope start) evidence
1148
+ for homology-assisted start refinement; None when no hits were supplied or the assist is off."""
1149
+ if not self.homology_start_assist or not (self.retain_blastp_hits or self.retain_pfam_hits):
1150
+ return None
1151
+ blast, pfam = None, None
1152
+ if self.retain_blastp_hits:
1153
+ blast = {q: (int(p[6]), int(p[8])) for q, p in self._read_blastp_first_hits().items()}
1154
+ if self.retain_pfam_hits:
1155
+ pfam = {}
1156
+ for orf_id, _, _, _, parts in self._read_pfam_domains():
1157
+ env_from = int(parts[19])
1158
+ if orf_id not in pfam or env_from < pfam[orf_id]:
1159
+ pfam[orf_id] = env_from
1160
+ logger.info("Homology-assisted start refinement: BLASTP %s, Pfam %s",
1161
+ f"{len(blast)} ORFs with hits" if blast is not None else "not supplied",
1162
+ f"{len(pfam)} ORFs with domains" if pfam is not None else "not supplied")
1163
+ return {"blast": blast, "pfam": pfam}
1164
+
1165
+ def _load_hexamer_scores(self, hexamer_scores_file: Path) -> Dict[str, float]:
1166
+ scores = {}
1167
+ with open(hexamer_scores_file) as f:
1168
+ for line in f:
1169
+ if line.startswith('#'):
1170
+ continue
1171
+ parts = line.strip().split('\t')
1172
+ if len(parts) >= 4:
1173
+ scores[parts[0]] = float(parts[3])
1174
+ return scores
1175
+
1176
+ def _frame_contrast(self, sequence: str, scores: Dict[str, float]) -> float:
1177
+ """In-frame Markov score minus the better of the two shifted-frame scores."""
1178
+ return self._score_cds_via_markov(sequence, 5, scores) - max(
1179
+ self._score_cds_via_markov(sequence[1:], 5, scores),
1180
+ self._score_cds_via_markov(sequence[2:], 5, scores),
1181
+ )
1182
+
1183
+ def _train_kozak_model(self, training_orfs_file: Path, transcripts: Dict[str, str]):
1184
+ """
1185
+ Start-codon (Kozak) context model (core/kozak.py): log2-odds (bits) of -6..-1 and +4..+5 for the
1186
+ training ORFs' starts vs in-frame ATGs inside them. Writes start_refinement.kozak.tsv,
1187
+ .kozak.counts.tsv and .kozak.logo.svg (or .kozak.SKIPPED) in the work directory.
1188
+
1189
+ Returns the model, or None if fewer than kozak.MIN_TRAINING_STARTS training ORFs start
1190
+ with ATG.
1191
+ """
1192
+ from pytransdecoder.core import kozak
1193
+ from pytransdecoder.core.sequence import reverse_complement
1194
+
1195
+ prefix = self.workdir / "start_refinement"
1196
+ pos_counts, neg_counts = kozak.new_counts(), kozak.new_counts()
1197
+ num_starts = 0
1198
+ pattern = re.compile(r"(\S+):(\d+)-(\d+)\(([+-])\)")
1199
+ with open(training_orfs_file) as handle:
1200
+ for record in read_fasta(handle):
1201
+ match = pattern.search(record.description)
1202
+ if not match or match.group(1) not in transcripts:
1203
+ continue
1204
+ seq = transcripts[match.group(1)].upper()
1205
+ lo, hi = sorted((int(match.group(2)), int(match.group(3))))
1206
+ if match.group(4) == "-":
1207
+ seq = reverse_complement(seq)
1208
+ lo, hi = len(seq) - hi + 1, len(seq) - lo + 1
1209
+ start, end = lo - 1, hi
1210
+ if seq[start:start + 3] != "ATG":
1211
+ continue
1212
+ num_starts += 1
1213
+ kozak.add_site(pos_counts, seq, start)
1214
+ for i in range(start + 3, end - 3, 3):
1215
+ if seq[i:i + 3] == "ATG":
1216
+ kozak.add_site(neg_counts, seq, i)
1217
+
1218
+ skipped = Path(f"{prefix}.kozak.SKIPPED")
1219
+ skipped.unlink(missing_ok=True)
1220
+ if num_starts < kozak.MIN_TRAINING_STARTS:
1221
+ msg = (f"only {num_starts} training ORFs start with ATG (minimum {kozak.MIN_TRAINING_STARTS}); "
1222
+ "start refinement uses frame contrast only")
1223
+ logger.warning(msg)
1224
+ skipped.write_text(msg + "\n")
1225
+ return None
1226
+
1227
+ model = kozak.log_odds_model(pos_counts, neg_counts)
1228
+ kozak.write_model(model, num_starts, Path(f"{prefix}.kozak.tsv"))
1229
+ kozak.write_counts(pos_counts, neg_counts, Path(f"{prefix}.kozak.counts.tsv"))
1230
+ kozak.write_logo_svg(pos_counts, model, num_starts, Path(f"{prefix}.kozak.logo.svg"))
1231
+ logger.info("Kozak model from %d training starts: %s.kozak.tsv, %s.kozak.logo.svg",
1232
+ num_starts, prefix, prefix)
1233
+ # score with the model as written (3 decimals), as Perl TransDecoder does
1234
+ return kozak.read_model(Path(f"{prefix}.kozak.tsv"))
1235
+
1236
+ @staticmethod
1237
+ def _kozak_score(model, sequence: str, atg_index: int) -> float:
1238
+ from pytransdecoder.core import kozak
1239
+ return kozak.score(model, sequence, atg_index)
1240
+
1241
+ def _revise_start_sites(
1242
+ self,
1243
+ gff3_file: Path,
1244
+ revised_gff3_file: Path,
1245
+ transcripts: Dict[str, str],
1246
+ scores: Dict[str, float],
1247
+ coding_contrast: float,
1248
+ kozak=None,
1249
+ homology: Optional[Dict[str, Dict]] = None,
1250
+ ) -> int:
1251
+ """Trim open 5' ends that extend into a UTR to their first in-frame ATG
1252
+ (see _refine_start_codons). With homology evidence (_homology_start_evidence), the
1253
+ homology rules (core/homology_starts.py) decide where they fire; a per-ORF table is written
1254
+ to start_refinement.homology.tsv in the work directory."""
1255
+ from pytransdecoder.core.sequence import reverse_complement
1256
+
1257
+ num_revised = 0
1258
+ table = None
1259
+ if homology is not None:
1260
+ from pytransdecoder.core.homology_starts import homology_start_call
1261
+ table = open(self.workdir / "start_refinement.homology.tsv", "w")
1262
+ table.write("orf_id\ttranscript_id\tstrand\tatg_offset_nt\tatg_aa\tblast_qstart\tblast_sstart\t"
1263
+ "pfam_env_from\trules_fired\tcoding_score\tutr_score\tab_initio_decision\t"
1264
+ "decision\tsource\n")
1265
+ counts = dict.fromkeys(["R1", "R2", "R3", "trim", "keep", "conflict", "changed"], 0)
1266
+ with open(gff3_file) as src, open(revised_gff3_file, "w") as dst:
1267
+ for line in src:
1268
+ parts = line.rstrip("\n").split("\t")
1269
+ if line.startswith("#") or len(parts) != 9 or parts[2] != "mRNA":
1270
+ dst.write(line)
1271
+ continue
1272
+
1273
+ seqid, start, end, strand = parts[0], int(parts[3]), int(parts[4]), parts[6]
1274
+ transcript_seq = transcripts.get(seqid)
1275
+ if transcript_seq is None:
1276
+ dst.write(line)
1277
+ continue
1278
+
1279
+ if strand == "-":
1280
+ oriented = reverse_complement(transcript_seq)
1281
+ orf_start = len(transcript_seq) - end
1282
+ else:
1283
+ oriented = transcript_seq
1284
+ orf_start = start - 1
1285
+ orf = oriented[orf_start:orf_start + end - start + 1]
1286
+ if orf[:3] == "ATG":
1287
+ dst.write(line)
1288
+ continue
1289
+
1290
+ atg = next((i for i in range(3, len(orf) - 3, 3) if orf[i:i + 3] == "ATG"), None)
1291
+ if atg is None:
1292
+ dst.write(line)
1293
+ continue
1294
+
1295
+ # both scores in bases' worth of typical coding signal
1296
+ coding_score = self._frame_contrast(orf[:atg], scores) / coding_contrast
1297
+ kozak_score = self._kozak_score(kozak, oriented, orf_start + atg) if kozak else 0.0
1298
+ utr_score = self.start_contrast_alpha * atg + self.start_kozak_weight * kozak_score
1299
+ homology_rule = None
1300
+ if table is not None:
1301
+ orf_id = self._parse_attrs(parts[8])["ID"][0]
1302
+ blast = homology["blast"].get(orf_id) if homology["blast"] is not None else None
1303
+ env_from = homology["pfam"].get(orf_id) if homology["pfam"] is not None else None
1304
+ fired, call = homology_start_call(atg // 3 + 1, blast, env_from)
1305
+ ab_initio_trim = coding_score < utr_score
1306
+ trim = ab_initio_trim
1307
+ source = "ab_initio"
1308
+ for r in fired:
1309
+ counts[r] += 1
1310
+ if call == "conflict":
1311
+ counts["conflict"] += 1
1312
+ source = "ab_initio(conflict)"
1313
+ elif call is not None:
1314
+ counts[call] += 1
1315
+ trim = call == "trim"
1316
+ source = "homology"
1317
+ homology_rule = "+".join(r for r in fired if (r == "R2") == trim)
1318
+ counts["changed"] += trim != ab_initio_trim
1319
+ table.write("\t".join(map(str, [
1320
+ orf_id, seqid, strand, atg, atg // 3 + 1,
1321
+ blast[0] if blast else ".", blast[1] if blast else ".",
1322
+ env_from if env_from is not None else ".", ",".join(fired) or ".",
1323
+ f"{coding_score:.1f}", f"{utr_score:.1f}", "trim" if ab_initio_trim else "keep",
1324
+ "trim" if trim else "keep", source])) + "\n")
1325
+ if not trim:
1326
+ dst.write(line)
1327
+ continue
1328
+ elif coding_score >= utr_score:
1329
+ dst.write(line) # upstream segment looks coding: 5' partial
1330
+ continue
1331
+
1332
+ if strand == "-":
1333
+ parts[4] = str(end - atg)
1334
+ else:
1335
+ parts[3] = str(start + atg)
1336
+ attrs = self._parse_attrs(parts[8])
1337
+ attrs["start_revised"] = ["true"]
1338
+ attrs["upstream_coding_score"] = [f"{coding_score:.1f}"]
1339
+ attrs["upstream_utr_score"] = [f"{utr_score:.1f}"]
1340
+ attrs["kozak_score"] = [f"{kozak_score:.2f}"]
1341
+ if homology_rule:
1342
+ attrs["start_homology"] = [homology_rule]
1343
+ parts[8] = self._format_attrs(attrs)
1344
+ dst.write("\t".join(parts) + "\n")
1345
+ num_revised += 1
1346
+
1347
+ if table is not None:
1348
+ table.close()
1349
+ logger.info("Homology-assisted start refinement: R1 (BLASTP, keep open) fired %d, R2 (BLASTP, "
1350
+ "trim) %d, R3 (Pfam, keep open) %d; homology decided %d trim / %d keep open, "
1351
+ "%d conflicts left to the ab initio rule; %d decisions differ from the ab initio "
1352
+ "rule (table: %s)", counts["R1"], counts["R2"], counts["R3"], counts["trim"],
1353
+ counts["keep"], counts["conflict"], counts["changed"],
1354
+ self.workdir / "start_refinement.homology.tsv")
1355
+ return num_revised
1356
+
1357
+ def _load_transcripts(self) -> Dict[str, str]:
1358
+ transcripts = {}
1359
+ with open(self.transcripts_file) as handle:
1360
+ for record in read_fasta(handle):
1361
+ transcripts[record.id] = str(record.seq).upper()
1362
+ return transcripts
1363
+
1364
+ def _parse_attrs(self, attrs_str: str) -> Dict[str, List[str]]:
1365
+ attrs = {}
1366
+ for entry in attrs_str.split(";"):
1367
+ if "=" not in entry:
1368
+ continue
1369
+ key, value = entry.split("=", 1)
1370
+ attrs[key] = value.split(",")
1371
+ return attrs
1372
+
1373
+ def _format_attrs(self, attrs: Dict[str, List[str]]) -> str:
1374
+ return ";".join(f"{key}={','.join(values)}" for key, values in attrs.items())
1375
+
1376
+ def _translate_brief(self, sequence: str) -> str:
1377
+ from pytransdecoder.core.translator import Translator
1378
+
1379
+ translator = Translator(self.genetic_code)
1380
+ return translator.translate(sequence, frame=0)
1381
+
1382
+ def _generate_final_outputs(self, gff3_file: Path):
1383
+ """
1384
+ Generate final output files (GFF3, BED, PEP, CDS)
1385
+
1386
+ Creates four output files in the same directory as the input transcripts:
1387
+ - transcripts.transdecoder.gff3 - GFF3 format predictions
1388
+ - transcripts.transdecoder.bed - BED format predictions
1389
+ - transcripts.transdecoder.pep - Predicted peptide sequences
1390
+ - transcripts.transdecoder.cds - Predicted CDS sequences
1391
+
1392
+ Args:
1393
+ gff3_file: Input GFF3 file with selected/refined ORFs
1394
+ """
1395
+ checkpoint = self.checkpoints_dir / "final_outputs.ok"
1396
+
1397
+ if checkpoint.exists():
1398
+ logger.info("Final outputs already generated (checkpoint exists)")
1399
+ return
1400
+
1401
+ # Output file paths
1402
+ # When output_dir is specified, write final outputs there
1403
+ # Otherwise write to the directory containing the transcripts file
1404
+ base_name = self.transcripts_file.name
1405
+ output_base = self.output_dir / f"{base_name}.transdecoder"
1406
+
1407
+ final_gff3 = Path(str(output_base) + ".gff3")
1408
+ final_bed = Path(str(output_base) + ".bed")
1409
+ final_pep = Path(str(output_base) + ".pep")
1410
+ final_cds = Path(str(output_base) + ".cds")
1411
+
1412
+ # Write final GFF3 with full gene/mRNA/CDS hierarchy
1413
+ logger.info(f"Creating {final_gff3}")
1414
+ self._write_final_gff3(gff3_file, final_gff3)
1415
+
1416
+ # Generate BED file
1417
+ logger.info(f"Creating {final_bed}")
1418
+ self._gff3_to_bed(gff3_file, final_bed)
1419
+
1420
+ # Generate peptide file
1421
+ logger.info(f"Creating {final_pep}")
1422
+ self._gff3_to_proteins(gff3_file, final_pep, seq_type='pep')
1423
+
1424
+ # Generate CDS file
1425
+ logger.info(f"Creating {final_cds}")
1426
+ self._gff3_to_proteins(gff3_file, final_cds, seq_type='cds')
1427
+
1428
+ logger.info(f"Final outputs written to: {base_name}.transdecoder.*")
1429
+ checkpoint.write_text("OK")
1430
+
1431
+ def _final_models(self, gff3_file: Path):
1432
+ """
1433
+ Transcript-level coding models for the selected ORFs: one exon spanning the transcript,
1434
+ the CDS inside it, oriented by the ORF strand (as Perl TransDecoder reports them).
1435
+
1436
+ Yields (gene_id, CodingModel).
1437
+ """
1438
+ from pytransdecoder.core.gene_model import CodingModel
1439
+ from pytransdecoder.core.gff3_parser import parse_gff3
1440
+ from pytransdecoder.core.sequence import reverse_complement
1441
+ from pytransdecoder.core.translator import Translator
1442
+
1443
+ translator = Translator(self.genetic_code)
1444
+ transcripts = self._load_transcripts()
1445
+ for feature in parse_gff3(gff3_file):
1446
+ if feature['type'] != 'mRNA' or feature['seqid'] not in transcripts:
1447
+ continue
1448
+ attrs = feature['attributes']
1449
+ seq = transcripts[feature['seqid']]
1450
+ start, end, strand = feature['start'], min(feature['end'], len(seq)), feature['strand']
1451
+ orf_seq = seq[start - 1:end]
1452
+ if strand == '-':
1453
+ orf_seq = reverse_complement(orf_seq)
1454
+ orf_type = self._get_orf_type_from_protein(translator.translate(orf_seq, frame=0))
1455
+ extra = ";".join(f"{k}={','.join(v)}" for k, v in attrs.items()
1456
+ if k not in ("ID", "Parent", "Name", "blast", "pfam"))
1457
+ model = CodingModel(seqid=feature['seqid'], strand=strand, exons=[(1, len(seq))],
1458
+ cds_lend=start, cds_rend=end, model_id=attrs.get('ID', [''])[0],
1459
+ name=self._orf_com_name(attrs, strand, orf_type), extra_attrs=extra)
1460
+ yield attrs.get('Parent', [''])[0], model
1461
+
1462
+ def _write_final_gff3(self, src_gff3: Path, out_gff3: Path):
1463
+ """
1464
+ Final GFF3 as in Perl TransDecoder: per ORF a gene and mRNA spanning the transcript,
1465
+ the transcript as one exon, the CDS, and five_prime_UTR / three_prime_UTR features.
1466
+ """
1467
+ from pytransdecoder.core.gene_model import CodingGene
1468
+
1469
+ with open(out_gff3, 'w') as out:
1470
+ for gene_id, model in self._final_models(src_gff3):
1471
+ out.write(CodingGene(gene_id=gene_id, models=[model]).gff3_text())
1472
+
1473
+ def _gff3_to_bed(self, gff3_file: Path, bed_file: Path):
1474
+ """BED12 of the final ORFs as in Perl TransDecoder (thickStart/End = CDS)."""
1475
+ with open(bed_file, 'w') as out:
1476
+ for gene_id, model in self._final_models(gff3_file):
1477
+ name = f"ID={model.model_id};{gene_id};{model.name.replace(' ', '_')}"
1478
+ out.write(model.bed12(name) + "\n")
1479
+
1480
+ def _orf_com_name(self, attrs: Dict[str, List[str]], strand: str, orf_type: str) -> str:
1481
+ """Perl-style ORF description: 'ORF type:<type> (<strand>),score=<s>[,blast...][,pfam...]'."""
1482
+ com_name = f"ORF type:{orf_type} ({strand})"
1483
+ score = attrs.get('score', [''])[0]
1484
+ if score:
1485
+ try:
1486
+ score = f"{float(score):.2f}"
1487
+ except ValueError:
1488
+ pass
1489
+ com_name += f",score={score}"
1490
+ for key in ('blast', 'pfam'):
1491
+ info = attrs.get(key, [''])[0]
1492
+ if info:
1493
+ com_name += info if info.startswith(',') else f",{info}"
1494
+ return com_name
1495
+
1496
+ @staticmethod
1497
+ def _get_orf_type_from_protein(protein: str) -> str:
1498
+ """Infer ORF completeness from the translated protein sequence."""
1499
+ has_start = protein.startswith("M")
1500
+ has_stop = protein.endswith("*")
1501
+
1502
+ if has_start and has_stop:
1503
+ return "complete"
1504
+ if has_start:
1505
+ return "3prime_partial"
1506
+ if has_stop:
1507
+ return "5prime_partial"
1508
+ return "internal"
1509
+
1510
+ def _format_transdecoder_fasta_header(
1511
+ self,
1512
+ feature: Dict[str, object],
1513
+ gene_id: str,
1514
+ sequence: str,
1515
+ protein: str,
1516
+ seq_type: str,
1517
+ ) -> str:
1518
+ """Reconstruct the Perl-style FASTA header for final outputs."""
1519
+ attrs = feature['attributes']
1520
+ model_id = attrs.get('ID', [''])[0]
1521
+ transcript_id = feature['seqid']
1522
+ start = feature['start']
1523
+ end = feature['end']
1524
+ strand = feature['strand']
1525
+
1526
+ orf_type = self._get_orf_type_from_protein(protein)
1527
+ com_name = self._orf_com_name(attrs, strand, orf_type)
1528
+
1529
+ seq_len = len(protein.rstrip('*')) if seq_type == 'pep' else len(sequence)
1530
+
1531
+ header_parts = [model_id]
1532
+ if gene_id:
1533
+ header_parts.append(gene_id)
1534
+ header_parts.extend([
1535
+ com_name,
1536
+ f"len:{seq_len}",
1537
+ f"{transcript_id}:{start}-{end}({strand})",
1538
+ ])
1539
+
1540
+ return " ".join(part for part in header_parts if part)
1541
+
1542
+ def _gff3_to_proteins(self, gff3_file: Path, output_file: Path, seq_type: str = 'pep'):
1543
+ """
1544
+ Extract protein or CDS sequences from GFF3
1545
+
1546
+ Args:
1547
+ gff3_file: Input GFF3 file
1548
+ output_file: Output FASTA file
1549
+ seq_type: 'pep' for protein sequences, 'cds' for CDS sequences
1550
+ """
1551
+ from pytransdecoder.core.gff3_parser import parse_gff3
1552
+ from pytransdecoder.core.translator import Translator
1553
+
1554
+ # Create translator
1555
+ translator = Translator(self.genetic_code)
1556
+
1557
+ # Load transcript sequences
1558
+ transcripts = {}
1559
+ with open(self.transcripts_file) as f:
1560
+ for record in read_fasta(f):
1561
+ transcripts[record.id] = str(record.seq).upper()
1562
+
1563
+ # Extract sequences
1564
+ with open(output_file, 'w') as out:
1565
+ for feature in parse_gff3(gff3_file):
1566
+ transcript_id = feature['seqid']
1567
+ start = feature['start'] - 1 # Convert to 0-based
1568
+ end = feature['end']
1569
+ strand = feature['strand']
1570
+ orf_id = feature['attributes'].get('ID', [''])[0]
1571
+ gene_id = feature['attributes'].get('Parent', [''])[0]
1572
+
1573
+ if transcript_id not in transcripts:
1574
+ logger.warning(f"Transcript {transcript_id} not found in input file")
1575
+ continue
1576
+
1577
+ transcript_seq = transcripts[transcript_id]
1578
+ transcript_len = len(transcript_seq)
1579
+
1580
+ # Handle invalid coordinates (negative or beyond transcript boundaries)
1581
+ # This can happen with partial ORFs at transcript boundaries
1582
+ if start < 0:
1583
+ logger.warning(f"ORF {orf_id} has negative start coordinate ({feature['start']}), clamping to 0")
1584
+ start = 0
1585
+ if end > transcript_len:
1586
+ logger.warning(f"ORF {orf_id} end coordinate ({end}) exceeds transcript length ({transcript_len}), clamping")
1587
+ end = transcript_len
1588
+ if start >= end:
1589
+ logger.warning(f"ORF {orf_id} has invalid coordinates (start={start}, end={end}), skipping")
1590
+ continue
1591
+
1592
+ # Extract ORF sequence
1593
+ orf_seq = transcript_seq[start:end]
1594
+
1595
+ # Reverse complement if on minus strand
1596
+ if strand == '-':
1597
+ from pytransdecoder.core.sequence import reverse_complement
1598
+ orf_seq = reverse_complement(orf_seq)
1599
+
1600
+ protein_seq = translator.translate(orf_seq, frame=0)
1601
+ header = self._format_transdecoder_fasta_header(
1602
+ feature=feature,
1603
+ gene_id=gene_id,
1604
+ sequence=orf_seq,
1605
+ protein=protein_seq,
1606
+ seq_type=seq_type,
1607
+ )
1608
+
1609
+ # Generate output sequence
1610
+ if seq_type == 'pep':
1611
+ output_seq = protein_seq
1612
+ else:
1613
+ # CDS sequence
1614
+ output_seq = orf_seq
1615
+
1616
+ # Write FASTA record
1617
+ out.write(f">{header}\n")
1618
+
1619
+ # Write sequence (60 chars per line)
1620
+ for i in range(0, len(output_seq), 60):
1621
+ out.write(output_seq[i:i+60] + '\n')