TST Move common binary Display class tests to `test_common_curve_display.py`

#### [NAME_REDACTED]/PRs
Related to #33217
Moving the test changes from #30508 as requested

#### What does this implement/fix? Explain your changes.

* In #30508 I noticed tests that were common between binary Display classes `PrecisionRecallDisplay` and `RocCurveDisplay` thus I moved them to common test file to avoid duplication and we can easily add new displays to the parametrization, as we add `from_cv_results` method to more display classes
* Most tests relate to the new `from_cv_results` method

Summary of migrated tests

`test_validate_plot_params` -> `test_display_validate_plot_params`

`test_roc_curve_plot_legend_label` -> `test_display_plot_legend_label`

`test_roc_curve_from_cv_results_legend_label` -> `test_display_from_cv_results_legend_label`

`test_roc_curve_from_cv_results_param_validation` -> `test_display_from_cv_results_param_validation`

`test_roc_curve_from_cv_results_pos_label_inferred` -> `test_display_from_cv_results_pos_label_inferred`

`test_roc_curve_display_from_cv_results_curve_kwargs` -> `test_display_from_cv_results_curve_kwargs`

New test: `test_display_default_name`

#### AI usage disclosure
<!--
If AI tools were involved in creating this PR, please check all boxes that apply
below and make sure that you adhere to our [NAME_REDACTED]:
https://scikit-learn.org/dev/developers/contributing.html#automated-contributions-policy
-->
I used AI assistance for:
- [ ] Code generation (e.g., when writing an implementation or fixing a bug)
- [ ] Test/benchmark generation
- [ ] Documentation (including examples)
- [ ] Research and understanding

#### Any other comments?

cc [USER_REDACTED] [USER_REDACTED]

@@ -1,10 +1,13 @@
+from collections.abc import Mapping
+from contextlib import suppress
+
 import numpy as np
 import pytest

 from sklearn.base import BaseEstimator, ClassifierMixin, clone
 from sklearn.calibration import CalibrationDisplay
 from sklearn.compose import make_column_transformer
