diff --git a/docs/source/cuml-accel/compatibility.rst b/docs/source/cuml-accel/compatibility.rst index c289a9afb0..e543a9bde9 100644 --- a/docs/source/cuml-accel/compatibility.rst +++ b/docs/source/cuml-accel/compatibility.rst @@ -619,10 +619,38 @@ UMAP HDBSCAN ------- -.. dropdown:: ``HDBSCAN`` +.. dropdown:: ``sklearn.cluster.HDBSCAN`` + :name: sklearn-hdbscan-limitations + + ``sklearn.cluster.HDBSCAN`` will fall back to CPU in the following cases: + + - If ``metric`` is not ``"l2"`` or ``"euclidean"``. + - If ``metric_params`` is not empty. + - If ``store_centers`` is not ``None``. + - If the effective scikit-learn ``min_samples`` value is outside the range + of 2 through 1024. In particular, ``min_samples=1`` falls back to CPU. + - If the input is sparse, precomputed, or contains non-finite values. + + The ``algorithm``, ``leaf_size``, ``n_jobs``, and ``copy`` parameters control + CPU implementation details and do not change GPU execution for supported + dense inputs. The public ``dbscan_clustering`` method runs on CPU using the + linkage tree computed during the GPU fit; it does not refit the estimator. + + Additional notes: + + - ``max_cluster_size=None`` is translated to cuML's unlimited value. + - cuML uses float32 computation and a parallel MST, so linkage distances, + probabilities, cluster labels, and even cluster assignments may differ + numerically from scikit-learn's CPU implementation. + - cuML's ``HDBSCAN`` implementation uses a parallel MST, which means + the results are not deterministic when there are duplicates in the mutual + reachability graph. + - ONNX export via ``skl2onnx`` is not supported for this estimator. + +.. dropdown:: ``hdbscan.HDBSCAN`` :name: hdbscan-limitations - ``HDBSCAN`` will fall back to CPU in the following cases: + ``hdbscan.HDBSCAN`` will fall back to CPU in the following cases: - If ``metric`` is not ``"l2"`` or ``"euclidean"``. - If a ``memory`` location is configured. @@ -637,6 +665,9 @@ HDBSCAN Additional notes: + - cuML uses float32 computation and a parallel MST, so linkage distances, + probabilities, cluster labels, and even cluster assignments may differ + numerically from the contrib CPU implementation. - cuML's ``HDBSCAN`` implementation uses a parallel MST, which means the results are not deterministic when there are duplicates in the mutual reachability graph. diff --git a/python/cuml/cuml/accel/_overrides/sklearn/cluster.py b/python/cuml/cuml/accel/_overrides/sklearn/cluster.py index 380fea3d6e..7e542cfda4 100644 --- a/python/cuml/cuml/accel/_overrides/sklearn/cluster.py +++ b/python/cuml/cuml/accel/_overrides/sklearn/cluster.py @@ -1,12 +1,220 @@ # -# SPDX-FileCopyrightText: Copyright (c) 2025-2026, NVIDIA CORPORATION. +# SPDX-FileCopyrightText: Copyright (c) 2025-2026, NVIDIA CORPORATION & AFFILIATES. All rights reserved. # SPDX-License-Identifier: Apache-2.0 # +import warnings + +import cupy as cp +import numpy as np + import cuml.cluster from cuml.accel.estimator_proxy import ProxyBase +from cuml.cluster.hdbscan.hdbscan import _HDBSCANState +from cuml.internals.interop import InteropMixin, UnsupportedOnGPU +from cuml.internals.outputs import ArrayIndexPair + +__all__ = ("KMeans", "DBSCAN", "HDBSCAN", "SpectralClustering") + + +_SKLEARN_HIERARCHY_DTYPE = np.dtype( + [ + ("left_node", np.intp), + ("right_node", np.intp), + ("value", np.float64), + ("cluster_size", np.intp), + ] +) + + +def _linkage_to_sklearn(linkage): + """Convert a cuML dense linkage matrix to sklearn's structured array.""" + linkage = np.asarray(linkage) + out = np.empty(linkage.shape[0], dtype=_SKLEARN_HIERARCHY_DTYPE) + out["left_node"] = linkage[:, 0] + out["right_node"] = linkage[:, 1] + out["value"] = linkage[:, 2] + out["cluster_size"] = linkage[:, 3] + return out + + +def _linkage_from_sklearn(linkage): + """Convert sklearn's structured linkage tree to a cuML dense matrix.""" + return np.column_stack( + [ + linkage["left_node"], + linkage["right_node"], + linkage["value"], + linkage["cluster_size"], + ] + ).astype(np.float64, copy=False) + + +def _check_hdbscan_input(model, X): + """Check data-dependent conditions unsupported by cuML HDBSCAN.""" + try: + if hasattr(X, "__cuda_array_interface__"): + X = cp.asarray(X) + is_finite = bool(cp.isfinite(X).all().item()) + else: + X = np.asarray(X) + if X.dtype.kind not in "buif": + raise TypeError + is_finite = bool(np.isfinite(X).all()) + except (TypeError, ValueError): + # Defer input coercion and validation to sklearn. + raise UnsupportedOnGPU("Input type is not supported") from None + + if X.ndim != 2: + raise UnsupportedOnGPU("Input does not have a two-dimensional shape") + + min_samples = ( + model.min_cluster_size + if model.min_samples is None + else model.min_samples + ) + if X.shape[0] < 2 or min_samples > X.shape[0]: + # Let sklearn produce its data-dependent validation error. + raise UnsupportedOnGPU("The number of samples is not supported") + + if not is_finite: + raise UnsupportedOnGPU("Input contains NaN or infinity") + -__all__ = ("KMeans", "DBSCAN", "SpectralClustering") +class _SklearnHDBSCAN(cuml.cluster.HDBSCAN): + """cuML HDBSCAN adapter for sklearn.cluster.HDBSCAN.""" + + _cpu_class_path = "sklearn.cluster.HDBSCAN" + + @classmethod + def _params_from_cpu(cls, model): + if model.metric not in ("euclidean", "l2"): + raise UnsupportedOnGPU( + f"`metric={model.metric!r}` is not supported" + ) + if model.metric_params: + raise UnsupportedOnGPU( + f"`metric_params={model.metric_params!r}` is not supported" + ) + if model.store_centers is not None: + raise UnsupportedOnGPU( + f"`store_centers={model.store_centers!r}` is not supported" + ) + + sklearn_min_samples = ( + model.min_cluster_size + if model.min_samples is None + else model.min_samples + ) + # Native cuML follows the contrib HDBSCAN convention and adds one to + # min_samples before constructing the KNN graph. sklearn's + # min_samples=k therefore corresponds to native min_samples=k-1. + if not 2 <= sklearn_min_samples <= 1024: + raise UnsupportedOnGPU( + f"`min_samples={model.min_samples!r}` is not supported" + ) + min_samples = sklearn_min_samples - 1 + + return { + "min_cluster_size": model.min_cluster_size, + "min_samples": min_samples, + "cluster_selection_epsilon": model.cluster_selection_epsilon, + "max_cluster_size": model.max_cluster_size or 0, + "metric": model.metric, + "alpha": model.alpha, + "cluster_selection_method": model.cluster_selection_method, + "allow_single_cluster": model.allow_single_cluster, + } + + def _params_to_cpu(self): + min_samples = ( + self.min_cluster_size + if self.min_samples is None + else self.min_samples + ) + return { + "min_cluster_size": self.min_cluster_size, + "min_samples": min_samples + 1, + "cluster_selection_epsilon": self.cluster_selection_epsilon, + "max_cluster_size": self.max_cluster_size or None, + "metric": self.metric, + "metric_params": None, + "alpha": self.alpha, + "algorithm": "auto", + "leaf_size": 40, + "n_jobs": None, + "cluster_selection_method": self.cluster_selection_method, + "allow_single_cluster": self.allow_single_cluster, + "store_centers": None, + "copy": False, + } + + def _attrs_from_cpu(self, model): + raw_data_cpu = getattr(model, "_raw_data", None) + if not isinstance(raw_data_cpu, np.ndarray): + raise UnsupportedOnGPU("Sparse inputs are not supported") + if not np.isfinite(raw_data_cpu).all(): + raise UnsupportedOnGPU("Input contains NaN or infinity") + + raw_data = cp.asarray(raw_data_cpu, order="C", dtype=np.float32) + labels = cp.asarray(model.labels_, order="C", dtype=np.int64) + try: + linkage = _linkage_from_sklearn(model._single_linkage_tree_) + state = _HDBSCANState.from_sklearn(self, raw_data, linkage) + except ( + AttributeError, + IndexError, + KeyError, + RuntimeError, + TypeError, + ValueError, + ) as exc: + raise UnsupportedOnGPU( + "Fitted model does not contain a supported single-linkage tree" + ) from exc + + n_clusters = np.unique(model.labels_[model.labels_ >= 0]).size + if state.n_clusters != n_clusters: + raise UnsupportedOnGPU( + "Fitted model cannot populate equivalent native cluster state" + ) + + return { + "labels_": ArrayIndexPair(labels, None), + "probabilities_": cp.asarray( + model.probabilities_, dtype=np.float32 + ), + "_raw_data": ArrayIndexPair(raw_data, None), + "_raw_data_cpu": raw_data_cpu, + "_single_linkage_tree": linkage, + "_min_spanning_tree": None, + "_prediction_data": None, + "_state": state, + "n_clusters_": state.n_clusters, + **InteropMixin._attrs_from_cpu(self, model), + } + + def _attrs_to_cpu(self, model): + min_samples = ( + self.min_cluster_size + if self.min_samples is None + else self.min_samples + ) + return { + "labels_": self.labels_.array.get(order="A"), + "probabilities_": self.probabilities_.get(order="A").astype( + np.float64 + ), + "_raw_data": np.asarray( + self._get_raw_data_cpu(), dtype=np.float64 + ), + "_single_linkage_tree_": _linkage_to_sklearn( + self._single_linkage_tree + ), + "_min_samples": min_samples + 1, + "_metric_params": {}, + **InteropMixin._attrs_to_cpu(self, model), + } class KMeans(ProxyBase): @@ -33,6 +241,35 @@ def _gpu_fit_predict(self, X, y=None, sample_weight=None): return self._gpu.fit_predict(X, y=y, sample_weight=sample_weight) +class HDBSCAN(ProxyBase): + _gpu_class = _SklearnHDBSCAN + # Used by sklearn's HDBSCAN helpers and test suite. The attribute is + # populated on the CPU model when fitted state is synchronized. + _other_attributes = frozenset(("_single_linkage_tree_",)) + + def _warn_copy_default(self): + # TODO(scikit-learn 1.10): Remove this compatibility warning when the + # temporary "warn" default is removed from sklearn. + if self._cpu.copy == "warn": + warnings.warn( + "The default value of `copy` will change from False to True " + "in 1.10. Explicitly set a value for `copy` to silence this " + "warning.", + FutureWarning, + stacklevel=3, + ) + + def _gpu_fit(self, X, y=None): + _check_hdbscan_input(self._cpu, X) + self._warn_copy_default() + return self._gpu.fit(X, y=y) + + def _gpu_fit_predict(self, X, y=None): + _check_hdbscan_input(self._cpu, X) + self._warn_copy_default() + return self._gpu.fit_predict(X, y=y) + + class SpectralClustering(ProxyBase): _gpu_class = cuml.cluster.SpectralClustering _not_implemented_attributes = frozenset(("affinity_matrix_",)) diff --git a/python/cuml/cuml/cluster/hdbscan/hdbscan.pyx b/python/cuml/cuml/cluster/hdbscan/hdbscan.pyx index c6b17f4df7..0358e1859d 100644 --- a/python/cuml/cuml/cluster/hdbscan/hdbscan.pyx +++ b/python/cuml/cuml/cluster/hdbscan/hdbscan.pyx @@ -165,8 +165,8 @@ cdef class _HDBSCANState: return self @staticmethod - def from_sklearn(model, X): - """Initialize internal state from a `hdbscan.HDBSCAN` instance.""" + def from_sklearn(model, X, dendrogram=None): + """Initialize internal state from a fitted CPU HDBSCAN instance.""" cdef DistanceType metric = _metrics_mapping[model.metric] cdef lib.CLUSTER_SELECTION_METHOD cluster_selection_method = { "eom": lib.CLUSTER_SELECTION_METHOD.EOM, @@ -179,7 +179,14 @@ cdef class _HDBSCANState: cdef int n_cols = X.shape[1] handle = get_handle() - self._init_from_condensed_tree_array(handle, model._condensed_tree, n_rows) + if dendrogram is None: + self._init_from_condensed_tree_array( + handle, model._condensed_tree, n_rows + ) + else: + self._init_from_dendrogram( + handle, dendrogram, model.min_cluster_size + ) self.core_dists = cp.empty(n_rows, dtype=np.float32) cdef handle_t *handle_ = handle.getHandle() @@ -232,6 +239,36 @@ cdef class _HDBSCANState: return self + def _init_from_dendrogram( + self, handle, dendrogram, int min_cluster_size + ): + """Initialize the condensed hierarchy from a linkage dendrogram.""" + children = cp.asarray( + dendrogram[:, 0:2], order="C", dtype="int64" + ) + lambdas = cp.asarray(dendrogram[:, 2], order="C", dtype="float32") + sizes = cp.asarray(dendrogram[:, 3], order="C", dtype="int64") + + cdef size_t n_leaves = dendrogram.shape[0] + 1 + cdef handle_t *handle_ = handle.getHandle() + + self.condensed_tree = new lib.CondensedHierarchy[int64_t, float]( + handle_[0], n_leaves + ) + cdef int64_t* children_ptr = children.data.ptr + cdef float* lambdas_ptr = lambdas.data.ptr + cdef int64_t* sizes_ptr = sizes.data.ptr + with nogil: + lib.build_condensed_hierarchy( + handle_[0], + children_ptr, + lambdas_ptr, + sizes_ptr, + min_cluster_size, + n_leaves, + deref(self.condensed_tree) + ) + cdef lib.CondensedHierarchy[int64_t, float]* get_condensed_tree(self) nogil: if self.hdbscan_output != NULL: return &(self.hdbscan_output.get_condensed_tree()) @@ -251,29 +288,8 @@ cdef class _HDBSCANState: """ cdef _HDBSCANState self = _HDBSCANState.__new__(_HDBSCANState) - children = cp.asarray(dendrogram[:, 0:2], order="C", dtype="int64") - lambdas = cp.asarray(dendrogram[:, 2], order="C", dtype="float32") - sizes = cp.asarray(dendrogram[:, 3], order="C", dtype="int64") - - cdef size_t n_leaves = dendrogram.shape[0] + 1 - handle = get_handle() - cdef handle_t *handle_ = handle.getHandle() - - self.condensed_tree = new lib.CondensedHierarchy[int64_t, float](handle_[0], n_leaves) - cdef int64_t* children_ptr = children.data.ptr - cdef float* lambdas_ptr = lambdas.data.ptr - cdef int64_t* sizes_ptr = sizes.data.ptr - with nogil: - lib.build_condensed_hierarchy( - handle_[0], - children_ptr, - lambdas_ptr, - sizes_ptr, - min_cluster_size, - n_leaves, - deref(self.condensed_tree) - ) + self._init_from_dendrogram(handle, dendrogram, min_cluster_size) return self @staticmethod @@ -924,6 +940,7 @@ class HDBSCAN(InteropMixin, ClusterMixin, CMajorInputTagMixin, Base): X, dtype="float32", mem_type=mem_type, + order="C", ensure_min_samples=2, return_index=True, reset=True, diff --git a/python/cuml/cuml_accel_tests/integration/test_sklearn_hdbscan.py b/python/cuml/cuml_accel_tests/integration/test_sklearn_hdbscan.py new file mode 100644 index 0000000000..d4d770f57a --- /dev/null +++ b/python/cuml/cuml_accel_tests/integration/test_sklearn_hdbscan.py @@ -0,0 +1,305 @@ +# SPDX-FileCopyrightText: Copyright (c) 2026, NVIDIA CORPORATION & AFFILIATES. All rights reserved. +# SPDX-License-Identifier: Apache-2.0 + +import inspect + +import cupy as cp +import numpy as np +import pandas as pd +import pytest +import scipy.sparse +from hdbscan import HDBSCAN as ContribHDBSCAN +from sklearn.cluster import HDBSCAN +from sklearn.datasets import make_blobs, make_moons +from sklearn.metrics import adjusted_rand_score, pairwise_distances + +from cuml.accel import is_proxy +from cuml.cluster import HDBSCAN as CumlHDBSCAN +from cuml.internals.interop import UnsupportedOnGPU + +CPUHDBSCAN = HDBSCAN._cpu_class +COPY_DEFAULT_WARNS = ( + inspect.signature(CPUHDBSCAN).parameters["copy"].default == "warn" +) + + +@pytest.fixture(scope="module") +def blobs(): + return make_blobs(n_samples=100, centers=3, random_state=42)[0] + + +def _gpu_params(**kwargs): + model = HDBSCAN(copy=False, **kwargs) + return model._gpu_class._params_from_cpu(model._cpu) + + +def _assert_cpu_fallback(model): + assert model._gpu is None + assert type(model._cpu) is CPUHDBSCAN + + +def _with_nan(X): + X = X.copy() + X[0, 0] = np.nan + return X + + +def test_hdbscan_implementations_are_distinct_proxies(): + assert is_proxy(HDBSCAN) + assert is_proxy(ContribHDBSCAN) + assert HDBSCAN is not ContribHDBSCAN + assert HDBSCAN._cpu_class is CPUHDBSCAN + assert HDBSCAN._gpu_class._cpu_class_path == "sklearn.cluster.HDBSCAN" + assert ContribHDBSCAN._gpu_class._cpu_class_path == "hdbscan.HDBSCAN" + assert CumlHDBSCAN._cpu_class_path == "hdbscan.HDBSCAN" + assert inspect.signature(HDBSCAN) == inspect.signature(CPUHDBSCAN) + assert inspect.signature(ContribHDBSCAN) == inspect.signature( + ContribHDBSCAN._cpu_class + ) + assert set(HDBSCAN().get_params()) != set(ContribHDBSCAN().get_params()) + + +@pytest.mark.skipif( + not COPY_DEFAULT_WARNS, + reason="This sklearn version does not use copy='warn'", +) +def test_hdbscan_copy_default_warning(blobs): + msg = ( + r"The default value of `copy` will change from False to True in 1.10." + ) + with pytest.warns(FutureWarning, match=msg): + model = HDBSCAN().fit(blobs) + + assert model._gpu is not None + + +@pytest.mark.parametrize( + "kwargs,name,expected", + [ + ({}, "min_samples", 4), + ({"min_samples": 2}, "min_samples", 1), + ({"min_cluster_size": 20, "min_samples": 8}, "min_samples", 7), + ({"min_samples": 1024}, "min_samples", 1023), + ({}, "max_cluster_size", 0), + ({"max_cluster_size": 25}, "max_cluster_size", 25), + ], +) +def test_hdbscan_parameter_translation(kwargs, name, expected): + assert _gpu_params(**kwargs)[name] == expected + + +@pytest.mark.parametrize("min_samples", [1, 1025]) +def test_hdbscan_min_samples_gpu_bound(min_samples): + with pytest.raises(UnsupportedOnGPU): + _gpu_params(min_samples=min_samples) + + +def test_hdbscan_min_samples_semantics(): + X, _ = make_moons(n_samples=200, noise=0.12, random_state=1) + params = { + "min_cluster_size": 50, + "min_samples": 4, + "copy": False, + } + + expected = CPUHDBSCAN(**params).fit_predict(X) + off_by_one = CPUHDBSCAN(**{**params, "min_samples": 5}).fit_predict(X) + assert adjusted_rand_score(expected, off_by_one) <= 0.1 + + result = HDBSCAN(**params) + labels = result.fit_predict(X) + + assert result._gpu.min_samples == 3 + expected_score = adjusted_rand_score(expected, labels) + off_by_one_score = adjusted_rand_score(off_by_one, labels) + assert expected_score > off_by_one_score + + +@pytest.mark.parametrize( + "n_samples,min_cluster_size,min_samples,method,algorithm", + [ + (100, 5, None, "eom", "auto"), + (1_000, 15, 8, "leaf", "brute"), + (10_000, 30, 15, "eom", "kd_tree"), + ], +) +def test_hdbscan_cpu_gpu_agreement( + n_samples, min_cluster_size, min_samples, method, algorithm +): + X, _ = make_blobs( + n_samples=n_samples, + n_features=8, + centers=5, + cluster_std=0.7, + random_state=42, + ) + params = { + "min_cluster_size": min_cluster_size, + "min_samples": min_samples, + "cluster_selection_method": method, + "algorithm": algorithm, + "copy": False, + } + + expected = CPUHDBSCAN(**params).fit(X) + result = HDBSCAN(**params).fit(X) + + assert result._gpu is not None + assert adjusted_rand_score(expected.labels_, result.labels_) >= 0.95 + + +def test_hdbscan_fit_predict_and_dbscan_clustering(): + X, _ = make_moons(n_samples=500, noise=0.08, random_state=42) + X = pd.DataFrame(X, columns=["x", "y"]) + params = {"min_cluster_size": 10, "min_samples": 6, "copy": False} + + expected = CPUHDBSCAN(**params).fit(X) + result = HDBSCAN(**params) + labels = result.fit_predict(X) + + assert result._gpu is not None + assert adjusted_rand_score(expected.labels_, labels) >= 0.95 + + # Accessing fitted attributes synchronizes a valid sklearn model without + # dropping the GPU model. + assert labels.dtype == np.dtype(np.intp) + assert result.probabilities_.dtype == np.dtype(np.float64) + assert labels.shape == result.probabilities_.shape == (len(X),) + np.testing.assert_array_equal(result.feature_names_in_, X.columns) + assert result.n_features_in_ == X.shape[1] + assert result._cpu._raw_data.dtype == np.dtype(np.float64) + assert result._cpu._min_samples == result.min_samples + # sklearn uses this private fitted attribute in its HDBSCAN helpers. It + # must be available through the proxy, not only on the synchronized model. + linkage_tree = result._single_linkage_tree_ + assert linkage_tree.dtype.names == ( + "left_node", + "right_node", + "value", + "cluster_size", + ) + np.testing.assert_array_equal( + linkage_tree, result._cpu._single_linkage_tree_ + ) + + expected_cut = expected.dbscan_clustering( + cut_distance=0.25, min_cluster_size=5 + ) + result_cut = result.dbscan_clustering( + cut_distance=0.25, min_cluster_size=5 + ) + assert adjusted_rand_score(expected_cut, result_cut) >= 0.95 + assert result._gpu is not None + + device_labels = HDBSCAN(copy=False).fit_predict(cp.asarray(X.to_numpy())) + assert isinstance(device_labels, np.ndarray) + + +def test_hdbscan_single_cluster(): + X, _ = make_blobs( + n_samples=500, + centers=1, + cluster_std=0.25, + random_state=42, + ) + params = { + "min_cluster_size": 10, + "allow_single_cluster": True, + "copy": False, + } + + expected = CPUHDBSCAN(**params).fit_predict(X) + result = HDBSCAN(**params).fit_predict(X) + + clustered = result >= 0 + assert np.unique(result[clustered]).size == 1 + assert clustered.sum() >= params["min_cluster_size"] + + # ARI treats every noise point as belonging to one ordinary second + # cluster, making it misleading for a single cluster plus noise. Compare + # the per-sample cluster/noise decision directly instead. + assert np.mean((expected >= 0) == (result >= 0)) >= 0.9 + + +def test_hdbscan_parameter_updates(blobs): + model = HDBSCAN(copy=False).fit(blobs) + + model.set_params( + min_samples=8, + max_cluster_size=25, + algorithm="brute", + leaf_size=25, + n_jobs=2, + ) + assert model._gpu is not None + assert model._gpu.min_samples == 7 + assert model._gpu.max_cluster_size == 25 + assert model.fit(blobs)._gpu is not None + + model.set_params(store_centers="centroid") + assert model._gpu is None + model.fit(blobs) + _assert_cpu_fallback(model) + + +@pytest.mark.parametrize( + "kwargs,transform,expected_label", + [ + pytest.param({"metric": "manhattan"}, lambda X: X, None, id="metric"), + pytest.param( + {"metric": "minkowski", "metric_params": {"p": 2}}, + lambda X: X, + None, + id="metric-params", + ), + pytest.param( + {"store_centers": "centroid"}, + lambda X: X, + None, + id="centers", + ), + pytest.param({}, scipy.sparse.csr_matrix, None, id="sparse-input"), + pytest.param( + {"metric": "precomputed"}, + pairwise_distances, + None, + id="precomputed-input", + ), + pytest.param({}, _with_nan, -3, id="nonfinite-input"), + ], +) +def test_hdbscan_fallback_uses_sklearn( + blobs, kwargs, transform, expected_label +): + model = HDBSCAN(copy=False, **kwargs).fit(transform(blobs)) + _assert_cpu_fallback(model) + assert model.labels_.shape == (len(blobs),) + if kwargs.get("store_centers"): + assert model.centroids_.shape[1] == blobs.shape[1] + if expected_label is not None: + assert model.labels_[0] == expected_label + + +def test_hdbscan_data_dependent_min_samples_falls_back(blobs): + model = HDBSCAN(min_samples=101, copy=False) + + with pytest.raises(ValueError, match="min_samples .* must be at most"): + model.fit(blobs) + + _assert_cpu_fallback(model) + + +def test_hdbscan_min_samples_one_falls_back(blobs): + params = {"min_samples": 1, "copy": False} + expected = CPUHDBSCAN(**params).fit_predict(blobs) + + model = HDBSCAN(**params) + result = model.fit_predict(blobs) + + _assert_cpu_fallback(model) + np.testing.assert_array_equal(result, expected) + + +def test_hdbscan_complex_input_uses_sklearn_validation(blobs): + with pytest.raises(ValueError, match="Complex data not supported"): + HDBSCAN(copy=False).fit(blobs.astype(np.complex128)) diff --git a/python/cuml/cuml_accel_tests/test_basic_estimators.py b/python/cuml/cuml_accel_tests/test_basic_estimators.py index 32e8e169a7..731844e4cf 100644 --- a/python/cuml/cuml_accel_tests/test_basic_estimators.py +++ b/python/cuml/cuml_accel_tests/test_basic_estimators.py @@ -1,7 +1,7 @@ -# SPDX-FileCopyrightText: Copyright (c) 2024-2026, NVIDIA CORPORATION. +# SPDX-FileCopyrightText: Copyright (c) 2024-2026, NVIDIA CORPORATION & AFFILIATES. All rights reserved. # SPDX-License-Identifier: Apache-2.0 -from sklearn.cluster import DBSCAN, KMeans, SpectralClustering +from sklearn.cluster import DBSCAN, HDBSCAN, KMeans, SpectralClustering from sklearn.datasets import make_blobs, make_classification, make_regression from sklearn.decomposition import PCA, TruncatedSVD from sklearn.linear_model import ( @@ -32,6 +32,12 @@ def test_dbscan(): clf.labels_ +def test_hdbscan(): + X, y_true = make_blobs(n_samples=100, centers=3, random_state=42) + clf = HDBSCAN(copy=False).fit(X) + clf.labels_ + + def test_spectral_clustering(): X, y_true = make_blobs(n_samples=100, centers=3, random_state=42) X = X.astype("float32") diff --git a/python/cuml/tests/test_hdbscan.py b/python/cuml/tests/test_hdbscan.py index 0117fc5e49..87e13ce359 100644 --- a/python/cuml/tests/test_hdbscan.py +++ b/python/cuml/tests/test_hdbscan.py @@ -1,4 +1,4 @@ -# SPDX-FileCopyrightText: Copyright (c) 2021-2026, NVIDIA CORPORATION. +# SPDX-FileCopyrightText: Copyright (c) 2021-2026, NVIDIA CORPORATION & AFFILIATES. All rights reserved. # SPDX-License-Identifier: Apache-2.0 # import importlib.metadata @@ -219,6 +219,28 @@ def test_hdbscan_blobs( ) +def test_hdbscan_fortran_contiguous_pandas_input(): + X, _ = make_blobs( + n_samples=200, + n_features=8, + centers=3, + cluster_std=0.7, + random_state=42, + ) + X_c = np.asarray(X, dtype=np.float32, order="C") + X_f = np.asarray(X, dtype=np.float32, order="F") + X_df = pd.DataFrame(X_f, columns=[f"x{i}" for i in range(X_f.shape[1])]) + X_df_values = X_df.to_numpy(dtype=np.float32, copy=False) + assert X_df_values.flags.f_contiguous + assert not X_df_values.flags.c_contiguous + + expected = HDBSCAN(min_cluster_size=10).fit_predict(X_c) + result = HDBSCAN(min_cluster_size=10).fit(X_df) + + assert result._raw_data.array.flags.c_contiguous + assert adjusted_rand_score(expected, result.labels_) == 1.0 + + @pytest.mark.parametrize("cluster_selection_epsilon", [0.0, 50.0, 150.0]) @pytest.mark.parametrize( "min_samples_cluster_size_bounds", [(150, 150, 0), (50, 25, 0)] diff --git a/python/cuml/tests/test_sklearn_import_export.py b/python/cuml/tests/test_sklearn_import_export.py index eefa126b58..4353bb9326 100644 --- a/python/cuml/tests/test_sklearn_import_export.py +++ b/python/cuml/tests/test_sklearn_import_export.py @@ -14,6 +14,7 @@ import umap from numpy.testing import assert_allclose from packaging.version import Version +from sklearn.cluster import HDBSCAN as SkHDBSCAN from sklearn.cluster import KMeans as SkKMeans from sklearn.cluster import SpectralClustering as SkSpectralClustering from sklearn.datasets import ( @@ -36,6 +37,7 @@ from sklearn.utils.validation import check_is_fitted import cuml +from cuml.accel._overrides.sklearn.cluster import _SklearnHDBSCAN from cuml.cluster import DBSCAN, KMeans, SpectralClustering from cuml.decomposition import PCA, TruncatedSVD from cuml.internals.interop import UnsupportedOnCPU, UnsupportedOnGPU @@ -971,6 +973,81 @@ def test_hdbscan(random_state, prediction_data, gen_min_span_tree): sk_model2.fit(X, y) +def test_sklearn_hdbscan_roundtrip(random_state): + X, _ = make_blobs( + n_samples=200, + n_features=4, + centers=4, + random_state=random_state, + ) + params = { + "min_cluster_size": 8, + "min_samples": 5, + "cluster_selection_epsilon": 0.05, + "max_cluster_size": 50, + "alpha": 1.2, + "allow_single_cluster": True, + } + original = _SklearnHDBSCAN(**params).fit(X) + + sklearn_model = original.as_sklearn() + assert type(sklearn_model) is SkHDBSCAN + check_is_fitted(sklearn_model) + assert sklearn_model.min_samples == params["min_samples"] + 1 + + roundtrip = _SklearnHDBSCAN.from_sklearn(sklearn_model) + check_is_fitted(roundtrip) + assert roundtrip._state is not None + assert ( + roundtrip.n_clusters_ + == np.unique(sklearn_model.labels_[sklearn_model.labels_ >= 0]).size + ) + + for name, expected in params.items(): + assert getattr(roundtrip, name) == expected + + with cuml.using_output_type("numpy"): + np.testing.assert_array_equal(original.labels_, roundtrip.labels_) + assert_allclose( + original.probabilities_, roundtrip.probabilities_, rtol=1e-6 + ) + + np.testing.assert_array_equal( + original._single_linkage_tree, roundtrip._single_linkage_tree + ) + assert original.n_features_in_ == roundtrip.n_features_in_ + + # The sklearn state produced after the full roundtrip remains usable. + sklearn_roundtrip = roundtrip.as_sklearn() + assert type(sklearn_roundtrip) is SkHDBSCAN + check_is_fitted(sklearn_roundtrip) + np.testing.assert_array_equal( + sklearn_model.labels_, sklearn_roundtrip.labels_ + ) + assert_allclose( + sklearn_model.probabilities_, + sklearn_roundtrip.probabilities_, + rtol=1e-6, + ) + np.testing.assert_array_equal( + sklearn_model._single_linkage_tree_, + sklearn_roundtrip._single_linkage_tree_, + ) + np.testing.assert_array_equal( + sklearn_model.dbscan_clustering(1.0), + sklearn_roundtrip.dbscan_clustering(1.0), + ) + + +def test_sklearn_hdbscan_rejects_incomplete_fitted_state(random_state): + X, _ = make_blobs(n_samples=100, random_state=random_state) + model = SkHDBSCAN(copy=False).fit(X) + del model._single_linkage_tree_ + + with pytest.raises(UnsupportedOnGPU, match="single-linkage tree"): + _SklearnHDBSCAN.from_sklearn(model) + + def test_linear_svr(random_state): X, y = make_regression(n_samples=100, random_state=random_state) original = cuml.LinearSVR(loss="squared_epsilon_insensitive", penalty="l2")