Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
62 changes: 40 additions & 22 deletions python/cuml/cuml/metrics/regression.py
Original file line number Diff line number Diff line change
@@ -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(
Expand All @@ -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)

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Not a problem, but note that check_consistent_length works fine even if some of the inputs are None. So you can unconditionally call it on all input arrays, including optional ones.


# 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))
Comment thread
coderabbitai[bot] marked this conversation as resolved.

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")
Expand All @@ -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

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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]"
Expand All @@ -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]"
Expand All @@ -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]"
Expand Down
97 changes: 97 additions & 0 deletions python/cuml/tests/test_metrics.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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
Comment thread
csadorf marked this conversation as resolved.

# 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).
Expand Down
Loading