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,543 @@
|
|
|
1
|
+
"""
|
|
2
|
+
Streaming, low-memory primary clustering via union-find (disjoint set).
|
|
3
|
+
|
|
4
|
+
Primary clustering in dRep is single-linkage hierarchical clustering at a fixed
|
|
5
|
+
distance cutoff. That is mathematically identical to finding the connected
|
|
6
|
+
components of the graph whose nodes are genomes and whose edges are the pairs
|
|
7
|
+
with ``distance <= cutoff``.
|
|
8
|
+
|
|
9
|
+
The classic dRep path materializes every pairwise MASH comparison into a long
|
|
10
|
+
``Mdb`` DataFrame (N^2 rows) and then pivots it into a dense N x N matrix before
|
|
11
|
+
handing it to scipy. For tens of thousands of genomes this is tens of GiB of RAM
|
|
12
|
+
and is the source of the crashes in issue #259 and the "Big dRep issue".
|
|
13
|
+
|
|
14
|
+
This module never builds the dense matrix and never needs to hold all N^2 pairs
|
|
15
|
+
in memory. It streams the MASH ``dist`` output, discards the ~99.9% of pairs that
|
|
16
|
+
are above the cutoff the instant they are read, and unions the survivors. Memory
|
|
17
|
+
is O(genomes + kept_edges) instead of O(genomes^2).
|
|
18
|
+
"""
|
|
19
|
+
|
|
20
|
+
import logging
|
|
21
|
+
|
|
22
|
+
import numpy as np
|
|
23
|
+
import pandas as pd
|
|
24
|
+
|
|
25
|
+
import drep.d_cluster.utils
|
|
26
|
+
|
|
27
|
+
|
|
28
|
+
class UnionFind:
|
|
29
|
+
"""
|
|
30
|
+
Disjoint-set / union-find with path compression and union by rank.
|
|
31
|
+
|
|
32
|
+
``union`` and ``find`` are effectively O(alpha(N)) ~ O(1), so clustering the
|
|
33
|
+
surviving edges is linear in the number of edges.
|
|
34
|
+
"""
|
|
35
|
+
|
|
36
|
+
def __init__(self):
|
|
37
|
+
self.parent = {}
|
|
38
|
+
self.rank = {}
|
|
39
|
+
|
|
40
|
+
def add(self, x):
|
|
41
|
+
if x not in self.parent:
|
|
42
|
+
self.parent[x] = x
|
|
43
|
+
self.rank[x] = 0
|
|
44
|
+
|
|
45
|
+
def find(self, x):
|
|
46
|
+
# Find root
|
|
47
|
+
root = x
|
|
48
|
+
while self.parent[root] != root:
|
|
49
|
+
root = self.parent[root]
|
|
50
|
+
# Path compression (iterative, no recursion depth limit)
|
|
51
|
+
while self.parent[x] != root:
|
|
52
|
+
self.parent[x], x = root, self.parent[x]
|
|
53
|
+
return root
|
|
54
|
+
|
|
55
|
+
def union(self, a, b):
|
|
56
|
+
ra, rb = self.find(a), self.find(b)
|
|
57
|
+
if ra == rb:
|
|
58
|
+
return
|
|
59
|
+
if self.rank[ra] < self.rank[rb]:
|
|
60
|
+
ra, rb = rb, ra
|
|
61
|
+
self.parent[rb] = ra
|
|
62
|
+
if self.rank[ra] == self.rank[rb]:
|
|
63
|
+
self.rank[ra] += 1
|
|
64
|
+
|
|
65
|
+
def components(self):
|
|
66
|
+
"""
|
|
67
|
+
Return {root: [members...]} for every set.
|
|
68
|
+
"""
|
|
69
|
+
comps = {}
|
|
70
|
+
for node in self.parent:
|
|
71
|
+
comps.setdefault(self.find(node), []).append(node)
|
|
72
|
+
return comps
|
|
73
|
+
|
|
74
|
+
|
|
75
|
+
def _components_to_cdb(uf):
|
|
76
|
+
"""
|
|
77
|
+
Turn a populated UnionFind into a Cdb (columns: genome, primary_cluster).
|
|
78
|
+
|
|
79
|
+
Clusters are numbered deterministically: largest first, ties broken by the
|
|
80
|
+
alphabetically-smallest member. This makes runs reproducible regardless of
|
|
81
|
+
the order edges happened to stream in.
|
|
82
|
+
"""
|
|
83
|
+
comps = uf.components()
|
|
84
|
+
|
|
85
|
+
ordered = sorted(
|
|
86
|
+
comps.values(),
|
|
87
|
+
key=lambda members: (-len(members), min(members)),
|
|
88
|
+
)
|
|
89
|
+
|
|
90
|
+
genomes = []
|
|
91
|
+
clusters = []
|
|
92
|
+
for cluster_id, members in enumerate(ordered, start=1):
|
|
93
|
+
for genome in sorted(members):
|
|
94
|
+
genomes.append(genome)
|
|
95
|
+
clusters.append(cluster_id)
|
|
96
|
+
|
|
97
|
+
Cdb = pd.DataFrame({'genome': genomes, 'primary_cluster': clusters})
|
|
98
|
+
Cdb['primary_cluster'] = Cdb['primary_cluster'].astype(int)
|
|
99
|
+
return Cdb
|
|
100
|
+
|
|
101
|
+
|
|
102
|
+
def cluster_long_df(db, cutoff, all_genomes=None):
|
|
103
|
+
"""
|
|
104
|
+
Cluster an in-memory long-format MASH table with union-find (no pivot).
|
|
105
|
+
|
|
106
|
+
This is the drop-in, single-linkage replacement for the pivot -> squareform
|
|
107
|
+
-> scipy path in ``cluster_mash_database``.
|
|
108
|
+
|
|
109
|
+
Args:
|
|
110
|
+
db: DataFrame with columns 'genome1', 'genome2', and either 'dist' or
|
|
111
|
+
'similarity'.
|
|
112
|
+
cutoff: distance cutoff (1 - P_ani). Pairs with dist <= cutoff are edges.
|
|
113
|
+
all_genomes: optional iterable of every genome name, so singletons that
|
|
114
|
+
never appear in an above-cutoff edge still get their own cluster. If
|
|
115
|
+
not given, it is inferred from the genome1/genome2 columns.
|
|
116
|
+
|
|
117
|
+
Returns:
|
|
118
|
+
Cdb: DataFrame with columns 'genome', 'primary_cluster'.
|
|
119
|
+
"""
|
|
120
|
+
if 'dist' in db.columns:
|
|
121
|
+
dist = db['dist'].values
|
|
122
|
+
else:
|
|
123
|
+
dist = 1 - db['similarity'].values
|
|
124
|
+
|
|
125
|
+
uf = UnionFind()
|
|
126
|
+
|
|
127
|
+
# Seed every genome so singletons are represented
|
|
128
|
+
if all_genomes is None:
|
|
129
|
+
all_genomes = pd.unique(
|
|
130
|
+
pd.concat([db['genome1'], db['genome2']], ignore_index=True)
|
|
131
|
+
)
|
|
132
|
+
for g in all_genomes:
|
|
133
|
+
uf.add(g)
|
|
134
|
+
|
|
135
|
+
mask = dist <= cutoff
|
|
136
|
+
g1 = db['genome1'].values[mask]
|
|
137
|
+
g2 = db['genome2'].values[mask]
|
|
138
|
+
for a, b in zip(g1, g2):
|
|
139
|
+
uf.add(a)
|
|
140
|
+
uf.add(b)
|
|
141
|
+
uf.union(a, b)
|
|
142
|
+
|
|
143
|
+
return _components_to_cdb(uf)
|
|
144
|
+
|
|
145
|
+
|
|
146
|
+
def cluster_mash_files(dist_files, cutoff, all_genomes=None, chunksize=5_000_000,
|
|
147
|
+
name_from_fasta=True, progress=False):
|
|
148
|
+
"""
|
|
149
|
+
Stream one or more MASH ``dist`` output files and cluster with union-find.
|
|
150
|
+
|
|
151
|
+
Never builds the dense matrix and never holds all N^2 pairs in memory. Only
|
|
152
|
+
genome names, the union-find bookkeeping, and one ``chunksize`` block of rows
|
|
153
|
+
are resident at a time.
|
|
154
|
+
|
|
155
|
+
Args:
|
|
156
|
+
dist_files: path (str) or list of paths to MASH dist tsv output
|
|
157
|
+
(columns: genome1, genome2, dist, p, kmers).
|
|
158
|
+
cutoff: distance cutoff (1 - P_ani).
|
|
159
|
+
all_genomes: optional iterable of every genome name to seed singletons.
|
|
160
|
+
chunksize: rows per streamed block.
|
|
161
|
+
name_from_fasta: if True, map file paths in the table to genome names via
|
|
162
|
+
drep's basename logic (matches parse_mash_table behavior).
|
|
163
|
+
progress: if True, show a tqdm progress bar over streamed rows.
|
|
164
|
+
|
|
165
|
+
Returns:
|
|
166
|
+
(Cdb, stats) where stats is a dict of counters for benchmarking/logging.
|
|
167
|
+
"""
|
|
168
|
+
if isinstance(dist_files, (str, bytes)):
|
|
169
|
+
dist_files = [dist_files]
|
|
170
|
+
|
|
171
|
+
uf = UnionFind()
|
|
172
|
+
if all_genomes is not None:
|
|
173
|
+
for g in all_genomes:
|
|
174
|
+
uf.add(g)
|
|
175
|
+
|
|
176
|
+
if progress:
|
|
177
|
+
try:
|
|
178
|
+
from tqdm import tqdm
|
|
179
|
+
except ImportError:
|
|
180
|
+
logging.warning("tqdm not installed; primary-clustering progress bar disabled")
|
|
181
|
+
progress = False
|
|
182
|
+
|
|
183
|
+
total_rows = 0
|
|
184
|
+
kept_edges = 0
|
|
185
|
+
|
|
186
|
+
name_cache = {}
|
|
187
|
+
|
|
188
|
+
def to_name(x):
|
|
189
|
+
n = name_cache.get(x)
|
|
190
|
+
if n is None:
|
|
191
|
+
n = drep.d_cluster.utils._get_genome_name_from_fasta(x)
|
|
192
|
+
name_cache[x] = n
|
|
193
|
+
return n
|
|
194
|
+
|
|
195
|
+
bar = tqdm(desc=" Primary clustering (streaming)", unit=" pairs") if progress else None
|
|
196
|
+
|
|
197
|
+
for dist_file in dist_files:
|
|
198
|
+
reader = pd.read_csv(
|
|
199
|
+
dist_file,
|
|
200
|
+
names=['genome1', 'genome2', 'dist', 'p', 'kmers'],
|
|
201
|
+
usecols=['genome1', 'genome2', 'dist'],
|
|
202
|
+
dtype={'genome1': str, 'genome2': str, 'dist': np.float32},
|
|
203
|
+
sep='\t',
|
|
204
|
+
chunksize=chunksize,
|
|
205
|
+
)
|
|
206
|
+
for chunk in reader:
|
|
207
|
+
n = len(chunk)
|
|
208
|
+
total_rows += n
|
|
209
|
+
if bar is not None:
|
|
210
|
+
bar.update(n)
|
|
211
|
+
|
|
212
|
+
hits = chunk[chunk['dist'] <= cutoff]
|
|
213
|
+
if len(hits) == 0:
|
|
214
|
+
continue
|
|
215
|
+
|
|
216
|
+
g1 = hits['genome1'].values
|
|
217
|
+
g2 = hits['genome2'].values
|
|
218
|
+
for a, b in zip(g1, g2):
|
|
219
|
+
if name_from_fasta:
|
|
220
|
+
a = to_name(a)
|
|
221
|
+
b = to_name(b)
|
|
222
|
+
uf.add(a)
|
|
223
|
+
uf.add(b)
|
|
224
|
+
uf.union(a, b)
|
|
225
|
+
kept_edges += 1
|
|
226
|
+
|
|
227
|
+
if bar is not None:
|
|
228
|
+
bar.close()
|
|
229
|
+
|
|
230
|
+
Cdb = _components_to_cdb(uf)
|
|
231
|
+
|
|
232
|
+
stats = {
|
|
233
|
+
'total_pairs_streamed': total_rows,
|
|
234
|
+
'edges_kept': kept_edges,
|
|
235
|
+
'genomes': len(uf.parent),
|
|
236
|
+
'primary_clusters': Cdb['primary_cluster'].nunique() if len(Cdb) else 0,
|
|
237
|
+
}
|
|
238
|
+
return Cdb, stats
|
|
239
|
+
|
|
240
|
+
|
|
241
|
+
def edges_to_dense_dist(edges, genomes):
|
|
242
|
+
"""
|
|
243
|
+
Build a dense, symmetric distance matrix from a sparse edge table.
|
|
244
|
+
|
|
245
|
+
Only for modest genome counts -- this is the O(N^2) representation the rest of
|
|
246
|
+
this module exists to avoid. It is used solely so the primary dendrogram can
|
|
247
|
+
still be plotted for small runs; clustering itself never needs it.
|
|
248
|
+
|
|
249
|
+
Pairs absent from the edge list have no detectable similarity, so they get
|
|
250
|
+
distance 1. The diagonal is 0.
|
|
251
|
+
|
|
252
|
+
Returns:
|
|
253
|
+
DataFrame indexed and columned by genome, values = 1 - ani.
|
|
254
|
+
"""
|
|
255
|
+
names = sorted(genomes)
|
|
256
|
+
idx = {g: i for i, g in enumerate(names)}
|
|
257
|
+
n = len(names)
|
|
258
|
+
|
|
259
|
+
arr = np.ones((n, n), dtype=np.float32)
|
|
260
|
+
np.fill_diagonal(arr, 0.0)
|
|
261
|
+
|
|
262
|
+
for a, b, ani in zip(edges['genome1'].values, edges['genome2'].values,
|
|
263
|
+
edges['ani'].values):
|
|
264
|
+
i, j = idx.get(a), idx.get(b)
|
|
265
|
+
if i is None or j is None:
|
|
266
|
+
continue
|
|
267
|
+
d = 1.0 - ani
|
|
268
|
+
# edges are symmetric already, but write both to be safe against
|
|
269
|
+
# one-directional input
|
|
270
|
+
arr[i, j] = d
|
|
271
|
+
arr[j, i] = d
|
|
272
|
+
|
|
273
|
+
return pd.DataFrame(arr, index=names, columns=names)
|
|
274
|
+
|
|
275
|
+
|
|
276
|
+
def build_ndb_from_edges(edges, Cdb):
|
|
277
|
+
"""
|
|
278
|
+
Build a secondary-clustering Ndb out of primary's edge table, without running
|
|
279
|
+
any new comparisons.
|
|
280
|
+
|
|
281
|
+
Primary clustering (skani, sparse) already computed the exact ANI for every
|
|
282
|
+
pair above skani's screening threshold. Secondary clustering only ever
|
|
283
|
+
compares genomes *within* a primary cluster, and those pairs are a subset of
|
|
284
|
+
what primary already has -- so re-running skani per primary cluster
|
|
285
|
+
recomputes numbers that are already known, bit for bit.
|
|
286
|
+
|
|
287
|
+
dRep's hierarchical secondary clustering needs a complete matrix per primary
|
|
288
|
+
cluster, so pairs absent from the sparse edge list (i.e. below skani's
|
|
289
|
+
screen, meaning no meaningful similarity) are filled in as ani=0 and
|
|
290
|
+
coverage=0, and self-comparisons as 1.
|
|
291
|
+
|
|
292
|
+
Args:
|
|
293
|
+
edges: DataFrame from load_skani_sparse_edges.
|
|
294
|
+
Cdb: primary clustering result with ['genome', 'primary_cluster'].
|
|
295
|
+
|
|
296
|
+
Returns:
|
|
297
|
+
Ndb: ['reference', 'querry', 'ani', 'alignment_coverage', 'primary_cluster']
|
|
298
|
+
"""
|
|
299
|
+
g2p = Cdb.set_index('genome')['primary_cluster'].to_dict()
|
|
300
|
+
|
|
301
|
+
e = edges.copy()
|
|
302
|
+
e['pc'] = e['genome1'].map(g2p)
|
|
303
|
+
# secondary only ever compares within a primary cluster
|
|
304
|
+
e = e[e['pc'].notna() & (e['pc'] == e['genome2'].map(g2p))]
|
|
305
|
+
|
|
306
|
+
have = set(zip(e['genome1'].values, e['genome2'].values))
|
|
307
|
+
|
|
308
|
+
fill_r, fill_q, fill_pc, fill_ani, fill_cov = [], [], [], [], []
|
|
309
|
+
for pc, sub in Cdb.groupby('primary_cluster'):
|
|
310
|
+
gs = list(sub['genome'])
|
|
311
|
+
for x in gs:
|
|
312
|
+
for y in gs:
|
|
313
|
+
if x == y:
|
|
314
|
+
fill_r.append(x); fill_q.append(y); fill_pc.append(pc)
|
|
315
|
+
fill_ani.append(1.0); fill_cov.append(1.0)
|
|
316
|
+
elif (x, y) not in have:
|
|
317
|
+
fill_r.append(x); fill_q.append(y); fill_pc.append(pc)
|
|
318
|
+
fill_ani.append(0.0); fill_cov.append(0.0)
|
|
319
|
+
|
|
320
|
+
kept = e.rename(columns={'genome1': 'reference', 'genome2': 'querry',
|
|
321
|
+
'pc': 'primary_cluster'})[
|
|
322
|
+
['reference', 'querry', 'ani', 'alignment_coverage', 'primary_cluster']]
|
|
323
|
+
filled = pd.DataFrame({
|
|
324
|
+
'reference': fill_r, 'querry': fill_q, 'ani': fill_ani,
|
|
325
|
+
'alignment_coverage': fill_cov, 'primary_cluster': fill_pc,
|
|
326
|
+
})
|
|
327
|
+
|
|
328
|
+
Ndb = pd.concat([kept, filled], ignore_index=True)
|
|
329
|
+
Ndb['primary_cluster'] = Ndb['primary_cluster'].astype(int)
|
|
330
|
+
return Ndb
|
|
331
|
+
|
|
332
|
+
|
|
333
|
+
SKANI_SPARSE_COLUMNS = ['Ref_file', 'Query_file', 'ANI',
|
|
334
|
+
'Align_fraction_ref', 'Align_fraction_query']
|
|
335
|
+
|
|
336
|
+
|
|
337
|
+
def load_skani_sparse_edges(sparse_files):
|
|
338
|
+
"""
|
|
339
|
+
Load `skani triangle --sparse` output into a symmetric edge table.
|
|
340
|
+
|
|
341
|
+
skani's sparse output is already only the pairs above its screening
|
|
342
|
+
threshold, so unlike Mash's N^2 output it is small enough to hold in memory
|
|
343
|
+
(~390k rows for 10,000 genomes) and can be reused rather than recomputed.
|
|
344
|
+
|
|
345
|
+
Each input pair is emitted in both directions, because dRep treats
|
|
346
|
+
alignment_coverage as the aligned fraction of the genome named in the first
|
|
347
|
+
column, and the two directions have different coverages.
|
|
348
|
+
|
|
349
|
+
Returns:
|
|
350
|
+
DataFrame with ['genome1', 'genome2', 'ani', 'alignment_coverage'],
|
|
351
|
+
where ani and alignment_coverage are 0-1 fractions.
|
|
352
|
+
"""
|
|
353
|
+
if isinstance(sparse_files, (str, bytes)):
|
|
354
|
+
sparse_files = [sparse_files]
|
|
355
|
+
|
|
356
|
+
frames = []
|
|
357
|
+
for f in sparse_files:
|
|
358
|
+
d = pd.read_csv(f, sep='\t', usecols=SKANI_SPARSE_COLUMNS,
|
|
359
|
+
dtype={'Ref_file': str, 'Query_file': str,
|
|
360
|
+
'ANI': np.float32,
|
|
361
|
+
'Align_fraction_ref': np.float32,
|
|
362
|
+
'Align_fraction_query': np.float32})
|
|
363
|
+
if len(d) == 0:
|
|
364
|
+
continue
|
|
365
|
+
a = np.array([drep.d_cluster.utils._get_genome_name_from_fasta(x)
|
|
366
|
+
for x in d['Ref_file'].values])
|
|
367
|
+
b = np.array([drep.d_cluster.utils._get_genome_name_from_fasta(x)
|
|
368
|
+
for x in d['Query_file'].values])
|
|
369
|
+
ani = (d['ANI'].values / 100).astype(np.float32)
|
|
370
|
+
af_a = (d['Align_fraction_ref'].values / 100).astype(np.float32)
|
|
371
|
+
af_b = (d['Align_fraction_query'].values / 100).astype(np.float32)
|
|
372
|
+
|
|
373
|
+
frames.append(pd.DataFrame({
|
|
374
|
+
'genome1': np.concatenate([a, b]),
|
|
375
|
+
'genome2': np.concatenate([b, a]),
|
|
376
|
+
'ani': np.concatenate([ani, ani]),
|
|
377
|
+
# coverage is always the aligned fraction of the genome1 genome
|
|
378
|
+
'alignment_coverage': np.concatenate([af_a, af_b]),
|
|
379
|
+
}))
|
|
380
|
+
|
|
381
|
+
if not frames:
|
|
382
|
+
return pd.DataFrame(columns=['genome1', 'genome2', 'ani', 'alignment_coverage'])
|
|
383
|
+
|
|
384
|
+
edges = pd.concat(frames, ignore_index=True)
|
|
385
|
+
# drop self comparisons; they are added back explicitly where needed
|
|
386
|
+
return edges[edges['genome1'] != edges['genome2']].reset_index(drop=True)
|
|
387
|
+
|
|
388
|
+
|
|
389
|
+
def cluster_edges(edges, ani_threshold, all_genomes, cov_threshold=0.0):
|
|
390
|
+
"""
|
|
391
|
+
Union-find clustering of an in-memory edge table (see load_skani_sparse_edges).
|
|
392
|
+
|
|
393
|
+
Args:
|
|
394
|
+
edges: DataFrame with ['genome1', 'genome2', 'ani', 'alignment_coverage'].
|
|
395
|
+
ani_threshold: minimum ANI (0-1) for a pair to be an edge.
|
|
396
|
+
all_genomes: every genome name, so singletons get their own cluster.
|
|
397
|
+
cov_threshold: minimum alignment_coverage (0-1) for a pair to be an edge.
|
|
398
|
+
See run_skani_triangle_sparse for why this matters -- without it,
|
|
399
|
+
genomes sharing a small conserved region chain together.
|
|
400
|
+
|
|
401
|
+
Returns:
|
|
402
|
+
(Cdb, stats)
|
|
403
|
+
"""
|
|
404
|
+
uf = UnionFind()
|
|
405
|
+
for g in all_genomes:
|
|
406
|
+
uf.add(g)
|
|
407
|
+
|
|
408
|
+
keep = edges['ani'].values >= ani_threshold
|
|
409
|
+
if cov_threshold > 0:
|
|
410
|
+
keep &= edges['alignment_coverage'].values >= cov_threshold
|
|
411
|
+
|
|
412
|
+
g1 = edges['genome1'].values[keep]
|
|
413
|
+
g2 = edges['genome2'].values[keep]
|
|
414
|
+
for a, b in zip(g1, g2):
|
|
415
|
+
uf.add(a)
|
|
416
|
+
uf.add(b)
|
|
417
|
+
uf.union(a, b)
|
|
418
|
+
|
|
419
|
+
Cdb = _components_to_cdb(uf)
|
|
420
|
+
stats = {
|
|
421
|
+
'edges_total': len(edges),
|
|
422
|
+
'edges_kept': int(keep.sum()),
|
|
423
|
+
'genomes': len(uf.parent),
|
|
424
|
+
'primary_clusters': Cdb['primary_cluster'].nunique() if len(Cdb) else 0,
|
|
425
|
+
}
|
|
426
|
+
return Cdb, stats
|
|
427
|
+
|
|
428
|
+
|
|
429
|
+
def cluster_skani_sparse_files(sparse_files, ani_threshold, all_genomes,
|
|
430
|
+
cov_threshold=0.0, chunksize=2_000_000, progress=False):
|
|
431
|
+
"""
|
|
432
|
+
Stream `skani triangle --sparse` output and cluster with union-find.
|
|
433
|
+
|
|
434
|
+
Unlike MASH ``dist``, the sparse skani output already contains *only* the
|
|
435
|
+
above-screening-threshold pairs (an edge list), so there is no N^2 to stream
|
|
436
|
+
at all -- just the surviving edges. This is the low-RAM, low-disk primary
|
|
437
|
+
clustering path for very large genome sets.
|
|
438
|
+
|
|
439
|
+
Expected columns (skani >= 0.2, with a header row):
|
|
440
|
+
Ref_file, Query_file, ANI, Align_fraction_ref, Align_fraction_query,
|
|
441
|
+
Ref_name, Query_name
|
|
442
|
+
|
|
443
|
+
Args:
|
|
444
|
+
sparse_files: path or list of paths to sparse skani output.
|
|
445
|
+
ani_threshold: percent ANI (e.g. 90.0 for P_ani=0.9). Pairs at or above
|
|
446
|
+
this are treated as edges.
|
|
447
|
+
all_genomes: iterable of every genome name, so singletons that skani
|
|
448
|
+
screened out still get their own primary cluster.
|
|
449
|
+
cov_threshold: minimum aligned fraction (0-1) for a pair to count as an
|
|
450
|
+
edge. skani reports two percentages; the larger is used (matching
|
|
451
|
+
dRep's 'larger' coverage convention). 0 disables the filter.
|
|
452
|
+
chunksize: rows per streamed block.
|
|
453
|
+
progress: show a tqdm bar over streamed edges.
|
|
454
|
+
|
|
455
|
+
Returns:
|
|
456
|
+
(Cdb, Mdb, stats):
|
|
457
|
+
Cdb: ['genome', 'primary_cluster']
|
|
458
|
+
Mdb: reduced long-format table of the surviving edges only
|
|
459
|
+
(['genome1', 'genome2', 'similarity', 'dist']), for storage/plots.
|
|
460
|
+
stats: dict of counters.
|
|
461
|
+
"""
|
|
462
|
+
if isinstance(sparse_files, (str, bytes)):
|
|
463
|
+
sparse_files = [sparse_files]
|
|
464
|
+
|
|
465
|
+
uf = UnionFind()
|
|
466
|
+
for g in all_genomes:
|
|
467
|
+
uf.add(g)
|
|
468
|
+
|
|
469
|
+
if progress:
|
|
470
|
+
try:
|
|
471
|
+
from tqdm import tqdm
|
|
472
|
+
except ImportError:
|
|
473
|
+
logging.warning("tqdm not installed; primary-clustering progress bar disabled")
|
|
474
|
+
progress = False
|
|
475
|
+
bar = tqdm(desc=" Primary clustering (sparse skani)", unit=" edges") if progress else None
|
|
476
|
+
|
|
477
|
+
edges_seen = 0
|
|
478
|
+
kept_edges = 0
|
|
479
|
+
cov_pct = cov_threshold * 100.0
|
|
480
|
+
mdb_g1, mdb_g2, mdb_sim = [], [], []
|
|
481
|
+
|
|
482
|
+
name_cache = {}
|
|
483
|
+
|
|
484
|
+
def to_name(x):
|
|
485
|
+
n = name_cache.get(x)
|
|
486
|
+
if n is None:
|
|
487
|
+
n = drep.d_cluster.utils._get_genome_name_from_fasta(x)
|
|
488
|
+
name_cache[x] = n
|
|
489
|
+
return n
|
|
490
|
+
|
|
491
|
+
for sparse_file in sparse_files:
|
|
492
|
+
reader = pd.read_csv(
|
|
493
|
+
sparse_file,
|
|
494
|
+
sep='\t',
|
|
495
|
+
usecols=['Ref_file', 'Query_file', 'ANI',
|
|
496
|
+
'Align_fraction_ref', 'Align_fraction_query'],
|
|
497
|
+
dtype={'Ref_file': str, 'Query_file': str, 'ANI': np.float32,
|
|
498
|
+
'Align_fraction_ref': np.float32, 'Align_fraction_query': np.float32},
|
|
499
|
+
chunksize=chunksize,
|
|
500
|
+
)
|
|
501
|
+
for chunk in reader:
|
|
502
|
+
edges_seen += len(chunk)
|
|
503
|
+
if bar is not None:
|
|
504
|
+
bar.update(len(chunk))
|
|
505
|
+
|
|
506
|
+
hits = chunk[chunk['ANI'] >= ani_threshold]
|
|
507
|
+
if cov_pct > 0:
|
|
508
|
+
larger_af = np.maximum(hits['Align_fraction_ref'].values,
|
|
509
|
+
hits['Align_fraction_query'].values)
|
|
510
|
+
hits = hits[larger_af >= cov_pct]
|
|
511
|
+
if len(hits) == 0:
|
|
512
|
+
continue
|
|
513
|
+
|
|
514
|
+
for r, q, ani in zip(hits['Ref_file'].values,
|
|
515
|
+
hits['Query_file'].values,
|
|
516
|
+
hits['ANI'].values):
|
|
517
|
+
a, b = to_name(r), to_name(q)
|
|
518
|
+
if a == b:
|
|
519
|
+
continue
|
|
520
|
+
uf.add(a)
|
|
521
|
+
uf.add(b)
|
|
522
|
+
uf.union(a, b)
|
|
523
|
+
mdb_g1.append(a)
|
|
524
|
+
mdb_g2.append(b)
|
|
525
|
+
mdb_sim.append(ani / 100.0)
|
|
526
|
+
kept_edges += 1
|
|
527
|
+
|
|
528
|
+
if bar is not None:
|
|
529
|
+
bar.close()
|
|
530
|
+
|
|
531
|
+
Cdb = _components_to_cdb(uf)
|
|
532
|
+
|
|
533
|
+
Mdb = pd.DataFrame({'genome1': mdb_g1, 'genome2': mdb_g2,
|
|
534
|
+
'similarity': np.array(mdb_sim, dtype=np.float32)})
|
|
535
|
+
Mdb['dist'] = 1 - Mdb['similarity']
|
|
536
|
+
|
|
537
|
+
stats = {
|
|
538
|
+
'edges_seen': edges_seen,
|
|
539
|
+
'edges_kept': kept_edges,
|
|
540
|
+
'genomes': len(uf.parent),
|
|
541
|
+
'primary_clusters': Cdb['primary_cluster'].nunique() if len(Cdb) else 0,
|
|
542
|
+
}
|
|
543
|
+
return Cdb, Mdb, stats
|