hogprof 0.3.0__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.
- hogprof/__init__.py +5 -0
- hogprof/cli.py +484 -0
- hogprof/extract_levels_hits.py +518 -0
- hogprof/generate_jaccard_distr.py +516 -0
- hogprof/lshbuilder.py +710 -0
- hogprof/metadata.py +126 -0
- hogprof/presets.py +21 -0
- hogprof/profiler.py +333 -0
- hogprof/splitter.py +53 -0
- hogprof/utils/__init__.py +1 -0
- hogprof/utils/goautils.py +229 -0
- hogprof/utils/hashutils.py +190 -0
- hogprof/utils/orthoxml.py +83 -0
- hogprof/utils/phylo.py +305 -0
- hogprof/utils/pyhamutils.py +256 -0
- hogprof-0.3.0.dist-info/METADATA +231 -0
- hogprof-0.3.0.dist-info/RECORD +21 -0
- hogprof-0.3.0.dist-info/WHEEL +5 -0
- hogprof-0.3.0.dist-info/entry_points.txt +3 -0
- hogprof-0.3.0.dist-info/licenses/LICENSE +21 -0
- hogprof-0.3.0.dist-info/top_level.txt +1 -0
hogprof/lshbuilder.py
ADDED
|
@@ -0,0 +1,710 @@
|
|
|
1
|
+
import functools
|
|
2
|
+
import glob
|
|
3
|
+
import logging
|
|
4
|
+
import multiprocessing as mp
|
|
5
|
+
import pickle
|
|
6
|
+
import queue
|
|
7
|
+
import random
|
|
8
|
+
import time
|
|
9
|
+
import time as t
|
|
10
|
+
import xml.etree.ElementTree as ET
|
|
11
|
+
from contextlib import nullcontext
|
|
12
|
+
from datetime import datetime
|
|
13
|
+
from pathlib import Path
|
|
14
|
+
|
|
15
|
+
import h5py
|
|
16
|
+
import numpy as np
|
|
17
|
+
import pandas as pd
|
|
18
|
+
import polars as pl
|
|
19
|
+
from datasketch import MinHashLSHForest, WeightedMinHash, WeightedMinHashGenerator
|
|
20
|
+
from pyoma.browser import db
|
|
21
|
+
from tables import open_file
|
|
22
|
+
|
|
23
|
+
from hogprof.cli import track_progress
|
|
24
|
+
from hogprof.metadata import HogEntry, HogEntryIndex
|
|
25
|
+
from hogprof.presets import DB_PRESETS
|
|
26
|
+
from hogprof.utils import hashutils, phylo, pyhamutils
|
|
27
|
+
from hogprof.utils import orthoxml as oxml
|
|
28
|
+
|
|
29
|
+
logger = logging.getLogger(__name__)
|
|
30
|
+
|
|
31
|
+
|
|
32
|
+
class LSHBuilder:
|
|
33
|
+
"""
|
|
34
|
+
This class contains the stuff you need to make
|
|
35
|
+
a phylogenetic profiling
|
|
36
|
+
database with input orthxml files and a taxonomic tree
|
|
37
|
+
You must either input an OMA hdf5 file or an ensembl tarfile
|
|
38
|
+
containing orthoxml file with orthologous groups.
|
|
39
|
+
|
|
40
|
+
You can provide a species tree or use the ncbi taxonomy
|
|
41
|
+
with a list of taxonomic codes for all the species in your db
|
|
42
|
+
"""
|
|
43
|
+
|
|
44
|
+
def __init__(self,
|
|
45
|
+
h5_oma=None, fileglob=None, taxa=None,
|
|
46
|
+
masterTree=None,
|
|
47
|
+
output_dir=None,
|
|
48
|
+
numperm=256,
|
|
49
|
+
treeweights=None, taxfilter=None, taxmask=None,
|
|
50
|
+
lossonly=False, duplonly=False, verbose=False,
|
|
51
|
+
use_taxcodes=False,
|
|
52
|
+
datetime=datetime.now(),
|
|
53
|
+
reformat_names=False,
|
|
54
|
+
slicesubhogs=False,
|
|
55
|
+
limit_species=10, limit_events=0,
|
|
56
|
+
seed=0):
|
|
57
|
+
|
|
58
|
+
"""
|
|
59
|
+
Initializes the LSHBuilder class with the specified parameters and sets up the necessary objects.
|
|
60
|
+
|
|
61
|
+
Args:
|
|
62
|
+
- tarfile_ortho (str): path to an ensembl tarfile containing orthoxml files
|
|
63
|
+
- h5_oma (str): path to an OMA hdf5 file
|
|
64
|
+
- masterTree (str): path to a newick tree file
|
|
65
|
+
- output_dir (str): path to the directory where the output files will be saved
|
|
66
|
+
- numperm (int): the number of permutations to use in the MinHash generation (default: 256)
|
|
67
|
+
- treeweights (str): path to a pickled file containing the weights for the tree
|
|
68
|
+
- taxfilter (str): path to a file containing a list of taxonomic codes to filter from the tree
|
|
69
|
+
- taxmask (str): path to a file containing a list of taxonomic codes to mask from the tree
|
|
70
|
+
- verbose (bool): whether to print verbose output (default: False)
|
|
71
|
+
- slicesubhogs (bool): whether to slice the subhogs (default: False)
|
|
72
|
+
- limit_species (int): the minimum number of species in a subHOG that is included in the database (default: 10)
|
|
73
|
+
- limit_events (int): the minimum number of events (loss,duplication) in a subHOG that is included in the database (default: 0)
|
|
74
|
+
|
|
75
|
+
"""
|
|
76
|
+
logger.debug("Initializing LSHBuilder")
|
|
77
|
+
|
|
78
|
+
random.seed(seed)
|
|
79
|
+
np.random.seed(seed)
|
|
80
|
+
|
|
81
|
+
if h5_oma:
|
|
82
|
+
self.h5OMA = h5_oma
|
|
83
|
+
self.db_obj = db.Database(h5_oma)
|
|
84
|
+
self.oma_id_obj = db.OmaIdMapper(self.db_obj)
|
|
85
|
+
else:
|
|
86
|
+
self.h5OMA = None
|
|
87
|
+
self.db_obj = None
|
|
88
|
+
self.oma_id_obj = None
|
|
89
|
+
|
|
90
|
+
self.reformat_names = reformat_names
|
|
91
|
+
self.slicesubhogs = slicesubhogs
|
|
92
|
+
self.swap2taxcode = use_taxcodes
|
|
93
|
+
self.tax_filter = taxfilter
|
|
94
|
+
self.tax_mask = taxmask
|
|
95
|
+
self.verbose = verbose
|
|
96
|
+
self.datetime = datetime
|
|
97
|
+
self.fileglob = fileglob
|
|
98
|
+
self.idmapper = None
|
|
99
|
+
self.date_string = "{:%B_%d_%Y_%H_%M}".format(datetime.now())
|
|
100
|
+
self.limit_species = limit_species
|
|
101
|
+
self.limit_events = limit_events
|
|
102
|
+
|
|
103
|
+
self.min_hog_size = 0 if self.slicesubhogs else self.limit_species
|
|
104
|
+
self.max_hog_size = np.inf
|
|
105
|
+
|
|
106
|
+
self.output_dir = output_dir
|
|
107
|
+
self.output_dir.mkdir(parents=True, exist_ok=True)
|
|
108
|
+
|
|
109
|
+
species = self._get_species_names()
|
|
110
|
+
if masterTree is None:
|
|
111
|
+
if not h5_oma:
|
|
112
|
+
raise TypeError('Please specify either a database or a tree')
|
|
113
|
+
|
|
114
|
+
self.tree_string, self.tree = phylo.get_tree(genomes=species, outdir=self.output_dir)
|
|
115
|
+
else:
|
|
116
|
+
# validate tree
|
|
117
|
+
self.tree = phylo.TreeValidator(masterTree, species).run()
|
|
118
|
+
|
|
119
|
+
# save the corrected tree
|
|
120
|
+
phylo.to_file(self.tree, self.output_dir / 'master_tree.corrected.nwk')
|
|
121
|
+
self.tree_string = phylo.to_string(self.tree)
|
|
122
|
+
|
|
123
|
+
self.taxaIndex, self.reverse = phylo.generate_taxa_index(self.tree, self.tax_filter, self.tax_mask)
|
|
124
|
+
|
|
125
|
+
with open(self.output_dir / 'taxaIndex.pkl', 'wb') as taxout:
|
|
126
|
+
taxout.write( pickle.dumps(self.taxaIndex))
|
|
127
|
+
|
|
128
|
+
self.numperm = numperm
|
|
129
|
+
### if no weights are provided, generate them
|
|
130
|
+
if treeweights is None:
|
|
131
|
+
#generate aconfig_utilsll ones
|
|
132
|
+
self.treeweights = hashutils.generate_treeweights(self.tree, self.taxaIndex, self.tax_filter, self.tax_mask)
|
|
133
|
+
else:
|
|
134
|
+
#load machine learning weights
|
|
135
|
+
self.treeweights = treeweights
|
|
136
|
+
tax_max = max(self.taxaIndex.values())+1
|
|
137
|
+
wmg = WeightedMinHashGenerator(3*tax_max , sample_size = numperm , seed=1)
|
|
138
|
+
|
|
139
|
+
with open(self.output_dir / 'wmg.pkl', 'wb') as wmgout:
|
|
140
|
+
wmgout.write( pickle.dumps(wmg))
|
|
141
|
+
|
|
142
|
+
self.wmg = wmg
|
|
143
|
+
|
|
144
|
+
logger.debug("Configuring pyham functions")
|
|
145
|
+
logger.debug("taxfilter: %s", self.tax_filter)
|
|
146
|
+
logger.debug("taxmask: %s", self.tax_mask)
|
|
147
|
+
logger.debug("swap ids: %s", self.swap2taxcode)
|
|
148
|
+
logger.debug("reformat names: %s", self.reformat_names)
|
|
149
|
+
logger.debug("use taxcodes: %s", self.swap2taxcode)
|
|
150
|
+
|
|
151
|
+
hamfunction = pyhamutils.get_ham_treemap_from_row
|
|
152
|
+
hashfunction = hashutils.row2hash
|
|
153
|
+
if slicesubhogs:
|
|
154
|
+
hashfunction = hashutils.hash_trees_subhogs
|
|
155
|
+
|
|
156
|
+
### set up the pyHAM pipeline with different parameters depending on whether the input is an OMA hdf5 file or a list of orthoxml files
|
|
157
|
+
logger.debug('lossonly', lossonly)
|
|
158
|
+
logger.debug('duplonly', duplonly)
|
|
159
|
+
|
|
160
|
+
self.dataset_nodes = None
|
|
161
|
+
self.idmapper = None
|
|
162
|
+
|
|
163
|
+
# remap taxfilter and taxmask
|
|
164
|
+
if taxfilter:
|
|
165
|
+
#self.tax_filter = [ self.idmapper[tax] for tax in taxfilter ]
|
|
166
|
+
unacceptable_nodes = []
|
|
167
|
+
for filterobj in self.tax_filter:
|
|
168
|
+
filter_node = self.tree.search_nodes(name=filterobj)
|
|
169
|
+
print('Found filter node:', filter_node)
|
|
170
|
+
unacceptable_nodes.extend([node.name for node in filter_node[0].traverse()])
|
|
171
|
+
|
|
172
|
+
# print(f"Error searching for node with name: {filterobj}")
|
|
173
|
+
# print("Clade could not be excluded")
|
|
174
|
+
# continue
|
|
175
|
+
# update dataset_nodes to exclude the filtered nodes
|
|
176
|
+
self.dataset_nodes = [node.name for node in self.tree.traverse() if node.name not in unacceptable_nodes]
|
|
177
|
+
|
|
178
|
+
if taxmask:
|
|
179
|
+
#print(self.idmapper)
|
|
180
|
+
#self.tax_mask = self.idmapper[taxmask]
|
|
181
|
+
### get acceptable ids here:
|
|
182
|
+
tax_mask_node = self.tree.search_nodes(name=self.tax_mask)
|
|
183
|
+
if tax_mask_node:
|
|
184
|
+
tax_mask_node = tax_mask_node[0]
|
|
185
|
+
print(f"Found tax_mask_node: {tax_mask_node.name}")
|
|
186
|
+
self.dataset_nodes = [node.name for node in tax_mask_node.traverse()]
|
|
187
|
+
else:
|
|
188
|
+
raise RuntimeError(f"No node found with name: {self.tax_mask}")
|
|
189
|
+
|
|
190
|
+
self.HAM_PIPELINE = functools.partial(
|
|
191
|
+
hamfunction, tree_string=self.tree_string, swap_ids=self.swap2taxcode,
|
|
192
|
+
orthoXML_as_string=self.h5OMA is not None, slicesubhogs=self.slicesubhogs,
|
|
193
|
+
limit_species=self.limit_species, limit_events=self.limit_events,
|
|
194
|
+
dataset_nodes=self.dataset_nodes, verbose=self.verbose)
|
|
195
|
+
|
|
196
|
+
### set up the hash pipeline
|
|
197
|
+
self.HASH_PIPELINE = functools.partial( hashfunction , taxaIndex=self.taxaIndex, treeweights=self.treeweights, wmg=wmg , lossonly = lossonly, duplonly = duplonly)
|
|
198
|
+
|
|
199
|
+
logger.debug("Setting up input data reader")
|
|
200
|
+
### if the input is an OMA hdf5 file, set up the function to read the orthoxml data
|
|
201
|
+
if self.h5OMA:
|
|
202
|
+
self.READ_ORTHO = functools.partial(pyhamutils.get_orthoxml_oma, db_obj=self.db_obj)
|
|
203
|
+
self.n_groups = len(self.h5OMA.root.OrthoXML.Index)
|
|
204
|
+
logger.info( 'reading oma hdf5 with n groups: %d', self.n_groups)
|
|
205
|
+
elif self.fileglob:
|
|
206
|
+
logger.info('reading orthoxml files: %d' , len(self.fileglob))
|
|
207
|
+
self.n_groups = len(self.fileglob)
|
|
208
|
+
else:
|
|
209
|
+
raise RuntimeError('please specify an input file' )
|
|
210
|
+
|
|
211
|
+
self.hashes_path = self.output_dir / 'hashes.h5'
|
|
212
|
+
self.lshpath = self.output_dir / 'newlsh.pkl'
|
|
213
|
+
self.lshforestpath = self.output_dir / 'newlshforest.pkl'
|
|
214
|
+
self.mat_path = self.output_dir / 'hogmat.h5'
|
|
215
|
+
self.columns = len(self.taxaIndex)
|
|
216
|
+
self.verbose = verbose
|
|
217
|
+
logger.debug('Done.')
|
|
218
|
+
|
|
219
|
+
def _get_species_names(self):
|
|
220
|
+
"""Read the DB or orthoxml to extract the list of species"""
|
|
221
|
+
if self.h5OMA:
|
|
222
|
+
field = "NCBITaxonId" if self.swap2taxcode else "SciName"
|
|
223
|
+
values = self.h5OMA.root.Genome.read(field=field)
|
|
224
|
+
return {
|
|
225
|
+
value.decode("utf-8") if isinstance(value, bytes) else str(value)
|
|
226
|
+
for value in values
|
|
227
|
+
}
|
|
228
|
+
else:
|
|
229
|
+
names = set()
|
|
230
|
+
for filename in self.fileglob:
|
|
231
|
+
for child in oxml.iter_species(filename):
|
|
232
|
+
field = "NCBITaxId" if self.swap2taxcode else "name"
|
|
233
|
+
names.add(child.attrib[field])
|
|
234
|
+
|
|
235
|
+
return names
|
|
236
|
+
|
|
237
|
+
def load_one(self, fam):
|
|
238
|
+
#test function to try out the pipeline on one orthoxml
|
|
239
|
+
ortho_fam = self.READ_ORTHO(fam)
|
|
240
|
+
pyham_tree = self.HAM_PIPELINE([fam, ortho_fam])
|
|
241
|
+
hog_matrix,weighted_hash = hashutils.hash_tree(pyham_tree , self.taxaIndex , self.treeweights , self.wmg)
|
|
242
|
+
return ortho_fam , pyham_tree, weighted_hash,hog_matrix
|
|
243
|
+
|
|
244
|
+
def _hog_size_ok(self, hog_size):
|
|
245
|
+
result = (self.max_hog_size is None or hog_size <= self.max_hog_size)
|
|
246
|
+
result &= (self.min_hog_size is None or hog_size >= self.min_hog_size)
|
|
247
|
+
return result
|
|
248
|
+
|
|
249
|
+
def _check_hog_size(self, orthoxml_file):
|
|
250
|
+
with open(orthoxml_file) as ortho:
|
|
251
|
+
ortho_str = ortho.read()
|
|
252
|
+
|
|
253
|
+
# filtering by HOG size in the # of species
|
|
254
|
+
hog_size = oxml.count_species(ortho_str)
|
|
255
|
+
return self._hog_size_ok(hog_size)
|
|
256
|
+
|
|
257
|
+
def generate_dataframes(self, size=100):
|
|
258
|
+
families = {}
|
|
259
|
+
start = -1
|
|
260
|
+
if self.h5OMA:
|
|
261
|
+
self.groups = self.h5OMA.root.OrthoXML.Index
|
|
262
|
+
buffer = self.h5OMA.root.OrthoXML.Buffer
|
|
263
|
+
oxml_builder = oxml.OrthoXMLBuilder()
|
|
264
|
+
self.rows = len(self.groups)
|
|
265
|
+
|
|
266
|
+
unique_hog_id = 0
|
|
267
|
+
|
|
268
|
+
for i, row in enumerate(track_progress(
|
|
269
|
+
self.groups,
|
|
270
|
+
description="Processing OMA groups",
|
|
271
|
+
total=self.rows,
|
|
272
|
+
)):
|
|
273
|
+
if i > start:
|
|
274
|
+
# The current index row already locates the unaugmented XML
|
|
275
|
+
offset = int(row['HogBufferOffset'])
|
|
276
|
+
stop = offset + int(row['HogBufferLength'])
|
|
277
|
+
xml = ET.fromstring(buffer[offset:stop].tobytes().decode())
|
|
278
|
+
|
|
279
|
+
# filtering by HOG size in the # of species
|
|
280
|
+
hog_size = oxml.count_species(xml)
|
|
281
|
+
if self._hog_size_ok(hog_size):
|
|
282
|
+
ortho_fam = oxml_builder.add_loft_ids(xml)
|
|
283
|
+
families[unique_hog_id] = {
|
|
284
|
+
'ortho': ortho_fam,
|
|
285
|
+
'hog_id': oxml.get_root_hog_id(xml),
|
|
286
|
+
}
|
|
287
|
+
unique_hog_id += 1
|
|
288
|
+
|
|
289
|
+
if len(families) >= size:
|
|
290
|
+
pd_dataframe = pd.DataFrame.from_dict(families, orient='index')
|
|
291
|
+
pd_dataframe['hog_key'] = pd_dataframe.index
|
|
292
|
+
yield pd_dataframe
|
|
293
|
+
families = {}
|
|
294
|
+
if families:
|
|
295
|
+
pd_dataframe = pd.DataFrame.from_dict(families, orient='index')
|
|
296
|
+
pd_dataframe['hog_key'] = pd_dataframe.index
|
|
297
|
+
yield pd_dataframe
|
|
298
|
+
|
|
299
|
+
elif self.fileglob:
|
|
300
|
+
unique_fam_id = 0
|
|
301
|
+
|
|
302
|
+
for orthoxml_file in track_progress(
|
|
303
|
+
self.fileglob,
|
|
304
|
+
description="Processing OrthoXML files",
|
|
305
|
+
total=len(self.fileglob),
|
|
306
|
+
):
|
|
307
|
+
if self._check_hog_size(orthoxml_file):
|
|
308
|
+
families[unique_fam_id] = {'ortho': orthoxml_file, 'hog_id': ''}
|
|
309
|
+
unique_fam_id += 1
|
|
310
|
+
|
|
311
|
+
if len(families) >= size:
|
|
312
|
+
pd_dataframe = pd.DataFrame.from_dict(families, orient='index')
|
|
313
|
+
pd_dataframe['hog_key'] = pd_dataframe.index
|
|
314
|
+
#print(pd_dataframe)
|
|
315
|
+
yield pd_dataframe
|
|
316
|
+
families = {}
|
|
317
|
+
|
|
318
|
+
if families:
|
|
319
|
+
pd_dataframe = pd.DataFrame.from_dict(families, orient='index')
|
|
320
|
+
pd_dataframe['hog_key'] = pd_dataframe.index
|
|
321
|
+
yield pd_dataframe
|
|
322
|
+
|
|
323
|
+
def _extract_hog_name(self, orthoxml_file: Path) -> str:
|
|
324
|
+
return Path(orthoxml_file).name.split(".", 1)[0]
|
|
325
|
+
|
|
326
|
+
def worker(self, i, work_queue, result_queue):
|
|
327
|
+
logger.debug('Starting worker #%d', i)
|
|
328
|
+
while True:
|
|
329
|
+
df = work_queue.get()
|
|
330
|
+
if df is None:
|
|
331
|
+
logger.debug("Worker #%d done", i)
|
|
332
|
+
break
|
|
333
|
+
|
|
334
|
+
if self.slicesubhogs:
|
|
335
|
+
tree_dicts = []
|
|
336
|
+
for index, row in df[["hog_key", "ortho"]].iterrows():
|
|
337
|
+
result = self.HAM_PIPELINE(row)
|
|
338
|
+
tree_dicts.append(result)
|
|
339
|
+
|
|
340
|
+
# Assign the results back to the DataFrame
|
|
341
|
+
df["tree_dicts"] = tree_dicts
|
|
342
|
+
df["hash_dicts"] = df[["hog_key", "tree_dicts"]].apply(
|
|
343
|
+
self.HASH_PIPELINE, axis=1
|
|
344
|
+
)
|
|
345
|
+
|
|
346
|
+
# Filter out rows with empty hash_dicts to save time
|
|
347
|
+
df = df[df["hash_dicts"].apply(bool)]
|
|
348
|
+
if df.empty:
|
|
349
|
+
continue
|
|
350
|
+
|
|
351
|
+
rows = []
|
|
352
|
+
for _, row in df.iterrows():
|
|
353
|
+
fam_id = self._extract_hog_name(row["ortho"]) if self.fileglob else str(row["hog_id"])
|
|
354
|
+
for subhog in row["hash_dicts"]:
|
|
355
|
+
level = row["tree_dicts"][subhog].name
|
|
356
|
+
hashed = row["hash_dicts"][subhog][1]
|
|
357
|
+
|
|
358
|
+
rows.append(
|
|
359
|
+
{
|
|
360
|
+
"hog_key": row["hog_key"],
|
|
361
|
+
"fam_id": fam_id,
|
|
362
|
+
"hog_id": subhog.removeprefix(f"{level}_"),
|
|
363
|
+
"level": level,
|
|
364
|
+
# Polars Object columns cannot be pickled by mp.Queue.
|
|
365
|
+
# Send native values and reconstruct the hash in saver
|
|
366
|
+
"hash": hashed.hashvalues.tolist(),
|
|
367
|
+
"hash_seed": hashed.seed,
|
|
368
|
+
"source": str(row["ortho"]) if self.fileglob else "",
|
|
369
|
+
}
|
|
370
|
+
)
|
|
371
|
+
|
|
372
|
+
result_queue.put(pl.DataFrame(rows))
|
|
373
|
+
else:
|
|
374
|
+
df["tree"] = df[["hog_key", "ortho"]].apply(self.HAM_PIPELINE, axis=1)
|
|
375
|
+
df[["hash", "rows"]] = df[["hog_key", "tree"]].apply(self.HASH_PIPELINE, axis=1)
|
|
376
|
+
|
|
377
|
+
# HOG names are extracted from the names of orthoxml files.
|
|
378
|
+
# Let's hope they are correct
|
|
379
|
+
if self.fileglob:
|
|
380
|
+
df["hog_id"] = df["ortho"].apply(self._extract_hog_name)
|
|
381
|
+
df["source"] = df["ortho"].map(str)
|
|
382
|
+
else:
|
|
383
|
+
df["hog_id"] = df["hog_id"].map(str)
|
|
384
|
+
df["source"] = ""
|
|
385
|
+
df["fam_id"] = df["hog_id"]
|
|
386
|
+
df["level"] = df["tree"].map(lambda tree: tree.name if tree is not None else "")
|
|
387
|
+
|
|
388
|
+
result_queue.put(df[[*HogEntryIndex.COLUMNS, "hash"]])
|
|
389
|
+
|
|
390
|
+
|
|
391
|
+
def _make_tax_str(self):
|
|
392
|
+
taxstr = ""
|
|
393
|
+
if self.tax_filter is None:
|
|
394
|
+
taxstr = "NoFilter"
|
|
395
|
+
if self.tax_mask is None:
|
|
396
|
+
taxstr += "NoMask"
|
|
397
|
+
else:
|
|
398
|
+
taxstr = str(self.tax_filter)
|
|
399
|
+
return taxstr
|
|
400
|
+
|
|
401
|
+
def _save_forest(self, forest):
|
|
402
|
+
logger.debug("Saving forest at: %.2f", t.time() - self.start_time)
|
|
403
|
+
|
|
404
|
+
forest.index()
|
|
405
|
+
with open(self.lshforestpath, "wb") as forest_out:
|
|
406
|
+
forest_out.write(pickle.dumps(forest, -1))
|
|
407
|
+
|
|
408
|
+
logger.debug("Save done at: %.2f", t.time() - self.start_time)
|
|
409
|
+
|
|
410
|
+
def _save_metadata(self, entries):
|
|
411
|
+
df = pd.DataFrame(entries, columns=HogEntryIndex.COLUMNS)
|
|
412
|
+
df.to_csv(self.output_dir / 'fam2orthoxml.csv', index=False)
|
|
413
|
+
|
|
414
|
+
|
|
415
|
+
def saver(self, result_queue):
|
|
416
|
+
global_time = t.time()
|
|
417
|
+
self.start_time = global_time
|
|
418
|
+
|
|
419
|
+
chunk_size = 100
|
|
420
|
+
count = 0
|
|
421
|
+
forest = MinHashLSHForest(num_perm=self.numperm)
|
|
422
|
+
metadata_entries = []
|
|
423
|
+
taxstr = self._make_tax_str()
|
|
424
|
+
|
|
425
|
+
# With --slicesubhogs, we don't know in advance into how many
|
|
426
|
+
# subHOGs a family will be expanded. Therefore, the global hog_key
|
|
427
|
+
# has to be reassigned so that:
|
|
428
|
+
# 1) every subHOG gets a unique key
|
|
429
|
+
# 2) hog_keys are continuous
|
|
430
|
+
hog_key = 0
|
|
431
|
+
|
|
432
|
+
with h5py.File(self.hashes_path, 'w', libver='latest') as h5hashes:
|
|
433
|
+
hash_width = 2 * self.numperm
|
|
434
|
+
|
|
435
|
+
# The main hash mapping. LSHForest.add takes a pair = (key, MihHash object),
|
|
436
|
+
# however you cannot query it with 'key', only with MinHash.
|
|
437
|
+
# To support that, we store the mapping { key -> MinHash }
|
|
438
|
+
# for everything that is indexed in the forest.
|
|
439
|
+
dataset = h5hashes.create_dataset(
|
|
440
|
+
taxstr,
|
|
441
|
+
(chunk_size, hash_width),
|
|
442
|
+
maxshape=(None, hash_width),
|
|
443
|
+
dtype="int32",
|
|
444
|
+
)
|
|
445
|
+
h5hashes.flush()
|
|
446
|
+
logger.debug('Creating dataset filtered at taxonomic level: %s', taxstr)
|
|
447
|
+
|
|
448
|
+
while True:
|
|
449
|
+
current_df = result_queue.get()
|
|
450
|
+
if current_df is None:
|
|
451
|
+
break
|
|
452
|
+
|
|
453
|
+
# Temporary: Pandas is to be replaced by Polars everywhere.
|
|
454
|
+
# currently we still use pd for the rootHOG mode
|
|
455
|
+
is_polars = isinstance(current_df, pl.DataFrame)
|
|
456
|
+
rows = current_df.to_dicts() if is_polars else current_df.to_dict("records")
|
|
457
|
+
for row in rows:
|
|
458
|
+
hashed = row["hash"]
|
|
459
|
+
if hashed is None:
|
|
460
|
+
continue
|
|
461
|
+
|
|
462
|
+
if is_polars:
|
|
463
|
+
hashed = WeightedMinHash(
|
|
464
|
+
seed=row["hash_seed"],
|
|
465
|
+
hashvalues=np.asarray(hashed, dtype=np.int64),
|
|
466
|
+
)
|
|
467
|
+
|
|
468
|
+
key = hog_key if self.slicesubhogs else int(row["hog_key"])
|
|
469
|
+
|
|
470
|
+
# resize the HogEntryIndex dataset if all rows filled
|
|
471
|
+
if dataset.shape[0] <= key:
|
|
472
|
+
dataset.resize(key + chunk_size, axis=0)
|
|
473
|
+
|
|
474
|
+
# save the hash
|
|
475
|
+
dataset[key, :] = hashed.hashvalues.ravel()
|
|
476
|
+
|
|
477
|
+
# index the hash
|
|
478
|
+
forest.add(str(key), hashed)
|
|
479
|
+
|
|
480
|
+
metadata_row = HogEntry(
|
|
481
|
+
hog_key=key, fam_id=row["fam_id"], hog_id=row["hog_id"],
|
|
482
|
+
level=row["level"], source=row["source"],
|
|
483
|
+
)
|
|
484
|
+
metadata_entries.append(metadata_row)
|
|
485
|
+
hog_key += 1
|
|
486
|
+
count += 1
|
|
487
|
+
|
|
488
|
+
logger.info('Wrapping up the run')
|
|
489
|
+
self._save_forest(forest)
|
|
490
|
+
h5hashes.flush()
|
|
491
|
+
self._save_metadata(metadata_entries)
|
|
492
|
+
logger.info('Saver wrote %d hashes', count)
|
|
493
|
+
|
|
494
|
+
|
|
495
|
+
@staticmethod
|
|
496
|
+
def _raise_if_failed(processes, required_alive=()):
|
|
497
|
+
"""Raise if a child process failed"""
|
|
498
|
+
failed = [
|
|
499
|
+
process for process in processes
|
|
500
|
+
if process.exitcode not in (None, 0)
|
|
501
|
+
]
|
|
502
|
+
if failed:
|
|
503
|
+
details = ", ".join(
|
|
504
|
+
f"{process.name} (exit code {process.exitcode})"
|
|
505
|
+
for process in failed
|
|
506
|
+
)
|
|
507
|
+
raise RuntimeError(f"Child process failure: {details}")
|
|
508
|
+
|
|
509
|
+
stopped = [
|
|
510
|
+
process for process in required_alive
|
|
511
|
+
if process.exitcode is not None
|
|
512
|
+
]
|
|
513
|
+
if stopped:
|
|
514
|
+
names = ", ".join(process.name for process in stopped)
|
|
515
|
+
raise RuntimeError(f"Child process exited unexpectedly: {names}")
|
|
516
|
+
|
|
517
|
+
@classmethod
|
|
518
|
+
def _safe_put(cls, destination, value, processes, required_alive=()):
|
|
519
|
+
"""
|
|
520
|
+
Enqueue data value with process/queue checks:
|
|
521
|
+
- if any of the workers failed already, don't send
|
|
522
|
+
- if queue is full, try again
|
|
523
|
+
"""
|
|
524
|
+
while True:
|
|
525
|
+
cls._raise_if_failed(processes, required_alive)
|
|
526
|
+
|
|
527
|
+
try:
|
|
528
|
+
destination.put(value, timeout=0.2)
|
|
529
|
+
return
|
|
530
|
+
except queue.Full:
|
|
531
|
+
pass
|
|
532
|
+
|
|
533
|
+
@classmethod
|
|
534
|
+
def _wait_for_all(cls, processes, monitored_processes=()):
|
|
535
|
+
"""
|
|
536
|
+
Joins a list of processes; raises if any of them
|
|
537
|
+
failed or if the monitored processes failed
|
|
538
|
+
"""
|
|
539
|
+
alive = list(processes)
|
|
540
|
+
all_processes = [*alive, *monitored_processes]
|
|
541
|
+
|
|
542
|
+
while alive:
|
|
543
|
+
for process in alive:
|
|
544
|
+
process.join(timeout=0)
|
|
545
|
+
|
|
546
|
+
cls._raise_if_failed(all_processes)
|
|
547
|
+
|
|
548
|
+
alive = [process for process in alive if process.is_alive()]
|
|
549
|
+
if alive:
|
|
550
|
+
time.sleep(0.1)
|
|
551
|
+
|
|
552
|
+
@staticmethod
|
|
553
|
+
def _terminate_all(processes):
|
|
554
|
+
for process in processes:
|
|
555
|
+
if process.is_alive():
|
|
556
|
+
process.terminate()
|
|
557
|
+
|
|
558
|
+
for process in processes:
|
|
559
|
+
process.join()
|
|
560
|
+
|
|
561
|
+
|
|
562
|
+
def _run_parallel(self, threads, data_generator):
|
|
563
|
+
if threads < 1:
|
|
564
|
+
raise ValueError("threads must be at least 1")
|
|
565
|
+
|
|
566
|
+
queue_size = max(2, threads * 2)
|
|
567
|
+
work_queue = mp.Queue(maxsize=queue_size)
|
|
568
|
+
result_queue = mp.Queue(maxsize=queue_size)
|
|
569
|
+
saver = mp.Process(name="saver", target=self.saver, args=(result_queue,))
|
|
570
|
+
workers = [
|
|
571
|
+
mp.Process(name=f"worker-{i}", target=self.worker, args=(i, work_queue, result_queue))
|
|
572
|
+
for i in range(threads)
|
|
573
|
+
]
|
|
574
|
+
processes = [saver, *workers]
|
|
575
|
+
started_processes = []
|
|
576
|
+
completed = False
|
|
577
|
+
|
|
578
|
+
try:
|
|
579
|
+
# start saver
|
|
580
|
+
saver.start()
|
|
581
|
+
started_processes.append(saver)
|
|
582
|
+
logger.info('Started one saver process')
|
|
583
|
+
|
|
584
|
+
# start workers
|
|
585
|
+
for process in workers:
|
|
586
|
+
process.start()
|
|
587
|
+
started_processes.append(process)
|
|
588
|
+
logger.info('Started %d workers', threads)
|
|
589
|
+
|
|
590
|
+
# send data batches to workers
|
|
591
|
+
for data in data_generator:
|
|
592
|
+
self._safe_put(work_queue, data, processes, required_alive=processes)
|
|
593
|
+
|
|
594
|
+
# send sentinels (end of work signals) to all workers
|
|
595
|
+
for _ in workers:
|
|
596
|
+
self._safe_put(work_queue,None, processes, required_alive=(saver,))
|
|
597
|
+
logger.info('Input queued; waiting for workers to finish')
|
|
598
|
+
|
|
599
|
+
# the main join -- wait for workers to be done
|
|
600
|
+
self._wait_for_all(workers, monitored_processes=(saver,))
|
|
601
|
+
|
|
602
|
+
logger.info('Workers finished; finalizing saver')
|
|
603
|
+
# send the sentinel to the saver
|
|
604
|
+
self._safe_put(result_queue,None,(saver,), required_alive=(saver,))
|
|
605
|
+
|
|
606
|
+
# wait for the saver
|
|
607
|
+
self._wait_for_all((saver,))
|
|
608
|
+
completed = True
|
|
609
|
+
|
|
610
|
+
finally:
|
|
611
|
+
# if error
|
|
612
|
+
if not completed:
|
|
613
|
+
self._terminate_all(started_processes)
|
|
614
|
+
|
|
615
|
+
# finalize queues
|
|
616
|
+
for process_queue in (work_queue, result_queue):
|
|
617
|
+
if not completed:
|
|
618
|
+
# if failed, the join below is ignored
|
|
619
|
+
process_queue.cancel_join_thread()
|
|
620
|
+
|
|
621
|
+
process_queue.close()
|
|
622
|
+
if completed:
|
|
623
|
+
process_queue.join_thread()
|
|
624
|
+
|
|
625
|
+
logger.info('Pipeline complete')
|
|
626
|
+
|
|
627
|
+
|
|
628
|
+
def run_pipeline(self , threads):
|
|
629
|
+
logger.info('Running with %d threads', threads)
|
|
630
|
+
# Levels mode counts represented species on each parsed HOG
|
|
631
|
+
data_generator = self.generate_dataframes(size=100)
|
|
632
|
+
self._run_parallel(threads=threads, data_generator=data_generator)
|
|
633
|
+
return self.hashes_path, self.lshforestpath, self.mat_path
|
|
634
|
+
|
|
635
|
+
|
|
636
|
+
def run(
|
|
637
|
+
*,
|
|
638
|
+
output_dir,
|
|
639
|
+
tree=None,
|
|
640
|
+
oma=None,
|
|
641
|
+
orthoxml_glob=None,
|
|
642
|
+
tax_weights=None,
|
|
643
|
+
taxon_mask=None,
|
|
644
|
+
exclude_taxa=None,
|
|
645
|
+
min_species=10,
|
|
646
|
+
min_events=0,
|
|
647
|
+
db_type=None,
|
|
648
|
+
num_permutations=256,
|
|
649
|
+
loss_only=False,
|
|
650
|
+
duplication_only=False,
|
|
651
|
+
use_tax_ids=False,
|
|
652
|
+
slice_subhogs=False,
|
|
653
|
+
seed=0,
|
|
654
|
+
njobs=1,
|
|
655
|
+
verbose=False,
|
|
656
|
+
):
|
|
657
|
+
if (oma is not None) == (orthoxml_glob is not None):
|
|
658
|
+
raise ValueError("Supply exactly one of oma and orthoxml_glob")
|
|
659
|
+
|
|
660
|
+
if loss_only and duplication_only:
|
|
661
|
+
raise ValueError("loss_only and duplication_only are mutually exclusive")
|
|
662
|
+
|
|
663
|
+
if db_type is not None:
|
|
664
|
+
preset = DB_PRESETS[db_type]
|
|
665
|
+
if taxon_mask is None:
|
|
666
|
+
taxon_mask = preset['taxon_mask']
|
|
667
|
+
if exclude_taxa is None:
|
|
668
|
+
exclude_taxa = preset['exclude_taxa']
|
|
669
|
+
|
|
670
|
+
weights = None
|
|
671
|
+
if tax_weights is not None:
|
|
672
|
+
from keras.models import model_from_json
|
|
673
|
+
|
|
674
|
+
with open(f"{tax_weights}.json") as model_file:
|
|
675
|
+
model = model_from_json(model_file.read())
|
|
676
|
+
model.load_weights(f"{tax_weights}.h5")
|
|
677
|
+
logger.info("Loaded model from disk")
|
|
678
|
+
weights = model.get_weights()[0] + 1e-10
|
|
679
|
+
|
|
680
|
+
# Discover input files from the glob
|
|
681
|
+
orthoxml_files = None
|
|
682
|
+
if orthoxml_glob is not None:
|
|
683
|
+
orthoxml_files = glob.glob(orthoxml_glob)
|
|
684
|
+
logger.info("Using OrthoXML files from: %s", orthoxml_glob)
|
|
685
|
+
|
|
686
|
+
start = time.monotonic()
|
|
687
|
+
database = open_file(oma, mode="r") if oma is not None else nullcontext(None)
|
|
688
|
+
with database as h5_oma:
|
|
689
|
+
lsh_builder = LSHBuilder(
|
|
690
|
+
h5_oma=h5_oma,
|
|
691
|
+
fileglob=orthoxml_files,
|
|
692
|
+
output_dir=Path(output_dir),
|
|
693
|
+
masterTree=Path(tree) if tree is not None else None,
|
|
694
|
+
numperm=num_permutations,
|
|
695
|
+
treeweights=weights,
|
|
696
|
+
taxfilter=exclude_taxa,
|
|
697
|
+
taxmask=taxon_mask,
|
|
698
|
+
lossonly=loss_only,
|
|
699
|
+
duplonly=duplication_only,
|
|
700
|
+
use_taxcodes=use_tax_ids,
|
|
701
|
+
slicesubhogs=slice_subhogs,
|
|
702
|
+
limit_species=min_species,
|
|
703
|
+
limit_events=min_events,
|
|
704
|
+
seed=seed,
|
|
705
|
+
verbose=verbose,
|
|
706
|
+
)
|
|
707
|
+
paths = lsh_builder.run_pipeline(njobs)
|
|
708
|
+
|
|
709
|
+
logger.info("Done in %.2fs", time.monotonic() - start)
|
|
710
|
+
return paths
|