From 6a44331f62dc524c907e3413536abd13622d7775 Mon Sep 17 00:00:00 2001 From: brsnw250 Date: Wed, 3 May 2023 11:00:08 +0300 Subject: [PATCH 01/18] save exog last available date --- etna/transforms/base.py | 18 ++++++++++++++++ tests/test_transforms/test_base/test_base.py | 22 ++++++++++++++++++++ 2 files changed, 40 insertions(+) diff --git a/etna/transforms/base.py b/etna/transforms/base.py index bd2e03ee9..50ce0ac3f 100644 --- a/etna/transforms/base.py +++ b/etna/transforms/base.py @@ -26,6 +26,7 @@ class Transform(SaveMixin, AbstractSaveable, BaseMixin): def __init__(self, required_features: Union[Literal["all"], List[str]]): self.required_features = required_features + self._exog_last_date: Optional[Dict[str, pd.Timestamp]] = None @abstractmethod def get_regressors_info(self) -> List[str]: @@ -87,6 +88,10 @@ def fit(self, ts: TSDataset) -> "Transform": The fitted transform instance. """ df = ts.to_pandas(flatten=False, features=self.required_features) + + if ts.df_exog is not None: + self._save_exog_last_date(df_exog=ts.df_exog) + self._fit(df=df) return self @@ -127,6 +132,19 @@ def transform(self, ts: TSDataset) -> TSDataset: ts = self._update_dataset(ts=ts, columns_before=columns_before, df_transformed=df_transformed) return ts + def _save_exog_last_date(self, df_exog: pd.DataFrame): + """Save last available date of each exogenous variable.""" + exog_names = set(df_exog.columns.get_level_values("feature")) + + self._exog_last_date = dict() + for name in exog_names: + feature = df_exog.loc[:, pd.IndexSlice[:, name]] + + na_mask = pd.isna(feature).any(axis=1) + last_date = feature.index[~na_mask].max() + + self._exog_last_date[name] = last_date + def fit_transform(self, ts: TSDataset) -> TSDataset: """Fit and transform TSDataset. diff --git a/tests/test_transforms/test_base/test_base.py b/tests/test_transforms/test_base/test_base.py index 03cd58b5f..8f6c5ca1e 100644 --- a/tests/test_transforms/test_base/test_base.py +++ b/tests/test_transforms/test_base/test_base.py @@ -234,3 +234,25 @@ def test_inverse_transform_with_target_components_target_not_in_required_feature transform = AddConstTransform(in_column="exog", value=-10) transform.inverse_transform(ts=ts_with_target_components) pd.testing.assert_frame_equal(ts_with_target_components.get_target_components(), target_components_before) + + +@pytest.fixture() +def df_exog_with_nans(): + df = pd.DataFrame( + { + "timestamp": list(pd.date_range("2023-01-01", periods=5)) * 2, + "segment": ["A"] * 5 + ["B"] * 5, + "feat1": [1, 2, 3, 4, None] + [1, 2, 3, 4, 5], + "feat2": [1, 2, 3, None, None] + [1, 2, 3, None, None], + } + ) + + return TSDataset.to_dataset(df=df) + + +def test_save_exog_last_date( + df_exog_with_nans, expected={"feat1": pd.Timestamp("2023-01-04"), "feat2": pd.Timestamp("2023-01-03")} +): + tr = TransformMock(required_features="all") + tr._save_exog_last_date(df_exog=df_exog_with_nans) + assert tr._exog_last_date == expected From df3f3dabc4cf029555cc48b5f2dce3d81f7957bb Mon Sep 17 00:00:00 2001 From: brsnw250 Date: Wed, 3 May 2023 11:00:44 +0300 Subject: [PATCH 02/18] added exog shift transform --- etna/transforms/__init__.py | 1 + etna/transforms/math/__init__.py | 1 + etna/transforms/math/lags.py | 147 +++++++++++++++++++++++++++++++ 3 files changed, 149 insertions(+) diff --git a/etna/transforms/__init__.py b/etna/transforms/__init__.py index 00c67b6ce..4c8332cae 100644 --- a/etna/transforms/__init__.py +++ b/etna/transforms/__init__.py @@ -26,6 +26,7 @@ from etna.transforms.math import AddConstTransform from etna.transforms.math import BoxCoxTransform from etna.transforms.math import DifferencingTransform +from etna.transforms.math import ExogShiftTransform from etna.transforms.math import LagTransform from etna.transforms.math import LambdaTransform from etna.transforms.math import LogTransform diff --git a/etna/transforms/math/__init__.py b/etna/transforms/math/__init__.py index 71877775d..dc3df9b09 100644 --- a/etna/transforms/math/__init__.py +++ b/etna/transforms/math/__init__.py @@ -1,6 +1,7 @@ from etna.transforms.math.add_constant import AddConstTransform from etna.transforms.math.apply_lambda import LambdaTransform from etna.transforms.math.differencing import DifferencingTransform +from etna.transforms.math.lags import ExogShiftTransform from etna.transforms.math.lags import LagTransform from etna.transforms.math.log import LogTransform from etna.transforms.math.power import BoxCoxTransform diff --git a/etna/transforms/math/lags.py b/etna/transforms/math/lags.py index aae433624..7db80a8fe 100644 --- a/etna/transforms/math/lags.py +++ b/etna/transforms/math/lags.py @@ -1,4 +1,6 @@ +from typing import Dict from typing import List +from typing import Literal from typing import Optional from typing import Union @@ -96,3 +98,148 @@ def _transform(self, df: pd.DataFrame) -> pd.DataFrame: def get_regressors_info(self) -> List[str]: """Return the list with regressors created by the transform.""" return [self._get_column_name(lag) for lag in self.lags] + + +class ExogShiftTransform(IrreversibleTransform, FutureMixin): + """Shifts exogenous variables from a given dataframe.""" + + def __init__(self, lag: Union[int, Literal["auto"]], horizon: Optional[int] = None): + """Create instance of ExogShiftTransform. + + Parameters + ---------- + lag: + value for shift estimation + + * if set to `int` all exogenous variables will be shifted `lag` steps forward; + + * if set to `auto` minimal shift will be estimated for each variable based on + the prediction horizon and available timeline + + horizon: + prediction horizon. Mandatory when set to `lag="auto"`, ignored otherwise + """ + super().__init__(required_features="all") + + self.lag: Optional[int] = None + self.horizon: Optional[int] = None + self.auto = False + + self._created_regressors: Optional[List[str]] = None + self._exog_shifts: Optional[Dict[str, int]] = None + + if isinstance(lag, int): + if lag <= 0: + raise ValueError(f"{type(self).__name__} works only with positive lags values, {lag} given") + self.lag = lag + + else: + if horizon is None: + raise ValueError("`horizon` should be specified when using `auto`!") + + if horizon < 1: + raise ValueError(f"{type(self).__name__} works only with positive horizon values, {horizon} given") + + self.horizon = horizon + self.auto = True + + def _fit(self, df: pd.DataFrame) -> "ExogShiftTransform": + """Estimate shifts for exogenous variables. + + Parameters + ---------- + df: + dataframe with data. + + Returns + ------- + result: ExogShiftTransform + """ + feature_names = self._get_feature_names(df=df) + + self._exog_shifts = dict() + self._created_regressors = [] + + for feature_name in feature_names: + shift = self._estimate_shift(df=df, feature_name=feature_name) + self._exog_shifts[feature_name] = shift + + if shift > 0: + self._created_regressors.append(f"{feature_name}_shift_{shift}") + + return self + + @staticmethod + def _get_feature_names(df: pd.DataFrame) -> List[str]: + """Return the names of exogenous variables.""" + names = set(df.columns.get_level_values("feature")) + if "target" in names: + names.remove("target") + + return list(names) + + def _estimate_shift(self, df: pd.DataFrame, feature_name: str) -> int: + """Estimate shift value for exogenous variable.""" + if not self.auto: + return self.lag # type: ignore + + freq = pd.infer_freq(df.index) + + last_date = df.index.max() + pd.Timedelta(self.horizon, unit=freq) + last_feature_date = self._exog_last_date[feature_name] # type: ignore + + delta = last_date - last_feature_date + shift = max(0, int(delta / pd.Timedelta(1, unit=freq))) + + return shift + + def _transform(self, df: pd.DataFrame) -> pd.DataFrame: + """Shift exogenous variables. + + Parameters + ---------- + df: + dataframe with data to transform. + + Returns + ------- + result: pd.Dataframe + transformed dataframe + """ + if self._exog_shifts is None: + raise ValueError("Transform is not fitted!") + + result = df + freq = pd.infer_freq(df.index) + segments = sorted(set(df.columns.get_level_values("segment"))) + feature_names = self._get_feature_names(df=df) + + shifted_features = [] + features_to_remove = [] + for feature_name in feature_names: + shift = self._exog_shifts[feature_name] + + feature = df.loc[:, pd.IndexSlice[:, feature_name]] + + if shift > 0: + shifted_feature = feature.shift(shift, freq=freq) + + column_name = f"{feature_name}_shift_{shift}" + shifted_feature.columns = pd.MultiIndex.from_product([segments, [column_name]]) + + shifted_features.append(shifted_feature) + features_to_remove.append(feature_name) + + if len(features_to_remove) > 0: + result = result.drop(columns=pd.MultiIndex.from_product([segments, features_to_remove])) + + result = pd.concat([result] + shifted_features, axis=1) + result.sort_index(axis=1, inplace=True) + return result + + def get_regressors_info(self) -> List[str]: + """Return the list with regressors created by the transform.""" + if self._created_regressors is None: + return [] + + return self._created_regressors From 01dc9b00b17ee22e752a90c01b5947105d9e655b Mon Sep 17 00:00:00 2001 From: brsnw250 Date: Wed, 3 May 2023 11:00:57 +0300 Subject: [PATCH 03/18] added tests --- .../test_math/test_exog_shift_transform.py | 131 ++++++++++++++++++ 1 file changed, 131 insertions(+) create mode 100644 tests/test_transforms/test_math/test_exog_shift_transform.py diff --git a/tests/test_transforms/test_math/test_exog_shift_transform.py b/tests/test_transforms/test_math/test_exog_shift_transform.py new file mode 100644 index 000000000..a11c44240 --- /dev/null +++ b/tests/test_transforms/test_math/test_exog_shift_transform.py @@ -0,0 +1,131 @@ +import numpy as np +import pandas as pd +import pytest + +from etna.datasets import TSDataset +from etna.metrics import MAE +from etna.models import LinearPerSegmentModel +from etna.pipeline import Pipeline +from etna.transforms import ExogShiftTransform + + +@pytest.fixture() +def df_exog_with_nans(): + df = pd.DataFrame( + { + "timestamp": list(pd.date_range("2023-01-01", periods=5)) * 2, + "segment": ["A"] * 5 + ["B"] * 5, + "feat1": [1, 2, 3, 4, None] + [1, 2, 3, 4, 5], + "feat2": [1, 2, 3, None, None] + [1, 2, 3, None, None], + "feat3": [1, 2, 3, 4, 5] + [1, 2, 3, 4, 5], + } + ) + + return TSDataset.to_dataset(df=df) + + +@pytest.fixture() +def ts_with_exogs(df_exog_with_nans): + df = pd.DataFrame( + { + "timestamp": list(pd.date_range("2023-01-01", periods=4)) * 2, + "segment": ["A"] * 4 + ["B"] * 4, + "target": list(3 * np.arange(1, 5)) * 2, + } + ) + + df = TSDataset.to_dataset(df=df) + ts = TSDataset(df=df, df_exog=df_exog_with_nans, freq="D") + return ts + + +def test_negative_lag(): + with pytest.raises(ValueError, match=".* works only with positive lags"): + ExogShiftTransform(lag=-1) + + +def test_horizon_not_set(): + with pytest.raises(ValueError, match="`horizon` should be specified"): + ExogShiftTransform(lag="auto") + + +def test_negative_horizon_set(): + with pytest.raises(ValueError, match=".* works only with positive horizon"): + ExogShiftTransform(lag="auto", horizon=-1) + + +def test_get_feature_names(example_reg_tsds, expected={"regressor_exog_weekend"}): + feature_names = ExogShiftTransform._get_feature_names(example_reg_tsds.df) + assert set(feature_names) == expected + + +def test_regressors_info_not_fit(): + assert ExogShiftTransform(lag=1).get_regressors_info() == [] + + +@pytest.mark.parametrize( + "horizon,expected", + ((1, {"feat1_shift_1", "feat2_shift_2"}), (2, {"feat1_shift_2", "feat2_shift_3", "feat3_shift_1"})), +) +def test_regressors_info(ts_with_exogs, horizon, expected): + t = ExogShiftTransform(lag="auto", horizon=horizon) + t.fit(ts=ts_with_exogs) + assert set(t.get_regressors_info()) == expected + + +@pytest.mark.parametrize( + "lag,horizon,expected", + ( + (1, None, {"feat1": 1, "feat2": 1, "feat3": 1}), + ("auto", 1, {"feat1": 1, "feat2": 2, "feat3": 0}), + ("auto", 2, {"feat1": 2, "feat2": 3, "feat3": 1}), + ), +) +def test_estimate_shift(ts_with_exogs, lag, horizon, expected): + t = ExogShiftTransform(lag=lag, horizon=horizon) + t.fit(ts=ts_with_exogs) + assert t._exog_shifts == expected + + +@pytest.mark.parametrize("lag", (1, "auto")) +def test_shift_no_exog(simple_df, lag, expected={"target"}): + t = ExogShiftTransform(lag=lag, horizon=1) + transformed = t.fit_transform(simple_df) + assert set(transformed.df.columns.get_level_values("feature")) == expected + + +@pytest.mark.parametrize( + "lag,horizon,expected", + ( + (1, None, {"feat1_shift_1", "feat2_shift_1", "feat3_shift_1", "target"}), + ("auto", 1, {"feat1_shift_1", "feat2_shift_2", "feat3", "target"}), + ("auto", 2, {"feat1_shift_2", "feat2_shift_3", "feat3_shift_1", "target"}), + ), +) +def test_transformed_names(ts_with_exogs, lag, horizon, expected): + t = ExogShiftTransform(lag=lag, horizon=horizon) + transformed = t.fit_transform(ts=ts_with_exogs) + column_names = transformed.df.columns.get_level_values("feature") + assert set(column_names) == expected + + +@pytest.mark.parametrize("lag", (3, "auto")) +@pytest.mark.parametrize("horizon", range(1, 3)) +def test_pipeline_forecast(ts_with_exogs, lag, horizon): + pipeline = Pipeline( + transforms=[ExogShiftTransform(lag=lag, horizon=horizon)], model=LinearPerSegmentModel(), horizon=horizon + ) + pipeline.fit(ts_with_exogs) + pipeline.forecast() + + +@pytest.mark.parametrize("lag", (3, "auto")) +@pytest.mark.parametrize("horizon", range(7, 10)) +def test_pipeline_backtest(example_reg_tsds, lag, horizon): + ts = example_reg_tsds + + pipeline = Pipeline( + transforms=[ExogShiftTransform(lag=lag, horizon=horizon)], model=LinearPerSegmentModel(), horizon=horizon + ) + + pipeline.backtest(ts=ts, metrics=[MAE()]) From 6b42654dd14166c2b043c91f11fb18bb438692ce Mon Sep 17 00:00:00 2001 From: brsnw250 Date: Wed, 3 May 2023 11:57:53 +0300 Subject: [PATCH 04/18] fixed tests --- etna/transforms/base.py | 5 +++-- 1 file changed, 3 insertions(+), 2 deletions(-) diff --git a/etna/transforms/base.py b/etna/transforms/base.py index 50ce0ac3f..8e6962b86 100644 --- a/etna/transforms/base.py +++ b/etna/transforms/base.py @@ -89,8 +89,9 @@ def fit(self, ts: TSDataset) -> "Transform": """ df = ts.to_pandas(flatten=False, features=self.required_features) - if ts.df_exog is not None: - self._save_exog_last_date(df_exog=ts.df_exog) + df_exog = ts.df_exog + if df_exog is not None and isinstance(df_exog, pd.DataFrame): + self._save_exog_last_date(df_exog=df_exog) self._fit(df=df) return self From d8fa4c25906b1f4491c1d5bdab3afdf79fd0a740 Mon Sep 17 00:00:00 2001 From: brsnw250 Date: Fri, 5 May 2023 12:09:05 +0300 Subject: [PATCH 05/18] fixed feature names selection --- etna/transforms/math/lags.py | 9 ++++++--- 1 file changed, 6 insertions(+), 3 deletions(-) diff --git a/etna/transforms/math/lags.py b/etna/transforms/math/lags.py index 7db80a8fe..1f696657e 100644 --- a/etna/transforms/math/lags.py +++ b/etna/transforms/math/lags.py @@ -173,10 +173,13 @@ def _fit(self, df: pd.DataFrame) -> "ExogShiftTransform": def _get_feature_names(df: pd.DataFrame) -> List[str]: """Return the names of exogenous variables.""" names = set(df.columns.get_level_values("feature")) - if "target" in names: - names.remove("target") - return list(names) + names_to_remove = {"target"} + names_to_remove |= match_target_quantiles(names) + names_to_remove |= match_target_components(names) + + features = names - names_to_remove + return list(features) def _estimate_shift(self, df: pd.DataFrame, feature_name: str) -> int: """Estimate shift value for exogenous variable.""" From 7070b1838b3c2d5044e3f114f47b79492e5470ef Mon Sep 17 00:00:00 2001 From: brsnw250 Date: Fri, 5 May 2023 12:09:52 +0300 Subject: [PATCH 06/18] new shift estimation logic --- etna/transforms/math/lags.py | 14 +++++++++++--- 1 file changed, 11 insertions(+), 3 deletions(-) diff --git a/etna/transforms/math/lags.py b/etna/transforms/math/lags.py index 1f696657e..dfe9b8dd0 100644 --- a/etna/transforms/math/lags.py +++ b/etna/transforms/math/lags.py @@ -188,11 +188,19 @@ def _estimate_shift(self, df: pd.DataFrame, feature_name: str) -> int: freq = pd.infer_freq(df.index) - last_date = df.index.max() + pd.Timedelta(self.horizon, unit=freq) + last_date = df.index.max() last_feature_date = self._exog_last_date[feature_name] # type: ignore - delta = last_date - last_feature_date - shift = max(0, int(delta / pd.Timedelta(1, unit=freq))) + if last_feature_date > last_date: + delta = -determine_num_steps(start_timestamp=last_date, end_timestamp=last_feature_date, freq=freq) + + elif last_feature_date < last_date: + delta = determine_num_steps(start_timestamp=last_feature_date, end_timestamp=last_date, freq=freq) + + else: + delta = 0 + + shift = max(0, delta + self.horizon) # type: ignore return shift From de77c595e1d72b5a45242b25b73c765856417b2d Mon Sep 17 00:00:00 2001 From: brsnw250 Date: Fri, 5 May 2023 12:10:27 +0300 Subject: [PATCH 07/18] review fixes --- etna/transforms/math/lags.py | 24 ++++++++++++++---------- 1 file changed, 14 insertions(+), 10 deletions(-) diff --git a/etna/transforms/math/lags.py b/etna/transforms/math/lags.py index dfe9b8dd0..da40ff496 100644 --- a/etna/transforms/math/lags.py +++ b/etna/transforms/math/lags.py @@ -6,6 +6,9 @@ import pandas as pd +from etna.datasets.utils import match_target_components +from etna.datasets.utils import match_target_quantiles +from etna.models.utils import determine_num_steps from etna.transforms.base import FutureMixin from etna.transforms.base import IrreversibleTransform @@ -123,25 +126,25 @@ def __init__(self, lag: Union[int, Literal["auto"]], horizon: Optional[int] = No self.lag: Optional[int] = None self.horizon: Optional[int] = None - self.auto = False + self._auto = False self._created_regressors: Optional[List[str]] = None self._exog_shifts: Optional[Dict[str, int]] = None if isinstance(lag, int): if lag <= 0: - raise ValueError(f"{type(self).__name__} works only with positive lags values, {lag} given") + raise ValueError(f"{self.__class__.__name__} works only with positive lags values, {lag} given") self.lag = lag else: if horizon is None: - raise ValueError("`horizon` should be specified when using `auto`!") + raise ValueError("Value of `horizon` should be specified when using `auto`!") if horizon < 1: - raise ValueError(f"{type(self).__name__} works only with positive horizon values, {horizon} given") + raise ValueError(f"{self.__class__.__name__} works only with positive horizon values, {horizon} given") self.horizon = horizon - self.auto = True + self._auto = True def _fit(self, df: pd.DataFrame) -> "ExogShiftTransform": """Estimate shifts for exogenous variables. @@ -153,7 +156,8 @@ def _fit(self, df: pd.DataFrame) -> "ExogShiftTransform": Returns ------- - result: ExogShiftTransform + : + Fitted `ExogShiftTransform` instance. """ feature_names = self._get_feature_names(df=df) @@ -183,7 +187,7 @@ def _get_feature_names(df: pd.DataFrame) -> List[str]: def _estimate_shift(self, df: pd.DataFrame, feature_name: str) -> int: """Estimate shift value for exogenous variable.""" - if not self.auto: + if not self._auto: return self.lag # type: ignore freq = pd.infer_freq(df.index) @@ -214,8 +218,8 @@ def _transform(self, df: pd.DataFrame) -> pd.DataFrame: Returns ------- - result: pd.Dataframe - transformed dataframe + : + Transformed dataframe. """ if self._exog_shifts is None: raise ValueError("Transform is not fitted!") @@ -251,6 +255,6 @@ def _transform(self, df: pd.DataFrame) -> pd.DataFrame: def get_regressors_info(self) -> List[str]: """Return the list with regressors created by the transform.""" if self._created_regressors is None: - return [] + raise ValueError("Fit the transform to get the regressors info!") return self._created_regressors From 70a7d5f9614793e3e3c9ac5f53aade343e15357a Mon Sep 17 00:00:00 2001 From: brsnw250 Date: Fri, 5 May 2023 12:11:22 +0300 Subject: [PATCH 08/18] added tests --- .../test_math/test_exog_shift_transform.py | 15 +++++++++++++-- 1 file changed, 13 insertions(+), 2 deletions(-) diff --git a/tests/test_transforms/test_math/test_exog_shift_transform.py b/tests/test_transforms/test_math/test_exog_shift_transform.py index a11c44240..93d4d02b5 100644 --- a/tests/test_transforms/test_math/test_exog_shift_transform.py +++ b/tests/test_transforms/test_math/test_exog_shift_transform.py @@ -54,13 +54,24 @@ def test_negative_horizon_set(): ExogShiftTransform(lag="auto", horizon=-1) +def test_regressors_info_not_fit(): + with pytest.raises(ValueError, match="Fit the transform"): + ExogShiftTransform(lag=1).get_regressors_info() + + def test_get_feature_names(example_reg_tsds, expected={"regressor_exog_weekend"}): feature_names = ExogShiftTransform._get_feature_names(example_reg_tsds.df) assert set(feature_names) == expected -def test_regressors_info_not_fit(): - assert ExogShiftTransform(lag=1).get_regressors_info() == [] +@pytest.mark.parametrize( + "ts_name", ("toy_dataset_with_mean_shift_in_target", "product_level_constant_forecast_with_target_components") +) +def test_ts_with_quantiles_and_components(ts_name, request): + ts = request.getfixturevalue(ts_name) + t = ExogShiftTransform(lag=1) + t.fit(ts=ts) + assert t.get_regressors_info() == [] @pytest.mark.parametrize( From bdf1b6983a0025a359758cce9fb0c238d5c7a462 Mon Sep 17 00:00:00 2001 From: brsnw250 Date: Fri, 5 May 2023 12:11:36 +0300 Subject: [PATCH 09/18] reworked tests --- .../test_math/test_exog_shift_transform.py | 48 ++++++++++++++----- 1 file changed, 37 insertions(+), 11 deletions(-) diff --git a/tests/test_transforms/test_math/test_exog_shift_transform.py b/tests/test_transforms/test_math/test_exog_shift_transform.py index 93d4d02b5..400b08eb1 100644 --- a/tests/test_transforms/test_math/test_exog_shift_transform.py +++ b/tests/test_transforms/test_math/test_exog_shift_transform.py @@ -9,11 +9,10 @@ from etna.transforms import ExogShiftTransform -@pytest.fixture() -def df_exog_with_nans(): +def build_df_exog_with_nans(freq="D"): df = pd.DataFrame( { - "timestamp": list(pd.date_range("2023-01-01", periods=5)) * 2, + "timestamp": list(pd.date_range("2023-01-01", periods=5, freq=freq)) * 2, "segment": ["A"] * 5 + ["B"] * 5, "feat1": [1, 2, 3, 4, None] + [1, 2, 3, 4, 5], "feat2": [1, 2, 3, None, None] + [1, 2, 3, None, None], @@ -24,21 +23,30 @@ def df_exog_with_nans(): return TSDataset.to_dataset(df=df) -@pytest.fixture() -def ts_with_exogs(df_exog_with_nans): +def build_ts_with_exogs(freq="D"): df = pd.DataFrame( { - "timestamp": list(pd.date_range("2023-01-01", periods=4)) * 2, + "timestamp": list(pd.date_range("2023-01-01", periods=4, freq=freq)) * 2, "segment": ["A"] * 4 + ["B"] * 4, "target": list(3 * np.arange(1, 5)) * 2, } ) df = TSDataset.to_dataset(df=df) - ts = TSDataset(df=df, df_exog=df_exog_with_nans, freq="D") + ts = TSDataset(df=df, df_exog=build_df_exog_with_nans(freq=freq), freq=freq) return ts +@pytest.fixture() +def ts_with_exogs(): + return build_ts_with_exogs(freq="D") + + +@pytest.fixture() +def ts_with_exogs_ms_freq(): + return build_ts_with_exogs(freq="MS") + + def test_negative_lag(): with pytest.raises(ValueError, match=".* works only with positive lags"): ExogShiftTransform(lag=-1) @@ -113,20 +121,38 @@ def test_shift_no_exog(simple_df, lag, expected={"target"}): ("auto", 2, {"feat1_shift_2", "feat2_shift_3", "feat3_shift_1", "target"}), ), ) -def test_transformed_names(ts_with_exogs, lag, horizon, expected): +@pytest.mark.parametrize( + "ts_name", + ( + "ts_with_exogs", + "ts_with_exogs_ms_freq", + ), +) +def test_transformed_names(ts_name, lag, horizon, expected, request): + ts = request.getfixturevalue(ts_name) + t = ExogShiftTransform(lag=lag, horizon=horizon) - transformed = t.fit_transform(ts=ts_with_exogs) + transformed = t.fit_transform(ts=ts) column_names = transformed.df.columns.get_level_values("feature") assert set(column_names) == expected @pytest.mark.parametrize("lag", (3, "auto")) @pytest.mark.parametrize("horizon", range(1, 3)) -def test_pipeline_forecast(ts_with_exogs, lag, horizon): +@pytest.mark.parametrize( + "ts_name", + ( + "ts_with_exogs", + "ts_with_exogs_ms_freq", + ), +) +def test_pipeline_forecast(ts_name, lag, horizon, request): + ts = request.getfixturevalue(ts_name) + pipeline = Pipeline( transforms=[ExogShiftTransform(lag=lag, horizon=horizon)], model=LinearPerSegmentModel(), horizon=horizon ) - pipeline.fit(ts_with_exogs) + pipeline.fit(ts) pipeline.forecast() From 3a1a3d95fc302685e55f8ef5ba3e5d8e8339c350 Mon Sep 17 00:00:00 2001 From: brsnw250 Date: Fri, 5 May 2023 14:10:07 +0300 Subject: [PATCH 10/18] moved exog dates from base --- etna/transforms/base.py | 19 ---------- etna/transforms/math/lags.py | 37 +++++++++++++++++++ tests/test_transforms/test_base/test_base.py | 22 ----------- .../test_math/test_exog_shift_transform.py | 20 ++++++++++ 4 files changed, 57 insertions(+), 41 deletions(-) diff --git a/etna/transforms/base.py b/etna/transforms/base.py index 8e6962b86..bd2e03ee9 100644 --- a/etna/transforms/base.py +++ b/etna/transforms/base.py @@ -26,7 +26,6 @@ class Transform(SaveMixin, AbstractSaveable, BaseMixin): def __init__(self, required_features: Union[Literal["all"], List[str]]): self.required_features = required_features - self._exog_last_date: Optional[Dict[str, pd.Timestamp]] = None @abstractmethod def get_regressors_info(self) -> List[str]: @@ -88,11 +87,6 @@ def fit(self, ts: TSDataset) -> "Transform": The fitted transform instance. """ df = ts.to_pandas(flatten=False, features=self.required_features) - - df_exog = ts.df_exog - if df_exog is not None and isinstance(df_exog, pd.DataFrame): - self._save_exog_last_date(df_exog=df_exog) - self._fit(df=df) return self @@ -133,19 +127,6 @@ def transform(self, ts: TSDataset) -> TSDataset: ts = self._update_dataset(ts=ts, columns_before=columns_before, df_transformed=df_transformed) return ts - def _save_exog_last_date(self, df_exog: pd.DataFrame): - """Save last available date of each exogenous variable.""" - exog_names = set(df_exog.columns.get_level_values("feature")) - - self._exog_last_date = dict() - for name in exog_names: - feature = df_exog.loc[:, pd.IndexSlice[:, name]] - - na_mask = pd.isna(feature).any(axis=1) - last_date = feature.index[~na_mask].max() - - self._exog_last_date[name] = last_date - def fit_transform(self, ts: TSDataset) -> TSDataset: """Fit and transform TSDataset. diff --git a/etna/transforms/math/lags.py b/etna/transforms/math/lags.py index da40ff496..cc38f3ad6 100644 --- a/etna/transforms/math/lags.py +++ b/etna/transforms/math/lags.py @@ -6,6 +6,7 @@ import pandas as pd +from etna.datasets import TSDataset from etna.datasets.utils import match_target_components from etna.datasets.utils import match_target_quantiles from etna.models.utils import determine_num_steps @@ -130,6 +131,7 @@ def __init__(self, lag: Union[int, Literal["auto"]], horizon: Optional[int] = No self._created_regressors: Optional[List[str]] = None self._exog_shifts: Optional[Dict[str, int]] = None + self._exog_last_date: Optional[Dict[str, pd.Timestamp]] = None if isinstance(lag, int): if lag <= 0: @@ -146,6 +148,41 @@ def __init__(self, lag: Union[int, Literal["auto"]], horizon: Optional[int] = No self.horizon = horizon self._auto = True + def _save_exog_last_date(self, df_exog: pd.DataFrame): + """Save last available date of each exogenous variable.""" + exog_names = set(df_exog.columns.get_level_values("feature")) + + self._exog_last_date = dict() + for name in exog_names: + feature = df_exog.loc[:, pd.IndexSlice[:, name]] + + na_mask = pd.isna(feature).any(axis=1) + last_date = feature.index[~na_mask].max() + + self._exog_last_date[name] = last_date + + def fit(self, ts: TSDataset) -> "ExogShiftTransform": + """Fit the transform. + + Parameters + ---------- + ts: + Dataset to fit the transform on. + + Returns + ------- + : + The fitted transform instance. + """ + + df_exog = ts.df_exog + if df_exog is not None and isinstance(df_exog, pd.DataFrame): + self._save_exog_last_date(df_exog=df_exog) + + super().fit(ts=ts) + + return self + def _fit(self, df: pd.DataFrame) -> "ExogShiftTransform": """Estimate shifts for exogenous variables. diff --git a/tests/test_transforms/test_base/test_base.py b/tests/test_transforms/test_base/test_base.py index 8f6c5ca1e..03cd58b5f 100644 --- a/tests/test_transforms/test_base/test_base.py +++ b/tests/test_transforms/test_base/test_base.py @@ -234,25 +234,3 @@ def test_inverse_transform_with_target_components_target_not_in_required_feature transform = AddConstTransform(in_column="exog", value=-10) transform.inverse_transform(ts=ts_with_target_components) pd.testing.assert_frame_equal(ts_with_target_components.get_target_components(), target_components_before) - - -@pytest.fixture() -def df_exog_with_nans(): - df = pd.DataFrame( - { - "timestamp": list(pd.date_range("2023-01-01", periods=5)) * 2, - "segment": ["A"] * 5 + ["B"] * 5, - "feat1": [1, 2, 3, 4, None] + [1, 2, 3, 4, 5], - "feat2": [1, 2, 3, None, None] + [1, 2, 3, None, None], - } - ) - - return TSDataset.to_dataset(df=df) - - -def test_save_exog_last_date( - df_exog_with_nans, expected={"feat1": pd.Timestamp("2023-01-04"), "feat2": pd.Timestamp("2023-01-03")} -): - tr = TransformMock(required_features="all") - tr._save_exog_last_date(df_exog=df_exog_with_nans) - assert tr._exog_last_date == expected diff --git a/tests/test_transforms/test_math/test_exog_shift_transform.py b/tests/test_transforms/test_math/test_exog_shift_transform.py index 400b08eb1..bfa9ca23e 100644 --- a/tests/test_transforms/test_math/test_exog_shift_transform.py +++ b/tests/test_transforms/test_math/test_exog_shift_transform.py @@ -37,6 +37,11 @@ def build_ts_with_exogs(freq="D"): return ts +@pytest.fixture() +def df_exog_with_nans(): + return build_df_exog_with_nans(freq="D") + + @pytest.fixture() def ts_with_exogs(): return build_ts_with_exogs(freq="D") @@ -46,6 +51,21 @@ def ts_with_exogs(): def ts_with_exogs_ms_freq(): return build_ts_with_exogs(freq="MS") +@pytest.mark.parametrize( + "expected", + ( + { + "feat1": pd.Timestamp("2023-01-04"), + "feat2": pd.Timestamp("2023-01-03"), + 'feat3': pd.Timestamp('2023-01-05 00:00:00') + }, + ) +) +def test_save_exog_last_date(df_exog_with_nans, expected): + t = ExogShiftTransform(lag=1) + t._save_exog_last_date(df_exog=df_exog_with_nans) + assert t._exog_last_date == expected + def test_negative_lag(): with pytest.raises(ValueError, match=".* works only with positive lags"): From e62c4c9fcd0e5d2e6b08076de6542f75d2a205f9 Mon Sep 17 00:00:00 2001 From: brsnw250 Date: Fri, 5 May 2023 15:03:10 +0300 Subject: [PATCH 11/18] reworked `_get_feature_names` --- etna/transforms/math/lags.py | 22 ++++++++++++---------- 1 file changed, 12 insertions(+), 10 deletions(-) diff --git a/etna/transforms/math/lags.py b/etna/transforms/math/lags.py index cc38f3ad6..ec0c082bf 100644 --- a/etna/transforms/math/lags.py +++ b/etna/transforms/math/lags.py @@ -7,8 +7,6 @@ import pandas as pd from etna.datasets import TSDataset -from etna.datasets.utils import match_target_components -from etna.datasets.utils import match_target_quantiles from etna.models.utils import determine_num_steps from etna.transforms.base import FutureMixin from etna.transforms.base import IrreversibleTransform @@ -132,6 +130,7 @@ def __init__(self, lag: Union[int, Literal["auto"]], horizon: Optional[int] = No self._created_regressors: Optional[List[str]] = None self._exog_shifts: Optional[Dict[str, int]] = None self._exog_last_date: Optional[Dict[str, pd.Timestamp]] = None + self._filter_out_columns = {"target"} if isinstance(lag, int): if lag <= 0: @@ -210,17 +209,20 @@ def _fit(self, df: pd.DataFrame) -> "ExogShiftTransform": return self - @staticmethod - def _get_feature_names(df: pd.DataFrame) -> List[str]: + def _get_feature_names(self, df: pd.DataFrame) -> List[str]: """Return the names of exogenous variables.""" - names = set(df.columns.get_level_values("feature")) + if self._exog_last_date is not None: + feature_names = list(self._exog_last_date.keys()) - names_to_remove = {"target"} - names_to_remove |= match_target_quantiles(names) - names_to_remove |= match_target_components(names) + else: + feature_names = [] + + df_columns = df.columns.get_level_values("feature") + for name in feature_names: + if name not in df_columns: + raise ValueError(f"Feature `{name}` is expected to be in the dataframe!") - features = names - names_to_remove - return list(features) + return feature_names def _estimate_shift(self, df: pd.DataFrame, feature_name: str) -> int: """Estimate shift value for exogenous variable.""" From 6e6572e7a876efac67ac65501d52f67a0ffa736b Mon Sep 17 00:00:00 2001 From: brsnw250 Date: Fri, 5 May 2023 15:03:33 +0300 Subject: [PATCH 12/18] added tests --- .../test_math/test_exog_shift_transform.py | 13 ++++++++++++- 1 file changed, 12 insertions(+), 1 deletion(-) diff --git a/tests/test_transforms/test_math/test_exog_shift_transform.py b/tests/test_transforms/test_math/test_exog_shift_transform.py index bfa9ca23e..efc07484f 100644 --- a/tests/test_transforms/test_math/test_exog_shift_transform.py +++ b/tests/test_transforms/test_math/test_exog_shift_transform.py @@ -88,10 +88,21 @@ def test_regressors_info_not_fit(): def test_get_feature_names(example_reg_tsds, expected={"regressor_exog_weekend"}): - feature_names = ExogShiftTransform._get_feature_names(example_reg_tsds.df) + t = ExogShiftTransform(lag=1) + t.fit(ts=example_reg_tsds) + feature_names = t._get_feature_names(example_reg_tsds.df) assert set(feature_names) == expected +def test_get_feature_names_no_exog_error(ts_with_exogs): + t = ExogShiftTransform(lag=1) + t.fit(ts=ts_with_exogs) + df = ts_with_exogs.df.drop(columns=["feat3"], level=1) + + with pytest.raises(ValueError, match="Feature `feat3` is expected to be in the dataframe!"): + t._get_feature_names(df) + + @pytest.mark.parametrize( "ts_name", ("toy_dataset_with_mean_shift_in_target", "product_level_constant_forecast_with_target_components") ) From 826ed0e424db47882d3babcac81f11b70a16bda9 Mon Sep 17 00:00:00 2001 From: brsnw250 Date: Fri, 5 May 2023 15:42:52 +0300 Subject: [PATCH 13/18] formatting --- etna/transforms/math/lags.py | 1 - .../test_math/test_exog_shift_transform.py | 13 +++++++------ 2 files changed, 7 insertions(+), 7 deletions(-) diff --git a/etna/transforms/math/lags.py b/etna/transforms/math/lags.py index ec0c082bf..023b10f23 100644 --- a/etna/transforms/math/lags.py +++ b/etna/transforms/math/lags.py @@ -173,7 +173,6 @@ def fit(self, ts: TSDataset) -> "ExogShiftTransform": : The fitted transform instance. """ - df_exog = ts.df_exog if df_exog is not None and isinstance(df_exog, pd.DataFrame): self._save_exog_last_date(df_exog=df_exog) diff --git a/tests/test_transforms/test_math/test_exog_shift_transform.py b/tests/test_transforms/test_math/test_exog_shift_transform.py index efc07484f..317b5869b 100644 --- a/tests/test_transforms/test_math/test_exog_shift_transform.py +++ b/tests/test_transforms/test_math/test_exog_shift_transform.py @@ -51,15 +51,16 @@ def ts_with_exogs(): def ts_with_exogs_ms_freq(): return build_ts_with_exogs(freq="MS") + @pytest.mark.parametrize( "expected", ( - { - "feat1": pd.Timestamp("2023-01-04"), - "feat2": pd.Timestamp("2023-01-03"), - 'feat3': pd.Timestamp('2023-01-05 00:00:00') - }, - ) + { + "feat1": pd.Timestamp("2023-01-04"), + "feat2": pd.Timestamp("2023-01-03"), + "feat3": pd.Timestamp("2023-01-05 00:00:00"), + }, + ), ) def test_save_exog_last_date(df_exog_with_nans, expected): t = ExogShiftTransform(lag=1) From bced70bb18b82343c64fc7edb987cee9c3560e17 Mon Sep 17 00:00:00 2001 From: brsnw250 Date: Wed, 10 May 2023 11:39:18 +0300 Subject: [PATCH 14/18] review fixes --- CHANGELOG.md | 2 +- etna/transforms/math/lags.py | 22 ++++++++++++++-------- 2 files changed, 15 insertions(+), 9 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 6b9b19610..fd8b07b0a 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -8,7 +8,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ## Unreleased ### Added - Notebook `forecast_interpretation.ipynb` with forecast decomposition ([#1220](https://github.com/tinkoff-ai/etna/pull/1220)) -- +- Exogenous variables shift transform `ExogShiftTransform`([#1254](https://github.com/tinkoff-ai/etna/pull/1254)) - - ### Changed diff --git a/etna/transforms/math/lags.py b/etna/transforms/math/lags.py index 023b10f23..e9397faef 100644 --- a/etna/transforms/math/lags.py +++ b/etna/transforms/math/lags.py @@ -127,6 +127,7 @@ def __init__(self, lag: Union[int, Literal["auto"]], horizon: Optional[int] = No self.horizon: Optional[int] = None self._auto = False + self._freq: Optional[str] = None self._created_regressors: Optional[List[str]] = None self._exog_shifts: Optional[Dict[str, int]] = None self._exog_last_date: Optional[Dict[str, pd.Timestamp]] = None @@ -173,10 +174,15 @@ def fit(self, ts: TSDataset) -> "ExogShiftTransform": : The fitted transform instance. """ + self._freq = ts.freq df_exog = ts.df_exog - if df_exog is not None and isinstance(df_exog, pd.DataFrame): + + if df_exog is not None: self._save_exog_last_date(df_exog=df_exog) + else: + self._exog_last_date = {} + super().fit(ts=ts) return self @@ -210,7 +216,7 @@ def _fit(self, df: pd.DataFrame) -> "ExogShiftTransform": def _get_feature_names(self, df: pd.DataFrame) -> List[str]: """Return the names of exogenous variables.""" - if self._exog_last_date is not None: + if self._exog_last_date is not None and len(self._exog_last_date) > 0: feature_names = list(self._exog_last_date.keys()) else: @@ -228,16 +234,17 @@ def _estimate_shift(self, df: pd.DataFrame, feature_name: str) -> int: if not self._auto: return self.lag # type: ignore - freq = pd.infer_freq(df.index) + if self._exog_last_date is None or self._freq is None: + raise ValueError("Call `fit()` method before estimating exog shifts!") last_date = df.index.max() - last_feature_date = self._exog_last_date[feature_name] # type: ignore + last_feature_date = self._exog_last_date[feature_name] if last_feature_date > last_date: - delta = -determine_num_steps(start_timestamp=last_date, end_timestamp=last_feature_date, freq=freq) + delta = -determine_num_steps(start_timestamp=last_date, end_timestamp=last_feature_date, freq=self._freq) elif last_feature_date < last_date: - delta = determine_num_steps(start_timestamp=last_feature_date, end_timestamp=last_date, freq=freq) + delta = determine_num_steps(start_timestamp=last_feature_date, end_timestamp=last_date, freq=self._freq) else: delta = 0 @@ -263,7 +270,6 @@ def _transform(self, df: pd.DataFrame) -> pd.DataFrame: raise ValueError("Transform is not fitted!") result = df - freq = pd.infer_freq(df.index) segments = sorted(set(df.columns.get_level_values("segment"))) feature_names = self._get_feature_names(df=df) @@ -275,7 +281,7 @@ def _transform(self, df: pd.DataFrame) -> pd.DataFrame: feature = df.loc[:, pd.IndexSlice[:, feature_name]] if shift > 0: - shifted_feature = feature.shift(shift, freq=freq) + shifted_feature = feature.shift(shift, freq=self._freq) column_name = f"{feature_name}_shift_{shift}" shifted_feature.columns = pd.MultiIndex.from_product([segments, [column_name]]) From 362ec4bbb512285376a7b6b26063616a91dcdca1 Mon Sep 17 00:00:00 2001 From: brsnw250 Date: Wed, 10 May 2023 11:39:30 +0300 Subject: [PATCH 15/18] added test --- tests/test_transforms/test_math/test_exog_shift_transform.py | 5 +++++ 1 file changed, 5 insertions(+) diff --git a/tests/test_transforms/test_math/test_exog_shift_transform.py b/tests/test_transforms/test_math/test_exog_shift_transform.py index 317b5869b..6ce7082e0 100644 --- a/tests/test_transforms/test_math/test_exog_shift_transform.py +++ b/tests/test_transforms/test_math/test_exog_shift_transform.py @@ -88,6 +88,11 @@ def test_regressors_info_not_fit(): ExogShiftTransform(lag=1).get_regressors_info() +def test_estimate_shift_not_fit(): + with pytest.raises(ValueError, match=".* method before estimating exog shifts!"): + ExogShiftTransform(lag="auto", horizon=1)._estimate_shift(None, None) + + def test_get_feature_names(example_reg_tsds, expected={"regressor_exog_weekend"}): t = ExogShiftTransform(lag=1) t.fit(ts=example_reg_tsds) From 49ed970bd2410c6aef9a0552893390cea39594a7 Mon Sep 17 00:00:00 2001 From: brsnw250 Date: Wed, 10 May 2023 13:48:15 +0300 Subject: [PATCH 16/18] moved exog check --- etna/transforms/math/lags.py | 25 ++++++++++--------------- 1 file changed, 10 insertions(+), 15 deletions(-) diff --git a/etna/transforms/math/lags.py b/etna/transforms/math/lags.py index e9397faef..de88b2af6 100644 --- a/etna/transforms/math/lags.py +++ b/etna/transforms/math/lags.py @@ -148,18 +148,19 @@ def __init__(self, lag: Union[int, Literal["auto"]], horizon: Optional[int] = No self.horizon = horizon self._auto = True - def _save_exog_last_date(self, df_exog: pd.DataFrame): + def _save_exog_last_date(self, df_exog: Optional[pd.DataFrame] = None): """Save last available date of each exogenous variable.""" - exog_names = set(df_exog.columns.get_level_values("feature")) + self._exog_last_date = {} + if df_exog is not None: + exog_names = set(df_exog.columns.get_level_values("feature")) - self._exog_last_date = dict() - for name in exog_names: - feature = df_exog.loc[:, pd.IndexSlice[:, name]] + for name in exog_names: + feature = df_exog.loc[:, pd.IndexSlice[:, name]] - na_mask = pd.isna(feature).any(axis=1) - last_date = feature.index[~na_mask].max() + na_mask = pd.isna(feature).any(axis=1) + last_date = feature.index[~na_mask].max() - self._exog_last_date[name] = last_date + self._exog_last_date[name] = last_date def fit(self, ts: TSDataset) -> "ExogShiftTransform": """Fit the transform. @@ -175,13 +176,7 @@ def fit(self, ts: TSDataset) -> "ExogShiftTransform": The fitted transform instance. """ self._freq = ts.freq - df_exog = ts.df_exog - - if df_exog is not None: - self._save_exog_last_date(df_exog=df_exog) - - else: - self._exog_last_date = {} + self._save_exog_last_date(df_exog=ts.df_exog) super().fit(ts=ts) From 4dd6333e7adbf0f6948fb64c2952c2f1c2aa70b5 Mon Sep 17 00:00:00 2001 From: brsnw250 Date: Wed, 10 May 2023 15:09:10 +0300 Subject: [PATCH 17/18] reworked check --- etna/transforms/math/lags.py | 6 ++---- 1 file changed, 2 insertions(+), 4 deletions(-) diff --git a/etna/transforms/math/lags.py b/etna/transforms/math/lags.py index de88b2af6..38d8f4f40 100644 --- a/etna/transforms/math/lags.py +++ b/etna/transforms/math/lags.py @@ -211,12 +211,10 @@ def _fit(self, df: pd.DataFrame) -> "ExogShiftTransform": def _get_feature_names(self, df: pd.DataFrame) -> List[str]: """Return the names of exogenous variables.""" - if self._exog_last_date is not None and len(self._exog_last_date) > 0: + feature_names = [] + if self._exog_last_date is not None: feature_names = list(self._exog_last_date.keys()) - else: - feature_names = [] - df_columns = df.columns.get_level_values("feature") for name in feature_names: if name not in df_columns: From 380fb7f0398bef6b9390c21d9339a97c5fcfc646 Mon Sep 17 00:00:00 2001 From: brsnw250 Date: Wed, 10 May 2023 15:09:28 +0300 Subject: [PATCH 18/18] added test --- .../test_transforms/test_math/test_exog_shift_transform.py | 6 ++++++ 1 file changed, 6 insertions(+) diff --git a/tests/test_transforms/test_math/test_exog_shift_transform.py b/tests/test_transforms/test_math/test_exog_shift_transform.py index 6ce7082e0..3d13319fe 100644 --- a/tests/test_transforms/test_math/test_exog_shift_transform.py +++ b/tests/test_transforms/test_math/test_exog_shift_transform.py @@ -93,6 +93,12 @@ def test_estimate_shift_not_fit(): ExogShiftTransform(lag="auto", horizon=1)._estimate_shift(None, None) +def test_transform_not_fit(ts_with_exogs): + t = ExogShiftTransform(lag=1) + with pytest.raises(ValueError, match="Transform is not fitted!"): + t.transform(ts=ts_with_exogs) + + def test_get_feature_names(example_reg_tsds, expected={"regressor_exog_weekend"}): t = ExogShiftTransform(lag=1) t.fit(ts=example_reg_tsds)