Repository: scikit-learn/scikit-learn
Pull request: ENH move estimator type to tags
Closes https://github.com/scikit-learn/scikit-learn/issues/16469

Moving `_estimator_tpye` to tags.

Right now this doesn't work.

Problem statement:
Deprecate _estimator_type, replace by estimator tag
I think we should deprecate ``_estimator_type`` which predates ``estimator_tags``. It's private but we should probably still deprecate it as it can lead to weird errors downstream. If we change it and downstream is not updating it, it would lead to classifiers using KFold cross-validation, which can be really bad if the target is sorted.

Hints from issue discussion:
Should we do the same with _pairwise?

I think so.
Not against it.

Another not directly related question: since we are integrating such tag, do we intend (or allow third-party) to use it outside of the common tests?
That was my intention. Right now both the ``_pairwise`` and ``_estimator_type`` are already used in the code for cross-validation purposes.
OK. So we should modify the documentation to be more assertive regarding the usage as well: https://scikit-learn.org/stable/developers/develop.html#estimator-tags

For the current usage, I don't have any issue. On a broader scope, I only have one concern regarding the usage of the tags. They might lead to less explicit validation in some cases (e.g. if the validation occurs in some nested function using some tags defined in the base class).

But I assume that we will have more discussion when we are going to have the real use case and we should most probably move forward on this matter.
I think the implications for using tags can be seen in "MinimalRegressor":

https://github.com/scikit-learn/scikit-learn/blob/3c732b9f6a77e95dfa6beb154ca2e1e7848b74f9/sklearn/utils/_testing.py#L968-L976

The current use of `_estimator_type` is almost like an protocol, because it is used in combination with `is_classifier()` or `is_regressor()`. I do not know if there is code that actually looks at `_estimator_type` directly.

If we convert this to using tags, a developer needs to add:

```python
class MinimalRegressor(BaseEstimator):
    def _more_tags(self):
        return {"estimator_type": "regressor"}
    ...
```

This minimal example is less "minimal" because it needs to use the whole tags machinery.

So we start to impose our tags machinery but by not enforcing inheriting from `BaseEstimator`. Isn't it kind of weird?
> So we start to impose our tags machinery but by not enforcing inheriting from BaseEstimator. Isn't it kind of weird?

Yea I think it is kind of weird. I am starting to rethink moving `_pairwise` into tags, because of how it enforces the tags machinery...
We just merged `multilabel` and removed the Mixin :)

Commit message: use method instead of operator

Patch to review:
diff --git a/doc/api_reference.py b/doc/api_reference.py
index 86fa072d3ed25..b3e658bd22120 100644
--- a/doc/api_reference.py
+++ b/doc/api_reference.py
@@ -1176,7 +1176,6 @@ def _get_submodule(module_name, submodule_name):
                     "ClassifierTags",
                     "RegressorTags",
                     "TransformerTags",
-                    "default_tags",
                     "get_tags",
                 ],
             },
diff --git a/doc/whats_new/upcoming_changes/sklearn.base/30122.api.rst b/doc/whats_new/upcoming_changes/sklearn.base/30122.api.rst
new file mode 100644
index 0000000000000..370a2adc1996d
--- /dev/null
+++ b/doc/whats_new/upcoming_changes/sklearn.base/30122.api.rst
@@ -0,0 +1,5 @@
+- Passing a class object to:func:`~sklearn.base.is_classifier`,
+  :func:`~sklearn.base.is_regressor`, :func:`~sklearn.base.is_transformer`, and
+  :func:`~sklearn.base.is_outlier_detector` is now deprecated. Pass an instance
+  instead.
+  By `Adrin Jalali`_
diff --git a/doc/whats_new/upcoming_changes/sklearn.utils/30122.api.rst b/doc/whats_new/upcoming_changes/sklearn.utils/30122.api.rst
new file mode 100644
index 0000000000000..50dec6ff8c82d
--- /dev/null
+++ b/doc/whats_new/upcoming_changes/sklearn.utils/30122.api.rst
@@ -0,0 +1,6 @@
+- Using `_estimator_type` to set the estimator type is deprecated. Inherit from
+  :class:`~sklearn.base.ClassifierMixin`, :class:`~sklearn.base.RegressorMixin`,
+  :class:`~sklearn.base.TransformerMixin`, or :class:`~sklearn.base.OutlierMixin`
+  instead. Alternatively, you can set `estimator_type` in :class:`~sklearn.utils.Tags`
+  in the `__sklearn_tags__` method.
+  By `Adrin Jalali`_
diff --git a/sklearn/base.py b/sklearn/base.py
index b1121af3f06db..bd5e07c2167dd 100644
--- a/sklearn/base.py
+++ b/sklearn/base.py
@@ -20,7 +20,14 @@
 from .utils._metadata_requests import _MetadataRequester, _routing_enabled
 from .utils._param_validation import validate_parameter_constraints
 from .utils._set_output import _SetOutputMixin
