Skip to content
Closed
Show file tree
Hide file tree
Changes from 18 commits
Commits
Show all changes
45 commits
Select commit Hold shift + click to select a range
aa76e2b
forecaster base and dummy
TonyBagnall Oct 24, 2024
3e026e2
Merge branch 'main' into ajb/forecasting
TonyBagnall Oct 24, 2024
5c231ed
Merge branch 'main' into ajb/forecasting
TonyBagnall Oct 24, 2024
a5dbc28
forecasting tests
TonyBagnall Oct 25, 2024
7fee274
forecasting tests
TonyBagnall Oct 25, 2024
c4628ca
forecasting tests
TonyBagnall Oct 25, 2024
e154078
forecasting tests
TonyBagnall Oct 25, 2024
a6043d6
regression
TonyBagnall Oct 25, 2024
5db044b
notebook
TonyBagnall Oct 25, 2024
b99af60
Merge branch 'main' into ajb/forecasting
TonyBagnall Oct 25, 2024
c39db43
Merge branch 'main' into ajb/forecasting
TonyBagnall Oct 30, 2024
12dc180
Merge branch 'main' into ajb/forecasting
TonyBagnall Oct 31, 2024
7d932a0
regressor
TonyBagnall Oct 31, 2024
b89f165
regressor
TonyBagnall Oct 31, 2024
72fe631
regressor
TonyBagnall Oct 31, 2024
80f8bd5
tags
TonyBagnall Oct 31, 2024
f4f828f
tags
TonyBagnall Oct 31, 2024
aeb8bbb
requires_y
TonyBagnall Oct 31, 2024
b1e1f32
Merge branch 'main' into ajb/forecasting
TonyBagnall Nov 1, 2024
f820984
forecasting notebook
TonyBagnall Nov 1, 2024
5f37757
forecasting notebook
TonyBagnall Nov 1, 2024
560f664
remove tags
TonyBagnall Nov 2, 2024
8b7555b
Merge branch 'main' into ajb/forecasting
TonyBagnall Nov 4, 2024
765eacd
Merge remote-tracking branch 'origin/main' into ajb/forecasting
MatthewMiddlehurst Nov 4, 2024
413bd28
Merge remote-tracking branch 'origin/main' into ajb/forecasting
MatthewMiddlehurst Nov 4, 2024
26aec53
fix forecasting testing (they still fail though)
MatthewMiddlehurst Nov 5, 2024
da1c970
Merge remote-tracking branch 'origin/ajb/forecasting' into ajb/foreca…
MatthewMiddlehurst Nov 5, 2024
47f0559
Merge branch 'main' into ajb/forecasting
TonyBagnall Nov 5, 2024
ea385a5
Merge remote-tracking branch 'origin/ajb/forecasting' into ajb/foreca…
MatthewMiddlehurst Nov 5, 2024
54743a0
_is_fitted -> is_fitted
TonyBagnall Nov 5, 2024
b9bba0f
_is_fitted -> is_fitted
TonyBagnall Nov 5, 2024
9f8bed6
_forecast
TonyBagnall Nov 5, 2024
63d22ff
notebook
TonyBagnall Nov 5, 2024
107cab7
is_fitted
TonyBagnall Nov 5, 2024
4474593
y_fitted
TonyBagnall Nov 6, 2024
2d28b3a
ETS forecaster
TonyBagnall Nov 6, 2024
87c5969
add y checks and conversion
TonyBagnall Nov 6, 2024
afde1f5
add tag
TonyBagnall Nov 6, 2024
5bf2828
tidy
TonyBagnall Nov 7, 2024
c292f5d
_check_is_fitted()
TonyBagnall Nov 7, 2024
608558d
_check_is_fitted()
TonyBagnall Nov 7, 2024
5f0a3f5
Add fully functional ETS Forecaster. Modify base to not set default y…
alexbanwell1 Nov 8, 2024
d78e695
Merge remote-tracking branch 'origin/main' into ajb/forecasting
MatthewMiddlehurst Nov 13, 2024
a8c00e2
Ajb/forecasting (#2357)
alexbanwell1 Nov 15, 2024
2f891cf
Merge branch 'main' into ajb/forecasting
TonyBagnall Nov 19, 2024
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
7 changes: 7 additions & 0 deletions aeon/forecasting/__init__.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,7 @@
"""Forecasters."""

__all__ = ["DummyForecaster", "BaseForecaster", "RegressionForecaster"]

from aeon.forecasting._dummy import DummyForecaster
from aeon.forecasting._regression import RegressionForecaster
from aeon.forecasting.base import BaseForecaster
27 changes: 27 additions & 0 deletions aeon/forecasting/_dummy.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,27 @@
"""DummyForecaster always predicts the last value seen in training."""

from aeon.forecasting.base import BaseForecaster


class DummyForecaster(BaseForecaster):
"""Dummy forecaster always predicts the last value seen in training."""

def __init__(self):
"""Initialize DummyForecaster."""
self.last_value_ = None
super().__init__()

def _fit(self, y, exog=None):
"""Fit dummy forecaster."""
y = y.squeeze()
self.last_value_ = y[-1]
return self

def _predict(self, y=None, exog=None):
"""Predict using dummy forecaster."""
return self.last_value_

def _forecast(self, y, exog=None):
"""Forecast using dummy forecaster."""
y = y.squeeze()
return y[-1]
104 changes: 104 additions & 0 deletions aeon/forecasting/_regression.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,104 @@
"""Window-based regression forecaster.

General purpose forecaster to use with any scikit learn or aeon compatible
regressor. Simply forms a collection of windows from the time series and trains to
predict the next
"""

import numpy as np
from sklearn.linear_model import LinearRegression

from aeon.forecasting.base import BaseForecaster


class RegressionForecaster(BaseForecaster):
"""
Regression based forecasting.

Container for forecaster that reduces forecasting to regression through a
window. Form a collection of sub series of length `window` through a sliding
winodw to form X, take `horizon` points ahead to form `y`, then apply an aeon or
sklearn regressor.


Parameters
----------
window : int
The window prior to the current time point to use in forecasting. So if
horizon is one, forecaster will train using points $i$ to $window+i-1$ to
predict value $window+i$. If horizon is 4, forecaster will used points $i$
to $window+i-1$ to predict value $window+i+3$. If None, the algorithm will
internally determine what data to use to predict `horizon` steps ahead.
horizon : int, default =1
The number of time steps ahead to forecast. If horizon is one, the forecaster
will learn to predict one point ahead
regressor : object, default =None
Regression estimator that implements BaseRegressor or is otherwise compatible
with sklearn regressors.
"""

def __init__(self, window, horizon=1, regressor=None):
self.regressor = regressor
super().__init__(horizon, axis=1)

def _fit(self, y, exog=None):
"""Fit forecaster to time series.

Split X into windows of length window and train the forecaster on each window
to predict the horizon ahead.

Parameters
----------
X : Time series on which to learn a forecaster

Returns
-------
self
Fitted estimator
"""
# Window data
if self.regressor is None:
self.regressor_ = LinearRegression()
else:
self.regressor_ = self.regressor
y = y.squeeze()
X = np.lib.stride_tricks.sliding_window_view(y, window_shape=self.window)
# Ignore the final horizon values: need to store these for pred with empty y
X = X[: -self.horizon]
# Extract y
y = y[self.window + self.horizon - 1 :]
self.last_ = y[-self.window :]
self.last_ = self.last_.reshape(1, -1)
self.regressor_.fit(X=X, y=y)
return self

def _predict(self, y=None, exog=None):
"""Predict values for time series X."""
if y is None:
return self.regressor_.predict(self.last_)
last = y[:, -self.window :]
return self.regressor_.predict(last)

def _forecast(self, y, exog=None):
"""Forecast values for time series X.

NOTE: deal with horizons
"""
self.fit(y, exog)
return self.predict(y)

@classmethod
def _get_test_params(cls, parameter_set="default"):
"""Return testing parameter settings for the estimator.

Parameters
----------
parameter_set : str, default='default'
Name of the parameter set to return.

Returns
-------
dict
Dictionary of testing parameter settings.
"""
return {"window": 4}
118 changes: 118 additions & 0 deletions aeon/forecasting/base.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,118 @@
"""BaseForecaster class.

A simplified first base class for foreacasting models. The focus here is on a
specific form of forecasting: longer series, long winodws and single step forecasting.

aeon enhancement proposal
https://github.com/aeon-toolkit/aeon-admin/pull/14

"""

from abc import abstractmethod

from aeon.base import BaseSeriesEstimator


class BaseForecaster(BaseSeriesEstimator):
"""
Abstract base class for time series forecasters.

The base forecaster specifies the methods and method signatures that all
forecasters have to implement. Attributes with an underscore suffix are set in the
method fit.

Parameters
----------
horizon : int, default =1
The number of time steps ahead to forecast. If horizon is one, the forecaster
will learn to predict one point ahead
"""

_tags = {
"capability:univariate": True,
"capability:multivariate": False,
"capability:missing_values": False,
"y_inner_type": "np.ndarray",
"fit_is_empty": False,
"requires_y": True,
}

def __init__(self, horizon=1, axis=1):
self.horizon = horizon
self._is_fitted = False
Comment thread
TonyBagnall marked this conversation as resolved.
Outdated
super().__init__(axis)

def fit(self, y, exog=None):
"""Fit forecaster to series y.

Fit a forecaster to predict self.horizon steps ahead using y.

Parameters
----------
y : np.ndarray
A time series on which to learn a forecaster to predict horizon ahead
exog : np.ndarray, default =None
Optional exogenous time series data assumed to be aligned with y

Returns
-------
self
Fitted BaseForecaster.
"""
# Validate y

# Convert if necessary
y = self._preprocess_series(y, axis=self.axis, store_metadata=False)
if exog is not None:
raise NotImplementedError("Exogenous variables not yet supported")
# Validate exog
self._is_fitted = True
return self._fit(y, exog)

@abstractmethod
def _fit(self, y, exog=None): ...

def predict(self, y=None, 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.
"""
if y is not None:
y = self._preprocess_series(y, axis=self.axis, store_metadata=False)
if not self._is_fitted:
raise ValueError("Forecaster must be fitted before predicting")
if exog is not None:
raise NotImplementedError("Exogenous variables not yet supported")
# Validate exog
self._is_fitted = True
return self._predict(y, exog)

@abstractmethod
def _predict(self, y=None, exog=None): ...

def forecast(self, y, X=None):
"""

Forecast basically fit_predict.

Returns
-------
np.ndarray
single prediction directly after the last point in X.
"""
y = self._preprocess_series(y, axis=self.axis, store_metadata=False)
return self._forecast(y, X)

@abstractmethod
def _forecast(self, y=None, exog=None): ...
1 change: 1 addition & 0 deletions aeon/forecasting/tests/__init__.py
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
"""Forecaster tests."""
16 changes: 16 additions & 0 deletions aeon/forecasting/tests/test_base.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,16 @@
"""Test base forecaster."""

import numpy as np

from aeon.forecasting import DummyForecaster


def test_base_forecaster():
"""Test base forecaster functionality."""
f = DummyForecaster()
y = np.random.rand(50)
f.fit(y)
p1 = f.predict()
assert p1 == y[-1]
p2 = f.forecast(y)
assert p2 == p1
52 changes: 52 additions & 0 deletions aeon/testing/estimator_checking/_yield_forecasting_checks.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,52 @@
"""Tests for all forecasters."""

from functools import partial

import numpy as np

from aeon.base._base import _clone_estimator
from aeon.base._base_series import VALID_INNER_TYPES


def _yield_forecasting_checks(estimator_class, estimator_instances, datatypes):
"""Yield all forecasting checks for an aeon forecaster."""
# only class required
yield partial(check_forecasting_base_functionality, estimator_class=estimator_class)

# test class instances
for _, estimator in enumerate(estimator_instances):
# no data needed
yield partial(check_forecaster_instance, estimator=estimator)


def check_forecasting_base_functionality(estimator_class):
"""Test compliance with the base class contract."""
# Test they dont override final methods, because python does not enforce this
assert "fit" not in estimator_class.__dict__
assert "predict" not in estimator_class.__dict__
assert "forecast" not in estimator_class.__dict__
fit_is_empty = estimator_class.get_class_tag(tag_name="fit_is_empty")
assert not 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")
assert X_inner_type in VALID_INNER_TYPES
# Must have at least one set to True
multi = estimator_class.get_class_tag(tag_name="capability:multivariate")
uni = estimator_class.get_class_tag(tag_name="capability:univariate")
assert multi or uni


def check_forecaster_instance(estimator):
"""Test forecasters."""
estimator = _clone_estimator(estimator)
pass
# Sort
# Check output correct: predict should return a float
y = np.array([0.5, 0.7, 0.8, 0.9, 1.0])
estimator.fit(y)
p = estimator.predict()
assert isinstance(p, float)
# forecast should return a float equal to fit/predict
p2 = estimator.forecast(y)
assert p == p2
# Add other tests when
22 changes: 22 additions & 0 deletions aeon/testing/mock_estimators/_mock_forecasters.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,22 @@
"""Mock forecasters useful for testing and debugging.

Used in tests for the forecasting base class.
"""

from aeon.forecasting.base import BaseForecaster


class MockForecaster(BaseForecaster):
"""Mock segmenter for testing."""
Comment thread
TonyBagnall marked this conversation as resolved.
Outdated

def __init__(self):
super().__init__()

def _fit(self, y, X=None):
return self

def _predict(self, y):
return 1.0

def _forecast(self, y, X=None):
return 1.0
16 changes: 13 additions & 3 deletions aeon/testing/testing_data.py
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,7 @@
from aeon.classification import BaseClassifier
from aeon.classification.early_classification import BaseEarlyClassifier
from aeon.clustering import BaseClusterer
from aeon.forecasting import BaseForecaster
from aeon.regression import BaseRegressor
from aeon.segmentation import BaseSegmenter
from aeon.similarity_search import BaseSimilaritySearch
Expand Down Expand Up @@ -721,8 +722,8 @@ def _get_datatypes_for_estimator(estimator):
Returns
-------
datatypes : list of tuple
List of valid data types keys for the estimator usable in FULL_TEST_DATA_DICT
and TEST_LABEL_DICT. Each tuple is formatted (data_key, label_key).
List of valid data types keys for the estimator usable in
FULL_TEST_DATA_DICT. Each tuple is formatted (data_key, label_key).
"""
datatypes = []
univariate, multivariate, unequal_length, missing_values = (
Expand Down Expand Up @@ -813,7 +814,15 @@ def _get_label_type_for_estimator(estimator):
Returns
-------
label_type : str
Label type key for the estimator for use in TEST_LABEL_DICT.
Label type key for the estimator for use in FULL_TEST_DATA_DICT. Indicates
whether estimator can take labels for training data, and if so what kind.
Classification indicates the estimator takes a discrete target variable,
modelled by an np.array of integers.
Regression indicates the estimator takes a continuous target variable,
modelled by an np.array of floats.
NoLabel indicates the estimator does not take a target variable, or that no
estimator is yet implemented to take labels.

"""
if (
isinstance(estimator, BaseClassifier)
Expand All @@ -829,6 +838,7 @@ def _get_label_type_for_estimator(estimator):
isinstance(estimator, BaseAnomalyDetector)
or isinstance(estimator, BaseSegmenter)
or isinstance(estimator, BaseSeriesTransformer)
or isinstance(estimator, BaseForecaster)
):
label_type = "NoLabel"
else:
Expand Down
Loading