Coverage for cosmolayer/store/_chalcedon/butina_cluster.py: 87%
69 statements
« prev ^ index » next coverage.py v7.15.4, created at 2026-08-26 00:09 +0000
« prev ^ index » next coverage.py v7.15.4, created at 2026-08-26 00:09 +0000
1"""Butina clustering algorithm (Butina, JCICS 39 747-750, 1999).
3Vendored from https://github.com/rowansci/chalcedon at commit
492da3cc5bd6ffb0d397cb49ea556f168d1d38b7e (MIT license, see ``NOTICE`` in
5this directory). Modified from upstream to add the ``progress`` parameter
6gating the tqdm bars, so a library call doesn't print to stderr by
7default; to use ``tqdm.auto``; and to leave bars on screen when enabled.
8"""
10from __future__ import annotations
12from typing import TYPE_CHECKING
14import numpy as np
15from tqdm.auto import tqdm
17from cosmolayer.store._chalcedon.tanimoto_similarity import (
18 Precision,
19 TanimotoSimilarity,
20)
22if TYPE_CHECKING:
23 from numpy.typing import NDArray
25_DEFAULT_COUNT_BLOCK_SIZE = 2500
26_DEFAULT_ASSIGN_BATCH_SIZE = 500
27_DIAGONAL_SPLIT_MINIMUM = 512 # diagonal blocks below this size aren't split
30def butina_cluster(
31 fingerprints: NDArray[np.integer | np.floating],
32 cutoff: float = 0.65,
33 count_block_size: int = _DEFAULT_COUNT_BLOCK_SIZE,
34 assign_batch_size: int = _DEFAULT_ASSIGN_BATCH_SIZE,
35 dtype: Precision = "float32",
36 progress: bool = False,
37) -> NDArray[np.intp]:
38 """Cluster fingerprints using the Butina algorithm.
40 Count-sort-assign strategy: count neighbors via upper-triangle BLAS
41 blocks, sort by count descending, greedily assign in batched sgemm
42 passes. Produces clusters matching RDKit's reference implementation at
43 typical cheminformatics cutoffs; at uncommon cutoffs a small fraction
44 of boundary-pair decisions may differ due to float rounding.
46 Args:
47 fingerprints: non-negative fingerprint matrix of shape `(n, d)`.
48 Binary, count, and positive float vectors are all supported.
49 cutoff: Tanimoto distance cutoff; pairs with distance ≤ `cutoff`
50 are neighbors.
51 count_block_size: side length of the count-phase BLAS blocks.
52 Workspace cost scales as O(count_block_size**2).
53 assign_batch_size: number of centers per assign-phase sgemm call.
54 Workspace cost scales as O(assign_batch_size * n).
55 dtype: working precision. Pass `"float64"` for higher precision at
56 ≈2x runtime and ≈2x memory.
57 progress: whether to print tqdm progress bars to stderr. Not part
58 of upstream chalcedon; added for library use. Default False.
60 Returns:
61 Cluster ID per point, shape `(n,)`. Cluster 0 is the largest.
63 Examples:
64 >>> import numpy as np
65 >>> fingerprints = np.array([
66 ... [1, 1, 0, 0],
67 ... [1, 1, 1, 0],
68 ... [0, 0, 1, 1],
69 ... [0, 0, 0, 1],
70 ... ], dtype=np.uint8)
71 >>> butina_cluster(fingerprints, cutoff=0.5).tolist()
72 [1, 1, 0, 0]
73 """
74 similarity = TanimotoSimilarity(fingerprints, dtype=dtype)
75 fingerprint_count = similarity.fingerprint_count
76 block_size = min(count_block_size, fingerprint_count)
77 batch_size = min(assign_batch_size, fingerprint_count)
79 # Shared workspace, reshaped per block.
80 max_workspace = max(block_size * block_size, batch_size * fingerprint_count)
81 dot_products_flat = np.empty(max_workspace, dtype=similarity.precision)
82 unions_flat = np.empty(max_workspace, dtype=similarity.precision)
83 boolean_flat = np.empty(max_workspace, dtype=bool)
85 # Phase 1: count neighbors via upper-triangle blocks. Diagonal blocks split
86 # recursively for ssyrk-equivalent FLOPs savings in pure numpy.
87 neighbor_counts = np.zeros(fingerprint_count, dtype=np.intp)
89 def count_block(row_start: int, row_end: int, column_start: int, column_end: int) -> None:
90 """Add neighbor counts contributed by one upper-triangle block."""
91 row_count = row_end - row_start
92 column_count = column_end - column_start
93 size = row_count * column_count
94 neighbors = similarity._block_neighbors(
95 row_start,
96 row_end,
97 column_start,
98 column_end,
99 cutoff,
100 dot_products_flat[:size].reshape(row_count, column_count),
101 unions_flat[:size].reshape(row_count, column_count),
102 boolean_flat[:size].reshape(row_count, column_count),
103 )
104 neighbor_counts[row_start:row_end] += np.count_nonzero(neighbors, axis=1)
105 if row_start != column_start:
106 neighbor_counts[column_start:column_end] += np.count_nonzero(neighbors, axis=0)
108 def count_diagonal(start: int, end: int) -> None:
109 """Count neighbors in a diagonal block, recursively splitting if large enough."""
110 if end - start >= _DIAGONAL_SPLIT_MINIMUM:
111 middle = start + (end - start) // 2
112 count_diagonal(start, middle)
113 count_diagonal(middle, end)
114 count_block(start, middle, middle, end)
115 else:
116 count_block(start, end, start, end)
118 for row_start in tqdm(
119 range(0, fingerprint_count, block_size),
120 desc="Counting neighbors",
121 leave=True,
122 disable=not progress,
123 ):
124 row_end = min(row_start + block_size, fingerprint_count)
125 count_diagonal(row_start, row_end)
126 for column_start in range(row_start + block_size, fingerprint_count, block_size):
127 column_end = min(column_start + block_size, fingerprint_count)
128 count_block(row_start, row_end, column_start, column_end)
130 # Phase 2: sort by count desc, ties by higher index first.
131 order = np.lexsort((-np.arange(fingerprint_count, dtype=np.intp), -neighbor_counts))
133 # Phase 3: batched greedy assign against a shrinking compact unassigned list.
134 cluster_id = np.full(fingerprint_count, -1, dtype=np.intp)
135 unassigned = np.arange(fingerprint_count, dtype=np.intp)
136 next_cluster_id = 0
137 cursor = 0
138 with tqdm(
139 total=fingerprint_count,
140 desc="Assigning clusters",
141 leave=True,
142 disable=not progress,
143 ) as progress_bar:
144 while cursor < fingerprint_count:
145 pending = order[cursor:]
146 pending_positions = np.flatnonzero(cluster_id[pending] == -1)
147 if len(pending_positions) == 0:
148 progress_bar.update(fingerprint_count - cursor)
149 break
150 center_count = min(batch_size, len(pending_positions))
151 step = int(pending_positions[center_count - 1]) + 1
152 centers = pending[pending_positions[:center_count]]
153 cursor += step
154 progress_bar.update(step)
156 unassigned_count = len(unassigned)
157 size = center_count * unassigned_count
158 is_neighbor = similarity._rows_neighbors_against(
159 centers,
160 unassigned,
161 cutoff,
162 dot_products_flat[:size].reshape(center_count, unassigned_count),
163 unions_flat[:size].reshape(center_count, unassigned_count),
164 boolean_flat[:size].reshape(center_count, unassigned_count),
165 )
166 # Avoids recomputing `cluster_id[unassigned] == -1` per center.
167 still_unassigned = np.ones(unassigned_count, dtype=bool)
168 for batch_index, center in enumerate(centers.tolist()):
169 if cluster_id[center] != -1:
170 continue
171 # Drop points already claimed by earlier centers in this batch.
172 member_mask = is_neighbor[batch_index] & still_unassigned
173 cluster_id[unassigned[member_mask]] = next_cluster_id
174 still_unassigned[member_mask] = False
175 next_cluster_id += 1
177 unassigned = np.flatnonzero(cluster_id == -1)
179 return cluster_id