Repository: aeon-toolkit/aeon (python)

Issue:
[ENH] Forecasting predict function: require y
### Describe the feature or idea you want to propose

after discussion on slack, an opinion was put forward to remove the option of calling `predict` without y. This issue  puts that in context and opens up discussion.

Currently the simplest forecasting use case is

```python
f = MyForecaster(horizon=1)
f.fit(y)
pred = f.predict()
```
`predict` signature is `predict(y=none)`. If `y` is not passed, the prediction is the next value from the series passed in fit, if it is passed the prediction is the next value of the series passed in `y`. 

```python
f = MyForecaster(horizon=1)
f.fit(y)
pred = f.predict(other_y) # predict the value horizon after `other_y`
```

Predict applies the model from fit to a previously unseen series, with no time index. This is a natural model for regression, but a bit confusing for stats forecasters. The equivalent operation to `predict(y)` in stats models is `filter(y)`, in stats parlance I think this is called making out of sample predictions, although we only make a single prediction from `y` which is assumed to be prior to our `horizon`. We do not assume `y` in predict directly follows `y` in train. So for MA component in ARMA we can use the out of sample residuals from `y` in predict, but it doesnt work with seasonal component models such as ETS.


### Describe your proposed solution


So one way forward is to not make fit/predict the main access, instead we retain the temporal link between train/test and use

 predict one ahead  
```
f = MyForecaster(horizon=1)
pred = f.forecast(y)
```
predict `prediction_horizon` ahead using iterative or direct
```
f = MyForecaster(horizon=1)
preds1 = f.direct_forecast(y, prediction_horizon=10)
preds1 = f.iterative_forecast(y, prediction_horizon=10)
```

`predict` is retained primarily for regression models. We then change `predict` to require `y` and make naive have tag `fit_is_empty` and forecast then becomes
```python
def _forecast(self, y):
   return self.predict(y)
```
and put the Naive functionality in `_predict`. We then work out whether ETS can also have fit is empty as true. 

### Describe alternatives you've considered, if relevant

_No response_

### Additional context

_No response_

Pull request description:
[ENH] Refactor to set forecast_ in fit and to require y in predict
after discussion, we decided to remove the y=None option in predict for forecasters, and this PR refactors our current forecasters to do this.

the model is now that predict stores the single prediction from the train set in self.forecast_, so that now 

```python
forecast(y):
   self.fit(y)
   return self.forecast_
```
predict is now reserved for algorithms than can fit independent of time, so nothing seasonal etc. These should use `forecast()` or `iterative_forecast()`

closes #2909 

Patch:
diff --git a/aeon/forecasting/_ets.py b/aeon/forecasting/_ets.py
index d0aa466b..2d3eac89 100644
--- a/aeon/forecasting/_ets.py
+++ b/aeon/forecasting/_ets.py
@@ -84,9 +84,7 @@ class ETSForecaster(BaseForecaster):
     ...     error_type='additive', trend_type='multiplicative',
     ...     seasonality_type='multiplicative', seasonal_period=4
     ... )
-    >>> forecaster.fit(y)
-    ETSForecaster(...)
-    >>> forecaster.predict()
+    >>> forecaster.forecast(y)
     366.90200486015596
     """
 
@@ -195,9 +193,21 @@ class ETSForecaster(BaseForecaster):
             self._gamma,
             self.phi,
         )
+        self.forecast_ = _predict(
+            self._trend_type,
+            self._seasonality_type,
+            self.level_,
+            self.trend_,
+            self.seasonality_,
+            self.phi,
+            self.horizon,
+            self.n_timepoints_,
+            self._seasonal_period,
+        )
+
         return self
 
-    def _predict(self, y=None, exog=None):
+    def _predict(self, y, exog=None):
         """
         Predict the next horizon steps ahead.
 
@@ -214,18 +224,7 @@ class ETSForecaster(BaseForecaster):
         float
             single prediction self.horizon steps ahead of y.
         """
-        fitted_value = _predict(
-            self._trend_type,
-            self._seasonality_type,
-            self.level_,
-            self.trend_,
-            self.seasonality_,
-            self.phi,
-            self.horizon,
-            self.n_timepoints_,
-            self._seasonal_period,
-        )
-        return fitted_value
+        return self.forecast_
 
     def _initialise(self, data):
         """
diff --git a/aeon/forecasting/_naive.py b/aeon/forecasting/_naive.py
index da242018..edb5b3ea 100644
--- a/aeon/forecasting/_naive.py
+++ b/aeon/forecasting/_naive.py
@@ -41,11 +41,13 @@ class NaiveForecaster(BaseForecaster):
         y_squeezed = y.squeeze()
 
         if self.strategy == "last":
-            self._fitted_scalar_value = y_squeezed[-1]
+            self.forecast_ = y_squeezed[-1]
         elif self.strategy == "mean":
-            self._fitted_scalar_value = np.mean(y_squeezed)
+            self.forecast_ = np.mean(y_squeezed)
         elif self.strategy == "seasonal_last":
