Repository: sktime/sktime

Issue:
[BUG] StackingForecaster doesn't work with exogenous factors the way normally users expect to feed the data
**Describe the bug**
<!--
A clear and concise description of what the bug is.
-->
StackingForecaster throws error during fit() when being used with forecasters which can handle exogenous variables.

**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._stack import StackingForecaster
from sktime.forecasting.compose._ensemble import AutoEnsembleForecaster
from sktime.forecasting.naive import NaiveForecaster
from sktime.forecasting.structural import UnobservedComponents
from sktime.forecasting.ets import AutoETS
from sktime.datasets import load_airline
import random

y = load_airline()
# create multivariate data
r1 = pd.Series([random.randrange(-10,10) for i in range(len(y))])
r2 = pd.Series([random.randrange(-20,20) for i in range(len(y))])
r1.index = r2.index = y.index
X = pd.DataFrame({"y_1": (y.copy() * 2) + r1, "y_2": y.copy() * 0.5 + r2})
y_train, y_test, X_train, X_pred = temporal_train_test_split(y, X, test_size=12)

fh = ForecastingHorizon(y_test.index, is_relative=False)

forecasters = [
    ("arima", AutoARIMA(sp=12)),
    ("ets", UnobservedComponents(seasonal=12))
]

fc1 = StackingForecaster(forecasters=forecasters)

fc1.fit(y=y_train, X=X_train, fh=list(range(1, len(fh)+1)))
fc1.predict(fh=fh, X=X_pred)
```

**Error returned**
```python
---------------------------------------------------------------------------
ValueError                                Traceback (most recent call last)
~/local/errata_univariate.py in <module>
     23 fc1 = StackingForecaster(forecasters=forecasters)
     24 
---> 25 fc1.fit(y=y_train, X=X_train, fh=list(range(1, len(fh)+1)))
     26 fc1.predict(fh=fh, X=X_pred)

~/work/sktime/sktime/forecasting/base/_base.py in fit(self, y, X, fh)
    161         # we call the ordinary _fit if no looping/vectorization needed
    162         if not vectorization_needed:
--> 163             self._fit(y=y_inner, X=X_inner, fh=fh)
    164         else:
    165             # otherwise we call the vectorized version of fit

~/work/sktime/sktime/forecasting/compose/_stack.py in _fit(self, y, X, fh)
    108 
    109         # fit forecasters on training window
--> 110         self._fit_forecasters(forecasters, y_fcst, fh=fh, X=X)
    111         X_meta = np.column_stack(self._predict_forecasters(fh=fh, X=X_meta))
    112 

~/work/sktime/sktime/forecasting/base/_meta.py in _fit_forecasters(self, forecasters, y, X, fh)
     66         self.forecasters_ = Parallel(n_jobs=self.n_jobs)(
     67             delayed(_fit_forecaster)(clone(forecaster), y, X, fh)
---> 68             for forecaster in forecasters
     69         )
     70 

~/opt/miniconda3/envs/chronos_dev2/lib/python3.7/site-packages/joblib/parallel.py in __call__(self, iterable)
   1041             # remaining jobs.
   1042             self._iterating = False
-> 1043             if self.dispatch_one_batch(iterator):
   1044                 self._iterating = self._original_iterator is not None
   1045 

~/opt/miniconda3/envs/chronos_dev2/lib/python3.7/site-packages/joblib/parallel.py in dispatch_one_batch(self, iterator)
    859                 return False
    860             else:
--> 861                 self._dispatch(tasks)
    862                 return True
    863 

~/opt/miniconda3/envs/chronos_dev2/lib/python3.7/site-packages/joblib/parallel.py in _dispatch(self, batch)
    777         with self._lock:
    778             job_idx = len(self._jobs)
--> 779             job = self._backend.apply_async(batch, callback=cb)
    780             # A job can complete so quickly than its callback is
    781             # called before we get here, causing self._jobs to

~/opt/miniconda3/envs/chronos_dev2/lib/python3.7/site-packages/joblib/_parallel_backends.py in apply_async(self, func, callback)
    206     def apply_async(self, func, callback=None):
    207         """Schedule a func to be run"""
--> 208         result = ImmediateResult(func)
    209         if callback:
    210             callback(result)

~/opt/miniconda3/envs/chronos_dev2/lib/python3.7/site-packages/joblib/_parallel_backends.py in __init__(self, batch)
    570         # Don't delay the application, to avoid keeping the input
    571         # arguments in memory
--> 572         self.results = batch()
    573 
    574     def get(self):

~/opt/miniconda3/envs/chronos_dev2/lib/python3.7/site-packages/joblib/parallel.py in __call__(self)
    261         with parallel_backend(self._backend, n_jobs=self._n_jobs):
    262             return [func(*args, **kwargs)
--> 263                     for func, args, kwargs in self.items]
    264 
    265     def __reduce__(self):

