Repository: sktime/sktime
Pull request #8131 (2025-04-04 11:33:27)

[ENH] refactor `scitype` detection logic and meta-estimator clone logic to include `sklearn` estimator types
This PR refactors the `scitype` detection logic to include `sklearn` scitypes.

The `scitype` method can now return sklearn scitypes such as `"regressor_tabular"` or `"classifier_tabular"`

In addition, the meta-estimator clone logic is relaxed so that `sklearn` types are also natively supported.


Patch:
diff --git a/sktime/base/_base.py b/sktime/base/_base.py
index ff6c131f1..60dce2411 100644
--- a/sktime/base/_base.py
+++ b/sktime/base/_base.py
@@ -529,6 +529,19 @@ def _clone_estimator(base_estimator, random_state=None):
     return estimator
 
 
+def _safe_clone(object):
+    """Clone an object.
+
+    If the object has a clone method, use that.
+
+    Otherwise delegates to sklearn's clone function.
+    """
+    if hasattr(object, "clone"):
+        return object.clone()
+    else:
+        return clone(object)
+
+
 def deepcopy_func(f, name=None):
     """Deepcopy of a function."""
     import types
diff --git a/sktime/base/_meta.py b/sktime/base/_meta.py
index 9f7ca96df..f2a58c2ea 100644
--- a/sktime/base/_meta.py
+++ b/sktime/base/_meta.py
@@ -11,6 +11,7 @@ import numpy as np
 import pandas as pd
 
 from sktime.base import BaseEstimator
+from sktime.base._base import _safe_clone
 from sktime.utils._estimator_html_repr import _VisualBlock
 
 
@@ -350,7 +351,7 @@ class _HeterogenousMetaEstimator:
             name = type(obj).__name__
 
         if clone_est:
-            return (name, est.clone())
+            return (name, _safe_clone(est))
         else:
             return (name, est)
 
@@ -405,7 +406,7 @@ class _HeterogenousMetaEstimator:
         """
         ests = self._get_estimator_list(estimators)
         if clone_ests:
-            ests = [e.clone() for e in ests]
+            ests = [_safe_clone(e) for e in ests]
         unique_names = self._get_estimator_names(estimators, make_unique=True)
         est_tuples = list(zip(unique_names, ests))
         return est_tuples
@@ -951,7 +952,7 @@ class _ColumnEstimator:
         if isinstance(estimators, cls):
             ycols = [str(col) for col in X.columns]
             colrange = range(len(ycols))
-            est_list = [estimators.clone() for _ in colrange]
+            est_list = [_safe_clone(estimators) for _ in colrange]
             return list(zip(ycols, est_list, colrange))
 
         if (
diff --git a/sktime/registry/_scitype.py b/sktime/registry/_scitype.py
index 7ed5028e9..41837d253 100644
--- a/sktime/registry/_scitype.py
+++ b/sktime/registry/_scitype.py
@@ -36,52 +36,121 @@ def scitype(
 ):
     """Determine scitype string of obj.
 
+    This function returns the ``sktime`` internal type for the object ``obj``,
+    the so-called :term:`scitype`, e.g., ``"forecaster"``, ``"transformer"``.
+
+    A scitype defines a unified interface for a family of objects, e.g.,
+    all forecasters strictly adhere to the ``BaseForecaster`` API.
+
+    A list of all ``sktime`` scitypes can be found in the
+    ``BASE_CLASS_SCITYPE_LIST`` in the ``sktime.registry`` module,
+    a table with explanations in the ``BASE_CLASS_REGISTER`` in the same module.
+
+    For ``sktime`` objects, the scitype is determined by the ``object_type`` tag.
+
+    For ``sklearn`` objects, the scitype is determined by
+    inheritance from ``sklearn`` mixin classes.
+
     Parameters
     ----------
-    obj : class or object inheriting from sktime BaseObject
+    obj : class or object inheriting from sktime BaseObject or sklearn BaseEstimator
+
     force_single_scitype : bool, optional, default = True
