Repository: sktime/sktime

Issue:
[ENH] skip training and prediction of `forecaster_X` in `ForecastX` if `forecaster_y` has `ignores-exogeneous-X` as `True`
As of now, `ForecastX` trains and predicts `forecaster_X` always, irrespective of the model specified as `forecaster_y`. But if the endogenous model lacks the capability of using exogenous features, this training and prediction is of no use, and can/should be skipped.

Enhancement Proposal:

1. check tag of `forecaster_y` in `__init__`.
2. if `True`, continue as it is.
3. if `False`, return `forecaster_y` as it is.

Based on my understanding, (3) above should not cause any issue becuase of same API format for all forecasters and pased exogenous features will be ignored.

Optional suggestions:

1. add an argument which will control whether to raise an error, throw a warning, or do nothing.

Documentation issue (unrelated):

1. `forecaster_X` is documented as optional argument which uses `forecaster_y` when not provided explicitly. But this needs to be provided as `None` in that case as it is a positional argument, and can not be skipped in the call altogether.


Discussion:
>  this training and prediction is of no use, and can/should be skipped.

Makes sense.

Question, should a warning be raised as well in this case?

Because the user is doing something that is superfluous in this case.

Now, I'm not sure how confusing or problematic such a warning would be in a case of tuning over the inner forecaster where some forecasters ignore `X` and some don't.
> if False, return forecaster_y as it is.

That wouldn't work - even if it would work, it would violate the `sklearn` interface assumptions, since `forecaster_y` has different parameters from `self`.

Instead, we can skip the `X`-fitting and let the forecaster behave as a delegator to `forecaster_y`.

Resolving patch:
diff --git a/sktime/forecasting/compose/_pipeline.py b/sktime/forecasting/compose/_pipeline.py
--- a/sktime/forecasting/compose/_pipeline.py
+++ b/sktime/forecasting/compose/_pipeline.py
@@ -1184,7 +1184,7 @@ class ForecastX(BaseForecaster):
     ----------
     forecaster_y : BaseForecaster
         sktime forecaster to use for endogeneous data `y`
-    forecaster_X : BaseForecaster, optional, default = forecaster_y
+    forecaster_X : BaseForecaster
         sktime forecaster to use for exogeneous data `X`
     fh_X : None, ForecastingHorizon, or valid input to construct ForecastingHorizon
         optional, default = None = same as used for `y` in any instance.
@@ -1197,14 +1197,16 @@ class ForecastX(BaseForecaster):
         if "refit", then forecaster_X is fit to `X` in `predict` only,
             Forecast added to `X` in `predict` is obtained from this state.
     columns : None, or pandas compatible index iterator (e.g., list of str), optional
-        default = None = all columns in X are used for forecast
-        columns to which `forecaster_X` is applied
+        default = None = all columns in X are used for forecast columns to which
+        `forecaster_X` is applied. if not ``None``, this must be a non-empty list of
+        valid column names (``[]`` and ``None`` do not imply the same)
 
     Attributes
     ----------
     forecaster_X_ : BaseForecaster
         clone of forecaster_X, state updates with `fit` and `update`
         created only if behaviour="update" and `X` passed is not None
+        and ``forecaster_y`` has ``ignores-exogeneous-X`` tag as ``False``
     forecaster_y_ : BaseForecaster
         clone of forecaster_y, state updates with `fit` and `update`
 
@@ -1276,6 +1278,7 @@ def __init__(
             "capability:pred_int",
             "capability:pred_int:insample",
             "capability:insample",
+            "ignores-exogeneous-X",
         ]
 
         self.clone_tags(forecaster_y, tags_to_clone_from_forecaster_y)


Test patch:
diff --git a/sktime/forecasting/compose/tests/test_pipeline.py b/sktime/forecasting/compose/tests/test_pipeline.py
--- a/sktime/forecasting/compose/tests/test_pipeline.py
+++ b/sktime/forecasting/compose/tests/test_pipeline.py
@@ -499,9 +499,7 @@ def test_forecastx_attrib_broadcast():
     model_1 = model.clone()
     model_1.fit(df[["c"]], X=df[["d", "e"]], fh=[1, 2, 3])
 
-    assert hasattr(model_1, "forecaster_X_")
-    assert isinstance(model_1.forecaster_X_, NaiveForecaster)
-    assert model_1.forecaster_X_.is_fitted
+    assert not hasattr(model_1, "forecaster_X_")
 
     assert hasattr(model_1, "forecaster_y_")
     assert isinstance(model_1.forecaster_y_, NaiveForecaster)
@@ -509,17 +507,140 @@ def test_forecastx_attrib_broadcast():
 
     model_2 = model.clone()
     model_2.fit(df[["c", "d"]], X=df[["e"]], fh=[1, 2, 3])
-    assert hasattr(model_2, "forecaster_X_")
 
-    assert hasattr(model_2, "forecaster_X_")
-    assert isinstance(model_2.forecaster_X_, NaiveForecaster)
-    assert model_2.forecaster_X_.is_fitted
+    assert not hasattr(model_2, "forecaster_X_")
 
     assert hasattr(model_2, "forecaster_y_")
     assert isinstance(model_2.forecaster_y_, NaiveForecaster)
     assert model_2.forecaster_y_.is_fitted
 
 
