Repository: scikit-learn/scikit-learn
Pull request: PERF speedup classification_report by attaching unique values to dtype.metadata
Fixes #26808 
Closes #26820

This is alternative to https://github.com/scikit-learn/scikit-learn/pull/26820 where we attach unique values to the `dtype.metadata` of a view on `y`.

This gets the same speedup as reported in #26820 but is a lot cleaner IMO.

WDYT @ogrisel @glemaitre @thomasjpfan 

(I'm still working on speeding up `np.unique` independent of this)

Problem statement:
Speed up classification_report
### Describe the workflow you want to enable

I'm concerned with slow execution speed of the classification_report  procedure which makes it barely suitable for production-grade workloads.
On a 8M sample it already takes 15 seconds whereas simple POC numba implementation takes 20 to 40 **milli**seconds. I understand that sklearn code is well covered with tests, has wide functionality, follows style guildlines and best practices, but there should be no excuse for the performace leap with simple POC of magnitude that big (x1000).

```python
import numpy as np
from sklearn.metrics import classification_report

y_true = np.random.randint(0, 2, size=2 ** 23)
y_pred = y_true.copy()
np.random.shuffle(y_pred[2 ** 20 : 2 ** 21])

print(classification_report(y_true=y_true, y_pred=y_pred, digits=10, output_dict=False, zero_division=0,))
```

>               precision    recall  f1-score   support
> 
>            0  0.9373906697 0.9373906697 0.9373906697   4192570
>            1  0.9374424159 0.9374424159 0.9374424159   4196038
> 
>     accuracy                      0.9374165535   8388608
>     macro avg  0.9374165428 0.9374165428 0.9374165428   8388608
>     weighted avg  0.9374165535 0.9374165535 0.9374165535   8388608
> 
> time: 18.6 s (started: 2023-07-08 16:10:08 +03:00)
> 


`print(own_classification_report(y_true=y_true, y_pred=y_pred, zero_division=0))`


> (3930076, 3933544, 262494, 262494, 0.9374165534973145, array([4192570, 4196038], dtype=int64), array([0.93739067, 0.93744242]), array([0.93739067, 0.93744242]), array([0.93739067, 0.93744242]))
> time: 16 ms (started: 2023-07-08 16:11:18 +03:00)

### Describe your proposed solution

```python
import numpy as np
from numba import njit


@njit()
def own_classification_report(y_true: np.ndarray, y_pred: np.ndarray, nclasses: int = 2, zero_division: int = 0):

    correct = np.zeros(nclasses, dtype=np.int64)
    wrong = np.zeros(nclasses, dtype=np.int64)
    for truth, pred in zip(y_true, y_pred):
        if pred == truth:
            correct[pred] += 1
        else:
            wrong[pred] += 1
    tn, tp = correct
    fn, fp = wrong
    accuracy = (tn + tp) / len(y_true)

    groups = np.array([(tn + fn), (tp + fp)])
    supports = np.array([(tn + fp), (tp + fn)])

    precisions = np.array([tn, tp]) / groups
    recalls = np.array([tn, tp]) / supports
    f1s = 2 * (precisions * recalls) / (precisions + recalls)

    for arr in (precisions, recalls, f1s):
        np.nan_to_num(arr, copy=False, nan=zero_division)

    return tn, tp, fn, fp, accuracy, supports, precisions, recalls, f1s
```

### Describe alternatives you've considered, if relevant

Can someone from original classification_report authors take a look please why it's taking so long?

### Additional context

I have the latest versions of scikit-learn and numba to this date, an Intel CPU with AVX.

Hints from issue discussion:
I have actually improved it slightly to have more concise variable names & handle multiclasses. The only things of sklearn's classification report it does not support are micro,sample averages and sample weights (not hard to add them as well). As an extra it has balanced accuracy though.

```python
@njit()
def fast_classification_report(
    y_true: np.ndarray, y_pred: np.ndarray, nclasses: int = 2, zero_division: int = 0):
    """Custom classification report, proof of concept."""

    N_AVG_ARRAYS = 3  # precisions, recalls, f1s

    # storage inits
    weighted_averages = np.empty(N_AVG_ARRAYS, dtype=np.float64)
    macro_averages = np.empty(N_AVG_ARRAYS, dtype=np.float64)
    supports = np.zeros(nclasses, dtype=np.int64)
    allpreds = np.zeros(nclasses, dtype=np.int64)
    misses = np.zeros(nclasses, dtype=np.int64)
    hits = np.zeros(nclasses, dtype=np.int64)

    # count stats
    for true_class, predicted_class in zip(y_true, y_pred):
        supports[true_class] += 1
        allpreds[predicted_class] += 1
        if predicted_class == true_class:
            hits[predicted_class] += 1
        else:
            misses[predicted_class] += 1
    
    # main calcs
    accuracy = hits.sum() / len(y_true)
    balanced_accuracy = np.nan_to_num(hits / supports, copy=True, nan=zero_division).mean()

    recalls = hits / supports
    precisions = hits / allpreds
    f1s = 2 * (precisions * recalls) / (precisions + recalls)

    # fix nans & compute averages
    i=0
    for arr in (precisions, recalls, f1s):
        np.nan_to_num(arr, copy=False, nan=zero_division)
        weighted_averages[i] = (arr * supports).sum() / len(y_true)
        macro_averages[i] = arr.mean()
        i+=1
    return hits, misses, accuracy, balanced_accuracy, supports, precisions, recalls, f1s, macro_averages, weighted_averages
```


`print(fast_classification_report_numba(y_true=y_true, y_pred=y_pred, zero_division=0))`

> (array([3930076, 3933544], dtype=int64), array([262494, 262494], dtype=int64), 0.9374165534973145, 0.9374165428009072, array([4192570, 4196038], dtype=int64), array([0.93739067, 0.93744242]), array([0.93739067, 0.93744242]), array([0.93739067, 0.93744242]), array([0.93741654, 0.93741654, 0.93741654]), array([0.93741655, 0.93741655, 0.93741655]))
> time: 31 ms (started: 2023-07-09 15:22:31 +03:00)


`%timeit fast_classification_report_numba(y_true=y_true, y_pred=y_pred, zero_division=0)`

> 29.9 ms ± 312 µs per loop (mean ± std. dev. of 7 runs, 10 loops each)
Could you please do a profiling and show where in the `classification_report` most of the time is spent? Then we can figure out what the best way improving it would be.
> Could you please do a profiling and show where in the `classification_report` most of the time is spent? Then we can figure out what the best way improving it would be.

Well,` %lprun -f classification_report classification_report(y_true=...)` points at line 184 in _param_validation.py for some reason.
I don't know how to profile it correctly.

```
File: C:\ProgramData\Anaconda3\lib\site-packages\sklearn\utils\_param_validation.py
Function: wrapper at line 180

Line #      Hits         Time  Per Hit   % Time  Line Contents
==============================================================
   180                                                   @functools.wraps(func)
   181                                                   def wrapper(*args, **kwargs):
   182         9       1953.0    217.0      0.0              global_skip_validation = get_config()["skip_parameter_validation"]
   183         9        233.0     25.9      0.0              if global_skip_validation:
   184         8  179797782.0 22474722.8     91.0                  return func(*args, **kwargs)
```
So it turns out the culprit is pretty much mostly numpy's `unique` / `unique_values` which does a `sort`. 

There's an open issue here: https://github.com/numpy/numpy/issues/11136 about that

Even pandas' `unique` is twice as fast on even a bunch of random floats.

Note that you can profile your script with `python -m cProfile -o script.prof script.py` and then visualize the results nicely with `snakeviz script.prof` (needs `snakeviz` installed available via `pip`)

Looking at this table, `10.7s` our of the `14.4s` are spent in `sort`, and that's bad:

```
   ncalls  tottime  percall  cumtime  percall filename:lineno(function)
    970/1    0.019    0.000   14.388   14.388 {built-in method builtins.exec}
        1    0.000    0.000   14.388   14.388 1.py:1(<module>)
    65/57    0.004    0.000   13.788    0.242 _param_validation.py:180(wrapper)
        1    0.000    0.000   13.781   13.781 _classification.py:2405(classification_report)
  432/218    0.447    0.001   13.604    0.062 {built-in method numpy.core._multiarray_umath.implement_array_function}
       88    0.000    0.000   12.703    0.144 <__array_function__ internals>:177(unique)
       88    0.025    0.000   12.702    0.144 arraysetops.py:138(unique)
       88    0.694    0.008   12.677    0.144 arraysetops.py:323(_unique1d)
        4    0.000    0.000   12.632    3.158 _classification.py:1531(precision_recall_fscore_support)
       88   10.651    0.121   10.651    0.121 {method 'sort' of 'numpy.ndarray' objects}
```

We can maybe try to optimize the code, but the real fix would be to optimize numpy's `unique`.

@thomasjpfan was mentioning that pytorch doesn't sort on unique, so we might get that into numpy?

cc @rgommers 
Agreed, adding a keyword to disable sorting to `numpy.unique` & co would make sense to me. 
Wow that's weird 'cause when I apply np.unique to either y_true or y_pred, it's never above 200 milliseconds on my 8M elements array. 

Is it calling np. unique multiple times within one classification_report call, why would it do that? 
Where are random floats coming from, we only have integer  inputs, no?

What's making it even more sad is that I need to fit hundreds and thousands of models and then estimate their quality on a bunch of subsets like that, and I know there are only 2 classes possible 'cause it's a binary classification. Is there an option to pass nclasses directly to save on recomputing them each time?
@fingoldo sounds like it would be a win-win for everybody if you could invest some time optimizing these routines then ;)