-            self._fitted_last_season = y_squeezed[-self.seasonal_period :]
+            season = y_squeezed[-self.seasonal_period :]
+            idx = (self.horizon - 1) % self.seasonal_period
+            self.forecast_ = season[idx]
         else:
             raise ValueError(
                 f"Unknown strategy: {self.strategy}. "
@@ -53,27 +55,19 @@ class NaiveForecaster(BaseForecaster):
             )
         return self
 
-    def _predict(self, y=None, exog=None):
-        if y is None:
-            if self.strategy == "last" or self.strategy == "mean":
-                return self._fitted_scalar_value
+    def _predict(self, y, exog=None):
+        y_squeezed = y.squeeze()
 
-            # For "seasonal_last" strategy
-            prediction_index = (self.horizon - 1) % self.seasonal_period
-            return self._fitted_last_season[prediction_index]
+        if self.strategy == "last":
+            return y_squeezed[-1]
+        elif self.strategy == "mean":
+            return np.mean(y_squeezed)
+        elif self.strategy == "seasonal_last":
+            period = y_squeezed[-self.seasonal_period :]
+            idx = (self.horizon - 1) % self.seasonal_period
+            return period[idx]
         else:
-            y_squeezed = y.squeeze()
-
-            if self.strategy == "last":
-                return y_squeezed[-1]
-            elif self.strategy == "mean":
-                return np.mean(y_squeezed)
-            elif self.strategy == "seasonal_last":
-                period = y_squeezed[-self.seasonal_period :]
-                idx = (self.horizon - 1) % self.seasonal_period
-                return period[idx]
-            else:
-                raise ValueError(
-                    f"Unknown strategy: {self.strategy}. "
-                    "Valid strategies are 'last', 'mean', 'seasonal_last'."
-                )
+            raise ValueError(
+                f"Unknown strategy: {self.strategy}. "
+                "Valid strategies are 'last', 'mean', 'seasonal_last'."
+            )
diff --git a/aeon/forecasting/_regression.py b/aeon/forecasting/_regression.py
index 206d0ca0..95fc2fb1 100644
--- a/aeon/forecasting/_regression.py
+++ b/aeon/forecasting/_regression.py
@@ -70,9 +70,10 @@ class RegressionForecaster(BaseForecaster):
             self.regressor_ = LinearRegression()
         else:
             self.regressor_ = self.regressor
-
+        self._n_exog = 0
         # Combine y and exog for windowing
         if exog is not None:
+            self._n_exog = exog.shape[0]
             if exog.ndim == 1:
                 exog = exog.reshape(1, -1)
             if exog.shape[1] != y.shape[1]:
@@ -96,16 +97,18 @@ class RegressionForecaster(BaseForecaster):
         X = X[:, :, :].reshape(X.shape[0], -1)
 
         # Ignore the final horizon values for X
-        X = X[: -self.horizon]
+        X_train = X[: -self.horizon]
 
         # Extract y_train from the original series
         y_train = y.squeeze()[self.window + self.horizon - 1 :]
 
-        self.last_ = combined_data[:, -self.window :]
-        self.regressor_.fit(X=X, y=y_train)
+        self.regressor_.fit(X=X_train, y=y_train)
+
+        last = X[[-1]]
+        self.forecast_ = self.regressor_.predict(last)[0]
         return self
 
-    def _predict(self, y=None, exog=None):
+    def _predict(self, y, exog=None):
         """
         Predict the next horizon steps ahead.
 
@@ -122,26 +125,52 @@ class RegressionForecaster(BaseForecaster):
         float
             single prediction self.horizon steps ahead of y.
         """
-        if y is None:
-            # Flatten the last window to be compatible with sklearn regressors
-            last_window_flat = self.last_.reshape(1, -1)
-            return self.regressor_.predict(last_window_flat)[0]
-
+        y = y[:, -self.window :]
+        y = y.squeeze()
+        # Test data compliant for regression based
+        if len(y) < self.window:
+            raise ValueError(
+                f" Series passed in predict length = {len(y)} but this "
+                f"RegressionForecaster was trained on window length = "
+                f"{self.window}"
+            )
         # Combine y and exog for prediction
         if exog is not None:
+            if exog.shape[0] != self._n_exog:
+                raise ValueError(
+                    f" Forecaster passed {exog.shape[0]} exogenous variables in "
+                    f"predict but this RegressionForecaster was trained on"
+                    f" {self._n_exog} variables in fit"
+                )
+
             if exog.ndim == 1:
                 exog = exog.reshape(1, -1)
-            if exog.shape[1] != y.shape[1]:
-                raise ValueError("y and exog must have the same number of time points.")
+            if exog.shape[1] < self.window:
+                raise ValueError(
+                    f" Exogenous variables passed in predict of length = {len(y)} but "
+                    f"this RegressionForecaster was trained on window length = "
+                    f"{self.window}"
+                )
+
+            exog = exog[:, -self.window :]
             combined_data = np.vstack([y, exog])
         else:
+            if self._n_exog > 0:
+                raise ValueError(
+                    f" predict passed no exogenous variables, but this "
+                    f"RegressionForecaster was trained on {self._n_exog} exog in fit"
+                )
             combined_data = y
 
         # Extract the last window and flatten for prediction
-        last_window = combined_data[:, -self.window :]
-        last_window_flat = last_window.reshape(1, -1)
+        last_window = combined_data.reshape(1, -1)
+
+        return self.regressor_.predict(last_window)[0]
 
-        return self.regressor_.predict(last_window_flat)[0]
+    def _forecast(self, y, exog=None):
+        """Forecast values for time series X."""
+        self.fit(y, exog)
+        return self.forecast_
 
     @classmethod
     def _get_test_params(cls, parameter_set: str = "default"):
diff --git a/aeon/forecasting/base.py b/aeon/forecasting/base.py
index 5b37e7ca..18e45fc7 100644
--- a/aeon/forecasting/base.py
+++ b/aeon/forecasting/base.py
@@ -96,14 +96,13 @@ class BaseForecaster(BaseSeriesEstimator):
     def _fit(self, y, exog=None): ...
 
     @final
-    def predict(self, y=None, exog=None):
+    def predict(self, y, exog=None):
         """Predict the next horizon steps ahead.
 
         Parameters
         ----------
-        y : np.ndarray, default = None
-            A time series to predict the next horizon value for. If None,
-            predict the next horizon value after series seen in fit.
+        y : np.ndarray
+            A time series to predict the next horizon value for.
         exog : np.ndarray, default =None
             Optional exogenous time series data assumed to be aligned with y.
 
@@ -113,28 +112,25 @@ class BaseForecaster(BaseSeriesEstimator):
             single prediction self.horizon steps ahead of y.
         """
         self._check_is_fitted()
-        if y is not None:
-            self._check_X(y, self.axis)
-            y = self._convert_y(y, self.axis)
+        self._check_X(y, self.axis)
+        y = self._convert_y(y, self.axis)
         if exog is not None:
             exog = self._convert_y(exog, self.axis)
-
         return self._predict(y, exog)
 
     @abstractmethod
-    def _predict(self, y=None, exog=None): ...
+    def _predict(self, y, exog=None): ...
 
     @final
     def forecast(self, y, exog=None):
-        """Forecast the next horizon steps ahead.
+        """Forecast the next horizon steps ahead of ``y``.
 
-        By default this is simply fit followed by predict.
+        By default this is simply fit followed by returning forecast_.
 
         Parameters
         ----------
-        y : np.ndarray, default = None
-            A time series to predict the next horizon value for. If None,
-            predict the next horizon value after series seen in fit.
+        y : np.ndarray
+            A time series to predict the next horizon value for.
         exog : np.ndarray, default =None
             Optional exogenous time series data assumed to be aligned with y.
 
@@ -150,9 +146,9 @@ class BaseForecaster(BaseSeriesEstimator):
         return self._forecast(y, exog)
 
     def _forecast(self, y, exog=None):
-        """Forecast values for time series X."""
+        """Forecast horizon steps ahead for time series ``y``."""
         self.fit(y, exog)
-        return self._predict(y, exog)
+        return self.forecast_
 
     @final
     def direct_forecast(self, y, prediction_horizon, exog=None):
@@ -174,7 +170,10 @@ class BaseForecaster(BaseSeriesEstimator):
             The number of future time steps to forecast.
         exog : np.ndarray, default =None
             Optional exogenous time series data assumed to be aligned with y.
-        predictions : np.ndarray
+
+        Returns
+        -------
+        np.ndarray
             An array of shape `(prediction_horizon,)` containing the forecasts for
             each horizon.
 