+def test_forecastx_skip_forecaster_X_fitting_logic():
+    """Test that ForecastX does not fit forecaster_X, if forecaster_y ignores X"""
+    from sklearn.linear_model import LinearRegression
+
+    from sktime.forecasting.compose import ForecastX, YfromX
+
+    y, X = load_longley()
+
+    fh = [1, 2, 3]
+
+    model_supporting_exogenous = YfromX(LinearRegression())
+    model_ignoring_exogenous = NaiveForecaster()
+
+    model_1 = ForecastX(
+        model_supporting_exogenous.clone(), model_supporting_exogenous.clone()
+    )
+    model_2 = ForecastX(
+        model_supporting_exogenous.clone(), model_ignoring_exogenous.clone()
+    )
+    model_3 = ForecastX(
+        model_ignoring_exogenous.clone(), model_supporting_exogenous.clone()
+    )
+    model_4 = ForecastX(
+        model_ignoring_exogenous.clone(), model_ignoring_exogenous.clone()
+    )
+
+    assert hasattr(model_1, "forecaster_y")
+    assert hasattr(model_2, "forecaster_y")
+    assert hasattr(model_3, "forecaster_y")
+    assert hasattr(model_4, "forecaster_y")
+
+    assert hasattr(model_1, "forecaster_X")
+    assert hasattr(model_2, "forecaster_X")
+    assert hasattr(model_3, "forecaster_X")
+    assert hasattr(model_4, "forecaster_X")
+
+    assert not hasattr(model_1, "forecaster_y_")
+    assert not hasattr(model_2, "forecaster_y_")
+    assert not hasattr(model_3, "forecaster_y_")
+    assert not hasattr(model_4, "forecaster_y_")
+
+    assert not hasattr(model_1, "forecaster_X_")
+    assert not hasattr(model_2, "forecaster_X_")
+    assert not hasattr(model_3, "forecaster_X_")
+    assert not hasattr(model_4, "forecaster_X_")
+
+    model_1.fit(y, X=X, fh=fh)
+    model_2.fit(y, X=X, fh=fh)
+    model_3.fit(y, X=X, fh=fh)
+    model_4.fit(y, X=X, fh=fh)
+
+    assert hasattr(model_1, "forecaster_y_")
+    assert hasattr(model_2, "forecaster_y_")
+    assert hasattr(model_3, "forecaster_y_")
+    assert hasattr(model_4, "forecaster_y_")
+
+    assert model_1.forecaster_y_.is_fitted
+    assert model_2.forecaster_y_.is_fitted
+    assert model_3.forecaster_y_.is_fitted
+    assert model_4.forecaster_y_.is_fitted
+
+    assert hasattr(model_1, "forecaster_X_")
+    assert hasattr(model_2, "forecaster_X_")
+    assert not hasattr(model_3, "forecaster_X_")
+    assert not hasattr(model_4, "forecaster_X_")
+
+    assert model_1.forecaster_X_.is_fitted
+    assert model_2.forecaster_X_.is_fitted
+
+
+@pytest.mark.parametrize(
+    "forecasting_algorithm", [make_reduction(SVR(), window_length=2), NaiveForecaster()]
+)
+@pytest.mark.parametrize(
+    "future_unknown_columns",
+    [["GNPDEFL", "GNP"], ["GNPDEFL", "GNP", "UNEMP", "ARMED", "POP"], None],
+)
+def test_forecastx_flow_known_unknown_columns(
+    forecasting_algorithm, future_unknown_columns
+):
+    """Test that ForecastX does not fit forecaster_X, if forecaster_y ignores X"""
+    from sktime.forecasting.compose import ForecastX
+
+    y, X = load_longley()
+
+    fh = [1, 2]
+
+    y_train_val, y_test, X_train_val, X_test = temporal_train_test_split(
+        y, X, test_size=max(fh)
+    )
+    y_train, y_val, X_train, X_val = temporal_train_test_split(
+        y_train_val, X_train_val, test_size=max(fh)
+    )
+
+    model = ForecastX(
+        forecasting_algorithm.clone(),
+        forecasting_algorithm.clone(),
+        columns=future_unknown_columns,
+    )
+
+    assert hasattr(model, "forecaster_y")
+    assert hasattr(model, "forecaster_X")
+
+    assert not hasattr(model, "forecaster_y_")
+    assert not hasattr(model, "forecaster_X_")
+
+    model.fit(y_train, X=X_train, fh=fh)
+
+    assert hasattr(model, "forecaster_y_")
+    assert model.forecaster_y_.is_fitted
+
+    if model.get_tag("ignores-exogeneous-X"):
+        assert not hasattr(model, "forecaster_X_")
+    else:
+        assert hasattr(model, "forecaster_X_")
+        assert model.forecaster_X_.is_fitted
+
+    y_val_pred = model.predict(X=X_test)
+    np.testing.assert_array_equal(y_val.index, y_val_pred.index)
+
+    model.update(y_val, X=X_val)
+
+    y_test_pred = model.predict(X=X_test)
+    np.testing.assert_array_equal(y_test.index, y_test_pred.index)
+
+
 @pytest.mark.skipif(
     not _check_soft_dependencies("statsmodels", severity="none"),
     reason="skip test if required soft dependency is not available",