GenomeTreeTk 0.3.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.
@@ -0,0 +1,373 @@
1
+ ###############################################################################
2
+ # #
3
+ # This program is free software: you can redistribute it and/or modify #
4
+ # it under the terms of the GNU General Public License as published by #
5
+ # the Free Software Foundation, either version 3 of the License, or #
6
+ # (at your option) any later version. #
7
+ # #
8
+ # This program is distributed in the hope that it will be useful, #
9
+ # but WITHOUT ANY WARRANTY; without even the implied warranty of #
10
+ # MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the #
11
+ # GNU General Public License for more details. #
12
+ # #
13
+ # You should have received a copy of the GNU General Public License #
14
+ # along with this program. If not, see <http://www.gnu.org/licenses/>. #
15
+ # #
16
+ ###############################################################################
17
+
18
+ import os
19
+ import sys
20
+ import gzip
21
+ import logging
22
+ import multiprocessing as mp
23
+ from pathlib import Path
24
+
25
+ import numpy as np
26
+ from tqdm import tqdm
27
+
28
+ from gtdblib.tree.bootstrap_merge import bootstrap_merge_replicates
29
+ from gtdblib.util.shell.gtdbshutil import make_sure_path_exists
30
+
31
+ from genometreetk.fasttree import FastTreeError, infer_trees, is_complete_tree
32
+
33
+
34
+ # Alignment shared with forked worker processes. It is stored as a module
35
+ # level global so workers inherit it through copy-on-write memory instead of
36
+ # receiving a pickled copy. Since the numpy buffer is never written to and
37
+ # has no per-row Python objects, no pages are duplicated in the workers.
38
+ _SHARED = {}
39
+
40
+ # Number of alignment rows gathered and written at a time by a worker.
41
+ ROW_BLOCK_SIZE = 4096
42
+
43
+ TREE_EXTENSIONS = ('.tree', '.tre', '.treefile')
44
+ MSA_EXTENSIONS = ('.fna', '.faa', '.fasta', '.fa')
45
+ NT_CHARS = b'ACGTUNacgtun'
46
+
47
+
48
+ class BootstrapError(Exception):
49
+ pass
50
+
51
+
52
+ def read_msa(msa_file):
53
+ """Read MSA into a 2D uint8 matrix.
54
+
55
+ Parameters
56
+ ----------
57
+ msa_file : str
58
+ FASTA file with aligned sequences (optionally gzipped).
59
+
60
+ Returns
61
+ -------
62
+ list of bytes
63
+ Sequence IDs in file order.
64
+ np.ndarray
65
+ Matrix of shape (num_seqs, alignment_len) holding one byte per base.
66
+ """
67
+
68
+ open_file = gzip.open if msa_file.endswith('.gz') else open
69
+
70
+ # first pass: count sequences and determine alignment length so the
71
+ # matrix can be allocated once (avoids holding two copies in memory)
72
+ num_seqs = 0
73
+ aln_len = 0
74
+ with open_file(msa_file, 'rb') as f:
75
+ for line in f:
76
+ if line[:1] == b'>':
77
+ num_seqs += 1
78
+ elif num_seqs == 1:
79
+ aln_len += len(line.strip())
80
+
81
+ if num_seqs == 0:
82
+ raise BootstrapError(f'No sequences found in {msa_file}.')
83
+ if aln_len == 0:
84
+ raise BootstrapError(f'First sequence in {msa_file} is empty.')
85
+
86
+ msa = np.empty((num_seqs, aln_len), dtype=np.uint8)
87
+ seq_ids = []
88
+
89
+ def store(row_idx, pieces):
90
+ seq = b''.join(pieces)
91
+ if len(seq) != aln_len:
92
+ raise BootstrapError(
93
+ 'Sequences in MSA have different lengths: {} has length {:,} but expected {:,}.'.format(
94
+ seq_ids[row_idx].decode(), len(seq), aln_len))
95
+ msa[row_idx] = np.frombuffer(seq, dtype=np.uint8)
96
+
97
+ # second pass: fill matrix
98
+ row_idx = -1
99
+ pieces = []
100
+ with open_file(msa_file, 'rb') as f:
101
+ for line in f:
102
+ if line[:1] == b'>':
103
+ if row_idx >= 0:
104
+ store(row_idx, pieces)
105
+ row_idx += 1
106
+ fields = line[1:].split(None, 1)
107
+ if not fields:
108
+ raise BootstrapError(f'Sequence {row_idx + 1:,} in {msa_file} has an empty header.')
109
+ seq_ids.append(fields[0])
110
+ pieces = []
111
+ else:
112
+ line = line.strip()
113
+ if line:
114
+ pieces.append(line)
115
+ store(row_idx, pieces)
116
+
117
+ if len(set(seq_ids)) != len(seq_ids):
118
+ raise BootstrapError(f'MSA contains duplicate sequence IDs: {msa_file}')
119
+
120
+ return seq_ids, msa
121
+
122
+
123
+ def nucleotide_fraction(msa, max_rows=1000):
124
+ """Estimate fraction of non-gap characters that are nucleotides."""
125
+
126
+ counts = np.bincount(msa[:max_rows].ravel(), minlength=256)
127
+ nt = sum(counts[c] for c in NT_CHARS)
128
+ non_gap = counts.sum() - counts[ord('-')] - counts[ord('.')]
129
+ return nt / non_gap if non_gap else 0.0
130
+
131
+
132
+ def replicate_seed(base_seed, rep_idx):
133
+ """Seed sequence for a replicate (independent of worker scheduling)."""
134
+
135
+ return np.random.SeedSequence(entropy=base_seed, spawn_key=(rep_idx,))
136
+
137
+
138
+ def expected_replicate_size(headers, sample_len):
139
+ """Exact size in bytes of a complete replicate MSA file."""
140
+
141
+ return sum(len(h) for h in headers) + len(headers) * (sample_len + 1)
142
+
143
+
144
+ def write_replicate(rep_idx, output_msa):
145
+ """Write a single bootstrap replicate of the shared MSA.
146
+
147
+ Columns are sampled with replacement. The file is written to a temporary
148
+ path and atomically renamed, so an interrupted run never leaves a file
149
+ that looks complete.
150
+ """
151
+
152
+ msa = _SHARED['msa']
153
+ headers = _SHARED['headers']
154
+ sample_len = _SHARED['sample_len']
155
+
156
+ rng = np.random.default_rng(replicate_seed(_SHARED['seed'], rep_idx))
157
+ cols = rng.integers(0, msa.shape[1], size=sample_len)
158
+
159
+ num_seqs = msa.shape[0]
160
+ block = np.empty((min(ROW_BLOCK_SIZE, num_seqs), sample_len + 1), dtype=np.uint8)
161
+ block[:, -1] = ord('\n')
162
+
163
+ tmp_file = output_msa + '.tmp'
164
+ with open(tmp_file, 'wb', buffering=8 * 1024 * 1024) as fout:
165
+ for start in range(0, num_seqs, ROW_BLOCK_SIZE):
166
+ end = min(start + ROW_BLOCK_SIZE, num_seqs)
167
+ out = block[:end - start]
168
+ np.take(msa[start:end], cols, axis=1, out=out[:, :-1], mode='clip')
169
+ parts = []
170
+ for i in range(end - start):
171
+ parts.append(headers[start + i])
172
+ parts.append(memoryview(out[i]))
173
+ fout.writelines(parts)
174
+ os.replace(tmp_file, output_msa)
175
+
176
+ return rep_idx
177
+
178
+
179
+ class Bootstrap(object):
180
+ """Assess robustness of genome tree by bootstrapping multiple sequence alignment."""
181
+
182
+ def __init__(self, cpus, multiple_processors=False):
183
+ """Initialization.
184
+
185
+ Parameters
186
+ ----------
187
+ cpus : int
188
+ Number of cpus to use.
189
+ multiple_processors : bool
190
+ Use FastTreeMP instead of FastTree.
191
+ """
192
+
193
+ self.logger = logging.getLogger()
194
+
195
+ self.cpus = cpus
196
+ self.mp = multiple_processors
197
+
198
+ def _infer_trees(self, msa_files, tree_files):
199
+ """Infer trees for replicate MSAs in parallel."""
200
+
201
+ self.logger.info(f'Inferring {len(msa_files):,} bootstrap trees using {self.cpus:,} CPUs:')
202
+ infer_trees(msa_files, tree_files,
203
+ cpus=self.cpus,
204
+ base_type=self.base_type,
205
+ model=self.model,
206
+ gamma=self.gamma,
207
+ multithreaded=self.mp)
208
+
209
+ def _create_msa_replicates(self, msa_file, num_replicates, frac, seed):
210
+ """Create bootstrap replicates of MSA in parallel."""
211
+
212
+ self.logger.info('Reading MSA:')
213
+ seq_ids, msa = read_msa(msa_file)
214
+ self.logger.info(' - read {:,} sequences with an alignment length of {:,}'.format(
215
+ msa.shape[0], msa.shape[1]))
216
+
217
+ if self.base_type == 'prot' and nucleotide_fraction(msa) > 0.9:
218
+ self.logger.warning('MSA appears to contain nucleotides, but bases are being treated '
219
+ 'as amino acids. Use --base_type nt for nucleotide alignments.')
220
+
221
+ sample_len = int(msa.shape[1] * frac)
222
+ if sample_len < 1:
223
+ raise BootstrapError(f'Fraction {frac} of alignment results in no columns being sampled.')
224
+
225
+ headers = [b'>' + sid + b'\n' for sid in seq_ids]
226
+ expected_size = expected_replicate_size(headers, sample_len)
227
+
228
+ msa_files = []
229
+ todo = []
230
+ for rep_idx in range(num_replicates):
231
+ output_msa = os.path.join(self.replicate_dir, f'bootstrap_msa.r_{rep_idx}.fna')
232
+ msa_files.append(output_msa)
233
+ if os.path.exists(output_msa) and os.path.getsize(output_msa) == expected_size:
234
+ continue
235
+ elif os.path.exists(output_msa):
236
+ self.logger.warning(f'Recreating incomplete or mismatched replicate: {output_msa}')
237
+ todo.append((rep_idx, output_msa))
238
+
239
+ if len(todo) < num_replicates:
240
+ self.logger.info(f'Skipping {num_replicates - len(todo):,} MSA replicates which already exist.')
241
+
242
+ if todo:
243
+ self.logger.info('Creating {:,} MSA replicates of length {:,} using {:,} CPUs (seed = {}):'.format(
244
+ len(todo), sample_len, self.cpus, seed))
245
+
246
+ _SHARED.update(msa=msa, headers=headers, sample_len=sample_len, seed=seed)
247
+ try:
248
+ if self.cpus > 1 and len(todo) > 1:
249
+ # fork is used explicitly so workers share the MSA in memory
250
+ # (spawn/forkserver would pickle a full copy to each worker)
251
+ ctx = mp.get_context('fork')
252
+ with ctx.Pool(min(self.cpus, len(todo))) as pool:
253
+ for _ in tqdm(pool.imap_unordered(_write_replicate_task, todo),
254
+ total=len(todo), unit=' replicates'):
255
+ pass
256
+ else:
257
+ for task in tqdm(todo, unit=' replicates'):
258
+ _write_replicate_task(task)
259
+ finally:
260
+ _SHARED.clear()
261
+
262
+ return msa_files
263
+
264
+ def run(self,
265
+ input_tree,
266
+ msa_file,
267
+ num_replicates,
268
+ model,
269
+ gamma,
270
+ base_type,
271
+ frac,
272
+ boot_dir,
273
+ msa_only,
274
+ output_dir,
275
+ seed=None):
276
+ """Bootstrap multiple sequence alignment.
277
+
278
+ Parameters
279
+ ----------
280
+ input_tree : str
281
+ Tree inferred with all data, or 'none'.
282
+ msa_file : str
283
+ File containing multiple sequence alignment for all taxa.
284
+ num_replicates : int
285
+ Number of replicates to perform.
286
+ model : str
287
+ Desired model of evolution.
288
+ gamma : bool
289
+ Use GAMMA model.
290
+ base_type : str
291
+ Indicates if bases are nucleotides or amino acids.
292
+ frac : float
293
+ Fraction of alignment to subsample.
294
+ boot_dir : str
295
+ Directory with pre-computed bootstrap trees or MSA replicates.
296
+ msa_only : bool
297
+ Only create MSA replicates.
298
+ output_dir : str
299
+ Directory for bootstrap trees.
300
+ seed : int
301
+ Seed for random number generator.
302
+
303
+ Returns
304
+ -------
305
+ str
306
+ Tree decorated with bootstrap support, or None.
307
+ """
308
+
309
+ assert(model in ['wag', 'lg', 'jtt'])
310
+ assert(base_type in ['nt', 'prot'])
311
+
312
+ self.model = model
313
+ self.gamma = gamma
314
+ self.base_type = base_type
315
+
316
+ self.replicate_dir = os.path.join(output_dir, 'replicates')
317
+ make_sure_path_exists(self.replicate_dir)
318
+
319
+ rep_tree_files = []
320
+ try:
321
+ if not boot_dir:
322
+ if seed is None:
323
+ seed = np.random.SeedSequence().entropy
324
+ msa_files = self._create_msa_replicates(msa_file, num_replicates, frac, seed)
325
+
326
+ if msa_only:
327
+ self.logger.info(f'MSA replicates written to: {self.replicate_dir}')
328
+ return None
329
+
330
+ rep_tree_files = [os.path.join(self.replicate_dir, f'bootstrap_tree.r_{i}.tree')
331
+ for i in range(num_replicates)]
332
+ self._infer_trees(msa_files, rep_tree_files)
333
+ else:
334
+ files = sorted(os.listdir(boot_dir))
335
+ rep_tree_files = [os.path.join(boot_dir, f) for f in files if f.endswith(TREE_EXTENSIONS)]
336
+ msa_files = [os.path.join(boot_dir, f) for f in files if f.endswith(MSA_EXTENSIONS)]
337
+
338
+ if rep_tree_files:
339
+ self.logger.info(f'Read {len(rep_tree_files):,} bootstrap trees from: {boot_dir}')
340
+ incomplete = [t for t in rep_tree_files if not is_complete_tree(t)]
341
+ if incomplete:
342
+ raise BootstrapError('Found {:,} empty or incomplete bootstrap trees, e.g.: {}'.format(
343
+ len(incomplete), incomplete[0]))
344
+ elif msa_files:
345
+ self.logger.info(f'Read {len(msa_files):,} MSA replicates from: {boot_dir}')
346
+ if msa_only:
347
+ self.logger.info('No trees inferred as --msa_only was specified.')
348
+ return None
349
+
350
+ rep_tree_files = [os.path.join(self.replicate_dir, Path(m).stem + '.tree')
351
+ for m in msa_files]
352
+ self._infer_trees(msa_files, rep_tree_files)
353
+ else:
354
+ raise BootstrapError(f'No bootstrap trees or MSA replicates found in: {boot_dir}')
355
+ except (BootstrapError, FastTreeError) as e:
356
+ self.logger.error(str(e))
357
+ sys.exit(1)
358
+
359
+ # calculate support values
360
+ output_tree = None
361
+ if input_tree.lower() != 'none' and not msa_only:
362
+ self.logger.info(f'Calculating bootstrap support values from {len(rep_tree_files):,} trees.')
363
+ output_tree = os.path.join(output_dir, Path(input_tree).stem + '.bootstrap.tree')
364
+ bootstrap_merge_replicates(input_tree, output_tree, rep_tree_files, self.cpus)
365
+
366
+ return output_tree
367
+
368
+
369
+ def _write_replicate_task(task):
370
+ """Pool entry point for writing a replicate."""
371
+
372
+ rep_idx, output_msa = task
373
+ return write_replicate(rep_idx, output_msa)
@@ -0,0 +1,2 @@
1
+ CM models are from Rfam v12.1.
2
+