-from .utils._tags import default_tags
+from .utils._tags import (
+    ClassifierTags,
+    RegressorTags,
+    Tags,
+    TargetTags,
+    TransformerTags,
+    get_tags,
+)
 from .utils.fixes import _IS_32BIT
 from .utils.validation import (
     _check_feature_names_in,
@@ -380,7 +387,13 @@ def __setstate__(self, state):
             self.__dict__.update(state)
 
     def __sklearn_tags__(self):
-        return default_tags(self)
+        return Tags(
+            estimator_type=None,
+            target_tags=TargetTags(required=False),
+            transformer_tags=None,
+            regressor_tags=None,
+            classifier_tags=None,
+        )
 
     def _validate_params(self):
         """Validate types and values of constructor parameters
@@ -432,9 +445,10 @@ class ClassifierMixin:
 
     This mixin defines the following functionality:
 
-    - `_estimator_type` class attribute defaulting to `"classifier"`;
+    - set estimator type to `"classifier"` through the `estimator_type` tag;
     - `score` method that default to :func:`~sklearn.metrics.accuracy_score`.
-    - enforce that `fit` requires `y` to be passed through the `requires_y` tag.
+    - enforce that `fit` requires `y` to be passed through the `requires_y` tag,
+      which is done by setting the classifier type tag.
 
     Read more in the :ref:`User Guide <rolling_your_own_estimator>`.
 
@@ -460,8 +474,16 @@ class ClassifierMixin:
     0.66...
     """
 
+    # TODO(1.8): Remove this attribute
     _estimator_type = "classifier"
 
+    def __sklearn_tags__(self):
+        tags = super().__sklearn_tags__()
+        tags.estimator_type = "classifier"
+        tags.classifier_tags = ClassifierTags()
+        tags.target_tags.required = True
+        return tags
+
     def score(self, X, y, sample_weight=None):
         """
         Return the mean accuracy on the given test data and labels.
@@ -496,9 +518,10 @@ class RegressorMixin:
 
     This mixin defines the following functionality:
 
-    - `_estimator_type` class attribute defaulting to `"regressor"`;
+    - set estimator type to `"regressor"` through the `estimator_type` tag;
     - `score` method that default to :func:`~sklearn.metrics.r2_score`.
-    - enforce that `fit` requires `y` to be passed through the `requires_y` tag.
+    - enforce that `fit` requires `y` to be passed through the `requires_y` tag,
+      which is done by setting the regressor type tag.
 
     Read more in the :ref:`User Guide <rolling_your_own_estimator>`.
 
@@ -524,8 +547,16 @@ class RegressorMixin:
     0.0
     """
 
+    # TODO(1.8): Remove this attribute
     _estimator_type = "regressor"
 
+    def __sklearn_tags__(self):
+        tags = super().__sklearn_tags__()
+        tags.estimator_type = "regressor"
+        tags.regressor_tags = RegressorTags()
+        tags.target_tags.required = True
+        return tags
+
     def score(self, X, y, sample_weight=None):
         """Return the coefficient of determination of the prediction.
 
@@ -576,7 +607,7 @@ def score(self, X, y, sample_weight=None):
 class ClusterMixin:
     """Mixin class for all cluster estimators in scikit-learn.
 
-    - `_estimator_type` class attribute defaulting to `"clusterer"`;
+    - set estimator type to `"clusterer"` through the `estimator_type` tag;
     - `fit_predict` method returning the cluster labels associated to each sample.
 
     Examples
@@ -592,8 +623,16 @@ class ClusterMixin:
     array([1, 1, 1])
     """
 
+    # TODO(1.8): Remove this attribute
     _estimator_type = "clusterer"
 
+    def __sklearn_tags__(self):
+        tags = super().__sklearn_tags__()
+        tags.estimator_type = "clusterer"
+        if tags.transformer_tags is not None:
+            tags.transformer_tags.preserves_dtype = []
+        return tags
+
     def fit_predict(self, X, y=None, **kwargs):
         """
         Perform clustering on `X` and returns cluster labels.
@@ -621,12 +660,6 @@ def fit_predict(self, X, y=None, **kwargs):
         self.fit(X, **kwargs)
         return self.labels_
 
-    def __sklearn_tags__(self):
-        tags = super().__sklearn_tags__()
-        if tags.transformer_tags is not None:
-            tags.transformer_tags.preserves_dtype = []
-        return tags
-
 
 class BiclusterMixin:
     """Mixin class for all bicluster estimators in scikit-learn.
@@ -763,6 +796,11 @@ class TransformerMixin(_SetOutputMixin):
     array([1, 1, 1])
     """
 
+    def __sklearn_tags__(self):
+        tags = super().__sklearn_tags__()
+        tags.transformer_tags = TransformerTags()
+        return tags
+
     def fit_transform(self, X, y=None, **fit_params):
         """
         Fit to data, then transform it.
@@ -833,8 +871,8 @@ class OneToOneFeatureMixin:
     Examples
     --------
     >>> import numpy as np
-    >>> from sklearn.base import OneToOneFeatureMixin
-    >>> class MyEstimator(OneToOneFeatureMixin):
+    >>> from sklearn.base import OneToOneFeatureMixin, BaseEstimator
+    >>> class MyEstimator(OneToOneFeatureMixin, BaseEstimator):
     ...     def fit(self, X, y=None):
     ...         self.n_features_in_ = X.shape[1]
     ...         return self
@@ -885,8 +923,8 @@ class ClassNamePrefixFeaturesOutMixin:
     Examples
     --------
     >>> import numpy as np
-    >>> from sklearn.base import ClassNamePrefixFeaturesOutMixin
-    >>> class MyEstimator(ClassNamePrefixFeaturesOutMixin):
+    >>> from sklearn.base import ClassNamePrefixFeaturesOutMixin, BaseEstimator
+    >>> class MyEstimator(ClassNamePrefixFeaturesOutMixin, BaseEstimator):
     ...     def fit(self, X, y=None):
     ...         self._n_features_out = X.shape[1]
     ...         return self
@@ -923,7 +961,7 @@ class DensityMixin:
 
     This mixin defines the following functionality:
 
-    - `_estimator_type` class attribute defaulting to `"DensityEstimator"`;
+    - sets estimator type to `"density_estimator"` through the `estimator_type` tag;
     - `score` method that default that do no-op.
 
     Examples
@@ -938,8 +976,14 @@ class DensityMixin:
     True
     """
 
+    # TODO(1.8): Remove this attribute
     _estimator_type = "DensityEstimator"
 
+    def __sklearn_tags__(self):
+        tags = super().__sklearn_tags__()
+        tags.estimator_type = "density_estimator"
+        return tags
+
     def score(self, X, y=None):
         """Return the score of the model on the data `X`.
 
@@ -963,7 +1007,7 @@ class OutlierMixin:
 
     This mixin defines the following functionality:
 
-    - `_estimator_type` class attribute defaulting to `outlier_detector`;
+    - set estimator type to `"outlier_detector"` through the `estimator_type` tag;
     - `fit_predict` method that default to `fit` and `predict`.
 
     Examples
@@ -982,8 +1026,14 @@ class OutlierMixin:
     array([1., 1., 1.])
     """
 
+    # TODO(1.8): Remove this attribute
     _estimator_type = "outlier_detector"
 
+    def __sklearn_tags__(self):
+        tags = super().__sklearn_tags__()
+        tags.estimator_type = "outlier_detector"
+        return tags
+
     def fit_predict(self, X, y=None, **kwargs):
         """Perform fit on X and returns labels for X.
 
@@ -1116,7 +1166,16 @@ def is_classifier(estimator):
     >>> is_classifier(kmeans)
     False
     """
-    return getattr(estimator, "_estimator_type", None) == "classifier"
+    # TODO(1.8): Remove this check
+    if isinstance(estimator, type):
+        warnings.warn(
+            f"passing a class to {print(inspect.stack()[0][3])} is deprecated and "
+            "will be removed in 1.8. Use an instance of the class instead.",
+            FutureWarning,
+        )
+        return getattr(estimator, "_estimator_type", None) == "classifier"
+
+    return get_tags(estimator).estimator_type == "classifier"
 
 
 def is_regressor(estimator):
@@ -1147,7 +1206,16 @@ def is_regressor(estimator):
     >>> is_regressor(kmeans)
     False
     """
-    return getattr(estimator, "_estimator_type", None) == "regressor"
+    # TODO(1.8): Remove this check
+    if isinstance(estimator, type):
+        warnings.warn(
+            f"passing a class to {print(inspect.stack()[0][3])} is deprecated and "
+            "will be removed in 1.8. Use an instance of the class instead.",
+            FutureWarning,
+        )
+        return getattr(estimator, "_estimator_type", None) == "regressor"
+
+    return get_tags(estimator).estimator_type == "regressor"
 
 
 def is_clusterer(estimator):
@@ -1180,7 +1248,16 @@ def is_clusterer(estimator):
     >>> is_clusterer(kmeans)
     True
     """
-    return getattr(estimator, "_estimator_type", None) == "clusterer"
+    # TODO(1.8): Remove this check
+    if isinstance(estimator, type):
+        warnings.warn(
+            f"passing a class to {print(inspect.stack()[0][3])} is deprecated and "
+            "will be removed in 1.8. Use an instance of the class instead.",
+            FutureWarning,
+        )
+        return getattr(estimator, "_estimator_type", None) == "clusterer"
+
+    return get_tags(estimator).estimator_type == "clusterer"
 
 
 def is_outlier_detector(estimator):
@@ -1196,7 +1273,16 @@ def is_outlier_detector(estimator):
     out : bool
         True if estimator is an outlier detector and False otherwise.
     """
-    return getattr(estimator, "_estimator_type", None) == "outlier_detector"
+    # TODO(1.8): Remove this check
+    if isinstance(estimator, type):
+        warnings.warn(
+            f"passing a class to {print(inspect.stack()[0][3])} is deprecated and "
+            "will be removed in 1.8. Use an instance of the class instead.",
+            FutureWarning,
+        )
+        return getattr(estimator, "_estimator_type", None) == "outlier_detector"
+
+    return get_tags(estimator).estimator_type == "outlier_detector"
 
 
 def _fit_context(*, prefer_skip_nested_validation):
diff --git a/sklearn/cluster/_agglomerative.py b/sklearn/cluster/_agglomerative.py
index 4dd8b57364301..445c58f9803ca 100644
--- a/sklearn/cluster/_agglomerative.py
+++ b/sklearn/cluster/_agglomerative.py
@@ -1116,7 +1116,7 @@ def fit_predict(self, X, y=None):
 
 
 class FeatureAgglomeration(
-    ClassNamePrefixFeaturesOutMixin, AgglomerativeClustering, AgglomerationTransform
+    ClassNamePrefixFeaturesOutMixin, AgglomerationTransform, AgglomerativeClustering
 ):
     """Agglomerate features.
 
diff --git a/sklearn/ensemble/_base.py b/sklearn/ensemble/_base.py
index 2789dd234294e..386c4875a1804 100644
--- a/sklearn/ensemble/_base.py
+++ b/sklearn/ensemble/_base.py
@@ -298,5 +298,4 @@ def __sklearn_tags__(self):
             # validation will raise an error during `fit`.
             allow_nan = False
         tags.input_tags.allow_nan = allow_nan
-        tags.transformer_tags.preserves_dtype = []
         return tags
diff --git a/sklearn/ensemble/_forest.py b/sklearn/ensemble/_forest.py
index 7c7663864ad92..92713eecec9dd 100644
--- a/sklearn/ensemble/_forest.py
+++ b/sklearn/ensemble/_forest.py
@@ -513,8 +513,7 @@ def fit(self, X, y, sample_weight=None):
         ):
             y_type = type_of_target(y)
             if y_type == "unknown" or (
-                self._estimator_type == "classifier"
-                and y_type == "multiclass-multioutput"
+                is_classifier(self) and y_type == "multiclass-multioutput"
             ):
                 # FIXME: we could consider to support multiclass-multioutput if
                 # we introduce or reuse a constructor parameter (e.g.
diff --git a/sklearn/ensemble/_voting.py b/sklearn/ensemble/_voting.py
index 5c9a4de5882d6..bcf2d749725ff 100644
--- a/sklearn/ensemble/_voting.py
+++ b/sklearn/ensemble/_voting.py
@@ -547,6 +547,11 @@ def get_feature_names_out(self, input_features=None):
         ]
         return np.asarray(names_out, dtype=object)
 