-        whether only a single scitype is returned
-        if True, only the *first* scitype found will be returned
-        order is determined by the order in BASE_CLASS_REGISTER
+        whether only a single scitype is returned.
+
+        * if True, only the *first* scitype found will be returned
+          order is determined by the order in ``BASE_CLASS_REGISTER`` resp
+          ``BASE_CLASS_SCITYPE_LIST`` (both imply the same odrer)
+        * if False, a list of all scitypes is returned
+
     coerce_to_list : bool, optional, default = False
-        determines the return type: if True, returns a single str,
-        if False, returns a list of str
+        determines the return type:
+
+        * if True, always returns a list of str
+        * if False, returns a single str for a single scitype,
+          and a list of str for multiple scitypes
+
     raise_on_unknown : bool, optional, default = True
-        if True, raises an error if no scitype can be determined for obj
-        if False, returns "object" scitype
+
+        * if True, raises an error if no scitype can be determined for obj
+        * if False, returns "object" scitype
 
     Returns
     -------
-    scitype : str, or list of str of sktime scitype strings from BASE_CLASS_REGISTER
-        str, sktime scitype string, if exactly one scitype can be determined for obj
-        or force_single_scitype is True, and if coerce_to_list is False
-        list of str, of scitype strings, if more than one scitype are determined,
-        or if coerce_to_list is True
-        obj has scitype if it inherits from class in same row of BASE_CLASS_REGISTER
+    scitype : str, or list of str of sktime scitype strings
+
+        strings in the return object can be:
+
+        * an ``sktime`` scitype string, from ``BASE_CLASS_SCITYPE_LIST`` resp
+          ``BASE_CLASS_REGISTER`` (both contain the same strings).
+          These include (not an exhaustive list):
+
+            * ``"forecaster"`` - ``sktime`` forecaster
+            * ``"classifier"`` - ``sktime`` time series classifier
+            * ``"regressor"`` - ``sktime`` time series regressor
+            * ``"clusterer"`` - ``sktime`` time series clusterer
+            * ``"transformer"`` - ``sktime`` time series transformation
+
+        * an ``sklearn`` scitype string used in ``sktime``, this can be:
+
+            * ``"classifier_tabular"`` - ``sklearn`` classifier
+            * ``"regressor_tabular"`` - ``sklearn`` regressor
+            * ``"clusterer_tabular"`` - ``sklearn`` clusterer
+            * ``"transformer_tabular"`` - ``sklearn`` transformation
+
+        The return type is:
+
+        * str, sktime scitype string, if exactly one scitype can be determined for obj
+          or ``force_single_scitype`` is True, and if ``coerce_to_list`` is False
+        * list of str, of scitype strings, if more than one scitype are determined,
+          or if ``coerce_to_list`` is True
 
     Raises
     ------
     TypeError if no scitype can be determined for obj
     """
-    # if object has tag, return tag
+    # this function uses three passes to determine scitype:
+    #
+    # 1st pass: check if object has skbase tag manager, then use that
+    # 2nd pass: check if object is sklearn estimator, then use sklearn scitype
+    # 3rd pass: check if object is subclass of any of the base classes in
+    # the BASE_CLASS_REGISTER, then use that
+    # if neither: if no scitype can be determined, return "object" scitype
+
+    def handle_output_format(detected_scitype):
+        """Handle the return format of the detected scitype."""
+        if not isinstance(detected_scitype, list):
+            detected_scitype = [detected_scitype]
+        if force_single_scitype and len(detected_scitype) > 1:
+            detected_scitype = [detected_scitype[0]]
+        if not coerce_to_list:
+            detected_scitype = detected_scitype[0]
+        return detected_scitype
+
+    # 1st pass
+    # if object has scitype tag, return tag
     if hasattr(obj, "get_tag"):
         if isclass(obj):
-            tag_type = obj.get_class_tag("object_type", None)
+            detected_scitype = obj.get_class_tag("object_type", None)
         else:
-            tag_type = obj.get_tag("object_type", None, raise_error=False)
-        if tag_type is not None:
-            if not isinstance(tag_type, list):
-                tag_type = [tag_type]
-            if force_single_scitype and len(tag_type) > 1:
-                tag_type = [tag_type[0]]
-            if coerce_to_list:
-                return tag_type
-            else:
-                return tag_type[0]
+            detected_scitype = obj.get_tag("object_type", None, raise_error=False)
+        if detected_scitype is not None:
+            return handle_output_format(detected_scitype)
 
-    BASE_CLASS_REGISTER = get_base_class_register()
+    # 2nd pass
+    # check if object is sklearn estimator
+    # if it is, return sklearn scitype
+    from sktime.utils.sklearn import is_sklearn_estimator, sklearn_scitype
+
+    if is_sklearn_estimator(obj):
+        detected_scitype = f"{sklearn_scitype(obj)}_tabular"
+        return handle_output_format(detected_scitype)
 
+    # 3rd pass
+    # check in the base class register
     # if the tag is not present, determine scitype from legacy base class logic
+    BASE_CLASS_REGISTER = get_base_class_register()
+
     if isclass(obj):
         scitypes = [sci[0] for sci in BASE_CLASS_REGISTER if issubclass(obj, sci[1])]
     else:
diff --git a/sktime/registry/_scitype_coercion.py b/sktime/registry/_scitype_coercion.py
index c09d2fb4d..938f57133 100644
--- a/sktime/registry/_scitype_coercion.py
+++ b/sktime/registry/_scitype_coercion.py
@@ -103,6 +103,7 @@ def coerce_scitype(
     TypeError if ``raise_on_mismatch``, and ``from_scitype`` is not None, and
         the detected scitype does not match the expected scitype ``from_scitype``.
     """
