pytransdecoder 0.2.1__py3-none-any.whl
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- pytransdecoder/__init__.py +7 -0
- pytransdecoder/__main__.py +6 -0
- pytransdecoder/cli.py +781 -0
- pytransdecoder/core/__init__.py +1 -0
- pytransdecoder/core/base_freqs.py +71 -0
- pytransdecoder/core/decoys.py +113 -0
- pytransdecoder/core/fasta.py +55 -0
- pytransdecoder/core/gene_model.py +143 -0
- pytransdecoder/core/gff3_parser.py +58 -0
- pytransdecoder/core/homology_starts.py +54 -0
- pytransdecoder/core/kozak.py +180 -0
- pytransdecoder/core/models.py +123 -0
- pytransdecoder/core/orf_finder.py +317 -0
- pytransdecoder/core/presets.py +152 -0
- pytransdecoder/core/prng.py +54 -0
- pytransdecoder/core/pwm.py +174 -0
- pytransdecoder/core/sequence.py +19 -0
- pytransdecoder/core/translator.py +143 -0
- pytransdecoder/core/writers.py +69 -0
- pytransdecoder/genome.py +338 -0
- pytransdecoder/longorfs.py +180 -0
- pytransdecoder/predict.py +1621 -0
- pytransdecoder/presets/apicomplexan/hexamer.scores.gz +0 -0
- pytransdecoder/presets/apicomplexan/kozak.tsv +9 -0
- pytransdecoder/presets/apicomplexan/preset.json +20 -0
- pytransdecoder/presets/apicomplexan/thresholds.tsv.gz +0 -0
- pytransdecoder/presets/apicomplexan/training_orfs.cds.gz +0 -0
- pytransdecoder/presets/ciliate/hexamer.scores.gz +0 -0
- pytransdecoder/presets/ciliate/kozak.tsv +9 -0
- pytransdecoder/presets/ciliate/preset.json +20 -0
- pytransdecoder/presets/ciliate/thresholds.tsv.gz +0 -0
- pytransdecoder/presets/ciliate/training_orfs.cds.gz +0 -0
- pytransdecoder/presets/fungus/hexamer.scores.gz +0 -0
- pytransdecoder/presets/fungus/kozak.tsv +9 -0
- pytransdecoder/presets/fungus/preset.json +20 -0
- pytransdecoder/presets/fungus/thresholds.tsv.gz +0 -0
- pytransdecoder/presets/fungus/training_orfs.cds.gz +0 -0
- pytransdecoder/presets/green_alga/hexamer.scores.gz +0 -0
- pytransdecoder/presets/green_alga/kozak.tsv +9 -0
- pytransdecoder/presets/green_alga/preset.json +20 -0
- pytransdecoder/presets/green_alga/thresholds.tsv.gz +0 -0
- pytransdecoder/presets/green_alga/training_orfs.cds.gz +0 -0
- pytransdecoder/presets/insect/hexamer.scores.gz +0 -0
- pytransdecoder/presets/insect/kozak.tsv +9 -0
- pytransdecoder/presets/insect/preset.json +20 -0
- pytransdecoder/presets/insect/thresholds.tsv.gz +0 -0
- pytransdecoder/presets/insect/training_orfs.cds.gz +0 -0
- pytransdecoder/presets/mammal/hexamer.scores.gz +0 -0
- pytransdecoder/presets/mammal/kozak.tsv +9 -0
- pytransdecoder/presets/mammal/preset.json +20 -0
- pytransdecoder/presets/mammal/thresholds.tsv.gz +0 -0
- pytransdecoder/presets/mammal/training_orfs.cds.gz +0 -0
- pytransdecoder/presets/plant/hexamer.scores.gz +0 -0
- pytransdecoder/presets/plant/kozak.tsv +9 -0
- pytransdecoder/presets/plant/preset.json +20 -0
- pytransdecoder/presets/plant/thresholds.tsv.gz +0 -0
- pytransdecoder/presets/plant/training_orfs.cds.gz +0 -0
- pytransdecoder-0.2.1.dist-info/METADATA +307 -0
- pytransdecoder-0.2.1.dist-info/RECORD +63 -0
- pytransdecoder-0.2.1.dist-info/WHEEL +5 -0
- pytransdecoder-0.2.1.dist-info/entry_points.txt +3 -0
- pytransdecoder-0.2.1.dist-info/licenses/LICENSE +28 -0
- pytransdecoder-0.2.1.dist-info/top_level.txt +1 -0
|
@@ -0,0 +1,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')
|