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/cluster.py ADDED
@@ -0,0 +1,510 @@
1
+ import multiprocessing as mp
2
+ import pandas as pd
3
+ import numpy as np
4
+ import shutil
5
+ import os, sys, subprocess
6
+ from copy import deepcopy
7
+ from functools import lru_cache
8
+ from time import sleep
9
+
10
+ # from Bio.Blast import NCBIXML
11
+ from Bio import SeqIO
12
+
13
+ import logging
14
+ import gc
15
+ from funvip.src.ext import mmseqs
16
+
17
+
18
+ # return list of original group of given FI
19
+ def get_naive_group(V):
20
+ return list(set([FI.group for FI in V.list_FI if type(FI.group) == str]))
21
+
22
+
23
+ # Append query_group information column to concatenated search result
24
+ def append_query_group(V):
25
+ # For concatenated gene matrix
26
+ group_dict = {}
27
+ for FI in V.list_FI:
28
+ group_dict[FI.hash] = FI.adjusted_group
29
+
30
+ V.cSR["query_group"] = V.cSR["qseqid"].apply(lambda x: group_dict.get(x))
31
+
32
+ # Indicate queries without any corresponding group
33
+ for FI in V.list_FI:
34
+ if FI.adjusted_group == "" and not ("noseq" in FI.issues):
35
+ FI.issues.add("nodb")
36
+
37
+ return V
38
+
39
+
40
+ # assign gene to unclassified gene by search result
41
+ def assign_gene(result_dict, V, cutoff=0.99):
42
+ # if no query exists to assign, return dataset without operations
43
+ if len(result_dict.keys()) == 0:
44
+ return V
45
+
46
+ for result in result_dict:
47
+ # add gene column to result_dict
48
+ result_dict[result]["gene"] = result
49
+
50
+ # combine all result in single dataframe
51
+ gene_result_all = pd.concat([result_dict[result] for result in result_dict], axis=0)
52
+
53
+ # split by query
54
+ gene_result_grouped = gene_result_all.groupby(gene_result_all["qseqid"])
55
+
56
+ for FI in V.list_FI:
57
+ # for seq of FI
58
+ for n, seq in enumerate(FI.unclassified_seq):
59
+ try:
60
+ # get each of the dataframe for each FI
61
+ current_df = gene_result_grouped.get_group(f"{FI.hash}_{n}")
62
+ # sort by bitscore
63
+ # sorting has peformed after split for better performance
64
+ current_df.sort_values(by=["bitscore"], inplace=True, ascending=False)
65
+ # reset index to easily get maximum
66
+ current_df.reset_index(inplace=True, drop=True)
67
+ # get result stasifies over cutoff
68
+ cutoff_df = current_df[
69
+ current_df["bitscore"] > current_df["bitscore"][0] * cutoff
70
+ ]
71
+ gene_count = len(set(cutoff_df["gene"]))
72
+ gene_list = list(set(cutoff_df["gene"]))
73
+
74
+ except:
75
+ gene_count = 0
76
+ gene_list = []
77
+
78
+ # if only 1 gene available, take it
79
+ if gene_count == 1:
80
+ FI.update_seq(gene_list[0], seq)
81
+ logging.info(f" Query seq in {FI.id} has assigned to {gene_list[0]}.")
82
+ # if no gene matched, warn it
83
+ elif gene_count == 0:
84
+ logging.warning(
85
+ f" Query seq in {FI.id} cannot be assigned to any gene. Check sequence. Skipping {FI.id}"
86
+ )
87
+ elif gene_count >= 2:
88
+ logging.warning(f" Query seq in {FI.id} has multiple matches to gene.")
89
+ FI.update_seq(gene_list[0], seq)
90
+
91
+ else:
92
+ logging.error("DEVELOPMENTAL ERROR IN GENE ASSIGN")
93
+ raise Exception
94
+ return V
95
+
96
+
97
+ # cluster each of FI object and assign group
98
+ def cluster(FI, V, path, opt):
99
+ list_group = deepcopy(V.list_group)
100
+
101
+ # Reduce search df space by selecting only FI related ones
102
+ df_group = V.cSR.groupby(V.cSR["qseqid"])
103
+ list_id = list(set(V.cSR["qseqid"]))
104
+ df_search = df_group.get_group(FI.hash)
105
+
106
+ # delete V to reduce memory assumption
107
+ del V
108
+ gc.collect()
109
+
110
+ # If no evidence available, return it
111
+ if df_search is None:
112
+ # if confident is True, no adjusted_group for db is normal situation
113
+ if opt.confident is False and FI.datatype is "db":
114
+ logging.warning(f"No adjusted_group assigned to {FI}")
115
+ FI.adjusted_group = FI.group
116
+ return FI, None
117
+
118
+ # for db sequence with group, retain it
119
+ elif not (FI.group == "") and FI.datatype == "db": # or type(FI.group) != str):
120
+ FI.adjusted_group = FI.group
121
+ return FI, FI.adjusted_group
122
+
123
+ # update group if sequence does not have group
124
+ else:
125
+ # sorting has peformed after split for better performance
126
+ sorted_df_search = df_search.sort_values(by=["bitscore"], ascending=False)
127
+ # reset index to easily get maximum
128
+ sorted_df_search.reset_index(inplace=True, drop=True)
129
+ # get result stasifies over cutoff
130
+ cutoff_df = sorted_df_search[
131
+ sorted_df_search["bitscore"]
132
+ > sorted_df_search["bitscore"][0] * opt.cluster.cutoff
133
+ ]
134
+
135
+ # Garbage collection
136
+ del df_search
137
+ del sorted_df_search
138
+ gc.collect()
139
+
140
+ group_count = len(set(cutoff_df["subject_group"]))
141
+ list_group = list(set(cutoff_df["subject_group"]))
142
+
143
+ # if first time of group update
144
+ if FI.adjusted_group == "" or FI.adjusted_group == "":
145
+ # if only 1 group available, take it
146
+ if group_count == 1:
147
+ FI.adjusted_group = list_group[0]
148
+ # if no group matched, warn it
149
+ elif group_count == 0:
150
+ logging.warning(
151
+ f"Query seq in {FI.id} cannot be assigned to group. Check sequence"
152
+ )
153
+ elif group_count >= 2:
154
+ logging.warning(
155
+ f"Query seq in {FI.id} has multiple matches to group, {list_group}."
156
+ )
157
+ FI.adjusted_group = list_group[0]
158
+ else:
159
+ logging.error("DEVELOPMENTAL ERROR IN GROUP ASSIGN")
160
+ raise Exception
161
+
162
+ logging.info(f"{FI.id} has clustered to {FI.adjusted_group}")
163
+
164
+ # if group already updated
165
+ else:
166
+ if not (FI.adjusted_group == ""):
167
+ if not (FI.adjusted_group in list_group):
168
+ logging.warning(f"Clustering result colliding in {FI.id}")
169
+
170
+ if len(list_group) > 0:
171
+ return FI, list_group[0]
172
+ else:
173
+ return FI, None
174
+
175
+
176
+ ### Append outgroup to given group-gene dataset by search matrix
177
+ def append_outgroup(V, df_search, gene, group, path, opt):
178
+ logging.info(f"Appending outgroup on group: {group}, Gene: {gene}")
179
+ list_FI = deepcopy(V.list_FI)
180
+
181
+ # In multiprocessing, delete V to reduce memory consumption
182
+ del V
183
+ gc.collect()
184
+
185
+ # ready for by sseqid hash, which group to append
186
+ # this time, append adjusted group
187
+ group_dict = {}
188
+ FI_dict = {}
189
+
190
+ for FI in list_FI:
191
+ group_dict[FI.hash] = FI.adjusted_group
192
+ FI_dict[FI.hash] = FI
193
+
194
+ # For non-concatenated analysis
195
+ # if gene != "concatenated":
196
+ # generate minimal bitscore cutoff that does not overlaps to query-query bitscore value range
197
+
198
+ ## For getting inner group
199
+ cutoff_set_df = df_search[df_search["subject_group"] == group]
200
+ try:
201
+ # offset will prevent selecting outgroup too close to ingroup, which may confuse the monophyly of outgroup
202
+ bitscore_cutoff = max(
203
+ 1, min(cutoff_set_df["bitscore"]) - opt.cluster.outgroupoffset
204
+ )
205
+ except:
206
+ bitscore_cutoff = 999999 # use infinite if failed
207
+
208
+ # print(f"Ingroup cutoff {bitscore_cutoff} selected for group {group} gene {gene}")
209
+
210
+ ## get result stasifies over cutoff
211
+ # outgroup should be outside of ingroup
212
+ cutoff_df = df_search[df_search["bitscore"] < bitscore_cutoff]
213
+
214
+ # Remove malformat result, which bitscore is under 0
215
+ cutoff_df = cutoff_df[cutoff_df["bitscore"] > 0]
216
+
217
+ # split that same group to include all to alignment, and leave other groups for outgroup selection
218
+ cutoff_df = cutoff_df[cutoff_df["subject_group"] != group]
219
+
220
+ ## For ambiugous database, mostly because of contaminated database
221
+ # For each of the input, should use different cutoff
222
+ ambiguous_db = set()
223
+ for qseqid, _df in cutoff_set_df.groupby(["qseqid"]):
224
+ # Select dataframe corresponding to current qseqid
225
+ df_qseqid = df_search[df_search["qseqid"] == qseqid]
226
+ """
227
+ print(
228
+ f"Ambiguous ingroup cutoff selected for query {qseqid} group {group} gene {gene} cutoff {min(list(_df['bitscore']))}"
229
+ )
230
+ """
231
+ # Get the list of subjects, which is closer than furtest ingroup
232
+ ambiguous_df = df_qseqid[df_qseqid["bitscore"] >= min(list(_df["bitscore"]))]
233
+ # Within the furthest match, get possible ingroups with ambiguous group
234
+ ambiguous_df = ambiguous_df[ambiguous_df["subject_group"] != group]
235
+ # Add inner ambiugities to ambiguous db
236
+ ambiguous_db.update([FI_dict[i] for i in list(ambiguous_df["sseqid"])])
237
+
238
+ ambiguous_db = list(ambiguous_db)
239
+
240
+ # If no or fewer than designated number of outgroup matches to condition, use flexible criteria
241
+ if cutoff_df.groupby(["subject_group"]).count().empty:
242
+ logging.warning(
243
+ f"Not enough outgroup sequences matched for group {group} | gene {gene}. There might be outlier sequence that does not matches to group. Trying flexible cutoff"
244
+ )
245
+ cutoff_df = df_search[df_search["bitscore"] > 0]
246
+ cutoff_df = cutoff_df[cutoff_df["subject_group"] != group]
247
+
248
+ elif cutoff_df.groupby(["subject_group"]).count()["sseqid"].max() < opt.maxoutgroup:
249
+ logging.warning(
250
+ f"Not enough outgroup sequences matched for group {group} | gene {gene}. There might be outlier sequence that does not matches to group. Trying flexible cutoff"
251
+ )
252
+ cutoff_df = df_search[df_search["bitscore"] > 0]
253
+ cutoff_df = cutoff_df[cutoff_df["subject_group"] != group]
254
+
255
+ # Garbage collection to reduce memory consumption in this process
256
+ del df_search
257
+ gc.collect()
258
+
259
+ # sort by bitscore
260
+ cutoff_df.sort_values(by=["bitscore"], inplace=True, ascending=False)
261
+ # reset index to easily get maximum
262
+ cutoff_df.reset_index(inplace=True, drop=True)
263
+
264
+ # iterate until designated number of sequences from the most closest group selected
265
+ # we should get outgroup from columns
266
+ # outgroup_dict = {group1 : [FI1, FI2, ...]}
267
+ outgroup_dict = {}
268
+ max_cnt = 0
269
+ max_group = ""
270
+
271
+ for n, subject_group in enumerate(cutoff_df["subject_group"]):
272
+ ## Check if outgroup sequence that we're going to use actually exists
273
+ # If gene is not "concatenated", the outgroup sequence should actually exists
274
+ # keep in mind if case of certain gene is blank
275
+
276
+ cond1 = gene in FI_dict[cutoff_df["sseqid"][n]].seq
277
+ if cond1 is True:
278
+ cond1 = FI_dict[cutoff_df["sseqid"][n]].seq[gene] != ""
279
+
280
+ # If gene is "concatenated", any of the genes should exists
281
+ cond2 = (
282
+ gene == "concatenated"
283
+ and len(FI_dict[cutoff_df["sseqid"][n]].seq.keys()) > 0
284
+ )
285
+ if cond1 or cond2:
286
+ # if first sequence belonging to the group found, make new key to dict
287
+ if not (subject_group) in outgroup_dict:
288
+ outgroup_dict[subject_group] = [FI_dict[cutoff_df["sseqid"][n]]]
289
+ # if group key already exists in dict, append it
290
+ else:
291
+ if (
292
+ not (FI_dict[cutoff_df["sseqid"][n]])
293
+ in outgroup_dict[subject_group]
294
+ ):
295
+ outgroup_dict[subject_group].append(FI_dict[cutoff_df["sseqid"][n]])
296
+
297
+ # if enough outgroup sequences found while running
298
+ if len(outgroup_dict[subject_group]) >= opt.maxoutgroup:
299
+ text_outgroup_list = "\n ".join(
300
+ [FI.id for FI in outgroup_dict[subject_group]]
301
+ )
302
+ logging.info(
303
+ f"Outgroup [{subject_group}] selected to [{group}]\n {text_outgroup_list}"
304
+ )
305
+
306
+ return (
307
+ group,
308
+ gene,
309
+ outgroup_dict[subject_group],
310
+ ambiguous_db,
311
+ )
312
+ else:
313
+ if len(outgroup_dict[subject_group]) > max_cnt:
314
+ max_cnt = len(outgroup_dict[subject_group])
315
+ max_group = subject_group
316
+
317
+ # If not enough outgroup sequences found while running
318
+ logging.warning(
319
+ f"Not enough sequences are available for outgroup number {opt.maxoutgroup} in {group}, using '{max_group}' despite of lower number"
320
+ )
321
+
322
+ # If outgroup are selected
323
+ if not (max_group) == "":
324
+ logging.info(
325
+ f"Final outgroup selection for group {group} : {outgroup_dict[max_group]}"
326
+ )
327
+
328
+ return (group, gene, outgroup_dict[max_group], ambiguous_db)
329
+
330
+ # If outgroup cannot be selected
331
+ else:
332
+ logging.warning(
333
+ f"No outgroup sequence available for {group}. Try to use\n A. Higher --cluster-evalue\n B. Lower --outgroupoffset\n C. Add closer sequence of {group} to database"
334
+ )
335
+ return (group, gene, [], [])
336
+
337
+
338
+ def group_cluster_opt_generator(V, opt, path):
339
+ # cluster(FO, df_search, V, path, opt)
340
+ if len(V.list_qr_gene) == 0:
341
+ logging.error(
342
+ "In group_cluster_option_generator, no available query genes were selected"
343
+ )
344
+ raise Exception
345
+
346
+ # For concatenated analysis
347
+ else:
348
+ # cluster group by concatenated search result
349
+ list_id = list(set(V.cSR["qseqid"]))
350
+ for FI in V.list_FI:
351
+ if FI.hash in list_id:
352
+ V.opt_cluster.append((FI, V, path, opt))
353
+
354
+ return V
355
+
356
+
357
+ # opts ready for multithreading in outgroup append
358
+ def outgroup_append_opt_generator(V, path, opt):
359
+ opt_append_outgroup = []
360
+
361
+ #### Pararellize this part
362
+ # Assign different outgroup for each dataset
363
+
364
+ # if concatenated analysis is true
365
+ # concatenated
366
+ for group in V.dict_dataset:
367
+ if "concatenated" in V.dict_dataset[group]:
368
+ try:
369
+ df = V.cSR
370
+ df_group = df.groupby(df["query_group"])
371
+ df_group_ = df_group.get_group(group)
372
+ # Generating outgroup opt for multiprocessing
373
+ for gene in V.dict_dataset[group]:
374
+ opt_append_outgroup.append((V, df_group_, gene, group, path, opt))
375
+
376
+ except:
377
+ logging.warning(
378
+ f"{group} / concatenated dataset exists, but cannot append outgroup due to no corresponding search result"
379
+ )
380
+
381
+ return opt_append_outgroup
382
+
383
+
384
+ ## Main cluster pipe
385
+ def pipe_cluster(V, opt, path):
386
+ # If clustering enabled
387
+ if opt.method.search in ("blast", "mmseqs"):
388
+ logging.info("group clustering")
389
+
390
+ # cluster opt generation for multiprocessing
391
+ # (FI, V, path, opt)
392
+ V = group_cluster_opt_generator(V, opt, path)
393
+
394
+ # run multiprocessing start
395
+ if opt.verbose < 3:
396
+ p = mp.Pool(opt.thread)
397
+ V.rslt_cluster = p.starmap(cluster, V.opt_cluster)
398
+ p.close()
399
+ p.join()
400
+ else:
401
+ # non-multithreading mode for debugging
402
+ V.rslt_cluster = [cluster(*o) for o in V.opt_cluster]
403
+ # gather cluster result
404
+ for cluster_result in V.rslt_cluster:
405
+ FI = cluster_result[0]
406
+ logging.debug((FI.id, FI.datatype, FI.group, FI.adjusted_group))
407
+
408
+ # replace group assigning result
409
+ # collect FI from cluster result
410
+ replace_FI = [r[0] for r in V.rslt_cluster]
411
+ # collect hash
412
+ replace_hash_FI = [FI.hash for FI in replace_FI]
413
+ # maintain not clustered result and append clustered result
414
+ V.list_FI = [
415
+ FI for FI in V.list_FI if not (FI.hash in replace_hash_FI)
416
+ ] + replace_FI
417
+ # For syncyhronizing FI in dict_hash_FI to prevent error
418
+ for FI in replace_FI:
419
+ V.dict_hash_FI[FI.hash] = FI
420
+
421
+ V.list_group = list(set([r[1] for r in V.rslt_cluster if (not (r[1] is None))]))
422
+
423
+ if opt.queryonly is True:
424
+ for FI in V.list_FI:
425
+ if FI.datatype == "db":
426
+ FI.adjusted_group = FI.group
427
+
428
+ # Update dict_hash_FI
429
+ for FI in V.list_FI:
430
+ V.dict_hash_FI[FI.hash] = FI
431
+
432
+ for FI in V.list_FI:
433
+ logging.debug((FI.id, FI.datatype, FI.group, FI.adjusted_group))
434
+
435
+ # If not, try to use original groups in tabled format
436
+ else:
437
+ logging.info(
438
+ "[INFO] No searching method designated. Trying to use designated group"
439
+ )
440
+ group_list = get_naive_group(V)
441
+ ## [WIP] Need to make validation process and warn if the input does not have
442
+ for FI in V.list_FI:
443
+ FI.adjusted_group = FI.group
444
+
445
+ # Update dict_hash_FI
446
+ for FI in V.list_FI:
447
+ V.dict_hash_FI[FI.hash] = FI
448
+
449
+ return V, opt, path
450
+
451
+
452
+ ## Main outgroup appending pipeline
453
+ def pipe_append_outgroup(V, path, opt):
454
+ opt_append_outgroup = outgroup_append_opt_generator(V, path, opt)
455
+
456
+ # run multiprocessing start
457
+ if opt.verbose < 3:
458
+ p = mp.Pool(opt.thread)
459
+ result_append_outgroup = p.starmap(append_outgroup, opt_append_outgroup)
460
+ p.close()
461
+ p.join()
462
+
463
+ else:
464
+ # non-multithreading mode for debugging
465
+ result_append_outgroup = [append_outgroup(*o) for o in opt_append_outgroup]
466
+
467
+ # append outgroup by running result
468
+ # (group, gene, outgroup, ambiguous_group)
469
+ for result in result_append_outgroup:
470
+ # Parsing result
471
+ group = result[0]
472
+ gene = result[1]
473
+ outgroup = result[2]
474
+ ambiguous_group = result[3]
475
+
476
+ # Add outgroup and ambiguous groups to dataset
477
+ # Ambiguous groups are strains locating between outgroup and ingroups, so cannot be decided
478
+ if len(outgroup) == 0 and len(ambiguous_group) == 0:
479
+ logging.warning(
480
+ f"Removing {group} {gene} from analysis because outgroup cannot be selected"
481
+ )
482
+ V.dict_dataset[group].pop(gene, None)
483
+
484
+ else:
485
+ V.dict_dataset[group][gene].list_og_FI = outgroup
486
+ # Add ambiguous group to FI
487
+ V.dict_dataset[group][gene].list_db_FI += ambiguous_group
488
+ # Add outgroup and db in to dict_hash_FI
489
+
490
+ # Should find out why this does not work at the end
491
+ """
492
+ for FI in outgroup + ambiguous_group:
493
+ # If already in dict_hash_FI, they have priority
494
+ if not (FI.hash in V.dict_hash_FI):
495
+ V.dict_hash_FI[FI.hash] = FI
496
+ """
497
+
498
+ groups = deepcopy(list(V.dict_dataset.keys()))
499
+
500
+ for group in groups:
501
+ try:
502
+ if len(V.dict_dataset[group]) == 0:
503
+ logging.warning(
504
+ f"Removing {group} from analysis because outgroup cannot be selected to all genes"
505
+ )
506
+ V.dict_dataset.pop(group, None)
507
+ except:
508
+ pass
509
+
510
+ return V, path, opt