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/utils/phylo.py ADDED
@@ -0,0 +1,305 @@
1
+ """
2
+ Wrapper module isolating the ete3 backend.
3
+ Provides functions to read and load newick trees
4
+ and owns the details about the underlying format
5
+ that HogProf expects.
6
+
7
+ This module is expected to be used instead
8
+ directly importing ete3 and any of its functions.
9
+ """
10
+
11
+ import copy
12
+ import pickle
13
+ import logging
14
+ from pathlib import Path
15
+ from typing import Union, Iterable
16
+ import ete3
17
+ from Bio import Entrez
18
+
19
+ logger = logging.getLogger(__name__)
20
+
21
+
22
+ # contains the key-value args passed to ete3 every time
23
+ # we need to parse the tree from newick
24
+ default_kwargs = {
25
+ "format" : 1,
26
+ "quoted_node_names" : True
27
+ }
28
+
29
+ def from_file(filename: Union[str, Path], **kwargs) -> ete3.Tree:
30
+ logger.debug(f"Reading file {filename}")
31
+ ete3_kwargs = default_kwargs.copy()
32
+ ete3_kwargs.update(kwargs)
33
+ file_str = Path(filename).absolute().as_posix()
34
+ return ete3.Tree(file_str, **ete3_kwargs)
35
+
36
+ def from_string(string: str, **kwargs) -> ete3.Tree:
37
+ ete3_kwargs = default_kwargs.copy()
38
+ ete3_kwargs.update(kwargs)
39
+ return ete3.Tree(string, **ete3_kwargs)
40
+
41
+ def to_file(tree: ete3.Tree, filename: Union[str, Path], **kwargs) -> None:
42
+ logger.debug(f"Saving to file {filename}")
43
+ ete3_kwargs = default_kwargs.copy()
44
+ ete3_kwargs.update(kwargs)
45
+ with open(filename, 'w') as outfile:
46
+ outfile.write(tree.write(**ete3_kwargs))
47
+
48
+ def to_string(tree: ete3.Tree, **kwargs) -> str:
49
+ ete3_kwargs = default_kwargs.copy()
50
+ ete3_kwargs.update(kwargs)
51
+ return tree.write(**ete3_kwargs)
52
+
53
+
54
+ def add_orphans(orphan_info, tree, genome_ids_list, verbose=False):
55
+ """
56
+ Fix the NCBI taxonomy by adding missing species.
57
+ :param: orphan_info: a dictionary containing info from the NCBI on the missing taxa
58
+ :param: tree : an ete3 tree missing some species
59
+ :param: genome_ids_list: the comlete set of taxids that should be on the tree
60
+ :verbose: Bool print debugging stuff
61
+ :return: tree: a species tree with the orphan genomes added
62
+ """
63
+ first = True
64
+
65
+
66
+ newdict = {}
67
+
68
+ leaves = set([leaf.name for leaf in tree.get_leaves()])
69
+
70
+ orphans = set(genome_ids_list) - leaves
71
+ oldkeys = set(list(newdict.keys()))
72
+
73
+ keys = set()
74
+ i = 0
75
+ logger.debug("Starting orphan repair pass %d", i)
76
+
77
+ while first or ( len(orphans) > 0 and keys != oldkeys ) :
78
+ first = False
79
+ oldkeys = keys
80
+ leaves = set([leaf.name for leaf in tree.get_leaves()])
81
+ orphans = set(genome_ids_list) - leaves
82
+ logger.debug("Taxonomy still has %d orphan taxa", len(orphans))
83
+ for orphan in orphans:
84
+ if str(orphan_info[orphan][-1]) in newdict:
85
+ newdict[str(orphan_info[orphan][-1])].append(orphan)
86
+ else:
87
+ newdict[str(orphan_info[orphan][-1])] = [orphan]
88
+ keys = set(list(newdict.keys()))
89
+ for n in tree.traverse():
90
+ if n.name in newdict and n.name not in leaves:
91
+ for orph in newdict[n.name]:
92
+ n.add_sister(name=orph)
93
+ del newdict[n.name]
94
+
95
+ for orphan in orphans:
96
+ if len(orphan_info[orphan]) > 1:
97
+ orphan_info[orphan].pop()
98
+
99
+ newdict = {}
100
+ nodes = {}
101
+ logger.debug("Orphan taxa after repair: %s", sorted(orphans))
102
+ #clean up duplicates
103
+ for n in tree.traverse():
104
+ if n.name not in nodes:
105
+ nodes[ n.name] =1
106
+ else:
107
+ nodes[ n.name] +=1
108
+
109
+ for n in tree.traverse():
110
+ if nodes[ n.name] >1:
111
+ if n.is_leaf()== False:
112
+ n.delete()
113
+ nodes[ n.name]-= 1
114
+
115
+
116
+ return tree
117
+
118
+
119
+ def get_tree(genomes, outdir=None):
120
+ """
121
+ Generates a taxonomic tree using the ncbi taxonomy and
122
+ :param oma: a pyoma db object
123
+ :param saveTree: Bool for whether or not to save a mastertree newick file
124
+ :return: tree_string: a newick string tree: an ete3 object
125
+
126
+ """
127
+ ncbi = ete3.NCBITaxa()
128
+ genomes = set(genomes)
129
+ tree = ete3.PhyloTree(name='-1')
130
+ topo = ncbi.get_topology(genomes, collapse_subspecies=False)
131
+ tree.add_child(topo)
132
+ orphans = list(genomes - set([x.name for x in tree.get_leaves()]))
133
+ logger.warning('Missing taxa: %d', len(orphans))
134
+
135
+ orphans_info1 = {}
136
+ orphans_info2 = {}
137
+ for x in orphans:
138
+ search_handle = Entrez.efetch('taxonomy', id=str(x), retmode='xml')
139
+ record = next(Entrez.parse(search_handle))
140
+ logger.debug("NCBI taxonomy record for %s: %s", x, record)
141
+ orphans_info1[record['ParentTaxId']] = x
142
+ orphans_info2[x] = [x['TaxId'] for x in record['LineageEx']]
143
+ for n in tree.traverse():
144
+ if n.name in orphans_info1:
145
+ n.add_sister(name=orphans_info1[n.name])
146
+ logger.debug("Attached orphan taxon beside node %s", n.name)
147
+ orphans = set(genomes) - set([x.name for x in tree.get_leaves()])
148
+ tree = add_orphans(orphans_info2, tree, genomes)
149
+ orphans = set(genomes) - set([x.name for x in tree.get_leaves()])
150
+ tree_string = tree.write(format=1)
151
+
152
+ with open(outdir + 'master_tree.nwk', 'w') as nwkout:
153
+ nwkout.write(tree_string)
154
+ with open(outdir + '_master_tree.pkl', 'wb') as pklout:
155
+ pklout.write(pickle.dumps(tree))
156
+
157
+ return tree_string, tree
158
+
159
+
160
+ def generate_taxa_index(tree , taxfilter= None, taxmask=None):
161
+ """
162
+ Generates an index for the global taxonomic tree for all OMA
163
+ :param tree: ete3 tree
164
+ :return: taxaIndex: dictionary key: node name (species name); value: index
165
+ taxaIndexReverse: dictionary key: index: value: species name
166
+ """
167
+ newtree = copy.deepcopy(tree)
168
+ for n in newtree.traverse():
169
+ if taxmask:
170
+ if str(n.name) == str(taxmask):
171
+ newtree = n
172
+ break
173
+ if taxfilter:
174
+ if n.name in taxfilter:
175
+ #set weight for descendants of n to 0
176
+ n.delete()
177
+ taxa_index = {}
178
+ taxa_index_reverse = {}
179
+ for i, n in enumerate(tree.traverse()):
180
+ taxa_index_reverse[i] = n.name
181
+ taxa_index[n.name] = i
182
+
183
+ return taxa_index, taxa_index_reverse
184
+
185
+
186
+ class TreeValidator:
187
+ """Validate and repair a species tree before starting worker processes."""
188
+
189
+ def __init__(self, filename: Path, species_names: Iterable[str] = ()):
190
+ self.filename = Path(filename)
191
+ self.species_names = {str(name) for name in species_names}
192
+
193
+ def run(self):
194
+ self._validate_format()
195
+ tree = from_file(self.filename)
196
+
197
+ self._fix_internal_species(tree)
198
+
199
+ leaf_names_before = tree.get_leaf_names()
200
+ new_tree = self._promote_single_leaves(tree.copy())
201
+
202
+ # repaired tree check: kept every leaf
203
+ assert sorted(new_tree.get_leaf_names()) == sorted(leaf_names_before)
204
+ # check: new tree is connected
205
+ assert all(child.up is node for node in new_tree.traverse() for child in node.children)
206
+
207
+ return new_tree
208
+
209
+ def _validate_format(self):
210
+ # validate name
211
+ valid = self.filename.suffix.lower() in [".nwk", ".newick"]
212
+ if not valid:
213
+ raise ValueError("Input tree must be in the newick format")
214
+
215
+ def _check_missing_duplicated_species(self, tree, nodes_by_name):
216
+ if not self.species_names:
217
+ return
218
+
219
+ # check if species list has the names not existing in the tree
220
+ missing = self.species_names - nodes_by_name.keys()
221
+ duplicated = {
222
+ name for name, nodes in nodes_by_name.items() if len(nodes) != 1
223
+ }
224
+ if missing or duplicated:
225
+ problems = []
226
+ if missing:
227
+ problems.append("missing species: " + ", ".join(sorted(missing)))
228
+ if duplicated:
229
+ problems.append(
230
+ "species mapped to multiple tree nodes: "
231
+ + ", ".join(sorted(duplicated))
232
+ )
233
+ raise ValueError("Invalid species tree (" + "; ".join(problems) + ")")
234
+
235
+
236
+ def _fix_internal_species(self, tree):
237
+ """
238
+ Pyham raises the following error
239
+ `TypeError: species name 'XXX' maps to an ancestral name, not a leaf of the taxonomy`
240
+ if an internal node is marked as species. Repair the input tree for these cases
241
+ """
242
+ if not self.species_names:
243
+ return
244
+
245
+ nodes_by_name = {}
246
+ for node in tree.traverse():
247
+ if node.name in self.species_names:
248
+ nodes_by_name.setdefault(node.name, []).append(node)
249
+
250
+ self._check_missing_duplicated_species(tree, nodes_by_name)
251
+
252
+ repaired = []
253
+ # Postorder also handles the unlikely case of nested species nodes
254
+ for node in tree.traverse("postorder"):
255
+ if node.name not in self.species_names or node.is_leaf():
256
+ continue
257
+ if node.is_root():
258
+ raise ValueError(
259
+ f"Species {node.name!r} maps to the root of a non-trivial tree"
260
+ )
261
+
262
+ parent = node.up
263
+ for child in list(node.get_children()):
264
+ child.detach()
265
+ parent.add_child(child)
266
+ repaired.append(node.name)
267
+
268
+ if repaired:
269
+ logger.warning(
270
+ "Converted %d internal species nodes to leaves: %s",
271
+ len(repaired),
272
+ ", ".join(sorted(repaired)),
273
+ )
274
+
275
+ invalid = [
276
+ name
277
+ for name, nodes in nodes_by_name.items()
278
+ if len(nodes) != 1 or not nodes[0].is_leaf()
279
+ ]
280
+ if invalid:
281
+ raise ValueError(
282
+ "Species did not resolve to leaves after tree repair: "
283
+ + ", ".join(sorted(invalid))
284
+ )
285
+
286
+ @staticmethod
287
+ def _promote_single_leaves(tree: ete3.Tree) -> ete3.Tree:
288
+ """
289
+ If a node has a single descendant that's a leaf,
290
+ makes it a sister node
291
+ """
292
+ new_tree = tree
293
+
294
+ count = 0
295
+ for node in new_tree.traverse("postorder"):
296
+ children = node.get_children()
297
+ to_delete = not node.is_root() and len(children) == 1 and children[0].is_leaf()
298
+ if to_delete:
299
+ logger.debug("Reparenting node %s", children[0].name)
300
+ count += 1
301
+ node.delete()
302
+
303
+ if count > 0:
304
+ logger.info("Repaired %d internal single-child nodes", count)
305
+ return new_tree
@@ -0,0 +1,256 @@
1
+ import xml.etree.ElementTree as ET
2
+ import logging
3
+ import os
4
+ import sys
5
+ import pyham
6
+
7
+ logger = logging.getLogger(__name__)
8
+
9
+
10
+ def get_orthoxml_oma(fam, db_obj):
11
+ orthoxml = db_obj.get_orthoxml(fam).decode()
12
+ return orthoxml
13
+
14
+ def get_orthoxml_tar(fam, tar):
15
+ f = tar.extractfile(fam)
16
+ if f is not None:
17
+ return f.read()
18
+ else:
19
+ raise Exception( member + ' : not found in tarfile ')
20
+ return orthoxml
21
+
22
+ def get_species_from_orthoxml(orthoxml):
23
+ NCBI_taxid2name = {}
24
+ root = ET.fromstring(orthoxml)
25
+ for child in root:
26
+ if 'species' in child.tag:
27
+ NCBI_taxid2name[child.attrib['NCBITaxId']] = child.attrib['name']
28
+ return NCBI_taxid2name
29
+
30
+ def switch_name_ncbi_id(orthoxml , mapdict = None ):
31
+ #swap ncbi taxid for species name to avoid ambiguity
32
+ #mapdict should be a mapping from species name to taxid if the info isnt in the orthoxmls
33
+ root = ET.fromstring(orthoxml)
34
+ for child in root:
35
+ if 'species' in child.tag:
36
+ child.attrib['name'] = child.attrib['NCBITaxId']
37
+ elif mapdict:
38
+ child.attrib['name'] = mapdict[child.attrib['name']]
39
+
40
+ orthoxml = ET.tostring(root, encoding='unicode', method='xml')
41
+ return orthoxml
42
+
43
+ def reformat_treenames( tree , mapdict = None ):
44
+ #tree is an ete3 tree instance
45
+ #replace ( ) - / . and spaces with underscores
46
+ #iterate over all nodes
47
+ for node in tree.traverse():
48
+ if mapdict:
49
+ node.name = mapdict[node.name]
50
+ else:
51
+ node.name = node.name.replace('(', '').replace(')', '').replace('-', '_').replace('/', '_')
52
+ return tree
53
+
54
+ def reformat_names_orthoxml(orthoxml , mapdict = None ):
55
+ #replace ( ) - / . and spaces with underscores
56
+ root = ET.fromstring(orthoxml)
57
+ for child in root:
58
+ if 'species' in child.tag:
59
+ child.attrib['name'] = child.attrib['name'].replace('(', '').replace(')', '').replace('-', '_').replace('/', '_')
60
+ elif mapdict:
61
+ child.attrib['name'] = mapdict[child.attrib['name']]
62
+ orthoxml = ET.tostring(root, encoding='unicode', method='xml')
63
+ return orthoxml
64
+
65
+ def create_nodemapping(tree):
66
+ #create a mapping from node name to node
67
+ nodemapping = {}
68
+ for i,node in enumerate(tree.traverse()):
69
+ nodemapping[node.name] = str(i)
70
+
71
+ #assert number of nodes is equal to number of unique names
72
+ assert len([n for n in tree.traverse()]) == len(set(nodemapping.values()))
73
+
74
+ return nodemapping
75
+
76
+ def tree2numerical(tree):
77
+ mapper = create_nodemapping(tree)
78
+ for i,node in enumerate(tree.traverse()):
79
+ node.name = mapper[node.name]
80
+ return tree , mapper
81
+
82
+ def orthoxml2numerical(orthoxml , mapper):
83
+ #print(orthoxml)
84
+ try:
85
+ root = ET.fromstring(orthoxml)
86
+ except Exception as e:
87
+ with open( orthoxml , 'r') as f:
88
+ orthoxml = f.read()
89
+ root = ET.fromstring(orthoxml)
90
+ for child in root:
91
+ if 'name' in child.attrib:
92
+ child.attrib['name'] = mapper[child.attrib['name']]
93
+ orthoxml = ET.tostring(root, encoding='unicode', method='xml')
94
+ return orthoxml
95
+
96
+ def _root_treemap(ham, root_hog, *, dataset_nodes, **kwargs):
97
+ """Make a treemap for the rootHOG"""
98
+ if dataset_nodes is not None and root_hog.genome.name not in dataset_nodes:
99
+ return None
100
+
101
+ return ham.create_tree_profile(hog=root_hog).treemap
102
+
103
+
104
+ def _check_limits(treemap, limit_events):
105
+ """
106
+ Check if |losses| AND |duplications| are less than limit_events
107
+ """
108
+ duplications = sum(node.dupl or 0 for node in treemap.traverse())
109
+ losses = sum(node.lost or 0 for node in treemap.traverse())
110
+ return duplications >= limit_events or losses >= limit_events
111
+
112
+
113
+ def _subhog_keys(hogs, family):
114
+ """Generate keys for subhogs in family"""
115
+ bases = [
116
+ f"{hog.genome.name}_{hog.hog_id if hog.hog_id is not None else family}"
117
+ for hog in hogs
118
+ ]
119
+ reserved = set(bases)
120
+ seen = set()
121
+ suffixes = {}
122
+ for base in bases:
123
+ key = base
124
+ suffix = suffixes.get(base, 2)
125
+ while key in seen or (key != base and key in reserved):
126
+ key = f"{base}__{suffix}"
127
+ suffix += 1
128
+ suffixes[base] = suffix
129
+ seen.add(key)
130
+ yield key
131
+
132
+
133
+ def _subhog_treemaps(ham, root_hog, *, family, limit_species,
134
+ limit_events, dataset_nodes, verbose):
135
+ """Make treemaps for the levels mode"""
136
+ hogs = root_hog.get_all_descendant_hogs()
137
+
138
+ fallback_id = root_hog.hog_id if root_hog.hog_id is not None else family
139
+ treemaps = {}
140
+
141
+ # for every subhog
142
+ for key, hog in zip(_subhog_keys(hogs, fallback_id), hogs):
143
+
144
+ # check that the subHOG taxa was not filtered out
145
+ if dataset_nodes is not None and hog.genome.name not in dataset_nodes:
146
+ continue
147
+
148
+ # check the minimal number of represented species
149
+ if len(hog.get_all_descendant_genes_clustered_by_species()) < limit_species:
150
+ continue
151
+
152
+ treemap = ham.create_tree_profile(hog=hog).treemap
153
+
154
+ # check the minimal number of events
155
+ if _check_limits(treemap, limit_events):
156
+ treemaps[key] = treemap
157
+
158
+ if verbose:
159
+ logger.debug("Family %s: retained %d of %d HOG profiles",
160
+ family, len(treemaps), len(hogs))
161
+ return treemaps
162
+
163
+
164
+ # Profiling strategies supported in HogProf
165
+ _PROFILE_STRATEGIES = {
166
+ # the default strategy -- 1 profile per rootHOG
167
+ "root": _root_treemap,
168
+
169
+ # Levels -- 1 profile per rootHOG + every subHOG
170
+ "levels": _subhog_treemaps
171
+ }
172
+
173
+
174
+ def get_ham_treemap_from_row(row, tree_string,
175
+ *,
176
+ swap_ids=True,
177
+ orthoXML_as_string=True,
178
+ use_internal_name=True,
179
+ limit_species=10, limit_events=0,
180
+ dataset_nodes=None, verbose=False,
181
+ slicesubhogs=False):
182
+ """Parse one root-HOG input and produce treemaps depending on the mode:
183
+
184
+ - Root mode (default) returns a treemap or None
185
+ - Levels mode (slicesubhogs=True) returns a dictionary,
186
+ including the root when it passes the species and event thresholds.
187
+ """
188
+ family, orthoxml = row
189
+
190
+ if not orthoxml:
191
+ logger.warning("Family %s: empty OrthoXML provided", family)
192
+ return {} if slicesubhogs else None
193
+
194
+ if swap_ids:
195
+ if not orthoXML_as_string:
196
+ with open(orthoxml) as source:
197
+ orthoxml = source.read()
198
+ orthoxml = switch_name_ncbi_id(orthoxml)
199
+ orthoXML_as_string = True
200
+
201
+ ham = pyham.Ham(tree_string, orthoxml,
202
+ type_hog_file="orthoxml",
203
+ tree_format="newick_string",
204
+ use_internal_name=use_internal_name,
205
+ orthoXML_as_string=orthoXML_as_string)
206
+
207
+ roots = ham.get_list_top_level_hogs()
208
+ if len(roots) != 1:
209
+ raise ValueError(f"Family {family}: expected one top-level HOG, found {len(roots)}")
210
+
211
+ # pick the profiling strategy
212
+ strategy = _PROFILE_STRATEGIES["levels" if slicesubhogs else "root"]
213
+
214
+ return strategy(ham, roots[0],
215
+ family=family,
216
+ limit_species=limit_species, limit_events=limit_events,
217
+ dataset_nodes=None if dataset_nodes is None else set(dataset_nodes),
218
+ verbose=verbose)
219
+
220
+
221
+ def get_subhog_ham_treemaps_from_row(row, tree_string, **kwargs):
222
+ """Compatibility entry point for levels-mode callers."""
223
+ return get_ham_treemap_from_row(row, tree_string, slicesubhogs=True, **kwargs)
224
+
225
+
226
+ def add_library_path(library_path):
227
+ """Add the directory containing profiler.py to Python's sys.path"""
228
+ if not os.path.exists(library_path):
229
+ raise FileNotFoundError(f"Path does not exist: {library_path}")
230
+ profiler_dir = os.path.dirname(os.path.abspath(library_path))
231
+ if profiler_dir not in sys.path:
232
+ sys.path.append(profiler_dir)
233
+
234
+
235
+ def yield_families(h5file, start_fam):
236
+ """
237
+ Given a h5file containing OMA server, returns an iterator over the families
238
+ (not sure if still in use)
239
+ :param h5file: omafile
240
+ :param start_fam: fam to start on
241
+ :return: fam number
242
+ """
243
+ for row in h5file.root.OrthoXML.Index:
244
+ if row[0] > start_fam:
245
+ yield row[0]
246
+ def get_one_family(i, h5file):
247
+ '''
248
+ get one family from database
249
+ Args:
250
+ i : family number
251
+ h5file : OMA server file
252
+ Return :
253
+ family
254
+ Not sure if still in use
255
+ '''
256
+ return h5file.root.OrthoXML.Index[i][0]