This is definitely something which could use some optimization, but we might not have the bandwidth to get to it ATM. However, we'd appreciate your contributions.
> @fingoldo sounds like it would be a win-win for everybody if you could invest some time optimizing these routines then ;)
> 
> This is definitely something which could use some optimization, but we might not have the bandwidth to get to it ATM. However, we'd appreciate your contributions.

Great. Is optional  numba allowed? Is optional passing nclasses directly allowed?
numba no, but passing optional n_classes makes sense.
> numba no, but passing optional n_classes makes sense.

Wow. What's wrong with optional use of numba? Like, if it's installed on the user's system, it can speed up critical parts of our python/numpy code 10 to 100 times, which I have demonstrated above. Is there some general policy of not using numba in scikit-learn?

Commit message: add another test

Patch to review:
diff --git a/doc/whats_new/v1.6.rst b/doc/whats_new/v1.6.rst
index d4d373e9bf280..347fa429e12d3 100644
--- a/doc/whats_new/v1.6.rst
+++ b/doc/whats_new/v1.6.rst
@@ -271,6 +271,9 @@ Changelog
   :pr:`29210` by :user:`Marc Torrellas Socastro <marctorsoc>` and
   :user:`Stefanie Senger <StefanieSenger>`.
 
+- |Efficiency| :func:`sklearn.metrics.classification_report` is not faster.
+  :pr:`29738` by `Adrin Jalali`_.
+
 - |API| scoring="neg_max_error" should be used instead of
   scoring="max_error" which is now deprecated.
   :pr:`29462` by :user:`Farid "Freddie" Taba <artificialfintelligence>`.