+    def __sklearn_tags__(self):
+        tags = super().__sklearn_tags__()
+        tags.transformer_tags.preserves_dtype = []
+        return tags
+
 
 class VotingRegressor(RegressorMixin, _BaseVoting):
     """Prediction voting regressor for unfitted estimators.
diff --git a/sklearn/feature_selection/_rfe.py b/sklearn/feature_selection/_rfe.py
index 0282facf9fd31..dfe96e14a6fcd 100644
--- a/sklearn/feature_selection/_rfe.py
+++ b/sklearn/feature_selection/_rfe.py
@@ -4,6 +4,7 @@
 """Recursive feature elimination for feature ranking"""
 
 import warnings
+from copy import deepcopy
 from numbers import Integral
 
 import numpy as np
@@ -239,6 +240,7 @@ def __init__(
         self.importance_getter = importance_getter
         self.verbose = verbose
 
+    # TODO(1.8) remove this property
     @property
     def _estimator_type(self):
         return self.estimator._estimator_type
@@ -528,12 +530,16 @@ def predict_log_proba(self, X):
 
     def __sklearn_tags__(self):
         tags = super().__sklearn_tags__()
+        sub_estimator_tags = get_tags(self.estimator)
+        tags.estimator_type = sub_estimator_tags.estimator_type
+        tags.classifier_tags = deepcopy(sub_estimator_tags.classifier_tags)
+        tags.regressor_tags = deepcopy(sub_estimator_tags.regressor_tags)
         if tags.classifier_tags is not None:
             tags.classifier_tags.poor_score = True
         if tags.regressor_tags is not None:
             tags.regressor_tags.poor_score = True
         tags.target_tags.required = True
-        tags.input_tags.allow_nan = get_tags(self.estimator).input_tags.allow_nan
+        tags.input_tags.allow_nan = sub_estimator_tags.input_tags.allow_nan
         return tags
 
     def get_metadata_routing(self):
diff --git a/sklearn/feature_selection/tests/test_rfe.py b/sklearn/feature_selection/tests/test_rfe.py
index 98b55366c5853..4e728e22af30c 100644
--- a/sklearn/feature_selection/tests/test_rfe.py
+++ b/sklearn/feature_selection/tests/test_rfe.py
@@ -8,7 +8,7 @@
 import pytest
 from numpy.testing import assert_allclose, assert_array_almost_equal, assert_array_equal
 
-from sklearn.base import BaseEstimator, ClassifierMixin
+from sklearn.base import BaseEstimator, ClassifierMixin, is_classifier
 from sklearn.compose import TransformedTargetRegressor
 from sklearn.cross_decomposition import CCA, PLSCanonical, PLSRegression
 from sklearn.datasets import load_iris, make_classification, make_friedman1
@@ -22,12 +22,11 @@
 from sklearn.preprocessing import StandardScaler
 from sklearn.svm import SVC, SVR, LinearSVR
 from sklearn.utils import check_random_state
-from sklearn.utils._tags import default_tags
 from sklearn.utils._testing import ignore_warnings
 from sklearn.utils.fixes import CSR_CONTAINERS
 
 
-class MockClassifier(ClassifierMixin):
+class MockClassifier(ClassifierMixin, BaseEstimator):
     """
     Dummy classifier to test recursive feature elimination
     """
@@ -58,7 +57,7 @@ def set_params(self, **params):
         return self
 
     def __sklearn_tags__(self):
-        tags = default_tags(self)
+        tags = super().__sklearn_tags__()
         tags.input_tags.allow_nan = True
         return tags
 
@@ -325,7 +324,7 @@ def test_rfecv_cv_results_size(global_random_seed):
 
 def test_rfe_estimator_tags():
     rfe = RFE(SVC(kernel="linear"))
-    assert rfe._estimator_type == "classifier"
+    assert is_classifier(rfe)
     # make sure that cross-validation is stratified
     iris = load_iris()
     score = cross_val_score(rfe, iris.data, iris.target)
diff --git a/sklearn/inspection/_plot/tests/test_boundary_decision_display.py b/sklearn/inspection/_plot/tests/test_boundary_decision_display.py
index b6767cbe72857..d0aabbbb15db9 100644
--- a/sklearn/inspection/_plot/tests/test_boundary_decision_display.py
+++ b/sklearn/inspection/_plot/tests/test_boundary_decision_display.py
@@ -334,7 +334,7 @@ def test_decision_boundary_display_regressor(pyplot, response_method, plot_metho
 def test_error_bad_response(pyplot, response_method, msg):
     """Check errors for bad response."""
 
-    class MyClassifier(BaseEstimator, ClassifierMixin):
+    class MyClassifier(ClassifierMixin, BaseEstimator):
         def fit(self, X, y):
             self.fitted_ = True
             self.classes_ = [0, 1]
diff --git a/sklearn/linear_model/_ridge.py b/sklearn/linear_model/_ridge.py
index 2b7b3708354e3..56bb9fbc50570 100644
--- a/sklearn/linear_model/_ridge.py
+++ b/sklearn/linear_model/_ridge.py
@@ -16,6 +16,8 @@
 from scipy import linalg, optimize, sparse
 from scipy.sparse import linalg as sp_linalg
 
+from sklearn.base import BaseEstimator
+
 from ..base import MultiOutputMixin, RegressorMixin, _fit_context, is_classifier
 from ..exceptions import ConvergenceWarning
 from ..metrics import check_scoring, get_scorer_names
@@ -1682,7 +1684,7 @@ def _matmat(self, v):
         return res
 
 
-class _IdentityRegressor:
+class _IdentityRegressor(RegressorMixin, BaseEstimator):
     """Fake regressor which will directly output the prediction."""
 
     def decision_function(self, y_predict):
@@ -1692,7 +1694,7 @@ def predict(self, y_predict):
         return y_predict
 
 
-class _IdentityClassifier(LinearClassifierMixin):
+class _IdentityClassifier(LinearClassifierMixin, BaseEstimator):
     """Fake classifier which will directly output the prediction.
 
     We inherit from LinearClassifierMixin to get the proper shape for the
diff --git a/sklearn/metrics/_plot/tests/test_common_curve_display.py b/sklearn/metrics/_plot/tests/test_common_curve_display.py
index 7fe0f0fc6fa7f..0014a73055e41 100644
--- a/sklearn/metrics/_plot/tests/test_common_curve_display.py
+++ b/sklearn/metrics/_plot/tests/test_common_curve_display.py
@@ -1,7 +1,7 @@
 import numpy as np
 import pytest
 
-from sklearn.base import ClassifierMixin, clone
+from sklearn.base import BaseEstimator, ClassifierMixin, clone
 from sklearn.calibration import CalibrationDisplay
 from sklearn.compose import make_column_transformer
 from sklearn.datasets import load_iris