@@ -210,15 +209,15 @@ class BaseForecaster(BaseSeriesEstimator):
 
     def iterative_forecast(self, y, prediction_horizon):
         """
-        Forecast ``prediction_horizon`` prediction using a single model from `y`.
+        Forecast ``prediction_horizon`` prediction using a single model fit on `y`.
 
         This function implements the iterative forecasting strategy (also called
-        recursive or iterated). This involves a single model fit on y which is then
-        used to make ``prediction_horizon`` ahead  using its own predictions as
-        inputs for future forecasts. This is done by taking
-        the prediction at step ``i`` and feeding it back into the model to help
-        predict for step ``i+1``. The basic contract of
-        `iterative_forecast` is that `fit` is only ever called once.
+        recursive or iterated). This involves a single model fit on ``y`` which is then
+        used to make ``prediction_horizon`` ahead forecasts using its own predictions as
+        inputs for future forecasts. This is done by taking the prediction at step
+        ``i`` and feeding it back into the model to help predict for step ``i+1``.
+        The basic contract of `iterative_forecast` is that `fit` is only ever called
+        once.
 
         y : np.ndarray
             The time series to make forecasts about.
@@ -227,7 +226,7 @@ class BaseForecaster(BaseSeriesEstimator):
 
         Returns
         -------
-        predictions : np.ndarray
+        np.ndarray
             An array of shape `(prediction_horizon,)` containing the forecasts for
             each horizon.
 
diff --git a/examples/forecasting/forecasting.ipynb b/examples/forecasting/forecasting.ipynb
index aa7f8f3a..3e2bafc6 100644
--- a/examples/forecasting/forecasting.ipynb
+++ b/examples/forecasting/forecasting.ipynb
@@ -58,9 +58,12 @@
   },
   {
    "cell_type": "code",
-   "execution_count": null,
-   "metadata": {},
-   "outputs": [],
+   "metadata": {
+    "ExecuteTime": {
+     "end_time": "2025-07-04T14:04:45.079757Z",
+     "start_time": "2025-07-04T14:04:41.363385Z"
+    }
+   },
    "source": [
     "import inspect\n",
     "\n",
@@ -73,7 +76,17 @@
     "    if not func[0].startswith(\"_\")\n",
     "]\n",
     "print(public_methods)"
-   ]
+   ],
+   "outputs": [
+    {
+     "name": "stdout",
+     "output_type": "stream",
+     "text": [
+      "['clone', 'direct_forecast', 'fit', 'forecast', 'get_fitted_params', 'get_metadata_routing', 'get_params', 'get_tag', 'get_tags', 'iterative_forecast', 'predict', 'reset', 'set_params', 'set_tags']\n"
+     ]
+    }
+   ],
+   "execution_count": 1
   },
   {
    "cell_type": "markdown",
@@ -89,15 +102,30 @@
   },
   {
    "cell_type": "code",
-   "execution_count": null,
-   "metadata": {},
-   "outputs": [],
+   "metadata": {
+    "ExecuteTime": {
+     "end_time": "2025-07-04T14:04:46.224571Z",
+     "start_time": "2025-07-04T14:04:46.203552Z"
+    }
+   },
    "source": [
     "from aeon.utils.data_types import SERIES_DATA_TYPES\n",
     "\n",
     "print(\" Possible data structures for input to forecaster \", SERIES_DATA_TYPES)\n",
     "print(\"\\n Tags for BaseForecaster: \", BaseForecaster.get_class_tags())"
-   ]
+   ],
+   "outputs": [
+    {
+     "name": "stdout",
+     "output_type": "stream",
+     "text": [
+      " Possible data structures for input to forecaster  ['pd.Series', 'pd.DataFrame', 'np.ndarray']\n",
+      "\n",
+      " Tags for BaseForecaster:  {'python_version': None, 'python_dependencies': None, 'cant_pickle': False, 'non_deterministic': False, 'algorithm_type': None, 'capability:missing_values': False, 'capability:multithreading': False, 'capability:univariate': True, 'capability:multivariate': False, 'X_inner_type': 'np.ndarray', 'capability:horizon': True, 'capability:exogenous': False, 'fit_is_empty': False, 'y_inner_type': 'np.ndarray'}\n"
+     ]
+    }
+   ],
+   "execution_count": 2
   },
   {
    "cell_type": "markdown",
@@ -109,9 +137,12 @@
   },
   {
    "cell_type": "code",
-   "execution_count": null,
-   "metadata": {},
-   "outputs": [],
+   "metadata": {
+    "ExecuteTime": {
+     "end_time": "2025-07-04T14:04:46.412151Z",
+     "start_time": "2025-07-04T14:04:46.323867Z"
+    }
+   },
    "source": [
     "import pandas as pd\n",
     "\n",
@@ -121,7 +152,17 @@
     "print(type(y))\n",
     "y2 = pd.Series(y)\n",
     "y3 = pd.DataFrame(y)"
-   ]
+   ],
+   "outputs": [
+    {
+     "name": "stdout",
+     "output_type": "stream",
+     "text": [
+      "<class 'numpy.ndarray'>\n"
+     ]
+    }
+   ],
+   "execution_count": 3
   },
   {
    "cell_type": "markdown",
@@ -137,41 +178,54 @@
   },
   {
    "cell_type": "code",
-   "execution_count": 1,
-   "metadata": {},
-   "outputs": [
-    {
-     "ename": "NameError",
-     "evalue": "name 'y' is not defined",
-     "output_type": "error",
-     "traceback": [
-      "\u001b[31m---------------------------------------------------------------------------\u001b[39m",
-      "\u001b[31mNameError\u001b[39m                                 Traceback (most recent call last)",
-      "\u001b[36mCell\u001b[39m\u001b[36m \u001b[39m\u001b[32mIn[1]\u001b[39m\u001b[32m, line 5\u001b[39m\n\u001b[32m      2\u001b[39m \u001b[38;5;28;01mfrom\u001b[39;00m\u001b[38;5;250m \u001b[39m\u001b[34;01maeon\u001b[39;00m\u001b[34;01m.\u001b[39;00m\u001b[34;01mforecasting\u001b[39;00m\u001b[38;5;250m \u001b[39m\u001b[38;5;28;01mimport\u001b[39;00m NaiveForecaster\n\u001b[32m      4\u001b[39m d = NaiveForecaster(strategy=\u001b[33m\"\u001b[39m\u001b[33mlast\u001b[39m\u001b[33m\"\u001b[39m)\n\u001b[32m----> \u001b[39m\u001b[32m5\u001b[39m d.fit(\u001b[43my\u001b[49m)\n\u001b[32m      6\u001b[39m p = d.predict()\n\u001b[32m      7\u001b[39m \u001b[38;5;28mprint\u001b[39m(p)\n",
-      "\u001b[31mNameError\u001b[39m: name 'y' is not defined"
-     ]
+   "metadata": {
+    "ExecuteTime": {
+     "end_time": "2025-07-04T14:04:48.700995Z",
+     "start_time": "2025-07-04T14:04:48.693450Z"
     }
-   ],
+   },
    "source": [
     "# Fit then predict\n",
     "from aeon.forecasting import NaiveForecaster\n",
     "\n",
     "d = NaiveForecaster(strategy=\"last\")\n",
-    "d.fit(y)\n",
-    "p = d.predict()\n",
+    "p = d.forecast(y)\n",
     "print(p)"
-   ]
+   ],
+   "outputs": [
+    {
+     "name": "stdout",
+     "output_type": "stream",
+     "text": [
+      "432.0\n"
+     ]
+    }
+   ],
+   "execution_count": 4
   },
   {
    "cell_type": "code",
-   "execution_count": null,
-   "metadata": {},
-   "outputs": [],
+   "metadata": {
+    "ExecuteTime": {
+     "end_time": "2025-07-04T14:05:28.648536Z",
+     "start_time": "2025-07-04T14:05:28.633476Z"
+    }
+   },
    "source": [
-    "# forecast is equivalent to fit_predict in other estimators\n",
-    "p2 = d.forecast(y)\n",
-    "print(p2)"
-   ]
+    "d = NaiveForecaster(strategy=\"mean\")\n",
+    "p = d.forecast(y)\n",
+    "print(p)\n"
+   ],
+   "outputs": [
+    {
+     "name": "stdout",
+     "output_type": "stream",
+     "text": [
+      "280.2986111111111\n"
+     ]
+    }
+   ],
+   "execution_count": 6
   },
   {
    "cell_type": "markdown",
@@ -182,16 +236,29 @@
   },
   {
    "cell_type": "code",
-   "execution_count": null,
-   "metadata": {},
-   "outputs": [],
+   "metadata": {
+    "ExecuteTime": {
+     "end_time": "2025-07-04T14:05:52.382942Z",
+     "start_time": "2025-07-04T14:05:52.367205Z"
+    }
+   },
    "source": [
     "s = NaiveForecaster(strategy=\"seasonal_last\", horizon=2, seasonal_period=4)\n",
-    "s.fit(y)\n",
-    "p = s.predict()\n",
+    "p = s.forecast(y)\n",
     "print(f\"Last season: {y[-4:]}\")\n",
     "print(f\"Forecast: {p}\")"
-   ]
+   ],
+   "outputs": [
+    {
+     "name": "stdout",
+     "output_type": "stream",
+     "text": [
+      "Last season: [508. 461. 390. 432.]\n",
+      "Forecast: 461.0\n"
+     ]
+    }
+   ],
+   "execution_count": 7
   },
   {
    "cell_type": "markdown",
@@ -216,8 +283,7 @@
     "from aeon.forecasting import RegressionForecaster\n",
     "\n",
     "r = RegressionForecaster(window=20)\n",
-    "r.fit(y)\n",
-    "p = r.predict()\n",
+    "p = r.forecast(y)\n",
     "print(p)\n",
     "r2 = RegressionForecaster(window=10, horizon=5)\n",
     "r2.fit(y)\n",
@@ -257,14 +323,19 @@
   },
   {
    "cell_type": "code",
-   "execution_count": 20,
    "metadata": {
+    "collapsed": false,
     "ExecuteTime": {
-     "end_time": "2024-11-16T19:21:26.225501Z",
-     "start_time": "2024-11-16T19:21:26.204872Z"
-    },
-    "collapsed": false
+     "end_time": "2025-07-04T14:06:53.435103Z",
+     "start_time": "2025-07-04T14:06:49.740533Z"
+    }
    },
+   "source": [
+    "from aeon.forecasting import ETSForecaster\n",
+    "\n",
+    "ets = ETSForecaster()\n",
+    "ets.forecast(y)"
+   ],
    "outputs": [
     {
      "data": {
@@ -272,18 +343,12 @@
        "460.302772481884"
       ]
      },
-     "execution_count": 20,
+     "execution_count": 8,
      "metadata": {},
      "output_type": "execute_result"
     }
    ],
-   "source": [
-    "from aeon.forecasting import ETSForecaster\n",
-    "\n",
-    "ets = ETSForecaster()\n",
-    "ets.fit(y)\n",
-    "ets.predict()"
-   ]
+   "execution_count": 8
   },
   {
    "cell_type": "code",
diff --git a/examples/forecasting/regression.ipynb b/examples/forecasting/regression.ipynb
index b442c6b4..e451051b 100644
--- a/examples/forecasting/regression.ipynb
+++ b/examples/forecasting/regression.ipynb
@@ -220,11 +220,11 @@
     "airline = load_airline()\n",
     "rf = RegressionForecaster(window=50)\n",
     "rf.fit(airline)\n",
-    "p3 = rf.predict()\n",
+    "p3 = rf.predict(airline)\n",
     "print(f\" Forecast for airline with linear regression = {p1} and {p3}\")\n",
     "rf2 = RegressionForecaster(regressor=DrCIFRegressor(n_estimators=10), window=50)\n",
     "rf2.fit(airline)\n",
-    "p4 = rf.predict()\n",
+    "p4 = rf.predict(airline)\n",
     "print(f\" Forecast for airline with DrCIF = {p1} and {p3}\")"
    ],
    "id": "bdc35a7a671ee254",


Test patch:
diff --git a/aeon/forecasting/tests/test_base.py b/aeon/forecasting/tests/test_base.py
index e6b729c6..b8bddb49 100644
--- a/aeon/forecasting/tests/test_base.py
+++ b/aeon/forecasting/tests/test_base.py
@@ -4,7 +4,7 @@ import numpy as np
 import pandas as pd
 import pytest
 
-from aeon.forecasting import NaiveForecaster, RegressionForecaster
+from aeon.forecasting import BaseForecaster, NaiveForecaster, RegressionForecaster
 
 
 def test_base_forecaster():
@@ -12,7 +12,7 @@ def test_base_forecaster():
     f = NaiveForecaster()
     y = np.random.rand(50)
     f.fit(y)
-    p1 = f.predict()
+    p1 = f.predict(y)
     assert p1 == y[-1]
     p2 = f.forecast(y)
     p3 = f._forecast(y)
@@ -54,8 +54,8 @@ def test_direct_forecast():
         assert p == preds[i]
 
 
-def test_recursive_forecast():
-    """Test recursive forecasting."""
+def test_iterative_forecast():
+    """Test iterative forecasting."""
     y = np.random.rand(50)
     f = RegressionForecaster(window=4)
     preds = f.iterative_forecast(y, prediction_horizon=10)
@@ -79,3 +79,21 @@ def test_direct_forecast_with_exog():
     # Check that predictions are different from when no exog is used
     preds_no_exog = f.direct_forecast(y, prediction_horizon=10)
     assert not np.array_equal(preds, preds_no_exog)
+
+
+def test_fit_is_empty():
+    """Test empty fit."""
+
+    class _EmptyFit(BaseForecaster):
+        _tags = {"fit_is_empty": True}
+
+        def _fit(self, y):
+            return self
+
+        def _predict(self, y):
+            return 0
+
+    dummy = _EmptyFit(horizon=1, axis=1)
+    y = np.arange(50)
+    dummy.fit(y)
+    assert dummy.is_fitted
diff --git a/aeon/forecasting/tests/test_ets.py b/aeon/forecasting/tests/test_ets.py
index 1c2f2ad1..64275a2d 100644
--- a/aeon/forecasting/tests/test_ets.py
+++ b/aeon/forecasting/tests/test_ets.py
@@ -71,12 +71,12 @@ def test_ets_forecaster(params, expected):
     """Test ETSForecaster for multiple parameter combinations."""
     data = np.array([3, 10, 12, 13, 12, 10, 12, 3, 10, 12, 13, 12, 10, 12])
     forecaster = ETSForecaster(**params)
-    forecaster.fit(data)
-    p = forecaster.predict()
+    p = forecaster.forecast(data)
     assert np.isclose(p, expected)
 
 
 def test_incorrect_parameters():
+    """Test incorrect set up."""
     _validate_parameter(0, True)
     _validate_parameter(None, True)
     with pytest.raises(ValueError):
@@ -84,3 +84,13 @@ def test_incorrect_parameters():
         _validate_parameter(None, True)
         _validate_parameter(10, False)
         _validate_parameter("Foo", True)
+    forecaster = ETSForecaster()
+    forecaster.horizon = 2
+    data = np.array([3, 10, 12, 13, 12, 10, 12, 3, 10, 12, 13, 12, 10, 12])
+    with pytest.raises(ValueError, match="Horizon is set >1, but"):
+        forecaster.fit(data)
+    forecaster = ETSForecaster()
+    with pytest.raises(
+        ValueError, match="This forecaster cannot be used with the " "direct strategy"
+    ):
+        forecaster.direct_forecast(data, prediction_horizon=6)
diff --git a/aeon/forecasting/tests/test_naive.py b/aeon/forecasting/tests/test_naive.py
index c0f9a98b..50f1b742 100644
--- a/aeon/forecasting/tests/test_naive.py
+++ b/aeon/forecasting/tests/test_naive.py
@@ -9,8 +9,7 @@ def test_naive_forecaster_last_strategy():
     """Test NaiveForecaster with 'last' strategy."""
     sample_data = np.array([10, 20, 30, 40, 50])
     forecaster = NaiveForecaster(strategy="last", horizon=3)
-    forecaster.fit(sample_data)
-    predictions = forecaster.predict()
+    predictions = forecaster.forecast(sample_data)
     expected = 50
     np.testing.assert_array_equal(predictions, expected)
 
@@ -19,8 +18,7 @@ def test_naive_forecaster_mean_strategy():
     """Test NaiveForecaster with 'mean' strategy."""
     sample_data = np.array([10, 20, 30, 40, 50])
     forecaster = NaiveForecaster(strategy="mean", horizon=2)
-    forecaster.fit(sample_data)
-    predictions = forecaster.predict()
+    predictions = forecaster.forecast(sample_data)
     expected = 30  # Mean of [10, 20, 30, 40, 50] is 30
     np.testing.assert_array_equal(predictions, expected)
 
@@ -31,36 +29,36 @@ def test_naive_forecaster_seasonal_last_strategy():
 
     # Last season is [6, 7, 8] for seasonal_period = 3
     forecaster = NaiveForecaster(strategy="seasonal_last", seasonal_period=3, horizon=4)
+    pred = forecaster.forecast(data)
     forecaster.fit(data)
-    pred = forecaster.predict()
-    pred2 = forecaster.predict(y=data)
+    pred2 = forecaster.forecast_
+    pred3 = forecaster.predict(data)
     expected = 6  # predicts the 1-st element of the last season.
-    np.testing.assert_array_equal(pred, expected)
-    np.testing.assert_array_equal(pred2, expected)
+    np.testing.assert_allclose(pred, pred2, pred3, expected)
 
     # Test horizon within the season length
     forecaster = NaiveForecaster(strategy="seasonal_last", seasonal_period=3, horizon=2)
+    pred = forecaster.forecast(data)
     forecaster.fit(data)
-    pred = forecaster.predict()
-    pred2 = forecaster.predict(y=data)
+    pred2 = forecaster.forecast_
+    pred3 = forecaster.predict(data)
     expected = 7  # predicts the 2-nd element of the last season.
-    np.testing.assert_array_equal(pred, expected)
-    np.testing.assert_array_equal(pred2, expected)
+    np.testing.assert_allclose(pred, pred2, pred3, expected)
 
     # Test horizon wrapping around to a new season
     forecaster = NaiveForecaster(strategy="seasonal_last", seasonal_period=3, horizon=7)
+    pred = forecaster.forecast(data)
     forecaster.fit(data)
-    pred = forecaster.predict()
-    pred2 = forecaster.predict(y=data)
+    pred2 = forecaster.forecast_
+    pred3 = forecaster.predict(data)
     expected = 6  # predicts the 1-st element of the last season.
-    np.testing.assert_array_equal(pred, expected)
-    np.testing.assert_array_equal(pred2, expected)
+    np.testing.assert_allclose(pred, pred2, pred3, expected)
 
     # Last season is now [5, 6, 7, 8] with seasonal_period = 4
     forecaster = NaiveForecaster(strategy="seasonal_last", seasonal_period=4, horizon=6)
+    pred = forecaster.forecast(data)
     forecaster.fit(data)
-    pred = forecaster.predict()
-    pred2 = forecaster.predict(y=data)
+    pred2 = forecaster.forecast_
+    pred3 = forecaster.predict(data)
     expected = 6  # predicts the 2nd element of the new last season.
-    np.testing.assert_array_equal(pred, expected)
-    np.testing.assert_array_equal(pred2, expected)
+    np.testing.assert_allclose(pred, pred2, pred3, expected)
diff --git a/aeon/forecasting/tests/test_regressor.py b/aeon/forecasting/tests/test_regressor.py
index f50519d1..0f2040d2 100644
--- a/aeon/forecasting/tests/test_regressor.py
+++ b/aeon/forecasting/tests/test_regressor.py
@@ -9,22 +9,23 @@ from aeon.regression import DummyRegressor
 
 
 def test_regression_forecaster():
-    """Test the regression forecaster."""
+    """Test the regression forecaster.
+
+    Test fit/predict and forecast are equivalent for sklearn and aeon regressors.
+    Test invalid window handling.
+    """
     y = np.random.rand(100)
     f = RegressionForecaster(window=10)
+    p = f.forecast(y)
     f.fit(y)
-    p = f.predict()
     p2 = f.predict(y)
     assert p == p2
-    p3 = f.forecast(y)
-    assert p == p3
     f2 = RegressionForecaster(regressor=LinearRegression(), window=10)
-    f2.fit(y)
-    p2 = f2.predict()
+    p2 = f2.forecast(y)
     assert p == p2
     f2 = RegressionForecaster(regressor=DummyRegressor(), window=10)
     f2.fit(y)
-    f2.predict()
+    f2.predict(y)
 
     with pytest.raises(ValueError):
         f = RegressionForecaster(window=-1)
@@ -32,27 +33,29 @@ def test_regression_forecaster():
     with pytest.raises(ValueError):
         f = RegressionForecaster(window=101)
         f.fit(y)
+    f = RegressionForecaster(window=10)
+    f.fit(y)
+    y_test = np.random.rand(5)
+    with pytest.raises(ValueError):
+        p = f.predict(y_test)
 
 
 def test_regression_forecaster_with_exog():
     """Test the regression forecaster with exogenous variables."""
-    np.random.seed(0)
-
     n_samples = 100
     exog = np.random.rand(n_samples) * 10
     y = 2 * exog + np.random.rand(n_samples) * 0.1
-
     f = RegressionForecaster(window=10)
 
     # Test fit and predict with exog
     f.fit(y, exog=exog)
-    p1 = f.predict()
+    p1 = f.forecast_
     assert isinstance(p1, float)
 
-    # Test that exog variable has an impact
+    # Test that exog variable have an impact
     exog_zeros = np.zeros(n_samples)
     f.fit(y, exog=exog_zeros)
-    p2 = f.predict()
+    p2 = f.forecast_
     assert p1 != p2
 
     # Test that forecast method works and is equivalent to fit+predict
@@ -61,12 +64,19 @@ def test_regression_forecaster_with_exog():
 
     # Manual fit + predict
     f.fit(y=y_new, exog=exog_new)
-    p_manual = f.predict()
+    p_manual = f.predict(y_new, exog=exog_new)
 
     # forecast() method
     p_forecast = f.forecast(y=y_new, exog=exog_new)
     assert p_manual == pytest.approx(p_forecast)
 
+    # Test with multivariate exog
+    exog_m = np.array([exog, exog_zeros])
+    p1 = f.forecast(y, exog_m)
+    f.fit(y, exog_m)
+    p2 = f.predict(y, exog_m)
+    assert p1 == p2
+
 
 def test_regression_forecaster_with_exog_errors():
     """Test errors in regression forecaster with exogenous variables."""
@@ -74,10 +84,28 @@ def test_regression_forecaster_with_exog_errors():
     exog_short = np.random.rand(99)
     f = RegressionForecaster(window=10)
 
-    # Test for unequal length series
-    with pytest.raises(ValueError, match="must have the same number of time points"):
+    # Test for unequal length series in fit
+    with pytest.raises(
+        ValueError, match="y and exog must have the same number of time points"
+    ):
         f.fit(y, exog=exog_short)
-
-    with pytest.raises(ValueError, match="must have the same number of time points"):
-        f.fit(y)
-        f.predict(y, exog=exog_short)
+    # Test for fit/predict mismatches in shape
+
+    # If exog in fit, must have them in predict
+    exog_train = np.array(np.random.rand(100))
+    with pytest.raises(ValueError, match="predict passed no exogenous variables"):
+        f.fit(y, exog=exog_train)
+        f.predict(y)
+    exog_test = np.array([np.random.rand(10), np.random.rand(10)])
+    f.fit(y, exog=exog_train)
+    with pytest.raises(ValueError, match="Forecaster passed"):
+        f.predict(y, exog_test)
+    exog_short = np.random.rand(5)
+    with pytest.raises(
+        ValueError, match="Exogenous variables passed in predict of length"
+    ):
+        f.predict(y, exog_short)
+    with pytest.raises(ValueError, match="predict passed no exogenous variables"):
+        f.predict(y)
+    with pytest.raises(ValueError, match="must be greater than or equal to 1"):
+        f.direct_forecast(y, prediction_horizon=0)
diff --git a/aeon/testing/estimator_checking/_yield_forecasting_checks.py b/aeon/testing/estimator_checking/_yield_forecasting_checks.py
index 0a2fc3be..9c57386a 100644
--- a/aeon/testing/estimator_checking/_yield_forecasting_checks.py
+++ b/aeon/testing/estimator_checking/_yield_forecasting_checks.py
@@ -62,17 +62,17 @@ def check_forecaster_output(estimator, datatype):
     estimator.fit(
         FULL_TEST_DATA_DICT[datatype]["train"][0],
     )
-    y_pred = estimator.predict()
+    assert hasattr(estimator, "forecast_"), "fit() must set the attribute forecast_"
+    assert isinstance(estimator.forecast_, float), "attribute forecast_ must be a float"
+
+    y_pred = estimator.predict(FULL_TEST_DATA_DICT[datatype]["test"][0])
+
     assert isinstance(y_pred, float), (
-        f"predict() output should be float, got" f" {type(y_pred)}"
+        f"predict(y) output should be float, got" f" {type(y_pred)}"
     )
 
+    y_pred = estimator.forecast_
     y_pred2 = estimator.forecast(FULL_TEST_DATA_DICT[datatype]["train"][0])
     assert y_pred == y_pred2, (
-        f"predict() and forecast() output differ: {y_pred} !=" f" {y_pred2}"
-    )
-    y_pred3 = estimator.predict(FULL_TEST_DATA_DICT[datatype]["train"][0])
-    assert y_pred == y_pred3, (
-        f"after fit(), predict() and predict(y_train) should be the same, but"
-        f"output differ: {y_pred} != {y_pred3}"
+        f"estimator.forecast_ and forecast(y) output differ: {y_pred} !=" f" {y_pred2}"
     )