diff --git a/sklearn/metrics/_classification.py b/sklearn/metrics/_classification.py
index b69855633a730..65bedb99d573d 100644
--- a/sklearn/metrics/_classification.py
+++ b/sklearn/metrics/_classification.py
@@ -41,6 +41,7 @@
     StrOptions,
     validate_params,
 )
+from ..utils._unique import attach_unique
 from ..utils.extmath import _nanaverage
 from ..utils.multiclass import type_of_target, unique_labels
 from ..utils.sparsefuncs import count_nonzero
@@ -216,6 +217,7 @@ def accuracy_score(y_true, y_pred, *, normalize=True, sample_weight=None):
     """
     xp, _, device = get_namespace_and_device(y_true, y_pred, sample_weight)
     # Compute accuracy for each possible representation
+    y_true, y_pred = attach_unique(y_true, y_pred)
     y_type, y_true, y_pred = _check_targets(y_true, y_pred)
     check_consistent_length(y_true, y_pred, sample_weight)
     if y_type.startswith("multilabel"):
@@ -327,6 +329,7 @@ def confusion_matrix(
     >>> (tn, fp, fn, tp)
     (np.int64(0), np.int64(2), np.int64(1), np.int64(1))
     """
+    y_true, y_pred = attach_unique(y_true, y_pred)
     y_type, y_true, y_pred = _check_targets(y_true, y_pred)
     if y_type not in ("binary", "multiclass"):
         raise ValueError("%s is not supported" % y_type)