@@ -121,7 +121,7 @@ def test_display_curve_error_no_response(
     is not defined for the given trained classifier."""
     X, y = data_binary
 
-    class MyClassifier(ClassifierMixin):
+    class MyClassifier(ClassifierMixin, BaseEstimator):
         def fit(self, X, y):
             self.classes_ = [0, 1]
             return self
diff --git a/sklearn/metrics/tests/test_score_objects.py b/sklearn/metrics/tests/test_score_objects.py
index 58d6561d566db..66bf521e43ec5 100644
--- a/sklearn/metrics/tests/test_score_objects.py
+++ b/sklearn/metrics/tests/test_score_objects.py
@@ -3,7 +3,6 @@
 import warnings
 from copy import deepcopy
 from functools import partial
-from unittest.mock import Mock
 
 import joblib
 import numpy as np
@@ -11,7 +10,7 @@
 from numpy.testing import assert_allclose
 
 from sklearn import config_context
-from sklearn.base import BaseEstimator
+from sklearn.base import BaseEstimator, ClassifierMixin
 from sklearn.cluster import KMeans
 from sklearn.datasets import (
     load_diabetes,
@@ -185,7 +184,7 @@ def fit(self, X, y):
         return self
 
 
-class EstimatorWithFitAndScore:
+class EstimatorWithFitAndScore(BaseEstimator):
     """Dummy estimator to test scoring validators"""
 
     def fit(self, X, y):
@@ -195,7 +194,7 @@ def score(self, X, y):
         return 1.0
 
 
-class EstimatorWithFitAndPredict:
+class EstimatorWithFitAndPredict(BaseEstimator):
     """Dummy estimator to test scoring validators"""
 
     def fit(self, X, y):
@@ -748,37 +747,41 @@ def test_multimetric_scorer_calls_method_once(
     expected_decision_func_count,
 ):
     X, y = np.array([[1], [1], [0], [0], [0]]), np.array([0, 1, 1, 1, 0])
-
-    mock_est = Mock()
-    mock_est._estimator_type = "classifier"
-    fit_func = Mock(return_value=mock_est, name="fit")
-    fit_func.__name__ = "fit"
-    predict_func = Mock(return_value=y, name="predict")
-    predict_func.__name__ = "predict"
-
     pos_proba = np.random.rand(X.shape[0])
     proba = np.c_[1 - pos_proba, pos_proba]
-    predict_proba_func = Mock(return_value=proba, name="predict_proba")
-    predict_proba_func.__name__ = "predict_proba"
-    decision_function_func = Mock(return_value=pos_proba, name="decision_function")
-    decision_function_func.__name__ = "decision_function"
-
-    mock_est.fit = fit_func
-    mock_est.predict = predict_func
-    mock_est.predict_proba = predict_proba_func
-    mock_est.decision_function = decision_function_func
-    # add the classes that would be found during fit
-    mock_est.classes_ = np.array([0, 1])
 
+    class MyClassifier(ClassifierMixin, BaseEstimator):
+        def __init__(self):
+            self._expected_predict_count = 0
+            self._expected_predict_proba_count = 0
+            self._expected_decision_function_count = 0
+
+        def fit(self, X, y):
+            self.classes_ = np.unique(y)
+            return self
+
+        def predict(self, X):
+            self._expected_predict_count += 1
+            return y
+
+        def predict_proba(self, X):
+            self._expected_predict_proba_count += 1
+            return proba
+
+        def decision_function(self, X):
+            self._expected_decision_function_count += 1
+            return pos_proba
+
+    mock_est = MyClassifier().fit(X, y)
     scorer_dict = _check_multimetric_scoring(LogisticRegression(), scorers)
     multi_scorer = _MultimetricScorer(scorers=scorer_dict)
     results = multi_scorer(mock_est, X, y)
 
     assert set(scorers) == set(results)  # compare dict keys
 
-    assert predict_func.call_count == expected_predict_count
-    assert predict_proba_func.call_count == expected_predict_proba_count
-    assert decision_function_func.call_count == expected_decision_func_count
+    assert mock_est._expected_predict_count == expected_predict_count
+    assert mock_est._expected_predict_proba_count == expected_predict_proba_count
+    assert mock_est._expected_decision_function_count == expected_decision_func_count
 
 
 @pytest.mark.parametrize(
diff --git a/sklearn/model_selection/_search.py b/sklearn/model_selection/_search.py
index 2935f7ce2465c..5a8284c49888b 100644
--- a/sklearn/model_selection/_search.py
+++ b/sklearn/model_selection/_search.py
@@ -13,6 +13,7 @@
 from abc import ABCMeta, abstractmethod
 from collections import defaultdict
 from collections.abc import Iterable, Mapping, Sequence
+from copy import deepcopy
 from functools import partial, reduce
 from itertools import product
 
@@ -476,18 +477,23 @@ def __init__(
         self.return_train_score = return_train_score
 
     @property
+    # TODO(1.8) remove this property
     def _estimator_type(self):
         return self.estimator._estimator_type
 
     def __sklearn_tags__(self):
         tags = super().__sklearn_tags__()
+        sub_estimator_tags = get_tags(self.estimator)
+        tags.estimator_type = sub_estimator_tags.estimator_type
+        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.input_tags.pairwise = sub_estimator_tags.input_tags.pairwise
         tags._xfail_checks = {
             "check_supervised_y_2d": "DataConversionWarning not caught",
             "check_requires_y_none": "Doesn't fail gracefully",
         }
-        tags.array_api_support = get_tags(self.estimator).array_api_support
+        tags.array_api_support = sub_estimator_tags.array_api_support
         return tags
 
     def score(self, X, y=None, **params):
diff --git a/sklearn/model_selection/tests/test_search.py b/sklearn/model_selection/tests/test_search.py
index c0442906d99de..5313e5d28a1a7 100644
--- a/sklearn/model_selection/tests/test_search.py
+++ b/sklearn/model_selection/tests/test_search.py
@@ -100,7 +100,7 @@
 
 # Neither of the following two estimators inherit from BaseEstimator,
 # to test hyperparameter search on user-defined classifiers.
-class MockClassifier:
+class MockClassifier(ClassifierMixin, BaseEstimator):
     """Dummy classifier to test the parameter search algorithms"""
 
     def __init__(self, foo_param=0):
@@ -213,7 +213,7 @@ def test_parameter_grid():
 def test_grid_search():
     # Test that the best estimator contains the right value for foo_param
     clf = MockClassifier()
-    grid_search = GridSearchCV(clf, {"foo_param": [1, 2, 3]}, cv=3, verbose=3)
+    grid_search = GridSearchCV(clf, {"foo_param": [1, 2, 3]}, cv=2, verbose=3)
     # make sure it selects the smallest parameter in case of ties
     old_stdout = sys.stdout
     sys.stdout = StringIO()
@@ -383,11 +383,11 @@ def test_classes__property():
 def test_trivial_cv_results_attr():
     # Test search over a "grid" with only one point.
     clf = MockClassifier()
-    grid_search = GridSearchCV(clf, {"foo_param": [1]}, cv=3)
+    grid_search = GridSearchCV(clf, {"foo_param": [1]}, cv=2)
     grid_search.fit(X, y)
     assert hasattr(grid_search, "cv_results_")
 
-    random_search = RandomizedSearchCV(clf, {"foo_param": [0]}, n_iter=1, cv=3)
+    random_search = RandomizedSearchCV(clf, {"foo_param": [0]}, n_iter=1, cv=2)
     random_search.fit(X, y)
     assert hasattr(grid_search, "cv_results_")
 
@@ -396,7 +396,7 @@ def test_no_refit():
     # Test that GSCV can be used for model selection alone without refitting
     clf = MockClassifier()
     for scoring in [None, ["accuracy", "precision"]]:
-        grid_search = GridSearchCV(clf, {"foo_param": [1, 2, 3]}, refit=False, cv=3)
+        grid_search = GridSearchCV(clf, {"foo_param": [1, 2, 3]}, refit=False, cv=2)
         grid_search.fit(X, y)
         assert (
             not hasattr(grid_search, "best_estimator_")
@@ -463,7 +463,7 @@ def test_grid_search_when_param_grid_includes_range():
     # Test that the best estimator contains the right value for foo_param
     clf = MockClassifier()
     grid_search = None
-    grid_search = GridSearchCV(clf, {"foo_param": range(1, 4)}, cv=3)
+    grid_search = GridSearchCV(clf, {"foo_param": range(1, 4)}, cv=2)
     grid_search.fit(X, y)
     assert grid_search.best_estimator_.foo_param == 2
 
@@ -1496,13 +1496,13 @@ def test_grid_search_correct_score_results():
 def test_pickle():
     # Test that a fit search can be pickled
     clf = MockClassifier()
-    grid_search = GridSearchCV(clf, {"foo_param": [1, 2, 3]}, refit=True, cv=3)
+    grid_search = GridSearchCV(clf, {"foo_param": [1, 2, 3]}, refit=True, cv=2)
     grid_search.fit(X, y)
     grid_search_pickled = pickle.loads(pickle.dumps(grid_search))
     assert_array_almost_equal(grid_search.predict(X), grid_search_pickled.predict(X))
 
     random_search = RandomizedSearchCV(
-        clf, {"foo_param": [1, 2, 3]}, refit=True, n_iter=3, cv=3
+        clf, {"foo_param": [1, 2, 3]}, refit=True, n_iter=3, cv=2
     )
     random_search.fit(X, y)
     random_search_pickled = pickle.loads(pickle.dumps(random_search))
@@ -1901,7 +1901,7 @@ def _pop_time_keys(cv_results):
 
 def test_transform_inverse_transform_round_trip():
     clf = MockClassifier()
-    grid_search = GridSearchCV(clf, {"foo_param": [1, 2, 3]}, cv=3, verbose=3)
+    grid_search = GridSearchCV(clf, {"foo_param": [1, 2, 3]}, cv=2, verbose=3)
 
     grid_search.fit(X, y)
     X_round_trip = grid_search.inverse_transform(grid_search.transform(X))
@@ -2571,7 +2571,7 @@ def test_search_html_repr():
 @pytest.mark.parametrize("SearchCV", [GridSearchCV, RandomizedSearchCV])
 def test_inverse_transform_Xt_deprecation(SearchCV):
     clf = MockClassifier()
-    search = SearchCV(clf, {"foo_param": [1, 2, 3]}, cv=3, verbose=3)
+    search = SearchCV(clf, {"foo_param": [1, 2, 3]}, cv=2, verbose=3)
 
     X2 = search.fit(X, y).transform(X)
 
diff --git a/sklearn/model_selection/tests/test_validation.py b/sklearn/model_selection/tests/test_validation.py
index 81f716ed88516..2d579772b1fbe 100644
--- a/sklearn/model_selection/tests/test_validation.py
+++ b/sklearn/model_selection/tests/test_validation.py
@@ -14,7 +14,7 @@
 from scipy.sparse import issparse
 
 from sklearn import config_context
-from sklearn.base import BaseEstimator, clone
+from sklearn.base import BaseEstimator, ClassifierMixin, clone
 from sklearn.cluster import KMeans
 from sklearn.datasets import (
     load_diabetes,
@@ -186,7 +186,7 @@ def predict(self, X):
         raise NotImplementedError
 
 
-class MockClassifier:
+class MockClassifier(ClassifierMixin, BaseEstimator):
     """Dummy classifier to test the cross-validation"""
 
     def __init__(self, a=0, allow_nd=False):
@@ -254,7 +254,7 @@ def fit(
                 P.shape[0],
                 P.shape[1],
             )
-        self.fitted_ = True
+        self.classes_ = np.unique(y)
         return self
 
     def predict(self, T):
@@ -274,11 +274,11 @@ def get_params(self, deep=False):
 
 # XXX: use 2D array, since 1D X is being detected as a single sample in
 # check_consistent_length
-X = np.ones((10, 2))
-y = np.array([0, 0, 1, 1, 2, 2, 3, 3, 4, 4])
+X = np.ones((15, 2))
+y = np.array([0, 0, 1, 1, 2, 2, 3, 3, 4, 4, 5, 5, 6, 6, 6])
 # The number of samples per class needs to be > n_splits,
 # for StratifiedKFold(n_splits=3)
-y2 = np.array([1, 1, 1, 2, 2, 2, 3, 3, 3, 3])
+y2 = np.array([1, 1, 1, 1, 1, 2, 2, 2, 2, 2, 3, 3, 3, 3, 3])
 P = np.eye(5)
 
 
@@ -694,7 +694,7 @@ def test_cross_val_score_fit_params(coo_container):
     n_classes = len(np.unique(y))
 
     W_sparse = coo_container(
-        (np.array([1]), (np.array([1]), np.array([0]))), shape=(10, 1)
+        (np.array([1]), (np.array([1]), np.array([0]))), shape=(15, 1)
     )
     P_sparse = coo_container(np.eye(5))
 
@@ -720,7 +720,7 @@ def assert_fit_params(clf):
         "dummy_obj": DUMMY_OBJ,
         "callback": assert_fit_params,
     }
-    cross_val_score(clf, X, y, params=fit_params)
+    cross_val_score(clf, X, y2, params=fit_params)
 
 
 def test_cross_val_score_score_func():
diff --git a/sklearn/pipeline.py b/sklearn/pipeline.py
index 90a62d9e4e8ab..54fc572e12672 100644
--- a/sklearn/pipeline.py
+++ b/sklearn/pipeline.py
@@ -6,6 +6,7 @@
 import warnings
 from collections import Counter, defaultdict
 from contextlib import contextmanager
+from copy import deepcopy
 from itertools import chain, islice
 
 import numpy as np
@@ -344,6 +345,7 @@ def __getitem__(self, ind):
             return self.named_steps[ind]
         return est
 
+    # TODO(1.8): Remove this property
     @property
     def _estimator_type(self):
         return self.steps[-1][1]._estimator_type
@@ -1059,16 +1061,23 @@ def __sklearn_tags__(self):
         }
 
         try:
-            tags.input_tags.pairwise = get_tags(self.steps[0][1]).input_tags.pairwise
+            if self.steps[0][1] is not None and self.steps[0][1] != "passthrough":
+                tags.input_tags.pairwise = get_tags(
+                    self.steps[0][1]
+                ).input_tags.pairwise
         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.
             pass
 
         try:
-            tags.target_tags.multi_output = get_tags(
-                self.steps[-1][1]
-            ).target_tags.multi_output
+            if self.steps[-1][1] is not None and self.steps[-1][1] != "passthrough":
+                last_step_tags = get_tags(self.steps[-1][1])
+                tags.estimator_type = last_step_tags.estimator_type
+                tags.target_tags.multi_output = last_step_tags.target_tags.multi_output
+                tags.classifier_tags = deepcopy(last_step_tags.classifier_tags)
+                tags.regressor_tags = deepcopy(last_step_tags.regressor_tags)
+                tags.transformer_tags = deepcopy(last_step_tags.transformer_tags)
         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/semi_supervised/_self_training.py b/sklearn/semi_supervised/_self_training.py
index 3e1709adaa267..5ac0b8ca28533 100644
--- a/sklearn/semi_supervised/_self_training.py
+++ b/sklearn/semi_supervised/_self_training.py
@@ -4,6 +4,8 @@
 
 import numpy as np
 
+from sklearn.base import ClassifierMixin
+
 from ..base import BaseEstimator, MetaEstimatorMixin, _fit_context, clone
 from ..utils import Bunch, safe_mask
 from ..utils._param_validation import HasMethods, Hidden, Interval, StrOptions
@@ -42,7 +44,7 @@ def check(self):
     return check
 
 
-class SelfTrainingClassifier(MetaEstimatorMixin, BaseEstimator):
+class SelfTrainingClassifier(ClassifierMixin, MetaEstimatorMixin, BaseEstimator):
     """Self-training classifier.
 
     This :term:`metaestimator` allows a given supervised classifier to function as a
@@ -171,8 +173,6 @@ class SelfTrainingClassifier(MetaEstimatorMixin, BaseEstimator):
     SelfTrainingClassifier(...)
     """
 
-    _estimator_type = "classifier"
-
     _parameter_constraints: dict = {
         # We don't require `predic_proba` here to allow passing a meta-estimator
         # that only exposes `predict_proba` after fitting.
diff --git a/sklearn/tests/test_base.py b/sklearn/tests/test_base.py
index e23c347708b1f..b65baa78802bc 100644
--- a/sklearn/tests/test_base.py
+++ b/sklearn/tests/test_base.py
@@ -19,10 +19,12 @@
     clone,
     is_classifier,
     is_clusterer,
+    is_outlier_detector,
     is_regressor,
 )
 from sklearn.cluster import KMeans
 from sklearn.decomposition import PCA
+from sklearn.ensemble import IsolationForest
 from sklearn.exceptions import InconsistentVersionWarning
 from sklearn.model_selection import GridSearchCV
 from sklearn.pipeline import Pipeline
@@ -266,6 +268,21 @@ def test_get_params():
         test.set_params(a__a=2)
 
 
+# TODO(1.8): Remove this test when the deprecation is removed
+def test_is_estimator_type_class():
+    with pytest.warns(FutureWarning, match="passing a class to.*is deprecated"):
+        assert is_classifier(SVC)
+
+    with pytest.warns(FutureWarning, match="passing a class to.*is deprecated"):
+        assert is_regressor(SVR)
+
+    with pytest.warns(FutureWarning, match="passing a class to.*is deprecated"):
+        assert is_clusterer(KMeans)
+
+    with pytest.warns(FutureWarning, match="passing a class to.*is deprecated"):
+        assert is_outlier_detector(IsolationForest)
+
+
 @pytest.mark.parametrize(
     "estimator, expected_result",
     [
diff --git a/sklearn/tests/test_calibration.py b/sklearn/tests/test_calibration.py
index 0f23bb7463126..158d9e09b088a 100644
--- a/sklearn/tests/test_calibration.py
+++ b/sklearn/tests/test_calibration.py
@@ -5,7 +5,7 @@
 import pytest
 from numpy.testing import assert_allclose
 
-from sklearn.base import BaseEstimator, clone
+from sklearn.base import BaseEstimator, ClassifierMixin, clone
 from sklearn.calibration import (
     CalibratedClassifierCV,
     CalibrationDisplay,
@@ -491,11 +491,9 @@ def test_calibration_accepts_ndarray(X):
     """Test that calibration accepts n-dimensional arrays as input"""
     y = [1, 0, 0, 1, 1, 0, 1, 1, 0, 0, 1, 0, 0, 1, 0]
 
-    class MockTensorClassifier(BaseEstimator):
+    class MockTensorClassifier(ClassifierMixin, BaseEstimator):
         """A toy estimator that accepts tensor inputs"""
 
-        _estimator_type = "classifier"
-
         def fit(self, X, y):
             self.classes_ = np.unique(y)
             return self
diff --git a/sklearn/tests/test_common.py b/sklearn/tests/test_common.py
index a985f6a02289a..455234adfad5b 100644
--- a/sklearn/tests/test_common.py
+++ b/sklearn/tests/test_common.py
@@ -35,7 +35,14 @@
     StandardScaler,
 )
 from sklearn.utils import all_estimators
-from sklearn.utils._tags import get_tags
+from sklearn.utils._tags import (
+    ClassifierTags,
+    InputTags,
+    RegressorTags,
+    TargetTags,
+    TransformerTags,
+    get_tags,
+)
 from sklearn.utils._test_common.instance_generator import (
     _get_check_estimator_ids,
     _tested_estimators,
@@ -217,29 +224,49 @@ def test_class_support_removed():
 )
 def test_valid_tag_types(estimator):
     """Check that estimator tags are valid."""
-    from dataclasses import fields
-
-    from ..utils._tags import default_tags
-
-    def check_field_types(tags, defaults):
-        if tags is None:
-            return
-        tags_fields = fields(tags)
-        for field in tags_fields:
-            correct_tags = type(getattr(defaults, field.name))
-            if field.name == "_xfail_checks":
-                # _xfail_checks can be a dictionary
-                correct_tags = (correct_tags, dict)
-            assert isinstance(getattr(tags, field.name), correct_tags)
-
     tags = get_tags(estimator)
-    defaults = default_tags(estimator)
-    check_field_types(tags, defaults)
-    check_field_types(tags.input_tags, defaults.input_tags)
-    check_field_types(tags.target_tags, defaults.target_tags)
-    check_field_types(tags.classifier_tags, defaults.classifier_tags)
-    check_field_types(tags.regressor_tags, defaults.regressor_tags)
-    check_field_types(tags.transformer_tags, defaults.transformer_tags)
+    assert isinstance(tags.estimator_type, (str, type(None)))
+    assert isinstance(tags.target_tags, TargetTags)
+    assert isinstance(tags.classifier_tags, (ClassifierTags, type(None)))
+    assert isinstance(tags.regressor_tags, (RegressorTags, type(None)))
+    assert isinstance(tags.transformer_tags, (TransformerTags, type(None)))
+    assert isinstance(tags.input_tags, InputTags)
+    assert isinstance(tags.array_api_support, bool)
+    assert isinstance(tags.no_validation, bool)
+    assert isinstance(tags.non_deterministic, bool)
+    assert isinstance(tags.requires_fit, bool)
+    assert isinstance(tags._skip_test, bool)
+    assert isinstance(tags._xfail_checks, dict)
+
+    assert isinstance(tags.target_tags.required, bool)
+    assert isinstance(tags.target_tags.one_d_labels, bool)
+    assert isinstance(tags.target_tags.two_d_labels, bool)
+    assert isinstance(tags.target_tags.positive_only, bool)
+    assert isinstance(tags.target_tags.multi_output, bool)
+    assert isinstance(tags.target_tags.single_output, bool)
+
+    assert isinstance(tags.input_tags.pairwise, bool)
+    assert isinstance(tags.input_tags.allow_nan, bool)
+    assert isinstance(tags.input_tags.sparse, bool)
+    assert isinstance(tags.input_tags.categorical, bool)
+    assert isinstance(tags.input_tags.string, bool)
+    assert isinstance(tags.input_tags.dict, bool)
+    assert isinstance(tags.input_tags.one_d_array, bool)
+    assert isinstance(tags.input_tags.two_d_array, bool)
+    assert isinstance(tags.input_tags.three_d_array, bool)
+    assert isinstance(tags.input_tags.positive_only, bool)
+
+    if tags.classifier_tags is not None:
+        assert isinstance(tags.classifier_tags.poor_score, bool)
+        assert isinstance(tags.classifier_tags.multi_class, bool)
+        assert isinstance(tags.classifier_tags.multi_label, bool)
+
+    if tags.regressor_tags is not None:
+        assert isinstance(tags.regressor_tags.poor_score, bool)
+        assert isinstance(tags.regressor_tags.multi_label, bool)
+
+    if tags.transformer_tags is not None:
+        assert isinstance(tags.transformer_tags.preserves_dtype, list)
 
 
 def _estimators_that_predict_in_fit():
diff --git a/sklearn/tests/test_metaestimators.py b/sklearn/tests/test_metaestimators.py
index faec281d090dd..4bb1ee1ffcfeb 100644
--- a/sklearn/tests/test_metaestimators.py
+++ b/sklearn/tests/test_metaestimators.py
@@ -18,6 +18,7 @@
 from sklearn.preprocessing import MaxAbsScaler, StandardScaler
 from sklearn.semi_supervised import SelfTrainingClassifier
 from sklearn.utils import all_estimators
+from sklearn.utils._test_common.instance_generator import _construct_instances
 from sklearn.utils._testing import set_random_state
 from sklearn.utils.estimator_checks import (
     _enforce_estimator_tags_X,
@@ -197,6 +198,52 @@ def score(self, X, y, *args, **kwargs):
             )
 
 
+def _get_instance_with_pipeline(meta_estimator, init_params):
+    """Given a single meta-estimator instance, generate an instance with a pipeline"""
+    if {"estimator", "base_estimator", "regressor"} & init_params:
+        if is_regressor(meta_estimator):
+            estimator = make_pipeline(TfidfVectorizer(), Ridge())
+            param_grid = {"ridge__alpha": [0.1, 1.0]}
+        else:
+            estimator = make_pipeline(TfidfVectorizer(), LogisticRegression())
+            param_grid = {"logisticregression__C": [0.1, 1.0]}
+
+        if {"param_grid", "param_distributions"} & init_params:
+            # SearchCV estimators
+            extra_params = {"n_iter": 2} if "n_iter" in init_params else {}
+            return type(meta_estimator)(estimator, param_grid, **extra_params)
+        else:
+            return type(meta_estimator)(estimator)
+
+    if "transformer_list" in init_params:
+        # FeatureUnion
+        transformer_list = [
+            ("trans1", make_pipeline(TfidfVectorizer(), MaxAbsScaler())),
+            (
+                "trans2",
+                make_pipeline(TfidfVectorizer(), StandardScaler(with_mean=False)),
+            ),
+        ]
+        return type(meta_estimator)(transformer_list)
+
+    if "estimators" in init_params:
+        # stacking, voting
+        if is_regressor(meta_estimator):
+            estimator = [
+                ("est1", make_pipeline(TfidfVectorizer(), Ridge(alpha=0.1))),
+                ("est2", make_pipeline(TfidfVectorizer(), Ridge(alpha=1))),
+            ]
+        else:
+            estimator = [
+                (
+                    "est1",
+                    make_pipeline(TfidfVectorizer(), LogisticRegression(C=0.1)),
+                ),
+                ("est2", make_pipeline(TfidfVectorizer(), LogisticRegression(C=1))),
+            ]
+        return type(meta_estimator)(estimator)
+
+
 def _generate_meta_estimator_instances_with_pipeline():
     """Generate instances of meta-estimators fed with a pipeline
 
@@ -206,52 +253,20 @@ def _generate_meta_estimator_instances_with_pipeline():
     for _, Estimator in sorted(all_estimators()):
         sig = set(signature(Estimator).parameters)
 
-        if "estimator" in sig or "base_estimator" in sig or "regressor" in sig:
-            if is_regressor(Estimator):
-                estimator = make_pipeline(TfidfVectorizer(), Ridge())
-                param_grid = {"ridge__alpha": [0.1, 1.0]}
-            else:
-                estimator = make_pipeline(TfidfVectorizer(), LogisticRegression())
-                param_grid = {"logisticregression__C": [0.1, 1.0]}
-
-            if "param_grid" in sig or "param_distributions" in sig:
-                # SearchCV estimators
-                extra_params = {"n_iter": 2} if "n_iter" in sig else {}
-                yield Estimator(estimator, param_grid, **extra_params)
-            else:
-                yield Estimator(estimator)
-
-        elif "transformer_list" in sig:
-            # FeatureUnion
-            transformer_list = [
-                ("trans1", make_pipeline(TfidfVectorizer(), MaxAbsScaler())),
-                (
-                    "trans2",
-                    make_pipeline(TfidfVectorizer(), StandardScaler(with_mean=False)),
-                ),
-            ]
-            yield Estimator(transformer_list)
-
-        elif "estimators" in sig:
-            # stacking, voting
-            if is_regressor(Estimator):
-                estimator = [
-                    ("est1", make_pipeline(TfidfVectorizer(), Ridge(alpha=0.1))),
-                    ("est2", make_pipeline(TfidfVectorizer(), Ridge(alpha=1))),
-                ]
-            else:
-                estimator = [
-                    (
-                        "est1",
-                        make_pipeline(TfidfVectorizer(), LogisticRegression(C=0.1)),
-                    ),
-                    ("est2", make_pipeline(TfidfVectorizer(), LogisticRegression(C=1))),
-                ]
-            yield Estimator(estimator)
-
-        else:
+        if not sig.intersection(
+            {
+                "estimator",
+                "base_estimator",
+                "regressor",
+                "transformer_list",
+                "estimators",
+            }
+        ):
             continue
 
+        for meta_estimator in _construct_instances(Estimator):
+            yield _get_instance_with_pipeline(meta_estimator, sig)
+
 
 # TODO: remove data validation for the following estimators
 # They should be able to work on any data and delegate data validation to
diff --git a/sklearn/tests/test_metaestimators_metadata_routing.py b/sklearn/tests/test_metaestimators_metadata_routing.py
index 7117c27e32e42..b733f4d119f5e 100644
--- a/sklearn/tests/test_metaestimators_metadata_routing.py
+++ b/sklearn/tests/test_metaestimators_metadata_routing.py
@@ -5,7 +5,7 @@
 import pytest
 
 from sklearn import config_context
-from sklearn.base import is_classifier
+from sklearn.base import BaseEstimator, is_classifier
 from sklearn.calibration import CalibratedClassifierCV
 from sklearn.compose import TransformedTargetRegressor
 from sklearn.covariance import GraphicalLassoCV
@@ -551,13 +551,13 @@ def get_init_args(metaestimator_info, sub_estimator_consumes):
     )
 
 
