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
184 changes: 96 additions & 88 deletions python/cuml/cuml/metrics/hinge_loss.py
Original file line number Diff line number Diff line change
@@ -1,130 +1,138 @@
#
# SPDX-FileCopyrightText: Copyright (c) 2021-2025, NVIDIA CORPORATION.
# SPDX-FileCopyrightText: Copyright (c) 2021-2026, NVIDIA CORPORATION.
# SPDX-License-Identifier: Apache-2.0
#
import cudf
import warnings

import cupy as cp
import numpy as np

from cuml.internals.input_utils import determine_array_type
from cuml.preprocessing import LabelBinarizer, LabelEncoder
from cuml.internals.validation import (
check_array,
check_consistent_length,
check_sample_weight,
check_y,
)


def hinge_loss(
y_true, pred_decision, labels=None, sample_weights=None
y_true,
pred_decision,
labels=None,
sample_weight=None,
*,
sample_weights="deprecated",
) -> float:
"""
Calculates non-regularized hinge loss. Adapted from scikit-learn hinge loss
Calculates non-regularized hinge loss. Adapted from scikit-learn hinge loss.

Parameters
----------
y_true: cuDF Series or cuPy array of shape (n_samples,)
True labels, consisting of labels for the classes.
In binary classification, the positive label must be
greater than negative class
y_true : array-like of shape (n_samples,)
True labels, consisting of labels for the classes. In binary
classification, the positive label must be greater than the negative
label.

pred_decision: cuDF DataFrame or cuPy array of shape (n_samples,) or \
(n_samples, n_classes)
Predicted decisions, as output by decision_function (floats)
pred_decision : array-like of shape (n_samples,) or (n_samples, n_classes)
Predicted decisions, as output by ``decision_function`` (floats).

labels: cuDF Series or cuPy array, default=None
labels : array-like, default=None
In multiclass problems, this must include all class labels.

sample_weight: cupy array of shape (n_samples,), default=None
Sample weights to be used for computing the average
sample_weight : array-like of shape (n_samples,), default=None
Sample weights to be used for computing the average.

sample_weights : array-like, default="deprecated"
Deprecated alias for ``sample_weight``.

.. deprecated:: 26.06
``sample_weights`` was renamed to ``sample_weight`` and will be
removed in 26.08.

Returns
-------
loss : float
The average hinge loss.
The average hinge loss.
"""

yt_type = determine_array_type(y_true)
pd_type = determine_array_type(pred_decision)
labels_type = determine_array_type(labels)

if yt_type not in ["cupy", "numba", "cudf"]:
raise TypeError(
"y_true needs to be either a cuDF Series or \
a cuPy/numba array."
)

if pd_type not in ["cupy", "numba", "cudf"]:
raise TypeError(
"pred_decision needs to be either a cuDF DataFrame or \
a cuPy/numba array."
)

if labels_type not in ["cupy", "numba", "cudf"]:
raise TypeError(
"labels needs to be either a cuDF Series or \
a cuPy/numba array."
)