@@ -516,6 +519,7 @@ def multilabel_confusion_matrix(
            [[2, 1],
             [1, 2]]])
     """
+    y_true, y_pred = attach_unique(y_true, y_pred)
     y_type, y_true, y_pred = _check_targets(y_true, y_pred)
     if sample_weight is not None:
         sample_weight = column_or_1d(sample_weight)
@@ -1054,6 +1058,7 @@ def matthews_corrcoef(y_true, y_pred, *, sample_weight=None):
     >>> matthews_corrcoef(y_true, y_pred)
     np.float64(-0.33...)
     """
+    y_true, y_pred = attach_unique(y_true, y_pred)
     y_type, y_true, y_pred = _check_targets(y_true, y_pred)
     check_consistent_length(y_true, y_pred, sample_weight)
     if y_type not in {"binary", "multiclass"}:
@@ -1612,6 +1617,7 @@ def _check_set_wise_labels(y_true, y_pred, average, labels, pos_label):
     if average not in average_options and average != "binary":
         raise ValueError("average has to be one of " + str(average_options))
 
+    y_true, y_pred = attach_unique(y_true, y_pred)
     y_type, y_true, y_pred = _check_targets(y_true, y_pred)
     # Convert to Python primitive type to avoid NumPy type / Python str
     # comparison. See https://github.com/numpy/numpy/issues/6784
@@ -2031,7 +2037,7 @@ class after being classified as negative. This is the case when the
     >>> class_likelihood_ratios(y_true, y_pred, labels=["non-cat", "cat"])
     (np.float64(1.5), np.float64(0.75))
     """
-
+    y_true, y_pred = attach_unique(y_true, y_pred)
     y_type, y_true, y_pred = _check_targets(y_true, y_pred)
     if y_type != "binary":
         raise ValueError(
@@ -2681,6 +2687,7 @@ class 2       1.00      0.67      0.80         3
     <BLANKLINE>
     """
 
+    y_true, y_pred = attach_unique(y_true, y_pred)
     y_type, y_true, y_pred = _check_targets(y_true, y_pred)
 
     if labels is None:
@@ -2869,7 +2876,7 @@ def hamming_loss(y_true, y_pred, *, sample_weight=None):
     >>> hamming_loss(np.array([[0, 1], [1, 1]]), np.zeros((2, 2)))
     0.75
     """
-
+    y_true, y_pred = attach_unique(y_true, y_pred)
     y_type, y_true, y_pred = _check_targets(y_true, y_pred)
     check_consistent_length(y_true, y_pred, sample_weight)
 
diff --git a/sklearn/utils/_array_api.py b/sklearn/utils/_array_api.py
index 645f1d61cf527..805d9113e7eaa 100644
--- a/sklearn/utils/_array_api.py
+++ b/sklearn/utils/_array_api.py
@@ -208,7 +208,11 @@ def _is_numpy_namespace(xp):
 
 def _union1d(a, b, xp):
     if _is_numpy_namespace(xp):
-        return xp.asarray(numpy.union1d(a, b))
+        # avoid circular import
+        from ._unique import cached_unique
+
+        a_unique, b_unique = cached_unique(a, b, xp=xp)
+        return xp.asarray(numpy.union1d(a_unique, b_unique))
     assert a.ndim == b.ndim == 1
     return xp.unique_values(xp.concat([xp.unique_values(a), xp.unique_values(b)]))
 
diff --git a/sklearn/utils/_unique.py b/sklearn/utils/_unique.py
new file mode 100644
index 0000000000000..153358ce76420
--- /dev/null
+++ b/sklearn/utils/_unique.py
@@ -0,0 +1,96 @@
+# Authors: The scikit-learn developers
+# SPDX-License-Identifier: BSD-3-Clause
+
+import numpy as np
+
+from sklearn.utils._array_api import get_namespace
+
+
+def _attach_unique(y):
+    """Attach unique values of y to y and return the result.
+
+    The result is a view of y, and the metadata (unique) is not attached to y.
+    """
+    if not isinstance(y, np.ndarray):
+        return y
+    try:
+        # avoid recalculating unique in nested calls.
+        if "unique" in y.dtype.metadata:
+            return y
+    except (AttributeError, TypeError):
+        pass
+
+    unique = np.unique(y)
+    unique_dtype = np.dtype(y.dtype, metadata={"unique": unique})
+    return y.view(dtype=unique_dtype)
+
+
+def attach_unique(*ys, return_tuple=False):
+    """Attach unique values of ys to ys and return the results.
+
+    The result is a view of y, and the metadata (unique) is not attached to y.
+
+    Parameters
+    ----------
+    *ys : array-like
+        Input data arrays.
+
+    return_tuple : bool, default=False
+        If True, always return a tuple even if there is only one array.
+
+    Returns
+    -------
+    ys : tuple of array-like or array-like
+        Input data with unique values attached.
+    """
+    res = tuple(_attach_unique(y) for y in ys)
+    if len(res) == 1 and not return_tuple:
+        return res[0]
+    return res
+
+
+def _cached_unique(y, xp=None):
+    """Return the unique values of y.
+
+    Use the cached values from dtype.metadata if present.
+
+    This function does NOT cache the values in y, i.e. it doesn't change y.
+
+    Call `attach_unique` to attach the unique values to y.
+    """
+    try:
+        if y.dtype.metadata is not None and "unique" in y.dtype.metadata:
+            return y.dtype.metadata["unique"]
+    except AttributeError:
+        # in case y is not a numpy array
+        pass
+    xp, _ = get_namespace(y, xp=xp)
+    return xp.unique_values(y)
+
+
+def cached_unique(*ys, xp=None):
+    """Return the unique values of ys.
+
+    Use the cached values from dtype.metadata if present.
+
+    This function does NOT cache the values in y, i.e. it doesn't change y.
+
+    Call `attach_unique` to attach the unique values to y.
+
+    Parameters
+    ----------
+    *ys : array-like
+        Input data arrays.
+
+    xp : array-like, default=None
+        Array namespace.
+
+    Returns
+    -------
+    res : tuple of array-like or array-like
+        Unique values of ys.
+    """
+    res = tuple(_cached_unique(y, xp=xp) for y in ys)
+    if len(res) == 1:
+        return res[0]
+    return res
diff --git a/sklearn/utils/multiclass.py b/sklearn/utils/multiclass.py
index 307e4269747b7..827615800acd9 100644
--- a/sklearn/utils/multiclass.py
+++ b/sklearn/utils/multiclass.py
@@ -12,19 +12,20 @@
 
 from ..utils._array_api import get_namespace
 from ..utils.fixes import VisibleDeprecationWarning
+from ._unique import attach_unique, cached_unique
 from .validation import _assert_all_finite, check_array
 
 
-def _unique_multiclass(y):
-    xp, is_array_api_compliant = get_namespace(y)
+def _unique_multiclass(y, xp=None):
+    xp, is_array_api_compliant = get_namespace(y, xp=xp)
     if hasattr(y, "__array__") or is_array_api_compliant:
-        return xp.unique_values(xp.asarray(y))
+        return cached_unique(xp.asarray(y), xp=xp)
     else:
         return set(y)
 
 
-def _unique_indicator(y):
-    xp, _ = get_namespace(y)
+def _unique_indicator(y, xp=None):
+    xp, _ = get_namespace(y, xp=xp)
     return xp.arange(
         check_array(y, input_name="y", accept_sparse=["csr", "csc", "coo"]).shape[1]
     )
@@ -69,8 +70,9 @@ def unique_labels(*ys):
     >>> unique_labels([1, 2, 10], [5, 11])
     array([ 1,  2,  5, 10, 11])
     """