-def set_requests(estimator, *, method_mapping, methods, metadata_name, value=True):
+def set_requests(obj, *, method_mapping, methods, metadata_name, value=True):
     """Call `set_{method}_request` on a list of methods from the sub-estimator.
 
     Parameters
     ----------
-    estimator : BaseEstimator
-        The estimator for which `set_{method}_request` methods are called.
+    obj : BaseEstimator
+        The object for which `set_{method}_request` methods are called.
 
     method_mapping : dict
         The method mapping in the form of `{caller: [callee, ...]}`.
@@ -577,9 +577,13 @@ def set_requests(estimator, *, method_mapping, methods, metadata_name, value=Tru
     """
     for caller in methods:
         for callee in method_mapping.get(caller, [caller]):
-            set_request_for_method = getattr(estimator, f"set_{callee}_request")
+            set_request_for_method = getattr(obj, f"set_{callee}_request")
             set_request_for_method(**{metadata_name: value})
-            if is_classifier(estimator) and callee == "partial_fit":
+            if (
+                isinstance(obj, BaseEstimator)
+                and is_classifier(obj)
+                and callee == "partial_fit"
+            ):
                 set_request_for_method(classes=True)
 
 
diff --git a/sklearn/tests/test_pipeline.py b/sklearn/tests/test_pipeline.py
index 1a876e050f4f4..d425c00f114a2 100644
--- a/sklearn/tests/test_pipeline.py
+++ b/sklearn/tests/test_pipeline.py
@@ -71,7 +71,7 @@
 )
 
 
