Repository: scikit-learn/scikit-learn
Pull request: FIX Check and correct the input_tags.sparse flag
#### Reference Issues/PRs

Fixes #30139


#### What does this implement/fix? Explain your changes.

The test `check_estimator_sparse_tag` is added.
It checks that the `input_tags.sparse` tag of an estimator is consistent:
- if  `input_tags.sparse=True` the estimator can be fitted on sparse data
- if `input_tags.sparse=False` the estimator must raise a error when fitted on sparse data

The `input_tags.sparse` flag was edited for a lot of estimators. 


Problem statement:
The input_tags.sparse flag is often incorrect
### Describe the bug

If I understood correctly the developer API for tags, `input_tags.sparse` tells us whether an estimator can accept sparse data or not. For many estimators it seems that `input_tags.sparse` is False but should be True.

### Steps/Code to Reproduce

```python
from sklearn.linear_model import LinearRegression
from sklearn.utils import get_tags

reg = LinearRegression()
tags = get_tags(reg)
tags.input_tags.sparse
```

### Expected Results

`True` as `LinearRegression` accepts sparse input data.

### Actual Results

`False`

### Versions

```shell
System:
    python: 3.12.5 | packaged by conda-forge | (main, Aug  8 2024, 18:32:50) [Clang 16.0.6 ]
executable: /Users/abaker/miniforge3/envs/sklearn-dev/bin/python
   machine: macOS-14.5-arm64-arm-64bit

Python dependencies:
      sklearn: 1.6.dev0
          pip: 24.2
   setuptools: 73.0.1
        numpy: 2.1.0
        scipy: 1.14.1
       Cython: 3.0.11
       pandas: 2.2.2
   matplotlib: 3.9.2
       joblib: 1.4.2
threadpoolctl: 3.5.0

Built with OpenMP: True

threadpoolctl info:
       user_api: blas
   internal_api: openblas
    num_threads: 8
         prefix: libopenblas
       filepath: /Users/abaker/miniforge3/envs/sklearn-dev/lib/libopenblas.0.dylib
        version: 0.3.27
threading_layer: openmp
   architecture: VORTEX

       user_api: openmp
   internal_api: openmp
    num_threads: 8
         prefix: libomp
       filepath: /Users/abaker/miniforge3/envs/sklearn-dev/lib/libomp.dylib
        version: None
```

Hints from issue discussion:
The current estimator check `_check_estimator_sparse_container` does not rely on the presence of the tag: it just calls the estimator with sparse input and checks that the word "sparse" appears somewhere in the exception message if a `ValueError` is raised.

This is quite a low requirement. We could make this a stronger requirement based on the presence of the `input_tags.sparse` value (not raise any exception in this when `True` and check for exception with an informative error message when `False`).
I have submitted a pull request related to this topic. My pull request introduces a custom class, `SparseLinearRegressor`, which supports both sparse matrices and non-negative constraints on the coefficients.

The code may potentially address or complement the solution you're working on. If you have time, I’d be grateful if you could review it, as I believe it could offer valuable insights. I have also included a Jupyter notebook (`SparseLinearRegressor.ipynb`) and its Python conversion (`SparseLinearRegressor.py`) to showcase the functionality in different scenarios, such as sparse data handling.

PR Title: Implementation of a Custom Sparse Linear Regression Class with Sparse Support and Non-Negativity Constraints 
PR Number: #30146.
@Vishal-sys-code this is not really related to this issue. Let's keep the discussion focussed on the tag system of existing scikit-learn estimators and the developer API used to run common estimator checks on all estimators, rather than discussing of the inclusion of new estimators.

Commit message: Merge remote-tracking branch 'upstream/main' into fix_input_sparse_tag

Patch to review:
diff --git a/doc/whats_new/upcoming_changes/changed-models/30187.fix.rst b/doc/whats_new/upcoming_changes/changed-models/30187.fix.rst
new file mode 100644
index 0000000000000..0b662db8f8091
--- /dev/null
+++ b/doc/whats_new/upcoming_changes/changed-models/30187.fix.rst
@@ -0,0 +1,3 @@
+- The `tags.input_tags.sparse` flag was corrected for a
+  majority of estimators.
+  By :user:`Antoine Baker <antoinebaker>`
diff --git a/doc/whats_new/upcoming_changes/sklearn.utils/30187.enhancement.rst b/doc/whats_new/upcoming_changes/sklearn.utils/30187.enhancement.rst
new file mode 100644
index 0000000000000..0a7bf5b06e8df
--- /dev/null
+++ b/doc/whats_new/upcoming_changes/sklearn.utils/30187.enhancement.rst
@@ -0,0 +1,4 @@
+- :func:`utils.estimator_checks.check_estimator_sparse_tag` ensures that
+  the estimator tag `input_tags.sparse` is consistent with its `fit`
+  method (acceting sparse input `X` or raising the appropriate error).
+  By :user:`Antoine Baker <antoinebaker>`
diff --git a/sklearn/calibration.py b/sklearn/calibration.py
index b4023172bb20c..1a39315ba6557 100644
--- a/sklearn/calibration.py
+++ b/sklearn/calibration.py
@@ -28,11 +28,7 @@
 from .model_selection import LeaveOneOut, check_cv, cross_val_predict
 from .preprocessing import LabelEncoder, label_binarize
 from .svm import LinearSVC
-from .utils import (
-    _safe_indexing,
-    column_or_1d,
-    indexable,
-)
+from .utils import _safe_indexing, column_or_1d, get_tags, indexable
 from .utils._param_validation import (
     HasMethods,
     Hidden,
@@ -554,6 +550,11 @@ def get_metadata_routing(self):
         )
         return router
 