+    ys = attach_unique(*ys, return_tuple=True)
     xp, is_array_api_compliant = get_namespace(*ys)
-    if not ys:
+    if len(ys) == 0:
         raise ValueError("No argument has been passed.")
     # Check that we don't mix label format
 
@@ -104,10 +106,12 @@ def unique_labels(*ys):
 
     if is_array_api_compliant:
         # array_api does not allow for mixed dtypes
-        unique_ys = xp.concat([_unique_labels(y) for y in ys])
+        unique_ys = xp.concat([_unique_labels(y, xp=xp) for y in ys])
         return xp.unique_values(unique_ys)
 
-    ys_labels = set(chain.from_iterable((i for i in _unique_labels(y)) for y in ys))
+    ys_labels = set(
+        chain.from_iterable((i for i in _unique_labels(y, xp=xp)) for y in ys)
+    )
     # Check that we don't mix string type with number type
     if len(set(isinstance(label, str) for label in ys_labels)) > 1:
         raise ValueError("Mix of label input types (string and number)")
@@ -187,7 +191,7 @@ def is_multilabel(y):
             and (y.dtype.kind in "biu" or _is_integral_float(labels))  # bool, int, uint
         )
     else:
-        labels = xp.unique_values(y)
+        labels = cached_unique(y, xp=xp)
 
         return labels.shape[0] < 3 and (
             xp.isdtype(y.dtype, ("bool", "signed integer", "unsigned integer"))
@@ -400,7 +404,7 @@ def type_of_target(y, input_name=""):
     # Check multiclass
     if issparse(first_row_or_val):
         first_row_or_val = first_row_or_val.data
-    if xp.unique_values(y).shape[0] > 2 or (y.ndim == 2 and len(first_row_or_val) > 1):
+    if cached_unique(y).shape[0] > 2 or (y.ndim == 2 and len(first_row_or_val) > 1):
         # [1, 2, 3] or [[1., 2., 3]] or [[1, 2]]
         return "multiclass" + suffix
     else:
diff --git a/sklearn/utils/tests/test_unique.py b/sklearn/utils/tests/test_unique.py
new file mode 100644
index 0000000000000..daa6918b49cda
--- /dev/null
+++ b/sklearn/utils/tests/test_unique.py
@@ -0,0 +1,54 @@
+import numpy as np
+from numpy.testing import assert_array_equal
+
+from sklearn.utils._unique import attach_unique, cached_unique
+from sklearn.utils.validation import check_array
+
+
+def test_attach_unique_attaches_unique_to_array():
+    arr = np.array([1, 2, 2, 3, 4, 4, 5])
+    arr_ = attach_unique(arr)
+    assert_array_equal(arr_.dtype.metadata["unique"], np.array([1, 2, 3, 4, 5]))
+    assert_array_equal(arr_, arr)
+
+
+def test_cached_unique_returns_cached_unique():
+    my_dtype = np.dtype(np.float64, metadata={"unique": np.array([1, 2])})
+    arr = np.array([1, 2, 2, 3, 4, 4, 5], dtype=my_dtype)
+    assert_array_equal(cached_unique(arr), np.array([1, 2]))
+
+
+def test_attach_unique_not_ndarray():
+    """Test that when not np.ndarray, we don't touch the array."""
+    arr = [1, 2, 2, 3, 4, 4, 5]
+    arr_ = attach_unique(arr)
+    assert arr_ is arr
+
+
+def test_attach_unique_returns_view():
+    """Test that attach_unique returns a view of the array."""
+    arr = np.array([1, 2, 2, 3, 4, 4, 5])
+    arr_ = attach_unique(arr)
+    assert arr_.base is arr
+
+
+def test_attach_unique_return_tuple():
+    """Test return_tuple argument of the function."""
+    arr = np.array([1, 2, 2, 3, 4, 4, 5])
+    arr_tuple = attach_unique(arr, return_tuple=True)
+    assert isinstance(arr_tuple, tuple)
+    assert len(arr_tuple) == 1
+    assert_array_equal(arr_tuple[0], arr)
+
+    arr_single = attach_unique(arr, return_tuple=False)
+    assert isinstance(arr_single, np.ndarray)
+    assert_array_equal(arr_single, arr)
+
+
+def test_check_array_keeps_unique():
+    """Test that check_array keeps the unique metadata."""
+    arr = np.array([[1, 2, 2, 3, 4, 4, 5]])
+    arr_ = attach_unique(arr)
+    arr_ = check_array(arr_)
+    assert_array_equal(arr_.dtype.metadata["unique"], np.array([1, 2, 3, 4, 5]))
+    assert_array_equal(arr_, arr)


Code review comments:
diff_hunk: @@ -0,0 +1,96 @@
+# Authors: The scikit-learn developers
+# SPDX-License-Identifier: BSD-3-Clause
+
+import numpy as np
+
+from sklearn.utils._array_api import get_namespace
+
+
+def _attach_unique(y):
+    """Attach unique values of y to y and return the result.
+
+    The result is a view of y, and the metadata (unique) is not attached to y.
+    """
+    if not isinstance(y, np.ndarray):
+        return y
+    try:
+        # avoid recalculating unique in nested calls.
+        if "unique" in y.dtype.metadata:
+            return y
+    except (AttributeError, TypeError):
+        pass
+
+    unique = np.unique(y)
+    unique_dtype = np.dtype(y.dtype, metadata={"unique": unique})
+    return y.view(dtype=unique_dtype)
+
+
+def attach_unique(*ys, return_tuple=False):
+    """Attach unique values of ys to ys and return the results.
+
+    The result is a view of y, and the metadata (unique) is not attached to y.
+
+    Parameters
+    ----------
+    *ys : array-like
path: sklearn/utils/_unique.py
text: @user1:

```suggestion
    *ys : sequence of array-like
```

diff_hunk: @@ -0,0 +1,96 @@
+# Authors: The scikit-learn developers
+# SPDX-License-Identifier: BSD-3-Clause
+
+import numpy as np
+
+from sklearn.utils._array_api import get_namespace
+
+
+def _attach_unique(y):
+    """Attach unique values of y to y and return the result.
+
+    The result is a view of y, and the metadata (unique) is not attached to y.
+    """
+    if not isinstance(y, np.ndarray):
+        return y
+    try:
+        # avoid recalculating unique in nested calls.
+        if "unique" in y.dtype.metadata:
+            return y
+    except (AttributeError, TypeError):
+        pass
+
+    unique = np.unique(y)
+    unique_dtype = np.dtype(y.dtype, metadata={"unique": unique})
+    return y.view(dtype=unique_dtype)
+
+
+def attach_unique(*ys, return_tuple=False):
+    """Attach unique values of ys to ys and return the results.
+
+    The result is a view of y, and the metadata (unique) is not attached to y.
+
+    Parameters
+    ----------
+    *ys : array-like
+        Input data arrays.
+
+    return_tuple : bool, default=False
+        If True, always return a tuple even if there is only one array.
+
+    Returns
+    -------
+    ys : tuple of array-like or array-like
+        Input data with unique values attached.
+    """
+    res = tuple(_attach_unique(y) for y in ys)
+    if len(res) == 1 and not return_tuple:
+        return res[0]
+    return res
+
+
+def _cached_unique(y, xp=None):
+    """Return the unique values of y.
+
+    Use the cached values from dtype.metadata if present.
+
+    This function does NOT cache the values in y, i.e. it doesn't change y.
+
+    Call `attach_unique` to attach the unique values to y.
+    """
+    try:
+        if y.dtype.metadata is not None and "unique" in y.dtype.metadata:
+            return y.dtype.metadata["unique"]
+    except AttributeError:
+        # in case y is not a numpy array
+        pass
+    xp, _ = get_namespace(y, xp=xp)
+    return xp.unique_values(y)
+
+
+def cached_unique(*ys, xp=None):
+    """Return the unique values of ys.
+
+    Use the cached values from dtype.metadata if present.
+
+    This function does NOT cache the values in y, i.e. it doesn't change y.
+
+    Call `attach_unique` to attach the unique values to y.
+
+    Parameters
+    ----------
+    *ys : array-like
path: sklearn/utils/_unique.py
text: @user1:

```suggestion
    *ys : sequence of array-like
```

diff_hunk: @@ -271,6 +271,9 @@ Changelog
   :pr:`29210` by :user:`Marc Torrellas Socastro <marctorsoc>` and
   :user:`Stefanie Senger <StefanieSenger>`.
 
+- |Efficiency| :func:`sklearn.metrics.classification_report` is not faster.
path: doc/whats_new/v1.6.rst
text: @user1:
I hope it is ;)

