Repository: sktime/sktime

Issue:
[BUG] Graphical pipeline requires `X` in `fit` - contradiction to forecaster interface specs
**Describe the bug**
<!--
A clear and concise description of what the bug is.
-->
Building a pipeline with transforms working on `y` requires passing in a `X` in the fitting. I was hoping to be able to build a pipeline and transform `y` before passing it to the forecaster but that does not seem to work. 


**To Reproduce**
<!--
Add a Minimal, Complete, and Verifiable example (for more details, see e.g. https://stackoverflow.com/help/mcve

If the code is too long, feel free to put it in a public gist and link it in the issue: https://gist.github.com
-->

```python
from sktime.forecasting.compose import make_reduction
from sklearn.linear_model   import Ridge
from sktime.transformations.series.difference import Differencer
from sktime.transformations.series.lag import Lag
from sktime.datasets import load_airline
from sktime.split import temporal_train_test_split
from sktime.pipeline import Pipeline

y = load_airline()
y_train, y_test = temporal_train_test_split(y)

regressor = Ridge()
forecaster = make_reduction(regressor, window_length=12, strategy="recursive")

pipe = Pipeline()
pipe = pipe.add_step(Differencer(lags=[1,3,4,12]), "differencer", edges={"X": "y"})
pipe = pipe.add_step(Lag([1], index_out="original"),name="lag",edges={"X": "y"})
pipe = pipe.add_step(forecaster,
                  name="forecaster",
                  edges={
                      "X": ["lag","differencer"],
                      "y": "y"
                  })

pipe.fit(y=y_train) #<-- can't pass X=y_train since at predict time one would need to pass X=X_test=y_test
y_pred = pipe.predict(fh=[1, 2, 3])
```

**Expected behavior**
<!--
A clear and concise description of what you expected to happen.
-->
The expected behavior was no requirement for `X` in the fit function.

**Additional context**
<!--
Add any other context about the problem here.
-->

**Versions**
<details>

<!--
Please run the following code snippet and paste the output here:

from sktime import show_versions; show_versions()
-->
System:
    python: 3.9.13 (main, Aug 25 2022, 23:51:50) [MSC v.1916 64 bit (AMD64)]
executable: [c:\Users\user\anaconda3\python.exe](file:///C:/Users/user/anaconda3/python.exe)
   machine: Windows-10-10.0.22621-SP0

Python dependencies:
          pip: 22.3.1
       sktime: 0.24.1
      sklearn: 1.0.2
       skbase: 0.6.1
        numpy: 1.23.5
        scipy: 1.9.3
       pandas: 1.4.4
   matplotlib: 3.6.2
       joblib: 1.1.1
        numba: 0.56.4
  statsmodels: 0.13.2
     pmdarima: None
statsforecast: None
      tsfresh: None
      tslearn: None
        torch: 1.13.1+cpu
   tensorflow: None
tensorflow_probability: None
</details>

<!-- Thanks for contributing! -->



Discussion:
Agreed - this is indeed a bug. There are not one but three related issues that I can see:

* `Pipeline` instances are polymorphic, if the instance is of forecaster type, it should conform with the interface. That means, `X` in `fit` cannot be required, because forecasters do not require `X` in `fit`.
* the type tag should be set to a base type at the end of `__init__`, where possible - this is `object_type`, and it should be set to the string `"forecaster"` if the expected type is a forecaster (via `self.set_tags`)
* the conformance should be tested, this can be achieved by implementing `get_test_params` and ensuring some test instances are of forecaster type. Fingers crossed, the test framework will collect these instances and subject them to forecaster specific tests. (we should check whether this is true, e.g., by inserting an obvious error)

Resolving patch:
diff --git a/sktime/pipeline/pipeline.py b/sktime/pipeline/pipeline.py
--- a/sktime/pipeline/pipeline.py
+++ b/sktime/pipeline/pipeline.py
@@ -1,6 +1,5 @@
 """class that implements a graph pipeline."""
 import warnings
-import weakref
 from copy import copy, deepcopy
 
 from sktime.base import BaseEstimator
@@ -183,6 +182,17 @@ def __init__(self, steps=None):
         self.steps = steps
         self._steps = steps if steps is not None else []
 
+        object_types = [step["skobject"].get_tag("object_type") for step in self._steps]
+        if len(set(object_types)) == 1:
+            self.set_tags(**{"object_type": object_types[0]})
+        elif len(set(object_types) - {"transformer"}) == 1:
+            self.set_tags(
+                **{"object_type": list(set(object_types) - {"transformer"})[0]}
+            )
+        else:
+            # Mixture of different object types
+            pass
+
         for step_information in self._steps:
             if "method" not in step_information:
                 step_information["method"] = None
@@ -194,12 +204,10 @@ def _get_unique_id(self, skobject):
         self.counter += 1
         # Check if not already an skobject cloned from the provided
         # skobject is part of the pipeline
-        if (id(skobject) not in self.id_to_obj) or self.id_to_obj[
-            id(skobject)
-        ]() is None:
-            # In this case set a weakref of that skobject to id_to_obj to prevent that
+        if id(skobject) not in self.id_to_obj:
+            # In this case store that skobject to id_to_obj to prevent that
             # the garbage collector reassigns the id.
-            self.id_to_obj[id(skobject)] = weakref.ref(skobject)
+            self.id_to_obj[id(skobject)] = skobject
             self.id_to_true_id[id(skobject)] = self.counter
         return self.id_to_true_id[id(skobject)]
 
@@ -358,7 +366,7 @@ def _assemble_steps(self):
             self._last_step_name = name
         self._assembled = True
 
-    def fit(self, X, y=None, **kwargs):
+    def fit(self, X=None, y=None, **kwargs):
         """Fit graph pipeline to training data.
 
         Parameters
@@ -377,6 +385,8 @@ def fit(self, X, y=None, **kwargs):
         """
         self._assembled = False
         self._initiate_call(X, y, kwargs)
+
+        assert (X is not None) or (y is not None), "Either X or y must be provided."
         self._y = y
         self._X = X
 
@@ -452,7 +462,7 @@ def transform(self, X, y=None, **kwargs):
             .result
         )
 
-    def predict(self, X, y=None, **kwargs):
+    def predict(self, X=None, y=None, **kwargs):
         """Perform a prediction.
 
         I.e. calls predict or transform on each element in the  graph pipeline.
@@ -620,3 +630,72 @@ def _create_subsetter(self, edg):
             params={},
         )
         self.assembled_steps[edg] = step
+
+    @classmethod
+    def get_test_params(cls, parameter_set="default"):
+        """Return testing parameter settings for the estimator.
+
+        Parameters
+        ----------
+        parameter_set : str, default="default"
+            Name of the set of test parameters to return, for use in tests. If no
+            special parameters are defined for a value, will return `"default"` set.
+            There are currently no reserved values for forecasters.
+
+        Returns
+        -------
+        params : dict or list of dict, default = {}
+            Parameters to create testing instances of the class
+            Each dict are parameters to construct an "interesting" test instance, i.e.,
+            `MyClass(**params)` or `MyClass(**params[i])` creates a valid test instance.
+            `create_test_instance` uses the first (or only) dictionary in `params`
+        """
+        from sktime.classification.distance_based import KNeighborsTimeSeriesClassifier
+        from sktime.forecasting.naive import NaiveForecaster
+        from sktime.transformations.series.boxcox import BoxCoxTransformer
+        from sktime.transformations.series.exponent import ExponentTransformer
+
+        return [
+            {
+                "steps": [
+                    {
+                        "skobject": ExponentTransformer(),
+                        "name": "exp",
+                        "edges": {"X": "X"},
+                    },
+                    {
+                        "skobject": BoxCoxTransformer(),
+                        "name": "box",
+                        "edges": {"X": "exp"},
+                    },
+                ]
+            },
+            {
+                "steps": [
+                    {
+                        "skobject": ExponentTransformer(),
+                        "name": "exp",
+                        "edges": {"X": "X"},
+                    },
+                    {
+                        "skobject": KNeighborsTimeSeriesClassifier(),
+                        "name": "knnclassifier",
+                        "edges": {"X": "exp", "y": "y"},
+                    },
+                ]
+            },
+            {
+                "steps": [
+                    {
+                        "skobject": ExponentTransformer(),
+                        "name": "exp",
+                        "edges": {"X": "y"},
+                    },
+                    {
+                        "skobject": NaiveForecaster(),
+                        "name": "naive",
+                        "edges": {"X": "exp", "y": "y"},
+                    },
+                ]
+            },
+        ]


Test patch:
diff --git a/sktime/tests/test_all_estimators.py b/sktime/tests/test_all_estimators.py
--- a/sktime/tests/test_all_estimators.py
+++ b/sktime/tests/test_all_estimators.py
@@ -876,6 +876,10 @@ def test_inheritance(self, estimator_class):
                 f"estimator: {estimator_class} has fit method, but"
                 f"is not a sub-class of BaseEstimator."
             )
+        from sktime.pipeline import Pipeline
+
+        if issubclass(estimator_class, Pipeline):
+            return
 
         # Usually estimators inherit only from one BaseEstimator type, but in some cases
         # they may be predictor and transformer at the same time (e.g. pipelines)