+    def __sklearn_tags__(self):
+        tags = super().__sklearn_tags__()
+        tags.input_tags.sparse = get_tags(self._get_estimator()).input_tags.sparse
+        return tags
+
 
 def _fit_classifier_calibrator_pair(
     estimator,
diff --git a/sklearn/cluster/_affinity_propagation.py b/sklearn/cluster/_affinity_propagation.py
index 677421974bdc0..76b5f27a21af4 100644
--- a/sklearn/cluster/_affinity_propagation.py
+++ b/sklearn/cluster/_affinity_propagation.py
@@ -483,6 +483,7 @@ def __init__(
     def __sklearn_tags__(self):
         tags = super().__sklearn_tags__()
         tags.input_tags.pairwise = self.affinity == "precomputed"
+        tags.input_tags.sparse = True
         return tags
 
     @_fit_context(prefer_skip_nested_validation=True)
diff --git a/sklearn/cluster/_bicluster.py b/sklearn/cluster/_bicluster.py
index b3b129d205768..95f49056ef646 100644
--- a/sklearn/cluster/_bicluster.py
+++ b/sklearn/cluster/_bicluster.py
@@ -193,6 +193,11 @@ def _k_means(self, data, n_clusters):
         labels = model.labels_
         return centroid, labels
 
+    def __sklearn_tags__(self):
+        tags = super().__sklearn_tags__()
+        tags.input_tags.sparse = True
+        return tags
+
 
 class SpectralCoclustering(BaseSpectral):
     """Spectral Co-Clustering algorithm (Dhillon, 2001).
diff --git a/sklearn/cluster/_birch.py b/sklearn/cluster/_birch.py
index 3e5f9d10a79e8..4d8abb43513dc 100644
--- a/sklearn/cluster/_birch.py
+++ b/sklearn/cluster/_birch.py
@@ -742,4 +742,5 @@ def _global_clustering(self, X=None):
     def __sklearn_tags__(self):
         tags = super().__sklearn_tags__()
         tags.transformer_tags.preserves_dtype = ["float64", "float32"]
+        tags.input_tags.sparse = True
         return tags
diff --git a/sklearn/cluster/_bisect_k_means.py b/sklearn/cluster/_bisect_k_means.py
index 3c9ccdcf06414..77e24adbf8084 100644
--- a/sklearn/cluster/_bisect_k_means.py
+++ b/sklearn/cluster/_bisect_k_means.py
@@ -538,5 +538,6 @@ def _predict_recursive(self, X, sample_weight, cluster_node):
 
     def __sklearn_tags__(self):
         tags = super().__sklearn_tags__()
+        tags.input_tags.sparse = True
         tags.transformer_tags.preserves_dtype = ["float64", "float32"]
         return tags
diff --git a/sklearn/cluster/_dbscan.py b/sklearn/cluster/_dbscan.py
index 7764bff94582f..d79c4f286d76d 100644
--- a/sklearn/cluster/_dbscan.py
+++ b/sklearn/cluster/_dbscan.py
@@ -473,4 +473,5 @@ def fit_predict(self, X, y=None, sample_weight=None):
     def __sklearn_tags__(self):
         tags = super().__sklearn_tags__()
         tags.input_tags.pairwise = self.metric == "precomputed"
+        tags.input_tags.sparse = True
         return tags
diff --git a/sklearn/cluster/_hdbscan/hdbscan.py b/sklearn/cluster/_hdbscan/hdbscan.py
index 8bf402a5081c9..0607bbb23ba51 100644
--- a/sklearn/cluster/_hdbscan/hdbscan.py
+++ b/sklearn/cluster/_hdbscan/hdbscan.py
@@ -1003,5 +1003,6 @@ def dbscan_clustering(self, cut_distance, min_cluster_size=5):
 
     def __sklearn_tags__(self):
         tags = super().__sklearn_tags__()
+        tags.input_tags.sparse = True
         tags.input_tags.allow_nan = self.metric != "precomputed"
         return tags
diff --git a/sklearn/cluster/_kmeans.py b/sklearn/cluster/_kmeans.py
index 4fdcb4d5eea0f..d400c8e47716f 100644
--- a/sklearn/cluster/_kmeans.py
+++ b/sklearn/cluster/_kmeans.py
@@ -1177,6 +1177,11 @@ def score(self, X, y=None, sample_weight=None):
         )
         return -scores
 
+    def __sklearn_tags__(self):
+        tags = super().__sklearn_tags__()
+        tags.input_tags.sparse = True
+        return tags
+
 
 class KMeans(_BaseKMeans):
     """K-Means clustering.
diff --git a/sklearn/cluster/_spectral.py b/sklearn/cluster/_spectral.py
index ebfeccee677a9..6d1dcd093e803 100644
--- a/sklearn/cluster/_spectral.py
+++ b/sklearn/cluster/_spectral.py
@@ -794,6 +794,7 @@ def fit_predict(self, X, y=None):
 
     def __sklearn_tags__(self):
         tags = super().__sklearn_tags__()
+        tags.input_tags.sparse = True
         tags.input_tags.pairwise = self.affinity in [
             "precomputed",
             "precomputed_nearest_neighbors",
diff --git a/sklearn/compose/_column_transformer.py b/sklearn/compose/_column_transformer.py
index 1985d352619af..ba914ce7821f6 100644
--- a/sklearn/compose/_column_transformer.py
+++ b/sklearn/compose/_column_transformer.py
@@ -29,6 +29,7 @@
     _get_output_config,
     _safe_set_output,
 )
+from ..utils._tags import get_tags
 from ..utils.metadata_routing import (
     MetadataRouter,
     MethodMapping,
@@ -1315,6 +1316,14 @@ def get_metadata_routing(self):
 
         return router
 
+    def __sklearn_tags__(self):
+        tags = super().__sklearn_tags__()
+        tags.input_tags.sparse = all(
+            get_tags(trans).input_tags.sparse if trans != "drop" else True
+            for name, trans, _ in self.transformers
+        )
+        return tags
+
 
 def _check_X(X):
     """Use check_array only when necessary, e.g. on lists and other non-array-likes."""
diff --git a/sklearn/compose/_target.py b/sklearn/compose/_target.py
index d90ee17d13f49..86fc6294878b9 100644
--- a/sklearn/compose/_target.py
+++ b/sklearn/compose/_target.py
@@ -348,6 +348,7 @@ def __sklearn_tags__(self):
         regressor = self._get_regressor()
         tags = super().__sklearn_tags__()
         tags.regressor_tags.poor_score = True
+        tags.input_tags.sparse = get_tags(regressor).input_tags.sparse
         tags.target_tags.multi_output = get_tags(regressor).target_tags.multi_output
         return tags
 
diff --git a/sklearn/decomposition/_incremental_pca.py b/sklearn/decomposition/_incremental_pca.py
index 8fda4ddd1470f..a8ea2dcd558aa 100644
--- a/sklearn/decomposition/_incremental_pca.py
+++ b/sklearn/decomposition/_incremental_pca.py
@@ -418,3 +418,8 @@ def transform(self, X):
             return np.vstack(output)
         else:
             return super().transform(X)
+
+    def __sklearn_tags__(self):
+        tags = super().__sklearn_tags__()
+        tags.input_tags.sparse = True
+        return tags
diff --git a/sklearn/decomposition/_kernel_pca.py b/sklearn/decomposition/_kernel_pca.py
index d9757c7845be1..37ff77c8d7c64 100644
--- a/sklearn/decomposition/_kernel_pca.py
+++ b/sklearn/decomposition/_kernel_pca.py
@@ -566,6 +566,7 @@ def inverse_transform(self, X):
 
     def __sklearn_tags__(self):
         tags = super().__sklearn_tags__()
+        tags.input_tags.sparse = True
         tags.transformer_tags.preserves_dtype = ["float64", "float32"]
         tags.input_tags.pairwise = self.kernel == "precomputed"
         return tags
diff --git a/sklearn/decomposition/_lda.py b/sklearn/decomposition/_lda.py
index 875c6e25fbb10..4580ff073bca5 100644
--- a/sklearn/decomposition/_lda.py
+++ b/sklearn/decomposition/_lda.py
@@ -549,6 +549,7 @@ def _em_step(self, X, total_samples, batch_update, parallel=None):
     def __sklearn_tags__(self):
         tags = super().__sklearn_tags__()
         tags.input_tags.positive_only = True
+        tags.input_tags.sparse = True
         tags.transformer_tags.preserves_dtype = ["float32", "float64"]
         return tags
 
diff --git a/sklearn/decomposition/_nmf.py b/sklearn/decomposition/_nmf.py
index 6be97f2223fb5..dc21e389f6849 100644
--- a/sklearn/decomposition/_nmf.py
+++ b/sklearn/decomposition/_nmf.py
@@ -1331,6 +1331,7 @@ def _n_features_out(self):
     def __sklearn_tags__(self):
         tags = super().__sklearn_tags__()
         tags.input_tags.positive_only = True
+        tags.input_tags.sparse = True
         tags.transformer_tags.preserves_dtype = ["float64", "float32"]
         return tags
 
diff --git a/sklearn/decomposition/_pca.py b/sklearn/decomposition/_pca.py
index 24cb1649c5fee..ba5ca4dd4c680 100644
--- a/sklearn/decomposition/_pca.py
+++ b/sklearn/decomposition/_pca.py
@@ -851,4 +851,5 @@ def __sklearn_tags__(self):
         tags = super().__sklearn_tags__()
         tags.transformer_tags.preserves_dtype = ["float64", "float32"]
         tags.array_api_support = True
+        tags.input_tags.sparse = True
         return tags
diff --git a/sklearn/decomposition/_truncated_svd.py b/sklearn/decomposition/_truncated_svd.py
index b87a53684c140..b77882f5da78d 100644
--- a/sklearn/decomposition/_truncated_svd.py
+++ b/sklearn/decomposition/_truncated_svd.py
@@ -312,6 +312,7 @@ def inverse_transform(self, X):
 
     def __sklearn_tags__(self):
         tags = super().__sklearn_tags__()
+        tags.input_tags.sparse = True
         tags.transformer_tags.preserves_dtype = ["float64", "float32"]
         return tags
 
diff --git a/sklearn/dummy.py b/sklearn/dummy.py
index 571c6e068099a..4610548581afe 100644
--- a/sklearn/dummy.py
+++ b/sklearn/dummy.py
@@ -423,6 +423,7 @@ def predict_log_proba(self, X):
 
     def __sklearn_tags__(self):
         tags = super().__sklearn_tags__()
+        tags.input_tags.sparse = True
         tags.classifier_tags.poor_score = True
         tags.no_validation = True
         return tags
@@ -662,6 +663,7 @@ def predict(self, X, return_std=False):
 
     def __sklearn_tags__(self):
         tags = super().__sklearn_tags__()
+        tags.input_tags.sparse = True
         tags.regressor_tags.poor_score = True
         tags.no_validation = True
         return tags
diff --git a/sklearn/ensemble/_bagging.py b/sklearn/ensemble/_bagging.py
index ca133e9fed27a..20013e1f6d000 100644
--- a/sklearn/ensemble/_bagging.py
+++ b/sklearn/ensemble/_bagging.py
@@ -627,6 +627,7 @@ def _get_estimator(self):
 
     def __sklearn_tags__(self):
         tags = super().__sklearn_tags__()
+        tags.input_tags.sparse = get_tags(self._get_estimator()).input_tags.sparse
         tags.input_tags.allow_nan = get_tags(self._get_estimator()).input_tags.allow_nan
         return tags
 
diff --git a/sklearn/ensemble/_base.py b/sklearn/ensemble/_base.py
index 386c4875a1804..5a51e8e015727 100644
--- a/sklearn/ensemble/_base.py
+++ b/sklearn/ensemble/_base.py
@@ -292,10 +292,16 @@ def __sklearn_tags__(self):
                 get_tags(est[1]).input_tags.allow_nan if est[1] != "drop" else True
                 for est in self.estimators
             )
+            sparse = all(
+                get_tags(est[1]).input_tags.sparse if est[1] != "drop" else True
+                for est in self.estimators
+            )
         except Exception:
             # If `estimators` does not comply with our API (list of tuples) then it will
             # fail. In this case, we assume that `allow_nan` is False but the parameter
             # validation will raise an error during `fit`.
             allow_nan = False
+            sparse = False
         tags.input_tags.allow_nan = allow_nan
+        tags.input_tags.sparse = sparse
         return tags
diff --git a/sklearn/ensemble/_forest.py b/sklearn/ensemble/_forest.py
index a5475eb0e6e62..cb4a12aaf0ddc 100644
--- a/sklearn/ensemble/_forest.py
+++ b/sklearn/ensemble/_forest.py
@@ -1002,6 +1002,7 @@ def predict_log_proba(self, X):
     def __sklearn_tags__(self):
         tags = super().__sklearn_tags__()
         tags.classifier_tags.multi_label = True
+        tags.input_tags.sparse = True
         return tags
 
 
@@ -1168,6 +1169,7 @@ def _compute_partial_dependence_recursion(self, grid, target_features):
     def __sklearn_tags__(self):
         tags = super().__sklearn_tags__()
         tags.regressor_tags.multi_label = True
+        tags.input_tags.sparse = True
         return tags
 
 
@@ -2992,3 +2994,8 @@ def transform(self, X):
         """
         check_is_fitted(self)
         return self.one_hot_encoder_.transform(self.apply(X))
+
+    def __sklearn_tags__(self):
+        tags = super().__sklearn_tags__()
+        tags.input_tags.sparse = True
+        return tags
diff --git a/sklearn/ensemble/_gb.py b/sklearn/ensemble/_gb.py
index 5d67847d3544d..34282db589ff9 100644
--- a/sklearn/ensemble/_gb.py
+++ b/sklearn/ensemble/_gb.py
@@ -1725,6 +1725,11 @@ def staged_predict_proba(self, X):
                 "loss=%r does not support predict_proba" % self.loss
             ) from e
 
+    def __sklearn_tags__(self):
+        tags = super().__sklearn_tags__()
+        tags.input_tags.sparse = True
+        return tags
+
 
 class GradientBoostingRegressor(RegressorMixin, BaseGradientBoosting):
     """Gradient Boosting for regression.
@@ -2185,3 +2190,8 @@ def apply(self, X):
         leaves = super().apply(X)
         leaves = leaves.reshape(X.shape[0], self.estimators_.shape[0])
         return leaves
+
+    def __sklearn_tags__(self):
+        tags = super().__sklearn_tags__()
+        tags.input_tags.sparse = True
+        return tags
diff --git a/sklearn/ensemble/_weight_boosting.py b/sklearn/ensemble/_weight_boosting.py
index cbd5bfe74dba3..065c7204b0a91 100644
--- a/sklearn/ensemble/_weight_boosting.py
+++ b/sklearn/ensemble/_weight_boosting.py
@@ -858,6 +858,11 @@ def predict_log_proba(self, X):
         """
         return np.log(self.predict_proba(X))
 
+    def __sklearn_tags__(self):
+        tags = super().__sklearn_tags__()
+        tags.input_tags.sparse = True
+        return tags
+
 
 class AdaBoostRegressor(_RoutingNotSupportedMixin, RegressorMixin, BaseWeightBoosting):
     """An AdaBoost regressor.
@@ -1166,3 +1171,8 @@ def staged_predict(self, X):
 
         for i, _ in enumerate(self.estimators_, 1):
             yield self._get_median_predict(X, limit=i)
+
+    def __sklearn_tags__(self):
+        tags = super().__sklearn_tags__()
+        tags.input_tags.sparse = True
+        return tags
diff --git a/sklearn/feature_selection/_from_model.py b/sklearn/feature_selection/_from_model.py
index 28af66d524623..d73b53eea647e 100644
--- a/sklearn/feature_selection/_from_model.py
+++ b/sklearn/feature_selection/_from_model.py
@@ -501,5 +501,6 @@ def get_metadata_routing(self):
 
     def __sklearn_tags__(self):
         tags = super().__sklearn_tags__()
+        tags.input_tags.sparse = get_tags(self.estimator).input_tags.sparse
         tags.input_tags.allow_nan = get_tags(self.estimator).input_tags.allow_nan
         return tags
diff --git a/sklearn/feature_selection/_rfe.py b/sklearn/feature_selection/_rfe.py
index bd6a28b97b557..3c2a351440342 100644
--- a/sklearn/feature_selection/_rfe.py
+++ b/sklearn/feature_selection/_rfe.py
@@ -521,6 +521,7 @@ def __sklearn_tags__(self):
         if tags.regressor_tags is not None:
             tags.regressor_tags.poor_score = True
         tags.target_tags.required = True
+        tags.input_tags.sparse = sub_estimator_tags.input_tags.sparse
         tags.input_tags.allow_nan = sub_estimator_tags.input_tags.allow_nan
         return tags
 
diff --git a/sklearn/feature_selection/_sequential.py b/sklearn/feature_selection/_sequential.py
index ac5f13fd00e7d..676106c4a19ca 100644
--- a/sklearn/feature_selection/_sequential.py
+++ b/sklearn/feature_selection/_sequential.py
@@ -329,6 +329,7 @@ def _get_support_mask(self):
     def __sklearn_tags__(self):
         tags = super().__sklearn_tags__()
         tags.input_tags.allow_nan = get_tags(self.estimator).input_tags.allow_nan
+        tags.input_tags.sparse = get_tags(self.estimator).input_tags.sparse
         return tags
 
     def get_metadata_routing(self):
diff --git a/sklearn/feature_selection/_univariate_selection.py b/sklearn/feature_selection/_univariate_selection.py
index 7933818a6a19b..996d5423995d2 100644
--- a/sklearn/feature_selection/_univariate_selection.py
+++ b/sklearn/feature_selection/_univariate_selection.py
@@ -581,6 +581,7 @@ def _check_params(self, X, y):
     def __sklearn_tags__(self):
         tags = super().__sklearn_tags__()
         tags.target_tags.required = True
+        tags.input_tags.sparse = True
         return tags
 
 
diff --git a/sklearn/feature_selection/_variance_threshold.py b/sklearn/feature_selection/_variance_threshold.py
index 1aab9080b964d..f26d70ecf8f82 100644
--- a/sklearn/feature_selection/_variance_threshold.py
+++ b/sklearn/feature_selection/_variance_threshold.py
@@ -137,4 +137,5 @@ def _get_support_mask(self):
     def __sklearn_tags__(self):
         tags = super().__sklearn_tags__()
         tags.input_tags.allow_nan = True
+        tags.input_tags.sparse = True
         return tags
diff --git a/sklearn/impute/_base.py b/sklearn/impute/_base.py
index faf1f9e23b678..7a8f2cc4483e2 100644
--- a/sklearn/impute/_base.py
+++ b/sklearn/impute/_base.py
@@ -739,6 +739,7 @@ def inverse_transform(self, X):
 
     def __sklearn_tags__(self):
         tags = super().__sklearn_tags__()
+        tags.input_tags.sparse = True
         tags.input_tags.allow_nan = is_pandas_na(self.missing_values) or is_scalar_nan(
             self.missing_values
         )
@@ -1130,5 +1131,6 @@ def __sklearn_tags__(self):
         tags = super().__sklearn_tags__()
         tags.input_tags.allow_nan = True
         tags.input_tags.string = True
+        tags.input_tags.sparse = True
         tags.transformer_tags.preserves_dtype = []
         return tags
diff --git a/sklearn/kernel_approximation.py b/sklearn/kernel_approximation.py
index 6364252c980be..35da4d08dcbf4 100644
--- a/sklearn/kernel_approximation.py
+++ b/sklearn/kernel_approximation.py
@@ -235,6 +235,11 @@ def transform(self, X):
 
         return data_sketch
 
+    def __sklearn_tags__(self):
+        tags = super().__sklearn_tags__()
+        tags.input_tags.sparse = True
+        return tags
+
 
 class RBFSampler(ClassNamePrefixFeaturesOutMixin, TransformerMixin, BaseEstimator):
     """Approximate a RBF kernel feature map using random Fourier features.
@@ -404,6 +409,7 @@ def transform(self, X):
 
     def __sklearn_tags__(self):
         tags = super().__sklearn_tags__()
+        tags.input_tags.sparse = True
         tags.transformer_tags.preserves_dtype = ["float64", "float32"]
         return tags
 
@@ -826,6 +832,7 @@ def __sklearn_tags__(self):
         tags = super().__sklearn_tags__()
         tags.requires_fit = False
         tags.input_tags.positive_only = True
+        tags.input_tags.sparse = True
         return tags
 
 
@@ -1094,5 +1101,6 @@ def _get_kernel_params(self):
 
     def __sklearn_tags__(self):
         tags = super().__sklearn_tags__()
+        tags.input_tags.sparse = True
         tags.transformer_tags.preserves_dtype = ["float64", "float32"]
         return tags
diff --git a/sklearn/kernel_ridge.py b/sklearn/kernel_ridge.py
index 983b463508c5b..29e744647acc9 100644
--- a/sklearn/kernel_ridge.py
+++ b/sklearn/kernel_ridge.py
@@ -169,6 +169,7 @@ def _get_kernel(self, X, Y=None):
 
     def __sklearn_tags__(self):
         tags = super().__sklearn_tags__()
+        tags.input_tags.sparse = True
         tags.input_tags.pairwise = self.kernel == "precomputed"
         return tags
 
diff --git a/sklearn/linear_model/_base.py b/sklearn/linear_model/_base.py
index 3bb3b8b7626d8..bb71cbe9ed550 100644
--- a/sklearn/linear_model/_base.py
+++ b/sklearn/linear_model/_base.py
@@ -687,6 +687,11 @@ def rmatvec(b):
         self._set_intercept(X_offset, y_offset, X_scale)
         return self
 
+    def __sklearn_tags__(self):
+        tags = super().__sklearn_tags__()
+        tags.input_tags.sparse = not self.positive
+        return tags
+
 
 def _check_precomputed_gram_matrix(
     X, precompute, X_offset, X_scale, rtol=None, atol=1e-5
diff --git a/sklearn/linear_model/_coordinate_descent.py b/sklearn/linear_model/_coordinate_descent.py
index 2dbb83c82fbaa..b8430fe00b862 100644
--- a/sklearn/linear_model/_coordinate_descent.py
+++ b/sklearn/linear_model/_coordinate_descent.py
@@ -1149,6 +1149,11 @@ def _decision_function(self, X):
         else:
             return super()._decision_function(X)
 
+    def __sklearn_tags__(self):
+        tags = super().__sklearn_tags__()
+        tags.input_tags.sparse = True
+        return tags
+
 
 ###############################################################################
 # Lasso model
@@ -2078,6 +2083,7 @@ def _is_multitask(self):
 
     def __sklearn_tags__(self):
         tags = super().__sklearn_tags__()
+        tags.input_tags.sparse = True
         tags.target_tags.multi_output = False
         return tags
 
@@ -2359,6 +2365,7 @@ def _is_multitask(self):
 
     def __sklearn_tags__(self):
         tags = super().__sklearn_tags__()
+        tags.input_tags.sparse = True
         tags.target_tags.multi_output = False
         return tags
 
@@ -2654,6 +2661,7 @@ def fit(self, X, y):
 
     def __sklearn_tags__(self):
         tags = super().__sklearn_tags__()
+        tags.input_tags.sparse = False
         tags.target_tags.multi_output = True
         tags.target_tags.single_output = False
         return tags
diff --git a/sklearn/linear_model/_glm/glm.py b/sklearn/linear_model/_glm/glm.py
index 093a813f60550..fc31f9825d2e5 100644
--- a/sklearn/linear_model/_glm/glm.py
+++ b/sklearn/linear_model/_glm/glm.py
@@ -442,6 +442,7 @@ def score(self, X, y, sample_weight=None):
 
     def __sklearn_tags__(self):
         tags = super().__sklearn_tags__()
+        tags.input_tags.sparse = True
         try:
             # Create instance of BaseLoss if fit wasn't called yet. This is necessary as
             # TweedieRegressor might set the used loss during fit different from
diff --git a/sklearn/linear_model/_huber.py b/sklearn/linear_model/_huber.py
index 9e41cc4eae3b5..81fdfa51ead51 100644
--- a/sklearn/linear_model/_huber.py
+++ b/sklearn/linear_model/_huber.py
@@ -351,3 +351,8 @@ def fit(self, X, y, sample_weight=None):
         residual = np.abs(y - safe_sparse_dot(X, self.coef_) - self.intercept_)
         self.outliers_ = residual > self.scale_ * self.epsilon
         return self
+
+    def __sklearn_tags__(self):
+        tags = super().__sklearn_tags__()
+        tags.input_tags.sparse = True
+        return tags
diff --git a/sklearn/linear_model/_logistic.py b/sklearn/linear_model/_logistic.py
index ff7f09aee896a..291c3972eb3e5 100644
--- a/sklearn/linear_model/_logistic.py
+++ b/sklearn/linear_model/_logistic.py
@@ -1457,6 +1457,11 @@ def predict_log_proba(self, X):
         """
         return np.log(self.predict_proba(X))
 
+    def __sklearn_tags__(self):
+        tags = super().__sklearn_tags__()
+        tags.input_tags.sparse = True
+        return tags
+
 
 class LogisticRegressionCV(LogisticRegression, LinearClassifierMixin, BaseEstimator):
     """Logistic Regression CV (aka logit, MaxEnt) classifier.
@@ -2274,3 +2279,8 @@ def _get_scorer(self):
         """
         scoring = self.scoring or "accuracy"
         return get_scorer(scoring)
+
+    def __sklearn_tags__(self):
+        tags = super().__sklearn_tags__()
+        tags.input_tags.sparse = True
+        return tags
diff --git a/sklearn/linear_model/_quantile.py b/sklearn/linear_model/_quantile.py
index 883a41558f2f7..446d232958e8d 100644
--- a/sklearn/linear_model/_quantile.py
+++ b/sklearn/linear_model/_quantile.py
@@ -294,3 +294,8 @@ def fit(self, X, y, sample_weight=None):
             self.coef_ = params
             self.intercept_ = 0.0
         return self
+
+    def __sklearn_tags__(self):
+        tags = super().__sklearn_tags__()
+        tags.input_tags.sparse = True
+        return tags
diff --git a/sklearn/linear_model/_ransac.py b/sklearn/linear_model/_ransac.py
index 1203ce71c0534..a5fa36aa4c468 100644
--- a/sklearn/linear_model/_ransac.py
+++ b/sklearn/linear_model/_ransac.py
@@ -721,3 +721,8 @@ def get_metadata_routing(self):
             .add(caller="predict", callee="predict"),
         )
         return router
+
+    def __sklearn_tags__(self):
+        tags = super().__sklearn_tags__()
+        tags.input_tags.sparse = True
+        return tags
diff --git a/sklearn/linear_model/_ridge.py b/sklearn/linear_model/_ridge.py
index fab71feb2e140..5adb70bb4bbab 100644
--- a/sklearn/linear_model/_ridge.py
+++ b/sklearn/linear_model/_ridge.py
@@ -1253,6 +1253,10 @@ def fit(self, X, y, sample_weight=None):
     def __sklearn_tags__(self):
         tags = super().__sklearn_tags__()
         tags.array_api_support = True
+        reject_sparse = (self.solver == "svd") or (
+            self.solver == "cholesky" and self.fit_intercept
+        )
+        tags.input_tags.sparse = not reject_sparse
         return tags
 
 
@@ -1570,6 +1574,14 @@ def fit(self, X, y, sample_weight=None):
         super().fit(X, Y, sample_weight=sample_weight)
         return self
 
+    def __sklearn_tags__(self):
+        tags = super().__sklearn_tags__()
+        reject_sparse = (self.solver == "svd") or (
+            self.solver == "cholesky" and self.fit_intercept
+        )
+        tags.input_tags.sparse = not reject_sparse
+        return tags
+
 
 def _check_gcv_mode(X, gcv_mode):
     if gcv_mode in ["eigen", "svd"]:
@@ -2534,6 +2546,11 @@ def _get_scorer(self):
     def cv_values_(self):
         return self.cv_results_
 
+    def __sklearn_tags__(self):
+        tags = super().__sklearn_tags__()
+        tags.input_tags.sparse = True
+        return tags
+
 
 class RidgeCV(MultiOutputMixin, RegressorMixin, _BaseRidgeCV):
     """Ridge regression with built-in cross-validation.
diff --git a/sklearn/linear_model/_stochastic_gradient.py b/sklearn/linear_model/_stochastic_gradient.py
index ab475f3e1f304..d4c196a6fc8ca 100644
--- a/sklearn/linear_model/_stochastic_gradient.py
+++ b/sklearn/linear_model/_stochastic_gradient.py
@@ -941,6 +941,11 @@ def fit(self, X, y, coef_init=None, intercept_init=None, sample_weight=None):
             sample_weight=sample_weight,
         )
 
+    def __sklearn_tags__(self):
+        tags = super().__sklearn_tags__()
+        tags.input_tags.sparse = True
+        return tags
+
 
 class SGDClassifier(BaseSGDClassifier):
     """Linear classifiers (SVM, logistic regression, etc.) with SGD training.
@@ -1772,6 +1777,11 @@ def _fit_regressor(
         else:
             self.intercept_ = np.atleast_1d(intercept)
 
+    def __sklearn_tags__(self):
+        tags = super().__sklearn_tags__()
+        tags.input_tags.sparse = True
+        return tags
+
 
 class SGDRegressor(BaseSGDRegressor):
     """Linear model fitted by minimizing a regularized empirical loss with SGD.
@@ -2633,3 +2643,8 @@ def predict(self, X):
         y = (self.decision_function(X) >= 0).astype(np.int32)
         y[y == 0] = -1  # for consistency with outlier detectors
         return y
+
+    def __sklearn_tags__(self):
+        tags = super().__sklearn_tags__()
+        tags.input_tags.sparse = True
+        return tags
diff --git a/sklearn/manifold/_isomap.py b/sklearn/manifold/_isomap.py
index ee302bc07b384..90154470c18a4 100644
--- a/sklearn/manifold/_isomap.py
+++ b/sklearn/manifold/_isomap.py
@@ -438,4 +438,5 @@ def transform(self, X):
     def __sklearn_tags__(self):
         tags = super().__sklearn_tags__()
         tags.transformer_tags.preserves_dtype = ["float64", "float32"]
+        tags.input_tags.sparse = True
         return tags
diff --git a/sklearn/manifold/_spectral_embedding.py b/sklearn/manifold/_spectral_embedding.py
index ebd5d7c5b651b..d3d45ec0773c3 100644
--- a/sklearn/manifold/_spectral_embedding.py
+++ b/sklearn/manifold/_spectral_embedding.py
@@ -650,6 +650,7 @@ def __init__(
 
     def __sklearn_tags__(self):
         tags = super().__sklearn_tags__()
+        tags.input_tags.sparse = True
         tags.input_tags.pairwise = self.affinity in [
             "precomputed",
             "precomputed_nearest_neighbors",
diff --git a/sklearn/model_selection/_classification_threshold.py b/sklearn/model_selection/_classification_threshold.py
index 4bd0ff9972fdc..ff1a82d584606 100644
--- a/sklearn/model_selection/_classification_threshold.py
+++ b/sklearn/model_selection/_classification_threshold.py
@@ -22,7 +22,7 @@
     _CurveScorer,
     _threshold_scores_to_class_labels,
 )
-from ..utils import _safe_indexing
+from ..utils import _safe_indexing, get_tags
 from ..utils._param_validation import HasMethods, Interval, RealNotInt, StrOptions
 from ..utils._response import _get_response_values_binary
 from ..utils.metadata_routing import (
@@ -206,6 +206,7 @@ def decision_function(self, X):
     def __sklearn_tags__(self):
         tags = super().__sklearn_tags__()
         tags.classifier_tags.multi_class = False
+        tags.input_tags.sparse = get_tags(self.estimator).input_tags.sparse
         return tags
 
 
diff --git a/sklearn/model_selection/_search.py b/sklearn/model_selection/_search.py
index 7515436af33da..30c3952f63551 100644
--- a/sklearn/model_selection/_search.py
+++ b/sklearn/model_selection/_search.py
@@ -488,8 +488,9 @@ def __sklearn_tags__(self):
         tags.classifier_tags = deepcopy(sub_estimator_tags.classifier_tags)
         tags.regressor_tags = deepcopy(sub_estimator_tags.regressor_tags)
         # allows cross-validation to see 'precomputed' metrics
-        tags.input_tags.pairwise = get_tags(self.estimator).input_tags.pairwise
-        tags.array_api_support = get_tags(self.estimator).array_api_support
+        tags.input_tags.pairwise = sub_estimator_tags.input_tags.pairwise
+        tags.input_tags.sparse = sub_estimator_tags.input_tags.sparse
+        tags.array_api_support = sub_estimator_tags.array_api_support
         return tags
 
     def score(self, X, y=None, **params):
diff --git a/sklearn/multiclass.py b/sklearn/multiclass.py
index dca055ecbfb4a..1ddb36ca4fa8f 100644
--- a/sklearn/multiclass.py
+++ b/sklearn/multiclass.py
@@ -601,6 +601,7 @@ def __sklearn_tags__(self):
         """Indicate if wrapped estimator is using a precomputed Gram matrix"""
         tags = super().__sklearn_tags__()
         tags.input_tags.pairwise = get_tags(self.estimator).input_tags.pairwise
+        tags.input_tags.sparse = get_tags(self.estimator).input_tags.sparse
         return tags
 
     def get_metadata_routing(self):
@@ -1004,6 +1005,7 @@ def __sklearn_tags__(self):
         """Indicate if wrapped estimator is using a precomputed Gram matrix"""
         tags = super().__sklearn_tags__()
         tags.input_tags.pairwise = get_tags(self.estimator).input_tags.pairwise
+        tags.input_tags.sparse = get_tags(self.estimator).input_tags.sparse
         return tags
 
     def get_metadata_routing(self):
@@ -1276,3 +1278,8 @@ def get_metadata_routing(self):
             method_mapping=MethodMapping().add(caller="fit", callee="fit"),
         )
         return router
+
+    def __sklearn_tags__(self):
+        tags = super().__sklearn_tags__()
+        tags.input_tags.sparse = get_tags(self.estimator).input_tags.sparse
+        return tags
diff --git a/sklearn/multioutput.py b/sklearn/multioutput.py
index ebcd73e95d881..38b6eb4a7e0ec 100644
--- a/sklearn/multioutput.py
+++ b/sklearn/multioutput.py
@@ -25,7 +25,7 @@
     is_classifier,
 )
 from .model_selection import cross_val_predict
-from .utils import Bunch, check_random_state
+from .utils import Bunch, check_random_state, get_tags
 from .utils._param_validation import HasMethods, StrOptions
 from .utils._response import _get_response_values
 from .utils._user_interface import _print_elapsed_time
@@ -311,6 +311,7 @@ def predict(self, X):
 
     def __sklearn_tags__(self):
         tags = super().__sklearn_tags__()
+        tags.input_tags.sparse = get_tags(self.estimator).input_tags.sparse
         tags.target_tags.single_output = False
         tags.target_tags.multi_output = True
         return tags
@@ -829,6 +830,11 @@ def predict(self, X):
         """
         return self._get_predictions(X, output_method="predict")
 
+    def __sklearn_tags__(self):
+        tags = super().__sklearn_tags__()
+        tags.input_tags.sparse = get_tags(self.base_estimator).input_tags.sparse
+        return tags
+
 
 class ClassifierChain(MetaEstimatorMixin, ClassifierMixin, _BaseChain):
     """A multi-label model that arranges binary classifiers into a chain.
diff --git a/sklearn/naive_bayes.py b/sklearn/naive_bayes.py
index a483fd0df0d37..3418e99e17c3c 100644
--- a/sklearn/naive_bayes.py
+++ b/sklearn/naive_bayes.py
@@ -880,6 +880,7 @@ def __init__(
 
     def __sklearn_tags__(self):
         tags = super().__sklearn_tags__()
+        tags.input_tags.sparse = True
         tags.input_tags.positive_only = True
         return tags
 
@@ -1028,6 +1029,7 @@ def __init__(
 
     def __sklearn_tags__(self):
         tags = super().__sklearn_tags__()
+        tags.input_tags.sparse = True
         tags.input_tags.positive_only = True
         return tags
 
@@ -1227,6 +1229,11 @@ def _joint_log_likelihood(self, X):
 
         return jll
 
+    def __sklearn_tags__(self):
+        tags = super().__sklearn_tags__()
+        tags.input_tags.sparse = True
+        return tags
+
 
 class CategoricalNB(_BaseDiscreteNB):
     """Naive Bayes classifier for categorical features.
@@ -1432,6 +1439,7 @@ def partial_fit(self, X, y, classes=None, sample_weight=None):
 
     def __sklearn_tags__(self):
         tags = super().__sklearn_tags__()
+        tags.input_tags.sparse = False
         tags.input_tags.positive_only = True
         return tags
 
diff --git a/sklearn/neighbors/_base.py b/sklearn/neighbors/_base.py
index cdcd8929da6ca..6fa99d49b639e 100644
--- a/sklearn/neighbors/_base.py
+++ b/sklearn/neighbors/_base.py
@@ -707,6 +707,7 @@ def _fit(self, X, y=None):
 
     def __sklearn_tags__(self):
         tags = super().__sklearn_tags__()
+        tags.input_tags.sparse = True
         # For cross-validation routines to split data correctly
         tags.input_tags.pairwise = self.metric == "precomputed"
         tags.input_tags.allow_nan = self.metric == "nan_euclidean"
diff --git a/sklearn/neighbors/_nearest_centroid.py b/sklearn/neighbors/_nearest_centroid.py
index b30dc309b2dd7..a780c27587792 100644
--- a/sklearn/neighbors/_nearest_centroid.py
+++ b/sklearn/neighbors/_nearest_centroid.py
@@ -355,4 +355,5 @@ def _check_euclidean_metric(self):
     def __sklearn_tags__(self):
         tags = super().__sklearn_tags__()
         tags.input_tags.allow_nan = self.metric == "nan_euclidean"
+        tags.input_tags.sparse = True
         return tags
diff --git a/sklearn/neural_network/_multilayer_perceptron.py b/sklearn/neural_network/_multilayer_perceptron.py
index 196203ce46763..47805857b5154 100644
--- a/sklearn/neural_network/_multilayer_perceptron.py
+++ b/sklearn/neural_network/_multilayer_perceptron.py
@@ -771,6 +771,11 @@ def _score_with_function(self, X, y, score_function):
 
         return score_function(y, y_pred)
 
+    def __sklearn_tags__(self):
+        tags = super().__sklearn_tags__()
+        tags.input_tags.sparse = True
+        return tags
+
 
 class MLPClassifier(ClassifierMixin, BaseMultilayerPerceptron):
     """Multi-layer Perceptron classifier.
diff --git a/sklearn/neural_network/_rbm.py b/sklearn/neural_network/_rbm.py
index c5f49087b758d..1e1d3c2e11b7c 100644
--- a/sklearn/neural_network/_rbm.py
+++ b/sklearn/neural_network/_rbm.py
@@ -440,5 +440,6 @@ def fit(self, X, y=None):
 
     def __sklearn_tags__(self):
         tags = super().__sklearn_tags__()
+        tags.input_tags.sparse = True
         tags.transformer_tags.preserves_dtype = ["float64", "float32"]
         return tags
diff --git a/sklearn/pipeline.py b/sklearn/pipeline.py
index 9ff8a3549ef28..f8422ae934d28 100644
--- a/sklearn/pipeline.py
+++ b/sklearn/pipeline.py
@@ -1224,6 +1224,7 @@ def __sklearn_tags__(self):
                 tags.input_tags.pairwise = get_tags(
                     self.steps[0][1]
                 ).input_tags.pairwise
+                tags.input_tags.sparse = get_tags(self.steps[0][1]).input_tags.sparse
         except (ValueError, AttributeError, TypeError):
             # This happens when the `steps` is not a list of (name, estimator)
             # tuples and `fit` is not called yet to validate the steps.
diff --git a/sklearn/preprocessing/_data.py b/sklearn/preprocessing/_data.py
index 74ea7431a5d72..8d428fe50c7f8 100644
--- a/sklearn/preprocessing/_data.py
+++ b/sklearn/preprocessing/_data.py
@@ -1130,6 +1130,7 @@ def inverse_transform(self, X, copy=None):
     def __sklearn_tags__(self):
         tags = super().__sklearn_tags__()
         tags.input_tags.allow_nan = True
+        tags.input_tags.sparse = not self.with_mean
         tags.transformer_tags.preserves_dtype = ["float64", "float32"]
         return tags
 
@@ -1363,6 +1364,7 @@ def inverse_transform(self, X):
     def __sklearn_tags__(self):
         tags = super().__sklearn_tags__()
         tags.input_tags.allow_nan = True
+        tags.input_tags.sparse = True
         return tags
 
 
@@ -2136,6 +2138,7 @@ def transform(self, X, copy=None):
 
     def __sklearn_tags__(self):
         tags = super().__sklearn_tags__()
+        tags.input_tags.sparse = True
         tags.requires_fit = False
         tags.array_api_support = True
         return tags
@@ -2343,6 +2346,7 @@ def transform(self, X, copy=None):
     def __sklearn_tags__(self):
         tags = super().__sklearn_tags__()
         tags.requires_fit = False
+        tags.input_tags.sparse = True
         return tags
 
 
@@ -3009,6 +3013,7 @@ def inverse_transform(self, X):
 
     def __sklearn_tags__(self):
         tags = super().__sklearn_tags__()
+        tags.input_tags.sparse = True
         tags.input_tags.allow_nan = True
         return tags
 
diff --git a/sklearn/preprocessing/_function_transformer.py b/sklearn/preprocessing/_function_transformer.py
index 02379273e302e..54166fdacb2ad 100644
--- a/sklearn/preprocessing/_function_transformer.py
+++ b/sklearn/preprocessing/_function_transformer.py
@@ -394,6 +394,7 @@ def __sklearn_tags__(self):
         tags = super().__sklearn_tags__()
         tags.no_validation = not self.validate
         tags.requires_fit = False
+        tags.input_tags.sparse = True
         return tags
 
     def set_output(self, *, transform=None):
diff --git a/sklearn/preprocessing/_polynomial.py b/sklearn/preprocessing/_polynomial.py
index a6c69d73666a6..6bf85c4d6f661 100644
--- a/sklearn/preprocessing/_polynomial.py
+++ b/sklearn/preprocessing/_polynomial.py
@@ -585,6 +585,11 @@ def transform(self, X):
                 XP = Xout
         return XP
 
+    def __sklearn_tags__(self):
+        tags = super().__sklearn_tags__()
+        tags.input_tags.sparse = True
+        return tags
+
 
 class SplineTransformer(TransformerMixin, BaseEstimator):
     """Generate univariate B-spline bases for features.
diff --git a/sklearn/random_projection.py b/sklearn/random_projection.py
index ca328f84733f8..74741585f7761 100644
--- a/sklearn/random_projection.py
+++ b/sklearn/random_projection.py
@@ -463,6 +463,7 @@ def inverse_transform(self, X):
     def __sklearn_tags__(self):
         tags = super().__sklearn_tags__()
         tags.transformer_tags.preserves_dtype = ["float64", "float32"]
+        tags.input_tags.sparse = True
         return tags
 
 
diff --git a/sklearn/semi_supervised/_label_propagation.py b/sklearn/semi_supervised/_label_propagation.py
index a2e25277cf450..3eaee712b93a4 100644
--- a/sklearn/semi_supervised/_label_propagation.py
+++ b/sklearn/semi_supervised/_label_propagation.py
@@ -336,6 +336,11 @@ def fit(self, X, y):
         self.transduction_ = transduction.ravel()
         return self
 
+    def __sklearn_tags__(self):
+        tags = super().__sklearn_tags__()
+        tags.input_tags.sparse = True
+        return tags
+
 
 class LabelPropagation(BaseLabelPropagation):
     """Label Propagation classifier.
diff --git a/sklearn/semi_supervised/_self_training.py b/sklearn/semi_supervised/_self_training.py
index 6b5c343ad661d..b28a6bd88305e 100644
--- a/sklearn/semi_supervised/_self_training.py
+++ b/sklearn/semi_supervised/_self_training.py
@@ -7,7 +7,7 @@
 from sklearn.base import ClassifierMixin
 
 from ..base import BaseEstimator, MetaEstimatorMixin, _fit_context, clone
-from ..utils import Bunch, safe_mask
+from ..utils import Bunch, get_tags, safe_mask
 from ..utils._param_validation import HasMethods, Hidden, Interval, StrOptions
 from ..utils.metadata_routing import (
     MetadataRouter,
@@ -613,3 +613,8 @@ def get_metadata_routing(self):
             ),
         )
         return router
+
+    def __sklearn_tags__(self):
+        tags = super().__sklearn_tags__()
+        tags.input_tags.sparse = get_tags(self.estimator).input_tags.sparse
+        return tags
diff --git a/sklearn/svm/_classes.py b/sklearn/svm/_classes.py
index 664c7443045d2..f29f9baa0f139 100644
--- a/sklearn/svm/_classes.py
+++ b/sklearn/svm/_classes.py
@@ -349,6 +349,11 @@ def fit(self, X, y, sample_weight=None):
 
         return self
 
+    def __sklearn_tags__(self):
+        tags = super().__sklearn_tags__()
+        tags.input_tags.sparse = True
+        return tags
+
 
 class LinearSVR(RegressorMixin, LinearModel):
     """Linear Support Vector Regression.
@@ -600,6 +605,11 @@ def fit(self, X, y, sample_weight=None):
 
         return self
 
+    def __sklearn_tags__(self):
+        tags = super().__sklearn_tags__()
+        tags.input_tags.sparse = True
+        return tags
+
 
 class SVC(BaseSVC):
     """C-Support Vector Classification.
@@ -877,6 +887,11 @@ def __init__(
             random_state=random_state,
         )
 
+    def __sklearn_tags__(self):
+        tags = super().__sklearn_tags__()
+        tags.input_tags.sparse = self.kernel != "precomputed"
+        return tags
+
 
 class NuSVC(BaseSVC):
     """Nu-Support Vector Classification.
@@ -1140,6 +1155,11 @@ def __init__(
             random_state=random_state,
         )
 
+    def __sklearn_tags__(self):
+        tags = super().__sklearn_tags__()
+        tags.input_tags.sparse = self.kernel != "precomputed"
+        return tags
+
 
 class SVR(RegressorMixin, BaseLibSVM):
     """Epsilon-Support Vector Regression.
@@ -1334,6 +1354,11 @@ def __init__(
             random_state=None,
         )
 
+    def __sklearn_tags__(self):
+        tags = super().__sklearn_tags__()
+        tags.input_tags.sparse = self.kernel != "precomputed"
+        return tags
+
 
 class NuSVR(RegressorMixin, BaseLibSVM):
     """Nu Support Vector Regression.
@@ -1521,6 +1546,11 @@ def __init__(
             random_state=None,
         )
 
+    def __sklearn_tags__(self):
+        tags = super().__sklearn_tags__()
+        tags.input_tags.sparse = self.kernel != "precomputed"
+        return tags
+
 
 class OneClassSVM(OutlierMixin, BaseLibSVM):
     """Unsupervised Outlier Detection.
@@ -1777,3 +1807,8 @@ def predict(self, X):
         """
         y = super().predict(X)
         return np.asarray(y, dtype=np.intp)
+
+    def __sklearn_tags__(self):
+        tags = super().__sklearn_tags__()
+        tags.input_tags.sparse = True
+        return tags
diff --git a/sklearn/tree/_classes.py b/sklearn/tree/_classes.py
index 93246a1376e85..f94dd70aefe1c 100644
--- a/sklearn/tree/_classes.py
+++ b/sklearn/tree/_classes.py
@@ -1100,6 +1100,7 @@ def __sklearn_tags__(self):
         }
         tags.classifier_tags.multi_label = True
         tags.input_tags.allow_nan = allow_nan
+        tags.input_tags.sparse = True
         return tags
 
 
@@ -1442,6 +1443,7 @@ def __sklearn_tags__(self):
             "poisson",
         }
         tags.input_tags.allow_nan = allow_nan
