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.
@@ -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