From 382e6f0bf5bc6db2a8d36a6725d9a00cbe415b38 Mon Sep 17 00:00:00 2001 From: Simon Adorf Date: Wed, 6 May 2026 18:44:25 +0000 Subject: [PATCH 1/4] metrics.cluster: migrate adjusted_rand_index to new validation Replace input_to_cuml_array with check_array / check_consistent_length from cuml.internals.validation. Device pointers are now accessed via .data.ptr on the returned cupy arrays. ensure_min_samples=0 preserves the existing behaviour for empty inputs (exercised by test_adjusted_rand_score_small). xref #7998 --- .../metrics/cluster/adjusted_rand_index.pyx | 44 +++++++++++++------ 1 file changed, 30 insertions(+), 14 deletions(-) diff --git a/python/cuml/cuml/metrics/cluster/adjusted_rand_index.pyx b/python/cuml/cuml/metrics/cluster/adjusted_rand_index.pyx index 957127e182..ac0993d22e 100644 --- a/python/cuml/cuml/metrics/cluster/adjusted_rand_index.pyx +++ b/python/cuml/cuml/metrics/cluster/adjusted_rand_index.pyx @@ -2,10 +2,10 @@ # SPDX-FileCopyrightText: Copyright (c) 2019-2026, NVIDIA CORPORATION. # SPDX-License-Identifier: Apache-2.0 # -import cupy as cp +import numpy as np -from cuml.common import input_to_cuml_array from cuml.internals import get_handle +from cuml.internals.validation import check_array, check_consistent_length from libc.stdint cimport uintptr_t from pylibraft.common.handle cimport handle_t @@ -38,19 +38,35 @@ def adjusted_rand_score(labels_true, labels_pred, convert_dtype=True) -> float: handle = get_handle() cdef handle_t* handle_ = handle.getHandle() - labels_true, n_rows, _, _ = \ - input_to_cuml_array(labels_true, order='C', check_dtype=cp.int32, - convert_to_dtype=(cp.int32 if convert_dtype - else None)) - - labels_pred, _, _, _ = \ - input_to_cuml_array(labels_pred, order='C', check_dtype=cp.int32, - convert_to_dtype=(cp.int32 if convert_dtype - else None)) + labels_true = check_array( + labels_true, + ensure_2d=False, + ensure_min_samples=0, + order='C', + dtype=np.int32, + convert_dtype=convert_dtype, + input_name='labels_true', + ) + labels_pred = check_array( + labels_pred, + ensure_2d=False, + ensure_min_samples=0, + order='C', + dtype=np.int32, + convert_dtype=convert_dtype, + input_name='labels_pred', + ) + if labels_true.ndim != 1 or labels_pred.ndim != 1: + raise ValueError( + "labels_true and labels_pred must be 1D arrays, got shapes " + f"{labels_true.shape} and {labels_pred.shape}" + ) + check_consistent_length(labels_true, labels_pred) + cdef int n_rows = labels_true.shape[0] rand_score = adjusted_rand_index(handle_[0], - labels_true.ptr, - labels_pred.ptr, - n_rows) + labels_true.data.ptr, + labels_pred.data.ptr, + n_rows) return rand_score From 1bad51f04d7a077074edba67cd6298e7ee20a4a9 Mon Sep 17 00:00:00 2001 From: Simon Adorf Date: Wed, 6 May 2026 18:44:30 +0000 Subject: [PATCH 2/4] metrics.cluster: migrate entropy to new validation Replace input_to_cupy_array with check_array from cuml.internals.validation. The check now accepts 1-D or (n, 1) inputs and rejects anything wider, matching the legacy check_cols=1 behaviour. xref #7998 --- python/cuml/cuml/metrics/cluster/entropy.pyx | 19 ++++++++++++++----- 1 file changed, 14 insertions(+), 5 deletions(-) diff --git a/python/cuml/cuml/metrics/cluster/entropy.pyx b/python/cuml/cuml/metrics/cluster/entropy.pyx index e5135636ee..9aa020905c 100644 --- a/python/cuml/cuml/metrics/cluster/entropy.pyx +++ b/python/cuml/cuml/metrics/cluster/entropy.pyx @@ -8,7 +8,7 @@ import cupy as cp import numpy as np from cuml.internals import get_handle -from cuml.internals.input_utils import input_to_cupy_array +from cuml.internals.validation import check_array from libc.stdint cimport uintptr_t from pylibraft.common.handle cimport handle_t @@ -44,11 +44,20 @@ def cython_entropy(clustering, base=None) -> float: handle = get_handle() cdef handle_t *handle_ = handle.getHandle() - clustering, n_rows, _, _ = input_to_cupy_array( + clustering = check_array( clustering, - check_dtype=np.int32, - check_cols=1 + ensure_2d=False, + order='C', + dtype=np.int32, + input_name='clustering', ) + if clustering.ndim == 2 and clustering.shape[1] != 1: + raise ValueError( + "clustering must have shape (n_samples,) or (n_samples, 1), got " + f"{clustering.shape}" + ) + clustering = clustering.ravel() + cdef int n_rows = clustering.shape[0] lower_class_range = cp.min(clustering).item() upper_class_range = cp.max(clustering).item() @@ -56,7 +65,7 @@ def cython_entropy(clustering, base=None) -> float: S = entropy(handle_[0], clustering_ptr, - n_rows, + n_rows, lower_class_range, upper_class_range) From c3d22f5e0db640277babcb16c1f129ca1691bcb1 Mon Sep 17 00:00:00 2001 From: Simon Adorf Date: Wed, 6 May 2026 18:44:36 +0000 Subject: [PATCH 3/4] metrics.cluster: migrate utils + v/h/c/mi scores to new validation Replace input_to_cuml_array in prepare_cluster_metric_inputs (utils.py) with check_array / check_consistent_length from cuml.internals.validation. The helper now returns plain cupy int32 ndarrays instead of CumlArrays, so the four Cython callers (v_measure, mutual_info_score, homogeneity_score, completeness_score) are updated to access the device pointer via .data.ptr rather than .ptr. ensure_min_samples=0 is used throughout to preserve the existing behaviour for empty inputs. xref #7998 --- .../metrics/cluster/completeness_score.pyx | 4 +- .../metrics/cluster/homogeneity_score.pyx | 4 +- .../metrics/cluster/mutual_info_score.pyx | 4 +- python/cuml/cuml/metrics/cluster/utils.py | 47 +++++++++++++------ .../cuml/cuml/metrics/cluster/v_measure.pyx | 4 +- 5 files changed, 40 insertions(+), 23 deletions(-) diff --git a/python/cuml/cuml/metrics/cluster/completeness_score.pyx b/python/cuml/cuml/metrics/cluster/completeness_score.pyx index f92db1513f..605e9d3117 100644 --- a/python/cuml/cuml/metrics/cluster/completeness_score.pyx +++ b/python/cuml/cuml/metrics/cluster/completeness_score.pyx @@ -60,8 +60,8 @@ def cython_completeness_score(labels_true, labels_pred) -> float: labels_pred ) - cdef uintptr_t ground_truth_ptr = y_true.ptr - cdef uintptr_t preds_ptr = y_pred.ptr + cdef uintptr_t ground_truth_ptr = y_true.data.ptr + cdef uintptr_t preds_ptr = y_pred.data.ptr com = completeness_score(handle_[0], ground_truth_ptr, diff --git a/python/cuml/cuml/metrics/cluster/homogeneity_score.pyx b/python/cuml/cuml/metrics/cluster/homogeneity_score.pyx index e1ce8e67b4..fa829fafec 100644 --- a/python/cuml/cuml/metrics/cluster/homogeneity_score.pyx +++ b/python/cuml/cuml/metrics/cluster/homogeneity_score.pyx @@ -61,8 +61,8 @@ def cython_homogeneity_score(labels_true, labels_pred) -> float: labels_pred ) - cdef uintptr_t ground_truth_ptr = y_true.ptr - cdef uintptr_t preds_ptr = y_pred.ptr + cdef uintptr_t ground_truth_ptr = y_true.data.ptr + cdef uintptr_t preds_ptr = y_pred.data.ptr hom = homogeneity_score(handle_[0], ground_truth_ptr, diff --git a/python/cuml/cuml/metrics/cluster/mutual_info_score.pyx b/python/cuml/cuml/metrics/cluster/mutual_info_score.pyx index 3aef63b82a..9e98eea959 100644 --- a/python/cuml/cuml/metrics/cluster/mutual_info_score.pyx +++ b/python/cuml/cuml/metrics/cluster/mutual_info_score.pyx @@ -63,8 +63,8 @@ def cython_mutual_info_score(labels_true, labels_pred) -> float: labels_pred ) - cdef uintptr_t ground_truth_ptr = y_true.ptr - cdef uintptr_t preds_ptr = y_pred.ptr + cdef uintptr_t ground_truth_ptr = y_true.data.ptr + cdef uintptr_t preds_ptr = y_pred.data.ptr mi = mutual_info_score(handle_[0], ground_truth_ptr, diff --git a/python/cuml/cuml/metrics/cluster/utils.py b/python/cuml/cuml/metrics/cluster/utils.py index fdddaa1005..714b1a1552 100644 --- a/python/cuml/cuml/metrics/cluster/utils.py +++ b/python/cuml/cuml/metrics/cluster/utils.py @@ -1,10 +1,10 @@ # -# SPDX-FileCopyrightText: Copyright (c) 2020-2025, NVIDIA CORPORATION. +# SPDX-FileCopyrightText: Copyright (c) 2020-2026, NVIDIA CORPORATION. # SPDX-License-Identifier: Apache-2.0 # -import cupy as cp +import numpy as np -from cuml.common import input_to_cuml_array +from cuml.internals.validation import check_array, check_consistent_length from cuml.metrics.utils import sorted_unique_labels from cuml.prims.label import make_monotonic @@ -12,26 +12,43 @@ def prepare_cluster_metric_inputs(labels_true, labels_pred): """Helper function to avoid code duplication for homogeneity score, mutual info score and completeness score. + + Returns ``(y_true, y_pred, n_rows, lower_class_range, upper_class_range)`` + where ``y_true`` and ``y_pred`` are C-contiguous int32 ``cupy.ndarray`` + arrays whose label values have been remapped to the contiguous range + ``[0, len(classes) - 1]``. """ - y_true, n_rows, _, dtype = input_to_cuml_array( + y_true = check_array( labels_true, - check_dtype=[cp.int32, cp.int64], - check_cols=1, - deepcopy=True, # deepcopy because we call make_monotonic inplace below + ensure_2d=False, + ensure_min_samples=0, + order="C", + dtype=np.int32, + input_name="labels_true", ) - - y_pred, _, _, _ = input_to_cuml_array( + y_pred = check_array( labels_pred, - check_dtype=dtype, - check_rows=n_rows, - check_cols=1, - deepcopy=True, # deepcopy because we call make_monotonic inplace below + ensure_2d=False, + ensure_min_samples=0, + order="C", + dtype=np.int32, + input_name="labels_pred", ) + if y_true.ndim != 1 or y_pred.ndim != 1: + raise ValueError( + "labels_true and labels_pred must be 1D arrays, got shapes " + f"{y_true.shape} and {y_pred.shape}" + ) + check_consistent_length(y_true, y_pred) + n_rows = y_true.shape[0] classes = sorted_unique_labels(y_true, y_pred) - make_monotonic(y_true, classes=classes, copy=False) - make_monotonic(y_pred, classes=classes, copy=False) + # Make copies so that we never mutate the caller's input arrays. + # ``make_monotonic`` with ``copy=True`` returns new cupy arrays; we + # use those for the downstream Cython callers. + y_true, _ = make_monotonic(y_true, classes=classes, copy=True) + y_pred, _ = make_monotonic(y_pred, classes=classes, copy=True) # Those values are only correct because we used make_monotonic lower_class_range = 0 diff --git a/python/cuml/cuml/metrics/cluster/v_measure.pyx b/python/cuml/cuml/metrics/cluster/v_measure.pyx index 4efcc7939a..ebca124934 100644 --- a/python/cuml/cuml/metrics/cluster/v_measure.pyx +++ b/python/cuml/cuml/metrics/cluster/v_measure.pyx @@ -67,8 +67,8 @@ def cython_v_measure(labels_true, labels_pred, beta=1.0) -> float: labels_pred ) - cdef uintptr_t ground_truth_ptr = y_true.ptr - cdef uintptr_t preds_ptr = y_pred.ptr + cdef uintptr_t ground_truth_ptr = y_true.data.ptr + cdef uintptr_t preds_ptr = y_pred.data.ptr v_measure_value = v_measure(handle_[0], ground_truth_ptr, From 259cb9c17a92bdf1e40fa112d79b28797225f204 Mon Sep 17 00:00:00 2001 From: Simon Adorf Date: Wed, 6 May 2026 18:44:43 +0000 Subject: [PATCH 4/4] metrics.cluster: migrate silhouette_score to new validation Replace input_to_cuml_array with check_array / check_consistent_length from cuml.internals.validation in _silhouette_coeff. Device pointers are now accessed via .data.ptr on the returned cupy arrays. Pass ensure_all_finite=False for the sil_scores output buffer: it is a pre-allocated cp.empty array that may contain uninitialised values (including NaN) before the C++ kernel writes into it, so checking for finite values up-front would raise a spurious ValueError. xref #7998 --- .../cuml/metrics/cluster/silhouette_score.pyx | 73 ++++++++++--------- 1 file changed, 40 insertions(+), 33 deletions(-) diff --git a/python/cuml/cuml/metrics/cluster/silhouette_score.pyx b/python/cuml/cuml/metrics/cluster/silhouette_score.pyx index 3cb4ff1c6b..ecef4d959f 100644 --- a/python/cuml/cuml/metrics/cluster/silhouette_score.pyx +++ b/python/cuml/cuml/metrics/cluster/silhouette_score.pyx @@ -5,8 +5,8 @@ import cupy as cp import numpy as np -from cuml.common import input_to_cuml_array from cuml.internals import get_handle +from cuml.internals.validation import check_array, check_consistent_length from cuml.metrics.pairwise_distances import _determine_metric from libc.stdint cimport uintptr_t @@ -72,62 +72,69 @@ def _silhouette_coeff( if chunksize is None: chunksize = 40000 - data, n_rows, n_cols, dtype = input_to_cuml_array( + data = check_array( X, order='C', - convert_to_dtype=(np.float32 if convert_dtype - else None), - check_dtype=[np.float32, np.float64], + dtype=[np.float32, np.float64], + convert_dtype=convert_dtype, + input_name='X', ) + cdef int n_rows = data.shape[0] + cdef int n_cols = data.shape[1] + dtype = data.dtype - labels, _, _, _ = input_to_cuml_array( + labels = check_array( labels, + ensure_2d=False, order='C', - convert_to_dtype=np.int32 + dtype=np.int32, + input_name='labels', ) + if labels.ndim != 1: + raise ValueError( + f"labels must be a 1D array, got shape {labels.shape}" + ) + check_consistent_length(data, labels) - # Use cp.unique with return_inverse to get monotonic labels efficiently - labels_cupy = labels.to_output(output_type='cupy', output_dtype='int') - unique_labels, inverse = cp.unique(labels_cupy, return_inverse=True) - n_labels = unique_labels.shape[0] - - mono_labels, _, _, _ = input_to_cuml_array( - inverse, - order='C', - convert_to_dtype=np.int32 - ) + # Use cp.unique with return_inverse to get monotonic labels efficiently. + unique_labels, inverse = cp.unique(labels, return_inverse=True) + cdef int n_labels = unique_labels.shape[0] + mono_labels = cp.ascontiguousarray(inverse, dtype=np.int32) cdef uintptr_t scores_ptr if sil_scores is None: scores_ptr = NULL else: - sil_scores = input_to_cuml_array( + sil_scores = check_array( sil_scores, - convert_to_dtype=(dtype if convert_dtype - else None), - check_dtype=dtype)[0] - - scores_ptr = sil_scores.ptr + ensure_2d=False, + order='C', + dtype=[dtype], + convert_dtype=convert_dtype, + input_name='sil_scores', + ensure_all_finite=False, # output buffer may be uninitialized + ) + scores_ptr = sil_scores.data.ptr metric = _determine_metric(metric) if dtype == np.float32: return silhouette_score(handle_[0], - data.ptr, - n_rows, - n_cols, - mono_labels.ptr, - n_labels, + data.data.ptr, + n_rows, + n_cols, + mono_labels.data.ptr, + n_labels, scores_ptr, chunksize, metric) elif dtype == np.float64: return silhouette_score(handle_[0], - data.ptr, - n_rows, - n_cols, - mono_labels.ptr, - n_labels, + data.data.ptr, + n_rows, + n_cols, + mono_labels.data.ptr, + n_labels, scores_ptr, chunksize, metric)