Repository: aeon-toolkit/aeon
Pull request #2834 (2025-05-23 11:37:58)

[ENH] introduce revised version of ETS
part of #2833 

this replaces the current ETS with a numba version that is refactored. It is tested for equivalence, same tests pass and speed up over statsmodels is significant, cannot sensibly compare to statsforecast because that is tuning only, so obvs does more work, can compare that to autoETS

The first version was for some reason predicting an array rather than a float. Its still not clear to me how we use this with fit and predict, we might need a wrapper for an example. Basically fitting the model is so amazingly quick, refitting for each forecast is I believe worthwhile for interface compliance.



aeon-actions-bot[bot]: 
## Thank you for contributing to `aeon`

I have added the following labels to this PR based on the title: **[ $\\color{#FEF1BE}{\\textsf{enhancement}}$ ]**.
I have added the following labels to this PR based on the changes made: **[ $\\color{#31FCCD}{\\textsf{forecasting}}$ ]**. Feel free to change these if they do not properly represent the PR.

The [Checks](https://github.com/aeon-toolkit/aeon/pull/2834/checks) tab will show the status of our automated tests. You can click on individual test runs in the tab or "Details" in the panel below to see more information if there is a failure.

If our `pre-commit` code quality check fails, any trivial fixes will automatically be pushed to your PR unless it is a draft.

Don't hesitate to ask questions on the `aeon` [Slack](https://join.slack.com/t/aeon-toolkit/shared_invite/zt-22vwvut29-HDpCu~7VBUozyfL_8j3dLA) channel if you have any.

### PR CI actions

These checkboxes will add labels to enable/disable CI functionality for this PR. This may not take effect immediately, and a new commit may be required to run the new configuration.

- [ ] Run `pre-commit` checks for all files
- [ ] Run `mypy` typecheck tests
- [ ] Run all `pytest` tests and configurations
- [ ] Run all notebook example tests
- [ ] Run numba-disabled `codecov` tests
- [ ] Stop automatic `pre-commit` fixes (always disabled for drafts)
- [ ] Disable numba cache loading
- [ ] Push an empty commit to re-run CI checks
    
TonyBagnall: output of the numba identical and runtime timing for main and this version

```python
def numba_compare():
    n =100
    import time
    import aeon.forecasting._ets as ets
    import aeon.forecasting._numba_ets as etsfast
    arr = np.random.rand(n)
    slow_ets = ets.ETSForecaster()
    fast_ets = etsfast.ETSForecaster()
    slow_ets.fit(arr)
    fast_ets.fit(arr)
    a1 = slow_ets.predict()
    a2 = fast_ets.predict()
    print(f" Slow = {a1} fast = {a2}")
    for n in range(100000,10000000,100000):
        arr = np.random.rand(n)

        ets_slow_time = time.time()
        slow_ets.fit(arr)
        ets_slow_time = time.time()-ets_slow_time
        ets_fast_time = time.time()
        fast_ets.fit(arr)
        ets_fast_time = time.time()-ets_fast_time
        ets_slow_pred = time.time()
        a1=slow_ets.predict()
        ets_slow_pred = time.time()-ets_slow_pred
        ets_fast_pred = time.time()
        a2=fast_ets.predict()
        ets_fast_pred = time.time()-ets_fast_pred
        equal = np.isclose(a1,a2, 4)
        print(f"{n},{ets_slow_time}, {ets_fast_time},{ets_slow_pred},,{ets_fast_pred}"
              f",{equal}")  # noqa
```
TonyBagnall: <html xmlns:v="urn:schemas-microsoft-com:vml"
xmlns:o="urn:schemas-microsoft-com:office:office"
xmlns:x="urn:schemas-microsoft-com:office:excel"
xmlns="http://www.w3.org/TR/REC-html40">

<head>

<meta name=ProgId content=Excel.Sheet>
<meta name=Generator content="Microsoft Excel 15">
<link id=Main-File rel=Main-File
href="file:///C:/Users/Tony/AppData/Local/Temp/msohtmlclip1/01/clip.htm">
<link rel=File-List
href="file:///C:/Users/Tony/AppData/Local/Temp/msohtmlclip1/01/clip_filelist.xml">
<style>
<!--table
	{mso-displayed-decimal-separator:"\.";
	mso-displayed-thousand-separator:"\,";}
@page
	{margin:.75in .7in .75in .7in;
	mso-header-margin:.3in;
	mso-footer-margin:.3in;}
tr
	{mso-height-source:auto;}
col
	{mso-width-source:auto;}
br
	{mso-data-placement:same-cell;}
td
	{padding-top:1px;
	padding-right:1px;
	padding-left:1px;
	mso-ignore:padding;
	color:black;
	font-size:11.0pt;
	font-weight:400;
	font-style:normal;
	text-decoration:none;
	font-family:"Aptos Narrow", sans-serif;
	mso-font-charset:0;
	mso-number-format:General;
	text-align:general;
	vertical-align:bottom;
	border:none;
	mso-background-source:auto;
	mso-pattern:auto;
	mso-protection:locked visible;
	white-space:nowrap;
	mso-rotate:0;}
.xl63
	{mso-number-format:Fixed;}
-->
</style>
</head>

<body link="#467886" vlink="#96607D">


2000000 | 4.12 | 0.04 | [ True]
-- | -- | -- | --
2100000 | 4.16 | 0.03 | [ True]
2200000 | 4.23 | 0.03 | [ True]
2300000 | 4.81 | 0.04 | [ True]
2400000 | 5.11 | 0.04 | [ True]
2500000 | 5.34 | 0.04 | [ True]
2600000 | 5.27 | 0.04 | [ True]
2700000 | 5.47 | 0.04 | [ True]
2800000 | 5.86 | 0.05 | [ True]
2900000 | 5.67 | 0.04 | [ True]
3000000 | 6.43 | 0.04 | [ True]
3100000 | 6.16 | 0.04 | [ True]
3200000 | 6.08 | 0.05 | [ True]
3300000 | 6.98 | 0.09 | [ True]
3400000 | 8.88 | 0.07 | [ True]
3500000 | 7.51 | 0.05 | [ True]
3600000 | 7.28 | 0.05 | [ True]
3700000 | 7.09 | 0.06 | [ True]
3800000 | 7.44 | 0.08 | [ True]
3900000 | 7.86 | 0.06 | [ True]
4000000 | 8.47 | 0.08 | [ True]
4100000 | 8.05 | 0.06 | [ True]
4200000 | 7.95 | 0.06 | [ True]
4300000 | 8.19 | 0.06 | [ True]
4400000 | 8.40 | 0.06 | [ True]
4500000 | 8.75 | 0.19 | [ True]



</body>

</html>

TonyBagnall: much faster than statsmodels version, as equivalent as we can make it

```python

def statsmodels_compare(setup_func, random_seed, catch_errors):
    """Run both our statsforecast and our implementation and crosschecks."""
    import warnings
    warnings.filterwarnings("ignore")
    random.seed(random_seed)
    (
        y,
        m,
        error,
        trendtype,
        seasontype,
        alpha,
        beta,
        gamma,
        phi,
    ) = setup_func()
    # tsml-eval implementation
    start = time.perf_counter()
    f1 = etsfast.ETSForecaster(
        error,
        trendtype,
        seasontype,
        m,
        alpha,
        beta,
        gamma,
        phi,
        1,
    )
    for n in range(10000,1000000,10000):
        y = np.random.rand(n)
        start = time.perf_counter()
        f1 = etsfast.ETSForecaster(
            error,
            trendtype,
            seasontype,
            m,
            alpha,
            beta,
            gamma,
            phi,
            1,
        )
        f1.fit(y)
        aeon_time = time.perf_counter()-start
        from statsmodels.tsa.holtwinters import ExponentialSmoothing
        start = time.perf_counter()
        ets_model = ExponentialSmoothing(
            y[m:],
            trend="add" if trendtype == 1 else "mul" if trendtype == 2 else None,
            damped_trend=(phi != 1 and trendtype != 0),
            seasonal="add" if seasontype == 1 else "mul" if seasontype == 2 else None,
            seasonal_periods=m if seasontype != 0 else None,
            initialization_method="known",
            initial_level=f1.level_,
            initial_trend=f1.trend_ if trendtype != 0 else None,
            initial_seasonal=f1.seasonality_ if seasontype != 0 else None,
        )
        results = ets_model.fit(
            smoothing_level=alpha,
            smoothing_trend=(
                beta / alpha if trendtype != 0 else None
            ),  # statsmodels uses beta*=beta/alpha
            smoothing_seasonal=gamma if seasontype != 0 else None,
            damping_trend=phi if trendtype != 0 else None,
            optimized=False,
        )
        sm_time = time.perf_counter()-start
        print(f"{n},{aeon_time}, {sm_time:0.20f}")  # noqa
 ```


TonyBagnall: length, aeon, statsmodels
<html xmlns:v="urn:schemas-microsoft-com:vml"
xmlns:o="urn:schemas-microsoft-com:office:office"
xmlns:x="urn:schemas-microsoft-com:office:excel"
xmlns="http://www.w3.org/TR/REC-html40">

<head>

<meta name=ProgId content=Excel.Sheet>
<meta name=Generator content="Microsoft Excel 15">
<link id=Main-File rel=Main-File
href="file:///C:/Users/Tony/AppData/Local/Temp/msohtmlclip1/01/clip.htm">
<link rel=File-List
href="file:///C:/Users/Tony/AppData/Local/Temp/msohtmlclip1/01/clip_filelist.xml">
<style>
<!--table
	{mso-displayed-decimal-separator:"\.";
	mso-displayed-thousand-separator:"\,";}
@page
	{margin:.75in .7in .75in .7in;
	mso-header-margin:.3in;
	mso-footer-margin:.3in;}
tr
	{mso-height-source:auto;}
col
	{mso-width-source:auto;}
br
	{mso-data-placement:same-cell;}
td
	{padding-top:1px;
	padding-right:1px;
	padding-left:1px;
	mso-ignore:padding;
	color:black;
	font-size:11.0pt;
	font-weight:400;
	font-style:normal;
	text-decoration:none;
	font-family:"Aptos Narrow", sans-serif;
	mso-font-charset:0;
	mso-number-format:General;
	text-align:general;
	vertical-align:bottom;
	border:none;
	mso-background-source:auto;
	mso-pattern:auto;
	mso-protection:locked visible;
	white-space:nowrap;
	mso-rotate:0;}
-->
</style>
</head>

<body link="#467886" vlink="#96607D">


700000 | 0.011834 | 3.057566
-- | -- | --
710000 | 0.012088 | 3.139149
720000 | 0.013966 | 3.217208
730000 | 0.012504 | 3.63404
740000 | 0.015816 | 3.24126
750000 | 0.013041 | 3.542698
760000 | 0.013758 | 3.303388
770000 | 0.013628 | 3.537901
780000 | 0.013103 | 3.474043
790000 | 0.013888 | 3.391304
800000 | 0.01361 | 3.626867
810000 | 0.013806 | 3.450841
820000 | 0.013852 | 3.513971
830000 | 0.017863 | 3.660681
840000 | 0.014946 | 3.756113
850000 | 0.015857 | 3.80876
860000 | 0.022167 | 3.932306



</body>

</html>

TonyBagnall: it cannot be compared to the statsforecast model, because you cannot set alpha, beta, you can only auto tune them
TonyBagnall: I have changed it so that you can input either strings or ints for error, trend and seasonality, these are now validated in fit and converted to ints for numba efficiency. Added a test of this conversion

Patch:
diff --git a/.github/workflows/weekly_github_maintenance.yml b/.github/workflows/weekly_github_maintenance.yml
index 9de93ccb..a854f8e7 100644
--- a/.github/workflows/weekly_github_maintenance.yml
+++ b/.github/workflows/weekly_github_maintenance.yml
@@ -91,7 +91,7 @@ jobs:
           persist-credentials: false
 
       - name: Run analysis
-        uses: ossf/scorecard-action@v2.4.1
+        uses: ossf/scorecard-action@v2.4.2
         with:
           results_file: results.sarif
           results_format: sarif
@@ -121,7 +121,7 @@ jobs:
           private-key: ${{ secrets.PR_APP_KEY }}
 
       - name: Stale Branches
-        uses: crs-k/stale-branches@v8.1.1
+        uses: crs-k/stale-branches@v8.2.0
         with:
           repo-token: ${{ steps.app-token.outputs.token }}
           days-before-stale: 180
diff --git a/.pre-commit-config.yaml b/.pre-commit-config.yaml
index 2dece2f2..5bfaa562 100644
--- a/.pre-commit-config.yaml
+++ b/.pre-commit-config.yaml
@@ -29,7 +29,7 @@ repos:
         args: [ "--create", "--python-folders", "aeon" ]
 
   - repo: https://github.com/astral-sh/ruff-pre-commit
-    rev: v0.11.11
+    rev: v0.11.12
     hooks:
       - id: ruff
         args: [ "--fix"]
diff --git a/aeon/forecasting/_ets.py b/aeon/forecasting/_ets.py
index efc99d6d..2899e0d7 100644
--- a/aeon/forecasting/_ets.py
+++ b/aeon/forecasting/_ets.py
@@ -1,91 +1,102 @@
 """ETSForecaster class.
 
 An implementation of the exponential smoothing statistics forecasting algorithm.
-Implements additive and multiplicative error models,
-None, additive and multiplicative (including damped) trend and
-None, additive and multiplicative seasonality
+Implements additive and multiplicative error models.
 """
 
 __maintainer__ = []
-__all__ = ["ETSForecaster", "NONE", "ADDITIVE", "MULTIPLICATIVE"]
+__all__ = ["ETSForecaster"]
+
+from typing import Union
 
 import numpy as np
 from numba import njit
 
 from aeon.forecasting.base import BaseForecaster
 
-NOGIL = False
-CACHE = True
-
-NONE = 0
-ADDITIVE = 1
-MULTIPLICATIVE = 2
+ADDITIVE = "additive"
+MULTIPLICATIVE = "multiplicative"
 
 
 class ETSForecaster(BaseForecaster):
-    """Exponential Smoothing forecaster.
+    """Exponential Smoothing (ETS) forecaster.
 
-    An implementation of the exponential smoothing forecasting algorithm.
-    Implements additive and multiplicative error models, None, additive and
-    multiplicative (including damped) trend and None, additive and mutliplicative
-    seasonality. See [1]_ for a description.
+    Implements the ETS (Error, Trend, Seasonality) forecaster, supporting additive
+    and multiplicative forms of error, trend (including damped), and seasonality
+    components. Based on the state space model formulation of exponential
+    smoothing as described in Hyndman and Athanasopoulos [1]_.
 
     Parameters
     ----------
-    error_type : int, default = 1
-        Either NONE (0), ADDITIVE (1) or MULTIPLICATIVE (2).
-    trend_type : int, default = 0
-        Either NONE (0), ADDITIVE (1) or MULTIPLICATIVE (2).
-    seasonality_type : int, default = 0
-        Either NONE (0), ADDITIVE (1) or MULTIPLICATIVE (2).
+    error_type : string or int, default=1
+        Type of error model: 'additive' (0) or 'multiplicative' (1)
+    trend_type : string, int or None, default=0
+        Type of trend component: None (0), `additive' (1) or 'multiplicative' (2)
+    seasonality_type : string or None, default=0
+        Type of seasonal component: None (0), `additive' (1) or 'multiplicative' (2)
     seasonal_period : int, default=1
-        Length of seasonality period. If seasonality_type is NONE, this is assumed to
-        be 1
-    alpha : float, default = 0.1
+        Number of time points in a seasonal cycle.
+    alpha : float, default=0.1
         Level smoothing parameter.
-    beta : float, default = 0.01
-        Trend smoothing parameter. If trend_type is NONE, this is assumed to be 0.0.
-    gamma : float, default = 0.01
-        Seasonal smoothing parameter. If seasonality is NONE, this is assumed to be
-        0.0.
-    phi : float, default = 0.99
-        Trend damping smoothing parameters
-    horizon : int, default = 1
-        The horizon to forecast to.
+    beta : float, default=0.01
+        Trend smoothing parameter.
+    gamma : float, default=0.01
+        Seasonal smoothing parameter.
+    phi : float, default=0.99
+        Trend damping parameter (used only for damped trend models).
+    horizon : int, default=1
+        Forecasting horizon (number of time steps ahead to predict).
 
     Attributes
     ----------
-    mean_sq_err_ : float
-        Mean squared error.
-    likelihood_ : float
-        Likelihood of the fitted model based on residuals.
-    residuals_ : arraylike
-        List of train set differences between fitted and actual values.
-    n_timpoints_ : int
-        Length of the series passed to fit.
+    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,
-        Forecasting: Principles and Practice. Melbourne, Australia: OTexts, 2014.
+       Forecasting: Principles and Practice, 2nd Edition. OTexts, 2014.
+       https://otexts.com/fpp3/
 
     Examples
     --------
     >>> from aeon.forecasting import ETSForecaster
     >>> from aeon.datasets import load_airline
     >>> y = load_airline()
-    >>> forecaster = ETSForecaster(alpha=0.4, beta=0.2, gamma=0.5, phi=0.8, horizon=1)
+    >>> forecaster = ETSForecaster(
+    ...     alpha=0.4, beta=0.2, gamma=0.5, phi=0.8, horizon=1,
+    ...     error_type='additive', trend_type='multiplicative',
+    ...     seasonality_type='multiplicative', seasonal_period=4
+    ... )
     >>> forecaster.fit(y)
-    ETSForecaster(alpha=0.4, beta=0.2, gamma=0.5, phi=0.8)
+    ETSForecaster(...)
     >>> forecaster.predict()
-    449.9435566831507
+    366.90200486015596
     """
 
     def __init__(
         self,
-        error_type: int = ADDITIVE,
-        trend_type: int = NONE,
-        seasonality_type: int = NONE,
+        error_type: Union[int, str] = 1,
+        trend_type: Union[int, str, None] = 0,
+        seasonality_type: Union[int, str, None] = 0,
         seasonal_period: int = 1,
         alpha: float = 0.1,
         beta: float = 0.01,
@@ -93,18 +104,28 @@ class ETSForecaster(BaseForecaster):
         phi: float = 0.99,
         horizon: int = 1,
     ):
-        self.error_type = error_type
-        self.trend_type = trend_type
-        self.seasonality_type = seasonality_type
-        self.seasonal_period = seasonal_period
         self.alpha = alpha
         self.beta = beta
         self.gamma = gamma
         self.phi = phi
-        self.mean_sq_err_ = 0
-        self.likelihood_ = 0
+        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.n_timpoints_ = 0
+        self.fitted_values_ = []
         super().__init__(horizon=horizon, axis=1)
 
     def _fit(self, y, exog=None):
@@ -122,33 +143,51 @@ class ETSForecaster(BaseForecaster):
         Returns
         -------
         self
-            Fitted BaseForecaster.
+            Fitted ETSForecaster.
         """
-        self.n_timepoints_ = len(y)
-        if self.error_type != MULTIPLICATIVE and self.error_type != ADDITIVE:
-            raise ValueError("Error must be either additive or multiplicative")
-        self._seasonal_period = self.seasonal_period
-        if self.seasonal_period < 1 or self.seasonality_type == NONE:
+        _validate_parameter(self.error_type, False)
+        _validate_parameter(self.seasonality_type, True)
+        _validate_parameter(self.trend_type, True)
+
+        # Convert to string parameters to ints for numba efficiency
+        def _get_int(x):
+            if x is None:
+                return 0
+            if x == ADDITIVE:
+                return 1
+            if x == MULTIPLICATIVE:
+                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
-        self._beta = self.beta
-        if self.trend_type == NONE or self.trend_type is None:
+
+        if self._trend_type == 0:
+            # Required for the equations in _update_states to work correctly
             self._beta = 0
-        self._gamma = self.gamma
-        if self.seasonality_type == NONE or self.trend_type is None:
+        if self._seasonality_type == 0:
+            # Required for the equations in _update_states to work correctly
             self._gamma = 0
-        data = np.array(y.squeeze(), dtype=np.float64)
+        data = y.squeeze()
         (
-            self._level,
-            self._trend,
-            self._seasonality,
+            self.level_,
+            self.trend_,
+            self.seasonality_,
+            self.n_timepoints_,
             self.residuals_,
-            self.mean_sq_err_,
-            self.likelihood_,
-        ) = _fit_numba(
+            self.fitted_values_,
+            self.avg_mean_sq_err_,
+            self.liklihood_,
+            self.k_,
+            self.aic_,
+        ) = _numba_fit(
             data,
-            self.error_type,
-            self.trend_type,
-            self.seasonality_type,
+            self._error_type,
+            self._trend_type,
+            self._seasonality_type,
             self._seasonal_period,
             self.alpha,
             self._beta,
@@ -166,7 +205,7 @@ class ETSForecaster(BaseForecaster):
         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
+        exog : np.ndarray, default =None
             Optional exogenous time series data assumed to be aligned with y
 
         Returns
@@ -174,40 +213,60 @@ class ETSForecaster(BaseForecaster):
         float
             single prediction self.horizon steps ahead of y.
         """
-        return _predict_numba(
-            self.trend_type,
-            self.seasonality_type,
-            self._level,
-            self._trend,
-            self._seasonality,
+        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,
+            self._seasonal_period,
+        )
+        return fitted_value
+
+    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
         )
 
 
-@njit(nogil=NOGIL, cache=CACHE)
-def _fit_numba(
+@njit(fastmath=True, cache=True)
+def _numba_fit(
     data,
-    error_type: int,
-    trend_type: int,
-    seasonality_type: int,
-    seasonal_period: int,
-    alpha: float,
-    beta: float,
-    gamma: float,
-    phi: float,
+    error_type,
+    trend_type,
+    seasonality_type,
+    seasonal_period,
+    alpha,
+    beta,
+    gamma,
+    phi,
 ):
-    n_timepoints = len(data)
+    n_timepoints = len(data) - seasonal_period
     level, trend, seasonality = _initialise(
         trend_type, seasonality_type, seasonal_period, data
     )
-    mse = 0
-    lhood = 0
-    mul_likelihood_pt2 = 0
-    res = np.zeros(n_timepoints)  # 1 Less residual than data points
-    for t, data_item in enumerate(data[seasonal_period:]):
+    avg_mean_sq_err_ = 0
+    liklihood_ = 0
+    residuals_ = np.zeros(n_timepoints)  # 1 Less residual than data points
+    fitted_values_ = np.zeros(n_timepoints)
+    for t in range(n_timepoints):
+        index = t + seasonal_period
+        s_index = t % seasonal_period
+
+        time_point = data[index]
+
         # Calculate level, trend, and seasonal components
         fitted_value, error, level, trend, seasonality[t % seasonal_period] = (
             _update_states(
@@ -216,39 +275,59 @@ def _fit_numba(
                 seasonality_type,
                 level,
                 trend,
-                seasonality[t % seasonal_period],
-                data_item,
+                seasonality[s_index],
+                time_point,
                 alpha,
                 beta,
                 gamma,
                 phi,
             )
         )
-        res[t] = error
-        mse += (data_item - fitted_value) ** 2
-        lhood += error * error
-        mul_likelihood_pt2 += np.log(np.fabs(fitted_value))
-    mse /= n_timepoints - seasonal_period
-    lhood = (n_timepoints - seasonal_period) * np.log(lhood)
-    if error_type == MULTIPLICATIVE:
-        lhood += 2 * mul_likelihood_pt2
-    return level, trend, seasonality, res, mse, lhood
-
-
-def _predict_numba(
-    trend_type: int,
-    seasonality_type: int,
-    level: float,
-    trend: float,
-    seasonality: float,
-    phi: float,
-    horizon: int,
-    n_timepoints: int,
-    seasonal_period: int,
+        residuals_[t] = error
+        fitted_values_[t] = fitted_value
+        avg_mean_sq_err_ += (time_point - fitted_value) ** 2
+        liklihood_error = error
+        if error_type == 2:  # Multiplicative
+            liklihood_error *= fitted_value
+        liklihood_ += liklihood_error**2
+    avg_mean_sq_err_ /= n_timepoints
+    liklihood_ = n_timepoints * np.log(liklihood_)
+    k_ = (
+        seasonal_period * (seasonality_type != 0)
+        + 2 * (trend_type != 0)
+        + 2
+        + 1 * (phi != 1)
+    )
+    aic_ = liklihood_ + 2 * k_ - n_timepoints * np.log(n_timepoints)
+    return (
+        level,
+        trend,
+        seasonality,
+        n_timepoints,
+        residuals_,
+        fitted_values_,
+        avg_mean_sq_err_,
+        liklihood_,
+        k_,
+        aic_,
+    )
+
+
+@njit(fastmath=True, cache=True)
+def _predict(
+    trend_type,
+    seasonality_type,
+    level,
+    trend,
+    seasonality,
+    phi,
+    horizon,
+    n_timepoints,
+    seasonal_period,
 ):
     # Generate forecasts based on the final values of level, trend, and seasonals
     if phi == 1:  # No damping case
-        phi_h = float(horizon)
+        phi_h = 1
     else:
         # Geometric series formula for calculating phi + phi^2 + ... + phi^h
         phi_h = phi * (1 - phi**horizon) / (1 - phi)
@@ -263,8 +342,8 @@ def _predict_numba(
     )[0]
 
 
-@njit(nogil=NOGIL, cache=CACHE)
-def _initialise(trend_type: int, seasonality_type: int, seasonal_period: int, data):
+@njit(fastmath=True, cache=True)
+def _initialise(trend_type, seasonality_type, seasonal_period, data):
     """
     Initialize level, trend, and seasonality values for the ETS model.
 
@@ -277,12 +356,12 @@ def _initialise(trend_type: int, seasonality_type: int, seasonal_period: int, da
     # Initial Level: Mean of the first season
     level = np.mean(data[:seasonal_period])
     # Initial Trend
-    if trend_type == ADDITIVE:
+    if trend_type == 1:
         # Average difference between corresponding points in the first two seasons
         trend = np.mean(
             data[seasonal_period : 2 * seasonal_period] - data[:seasonal_period]
         )
-    elif trend_type == MULTIPLICATIVE:
+    elif trend_type == 2:
         # Average ratio between corresponding points in the first two seasons
         trend = np.mean(
             data[seasonal_period : 2 * seasonal_period] / data[:seasonal_period]
@@ -291,33 +370,33 @@ def _initialise(trend_type: int, seasonality_type: int, seasonal_period: int, da
         # No trend
         trend = 0
     # Initial Seasonality
-    if seasonality_type == ADDITIVE:
+    if seasonality_type == 1:
         # Seasonal component is the difference
         # from the initial level for each point in the first season
         seasonality = data[:seasonal_period] - level
-    elif seasonality_type == MULTIPLICATIVE:
+    elif seasonality_type == 2:
         # Seasonal component is the ratio of each point in the first season
         # to the initial level
         seasonality = data[:seasonal_period] / level
     else:
         # No seasonality
-        seasonality = np.zeros(1)
+        seasonality = np.zeros(1, dtype=np.float64)
     return level, trend, seasonality
 
 
-@njit(nogil=NOGIL, cache=CACHE)
+@njit(fastmath=True, cache=True)
 def _update_states(
-    error_type: int,
-    trend_type: int,
-    seasonality_type: int,
-    level: float,
-    trend: float,
-    seasonality: float,
+    error_type,
+    trend_type,
+    seasonality_type,
+    level,
+    trend,
+    seasonality,
     data_item: int,
-    alpha: float,
-    beta: float,
-    gamma: float,
-    phi: float,
+    alpha,
+    beta,
+    gamma,
+    phi,
 ):
     """
     Update level, trend, and seasonality components.
@@ -338,34 +417,34 @@ def _update_states(
         trend_type, seasonality_type, level, trend, seasonality, phi
     )
     # Calculate the error term (observed value - fitted value)
-    if error_type == MULTIPLICATIVE:
+    if error_type == 2:
         error = data_item / fitted_value - 1  # Multiplicative error
     else:
         error = data_item - fitted_value  # Additive error
     # Update level
-    if error_type == MULTIPLICATIVE:
+    if error_type == 2:
         level = trend_level_combination * (1 + alpha * error)
         trend = damped_trend * (1 + beta * error)
         seasonality = curr_seasonality * (1 + gamma * error)
-        if seasonality_type == ADDITIVE:
+        if seasonality_type == 1:
             level += alpha * error * curr_seasonality  # Add seasonality correction
             seasonality += gamma * error * trend_level_combination
-            if trend_type == ADDITIVE:
+            if trend_type == 1:
                 trend += (curr_level + curr_seasonality) * beta * error
             else:
                 trend += curr_seasonality / curr_level * beta * error
-        elif trend_type == ADDITIVE:
+        elif trend_type == 1:
             trend += curr_level * beta * error
     else:
         level_correction = 1
         trend_correction = 1
         seasonality_correction = 1
-        if seasonality_type == MULTIPLICATIVE:
+        if seasonality_type == 2:
             # Add seasonality correction
             level_correction *= curr_seasonality
             trend_correction *= curr_seasonality
             seasonality_correction *= trend_level_combination
-        if trend_type == MULTIPLICATIVE:
+        if trend_type == 2:
             trend_correction *= curr_level
         level = trend_level_combination + alpha * error / level_correction
         trend = damped_trend + beta * error / trend_correction
@@ -373,15 +452,8 @@ def _update_states(
     return (fitted_value, error, level, trend, seasonality)
 
 
-@njit(nogil=NOGIL, cache=CACHE)
-def _predict_value(
-    trend_type: int,
-    seasonality_type: int,
-    level: float,
-    trend: float,
-    seasonality: float,
-    phi: float,
-):
+@njit(fastmath=True, cache=True)
+def _predict_value(trend_type, seasonality_type, level, trend, seasonality, phi):
     """
 
     Generate various useful values, including the next fitted value.
@@ -408,7 +480,7 @@ def _predict_value(
     """
     # Apply damping parameter and
     # calculate commonly used combination of trend and level components
-    if trend_type == MULTIPLICATIVE:
+    if trend_type == 2:  # Multiplicative
         damped_trend = trend**phi
         trend_level_combination = level * damped_trend
     else:  # Additive trend, if no trend, then trend = 0
@@ -416,8 +488,30 @@ def _predict_value(
         trend_level_combination = level + damped_trend
 
     # Calculate forecast (fitted value) based on the current components
-    if seasonality_type == MULTIPLICATIVE:
+    if seasonality_type == 2:  # Multiplicative
         fitted_value = trend_level_combination * seasonality
     else:  # Additive seasonality, if no seasonality, then seasonality = 0
         fitted_value = trend_level_combination + seasonality
     return fitted_value, damped_trend, trend_level_combination
+
+
+def _validate_parameter(var, can_be_none):
+    valid_str = (ADDITIVE, MULTIPLICATIVE)
+    valid_int = (1, 2)
+    if can_be_none:
+        valid_str = (None, ADDITIVE, MULTIPLICATIVE)
+        valid_int = (0, 1, 2)
+    valid = True
+    if isinstance(var, str) or var is None:
+        if var not in valid_str:
+            valid = False
+    elif isinstance(var, int):
+        if var not in valid_int:
+            valid = False
+    else:
+        valid = False
+    if not valid:
+        raise ValueError(
+            f"variable must be either string or integer with values"
+            f" {valid_str} or {valid_int} but saw {var}"
+        )


Test patch:
diff --git a/aeon/forecasting/tests/test_ets.py b/aeon/forecasting/tests/test_ets.py
index ce7513a9..f4f5f865 100644
--- a/aeon/forecasting/tests/test_ets.py
+++ b/aeon/forecasting/tests/test_ets.py
@@ -1,27 +1,104 @@
-"""Test ETS forecaster."""
+"""Test ETS."""
 
+__maintainer__ = []
+__all__ = []
+
+import numpy as np
 import pytest
 
 from aeon.forecasting import ETSForecaster
-from aeon.testing.data_generation import make_example_1d_numpy
-
-
-def test_ets_params():
-    """Test ETS forecaster."""
-    y = make_example_1d_numpy(n_timepoints=100)
-    forecaster = ETSForecaster(error_type=3)
-    with pytest.raises(
-        ValueError, match="Error must be either additive or " "multiplicative"
-    ):
-        forecaster.fit(y)
-    forecaster = ETSForecaster(seasonality_type=-3)
-    forecaster.fit(y)
-    assert forecaster._seasonal_period == 1
-    forecaster = ETSForecaster(trend_type=None, seasonality_type=0, beta=1.0, gamma=1.0)
-    forecaster.fit(y)
-    assert forecaster._beta == 0
-    assert forecaster._gamma == 0
-
-    forecaster = ETSForecaster(error_type=2, phi=1.0)
-    pred = forecaster.forecast(y)
-    assert isinstance(pred, float)
+from aeon.forecasting._ets import _validate_parameter
+
+
+def test_ets_forecaster_additive():
+    """TestETSForecaster."""
+    data = np.array(
+        [3, 10, 12, 13, 12, 10, 12, 3, 10, 12, 13, 12, 10, 12]
+    )  # Sample seasonal data
+    forecaster = ETSForecaster(
+        alpha=0.5,
+        beta=0.3,
+        gamma=0.4,
+        phi=1,
+        horizon=1,
+        error_type="additive",
+        trend_type="additive",
+        seasonality_type="additive",
+        seasonal_period=4,
+    )
+    forecaster.fit(data)
+    p = forecaster.predict()
+    assert np.isclose(p, 9.191190608800001)
+
+
+def test_ets_forecaster_mult_error():
+    """TestETSForecaster."""
+    data = np.array(
+        [3, 10, 12, 13, 12, 10, 12, 3, 10, 12, 13, 12, 10, 12]
+    )  # Sample seasonal data
+    forecaster = ETSForecaster(
+        alpha=0.7,
+        beta=0.6,
+        gamma=0.1,
+        phi=0.97,
+        horizon=1,
+        error_type="multiplicative",
+        trend_type="additive",
+        seasonality_type="additive",
+        seasonal_period=4,
+    )
+    forecaster.fit(data)
+    p = forecaster.predict()
+    assert np.isclose(p, 16.20176819429869)
+
+
+def test_ets_forecaster_mult_compnents():
+    """TestETSForecaster."""
+    data = np.array(
+        [3, 10, 12, 13, 12, 10, 12, 3, 10, 12, 13, 12, 10, 12]
+    )  # Sample seasonal data
+    forecaster = ETSForecaster(
+        alpha=0.4,
+        beta=0.2,
+        gamma=0.5,
+        phi=0.8,
+        horizon=1,
+        error_type="additive",
+        trend_type="multiplicative",
+        seasonality_type="multiplicative",
+        seasonal_period=4,
+    )
+    forecaster.fit(data)
+    p = forecaster.predict()
+    assert np.isclose(p, 12.301259229712382)
+
+
+def test_ets_forecaster_multiplicative():
+    """TestETSForecaster."""
+    data = np.array(
+        [3, 10, 12, 13, 12, 10, 12, 3, 10, 12, 13, 12, 10, 12]
+    )  # Sample seasonal data
+    forecaster = ETSForecaster(
+        alpha=0.7,
+        beta=0.5,
+        gamma=0.2,
+        phi=0.85,
+        horizon=1,
+        error_type="multiplicative",
+        trend_type="multiplicative",
+        seasonality_type="multiplicative",
+        seasonal_period=4,
+    )
+    forecaster.fit(data)
+    p = forecaster.predict()
+    assert np.isclose(p, 16.811888294476528)
+
+
+def test_incorrect_parameters():
+    _validate_parameter(0, True)
+    _validate_parameter(None, True)
+    with pytest.raises(ValueError):
+        _validate_parameter(0, False)
+        _validate_parameter(None, True)
+        _validate_parameter(10, False)
+        _validate_parameter("Foo", True)