```suggestion
- |Efficiency| :func:`sklearn.metrics.classification_report` is now faster by caching
  classification labels.
```

diff_hunk: @@ -0,0 +1,96 @@
+# Authors: The scikit-learn developers
+# SPDX-License-Identifier: BSD-3-Clause
+
+import numpy as np
+
+from sklearn.utils._array_api import get_namespace
+
+
+def _attach_unique(y):
+    """Attach unique values of y to y and return the result.
+
+    The result is a view of y, and the metadata (unique) is not attached to y.
+    """
+    if not isinstance(y, np.ndarray):
+        return y
+    try:
+        # avoid recalculating unique in nested calls.
+        if "unique" in y.dtype.metadata:
+            return y
+    except (AttributeError, TypeError):
+        pass
+
+    unique = np.unique(y)
+    unique_dtype = np.dtype(y.dtype, metadata={"unique": unique})
+    return y.view(dtype=unique_dtype)
+
+
+def attach_unique(*ys, return_tuple=False):
+    """Attach unique values of ys to ys and return the results.
+
+    The result is a view of y, and the metadata (unique) is not attached to y.
+
+    Parameters
+    ----------
+    *ys : array-like
+        Input data arrays.
+
+    return_tuple : bool, default=False
+        If True, always return a tuple even if there is only one array.
+
+    Returns
+    -------
+    ys : tuple of array-like or array-like
+        Input data with unique values attached.
+    """
+    res = tuple(_attach_unique(y) for y in ys)
+    if len(res) == 1 and not return_tuple:
+        return res[0]
+    return res
+
+
+def _cached_unique(y, xp=None):
+    """Return the unique values of y.
+
+    Use the cached values from dtype.metadata if present.
+
+    This function does NOT cache the values in y, i.e. it doesn't change y.
+
+    Call `attach_unique` to attach the unique values to y.
+    """
+    try:
+        if y.dtype.metadata is not None and "unique" in y.dtype.metadata:
+            return y.dtype.metadata["unique"]
+    except AttributeError:
+        # in case y is not a numpy array
+        pass
+    xp, _ = get_namespace(y, xp=xp)
+    return xp.unique_values(y)
+
+
+def cached_unique(*ys, xp=None):
+    """Return the unique values of ys.
+
+    Use the cached values from dtype.metadata if present.
+
+    This function does NOT cache the values in y, i.e. it doesn't change y.
+
+    Call `attach_unique` to attach the unique values to y.
+
+    Parameters
+    ----------
+    *ys : array-like
+        Input data arrays.
+
+    xp : array-like, default=None
+        Array namespace.
path: sklearn/utils/_unique.py
text: @user1:
This is the description that we have in `get_namespace`.

```suggestion
    xp : module, default=None
        Precomputed array namespace module. When passed, typically from a caller
        that has already performed inspection of its own inputs, skips array
        namespace inspection.
```