FunVIP 0.3.20__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.
src/save.py ADDED
@@ -0,0 +1,181 @@
1
+ # For save and load functions
2
+ import os
3
+ import sys
4
+ import shutil
5
+ import re
6
+ import subprocess
7
+ import json
8
+ import pandas as pd
9
+ import numpy as np
10
+ import logging
11
+ from unidecode import unidecode
12
+ from Bio import SeqIO
13
+ from funvip.src.tool import (
14
+ initialize_path,
15
+ get_genus_species,
16
+ get_id,
17
+ manage_unicode,
18
+ )
19
+ from funvip.src.logics import isnewicklegal
20
+ from funvip.src.hasher import decode, newick_legal, hash_funinfo_list
21
+ import shelve
22
+
23
+
24
+ # Saving functions should be in main branch because of globals() function
25
+ # try move it to other place by sending globals() as variable
26
+ # In future, try selectively save to reduce datasize
27
+ # Session saving function
28
+ def save_session(opt, path, global_var: dict, var: dict) -> None:
29
+ managed_keys = ("V", "R", "opt", "path", "model_dict")
30
+
31
+ # if opt.save_run is True:
32
+ save = shelve.open(path.save, "n")
33
+
34
+ for key in global_var:
35
+ if key in managed_keys:
36
+ save[key] = global_var[key]
37
+ if opt.verbose >= 3:
38
+ logging.debug(f"Saved {key}")
39
+ else:
40
+ if opt.verbose >= 3:
41
+ logging.debug(f"Did not saved {key}")
42
+
43
+ logging.info(f"Saved current session")
44
+ save.close()
45
+
46
+ """
47
+ if 1: # ADD saving options in further developmental stage
48
+ save = shelve.open(path.save, "n")
49
+ for key in global_var:
50
+ try:
51
+ save[key] = global_var[key]
52
+ if opt.verbose >= 3:
53
+ logging.debug(f"Saved {key}")
54
+ except:
55
+ if opt.verbose >= 3:
56
+ logging.debug(f"Failed shelving: {key}")
57
+ logging.info(f"Saved current session")
58
+ save.close()
59
+ """
60
+
61
+
62
+ # Session loading function
63
+ def load_session(opt, savefile: str) -> None:
64
+ var = {}
65
+ save = shelve.open(savefile)
66
+ for key in save:
67
+ try:
68
+ var[key] = save[key]
69
+ if opt.verbose >= 3:
70
+ logging.debug(f"Loading {key}")
71
+ except:
72
+ if opt.verbose >= 3:
73
+ logging.debug(f"Failed Loading {key}")
74
+
75
+ logging.info(f"Loaded previous session")
76
+ save.close()
77
+
78
+ return var
79
+
80
+
81
+ def save_fasta(list_funinfo, gene, filename, by="id"):
82
+ list_funinfo = list(set(list_funinfo)) # remove ambiguous seqs
83
+
84
+ with open(f"{filename}", "w") as fp:
85
+ if gene == "unclassified": # for unclassified query
86
+ flag = 0
87
+ for info in list_funinfo:
88
+ for n, seq in enumerate(info.unclassified_seq):
89
+ if by == "hash":
90
+ fp.write(f">{info.hash}_{n}\n{seq}\n")
91
+ else:
92
+ fp.write(f">{info.id}_{n}\n{seq}\n")
93
+ flag = 1
94
+
95
+ else:
96
+ flag = 0
97
+ for info in list_funinfo:
98
+ if gene in info.seq:
99
+ if len(info.seq[gene]) > 0:
100
+ if by == "hash":
101
+ fp.write(f">{info.hash}\n{info.seq[gene]}\n")
102
+ else:
103
+ fp.write(f">{info.id}\n{info.seq[gene]}\n")
104
+ flag = 1
105
+
106
+ # returns 1 if meaningful sequence exists
107
+ return flag
108
+
109
+
110
+ def save_originalfasta(list_info, path, filename):
111
+ with open(f"{path}/{filename}", "w") as fp:
112
+ for info in list_info:
113
+ fp.write(f">{info.description}\n{info.seq}\n")
114
+
115
+
116
+ def save_fastabygroup(list_funinfo, path, option, add="Reference", outgroup=False):
117
+ outpath = path.data
118
+ set_group = set()
119
+
120
+ for group in set_group:
121
+ tmp_list = []
122
+ for funinfo in list_funinfo:
123
+ if funinfo.adjusted_group == group:
124
+ tmp_list.append(funinfo)
125
+
126
+ save_fasta(tmp_list, outpath, f"{add}_{group}.fasta")
127
+
128
+
129
+ # Save tree file to designated path, and decode it
130
+ def save_tree(out, hash_dict, hash_file_path, decoded_file_path, fix=False):
131
+ # print(out)
132
+ file = out.split("/")[-1]
133
+ shutil.move(out, hash_file_path)
134
+ decode(
135
+ hash_dict=hash_dict,
136
+ file=hash_file_path,
137
+ out=decoded_file_path,
138
+ )
139
+
140
+ # In fasttree result, 0 supports are not shown. so fix it
141
+ if fix is True:
142
+ with open(hash_file_path, "r") as fr:
143
+ newick = fr.read()
144
+
145
+ with open(hash_file_path, "w") as fw:
146
+ fw.write(newick.replace("):", ")0.0:"))
147
+
148
+ with open(decoded_file_path, "r") as fr:
149
+ newick = fr.read()
150
+
151
+ with open(decoded_file_path, "w") as fw:
152
+ fw.write(newick.replace("):", ")0.0:"))
153
+
154
+
155
+ def save_mergedfasta(fasta_list, out_path):
156
+ out_fasta_list = []
157
+ for fasta in fasta_list:
158
+ out_fasta_list += list(SeqIO.parse(fasta, "fasta"))
159
+
160
+ SeqIO.write(out_fasta_list, out_path, "fasta")
161
+
162
+
163
+ # save dataframe
164
+ def save_df(df, out, fmt="csv"):
165
+ if fmt == "csv" or fmt == "tsv":
166
+ df.to_csv(out, index=False)
167
+ # For excel size limit
168
+ elif fmt == "xlsx" or fmt == "excel":
169
+ # Limit is 1048576 rows with 16384 column, exlcuding one for column
170
+ if len(df) <= 1048575 and df.shape[1] <= 16383:
171
+ df.to_excel(out, index=False)
172
+ else:
173
+ logging.warning(f"Dataframe size exceeds excel limit. Using csv instead")
174
+ df.to_csv(out, index=False)
175
+ elif fmt == "parquet":
176
+ df.to_parquet(out, index=False)
177
+ elif fmt == "feather" or fmt == "ftr":
178
+ df.to_feather(out, index=False)
179
+ else:
180
+ logging.warning(f"Matrix format not valid. Using csv as default")
181
+ df.to_csv(out, index=False)
src/search.py ADDED
@@ -0,0 +1,440 @@
1
+ from funvip.src import cluster, tool, hasher, validate_input, save
2
+ from funvip.src.ext import blast, makeblastdb, mmseqs, makemmseqsdb
3
+ from funvip.src.save import save_df
4
+ import copy
5
+ import pandas as pd
6
+ import numpy as np
7
+ import os
8
+ import logging
9
+ import subprocess
10
+ import shutil
11
+ import hashlib
12
+
13
+
14
+ def cleanblastdb(db):
15
+ os.remove(f"{db}.nsq")
16
+ os.remove(f"{db}.nin")
17
+ os.remove(f"{db}.nhr")
18
+
19
+
20
+ def cleanmmseqsdb(db):
21
+ os.remove(f"{db}.dbtype")
22
+ os.remove(f"{db}_h.dbtype")
23
+ os.remove(f"{db}.index")
24
+ os.remove(f"{db}_h.index")
25
+ os.remove(f"{db}.lookup")
26
+ os.remove(f"{db}.source")
27
+ os.remove(f"{db}")
28
+ os.remove(f"{db}_h")
29
+
30
+
31
+ # Make blast db file or mmseqs db file
32
+ def create_search_db(opt, db_fasta, db, path) -> None:
33
+ # make blastdb
34
+ if opt.method.search.lower() in ("blast", "blastn"):
35
+ makeblastdb(fasta=db_fasta, db=db, path=path)
36
+ # make mmseqssdb
37
+ elif opt.method.search.lower() in (
38
+ "mmseq",
39
+ "mmseqss",
40
+ "mmseqs",
41
+ "mmseq2",
42
+ "mmseqs2",
43
+ "mmseqss2",
44
+ ):
45
+ makemmseqsdb(fasta=db_fasta, db=db, path=path)
46
+ else:
47
+ logging.error("DEVELOPMENTAL ERROR on building search DB!")
48
+ raise Exception
49
+
50
+
51
+ # Merge fragmented search matches from given blast or mmseqss results
52
+ def merge_fragments(df) -> pd.DataFrame():
53
+ # Check if list (or pandas series or one-column DataFrame) has only one value
54
+ def get_unique(series) -> str:
55
+ if len(set(series)) == 1:
56
+ return list(series)[0]
57
+ elif len(set(series)) == 0:
58
+ logging.error(f"Found 0 values in {series} while merging search results")
59
+ raise Exception
60
+ else:
61
+ logging.error(
62
+ f"Found {len(set(series))} values in {series} while merging search results"
63
+ )
64
+ raise Exception
65
+
66
+ # Calculate overall percent identity of fragments
67
+ def calculate_pident(df):
68
+ pident = df["pident"]
69
+ length = df["length"]
70
+ if len(pident) != len(length):
71
+ logging.error(
72
+ f"During merge blast fragments, found pident {pident} and len {length} are different"
73
+ )
74
+ raise Exception
75
+ elif len(pident) == 0:
76
+ logging.error(f"No percent identitiy {pident} found")
77
+ raise Exception
78
+ else:
79
+ # Calculate overall percent identity
80
+ return np.sum(np.array(pident) * np.array(length)) / np.sum(length)
81
+
82
+ # Return empty df because if causes error
83
+ if len(df.index) == 0: # faster way for df.empty
84
+ return df
85
+
86
+ # pident needs access to other columns, will be calculated in next line
87
+ # qseqid and sseqid will return itself, after checking if they are unique
88
+ # mismatch, gaps, and bitscore were calculated as sum of fragments
89
+ # evalues were calculated by multiplying them, because they are probability
90
+
91
+ df = df.groupby(["qseqid", "sseqid"], dropna=True, as_index=False).aggregate(
92
+ {
93
+ "qseqid": lambda x: set(x),
94
+ "sseqid": lambda x: set(x),
95
+ "pident": lambda x: tuple(x),
96
+ "length": lambda x: tuple(x),
97
+ "mismatch": np.sum,
98
+ "gaps": np.sum,
99
+ "qstart": lambda x: tuple(x),
100
+ "qend": lambda x: tuple(x),
101
+ "sstart": lambda x: tuple(x),
102
+ "send": lambda x: tuple(x),
103
+ "evalue": np.prod,
104
+ "bitscore": np.sum,
105
+ }
106
+ )
107
+
108
+ # Calculate pident
109
+ df["pident"] = df.apply(calculate_pident, axis=1)
110
+
111
+ # Merge length
112
+ df["length"] = df["length"].apply(sum).astype(int)
113
+
114
+ # Update qseqid and sseqid
115
+ df["qseqid"] = df["qseqid"].apply(lambda x: tuple(x)[0])
116
+ df["sseqid"] = df["sseqid"].apply(lambda x: tuple(x)[0])
117
+
118
+ return df
119
+
120
+
121
+ # Core search : Running blast or mmseqss
122
+ def search(query_fasta, db_fasta, path, opt) -> pd.DataFrame():
123
+ # Working on saved database
124
+ # find key for which db to use
125
+ # get hash number of the given db to compare
126
+ _hash = hashlib.md5(open(db_fasta, "rb").read()).hexdigest()
127
+
128
+ if opt.cachedb is True or opt.usecache is True:
129
+ if _hash is None:
130
+ logging.error(f"Database file {db_fasta} missing")
131
+ raise Exception
132
+
133
+ # Try to parse DB
134
+ if opt.usecache is True:
135
+ # When succesfully parsed DB
136
+ if (
137
+ os.path.isdir(f"{path.in_db}/{opt.method.search.lower()}/{_hash}")
138
+ is True
139
+ ):
140
+ logging.info("[INFO] Found existing database! Skipping database build")
141
+ db = f"{path.in_db}/{opt.method.search.lower()}/{_hash}/{_hash}"
142
+
143
+ # When parsing existing DB failed
144
+ else:
145
+ logging.info("No existing database found")
146
+
147
+ # Try save DB
148
+ if opt.cachedb is True:
149
+ logging.info(
150
+ f"--cachedb selected, {opt.method.search.lower()} database will be saved"
151
+ )
152
+
153
+ # Create DB saving directory
154
+ os.mkdir(f"{path.in_db}/{opt.method.search.lower()}/{_hash}")
155
+ db = f"{path.in_db}/{opt.method.search.lower()}/{_hash}/{_hash}"
156
+
157
+ # DB saving starts
158
+ logging.info("The database is in first run, caching database")
159
+
160
+ # Create search database
161
+ create_search_db(opt, db_fasta, db, path)
162
+
163
+ # Passing Save DB
164
+ else:
165
+ logging.info(
166
+ "--cachedb not selected, saving database will be passed"
167
+ )
168
+ os.mkdir(f"{path.tmp}/{opt.runname}/{_hash}")
169
+ db = f"{path.tmp}/{opt.runname}/{_hash}/{_hash}"
170
+
171
+ # Create search database
172
+ create_search_db(opt, db_fasta, db, path)
173
+
174
+ else:
175
+ os.mkdir(f"{path.tmp}/{opt.runname}/{_hash}")
176
+ db = f"{path.tmp}/{opt.runname}/{_hash}/{_hash}"
177
+ # Create search database
178
+ create_search_db(opt, db_fasta, db, path)
179
+
180
+ # run searching
181
+ # blast
182
+ if opt.method.search.lower() in ("blast", "blastn"):
183
+ blast_result = blast(
184
+ query=query_fasta,
185
+ db=db,
186
+ out=f"{path.tmp}/{opt.runname}.m8",
187
+ path=path,
188
+ opt=opt,
189
+ )
190
+ # remove db when saving not enabled
191
+ # Temporarily disabled to remove bug
192
+ # if opt.cachedb is False:
193
+ # cleanblastdb(db)
194
+
195
+ # mmseqs
196
+ elif opt.method.search.lower() in ("mmseqs", "mmseq", "mmseq2", "mmseqs2"):
197
+ mmseqs(
198
+ query=query_fasta,
199
+ db=db,
200
+ out=f"{path.tmp}/{opt.runname}.m8",
201
+ tmp=path.tmp,
202
+ path=path,
203
+ opt=opt,
204
+ )
205
+ # remove db when saving not enabled
206
+ # Temporarily disabled to remove bug
207
+ # if opt.cachedb is False:
208
+ # cleanmmseqsdb(db)
209
+ # remove temporary file
210
+ # shutil.rmtree(f"{path.tmp}/{opt.runname}")
211
+ else:
212
+ logging.error("DEVELOPMENTAL ERROR on searching!")
213
+ raise Exception
214
+
215
+ # Parse out
216
+ df = pd.read_csv(
217
+ f"{path.tmp}/{opt.runname}.m8",
218
+ sep="\t",
219
+ header=None,
220
+ names=[
221
+ "qseqid",
222
+ "sseqid",
223
+ "pident",
224
+ "length",
225
+ "mismatch",
226
+ "gaps",
227
+ "qstart",
228
+ "qend",
229
+ "sstart",
230
+ "send",
231
+ "evalue",
232
+ "bitscore",
233
+ ],
234
+ )
235
+
236
+ # Remove temporary files
237
+ os.remove(f"{path.tmp}/{opt.runname}.m8")
238
+
239
+ # merge fragmented matches
240
+ df = merge_fragments(df)
241
+
242
+ return df
243
+
244
+
245
+ ### Main search: Get blast or mmseqs dataframe results from given dataset
246
+ def search_df(V, path, opt):
247
+ # Initialize variable for search result
248
+ dict_search = {}
249
+
250
+ # Ready for by sseqid hash, which group to append
251
+ # generating group dict to assign group column next to the output dataframe
252
+ group_dict = {}
253
+ for FI in V.list_FI:
254
+ group_dict[FI.hash] = FI.group
255
+
256
+ # genes available for db and query
257
+ V.list_db_gene = copy.copy(opt.gene)
258
+ V.list_qr_gene = copy.copy(opt.gene)
259
+
260
+ # make blastdb for this run
261
+ list_db_FI = tool.select(V.list_FI, datatype="db")
262
+
263
+ # Make database by genes
264
+ for gene in opt.gene:
265
+ db_state = save.save_fasta(
266
+ list_funinfo=list_db_FI,
267
+ gene=gene,
268
+ filename=f"{path.tmp}/{opt.runname}_DB_{gene}.fasta",
269
+ by="hash",
270
+ )
271
+
272
+ if db_state == 0:
273
+ logging.warning(
274
+ f"No data for {gene} found in database. Excluding from analysis"
275
+ )
276
+ V.list_db_gene.remove(gene)
277
+
278
+ if len(V.list_db_gene) == 0:
279
+ logging.error(
280
+ f"None of the gene seems to be valid in analysis. Please check your --gene flag"
281
+ )
282
+ raise Exception
283
+
284
+ # get query fasta from funinfo_list
285
+ list_qr_FI = tool.select(V.list_FI, datatype="query")
286
+
287
+ # hash_dict format - hash : id
288
+ V.dict_id_hash = hasher.encode(V.list_FI)
289
+
290
+ # if no query exists and only database sequence exists
291
+ if len(list_qr_FI) == 0:
292
+ logging.warning("No query selected. Changing to database validation mode")
293
+ # db by db search analysis
294
+ for gene in V.list_db_gene:
295
+ df_search = search(
296
+ query_fasta=f"{path.tmp}/{opt.runname}_DB_{gene}.fasta",
297
+ db_fasta=f"{path.tmp}/{opt.runname}_DB_{gene}.fasta",
298
+ path=path,
299
+ opt=opt,
300
+ )
301
+
302
+ # Cutoff by outgroupcutoff
303
+ df_search = df_search[df_search["bitscore"] > opt.cluster.outgroupoffset]
304
+
305
+ # append group column
306
+ df_search["subject_group"] = df_search["sseqid"].apply(
307
+ lambda x: group_dict.get(x)
308
+ )
309
+ # add to search dict
310
+ # dict_search[gene] = df_search
311
+ V.dict_gene_SR[gene] = df_search
312
+
313
+ # Save dataframe
314
+ if opt.nosearchresult is False:
315
+ save_df(
316
+ hasher.decode_df(V.dict_id_hash, df_search),
317
+ f"{path.out_matrix}/{opt.runname}_BLAST_result_{gene}.{opt.tableformat}",
318
+ fmt=opt.tableformat,
319
+ )
320
+
321
+ # if query sequence exists
322
+ else:
323
+ query_state = save.save_fasta(
324
+ list_qr_FI,
325
+ "unclassified",
326
+ f"{path.tmp}/{opt.runname}_Query_unclassified.fasta",
327
+ by="hash",
328
+ )
329
+
330
+ # first, run for unclassified query for assign gene
331
+ dict_unclassified = {}
332
+
333
+ if query_state == 1: # if unclassified sequence exists
334
+ for gene in V.list_db_gene:
335
+ df_search = search(
336
+ query_fasta=f"{path.tmp}/{opt.runname}_Query_unclassified.fasta",
337
+ db_fasta=f"{path.tmp}/{opt.runname}_DB_{gene}.fasta",
338
+ path=path,
339
+ opt=opt,
340
+ )
341
+
342
+ # Cutoff by outgroupcutoff
343
+ print(vars(opt))
344
+ print(opt.cluster.outgroupoffset)
345
+ df_search = df_search[
346
+ df_search["bitscore"] > opt.cluster.outgroupoffset
347
+ ]
348
+
349
+ logging.debug(f"{df_search}")
350
+
351
+ if len(df_search) == 0:
352
+ del df_search
353
+ logging.debug(
354
+ f"Deleted df because non of the result exceeds outgroupoffset!"
355
+ )
356
+
357
+ # if dataframe exists
358
+ try:
359
+ if isinstance(df_search, pd.DataFrame):
360
+ if not df_search.empty:
361
+ dict_unclassified[gene] = df_search
362
+ except:
363
+ pass
364
+
365
+ # assign gene by search result to unassigned sequences
366
+ V = cluster.assign_gene(dict_unclassified, V)
367
+
368
+ # then, gene by gene BLAST for outgroup
369
+ for gene in opt.gene:
370
+ # if database is very confident, so no more validation is required
371
+ if opt.confident is True:
372
+ query_state = save.save_fasta(
373
+ [FI for FI in V.list_FI if FI.datatype == "query"],
374
+ gene,
375
+ f"{path.tmp}/{opt.runname}_Query_{gene}.fasta",
376
+ by="hash",
377
+ )
378
+
379
+ # for most of the cases
380
+ else:
381
+ query_state = save.save_fasta(
382
+ V.list_FI,
383
+ gene,
384
+ f"{path.tmp}/{opt.runname}_Query_{gene}.fasta",
385
+ by="hash",
386
+ )
387
+
388
+ # if no sequence exists for gene, remove it
389
+ if query_state == 0:
390
+ V.list_qr_gene.remove(gene)
391
+ else:
392
+ # If db column and query column does not matches -> should be moved to validate_input
393
+ if not (gene in V.list_db_gene):
394
+ logging.error(
395
+ f"Gene {gene} found in query, but not found in database. Please add {gene} column to database"
396
+ )
397
+ raise Exception
398
+
399
+ # BLAST or mmseqs search
400
+ # This part should be changed by using former search result for faster performance
401
+ # No because changing database can result different bitscore, so each gene blast must be re-analyzed
402
+ for gene in V.list_qr_gene:
403
+ df_search = search(
404
+ query_fasta=f"{path.tmp}/{opt.runname}_Query_{gene}.fasta",
405
+ db_fasta=f"{path.tmp}/{opt.runname}_DB_{gene}.fasta",
406
+ path=path,
407
+ opt=opt,
408
+ )
409
+
410
+ # append group column
411
+ df_search["subject_group"] = df_search["sseqid"].apply(
412
+ lambda x: group_dict.get(x)
413
+ )
414
+ V.dict_gene_SR[gene] = df_search
415
+
416
+ # Save dataframe
417
+ if opt.nosearchresult is False:
418
+ save_df(
419
+ hasher.decode_df(V.dict_id_hash, df_search),
420
+ f"{path.out_matrix}/{opt.runname}_BLAST_result_{gene}.{opt.tableformat}",
421
+ opt.tableformat,
422
+ )
423
+
424
+ # remove tmp file after search
425
+ os.remove(f"{path.tmp}/{opt.runname}_Query_unclassified.fasta")
426
+
427
+ # remove temporary files
428
+ """
429
+ for gene in opt.gene:
430
+ try:
431
+ os.remove(f"{path.tmp}/{opt.runname}_Query_{gene}.fasta")
432
+ except:
433
+ pass
434
+ try:
435
+ os.remove(f"{path.tmp}/{opt.runname}_DB_{gene}.fasta")
436
+ except:
437
+ pass
438
+ """
439
+
440
+ return V