-
Notifications
You must be signed in to change notification settings - Fork 33
Add ButinaClustering estimator #625
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
Open
rayair250-droid
wants to merge
2
commits into
MLCIL:master
Choose a base branch
from
rayair250-droid:feat/butina-clustering
base: master
Could not load branches
Branch not found: {{ refName }}
Loading
Could not load tags
Nothing to show
Loading
Are you sure you want to change the base?
Some commits from the old base branch may be removed from the timeline,
and old review comments may become outdated.
Open
Changes from all commits
Commits
Show all changes
2 commits
Select commit
Hold shift + click to select a range
File filter
Filter by extension
Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
There are no files selected for viewing
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -1 +1,2 @@ | ||
| from .butina import ButinaClustering | ||
| from .maxmin import MaxMinClustering |
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,175 @@ | ||
| from collections.abc import Sequence | ||
|
|
||
| import numpy as np | ||
| from rdkit.DataStructs import BulkTanimotoSimilarity | ||
| from rdkit.DataStructs.cDataStructs import ExplicitBitVect | ||
| from rdkit.ML.Cluster import Butina | ||
| from scipy import sparse | ||
| from sklearn.base import BaseEstimator, ClusterMixin | ||
| from sklearn.utils._param_validation import Interval, RealNotInt | ||
| from sklearn.utils.validation import check_is_fitted, validate_data | ||
|
|
||
| from skfp.clustering import utils | ||
|
|
||
|
|
||
| class ButinaClustering(BaseEstimator, ClusterMixin): | ||
| """ | ||
| Taylor-Butina clustering. | ||
|
|
||
| A density-based clustering algorithm for binary fingerprints using Tanimoto | ||
| similarity, also known as sphere exclusion clustering [1]_ [2]_. Cluster centroids | ||
| are chosen so that no two centroids are closer than a given Tanimoto distance | ||
| ``distance_threshold``; every remaining sample joins the cluster of the first | ||
| centroid within that radius. In contrast to centroid-based methods like MaxMin | ||
| clustering, Butina clusters follow the density of the data and can vary widely | ||
| in size. | ||
|
|
||
| Parameters | ||
| ---------- | ||
| distance_threshold : float, default=0.65 | ||
| Tanimoto distance threshold (distance = 1 - Tanimoto similarity), the minimal | ||
| distance between cluster centroids. Must be between 0 and 1. The default follows | ||
| the ECFP4 activity threshold used by the Butina train/test splitter [3]_. | ||
|
|
||
| Attributes | ||
| ---------- | ||
| centroid_indices_ : list of int | ||
| Indices of samples chosen as cluster centroids after :meth:`fit`. | ||
|
|
||
| centroid_bitvectors_ : list of ExplicitBitVect | ||
| Centroid fingerprints as RDKit ExplicitBitVect objects. | ||
|
|
||
| centroids_ : ndarray of uint8, shape (n_centroids, n_bits) | ||
| Centroids as a boolean NumPy array when the input was a dense array or | ||
| sparse matrix. | ||
|
|
||
| labels_ : ndarray of int, shape (n_samples,) | ||
| Cluster labels for each sample. | ||
|
|
||
| Notes | ||
| ----- | ||
| This estimator follows the scikit-learn estimator API and accepts dense NumPy | ||
| arrays, SciPy CSR sparse arrays, or lists/tuples of RDKit ``ExplicitBitVect`` | ||
| objects as input. | ||
|
|
||
| References | ||
| ---------- | ||
| .. [1] `Darko Butina | ||
| "Unsupervised Data Base Clustering Based on Daylight's Fingerprint and Tanimoto | ||
| Similarity: A Fast and Automated Way To Cluster Small and Large Data Sets" | ||
| J. Chem. Inf. Comput. Sci., 1999, 39, 4, 747-750 | ||
| <https://doi.org/10.1021/ci9803381>`_ | ||
|
|
||
| .. [2] `Robin Taylor | ||
| "Simulation Analysis of Experimental Design Strategies for Screening Random | ||
| Compounds as Potential New Drugs and Agrochemicals" | ||
| J. Chem. Inf. Comput. Sci., 1995, 35, 1, 59-67 | ||
| <https://doi.org/10.1021/ci00023a009>`_ | ||
|
|
||
| .. [3] `Roger A. Sayle | ||
| "2D similarity, diversity and clustering in RDKit" | ||
| RDKit User Group Meeting 2019 | ||
| <https://github.com/rdkit/UGM_2019/blob/master/Presentations/Sayle_Clustering.pdf>`_ | ||
| """ | ||
|
|
||
| _parameter_constraints: dict = { | ||
| "distance_threshold": [Interval(RealNotInt, 0, 1, closed="both")], | ||
| } | ||
|
|
||
| def __init__(self, distance_threshold: float = 0.65): | ||
| self.distance_threshold = distance_threshold | ||
|
|
||
| def fit(self, X: np.ndarray | sparse.csr_array | Sequence[ExplicitBitVect], y=None): # noqa: ARG002 | ||
| """ | ||
| Fit the Butina clustering model. | ||
|
|
||
| Parameters | ||
| ---------- | ||
| X : {array-like, sparse matrix, sequence of ExplicitBitVect} | ||
| Binary fingerprint data of shape ``(n_samples, n_bits)``, or a | ||
| list/tuple of RDKit ``ExplicitBitVect`` objects. | ||
|
|
||
| y : ignored | ||
| Not used, present for API consistency with scikit-learn. | ||
|
|
||
| Returns | ||
| ------- | ||
| self : ButinaClustering | ||
| Fitted estimator. | ||
| """ | ||
| super()._validate_params() | ||
| X = validate_data(self, X, accept_sparse=["csr"], ensure_2d=False) | ||
|
|
||
| fps = utils.array_to_bitvectors(X) | ||
| n_samples = len(fps) | ||
|
|
||
| # Condensed lower-triangle Tanimoto distances, the format expected by | ||
| # Butina.ClusterData(isDistData=True): dist(i, j) for i > j, row-major. | ||
| condensed_distances: list[float] = [] | ||
| for i in range(1, n_samples): | ||
| sims = BulkTanimotoSimilarity(fps[i], fps[:i]) | ||
| condensed_distances.extend(1.0 - sim for sim in sims) | ||
|
|
||
| clusters = Butina.ClusterData( | ||
| condensed_distances, | ||
| n_samples, | ||
| self.distance_threshold, | ||
| isDistData=True, | ||
| reordering=True, | ||
| ) | ||
|
|
||
| # In each RDKit Butina cluster the first element is the centroid. | ||
| self.centroid_indices_ = [cluster[0] for cluster in clusters] | ||
| self.centroid_bitvectors_ = [fps[i] for i in self.centroid_indices_] | ||
|
|
||
| if sparse.issparse(X) or isinstance(X, np.ndarray): | ||
| arr = X.todense() if sparse.issparse(X) else X | ||
| self.centroids_ = np.asarray(arr)[self.centroid_indices_].astype(np.uint8) | ||
|
|
||
| labels = np.empty(n_samples, dtype=int) | ||
| for cluster_id, cluster in enumerate(clusters): | ||
| for sample_idx in cluster: | ||
| labels[sample_idx] = cluster_id | ||
| self.labels_ = labels | ||
|
|
||
| return self | ||
|
|
||
| def predict( | ||
| self, X: np.ndarray | sparse.csr_array | Sequence[ExplicitBitVect] | ||
| ) -> np.ndarray: | ||
| """ | ||
| Assign new samples to existing centroids. | ||
|
|
||
| Parameters | ||
| ---------- | ||
| X : {array-like, sparse matrix, sequence of ExplicitBitVect} | ||
| New samples to assign. The input formats match those accepted by :meth:`fit`. | ||
|
|
||
| Returns | ||
| ------- | ||
| labels : ndarray of int, shape (n_samples,) | ||
| Cluster labels for the input samples, assigned to the nearest centroid by | ||
| Tanimoto similarity. | ||
| """ | ||
| check_is_fitted(self) | ||
| X = validate_data(self, X, accept_sparse=["csr"], ensure_2d=False) | ||
| bitvecs = utils.array_to_bitvectors(X) | ||
| return utils.assign_labels(bitvecs, self.centroid_bitvectors_) | ||
|
|
||
| def fit_predict( | ||
| self, X: np.ndarray | sparse.csr_array | Sequence[ExplicitBitVect], y=None | ||
| ) -> np.ndarray: | ||
| """ | ||
| Fit the Butina clustering model and return cluster labels. | ||
|
|
||
| This is a convenience method that calls :meth:`fit` and returns ``labels_``. | ||
| """ | ||
| self.fit(X, y) | ||
| return self.labels_ | ||
|
|
||
| def get_clusters_and_points(self) -> dict[int, np.ndarray]: | ||
| """ | ||
| Return a mapping from cluster id to the indices of its member samples. | ||
| """ | ||
| check_is_fitted(self) | ||
| return utils.clusters_and_points(self.labels_) | ||
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,47 @@ | ||
| import numpy as np | ||
| from rdkit.DataStructs import BulkTanimotoSimilarity | ||
| from rdkit.DataStructs.cDataStructs import ExplicitBitVect | ||
| from scipy import sparse | ||
|
|
||
|
|
||
| def array_to_bitvectors(X) -> list[ExplicitBitVect]: | ||
| """Convert input data to a list of RDKit ExplicitBitVect objects.""" | ||
|
Member
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. All docstrings use 3 separate lines, even oneliners, by convention |
||
| if np.ndim(X) == 1 and len(X) > 0 and isinstance(X[0], ExplicitBitVect): | ||
| return list(X) | ||
|
|
||
| bitvecs: list[ExplicitBitVect] = [] | ||
|
|
||
| if sparse.issparse(X): | ||
| X = X.tocsr() | ||
| n_samples, n_bits = X.shape | ||
| for i in range(n_samples): | ||
| vec = ExplicitBitVect(n_bits) | ||
| for bit in X.indices[X.indptr[i] : X.indptr[i + 1]]: | ||
| vec.SetBit(int(bit)) | ||
| bitvecs.append(vec) | ||
| return bitvecs | ||
|
|
||
| n_samples, n_bits = X.shape | ||
| for i in range(n_samples): | ||
| vec = ExplicitBitVect(n_bits) | ||
| for bit in np.flatnonzero(X[i]): | ||
| vec.SetBit(int(bit)) | ||
| bitvecs.append(vec) | ||
|
|
||
| return bitvecs | ||
|
|
||
|
|
||
| def assign_labels( | ||
| vectors: list[ExplicitBitVect], centroid_bitvectors: list[ExplicitBitVect] | ||
| ) -> np.ndarray: | ||
| """Assign each sample to the nearest centroid by Tanimoto similarity.""" | ||
| labels = np.empty(len(vectors), dtype=int) | ||
| for i, fp in enumerate(vectors): | ||
| sims = BulkTanimotoSimilarity(fp, centroid_bitvectors) | ||
| labels[i] = int(np.argmax(sims)) | ||
| return labels | ||
|
|
||
|
|
||
| def clusters_and_points(labels: np.ndarray) -> dict[int, np.ndarray]: | ||
| """Map each cluster ID to the indices of its member samples.""" | ||
| return {int(k): np.where(labels == k)[0] for k in np.unique(labels)} | ||
Oops, something went wrong.
Add this suggestion to a batch that can be applied as a single commit.
This suggestion is invalid because no changes were made to the code.
Suggestions cannot be applied while the pull request is closed.
Suggestions cannot be applied while viewing a subset of changes.
Only one suggestion per line can be applied in a batch.
Add this suggestion to a batch that can be applied as a single commit.
Applying suggestions on deleted lines is not supported.
You must change the existing code in this line in order to create a valid suggestion.
Outdated suggestions cannot be applied.
This suggestion has been applied or marked resolved.
Suggestions cannot be applied from pending reviews.
Suggestions cannot be applied on multi-line comments.
Suggestions cannot be applied while the pull request is queued to merge.
Suggestion cannot be applied right now. Please check back later.
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Import directly from utils, this is the convention that we use