diff --git a/python/cuml/cuml/metrics/regression.py b/python/cuml/cuml/metrics/regression.py index cb57b44e1b..5a63776866 100644 --- a/python/cuml/cuml/metrics/regression.py +++ b/python/cuml/cuml/metrics/regression.py @@ -1,11 +1,17 @@ # -# SPDX-FileCopyrightText: Copyright (c) 2019-2025, NVIDIA CORPORATION. +# SPDX-FileCopyrightText: Copyright (c) 2019-2026, NVIDIA CORPORATION. # SPDX-License-Identifier: Apache-2.0 # import cupy as cp import numpy as np -from cuml.internals.input_utils import input_to_cupy_array +from cuml.internals.validation import ( + check_array, + check_consistent_length, + check_sample_weight, +) + +_FLOAT_OR_INT = (np.float32, np.float64, np.int32, np.int64) def _normalize_regression_metric_args( @@ -16,31 +22,39 @@ def _normalize_regression_metric_args( Validates inputs and coerces all arrays to cupy of proper shape and dtype. """ - # Coerce inputs to cupy arrays - float_or_int = [np.float32, np.float64, np.int32, np.int64] - y_true, n_rows, n_cols, _ = input_to_cupy_array( - y_true, check_dtype=float_or_int + y_true = check_array( + y_true, ensure_2d=False, dtype=_FLOAT_OR_INT, input_name="y_true" ) - y_pred, _, _, _ = input_to_cupy_array( - y_pred, check_dtype=float_or_int, check_rows=n_rows, check_cols=n_cols + y_pred = check_array( + y_pred, ensure_2d=False, dtype=_FLOAT_OR_INT, input_name="y_pred" ) - if sample_weight is not None: - sample_weight, _, _, _ = input_to_cupy_array( - sample_weight, - check_dtype=float_or_int, - check_rows=n_rows, - check_cols=1, + check_consistent_length(y_true, y_pred) + + # Treat (N,) and (N, 1) as equivalent (matches sklearn's behavior in + # `_check_reg_targets`). Only reject genuine multi-output mismatches. + if y_true.ndim == 2 and y_true.shape[1] == 1: + y_true = y_true.ravel() + if y_pred.ndim == 2 and y_pred.shape[1] == 1: + y_pred = y_pred.ravel() + + if y_true.ndim != y_pred.ndim or ( + y_true.ndim == 2 and y_true.shape[1] != y_pred.shape[1] + ): + raise ValueError( + f"y_true and y_pred have different shapes: " + f"{y_true.shape} vs {y_pred.shape}" ) - # Ensure y_true & y_pred are 2D and sample_weight is 1D + if ( + sample_weight := check_sample_weight(sample_weight, dtype=np.float64) + ) is not None: + check_consistent_length(y_true, sample_weight) + + # Promote 1D inputs to column vectors if y_true.ndim == 1: y_true = y_true.reshape((-1, 1)) - - if y_pred.ndim == 1: y_pred = y_pred.reshape((-1, 1)) - - if sample_weight is not None: - sample_weight = sample_weight.reshape(-1) + n_cols = y_true.shape[1] # Validate multioutput, and maybe coerce to a cupy array valid_multioutput = ("raw_values", "uniform_average", "variance_weighted") @@ -54,9 +68,13 @@ def _normalize_regression_metric_args( raise ValueError( "Custom weights are useful only in multi-output cases." ) - multioutput, _, _, _ = input_to_cupy_array( - multioutput, check_rows=n_cols + multioutput = check_array( + multioutput, ensure_2d=False, input_name="multioutput" ) + if multioutput.ndim != 1 or multioutput.shape[0] != n_cols: + raise ValueError( + f"There must be equally many custom weights ({n_cols}) as outputs." + ) return y_true, y_pred, sample_weight, multioutput diff --git a/python/cuml/cuml_accel_tests/upstream/scikit-learn/xfail-list.yaml b/python/cuml/cuml_accel_tests/upstream/scikit-learn/xfail-list.yaml index dd63ea8a9a..f4a3682a34 100644 --- a/python/cuml/cuml_accel_tests/upstream/scikit-learn/xfail-list.yaml +++ b/python/cuml/cuml_accel_tests/upstream/scikit-learn/xfail-list.yaml @@ -462,12 +462,10 @@ - "sklearn.tests.test_common::test_estimators[SpectralCoclustering()-check_fit2d_1sample]" - "sklearn.tests.test_common::test_estimators[SpectralCoclustering()-check_methods_subset_invariance]" - "sklearn.tests.test_common::test_search_cv[GridSearchCV(cv=2,error_score='raise',estimator=Pipeline(steps=[('pca',PCA()),('logisticregression',LogisticRegression())]),param_grid={'logisticregression__C':[0.1,1.0]})-check_fit2d_1feature]" - - "sklearn.tests.test_common::test_search_cv[GridSearchCV(cv=2,error_score='raise',estimator=Pipeline(steps=[('pca',PCA()),('ridge',Ridge())]),param_grid={'ridge__alpha':[0.1,1.0]})-check_dtype_object]" - "sklearn.tests.test_common::test_search_cv[GridSearchCV(cv=2,error_score='raise',estimator=Pipeline(steps=[('pca',PCA()),('ridge',Ridge())]),param_grid={'ridge__alpha':[0.1,1.0]})-check_fit2d_1feature]" - "sklearn.tests.test_common::test_search_cv[GridSearchCV(cv=2,error_score='raise',estimator=Pipeline(steps=[('pca',PCA()),('ridge',Ridge())]),param_grid={'ridge__alpha':[0.1,1.0]})-check_supervised_y_2d]" - "sklearn.tests.test_common::test_search_cv[GridSearchCV(cv=2,estimator=Ridge(),param_grid={'alpha':[0.1,1.0]})-check_supervised_y_2d]" - "sklearn.tests.test_common::test_search_cv[HalvingGridSearchCV(cv=2,error_score='raise',estimator=Pipeline(steps=[('pca',PCA()),('logisticregression',LogisticRegression())]),min_resources='smallest',param_grid={'logisticregression__C':[0.1,1.0]},random_state=0)-check_fit2d_1feature]" - - "sklearn.tests.test_common::test_search_cv[HalvingGridSearchCV(cv=2,error_score='raise',estimator=Pipeline(steps=[('pca',PCA()),('ridge',Ridge())]),min_resources='smallest',param_grid={'ridge__alpha':[0.1,1.0]},random_state=0)-check_dtype_object]" - "sklearn.tests.test_common::test_search_cv[HalvingGridSearchCV(cv=2,error_score='raise',estimator=Pipeline(steps=[('pca',PCA()),('ridge',Ridge())]),min_resources='smallest',param_grid={'ridge__alpha':[0.1,1.0]},random_state=0)-check_estimators_fit_returns_self(readonly_memmap=True)]" - "sklearn.tests.test_common::test_search_cv[HalvingGridSearchCV(cv=2,error_score='raise',estimator=Pipeline(steps=[('pca',PCA()),('ridge',Ridge())]),min_resources='smallest',param_grid={'ridge__alpha':[0.1,1.0]},random_state=0)-check_estimators_fit_returns_self]" - "sklearn.tests.test_common::test_search_cv[HalvingGridSearchCV(cv=2,error_score='raise',estimator=Pipeline(steps=[('pca',PCA()),('ridge',Ridge())]),min_resources='smallest',param_grid={'ridge__alpha':[0.1,1.0]},random_state=0)-check_estimators_overwrite_params]" @@ -480,7 +478,6 @@ - "sklearn.tests.test_common::test_search_cv[HalvingGridSearchCV(cv=2,estimator=Ridge(),min_resources='smallest',param_grid={'alpha':[0.1,1.0]},random_state=0)-check_supervised_y_2d1]" - "sklearn.tests.test_common::test_search_cv[HalvingRandomSearchCV(cv=2,error_score='raise',estimator=Pipeline(steps=[('pca',PCA()),('logisticregression',LogisticRegression())]),param_distributions={'logisticregression__C':[0.1,1.0]},random_state=0)-check_fit2d_1feature]" - "sklearn.tests.test_common::test_search_cv[HalvingRandomSearchCV(cv=2,error_score='raise',estimator=Pipeline(steps=[('pca',PCA()),('logisticregression',LogisticRegression())]),param_distributions={'logisticregression__C':[0.1,1.0]},random_state=0)-check_fit2d_1sample]" - - "sklearn.tests.test_common::test_search_cv[HalvingRandomSearchCV(cv=2,error_score='raise',estimator=Pipeline(steps=[('pca',PCA()),('ridge',Ridge())]),param_distributions={'ridge__alpha':[0.1,1.0]},random_state=0)-check_dtype_object]" - "sklearn.tests.test_common::test_search_cv[HalvingRandomSearchCV(cv=2,error_score='raise',estimator=Pipeline(steps=[('pca',PCA()),('ridge',Ridge())]),param_distributions={'ridge__alpha':[0.1,1.0]},random_state=0)-check_estimators_fit_returns_self(readonly_memmap=True)]" - "sklearn.tests.test_common::test_search_cv[HalvingRandomSearchCV(cv=2,error_score='raise',estimator=Pipeline(steps=[('pca',PCA()),('ridge',Ridge())]),param_distributions={'ridge__alpha':[0.1,1.0]},random_state=0)-check_estimators_fit_returns_self]" - "sklearn.tests.test_common::test_search_cv[HalvingRandomSearchCV(cv=2,error_score='raise',estimator=Pipeline(steps=[('pca',PCA()),('ridge',Ridge())]),param_distributions={'ridge__alpha':[0.1,1.0]},random_state=0)-check_estimators_overwrite_params]" @@ -489,7 +486,6 @@ - "sklearn.tests.test_common::test_search_cv[HalvingRandomSearchCV(cv=2,error_score='raise',estimator=Pipeline(steps=[('pca',PCA()),('ridge',Ridge())]),param_distributions={'ridge__alpha':[0.1,1.0]},random_state=0)-check_supervised_y_2d]" - "sklearn.tests.test_common::test_search_cv[RandomizedSearchCV(cv=2,error_score='raise',estimator=Pipeline(steps=[('pca',PCA()),('logisticregression',LogisticRegression())]),param_distributions={'logisticregression__C':[0.1,1.0]},random_state=0)-check_fit2d_1feature]" - "sklearn.tests.test_common::test_search_cv[RandomizedSearchCV(cv=2,error_score='raise',estimator=Pipeline(steps=[('pca',PCA()),('logisticregression',LogisticRegression())]),param_distributions={'logisticregression__C':[0.1,1.0]},random_state=0)-check_supervised_y_2d]" - - "sklearn.tests.test_common::test_search_cv[RandomizedSearchCV(cv=2,error_score='raise',estimator=Pipeline(steps=[('pca',PCA()),('ridge',Ridge())]),param_distributions={'ridge__alpha':[0.1,1.0]},random_state=0)-check_dtype_object]" - "sklearn.tests.test_common::test_search_cv[RandomizedSearchCV(cv=2,error_score='raise',estimator=Pipeline(steps=[('pca',PCA()),('ridge',Ridge())]),param_distributions={'ridge__alpha':[0.1,1.0]},random_state=0)-check_fit2d_1feature]" - "sklearn.tests.test_common::test_search_cv[RandomizedSearchCV(cv=2,error_score='raise',estimator=Pipeline(steps=[('pca',PCA()),('ridge',Ridge())]),param_distributions={'ridge__alpha':[0.1,1.0]},random_state=0)-check_supervised_y_2d]" - "sklearn.tests.test_common::test_search_cv[RandomizedSearchCV(cv=2,estimator=Ridge(),param_distributions={'alpha':[0.1,1.0]},random_state=0)-check_supervised_y_2d]" diff --git a/python/cuml/tests/test_metrics.py b/python/cuml/tests/test_metrics.py index 2cb8a8e13e..1f8a5acb63 100644 --- a/python/cuml/tests/test_metrics.py +++ b/python/cuml/tests/test_metrics.py @@ -16,6 +16,7 @@ import sklearn.metrics from numba import cuda from numpy.testing import assert_almost_equal +from packaging.version import Version from scipy.spatial import distance as scipy_pairwise_distances from scipy.special import rel_entr as scipy_kl_divergence from scipy.stats import entropy as sp_entropy @@ -766,6 +767,102 @@ def test_mean_squared_log_error_negative_values(inputs): ) +_REGRESSION_FUNCS = [ + "r2_score", + "mean_squared_error", + "mean_absolute_error", + "median_absolute_error", + "mean_squared_log_error", +] + + +@pytest.mark.parametrize("func", _REGRESSION_FUNCS) +def test_regression_metrics_scalar_sample_weight(func): + y_true = np.array([1.0, 2.0, 3.0, 4.0]) + y_pred = np.array([1.1, 1.9, 3.1, 3.9]) + + cu_metric = getattr(cuml.metrics, func) + skl_metric = getattr(sklearn.metrics, func) + + unweighted = cu_metric(y_true, y_pred) + assert cu_metric(y_true, y_pred, sample_weight=1.0) == unweighted + assert cu_metric(y_true, y_pred, sample_weight=2.5) == unweighted + + # Verify cuML matches scikit-learn for unweighted + np.testing.assert_allclose( + cu_metric(y_true, y_pred), skl_metric(y_true, y_pred) + ) + + # cuML accepts scalar sample_weight (equivalent to sample_weight=1.0). + # sklearn requires array-like sample_weight, so compare cuML scalar-sw + # with sklearn using a 1D array of ones. + sw_scalar = 1.0 + sw_array = np.array([sw_scalar] * len(y_true)) + np.testing.assert_allclose( + cu_metric(y_true, y_pred, sample_weight=sw_scalar), + skl_metric(y_true, y_pred, sample_weight=sw_array), + ) + + +@pytest.mark.parametrize("func", _REGRESSION_FUNCS) +@pytest.mark.parametrize( + "y_true_shape, y_pred_shape", + [((4,), (4, 1)), ((4, 1), (4,)), ((4, 1), (4, 1))], +) +def test_regression_metrics_1d_2d_equivalence( + func, y_true_shape, y_pred_shape +): + # (N,) and (N, 1) should be treated as equivalent (matches sklearn). + y_true_1d = np.array([1.0, 2.0, 3.0, 4.0]) + y_pred_1d = np.array([1.1, 1.9, 3.1, 3.9]) + + cu_metric = getattr(cuml.metrics, func) + skl_metric = getattr(sklearn.metrics, func) + expected = skl_metric(y_true_1d, y_pred_1d) + + got = cu_metric( + y_true_1d.reshape(y_true_shape), y_pred_1d.reshape(y_pred_shape) + ) + np.testing.assert_allclose(got, expected) + + +@pytest.mark.parametrize("func", _REGRESSION_FUNCS) +@pytest.mark.xfail( + condition=Version(sklearn.__version__) < Version("1.7"), + reason=( + "sklearn < 1.7 uses different error messages for invalid " + "sample_weight inputs; messages were standardized in sklearn 1.7" + ), + strict=True, +) +def test_regression_metrics_errors(func): + arr_3 = np.array([1.0, 2.0, 3.0]) + arr_4 = np.array([1.0, 2.0, 3.0, 4.0]) + arr_3x2 = np.ones((3, 2)) + + cu_metric = getattr(cuml.metrics, func) + skl_metric = getattr(sklearn.metrics, func) + + # cuML and sklearn both raise ValueError for mismatched sample counts. + # sklearn: "Found input variables with inconsistent numbers of samples" + # cuML: "inconsistent number of samples" — match common substring. + sw_mismatch = np.array([1.0, 2.0, 3.0, 4.0]) + with pytest.raises(ValueError, match="inconsistent"): + skl_metric(arr_3, arr_4) + with pytest.raises(ValueError, match="inconsistent"): + cu_metric(arr_3, arr_4) + + with pytest.raises(ValueError, match="inconsistent"): + skl_metric(arr_3, arr_3, sample_weight=sw_mismatch) + with pytest.raises(ValueError, match="inconsistent"): + cu_metric(arr_3, arr_3, sample_weight=sw_mismatch) + + with pytest.raises(ValueError, match="Sample weights must be 1D"): + skl_metric(arr_3, arr_3, sample_weight=arr_3x2) + with pytest.raises(ValueError, match="Sample weights must be 1D"): + cu_metric(arr_3, arr_3, sample_weight=arr_3x2) + + def test_entropy(): # The outcome of a fair coin is the most uncertain: # in base 2 the result is 1 (One bit of entropy).