Repository: sktime/sktime

Issue:
[BUG] `SARIMAX` fails when `X` is passed in `predict` but not used in `fit`
## Minimal Reproducible Example

```pycon
>>> 
>>> from sktime.datasets import load_longley
>>> from sktime.forecasting.sarimax import SARIMAX
>>> from sktime.split import temporal_train_test_split
>>> 
>>> y, X = load_longley()
>>> 
>>> y_train, _, _, X_test = temporal_train_test_split(y, X)
>>> 
>>> forecaster = SARIMAX()
>>> 
>>> forecaster.fit(y_train)
SARIMAX()
>>> 
>>> # works
>>> forecaster.predict(fh=[1, 2, 3, 4])
1959    66061.176439
1960    65682.034815
1961    65363.883253
1962    65096.910677
Freq: A-DEC, Name: TOTEMP, dtype: float64
>>> 
>>> # fails
>>> forecaster.predict(fh=[1, 2, 3, 4], X=X_test)
Traceback (most recent call last):
  File "<stdin>", line 1, in <module>
  File "/home/anirban/sktime-fork/sktime/forecasting/base/_base.py", line 412, in predict
    y_pred = self._predict(fh=fh, X=X_inner)
  File "/home/anirban/sktime-fork/sktime/forecasting/base/adapters/_statsmodels.py", line 108, in _predict
    ind_drop = self._X.index
AttributeError: 'NoneType' object has no attribute 'index'
>>> 
```

## Expectation

I was expecting no failures, and identical behaviour in both cases. I do get that behaviour if I try with `sktime.forecasting.arima.ARIMA`.

## Version

Operating System: Ubuntu 22.04.3 LTS (WSL)
Python: 3.10.12
Sktime: e51ec2472a



Discussion:
On looking into this issue, I found that the error was initially because of this code snippet
```python
if X is not None:
    ind_drop = self._X.index
    X = X.loc[~X.index.isin(ind_drop)]
    # Entire range of the forecast horizon is required
    X = X.iloc[: (fh_int[-1] + 1)]  # include end point
```
Since we are not passing any X in fit, `self._X` is None and it does not have an index attribute. This could be solved by simply adding an if condition to check if self._X is None or not and then dropping the indices accordingly.
However this fix throws another error when run :
```python
ValueError                                Traceback (most recent call last)
Cell In[3], line 1
----> 1 forecaster.predict(fh=[1, 2, 3,4],X=X_test)

File ~/sktime/sktime/forecasting/base/_base.py:444, in BaseForecaster.predict(self, fh, X)
    442 # we call the ordinary _predict if no looping/vectorization needed
    443 if not self._is_vectorized:
--> 444     y_pred = self._predict(fh=fh, X=X_inner)
    445 else:
    446     # otherwise we call the vectorized version of predict
    447     y_pred = self._vectorize("predict", X=X_inner, fh=fh)

File ~/sktime/sktime/forecasting/base/adapters/_statsmodels.py:121, in _StatsModelsAdapter._predict(self, fh, X)
    118     X = X.iloc[: (fh_int[-1] + 1)]  # include end point
    120 if "exog" in inspect.signature(self._forecaster.__init__).parameters.keys():
--> 121     y_pred = self._fitted_forecaster.predict(start=start, end=end, exog=X)
    122 else:
    123     y_pred = self._fitted_forecaster.predict(start=start, end=end)

File ~/anaconda3/envs/sktime-dev/lib/python3.10/site-packages/statsmodels/base/wrapper.py:113, in make_wrapper.<locals>.wrapper(self, *args, **kwargs)
    111     obj = data.wrap_output(func(results, *args, **kwargs), how[0], how[1:])
    112 elif how:
--> 113     obj = data.wrap_output(func(results, *args, **kwargs), how)
    114 return obj
...
    644     # endpoint, but does not know what the calling methods may
    645     # specifically require.
    646     updated_mat = np.asarray(kwargs[name])

ValueError: Model has time-invariant design matrix, so cannot provide an extended matrix.
```