-from sklearn.datasets import load_breast_cancer, load_iris
+from sklearn.datasets import load_breast_cancer, load_iris, make_classification
 from sklearn.exceptions import NotFittedError
 from sklearn.linear_model import LogisticRegression
 from sklearn.metrics import (
@@ -375,6 +378,376 @@ class SubclassOfDisplay(Display):
     assert isinstance(curve, SubclassOfDisplay)

+[USER_REDACTED].mark.parametrize(
+    "Display, display_args",
+    [
+        (
+            RocCurveDisplay,
+            {
+                "fpr": np.array([0, 0.5, 1]),
+                "tpr": [np.array([0, 0.5, 1])],
+                "roc_auc": None,
+                "name": "test_curve",
+            },
+        ),
+    ],
+)
+def test_display_validate_plot_params(pyplot, Display, display_args):
+    """Check `_validate_plot_params` returns the correct variables.
+
+    `display_args` should be given in the same order as output by
+    `_validate_plot_params`. All `display_args` should be for a single curve.
+    """
+    display = Display(**display_args)
+    results = display._validate_plot_params(ax=None, name=None)
+
+    # Check if the number of parameters match
+    assert len(results) == len(display_args)
+
+    for idx, (param, value) in enumerate(display_args.items()):
+        if param == "name":
+            assert results[idx] == [value] if isinstance(value, str) else value
+        elif value is None:
+            assert results[idx] is None
+        else:
+            assert isinstance(results[idx], list)
+            assert len(results[idx]) == 1
+
+
+auc_metrics = [[1.0, 1.0, 1.0], None]
+
+
+[USER_REDACTED].mark.parametrize(
+    "Display, auc_metric_name, auc_arg_name, display_args",
+    [
+        pytest.param(
+            RocCurveDisplay,
+            "AUC",
+            "roc_auc",
+            {
+                "fpr": [np.array([0, 0.5, 1])] * 3,
+                "tpr": [np.array([0, 0.5, 1])] * 3,
+                "roc_auc": auc_metric,
+            },
+        )
+        for auc_metric in auc_metrics
+    ],
+)
+[USER_REDACTED].mark.parametrize(
+    "curve_kwargs",
+    [None, {"color": "red"}, [{"c": "red"}, {"c": "green"}, {"c": "yellow"}]],
+)
+[USER_REDACTED].mark.parametrize("name", [None, "single", ["one", "two", "three"]])
+def test_display_plot_legend_label(
+    pyplot, Display, auc_metric_name, auc_arg_name, display_args, curve_kwargs, name
+):
+    """Check legend label correct with all `curve_kwargs`, `name` combinations.
+
+    Checks `from_estimator` and `from_predictions` methods, when plotting multiple
+    curves.
+    """
+    if not isinstance(curve_kwargs, list) and isinstance(name, list):
+        with pytest.raises(ValueError, match="To avoid labeling individual curves"):
+            Display(**display_args).plot(name=name, curve_kwargs=curve_kwargs)
+        return
+
+    display = Display(**display_args).plot(name=name, curve_kwargs=curve_kwargs)
+    legend = display.ax_.get_legend()
+    auc_metric = display_args[auc_arg_name]
+
+    if legend is None:
+        # No legend is created, exit test early
+        assert name is None
+        assert auc_metric is None
+        return
+    else:
+        legend_labels = [text.get_text() for text in legend.get_texts()]
+
+    if isinstance(curve_kwargs, list):
+        # Multiple labels in legend
+        assert len(legend_labels) == 3
+        for idx, label in enumerate(legend_labels):
+            if name is None:
+                expected_label = f"{auc_metric_name} = 1.00" if auc_metric else None
+                assert label == expected_label
+            elif isinstance(name, str):
+                expected_label = (
+                    f"single ({auc_metric_name} = 1.00)" if auc_metric else "single"
+                )
+                assert label == expected_label
+            else:
+                # `name` is a list of different strings
+                expected_label = (
+                    f"{name[idx]} ({auc_metric_name} = 1.00)"
+                    if auc_metric
+                    else f"{name[idx]}"
+                )
+                assert label == expected_label
+    else:
+        # Single label in legend
+        assert len(legend_labels) == 1
+        if name is None:
+            expected_label = (
+                f"{auc_metric_name} = 1.00 +/- 0.00" if auc_metric else None
+            )
+            assert legend_labels[0] == expected_label
+        else:
+            # name is single string
+            expected_label = (
+                f"single ({auc_metric_name} = 1.00 +/- 0.00)"
+                if auc_metric
+                else "single"
+            )
+            assert legend_labels[0] == expected_label
+    # Close plots, prevents "more than 20 figures" opened warning
+    pyplot.close("all")
+
+
+[USER_REDACTED].mark.parametrize(
+    "Display, auc_metrics, auc_metric_name",
+    [
+        (RocCurveDisplay, [0.96, 1.00, 1.00], "AUC"),
+    ],
+)
+[USER_REDACTED].mark.parametrize(
+    "curve_kwargs",
+    [None, {"color": "red"}, [{"c": "red"}, {"c": "green"}, {"c": "yellow"}]],
+)
+[USER_REDACTED].mark.parametrize("name", [None, "single", ["one", "two", "three"]])
+def test_display_from_cv_results_legend_label(
+    pyplot, Display, auc_metrics, auc_metric_name, curve_kwargs, name
+):
+    """Check legend label correct with all `curve_kwargs`, `name` combinations.
+
+    This function verifies that the legend labels in a Display object created from
+    cross-validation results are correctly formatted based on the provided parameters.
+    """
+    X, y = X, y = make_classification(n_classes=2, n_samples=50, random_state=0)
+    cv_results = cross_validate(
+        LogisticRegression(), X, y, cv=3, return_estimator=True, return_indices=True
+    )
+
+    if not isinstance(curve_kwargs, list) and isinstance(name, list):
+        with pytest.raises(ValueError, match="To avoid labeling individual curves"):
+            Display.from_cv_results(
+                cv_results, X, y, name=name, curve_kwargs=curve_kwargs
+            )
+    else:
+        display = Display.from_cv_results(
+            cv_results, X, y, name=name, curve_kwargs=curve_kwargs
+        )
+
+        legend = display.ax_.get_legend()
+        legend_labels = [text.get_text() for text in legend.get_texts()]
+        if isinstance(curve_kwargs, list):
+            # Multiple labels in legend
+            assert len(legend_labels) == 3
+            for idx, label in enumerate(legend_labels):
+                if name is None:
+                    assert label == f"{auc_metric_name} = {auc_metrics[idx]:.2f}"
+                elif isinstance(name, str):
+                    assert (
+                        label == f"single ({auc_metric_name} = {auc_metrics[idx]:.2f})"
+                    )
+                else:
+                    # `name` is a list of different strings
+                    assert (
+                        label
+                        == f"{name[idx]} ({auc_metric_name} = {auc_metrics[idx]:.2f})"
+                    )
+        else:
+            # Single label in legend
+            assert len(legend_labels) == 1
+            if name is None:
+                assert legend_labels[0] == (
+                    f"{auc_metric_name} = {np.mean(auc_metrics):.2f} +/- "
+                    f"{np.std(auc_metrics):.2f}"
+                )
+            else:
+                # name is single string
+                assert legend_labels[0] == (
+                    f"single ({auc_metric_name} = {np.mean(auc_metrics):.2f} +/- "
+                    f"{np.std(auc_metrics):.2f})"
+                )
+    # Close plots, prevents "more than 20 figures" opened warning
+    pyplot.close("all")
+
+
+[USER_REDACTED].mark.parametrize("Display", [RocCurveDisplay])
+def test_display_from_cv_results_param_validation(pyplot, data_binary, Display):
+    """Check parameter validation is correct."""
+    X, y = data_binary
+
+    # `cv_results` missing key
+    cv_results_no_est = cross_validate(
+        LogisticRegression(), X, y, cv=3, return_estimator=True, return_indices=False
+    )
+    cv_results_no_indices = cross_validate(
+        LogisticRegression(), X, y, cv=3, return_estimator=True, return_indices=False
+    )
+    for cv_results in (cv_results_no_est, cv_results_no_indices):
+        with pytest.raises(
+            ValueError,
+            match="`cv_results` does not contain one of the following required",
+        ):
+            Display.from_cv_results(cv_results, X, y)
+
+    cv_results = cross_validate(
+        LogisticRegression(), X, y, cv=3, return_estimator=True, return_indices=True
+    )
+
+    # `X` wrong length
+    with pytest.raises(ValueError, match="`X` does not contain the correct"):
+        Display.from_cv_results(cv_results, X[:10, :], y)
+
+    # `y` not binary
+    y_multi = y.copy()
+    y_multi[0] = 2
+    with pytest.raises(ValueError, match="The target `y` is not binary."):
+        Display.from_cv_results(cv_results, X, y_multi)
+
+    # input inconsistent length
+    with pytest.raises(ValueError, match="Found input variables with inconsistent"):
+        Display.from_cv_results(cv_results, X, y[:10])
+    with pytest.raises(ValueError, match="Found input variables with inconsistent"):
+        Display.from_cv_results(cv_results, X, y, sample_weight=[1, 2])
+
+    # `pos_label` inconsistency
+    y_multi[y_multi == 1] = 2
+    with suppress(ValueError):  # ignore any `pos_label` side errors
+        with pytest.warns(
+            # Also captures subclass warnings e.g., `UndefinedMetricWarning`
+            UserWarning,
+            match="No positive .* in y_true",
+        ):
+            Display.from_cv_results(cv_results, X, y_multi)
+
+    # `name` is list while `curve_kwargs` is None or dict
+    for curve_kwargs in (None, {"alpha": 0.2}):
+        with pytest.raises(ValueError, match="To avoid labeling individual curves"):
+            Display.from_cv_results(
+                cv_results,
+                X,
+                y,
+                name=["one", "two", "three"],
+                curve_kwargs=curve_kwargs,
+            )
+
+    # `curve_kwargs` incorrect length
+    with pytest.raises(ValueError, match="`curve_kwargs` must be None, a dictionary"):
+        Display.from_cv_results(cv_results, X, y, curve_kwargs=[{"alpha": 1}])
+
+    # `curve_kwargs` both alias provided
+    with pytest.raises(TypeError, match="Got both c and"):
+        Display.from_cv_results(
+            cv_results, X, y, curve_kwargs={"c": "blue", "color": "red"}
+        )
+
+
+[USER_REDACTED].mark.parametrize("Display", [RocCurveDisplay])
+def test_display_from_cv_results_pos_label_inferred(pyplot, data_binary, Display):
+    """Check `pos_label` inferred correctly by `from_cv_results(pos_label=None)`."""
+    X, y = data_binary
+    cv_results = cross_validate(
+        LogisticRegression(), X, y, cv=3, return_estimator=True, return_indices=True
+    )
+
+    disp = Display.from_cv_results(cv_results, X, y, pos_label=None)
+    # Should be `estimator.classes_[1]`
+    assert disp.pos_label == 1
+
+
+[USER_REDACTED].mark.parametrize("Display", [RocCurveDisplay])
+[USER_REDACTED].mark.parametrize(
+    "constructor_name, expected_clf_name",
+    [
+        ("from_estimator", "LogisticRegression"),
+        ("from_predictions", "Classifier"),
+    ],
+)
+def test_display_default_name(
+    pyplot,
+    data_binary,
+    constructor_name,
+    expected_clf_name,
+    Display,
+):
+    """Check the default name display in the figure when `name` is not provided."""
+    X, y = data_binary
+
+    lr = LogisticRegression().fit(X, y)
+    y_score = lr.predict_proba(X)[:, 1]
+
+    if constructor_name == "from_estimator":
+        disp = Display.from_estimator(lr, X, y)
+    else:
+        disp = Display.from_predictions(y, y_score)
+
+    assert expected_clf_name in disp.name
+    assert expected_clf_name in disp.line_.get_label()
+
+
+[USER_REDACTED].mark.parametrize("Display", [RocCurveDisplay])
+[USER_REDACTED].mark.parametrize(
+    "curve_kwargs",
+    [None, {"alpha": 0.2}, [{"alpha": 0.2}, {"alpha": 0.3}, {"alpha": 0.4}]],
+)
+def test_display_from_cv_results_curve_kwargs(
+    pyplot, data_binary, curve_kwargs, Display
+):
+    """Check `curve_kwargs` correctly passed."""
+    X, y = data_binary
+    cv_results = cross_validate(
+        LogisticRegression(), X, y, cv=3, return_estimator=True, return_indices=True
+    )
+    display = Display.from_cv_results(
+        cv_results,
+        X,
+        y,
+        curve_kwargs=curve_kwargs,
+    )
+    if curve_kwargs is None:
+        # Default `alpha` used
+        assert all(line.get_alpha() == 0.5 for line in display.line_)
+    elif isinstance(curve_kwargs, Mapping):
+        # `alpha` from dict used for all curves
+        assert all(line.get_alpha() == 0.2 for line in display.line_)
+    else:
+        # Different `alpha` used for each curve
+        assert all(
+            line.get_alpha() == curve_kwargs[i]["alpha"]
+            for i, line in enumerate(display.line_)
+        )
+
+
+[USER_REDACTED].mark.parametrize("Display", [RocCurveDisplay])
+[USER_REDACTED].mark.parametrize(
+    "curve_kwargs",
+    [None, {"color": "red"}, [{"c": "red"}, {"c": "green"}, {"c": "yellow"}]],
+)
+def test_display_from_cv_results_curve_kwargs_default_kwargs(
+    pyplot, data_binary, curve_kwargs, Display
+):
+    """Check `curve_kwargs` and default color handled correctly in `from_cv_results`."""
+
+    X, y = data_binary
+    cv_results = cross_validate(
+        LogisticRegression(), X, y, cv=3, return_estimator=True, return_indices=True
+    )
+    display = Display.from_cv_results(cv_results, X, y, curve_kwargs=curve_kwargs)
+
+    for idx, line in enumerate(display.line_):
+        color = line.get_color()
+        if curve_kwargs is None:
+            # Default color
+            assert color == "blue"
+        elif isinstance(curve_kwargs, Mapping):
+            # All curves "red"
+            assert color == "red"
+        else:
+            assert color == curve_kwargs[idx]["c"]
+
+
 [USER_REDACTED].mark.parametrize(
     "Display, display_kwargs",
     [

@@ -8,7 +8,7 @@
 from sklearn import clone
 from sklearn.compose import make_column_transformer
 from sklearn.datasets import make_classification
-from sklearn.exceptions import NotFittedError, UndefinedMetricWarning
+from sklearn.exceptions import NotFittedError
 from sklearn.linear_model import LogisticRegression
 from sklearn.metrics import RocCurveDisplay, auc, roc_curve
 from sklearn.metrics._plot.tests.test_common_curve_display import (
@@ -200,135 +200,6 @@ def test_roc_curve_plot_parameter_length_validation(pyplot, params, err_msg):
         display.plot()

-def test_validate_plot_params(pyplot):
-    """Check `_validate_plot_params` returns the correct variables."""
-    fpr = np.array([0, 0.5, 1])
-    tpr = [np.array([0, 0.5, 1])]
-    roc_auc = None
-    name = "test_curve"
-
-    # Initialize display with test inputs
-    display = RocCurveDisplay(
-        fpr=fpr,
-        tpr=tpr,
-        roc_auc=roc_auc,
-        name=name,
-        pos_label=None,
-    )
-    fpr_out, tpr_out, roc_auc_out, name_out = display._validate_plot_params(
-        ax=None, name=None
-    )
-
-    assert isinstance(fpr_out, list)
-    assert isinstance(tpr_out, list)
-    assert len(fpr_out) == 1
-    assert len(tpr_out) == 1
-    assert roc_auc_out is None
-    assert name_out == ["test_curve"]
-
-
-def test_roc_curve_from_cv_results_param_validation(pyplot, data_binary):
-    """Check parameter validation is correct."""
-    X, y = data_binary
-
-    # `cv_results` missing key
-    cv_results_no_est = cross_validate(
-        LogisticRegression(), X, y, cv=3, return_estimator=True, return_indices=False
-    )
-    cv_results_no_indices = cross_validate(
-        LogisticRegression(), X, y, cv=3, return_estimator=True, return_indices=False
-    )
-    for cv_results in (cv_results_no_est, cv_results_no_indices):
-        with pytest.raises(
-            ValueError,
-            match="`cv_results` does not contain one of the following required",
-        ):
-            RocCurveDisplay.from_cv_results(cv_results, X, y)
-
-    cv_results = cross_validate(
-        LogisticRegression(), X, y, cv=3, return_estimator=True, return_indices=True
-    )
-
-    # `X` wrong length
-    with pytest.raises(ValueError, match="`X` does not contain the correct"):
-        RocCurveDisplay.from_cv_results(cv_results, X[:10, :], y)
-
-    # `y` not binary
-    y_multi = y.copy()
-    y_multi[0] = 2
-    with pytest.raises(ValueError, match="The target `y` is not binary."):
-        RocCurveDisplay.from_cv_results(cv_results, X, y_multi)
-
-    # input inconsistent length
-    with pytest.raises(ValueError, match="Found input variables with inconsistent"):
-        RocCurveDisplay.from_cv_results(cv_results, X, y[:10])
-    with pytest.raises(ValueError, match="Found input variables with inconsistent"):
-        RocCurveDisplay.from_cv_results(cv_results, X, y, sample_weight=[1, 2])
-
-    # `pos_label` inconsistency
-    y_multi[y_multi == 1] = 2
-    with pytest.warns(UndefinedMetricWarning, match="No positive samples in y_true"):
-        RocCurveDisplay.from_cv_results(cv_results, X, y_multi)
-
-    # `name` is list while `curve_kwargs` is None or dict
-    for curve_kwargs in (None, {"alpha": 0.2}):
-        with pytest.raises(ValueError, match="To avoid labeling individual curves"):
-            RocCurveDisplay.from_cv_results(
-                cv_results,
-                X,
-                y,
-                name=["one", "two", "three"],
-                curve_kwargs=curve_kwargs,
-            )
-
-    # `curve_kwargs` incorrect length
-    with pytest.raises(ValueError, match="`curve_kwargs` must be None, a dictionary"):
-        RocCurveDisplay.from_cv_results(cv_results, X, y, curve_kwargs=[{"alpha": 1}])
-
-    # `curve_kwargs` both alias provided
-    with pytest.raises(TypeError, match="Got both c and"):
-        RocCurveDisplay.from_cv_results(
-            cv_results, X, y, curve_kwargs={"c": "blue", "color": "red"}
-        )
-
-
-[USER_REDACTED].mark.parametrize(
-    "curve_kwargs",
-    [None, {"alpha": 0.2}, [{"alpha": 0.2}, {"alpha": 0.3}, {"alpha": 0.4}]],
-)
-def test_roc_curve_display_from_cv_results_curve_kwargs(
-    pyplot, data_binary, curve_kwargs
-):
-    """Check `curve_kwargs` correctly passed."""
-    X, y = data_binary
-    n_cv = 3
-    cv_results = cross_validate(
-        LogisticRegression(), X, y, cv=n_cv, return_estimator=True, return_indices=True
-    )
-    display = RocCurveDisplay.from_cv_results(
-        cv_results,
-        X,
-        y,
-        curve_kwargs=curve_kwargs,
-    )
-    if curve_kwargs is None:
-        # Default `alpha` used
-        assert all(line.get_alpha() == 0.5 for line in display.line_)
-    elif isinstance(curve_kwargs, Mapping):
-        # `alpha` from dict used for all curves
-        assert all(line.get_alpha() == 0.2 for line in display.line_)
-    else:
-        # Different `alpha` used for each curve
-        assert all(
-            line.get_alpha() == curve_kwargs[i]["alpha"]
-            for i, line in enumerate(display.line_)
-        )
-    # Other default kwargs should be the same
-    for line in display.line_:
-        assert line.get_linestyle() == "--"
-        assert line.get_color() == "blue"
-
-
 # TODO(1.9): Remove in 1.9
 [USER_REDACTED].mark.parametrize(
     "constructor_name", ["from_estimator", "from_predictions", "plot"]
@@ -462,112 +333,6 @@ def test_roc_curve_display_plotting_from_cv_results(
             assert line.get_label() == aggregate_expected_labels[idx]

-[USER_REDACTED].mark.parametrize("roc_auc", [[1.0, 1.0, 1.0], None])
-[USER_REDACTED].mark.parametrize(
-    "curve_kwargs",
-    [None, {"color": "red"}, [{"c": "red"}, {"c": "green"}, {"c": "yellow"}]],
-)
-[USER_REDACTED].mark.parametrize("name", [None, "single", ["one", "two", "three"]])
-def test_roc_curve_plot_legend_label(pyplot, data_binary, name, curve_kwargs, roc_auc):
-    """Check legend label correct with all `curve_kwargs`, `name` combinations."""
-    fpr = [np.array([0, 0.5, 1]), np.array([0, 0.5, 1]), np.array([0, 0.5, 1])]
-    tpr = [np.array([0, 0.5, 1]), np.array([0, 0.5, 1]), np.array([0, 0.5, 1])]
-    if not isinstance(curve_kwargs, list) and isinstance(name, list):
-        with pytest.raises(ValueError, match="To avoid labeling individual curves"):
-            RocCurveDisplay(fpr=fpr, tpr=tpr, roc_auc=roc_auc).plot(
-                name=name, curve_kwargs=curve_kwargs
-            )
-
-    else:
-        display = RocCurveDisplay(fpr=fpr, tpr=tpr, roc_auc=roc_auc).plot(
-            name=name, curve_kwargs=curve_kwargs
-        )
-        legend = display.ax_.get_legend()
-        if legend is None:
-            # No legend is created, exit test early
-            assert name is None
-            assert roc_auc is None
-            return
-        else:
-            legend_labels = [text.get_text() for text in legend.get_texts()]
-
-        if isinstance(curve_kwargs, list):
-            # Multiple labels in legend
-            assert len(legend_labels) == 3
-            for idx, label in enumerate(legend_labels):
-                if name is None:
-                    expected_label = "AUC = 1.00" if roc_auc else None
-                    assert label == expected_label
-                elif isinstance(name, str):
-                    expected_label = "single (AUC = 1.00)" if roc_auc else "single"
-                    assert label == expected_label
-                else:
-                    # `name` is a list of different strings
-                    expected_label = (
-                        f"{name[idx]} (AUC = 1.00)" if roc_auc else f"{name[idx]}"
-                    )
-                    assert label == expected_label
-        else:
-            # Single label in legend
-            assert len(legend_labels) == 1
-            if name is None:
-                expected_label = "AUC = 1.00 +/- 0.00" if roc_auc else None
-                assert legend_labels[0] == expected_label
-            else:
-                # name is single string
-                expected_label = "single (AUC = 1.00 +/- 0.00)" if roc_auc else "single"
-                assert legend_labels[0] == expected_label
-
-
-[USER_REDACTED].mark.parametrize(
-    "curve_kwargs",
-    [None, {"color": "red"}, [{"c": "red"}, {"c": "green"}, {"c": "yellow"}]],
-)
-[USER_REDACTED].mark.parametrize("name", [None, "single", ["one", "two", "three"]])
-def test_roc_curve_from_cv_results_legend_label(
-    pyplot, data_binary, name, curve_kwargs
-):
-    """Check legend label correct with all `curve_kwargs`, `name` combinations."""
-    X, y = data_binary
-    n_cv = 3
-    cv_results = cross_validate(
-        LogisticRegression(), X, y, cv=n_cv, return_estimator=True, return_indices=True
-    )
-
-    if not isinstance(curve_kwargs, list) and isinstance(name, list):
-        with pytest.raises(ValueError, match="To avoid labeling individual curves"):
-            RocCurveDisplay.from_cv_results(
-                cv_results, X, y, name=name, curve_kwargs=curve_kwargs
-            )
-    else:
-        display = RocCurveDisplay.from_cv_results(
-            cv_results, X, y, name=name, curve_kwargs=curve_kwargs
-        )
-
-        legend = display.ax_.get_legend()
-        legend_labels = [text.get_text() for text in legend.get_texts()]
-        if isinstance(curve_kwargs, list):
-            # Multiple labels in legend
-            assert len(legend_labels) == 3
-            auc = ["0.62", "0.66", "0.55"]
-            for idx, label in enumerate(legend_labels):
-                if name is None:
-                    assert label == f"AUC = {auc[idx]}"
-                elif isinstance(name, str):
-                    assert label == f"single (AUC = {auc[idx]})"
-                else:
-                    # `name` is a list of different strings
-                    assert label == f"{name[idx]} (AUC = {auc[idx]})"
-        else:
-            # Single label in legend
-            assert len(legend_labels) == 1
-            if name is None:
-                assert legend_labels[0] == "AUC = 0.61 +/- 0.05"
-            else:
-                # name is single string
-                assert legend_labels[0] == "single (AUC = 0.61 +/- 0.05)"
-
-
 [USER_REDACTED].mark.parametrize(
     "curve_kwargs",
     [None, {"color": "red"}, [{"c": "red"}, {"c": "green"}, {"c": "yellow"}]],
@@ -595,18 +360,6 @@ def test_roc_curve_from_cv_results_curve_kwargs(pyplot, data_binary, curve_kwarg
             assert color == curve_kwargs[idx]["c"]

-def test_roc_curve_from_cv_results_pos_label_inferred(pyplot, data_binary):
-    """Check `pos_label` inferred correctly by `from_cv_results(pos_label=None)`."""
-    X, y = data_binary
-    cv_results = cross_validate(
-        LogisticRegression(), X, y, cv=3, return_estimator=True, return_indices=True
-    )
-
-    disp = RocCurveDisplay.from_cv_results(cv_results, X, y, pos_label=None)
-    # Should be `estimator.classes_[1]`
-    assert disp.pos_label == 1
-
-
 def _check_chance_level(plot_chance_level, chance_level_kw, display):
     """Check chance level line and line styles correct."""
     import matplotlib as mpl

Thanks for the split and summary of the moved/renamed and added tests.
I made a couple of suggestions to ease readability by [NAME_REDACTED] code and include it in the parametrization instead. It should also resolve the codecov issues as a bonus :)

LGTM otherwise

LGTM. Thanks !

maybe we can incorporate that into the parametrization ?
see next comment

ref previous comment, name the `auc_metric_name` such that it matches the arg name so we can later do `auc_metric = display_args[auc_metric_name]`
```suggestion
    "Display, auc_metric_name, display_args",
    [
        pytest.param(
            RocCurveDisplay,
            "roc_auc",
            {
                "fpr": [
                    np.array([0, 0.5, 1]),
                    np.array([0, 0.5, 1]),
                    np.array([0, 0.5, 1]),
                ],
                "tpr": [
                    np.array([0, 0.5, 1]),
                    np.array([0, 0.5, 1]),
                    np.array([0, 0.5, 1]),
                ],
                "roc_auc": auc_metric,
```

Alternatively if you want to keep "AUC", we can add another param "auc_arg_name", set to "roc_auc" so that we can later do `auc_metric = display_args[auc_arg_name]`

We could ignore the "pos_label=1 is not a valid label" error for this test and consider that the test is only about the param validation method.
```suggestion
    with suppress(ValueError):  # ignore pos_label side errors
        with pytest.warns(
            UndefinedMetricWarning, match="No positive .* in y_true"
        ):
            Display.from_cv_results(cv_results, X, y_multi)
```

We can include the expected warning message in the parametrization if we want to be more precise. In general I think we should avoid parametrization specific handling in parametrized tests when possible because it hurts readability.

I think `UndefinedMetricWarning` inherits from `UserWarning`, so I think I need to amend to `UserWarning`, but that looks good to me.

`auc_metric_name` is used in the legend e.g. 'AUC:' so I can't change that. But looking at the original test over in, it seems that (for these 2 displays at least) `auc_metrics` is the same, so let me just delete this part. We can always amend the test later if we add another display that requires different metrics

Ignore that, I used your second suggestion!

```suggestion
def test_display_plot_legend_label(
    pyplot, Display, auc_metric_name, auc_arg_name, display_args, curve_kwargs, name
```

```suggestion
    pyplot, Display, auc_metrics, auc_metric_name, curve_kwargs, name
```

```suggestion
                "fpr": [np.array([0, 0.5, 1])] * 3,
                "tpr": [
```
Maybe.

This is much better, thank you


TST Add `CalibrationDisplay` to `test_common_curve_display.py`

#### [NAME_REDACTED]/PRs
Noticed when separating test changes out from #30508

#### What does this implement/fix? Explain your changes.

* Adds `CalibrationDisplay` to relevant common tests
* Improves test names and docstrings such that they fit on one line
* Removes redundant test (possibly there via bad merge)

#### AI usage disclosure
<!--
If AI tools were involved in creating this PR, please check all boxes that apply
below and make sure that you adhere to our [NAME_REDACTED]:
https://scikit-learn.org/dev/developers/contributing.html#automated-contributions-policy
-->
I used AI assistance for:
- [ ] Code generation (e.g., when writing an implementation or fixing a bug)
- [ ] Test/benchmark generation
- [ ] Documentation (including examples)
- [ ] Research and understanding

#### Any other comments?
 cc [USER_REDACTED] [USER_REDACTED] in case you have time to review :pray:

<!--
Thank you for your patience. Changes to scikit-learn require careful
attention, but with limited maintainer time, not every contribution can be reviewed
quickly.
For more information and tips on improving your pull request, see:
https://scikit-learn.org/dev/faq.html#why-is-my-pull-request-not-getting-any-attention.

Thanks for contributing!
-->

@@ -34,9 +34,8 @@ def data_binary(data):
     "Display",
     [CalibrationDisplay, DetCurveDisplay, PrecisionRecallDisplay, RocCurveDisplay],
 )
-def test_display_curve_error_classifier(pyplot, data, data_binary, Display):
-    """Check that a proper error is raised when only binary classification is
-    supported."""
+def test_display_curve_error_binary_classifier(pyplot, data, data_binary, Display):
+    """Check correct error raised when only binary classification supported."""
     X, y = data
     X_binary, y_binary = data_binary
     clf = DecisionTreeClassifier().fit(X, y)
@@ -110,15 +109,14 @@ def test_display_curve_error_regression(pyplot, data_binary, Display):
 [USER_REDACTED].mark.parametrize(
     "Display", [DetCurveDisplay, PrecisionRecallDisplay, RocCurveDisplay]
 )
-def test_display_curve_error_no_response(
+def test_display_curve_error_no_response_method(
     pyplot,
     data_binary,
     response_method,
     msg,
     Display,
 ):
-    """Check that a proper error is raised when the response method requested
-    is not defined for the given trained classifier."""
+    """Check error raised when `response_method` not defined for `estimator`."""
     X, y = data_binary

     class MyClassifier(ClassifierMixin, BaseEstimator):
@@ -133,17 +131,17 @@ def fit(self, X, y):

 [USER_REDACTED].mark.parametrize(
-    "Display", [DetCurveDisplay, PrecisionRecallDisplay, RocCurveDisplay]
+    "Display",
+    [CalibrationDisplay, DetCurveDisplay, PrecisionRecallDisplay, RocCurveDisplay],
 )
 [USER_REDACTED].mark.parametrize("constructor_name", ["from_estimator", "from_predictions"])
-def test_display_curve_estimator_name_multiple_calls(
+def test_display_curve_name_overwritten_by_plot_multiple_calls(
     pyplot,
     data_binary,
     Display,
     constructor_name,
 ):
-    """Check that passing `name` when calling `plot` will overwrite the original name
-    in the legend."""
+    """Check passing `name` in `plot` overwrites name passed in `from_*` method."""
     X, y = data_binary
     clf_name = "my hand-crafted name"
     clf = LogisticRegression().fit(X, y)
@@ -181,11 +179,11 @@ def test_display_curve_estimator_name_multiple_calls(
     ],
 )
 [USER_REDACTED].mark.parametrize(
-    "Display", [DetCurveDisplay, PrecisionRecallDisplay, RocCurveDisplay]
+    "Display",
+    [CalibrationDisplay, DetCurveDisplay, PrecisionRecallDisplay, RocCurveDisplay],
 )
-def test_display_curve_not_fitted_errors_old_name(pyplot, data_binary, clf, Display):
-    """Check that a proper error is raised when the classifier is not
-    fitted."""
+def test_display_curve_not_fitted_errors(pyplot, data_binary, clf, Display):
+    """Check correct error raised when `estimator` is not fitted."""
     X, y = data_binary
     # clone since we parametrize the test and the classifier will be fitted
     # when testing the second and subsequent plotting function
@@ -203,36 +201,11 @@ def test_display_curve_not_fitted_errors_old_name(pyplot, data_binary, clf, Disp

 [USER_REDACTED].mark.parametrize(
-    "clf",
-    [
-        LogisticRegression(),
-        make_pipeline(StandardScaler(), LogisticRegression()),
-        make_pipeline(
-            make_column_transformer((StandardScaler(), [0, 1])), LogisticRegression()
-        ),
-    ],
-)
-[USER_REDACTED].mark.parametrize("Display", [RocCurveDisplay])
-def test_display_curve_not_fitted_errors(pyplot, data_binary, clf, Display):
-    """Check that a proper error is raised when the classifier is not fitted."""
-    X, y = data_binary
-    # clone since we parametrize the test and the classifier will be fitted
-    # when testing the second and subsequent plotting function
-    model = clone(clf)
-    with pytest.raises(NotFittedError):
-        Display.from_estimator(model, X, y)
-    model.fit(X, y)
-    disp = Display.from_estimator(model, X, y)
-    assert model.__class__.__name__ in disp.line_.get_label()
-    assert disp.name == model.__class__.__name__
-
-
-[USER_REDACTED].mark.parametrize(
-    "Display", [DetCurveDisplay, PrecisionRecallDisplay, RocCurveDisplay]
+    "Display",
+    [CalibrationDisplay, DetCurveDisplay, PrecisionRecallDisplay, RocCurveDisplay],
 )
 def test_display_curve_n_samples_consistency(pyplot, data_binary, Display):
-    """Check the error raised when `y_pred` or `sample_weight` have inconsistent
-    length."""
+    """Check error raised when `y_pred` or `sample_weight` have inconsistent length."""
     X, y = data_binary
     classifier = DecisionTreeClassifier().fit(X, y)

@@ -241,15 +214,20 @@ def test_display_curve_n_samples_consistency(pyplot, data_binary, Display):
         Display.from_estimator(classifier, X[:-2], y)
     with pytest.raises(ValueError, match=msg):
         Display.from_estimator(classifier, X, y[:-2])
-    with pytest.raises(ValueError, match=msg):
-        Display.from_estimator(classifier, X, y, sample_weight=np.ones(X.shape[0] - 2))
+    # `CalibrationDisplay` does not support `sample_weight`
+    if Display != CalibrationDisplay:
+        with pytest.raises(ValueError, match=msg):
+            Display.from_estimator(
+                classifier, X, y, sample_weight=np.ones(X.shape[0] - 2)
+            )

 [USER_REDACTED].mark.parametrize(
-    "Display", [DetCurveDisplay, PrecisionRecallDisplay, RocCurveDisplay]
+    "Display",
+    [CalibrationDisplay, DetCurveDisplay, PrecisionRecallDisplay, RocCurveDisplay],
 )
 def test_display_curve_error_pos_label(pyplot, data_binary, Display):
-    """Check consistence of error message when `pos_label` should be specified."""
+    """Check consistency of error message when `pos_label` should be specified."""
     X, y = data_binary
     y = y + 10

@@ -302,7 +280,6 @@ class SubclassOfDisplay(Display):
     assert isinstance(curve, SubclassOfDisplay)

-# TODO(1.10): Remove once deprecated in all Displays
 [USER_REDACTED].mark.parametrize(
     "Display, display_kwargs",
     [

LGTM!

This test is repeated, see above `test_display_curve_not_fitted_errors_old_name` which I have renamed to`test_display_curve_not_fitted_errors`


CI Have the name of the failing build in the tracking issue

<!--
🙌 Thanks for contributing a pull request!

👀 Please ensure you have taken a look at the contribution guidelines:
https://github.com/[USER_REDACTED]n/scikit-learn/blob/main/CONTRIBUTING.md

✅ In particular following the pull request checklist will increase the likelihood
of having maintainers review your PR:
https://scikit-learn.org/dev/developers/contributing.html#pull-request-checklist

📋 If your PR is likely to affect users, you will need to add a changelog entry
describing your PR changes, see:
https://github.com/[USER_REDACTED]n/scikit-learn/blob/main/doc/whats_new/upcoming_changes/README.md
-->

#### [NAME_REDACTED]/PRs
<!--
Example: Fixes #1234. See also #3456.
Please use keywords (e.g., Fixes) to create link to the issues or pull requests
you resolved, so that they will automatically be closed when your pull request
is merged. See https://github.com/blog/1506-closing-issues-via-pull-requests
-->
Fix issue raised in https://github.com/[USER_REDACTED]n/scikit-learn/issues/33152#issuecomment-3833793227

#### What does this implement/fix? Explain your changes.

Get the name of the failing build in the tracking issue name, even if the build is not in the job matrix.

#### AI usage disclosure
<!--
If AI tools were involved in creating this PR, please check all boxes that apply
below and make sure that you adhere to our [NAME_REDACTED]:
https://scikit-learn.org/dev/developers/contributing.html#automated-contributions-policy
-->
I used AI assistance for:
- [ ] Code generation (e.g., when writing an implementation or fixing a bug)
- [ ] Test/benchmark generation
- [ ] Documentation (including examples)
- [x] Research and understanding

#### Any other comments?

Apparently there is no way to access the job name from the job steps, so I used an env variable containing the job name as a workaround.

<!--
Thank you for your patience. Changes to scikit-learn require careful
attention, but with limited maintainer time, not every contribution can be reviewed
quickly.
For more information and tips on improving your pull request, see:
https://scikit-learn.org/dev/faq.html#why-is-my-pull-request-not-getting-any-attention.

Thanks for contributing!
-->

@@ -227,12 +227,21 @@ jobs:
       - name: Checkout
         uses: actions/checkout@v6

+      # This step is necessary to access the job name the same way in both matrix and
+      # non-matrix jobs (like free-threaded or scipy-dev builds).
+      - name: Set JOB_NAME variable
+        shell: bash
+        run: |
+          if [[ -z "$JOB_NAME" ]]; then
+            echo "JOB_NAME=${{ matrix.name }}" >> $GITHUB_ENV
+          fi
+
       - name: Create cache for ccache
         uses: actions/cache@v5
         with:
           path: ${{ env.CCACHE_DIR }}
-          key: ccache-v1-${{ matrix.name }}-${{ hashFiles('**/*.pyx*', '**/*.pxd*', '**/*.pxi*', '**/*.h', '**/*.c', '**/*.cpp', format('{0}', matrix.LOCK_FILE)) }}
-          restore-keys: ccache-${{ matrix.name }}
+          key: ccache-v1-${{ env.JOB_NAME }}-${{ hashFiles('**/*.pyx*', '**/*.pxd*', '**/*.pxi*', '**/*.h', '**/*.c', '**/*.cpp', format('{0}', env.LOCK_FILE)) }}
+          restore-keys: ccache-${{ env.JOB_NAME }}

       - name: Set up conda
         uses: conda-incubator/setup-miniconda@v3
@@ -286,15 +295,15 @@ jobs:
           pip install defusedxml PyGithub
           python maint_tools/update_tracking_issue.py \
             ${{ secrets.BOT_GITHUB_TOKEN }} \
-            "$GITHUB_WORKFLOW ${{ matrix.name }}" \
+            "$GITHUB_WORKFLOW $JOB_NAME" \
             "$GITHUB_REPOSITORY" \
             https://github.com/$GITHUB_REPOSITORY/actions/runs/$GITHUB_RUN_ID \
             --junit-file $TEST_DIR/$JUNITXML \
             --auto-close false \
-            --job-name "${{ matrix.name }}"
+            --job-name "$JOB_NAME"

   free-threaded:
-    name: Linux x86-64 pylatest_free_threaded
+    name: &free-threaded-job-name Linux x86-64 pylatest_free_threaded
     runs-on: ubuntu-latest
     needs: [lint, retrieve-commit-message, retrieve-selected-tests]
     if: contains(needs.retrieve-commit-message.outputs.message, '[free-threaded]') || github.event_name == 'schedule' || github.event_name == 'workflow_dispatch'
@@ -304,10 +313,12 @@ jobs:
       COVERAGE: false
       # Disable pytest-xdist to use multiple cores for stress-testing with pytest-run-parallel
       PYTEST_XDIST_VERSION: none
+      # To be able to access the job name in the steps, it must be set as an env variable.
+      JOB_NAME: *free-threaded-job-name
     steps: *unit-tests-steps

   scipy-dev:
-    name: Linux x86-64 pylatest_pip_scipy_dev
+    name: &scipy-dev-job-name Linux x86-64 pylatest_pip_scipy_dev
     runs-on: ubuntu-22.04
     needs: [lint, retrieve-commit-message, retrieve-selected-tests]
     if: contains(needs.retrieve-commit-message.outputs.message, '[scipy-dev]') || github.event_name == 'schedule' || github.event_name == 'workflow_dispatch'
@@ -316,4 +327,6 @@ jobs:
       LOCK_FILE: build_tools/azure/pylatest_pip_scipy_dev_linux-64_conda.lock
       SKLEARN_WARNINGS_AS_ERRORS: 1
       CHECK_PYTEST_SOFT_DEPENDENCY: true
+      # To be able to access the job name in the steps, it must be set as an env variable.
+      JOB_NAME: *scipy-dev-job-name
     steps: *unit-tests-steps

A few comments

Other than the two comments, looks good to me, thanks!

Actually I think we have a similar problem in other places like the ccache step, we use `${{ matrix.name }}` but actually this is empty for non-standard jobs (e.g. free-threaded).

I am wondering if one work-around would be to have a step early on that defines the env variable `JOB_NAME` by [NAME_REDACTED] `GITHUB_ENV` and then using `env.JOB_NAME` everywhere?

Yes good point, I noticed `${{ matrix.LOCK_FILE }}` is also used, I'll try to add a strep to put both variables in env.

Does the `$JOB_NAME` actually do something, this seems weird I would expect a `${{ }}` syntax is needed i.e. something like
```suggestion
          key: ccache-v1-${{ env.JOB_NAME }}-${{ hashFiles('**/*.pyx*', '**/*.pxd*', '**/*.pxi*', '**/*.h', '**/*.c', '**/*.cpp', format('{0}', env.LOCK_FILE)) }}
```

similar comment, I would expect something like this:
```suggestion
          restore-keys: ccache-${{ env.JOB_NAME }}
```

I am wondering whether using YAML anchor would be a good idea or not, something like (untested)
```yaml
name: Linux x86-64 pylatest_free_threaded &free-threaded-job-name

env:
    JOB_NAME: *free-threaded-job-name
```

This may be overkill to be honest not 100% sure ...

Maybe adding a comment would be useful (not sure where exactly) that setting the JOB_VARIABLE variable is needed to overcome a limitation of GitHub this is a limitation of GitHub with a link to a somewhat useful source?

Explain somewhere that this is needed to treat matrix jobs and separate jobs (like free-threaded or debian-32) in a uniform manner.

I think this should work but please double-check
```suggestion
          if [[ -z "$JOB_NAME" ]]; then
	          echo "JOB_NAME=${{ matrix.name }}" >> $GITHUB_ENV
          fi
```

The LOCK_FILE part is not needed because of the magical `env: ${{ matrix }}` which actually set the environment variables automatically. I copied this from joblib, not sure if this is documented somewhere ...

I believe both work equally but I haven't looked to verify.

I did not find a nice and clean source, my conclusion came from experiments on my fork and discussions with chatbots, but looking for a ref I stumbled upon a [3rd party action](https://github.com/[USER_REDACTED]n/actions--context) which provides the job name as an env variable. Not sure it's worth adding it though.

Probably not worth depending on a 3rd party action for such a small functionality.

Have you tried doing it the other way around. I would find it a tiny bit less ugly (first real content, then quirky thing for the anchor ...)

```suggestion
    name: Linux x86-64 pylatest_free_threaded &free-threaded-job-name
```

In case that works with YAML anchor and GitHub restricted implementation of them
```suggestion
    name: Linux x86-64 pylatest_pip_scipy_dev &scipy-dev-job-name
```

I tried but then ruff got angry at me :
```
check yaml...............................................................Failed
- hook id: check-yaml
- exit code: 1

found undefined alias 'free-threaded-job-name'
  in ".github/workflows/unit-tests.yml", line 316, column 17
```

OK, fair enough, it's more pre-commit and its check-yaml check than ruff but let's keep it like this anyway :shrug:


FIX `n_classes` in DecisionBoundaryDisplay with custom estimators

<!--
🙌 Thanks for contributing a pull request!

👀 Please ensure you have taken a look at the contribution guidelines:
https://github.com/[USER_REDACTED]n/scikit-learn/blob/main/CONTRIBUTING.md

✅ In particular following the pull request checklist will increase the likelihood
of having maintainers review your PR:
https://scikit-learn.org/dev/developers/contributing.html#pull-request-checklist

📋 If your PR is likely to affect users, you will need to add a changelog entry
describing your PR changes, see:
https://github.com/[USER_REDACTED]n/scikit-learn/blob/main/doc/whats_new/upcoming_changes/README.md
-->

#### [NAME_REDACTED]/PRs
<!--
Example: Fixes #1234. See also #3456.
Please use keywords (e.g., Fixes) to create link to the issues or pull requests
you resolved, so that they will automatically be closed when your pull request
is merged. See https://github.com/blog/1506-closing-issues-via-pull-requests
-->
Fixes https://github.com/[USER_REDACTED]n/scikit-learn/issues/33194

#### What does this implement/fix? Explain your changes.
Adds inferring `n_classes` from `type_of_target` and raises an error if it still can't be inferred.

#### AI usage disclosure
<!--
If AI tools were involved in creating this PR, please check all boxes that apply
below and make sure that you adhere to our [NAME_REDACTED]:
https://scikit-learn.org/dev/developers/contributing.html#automated-contributions-policy
-->
I used AI assistance for:
- [ ] Code generation (e.g., when writing an implementation or fixing a bug)
- [x] Test/benchmark generation
- [ ] Documentation (including examples)
- [ ] Research and understanding

#### Any other comments?
I also adapted the example to show the correct colormap (as `viridis` is no longer the default).

<!--
Thank you for your patience. Changes to scikit-learn require careful
attention, but with limited maintainer time, not every contribution can be reviewed
quickly.
For more information and tips on improving your pull request, see:
https://scikit-learn.org/dev/faq.html#why-is-my-pull-request-not-getting-any-attention.

Thanks for contributing!
-->

@@ -0,0 +1,4 @@
+- In :class:`inspection.DecisionBoundaryDisplay`, `n_classes` is now inferred more
+  robustly from the estimator. If it fails for custom estimators, a comprehensive error
+  message is shown.
+  By :user:`[NAME_REDACTED] <AnneBeyer>`.

@@ -25,7 +25,7 @@

 import matplotlib.pyplot as plt

-from sklearn.base import BaseEstimator, clone
+from sklearn.base import BaseEstimator, ClusterMixin, clone
 from sklearn.cluster import AgglomerativeClustering
 from sklearn.datasets import make_blobs
 from sklearn.ensemble import RandomForestClassifier
@@ -50,7 +50,7 @@ def _classifier_has(attr):
     )

-class InductiveClusterer(BaseEstimator):
+class InductiveClusterer(ClusterMixin, BaseEstimator):
     def __init__(self, clusterer, classifier):
         self.clusterer = clusterer
         self.classifier = classifier
@@ -60,6 +60,7 @@ def fit(self, X, y=None):
         self.classifier_ = clone(self.classifier)
         y = self.clusterer_.fit_predict(X)
         self.classifier_.fit(X, y)
+        self.labels_ = y
         return self

     [USER_REDACTED]_if(_classifier_has("predict"))
@@ -122,7 +123,12 @@ def plot_scatter(X, color, alpha=0.5):

 # Plotting decision regions
 DecisionBoundaryDisplay.from_estimator(
-    inductive_learner, X, response_method="predict", alpha=0.4, ax=ax
+    inductive_learner,
+    X,
+    response_method="predict",
+    multiclass_colors="viridis",
+    alpha=0.4,
+    ax=ax,
 )
 plt.title("Classify unknown instances")

@@ -12,6 +12,7 @@
 from sklearn.utils._optional_dependencies import check_matplotlib_support
 from sklearn.utils._response import _get_response_values
 from sklearn.utils._set_output import _get_adapter_from_container
+from sklearn.utils.multiclass import type_of_target
 from sklearn.utils.validation import (
     _is_arraylike_not_scalar,
     _num_features,
@@ -576,6 +577,31 @@ def from_estimator(
             encoder.classes_ = estimator.classes_
             response = encoder.transform(response)

+        # infer n_classes from the estimator
+        if (
+            class_of_interest is not None
+            or is_regressor(estimator)
+            or is_outlier_detector(estimator)
+        ):
+            n_classes = 2
+        elif is_classifier(estimator) and hasattr(estimator, "classes_"):
+            n_classes = len(estimator.classes_)
+        elif is_clusterer(estimator) and hasattr(estimator, "labels_"):
+            n_classes = len(np.unique(estimator.labels_))
+        else:
+            target_type = type_of_target(response)
+            if target_type in ("binary", "continuous"):
+                n_classes = 2
+            elif target_type == "multiclass":
+                n_classes = len(np.unique(response))
+            else:
+                raise ValueError(
+                    "Number of classes or labels cannot be inferred from "
+                    f"{estimator.__class__.__name__}. Please make sure your estimator "
+                    "follows scikit-learn's estimator API as described here: "
+                    "https://scikit-learn.org/stable/developers/develop.html#rolling-your-own-estimator"
+                )
+
         if response.ndim == 1:
             response = response.reshape(*xx0.shape)
         else:
@@ -591,17 +617,6 @@ def from_estimator(
             else:
                 response = response.reshape(*xx0.shape, response.shape[-1])

-        if (
-            class_of_interest is not None
-            or is_regressor(estimator)
-            or is_outlier_detector(estimator)
-        ):
-            n_classes = 2
-        elif is_classifier(estimator):
-            n_classes = len(estimator.classes_)
-        elif is_clusterer(estimator):
-            n_classes = len(np.unique(estimator.labels_))
-
         if xlabel is None:
             xlabel = X.columns[0] if hasattr(X, "columns") else ""

@@ -19,7 +19,8 @@
 from sklearn.inspection import DecisionBoundaryDisplay
 from sklearn.inspection._plot.decision_boundary import _check_boundary_response_method
 from sklearn.linear_model import LogisticRegression
-from sklearn.preprocessing import scale
+from sklearn.pipeline import Pipeline
+from sklearn.preprocessing import StandardScaler, scale
 from sklearn.tree import DecisionTreeClassifier, DecisionTreeRegressor
 from sklearn.utils._testing import (
     _convert_container,
@@ -703,6 +704,34 @@ def test_multiclass_colors_cmap(
     #    assert len(disp.surface_.levels) >= disp.n_classes

+# estimator classes for non-regression test cases for issue #33194
+class CustomBinaryEstimator(BaseEstimator):
+    def fit(self, X, y):
+        self.fitted_ = True
+        return self
+
+    def predict(self, X):
+        return np.arange(X.shape[0]) % 2
+
+
+class CustomMulticlassEstimator(BaseEstimator):
+    def fit(self, X, y):
+        self.fitted_ = True
+        return self
+
+    def predict(self, X):
+        return np.arange(X.shape[0]) % 7
+
+
+class CustomContinuousEstimator(BaseEstimator):
+    def fit(self, X, y):
+        self.fitted_ = True
+        return self
+
+    def predict(self, X):
+        return np.arange(X.shape[0]) * 0.5
+
+
 [USER_REDACTED].mark.parametrize(
     "estimator, n_blobs, expected_n_classes",
     [
@@ -712,12 +741,57 @@ def test_multiclass_colors_cmap(
         (KMeans(n_clusters=2, random_state=0), 2, 2),
         (DecisionTreeRegressor(random_state=0), 7, 2),
         (IsolationForest(random_state=0), 7, 2),
+        (CustomBinaryEstimator(), 2, 2),
+        (CustomMulticlassEstimator(), 7, 7),
+        (CustomContinuousEstimator(), 7, 2),
+        (
+            Pipeline(
+                [
+                    ("scale", StandardScaler()),
+                    ("dt", DecisionTreeClassifier(random_state=0)),
+                ]
+            ),
+            7,
+            7,
+        ),
+        # non-regression test case for issue #33194
+        (
+            Pipeline(
+                [
+                    ("scale", StandardScaler()),
+                    ("kmeans", KMeans(n_clusters=7, random_state=0)),
+                ]
+            ),
+            7,
+            7,
+        ),
+        (
+            Pipeline(
+                [
+                    ("scale", StandardScaler()),
+                    ("reg", DecisionTreeRegressor(random_state=0)),
+                ]
+            ),
+            7,
+            2,
+        ),
+        (
+            Pipeline(
+                [
+                    ("scale", StandardScaler()),
+                    ("kmeans", IsolationForest(random_state=0)),
+                ]
+            ),
+            7,
+            2,
+        ),
     ],
 )
 def test_n_classes_attribute(pyplot, estimator, n_blobs, expected_n_classes):
     """Check that `n_classes` is set correctly.

-    Introduced in https://github.com/[USER_REDACTED]n/scikit-learn/pull/33015"""
+    Introduced in https://github.com/[USER_REDACTED]n/scikit-learn/pull/33015.
+    """

     X, y = make_blobs(n_samples=150, centers=n_blobs, n_features=2, random_state=42)
     clf = estimator.fit(X, y)
@@ -731,6 +805,27 @@ def test_n_classes_attribute(pyplot, estimator, n_blobs, expected_n_classes):
     assert disp_coi.n_classes == 2

+def test_n_classes_raises_if_not_inferrable(pyplot):
+    """Check behaviour if `n_classes` can't be inferred.
+
+    Non-regression test for issue #33194.
+    """
+
+    class CustomUnknownEstimator(BaseEstimator):
+        def fit(self, X, y):
+            self.fitted_ = True
+            return self
+
+        def predict(self, X):
+            return np.array(0)
+
+    X, y = load_iris_2d_scaled()
+    est = CustomUnknownEstimator().fit(X, y)
+    msg = "Number of classes or labels cannot be inferred from CustomUnknownEstimator"
+    with pytest.raises(ValueError, match=msg):
+        DecisionBoundaryDisplay.from_estimator(est, X, response_method="predict")
+
+
 def test_cmap_and_colors_logic(pyplot):
     """Check the handling logic for `cmap` and `colors`."""
     X, y = load_iris_2d_scaled()

LGTM, just some nits

Honestly I haven't looked at the changes in detail, but it seems to mostly make sense.

I am going to merge this one, to reduce my stress levels of having CircleCI red on main for a few days 😅.

Follow-up PR to tweak the behaviour would be more than welcome if things can be improved.

I found the `KMeans-Pipeline` below as an example where the `type_of_target` inference is triggered, but I suspect this might actually not be the intended behaviour of `Pipeline`, i.e., shouldn't it be recognized as `is_clusterer`?

I had to move inferring `n_classes` up here before the `response` reshaping, so that `type_of_target` works properly.

```suggestion
    Non-regression test for issue #33194.
    """
```
nit

```suggestion
                    f"`{estimator.__class__.__name__}`. Please make sure your estimator "
```
nit, sorry

```python
Pipeline(
    [
        ("scale", StandardScaler()),
        ("kmeans", KMeans(n_clusters=7, random_state=0)),
    ]
).__sklearn_tags__()
```

gives

```
Out[13]: Tags(estimator_type='clusterer', target_tags=TargetTags(required=False, one_d_labels=False, two_d_labels=False, positive_only=False, multi_output=False, single_output=True), transformer_tags=TransformerTags(preserves_dtype=['float64']), classifier_tags=None, regressor_tags=None, array_api_support=False, no_validation=False, non_deterministic=False, requires_fit=True, _skip_test=False, input_tags=InputTags(one_d_array=False, two_d_array=True, three_d_array=False, sparse=False, categorical=False, string=False, dict=False, positive_only=False, allow_nan=False, pairwise=False))
```

So it should be recognized?

```suggestion
    Introduced in https://github.com/[USER_REDACTED]n/scikit-learn/pull/33015.
    """
```

Then maybe it doesn't fulfil the other condition (i.e., define `self.labels_`, similar to `self.classes_` for classifiers). I'll have a look.

I would leave this as it is because the estimator name is used like this in many other error messages as well ([here for example](https://github.com/[USER_REDACTED]n/scikit-learn/blob/0da13ec15b37e60db9935a23e4f3b48526cb5c5b/sklearn/utils/validation.py#L2272)) and the test would also need to be adpted.

Indeed. `classes_` is set if the last estimator is a classifier, but not `labels_` for clusterers. The `type_of_target` approach works here, so I would not mix this with this issue.

I also noticed that the docs don't correspond to the docstring, for example, the docstring contains
```
Attributes
    ----------
    named_steps : :class:`~sklearn.utils.Bunch`
        Dictionary-like object, with the following attributes.
        Read-only attribute to access any step parameter by [NAME_REDACTED] are step names and values are steps parameters.

    classes_ : ndarray of shape (n_classes,)
        The classes labels. Only exist if the last step of the pipeline is a
        classifier.

    n_features_in_ : int
        Number of features seen during :term:`fit`. Only defined if the
        underlying first estimator in `steps` exposes such an attribute
        when fit.

        .. versionadded:: 0.24

    feature_names_in_ : ndarray of shape (`n_features_in_`,)
        Names of features seen during :term:`fit`. Only defined if the
        underlying estimator exposes such an attribute when fit.

        .. versionadded:: 1.0
```
and the docs look like this
<img width="672" height="499" alt="image" src="https://github.com/[USER_REDACTED]nts/assets/a3fcfdf4-a081-4129-8ffd-64a2698d0115" />

Do you think this is important enough to open a separate issue?

That looks odd and unexpected.

What I found is that the rendered docs may be pulled from the property methods with the same name. The summary line of the property methods are the same as on the website.

This modification does not seem to be needed to make the example run, can you explain a bit more?

Looking at the code it goes to a different branch `is_clusterer` but the `type_of_target` fall-back branch seems to work fine.

This modification doesn't seem to be needed to make the example run, can you explain a bit more why you changed it?

Yes, `type_of_target` fallback would work, but since we do recommend use of our mixins for users developing their own estimators, so we thought that it would be nice if our examples also demonstrated this...

Fair enough, the code looks a tiny bit more cryptic and the previous code was still working but nothing really crucial :shrug:.

Yes, sorry, I didn't include this in the description, but in the issue we discussed that the example should also follow our recommendations for developing estimators, namely inheriting from the right `Mixin` and defining `self.labels_` for clusterers. But since we don't enforce this, we also added the fallback using `type_of_target`, which would also have handled this example without the modifications.

([USER_REDACTED], are you saying our API makes users write cryptic code? ;) )

It seems to be even more restrictive and only prints the `property` docstring until the first `.`, because the one for `classes` also says:
>"""The classes labels. Only exist if the last step is a classifier."""

If I remove the first full stop, the whole line is shown. :shrug:


CI Use a virtual package spec file for `conda-lock` solving

#### [NAME_REDACTED]/PRs
<!--
Example: Fixes #1234. See also #3456.
Please use keywords (e.g., Fixes) to create link to the issues or pull requests
you resolved, so that they will automatically be closed when your pull request
is merged. See https://github.com/blog/1506-closing-issues-via-pull-requests
-->
Closes #33106

#### What does this implement/fix? Explain your changes.
This modifies the environment solving/creation of lockfile for our CUDA CI jobs. Newer versions of packages like PyTorch require a virtual conda package called `__cuda` which signals that CUDA is available on the system (same mechanism exists for `__glibc`). This means that solving the environment on a machine that does not have CUDA (our CI job to update the lockfile, a developers local machine, etc) is not possible. I think this is why we were "stuck" on a fairly old version of PyTorch (older versions didn't have this dependency). I noticed this problem only when trying to add a new package to the environment (`cuvs`), which is needed for #33096. I think for `cuvs` there are no versions (or only super old ones?) that do not depend on `__cuda`. So solving the environment failed.

#### AI usage disclosure
<!--
If AI tools were involved in creating this PR, please check all boxes that apply
below and make sure that you adhere to our [NAME_REDACTED]:
https://scikit-learn.org/dev/developers/contributing.html#automated-contributions-policy
-->
I used AI assistance for:
- [x] Code generation (e.g., when writing an implementation or fixing a bug)
- [ ] Test/benchmark generation
- [ ] Documentation (including examples)
- [x] Research and understanding

#### Any other comments?

What do you think about adding `cuvs` in this PR already? It is needed in cupy for spatial distance functions like `cdist`. Without cuvs installed those functions fail when you call them.

@@ -12,6 +12,13 @@ source "${HOME}/conda/etc/profile.d/conda.sh"
 source build_tools/shared.sh
 conda activate base

+# Run these debug commands before installing our specific conda environment.
+# We want to see what is available on the runner before we make changes. But
+# we need to install miniforge before being able to look at the output of the
+# conda commands.
+conda info --json | python -c "import sys, json; print('Conda virtual packages versions:', json.load(sys.stdin).get('virtual_pkgs', []));"
+nvidia-smi
+
 CONDA_ENV_NAME=sklearn
 LOCK_FILE=build_tools/github/pylatest_conda_forge_cuda_array-api_linux-64_conda.lock
 create_conda_environment_from_lock_file $CONDA_ENV_NAME $LOCK_FILE

@@ -27,4 +27,5 @@ dependencies:
   - polars
   - pyarrow
   - cupy
+  - rapidsai::cuvs
   - array-api-strict

@@ -0,0 +1,9 @@
+# The versions of the virtual packages here are taken from running
+# build_tools/github/create_gpu_environment.sh which outputs
+# the versions of the packages used in the CUDA CI runner.
+# Do not set them to what you see on your local machine.
+subdirs:
+  linux-64:
+    packages:
+      __cuda: "12.8"
+      __glibc: "2.39"

@@ -109,8 +109,11 @@ def remove_from(alist, to_remove):
             "polars",
             "pyarrow",
             "cupy",
+            # cuvs is needed for cupyx.scipy.spatial.distance.cdist and friends
+            "rapidsai::cuvs",
             "array-api-strict",
         ],
+        "virtual_package_spec": True,
     },
     {
         "name": "pylatest_conda_forge_mkl_linux-64",
@@ -553,22 +556,26 @@ def write_all_conda_environments(build_metadata_list):
         write_conda_environment(build_metadata)

-def conda_lock(environment_path, lock_file_path, platform):
-    execute_command(
-        [
-            "conda-lock",
-            "lock",
-            "--mamba",
-            "--kind",
-            "explicit",
-            "--platform",
-            platform,
-            "--file",
-            str(environment_path),
-            "--filename-template",
-            str(lock_file_path),
-        ]
-    )
+def conda_lock(
+    environment_path, lock_file_path, platform, virtual_package_spec_path=None
+):
+    cmd = [
+        "conda-lock",
+        "lock",
+        "--mamba",
+        "--kind",
+        "explicit",
+        "--platform",
+        platform,
+        "--file",
+        str(environment_path),
+        "--filename-template",
+        str(lock_file_path),
+    ]
+    if virtual_package_spec_path is not None:
+        cmd.extend(["--virtual-package-spec", str(virtual_package_spec_path)])
+
+    execute_command(cmd)

 def create_conda_lock_file(build_metadata):
@@ -581,7 +588,14 @@ def create_conda_lock_file(build_metadata):
         lock_file_basename = f"{lock_file_basename}_{platform}"

     lock_file_path = folder_path / f"{lock_file_basename}_conda.lock"
-    conda_lock(environment_path, lock_file_path, platform)
+
+    virtual_package_spec_path = None
+    if build_metadata.get("virtual_package_spec"):
+        virtual_package_spec_path = (
+            folder_path / f"{lock_file_basename}_virtual_package_spec.yml"
+        )
+
+    conda_lock(environment_path, lock_file_path, platform, virtual_package_spec_path)

 def write_all_conda_lock_files(build_metadata_list):

@@ -1143,6 +1143,10 @@ def _yield_instances_for_check(check, estimator_orig):
             "sample_weight is not equivalent to removing/repeating samples."
         ),
     },
+    PCA: {
+        # TODO: see gh-33205 for details
+        "check_array_api_input": "`linalg.inv` fails because input is singular",
+    },
     Perceptron: {
         # TODO: replace by a statistical test, see meta-issue #16298
         "check_sample_weight_equivalence_on_dense_data": (

> What do you think about adding cuvs in this PR already? It is needed in cupy for spatial distance functions like cdist. Without cuvs installed those functions fail when you call them.

I am fine with this, but we should explain that it's an optional dependency of cupy to get the SciPy `cdist` function to work on CUDA devices.

Nice, it does look like this updates PyTorch to a recent version

Would the following be a suitable justification?

LGTM after rerunning the CUDA CI and XFAILing the PCA test as suggested in https://github.com/[USER_REDACTED]n/scikit-learn/pull/33175#issuecomment-3834360881.

Do we need to add the channel? Wouldn't just adding the `rapidsai`-scoped  `cuvs` (`rapidsai::cuvs`) in the dependencies section be enough?

Out of curiosity, do we need to mention `glibc`?

It would be nice to add a comment saying that the version need to match the GPU runner and ideally explain how to figure out what the CUDA version is on the GPU runner ...

Maybe rather than special-casing like this, add a `virtual_package_spec_filename` (or a better name) key in the build dictionary i.e. something like this
```py
build_metadata_list = [
    {
        "name": "pylatest_conda_forge_cuda_array-api_linux-64",
        "type": "conda",
        "tag": "cuda",
        "folder": "build_tools/github",
        "platform": "linux-64",
        "channels": ["conda-forge"],
        "conda_dependencies": common_dependencies
        + [
            "ccache",
            "pytorch-gpu",
            "polars",
            "pyarrow",
            "cupy",
            "array-api-strict",
        ],
        "virtual_package_spec_filename": "build_tools/github/virtual-packages-cuda.yml"
    },
    ...
]

I think we should add an inline comment to explain why these virtual packages are set to those values (and how/when they should be updated).

I just saw that Loïc had the same comment next...

I had the same question and the answer is yes. It seems like using this spec file removes the other defaults/detection mechanism. I'll add a comment to the file.

The way I chose the values is: educated guess. Trying to figure out what version of CUDA is installed in the CI runner is tricky - I think I might temporarily add something to the CUDA CI job that prints the version. Maybe it is also possible to run a `conda` command to find out which version of `__glibc` is used on the CUDA CI runner.

Resolved now. Added a comment on where to get the values from (CUDA CI job output)

The generated environment and lockfile are the same with and without -> removing the channel

```suggestion
            "rapidsai::cuvs",  # needed by [NAME_REDACTED]
```

While thinking about this I went with your suggestion of including it in the dictionary. It seemed weird to have the path to the file with directories, etc in the config though. So now if you set `"virtual_package_spec": True` in the dictionary it loads the spec file from a name that is generated based on the `"name"` key (similar to env and lockfile)

I used a variation on this. I am not sure I understand why `cuvs` isn't a dependency of `cupy` given that things like `cdist` require it.

It is technically an optional dependency, but if you want to use the spatial module you need it (there is no slower fallback).

This xfails the test all the time right? This seems a bit too coarse if this only fails for a particular combination (PyTorch CPU something something I think?), maybe add a comment with a TODO to feel better about it?

I created #33205 and reference this from the comment.

I was wondering if there was a nice way to only xfail a particular combo, but I think it would be tricky (you'd need code in the check itself). Seems too much extra stuff, especially because I hope Olivier will propose a PR soon that fixes this ;)

Good enough, thanks for getting to the bottom of this!


FIX _predict_proba_lr in LinearClassifierMixin

#### [NAME_REDACTED]/PRs
Fixes #17978.

#### What does this implement/fix? Explain your changes.
`LinearClassifierMixin._predict_proba_lr` might return nan or inf if `decision_function` returns large negative values, because all probabilities are zero in floating point arithmetic (e.g. exp(-1000) = 0).

#### AI usage disclosure
None

#### Any other comments?
I guess the only estimator is `SGDClassifier`, not 100% sure.

@@ -0,0 +1,4 @@
+- Fixed a bug in :class:`linear_model.SGDClassifier` for multiclass settings where
+  large negative values of :method:`decision_function` could lead to NaN values. In
+  this case, this fix assigns equal probability for each class.
+  By :user:`[NAME_REDACTED] <lorentzenchr>`.

@@ -405,7 +405,14 @@ def _predict_proba_lr(self, X):
             return np.vstack([1 - prob, prob]).T
         else:
             # OvR normalization, like LibLinear's predict_probability
-            prob /= prob.sum(axis=1).reshape((prob.shape[0], -1))
+            prob_sum = prob.sum(axis=1)
+            all_zero = prob_sum == 0
+            if np.any(all_zero):
+                # The above might assign zero to all classes, which doesn't
+                # normalize neatly; work around this to produce uniform probabilities.
+                prob[all_zero, :] = 1
+                prob_sum[all_zero] = prob.shape[1]  # n_classes
+            prob /= prob_sum.reshape((prob.shape[0], -1))
             return prob

@@ -7,9 +7,11 @@
 import pytest
 from scipy import linalg, sparse

+from sklearn.base import BaseEstimator
 from sklearn.datasets import load_iris, make_regression, make_sparse_uncorrelated
 from sklearn.linear_model import LinearRegression
 from sklearn.linear_model._base import (
+    LinearClassifierMixin,
     _preprocess_data,
     _rescale_data,
     make_dataset,
@@ -844,3 +846,28 @@ def test_linear_regression_sample_weight_consistency(
     assert_allclose(reg1.coef_, reg2.coef_, rtol=1e-6)
     if fit_intercept:
         assert_allclose(reg1.intercept_, reg2.intercept_)
+
+
+def test_predict_proba_lr_large_values():
+    """Test that _predict_proba_lr of LinearClassifierMixin deals with large
+    negative values.
+
+    Note that exp(-1000) = 0.
+    """
+
+    class MockClassifier(LinearClassifierMixin, BaseEstimator):
+        def __init__(self):
+            pass
+
+        def fit(self, X, y):
+            self.__sklearn_is_fitted__ = True
+
+        def decision_function(self, X):
+            n_samples = X.shape[0]
+            return np.tile([-1000.0] * 4, [n_samples, 1])
+
+    clf = MockClassifier()
+    clf.fit(X=None, y=None)
+
+    proba = clf._predict_proba_lr(np.ones(5))
+    assert_allclose(np.sum(proba, axis=1), 1)

LGTM. Thank you [USER_REDACTED]

LGTM.

```suggestion
    """Test that _predict_proba_lr of LinearClassifierMixin deals with large
    negative values.
```


ENH speedup gap safe screening

#### [NAME_REDACTED]/PRs
Improvement on top of #31882, #31986, #31987 and #32014.

#### What does this implement/fix? Explain your changes.
This PR prevents the update of the residual inside the screening if the coefficient is already zero.

#### AI usage disclosure
No

#### Any other comments?

@@ -0,0 +1,9 @@
+- |Efficiency| :class:`linear_model.ElasticNet`, :class:`linear_model.ElasticNetCV`,
+  :class:`linear_model.Lasso`, :class:`linear_model.LassoCV`,
+  :class:`linear_model.MultiTaskElasticNet`, :class:`linear_model.MultiTaskElasticNetCV`
+  :class:`linear_model.MultiTaskLasso`, :class:`linear_model.MultiTaskLassoCV`
+  as well as
+  :func:`linear_model.lasso_path` and :func:`linear_model.enet_path` are now faster when
+  fit with strong L1 penalty and many features. During gap safe screening of features,
+  the update of the residual is now only performed if the coefficient is not zero.
+  By :user:`[NAME_REDACTED] <lorentzenchr>`.

@@ -378,9 +378,10 @@ def enet_coordinate_descent(
                     excluded_set[j] = 0
                     n_active += 1
                 else:
-                    # R += w[j] * X[:,j]
-                    _axpy(n_samples, w[j], &X[0, j], 1, &R[0], 1)
-                    w[j] = 0
+                    if w[j] != 0:
+                        # R += w[j] * X[:,j]
+                        _axpy(n_samples, w[j], &X[0, j], 1, &R[0], 1)
+                        w[j] = 0
                     excluded_set[j] = 1

         for n_iter in range(max_iter):
@@ -448,9 +449,10 @@ def enet_coordinate_descent(
                             excluded_set[j] = 0
                             n_active += 1
                         else:
-                            # R += w[j] * X[:,j]
-                            _axpy(n_samples, w[j], &X[0, j], 1, &R[0], 1)
-                            w[j] = 0
+                            if w[j] != 0:
+                                # R += w[j] * X[:,j]
+                                _axpy(n_samples, w[j], &X[0, j], 1, &R[0], 1)
+                                w[j] = 0
                             excluded_set[j] = 1

         else:
@@ -809,21 +811,22 @@ def sparse_enet_coordinate_descent(
                     excluded_set[j] = 0
                     n_active += 1
                 else:
-                    # R += w[j] * X[:,j]
-                    R_plus_wj_Xj(
-                        n_samples,
-                        R,
-                        X_data,
-                        X_indices,
-                        X_indptr,
-                        X_mean,
-                        center,
-                        sample_weight,
-                        no_sample_weights,
-                        w[j],
-                        j,
-                    )
-                    w[j] = 0
+                    if w[j] != 0:
+                        # R += w[j] * X[:,j]
+                        R_plus_wj_Xj(
+                            n_samples,
+                            R,
+                            X_data,
+                            X_indices,
+                            X_indptr,
+                            X_mean,
+                            center,
+                            sample_weight,
+                            no_sample_weights,
+                            w[j],
+                            j,
+                        )
+                        w[j] = 0
                     excluded_set[j] = 1

         for n_iter in range(max_iter):
@@ -925,21 +928,22 @@ def sparse_enet_coordinate_descent(
                             excluded_set[j] = 0
                             n_active += 1
                         else:
-                            # R += w[j] * X[:,j]
-                            R_plus_wj_Xj(
-                                n_samples,
-                                R,
-                                X_data,
-                                X_indices,
-                                X_indptr,
-                                X_mean,
-                                center,
-                                sample_weight,
-                                no_sample_weights,
-                                w[j],
-                                j,
-                            )
-                            w[j] = 0
+                            if w[j] != 0:
+                                # R += w[j] * X[:,j]
+                                R_plus_wj_Xj(
+                                    n_samples,
+                                    R,
+                                    X_data,
+                                    X_indices,
+                                    X_indptr,
+                                    X_mean,
+                                    center,
+                                    sample_weight,
+                                    no_sample_weights,
+                                    w[j],
+                                    j,
+                                )
+                                w[j] = 0
                             excluded_set[j] = 1

         else:
@@ -1150,9 +1154,10 @@ def enet_coordinate_descent_gram(
                     excluded_set[j] = 0
                     n_active += 1
                 else:
-                    # Qw -= w[j] * Q[j]  # Update Qw = Q @ w
-                    _axpy(n_features, -w[j], &Q[j, 0], 1, &Qw[0], 1)
-                    w[j] = 0
+                    if w[j] != 0:
+                        # Qw -= w[j] * Q[j]  # Update Qw = Q @ w
+                        _axpy(n_features, -w[j], &Q[j, 0], 1, &Qw[0], 1)
+                        w[j] = 0
                     excluded_set[j] = 1

         for n_iter in range(max_iter):
@@ -1221,9 +1226,10 @@ def enet_coordinate_descent_gram(
                             excluded_set[j] = 0
                             n_active += 1
                         else:
-                            # Qw -= w[j] * Q[j]  # Update Qw = Q @ w
-                            _axpy(n_features, -w[j], &Q[j, 0], 1, &Qw[0], 1)
-                            w[j] = 0
+                            if w[j] != 0:
+                                # Qw -= w[j] * Q[j]  # Update Qw = Q @ w
+                                _axpy(n_features, -w[j], &Q[j, 0], 1, &Qw[0], 1)
+                                w[j] = 0
                             excluded_set[j] = 1

         else:
@@ -1462,8 +1468,9 @@ def enet_coordinate_descent_multi_task(
                 else:
                     # R += W[:, 1] * X[:, 1][:, None]
                     for t in range(n_tasks):
-                        _axpy(n_samples, W[t, j], &X[0, j], 1, &R[0, t], 1)
-                        W[t, j] = 0
+                        if W[t, j] != 0:
+                            _axpy(n_samples, W[t, j], &X[0, j], 1, &R[0, t], 1)
+                            W[t, j] = 0
                     excluded_set[j] = 1

         for n_iter in range(max_iter):
@@ -1559,8 +1566,9 @@ def enet_coordinate_descent_multi_task(
                         else:
                             # R += W[:, 1] * X[:, 1][:, None]
                             for t in range(n_tasks):
-                                _axpy(n_samples, W[t, j], &X[0, j], 1, &R[0, t], 1)
-                                W[t, j] = 0
+                                if W[t, j] != 0:
+                                    _axpy(n_samples, W[t, j], &X[0, j], 1, &R[0, t], 1)
+                                    W[t, j] = 0
                             excluded_set[j] = 1

         else:

LGTM

LGTM. Thank you [USER_REDACTED]

Should `R_plus_wj_Xj` itself early exit if `w_j` is zero? `R_plus_wj_Xj` is inline, so it should end up being the same.

As all the other cd-versions have it exactly like this - they don't need an extra inline function - I prefer to keep it as is. This way, it really becomes clear what happens and the logic is not hidden inside `R_plus_wj_Xj`.


Improve contribution guidelines to make it easier for maintainers to assess the user facing value of issues/PRs

As a follow-up to the discussion at our monthly meeting.

I tried to explain the reasons we ask for all those requirements as [USER_REDACTED] suggested.

@@ -10,9 +10,11 @@ body:
       addressed by [NAME_REDACTED] [the past issues](https://github.com/[USER_REDACTED]n/scikit-learn/issues).
 - type: textarea
   attributes:
-    label: Describe the bug
+    label: Describe the bug and give evidence about its user-facing impact
     description: >
-      A clear and concise description of what the bug is.
+      A clear and concise description of what the bug is and **how it affects you as a scikit-learn user**. Please give a few details about the context of the discovery, why you care about getting it fixed. Please do not create issues for problems you don't actually care about.
+
+      The scikit-learn issue tracker is swamped by [NAME_REDACTED] the expected user impact is critical to help maintainers and other contributors focus time and effort to review meaningful contributions.
   validations:
     required: true
 - type: textarea
@@ -36,13 +38,15 @@ body:
       model = lda_model.fit(lda_features)
       ```

+      If possible, craft a reproducer that only uses the public scikit-learn API or justify why you had to use some private API to trigger the problem. This helps us assess the user-facing impact of the bug.
+
       If the code is too long, feel free to put it in a public gist and link it in the issue: https://gist.github.com.

-      In short, **we are going to copy-paste your code** to run it and we expect to get the same result as you.
+      In short, **we need to be able to quickly copy-paste your code** to run it without modification and we expect to get the same result as you.

       We acknowledge that crafting a [minimal reproducible code example](https://scikit-learn.org/dev/developers/minimal_reproducer.html) requires some effort on your side but it really helps the maintainers quickly reproduce the problem and analyze its cause without any ambiguity. Ambiguous bug reports tend to be slower to fix because they will require more effort and back and forth discussion between the maintainers and the reporter to pin-point the precise conditions necessary to reproduce the problem.
     placeholder: |
-      ```
+      ```python
       Sample code to reproduce the problem
       ```
   validations:
@@ -89,6 +93,14 @@ body:
       ```
   validations:
     required: true
+- type: textarea
+  attributes:
+    label: Interest in fixing the bug
+    description: >
+      If your issue is triaged by [NAME_REDACTED] a bug that can be reproduced, would you be interested in working on a PR to resolve it?
+      And if you already have an idea, please explain your analysis of the root cause of the bug and a strategy for a possible fix, but please do not open a PR as long as the issue has not been triaged.
+  validations:
+    required: true
 - type: markdown
   attributes:
     value: >

@@ -241,6 +241,23 @@ feedback:
   <https://help.github.com/articles/creating-and-highlighting-code-blocks>`_
   for more details.

+- Please be explicit **how this issue impacts you as a scikit-learn user**. Giving
+  some details (a short paragraph) about how you use scikit-learn and why you need
+  this issue resolved will help the project maintainers invest time and effort
+  on issues that actually impact users.
+
+- Please tell us if you would be interested in opening a PR to resolve your issue
+  once triaged by a project maintainer.
+
+Note that the scikit-learn tracker receives `daily reports
+<https://github.com/[USER_REDACTED]n/scikit-learn/issues?q=label%3Aspam>`_ by
+GitHub accounts that are mostly interested in increasing contribution
+statistics and show little interest in the expected end-user impact of their
+contributions. As project maintainers we want to be able to assess if our
+efforts are likely to have a meaningful and positive impact to our end users.
+Therefore, we ask you to avoid opening issues for things you don't actually
+care about.
+
 If you want to help curate issues, read about :ref:`bug_triaging`.

 Contributing code and documentation
@@ -387,7 +404,25 @@ complies with the following rules before marking a PR as "ready for review". The
    cases "Fix <ISSUE TITLE>" is enough. "Fix #<ISSUE NUMBER>" is never a
    good title.

-2. **Make sure your code passes the tests**. The whole test suite can be run
+2. **Pull requests are expected to resolve one or more issues**.
+   Please **do not open PRs for issues that are labeled as "Needs triage"**
+   (see :ref:`issues_tagged_needs_triage`) or with other kinds of "Needs ..."
+   labels. Please do not open PRs for issues for which:
+
+   - the discussion has not settled down to an explicit resolution plan,
+   - the reporter has already expressed interest in opening a PR,
+   - there already exists cross-referenced and active PRs.
+
+   If merging your pull request means that some other issues/PRs should be closed,
+   you should `use keywords to create link to them
+   <https://github.com/blog/1506-closing-issues-via-pull-requests/>`_
+   (e.g., ``Fixes #1234``; multiple issues/PRs are allowed as long as each
+   one is preceded by a keyword). Upon merging, those issues/PRs will
+   automatically be closed by GitHub. If your pull request is simply
+   related to some other issues/PRs, or it only partially resolves the target
+   issue, create a link to them without using the keywords (e.g., ``Towards #1234``).
+
+3. **Make sure your code passes the tests**. The whole test suite can be run
    with `pytest`, but it is usually not recommended since it takes a long
    time. It is often enough to only run the test related to your changes:
    for example, if you changed something in
@@ -410,40 +445,30 @@ complies with the following rules before marking a PR as "ready for review". The
    you don't need to run the whole test suite locally. For guidelines on how
    to use ``pytest`` efficiently, see the :ref:`pytest_tips`.

-3. **Make sure your code is properly commented and documented**, and **make
+4. **Make sure your code is properly commented and documented**, and **make
    sure the documentation renders properly**. To build the documentation, please
    refer to our :ref:`contribute_documentation` guidelines. The CI will also
    build the docs: please refer to :ref:`generated_doc_CI`.

-4. **Tests are necessary for enhancements to be
+5. **Tests are necessary for enhancements to be
    accepted**. Bug-fixes or new features should be provided with non-regression tests.
    These tests verify the correct behavior of the fix or feature. In this manner,
    further modifications on the code base are granted to be consistent with the
    desired behavior. In the case of bug fixes, at the time of the PR, the
    non-regression tests should fail for the code base in the ``main`` branch
    and pass for the PR code.

-5. If your PR is likely to affect users, you need to add a changelog entry describing
+6. If your PR is likely to affect users, you need to add a changelog entry describing
    your PR changes. See the
    `README <https://github.com/[USER_REDACTED]n/scikit-learn/blob/main/doc/whats_new/upcoming_changes/README.md>`_
    for more details.

-6. Follow the :ref:`coding-guidelines`.
+7. Follow the :ref:`coding-guidelines`.

-7. When applicable, use the validation tools and scripts in the :mod:`sklearn.utils`
+8. When applicable, use the validation tools and scripts in the :mod:`sklearn.utils`
    module. A list of utility routines available for developers can be found in the
    :ref:`developers-utils` page.

-8. Often pull requests resolve one or more other issues (or pull requests).
-   If merging your pull request means that some other issues/PRs should
-   be closed, you should `use keywords to create link to them
-   <https://github.com/blog/1506-closing-issues-via-pull-requests/>`_
-   (e.g., ``Fixes #1234``; multiple issues/PRs are allowed as long as each
-   one is preceded by a keyword). Upon merging, those issues/PRs will
-   automatically be closed by GitHub. If your pull request is simply
-   related to some other issues/PRs, or it only partially resolves the target
-   issue, create a link to them without using the keywords (e.g., ``Towards #1234``).
-
 9. PRs should often substantiate the change, through benchmarks of
    performance and efficiency (see :ref:`monitoring_performances`) or through
    examples of usage. Examples also illustrate the features and intricacies of

Thank you for adding this! I just have a few small comments, but I'm all for making this more explicit!

I like it. As always I worry about adding more words than we remove, because the docs and templates we have are already quite long. But shorter isn't always better.

There are a lot of suggestions which should be resolved but I have no strong opinion on them. So approving already.

Lots of small nits but LGTM. These address the majority of the contribution problems we have been having - hopefully some people read them!

Thanks for working on this!

Asking the OP to be explicit about their interest in opening a PR could help reduce the comment spam from random people asking to open a PR on other contributors' issues.

This is the second part to my attempt at reducing comment spam.

```suggestion
  this issue resolved will help the project maintainers invest time and effort
```

Should an interest in fixing a bug always already require knowing a possible solution? This could be a point of frustration if the issue is then not triaged as a bug. Maybe add something like this?
```suggestion
      If your issue is triaged by [NAME_REDACTED] a bug that can be reproduced, would you be interested in working on a PR to resolve it?
      And if you already have an idea, please explain your analysis of the root cause of the bug and a strategy for a possible fix, but please do not open a PR as long as the issue has not been triaged.
```

```suggestion
      In short, **we need to be able to quickly copy-paste your code** to run it without modification and we expect to get the same result as you.
```

```suggestion
- Please tell us if you would be interested in opening a PR to resolve your issue
```

```suggestion
GitHub accounts that are mostly interested in increasing contribution
```

```suggestion
statistics and show little interest in the expected end-user impact of their
```

```suggestion
2. **Pull requests are expected to resolve one or more issues**.
```

Maybe link "Needs triage" to https://scikit-learn.org/dev/developers/contributing.html#issues-tagged-needs-triage?

I'm not sure if the comment about accounts interested in building reputation helps? Maybe it does? So suggesting to remove it based on "less text means higher chance people will read it"

```suggestion
      The scikit-learn issue tracker is swamped by [NAME_REDACTED] the expected user impact is critical to help maintainers and other contributors focus time and effort to review meaningful contributions.
```

I will push a follow up commit.

```suggestion
   - the discussion has not settled down to an explicit resolution plan,
```

```suggestion
      A clear and concise description of what the bug is and **how it affects you as a scikit-learn user**. Please give details about the context of the discovery and why you care about getting it fixed. Please do not create issues for problems you don't actually care about.
```

just in the interest of shortening text.

```suggestion
      The scikit-learn issue tracker is swamped by [NAME_REDACTED] the expected user impact is critical to helping maintainers and other contributors focus time and effort on reviewing meaningful contributions.
```
nit

```suggestion
      In short, **we need to be able to copy-paste your code** and run it without modification and we expect to get the same result as you.
```
Tims nit about adjectives 😅

```suggestion
      If you already have an idea, please explain your analysis of the root cause of the bug and a strategy for a possible fix, but please **do not** open a PR until the issue has been triaged.
```
nit

```suggestion
  details (a short paragraph) about how you use scikit-learn and why you need
```

```suggestion
GitHub accounts that are mostly interested in increasing their contribution
```

These seem related/and repeat the line above (L24):

> Please add a [minimal code example](https://scikit-learn.org/dev/developers/minimal_reproducer.html) that can reproduce the error when running it.

Maybe we could add these points to the above line?

```suggestion
   (see :ref:`issues_tagged_needs_triage`) or with "Needs ..."
   labels (e.g., "Needs investigation"). Please do not open PRs for issues for which:
```

```suggestion
   If merging your pull request means that some other issues/PRs should be closed
   (e.g., because it resolves an issue or supersedes a stalled PR),
```

Not 100% on this, feel free to ignore


DOC Add statement on supported versions for array API

Clarify that we develop against the latest versions of dependencies for array API work and will not introduce code to backport fixes that are available in newer versions of our dependencies.

#### [NAME_REDACTED]/PRs
<!--
Example: Fixes #1234. See also #3456.
Please use keywords (e.g., Fixes) to create link to the issues or pull requests
you resolved, so that they will automatically be closed when your pull request
is merged. See https://github.com/blog/1506-closing-issues-via-pull-requests
-->
For example https://github.com/[USER_REDACTED]n/scikit-learn/issues/32552#issuecomment-3782563204 and https://github.com/[USER_REDACTED]n/scikit-learn/pull/33022, but we've discussed "what versions do we support?" a few times already in various issues and PRs.

#### What does this implement/fix? Explain your changes.

This adds a statement to the array API documentation stating that we only support the latest version of scikit-learn's dependencies. It also says that we will not introduce changes that backport things to older versions of dependencies - which is kind of redundant but given https://github.com/[USER_REDACTED]n/scikit-learn/pull/33022 maybe worth stating explicitly (aka I shouldn't have made that PR).

#### AI usage disclosure
<!--
If AI tools were involved in creating this PR, please check all boxes that apply
below and make sure that you adhere to our [NAME_REDACTED]:
https://scikit-learn.org/dev/developers/contributing.html#automated-contributions-policy
-->
I used AI assistance for:
- [ ] Code generation (e.g., when writing an implementation or fixing a bug)
- [ ] Test/benchmark generation
- [ ] Documentation (including examples)
- [ ] Research and understanding

#### Any other comments?

I moved the environment variable paragraph as I was missing it in the "Enable array API support" section. In this case I went directly to that section without reading the page from the top. To me it makes sense to have it all in one place. Looking at it from a different angle: why is the env var thing mentioned at the very top, but how to enable it isn't? They both seem equally important.

@@ -12,17 +12,6 @@ Scikit-learn vendors pinned copies of
 `array-api-compat <https://github.com/data-apis/array-api-compat>`__
 and `array-api-extra <https://github.com/data-apis/array-api-extra>`__.

-Scikit-learn's support for the array API standard requires the environment variable
-`SCIPY_ARRAY_API` to be set to `1` before importing `scipy` and `scikit-learn`:
-
-.. prompt:: bash $
-
-   export SCIPY_ARRAY_API=1
-
-Please note that this environment variable is intended for temporary use.
-For more details, refer to SciPy's `Array API documentation
-<https://docs.scipy.org/doc/scipy/dev/api-dev/array_api.html#using-array-api-standard-support>`_.
-
 Some scikit-learn estimators that primarily rely on NumPy (as opposed to using
 Cython) to implement the algorithmic logic of their `fit`, `predict` or
 `transform` methods can be configured to accept any Array API compatible input
@@ -52,6 +41,23 @@ Note that in the examples below, we use a context manager (:func:`config_context
 to avoid having to reset it to `False` at the end of every code snippet, so as to
 not affect the rest of the documentation.

+Scikit-learn's support for the array API standard requires the environment variable
+`SCIPY_ARRAY_API` to be set to `1` before importing `scipy` and `scikit-learn`:
+
+.. prompt:: bash $
+
+   export SCIPY_ARRAY_API=1
+
+Please note that this environment variable is intended for temporary use.
+For more details, refer to SciPy's `Array API documentation
+<https://docs.scipy.org/doc/scipy/dev/api-dev/array_api.html#using-array-api-standard-support>`_.
+
+The array API functionality assumes that the latest versions of scikit-learn's dependencies are
+installed. Older versions might work, but we make no promises. While array API support is marked
+as experimental, backwards compatibility is not guaranteed. In particular, when a newer version
+of a dependency fixes a bug we will not introduce additional code to backport the fix or
+maintain compatibility with older versions.
+
 Scikit-learn accepts :term:`array-like` inputs for all :mod:`metrics`
 and some estimators. When `array_api_dispatch=False`, these inputs are converted
 into NumPy arrays using :func:`numpy.asarray` (or :func:`numpy.array`).

I like this! It is an important statement for users and the best solution while we develop.

I'm okay with this.

We can always be more 'generous' once we are out of experimental mode.

```suggestion
as experimental, backwards compatibility is not guaranteed. In particular, when a newer version
```

nits