-class NoFit:
+class NoFit(BaseEstimator):
     """Small class to test parameter dispatching."""
 
     def __init__(self, a=None, b=None):
@@ -80,7 +80,7 @@ def __init__(self, a=None, b=None):
 
 
 class NoTrans(NoFit):
-    def fit(self, X, y):
+    def fit(self, X, y=None):
         return self
 
     def get_params(self, deep=False):
@@ -91,7 +91,7 @@ def set_params(self, **params):
         return self
 
 
-class NoInvTransf(NoTrans):
+class NoInvTransf(TransformerMixin, NoTrans):
     def transform(self, X):
         return X
 
@@ -105,19 +105,19 @@ def inverse_transform(self, X):
 
 
 class TransfFitParams(Transf):
-    def fit(self, X, y, **fit_params):
+    def fit(self, X, y=None, **fit_params):
         self.fit_params = fit_params
         return self
 
 
-class Mult(BaseEstimator):
+class Mult(TransformerMixin, BaseEstimator):
     def __init__(self, mult=1):
         self.mult = mult
 
     def __sklearn_is_fitted__(self):
         return True
 
-    def fit(self, X, y):
+    def fit(self, X, y=None):
         return self
 
     def transform(self, X):
diff --git a/sklearn/utils/__init__.py b/sklearn/utils/__init__.py
index 1a7a43fdbc01f..58bce9cfd6fe4 100644
--- a/sklearn/utils/__init__.py
+++ b/sklearn/utils/__init__.py
@@ -33,7 +33,6 @@
     Tags,
     TargetTags,
     TransformerTags,
