diff --git a/aeon/forecasting/_ets.py b/aeon/forecasting/_ets.py index f6ec85fcaf..cea45881f1 100644 --- a/aeon/forecasting/_ets.py +++ b/aeon/forecasting/_ets.py @@ -12,13 +12,13 @@ import numpy as np from numba import njit -from aeon.forecasting.base import BaseForecaster +from aeon.forecasting.base import BaseForecaster, DirectForecastingMixin ADDITIVE = "additive" MULTIPLICATIVE = "multiplicative" -class ETSForecaster(BaseForecaster): +class ETSForecaster(BaseForecaster, DirectForecastingMixin): """Exponential Smoothing (ETS) forecaster. Implements the ETS (Error, Trend, Seasonality) forecaster, supporting additive @@ -45,29 +45,6 @@ class ETSForecaster(BaseForecaster): phi : float, default=0.99 Trend damping parameter (used only for damped trend models). - Attributes - ---------- - forecast_val_ : float - Forecast value for the given horizon. - level_ : float - Estimated level component. - trend_ : float - Estimated trend component. - seasonality_ : array-like or None - Estimated seasonal components. - aic_ : float - Akaike Information Criterion of the fitted model. - avg_mean_sq_err_ : float - Average mean squared error of the fitted model. - residuals_ : list of float - Residuals from the fitted model. - fitted_values_ : list of float - Fitted values for the training data. - liklihood_ : float - Log-likelihood of the fitted model. - n_timepoints_ : int - Number of time points in the training series. - References ---------- .. [1] R. J. Hyndman and G. Athanasopoulos, @@ -90,6 +67,7 @@ class ETSForecaster(BaseForecaster): _tags = { "capability:horizon": False, + "fit_is_empty": True, } def __init__( @@ -103,46 +81,33 @@ def __init__( gamma: float = 0.01, phi: float = 0.99, ): - self.alpha = alpha - self.beta = beta - self.gamma = gamma - self.phi = phi - self.forecast_val_ = 0.0 - self.level_ = 0.0 - self.trend_ = 0.0 - self.seasonality_ = None - self._beta = beta - self._gamma = gamma self.error_type = error_type self.trend_type = trend_type self.seasonality_type = seasonality_type self.seasonal_period = seasonal_period - self._seasonal_period = seasonal_period - self.n_timepoints_ = 0 - self.avg_mean_sq_err_ = 0 - self.liklihood_ = 0 - self.k_ = 0 - self.aic_ = 0 - self.residuals_ = [] - self.fitted_values_ = [] - super().__init__(horizon=1, axis=1) + self.alpha = alpha + self.beta = beta + self.gamma = gamma + self.phi = phi - def _fit(self, y, exog=None): - """Fit Exponential Smoothing forecaster to series y. + super().__init__(horizon=1, axis=1) - Fit a forecaster to predict self.horizon steps ahead using y. + def _predict(self, y=None, exog=None): + """ + Predict the next horizon steps ahead. Parameters ---------- - y : np.ndarray - A time series on which to learn a forecaster to predict horizon ahead + 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. exog : np.ndarray, default =None Optional exogenous time series data assumed to be aligned with y Returns ------- - self - Fitted ETSForecaster. + float + single prediction self.horizon steps ahead of y. """ _validate_parameter(self.error_type, False) _validate_parameter(self.seasonality_type, True) @@ -158,87 +123,60 @@ def _get_int(x): return 2 return x - self._error_type = _get_int(self.error_type) - self._seasonality_type = _get_int(self.seasonality_type) - self._trend_type = _get_int(self.trend_type) - if self._seasonal_period < 1 or self._seasonality_type == 0: - self._seasonal_period = 1 + error_type = _get_int(self.error_type) + seasonality_type = _get_int(self.seasonality_type) + trend_type = _get_int(self.trend_type) + + seasonal_period = self.seasonal_period + if self.seasonal_period < 1 or seasonality_type == 0: + seasonal_period = 1 - if self._trend_type == 0: + beta = self.beta + if trend_type == 0: # Required for the equations in _update_states to work correctly - self._beta = 0 - if self._seasonality_type == 0: + beta = 0 + + gamma = self.gamma + if seasonality_type == 0: # Required for the equations in _update_states to work correctly - self._gamma = 0 + gamma = 0 + data = y.squeeze() ( - self.level_, - self.trend_, - self.seasonality_, - self.n_timepoints_, - self.residuals_, - self.fitted_values_, - self.avg_mean_sq_err_, - self.liklihood_, - self.k_, - self.aic_, + level_, + trend_, + seasonality_, + n_timepoints_, + residuals_, + fitted_values_, + avg_mean_sq_err_, + liklihood_, + k_, + aic_, ) = _numba_fit( data, - self._error_type, - self._trend_type, - self._seasonality_type, - self._seasonal_period, + error_type, + trend_type, + seasonality_type, + seasonal_period, self.alpha, - self._beta, - self._gamma, + beta, + gamma, self.phi, ) - self.forecast_ = _predict( - self._trend_type, - self._seasonality_type, - self.level_, - self.trend_, - self.seasonality_, + + fitted_value = _predict( + trend_type, + seasonality_type, + level_, + trend_, + seasonality_, self.phi, self.horizon, - self.n_timepoints_, - self._seasonal_period, - ) - - return self - - 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. - exog : np.ndarray, default =None - Optional exogenous time series data assumed to be aligned with y - - Returns - ------- - float - single prediction self.horizon steps ahead of y. - """ - return self.forecast_ - - def _initialise(self, data): - """ - Initialize level, trend, and seasonality values for the ETS model. - - Parameters - ---------- - data : array-like - The time series data - (should contain at least two full seasons if seasonality is specified) - """ - self.level_, self.trend_, self.seasonality_ = _initialise( - self._trend_type, self._seasonality_type, self._seasonal_period, data + n_timepoints_, + seasonal_period, ) + return fitted_value @njit(fastmath=True, cache=True) diff --git a/aeon/forecasting/_naive.py b/aeon/forecasting/_naive.py index edb5b3ea62..706abd6219 100644 --- a/aeon/forecasting/_naive.py +++ b/aeon/forecasting/_naive.py @@ -31,30 +31,16 @@ class NaiveForecaster(BaseForecaster): Only relevant for "seasonal_last". """ + _tags = { + "fit_is_empty": True, + } + def __init__(self, strategy="last", seasonal_period=1, horizon=1): self.strategy = strategy self.seasonal_period = seasonal_period super().__init__(horizon=horizon, axis=1) - def _fit(self, y, exog=None): - y_squeezed = y.squeeze() - - if self.strategy == "last": - self.forecast_ = y_squeezed[-1] - elif self.strategy == "mean": - self.forecast_ = np.mean(y_squeezed) - elif self.strategy == "seasonal_last": - 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}. " - "Valid strategies are 'last', 'mean', 'seasonal_last'." - ) - return self - def _predict(self, y, exog=None): y_squeezed = y.squeeze() diff --git a/aeon/forecasting/_regression.py b/aeon/forecasting/_regression.py index 95fc2fb141..59dbfaff8f 100644 --- a/aeon/forecasting/_regression.py +++ b/aeon/forecasting/_regression.py @@ -8,10 +8,16 @@ import numpy as np from sklearn.linear_model import LinearRegression -from aeon.forecasting.base import BaseForecaster +from aeon.forecasting.base import ( + BaseForecaster, + DirectForecastingMixin, + IterativeForecastingMixin, +) -class RegressionForecaster(BaseForecaster): +class RegressionForecaster( + BaseForecaster, DirectForecastingMixin, IterativeForecastingMixin +): """ Regression based forecasting. diff --git a/aeon/forecasting/base.py b/aeon/forecasting/base.py index 2b065a507f..af9e93ea23 100644 --- a/aeon/forecasting/base.py +++ b/aeon/forecasting/base.py @@ -5,7 +5,7 @@ """ __maintainer__ = ["TonyBagnall"] -__all__ = ["BaseForecaster"] +__all__ = ["BaseForecaster", "DirectForecastingMixin", "IterativeForecastingMixin"] from abc import abstractmethod from typing import final @@ -45,7 +45,7 @@ class BaseForecaster(BaseSeriesEstimator): def __init__(self, horizon: int, axis: int): self.horizon = horizon - self.meta_ = None # Meta data related to y on the last fit + super().__init__(axis) @final @@ -90,11 +90,11 @@ def fit(self, y, exog=None): if exog is not None: exog = self._convert_y(exog, self.axis) - self.is_fitted = True - return self._fit(y, exog) + self._fit(y, exog) - @abstractmethod - def _fit(self, y, exog=None): ... + # this should happen last + self.is_fitted = True + return self @final def predict(self, y, exog=None): @@ -112,16 +112,30 @@ def predict(self, y, exog=None): float single prediction self.horizon steps ahead of y. """ - self._check_is_fitted() + if not self.get_tag("fit_is_empty"): + self._check_is_fitted() + + horizon = self.get_tag("capability:horizon") + if not horizon and self.horizon > 1: + raise ValueError( + f"Horizon is set >1, but {self.__class__.__name__} cannot handle a " + f"horizon greater than 1" + ) + + exog_tag = self.get_tag("capability:exogenous") + if not exog_tag and exog is not None: + raise ValueError( + f"Exogenous variables passed but {self.__class__.__name__} cannot " + "handle exogenous variables" + ) + 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, exog=None): ... - @final def forecast(self, y, exog=None): """Forecast the next horizon steps ahead of ``y``. @@ -140,16 +154,77 @@ def forecast(self, y, exog=None): float single prediction self.horizon steps ahead of y. """ + horizon = self.get_tag("capability:horizon") + if not horizon and self.horizon > 1: + raise ValueError( + f"Horizon is set >1, but {self.__class__.__name__} cannot handle a " + f"horizon greater than 1" + ) + + exog_tag = self.get_tag("capability:exogenous") + if not exog_tag and exog is not None: + raise ValueError( + f"Exogenous variables passed but {self.__class__.__name__} cannot " + "handle exogenous variables" + ) + 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._forecast(y, exog) - def _forecast(self, y, exog=None): - """Forecast horizon steps ahead for time series ``y``.""" + y_pred = self._forecast(y, exog) + + # this should happen last + self.is_fitted = True + return y_pred + + def _fit(self, y, exog): + return self + + @abstractmethod + def _predict(self, y, exog): ... + + def _forecast(self, y, exog): + """Forecast values for time series X.""" self.fit(y, exog) - return self.forecast_ + return self._predict(y, exog) + + def _convert_y(self, y: VALID_SERIES_INNER_TYPES, axis: int): + """Convert y to self.get_tag("y_inner_type").""" + if axis > 1 or axis < 0: + raise ValueError(f"Input axis should be 0 or 1, saw {axis}") + + inner_type = self.get_tag("y_inner_type") + if not isinstance(inner_type, list): + inner_type = [inner_type] + inner_names = [i.split(".")[-1] for i in inner_type] + + input = type(y).__name__ + if input not in inner_names: + if inner_names[0] == "ndarray": + y = y.to_numpy() + elif inner_names[0] == "DataFrame": + transpose = False + if y.ndim == 1 and axis == 1: + transpose = True + y = pd.DataFrame(y) + if transpose: + y = y.T + else: + raise ValueError( + f"Unsupported inner type {inner_names[0]} derived from {inner_type}" + ) + if y.ndim > 1 and self.axis != axis: + y = y.T + elif y.ndim == 1 and isinstance(y, np.ndarray): + y = y[np.newaxis, :] if self.axis == 1 else y[:, np.newaxis] + return y + + +class DirectForecastingMixin: + """Mixin class for direct forecasting.""" @final def direct_forecast(self, y, prediction_horizon, exog=None): @@ -209,6 +284,10 @@ def direct_forecast(self, y, prediction_horizon, exog=None): preds[i] = f.forecast(y, exog) return preds + +class IterativeForecastingMixin: + """Mixin class for iterative forecasting.""" + def iterative_forecast(self, y, prediction_horizon): """ Forecast ``prediction_horizon`` prediction using a single model fit on `y`. @@ -255,34 +334,3 @@ def iterative_forecast(self, y, prediction_horizon): preds[i] = self.predict(y) y = np.append(y, preds[i]) return preds - - def _convert_y(self, y: VALID_SERIES_INNER_TYPES, axis: int): - """Convert y to self.get_tag("y_inner_type").""" - if axis > 1 or axis < 0: - raise ValueError(f"Input axis should be 0 or 1, saw {axis}") - - inner_type = self.get_tag("y_inner_type") - if not isinstance(inner_type, list): - inner_type = [inner_type] - inner_names = [i.split(".")[-1] for i in inner_type] - - input = type(y).__name__ - if input not in inner_names: - if inner_names[0] == "ndarray": - y = y.to_numpy() - elif inner_names[0] == "DataFrame": - transpose = False - if y.ndim == 1 and axis == 1: - transpose = True - y = pd.DataFrame(y) - if transpose: - y = y.T - else: - raise ValueError( - f"Unsupported inner type {inner_names[0]} derived from {inner_type}" - ) - if y.ndim > 1 and self.axis != axis: - y = y.T - elif y.ndim == 1 and isinstance(y, np.ndarray): - y = y[np.newaxis, :] if self.axis == 1 else y[:, np.newaxis] - return y diff --git a/aeon/forecasting/tests/test_base.py b/aeon/forecasting/tests/test_base.py index 73250e4a9e..e57a5988ac 100644 --- a/aeon/forecasting/tests/test_base.py +++ b/aeon/forecasting/tests/test_base.py @@ -16,11 +16,11 @@ def test_base_forecaster(): p1 = f.predict(y) assert p1 == y[-1] p2 = f.forecast(y) - p3 = f._forecast(y) + p3 = f._forecast(y, None) assert p2 == p1 assert p3 == p2 with pytest.raises(ValueError, match="Exogenous variables passed"): - f.fit(y, exog=y) + f.predict(y, exog=y) def test_convert_y(): diff --git a/aeon/forecasting/tests/test_ets.py b/aeon/forecasting/tests/test_ets.py index 64275a2d1d..8aeae2faf2 100644 --- a/aeon/forecasting/tests/test_ets.py +++ b/aeon/forecasting/tests/test_ets.py @@ -1,8 +1,5 @@ """Test ETS.""" -__maintainer__ = [] -__all__ = [] - import numpy as np import pytest @@ -71,12 +68,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) - p = forecaster.forecast(data) + p = forecaster.predict(data) assert np.isclose(p, expected) def test_incorrect_parameters(): - """Test incorrect set up.""" + """Test ETSForecaster with incorrect parameters.""" _validate_parameter(0, True) _validate_parameter(None, True) with pytest.raises(ValueError): diff --git a/aeon/forecasting/tests/test_naive.py b/aeon/forecasting/tests/test_naive.py index 50f1b742e8..fd8a743334 100644 --- a/aeon/forecasting/tests/test_naive.py +++ b/aeon/forecasting/tests/test_naive.py @@ -9,7 +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) - predictions = forecaster.forecast(sample_data) + predictions = forecaster.predict(sample_data) expected = 50 np.testing.assert_array_equal(predictions, expected) @@ -18,7 +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) - predictions = forecaster.forecast(sample_data) + predictions = forecaster.predict(sample_data) expected = 30 # Mean of [10, 20, 30, 40, 50] is 30 np.testing.assert_array_equal(predictions, expected) @@ -31,34 +31,30 @@ def test_naive_forecaster_seasonal_last_strategy(): forecaster = NaiveForecaster(strategy="seasonal_last", seasonal_period=3, horizon=4) pred = forecaster.forecast(data) forecaster.fit(data) - pred2 = forecaster.forecast_ - pred3 = forecaster.predict(data) + pred = forecaster.predict(data) expected = 6 # predicts the 1-st element of the last season. - np.testing.assert_allclose(pred, pred2, pred3, expected) + np.testing.assert_array_equal(pred, expected) # Test horizon within the season length forecaster = NaiveForecaster(strategy="seasonal_last", seasonal_period=3, horizon=2) pred = forecaster.forecast(data) forecaster.fit(data) - pred2 = forecaster.forecast_ - pred3 = forecaster.predict(data) + pred = forecaster.predict(data) expected = 7 # predicts the 2-nd element of the last season. - np.testing.assert_allclose(pred, pred2, pred3, expected) + np.testing.assert_array_equal(pred, 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) - pred2 = forecaster.forecast_ - pred3 = forecaster.predict(data) + pred = forecaster.predict(data) expected = 6 # predicts the 1-st element of the last season. - np.testing.assert_allclose(pred, pred2, pred3, expected) + np.testing.assert_array_equal(pred, 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) - pred2 = forecaster.forecast_ - pred3 = forecaster.predict(data) + pred = forecaster.predict(data) expected = 6 # predicts the 2nd element of the new last season. - np.testing.assert_allclose(pred, pred2, pred3, expected) + np.testing.assert_array_equal(pred, expected) diff --git a/aeon/forecasting/tests/test_regressor.py b/aeon/forecasting/tests/test_regressor.py index 0f2040d238..4907502083 100644 --- a/aeon/forecasting/tests/test_regressor.py +++ b/aeon/forecasting/tests/test_regressor.py @@ -16,12 +16,15 @@ def test_regression_forecaster(): """ y = np.random.rand(100) f = RegressionForecaster(window=10) - p = f.forecast(y) f.fit(y) - p2 = f.predict(y) + p = f.predict(y) + p2 = f.forecast(y) assert p == p2 + p3 = f.forecast(y) + assert p == p3 f2 = RegressionForecaster(regressor=LinearRegression(), window=10) - p2 = f2.forecast(y) + f2.fit(y) + p2 = f2.predict(y) assert p == p2 f2 = RegressionForecaster(regressor=DummyRegressor(), window=10) f2.fit(y) @@ -49,14 +52,14 @@ def test_regression_forecaster_with_exog(): # Test fit and predict with exog f.fit(y, exog=exog) - p1 = f.forecast_ - assert isinstance(p1, float) + p = f.predict(y, exog=exog) + assert isinstance(p, float) # Test that exog variable have an impact exog_zeros = np.zeros(n_samples) f.fit(y, exog=exog_zeros) - p2 = f.forecast_ - assert p1 != p2 + p2 = f.predict(y, exog=exog) + assert p != p2 # Test that forecast method works and is equivalent to fit+predict y_new = np.arange(50, 150) diff --git a/aeon/testing/estimator_checking/_yield_forecasting_checks.py b/aeon/testing/estimator_checking/_yield_forecasting_checks.py index 9c57386ac6..79c32152eb 100644 --- a/aeon/testing/estimator_checking/_yield_forecasting_checks.py +++ b/aeon/testing/estimator_checking/_yield_forecasting_checks.py @@ -37,7 +37,9 @@ def check_forecaster_overrides_and_tags(estimator_class): # Test that all forecasters implement abstract predict. assert "_predict" in estimator_class.__dict__ - # todo decide what to do with "fit_is_empty" and abstract "_fit" + # Test that fit_is_empty is correctly set + fit_is_empty = estimator_class.get_class_tag(tag_name="fit_is_empty") + assert fit_is_empty == ("_fit" not in estimator_class.__dict__) # Test valid tag for X_inner_type X_inner_type = estimator_class.get_class_tag(tag_name="X_inner_type") @@ -62,17 +64,16 @@ def check_forecaster_output(estimator, datatype): estimator.fit( FULL_TEST_DATA_DICT[datatype]["train"][0], ) - 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]) - + y_pred = estimator.predict( + FULL_TEST_DATA_DICT[datatype]["test"][0], + ) assert isinstance(y_pred, float), ( 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"estimator.forecast_ and forecast(y) output differ: {y_pred} !=" f" {y_pred2}" + y_pred3 = estimator.predict(FULL_TEST_DATA_DICT[datatype]["train"][0]) + assert y_pred2 == y_pred3, ( + f"fit(y).predict(y) and forecast(y) should be the same, but" + f"output differ: {y_pred2} != {y_pred3}" ) diff --git a/examples/forecasting/iterative.ipynb b/examples/forecasting/iterative.ipynb index e98d588656..133907f97f 100644 --- a/examples/forecasting/iterative.ipynb +++ b/examples/forecasting/iterative.ipynb @@ -304,14 +304,6 @@ } ], "execution_count": 27 - }, - { - "metadata": {}, - "cell_type": "code", - "outputs": [], - "execution_count": null, - "source": "", - "id": "5e283827ebb7141b" } ], "metadata": {