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/metadata.py ADDED
@@ -0,0 +1,126 @@
1
+ from collections import defaultdict
2
+ from dataclasses import asdict, dataclass
3
+ from pathlib import Path
4
+
5
+ import polars as pl
6
+
7
+
8
+ @dataclass(frozen=True, slots=True)
9
+ class HogQueryHit:
10
+ """One HOG similarity search hit"""
11
+
12
+ query_hog_id: str
13
+ hit_hog_id: str
14
+ hit_level: str
15
+ jaccard: float
16
+
17
+
18
+ @dataclass(frozen=True, slots=True)
19
+ class HogQueryResult:
20
+ """Search results for one or more HOG queries"""
21
+
22
+ hits: pl.DataFrame
23
+
24
+ @classmethod
25
+ def from_hits(cls, hits: list[HogQueryHit]) -> "HogQueryResult":
26
+ df = pl.DataFrame(
27
+ [asdict(hit) for hit in hits],
28
+ schema={
29
+ "query_hog_id": pl.String,
30
+ "hit_hog_id": pl.String,
31
+ "hit_level": pl.String,
32
+ "jaccard": pl.Float64,
33
+ },
34
+ )
35
+ return cls(df.sort("jaccard", descending=True))
36
+
37
+ def __len__(self) -> int:
38
+ return self.hits.height
39
+
40
+ def to_pandas(self):
41
+ return self.hits.to_pandas()
42
+
43
+ def to_dicts(self) -> list[dict]:
44
+ return self.hits.to_dicts()
45
+
46
+
47
+ @dataclass(frozen=True, slots=True)
48
+ class HogEntry:
49
+ """
50
+ A metadata entry for one indexed HOG. Represents a row in the fam2orthoxml file
51
+ """
52
+ # The integer primary key used by the LSHForest.add and hashes.h5
53
+ hog_key: int
54
+
55
+ # Family ID in string format:
56
+ # - its HOG ID for the main mode
57
+ # - its rootHOG's ID for --slicesubhogs
58
+ # Example: "HOG:0015853_1"
59
+ fam_id: str
60
+
61
+ # HOG ID in string format:
62
+ # - the same as fam_id for the main mode.
63
+ # - subHOG ID for --slicesubhogs
64
+ # Example: "HOG:0015853.1aa_4"
65
+ hog_id: str
66
+
67
+ # Taxonomic level at which the HOG is defined.
68
+ # Example: "Archosauria"
69
+ level: str
70
+
71
+ # If orthoxml:
72
+ # - filename of the orthoxml file where the HOG was defined
73
+ # otherwise empty
74
+ source: str
75
+
76
+
77
+ class HogEntryIndex:
78
+ """Indexed access to HOG metadata."""
79
+
80
+ COLUMNS = ("hog_key", "fam_id", "hog_id", "level", "source")
81
+
82
+ def __init__(self, csv_path: Path):
83
+ schema = {
84
+ "hog_key": pl.Int64, "fam_id": pl.String, "hog_id": pl.String,
85
+ "level": pl.String, "source": pl.String
86
+ }
87
+ # read fam2orthoxml
88
+ self._df = pl.read_csv(csv_path, schema_overrides=schema).select(self.COLUMNS)
89
+
90
+ # check format
91
+ if tuple(self._df.columns) != self.COLUMNS:
92
+ raise ValueError(f"Invalid format for {csv_path}: expected {self.COLUMNS}, got {self._df.columns}")
93
+
94
+ # make O(1) search indexes
95
+ self._by_key: dict[int, int] = {}
96
+ self._by_hog_id: dict[str, list[int]] = defaultdict(list)
97
+ self._by_level: dict[str, list[int]] = defaultdict(list)
98
+
99
+ for i, (key, _, hog_id, level, _) in enumerate(self._df.iter_rows()):
100
+ if key in self._by_key:
101
+ raise ValueError(f"Duplicate hog_key: {key}")
102
+ self._by_key[key] = i
103
+
104
+ self._by_hog_id[hog_id].append(i)
105
+ self._by_level[level].append(i)
106
+
107
+ def __len__(self) -> int:
108
+ return self._df.height
109
+
110
+ def _entry_at(self, i: int) -> HogEntry:
111
+ return HogEntry(*self._df.row(i))
112
+
113
+ def get_hog_by_key(self, key: int) -> HogEntry:
114
+ if not isinstance(key, int):
115
+ raise TypeError(f"key must be an integer, got {type(key)}")
116
+
117
+ i = self._by_key.get(key)
118
+ if i is None:
119
+ raise ValueError(f"Invalid HOG key: {key}")
120
+ return self._entry_at(i)
121
+
122
+ def get_hog_by_hog_id(self, hog_id: str) -> list[HogEntry]:
123
+ return [self._entry_at(i) for i in self._by_hog_id.get(hog_id, ())]
124
+
125
+ def search_by_level(self, level: str) -> list[HogEntry]:
126
+ return [self._entry_at(i) for i in self._by_level.get(level, ())]
hogprof/presets.py ADDED
@@ -0,0 +1,21 @@
1
+ """Taxonomic ranges shared by the CLI and programmatic profile builder."""
2
+
3
+ DB_PRESETS = {
4
+ "all": {"exclude_taxa": None, "taxon_mask": None},
5
+ "eukarya": {"exclude_taxa": None, "taxon_mask": "Eukaryota"},
6
+
7
+ # These below are the old masks (pre 0.2.0) that use
8
+ # the hardcoded taxa IDs. They should be substituted
9
+ # with correct taxon names. To verify and enable later
10
+
11
+ #'plants': {'exclude_taxa': None, 'taxon_mask': '33090'},
12
+ #'archaea': {'exclude_taxa': None, 'taxon_mask': '2157'},
13
+ #'bacteria': {'exclude_taxa': None, 'taxon_mask': '2'},
14
+ #'protists': {
15
+ # 'exclude_taxa': ['2', '2157', '33090', '4751', '33208'],
16
+ # 'taxon_mask': None,
17
+ # },
18
+ #'fungi': {'exclude_taxa': None, 'taxon_mask': '4751'},
19
+ #'metazoa': {'exclude_taxa': None, 'taxon_mask': '33208'},
20
+ #'vertebrates': {'exclude_taxa': None, 'taxon_mask': '7742'},
21
+ }
hogprof/profiler.py ADDED
@@ -0,0 +1,333 @@
1
+ import logging
2
+ import os
3
+ import pickle
4
+ import random
5
+ from collections.abc import Iterable
6
+ from pathlib import Path
7
+
8
+ import h5py
9
+ import numpy as np
10
+ import tables
11
+ from pyoma.browser import db
12
+
13
+ from hogprof.metadata import HogEntry, HogEntryIndex, HogQueryHit, HogQueryResult
14
+ from hogprof.utils import hashutils
15
+
16
+ np.random.seed(0)
17
+ random.seed(0)
18
+ logger = logging.getLogger(__name__)
19
+
20
+
21
+ class Profiler:
22
+ """
23
+ A profiler object allows the user to query the LSH with HOGs and get a list of result HOGs back
24
+ """
25
+ def __init__(self,
26
+ lshforestpath=None,
27
+ hashes_h5=None,
28
+ oma=False,
29
+ nsamples=256,
30
+ slicesubhogs=False):
31
+ """
32
+ The Profiler class initializes a profiler object for querying the LSH with HOGs and returning a list of result HOGs.
33
+
34
+ Attributes:
35
+ lshobj (object): LSH object for querying.
36
+ hashes_h5 (h5py.File): H5 file containing HOGs.
37
+ nsamples (int): Number of samples to use.
38
+ tree (ete3.Tree): Master tree used for generating taxa index.
39
+ tree_string (str): String representation of the master tree.
40
+ taxaIndex (dict): Dictionary mapping taxa names to their indices in the master tree.
41
+ ReverseTaxaIndex (dict): Dictionary mapping indices in the master tree to their corresponding taxa names.
42
+ db_obj (db.Database): OMA database object.
43
+ treeweights (dict): Dictionary containing the tree weight for each taxon.
44
+ READ_ORTHO (callable): Function for reading orthoxml files from OMA.
45
+ HAM_PIPELINE (callable): Function for generating the Annotated tree from a row.
46
+ HASH_PIPELINE (callable): Function for generating the hash from a row.
47
+ slicesubhogs (bool): Whether to slice subhogs.
48
+
49
+ Parameters:
50
+ lshforestpath (str, optional): Path to the pickled LSH forest object.
51
+ hashes_h5 (str, optional): Path to the H5 file containing HOGs.
52
+ mat_path (str, optional): Path to the matrix file containing HOGs.
53
+ oma (str, optional): Path to the OMA database.
54
+ tar (str, optional): Path to the tar archive.
55
+ nsamples (int, optional): Number of samples to use. Defaults to 256.
56
+ mastertree (str, optional): Path to the master tree file.
57
+ """
58
+ logger.debug('Loading LSH')
59
+ with open(lshforestpath, 'rb') as lshpickle:
60
+ self.forest = pickle.loads(lshpickle.read())
61
+ logger.debug('Indexing LSH')
62
+ self.forest.index()
63
+
64
+ self.hashes_h5 = h5py.File(hashes_h5, mode='r')
65
+ self.slicesubhogs = slicesubhogs
66
+
67
+ self.oma = oma
68
+ self.fam2orthoxmlpath = f"{os.path.dirname(lshforestpath)}/fam2orthoxml.csv"
69
+
70
+ self.hog_index = HogEntryIndex(Path(self.fam2orthoxmlpath))
71
+
72
+ logger.debug("H5" + str(self.hashes_h5) + " " + str(self.hashes_h5.keys()))
73
+ self.nsamples = nsamples
74
+
75
+ if oma:
76
+ h5_oma = tables.open_file(oma, mode="r")
77
+ self.db_obj = db.Database(h5_oma)
78
+
79
+ logger.debug('Profiler init over')
80
+
81
+ def get_subhogs_of_fam(self, fam_id: int) -> Iterable[int]:
82
+ """
83
+ Returns what sub HOG ID (row numbers in the fam2orthoxml.csv)
84
+ belong to the family of given ID (`fam` column == fam_id)
85
+ """
86
+ if not isinstance(fam_id, str):
87
+ raise TypeError("fam_id must be a string")
88
+
89
+ if not self.slicesubhogs:
90
+ raise ValueError("SubHOGs only supported with --slicesubhogs")
91
+
92
+ fam_dict = (
93
+ self.id2famsubhog_df.groupby("fam")
94
+ .apply(lambda x: x.index.tolist())
95
+ .to_dict()
96
+ )
97
+ return fam_dict[fam_id]
98
+
99
+ def hogid2fam(self, hog_entry):
100
+ if type(hog_entry) not in [int, np.integer, str]:
101
+ raise TypeError("hog_entry must be integer or string")
102
+
103
+
104
+ if isinstance(hog_entry, (int, np.integer)) or hog_entry.isdecimal():
105
+ family = int(hog_entry)
106
+
107
+ # OMA mode
108
+ elif self.oma:
109
+ if hog_entry.startswith('HOG:'):
110
+ family = int(self.db_obj.parse_hog_id(hog_entry))
111
+ else:
112
+ entry = self.db_obj.entry_by_entry_nr(self.db_obj.id_resolver.resolve(hog_entry))
113
+ family = int(self.db_obj.hog_family(entry=entry))
114
+
115
+ # orthoxml mode -- rootHOGs
116
+ elif not self.slicesubhogs and hog_entry in self.hog2fam:
117
+ family = self.hog2fam[hog_entry]
118
+
119
+ # orthoxml mode -- subHOGs
120
+ elif self.slicesubhogs and hog_entry in self.subhogname_to_id_dict:
121
+ row = self.subhogname_to_id_dict[hog_entry]
122
+ return [row], {row: hog_entry}
123
+
124
+ else:
125
+ raise KeyError(f"Unknown HOG identifier in this OrthoXML index: {hog_entry!r}")
126
+
127
+ # return family if the rootHOG mode
128
+ if not self.slicesubhogs:
129
+ return family
130
+
131
+ # otherwise return a dict
132
+ rows = list(self.fam_dict[family])
133
+ return rows, {row: self.subhog_dict[row] for row in rows}
134
+
135
+
136
+ def _get_hog_of_level(self, hog_entries: list[HogEntry], level: str) -> HogEntry:
137
+ for h in hog_entries:
138
+ if h.level == level:
139
+ return h
140
+
141
+ raise ValueError(f"Cannot find the HOG at level {level}")
142
+
143
+
144
+ def query_hog(self, hog_name: str, level=None, k: int = 10) -> HogQueryResult:
145
+ """
146
+ Query the LSH index directly. `hog_id` MUST be a forest key
147
+ """
148
+ if not isinstance(hog_name, str) or not hog_name.startswith('HOG:'):
149
+ raise TypeError("`hog_name` must be a string of format 'HOG:{number}'")
150
+
151
+ hogs = self.hog_index.get_hog_by_hog_id(hog_name)
152
+ if len(hogs) == 0:
153
+ raise ValueError(f"Unknown HOG identifier: {hog_name!r}")
154
+
155
+ if len(hogs) > 1:
156
+ if not level:
157
+ levels = [h.level for h in hogs]
158
+ raise ValueError(f"{hog_name} exists at different levels: {levels}. Please indicate a level")
159
+
160
+ hog = self._get_hog_of_level(hogs, level)
161
+ else:
162
+ hog = hogs[0]
163
+
164
+ query_hash = hashutils.fam2hash_hdf5(hog.hog_key, self.hashes_h5, nsamples=self.nsamples)
165
+
166
+ rows = []
167
+ hit_keys = self.forest.query(query_hash, k)
168
+ for hit_key in hit_keys:
169
+ hit_key = int(hit_key)
170
+
171
+ # get hit metadata
172
+ hit = self.hog_index.get_hog_by_key(hit_key)
173
+
174
+ # compute jaccard
175
+ hit_hash = hashutils.fam2hash_hdf5(hit_key, self.hashes_h5, nsamples=self.nsamples)
176
+ jaccard = query_hash.jaccard(hit_hash)
177
+
178
+ # report
179
+ rows.append(
180
+ HogQueryHit(
181
+ query_hog_id=hog_name,
182
+ hit_hog_id=hit.hog_id,
183
+ hit_level=hit.level,
184
+ jaccard=jaccard,
185
+ )
186
+ )
187
+
188
+ hqr = HogQueryResult.from_hits(rows)
189
+ hqr.hits.sort("jaccard", descending=True)
190
+ return hqr
191
+
192
+
193
+ def query_fam(self, fam_id: str, k=10):
194
+ """
195
+ Query HOGs of a given family.
196
+
197
+ Default mode: only the rootHOG will be queried, and
198
+ `fam_id` MUST be a valid forest key. O
199
+
200
+ In the --slicesubhogs mode: all subHOGs of the given
201
+ family are queried.
202
+
203
+ Returns a dict of type
204
+ {
205
+ HOG_id : [ list of closest neibhbor IDs ]
206
+ }
207
+ """
208
+ raise NotImplementedError()
209
+
210
+ if self.slicesubhogs:
211
+ # resolve fam_id -> list of forest key for every subHOG
212
+ subhog_keys = self.get_subhogs_of_fam(fam_id)
213
+
214
+ # get subHOG hashes
215
+ query_hashes_dict = {
216
+ key: hashutils.fam2hash_hdf5(key, self.hashes_h5, nsamples=self.nsamples)
217
+ for key in subhog_keys
218
+ }
219
+ else:
220
+ query_hashes_dict = {fam_id: hashutils.fam2hash_hdf5(fam_id, self.hashes_h5, nsamples=self.nsamples)}
221
+
222
+ # query every subHOG
223
+ return {
224
+ forest_key: self.forest.query(hash, k)
225
+ for forest_key, hash in query_hashes_dict.items()
226
+ }
227
+
228
+ def hog_query(self, *,
229
+ hog_id: int | None = None,
230
+ fam_id: int | None = None,
231
+ k=10):
232
+ """
233
+ Given a hog_id or a fam_id as a query, returns a dictionary containing the results of the LSH.
234
+ :param hog_id: query hog id
235
+ :param fam_id: query fam id
236
+ :return: list containing the results of the LSH for the given query
237
+ """
238
+ logger.warning("`hog_query` is deprecated, use `query_hog` or `query_fam` instead")
239
+
240
+ if hog_id:
241
+ return self.query_hog(hog_id, k=k)
242
+ elif fam_id:
243
+ return self.query_fam(fam_id, k=k)
244
+ else:
245
+ raise ValueError("Provide either hog_id or fam_id")
246
+
247
+
248
+ def pull_hashes(self, hoglist, fam2orthoxmlpath = None):
249
+ """
250
+ Given a list of hog_ids , returns a dictionary containing their hashes.
251
+ This uses the hdf5 file to get the hashvalues
252
+ :param hog_id: query hog id
253
+ :param fam_id: query fam id
254
+ :return: a dict containing the hash values of the hogs in hoglist
255
+ """
256
+ #print('pulling hashes')
257
+ ### hogid2fam returns the index of the family in the h5 file for normal cases
258
+ ### for sliced subhogs, it returns the list of indices of the subhogs in the h5 file
259
+ ### returns indices, subhog_dict
260
+ if not self.slicesubhogs:
261
+ return { entry: hashutils.fam2hash_hdf5( self.hogid2fam(entry), self.hashes_h5 , nsamples= self.nsamples) for entry in hoglist}
262
+ else:
263
+ result_dict = {}
264
+ for entry in hoglist:
265
+ indices, subhog_dict = self.hogid2fam(entry)
266
+ result_dict[entry] = [hashutils.fam2hash_hdf5(idx, self.hashes_h5, nsamples=self.nsamples) for idx in indices][0]
267
+ return result_dict
268
+
269
+
270
+ @staticmethod
271
+ def sort_hashes(query_hash, hashes):
272
+ """
273
+ Given a dict of hogs:hashes, returns a sorted array of hogs and jaccard distances relative to query hog.
274
+ :param query hash: weighted minhash of the query
275
+ :param hashes: a dict of hogs:hashes
276
+ :return: sortedhogs, jaccard
277
+ """
278
+ # sort the hashes by their jaccard relative to query hash
279
+ jaccard = [query_hash.jaccard(hashes[hog]) for hog in hashes]
280
+ index = np.argsort(jaccard)
281
+ sorted_hogs = np.asarray(list(hashes.keys()))[index]
282
+ jaccard = jaccard[index]
283
+ return sorted_hogs, jaccard
284
+
285
+
286
+ @staticmethod
287
+ def allvall_hashes(hashes):
288
+ """
289
+ Given a dict of hogs:hashes, returns generate an all v all jaccard distance matrix.
290
+ :param hashes: a dict of hogs:hashes
291
+ :return: hashmat
292
+ """
293
+ #generate an all v all jaccard distance matrix
294
+ hashmat = np.zeros((len(hashes),len(hashes)))
295
+ for i , hog1 in enumerate(hashes):
296
+ for j, hog2 in enumerate(hashes):
297
+ if i < j :
298
+ hashmat[i,j]= hashes[hog1].jaccard(hashes[hog2])
299
+ hashmat = hashmat+hashmat.T
300
+ np.fill_diagonal(hashmat, 1)
301
+ return hashmat
302
+
303
+ def hog_v_hog(self, hogs):
304
+ """
305
+ give two hogs returns jaccard distance.
306
+ :param hog1 , hog2: str hog id
307
+ :return: jaccard score
308
+ """
309
+ hog1,hog2 = hogs
310
+ #generate an all v all jaccard distance matrix
311
+ hashes = self.pull_hashes([hog1,hog2])
312
+ hashes = list(hashes.values())
313
+ return hashes[0].jaccard(hashes[1])
314
+
315
+
316
+ def run_search(index_dir: Path,
317
+ hog_id: str,
318
+ level: str = None,
319
+ k: int = 10,
320
+ oma=None,
321
+ nsamples=None,
322
+ slice_subhogs=False,
323
+ ):
324
+
325
+ index_dir = Path(index_dir)
326
+ p = Profiler(
327
+ lshforestpath=index_dir / "newlshforest.pkl",
328
+ hashes_h5=index_dir / "hashes.h5",
329
+ oma=oma,
330
+ nsamples=nsamples,
331
+ slicesubhogs=slice_subhogs,
332
+ )
333
+ return p.query_hog(hog_id, level=level, k=k).hits
hogprof/splitter.py ADDED
@@ -0,0 +1,53 @@
1
+ import sys
2
+ import os
3
+ import argparse
4
+ import logging
5
+
6
+ logging.basicConfig(
7
+ level=logging.INFO,
8
+ format="%(asctime)s - %(levelname)s - %(message)s",
9
+ )
10
+
11
+ def add_fastoma_path(fastoma_path):
12
+ """Add the FastOMA utils directory to Python's sys.path."""
13
+ utils_path = os.path.join(os.path.abspath(fastoma_path), "utils")
14
+
15
+ if not os.path.isdir(utils_path):
16
+ raise FileNotFoundError(
17
+ f"FastOMA utils directory not found: {utils_path}"
18
+ )
19
+
20
+ if utils_path not in sys.path:
21
+ sys.path.append(utils_path)
22
+
23
+ def main():
24
+ parser = argparse.ArgumentParser(description="Split OrthoXML file by species.")
25
+ parser.add_argument("--orthoxml_dir", required=True, help="Path to the directory containing FastOMA_HOGs.orthoxml")
26
+ parser.add_argument(
27
+ "--fastoma-path",
28
+ required=True,
29
+ help="Path to the FastOMA installation directory",
30
+ )
31
+ args = parser.parse_args()
32
+ orthoxml_dir = args.orthoxml_dir
33
+
34
+ # Add FastOMA utils to the Python path
35
+ add_fastoma_path(args.fastoma_path)
36
+
37
+ # Import after updating sys.path
38
+ from OrthoXMLSplitter import OrthoXMLSplitter
39
+
40
+ # Change to the directory so that ./splits is created there
41
+ os.chdir(orthoxml_dir)
42
+ print(f"Changed working directory to: {orthoxml_dir}")
43
+
44
+ # Define the expected filename
45
+ orthoxml_filename = "FastOMA_HOGs.orthoxml"
46
+ # Run the splitter
47
+ splitter = OrthoXMLSplitter(orthoxml_filename, cache_dir="./splits")
48
+ # Extract all HOGs (default behavior: one file per HOG, in ./splits folder)
49
+ splitter()
50
+ logging.info("OrthoXML splitting completed.")
51
+
52
+ if __name__ == "__main__":
53
+ main()
@@ -0,0 +1 @@
1
+ name = "utils"