-    default_tags,
     get_tags,
 )
 from .class_weight import compute_class_weight, compute_sample_weight
@@ -99,7 +98,6 @@ class parallel_backend(_joblib.parallel_backend):
     "ClassifierTags",
     "RegressorTags",
     "TransformerTags",
-    "default_tags",
     "get_tags",
 ]
 
diff --git a/sklearn/utils/_tags.py b/sklearn/utils/_tags.py
index eb9b44a2163b3..de756901d98ef 100644
--- a/sklearn/utils/_tags.py
+++ b/sklearn/utils/_tags.py
@@ -1,5 +1,6 @@
 from __future__ import annotations
 
+import warnings
 from dataclasses import dataclass, field
 
 from .fixes import _dataclass_args
@@ -186,6 +187,15 @@ class Tags:
 
     Parameters
     ----------
+    estimator_type : str or None
+        The type of the estimator. Can be one of:
+        - "classifier"
+        - "regressor"
+        - "transformer"
+        - "clusterer"
+        - "outlier_detector"
+        - "density_estimator"
+
     target_tags : :class:`TargetTags`
         The target(y) tags.
 
@@ -232,6 +242,7 @@ class Tags:
         The input data(X) tags.
     """
 
+    estimator_type: str | None
     target_tags: TargetTags
     transformer_tags: TransformerTags | None
     classifier_tags: ClassifierTags | None
@@ -245,6 +256,7 @@ class Tags:
     input_tags: InputTags = field(default_factory=InputTags)
 
 
+# TODO(1.8): Remove this function
 def default_tags(estimator) -> Tags:
     """Get the default tags for an estimator.
 
@@ -274,19 +286,20 @@ def default_tags(estimator) -> Tags:
     tags : Tags
         The default tags for the estimator.
     """
-    from ..base import is_classifier, is_regressor
-
-    target_required = is_classifier(estimator) or is_regressor(estimator)
+    est_is_classifier = getattr(estimator, "_estimator_type", None) == "classifier"
+    est_is_regressor = getattr(estimator, "_estimator_type", None) == "regressor"
+    target_required = est_is_classifier or est_is_regressor
 
     return Tags(
+        estimator_type=getattr(estimator, "_estimator_type", None),
         target_tags=TargetTags(required=target_required),
         transformer_tags=(
             TransformerTags()
             if hasattr(estimator, "transform") or hasattr(estimator, "fit_transform")
             else None
         ),
-        classifier_tags=ClassifierTags() if is_classifier(estimator) else None,
-        regressor_tags=RegressorTags() if is_regressor(estimator) else None,
+        classifier_tags=ClassifierTags() if est_is_classifier else None,
+        regressor_tags=RegressorTags() if est_is_regressor else None,
     )
 
 
@@ -316,6 +329,16 @@ def get_tags(estimator) -> Tags:
     if hasattr(estimator, "__sklearn_tags__"):
         tags = estimator.__sklearn_tags__()
     else:
+        warnings.warn(
+            f"Estimator {estimator} has no __sklearn_tags__ attribute, which is "
+            "defined in `sklearn.base.BaseEstimator`. This will raise an error in "
+            "scikit-learn 1.8. Please define the __sklearn_tags__ method, or inherit "
+            "from `sklearn.base.BaseEstimator` and other appropriate mixins such as "
+            "`sklearn.base.TransformerMixin`, `sklearn.base.ClassifierMixin`, "
+            "`sklearn.base.RegressorMixin`, and `sklearn.base.ClusterMixin`, and "
+            "`sklearn.base.OutlierMixin`.",
+            category=FutureWarning,
+        )
         tags = default_tags(estimator)
 
     return tags
diff --git a/sklearn/utils/_testing.py b/sklearn/utils/_testing.py
index ef683089eb64d..91efe88eeb354 100644
--- a/sklearn/utils/_testing.py
+++ b/sklearn/utils/_testing.py
@@ -38,6 +38,13 @@
 )
 
 import sklearn
+from sklearn.utils import (
+    ClassifierTags,
+    RegressorTags,
+    Tags,
+    TargetTags,
+    TransformerTags,
+)
 from sklearn.utils._array_api import _check_array_api_dispatch
 from sklearn.utils.fixes import (
     _IS_32BIT,
@@ -1089,8 +1096,6 @@ class MinimalClassifier:
     * within a `SearchCV` in `test_search.py`.
     """
 