+    from sktime.base._base import _safe_clone
     from sktime.registry._scitype import scitype
     from sktime.utils.sklearn import is_sklearn_estimator, sklearn_scitype
 
@@ -138,12 +139,7 @@ def coerce_scitype(
         from_scitype = detected_scitype
 
     if clone_obj:
-        if is_sklearn_estimator(obj) or not hasattr(obj, "clone"):
-            from sklearn.base import clone
-
-            obj = clone(obj)
-        else:
-            obj = obj.clone()
+        obj = _safe_clone(obj)
 
     if (from_scitype, to_scitype) not in _coerce_register:
         return obj


Test patch:
diff --git a/sktime/registry/tests/test_scitype.py b/sktime/registry/tests/test_scitype.py
index 688801c2f..702f456d0 100644
--- a/sktime/registry/tests/test_scitype.py
+++ b/sktime/registry/tests/test_scitype.py
@@ -1,4 +1,4 @@
-"""Tests for scitype typipng function."""
+"""Tests for scitype typing function."""
 
 import pytest
 
@@ -104,3 +104,44 @@ def test_is_scitype():
     assert is_scitype(_DummyClass, "foo")
     assert is_scitype(_DummyClass, "bar")
     assert not is_scitype(_DummyClass, "baz")
+
+
+def test_sklearn_scitypes():
+    """Test that scitype correctly identifies sklearn scitypes."""
+    from sklearn.linear_model import LinearRegression
+    from sklearn.preprocessing import StandardScaler
+    from sklearn.svm import SVC
+
+    assert scitype(LinearRegression) == "regressor_tabular"
+    assert scitype(LinearRegression()) == "regressor_tabular"
+    assert scitype(StandardScaler) == "transformer_tabular"
+    assert scitype(StandardScaler()) == "transformer_tabular"
+    assert scitype(SVC) == "classifier_tabular"
+    assert scitype(SVC()) == "classifier_tabular"
+
+    assert is_scitype(LinearRegression, "regressor_tabular")
+    assert is_scitype(LinearRegression(), "regressor_tabular")
+    assert is_scitype(StandardScaler, "transformer_tabular")
+    assert is_scitype(StandardScaler(), "transformer_tabular")
+    assert is_scitype(SVC, "classifier_tabular")
+    assert is_scitype(SVC(), "classifier_tabular")
+
+    from sklearn.pipeline import Pipeline
+
+    class_pipe = Pipeline(
+        steps=[
+            ("scaler", StandardScaler()),
+            ("classifier", SVC()),
+        ]
+    )
+    assert scitype(class_pipe) == "classifier_tabular"
+    assert is_scitype(class_pipe, "classifier_tabular")
+
+    reg_pipe = Pipeline(
+        steps=[
+            ("scaler", StandardScaler()),
+            ("regressor", LinearRegression()),
+        ]
+    )
+    assert scitype(reg_pipe) == "regressor_tabular"
+    assert is_scitype(reg_pipe, "regressor_tabular")