This error is coming from a method within the `statsmodels` source code. I am not very familiar with what exactly happens in SARIMAX but are we sure that in order to pass exog args in predict, they do not have to be passed in fit first?


My expectation was that this should be handled by the base class itself. The expectation came from this:

https://github.com/sktime/sktime/blob/64e90aae0deb51ad89c2e29ef56607e533a7c984/extension_templates/forecasting.py#L114-L117

I interpreted this that even if user pass X only in predict, base will set that to None before forwarding it to _predict. And even for models supporting exogenous features in general but not in a particular case, it'll still set it as None.
It looks like the tag being False isn't setting the X passed in predict to None conditionally when X is not passed in fit. On the other hand it cannot be set to True as exogeneous features cannot be ignored in all cases. Simply adding an if check just before passing the exog X to the predict method seems to work.
```python
if "exog" in inspect.signature(self._forecaster.__init__).parameters.keys():
    if (self._X is None):
        X = None
    y_pred = self._fitted_forecaster.predict(start=start, end=end, exog=X)
else:
    y_pred = self._fitted_forecaster.predict(start=start, end=end)
```
Is this solution satisfactory or should we go about it some other way?
> My expectation was that this should be handled by the base class itself. The expectation came from this:
> 
> https://github.com/sktime/sktime/blob/64e90aae0deb51ad89c2e29ef56607e533a7c984/extension_templates/forecasting.py#L114-L117
> 
> I interpreted this that even if user pass X only in predict, base will set that to None before forwarding it to _predict. And even for models supporting exogenous features in general but not in a particular case, it'll still set it as None.

Also, how do you display code in this manner?

Resolving patch:
diff --git a/sktime/forecasting/base/adapters/_statsmodels.py b/sktime/forecasting/base/adapters/_statsmodels.py
--- a/sktime/forecasting/base/adapters/_statsmodels.py
+++ b/sktime/forecasting/base/adapters/_statsmodels.py
@@ -110,13 +110,15 @@ def _predict(self, fh, X):
         # bug fix for evaluate function as test_plus_train indices are passed
         # statsmodels exog must contain test indices only.
         # For discussion see https://github.com/sktime/sktime/issues/3830
-        if X is not None:
+        if X is not None and self._X is not None:
             ind_drop = self._X.index
             X = X.loc[~X.index.isin(ind_drop)]
             # Entire range of the forecast horizon is required
             X = X.iloc[: (fh_int[-1] + 1)]  # include end point
 
         if "exog" in inspect.signature(self._forecaster.__init__).parameters.keys():
+            if self._X is None:
+                X = None  # change X passed in predict to None if X wasn't passed to fit
             y_pred = self._fitted_forecaster.predict(start=start, end=end, exog=X)
         else:
             y_pred = self._fitted_forecaster.predict(start=start, end=end)


Test patch:
diff --git a/sktime/forecasting/tests/test_sarimax.py b/sktime/forecasting/tests/test_sarimax.py
--- a/sktime/forecasting/tests/test_sarimax.py
+++ b/sktime/forecasting/tests/test_sarimax.py
@@ -91,3 +91,20 @@ def test_SARIMAX_multiple_intervals_against_statsmodels():
 
     assert_frame_equal(sktime_pred_int_70, stats_pred_int_70)
     assert_frame_equal(sktime_pred_int_80, stats_pred_int_80)
+
+
+@pytest.mark.skipif(
+    not run_test_for_class(SARIMAX),
+    reason="run test only if softdeps are present and incrementally (if requested)",
+)
+def test_SARIMAX_for_exogeneous_features():
+    """Checking when X is passed to predict but not fit"""
+
+    from sktime.datasets import load_longley
+    from sktime.split import temporal_train_test_split
+
+    y, X = load_longley()
+    y_train, _, _, X_test = temporal_train_test_split(y, X)
+    forecaster = SARIMAX()
+    forecaster.fit(y_train)
+    forecaster.predict(fh=[1, 2, 3, 4], X=X_test)