-    _estimator_type = "classifier"
-
     def __init__(self, param=None):
         self.param = param
 
@@ -1127,6 +1132,15 @@ def score(self, X, y):
 
         return accuracy_score(y, self.predict(X))
 
+    def __sklearn_tags__(self):
+        return Tags(
+            estimator_type="classifier",
+            classifier_tags=ClassifierTags(),
+            regressor_tags=None,
+            transformer_tags=None,
+            target_tags=TargetTags(required=True),
+        )
+
 
 class MinimalRegressor:
     """Minimal regressor implementation without inheriting from BaseEstimator.
@@ -1138,8 +1152,6 @@ class MinimalRegressor:
     * within a `SearchCV` in `test_search.py`.
     """
 
-    _estimator_type = "regressor"
-
     def __init__(self, param=None):
         self.param = param
 
@@ -1167,6 +1179,15 @@ def score(self, X, y):
 
         return r2_score(y, self.predict(X))
 
+    def __sklearn_tags__(self):
+        return Tags(
+            estimator_type="regressor",
+            classifier_tags=None,
+            regressor_tags=RegressorTags(),
+            transformer_tags=None,
+            target_tags=TargetTags(required=True),
+        )
+
 
 class MinimalTransformer:
     """Minimal transformer implementation without inheriting from
@@ -1203,6 +1224,15 @@ def transform(self, X, y=None):
     def fit_transform(self, X, y=None):
         return self.fit(X, y).transform(X, y)
 
+    def __sklearn_tags__(self):
+        return Tags(
+            estimator_type="transformer",
+            classifier_tags=None,
+            regressor_tags=None,
+            transformer_tags=TransformerTags(),
+            target_tags=TargetTags(required=False),
+        )
+
 
 def _array_api_for_tests(array_namespace, device):
     try:
diff --git a/sklearn/utils/estimator_checks.py b/sklearn/utils/estimator_checks.py
index 728fd71844118..f5d542d9a59fc 100644
--- a/sklearn/utils/estimator_checks.py
+++ b/sklearn/utils/estimator_checks.py
@@ -1820,7 +1820,7 @@ def check_transformer_preserve_dtypes(name, transformer_orig):
             # check that the output dtype is preserved
             assert Xt.dtype == dtype, (
                 f"{name} (method={method}) does not preserve dtype. "
-                f"Original/Expected dtype={dtype.__name__}, got dtype={Xt.dtype}."
+                f"Original/Expected dtype={dtype}, got dtype={Xt.dtype}."
             )
 
 
diff --git a/sklearn/utils/tests/test_estimator_checks.py b/sklearn/utils/tests/test_estimator_checks.py
index 29611a853938f..846ee75a9bbd4 100644
--- a/sklearn/utils/tests/test_estimator_checks.py
+++ b/sklearn/utils/tests/test_estimator_checks.py
@@ -13,7 +13,7 @@
 import scipy.sparse as sp
 
 from sklearn import config_context, get_config
-from sklearn.base import BaseEstimator, ClassifierMixin, OutlierMixin
+from sklearn.base import BaseEstimator, ClassifierMixin, OutlierMixin, TransformerMixin
 from sklearn.cluster import MiniBatchKMeans
 from sklearn.datasets import (
     load_iris,
@@ -33,7 +33,6 @@
 from sklearn.svm import SVC, NuSVC
 from sklearn.utils import _array_api, all_estimators, deprecated
 from sklearn.utils._param_validation import Interval, StrOptions
-from sklearn.utils._tags import default_tags
 from sklearn.utils._testing import (
     MinimalClassifier,
     MinimalRegressor,
@@ -422,7 +421,7 @@ def fit(self, X, y):
         return self
 
 
-class SparseTransformer(BaseEstimator):
+class SparseTransformer(TransformerMixin, BaseEstimator):
     def __init__(self, sparse_container=None):
         self.sparse_container = sparse_container
 
@@ -1276,18 +1275,18 @@ def fit(self, X, y):
 def test_non_deterministic_estimator_skip_tests():
     # check estimators with non_deterministic tag set to True
     # will skip certain tests, refer to issue #22313 for details
-    for est in [MinimalTransformer, MinimalRegressor, MinimalClassifier]:
-        all_tests = list(_yield_all_checks(est(), legacy=True))
+    for Estimator in [MinimalTransformer, MinimalRegressor, MinimalClassifier]:
+        all_tests = list(_yield_all_checks(Estimator(), legacy=True))
         assert check_methods_sample_order_invariance in all_tests
         assert check_methods_subset_invariance in all_tests
 
-        class Estimator(est):
+        class MyEstimator(Estimator):
             def __sklearn_tags__(self):
-                tags = default_tags(self)
+                tags = super().__sklearn_tags__()
                 tags.non_deterministic = True
                 return tags
 
-        all_tests = list(_yield_all_checks(Estimator(), legacy=True))
+        all_tests = list(_yield_all_checks(MyEstimator(), legacy=True))
         assert check_methods_sample_order_invariance not in all_tests
         assert check_methods_subset_invariance not in all_tests
 
diff --git a/sklearn/utils/tests/test_tags.py b/sklearn/utils/tests/test_tags.py
index a6dab5078e5ac..5768a0d2b6b27 100644
--- a/sklearn/utils/tests/test_tags.py
+++ b/sklearn/utils/tests/test_tags.py
@@ -1,6 +1,10 @@
 import pytest
 
-from sklearn.base import BaseEstimator, RegressorMixin, TransformerMixin
+from sklearn.base import (
+    BaseEstimator,
+    RegressorMixin,
+    TransformerMixin,
+)
 from sklearn.utils._tags import get_tags
 
 
@@ -13,13 +17,22 @@ class ClassifierEstimator:
     _estimator_type = "classifier"
 
 
+class EmptyTransformer(TransformerMixin, BaseEstimator):
+    pass
+
+
+class EmptyRegressor(RegressorMixin, BaseEstimator):
+    pass
+
+
+@pytest.mark.filterwarnings("ignore:.*no __sklearn_tags__ attribute.*:FutureWarning")
 @pytest.mark.parametrize(
     "estimator, value",
     [
         [NoTagsEstimator(), False],
         [ClassifierEstimator(), True],
-        [TransformerMixin(), False],
-        [RegressorMixin(), True],
+        [EmptyTransformer(), False],
+        [EmptyRegressor(), True],
         [BaseEstimator(), False],
     ],
 )
diff --git a/sklearn/utils/tests/test_validation.py b/sklearn/utils/tests/test_validation.py
index 62627ecb1bae8..20aee5b439252 100644
--- a/sklearn/utils/tests/test_validation.py
+++ b/sklearn/utils/tests/test_validation.py
@@ -946,7 +946,7 @@ def test_check_is_fitted():
 
 
 def test_check_is_fitted_attributes():
-    class MyEstimator:
+    class MyEstimator(BaseEstimator):
         def fit(self, X, y):
             return self
 


Code review comments:
diff_hunk: @@ -197,6 +198,52 @@ def score(self, X, y, *args, **kwargs):
             )
 
 
+def _get_instance_with_pipeline(meta_estimator, init_params):
+    """Given a single meta-estimator instance, generate an instance with a pipeline"""
+    if {"estimator", "base_estimator", "regressor"} & init_params:
+        if is_regressor(meta_estimator):
+            estimator = make_pipeline(TfidfVectorizer(), Ridge())
+            param_grid = {"ridge__alpha": [0.1, 1.0]}
+        else:
+            estimator = make_pipeline(TfidfVectorizer(), LogisticRegression())
+            param_grid = {"logisticregression__C": [0.1, 1.0]}
+
+        if {"param_grid", "param_distributions"} & init_params:
path: sklearn/tests/test_metaestimators.py
text: @user1:
```suggestion
        if init_params.intersection({"param_grid", "param_distributions"}):
```

@author:
set operations are really nice though 😭 

@user1:
But this is the same operator that is run under the hood. I just find it easier to have the English name rather than trying to understand why you are doing a bit-wise operation between parameter names and then find out that they are sets and it make sense :)