if y_true.shape[0] != pred_decision.shape[0]:
raise ValueError(
"y_true and pred_decision must have the same"
" number of rows(found {} and {})".format(
y_true.shape[0], pred_decision.shape[0]
)
# Handle the deprecated `sample_weights` alias.
if not (
isinstance(sample_weights, str) and sample_weights == "deprecated"
):
warnings.warn(
"`sample_weights` was renamed to `sample_weight` in 26.06 and "
"will be removed in 26.08.",
FutureWarning,
stacklevel=2,
)

if sample_weights and sample_weights.shape[0] != y_true.shape[0]:
raise ValueError(
"y_true and sample_weights must have the same "
"number of rows (found {} and {})".format(
y_true.shape[0], sample_weights.shape[0]
)
if sample_weight is None:
sample_weight = sample_weights

pred_decision = check_array(
pred_decision,
ensure_2d=False,
dtype=(np.float32, np.float64),
input_name="pred_decision",
)
if labels is not None:
labels = check_array(
labels,
ensure_2d=False,
ensure_all_finite=False,
input_name="labels",
)
classes = np.unique(cp.asnumpy(labels))
else:
classes = None

if classes is None:
y_true, classes = check_y(y_true, return_classes=True)
elif classes.size > 2:
# For multiclass hinge loss, supplied labels define the column order
# for pred_decision and should be used to encode y_true.
y_true, classes = check_y(y_true, return_classes=classes)
else:
# For sklearn-compatible binary hinge loss, supplied labels select the
# binary branch, but the sign transform is fit from observed y_true.
y_true, _ = check_y(y_true, return_classes=True)

if not isinstance(labels, cudf.Series):
labels = cudf.Series(labels)

if not isinstance(y_true, cudf.Series):
y_true = cudf.Series(y_true)

y_true_unique = cp.unique(labels if labels is not None else y_true)
sample_weight = check_sample_weight(sample_weight, dtype=np.float64)
check_consistent_length(y_true, pred_decision, sample_weight)

if y_true_unique.size > 2:
if classes.size > 2:
# Multiclass case
if (
labels is None
and pred_decision.ndim > 1
and (cp.size(y_true_unique) != pred_decision.shape[1])
and classes.size != pred_decision.shape[1]
):
raise ValueError(
"Please include all labels in y_true "
"or pass labels as third argument"
)
if labels is None:
labels = y_true_unique
le = LabelEncoder(output_type="cudf")
le.fit(labels)
y_true = le.transform(y_true)
if isinstance(pred_decision, cudf.DataFrame):
pred_decision = pred_decision.values
if pred_decision.ndim != 2:
raise ValueError(
"pred_decision must be 2D for multiclass hinge loss, "
f"got a {pred_decision.ndim}D array instead."
)

# `y_true` is already encoded as column indices into `classes`.
n_samples = y_true.shape[0]
mask = cp.ones_like(pred_decision, dtype=bool)
mask[cp.arange(y_true.shape[0]), y_true.values] = False
mask[cp.arange(n_samples), y_true] = False
margin = pred_decision[~mask]
margin -= cp.max(
pred_decision[mask].reshape(y_true.shape[0], -1), axis=1
)
margin -= cp.max(pred_decision[mask].reshape(n_samples, -1), axis=1)
else:
# Handles binary class case
# this code assumes that positive and negative labels
# are encoded as +1 and -1 respectively
if isinstance(pred_decision, cudf.DataFrame):
pred_decision = pred_decision.values
pred_decision = cp.ravel(pred_decision)

lbin = LabelBinarizer(neg_label=-1, output_type="cupy")
y_true = lbin.fit_transform(y_true)[:, 1]

try:
margin = y_true * pred_decision
except TypeError:
raise TypeError("pred_decision should be an array of floats.")
# Binary case. Codes are 0/1 with `classes` sorted, so code 1
# corresponds to the larger class (positive label), matching the
# convention used by sklearn's LabelBinarizer.
if pred_decision.ndim > 1:
pred_decision = cp.ravel(pred_decision)
y_signed = cp.where(y_true == 1, 1, -1).astype(
pred_decision.dtype, copy=False
)
margin = y_signed * pred_decision

losses = 1 - margin
# The hinge_loss doesn't penalize good enough predictions.
cp.clip(losses, 0, None, out=losses)
return cp.average(losses, weights=sample_weights)
return float(cp.average(losses, weights=sample_weight))
87 changes: 87 additions & 0 deletions python/cuml/tests/test_metrics.py
Original file line number Diff line number Diff line change
Expand Up @@ -11,6 +11,7 @@
import cupy as cp
import cupyx
import numpy as np
import pandas as pd
import pytest
import scipy.sparse
import sklearn.metrics
Expand Down Expand Up @@ -1876,6 +1877,92 @@ def test_hinge_loss(nrows, ncols, n_info, input_type, n_classes):
cp.testing.assert_array_almost_equal(cu_loss, cu_loss_using_sk)


@pytest.mark.parametrize(
"container",
[np.asarray, cp.asarray, cudf.Series, pd.Series],
)
def test_hinge_loss_binary(container):
y_true = container(np.array([-1, 1, 1, -1]))
pred_decision = container(np.array([-2.18, 2.36, 0.09, -1.0]))
np.testing.assert_allclose(
cuml_hinge(y_true, pred_decision),
sk_hinge(
np.array([-1, 1, 1, -1]), np.array([-2.18, 2.36, 0.09, -1.0])
),
)


def test_hinge_loss_binary_labels_single_observed_positive_class():
y_true = np.array([1, 1])
pred_decision = np.array([0.5, 0.6])
labels = np.array([-1, 1])
np.testing.assert_allclose(
cuml_hinge(y_true, pred_decision, labels=labels),
sk_hinge(y_true, pred_decision, labels=labels),
)


def test_hinge_loss_binary_labels_single_observed_negative_class():
y_true = np.array([-1, -1])
pred_decision = np.array([-0.5, -0.6])
labels = np.array([-1, 1])
np.testing.assert_allclose(
cuml_hinge(y_true, pred_decision, labels=labels),
sk_hinge(y_true, pred_decision, labels=labels),
)


@pytest.mark.parametrize("with_labels", [True, False])
@pytest.mark.parametrize("with_sample_weight", [True, False])
def test_hinge_loss_multiclass(with_labels, with_sample_weight):
rng = np.random.RandomState(0)
y_true = np.array([0, 1, 2, 3, 1, 2])
pred_decision = rng.randn(6, 4).astype(np.float64)
labels = [0, 1, 2, 3] if with_labels else None
sample_weight = (
np.array([1.0, 2.0, 3.0, 1.0, 1.0, 1.0])
if with_sample_weight
else None
)
np.testing.assert_allclose(
cuml_hinge(
y_true,
pred_decision,
labels=labels,
sample_weight=sample_weight,
),
sk_hinge(
y_true,
pred_decision,
labels=labels,
sample_weight=sample_weight,
),
)


def test_hinge_loss_inconsistent_length():
with pytest.raises(ValueError, match="inconsistent number of samples"):
cuml_hinge(np.array([0, 1, 1]), np.array([0.5, -0.5]))


def test_hinge_loss_multiclass_missing_labels():
rng = np.random.RandomState(0)
# y_true has 3 classes but pred_decision only has 2 columns and labels
# is not provided.
with pytest.raises(ValueError, match="include all labels in y_true"):
cuml_hinge(np.array([0, 1, 2]), rng.randn(3, 2))


def test_hinge_loss_sample_weights_deprecated():
y_true = np.array([-1, 1, 1, -1])
pred_decision = np.array([-2.18, 2.36, 0.09, -1.0])
sw = np.array([1.0, 2.0, 1.0, 1.0])
expected = cuml_hinge(y_true, pred_decision, sample_weight=sw)
with pytest.warns(FutureWarning, match="sample_weights"):
result = cuml_hinge(y_true, pred_decision, sample_weights=sw)
np.testing.assert_allclose(result, expected)


@pytest.mark.parametrize(
"nfeatures",
[
Expand Down
Loading