drep 4.0.2__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.
- drep/VERSION +1 -0
- drep/WorkDirectory.py +355 -0
- drep/__init__.py +101 -0
- drep/argumentParser.py +279 -0
- drep/controller.py +105 -0
- drep/d_adjust.py +272 -0
- drep/d_analyze.py +1613 -0
- drep/d_bonus.py +429 -0
- drep/d_choose.py +362 -0
- drep/d_cluster/__init__.py +0 -0
- drep/d_cluster/cluster_utils.py +126 -0
- drep/d_cluster/compare_utils.py +636 -0
- drep/d_cluster/controller.py +228 -0
- drep/d_cluster/external.py +765 -0
- drep/d_cluster/greedy_clustering.py +181 -0
- drep/d_cluster/parsers.py +0 -0
- drep/d_cluster/union_find.py +543 -0
- drep/d_cluster/utils.py +687 -0
- drep/d_evaluate.py +355 -0
- drep/d_filter.py +831 -0
- drep/d_workflows.py +135 -0
- drep-4.0.2.data/scripts/ScaffoldLevel_dRep.py +1101 -0
- drep-4.0.2.data/scripts/dRep +32 -0
- drep-4.0.2.data/scripts/parse_stb.py +140 -0
- drep-4.0.2.dist-info/METADATA +23 -0
- drep-4.0.2.dist-info/RECORD +28 -0
- drep-4.0.2.dist-info/WHEEL +5 -0
- drep-4.0.2.dist-info/top_level.txt +1 -0
|
@@ -0,0 +1,636 @@
|
|
|
1
|
+
import glob
|
|
2
|
+
import logging
|
|
3
|
+
import os
|
|
4
|
+
import sys
|
|
5
|
+
|
|
6
|
+
import numpy as np
|
|
7
|
+
import pandas as pd
|
|
8
|
+
import scipy.cluster
|
|
9
|
+
from scipy.spatial import distance as ssd
|
|
10
|
+
|
|
11
|
+
import drep
|
|
12
|
+
import drep.d_cluster.cluster_utils
|
|
13
|
+
import drep.d_cluster.external
|
|
14
|
+
import drep.d_cluster.utils
|
|
15
|
+
import drep.d_cluster.greedy_clustering
|
|
16
|
+
import drep.d_cluster.union_find
|
|
17
|
+
|
|
18
|
+
class genomeChunk():
|
|
19
|
+
"""
|
|
20
|
+
This is an object that just holds stuff related to a chunk of genomes for Mash clustering
|
|
21
|
+
"""
|
|
22
|
+
|
|
23
|
+
def __init__(self, locations, number, sketch_folder, genome_names, no_create=False):
|
|
24
|
+
'''
|
|
25
|
+
Initialize this genome chunk
|
|
26
|
+
'''
|
|
27
|
+
self.name = "chunk_{0}".format(number)
|
|
28
|
+
self.genome_locations = locations
|
|
29
|
+
self.chunk_folder = os.path.join(sketch_folder, self.name)
|
|
30
|
+
self.genome_names = genome_names
|
|
31
|
+
|
|
32
|
+
if not no_create:
|
|
33
|
+
if not os.path.exists(self.chunk_folder):
|
|
34
|
+
os.makedirs(self.chunk_folder)
|
|
35
|
+
|
|
36
|
+
def gen_sketch_cmds(self, mash_exe, MASH_s):
|
|
37
|
+
cmds = []
|
|
38
|
+
for location, name in zip(self.genome_locations, self.genome_names):
|
|
39
|
+
file = os.path.join(self.chunk_folder, name)
|
|
40
|
+
if not os.path.isfile(file + '.msh'):
|
|
41
|
+
cmd = [mash_exe, 'sketch', location, '-s', str(MASH_s), '-o',
|
|
42
|
+
file]
|
|
43
|
+
cmds.append(cmd)
|
|
44
|
+
return cmds
|
|
45
|
+
|
|
46
|
+
def gen_paste_cmd(self, mash_exe):
|
|
47
|
+
all_file = os.path.join(self.chunk_folder, 'chunk_all.msh')
|
|
48
|
+
list_file = os.path.join(self.chunk_folder, 'sketch_list.txt')
|
|
49
|
+
with open(list_file, 'w') as f:
|
|
50
|
+
for path in glob.glob(os.path.join(self.chunk_folder, '*.msh')):
|
|
51
|
+
f.write(path + '\n')
|
|
52
|
+
cmd = [mash_exe, 'paste', '-l', all_file, list_file]
|
|
53
|
+
self.all_file = all_file
|
|
54
|
+
return cmd
|
|
55
|
+
|
|
56
|
+
def gen_dist_cmd(self, mash_exe, mash_folder, p):
|
|
57
|
+
dist_file = os.path.join(mash_folder, '{0}_MASH_table.tsv'.format(self.name))
|
|
58
|
+
self.dist_file = dist_file
|
|
59
|
+
cmd = [mash_exe, 'dist', '-p', str(p), self.all_file, self.all_file, '>', dist_file]
|
|
60
|
+
cmd = ' '.join(cmd)
|
|
61
|
+
|
|
62
|
+
return cmd
|
|
63
|
+
|
|
64
|
+
def load_mash_table(self):
|
|
65
|
+
Mdb = drep.d_cluster.utils.parse_mash_table(self.dist_file)
|
|
66
|
+
|
|
67
|
+
# Filter out those genomes that are in the MASH folder but shouldn't be in Mdb
|
|
68
|
+
gs = set(self.genome_names)
|
|
69
|
+
Mdb = Mdb[Mdb['genome1'].isin(gs)]
|
|
70
|
+
Mdb = Mdb[Mdb['genome2'].isin(gs)]
|
|
71
|
+
|
|
72
|
+
# Reorder categories to be correct
|
|
73
|
+
for g in ['genome1', 'genome2']:
|
|
74
|
+
Mdb[g] = Mdb[g].cat.remove_unused_categories()
|
|
75
|
+
Mdb[g] = Mdb[g].cat.reorder_categories(sorted((Mdb[g].unique())), ordered=True)
|
|
76
|
+
|
|
77
|
+
self.Mdb = Mdb
|
|
78
|
+
|
|
79
|
+
def cluster_mash_table(self, **kwargs):
|
|
80
|
+
if len(self.Mdb) > 1:
|
|
81
|
+
Cdb, cluster_ret = cluster_mash_database(self.Mdb, **kwargs)
|
|
82
|
+
self.Cdb = Cdb
|
|
83
|
+
else:
|
|
84
|
+
self.Cdb = pd.DataFrame({'primary_cluster':[1], 'genome':[self.Mdb['genome1'].tolist()[0]]})
|
|
85
|
+
|
|
86
|
+
def get_winning_genomes(self, g2l, **kwargs):
|
|
87
|
+
Cdb = self.Cdb
|
|
88
|
+
Cdb['length'] = Cdb['genome'].map(g2l)
|
|
89
|
+
return self.Cdb.sort_values('length').drop_duplicates(subset=['primary_cluster'], keep='last')['genome'].tolist()
|
|
90
|
+
|
|
91
|
+
def all_vs_all_MASH(Bdb, data_folder, **kwargs):
|
|
92
|
+
"""
|
|
93
|
+
Run MASH pairwise within all samples in Bdb
|
|
94
|
+
|
|
95
|
+
Args:
|
|
96
|
+
Bdb: dataframe with genome, location
|
|
97
|
+
data_folder: location to store temporary output files
|
|
98
|
+
|
|
99
|
+
Keyword Args:
|
|
100
|
+
MASH_sketch: size of mash sketches
|
|
101
|
+
dry: dont actually run anything
|
|
102
|
+
processors: number of processors to multithread with
|
|
103
|
+
mash_exe: location of mash excutible (will try and find with shutil if not provided)
|
|
104
|
+
groupSize: max number of mash sketches to hold in each folder
|
|
105
|
+
debug: if True, log all of the commands
|
|
106
|
+
wd: if you want to log commands, you also need the wd
|
|
107
|
+
"""
|
|
108
|
+
# Warn if sketch size may be too small for the requested P_ani threshold
|
|
109
|
+
MASH_s = kwargs.get('MASH_sketch', 1000)
|
|
110
|
+
P_ani = kwargs.get('P_ani', 0.9)
|
|
111
|
+
if P_ani < 0.90 and MASH_s <= 1000:
|
|
112
|
+
logging.warning(
|
|
113
|
+
f"P_ani is set to {P_ani} but MASH_sketch is only {MASH_s}. "
|
|
114
|
+
"At low ANI thresholds, a small sketch size can cause Mash to underestimate "
|
|
115
|
+
"distances and place related genomes into separate primary clusters, which "
|
|
116
|
+
"means they will never be compared with the secondary algorithm. "
|
|
117
|
+
"Consider increasing -ms (MASH_sketch) to 10000 or higher."
|
|
118
|
+
)
|
|
119
|
+
|
|
120
|
+
# Set up the mash folder structure
|
|
121
|
+
logdir, MASH_folder, sketch_folder, mash_exe = prepare_mash(data_folder, **kwargs)
|
|
122
|
+
|
|
123
|
+
# Set up chunks of genomes
|
|
124
|
+
genome_chunks = prepare_genome_chunks(Bdb, sketch_folder, MASH_folder, **kwargs)
|
|
125
|
+
if len(genome_chunks) > 1:
|
|
126
|
+
logging.info(f" Will split genomes into {len(genome_chunks)} groups for primary clustering")
|
|
127
|
+
|
|
128
|
+
# Process the chunks individually
|
|
129
|
+
genome_chunks = run_mash_on_genome_chunks(genome_chunks, mash_exe, sketch_folder, MASH_folder, logdir, **kwargs)
|
|
130
|
+
|
|
131
|
+
# If there's only one chunk, we're done here
|
|
132
|
+
if len(genome_chunks) == 1:
|
|
133
|
+
Mdb = genome_chunks[0].Mdb
|
|
134
|
+
Cdb, cluster_ret = cluster_mash_database(Mdb, **kwargs)
|
|
135
|
+
return Mdb, Cdb, cluster_ret
|
|
136
|
+
|
|
137
|
+
# If there's multiple chunks, run a second round
|
|
138
|
+
logging.info(" Final step: comparing between all groups")
|
|
139
|
+
return run_second_round_clustering(Bdb, genome_chunks, data_folder, verbose=True, **kwargs)
|
|
140
|
+
|
|
141
|
+
|
|
142
|
+
def all_vs_all_primary(Bdb, data_folder, **kwargs):
|
|
143
|
+
"""
|
|
144
|
+
Dispatch primary clustering to the requested algorithm.
|
|
145
|
+
|
|
146
|
+
'skani' (default) uses `skani triangle --sparse` + union-find, which never
|
|
147
|
+
builds the N^2 matrix on disk or in RAM and lets a skani --S_algorithm reuse
|
|
148
|
+
the comparisons. 'MASH' is the classic all-vs-all Mash path (pre-v4).
|
|
149
|
+
|
|
150
|
+
Returns (Mdb, Cdb, cluster_ret), matching all_vs_all_MASH.
|
|
151
|
+
"""
|
|
152
|
+
method = kwargs.get('primary_algorithm', 'skani')
|
|
153
|
+
if method == 'skani':
|
|
154
|
+
# These only mean something on the MASH path. skani's sparse output never
|
|
155
|
+
# builds the N^2 table they exist to work around, so say so rather than
|
|
156
|
+
# silently ignoring them.
|
|
157
|
+
if kwargs.get('multiround_primary_clustering', False):
|
|
158
|
+
logging.warning(
|
|
159
|
+
"--multiround_primary_clustering only applies to --primary_algorithm MASH "
|
|
160
|
+
"and is ignored with skani. skani's sparse output never builds the full "
|
|
161
|
+
"N^2 table that multiround exists to avoid, and it does not suffer the "
|
|
162
|
+
"chunk-splitting imprecision of multiround.")
|
|
163
|
+
return primary_cluster_skani_sparse(Bdb, data_folder, **kwargs)
|
|
164
|
+
return all_vs_all_MASH(Bdb, data_folder, **kwargs)
|
|
165
|
+
|
|
166
|
+
|
|
167
|
+
def primary_cluster_skani_sparse(Bdb, data_folder, **kwargs):
|
|
168
|
+
"""
|
|
169
|
+
Primary clustering via one `skani triangle --sparse` pass + union-find.
|
|
170
|
+
|
|
171
|
+
Only above-screen pairs are ever produced, so there is no N^2 matrix on disk
|
|
172
|
+
or in RAM. Always single-linkage (connected components);
|
|
173
|
+
--classic_primary_clustering / non-single primary_clusterAlg do not apply.
|
|
174
|
+
|
|
175
|
+
The returned Mdb holds *every* edge from that pass, not just the ones above
|
|
176
|
+
P_ani. That is deliberate: secondary clustering compares genomes within a
|
|
177
|
+
primary cluster, which is a subset of what this pass already computed, so it
|
|
178
|
+
can reuse these edges instead of re-running skani per cluster. See
|
|
179
|
+
secondary_clustering_from_primary_edges.
|
|
180
|
+
"""
|
|
181
|
+
P_ani = kwargs.get('P_ani', 0.9)
|
|
182
|
+
ani_threshold = P_ani * 100.0
|
|
183
|
+
|
|
184
|
+
# Screen a few points below the ANI threshold so skani's k-mer pre-filter
|
|
185
|
+
# doesn't drop a pair whose full ANI would clear the threshold. Also stay at
|
|
186
|
+
# or below the secondary threshold, since secondary reuses these edges.
|
|
187
|
+
S_ani = kwargs.get('S_ani', 0.95)
|
|
188
|
+
default_screen = max(1.0, min(ani_threshold - 5.0, S_ani * 100.0 - 5.0, 99.0))
|
|
189
|
+
screen = kwargs.get('primary_skani_screen', default_screen)
|
|
190
|
+
|
|
191
|
+
# Minimum percent of a genome that must align for a pair to be reported.
|
|
192
|
+
# Do not lower this casually: skani's ANI ignores how much of the genome
|
|
193
|
+
# aligned, so without this filter genomes sharing only a small conserved
|
|
194
|
+
# region become edges and single linkage chains them into one huge cluster.
|
|
195
|
+
# See run_skani_triangle_sparse for the measurements behind the default.
|
|
196
|
+
min_af = kwargs.get('primary_skani_min_af', 15)
|
|
197
|
+
|
|
198
|
+
# Secondary applies its own coverage filter at cov_thresh, so the single pass
|
|
199
|
+
# has to emit anything secondary might still care about. Ask skani for the
|
|
200
|
+
# looser of the two and apply the stricter primary filter ourselves below.
|
|
201
|
+
cov_thresh = float(kwargs.get('cov_thresh', 0.1))
|
|
202
|
+
emit_min_af = min(min_af, cov_thresh * 100.0)
|
|
203
|
+
|
|
204
|
+
skani_folder = os.path.join(data_folder, 'skani_sparse_files/')
|
|
205
|
+
genome_list = list(Bdb['location'].unique())
|
|
206
|
+
|
|
207
|
+
logging.info(f" Running sparse skani primary clustering on {len(genome_list):,} genomes "
|
|
208
|
+
f"(ANI threshold {ani_threshold:.1f}%, screen {screen:.1f}%, "
|
|
209
|
+
f"min-af {min_af}%, emitting min-af {emit_min_af:.1f}%)")
|
|
210
|
+
sparse_file = drep.d_cluster.external.run_skani_triangle_sparse(
|
|
211
|
+
genome_list, skani_folder, screen, min_af=emit_min_af, **kwargs)
|
|
212
|
+
|
|
213
|
+
all_genomes = list(Bdb['genome'].unique())
|
|
214
|
+
edges = drep.d_cluster.union_find.load_skani_sparse_edges(sparse_file)
|
|
215
|
+
Cdb, stats = drep.d_cluster.union_find.cluster_edges(
|
|
216
|
+
edges, P_ani, all_genomes, cov_threshold=min_af / 100.0)
|
|
217
|
+
|
|
218
|
+
logging.info(f" Sparse skani primary clustering: {stats['edges_kept']:,} edges above "
|
|
219
|
+
f"threshold, {stats['primary_clusters']:,} primary clusters "
|
|
220
|
+
f"({stats['edges_total']:,} edges retained for secondary)")
|
|
221
|
+
|
|
222
|
+
# Mdb keeps every edge so secondary can reuse them. similarity/dist mirror the
|
|
223
|
+
# MASH Mdb schema; alignment_coverage is the aligned fraction of genome1.
|
|
224
|
+
Mdb = edges.rename(columns={'ani': 'similarity'}).copy()
|
|
225
|
+
Mdb['dist'] = 1 - Mdb['similarity']
|
|
226
|
+
|
|
227
|
+
# The sparse path builds no dense matrix, so there is normally no scipy
|
|
228
|
+
# linkage to draw a primary dendrogram from. For modest genome sets the dense
|
|
229
|
+
# matrix is cheap, so build it from the edges purely so the dendrogram still
|
|
230
|
+
# works. Above the cutoff we store a marker and plotting skips it.
|
|
231
|
+
linkage = 'union_find_streaming'
|
|
232
|
+
linkage_db = None
|
|
233
|
+
dendro_max = kwargs.get('primary_dendrogram_max_genomes', 2000)
|
|
234
|
+
if len(all_genomes) <= dendro_max:
|
|
235
|
+
try:
|
|
236
|
+
linkage_db = drep.d_cluster.union_find.edges_to_dense_dist(edges, all_genomes)
|
|
237
|
+
arr = ssd.squareform(np.asarray(linkage_db), checks=False)
|
|
238
|
+
linkage = scipy.cluster.hierarchy.linkage(arr, method='single')
|
|
239
|
+
except Exception as e:
|
|
240
|
+
logging.debug(f"Skipping primary dendrogram linkage computation: {e}")
|
|
241
|
+
linkage = 'union_find_streaming'
|
|
242
|
+
linkage_db = None
|
|
243
|
+
|
|
244
|
+
arguments = {'linkage_method': 'single', 'linkage_cutoff': 1 - P_ani,
|
|
245
|
+
'comparison_algorithm': 'skani'}
|
|
246
|
+
cluster_ret = [linkage, linkage_db, arguments]
|
|
247
|
+
return Mdb, Cdb, cluster_ret
|
|
248
|
+
|
|
249
|
+
|
|
250
|
+
def secondary_clustering_from_primary_edges(Bdb, Cdb, Mdb, **kwargs):
|
|
251
|
+
"""
|
|
252
|
+
Secondary clustering that reuses primary's skani edges instead of re-running
|
|
253
|
+
skani once per primary cluster.
|
|
254
|
+
|
|
255
|
+
The per-cluster comparisons dRep normally runs here recompute ANI values that
|
|
256
|
+
the single sparse pass already produced exactly -- on 10,000 UHGG genomes,
|
|
257
|
+
94% of the pairs driving secondary clustering were already present, with
|
|
258
|
+
identical ANI to 6 decimal places, and the reused path reproduced the
|
|
259
|
+
two-stage partition exactly (1,232 clusters) in 24s instead of 15 minutes.
|
|
260
|
+
|
|
261
|
+
Returns (Ndb, Cdb, c2ret), matching secondary_clustering.
|
|
262
|
+
"""
|
|
263
|
+
edges = Mdb.rename(columns={'similarity': 'ani'})[
|
|
264
|
+
['genome1', 'genome2', 'ani', 'alignment_coverage']]
|
|
265
|
+
Ndb = drep.d_cluster.union_find.build_ndb_from_edges(edges, Cdb)
|
|
266
|
+
|
|
267
|
+
logging.info(f" Reusing {len(edges):,} primary skani edges for secondary clustering "
|
|
268
|
+
f"(no new comparisons); Ndb has {len(Ndb):,} rows")
|
|
269
|
+
|
|
270
|
+
Cdb2, c2ret = drep.d_cluster.utils._cluster_Ndb(Ndb, comp_method='skani', **kwargs)
|
|
271
|
+
return Ndb, Cdb2, c2ret
|
|
272
|
+
|
|
273
|
+
def prepare_mash(data_folder, **kwargs):
|
|
274
|
+
"""
|
|
275
|
+
Make some folders and things
|
|
276
|
+
"""
|
|
277
|
+
append = kwargs.get('v2', '')
|
|
278
|
+
|
|
279
|
+
# set up logdir
|
|
280
|
+
if ('wd' in kwargs) and (kwargs.get('debug', False) == True):
|
|
281
|
+
logdir = kwargs.get('wd').get_dir('cmd_logs')
|
|
282
|
+
else:
|
|
283
|
+
logdir = False
|
|
284
|
+
|
|
285
|
+
# Find mash excutable
|
|
286
|
+
mash_exe = kwargs.get('exe_loc', None)
|
|
287
|
+
if mash_exe == None:
|
|
288
|
+
mash_exe = drep.get_exe('mash')
|
|
289
|
+
|
|
290
|
+
# Make a folder to hold this information
|
|
291
|
+
MASH_folder = os.path.join(data_folder, 'MASH_files{0}/'.format(append))
|
|
292
|
+
if not os.path.exists(MASH_folder):
|
|
293
|
+
os.makedirs(MASH_folder)
|
|
294
|
+
|
|
295
|
+
# Make a folder in there to store sketches
|
|
296
|
+
sketch_folder = os.path.join(MASH_folder, 'sketches{0}/'.format(append))
|
|
297
|
+
if not os.path.exists(sketch_folder):
|
|
298
|
+
os.makedirs(sketch_folder)
|
|
299
|
+
|
|
300
|
+
return logdir, MASH_folder, sketch_folder, mash_exe
|
|
301
|
+
|
|
302
|
+
def prepare_genome_chunks(Bdb, sketch_folder, MASH_folder, **kwargs):
|
|
303
|
+
groupSize = kwargs.get('primary_chunksize', 5000)
|
|
304
|
+
l2g = Bdb.set_index('location')['genome'].to_dict()
|
|
305
|
+
|
|
306
|
+
locations = list(Bdb['location'].unique())
|
|
307
|
+
chunks = [locations[x:x + groupSize] for x in range(0, len(locations), groupSize)]
|
|
308
|
+
|
|
309
|
+
genome_chunks = []
|
|
310
|
+
for i, chunk in enumerate(chunks):
|
|
311
|
+
genome_chunks.append(genomeChunk(chunk, i, sketch_folder, [l2g[l] for l in chunk]))
|
|
312
|
+
|
|
313
|
+
return genome_chunks
|
|
314
|
+
|
|
315
|
+
def run_mash_on_genome_chunks(genome_chunks, mash_exe, sketch_folder, MASH_folder, logdir, **kwargs):
|
|
316
|
+
dry = kwargs.get('dry', False)
|
|
317
|
+
p = kwargs.get('processors', 6)
|
|
318
|
+
MASH_s = kwargs.get('MASH_sketch', 1000)
|
|
319
|
+
multi_round = kwargs.get('multiround_primary_clustering', True)
|
|
320
|
+
|
|
321
|
+
# Step 1) Create Mash sketches
|
|
322
|
+
cmds = []
|
|
323
|
+
for GC in genome_chunks:
|
|
324
|
+
cmds += GC.gen_sketch_cmds(mash_exe, MASH_s)
|
|
325
|
+
if (not dry) & (len(cmds) > 0):
|
|
326
|
+
drep.thread_cmds(cmds, logdir=logdir, t=int(p))
|
|
327
|
+
|
|
328
|
+
# Step 2) Combine MASH sketches within chunks
|
|
329
|
+
cmds = [GC.gen_paste_cmd(mash_exe) for GC in genome_chunks]
|
|
330
|
+
if (not dry) & (len(cmds) > 0):
|
|
331
|
+
drep.thread_cmds(cmds, logdir=logdir, t=int(p))
|
|
332
|
+
|
|
333
|
+
# Merge the pasted chunks and make a new genomeChunk if thats what you want
|
|
334
|
+
if (not multi_round) & (len(genome_chunks) > 1):
|
|
335
|
+
cmd, new_gc = drep.d_cluster.utils.merge_genome_chunks(mash_exe, genome_chunks, sketch_folder, MASH_folder)
|
|
336
|
+
genome_chunks = [new_gc]
|
|
337
|
+
drep.run_cmd(cmd, dry, shell=False, logdir=logdir)
|
|
338
|
+
|
|
339
|
+
# Step 3) Run Mash on each chunk
|
|
340
|
+
cmds = [GC.gen_dist_cmd(mash_exe, MASH_folder, p) for GC in genome_chunks]
|
|
341
|
+
for j, cmd in enumerate(cmds):
|
|
342
|
+
if not dry:
|
|
343
|
+
if len(cmds) > 1:
|
|
344
|
+
logging.info(f" Comparing group {j+1} of {len(cmds)}")
|
|
345
|
+
drep.run_cmd(cmd, dry, shell=True, logdir=logdir)
|
|
346
|
+
|
|
347
|
+
# Step 4) Load the Mash tables of each chunk
|
|
348
|
+
for GC in genome_chunks:
|
|
349
|
+
GC.load_mash_table()
|
|
350
|
+
|
|
351
|
+
return genome_chunks
|
|
352
|
+
|
|
353
|
+
def _subsample_mdb(mdb, max_rows):
|
|
354
|
+
"""
|
|
355
|
+
Cap a per-chunk Mdb to at most max_rows rows so multiround primary clustering
|
|
356
|
+
doesn't accumulate an O(N^2) table across all chunks (the source of the
|
|
357
|
+
43k-genome MemoryError at pd.concat). The full pairwise table is only kept for
|
|
358
|
+
storage/inspection; clustering itself does not use the concatenated Mdb.
|
|
359
|
+
"""
|
|
360
|
+
if max_rows is None or len(mdb) <= max_rows:
|
|
361
|
+
return mdb
|
|
362
|
+
return mdb.sample(n=max_rows, random_state=0)
|
|
363
|
+
|
|
364
|
+
|
|
365
|
+
def run_second_round_clustering(Bdb, genome_chunks, data_folder, **kwargs):
|
|
366
|
+
verbose = kwargs.get('verbose', False)
|
|
367
|
+
|
|
368
|
+
# Bound the total number of pairwise rows retained for the stored Mdb across
|
|
369
|
+
# all chunks. Set to 0/None to disable (restores pre-v4 unbounded behavior).
|
|
370
|
+
max_mdb_rows = kwargs.get('max_stored_mdb_rows', 5_000_000)
|
|
371
|
+
per_chunk_cap = None
|
|
372
|
+
if max_mdb_rows:
|
|
373
|
+
per_chunk_cap = max(1, int(max_mdb_rows // (len(genome_chunks) + 1)))
|
|
374
|
+
|
|
375
|
+
kwargs_copy = kwargs.copy()
|
|
376
|
+
kwargs_copy['multiround_primary_clustering'] = False
|
|
377
|
+
kwargs_copy['v2'] = '_v2'
|
|
378
|
+
|
|
379
|
+
mdbs = []
|
|
380
|
+
|
|
381
|
+
# Step 1) Create a merged Cdb file
|
|
382
|
+
dbs = []
|
|
383
|
+
for gc in genome_chunks:
|
|
384
|
+
gc.cluster_mash_table(**kwargs_copy)
|
|
385
|
+
cdb = gc.Cdb
|
|
386
|
+
cdb['subcluster'] = ["{0}_{1}".format(gc.name, x) for x in cdb['primary_cluster']]
|
|
387
|
+
dbs.append(cdb)
|
|
388
|
+
|
|
389
|
+
mdb = gc.Mdb
|
|
390
|
+
mdb['genome_chunk'] = gc.name
|
|
391
|
+
# Subsample before retaining so we never hold all N^2 rows at once
|
|
392
|
+
mdbs.append(_subsample_mdb(mdb, per_chunk_cap))
|
|
393
|
+
# Free the chunk's full table now that its clusters are computed
|
|
394
|
+
gc.Mdb = None
|
|
395
|
+
|
|
396
|
+
Cdb = pd.concat(dbs)
|
|
397
|
+
|
|
398
|
+
# Step 2) Pick winners
|
|
399
|
+
g2l = Bdb.set_index('genome')['length'].to_dict()
|
|
400
|
+
Cdb['length'] = Cdb['genome'].map(g2l)
|
|
401
|
+
second_round_genomes = Cdb.sort_values('length').drop_duplicates(subset=['subcluster'], keep='last')['genome'].tolist()
|
|
402
|
+
|
|
403
|
+
if verbose:
|
|
404
|
+
logging.info(f"Comparing {len(second_round_genomes):,} genomes")
|
|
405
|
+
|
|
406
|
+
# Step 3) Run a second round
|
|
407
|
+
logdir, MASH_folder, sketch_folder, mash_exe = prepare_mash(data_folder, **kwargs_copy)
|
|
408
|
+
genome_chunks = prepare_genome_chunks(Bdb[Bdb['genome'].isin(second_round_genomes)], sketch_folder, MASH_folder, **kwargs_copy)
|
|
409
|
+
genome_chunks = run_mash_on_genome_chunks(genome_chunks, mash_exe, sketch_folder, MASH_folder, logdir, **kwargs_copy)
|
|
410
|
+
|
|
411
|
+
# Step 4) Get results
|
|
412
|
+
assert len(genome_chunks) == 1
|
|
413
|
+
|
|
414
|
+
mdb = genome_chunks[0].Mdb
|
|
415
|
+
mdb['genome_chunk'] = 'v2'
|
|
416
|
+
# Cluster on the full second-round table, but only store a bounded subsample
|
|
417
|
+
Cdb2, cluster_ret = cluster_mash_database(mdb, **kwargs)
|
|
418
|
+
|
|
419
|
+
mdbs.append(_subsample_mdb(mdb, per_chunk_cap))
|
|
420
|
+
Mdb = pd.concat(mdbs).reset_index(drop=True)
|
|
421
|
+
Cdb2['primary_representitive'] = True
|
|
422
|
+
|
|
423
|
+
# Step 5) Merge the new Cdb back in with the old
|
|
424
|
+
del Cdb['primary_cluster']
|
|
425
|
+
Cdb = pd.merge(Cdb, Cdb2, on='genome', how='outer')
|
|
426
|
+
o2n = Cdb[Cdb['primary_representitive'] == True].set_index('subcluster')['primary_cluster'].to_dict()
|
|
427
|
+
Cdb['primary_cluster'] = Cdb['subcluster'].map(o2n).astype(int)
|
|
428
|
+
|
|
429
|
+
return Mdb, Cdb, cluster_ret
|
|
430
|
+
|
|
431
|
+
def cluster_mash_database(db, **kwargs):
|
|
432
|
+
'''
|
|
433
|
+
From a Mash database, cluster and return Cdb
|
|
434
|
+
|
|
435
|
+
Args:
|
|
436
|
+
db: Mdb (all_vs_all Mash results)
|
|
437
|
+
|
|
438
|
+
Keyword arguments:
|
|
439
|
+
primary_clusterAlg: how to cluster the primary database (default = single).
|
|
440
|
+
'single' uses the fast streaming union-find algorithm; any other
|
|
441
|
+
method uses the classic dense scipy path.
|
|
442
|
+
clusterAlg: legacy fallback for primary_clusterAlg (default = single)
|
|
443
|
+
P_ani: threshold to cluster at (default = 0.9)
|
|
444
|
+
classic_primary_clustering: force the dense scipy path
|
|
445
|
+
|
|
446
|
+
Returns:
|
|
447
|
+
list: [Cdb, [linkage, linkage_db, arguments]]
|
|
448
|
+
'''
|
|
449
|
+
logging.debug('Clustering MASH database')
|
|
450
|
+
|
|
451
|
+
# Load key words. Primary clustering has its own linkage method
|
|
452
|
+
# (primary_clusterAlg), independent of the secondary clusterAlg. Fall back to
|
|
453
|
+
# clusterAlg for older callers that only pass that.
|
|
454
|
+
P_Lmethod = kwargs.get('primary_clusterAlg') or kwargs.get('clusterAlg', 'single')
|
|
455
|
+
P_Lcutoff = 1 - kwargs.get('P_ani',.9)
|
|
456
|
+
classic = kwargs.get('classic_primary_clustering', False)
|
|
457
|
+
|
|
458
|
+
db['dist'] = 1 - db['similarity']
|
|
459
|
+
|
|
460
|
+
# Single-linkage clustering at a fixed cutoff is identical to connected
|
|
461
|
+
# components. Compute it directly on the long-format table with union-find and
|
|
462
|
+
# skip the O(N^2) dense pivot entirely (issue #259 / the large-N RAM crash).
|
|
463
|
+
# This is the default; --classic_primary_clustering forces the dense path.
|
|
464
|
+
use_union_find = (not classic) and (P_Lmethod == 'single')
|
|
465
|
+
if use_union_find:
|
|
466
|
+
Cdb = drep.d_cluster.union_find.cluster_long_df(db, P_Lcutoff)
|
|
467
|
+
|
|
468
|
+
arguments = {'linkage_method': 'single', 'linkage_cutoff': P_Lcutoff,
|
|
469
|
+
'comparison_algorithm': 'MASH'}
|
|
470
|
+
|
|
471
|
+
# The streaming path builds no dense matrix, so by default there is no
|
|
472
|
+
# scipy linkage to plot a primary dendrogram from. For modest genome sets
|
|
473
|
+
# the dense pivot is cheap, so compute the single-linkage matrix purely so
|
|
474
|
+
# the dendrogram can still be drawn. Above the cutoff (or if it fails) we
|
|
475
|
+
# store a marker and downstream plotting skips the dendrogram gracefully.
|
|
476
|
+
linkage = 'union_find_streaming'
|
|
477
|
+
linkage_db = None
|
|
478
|
+
dendro_max = kwargs.get('primary_dendrogram_max_genomes', 2000)
|
|
479
|
+
n_genomes = Cdb['genome'].nunique()
|
|
480
|
+
if n_genomes <= dendro_max and 'genome_chunk' not in db.columns:
|
|
481
|
+
try:
|
|
482
|
+
linkage_db = db.pivot(index="genome1", columns="genome2", values="dist")
|
|
483
|
+
arr = ssd.squareform(np.asarray(linkage_db))
|
|
484
|
+
linkage = scipy.cluster.hierarchy.linkage(arr, method='single')
|
|
485
|
+
except Exception as e:
|
|
486
|
+
logging.debug(f"Skipping primary dendrogram linkage computation: {e}")
|
|
487
|
+
linkage = 'union_find_streaming'
|
|
488
|
+
linkage_db = None
|
|
489
|
+
|
|
490
|
+
cluster_ret = [linkage, linkage_db, arguments]
|
|
491
|
+
return Cdb, cluster_ret
|
|
492
|
+
|
|
493
|
+
# Classic dense path (non-single linkage, or --classic_primary_clustering).
|
|
494
|
+
linkage_db = db.pivot(index="genome1", columns="genome2", values="dist")
|
|
495
|
+
Cdb, linkage = drep.d_cluster.cluster_utils.cluster_hierarchical(linkage_db, linkage_method= P_Lmethod, \
|
|
496
|
+
linkage_cutoff= P_Lcutoff)
|
|
497
|
+
Cdb = Cdb.rename(columns={'cluster':'primary_cluster'})
|
|
498
|
+
Cdb['primary_cluster'] = Cdb['primary_cluster'].astype(int)
|
|
499
|
+
|
|
500
|
+
# Preparing clustering for return
|
|
501
|
+
arguments = {'linkage_method':P_Lmethod,'linkage_cutoff':P_Lcutoff,\
|
|
502
|
+
'comparison_algorithm':'MASH'}
|
|
503
|
+
cluster_ret = [linkage, linkage_db, arguments]
|
|
504
|
+
|
|
505
|
+
return Cdb, cluster_ret
|
|
506
|
+
|
|
507
|
+
def secondary_clustering(Bdb, Cdb, algorithm, data_folder, **kwargs):
|
|
508
|
+
|
|
509
|
+
if kwargs.get('greedy_secondary_clustering', False) != True:
|
|
510
|
+
Ndb = pd.DataFrame()
|
|
511
|
+
for bdb, name in iteratre_clusters(Bdb, Cdb, id='primary_cluster'):
|
|
512
|
+
logging.debug('running cluster {0}'.format(name))
|
|
513
|
+
# logging.debug('total memory - {0:.2f} Mbp'.format(int(process.memory_info().rss)/1000000))
|
|
514
|
+
ndb = compare_genomes(bdb, algorithm, data_folder, **kwargs)
|
|
515
|
+
|
|
516
|
+
if len(ndb) == 0:
|
|
517
|
+
logging.error("CRITICAL ERROR WITH PRIMARY CLUSTER {0}; TRYING AGAIN".format(name))
|
|
518
|
+
ndb = compare_genomes(bdb, algorithm, data_folder, **kwargs)
|
|
519
|
+
|
|
520
|
+
if len(ndb) > 0:
|
|
521
|
+
ndb['primary_cluster'] = name
|
|
522
|
+
Ndb = pd.concat([Ndb, ndb])
|
|
523
|
+
#Ndb = Ndb.append(ndb)
|
|
524
|
+
else:
|
|
525
|
+
logging.error("DOUBLE CRITICAL ERROR AGAIN WITH PRIMARY CLUSTER {0}; SKIPPING".format(name))
|
|
526
|
+
|
|
527
|
+
# Run clustering on Ndb
|
|
528
|
+
Cdb, c2ret = drep.d_cluster.utils._cluster_Ndb(Ndb, comp_method=algorithm, **kwargs)
|
|
529
|
+
|
|
530
|
+
return Ndb, Cdb, c2ret
|
|
531
|
+
|
|
532
|
+
else:
|
|
533
|
+
return drep.d_cluster.greedy_clustering.greedy_secondary_clustering(Bdb, Cdb, algorithm, data_folder, **kwargs)
|
|
534
|
+
|
|
535
|
+
|
|
536
|
+
def iteratre_clusters(Bdb, Cdb, id='MASH_cluster'):
|
|
537
|
+
Bdb = pd.merge(Bdb, Cdb)
|
|
538
|
+
for cluster in Bdb[id].unique():
|
|
539
|
+
d = Bdb[Bdb[id] == cluster]
|
|
540
|
+
yield d, cluster
|
|
541
|
+
|
|
542
|
+
def compare_genomes(bdb, algorithm, data_folder, **kwargs):
|
|
543
|
+
'''
|
|
544
|
+
Compare a list of genomes using the algorithm specified
|
|
545
|
+
|
|
546
|
+
This method takes in bdb (a table with the columns location and genome), runs
|
|
547
|
+
pair-wise comparisons between all genomes in the sample, and returns a table
|
|
548
|
+
with at least the columns 'reference', 'querry', 'ani','coverage', depending
|
|
549
|
+
on what algorithm is called
|
|
550
|
+
|
|
551
|
+
Args:
|
|
552
|
+
bdb: DataFrame with ['genome', 'location'] (drep.d_filter.load_genomes)
|
|
553
|
+
algorithm: options are ANImf, ANIn, gANI
|
|
554
|
+
data_folder: location to store output files
|
|
555
|
+
|
|
556
|
+
Keyword Arguments:
|
|
557
|
+
wd: either this or prod_folder needed for gANI
|
|
558
|
+
prod_folder: either this or wd needed for gANI
|
|
559
|
+
|
|
560
|
+
Return:
|
|
561
|
+
DataFrame: Ndb (['reference', 'querry', 'ani','coverage'])
|
|
562
|
+
'''
|
|
563
|
+
# To handle other versions of this method which passed in a WorkDirectory
|
|
564
|
+
# instead of data_folder string
|
|
565
|
+
if isinstance(data_folder, drep.WorkDirectory.WorkDirectory):
|
|
566
|
+
data_folder = data_folder.get_dir('data')
|
|
567
|
+
|
|
568
|
+
if not kwargs.get('greedy_secondary_clustering', False):
|
|
569
|
+
if algorithm == 'ANImf':
|
|
570
|
+
genome_list = bdb['location'].tolist()
|
|
571
|
+
working_data_folder = os.path.join(data_folder, 'ANImf_files/')
|
|
572
|
+
df = drep.d_cluster.external.run_pairwise_ANImf(genome_list, working_data_folder, **kwargs)
|
|
573
|
+
return df
|
|
574
|
+
|
|
575
|
+
elif algorithm == 'ANIn':
|
|
576
|
+
genome_list = bdb['location'].tolist()
|
|
577
|
+
working_data_folder = os.path.join(data_folder, 'ANIn_files/')
|
|
578
|
+
df = drep.d_cluster.utils.run_pairwise_ANIn(genome_list, working_data_folder, **kwargs)
|
|
579
|
+
return df
|
|
580
|
+
|
|
581
|
+
elif algorithm == 'fastANI':
|
|
582
|
+
genome_list = bdb['location'].tolist()
|
|
583
|
+
working_data_folder = os.path.join(data_folder, 'fastANI_files/')
|
|
584
|
+
df = drep.d_cluster.external.run_pairwise_fastANI(genome_list, working_data_folder, **kwargs)
|
|
585
|
+
return df
|
|
586
|
+
|
|
587
|
+
elif algorithm == 'skani':
|
|
588
|
+
genome_list = bdb['location'].tolist()
|
|
589
|
+
working_data_folder = os.path.join(data_folder, 'skani_files/')
|
|
590
|
+
df = drep.d_cluster.external.run_pairwise_skani(genome_list, working_data_folder, **kwargs)
|
|
591
|
+
return df
|
|
592
|
+
|
|
593
|
+
elif algorithm == 'gANI':
|
|
594
|
+
# Figure out prodigal folder
|
|
595
|
+
wd = kwargs.get('wd', False)
|
|
596
|
+
if not wd:
|
|
597
|
+
prod_folder = kwargs.pop('prod_folder', False)
|
|
598
|
+
assert prod_folder != False
|
|
599
|
+
else:
|
|
600
|
+
prod_folder = wd.get_dir('prodigal')
|
|
601
|
+
|
|
602
|
+
working_data_folder = os.path.join(data_folder, 'gANI_files/')
|
|
603
|
+
df = drep.d_cluster.external.run_pairwise_gANI(bdb, working_data_folder, \
|
|
604
|
+
prod_folder=prod_folder, **kwargs)
|
|
605
|
+
return df
|
|
606
|
+
|
|
607
|
+
elif algorithm == 'goANI':
|
|
608
|
+
# Figure out prodigal folder
|
|
609
|
+
wd = kwargs.get('wd', False)
|
|
610
|
+
if not wd:
|
|
611
|
+
prod_folder = kwargs.pop('prod_folder', False)
|
|
612
|
+
assert prod_folder != False
|
|
613
|
+
else:
|
|
614
|
+
prod_folder = wd.get_dir('prodigal')
|
|
615
|
+
|
|
616
|
+
working_data_folder = os.path.join(data_folder, 'goANI_files/')
|
|
617
|
+
df = drep.d_cluster.external.run_pairwise_goANI(bdb, working_data_folder, \
|
|
618
|
+
prod_folder=prod_folder, **kwargs)
|
|
619
|
+
return df
|
|
620
|
+
|
|
621
|
+
else:
|
|
622
|
+
logging.error("{0} not supported".format(algorithm))
|
|
623
|
+
sys.exit()
|
|
624
|
+
|
|
625
|
+
else:
|
|
626
|
+
SUPPORTED = ['fastANI']
|
|
627
|
+
if algorithm not in SUPPORTED:
|
|
628
|
+
message = f"{algorithm} is not supported for greedy secondary clustering!\nChoose one of the following supported S_algorithm options: {' '.join(SUPPORTED)}"
|
|
629
|
+
logging.error(message)
|
|
630
|
+
print(message)
|
|
631
|
+
raise NameError
|
|
632
|
+
|
|
633
|
+
working_data_folder = os.path.join(data_folder, 'greedy_clustering/')
|
|
634
|
+
return drep.d_cluster.greedy_clustering.compare_genomes_greedy(bdb, algorithm, working_data_folder, **kwargs)
|
|
635
|
+
|
|
636
|
+
|