From 7493c0f55c8c18b457f71d30653f48ed4e5f6f4a Mon Sep 17 00:00:00 2001 From: Jim Crist-Harif Date: Mon, 15 Jun 2026 11:23:44 -0500 Subject: [PATCH 1/3] Cleanup `pairwise_distances` - Remove usage of legacy input validation and `CumlArray`/`SparseCumlArray`. - Simplify code flow, making `sparse_pairwise_distances` duplicative. - Release GIL in `libcuml` calls. - Fix bug in input reflection for `nan_euclidean_distances` - Add missing `copy` parameter to `nan_euclidean_distances` - Standardize error messages to better match sklearn - Improve test coverage for error handling and warnings - Improve test coverage for `nan_euclidean_distances` --- .../cuml/cuml/metrics/pairwise_distances.pyx | 533 ++++++++---------- python/cuml/cuml/neighbors/kernel_density.pyx | 24 +- python/cuml/tests/test_metrics.py | 268 +++++---- 3 files changed, 392 insertions(+), 433 deletions(-) diff --git a/python/cuml/cuml/metrics/pairwise_distances.pyx b/python/cuml/cuml/metrics/pairwise_distances.pyx index 6e7108bc6f..b0c7619e06 100644 --- a/python/cuml/cuml/metrics/pairwise_distances.pyx +++ b/python/cuml/cuml/metrics/pairwise_distances.pyx @@ -4,19 +4,13 @@ # import warnings -import cudf import cupy as cp -import numpy as np -import pandas as pd -import scipy.sparse +import cupyx.scipy.sparse as cp_sp +from sklearn.exceptions import DataConversionWarning -from cuml.common import CumlArray -from cuml.common.sparse_utils import is_sparse from cuml.internals import get_handle, reflect -from cuml.internals.array_sparse import SparseCumlArray -from cuml.internals.input_utils import sparse_scipy_to_cp +from cuml.internals.outputs import using_output_type from cuml.internals.validation import check_array -from cuml.thirdparty_adapters import _get_mask from libc.stdint cimport uintptr_t from libcpp cimport bool @@ -88,42 +82,32 @@ PAIRWISE_DISTANCE_SPARSE_METRICS = { } -def _determine_metric(metric_str, is_sparse_=False): - # Available options in scikit-learn and their pairs. See - # sklearn.metrics.pairwise.PAIRWISE_DISTANCE_FUNCTIONS: - # 'cityblock': L1 - # 'cosine': CosineExpanded - # 'euclidean': L2SqrtUnexpanded - # 'haversine': N/A - # 'l2': L2SqrtUnexpanded - # 'l1': L1 - # 'manhattan': L1 - # 'nan_euclidean': N/A - # 'sqeuclidean': L2Unexpanded - # Note: many are duplicates following this: - # https://github.com/scikit-learn/scikit-learn/blob/master/sklearn/metrics/pairwise.py#L1321 - - if metric_str == 'haversine': - raise ValueError(" The metric: '{}', is not supported at this time." - .format(metric_str)) - - if not is_sparse_ and (metric_str not in PAIRWISE_DISTANCE_METRICS): - if metric_str in PAIRWISE_DISTANCE_SPARSE_METRICS: - raise ValueError(" The metric: '{}', is only available on " - "sparse data.".format(metric_str)) - else: - raise ValueError("Unknown metric: {}".format(metric_str)) - elif is_sparse_ and (metric_str not in PAIRWISE_DISTANCE_SPARSE_METRICS): - raise ValueError("Unknown metric: {}".format(metric_str)) - - if is_sparse_: - return PAIRWISE_DISTANCE_SPARSE_METRICS[metric_str] +def _determine_metric(metric, is_sparse=False): + if is_sparse: + metrics = PAIRWISE_DISTANCE_SPARSE_METRICS + other = PAIRWISE_DISTANCE_METRICS + kind = "sparse" else: - return PAIRWISE_DISTANCE_METRICS[metric_str] + metrics = PAIRWISE_DISTANCE_METRICS + other = PAIRWISE_DISTANCE_SPARSE_METRICS + kind = "dense" + if metric not in metrics: + if metric in other: + raise ValueError(f"`{metric=!r}` is not supported on {kind} data") + raise ValueError(f"`{metric=!r}` is not supported") + return metrics[metric] + +@reflect def nan_euclidean_distances( - X, Y=None, *, squared=False, missing_values=cp.nan, convert_dtype=True + X, + Y=None, + *, + squared=False, + missing_values=cp.nan, + copy=True, + convert_dtype=True, ): """Calculate the euclidean distances in the presence of missing values. @@ -164,6 +148,11 @@ def nan_euclidean_distances( missing_values : np.nan or int, default=np.nan Representation of missing value. + copy : bool, default=True, + Whether to make a copy of X and Y when necessary. Setting to + False can reduce memory usage, but may result in mutation + of X and Y. + convert_dtype : bool, optional (default = True) When set to True, the method will, when necessary, convert ``X`` to a supported floating-point dtype and convert ``Y`` to match @@ -175,81 +164,73 @@ def nan_euclidean_distances( Returns the distances between the row vectors of ``X`` and the row vectors of ``Y``. """ + Y_is_X = Y is None or Y is X - if isinstance(X, cudf.DataFrame) or isinstance(X, pd.DataFrame): - if (X.isnull().any()).any(): - X.fillna(0, inplace=True) - - if isinstance(Y, cudf.DataFrame) or isinstance(Y, pd.DataFrame): - if (Y.isnull().any()).any(): - Y.fillna(0, inplace=True) - - X_m = check_array( + X = check_array( X, order="A", - dtype=[np.float32, np.float64], + dtype=("float32", "float64"), convert_dtype=convert_dtype, - ensure_all_finite=False, + ensure_all_finite="allow-nan", input_name="X", + copy=copy, ) - dtype_x = X_m.dtype - if Y is None: - Y_m = X_m + if Y_is_X: + Y = X else: - Y_m = check_array( + Y = check_array( Y, - order="F" if X_m.flags.f_contiguous else "C", - dtype=[dtype_x], + # If X is both C and F contiguous, let Y decide contiguity + order=( + "C" if not X.flags.f_contiguous else + "F" if not X.flags.c_contiguous else + "A" + ), + dtype=X.dtype, convert_dtype=convert_dtype, - ensure_all_finite=False, + ensure_all_finite="allow-nan", input_name="Y", + copy=copy, ) - # Get missing mask for X - missing_X = _get_mask(X_m, missing_values) + # Set missing values to zero + missing_X = cp.isnan(X) if cp.isnan(missing_values) else (X == missing_values) + X[missing_X] = 0 + if not Y_is_X: + missing_Y = cp.isnan(Y) if cp.isnan(missing_values) else (Y == missing_values) + Y[missing_Y] = 0 - # Get missing mask for Y - missing_Y = missing_X if Y is X else _get_mask(Y_m, missing_values) - - # set missing values to zero - X_m[missing_X] = 0 - Y_m[missing_Y] = 0 - - # Adjust distances for squared - if X_m.shape == Y_m.shape: - if (X_m == Y_m).all(): - distances = cp.asarray(pairwise_distances( - X_m, metric="sqeuclidean")) - else: - distances = cp.asarray(pairwise_distances( - X_m, Y_m, metric="sqeuclidean")) - else: - distances = cp.asarray(pairwise_distances( - X_m, Y_m, metric="sqeuclidean")) + with using_output_type("cupy"): + distances = pairwise_distances(X, Y, metric="sqeuclidean") # Adjust distances for missing values - XX = X_m * X_m - YY = Y_m * Y_m - distances -= cp.dot(XX, missing_Y.T) - distances -= cp.dot(missing_X, YY.T) + if Y_is_X: + XX = X * X + distances -= cp.dot(XX, missing_X.T) + distances -= cp.dot(missing_X, XX.T) + else: + XX = X * X + YY = Y * Y + distances -= cp.dot(XX, missing_Y.T) + distances -= cp.dot(missing_X, YY.T) cp.clip(distances, 0, None, out=distances) - if X_m is Y_m: + if Y_is_X: # Ensure that distances between vectors and themselves are set to 0.0. # This may not be the case due to floating point rounding errors. cp.fill_diagonal(distances, 0.0) present_X = 1 - missing_X - present_Y = present_X if Y_m is X_m else ~missing_Y + present_Y = present_X if Y_is_X else ~missing_Y present_count = cp.dot(present_X, present_Y.T) distances[present_count == 0] = cp.nan # avoid divide by zero cp.maximum(1, present_count, out=present_count) distances /= present_count - distances *= X_m.shape[1] + distances *= X.shape[1] if not squared: cp.sqrt(distances, out=distances) @@ -257,6 +238,31 @@ def nan_euclidean_distances( return distances +_all_boolean = cp.ReductionKernel( + "T x", + "uint8 out", + "x == 0 || x == 1", + "a && b", + "out = a", + "1", + "_all_boolean", +) + + +def _ensure_boolean(X, metric): + """Ensure X is bool-like (all 0 or 1), warning if conversion performed.""" + if not _all_boolean(X): + warnings.warn( + f"Data was converted to boolean for metric {metric}", + DataConversionWarning, + stacklevel=2, + ) + out = cp.zeros_like(X) + out[X != 0] = 1 + return out + return X + + @reflect def pairwise_distances( X, Y=None, metric="euclidean", convert_dtype=True, metric_arg=2, **kwds @@ -337,128 +343,163 @@ def pairwise_distances( [ 7., 5.], [12., 10.]]) """ - - if is_sparse(X): - return sparse_pairwise_distances( - X, - Y, - metric=metric, - convert_dtype=convert_dtype, - metric_arg=metric_arg, - **kwds - ) - - handle = get_handle() - cdef handle_t *handle_ = handle.getHandle() - - if metric in ['nan_euclidean']: + if metric == "nan_euclidean": return nan_euclidean_distances(X, Y, **kwds) - if metric in ['russellrao'] and not np.all(X.data == 1.): - warnings.warn("X was converted to boolean for metric {}" - .format(metric)) - X = np.where(X != 0., 1.0, 0.0) + Y_is_X = Y is None or Y is X - # Get the input arrays, preserve order and type where possible - X_m = check_array( + X = check_array( X, order="A", - dtype=[np.float32, np.float64], + dtype=("float32", "float64"), convert_dtype=convert_dtype, input_name="X", + accept_sparse="csr", ) - cdef int n_samples_x = X_m.shape[0] - cdef int n_features_x = X_m.shape[1] - dtype_x = X_m.dtype - - cdef uintptr_t d_X_ptr - cdef uintptr_t d_Y_ptr - cdef uintptr_t d_dest_ptr - cdef bint is_row_major = X_m.flags.c_contiguous - cdef int n_samples_y = n_samples_x - cdef int n_features_y = n_features_x - - if Y is not None: - if metric in ['russellrao'] and not np.all(Y.data == 1.): - warnings.warn("Y was converted to boolean for metric {}" - .format(metric)) - Y = np.where(Y != 0., 1.0, 0.0) - - if n_samples_x == 1 or n_features_x == 1: - # X is degenerate (both C- and F-contiguous); let Y choose the - # layout and propagate it. - Y_m = check_array( - Y, - order="A", - dtype=[dtype_x], - convert_dtype=convert_dtype, - input_name="Y", - ) - is_row_major = Y_m.flags.c_contiguous - else: - # X is the authority; force Y's layout to match X's. - Y_m = check_array( - Y, - order="C" if is_row_major else "F", - dtype=[dtype_x], - convert_dtype=convert_dtype, - input_name="Y", - ) - n_samples_y = Y_m.shape[0] - n_features_y = Y_m.shape[1] + cdef bool is_sparse = cp_sp.issparse(X) + + if Y_is_X: + Y = X else: - Y_m = X_m + Y = check_array( + Y, + # If X is both C and F contiguous, let Y decide contiguity + order=( + "A" if is_sparse else + "C" if not X.flags.f_contiguous else + "F" if not X.flags.c_contiguous else + "A" + ), + dtype=X.dtype, + convert_dtype=convert_dtype, + input_name="Y", + accept_sparse="csr", + ) - # Check feature sizes are equal - if (n_features_x != n_features_y): - raise ValueError("Incompatible dimension for X and Y matrices: \ - X.shape[1] == {} while Y.shape[1] == {}" - .format(n_features_x, n_features_y)) + if is_sparse != cp_sp.issparse(Y): + raise NotImplementedError( + "Support for a mix of sparse and dense arrays is not implemented" + ) - # Get the metric string to int - metric_val = _determine_metric(metric) + if X.shape[1] != Y.shape[1]: + raise ValueError( + f"Incompatible dimension for X and Y matrices: " + f"X.shape[1] == {X.shape[1]} while Y.shape[1] == {Y.shape[1]}" + ) - # Create the output array - dest_m = CumlArray.zeros((n_samples_x, n_samples_y), dtype=dtype_x, - order="C" if is_row_major else "F") - - d_X_ptr = X_m.data.ptr - d_Y_ptr = Y_m.data.ptr - d_dest_ptr = dest_m.ptr - - # Now execute the functions - if (dtype_x == np.float32): - pairwise_distance(handle_[0], - d_X_ptr, - d_Y_ptr, - d_dest_ptr, - n_samples_x, - n_samples_y, - n_features_x, - metric_val, - is_row_major, - metric_arg) - elif (dtype_x == np.float64): - pairwise_distance(handle_[0], - d_X_ptr, - d_Y_ptr, - d_dest_ptr, - n_samples_x, - n_samples_y, - n_features_x, - metric_val, - is_row_major, - metric_arg) + cdef DistanceType metric_c = _determine_metric(metric, is_sparse=is_sparse) + + # Decompose X and Y into components + cdef int X_n_rows = X.shape[0] + cdef int Y_n_rows = Y.shape[0] + cdef int n_cols = X.shape[1] + cdef int X_nnz, Y_nnz + cdef uintptr_t X_indptr_ptr, X_indices_ptr, Y_indptr_ptr, Y_indices_ptr + if is_sparse: + X_data = X.data + X_indptr_ptr = X.indptr.data.ptr + X_indices_ptr = X.indices.data.ptr + X_nnz = X.nnz + + Y_data = Y.data + Y_indptr_ptr = Y.indptr.data.ptr + Y_indices_ptr = Y.indices.data.ptr + Y_nnz = Y.nnz else: - raise NotImplementedError("Unsupported dtype: {}".format(dtype_x)) + X_data = X + Y_data = Y - # Sync on the stream before exiting. pairwise_distance does not sync. - handle.sync() + # Maybe transform original data values before extracting value pointers + if metric in ["jaccard", "dice", "russellrao"]: + X_data = _ensure_boolean(X_data, metric) + Y_data = X_data if Y_is_X else _ensure_boolean(Y_data, metric) - del X_m - del Y_m + cdef uintptr_t X_ptr = X_data.data.ptr + cdef uintptr_t Y_ptr = Y_data.data.ptr + cdef bool is_row_major = False if is_sparse else Y.flags.c_contiguous + cdef bool is_float32 = X_data.dtype == "float32" - return dest_m + # Create the output array + out = cp.zeros( + (X_n_rows, Y_n_rows), + dtype=X.dtype, + order="C" if is_row_major else "F" + ) + cdef uintptr_t out_ptr = out.data.ptr + + handle = get_handle() + cdef handle_t *handle_ = handle.getHandle() + + cdef double metric_arg_c = metric_arg + + with nogil: + if is_sparse: + if is_float32: + pairwiseDistance_sparse( + handle_[0], + X_ptr, + Y_ptr, + out_ptr, + X_n_rows, + Y_n_rows, + n_cols, + X_nnz, + Y_nnz, + X_indptr_ptr, + Y_indptr_ptr, + X_indices_ptr, + Y_indices_ptr, + metric_c, + metric_arg_c, + ) + else: + pairwiseDistance_sparse( + handle_[0], + X_ptr, + Y_ptr, + out_ptr, + X_n_rows, + Y_n_rows, + n_cols, + X_nnz, + Y_nnz, + X_indptr_ptr, + Y_indptr_ptr, + X_indices_ptr, + Y_indices_ptr, + metric_c, + metric_arg_c, + ) + else: + if is_float32: + pairwise_distance( + handle_[0], + X_ptr, + Y_ptr, + out_ptr, + X_n_rows, + Y_n_rows, + n_cols, + metric_c, + is_row_major, + metric_arg_c, + ) + else: + pairwise_distance( + handle_[0], + X_ptr, + Y_ptr, + out_ptr, + X_n_rows, + Y_n_rows, + n_cols, + metric_c, + is_row_major, + metric_arg_c, + ) + handle.sync() + + return out @reflect @@ -543,103 +584,11 @@ def sparse_pairwise_distances( array([[2. ], [2.333...]]) """ - handle = get_handle() - cdef handle_t *handle_ = handle.getHandle() - if (not is_sparse(X)) or (Y is not None and not is_sparse(Y)): - raise ValueError("Input matrices are not sparse.") - - dtype_x = X.data.dtype - if dtype_x not in [cp.float32, cp.float64]: - raise TypeError("Unsupported dtype: {}".format(dtype_x)) - - if scipy.sparse.issparse(X): - X = sparse_scipy_to_cp(X, dtype=None) - - if metric in ['jaccard', 'dice'] and not cp.all(X.data == 1.): - warnings.warn("X was converted to boolean for metric {}" - .format(metric)) - X.data = (X.data != 0.).astype(dtype_x) - - X_m = SparseCumlArray(X) - n_samples_x, n_features_x = X_m.shape - if Y is None: - Y_m = X_m - dtype_y = dtype_x - else: - if scipy.sparse.issparse(Y): - Y = sparse_scipy_to_cp(Y, dtype=dtype_x if convert_dtype else None) - if convert_dtype: - Y = Y.astype(dtype_x) - elif dtype_x != Y.data.dtype: - raise TypeError("Different data types unsupported when " - "convert_dtypes=False") - - if metric in ['jaccard', 'dice'] and not cp.all(Y.data == 1.): - dtype_y = Y.data.dtype - warnings.warn("Y was converted to boolean for metric {}" - .format(metric)) - Y.data = (Y.data != 0.).astype(dtype_y) - Y_m = SparseCumlArray(Y) - - n_samples_y, n_features_y = Y_m.shape - - # Check feature sizes are equal - if n_features_x != n_features_y: - raise ValueError("Incompatible dimension for X and Y matrices: \ - X.shape[1] == {} while Y.shape[1] == {}" - .format(n_features_x, n_features_y)) - - # Get the metric string to a distance enum - metric_val = _determine_metric(metric, is_sparse_=True) - - x_nrows, y_nrows = X_m.indptr.shape[0] - 1, Y_m.indptr.shape[0] - 1 - dest_m = CumlArray.zeros((x_nrows, y_nrows), dtype=dtype_x) - cdef uintptr_t d_dest_ptr = dest_m.ptr - - cdef uintptr_t d_X_ptr = X_m.data.ptr - cdef uintptr_t X_m_indptr = X_m.indptr.ptr - cdef uintptr_t X_m_indices = X_m.indices.ptr - - cdef uintptr_t d_Y_ptr = Y_m.data.ptr - cdef uintptr_t Y_m_indptr = Y_m.indptr.ptr - cdef uintptr_t Y_m_indices = Y_m.indices.ptr - - if (dtype_x == np.float32): - pairwiseDistance_sparse(handle_[0], - d_X_ptr, - d_Y_ptr, - d_dest_ptr, - x_nrows, - y_nrows, - n_features_x, - X_m.nnz, - Y_m.nnz, - X_m_indptr, - Y_m_indptr, - X_m_indices, - Y_m_indices, - metric_val, - metric_arg) - elif (dtype_x == np.float64): - pairwiseDistance_sparse(handle_[0], - d_X_ptr, - d_Y_ptr, - d_dest_ptr, - n_samples_x, - n_samples_y, - n_features_x, - X_m.nnz, - Y_m.nnz, - X_m_indptr, - Y_m_indptr, - X_m_indices, - Y_m_indices, - metric_val, - metric_arg) - - # Sync on the stream before exiting. - handle.sync() - - del X_m - del Y_m - return dest_m + return pairwise_distances( + X, + Y, + metric=metric, + convert_dtype=convert_dtype, + metric_arg=metric_arg, + **kwds, + ) diff --git a/python/cuml/cuml/neighbors/kernel_density.pyx b/python/cuml/cuml/neighbors/kernel_density.pyx index 9888d7d17e..ea647d3faf 100644 --- a/python/cuml/cuml/neighbors/kernel_density.pyx +++ b/python/cuml/cuml/neighbors/kernel_density.pyx @@ -2,9 +2,6 @@ # SPDX-FileCopyrightText: Copyright (c) 2022-2026, NVIDIA CORPORATION. # SPDX-License-Identifier: Apache-2.0 # - -import warnings - import cupy as cp import numpy as np from cupyx.scipy.special import gammainc @@ -23,6 +20,7 @@ from cuml.internals.validation import ( from cuml.metrics.pairwise_distances import ( PAIRWISE_DISTANCE_METRICS as SUPPORTED_METRICS, ) +from cuml.metrics.pairwise_distances import _ensure_boolean from libc.stdint cimport int64_t, uintptr_t from libcpp cimport bool as cpp_bool @@ -84,22 +82,6 @@ KDE_KERNEL_TYPES = { VALID_KERNELS = list(KDE_KERNEL_TYPES.keys()) -def _coerce_russellrao_binary(arr, *, input_name): - """Coerce values to {0, 1} for the russellrao metric, warning on non-binary input. - - The fused KDE kernel computes RussellRao assuming binary inputs, matching - the behavior of ``cuml.metrics.pairwise_distances`` for this metric. - """ - if not bool(cp.logical_or(arr == 0, arr == 1).all()): - warnings.warn( - f"{input_name} was converted to boolean for metric 'russellrao'", - DataConversionWarning, - stacklevel=2, - ) - return cp.where(arr != 0, arr.dtype.type(1), arr.dtype.type(0)) - return arr - - class KernelDensity(InteropMixin, Base): """ Kernel Density Estimation. Computes a non-parametric density estimate @@ -278,7 +260,7 @@ class KernelDensity(InteropMixin, Base): reset=True, ) if self.metric == "russellrao": - self._X = _coerce_russellrao_binary(self._X, input_name="X") + self._X = _ensure_boolean(self._X, metric=self.metric) if self._sample_weight is not None: check_non_negative(self._sample_weight, input_name="sample_weight") @@ -322,7 +304,7 @@ class KernelDensity(InteropMixin, Base): order="C", ) if self.metric == "russellrao": - X = _coerce_russellrao_binary(X, input_name="X") + X = _ensure_boolean(X, metric=self.metric) if self.metric_params: if len(self.metric_params) != 1: diff --git a/python/cuml/tests/test_metrics.py b/python/cuml/tests/test_metrics.py index 17fe8e15ab..589352697a 100644 --- a/python/cuml/tests/test_metrics.py +++ b/python/cuml/tests/test_metrics.py @@ -2,14 +2,14 @@ # SPDX-FileCopyrightText: Copyright (c) 2021-2026, NVIDIA CORPORATION. # SPDX-License-Identifier: Apache-2.0 # - import platform import random +import warnings from itertools import chain, combinations_with_replacement, permutations import cudf import cupy as cp -import cupyx +import cupyx.scipy.sparse as cp_sp import numpy as np import pandas as pd import pytest @@ -23,6 +23,7 @@ from scipy.stats import entropy as sp_entropy from sklearn import preprocessing from sklearn.datasets import make_blobs, make_classification +from sklearn.exceptions import DataConversionWarning from sklearn.metrics import confusion_matrix as sk_confusion_matrix from sklearn.metrics import hinge_loss as sk_hinge from sklearn.metrics import log_loss as sklearn_log_loss @@ -57,7 +58,6 @@ pairwise_distances, precision_recall_curve, roc_auc_score, - sparse_pairwise_distances, ) from cuml.metrics.cluster import adjusted_rand_score as cu_ars from cuml.metrics.cluster import entropy @@ -1241,12 +1241,6 @@ def prep_dense_array(array, metric, col_major=0): return np.asfortranarray(array) if col_major else array -@pytest.mark.filterwarnings( - "ignore:X was converted to boolean for metric russellrao:UserWarning" -) -@pytest.mark.filterwarnings( - "ignore:Y was converted to boolean for metric russellrao:UserWarning" -) @pytest.mark.filterwarnings( "ignore:Data was converted to boolean for metric russellrao:sklearn.exceptions.DataConversionWarning" ) @@ -1322,12 +1316,6 @@ def test_pairwise_distances(metric: str, matrix_size, is_col_major): pairwise_distances(X, Y, metric=metric.capitalize()) -@pytest.mark.filterwarnings( - "ignore:X was converted to boolean for metric russellrao:UserWarning" -) -@pytest.mark.filterwarnings( - "ignore:Y was converted to boolean for metric russellrao:UserWarning" -) @pytest.mark.filterwarnings( "ignore:Data was converted to boolean for metric russellrao:sklearn.exceptions.DataConversionWarning" ) @@ -1378,12 +1366,6 @@ def test_pairwise_distances_sklearn_comparison(metric: str, matrix_size): cp.testing.assert_array_almost_equal(S, S2, decimal=compare_precision) -@pytest.mark.filterwarnings( - "ignore:X was converted to boolean for metric russellrao:UserWarning" -) -@pytest.mark.filterwarnings( - "ignore:Y was converted to boolean for metric russellrao:UserWarning" -) @pytest.mark.filterwarnings( "ignore:Data was converted to boolean for metric russellrao:sklearn.exceptions.DataConversionWarning" ) @@ -1466,33 +1448,90 @@ def test_pairwise_distances_unsuppored_metrics(metric): pairwise_distances(X, metric=metric) -def test_pairwise_distances_exceptions(): - rng = np.random.RandomState(4) +def test_pairwise_distances_invalid_metric(): + sparse = cp_sp.random(5, 4, random_state=42, density=0.5) + dense = sparse.toarray() - X_int = rng.randint(10, size=(5, 4)) - X_double = rng.random_sample((5, 4)) - X_float = np.asarray(X_double, dtype=np.float32) + # Invalid metric + for X in [dense, sparse]: + with pytest.raises( + ValueError, match="`metric='invalid'` is not supported" + ): + pairwise_distances(X, metric="invalid") - # Test second int inputs (should not have an exception with - # convert_dtype=True) - pairwise_distances(X_double, X_int, metric="euclidean") + # Metric dense only + with pytest.raises( + ValueError, + match="`metric='russellrao'` is not supported on sparse data", + ): + pairwise_distances(sparse, metric="russellrao") - # Test sending different types with convert_dtype=False - with pytest.raises(ValueError, match="dtype"): - pairwise_distances( - X_double, X_float, metric="euclidean", convert_dtype=False - ) + # Metric sparse only + with pytest.raises( + ValueError, + match="`metric='dice'` is not supported on dense data", + ): + pairwise_distances(dense, metric="dice") - # Invalid metric name - with pytest.raises(ValueError): - pairwise_distances(X_double, metric="Not a metric") - # Invalid dimensions - X = rng.random_sample((5, 4)) - Y = rng.random_sample((5, 7)) +@pytest.mark.parametrize("kind", ["sparse", "dense"]) +def test_pairwise_distances_invalid_dimensions(kind): + X = cp_sp.random(5, 7, random_state=42, density=0.5) + Y = cp_sp.random(5, 4, random_state=42, density=0.5) + if kind == "dense": + X = X.toarray() + Y = Y.toarray() + with pytest.raises(ValueError, match="Incompatible dimension"): + pairwise_distances(X, Y) - with pytest.raises(ValueError): - pairwise_distances(X, Y, metric="euclidean") + +def test_pairwise_distances_mix_sparse_and_dense(): + sparse = cp_sp.random(5, 4, random_state=42, density=0.5) + dense = sparse.toarray() + with pytest.raises(NotImplementedError, match="mix of sparse and dense"): + pairwise_distances(sparse, dense) + + with pytest.raises(NotImplementedError, match="mix of sparse and dense"): + pairwise_distances(dense, sparse) + + +@pytest.mark.parametrize( + "metric, kind", + [ + ("russellrao", "dense"), + ("dice", "sparse"), + ("jaccard", "sparse"), + ], +) +def test_pairwise_distances_warns_bool_conversion(metric, kind): + X = cp_sp.random(10, 10, random_state=42, density=0.5) + if kind == "dense": + X = X.toarray() + X_bool = X.astype("bool") + X_bool_like = X_bool.astype(X.dtype) + + # Conversion for X and Y both warn + with pytest.warns( + DataConversionWarning, + match=f"Data was converted to boolean for metric {metric}", + ): + pairwise_distances(X, metric=metric) + + with pytest.warns( + DataConversionWarning, + match=f"Data was converted to boolean for metric {metric}", + ): + pairwise_distances(X_bool, X, metric=metric) + + # No warnings for bool inputs + with warnings.catch_warnings(): + warnings.simplefilter("error") + pairwise_distances(X_bool, metric=metric) + + # No warnings for bool-like inputs + with warnings.catch_warnings(): + warnings.simplefilter("error") + pairwise_distances(X_bool_like, metric=metric) @pytest.mark.parametrize("bad_value", [np.nan, np.inf, -np.inf]) @@ -1529,6 +1568,51 @@ def test_nan_euclidean_distances_y_none_diagonal_zero(): np.testing.assert_array_almost_equal(S, S_ref, decimal=4) +def test_nan_euclidean_distances_copy(): + X_orig = cp.array([[cp.nan, 1], [2, 3]]) + X = X_orig.copy() + sol = sklearn_pairwise_distances(X.get(), metric="nan_euclidean") + res = nan_euclidean_distances(X) + np.testing.assert_allclose(res.get(), sol, atol=1e-4) + # No mutation by default + cp.testing.assert_array_equal(X, X_orig) + + # copy=False allows mutation, nan values set to 0 + res = nan_euclidean_distances(X, copy=False) + np.testing.assert_allclose(res.get(), sol, atol=1e-4) + assert X[0, 0] == 0 + + # Can also pass copy=False to `pairwise_distances` + X = X_orig.copy() + res = pairwise_distances(X, copy=False, metric="nan_euclidean") + np.testing.assert_allclose(res.get(), sol, atol=1e-4) + assert X[0, 0] == 0 + + +@pytest.mark.parametrize("value", [np.nan, -1]) +def test_nan_euclidean_distances_missing_values(value): + X = np.array([[value, 1], [2, 3]], dtype="float32") + sol = sklearn_pairwise_distances( + X, metric="nan_euclidean", missing_values=value + ) + res = nan_euclidean_distances(X, missing_values=value) + np.testing.assert_allclose(res, sol, atol=1e-4) + res = pairwise_distances(X, metric="nan_euclidean", missing_values=value) + np.testing.assert_allclose(res, sol, atol=1e-4) + + +@pytest.mark.parametrize("squared", [False, True]) +def test_nan_euclidean_distances_squared(squared): + X = np.array([[np.nan, 1], [2, 3]]) + sol = sklearn_pairwise_distances( + X, metric="nan_euclidean", squared=squared + ) + res = nan_euclidean_distances(X, squared=squared) + np.testing.assert_allclose(res, sol, atol=1e-4) + res = pairwise_distances(X, metric="nan_euclidean", squared=squared) + np.testing.assert_allclose(res, sol, atol=1e-4) + + @pytest.mark.parametrize( "x_order,y_order", [("C", "C"), ("C", "F"), ("F", "C"), ("F", "F")], @@ -1594,7 +1678,7 @@ def naive_hellinger(X, Y, metric=None): def prepare_sparse_data(size0, size1, dtype, density, metric): # create sparse array, then normalize every row to one - data = cupyx.scipy.sparse.random( + data = cp_sp.random( size0, size1, dtype=dtype, random_state=123, density=density ).tocsr() if metric == "hellinger": @@ -1602,7 +1686,7 @@ def prepare_sparse_data(size0, size1, dtype, density, metric): return data -def ref_sparse_pairwise_dist(X, Y=None, metric=None): +def ref_pairwise_distances_sparse(X, Y=None, metric=None): # Select sklearn except for IP and Hellinger that sklearn doesn't support # Use sparse input for sklearn calls when possible if Y is None: @@ -1634,10 +1718,9 @@ def ref_sparse_pairwise_dist(X, Y=None, metric=None): ) # ignoring boolean conversion warning for both cuml and sklearn @pytest.mark.filterwarnings("ignore:(.*)converted(.*)::") -def test_sparse_pairwise_distances_corner_cases( +def test_pairwise_distances_sparse_corner_cases( metric: str, matrix_size, density: float ): - # Test the sparse_pairwise_distance helper function. # For fp64, compare at 7 decimals, (5 places less than the ~15 max) compare_precision = 7 @@ -1645,26 +1728,26 @@ def test_sparse_pairwise_distances_corner_cases( X = prepare_sparse_data( matrix_size[0], matrix_size[1], cp.float64, density, metric ) - S = sparse_pairwise_distances(X, metric=metric) - S2 = ref_sparse_pairwise_dist(X, metric=metric) + S = pairwise_distances(X, metric=metric) + S2 = ref_pairwise_distances_sparse(X, metric=metric) cp.testing.assert_array_almost_equal(S, S2, decimal=compare_precision) # Compare to sklearn, double input with same dimensions Y = X S = pairwise_distances(X, Y, metric=metric) - S2 = ref_sparse_pairwise_dist(X, Y, metric=metric) + S2 = ref_pairwise_distances_sparse(X, Y, metric=metric) cp.testing.assert_array_almost_equal(S, S2, decimal=compare_precision) # Compare to sklearn, with Y dim != X dim Y = prepare_sparse_data(2, matrix_size[1], cp.float64, density, metric) S = pairwise_distances(X, Y, metric=metric) - S2 = ref_sparse_pairwise_dist(X, Y, metric=metric) + S2 = ref_pairwise_distances_sparse(X, Y, metric=metric) cp.testing.assert_array_almost_equal(S, S2, decimal=compare_precision) # Change precision of one parameter, should work (convert_dtype=True) Y = Y.astype(cp.float32) - S = sparse_pairwise_distances(X, Y, metric=metric) - S2 = ref_sparse_pairwise_dist(X, Y, metric=metric) + S = pairwise_distances(X, Y, metric=metric) + S2 = ref_pairwise_distances_sparse(X, Y, metric=metric) cp.testing.assert_array_almost_equal(S, S2, decimal=compare_precision) # For fp32, compare at 3 decimals, (4 places less than the ~7 max) @@ -1677,8 +1760,8 @@ def test_sparse_pairwise_distances_corner_cases( Y = prepare_sparse_data( matrix_size[0], matrix_size[1], cp.float32, density, metric ) - S = sparse_pairwise_distances(X, Y, metric=metric) - S2 = ref_sparse_pairwise_dist(X, Y, metric=metric) + S = pairwise_distances(X, Y, metric=metric) + S2 = ref_pairwise_distances_sparse(X, Y, metric=metric) cp.testing.assert_array_almost_equal(S, S2, decimal=compare_precision) # Test sending an int type (convert_dtype=True) @@ -1686,60 +1769,9 @@ def test_sparse_pairwise_distances_corner_cases( compare_precision = 2 Y = Y * 100 Y.data = Y.data.astype(cp.int32) - S = sparse_pairwise_distances(X, Y, metric=metric) - S2 = ref_sparse_pairwise_dist(X, Y, metric=metric) + S = pairwise_distances(X, Y, metric=metric) + S2 = ref_pairwise_distances_sparse(X, Y, metric=metric) cp.testing.assert_array_almost_equal(S, S2, decimal=compare_precision) - # Test that uppercase on the metric name throws an error. - with pytest.raises(ValueError): - sparse_pairwise_distances(X, Y, metric=metric.capitalize()) - - -def test_sparse_pairwise_distances_exceptions(): - X_int = ( - scipy.sparse.random( - 5, 4, dtype=np.float32, random_state=123, density=0.3 - ) - * 10 - ) - X_int.dtype = cp.int32 - X_bool = scipy.sparse.random( - 5, 4, dtype=bool, random_state=123, density=0.3 - ) - X_double = cupyx.scipy.sparse.random( - 5, 4, dtype=cp.float64, random_state=123, density=0.3 - ) - X_float = cupyx.scipy.sparse.random( - 5, 4, dtype=cp.float32, random_state=123, density=0.3 - ) - - # Test int inputs (only float/double accepted at this time) - with pytest.raises(TypeError): - sparse_pairwise_distances(X_int, metric="euclidean") - - # Test second int inputs (should not have an exception with - # convert_dtype=True) - sparse_pairwise_distances(X_double, X_int, metric="euclidean") - - # Test bool inputs (only float/double accepted at this time) - with pytest.raises(TypeError): - sparse_pairwise_distances(X_bool, metric="euclidean") - - # Test sending different types with convert_dtype=False - with pytest.raises(TypeError): - sparse_pairwise_distances( - X_double, X_float, metric="euclidean", convert_dtype=False - ) - - # Invalid metric name - with pytest.raises(ValueError): - sparse_pairwise_distances(X_double, metric="Not a metric") - - # Invalid dimensions - X = cupyx.scipy.sparse.random(5, 4, dtype=np.float32, random_state=123) - Y = cupyx.scipy.sparse.random(5, 7, dtype=np.float32, random_state=123) - - with pytest.raises(ValueError): - sparse_pairwise_distances(X, Y, metric="euclidean") @pytest.mark.parametrize( @@ -1767,7 +1799,7 @@ def test_sparse_pairwise_distances_exceptions(): ) # ignoring boolean conversion warning for both cuml and sklearn @pytest.mark.filterwarnings("ignore:(.*)converted(.*)::") -def test_sparse_pairwise_distances_sklearn_comparison( +def test_pairwise_distances_sparse_sklearn_comparison( metric: str, matrix_size, density: float ): # Test larger sizes to sklearn @@ -1784,10 +1816,10 @@ def test_sparse_pairwise_distances_sklearn_comparison( compare_precision = 7 # Compare to sklearn, fp64 - S = sparse_pairwise_distances(X, Y, metric=metric) + S = pairwise_distances(X, Y, metric=metric) if element_count <= 2000000: - S2 = ref_sparse_pairwise_dist(X, Y, metric=metric) + S2 = ref_pairwise_distances_sparse(X, Y, metric=metric) cp.testing.assert_array_almost_equal(S, S2, decimal=compare_precision) # For fp32, compare at 3 decimals, (4 places less than the ~7 max) @@ -1797,30 +1829,26 @@ def test_sparse_pairwise_distances_sklearn_comparison( Y = Y.astype(np.float32) # Compare to sklearn, fp32 - S = sparse_pairwise_distances(X, Y, metric=metric) + S = pairwise_distances(X, Y, metric=metric) if element_count <= 2000000: - S2 = ref_sparse_pairwise_dist(X, Y, metric=metric) + S2 = ref_pairwise_distances_sparse(X, Y, metric=metric) cp.testing.assert_array_almost_equal(S, S2, decimal=compare_precision) @pytest.mark.parametrize("input_type", ["numpy", "cupy"]) @pytest.mark.parametrize("output_type", ["cudf", "numpy", "cupy"]) -def test_sparse_pairwise_distances_output_types(input_type, output_type): +def test_pairwise_distances_sparse_output_types(input_type, output_type): if input_type == "cupy": - X = cupyx.scipy.sparse.random( - 100, 100, dtype=cp.float64, random_state=123 - ) - Y = cupyx.scipy.sparse.random( - 100, 100, dtype=cp.float64, random_state=456 - ) + X = cp_sp.random(100, 100, dtype=cp.float64, random_state=123) + Y = cp_sp.random(100, 100, dtype=cp.float64, random_state=456) else: X = scipy.sparse.random(100, 100, dtype=np.float64, random_state=123) Y = scipy.sparse.random(100, 100, dtype=np.float64, random_state=456) # Use the global manager object. with cuml.using_output_type(output_type): - S = sparse_pairwise_distances(X, Y, metric="euclidean") + S = pairwise_distances(X, Y, metric="euclidean") if output_type == "cudf": assert isinstance(S, cudf.DataFrame) elif output_type == "numpy": From 33a3960ff1dca98b09b10e10231e11a3d2b8d579 Mon Sep 17 00:00:00 2001 From: Jim Crist-Harif Date: Mon, 15 Jun 2026 11:57:48 -0500 Subject: [PATCH 2/3] Deprecate `sparse_pairwise_distances` --- .../cuml/cuml/metrics/pairwise_distances.pyx | 99 +++++++++---------- python/cuml/tests/test_metrics.py | 9 ++ 2 files changed, 54 insertions(+), 54 deletions(-) diff --git a/python/cuml/cuml/metrics/pairwise_distances.pyx b/python/cuml/cuml/metrics/pairwise_distances.pyx index b0c7619e06..7c01a155fa 100644 --- a/python/cuml/cuml/metrics/pairwise_distances.pyx +++ b/python/cuml/cuml/metrics/pairwise_distances.pyx @@ -267,42 +267,31 @@ def _ensure_boolean(X, metric): def pairwise_distances( X, Y=None, metric="euclidean", convert_dtype=True, metric_arg=2, **kwds ): - """ - Compute the distance matrix from a vector array `X` and optional `Y`. - - This method takes either one or two vector arrays, and returns a distance - matrix. - - If `Y` is given (default is `None`), then the returned matrix is the - pairwise distance between the arrays from both `X` and `Y`. - - Valid values for metric are: + """Compute the distance matrix from a feature array X and optional Y. - - From scikit-learn: ['cityblock', 'cosine', 'euclidean', 'l1', 'l2', \ - 'manhattan']. - Sparse matrices are supported, see 'sparse_pairwise_distances'. - - From scipy.spatial.distance: ['sqeuclidean'] - See the documentation for scipy.spatial.distance for details on this - metric. Sparse matrices are supported. + This function takes either one or two feature arrays, and returns + a distance matrix. Parameters ---------- - X : {array-like, sparse matrix} (device or host) of shape \ - (n_samples_x, n_features) - Acceptable formats: cuDF DataFrame, NumPy ndarray, Numba device - ndarray, cuda array interface compliant array like CuPy, or - cupyx.scipy.sparse for sparse input. + X : {array-like, sparse matrix}, shape=(n_samples_X, n_features) + A feature array. - Y : array-like (device or host) of shape (n_samples_y, n_features), \ - default=None - A second feature array. If ``None``, ``Y`` is assumed to be ``X``. - Acceptable formats: cuDF DataFrame, NumPy ndarray, Numba device - ndarray, cuda array interface compliant array like CuPy. + Y : {array-like, sparse matrix}, shape=(n_samples_y, n_features), default=None + A second feature array. If None, Y=X will be used. - metric : {"cityblock", "cosine", "euclidean", "l1", "l2", "manhattan", \ - "sqeuclidean"} + metric : str, default="euclidean" The metric to use when calculating distance between instances in a - feature array. + feature array. Valid options are: + + - Supports both dense and sparse data: ['canberra', 'chebyshev', + 'cityblock', 'cosine', 'euclidean', 'hellinger', 'l1', 'l2', + 'manhattan', 'minkowski', 'sqeuclidean']. + + - Supports dense only: ['correlation', 'hamming', 'jensenshannon', + 'kldivergence', 'nan_euclidean', 'russellrao']. + + - Supports sparse only: ['dice', 'inner_product', 'jaccard']. convert_dtype : bool, optional (default = True) When set to True, the method will, when necessary, convert @@ -311,37 +300,27 @@ def pairwise_distances( Returns ------- - D : array [n_samples_x, n_samples_x] or [n_samples_x, n_samples_y] - A distance matrix D such that D_{i, j} is the distance between the - ith and jth vectors of the given matrix `X`, if `Y` is None. - If `Y` is not `None`, then D_{i, j} is the distance between the ith - array from `X` and the jth array from `Y`. + D : array, shape=(n_samples_X, n_samples_X) or (n_samples_X, n_samples_Y) + A distance matrix D such that D_{i, j} is the distance between the ith + and jth vectors of the given matrix X, if Y is None. If Y is not None, + then D_{i, j} is the distance between the ith array from X and the jth + array from Y. Examples -------- >>> import cupy as cp >>> from cuml.metrics import pairwise_distances - >>> X = cp.array([[2.0, 3.0], [3.0, 5.0], [5.0, 8.0]]) - >>> Y = cp.array([[1.0, 0.0], [2.0, 1.0]]) - - >>> # Euclidean Pairwise Distance, Single Input: - >>> pairwise_distances(X, metric='euclidean') - array([[0. , 2.236..., 5.830...], - [2.236..., 0. , 3.605...], - [5.830..., 3.605..., 0. ]]) - - >>> # Cosine Pairwise Distance, Multi-Input: - >>> pairwise_distances(X, Y, metric='cosine') - array([[0.445... , 0.131...], - [0.485..., 0.156...], - [0.470..., 0.146...]]) - - >>> # Manhattan Pairwise Distance, Multi-Input: - >>> pairwise_distances(X, Y, metric='manhattan') - array([[ 4., 2.], - [ 7., 5.], - [12., 10.]]) + >>> X = cp.array([[0., 0., 0.], [1., 1., 1.]]) + >>> Y = cp.array([[1., 0., 0.], [1., 1., 0.]]) + + >>> pairwise_distances(X, metric="sqeuclidean") + array([[0., 3.], + [3., 0.]]) + + >>> pairwise_distances(X, Y, metric="sqeuclidean") + array([[1., 2.], + [2., 1.]]) """ if metric == "nan_euclidean": return nan_euclidean_distances(X, Y, **kwds) @@ -509,6 +488,12 @@ def sparse_pairwise_distances( """ Compute the distance matrix from a vector array `X` and optional `Y`. + .. deprecated:: 26.08 + + The ``sparse_pairwise_distances`` function was deprecated in version + 26.08 and will be removed in version 26.10. Please use + ``pairwise_distances`` instead. + This method takes either one or two sparse vector arrays, and returns a dense distance matrix. @@ -584,6 +569,12 @@ def sparse_pairwise_distances( array([[2. ], [2.333...]]) """ + warnings.warn( + "The ``sparse_pairwise_distances`` function was deprecated " + "in version 26.08 and will be removed in version 26.10. " + "Please use ``pairwise_distances`` instead.", + FutureWarning, + ) return pairwise_distances( X, Y, diff --git a/python/cuml/tests/test_metrics.py b/python/cuml/tests/test_metrics.py index 589352697a..0755f20478 100644 --- a/python/cuml/tests/test_metrics.py +++ b/python/cuml/tests/test_metrics.py @@ -58,6 +58,7 @@ pairwise_distances, precision_recall_curve, roc_auc_score, + sparse_pairwise_distances, ) from cuml.metrics.cluster import adjusted_rand_score as cu_ars from cuml.metrics.cluster import entropy @@ -1241,6 +1242,14 @@ def prep_dense_array(array, metric, col_major=0): return np.asfortranarray(array) if col_major else array +def test_sparse_pairwise_distances_deprecated(): + X = cp_sp.random(10, 10, random_state=42, density=0.5) + with pytest.warns(FutureWarning, match="deprecated"): + res = sparse_pairwise_distances(X, metric="sqeuclidean") + sol = sklearn_pairwise_distances(X.toarray().get(), metric="sqeuclidean") + np.testing.assert_allclose(res.get(), sol, atol=1e-4) + + @pytest.mark.filterwarnings( "ignore:Data was converted to boolean for metric russellrao:sklearn.exceptions.DataConversionWarning" ) From 4ea63d03fdd248ff98ddf797836d6a7a5b24aaea Mon Sep 17 00:00:00 2001 From: Jim Crist-Harif Date: Mon, 15 Jun 2026 12:16:17 -0500 Subject: [PATCH 3/3] Deprecate `metric_arg` to `pairwise_distances` This standardizes the signature to `pairwise_distances` to be compatible with sklearn. Users wanting to configure the norm used in minkowski should use `p` instead of `metric_arg`. --- .../cuml/cuml/metrics/pairwise_distances.pyx | 49 +++++++++----- python/cuml/tests/test_metrics.py | 64 +++++++++++++------ 2 files changed, 78 insertions(+), 35 deletions(-) diff --git a/python/cuml/cuml/metrics/pairwise_distances.pyx b/python/cuml/cuml/metrics/pairwise_distances.pyx index 7c01a155fa..be53602da0 100644 --- a/python/cuml/cuml/metrics/pairwise_distances.pyx +++ b/python/cuml/cuml/metrics/pairwise_distances.pyx @@ -265,7 +265,7 @@ def _ensure_boolean(X, metric): @reflect def pairwise_distances( - X, Y=None, metric="euclidean", convert_dtype=True, metric_arg=2, **kwds + X, Y=None, metric="euclidean", convert_dtype=True, **kwds ): """Compute the distance matrix from a feature array X and optional Y. @@ -298,6 +298,10 @@ def pairwise_distances( Y to be the same data type as X if they differ. This will increase memory used for the method. + **kwds : optional keyword parameters + Any additional metric-specific parameters. For example, with + ``metric="minkowski"``, passing ``p`` sets the norm used. + Returns ------- D : array, shape=(n_samples_X, n_samples_X) or (n_samples_X, n_samples_Y) @@ -322,9 +326,23 @@ def pairwise_distances( array([[1., 2.], [2., 1.]]) """ + cdef double p = 2 + if "metric_arg" in kwds: + warnings.warn( + "The `metric_arg` keyword was deprecated in version 26.08 and will " + "be removed in version 26.10. Please use `p` instead.", + FutureWarning, + ) + p = kwds.pop("metric_arg") + elif metric == "minkowski": + p = kwds.pop("p", 2) + if metric == "nan_euclidean": return nan_euclidean_distances(X, Y, **kwds) + if kwds: + raise TypeError(f"Unknown parameters {sorted(kwds)}") + Y_is_X = Y is None or Y is X X = check_array( @@ -366,7 +384,7 @@ def pairwise_distances( f"X.shape[1] == {X.shape[1]} while Y.shape[1] == {Y.shape[1]}" ) - cdef DistanceType metric_c = _determine_metric(metric, is_sparse=is_sparse) + cdef DistanceType distance_type = _determine_metric(metric, is_sparse=is_sparse) # Decompose X and Y into components cdef int X_n_rows = X.shape[0] @@ -409,8 +427,6 @@ def pairwise_distances( handle = get_handle() cdef handle_t *handle_ = handle.getHandle() - cdef double metric_arg_c = metric_arg - with nogil: if is_sparse: if is_float32: @@ -428,8 +444,8 @@ def pairwise_distances( Y_indptr_ptr, X_indices_ptr, Y_indices_ptr, - metric_c, - metric_arg_c, + distance_type, + p, ) else: pairwiseDistance_sparse( @@ -446,8 +462,8 @@ def pairwise_distances( Y_indptr_ptr, X_indices_ptr, Y_indices_ptr, - metric_c, - metric_arg_c, + distance_type, + p, ) else: if is_float32: @@ -459,9 +475,9 @@ def pairwise_distances( X_n_rows, Y_n_rows, n_cols, - metric_c, + distance_type, is_row_major, - metric_arg_c, + p, ) else: pairwise_distance( @@ -472,9 +488,9 @@ def pairwise_distances( X_n_rows, Y_n_rows, n_cols, - metric_c, + distance_type, is_row_major, - metric_arg_c, + p, ) handle.sync() @@ -483,7 +499,7 @@ def pairwise_distances( @reflect def sparse_pairwise_distances( - X, Y=None, metric="euclidean", convert_dtype=True, metric_arg=2, **kwds + X, Y=None, metric="euclidean", convert_dtype=True, **kwds ): """ Compute the distance matrix from a vector array `X` and optional `Y`. @@ -530,9 +546,9 @@ def sparse_pairwise_distances( Y to be the same data type as X if they differ. This will increase memory used for the method. - metric_arg : float, optional (default = 2) - Additional metric-specific argument. - For Minkowski it's the p-norm to apply. + **kwds : optional keyword parameters + Any additional metric-specific parameters. For example, with + ``metric="minkowski"``, passing ``p`` sets the norm used. Returns ------- @@ -580,6 +596,5 @@ def sparse_pairwise_distances( Y, metric=metric, convert_dtype=convert_dtype, - metric_arg=metric_arg, **kwds, ) diff --git a/python/cuml/tests/test_metrics.py b/python/cuml/tests/test_metrics.py index 0755f20478..5e2958cace 100644 --- a/python/cuml/tests/test_metrics.py +++ b/python/cuml/tests/test_metrics.py @@ -1543,6 +1543,41 @@ def test_pairwise_distances_warns_bool_conversion(metric, kind): pairwise_distances(X_bool_like, metric=metric) +def test_pairwise_distances_metric_arg_deprecated(): + X = np.array([[1, 2], [3, 4]], dtype="float64") + sol = sklearn_pairwise_distances(X, metric="minkowski", p=1) + with pytest.warns(FutureWarning, match="deprecated"): + res = pairwise_distances(X, metric="minkowski", metric_arg=1) + np.testing.assert_allclose(sol, res) + + # edge case - check that nan_euclidean warns, but still runs + with pytest.warns(FutureWarning, match="deprecated"): + res = pairwise_distances(X, metric="nan_euclidean", metric_arg=1) + sol = sklearn_pairwise_distances(X, metric="nan_euclidean") + np.testing.assert_allclose(sol, res) + + +def test_pairwise_distances_metric_kwds(): + # Forward args to minkowski + X = np.array([[1, 2], [3, 4]], dtype="float64") + sol = sklearn_pairwise_distances(X, metric="minkowski", p=1) + res = pairwise_distances(X, metric="minkowski", p=1) + np.testing.assert_allclose(sol, res) + + # Forward args to nan_euclidean + sol = sklearn_pairwise_distances( + X, metric="nan_euclidean", missing_values=1 + ) + res = pairwise_distances(X, metric="nan_euclidean", missing_values=1) + np.testing.assert_allclose(sol, res) + + # Unknown parameters raise + with pytest.raises( + TypeError, match=r"Unknown parameters \['bar', 'foo'\]" + ): + pairwise_distances(X, bar=1, foo=2) + + @pytest.mark.parametrize("bad_value", [np.nan, np.inf, -np.inf]) @pytest.mark.parametrize("position", ["X", "Y"]) def test_pairwise_distances_rejects_non_finite(bad_value, position): @@ -1639,9 +1674,8 @@ def test_pairwise_distances_degenerate_x_layout(x_order, y_order): @pytest.mark.parametrize("input_type", ["cudf", "numpy", "cupy"]) -@pytest.mark.parametrize("output_type", ["cudf", "numpy", "cupy"]) -@pytest.mark.parametrize("use_global", [True, False]) -def test_pairwise_distances_output_types(input_type, output_type, use_global): +@pytest.mark.parametrize("output_type", ["input", "cudf", "numpy", "cupy"]) +def test_pairwise_distances_output_types(input_type, output_type): # Test larger sizes to sklearn rng = np.random.RandomState(5) @@ -1655,24 +1689,18 @@ def test_pairwise_distances_output_types(input_type, output_type, use_global): X = cp.asarray(X) Y = cp.asarray(Y) - # Set to None if we are using the global object - output_type_param = None if use_global else output_type - - # Use the global manager object. Should do nothing unless use_global is set with cuml.using_output_type(output_type): # Compare to sklearn, fp64 - S = pairwise_distances( - X, Y, metric="euclidean", output_type=output_type_param - ) + S = pairwise_distances(X, Y, metric="euclidean") - if output_type == "input": - assert isinstance(S, type(X)) - elif output_type == "cudf": - assert isinstance(S, cudf.DataFrame) - elif output_type == "numpy": - assert isinstance(S, np.ndarray) - elif output_type == "cupy": - assert isinstance(S, cp.ndarray) + if output_type == "input": + assert isinstance(S, type(X)) + elif output_type == "cudf": + assert isinstance(S, cudf.DataFrame) + elif output_type == "numpy": + assert isinstance(S, np.ndarray) + elif output_type == "cupy": + assert isinstance(S, cp.ndarray) def naive_inner(X, Y, metric=None):