Reverts the screening refactoring in enet_coordinate_descent_gram

<!--
🙌 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
-->
Reverts #32197

#### What does this implement/fix? Explain your changes.
- This reverts the refactoring with respect to the screening in `enet_coordinate_descent_gram`

#### 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

<!--
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!
-->

@@ -914,48 +914,6 @@ cdef (floating, floating) gap_enet_gram(
     return gap, dual_norm_XtA

-cdef inline uint32_t screen_features_enet_gram(
-    const floating[:, ::1] Q,
-    const floating[::1] XtA,
-    floating[::1] w,
-    floating[::1] Qw,
-    uint32_t[::1] active_set,
-    uint8_t[::1] excluded_set,
-    floating alpha,
-    floating beta,
-    floating gap,
-    floating dual_norm_XtA,
-    uint32_t n_features,
-) noexcept nogil:
-    """Apply gap safe screening for all features within enet_coordinate_descent_gram"""
-    cdef floating d_j
-    cdef floating Xj_theta
-    cdef uint32_t n_active = 0
-    # Due to floating point issues, gap might be negative.
-    cdef floating radius = sqrt(2 * fabs(gap)) / alpha
-
-    for j in range(n_features):
-        if Q[j, j] == 0:
-            w[j] = 0
-            excluded_set[j] = 1
-            continue
-
-        Xj_theta = XtA[j] / fmax(alpha, dual_norm_XtA)  # X[:,j] @ dual_theta
-        d_j = (1 - fabs(Xj_theta)) / sqrt(Q[j, j] + beta)
-        if d_j <= radius:
-            # include feature j
-            active_set[n_active] = j
-            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
-            excluded_set[j] = 1
-
-    return n_active
-
-
 def enet_coordinate_descent_gram(
     floating[::1] w,
     floating alpha,
@@ -1007,6 +965,9 @@ def enet_coordinate_descent_gram(
     cdef floating[::1] XtA = np.zeros(n_features, dtype=dtype)
     cdef floating y_norm2 = np.dot(y, y)

+    cdef floating d_j
+    cdef floating radius
+    cdef floating Xj_theta
     cdef floating tmp
     cdef floating w_j
     cdef floating d_w_max
@@ -1050,19 +1011,26 @@ def enet_coordinate_descent_gram(

         # [NAME_REDACTED] Rules, see https://arxiv.org/abs/1802.07481, Eq. 11
         if do_screening:
-            n_active = screen_features_enet_gram(
-                Q=Q,
-                XtA=XtA,
-                w=w,
-                Qw=Qw,
-                active_set=active_set,
-                excluded_set=excluded_set,
-                alpha=alpha,
-                beta=beta,
-                gap=gap,
-                dual_norm_XtA=dual_norm_XtA,
-                n_features=n_features,
-            )
+            # Due to floating point issues, gap might be negative.
+            radius = sqrt(2 * fabs(gap)) / alpha
+            n_active = 0
+            for j in range(n_features):
+                if Q[j, j] == 0:
+                    w[j] = 0
+                    excluded_set[j] = 1
+                    continue
+                Xj_theta = XtA[j] / fmax(alpha, dual_norm_XtA)  # X[:,j] @ dual_theta
+                d_j = (1 - fabs(Xj_theta)) / sqrt(Q[j, j] + beta)
+                if d_j <= radius:
+                    # include feature j
+                    active_set[n_active] = j
+                    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
+                    excluded_set[j] = 1

         for n_iter in range(max_iter):
             w_max = 0.0
@@ -1116,19 +1084,24 @@ def enet_coordinate_descent_gram(

                 # [NAME_REDACTED] Rules, see https://arxiv.org/abs/1802.07481, Eq. 11
                 if do_screening:
-                    n_active = screen_features_enet_gram(
-                        Q=Q,
-                        XtA=XtA,
-                        w=w,
-                        Qw=Qw,
-                        active_set=active_set,
-                        excluded_set=excluded_set,
-                        alpha=alpha,
-                        beta=beta,
-                        gap=gap,
-                        dual_norm_XtA=dual_norm_XtA,
-                        n_features=n_features,
-                    )
+                    # Due to floating point issues, gap might be negative.
+                    radius = sqrt(2 * fabs(gap)) / alpha
+                    n_active = 0
+                    for j in range(n_features):
+                        if excluded_set[j]:
+                            continue
+                        Xj_theta = XtA[j] / fmax(alpha, dual_norm_XtA)  # X @ dual_theta
+                        d_j = (1 - fabs(Xj_theta)) / sqrt(Q[j, j] + beta)
+                        if d_j <= radius:
+                            # include feature j
+                            active_set[n_active] = j
+                            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
+                            excluded_set[j] = 1

         else:
             # for/else, runs if for doesn't end with a `break`

Could you please check the impact of this change with benchmark?

While this PR is an improvement, I still prefer to revert #32197. The result of PR #32197 is more lines of code and one loop over features more (which is negligible).

[USER_REDACTED] Please don't feel offended or under-appreciated. It is just my (personal/professional) opinion, which I was not able to give at that PR.

Thanks [USER_REDACTED]

Why do we need to introduce `excluded_set_computed`? Cannot we just do?

```suggestion
        if excluded_set[j]:
            continue
```

This won't work. We have failing tests.

Since we initialize this to `empty` we kind of need the additional `excluded_set[j] = 0` here, otherwise we run into problems.

Keep this line


FIX: Fixed duplicate column error appearing with FeatureUnion polars

When using FeatureUnion with set_config(transform_output="polars"), the operation failed with polars.exceptions.DuplicateError because the ContainerAdapterProtocol.hstack() interface was incomplete: it didn't accept a `feature_names` parameter to apply column renaming before concatenation.

Fixes https://github.com/[USER_REDACTED]n/scikit-learn/issues/32852

@@ -0,0 +1 @@
+- Fixed :class:`pipeline.FeatureUnion` to properly handle column renaming when using Polars output, preventing duplicate column names. By :user:`[NAME_REDACTED] <leweex95>`. :pr:`32853`
\ No newline at end of file

@@ -1995,7 +1995,7 @@ def _hstack(self, Xs):

         adapter = _get_container_adapter("transform", self)
         if adapter and all(adapter.is_supported_container(X) for X in Xs):
-            return adapter.hstack(Xs)
+            return adapter.hstack(Xs, self.get_feature_names_out())

         if any(sparse.issparse(f) for f in Xs):
             return sparse.hstack(Xs).tocsr()

@@ -1850,20 +1850,22 @@ def test_pipeline_set_output_integration():
     assert_array_equal(feature_names_in_, log_reg_feature_names)

-def test_feature_union_set_output():
+[USER_REDACTED].mark.parametrize("df_library", ["pandas", "polars"])
+def test_feature_union_set_output(df_library):
     """Test feature union with set_output API."""
-    pd = pytest.importorskip("pandas")
+    lib = pytest.importorskip(df_library)

     X, _ = load_iris(as_frame=True, return_X_y=True)
     X_train, X_test = train_test_split(X, random_state=0)
     union = FeatureUnion([("scalar", StandardScaler()), ("pca", PCA())])
-    union.set_output(transform="pandas")
+    union.set_output(transform=df_library)
     union.fit(X_train)

     X_trans = union.transform(X_test)
-    assert isinstance(X_trans, pd.DataFrame)
+    assert isinstance(X_trans, lib.DataFrame)
     assert_array_equal(X_trans.columns, union.get_feature_names_out())
-    assert_array_equal(X_trans.index, X_test.index)
+    if df_library == "pandas":
+        assert_array_equal(X_trans.index, X_test.index)

 def test_feature_union_getitem():

@@ -95,14 +95,18 @@ def rename_columns(self, X, columns):
             Container with new names.
         """

-    def hstack(self, Xs):
+    def hstack(self, Xs, feature_names=None):
         """Stack containers horizontally (column-wise).

         Parameters
         ----------
         Xs : list of containers
             List of containers to stack.

+        feature_names : array-like of str, default=None
+            The feature names for the stacked container. If provided, the
+            columns of the result will be renamed to these names.
+
         Returns
         -------
         stacked_Xs : container
@@ -147,9 +151,12 @@ def rename_columns(self, X, columns):
         X.columns = columns
         return X

-    def hstack(self, Xs):
+    def hstack(self, Xs, feature_names=None):
         pd = check_library_installed("pandas")
-        return pd.concat(Xs, axis=1)
+        result = pd.concat(Xs, axis=1)
+        if feature_names is not None:
+            self.rename_columns(result, feature_names)
+        return result

 class PolarsAdapter:
@@ -178,8 +185,16 @@ def rename_columns(self, X, columns):
         X.columns = columns
         return X

-    def hstack(self, Xs):
+    def hstack(self, Xs, feature_names=None):
         pl = check_library_installed("polars")
+        if feature_names is not None:
+            # Rename columns in each X before concat to avoid duplicates
+            start = 0
+            for X in Xs:
+                n_features = X.shape[1]
+                names = feature_names[start : start + n_features]
+                self.rename_columns(X, names)
+                start += n_features
         return pl.concat(Xs, how="horizontal")

Thanks for the fix. It looks correct to me but I think the testing of pandas and polars should be decoupled:

Sorry for the late feedback. LGTM!

This looks good to me.

One small comment: for bug reports like https://github.com/[USER_REDACTED]n/scikit-learn/issues/32852 that come with a good and short reproducer we sometimes include an explicit test with the reproducer and a comment saying "non regression test for gh-32852". I think we test the relevant functionality already in `test_feature_union_set_output` (the reason it didn't catch the bug is because we didn't test with polars), but maybe it makes sense to add that non regression test

When I checkout this PR locally and run the following snippet everything works:
```python
from sklearn.pipeline import FeatureUnion
from sklearn.preprocessing import StandardScaler
from sklearn import set_config
import numpy as np
set_config(transform_output="polars")
X = np.array([[1, 2], [3, 4], [5, 6]])
union = FeatureUnion([
    ("scaler1", StandardScaler()),
    ("scaler2", StandardScaler())
])
result = union.fit_transform(X)
```

I would rather use `[USER_REDACTED].mark.parametrize(df_library, ["pandas", "polars"])` so that the full test is not marked as skipped if only one of the two libs is installed.

Thanks for the feedback, very valid point, fixed it in the new commit.


MNT Use consistent ruff version in pre-commit and linting

Noticed thanks to [USER_REDACTED] https://github.com/[USER_REDACTED]n/scikit-learn/pull/32813#pullrequestreview-3519391603

Sorry [USER_REDACTED] it would have been something you could have done indeed, but I only noticed your comment once I already did the work and was about to open the PR :sweat_smile:. Feel free to review my PR, in case you can spot something I missed.

@@ -7,6 +7,8 @@ repos:
     -   id: end-of-file-fixer
     -   id: trailing-whitespace
 -   repo: https://github.com/astral-sh/ruff-pre-commit
+    # WARNING if you update ruff version here, remember to update
+    # sklearn/_min_dependencies.py and doc .rst files mentioning ruff==<version>
     rev: v0.12.2
     hooks:
     -   id: ruff-check

@@ -15,14 +15,14 @@

 import numpy as np
 from joblib import Memory
+from sklearn.utils._openmp_helpers import _openmp_effective_n_threads

 from sklearn.datasets import fetch_openml
 from sklearn.decomposition import PCA
 from sklearn.manifold import TSNE
 from sklearn.neighbors import NearestNeighbors
 from sklearn.utils import check_array
 from sklearn.utils import shuffle as _shuffle
-from sklearn.utils._openmp_helpers import _openmp_effective_n_threads

 LOG_DIR = "mnist_tsne_output"
 if not os.path.exists(LOG_DIR):

@@ -129,7 +129,7 @@ the required packages.

             conda create -n sklearn-dev -c conda-forge ^
               python numpy scipy cython meson-python ninja ^
-              pytest pytest-cov ruff==0.11.2 mypy numpydoc ^
+              pytest pytest-cov ruff==0.12.2 mypy numpydoc ^
               joblib threadpoolctl pre-commit

           Activate the newly created conda environment:
@@ -167,7 +167,7 @@ the required packages.
           .. prompt::

             pip install wheel numpy scipy cython meson-python ninja ^
-              pytest pytest-cov ruff==0.11.2 mypy numpydoc ^
+              pytest pytest-cov ruff==0.12.2 mypy numpydoc ^
               joblib threadpoolctl pre-commit

@@ -199,7 +199,7 @@ the required packages.

             conda create -n sklearn-dev -c conda-forge python \
               numpy scipy cython meson-python ninja \
-              pytest pytest-cov ruff==0.11.2 mypy numpydoc \
+              pytest pytest-cov ruff==0.12.2 mypy numpydoc \
               joblib threadpoolctl compilers llvm-openmp pre-commit

           and activate the newly created conda environment:
@@ -244,7 +244,7 @@ the required packages.
           .. prompt::

             pip install wheel numpy scipy cython meson-python ninja \
-              pytest pytest-cov ruff==0.11.2 mypy numpydoc \
+              pytest pytest-cov ruff==0.12.2 mypy numpydoc \
               joblib threadpoolctl pre-commit

     .. tab-item:: Linux
@@ -267,7 +267,7 @@ the required packages.

             conda create -n sklearn-dev -c conda-forge python \
               numpy scipy cython meson-python ninja \
-              pytest pytest-cov ruff==0.11.2 mypy numpydoc \
+              pytest pytest-cov ruff==0.12.2 mypy numpydoc \
               joblib threadpoolctl compilers pre-commit

           and activate the newly created environment:
@@ -327,7 +327,7 @@ the required packages.
           .. prompt::

             pip install wheel numpy scipy cython meson-python ninja \
-              pytest pytest-cov ruff==0.11.2 mypy numpydoc \
+              pytest pytest-cov ruff==0.12.2 mypy numpydoc \
               joblib threadpoolctl pre-commit

No worries [USER_REDACTED], it makes sense to update everything in one go and I didn't know which version to choose (I'd be happy to learn what the heuristic is here, though). I just have two comments on possible future improvements, otherwise LGTM!

That looks good to me, but is this really defining the same version in the CI as in `pre-commit`?

I wonder if because of the `>=` a CI job would use the newest version of ruff available and only go down to until 0.12.2 if there are conflicting dependencies otherwise. (That might not be a big deal since it had worked out before, so this is just a comment).

We have tests comparing `_min_dependencies` with `readme`/`pyproject.toml`, I wonder if we should also include one to check the `pre-commit-config` for matching versions?

Small nit: To make the updating easier, we could add the ruff version here as a variable as well.

In general, I wonder, since we will also have upper bounds in the next release, do we need to specify more versions than only for ruff in the build environments?

good point, my personal opinion is that I am fine with the low-tech approach with a comment. I am not super convinced it's worth adding a test, because this affects only contributors not users. And even if the versions did not match it did not cause us any harm, basically we barely noticed..

Having said this if you think this is a fun thing to try and end up doing a PR and it's not too many lines of code to check the .pre-commit-config.yaml with our min-dependencies, I will happily review it :wink:.

True good point, let's do that!

Actually looks like you can not use substitutions inside a `.. block` (or any inline markup). I tried and could not make it work.

It seems like a [limitation of docutils](https://sourceforge.net/p/docutils/feature-requests/53/) and sphinx is based on docutils. A similar thing is https://github.com/[USER_REDACTED]nx-doc/sphinx/issues/2793. There is a work-around with parse-literal or source-read-event in https://github.com/[USER_REDACTED]nx-doc/sphinx/issues/4054#issuecomment-329066548 and below.

Maybe there is a sphinx way to do what we want, not sure but I am not going to spend more time on this :sweat_smile:.


Fix make sure enabling `array_api_dispatch=True` does not break any estimator on NumPy inputs

This is a follow-up to #32840. This should detect most possible silent breaking in future refactoring of helper functions that are used by [NAME_REDACTED] supporting and not supporting code in scikit-learn.

#### 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] Mostly bad code suggestions that I had to re-edit manually to get the code working correctly
- [ ] Test/benchmark generation
- [ ] Documentation (including examples)
- [ ] Research and understanding

@@ -0,0 +1,3 @@
+- Fixed a bug that would cause Cython-based estimators to fail when fit on
+  NumPy inputs when setting `sklearn.set_config(array_api_dispatch=True)`. By
+  :user:`[NAME_REDACTED] <ogrisel>`.

@@ -1360,7 +1360,7 @@ def fit(self, X, y=None):
         if X.shape[1] != self.dictionary.shape[1]:
             raise ValueError(
                 "Dictionary and X have different numbers of features:"
-                f"dictionary.shape: {self.dictionary.shape} X.shape{X.shape}"
+                f"dictionary.shape: {self.dictionary.shape} X.shape: {X.shape}"
             )
         return self

@@ -23,6 +23,11 @@
 __all__ = ["xpx"]  # we import xpx here just to re-export it, need this to appease ruff

 _NUMPY_NAMESPACE_NAMES = {"numpy", "sklearn.externals.array_api_compat.numpy"}
+REMOVE_TYPES_DEFAULT = (
+    str,
+    list,
+    tuple,
+)

 def yield_namespaces(include_numpy_namespaces=True):
@@ -167,7 +172,7 @@ def _single_array_device(array):
         return array.device

-def device(*array_list, remove_none=True, remove_types=(str,)):
+def device(*array_list, remove_none=True, remove_types=REMOVE_TYPES_DEFAULT):
     """Hardware device where the array data resides on.

     If the hardware device is not the same for all arrays, an error is raised.
@@ -180,7 +185,7 @@ def device(*array_list, remove_none=True, remove_types=(str,)):
     remove_none : bool, default=True
         Whether to ignore None objects passed in array_list.

-    remove_types : tuple or list, default=(str,)
+    remove_types : tuple or list, default=(str, list, tuple)
         Types to ignore in array_list.

     Returns
@@ -290,7 +295,7 @@ def supported_float_dtypes(xp, device=None):
     return tuple(valid_float_dtypes)

-def _remove_non_arrays(*arrays, remove_none=True, remove_types=(str,)):
+def _remove_non_arrays(*arrays, remove_none=True, remove_types=REMOVE_TYPES_DEFAULT):
     """Filter arrays to exclude None and/or specific types.

     Sparse arrays are always filtered out.
@@ -303,7 +308,7 @@ def _remove_non_arrays(*arrays, remove_none=True, remove_types=(str,)):
     remove_none : bool, default=True
         Whether to ignore None objects passed in arrays.

-    remove_types : tuple or list, default=(str,)
+    remove_types : tuple or list, default=(str, list, tuple)
         Types to ignore in the arrays.

     Returns
@@ -328,7 +333,27 @@ def _remove_non_arrays(*arrays, remove_none=True, remove_types=(str,)):
     return filtered_arrays

-def get_namespace(*arrays, remove_none=True, remove_types=(str,), xp=None):
+def _unwrap_memoryviewslices(*arrays):
+    # Since _cyutility._memoryviewslice is an implementation detail of the
+    # Cython runtime, we would rather not introduce a possibly brittle
+    # import statement to run `isinstance`-based filtering, hence the
+    # attribute-based type inspection.
+    unwrapped = []
+    for a in arrays:
+        a_type = type(a)
+        if (
+            a_type.__module__ == "_cyutility"
+            and a_type.__name__ == "_memoryviewslice"
+            and hasattr(a, "base")
+        ):
+            a = a.base
+        unwrapped.append(a)
+    return unwrapped
+
+
+def get_namespace(
+    *arrays, remove_none=True, remove_types=REMOVE_TYPES_DEFAULT, xp=None
+):
     """Get namespace of arrays.

     Introspect `arrays` arguments and return their common Array API compatible
@@ -364,7 +389,7 @@ def get_namespace(*arrays, remove_none=True, remove_types=(str,), xp=None):
     remove_none : bool, default=True
         Whether to ignore None objects passed in arrays.

-    remove_types : tuple or list, default=(str,)
+    remove_types : tuple or list, default=(str, list, tuple)
         Types to ignore in the arrays.

     xp : module, default=None
@@ -399,12 +424,19 @@ def get_namespace(*arrays, remove_none=True, remove_types=(str,), xp=None):
         remove_types=remove_types,
     )

+    # get_namespace can be called by [NAME_REDACTED] used both in
+    # array API compatible code and non-array API Cython related code. To
+    # support the latter on NumPy inputs without raising a TypeError, we
+    # unwrap potential Cython memoryview slices here.
+    arrays = _unwrap_memoryviewslices(*arrays)
+
     if not arrays:
         return np_compat, False

     _check_array_api_dispatch(array_api_dispatch)

-    namespace, is_array_api_compliant = array_api_compat.get_namespace(*arrays), True
+    namespace = array_api_compat.get_namespace(*arrays)
+    is_array_api_compliant = True

     if namespace.__name__ == "array_api_strict" and hasattr(
         namespace, "set_array_api_strict_flags"
@@ -415,7 +447,7 @@ def get_namespace(*arrays, remove_none=True, remove_types=(str,), xp=None):

 def get_namespace_and_device(
-    *array_list, remove_none=True, remove_types=(str,), xp=None
+    *array_list, remove_none=True, remove_types=REMOVE_TYPES_DEFAULT, xp=None
 ):
     """Combination into one single function of `get_namespace` and `device`.

@@ -425,7 +457,7 @@ def get_namespace_and_device(
         Array objects.
     remove_none : bool, default=True
         Whether to ignore None objects passed in arrays.
-    remove_types : tuple or list, default=(str,)
+    remove_types : tuple or list, default=(str, list, tuple)
         Types to ignore in the arrays.
     xp : module, default=None
         Precomputed array namespace module. When passed, typically from a caller

@@ -46,7 +46,10 @@
     SparsePCA,
     TruncatedSVD,
 )
-from sklearn.discriminant_analysis import LinearDiscriminantAnalysis
+from sklearn.discriminant_analysis import (
+    LinearDiscriminantAnalysis,
+    QuadraticDiscriminantAnalysis,
+)
 from sklearn.dummy import DummyClassifier
 from sklearn.ensemble import (
     AdaBoostClassifier,
@@ -79,6 +82,7 @@
     SequentialFeatureSelector,
 )
 from sklearn.frozen import FrozenEstimator
+from sklearn.impute import SimpleImputer
 from sklearn.kernel_approximation import (
     Nystroem,
     PolynomialCountSketch,
@@ -559,11 +563,16 @@
             dict(solver="lbfgs"),
         ],
     },
-    GaussianMixture: {"check_dict_unchanged": dict(max_iter=5, n_init=2)},
+    GaussianMixture: {
+        "check_dict_unchanged": dict(max_iter=5, n_init=2),
+        "check_array_api_input": dict(
+            max_iter=5, n_init=2, init_params="random_from_data"
+        ),
+    },
     GaussianRandomProjection: {"check_dict_unchanged": dict(n_components=1)},
+    GraphicalLasso: {"check_array_api_input": dict(max_iter=5, alpha=1.0)},
     IncrementalPCA: {"check_dict_unchanged": dict(batch_size=10, n_components=1)},
     Isomap: {"check_dict_unchanged": dict(n_components=1)},
-    KMeans: {"check_dict_unchanged": dict(max_iter=5, n_clusters=1, n_init=2)},
     # TODO(1.9) simplify when averaged_inverted_cdf is the default
     KBinsDiscretizer: {
         "check_sample_weight_equivalence_on_dense_data": [
@@ -595,7 +604,11 @@
             strategy="quantile", quantile_method="averaged_inverted_cdf"
         ),
     },
-    KernelPCA: {"check_dict_unchanged": dict(n_components=1)},
+    KernelPCA: {
+        "check_dict_unchanged": dict(n_components=1),
+        "check_array_api_input": dict(fit_inverse_transform=True),
+    },
+    KMeans: {"check_dict_unchanged": dict(max_iter=5, n_clusters=1, n_init=2)},
     LassoLars: {"check_non_transformer_estimators_n_iter": dict(alpha=0.0)},
     LatentDirichletAllocation: {
         "check_dict_unchanged": dict(batch_size=10, max_iter=5, n_components=1)
@@ -693,6 +706,7 @@
             dict(solver="highs-ipm"),
         ],
     },
+    QuadraticDiscriminantAnalysis: {"check_array_api_input": dict(reg_param=1.0)},
     RBFSampler: {"check_dict_unchanged": dict(n_components=1)},
     Ridge: {
         "check_sample_weight_equivalence_on_dense_data": [
@@ -720,7 +734,9 @@
         ],
     },
     SkewedChi2Sampler: {"check_dict_unchanged": dict(n_components=1)},
+    SimpleImputer: {"check_array_api_input": dict(add_indicator=True)},
     SparseCoder: {
+        "check_array_api_input": dict(dictionary=rng.normal(size=(5, 10))),
         "check_estimators_dtypes": dict(dictionary=rng.normal(size=(5, 5))),
         "check_dtype_object": dict(dictionary=rng.normal(size=(5, 10))),
         "check_transformers_unfitted_stateless": dict(

@@ -196,9 +196,11 @@ def _yield_checks(estimator):
     yield check_estimators_pickle
     yield partial(check_estimators_pickle, readonly_memmap=True)

-    if tags.array_api_support:
-        for check in _yield_array_api_checks(estimator):
-            yield check
+    for check in _yield_array_api_checks(
+        estimator,
+        only_numpy=not tags.array_api_support,
+    ):
+        yield check

     yield check_f_contiguous_array_estimator

@@ -336,18 +338,30 @@ def _yield_outliers_checks(estimator):
     yield check_non_transformer_estimators_n_iter

-def _yield_array_api_checks(estimator):
-    for (
-        array_namespace,
-        device,
-        dtype_name,
-    ) in yield_namespace_device_dtype_combinations():
+def _yield_array_api_checks(estimator, only_numpy=False):
+    if only_numpy:
+        # Enabling array API dispatch and using NumPy inputs should not
+        # change results, even if the estimator does not explicitly support
+        # array API.
         yield partial(
             check_array_api_input,
-            array_namespace=array_namespace,
-            dtype_name=dtype_name,
-            device=device,
+            array_namespace="numpy",
+            expect_only_array_outputs=False,
         )
+    else:
+        # These extended checks should pass for all estimators that declare
+        # array API support in their tags.
+        for (
+            array_namespace,
+            device,
+            dtype_name,
+        ) in yield_namespace_device_dtype_combinations():
+            yield partial(
+                check_array_api_input,
+                array_namespace=array_namespace,
+                dtype_name=dtype_name,
+                device=device,
+            )

 def _yield_all_checks(estimator, legacy: bool):
@@ -1048,6 +1062,7 @@ def check_array_api_input(
     dtype_name="float64",
     check_values=False,
     check_sample_weight=False,
+    expect_only_array_outputs=True,
 ):
     """Check that the estimator can work consistently with the Array API

@@ -1057,17 +1072,25 @@ def check_array_api_input(
     When check_values is True, it also checks that calling the estimator on the
     array_api Array gives the same results as ndarrays.

-    When sample_weight is True, dummy sample weights are passed to the fit call.
+    When check_sample_weight is True, dummy sample weights are passed to the
+    fit call.
+
+    When expect_only_array_outputs is False, the check is looser: in particular
+    it accepts non-array outputs such as sparse data structures. This is
+    useful to test that enabling array API dispatch does not change the
+    behavior of any estimator fed with NumPy inputs, even for estimators that
+    do not support array API.
     """
     xp = _array_api_for_tests(array_namespace, device)

-    X, y = make_classification(random_state=42)
+    X, y = make_classification(n_samples=30, n_features=10, random_state=42)
     X = X.astype(dtype_name, copy=False)

     X = _enforce_estimator_tags_X(estimator_orig, X)
     y = _enforce_estimator_tags_y(estimator_orig, y)

     est = clone(estimator_orig)
+    set_random_state(est)

     X_xp = xp.asarray(X, device=device)
     y_xp = xp.asarray(y, device=device)
@@ -1193,47 +1216,48 @@ def check_array_api_input(
             f"got {result_ns}."
         )

-        with config_context(array_api_dispatch=True):
-            assert array_device(result_xp) == array_device(X_xp)
-
-        result_xp_np = _convert_to_numpy(result_xp, xp=xp)
+        if expect_only_array_outputs:
+            with config_context(array_api_dispatch=True):
+                assert array_device(result_xp) == array_device(X_xp)

-        if check_values:
-            assert_allclose(
-                result,
-                result_xp_np,
-                err_msg=f"{method} did not the return the same result",
-                atol=_atol_for_type(X.dtype),
-            )
-        else:
-            if hasattr(result, "shape"):
+            result_xp_np = _convert_to_numpy(result_xp, xp=xp)
+            if check_values:
+                assert_allclose(
+                    result,
+                    result_xp_np,
+                    err_msg=f"{method} did not the return the same result",
+                    atol=_atol_for_type(X.dtype),
+                )
+            elif hasattr(result, "shape"):
                 assert result.shape == result_xp_np.shape
                 assert result.dtype == result_xp_np.dtype

         if method_name == "transform" and hasattr(est, "inverse_transform"):
             inverse_result = est.inverse_transform(result)
             with config_context(array_api_dispatch=True):
-                invese_result_xp = est_xp.inverse_transform(result_xp)
-                inverse_result_ns = get_namespace(invese_result_xp)[0].__name__
-            assert inverse_result_ns == input_ns, (
-                "'inverse_transform' output is in wrong namespace, expected"
-                f" {input_ns}, got {inverse_result_ns}."
-            )
-
-            with config_context(array_api_dispatch=True):
-                assert array_device(invese_result_xp) == array_device(X_xp)
-
-            invese_result_xp_np = _convert_to_numpy(invese_result_xp, xp=xp)
-            if check_values:
-                assert_allclose(
-                    inverse_result,
-                    invese_result_xp_np,
-                    err_msg="inverse_transform did not the return the same result",
-                    atol=_atol_for_type(X.dtype),
+                inverse_result_xp = est_xp.inverse_transform(result_xp)
+
+            if expect_only_array_outputs:
+                with config_context(array_api_dispatch=True):
+                    inverse_result_ns = get_namespace(inverse_result_xp)[0].__name__
+                assert inverse_result_ns == input_ns, (
+                    "'inverse_transform' output is in wrong namespace, expected"
+                    f" {input_ns}, got {inverse_result_ns}."
                 )
-            else:
-                assert inverse_result.shape == invese_result_xp_np.shape
-                assert inverse_result.dtype == invese_result_xp_np.dtype
+                with config_context(array_api_dispatch=True):
+                    assert array_device(result_xp) == array_device(X_xp)
+
+                inverse_result_xp_np = _convert_to_numpy(inverse_result_xp, xp=xp)
+                if check_values:
+                    assert_allclose(
+                        inverse_result,
+                        inverse_result_xp_np,
+                        err_msg="inverse_transform did not the return the same result",
+                        atol=_atol_for_type(X.dtype),
+                    )
+                elif hasattr(result, "shape"):
+                    assert inverse_result.shape == inverse_result_xp_np.shape
+                    assert inverse_result.dtype == inverse_result_xp_np.dtype

 def check_array_api_input_and_values(

@@ -50,8 +50,8 @@
 from sklearn.utils.fixes import _IS_32BIT, CSR_CONTAINERS, np_version, parse_version

-[USER_REDACTED].mark.parametrize("X", [numpy.asarray([1, 2, 3]), [1, 2, 3]])
-def test_get_namespace_ndarray_default(X):
+[USER_REDACTED].mark.parametrize("X", [numpy.asarray([1, 2, 3]), [1, 2, 3], (1, 2, 3)])
+def test_get_namespace_ndarray_or_similar_default(X):
     """Check that get_namespace returns NumPy wrapper"""
     xp_out, is_array_api_compliant = get_namespace(X)
     assert xp_out is np_compat
@@ -71,14 +71,13 @@ def test_get_namespace_ndarray_creation_device():

 [USER_REDACTED]_if_array_api_compat_not_configured
-def test_get_namespace_ndarray_with_dispatch():
+[USER_REDACTED].mark.parametrize("X", [numpy.asarray([1, 2, 3]), [1, 2, 3], (1, 2, 3)])
+def test_get_namespace_ndarray_or_similar_default_with_dispatch(X):
     """Test get_namespace on NumPy ndarrays."""

-    X_np = numpy.asarray([[1, 2, 3]])
-
     with config_context(array_api_dispatch=True):
-        xp_out, is_array_api_compliant = get_namespace(X_np)
-        assert is_array_api_compliant
+        xp_out, is_array_api_compliant = get_namespace(X)
+        assert is_array_api_compliant == isinstance(X, numpy.ndarray)

         # In the future, NumPy should become API compliant library and we should have
         # assert xp_out is numpy

Thank you for the PR [USER_REDACTED]

LGTM. Thank you [USER_REDACTED]

LGTM, thanks for the other fixes here. Nits only

One tweak to a comment, for me it made it easier to understand. Otherwise LGTM, let's merge

Note to reviewers: I considered adding `_cyutility._memoryviewslice` to the list of `removed_types` but since it's a private internal detail of Cython, I thought it would be too brittle and went for a more direct handling of unexpected types in `get_namespace` inputs.

Actually, the try/except approach is conflicting with our test that except `TypeError` on mixed namespaces. Let me iterate.

I think there are around 15 estimators that would have failed the updated common test. However, I would need to add the common test to the 1.7.X branch to know the list of estimators that are fixed by [NAME_REDACTED] to 1.8. I am a bit lazy to do that.

I think we could make the motivation behind this hackery a bit more explicit with an inline comment. Let's wait for the CI to complete before accepting this suggestion.

```suggestion
def _unwrap_memoryviewslices(*arrays):
    # Since _cyutility._memoryviewslice is an implementation detail of the
    # Cython runtime, we would rather not introduce a possibly brittle
    # import statement to run `isinstance`-based filtering, hence the
    # attribute-based type inspection.
```

I've added the tests to the `1.7.X` branch. These are the failures:

<details>
<summary>[NAME_REDACTED]</summary>

```bash
FAILED sklearn/decomposition/tests/test_dict_learning.py::test_max_iter - Failed: DID NOT WARN. No warnings of type (<class 'sklearn.exceptions.ConvergenceWarning'>,) were emitted.
 Emitted warnings: [].

FAILED sklearn/decomposition/tests/test_dict_learning.py::test_sparse_coder_estimator - AttributeError: property 'n_features_in_' of 'SparseCoder' object has no setter

FAILED sklearn/decomposition/tests/test_dict_learning.py::test_sparse_coder_parallel_mmap - AttributeError: property 'n_features_in_' of 'SparseCoder' object has no setter

FAILED sklearn/decomposition/tests/test_dict_learning.py::test_sparse_coder_common_transformer - AttributeError: property 'n_features_in_' of 'SparseCoder' object has no setter

FAILED sklearn/decomposition/tests/test_dict_learning.py::test_get_feature_names_out[SparseCoder] - AttributeError: property 'n_features_in_' of 'SparseCoder' object has no setter

FAILED sklearn/tests/test_common.py::test_estimators[GenericUnivariateSelect()-check_array_api_input(array_namespace=numpy,expect_only_array_outputs=False)] - ValueError: X has a different shape than during fitting.

FAILED sklearn/tests/test_common.py::test_estimators[RFE(estimator=LogisticRegression(C=1))-check_array_api_input(array_namespace=numpy,expect_only_array_outputs=False)] - ValueError: X has 10 features, but LogisticRegression is expecting 5 features as input.

FAILED sklearn/tests/test_common.py::test_estimators[RFECV(cv=3,estimator=LogisticRegression(C=1))-check_array_api_input(array_namespace=numpy,expect_only_array_outputs=False)] - ValueError: X has a different shape than during fitting.

FAILED sklearn/tests/test_common.py::test_estimators[SelectFdr(alpha=0.5)-check_array_api_input(array_namespace=numpy,expect_only_array_outputs=False)] - ValueError: X has a different shape than during fitting.

FAILED sklearn/tests/test_common.py::test_estimators[SelectFpr()-check_array_api_input(array_namespace=numpy,expect_only_array_outputs=False)] - ValueError: X has a different shape than during fitting.

FAILED sklearn/tests/test_common.py::test_estimators[SelectFromModel(estimator=SGDRegressor(random_state=0))-check_array_api_input(array_namespace=numpy,expect_only_array_outputs=False)] - ValueError: X has a different shape than during fitting.

FAILED sklearn/tests/test_common.py::test_estimators[SelectFwe()-check_array_api_input(array_namespace=numpy,expect_only_array_outputs=False)] - ValueError: X has a different shape than during fitting.

FAILED sklearn/tests/test_common.py::test_estimators[SelectKBest(k=1)-check_array_api_input(array_namespace=numpy,expect_only_array_outputs=False)] - ValueError: X has a different shape than during fitting.

FAILED sklearn/tests/test_common.py::test_estimators[SelectPercentile()-check_array_api_input(array_namespace=numpy,expect_only_array_outputs=False)] - ValueError: X has a different shape than during fitting.

FAILED sklearn/tests/test_common.py::test_estimators[SequentialFeatureSelector(cv=3,estimator=LogisticRegression(C=1))-check_array_api_input(array_namespace=numpy,expect_only_array_outputs=False)] - ValueError: X has a different shape than during fitting.

FAILED sklearn/tests/test_common.py::test_estimators[TransformedTargetRegressor()-check_array_api_input(array_namespace=numpy,expect_only_array_outputs=False)] - TypeError: int() argument must be a string, a bytes-like object or a real number, not 'slice'

FAILED sklearn/tests/test_docstring_parameters.py::test_fit_docstring_attributes[SparseCoder-SparseCoder] - AttributeError: property 'n_features_in_' of 'SparseCoder' object has no setter

FAILED sklearn/utils/tests/test_estimator_checks.py::test_estimator_with_set_output - TypeError: DataFrame is not a supported array type
```
</details>

I didn't check every random seed. My local branch is [here](https://github.com/[USER_REDACTED]n/scikit-learn/tree/1.7.X_32846).

Does this help?

```suggestion
    # unwrap potential Cython memoryview slices here.
```

How come the above checks are present within
```
if expect_only_array_outputs:
```
But these aren't?

Thanks!

> Does this help?

I am not sure. I have the feeling that many of those failures are a bit unrelated.

If people don't mind, I think we can live with a terse changelog entry for this fix.

I thought about it and I don't think we need to put this assertion under the `if expect_only_array_outputs` condition. Let's consider the following patter:

```python
X_trans = est.fit_transform(X_input, y_input)
X_inverse = est.inverse_transform(X_trans)
```

In this test, `X_input` is always an array, but `X_trans` could be a sparse matrix (hence the need for `expect_only_array_outputs` when anything about `X_trans`).

Since `X_input` is always an array in this test, I think we can assume that `X_inverse` has always a similar dense container because it mathematically lives in the same space as `X_input`, even if `X_trans` requires a sparse datastructure (because it leaves in a higher dimensional and sparse representation space).

Also: the CI is green, so I guess this assumption is correct empirically so far in scikit-learn.

But since estimator checks can be used by [NAME_REDACTED], maybe we don't want to impose this somewhat arbitrary expected rule to hold for other libraries?

Let me update the PR to relax this.

Done in bb4702b.

I think the change log seems okay.

```suggestion
    remove_types : tuple or list, default=(str, list, tuple)
```

nit, to match test above
```suggestion
def test_get_namespace_ndarray_or_similar_default_with_dispatch(X):
```

Could you expand on this? I was confused just reading this, but https://github.com/[USER_REDACTED]n/scikit-learn/pull/32846/changes#r2602981393 cleared it up for me.

I think you need a `config_context` here otherwise this assert statement will always pass, checking that None == None. This was noted some time ago by [NAME_REDACTED]://github.com/scikit-learn/scikit-learn/pull/31814.

Maybe we should do something about it because it's easy to fall into this trap. One potential idea was to add an `assert_same_device` that uses `array_api_compat.device` and does not depend on `config_context(array_api_dispatch=True)` (and a similar assert function like `assert_same_namespace`). Also maybe it would be nice to have a better name than device for our config_context-dependent device function since I find the clash with `array_api_compat.device` a tiny bit confusing.

```suggestion
                with config_context(array_api_dispatch=True):
                    assert array_device(result_xp) == array_device(X_xp)
```

```suggestion
    When expect_only_array_outputs is False, the check is looser: in particular
    it accepts non-array outputs such as sparse data structures. This is
    useful to test that enabling array API dispatch does not change the
    behavior of any estimator fed with NumPy inputs, even for estimators that
    do not support array API.
```

I pushed 969f06a as an attempt to clarify this.

```suggestion
    useful to test that enabling array API dispatch does not change the
```

Thanks! Feel free to merge when you're ready!

```suggestion
        # Enabling array API dispatch and using NumPy inputs should not
        # change results, even if the estimator does not explicitly support
        # array API.
```

I think the suggested changes in the comment make sense.


ENH/FIX: make coordinate descent solver converge for ridge regression

#### [NAME_REDACTED]/PRs
Warnings for `alpha=0` / `l1_ratio=0` where introduced in #620. A note that Enet with dual gap as stopping criterion does not converge was mentioned in https://github.com/[USER_REDACTED]n/scikit-learn/pull/19620#issue-822947581.

#### What does this implement/fix? Explain your changes.
This PR makes the coordinate descent solver of `Lasso` and `ElasticNet` etc. converge for Ridge regression.

##### [NAME_REDACTED] prevents convergence of the CD solver in the main branch is that the dual gap is not valid for `alpha=0` / `l1_ratio=0`. Fortunately, there is an alternative dual gap available, see #22836.

#### Any other comments?
- There are some surprising results, see the new `test_enet_ols_consistency`.
- `test_coordinate_descent.py` now passes without warnings (`pytest -We sklearn/linear_model/tests/test_coordinate_descent.py`).

@@ -0,0 +1,7 @@
+- :class:`linear_model.ElasticNet`, :class:`linear_model.ElasticNetCV` and
+  :func:`linear_model.enet_path`
+  now are able to fit Ridge regression, i.e. setting `l1_ratio=0`.
+  Before this PR, the stopping criterion was a formulation of the dual gap that breaks
+  down for `l1_ratio=0`. Now, an alternative dual gap formulation is used for this
+  setting. This reduces the noise of raised warnings.
+  By :user:`[NAME_REDACTED] <lorentzenchr>`.

@@ -98,6 +98,30 @@ message_ridge = (
 )

+cdef inline floating dual_gap_formulation_A(
+    floating alpha,  # L1 penalty
+    floating beta,  # L1 penalty
+    floating w_l1_norm,
+    floating w_l2_norm2,
+    floating R_norm2,  # R @ R
+    floating Ry,  # R @ y
+    floating dual_norm_XtA,
+) noexcept nogil:
+    """Compute dual gap according to formulation A."""
+    cdef floating gap, primal, dual
+    cdef floating scale  # Scaling factor to achieve dual feasible point.
+
+    primal = 0.5 * (R_norm2 + beta * w_l2_norm2) + alpha * w_l1_norm
+
+    if (dual_norm_XtA > alpha):
+        scale = alpha / dual_norm_XtA
+    else:
+        scale = 1.0
+    dual = -0.5 * (scale ** 2) * (R_norm2 + beta * w_l2_norm2) + scale * Ry
+    gap = primal - dual
+    return gap
+
+
 cdef (floating, floating) gap_enet(
     int n_samples,
     int n_features,
@@ -110,47 +134,71 @@ cdef (floating, floating) gap_enet(
     floating[::1] XtA,  # XtA = X.T @ R - beta * w is calculated inplace
     bint positive,
 ) noexcept nogil:
-    """Compute dual gap for use in enet_coordinate_descent."""
+    """Compute dual gap for use in enet_coordinate_descent.
+
+    alpha > 0:            formulation A of the duality gap
+    alpha = 0 & beta > 0: formulation B of the duality gap
+    alpha = beta = 0:     OLS first order condition (=gradient)
+    """
     cdef floating gap = 0.0
     cdef floating dual_norm_XtA
     cdef floating R_norm2
-    cdef floating w_norm2 = 0.0
-    cdef floating l1_norm
-    cdef floating A_norm2
-    cdef floating const_
+    cdef floating Ry
+    cdef floating w_l1_norm
+    cdef floating w_l2_norm2 = 0.0
+
+    # w_l2_norm2 = w @ w
+    if beta > 0:
+        w_l2_norm2 = _dot(n_features, &w[0], 1, &w[0], 1)
+    # R_norm2 = R @ R
+    R_norm2 = _dot(n_samples, &R[0], 1, &R[0], 1)
+    # Ry = R @ y
+    if not (alpha == 0 and beta == 0):
+        Ry = _dot(n_samples, &R[0], 1, &y[0], 1)
+
+    if alpha == 0:
+        # XtA = X.T @ R
+        _gemv(
+            ColMajor, Trans, n_samples, n_features, 1.0, &X[0, 0],
+            n_samples, &R[0], 1, 0, &XtA[0], 1,
+        )
+        # ||X'R||_2^2
+        dual_norm_XtA = _dot(n_features, &XtA[0], 1, &XtA[0], 1)
+        if beta == 0:
+            # This is OLS, no dual gap available. Resort to first order condition
+            #     X'R = 0
+            #     gap = ||X'R||_2^2
+            # Compare with stopping criterion of LSQR.
+            gap = dual_norm_XtA
+            return gap, dual_norm_XtA
+        # This is Ridge regression, we use formulation B for the dual gap.
+        gap = R_norm2 + 0.5 * beta * w_l2_norm2 - Ry
+        gap += 1 / (2 * beta) * dual_norm_XtA
+        return gap, dual_norm_XtA

     # XtA = X.T @ R - beta * w
     _copy(n_features, &w[0], 1, &XtA[0], 1)
     _gemv(ColMajor, Trans, n_samples, n_features, 1.0, &X[0, 0],
           n_samples, &R[0], 1,
           -beta, &XtA[0], 1)

+    # dual_norm_XtA
     if positive:
         dual_norm_XtA = max(n_features, &XtA[0])
     else:
         dual_norm_XtA = abs_max(n_features, &XtA[0])

-    # R_norm2 = R @ R
-    R_norm2 = _dot(n_samples, &R[0], 1, &R[0], 1)
-
-    # w_norm2 = w @ w
-    if beta > 0:
-        w_norm2 = _dot(n_features, &w[0], 1, &w[0], 1)
-
-    if (dual_norm_XtA > alpha):
-        const_ = alpha / dual_norm_XtA
-        A_norm2 = R_norm2 * (const_ ** 2)
-        gap = 0.5 * (R_norm2 + A_norm2)
-    else:
-        const_ = 1.0
-        gap = R_norm2
-
-    l1_norm = _asum(n_features, &w[0], 1)
-
-    gap += (
-        alpha * l1_norm
-        - const_ * _dot(n_samples, &R[0], 1, &y[0], 1)  # R @ y
-        + 0.5 * beta * (1 + const_ ** 2) * w_norm2
+    # w_l1_norm = np.sum(np.abs(w))
+    w_l1_norm = _asum(n_features, &w[0], 1)
+
+    gap = dual_gap_formulation_A(
+        alpha=alpha,
+        beta=beta,
+        w_l1_norm=w_l1_norm,
+        w_l2_norm2=w_l2_norm2,
+        R_norm2=R_norm2,
+        Ry=Ry,
+        dual_norm_XtA=dual_norm_XtA,
     )
     return gap, dual_norm_XtA

@@ -178,31 +226,49 @@ def enet_coordinate_descent(

     The dual for beta = 0, see e.g. [Fercoq 2015] with v = alpha * theta, is

-        D(v) = -1/2 ||v||_2^2 + y v
+        D(v) = -1/2 ||v||_2^2 + y' v    (formulation A)

     with dual feasible condition ||X^T v||_inf <= alpha.
     For beta > 0, one uses extended versions of X and y by adding n_features rows

         X -> (           X)    y -> (y)
              (sqrt(beta) I)         (0)

-    Note that the residual y - X w is an important ingredient for the estimation of a
-    dual feasible point v.
+    Note that the residual R = y - X w is an important ingredient for the estimation of
+    a dual feasible point v.
     At optimum of primal w* and dual v*, one has

-        v = y* - X w*
+        v* = y - X w*

     The duality gap is

         G(w, v) = P(w) - D(v) <= P(w) - P(w*)

+    Strong duality holds: G(w*, v*) = 0.
+    For testing convergence, one uses G(w, v) with current w and uses
+
+        v = R                            if ||X^T R||_inf <= alpha
+        v = R * alpha / ||X^T R||_inf    else
+
     The final stopping criterion is based on the duality gap

         tol ||y||_2^2 <= G(w, v)

     The tolerance here is multiplied by ||y||_2^2 to have an inequality that scales the
     same on both sides and because one has G(0, 0) = 1/2 ||y||_2^2.

+    Note:
+    The above dual D(v) and duality gap G require alpha > 0 because of the dual
+    feasible condition.
+    There is, however, an alternative dual formulation, see [Dünner 2016] 5.2.3 and
+    https://github.com/[USER_REDACTED]n/scikit-learn/issues/22836:
+
+        D(v) = -1/2 ||v||_2^2 + y' v
+               -1/(2 beta) sum_j (|X_j' v| - alpha)_+^2    (formulation B)
+
+    The dual feasible set is v element real numbers. It requires beta > 0, but
+    alpha = 0 is allowed. Strong duality holds and at optimum, v* = y - X w*.
+
     Returns
     -------
     w : ndarray of shape (n_features,)
@@ -225,6 +291,11 @@ def enet_coordinate_descent(
        [NAME_REDACTED], [NAME_REDACTED], [NAME_REDACTED]. (2015)
        Mind the duality gap: safer rules for the Lasso
        https://arxiv.org/abs/1505.03410
+
+    .. [Dünner 2016]
+       Celestine Dünner, [NAME_REDACTED], Martin Takác, [NAME_REDACTED]. (2016).
+       [NAME_REDACTED] and Certificates. In ICML 2016.
+       https://arxiv.org/abs/1602.05205
     """

     if floating is float:
@@ -266,9 +337,9 @@ def enet_coordinate_descent(
     cdef uint32_t rand_r_state_seed = rng.randint(0, RAND_R_MAX)
     cdef uint32_t* rand_r_state = &rand_r_state_seed

-    if alpha == 0 and beta == 0:
-        warnings.warn("Coordinate descent with no regularization may lead to "
-                      "unexpected results and is discouraged.")
+    if alpha == 0:
+        # No screeing without L1-penalty.
+        do_screening = False

     if do_screening:
         active_set = np.empty(n_features, dtype=np.uint32)  # map [:n_active] -> j
@@ -387,7 +458,7 @@ def enet_coordinate_descent(
             with gil:
                 message = (
                     message_conv +
-                    f" Duality gap: {gap:.3e}, tolerance: {tol:.3e}"
+                    f" Duality gap: {gap:.6e}, tolerance: {tol:.3e}"
                 )
                 if alpha < np.finfo(np.float64).eps:
                     message += "\n" + message_ridge
@@ -448,16 +519,61 @@ cdef (floating, floating) gap_enet_sparse(
     floating[::1] XtA,  # XtA = X.T @ R - beta * w is calculated inplace
     bint positive,
 ) noexcept nogil:
-    """Compute dual gap for use in sparse_enet_coordinate_descent."""
+    """Compute dual gap for use in sparse_enet_coordinate_descent.
+
+    alpha > 0:            formulation A of the duality gap
+    alpha = 0 & beta > 0: formulation B of the duality gap
+    alpha = beta = 0:     OLS first order condition (=gradient)
+    """
     cdef floating gap = 0.0
     cdef floating dual_norm_XtA
     cdef floating R_norm2
-    cdef floating w_norm2 = 0.0
-    cdef floating l1_norm
-    cdef floating A_norm2
-    cdef floating const_
+    cdef floating Ry
+    cdef floating w_l1_norm
+    cdef floating w_l2_norm2 = 0.0
     cdef unsigned int i, j

+    # w_l2_norm2 = w @ w
+    if beta > 0:
+        w_l2_norm2 = _dot(n_features, &w[0], 1, &w[0], 1)
+    # R_norm2 = R @ R
+    if no_sample_weights:
+        R_norm2 = _dot(n_samples, &R[0], 1, &R[0], 1)
+    else:
+        R_norm2 = 0.0
+        for i in range(n_samples):
+            # R is already multiplied by sample_weight
+            if sample_weight[i] != 0:
+                R_norm2 += (R[i] ** 2) / sample_weight[i]
+    # Ry = R @ y
+    if not (alpha == 0 and beta == 0):
+        # Note that with sample_weight, R equals R*sw and y is just y, such that
+        # Ry = (sw * R) @ y, as it should be.
+        Ry = _dot(n_samples, &R[0], 1, &y[0], 1)
+
+    if alpha == 0:
+        # XtA = X.T @ R
+        for j in range(n_features):
+            XtA[j] = 0.0
+            for i in range(X_indptr[j], X_indptr[j + 1]):
+                XtA[j] += X_data[i] * R[X_indices[i]]
+
+            if center:
+                XtA[j] -= X_mean[j] * R_sum
+        # ||X'R||_2^2
+        dual_norm_XtA = _dot(n_features, &XtA[0], 1, &XtA[0], 1)
+        if beta == 0:
+            # This is OLS, no dual gap available. Resort to first order condition
+            #     X'R = 0
+            #     gap = ||X'R||_2^2
+            # Compare with stopping criterion of LSQR.
+            gap = dual_norm_XtA
+            return gap, dual_norm_XtA
+        # This is Ridge regression, we use formulation B for the dual gap.
+        gap = R_norm2 + 0.5 * beta * w_l2_norm2 - Ry
+        gap += 1 / (2 * beta) * dual_norm_XtA
+        return gap, dual_norm_XtA
+
     # XtA = X.T @ R - beta * w
     # sparse X.T @ dense R
     for j in range(n_features):
@@ -469,39 +585,23 @@ cdef (floating, floating) gap_enet_sparse(
             XtA[j] -= X_mean[j] * R_sum
         XtA[j] -= beta * w[j]

+    # dual_norm_XtA
     if positive:
         dual_norm_XtA = max(n_features, &XtA[0])
     else:
         dual_norm_XtA = abs_max(n_features, &XtA[0])

-    # R_norm2 = R @ R
-    if no_sample_weights:
-        R_norm2 = _dot(n_samples, &R[0], 1, &R[0], 1)
-    else:
-        R_norm2 = 0.0
-        for i in range(n_samples):
-            # R is already multiplied by sample_weight
-            if sample_weight[i] != 0:
-                R_norm2 += (R[i] ** 2) / sample_weight[i]
-
-    # w_norm2 = w @ w
-    if beta > 0:
-        w_norm2 = _dot(n_features, &w[0], 1, &w[0], 1)
-
-    if (dual_norm_XtA > alpha):
-        const_ = alpha / dual_norm_XtA
-        A_norm2 = R_norm2 * const_**2
-        gap = 0.5 * (R_norm2 + A_norm2)
-    else:
-        const_ = 1.0
-        gap = R_norm2
-
-    l1_norm = _asum(n_features, &w[0], 1)
-
-    gap += (
-        alpha * l1_norm
-        - const_ * _dot(n_samples, &R[0], 1, &y[0], 1)  # R @ y
-        + 0.5 * beta * (1 + const_ ** 2) * w_norm2
+    # w_l1_norm = np.sum(np.abs(w))
+    w_l1_norm = _asum(n_features, &w[0], 1)
+
+    gap = dual_gap_formulation_A(
+        alpha=alpha,
+        beta=beta,
+        w_l1_norm=w_l1_norm,
+        w_l2_norm2=w_l2_norm2,
+        R_norm2=R_norm2,
+        Ry=Ry,
+        dual_norm_XtA=dual_norm_XtA,
     )
     return gap, dual_norm_XtA

@@ -606,6 +706,10 @@ def sparse_enet_coordinate_descent(
     cdef bint center = False
     cdef bint no_sample_weights = sample_weight is None

+    if alpha == 0:
+        # No screeing without L1-penalty.
+        do_screening = False
+
     if do_screening:
         active_set = np.empty(n_features, dtype=np.uint32)  # map [:n_active] -> j
         excluded_set = np.empty(n_features, dtype=np.uint8)
@@ -843,7 +947,7 @@ def sparse_enet_coordinate_descent(
             with gil:
                 message = (
                     message_conv +
-                    f" Duality gap: {gap:.3e}, tolerance: {tol:.3e}"
+                    f" Duality gap: {gap:.6e}, tolerance: {tol:.3e}"
                 )
                 if alpha < np.finfo(np.float64).eps:
                     message += "\n" + message_ridge
@@ -863,53 +967,75 @@ cdef (floating, floating) gap_enet_gram(
     floating[::1] XtA,  # XtA = X.T @ R - beta * w is calculated inplace
     bint positive,
 ) noexcept nogil:
-    """Compute dual gap for use in enet_coordinate_descent."""
+    """Compute dual gap for use in enet_coordinate_descent.
+
+    alpha > 0:            formulation A of the duality gap
+    alpha = 0 & beta > 0: formulation B of the duality gap
+    alpha = beta = 0:     OLS first order condition (=gradient)
+    """
     cdef floating gap = 0.0
     cdef floating dual_norm_XtA
     cdef floating R_norm2
-    cdef floating w_norm2 = 0.0
-    cdef floating l1_norm
-    cdef floating A_norm2
-    cdef floating const_
+    cdef floating Ry
+    cdef floating w_l1_norm
+    cdef floating w_l2_norm2 = 0.0
     cdef floating q_dot_w
     cdef floating wQw
     cdef unsigned int j

+    # w_l2_norm2 = w @ w
+    if beta > 0:
+        w_l2_norm2 = _dot(n_features, &w[0], 1, &w[0], 1)
     # q_dot_w = w @ q
     q_dot_w = _dot(n_features, &w[0], 1, &q[0], 1)
+    # wQw = w @ Q @ w
+    wQw = _dot(n_features, &w[0], 1, &Qw[0], 1)
+    # R_norm2 = R @ R, residual R = y - Xw
+    R_norm2 = y_norm2 + wQw - 2.0 * q_dot_w
+    # Ry = R @ y
+    if not (alpha == 0 and beta == 0):
+        # Note that R'y = (y - Xw)' y = ||y||_2^2 - w'X'y = y_norm2 - q_dot_w
+        Ry = y_norm2 - q_dot_w
+
+    if alpha == 0:
+        # XtA = X'R
+        for j in range(n_features):
+            XtA[j] = q[j] - Qw[j]
+        # ||X'R||_2^2
+        dual_norm_XtA = _dot(n_features, &XtA[0], 1, &XtA[0], 1)
+        if beta == 0:
+            # This is OLS, no dual gap available. Resort to first order condition
+            #     X'R = 0
+            #     gap = ||X'R||_2^2
+            # Compare with stopping criterion of LSQR.
+            gap = dual_norm_XtA
+            return gap, dual_norm_XtA
+        # This is Ridge regression, we use formulation B for the dual gap.
+        gap = R_norm2 + 0.5 * beta * w_l2_norm2 - Ry
+        gap += 1 / (2 * beta) * dual_norm_XtA
+        return gap, dual_norm_XtA

     # XtA = X.T @ R - beta * w = X.T @ y - X.T @ X @ w - beta * w
     for j in range(n_features):
         XtA[j] = q[j] - Qw[j] - beta * w[j]

+    # dual_norm_XtA
     if positive:
         dual_norm_XtA = max(n_features, &XtA[0])
     else:
         dual_norm_XtA = abs_max(n_features, &XtA[0])

-    # wQw = w @ Q @ w
-    wQw = _dot(n_features, &w[0], 1, &Qw[0], 1)
-    # R_norm2 = R @ R
-    R_norm2 = y_norm2 + wQw - 2.0 * q_dot_w
-
-    # w_norm2 = w @ w
-    if beta > 0:
-        w_norm2 = _dot(n_features, &w[0], 1, &w[0], 1)
-
-    if (dual_norm_XtA > alpha):
-        const_ = alpha / dual_norm_XtA
-        A_norm2 = R_norm2 * (const_ ** 2)
-        gap = 0.5 * (R_norm2 + A_norm2)
-    else:
-        const_ = 1.0
-        gap = R_norm2
-
-    l1_norm = _asum(n_features, &w[0], 1)
-
-    gap += (
-        alpha * l1_norm
-        - const_ * (y_norm2 - q_dot_w)  # -const_ * R @ y
-        + 0.5 * beta * (1 + const_ ** 2) * w_norm2
+    # w_l1_norm = np.sum(np.abs(w))
+    w_l1_norm = _asum(n_features, &w[0], 1)
+
+    gap = dual_gap_formulation_A(
+        alpha=alpha,
+        beta=beta,
+        w_l1_norm=w_l1_norm,
+        w_l2_norm2=w_l2_norm2,
+        R_norm2=R_norm2,
+        Ry=Ry,
+        dual_norm_XtA=dual_norm_XtA,
     )
     return gap, dual_norm_XtA

@@ -987,11 +1113,8 @@ def enet_coordinate_descent_gram(
     cdef uint32_t* rand_r_state = &rand_r_state_seed

     if alpha == 0:
-        warnings.warn(
-            "Coordinate descent without L1 regularization may "
-            "lead to unexpected results and is discouraged. "
-            "Set l1_ratio > 0 to add L1 regularization."
-        )
+        # No screeing without L1-penalty.
+        do_screening = False

     if do_screening:
         active_set = np.empty(n_features, dtype=np.uint32)  # map [:n_active] -> j
@@ -1108,8 +1231,10 @@ def enet_coordinate_descent_gram(
             with gil:
                 message = (
                     message_conv +
-                    f" Duality gap: {gap:.3e}, tolerance: {tol:.3e}"
+                    f" Duality gap: {gap:.6e}, tolerance: {tol:.3e}"
                 )
+                if alpha < np.finfo(np.float64).eps:
+                    message += "\n" + message_ridge
                 warnings.warn(message, ConvergenceWarning)

     return np.asarray(w), gap, tol, n_iter + 1
@@ -1145,12 +1270,39 @@ cdef (floating, floating) gap_enet_multi_task(
     cdef floating gap = 0.0
     cdef floating dual_norm_XtA
     cdef floating R_norm2
-    cdef floating w_norm2 = 0.0
-    cdef floating l21_norm
-    cdef floating A_norm2
-    cdef floating const_
+    cdef floating Ry
+    cdef floating w_l21_norm
+    cdef floating w_l2_norm2 = 0.0
     cdef unsigned int t, j

+    # w_l2_norm2 = linalg.norm(W, ord="fro") ** 2
+    if l2_reg > 0:
+        w_l2_norm2 = _dot(n_features * n_tasks, &W[0, 0], 1, &W[0, 0], 1)
+    # R_norm2 = linalg.norm(R, ord="fro") ** 2
+    R_norm2 = _dot(n_samples * n_tasks, &R[0, 0], 1, &R[0, 0], 1)
+    # Ry = np.sum(R * Y)
+    if not (l1_reg == 0 and l2_reg == 0):
+        Ry = _dot(n_samples * n_tasks, &R[0, 0], 1, &Y[0, 0], 1)
+
+    if l1_reg == 0:
+        # XtA = X.T @ R
+        for j in range(n_features):
+            for t in range(n_tasks):
+                XtA[j, t] = _dot(n_samples, &X[0, j], 1, &R[0, t], 1)
+        # ||X'R||_2^2
+        dual_norm_XtA = _dot(n_features * n_tasks, &XtA[0, 0], 1, &XtA[0, 0], 1)
+        if l2_reg == 0:
+            # This is OLS, no dual gap available. Resort to first order condition
+            #     X'R = 0
+            #     gap = ||X'R||_2^2
+            # Compare with stopping criterion of LSQR.
+            gap = dual_norm_XtA
+            return gap, dual_norm_XtA
+        # This is Ridge regression, we use formulation B for the dual gap.
+        gap = R_norm2 + 0.5 * l2_reg * w_l2_norm2 - Ry
+        gap += 1 / (2 * l2_reg) * dual_norm_XtA
+        return gap, dual_norm_XtA
+
     # XtA = X.T @ R - l2_reg * W.T
     for j in range(n_features):
         for t in range(n_tasks):
@@ -1164,30 +1316,19 @@ cdef (floating, floating) gap_enet_multi_task(
         if XtA_row_norms[j] > dual_norm_XtA:
             dual_norm_XtA = XtA_row_norms[j]

-    # R_norm2 = linalg.norm(R, ord="fro") ** 2
-    R_norm2 = _dot(n_samples * n_tasks, &R[0, 0], 1, &R[0, 0], 1)
-
-    # w_norm2 = linalg.norm(W, ord="fro") ** 2
-    if l2_reg > 0:
-        w_norm2 = _dot(n_features * n_tasks, &W[0, 0], 1, &W[0, 0], 1)
-
-    if (dual_norm_XtA > l1_reg):
-        const_ = l1_reg / dual_norm_XtA
-        A_norm2 = R_norm2 * (const_ ** 2)
-        gap = 0.5 * (R_norm2 + A_norm2)
-    else:
-        const_ = 1.0
-        gap = R_norm2
-
-    # l21_norm = np.sqrt(np.sum(W ** 2, axis=0)).sum()
-    l21_norm = 0.0
+    # w_l21_norm = np.sqrt(np.sum(W ** 2, axis=0)).sum()
+    w_l21_norm = 0.0
     for ii in range(n_features):
-        l21_norm += _nrm2(n_tasks, &W[0, ii], 1)
-
-    gap += (
-        l1_reg * l21_norm
-        - const_ * _dot(n_samples * n_tasks, &R[0, 0], 1, &Y[0, 0], 1)  # np.sum(R * Y)
-        + 0.5 * l2_reg * (1 + const_ ** 2) * w_norm2
+        w_l21_norm += _nrm2(n_tasks, &W[0, ii], 1)
+
+    gap = dual_gap_formulation_A(
+        alpha=l1_reg,
+        beta=l2_reg,
+        w_l1_norm=w_l21_norm,
+        w_l2_norm2=w_l2_norm2,
+        R_norm2=R_norm2,
+        Ry=Ry,
+        dual_norm_XtA=dual_norm_XtA,
     )
     return gap, dual_norm_XtA

@@ -1274,10 +1415,8 @@ def enet_coordinate_descent_multi_task(
     cdef uint32_t* rand_r_state = &rand_r_state_seed

     if l1_reg == 0:
-        warnings.warn(
-            "Coordinate descent with l1_reg=0 may lead to unexpected"
-            " results and is discouraged."
-        )
+        # No screeing without L1-penalty.
+        do_screening = False

     if do_screening:
         active_set = np.empty(n_features, dtype=np.uint32)  # map [:n_active] -> j
@@ -1429,8 +1568,10 @@ def enet_coordinate_descent_multi_task(
             with gil:
                 message = (
                     message_conv +
-                    f" Duality gap: {gap:.3e}, tolerance: {tol:.3e}"
+                    f" Duality gap: {gap:.6e}, tolerance: {tol:.3e}"
                 )
+                if l1_reg < np.finfo(np.float64).eps:
+                    message += "\n" + message_ridge
                 warnings.warn(message, ConvergenceWarning)

     return np.asarray(W), gap, tol, n_iter + 1

@@ -124,9 +124,8 @@ def _alpha_grid(

     l1_ratio : float, default=1.0
         The elastic net mixing parameter, with ``0 < l1_ratio <= 1``.
-        For ``l1_ratio = 0`` the penalty is an L2 penalty. (currently not
-        supported) ``For l1_ratio = 1`` it is an L1 penalty. For
-        ``0 < l1_ratio <1``, the penalty is a combination of L1 and L2.
+        For ``l1_ratio = 0``, there would be no L1 penalty which is not supported
+        for the generation of alphas.

     eps : float, default=1e-3
         Length of the path. ``eps=1e-3`` means that
@@ -439,15 +438,15 @@ def enet_path(

     For multi-output tasks it is::

-        (1 / (2 * n_samples)) * ||Y - XW||_Fro^2
+        1 / (2 * n_samples) * ||Y - XW||_Fro^2
         + alpha * l1_ratio * ||W||_21
         + 0.5 * alpha * (1 - l1_ratio) * ||W||_Fro^2

     Where::

         ||W||_21 = \\sum_i \\sqrt{\\sum_j w_{ij}^2}

-    i.e. the sum of norm of each row.
+    i.e. the sum of L2-norm of each row (task) (i=feature, j=task)

     Read more in the :ref:`[NAME_REDACTED] <elastic_net>`.

@@ -18,6 +18,7 @@
     Lasso,
     LassoCV,
     LassoLarsCV,
+    LinearRegression,
     MultiTaskElasticNet,
     MultiTaskElasticNetCV,
     MultiTaskLasso,
@@ -580,16 +581,14 @@ def test_uniform_targets():
     for model in models_single_task:
         for y_values in (0, 5):
             y1.fill(y_values)
-            with ignore_warnings(category=ConvergenceWarning):
-                assert_array_equal(model.fit(X_train, y1).predict(X_test), y1)
+            assert_array_equal(model.fit(X_train, y1).predict(X_test), y1)
             assert_array_equal(model.alphas_, [np.finfo(float).resolution] * 3)

     for model in models_multi_task:
         for y_values in (0, 5):
             y2[:, 0].fill(y_values)
             y2[:, 1].fill(2 * y_values)
-            with ignore_warnings(category=ConvergenceWarning):
-                assert_array_equal(model.fit(X_train, y2).predict(X_test), y2)
+            assert_array_equal(model.fit(X_train, y2).predict(X_test), y2)
             assert_array_equal(model.alphas_, [np.finfo(float).resolution] * 3)

@@ -969,15 +968,14 @@ def test_check_input_false():
     X, y, _, _ = build_dataset(n_samples=20, n_features=10)
     X = check_array(X, order="F", dtype="float64")
     y = check_array(X, order="F", dtype="float64")
-    clf = ElasticNet(selection="cyclic", tol=1e-8)
+    clf = ElasticNet(selection="cyclic", tol=1e-7)
     # Check that no error is raised if data is provided in the right format
     clf.fit(X, y, check_input=False)
     # With check_input=False, an exhaustive check is not made on y but its
     # dtype is still cast in _preprocess_data to X's dtype. So the test should
     # pass anyway
     X = check_array(X, order="F", dtype="float32")
-    with ignore_warnings(category=ConvergenceWarning):
-        clf.fit(X, y, check_input=False)
+    clf.fit(X, y, check_input=False)
     # With no input checking, providing X in C order should result in false
     # computation
     X = check_array(X, order="C", dtype="float64")
@@ -1093,7 +1091,6 @@ def test_enet_float_precision():
             )

-[USER_REDACTED].mark.filterwarnings("ignore::sklearn.exceptions.ConvergenceWarning")
 def test_enet_l1_ratio():
     # Test that an error message is raised if an estimator that
     # uses _alpha_grid is called with l1_ratio=0
@@ -1111,31 +1108,25 @@ def test_enet_l1_ratio():
     with pytest.raises(ValueError, match=msg):
         MultiTaskElasticNetCV(l1_ratio=0, random_state=42).fit(X, y[:, None])

-    # Test that l1_ratio=0 with alpha>0 produces user warning
-    warning_message = (
-        "Coordinate descent without L1 regularization may "
-        "lead to unexpected results and is discouraged. "
-        "Set l1_ratio > 0 to add L1 regularization."
-    )
+    # But no error for ElasticNetCV with l1_ratio=0 and alpha>0.
     est = ElasticNetCV(l1_ratio=[0], alphas=[1])
-    with pytest.warns(UserWarning, match=warning_message):
+    with warnings.catch_warnings():
+        warnings.simplefilter("error")
         est.fit(X, y)

     # Test that l1_ratio=0 is allowed if we supply a grid manually
     alphas = [0.1, 10]
     estkwds = {"alphas": alphas, "random_state": 42}
     est_desired = ElasticNetCV(l1_ratio=0.00001, **estkwds)
     est = ElasticNetCV(l1_ratio=0, **estkwds)
-    with ignore_warnings():
-        est_desired.fit(X, y)
-        est.fit(X, y)
+    est_desired.fit(X, y)
+    est.fit(X, y)
     assert_array_almost_equal(est.coef_, est_desired.coef_, decimal=5)

     est_desired = MultiTaskElasticNetCV(l1_ratio=0.00001, **estkwds)
     est = MultiTaskElasticNetCV(l1_ratio=0, **estkwds)
-    with ignore_warnings():
-        est.fit(X, y[:, None])
-        est_desired.fit(X, y[:, None])
+    est.fit(X, y[:, None])
+    est_desired.fit(X, y[:, None])
     assert_array_almost_equal(est.coef_, est_desired.coef_, decimal=5)

@@ -1553,39 +1544,85 @@ def test_enet_sample_weight_does_not_overwrite_sample_weight(check_input):
     assert_array_equal(sample_weight, sample_weight_1_25)

-[USER_REDACTED].mark.filterwarnings("ignore::sklearn.exceptions.ConvergenceWarning")
-[USER_REDACTED].mark.parametrize("ridge_alpha", [1e-1, 1.0, 1e6])
-def test_enet_ridge_consistency(ridge_alpha):
+[USER_REDACTED].mark.parametrize("ridge_alpha", [1e-6, 1e-1, 1.0, 1e6])
+[USER_REDACTED].mark.parametrize(
+    ["precompute", "n_targets"], [(False, 1), (True, 1), (False, 3)]
+)
+def test_enet_ridge_consistency(ridge_alpha, precompute, n_targets):
     # Check that ElasticNet(l1_ratio=0) converges to the same solution as Ridge
     # provided that the value of alpha is adapted.
-    #
-    # XXX: this test does not pass for weaker regularization (lower values of
-    # ridge_alpha): it could be either a problem of ElasticNet or Ridge (less
-    # likely) and depends on the dataset statistics: lower values for
-    # effective_rank are more problematic in particular.

     rng = np.random.RandomState(42)
     n_samples = 300
     X, y = make_regression(
         n_samples=n_samples,
         n_features=100,
+        n_targets=n_targets,
         effective_rank=10,
         n_informative=50,
         random_state=rng,
     )
     sw = rng.uniform(low=0.01, high=10, size=X.shape[0])
-    alpha = 1.0
-    common_params = dict(
-        tol=1e-12,
+
+    if n_targets == 1:
+        sw_arg = dict(sample_weight=sw)
+    else:
+        # MultiTaskElasticNet does not support sample weights (yet).
+        sw_arg = dict()
+
+    ridge = Ridge(alpha=ridge_alpha, solver="svd").fit(X, y, **sw_arg)
+
+    tol = 1e-11 if ridge_alpha >= 1e-2 else 1e-16
+    if n_targets == 1:
+        alpha_enet = ridge_alpha / sw.sum()
+        enet = ElasticNet(alpha=alpha_enet, l1_ratio=0, precompute=precompute, tol=tol)
+    else:
+        alpha_enet = ridge_alpha / n_samples
+        enet = MultiTaskElasticNet(alpha=alpha_enet, l1_ratio=0, tol=tol)
+    enet.fit(X, y, **sw_arg)
+
+    # The CD solver using the gram matrix (precompute = True) loses numerical precision
+    # by [NAME_REDACTED] of matrices like Q=X'X (=gram) and
+    # R^2 = y^2 + wQw - 2yQw (=square of residuals).
+    rtol = 1e-5 if precompute else 1e-7
+    assert_allclose(enet.coef_, ridge.coef_, rtol=rtol)
+    assert_allclose(enet.intercept_, ridge.intercept_)
+
+
+[USER_REDACTED].mark.filterwarnings("ignore:With alpha=0, this algorithm:UserWarning")
+[USER_REDACTED].mark.parametrize("precompute", [False, True])
+[USER_REDACTED].mark.parametrize("effective_rank", [None, 10])
+def test_enet_ols_consistency(precompute, effective_rank):
+    """Test that ElasticNet(alpha=0) converges to the same solution as OLS."""
+    rng = np.random.RandomState(42)
+    n_samples = 300
+    X, y = make_regression(
+        n_samples=n_samples,
+        n_features=100,
+        effective_rank=effective_rank,
+        n_informative=50,
+        random_state=rng,
     )
-    ridge = Ridge(alpha=alpha, **common_params).fit(X, y, sample_weight=sw)
+    sw = rng.uniform(low=0.01, high=10, size=X.shape[0])

-    alpha_enet = alpha / sw.sum()
-    enet = ElasticNet(alpha=alpha_enet, l1_ratio=0, **common_params).fit(
+    ols = LinearRegression().fit(X, y, sample_weight=sw)
+    enet = ElasticNet(alpha=0, precompute=precompute, tol=1e-15).fit(
         X, y, sample_weight=sw
     )
-    assert_allclose(ridge.coef_, enet.coef_)
-    assert_allclose(ridge.intercept_, enet.intercept_)
+
+    # Might be a singular problem, so check for same predictions
+    assert_allclose(enet.predict(X), ols.predict(X))
+    # and for similar objective function (squared error)
+    se_ols = np.sum((y - ols.predict(X)) ** 2)
+    se_enet = np.sum((y - enet.predict(X)) ** 2)
+    if precompute:
+        assert se_ols <= 1e-20
+        assert se_enet <= 1e-20
+    else:
+        assert se_enet <= se_ols <= 1e-20  # Who would have thought that?
+    # We check equal coefficients, but "only" with absolute tolerance.
+    assert_allclose(enet.coef_, ols.coef_, atol=1e-11)
+    assert_allclose(enet.intercept_, ols.intercept_, atol=1e-12)

 [USER_REDACTED].mark.parametrize(
@@ -1769,7 +1806,9 @@ def test_linear_model_cv_alphas_n_alphas_unset(Estimator):

 # TODO(1.9): remove
 [USER_REDACTED].mark.filterwarnings("ignore:'n_alphas' was deprecated in 1.7")
-[USER_REDACTED].mark.filterwarnings("ignore:.*with no regularization.*:UserWarning")
+[USER_REDACTED].mark.filterwarnings(
+    "ignore:With alpha=0, this algorithm does not converge well.*:UserWarning"
+)
 [USER_REDACTED].mark.parametrize(
     "Estimator", [ElasticNetCV, LassoCV, MultiTaskLassoCV, MultiTaskElasticNetCV]
 )

@@ -271,11 +271,18 @@ def test_path_parameters(csc_container):

 [USER_REDACTED].mark.parametrize("Model", [Lasso, ElasticNet, LassoCV, ElasticNetCV])
 [USER_REDACTED].mark.parametrize("fit_intercept", [False, True])
+[USER_REDACTED].mark.parametrize("l1_ratio", [0.5, 0])
 [USER_REDACTED].mark.parametrize("n_samples, n_features", [(24, 6), (6, 24)])
 [USER_REDACTED].mark.parametrize("with_sample_weight", [True, False])
 [USER_REDACTED].mark.parametrize("csc_container", CSC_CONTAINERS)
 def test_sparse_dense_equality(
-    Model, fit_intercept, n_samples, n_features, with_sample_weight, csc_container
+    Model,
+    fit_intercept,
+    l1_ratio,
+    n_samples,
+    n_features,
+    with_sample_weight,
+    csc_container,
 ):
     X, y = make_regression(
         n_samples=n_samples,
@@ -292,6 +299,11 @@ def test_sparse_dense_equality(
         sw = None
     Xs = csc_container(X)
     params = {"fit_intercept": fit_intercept, "tol": 1e-6}
+    if Model != ElasticNet:
+        if l1_ratio == 0:
+            return
+    else:
+        params["l1_ratio"] = l1_ratio
     reg_dense = Model(**params).fit(X, y, sample_weight=sw)
     reg_sparse = Model(**params).fit(Xs, y, sample_weight=sw)
     if fit_intercept:

LGTM! Thanks, [USER_REDACTED]!

I just have a few questions for my own understanding.

Looks good [USER_REDACTED]
Thank you for the PR

I find this quite surprising.

Note: The test passes with `se_enet < se_ols`, e.g. `4.8032617180978374e-23 < 2.031868402489036e-22`. This means the CD solver of `ElasticNet`, indeed, finds better solutions than `LinearRegression`!

I understand that this is an implementation detail. However, would it be useful to include this mathematical detail in the [user guide](https://scikit-learn.org/stable/modules/linear_model.html#mathematical-details), given that Formulation A is already covered?

Can we use `assert_allclose` here, since we want to check that `ElasticNet(alpha=0.0)` converges to `Ridge`? Also, I'm curious. --- what surprised you, aside from floating-point effects?

The formulation B is - so far - only used in the edge case of no L1 penalty. Therefore, I‘d be hesitant to add it to the user guide.

If later, in a later PR, we decide to make more use of it, the sure, let’s include it. For the time being it is at least documented in issue #22836.

Yes, assert_allclose to zero would also work. But it would hide the fact that the squared error of enet gets closer to zero than ols!

```suggestion
    The above dual D(v) and duality gap G require alpha > 0 because of the dual
```


Fix `_safe_indexing` with non integer arrays on array API inputs

This is a fix for #32837.

While investigating the issue above, I realized that we needed unittest for array API support for `_safe_indexing`.

I am not yet sure if this problem already existing in 1.7 or not. If it was I will add a changelog entry.

@@ -469,7 +469,7 @@ def move_to(*arrays, xp, device):
     `array` may contain `None` entries, these are left unchanged.

     Sparse arrays are accepted (as pass through) if the reference namespace is
-    Numpy, in which case they are returned unchanged. Otherwise a `TypeError`
+    NumPy, in which case they are returned unchanged. Otherwise a `TypeError`
     is raised.

     Parameters

@@ -36,8 +36,24 @@ def _array_indexing(array, key, key_dtype, axis):
     """Index an array or scipy.sparse consistently across NumPy version."""
     xp, is_array_api, device_ = get_namespace_and_device(array)
     if is_array_api:
-        key = move_to(key, xp=xp, device=device_)
-        return xp.take(array, key, axis=axis)
+        if hasattr(key, "shape"):
+            key = move_to(key, xp=xp, device=device_)
+        elif isinstance(key, (int, slice)):
+            # Passthrough for valid __getitem__ inputs as noted in the array
+            # API spec.
+            pass
+        else:
+            key = xp.asarray(key, device=device_)
+
+        if hasattr(key, "dtype"):
+            if xp.isdtype(key.dtype, "integral"):
+                return xp.take(array, key, axis=axis)
+            elif xp.isdtype(key.dtype, "bool"):
+                # Array API does not support boolean indexing for n-dim arrays
+                # yet hence the need to turn to equivalent integer indexing.
+                indices = xp.arange(array.shape[axis], device=device_)
+                return xp.take(array, indices[key], axis=axis)
+
     if issparse(array) and key_dtype == "bool":
         key = np.asarray(key)
     if isinstance(key, tuple):

@@ -10,7 +10,10 @@
 from sklearn.externals._packaging.version import parse as parse_version
 from sklearn.utils import _safe_indexing, resample, shuffle
 from sklearn.utils._array_api import (
+    _convert_to_numpy,
     _get_namespace_device_dtype_ids,
+    device,
+    move_to,
     yield_namespace_device_dtype_combinations,
 )
 from sklearn.utils._indexing import (
@@ -22,6 +25,7 @@
 from sklearn.utils._testing import (
     _array_api_for_tests,
     _convert_container,
+    assert_allclose,
     assert_allclose_dense_sparse,
     assert_array_equal,
     skip_if_array_api_compat_not_configured,
@@ -108,22 +112,22 @@ def test_determine_key_type_slice_error():

 [USER_REDACTED]_if_array_api_compat_not_configured
 [USER_REDACTED].mark.parametrize(
-    "array_namespace, device, dtype_name",
+    "array_namespace, device_, dtype_name",
     yield_namespace_device_dtype_combinations(),
     ids=_get_namespace_device_dtype_ids,
 )
-def test_determine_key_type_array_api(array_namespace, device, dtype_name):
-    xp = _array_api_for_tests(array_namespace, device)
+def test_determine_key_type_array_api(array_namespace, device_, dtype_name):
+    xp = _array_api_for_tests(array_namespace, device_)

     with sklearn.config_context(array_api_dispatch=True):
-        int_array_key = xp.asarray([1, 2, 3])
+        int_array_key = xp.asarray([1, 2, 3], device=device_)
         assert _determine_key_type(int_array_key) == "int"

-        bool_array_key = xp.asarray([True, False, True])
+        bool_array_key = xp.asarray([True, False, True], device=device_)
         assert _determine_key_type(bool_array_key) == "bool"

         try:
-            complex_array_key = xp.asarray([1 + 1j, 2 + 2j, 3 + 3j])
+            complex_array_key = xp.asarray([1 + 1j, 2 + 2j, 3 + 3j], device=device_)
         except TypeError:
             # Complex numbers are not supported by [NAME_REDACTED]
             complex_array_key = None
@@ -133,6 +137,42 @@ def test_determine_key_type_array_api(array_namespace, device, dtype_name):
                 _determine_key_type(complex_array_key)

+[USER_REDACTED]_if_array_api_compat_not_configured
+[USER_REDACTED].mark.parametrize(
+    "array_namespace, device_, dtype_name",
+    yield_namespace_device_dtype_combinations(),
+    ids=_get_namespace_device_dtype_ids,
+)
+[USER_REDACTED].mark.parametrize(
+    "indexing_key",
+    (
+        0,
+        -1,
+        [1, 3],
+        np.array([1, 3]),
+        slice(1, 2),
+        [True, False, True, True],
+        np.asarray([False, False, False, False]),
+    ),
+)
+[USER_REDACTED].mark.parametrize("axis", [0, 1])
+def test_safe_indexing_array_api_support(
+    array_namespace, device_, dtype_name, indexing_key, axis
+):
+    xp = _array_api_for_tests(array_namespace, device_)
+
+    array_to_index_np = np.arange(16).reshape(4, 4)
+    expected_result = _safe_indexing(array_to_index_np, indexing_key, axis=axis)
+    array_to_index_xp = move_to(array_to_index_np, xp=xp, device=device_)
+
+    with sklearn.config_context(array_api_dispatch=True):
+        indexed_array_xp = _safe_indexing(array_to_index_xp, indexing_key, axis=axis)
+        assert device(indexed_array_xp) == device(array_to_index_xp)
+        assert indexed_array_xp.dtype == array_to_index_xp.dtype
+
+    assert_allclose(_convert_to_numpy(indexed_array_xp, xp=xp), expected_result)
+
+
 [USER_REDACTED].mark.parametrize(
     "array_type", ["list", "array", "sparse", "dataframe", "polars", "pyarrow"]
 )

LGTM. Thank you [USER_REDACTED]

Just one minor comment. Otherwise, LGTM! Thanks, [USER_REDACTED]!

Do we need this here considering that this is the default return statement anyways at the end?

I ran the CI locally with the `else` block commented out, and it passed with CUDA. Even the code mentioned in #32837 compiled without any issues. So I think it's safe to remove it here.

It's logically equivalent to remove those lines because array API inputs can never be tuples or sparse datastructures. I originally found the explicit else close more explicit but it's true that it's redundant. Let's remove it.

```suggestion
```


MAINT Remove CSS template substitution in estimators' HTML Display

<!--
🙌 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
-->
#32834

#### What does this implement/fix? Explain your changes.
Maybe the template/substitution can be avoided.
The css doesn't change for each estimator, so why do we need a dynamic selector?

Edit: Removed `#$id` to `estimator.css` to avoid the dynamic behaviour. Added a selector called `css-selector` instead.
On `estimator.py` I'm adding a class with the same name - `css-selector`.
That way we don't need to substitute the id number, that I believe it's not needed.
Worked locally with vscode and jupyter notebook.

#### 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?

<!--
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!
-->

@@ -1,4 +1,4 @@
-#$id {
+.sk-global {
   /* Definition of color scheme common for light and dark mode */
   --sklearn-color-text: #000;
   --sklearn-color-text-muted: #666;
@@ -15,30 +15,30 @@
   --sklearn-color-fitted-level-3: cornflowerblue;
 }

-#$id.light {
+.sk-global.light {
   /* Specific color for light theme */
   --sklearn-color-text-on-default-background: black;
   --sklearn-color-background: white;
   --sklearn-color-border-box: black;
   --sklearn-color-icon: #696969;
 }

-#$id.dark {
+.sk-global.dark {
   --sklearn-color-text-on-default-background: white;
   --sklearn-color-background: #111;
   --sklearn-color-border-box: white;
   --sklearn-color-icon: #878787;
 }

-#$id {
+.sk-global {
   color: var(--sklearn-color-text);
 }

-#$id pre {
+.sk-global pre {
   padding: 0;
 }

-#$id input.sk-hidden--visually {
+.sk-global input.sk-hidden--visually {
   border: 0;
   clip: rect(1px 1px 1px 1px);
   clip: rect(1px, 1px, 1px, 1px);
@@ -50,15 +50,15 @@
   width: 1px;
 }

-#$id div.sk-dashed-wrapped {
+.sk-global div.sk-dashed-wrapped {
   border: 1px dashed var(--sklearn-color-line);
   margin: 0 0.4em 0.5em 0.4em;
   box-sizing: border-box;
   padding-bottom: 0.4em;
   background-color: var(--sklearn-color-background);
 }

-#$id div.sk-container {
+.sk-global div.sk-container {
   /* jupyter's `normalize.less` sets `[hidden] { display: none; }`
      but bootstrap.min.css set `[hidden] { display: none !important; }`
      so we also need the `!important` here to be able to override the
@@ -68,7 +68,7 @@
   position: relative;
 }

-#$id div.sk-text-repr-fallback {
+.sk-global div.sk-text-repr-fallback {
   display: none;
 }

@@ -84,43 +84,43 @@ div.sk-item {

 /* Parallel-specific style estimator block */

-#$id div.sk-parallel-item::after {
+.sk-global div.sk-parallel-item::after {
   content: "";
   width: 100%;
   border-bottom: 2px solid var(--sklearn-color-text-on-default-background);
   flex-grow: 1;
 }

-#$id div.sk-parallel {
+.sk-global div.sk-parallel {
   display: flex;
   align-items: stretch;
   justify-content: center;
   background-color: var(--sklearn-color-background);
   position: relative;
 }

-#$id div.sk-parallel-item {
+.sk-global div.sk-parallel-item {
   display: flex;
   flex-direction: column;
 }

-#$id div.sk-parallel-item:first-child::after {
+.sk-global div.sk-parallel-item:first-child::after {
   align-self: flex-end;
   width: 50%;
 }

-#$id div.sk-parallel-item:last-child::after {
+.sk-global div.sk-parallel-item:last-child::after {
   align-self: flex-start;
   width: 50%;
 }

-#$id div.sk-parallel-item:only-child::after {
+.sk-global div.sk-parallel-item:only-child::after {
   width: 0;
 }

 /* Serial-specific style estimator block */

-#$id div.sk-serial {
+.sk-global div.sk-serial {
   display: flex;
   flex-direction: column;
   align-items: center;
@@ -138,14 +138,14 @@ clickable and can be expanded/collapsed.

 /* Pipeline and ColumnTransformer style (default) */

-#$id div.sk-toggleable {
+.sk-global div.sk-toggleable {
   /* Default theme specific background. It is overwritten whether we have a
   specific estimator or a Pipeline/ColumnTransformer */
   background-color: var(--sklearn-color-background);
 }

 /* Toggleable label */
-#$id label.sk-toggleable__label {
+.sk-global label.sk-toggleable__label {
   cursor: pointer;
   display: flex;
   width: 100%;
@@ -158,118 +158,118 @@ clickable and can be expanded/collapsed.
   gap: 0.5em;
 }

-#$id label.sk-toggleable__label .caption {
+.sk-global label.sk-toggleable__label .caption {
   font-size: 0.6rem;
   font-weight: lighter;
   color: var(--sklearn-color-text-muted);
 }

-#$id label.sk-[REDACTED]:before {
+.sk-global label.sk-[REDACTED]:before {
   /* Arrow on the left of the label */
   content: "▸";
   float: left;
   margin-right: 0.25em;
   color: var(--sklearn-color-icon);
 }

-#$id label.sk-[REDACTED]:hover:before {
+.sk-global label.sk-[REDACTED]:hover:before {
   color: var(--sklearn-color-text);
 }

 /* Toggleable content - dropdown */

-#$id div.sk-toggleable__content {
+.sk-global div.sk-toggleable__content {
   display: none;
   text-align: left;
   /* unfitted */
   background-color: var(--sklearn-color-unfitted-level-0);
 }

-#$id div.sk-toggleable__content.fitted {
+.sk-global div.sk-toggleable__content.fitted {
   /* fitted */
   background-color: var(--sklearn-color-fitted-level-0);
 }

-#$id div.sk-toggleable__content pre {
+.sk-global div.sk-toggleable__content pre {
   margin: 0.2em;
   border-radius: 0.25em;
   color: var(--sklearn-color-text);
   /* unfitted */
   background-color: var(--sklearn-color-unfitted-level-0);
 }

-#$id div.sk-toggleable__content.fitted pre {
+.sk-global div.sk-toggleable__content.fitted pre {
   /* unfitted */
   background-color: var(--sklearn-color-fitted-level-0);
 }

-#$id input.sk-toggleable__control:checked~div.sk-toggleable__content {
+.sk-global input.sk-toggleable__control:checked~div.sk-toggleable__content {
   /* Expand drop-down */
   display: block;
   width: 100%;
   overflow: visible;
 }

-#$id input.sk-toggleable__control:checked~label.sk-[REDACTED]:before {
+.sk-global input.sk-toggleable__control:checked~label.sk-[REDACTED]:before {
   content: "▾";
 }

 /* Pipeline/ColumnTransformer-specific style */

-#$id div.sk-label input.sk-toggleable__control:checked~label.sk-toggleable__label {
+.sk-global div.sk-label input.sk-toggleable__control:checked~label.sk-toggleable__label {
   color: var(--sklearn-color-text);
   background-color: var(--sklearn-color-unfitted-level-2);
 }

-#$id div.sk-label.fitted input.sk-toggleable__control:checked~label.sk-toggleable__label {
+.sk-global div.sk-label.fitted input.sk-toggleable__control:checked~label.sk-toggleable__label {
   background-color: var(--sklearn-color-fitted-level-2);
 }

 /* Estimator-specific style */

 /* Colorize estimator box */
-#$id div.sk-estimator input.sk-toggleable__control:checked~label.sk-toggleable__label {
+.sk-global div.sk-estimator input.sk-toggleable__control:checked~label.sk-toggleable__label {
   /* unfitted */
   background-color: var(--sklearn-color-unfitted-level-2);
 }

-#$id div.sk-estimator.fitted input.sk-toggleable__control:checked~label.sk-toggleable__label {
+.sk-global div.sk-estimator.fitted input.sk-toggleable__control:checked~label.sk-toggleable__label {
   /* fitted */
   background-color: var(--sklearn-color-fitted-level-2);
 }

-#$id div.sk-label label.sk-toggleable__label,
-#$id div.sk-label label {
+.sk-global div.sk-label label.sk-toggleable__label,
+.sk-global div.sk-label label {
   /* The background is the default theme color */
   color: var(--sklearn-color-text-on-default-background);
 }

 /* On hover, darken the color of the background */
-#$id div.sk-label:hover label.sk-toggleable__label {
+.sk-global div.sk-label:hover label.sk-toggleable__label {
   color: var(--sklearn-color-text);
   background-color: var(--sklearn-color-unfitted-level-2);
 }

 /* Label box, darken color on hover, fitted */
-#$id div.sk-label.fitted:hover label.sk-toggleable__label.fitted {
+.sk-global div.sk-label.fitted:hover label.sk-toggleable__label.fitted {
   color: var(--sklearn-color-text);
   background-color: var(--sklearn-color-fitted-level-2);
 }

 /* Estimator label */

-#$id div.sk-label label {
+.sk-global div.sk-label label {
   font-family: monospace;
   font-weight: bold;
   line-height: 1.2em;
 }

-#$id div.sk-label-container {
+.sk-global div.sk-label-container {
   text-align: center;
 }

 /* Estimator-specific */
-#$id div.sk-estimator {
+.sk-global div.sk-estimator {
   font-family: monospace;
   border: 1px dotted var(--sklearn-color-border-box);
   border-radius: 0.25em;
@@ -279,18 +279,18 @@ clickable and can be expanded/collapsed.
   background-color: var(--sklearn-color-unfitted-level-0);
 }

-#$id div.sk-estimator.fitted {
+.sk-global div.sk-estimator.fitted {
   /* fitted */
   background-color: var(--sklearn-color-fitted-level-0);
 }

 /* on hover */
-#$id div.sk-estimator:hover {
+.sk-global div.sk-estimator:hover {
   /* unfitted */
   background-color: var(--sklearn-color-unfitted-level-2);
 }

-#$id div.sk-estimator.fitted:hover {
+.sk-global div.sk-estimator.fitted:hover {
   /* fitted */
   background-color: var(--sklearn-color-fitted-level-2);
 }
@@ -381,7 +381,7 @@ div.sk-label-container:hover .sk-estimator-doc-link.fitted:hover,

 /* "?"-specific style due to the `<a>` HTML tag */

-#$id a.estimator_doc_link {
+.sk-global a.estimator_doc_link {
   float: right;
   font-size: 1rem;
   line-height: 1em;
@@ -396,22 +396,22 @@ div.sk-label-container:hover .sk-estimator-doc-link.fitted:hover,
   border: var(--sklearn-color-unfitted-level-1) 1pt solid;
 }

-#$id a.estimator_doc_link.fitted {
+.sk-global a.estimator_doc_link.fitted {
   /* fitted */
   background-color: var(--sklearn-color-fitted-level-0);
   border: var(--sklearn-color-fitted-level-1) 1pt solid;
   color: var(--sklearn-color-fitted-level-1);
 }

 /* On hover */
-#$id a.estimator_doc_link:hover {
+.sk-global a.estimator_doc_link:hover {
   /* unfitted */
   background-color: var(--sklearn-color-unfitted-level-3);
   color: var(--sklearn-color-background);
   text-decoration: none;
 }

-#$id a.estimator_doc_link.fitted:hover {
+.sk-global a.estimator_doc_link.fitted:hover {
   /* fitted */
   background-color: var(--sklearn-color-fitted-level-3);
 }

@@ -6,7 +6,6 @@
 from inspect import isclass
 from io import StringIO
 from pathlib import Path
-from string import Template

 from sklearn import config_context

@@ -203,7 +202,8 @@ def _write_label_html(
         )

         fmt_str = (
-            f'<input class="sk-toggleable__control sk-hidden--visually" id="{est_id}" '
+            f'<input class="sk-toggleable__control sk-hidden--visually '
+            f'sk-global" id="{est_id}" '
             f'type="checkbox" {checked_str}>{label_html}
'
@@ -424,7 +424,7 @@ def estimator_html_repr(estimator):
     >>> from sklearn.utils._repr_html.estimator import estimator_html_repr
     >>> from sklearn.linear_model import LogisticRegression
     >>> estimator_html_repr(LogisticRegression())
-    '"
+            f""
             f""
-            f'
'
+            f'
'
             '
'
             f"
{html.escape(estimator_str)}
{fallback_msg}"
             "
"

From memory, I had the `$id` because I did not know how dynamic the CSS would be in the future. For example, lets say we had a CSS very specific to a particular estimator, then the `id` prevents that CSS from impacting all the other estimators.

From looking at the code now, I do not think thats the case, so I'm okay with a global selector.

(An interesting follow is to only write the CSS on the **first** repr call that is also in Jupyter, so it does not get copied over and over again.)

So it looks good from what I read here. Next steps would be to implement the shadow DOM.

Interestingly, I saw that even with a shadow DOM in `skrub`, the rendering is broken in Marimo (https://github.com/skrub-data/skrub/issues/1817). It is something to have a look at when implementing such option.

Can this be prefixed with `sk`, i.e. `.sk-css-selector`? There is the edge case where another library may add CSS to the page with the same name and override our selector.

This is done. Thanks.

Is it required to have `sk-css-selector` here?

`sk-css-selector` does not really describe what this selector is for, besides that it is a selector. Do you see a better name for this? Some options are: `.sk-root`, `.sk-all` or `.sk-global`.

I think so. If we remove it, this doesn't work:
```
#$id input.sk-hidden--visually {
  border: 0;
  clip: rect(1px 1px 1px 1px);
  clip: rect(1px, 1px, 1px, 1px);
  height: 1px;
  margin: -1px;
  overflow: hidden;
  padding: 0;
  position: absolute;
  width: 1px;
}
```
[That code is here](https://github.com/[USER_REDACTED]n/scikit-learn/blob/173b8d51ecd9586dba2559e3389609ed5cd66f61/sklearn/utils/_repr_html/estimator.css#L41)

... I did try to remove it anyway and adjusted the css but I couldn't make it work. Unless I'm missing something.

Thanks. I changed it.