+        tags.input_tags.sparse = True
         return tags
 
 
diff --git a/sklearn/utils/_test_common/instance_generator.py b/sklearn/utils/_test_common/instance_generator.py
index e74afd28a0dc3..7eeb0c848c508 100644
--- a/sklearn/utils/_test_common/instance_generator.py
+++ b/sklearn/utils/_test_common/instance_generator.py
@@ -545,6 +545,9 @@
         "check_dict_unchanged": dict(batch_size=10, max_iter=5, n_components=1)
     },
     LinearDiscriminantAnalysis: {"check_dict_unchanged": dict(n_components=1)},
+    LinearRegression: {
+        "check_estimator_sparse_tag": [dict(positive=False), dict(positive=True)]
+    },
     LocallyLinearEmbedding: {"check_dict_unchanged": dict(max_iter=5, n_components=1)},
     LogisticRegression: {
         "check_sample_weight_equivalence": [
diff --git a/sklearn/utils/estimator_checks.py b/sklearn/utils/estimator_checks.py
index abf272e955bc2..c963040f1ff55 100644
--- a/sklearn/utils/estimator_checks.py
+++ b/sklearn/utils/estimator_checks.py
@@ -187,6 +187,7 @@ def _yield_checks(estimator):
     if hasattr(estimator, "sparsify"):
         yield check_sparsify_coefficients
 
+    yield check_estimator_sparse_tag
     yield check_estimator_sparse_array
     yield check_estimator_sparse_matrix
 
@@ -1192,6 +1193,39 @@ def check_array_api_input_and_values(
     )
 
 
+def check_estimator_sparse_tag(name, estimator_orig):
+    if SPARSE_ARRAY_PRESENT:
+        sparse_container = sparse.csr_array
+    else:
+        sparse_container = sparse.csr_matrix
+    estimator = clone(estimator_orig)
+
+    rng = np.random.RandomState(0)
+    n_samples = 15 if name == "SpectralCoclustering" else 40
+    X = rng.uniform(size=(n_samples, 3))
+    X[X < 0.6] = 0
+    y = rng.randint(0, 3, size=n_samples)
+    X = _enforce_estimator_tags_X(estimator, X)
+    y = _enforce_estimator_tags_y(estimator, y)
+    X = sparse_container(X)
+
+    tags = get_tags(estimator)
+    if tags.input_tags.sparse:
+        estimator.fit(X, y)  # should pass
+    else:
+        err_msg = (
+            f"Estimator {name} with input_tags.sparse=False doesn't "
+            "fail gracefully when fitted on sparse data."
+        )
+        with raises(
+            (TypeError, ValueError),
+            match=["sparse", "Sparse"],
+            may_pass=False,
+            err_msg=err_msg,
+        ):
+            estimator.fit(X, y)  # should fail
+
+
 def _check_estimator_sparse_container(name, estimator_orig, sparse_type):
     rng = np.random.RandomState(0)
     X = rng.uniform(size=(40, 3))
diff --git a/sklearn/utils/tests/test_estimator_checks.py b/sklearn/utils/tests/test_estimator_checks.py
index d09b3e7f366ec..7329277722dfb 100644
--- a/sklearn/utils/tests/test_estimator_checks.py
+++ b/sklearn/utils/tests/test_estimator_checks.py
@@ -507,7 +507,8 @@ def __sklearn_tags__(self):
 
 class RequiresPositiveXRegressor(LinearRegression):
     def fit(self, X, y):
-        X, y = validate_data(self, X, y, multi_output=True)
+        # reject sparse X to be able to call (X < 0).any()
+        X, y = validate_data(self, X, y, accept_sparse=False, multi_output=True)
         if (X < 0).any():
             raise ValueError("negative X values not supported!")
         return super().fit(X, y)
@@ -515,12 +516,14 @@ def fit(self, X, y):
     def __sklearn_tags__(self):
         tags = super().__sklearn_tags__()
         tags.input_tags.positive_only = True
+        # reject sparse X to be able to call (X < 0).any()
+        tags.input_tags.sparse = False
         return tags
 
 
 class RequiresPositiveYRegressor(LinearRegression):
     def fit(self, X, y):
-        X, y = validate_data(self, X, y, multi_output=True)
+        X, y = validate_data(self, X, y, accept_sparse=True, multi_output=True)
         if (y <= 0).any():
             raise ValueError("negative y values not supported!")
         return super().fit(X, y)


Code review comments:
diff_hunk: @@ -0,0 +1,4 @@
+- :func:`utils.estimator_checks.check_estimator_sparse_tag` ensures that
+  the estimator tag `input_tags.sparse` is consistent with its `fit`
+  method (acceting sparse input `X` or raising the appropriate error).
path: doc/whats_new/upcoming_changes/sklearn.utils/30187.enhancement.rst
text: @user1:

```suggestion
  method (accepting sparse input `X` or raising the appropriate error).
```

diff_hunk: @@ -1725,6 +1725,11 @@ def staged_predict_proba(self, X):
                 "loss=%r does not support predict_proba" % self.loss
             ) from e
 
+    def __sklearn_tags__(self):
path: sklearn/ensemble/_gb.py
text: @user1:
Isn't it possible to have a single `__sklearn_tags__(self)` defined in `BaseGradientBoosting`?

@author:
done in 705c414

diff_hunk: @@ -2359,6 +2365,7 @@ def _is_multitask(self):
 
     def __sklearn_tags__(self):
         tags = super().__sklearn_tags__()
+        tags.input_tags.sparse = True
path: sklearn/linear_model/_coordinate_descent.py
text: @user1:
Same here. Move it to `LinearModelCV`.

@author:
done in 705c414

diff_hunk: @@ -483,6 +483,7 @@ def __init__(
     def __sklearn_tags__(self):
         tags = super().__sklearn_tags__()
         tags.input_tags.pairwise = self.affinity == "precomputed"
+        tags.input_tags.sparse = True
path: sklearn/cluster/_affinity_propagation.py
text: @user1:
From what I see in `fit` I don't think that we should accept sparse in the "precomputed" case.


```suggestion
        tags.input_tags.sparse = self.affinity != "precomputed"
```

diff_hunk: @@ -2078,6 +2083,7 @@ def _is_multitask(self):
 
     def __sklearn_tags__(self):
         tags = super().__sklearn_tags__()
+        tags.input_tags.sparse = True
         tags.target_tags.multi_output = False
path: sklearn/linear_model/_coordinate_descent.py
text: @user1:
I'm even wondering if the multi_output could also be moved together with teh `_is_multitask` method but it needs to be checked.

@author:
done in 3ac38e1

diff_hunk: @@ -721,3 +721,8 @@ def get_metadata_routing(self):
             .add(caller="predict", callee="predict"),
         )
         return router
+
+    def __sklearn_tags__(self):
+        tags = super().__sklearn_tags__()
+        tags.input_tags.sparse = True
path: sklearn/linear_model/_ransac.py
text: @user1:
I agree that `fit` involved this change. However, I'm wondering if we should not instead delegate to the underlying estimator instead: if the underlying estimator does not support it, this `RANSACRegressor` will fail (e.g. `LinearRegression(positive_only=True)`).

@author:
done in 8f2f3db

path: sklearn/pipeline.py
text: @user1:
I think that we are missing `FeatureUnion` here.

diff_hunk: @@ -1224,6 +1224,7 @@ def __sklearn_tags__(self):
                 tags.input_tags.pairwise = get_tags(
                     self.steps[0][1]
                 ).input_tags.pairwise
+                tags.input_tags.sparse = get_tags(self.steps[0][1]).input_tags.sparse
path: sklearn/pipeline.py
text: @user1:
Ouch I don't think this is that simple. To be complete, we should check the sparse of each step and somewhow the output as well. If the previous output agree with the next input then we would be fine.

Somehow, we might want to be lenient and set it to True and hope that the underlying estimator will just raise.

I would need a second opinion here.

@author:
Maybe @user2 ?

@user1:
Yep indeed I would do something similar to what you did for the `FeatureUnion` and checking that each steps accept sparse data.

@author:
done in a9fb7d7

diff_hunk: @@ -1192,6 +1193,39 @@ def check_array_api_input_and_values(
     )
 
 
+def check_estimator_sparse_tag(name, estimator_orig):
+    if SPARSE_ARRAY_PRESENT:
+        sparse_container = sparse.csr_array
+    else:
+        sparse_container = sparse.csr_matrix
+    estimator = clone(estimator_orig)
+
+    rng = np.random.RandomState(0)
+    n_samples = 15 if name == "SpectralCoclustering" else 40
+    X = rng.uniform(size=(n_samples, 3))
+    X[X < 0.6] = 0
+    y = rng.randint(0, 3, size=n_samples)
+    X = _enforce_estimator_tags_X(estimator, X)
+    y = _enforce_estimator_tags_y(estimator, y)
+    X = sparse_container(X)
+
+    tags = get_tags(estimator)
+    if tags.input_tags.sparse:
+        estimator.fit(X, y)  # should pass
path: sklearn/utils/estimator_checks.py
text: @user1:
I'm thinking that we could help third-party library a bit better here by re-raising a better error message.

```suggestion
        try:
            estimator.fit(X, y)  # should pass
        except Exception as e:
            raise AssertionError(
                f"Estimator {name} raised an exception: {e}. The tag "
                "self.input_tags.sparse_tag might not be consistent with the "
                "estimator's ability to handle sparse data (i.e. controlled by the "
                "parameter `accept_sparse` in `validate_data` or `check_array` "
                f"functions). Got input_tags.sparse={tags.input_tags.sparse}."
            )
```

diff_hunk: @@ -880,6 +880,7 @@ def __init__(
 
     def __sklearn_tags__(self):
         tags = super().__sklearn_tags__()
+        tags.input_tags.sparse = True
path: sklearn/naive_bayes.py
text: @user1:
It seems that this is shared with `_BaseDiscreteNB`. So we can define it once only in the base class since the validation of `fit` is located there. 

@author:
done in 705c414

diff_hunk: @@ -851,4 +851,5 @@ def __sklearn_tags__(self):
         tags = super().__sklearn_tags__()
         tags.transformer_tags.preserves_dtype = ["float64", "float32"]
         tags.array_api_support = True
+        tags.input_tags.sparse = True
path: sklearn/decomposition/_pca.py
text: @user1:
Here, it will depends on the solver:


```suggestion
        tags.input_tags.sparse = self.svd_solver in ("auto", "arpack", "covariance_eigh")
```

I just realise that `score_samples` does not accept at all sparse data. We might check if it is still the case with the improvement recently done with the `"covariance_eigh"` solver or if we can go around the original issue.

path: sklearn/preprocessing/_data.py
text: @user1:
`RobustScaler` should accept sparse data but it is not set for the moment.

@author:
done in e85f94a

diff_hunk: @@ -394,6 +394,7 @@ def __sklearn_tags__(self):
         tags = super().__sklearn_tags__()
         tags.no_validation = not self.validate
         tags.requires_fit = False
+        tags.input_tags.sparse = True
path: sklearn/preprocessing/_function_transformer.py
text: @user1:
This should be a bit more tricky because it will depend on `self.validate` and `self.accept_sparse`


```suggestion
        tags.input_tags.sparse = not self.validate or (self.validate and self.accept_sparse)
```

diff_hunk: @@ -1140,6 +1155,11 @@ def __init__(
             random_state=random_state,
         )
 
+    def __sklearn_tags__(self):
path: sklearn/svm/_classes.py
text: @user1:
This could be set in `BaseSVC` instead.

@author:
done in 705c414

diff_hunk: @@ -1521,6 +1546,11 @@ def __init__(
             random_state=None,
         )
 
+    def __sklearn_tags__(self):
path: sklearn/svm/_classes.py
text: @user1:
This could be set in BaseLibSVM if possible.



@author:
done in 705c414

diff_hunk: @@ -877,6 +887,11 @@ def __init__(
             random_state=random_state,
         )
 
+    def __sklearn_tags__(self):
path: sklearn/svm/_classes.py
text: @user1:
This could be set in `BaseSVC` instead.

@author:
done in 705c414

diff_hunk: @@ -1100,6 +1100,7 @@ def __sklearn_tags__(self):
         }
         tags.classifier_tags.multi_label = True
         tags.input_tags.allow_nan = allow_nan
+        tags.input_tags.sparse = True
path: sklearn/tree/_classes.py
text: @user1:
This could be set in the in the base class as well and for the decision tree regressor.

@author:
done in 705c414

diff_hunk: @@ -7,7 +7,7 @@
 from sklearn.base import ClassifierMixin
 
 from ..base import BaseEstimator, MetaEstimatorMixin, _fit_context, clone
-from ..utils import Bunch, safe_mask
+from ..utils import Bunch, get_tags, safe_mask
path: sklearn/semi_supervised/_self_training.py
text: @user1:
Oh, could you modify the import above and import `ClassifierMixin` from the relative import `..base`.

@author:
done in 705c414