Alexander Hess
b8952213d8
- the function implements a forecasting "method" similar to the seasonal naive method => instead of simply taking the last observation given a seasonal lag, it linearly extrapolates all observations of the same seasonal lag from the past into the future; conceptually, it is like the seasonal naive method with built-in smoothing - the function is tested just like the `arima.predict()` and `ets.predict()` functions + rename the `tests.forecasts.methods.test_ts_methods` module into `tests.forecasts.methods.test_predictions` - re-organize some constants in the `tests` package - streamline some docstrings
243 lines
8 KiB
Python
243 lines
8 KiB
Python
"""Test the `stl()` function."""
|
|
|
|
import math
|
|
|
|
import pandas as pd
|
|
import pytest
|
|
|
|
from tests import config as test_config
|
|
from urban_meal_delivery.forecasts.methods import decomposition
|
|
|
|
|
|
# The "periodic" `ns` suggested for the STL method.
|
|
NS = 999
|
|
|
|
|
|
class TestInvalidArguments:
|
|
"""Test `stl()` with invalid arguments."""
|
|
|
|
def test_no_nans_in_time_series(self, vertical_datetime_index):
|
|
"""`stl()` requires a `time_series` without `NaN` values."""
|
|
time_series = pd.Series(dtype=float, index=vertical_datetime_index)
|
|
|
|
with pytest.raises(ValueError, match='`NaN` values'):
|
|
decomposition.stl(
|
|
time_series, frequency=test_config.VERTICAL_FREQUENCY_LONG, ns=NS,
|
|
)
|
|
|
|
def test_ns_not_odd(self, vertical_no_demand):
|
|
"""`ns` must be odd and `>= 7`."""
|
|
with pytest.raises(ValueError, match='`ns`'):
|
|
decomposition.stl(
|
|
vertical_no_demand, frequency=test_config.VERTICAL_FREQUENCY_LONG, ns=8,
|
|
)
|
|
|
|
@pytest.mark.parametrize('ns', [-99, -1, 1, 5])
|
|
def test_ns_smaller_than_seven(self, vertical_no_demand, ns):
|
|
"""`ns` must be odd and `>= 7`."""
|
|
with pytest.raises(ValueError, match='`ns`'):
|
|
decomposition.stl(
|
|
vertical_no_demand,
|
|
frequency=test_config.VERTICAL_FREQUENCY_LONG,
|
|
ns=ns,
|
|
)
|
|
|
|
def test_nt_not_odd(self, vertical_no_demand):
|
|
"""`nt` must be odd and `>= default_nt`."""
|
|
nt = 200
|
|
default_nt = math.ceil(
|
|
(1.5 * test_config.VERTICAL_FREQUENCY_LONG) / (1 - (1.5 / NS)),
|
|
)
|
|
|
|
assert nt > default_nt # sanity check
|
|
|
|
with pytest.raises(ValueError, match='`nt`'):
|
|
decomposition.stl(
|
|
vertical_no_demand,
|
|
frequency=test_config.VERTICAL_FREQUENCY_LONG,
|
|
ns=NS,
|
|
nt=nt,
|
|
)
|
|
|
|
@pytest.mark.parametrize('nt', [-99, -1, 0, 1, 99, 125])
|
|
def test_nt_not_at_least_the_default(self, vertical_no_demand, nt):
|
|
"""`nt` must be odd and `>= default_nt`."""
|
|
# `default_nt` becomes 161.
|
|
default_nt = math.ceil(
|
|
(1.5 * test_config.VERTICAL_FREQUENCY_LONG) / (1 - (1.5 / NS)),
|
|
)
|
|
|
|
assert nt < default_nt # sanity check
|
|
|
|
with pytest.raises(ValueError, match='`nt`'):
|
|
decomposition.stl(
|
|
vertical_no_demand,
|
|
frequency=test_config.VERTICAL_FREQUENCY_LONG,
|
|
ns=NS,
|
|
nt=nt,
|
|
)
|
|
|
|
def test_nl_not_odd(self, vertical_no_demand):
|
|
"""`nl` must be odd and `>= frequency`."""
|
|
nl = 200
|
|
|
|
assert nl > test_config.VERTICAL_FREQUENCY_LONG # sanity check
|
|
|
|
with pytest.raises(ValueError, match='`nl`'):
|
|
decomposition.stl(
|
|
vertical_no_demand,
|
|
frequency=test_config.VERTICAL_FREQUENCY_LONG,
|
|
ns=NS,
|
|
nl=nl,
|
|
)
|
|
|
|
def test_nl_at_least_the_frequency(self, vertical_no_demand):
|
|
"""`nl` must be odd and `>= frequency`."""
|
|
nl = 77
|
|
|
|
assert nl < test_config.VERTICAL_FREQUENCY_LONG # sanity check
|
|
|
|
with pytest.raises(ValueError, match='`nl`'):
|
|
decomposition.stl(
|
|
vertical_no_demand,
|
|
frequency=test_config.VERTICAL_FREQUENCY_LONG,
|
|
ns=NS,
|
|
nl=nl,
|
|
)
|
|
|
|
def test_ds_not_zero_or_one(self, vertical_no_demand):
|
|
"""`ds` must be `0` or `1`."""
|
|
with pytest.raises(ValueError, match='`ds`'):
|
|
decomposition.stl(
|
|
vertical_no_demand,
|
|
frequency=test_config.VERTICAL_FREQUENCY_LONG,
|
|
ns=NS,
|
|
ds=2,
|
|
)
|
|
|
|
def test_dt_not_zero_or_one(self, vertical_no_demand):
|
|
"""`dt` must be `0` or `1`."""
|
|
with pytest.raises(ValueError, match='`dt`'):
|
|
decomposition.stl(
|
|
vertical_no_demand,
|
|
frequency=test_config.VERTICAL_FREQUENCY_LONG,
|
|
ns=NS,
|
|
dt=2,
|
|
)
|
|
|
|
def test_dl_not_zero_or_one(self, vertical_no_demand):
|
|
"""`dl` must be `0` or `1`."""
|
|
with pytest.raises(ValueError, match='`dl`'):
|
|
decomposition.stl(
|
|
vertical_no_demand,
|
|
frequency=test_config.VERTICAL_FREQUENCY_LONG,
|
|
ns=NS,
|
|
dl=2,
|
|
)
|
|
|
|
@pytest.mark.parametrize('js', [-1, 0])
|
|
def test_js_not_positive(self, vertical_no_demand, js):
|
|
"""`js` must be positive."""
|
|
with pytest.raises(ValueError, match='`js`'):
|
|
decomposition.stl(
|
|
vertical_no_demand,
|
|
frequency=test_config.VERTICAL_FREQUENCY_LONG,
|
|
ns=NS,
|
|
js=js,
|
|
)
|
|
|
|
@pytest.mark.parametrize('jt', [-1, 0])
|
|
def test_jt_not_positive(self, vertical_no_demand, jt):
|
|
"""`jt` must be positive."""
|
|
with pytest.raises(ValueError, match='`jt`'):
|
|
decomposition.stl(
|
|
vertical_no_demand,
|
|
frequency=test_config.VERTICAL_FREQUENCY_LONG,
|
|
ns=NS,
|
|
jt=jt,
|
|
)
|
|
|
|
@pytest.mark.parametrize('jl', [-1, 0])
|
|
def test_jl_not_positive(self, vertical_no_demand, jl):
|
|
"""`jl` must be positive."""
|
|
with pytest.raises(ValueError, match='`jl`'):
|
|
decomposition.stl(
|
|
vertical_no_demand,
|
|
frequency=test_config.VERTICAL_FREQUENCY_LONG,
|
|
ns=NS,
|
|
jl=jl,
|
|
)
|
|
|
|
@pytest.mark.parametrize('ni', [-1, 0])
|
|
def test_ni_not_positive(self, vertical_no_demand, ni):
|
|
"""`ni` must be positive."""
|
|
with pytest.raises(ValueError, match='`ni`'):
|
|
decomposition.stl(
|
|
vertical_no_demand,
|
|
frequency=test_config.VERTICAL_FREQUENCY_LONG,
|
|
ns=NS,
|
|
ni=ni,
|
|
)
|
|
|
|
def test_no_not_non_negative(self, vertical_no_demand):
|
|
"""`no` must be non-negative."""
|
|
with pytest.raises(ValueError, match='`no`'):
|
|
decomposition.stl(
|
|
vertical_no_demand,
|
|
frequency=test_config.VERTICAL_FREQUENCY_LONG,
|
|
ns=NS,
|
|
no=-1,
|
|
)
|
|
|
|
|
|
@pytest.mark.r
|
|
class TestValidArguments:
|
|
"""Test `stl()` with valid arguments."""
|
|
|
|
def test_structure_of_returned_dataframe(self, vertical_no_demand):
|
|
"""`stl()` returns a `pd.DataFrame` with three columns."""
|
|
result = decomposition.stl(
|
|
vertical_no_demand, frequency=test_config.VERTICAL_FREQUENCY_LONG, ns=NS,
|
|
)
|
|
|
|
assert isinstance(result, pd.DataFrame)
|
|
assert list(result.columns) == ['seasonal', 'trend', 'residual']
|
|
|
|
# Run the `stl()` function with all possible combinations of arguments,
|
|
# including default ones and explicitly set non-default ones.
|
|
@pytest.mark.parametrize('nt', [None, 163])
|
|
@pytest.mark.parametrize('nl', [None, 777])
|
|
@pytest.mark.parametrize('ds', [0, 1])
|
|
@pytest.mark.parametrize('dt', [0, 1])
|
|
@pytest.mark.parametrize('dl', [0, 1])
|
|
@pytest.mark.parametrize('js', [None, 1])
|
|
@pytest.mark.parametrize('jt', [None, 1])
|
|
@pytest.mark.parametrize('jl', [None, 1])
|
|
@pytest.mark.parametrize('ni', [2, 3])
|
|
@pytest.mark.parametrize('no', [0, 1])
|
|
def test_decompose_time_series_with_no_demand( # noqa:WPS211,WPS216
|
|
self, vertical_no_demand, nt, nl, ds, dt, dl, js, jt, jl, ni, no, # noqa:WPS110
|
|
):
|
|
"""Decomposing a time series with no demand ...
|
|
|
|
... returns a `pd.DataFrame` with three columns holding only `0.0` values.
|
|
"""
|
|
decomposed = decomposition.stl(
|
|
vertical_no_demand,
|
|
frequency=test_config.VERTICAL_FREQUENCY_LONG,
|
|
ns=NS,
|
|
nt=nt,
|
|
nl=nl,
|
|
ds=ds,
|
|
dt=dt,
|
|
dl=dl,
|
|
js=js,
|
|
jt=jt,
|
|
jl=jl,
|
|
ni=ni,
|
|
no=no, # noqa:WPS110
|
|
)
|
|
|
|
result = decomposed.sum().sum()
|
|
|
|
assert result == 0
|