~/opt/miniconda3/envs/chronos_dev2/lib/python3.7/site-packages/joblib/parallel.py in <listcomp>(.0)
    261         with parallel_backend(self._backend, n_jobs=self._n_jobs):
    262             return [func(*args, **kwargs)
--> 263                     for func, args, kwargs in self.items]
    264 
    265     def __reduce__(self):

~/work/sktime/sktime/forecasting/base/_meta.py in _fit_forecaster(forecaster, y, X, fh)
     62         def _fit_forecaster(forecaster, y, X, fh):
     63             """Fit single forecaster."""
---> 64             return forecaster.fit(y, X, fh)
     65 
     66         self.forecasters_ = Parallel(n_jobs=self.n_jobs)(

~/work/sktime/sktime/forecasting/base/_base.py in fit(self, y, X, fh)
    161         # we call the ordinary _fit if no looping/vectorization needed
    162         if not vectorization_needed:
--> 163             self._fit(y=y_inner, X=X_inner, fh=fh)
    164         else:
    165             # otherwise we call the vectorized version of fit

~/work/sktime/sktime/forecasting/base/adapters/_pmdarima.py in _fit(self, y, X, fh, **fit_params)
     47         """
     48         self._forecaster = self._instantiate_model()
---> 49         self._forecaster.fit(y, X=X, **fit_params)
     50         return self
     51 

~/opt/miniconda3/envs/chronos_dev2/lib/python3.7/site-packages/pmdarima/arima/auto.py in fit(self, y, X, **fit_args)
    218             with_intercept=self.with_intercept,
    219             sarimax_kwargs=sarimax_kwargs,
--> 220             **fit_args)
    221 
    222         return self

~/opt/miniconda3/envs/chronos_dev2/lib/python3.7/site-packages/pmdarima/arima/auto.py in auto_arima(y, X, start_p, d, start_q, max_p, max_d, max_q, start_P, D, start_Q, max_P, max_D, max_Q, max_order, m, seasonal, stationary, information_criterion, alpha, test, seasonal_test, stepwise, n_jobs, start_params, trend, method, maxiter, offset_test_args, seasonal_test_args, suppress_warnings, error_action, trace, random, random_state, n_fits, return_valid_fits, out_of_sample_size, scoring, scoring_args, with_intercept, sarimax_kwargs, **fit_args)
    507     xx = y.copy()
    508     if X is not None:
--> 509         lm = LinearRegression().fit(X, y)
    510         xx = y - lm.predict(X)
    511 

~/opt/miniconda3/envs/chronos_dev2/lib/python3.7/site-packages/sklearn/linear_model/_base.py in fit(self, X, y, sample_weight)
    661 
    662         X, y = self._validate_data(
--> 663             X, y, accept_sparse=accept_sparse, y_numeric=True, multi_output=True
    664         )
    665 

~/opt/miniconda3/envs/chronos_dev2/lib/python3.7/site-packages/sklearn/base.py in _validate_data(self, X, y, reset, validate_separately, **check_params)
    579                 y = check_array(y, **check_y_params)
    580             else:
--> 581                 X, y = check_X_y(X, y, **check_params)
    582             out = X, y
    583 

~/opt/miniconda3/envs/chronos_dev2/lib/python3.7/site-packages/sklearn/utils/validation.py in check_X_y(X, y, accept_sparse, accept_large_sparse, dtype, order, copy, force_all_finite, ensure_2d, allow_nd, multi_output, ensure_min_samples, ensure_min_features, y_numeric, estimator)
    979     y = _check_y(y, multi_output=multi_output, y_numeric=y_numeric)
    980 
--> 981     check_consistent_length(X, y)
    982 
    983     return X, y

~/opt/miniconda3/envs/chronos_dev2/lib/python3.7/site-packages/sklearn/utils/validation.py in check_consistent_length(*arrays)
    332         raise ValueError(
    333             "Found input variables with inconsistent numbers of samples: %r"
--> 334             % [int(l) for l in lengths]
    335         )
    336 

ValueError: Found input variables with inconsistent numbers of samples: [132, 120]
```

**Expected behavior**
<!--
A clear and concise description of what you expected to happen.
-->

**Additional context**
<!--
Add any other context about the problem here.
-->
Error is happening probably because of this line (as commendted) in the code below; source `sktime/sktime/forecasting/compose/_stack.py`:

**please read the comments with double hash, those were raised by me as question or advice.**

```python
def _fit(self, y, X=None, fh=None):
        """Fit to training data.
        Parameters
        ----------
        y : pd.Series
            Target time series to which to fit the forecaster.
        fh : int, list or np.array, optional (default=None)
            The forecasters horizon with the steps ahead to to predict.
        X : pd.DataFrame, optional (default=None)
            Exogenous variables are ignored
        Returns
        -------
        self : returns an instance of self.
        """
        _, forecasters = self._check_forecasters()
        self.regressor_ = check_regressor(
            regressor=self.regressor, random_state=self.random_state
        )

        # split training series into training set to fit forecasters and
        # validation set to fit meta-learner
        cv = SingleWindowSplitter(fh=fh.to_relative(self.cutoff))
        train_window, test_window = next(cv.split(y))
        y_fcst = y.iloc[train_window]   ## why call this y_fcst is it is y_train??
        y_meta = y.iloc[test_window].values
        if X is not None:
            X_meta = X.iloc[test_window]    ## X_train should also be created; here only test set is created
        else:
            X_meta = None

        # fit forecasters on training window
        ## ERROR as index of y_fcst does not match with X, here X_train should be passed
        self._fit_forecasters(forecasters, y_fcst, fh=fh, X=X)     
        ## ISn't it misleading to call this X_meta, as you are actually getting forecast of y, not X.
        X_meta = np.column_stack(self._predict_forecasters(fh=fh, X=X_meta))

        # fit final regressor on on validation window
        self.regressor_.fit(X_meta, y_meta)    ## same here X_meta should renamed to like y_fcst or y_pred

        # refit forecasters on entire training series
        self._fit_forecasters(forecasters, y, fh=self.fh, X=X)

        return self
```


**Versions**
<details>

<!--
Please run the following code snippet and paste the output here:
 
from sktime import show_versions; show_versions()
-->

</details>

<!-- Thanks for contributing! -->



Discussion:

Resolving patch:
diff --git a/sktime/forecasting/compose/_stack.py b/sktime/forecasting/compose/_stack.py
--- a/sktime/forecasting/compose/_stack.py
+++ b/sktime/forecasting/compose/_stack.py
@@ -3,7 +3,7 @@
 # copyright: sktime developers, BSD-3-Clause License (see LICENSE file)
 """Implements forecasters for combining forecasts via stacking."""
 
-__author__ = ["mloning", "fkiraly"]
+__author__ = ["mloning", "fkiraly", "indinewton"]
 __all__ = ["StackingForecaster"]
 
 from warnings import warn
@@ -63,10 +63,11 @@ class StackingForecaster(_HeterogenousEnsembleForecaster):
 
     _required_parameters = ["forecasters"]
     _tags = {
-        "ignores-exogeneous-X": True,
+        "ignores-exogeneous-X": False,
         "requires-fh-in-fit": True,
-        "handles-missing-data": False,
+        "handles-missing-data": True,
         "scitype:y": "univariate",
+        "X-y-must-have-same-index": True,
     }
 
     def __init__(self, forecasters, regressor=None, random_state=None, n_jobs=None):
@@ -74,6 +75,10 @@ def __init__(self, forecasters, regressor=None, random_state=None, n_jobs=None):
         self.regressor = regressor
         self.random_state = random_state
 
+        self._anytagis_then_set("ignores-exogeneous-X", False, True, forecasters)
+        self._anytagis_then_set("handles-missing-data", False, True, forecasters)
+        self._anytagis_then_set("fit_is_empty", False, True, forecasters)
+
     def _fit(self, y, X=None, fh=None):
         """Fit to training data.
 
@@ -97,24 +102,30 @@ def _fit(self, y, X=None, fh=None):
 
         # split training series into training set to fit forecasters and
         # validation set to fit meta-learner
-        cv = SingleWindowSplitter(fh=fh.to_relative(self.cutoff))
+        inner_fh = fh.to_relative(self.cutoff)
+        cv = SingleWindowSplitter(fh=inner_fh)
         train_window, test_window = next(cv.split(y))
-        y_fcst = y.iloc[train_window]
-        y_meta = y.iloc[test_window].values
+        y_train = y.iloc[train_window]
+        y_test = y.iloc[test_window]
         if X is not None:
-            X_meta = X.iloc[test_window]
+            X_test = X.iloc[test_window]
+            X_train = X.iloc[train_window]
         else:
-            X_meta = None
+            X_test = None
+            X_train = None
 
         # fit forecasters on training window
-        self._fit_forecasters(forecasters, y_fcst, fh=fh, X=X)
-        X_meta = np.column_stack(self._predict_forecasters(fh=fh, X=X_meta))
+        self._fit_forecasters(forecasters, y_train, fh=inner_fh, X=X_train)
+        y_preds = self._predict_forecasters(fh=inner_fh, X=X_test)
+
+        y_meta = y_test.values
+        X_meta = np.column_stack(y_preds)
 
         # fit final regressor on on validation window
         self.regressor_.fit(X_meta, y_meta)
 
         # refit forecasters on entire training series
-        self._fit_forecasters(forecasters, y, fh=self.fh, X=X)
+        self._fit_forecasters(forecasters, y, fh=fh, X=X)
 
         return self
 
@@ -173,8 +184,11 @@ def get_test_params(cls, parameter_set="default"):
         -------
         params : dict or list of dict
         """
+        from sktime.forecasting.arima import ARIMA
         from sktime.forecasting.naive import NaiveForecaster
 
-        FORECASTER = NaiveForecaster()
-        params = {"forecasters": [("f1", FORECASTER), ("f2", FORECASTER)]}
+        f1 = NaiveForecaster()
+        f2 = ARIMA()
+        params = {"forecasters": [("f1", f1), ("f2", f2)]}
+
         return params


Test patch: