From e486a241bc56f3d25b3a3a98ccaec5356a6e359c Mon Sep 17 00:00:00 2001 From: Yakov Malyshev <38911542+ostreech1997@users.noreply.github.com> Date: Thu, 6 Jul 2023 16:15:26 +0300 Subject: [PATCH] Add `DeseasonalityTransform` (#1307) --- CHANGELOG.md | 2 +- etna/transforms/__init__.py | 1 + etna/transforms/decomposition/__init__.py | 1 + etna/transforms/decomposition/deseasonal.py | 229 ++++++++++++++++++ .../test_deseasonal_transform.py | 216 +++++++++++++++++ .../test_inference/test_inverse_transform.py | 9 + .../test_inference/test_transform.py | 9 + 7 files changed, 466 insertions(+), 1 deletion(-) create mode 100644 etna/transforms/decomposition/deseasonal.py create mode 100644 tests/test_transforms/test_decomposition/test_deseasonal_transform.py diff --git a/CHANGELOG.md b/CHANGELOG.md index 52323a9c2..b53ebaae1 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -7,7 +7,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ## Unreleased ### Added -- +- `DeseasonalityTransform` ([#1307](https://github.com/tinkoff-ai/etna/pull/1307)) - - Add extension with models from `statsforecast`: `StatsForecastARIMAModel`, `StatsForecastAutoARIMAModel`, `StatsForecastAutoCESModel`, `StatsForecastAutoETSModel`, `StatsForecastAutoThetaModel` ([#1295](https://github.com/tinkoff-ai/etna/pull/1297)) - diff --git a/etna/transforms/__init__.py b/etna/transforms/__init__.py index 4c8332cae..cb5b47c96 100644 --- a/etna/transforms/__init__.py +++ b/etna/transforms/__init__.py @@ -9,6 +9,7 @@ from etna.transforms.decomposition import ChangePointsLevelTransform from etna.transforms.decomposition import ChangePointsSegmentationTransform from etna.transforms.decomposition import ChangePointsTrendTransform +from etna.transforms.decomposition import DeseasonalityTransform from etna.transforms.decomposition import IrreversibleChangePointsTransform from etna.transforms.decomposition import LinearTrendTransform from etna.transforms.decomposition import ReversibleChangePointsTransform diff --git a/etna/transforms/decomposition/__init__.py b/etna/transforms/decomposition/__init__.py index 88d3817ff..8efe3f539 100644 --- a/etna/transforms/decomposition/__init__.py +++ b/etna/transforms/decomposition/__init__.py @@ -8,6 +8,7 @@ from etna.transforms.decomposition.change_points_based.change_points_models import RupturesChangePointsModel from etna.transforms.decomposition.change_points_based.level import ChangePointsLevelTransform from etna.transforms.decomposition.change_points_based.trend import TrendTransform +from etna.transforms.decomposition.deseasonal import DeseasonalityTransform from etna.transforms.decomposition.detrend import LinearTrendTransform from etna.transforms.decomposition.detrend import TheilSenTrendTransform from etna.transforms.decomposition.stl import STLTransform diff --git a/etna/transforms/decomposition/deseasonal.py b/etna/transforms/decomposition/deseasonal.py new file mode 100644 index 000000000..eb7f061cf --- /dev/null +++ b/etna/transforms/decomposition/deseasonal.py @@ -0,0 +1,229 @@ +from enum import Enum +from typing import Dict +from typing import List +from typing import Literal +from typing import Optional + +import numpy as np +import pandas as pd +from statsmodels.tsa.seasonal import seasonal_decompose + +from etna.distributions import BaseDistribution +from etna.distributions import CategoricalDistribution +from etna.models.utils import determine_freq +from etna.models.utils import determine_num_steps +from etna.transforms.base import OneSegmentTransform +from etna.transforms.base import ReversiblePerSegmentWrapper +from etna.transforms.utils import match_target_quantiles + + +class DeseasonalModel(str, Enum): + """Enum for different types of deseasonality model.""" + + additive = "additive" + multiplicative = "multiplicative" + + @classmethod + def _missing_(cls, value): + raise NotImplementedError( + f"{value} is not a valid {cls.__name__}. Only {', '.join([repr(m.value) for m in cls])} types allowed." + ) + + +class _OneSegmentDeseasonalityTransform(OneSegmentTransform): + def __init__(self, in_column: str, period: int, model: str = DeseasonalModel.additive): + """ + Init _OneSegmentDeseasonalityTransform. + + Parameters + ---------- + in_column: + name of processed column + period: + size of seasonality + model: + 'additive' (default) or 'multiplicative' + """ + self.in_column = in_column + self.period = period + self.model = DeseasonalModel(model) + self._seasonal: Optional[pd.Series] = None + + def _roll_seasonal(self, x: pd.Series) -> np.ndarray: + """ + Roll out seasonal component by x's time index. + + Parameters + ---------- + x: + processed column + + Returns + ------- + result: + seasonal component + """ + if self._seasonal is None: + raise ValueError("Transform is not fitted! Fit the Transform before calling.") + freq = determine_freq(x.index) + if self._seasonal.index[0] <= x.index[0]: + shift = -determine_num_steps(self._seasonal.index[0], x.index[0], freq) % self.period + else: + shift = determine_num_steps(x.index[0], self._seasonal.index[0], freq) % self.period + return np.resize(np.roll(self._seasonal, shift=shift), x.shape[0]) + + def fit(self, df: pd.DataFrame) -> "_OneSegmentDeseasonalityTransform": + """ + Perform seasonal decomposition. + + Parameters + ---------- + df: + Features dataframe with time + + Returns + ------- + result: + instance after processing + + Raises + ------ + ValueError: + if input column contains NaNs in the middle of the series + """ + df = df.loc[df[self.in_column].first_valid_index() : df[self.in_column].last_valid_index()] + if df[self.in_column].isnull().values.any(): + raise ValueError("The input column contains NaNs in the middle of the series! Try to use the imputer.") + self._seasonal = seasonal_decompose( + x=df[self.in_column], model=self.model, filt=None, two_sided=False, extrapolate_trend=0 + ).seasonal[: self.period] + return self + + def transform(self, df: pd.DataFrame) -> pd.DataFrame: + """ + Subtract seasonal component. + + Parameters + ---------- + df: + Features dataframe with time + + Returns + ------- + result: + Dataframe with extracted features + + Raises + ------ + ValueError: + if input column contains zero or negative values + """ + result = df + seasonal = self._roll_seasonal(result[self.in_column]) + if self.model == "additive": + result[self.in_column] -= seasonal + else: + if np.any(result[self.in_column] <= 0): + raise ValueError( + "The input column contains zero or negative values," + "but multiplicative seasonality can not work with such values." + ) + result[self.in_column] /= seasonal + return result + + def inverse_transform(self, df: pd.DataFrame) -> pd.DataFrame: + """ + Add seasonal component. + + Parameters + ---------- + df: + Features dataframe with time + + Returns + ------- + result: + Dataframe with extracted features + + Raises + ------ + ValueError: + if input column contains zero or negative values + ValueError: + if quantile columns contains zero or negative values + """ + result = df + seasonal = self._roll_seasonal(result[self.in_column]) + if self.model == "additive": + result[self.in_column] += seasonal + else: + if np.any(result[self.in_column] <= 0): + raise ValueError( + "The input column contains zero or negative values," + "but multiplicative seasonality can not work with such values." + ) + result[self.in_column] *= seasonal + if self.in_column == "target": + quantiles = match_target_quantiles(set(result.columns)) + for quantile_column_nm in quantiles: + if self.model == "additive": + result.loc[:, quantile_column_nm] += seasonal + else: + if np.any(result.loc[quantile_column_nm] <= 0): + raise ValueError( + f"The {quantile_column_nm} column contains zero or negative values," + "but multiplicative seasonality can not work with such values." + ) + result.loc[:, quantile_column_nm] *= seasonal + return result + + +class DeseasonalityTransform(ReversiblePerSegmentWrapper): + """Transform that uses :py:func:`statsmodels.tsa.seasonal.seasonal_decompose` to subtract seasonal component from the data. + + Warning + ------- + This transform can suffer from look-ahead bias. For transforming data at some timestamp + it uses information from the whole train part. + """ + + def __init__(self, in_column: str, period: int, model: Literal["additive", "multiplicative"] = "additive"): + """ + Init DeseasonalityTransform. + + Parameters + ---------- + in_column: + name of processed column + period: + size of seasonality + model: + 'additive' (Y[t] = T[t] + S[t] + e[t], default option) or 'multiplicative' (Y[t] = T[t] * S[t] * e[t]) + """ + self.in_column = in_column + self.period = period + self.model = model + super().__init__( + transform=_OneSegmentDeseasonalityTransform( + in_column=self.in_column, + period=self.period, + model=self.model, + ), + required_features=[self.in_column], + ) + + def get_regressors_info(self) -> List[str]: + """Return the list with regressors created by the transform.""" + return [] + + def params_to_tune(self) -> Dict[str, BaseDistribution]: + """Get default grid for tuning hyperparameters. + + This grid tunes parameters: ``model``. Other parameters are expected to be set by the user. + + Returns + ------- + : + Grid to tune. + """ + return {"model": CategoricalDistribution(["additive", "multiplicative"])} diff --git a/tests/test_transforms/test_decomposition/test_deseasonal_transform.py b/tests/test_transforms/test_decomposition/test_deseasonal_transform.py new file mode 100644 index 000000000..123ca5157 --- /dev/null +++ b/tests/test_transforms/test_decomposition/test_deseasonal_transform.py @@ -0,0 +1,216 @@ +import numpy as np +import pandas as pd +import pytest + +from etna.datasets.tsdataset import TSDataset +from etna.models import NaiveModel +from etna.transforms.decomposition import DeseasonalityTransform +from etna.transforms.decomposition.deseasonal import _OneSegmentDeseasonalityTransform +from tests.test_transforms.utils import assert_sampling_is_valid +from tests.test_transforms.utils import assert_transformation_equals_loaded_original + + +def add_seasonality(series: pd.Series, period: int, magnitude: float) -> pd.Series: + """Add seasonality to given series.""" + new_series = series.copy() + size = series.shape[0] + indices = np.arange(size) + new_series += np.sin(2 * np.pi * indices / period) * magnitude + return new_series + + +def get_one_df(period: int, magnitude: float) -> pd.DataFrame: + df = pd.DataFrame() + df["timestamp"] = pd.date_range(start="2020-01-01", end="2020-03-01", freq="D") + df["target"] = 10 + df["target"] = add_seasonality(df["target"], period=period, magnitude=magnitude) + return df + + +def ts_seasonal() -> TSDataset: + df_1 = get_one_df(period=7, magnitude=1) + df_1["segment"] = "segment_1" + df_2 = get_one_df(period=7, magnitude=2) + df_2["segment"] = "segment_2" + classic_df = pd.concat([df_1, df_2], ignore_index=True) + return TSDataset(TSDataset.to_dataset(classic_df), freq="D") + + +@pytest.fixture +def df_seasonal_one_segment() -> pd.DataFrame: + df = get_one_df(period=7, magnitude=1) + df.set_index("timestamp", inplace=True) + return df + + +@pytest.fixture +def df_seasonal_starting_with_nans_one_segment(df_seasonal_one_segment) -> pd.DataFrame: + result = df_seasonal_one_segment + result.iloc[:2] = np.NaN + return result + + +@pytest.fixture +def ts_seasonal() -> TSDataset: + df_1 = get_one_df(period=7, magnitude=1) + df_1["segment"] = "segment_1" + df_2 = get_one_df(period=7, magnitude=2) + df_2["segment"] = "segment_2" + classic_df = pd.concat([df_1, df_2], ignore_index=True) + return TSDataset(TSDataset.to_dataset(classic_df), freq="D") + + +@pytest.fixture +def ts_seasonal_starting_with_nans() -> TSDataset: + df_1 = get_one_df(period=7, magnitude=1) + df_1["segment"] = "segment_1" + + df_2 = get_one_df(period=7, magnitude=2) + df_2["segment"] = "segment_2" + + classic_df = pd.concat([df_1, df_2], ignore_index=True) + df = TSDataset.to_dataset(classic_df) + df.loc[[df.index[0], df.index[1]], pd.IndexSlice["segment_1", "target"]] = None + return TSDataset(df, freq="D") + + +@pytest.fixture +def ts_seasonal_nan_tails() -> TSDataset: + df_1 = get_one_df(period=7, magnitude=1) + df_1["segment"] = "segment_1" + + df_2 = get_one_df(period=7, magnitude=2) + df_2["segment"] = "segment_2" + + classic_df = pd.concat([df_1, df_2], ignore_index=True) + df = TSDataset.to_dataset(classic_df) + df.loc[[df.index[-2], df.index[-1]], pd.IndexSlice["segment_1", "target"]] = None + return TSDataset(df, freq="D") + + +@pytest.mark.parametrize("model", ["additive", "multiplicative"]) +@pytest.mark.parametrize("df_name", ["df_seasonal_one_segment", "df_seasonal_starting_with_nans_one_segment"]) +def test_transform_one_segment(df_name, model, request): + """Test that transform for one segment removes seasonality.""" + df = request.getfixturevalue(df_name) + transform = _OneSegmentDeseasonalityTransform(in_column="target", period=7, model=model) + df_transformed = transform.fit_transform(df) + df_expected = df.copy() + df_expected.loc[~df_expected["target"].isna(), "target"] = 10 + np.testing.assert_allclose(df_transformed["target"], df_expected["target"], atol=0.3) + + +@pytest.mark.parametrize("model", ["additive", "multiplicative"]) +@pytest.mark.parametrize("ts_name", ["ts_seasonal", "ts_seasonal_starting_with_nans", "ts_seasonal_nan_tails"]) +def test_transform_multi_segments(ts_name, model, request): + """Test that transform for all segments removes seasonality.""" + ts = request.getfixturevalue(ts_name) + df_expected = ts.to_pandas(flatten=True) + df_expected.loc[~df_expected["target"].isna(), "target"] = 10 + transform = DeseasonalityTransform(in_column="target", period=7, model=model) + transform.fit_transform(ts=ts) + df_transformed = ts.to_pandas(flatten=True) + np.testing.assert_allclose(df_transformed["target"], df_expected["target"], atol=0.3) + + +@pytest.mark.parametrize("model", ["additive", "multiplicative"]) +@pytest.mark.parametrize("df_name", ["df_seasonal_one_segment", "df_seasonal_starting_with_nans_one_segment"]) +def test_inverse_transform_one_segment(df_name, model, request): + """Test that transform + inverse_transform don't change dataframe.""" + df = request.getfixturevalue(df_name) + transform = _OneSegmentDeseasonalityTransform(in_column="target", period=7, model=model) + df_transformed = transform.fit_transform(df) + df_inverse_transformed = transform.inverse_transform(df_transformed) + pd.util.testing.assert_frame_equal(df_inverse_transformed, df) + + +@pytest.mark.parametrize("model", ["additive", "multiplicative"]) +@pytest.mark.parametrize("ts_name", ["ts_seasonal", "ts_seasonal_starting_with_nans", "ts_seasonal_nan_tails"]) +def test_inverse_transform_multi_segments(ts_name, model, request): + """Test that transform + inverse_transform don't change tsdataset.""" + ts = request.getfixturevalue(ts_name) + transform = DeseasonalityTransform(in_column="target", period=7, model=model) + df = ts.to_pandas(flatten=True) + transform.fit_transform(ts) + transform.inverse_transform(ts) + df_inverse_transformed = ts.to_pandas(flatten=True) + pd.util.testing.assert_frame_equal(df_inverse_transformed, df) + + +@pytest.mark.parametrize("model_decompose", ["additive", "multiplicative"]) +def test_forecast(ts_seasonal, model_decompose): + """Test that transform works correctly in forecast.""" + transform = DeseasonalityTransform(in_column="target", period=7, model=model_decompose) + ts_train, ts_test = ts_seasonal.train_test_split(test_size=3) + transform.fit_transform(ts_train) + model = NaiveModel() + model.fit(ts_train) + ts_future = ts_train.make_future(future_steps=3, transforms=[transform], tail_steps=model.context_size) + ts_forecast = model.forecast(ts_future, prediction_size=3) + ts_forecast.inverse_transform([transform]) + for segment in ts_forecast.segments: + np.testing.assert_allclose(ts_forecast[:, segment, "target"], ts_test[:, segment, "target"], atol=0.1) + + +def test_transform_raise_error_if_not_fitted(df_seasonal_one_segment): + """Test that transform for one segment raise error when calling transform without being fit.""" + transform = _OneSegmentDeseasonalityTransform(in_column="target", period=7, model="additive") + with pytest.raises(ValueError, match="Transform is not fitted!"): + _ = transform.transform(df=df_seasonal_one_segment) + + +def test_inverse_transform_raise_error_if_not_fitted(df_seasonal_one_segment): + """Test that transform for one segment raise error when calling inverse_transform without being fit.""" + transform = _OneSegmentDeseasonalityTransform(in_column="target", period=7, model="additive") + with pytest.raises(ValueError, match="Transform is not fitted!"): + _ = transform.inverse_transform(df=df_seasonal_one_segment) + + +def test_fit_transform_with_nans_in_middle_raise_error(ts_with_nans): + transform = DeseasonalityTransform(in_column="target", period=7) + with pytest.raises(ValueError, match="The input column contains NaNs in the middle of the series!"): + _ = transform.fit_transform(ts_with_nans) + + +@pytest.mark.parametrize("model", ["additive", "multiplicative"]) +def test_transform_with_negative_input_values(df_seasonal_one_segment, model): + df = df_seasonal_one_segment + transform = _OneSegmentDeseasonalityTransform(in_column="target", period=7, model=model) + transform.fit(df) + df.iloc[:2] = -10 + if model == "additive": + _ = transform.transform(df) + else: + with pytest.raises( + ValueError, + match="The input column contains zero or negative values," + "but multiplicative seasonality can not work with such values.", + ): + _ = transform.transform(df) + + +@pytest.mark.parametrize("model", ["mult"]) +def test_not_allowed_model_name(model): + with pytest.raises( + NotImplementedError, + match="mult is not a valid DeseasonalModel. Only 'additive', 'multiplicative' types allowed.", + ): + _ = DeseasonalityTransform(in_column="target", period=7, model=model) + + +@pytest.mark.parametrize( + "transform", + [ + DeseasonalityTransform(in_column="target", period=7, model="additive"), + DeseasonalityTransform(in_column="target", period=7, model="multiplicative"), + ], +) +def test_save_load(transform, ts_seasonal): + assert_transformation_equals_loaded_original(transform=transform, ts=ts_seasonal) + + +def test_params_to_tune(ts_seasonal): + ts = ts_seasonal + transform = DeseasonalityTransform(in_column="target", period=7) + assert len(transform.params_to_tune()) > 0 + assert_sampling_is_valid(transform=transform, ts=ts) diff --git a/tests/test_transforms/test_inference/test_inverse_transform.py b/tests/test_transforms/test_inference/test_inverse_transform.py index e1aee98ba..dd3645cac 100644 --- a/tests/test_transforms/test_inference/test_inverse_transform.py +++ b/tests/test_transforms/test_inference/test_inverse_transform.py @@ -15,6 +15,7 @@ from etna.transforms import ChangePointsTrendTransform from etna.transforms import DateFlagsTransform from etna.transforms import DensityOutliersTransform +from etna.transforms import DeseasonalityTransform from etna.transforms import DifferencingTransform from etna.transforms import FilterFeaturesTransform from etna.transforms import FourierTransform @@ -110,6 +111,7 @@ def _test_inverse_transform_train_subset_segments(self, ts, transform, segments) (LinearTrendTransform(in_column="target"), "regular_ts"), (TheilSenTrendTransform(in_column="target"), "regular_ts"), (STLTransform(in_column="target", period=7), "regular_ts"), + (DeseasonalityTransform(in_column="target", period=7), "regular_ts"), ( TrendTransform( in_column="target", @@ -294,6 +296,8 @@ def _test_inverse_transform_future_subset_segments(self, ts, transform, segments (TheilSenTrendTransform(in_column="positive"), "ts_with_exog"), (STLTransform(in_column="target", period=7), "regular_ts"), (STLTransform(in_column="positive", period=7), "ts_with_exog"), + (DeseasonalityTransform(in_column="target", period=7), "regular_ts"), + (DeseasonalityTransform(in_column="positive", period=7), "ts_with_exog"), ( TrendTransform( in_column="target", @@ -686,6 +690,7 @@ def test_inverse_transform_train_new_segments(self, transform, dataset_name, exp (LinearTrendTransform(in_column="target"), "regular_ts"), (TheilSenTrendTransform(in_column="target"), "regular_ts"), (STLTransform(in_column="target", period=7), "regular_ts"), + (DeseasonalityTransform(in_column="target", period=7), "regular_ts"), ( TrendTransform( in_column="target", @@ -1012,6 +1017,7 @@ def test_inverse_transform_future_new_segments(self, transform, dataset_name, ex (LinearTrendTransform(in_column="target"), "regular_ts"), (TheilSenTrendTransform(in_column="target"), "regular_ts"), (STLTransform(in_column="target", period=7), "regular_ts"), + (DeseasonalityTransform(in_column="target", period=7), "regular_ts"), ( TrendTransform( in_column="target", @@ -1188,6 +1194,7 @@ def _test_inverse_transform_future_with_target( (LinearTrendTransform(in_column="target"), "regular_ts", {"change": {"target"}}), (TheilSenTrendTransform(in_column="target"), "regular_ts", {"change": {"target"}}), (STLTransform(in_column="target", period=7), "regular_ts", {"change": {"target"}}), + (DeseasonalityTransform(in_column="target", period=7), "regular_ts", {"change": {"target"}}), ( TrendTransform( in_column="target", @@ -1588,6 +1595,8 @@ def _test_inverse_transform_future_without_target( (TheilSenTrendTransform(in_column="positive"), "ts_with_exog", {"change": {"positive"}}), (STLTransform(in_column="target", period=7), "regular_ts", {}), (STLTransform(in_column="positive", period=7), "ts_with_exog", {"change": {"positive"}}), + (DeseasonalityTransform(in_column="target", period=7), "regular_ts", {}), + (DeseasonalityTransform(in_column="positive", period=7), "ts_with_exog", {"change": {"positive"}}), ( TrendTransform( in_column="target", diff --git a/tests/test_transforms/test_inference/test_transform.py b/tests/test_transforms/test_inference/test_transform.py index 5942dde87..6d33326b8 100644 --- a/tests/test_transforms/test_inference/test_transform.py +++ b/tests/test_transforms/test_inference/test_transform.py @@ -15,6 +15,7 @@ from etna.transforms import ChangePointsTrendTransform from etna.transforms import DateFlagsTransform from etna.transforms import DensityOutliersTransform +from etna.transforms import DeseasonalityTransform from etna.transforms import DifferencingTransform from etna.transforms import FilterFeaturesTransform from etna.transforms import FourierTransform @@ -104,6 +105,7 @@ def _test_transform_train_subset_segments(self, ts, transform, segments): (LinearTrendTransform(in_column="target"), "regular_ts"), (TheilSenTrendTransform(in_column="target"), "regular_ts"), (STLTransform(in_column="target", period=7), "regular_ts"), + (DeseasonalityTransform(in_column="target", period=7), "regular_ts"), ( TrendTransform( in_column="target", @@ -276,6 +278,8 @@ def _test_transform_future_subset_segments(self, ts, transform, segments, horizo (TheilSenTrendTransform(in_column="positive"), "ts_with_exog"), (STLTransform(in_column="target", period=7), "regular_ts"), (STLTransform(in_column="positive", period=7), "ts_with_exog"), + (DeseasonalityTransform(in_column="target", period=7), "regular_ts"), + (DeseasonalityTransform(in_column="positive", period=7), "ts_with_exog"), ( TrendTransform( in_column="target", @@ -637,6 +641,7 @@ def test_transform_train_new_segments(self, transform, dataset_name, expected_ch (LinearTrendTransform(in_column="target"), "regular_ts"), (TheilSenTrendTransform(in_column="target"), "regular_ts"), (STLTransform(in_column="target", period=7), "regular_ts"), + (DeseasonalityTransform(in_column="target", period=7), "regular_ts"), ( TrendTransform( in_column="target", @@ -956,6 +961,7 @@ def test_transform_future_new_segments(self, transform, dataset_name, expected_c (LinearTrendTransform(in_column="target"), "regular_ts"), (TheilSenTrendTransform(in_column="target"), "regular_ts"), (STLTransform(in_column="target", period=7), "regular_ts"), + (DeseasonalityTransform(in_column="target", period=7), "regular_ts"), ( TrendTransform( in_column="target", @@ -1075,6 +1081,7 @@ def _test_transform_future_with_target(self, ts, transform, expected_changes, ga (LinearTrendTransform(in_column="target"), "regular_ts", {"change": {"target"}}), (TheilSenTrendTransform(in_column="target"), "regular_ts", {"change": {"target"}}), (STLTransform(in_column="target", period=7), "regular_ts", {"change": {"target"}}), + (DeseasonalityTransform(in_column="target", period=7), "regular_ts", {"change": {"target"}}), ( TrendTransform( in_column="target", @@ -1403,6 +1410,8 @@ def _test_transform_future_without_target(self, ts, transform, expected_changes, (TheilSenTrendTransform(in_column="positive"), "ts_with_exog", {"change": {"positive"}}), (STLTransform(in_column="target", period=7), "regular_ts", {}), (STLTransform(in_column="positive", period=7), "ts_with_exog", {"change": {"positive"}}), + (DeseasonalityTransform(in_column="target", period=7), "regular_ts", {}), + (DeseasonalityTransform(in_column="positive", period=7), "ts_with_exog", {"change": {"positive"}}), ( TrendTransform( in_column="target",