diff --git a/pymc_marketing/constants.py b/pymc_marketing/constants.py index 429e3b60..7d9c4074 100644 --- a/pymc_marketing/constants.py +++ b/pymc_marketing/constants.py @@ -15,3 +15,4 @@ DAYS_IN_YEAR: float = 365.25 DAYS_IN_MONTH: float = DAYS_IN_YEAR / 12 +DAYS_IN_WEEK: int = 7 diff --git a/pymc_marketing/mmm/__init__.py b/pymc_marketing/mmm/__init__.py index ca742ef2..fa3a93b7 100644 --- a/pymc_marketing/mmm/__init__.py +++ b/pymc_marketing/mmm/__init__.py @@ -35,7 +35,7 @@ TanhSaturationBaselined, saturation_from_dict, ) -from pymc_marketing.mmm.fourier import MonthlyFourier, YearlyFourier +from pymc_marketing.mmm.fourier import MonthlyFourier, WeeklyFourier, YearlyFourier from pymc_marketing.mmm.hsgp import ( HSGP, CovFunc, @@ -85,6 +85,7 @@ "SaturationTransformation", "TanhSaturation", "TanhSaturationBaselined", + "WeeklyFourier", "WeibullCDFAdstock", "WeibullPDFAdstock", "YearlyFourier", diff --git a/pymc_marketing/mmm/fourier.py b/pymc_marketing/mmm/fourier.py index d62afdb0..bffc8729 100644 --- a/pymc_marketing/mmm/fourier.py +++ b/pymc_marketing/mmm/fourier.py @@ -21,6 +21,7 @@ - Yearly Fourier: A yearly seasonality with a period of 365.25 days - Monthly Fourier: A monthly seasonality with a period of 365.25 / 12 days +- Weekly Fourier: A weekly seasonality with a period of 7 days .. plot:: :context: close-figs @@ -221,7 +222,7 @@ from pydantic import BaseModel, Field, InstanceOf, field_serializer, model_validator from typing_extensions import Self -from pymc_marketing.constants import DAYS_IN_MONTH, DAYS_IN_YEAR +from pymc_marketing.constants import DAYS_IN_MONTH, DAYS_IN_WEEK, DAYS_IN_YEAR from pymc_marketing.deserialize import deserialize, register_deserialization from pymc_marketing.plot import SelToString, plot_curve, plot_hdi, plot_samples from pymc_marketing.prior import Prior, VariableFactory, create_dim_handler @@ -383,9 +384,20 @@ def _get_default_start_date(self) -> datetime.datetime: """ pass # pragma: no cover + @abstractmethod + def _get_days_in_period(self, dates: pd.DatetimeIndex) -> pd.Index: + """Return the relevant day within the characteristic periodicity. + + Returns + ------- + int or float + The relevant period within the characteristic periodicity + """ + pass + def apply( self, - dayofyear: pt.TensorLike, + dayofperiod: pt.TensorLike, result_callback: Callable[[pt.TensorVariable], None] | None = None, ) -> pt.TensorVariable: """Apply fourier seasonality to day of year. @@ -394,8 +406,8 @@ def apply( Parameters ---------- - dayofyear : pt.TensorLike - Day of year. + dayofperiod : pt.TensorLike + Day of year or weekday result_callback : Callable[[pt.TensorVariable], None], optional Callback function to apply to the result, by default None @@ -431,7 +443,7 @@ def callback(result): fourier.apply(dayofyear, result_callback=callback) """ - periods = dayofyear / self.days_in_period + periods = dayofperiod / self.days_in_period model = pm.modelcontext(None) model.add_coord(self.prefix, self.nodes) @@ -506,15 +518,15 @@ def sample_curve( start_date = self.get_default_start_date(start_date=start_date) date_range = pd.date_range( start=start_date, - periods=int(self.days_in_period) + 1, + periods=np.ceil(self.days_in_period) + 1, freq="D", ) coords["date"] = date_range.to_numpy() - dayofyear = date_range.dayofyear.to_numpy() + dayofperiod = self._get_days_in_period(date_range).to_numpy() else: coords["day"] = full_period - dayofyear = full_period + dayofperiod = full_period for key, values in parameters[self.variable_name].coords.items(): if key in {"chain", "draw", self.prefix}: @@ -525,7 +537,7 @@ def sample_curve( name = f"{self.prefix}_trend" pm.Deterministic( name, - self.apply(dayofyear=dayofyear), + self.apply(dayofperiod=dayofperiod), dims=tuple(coords.keys()), ) @@ -777,6 +789,16 @@ def _get_default_start_date(self) -> datetime.datetime: current_year = datetime.datetime.now().year return datetime.datetime(year=current_year, month=1, day=1) + def _get_days_in_period(self, dates: pd.DatetimeIndex) -> pd.Index: + """Return the dayofyear within the yearly periodicity. + + Returns + ------- + int or float + The relevant period within the characteristic periodicity + """ + return dates.dayofyear + class MonthlyFourier(FourierBase): """Monthly fourier seasonality. @@ -799,11 +821,11 @@ class MonthlyFourier(FourierBase): mu = np.array([0, 0, 0.5, 0]) b = 0.075 dist = Prior("Laplace", mu=mu, b=b, dims="fourier") - yearly = MonthlyFourier(n_order=2, prior=dist) - prior = yearly.sample_prior(samples=100) - curve = yearly.sample_curve(prior) + monthly = MonthlyFourier(n_order=2, prior=dist) + prior = monthly.sample_prior(samples=100) + curve = monthly.sample_curve(prior) - _, axes = yearly.plot_curve(curve) + _, axes = monthly.plot_curve(curve) axes[0].set(title="Monthly Fourier Seasonality") plt.show() @@ -832,6 +854,83 @@ def _get_default_start_date(self) -> datetime.datetime: now = datetime.datetime.now() return datetime.datetime(year=now.year, month=now.month, day=1) + def _get_days_in_period(self, dates: pd.DatetimeIndex) -> pd.Index: + """Return the dayofyear within the yearly periodicity. + + Returns + ------- + int or float + The relevant period within the characteristic periodicity + """ + return dates.dayofyear + + +class WeeklyFourier(FourierBase): + """Weekly fourier seasonality. + + .. plot:: + :context: close-figs + + import arviz as az + import matplotlib.pyplot as plt + import numpy as np + + from pymc_marketing.mmm import WeeklyFourier + from pymc_marketing.prior import Prior + + az.style.use("arviz-white") + + seed = sum(map(ord, "Weekly")) + rng = np.random.default_rng(seed) + + mu = np.array([0, 0, 0.5, 0]) + b = 0.075 + dist = Prior("Laplace", mu=mu, b=b, dims="fourier") + weekly = WeeklyFourier(n_order=2, prior=dist) + prior = weekly.sample_prior(samples=100) + curve = weekly.sample_curve(prior) + + _, axes = weekly.plot_curve(curve) + axes[0].set(title="Weekly Fourier Seasonality") + plt.show() + + n_order : int + Number of fourier modes to use. + prefix : str, optional + Alternative prefix for the fourier seasonality, by default None or + "fourier" + prior : Prior | VariableFactory, optional + Prior distribution or VariableFactory for the fourier seasonality beta parameters, by + default `Prior("Laplace", mu=0, b=1)` + name : str, optional + Name of the variable that multiplies the fourier modes, by default None + variable_name : str, optional + Name of the variable that multiplies the fourier modes, by default None + + """ + + days_in_period: float = DAYS_IN_WEEK + + def _get_default_start_date(self) -> datetime.datetime: + """Get the default start date for weekly seasonality. + + Returns the first day of the current month. + """ + now = datetime.datetime.now() + return datetime.datetime.fromisocalendar( + year=now.year, week=now.isocalendar().week, day=1 + ) + + def _get_days_in_period(self, dates: pd.DatetimeIndex) -> pd.Index: + """Return the weekday within the weekly periodicity. + + Returns + ------- + int or float + The relevant period within the characteristic periodicity + """ + return dates.weekday + def _is_yearly_fourier(data: Any) -> bool: return data.get("class") == "YearlyFourier" @@ -841,6 +940,10 @@ def _is_monthly_fourier(data: Any) -> bool: return data.get("class") == "MonthlyFourier" +def _is_weekly_fourier(data: Any) -> bool: + return data.get("class") == "WeeklyFourier" + + register_deserialization( is_type=_is_yearly_fourier, deserialize=lambda data: YearlyFourier.from_dict(data), @@ -850,3 +953,7 @@ def _is_monthly_fourier(data: Any) -> bool: is_type=_is_monthly_fourier, deserialize=lambda data: MonthlyFourier.from_dict(data), ) + +register_deserialization( + is_type=_is_weekly_fourier, deserialize=lambda data: WeeklyFourier.from_dict(data) +) diff --git a/tests/mmm/test_fourier.py b/tests/mmm/test_fourier.py index 54f8338e..08d90992 100644 --- a/tests/mmm/test_fourier.py +++ b/tests/mmm/test_fourier.py @@ -27,57 +27,144 @@ from pymc_marketing.mmm.fourier import ( FourierBase, MonthlyFourier, + WeeklyFourier, YearlyFourier, generate_fourier_modes, ) from pymc_marketing.prior import Prior -def test_prior_without_dims() -> None: +@pytest.mark.parametrize( + argnames="seasonality", + argvalues=[YearlyFourier, MonthlyFourier, WeeklyFourier], + ids=[ + "yearly", + "monthly", + "weekly", + ], +) +def test_prior_without_dims(seasonality) -> None: prior = Prior("Normal") - yearly = YearlyFourier(n_order=2, prior=prior) + periodicity = seasonality(n_order=2, prior=prior) - assert yearly.prior.dims == (yearly.prefix,) + assert periodicity.prior.dims == (periodicity.prefix,) assert prior.dims == () -def test_prior_doesnt_have_prefix() -> None: +@pytest.mark.parametrize( + argnames="seasonality", + argvalues=[YearlyFourier, MonthlyFourier, WeeklyFourier], + ids=[ + "yearly", + "monthly", + "weekly", + ], +) +def test_prior_doesnt_have_prefix(seasonality) -> None: prior = Prior("Normal", dims="hierarchy") with pytest.raises(ValueError, match="Prior distribution must have"): - YearlyFourier(n_order=2, prior=prior) + seasonality(n_order=2, prior=prior) -def test_nodes() -> None: - yearly = YearlyFourier(n_order=2) +@pytest.mark.parametrize( + argnames="seasonality", + argvalues=[YearlyFourier, MonthlyFourier, WeeklyFourier], + ids=[ + "yearly", + "monthly", + "weekly", + ], +) +def test_nodes(seasonality) -> None: + periodicity = seasonality(n_order=2) - assert yearly.nodes == ["sin_1", "sin_2", "cos_1", "cos_2"] + assert periodicity.nodes == ["sin_1", "sin_2", "cos_1", "cos_2"] -def test_sample_prior() -> None: +@pytest.mark.parametrize( + argnames="seasonality", + argvalues=[YearlyFourier, MonthlyFourier, WeeklyFourier], + ids=[ + "yearly", + "monthly", + "weekly", + ], +) +def test_sample_prior(seasonality) -> None: n_order = 2 - yearly = YearlyFourier(n_order=n_order) - prior = yearly.sample_prior(samples=10) + periodicity = seasonality(n_order=n_order) + prior = periodicity.sample_prior(samples=10) assert prior.sizes == { "chain": 1, "draw": 10, - yearly.prefix: n_order * 2, + periodicity.prefix: n_order * 2, } -def test_sample_curve() -> None: +@pytest.mark.parametrize( + argnames="seasonality", + argvalues=[YearlyFourier, MonthlyFourier, WeeklyFourier], + ids=[ + "yearly", + "monthly", + "weekly", + ], +) +def test_sample_curve(seasonality) -> None: n_order = 2 - yearly = YearlyFourier(n_order=n_order) - prior = yearly.sample_prior(samples=10) - curve = yearly.sample_curve(prior) + periodicity = seasonality(n_order=n_order) + prior = periodicity.sample_prior(samples=10) + curve = periodicity.sample_curve(prior) assert curve.sizes == { "chain": 1, "draw": 10, - "day": 367, + "day": np.ceil(periodicity.days_in_period) + 1, } +@pytest.mark.parametrize( + argnames="seasonality", + argvalues=[YearlyFourier, MonthlyFourier, WeeklyFourier], + ids=[ + "yearly", + "monthly", + "weekly", + ], +) +def test_sample_curve_use_dates(seasonality) -> None: + n_order = 2 + periodicity = seasonality(n_order=n_order) + prior = periodicity.sample_prior(samples=10) + curve = periodicity.sample_curve(prior, use_dates=True) + + assert curve.sizes == { + "chain": 1, + "draw": 10, + "date": np.ceil(periodicity.days_in_period) + 1, + } + + +@pytest.mark.parametrize( + argnames="seasonality", + argvalues=[YearlyFourier, MonthlyFourier, WeeklyFourier], + ids=[ + "yearly", + "monthly", + "weekly", + ], +) +def test_sample_curve_same_size(seasonality) -> None: + n_order = 2 + periodicity = seasonality(n_order=n_order) + prior = periodicity.sample_prior(samples=10) + curve_without_dates = periodicity.sample_curve(prior, use_dates=False) + curve_with_dates = periodicity.sample_curve(prior, use_dates=True) + + assert curve_without_dates.shape == curve_with_dates.shape + + def create_mock_variable(coords): shape = [len(values) for values in coords.values()] @@ -112,59 +199,95 @@ def mock_parameters() -> xr.Dataset: ) -def test_sample_curve_additional_dims(mock_parameters) -> None: - yearly = YearlyFourier(n_order=2) - curve = yearly.sample_curve(mock_parameters) +@pytest.mark.parametrize( + argnames="seasonality", + argvalues=[YearlyFourier, MonthlyFourier, WeeklyFourier], + ids=[ + "yearly", + "monthly", + "weekly", + ], +) +def test_sample_curve_additional_dims(mock_parameters, seasonality) -> None: + periodicity = seasonality(n_order=2) + curve = periodicity.sample_curve(mock_parameters) assert curve.sizes == { "chain": 1, "draw": 250, - "day": 367, + "day": np.ceil(periodicity.days_in_period) + 1, } -def test_additional_dimension() -> None: +@pytest.mark.parametrize( + argnames="seasonality", + argvalues=[YearlyFourier, MonthlyFourier, WeeklyFourier], + ids=[ + "yearly", + "monthly", + "weekly", + ], +) +def test_additional_dimension(seasonality) -> None: prior = Prior("Normal", dims=("fourier", "additional_dim", "yet_another_dim")) - yearly = YearlyFourier(n_order=2, prior=prior) + periodicity = YearlyFourier(n_order=2, prior=prior) coords = { "additional_dim": range(2), "yet_another_dim": range(3), } - prior = yearly.sample_prior(samples=10, coords=coords) - curve = yearly.sample_curve(prior) + prior = periodicity.sample_prior(samples=10, coords=coords) + curve = periodicity.sample_curve(prior) assert curve.sizes == { "chain": 1, "draw": 10, "additional_dim": 2, "yet_another_dim": 3, - "day": 367, + "day": np.ceil(periodicity.days_in_period) + 1, } -def test_plot_curve() -> None: +@pytest.mark.parametrize( + argnames="seasonality", + argvalues=[YearlyFourier, MonthlyFourier, WeeklyFourier], + ids=[ + "yearly", + "monthly", + "weekly", + ], +) +def test_plot_curve(seasonality) -> None: prior = Prior("Normal", dims=("fourier", "additional_dim")) - yearly = YearlyFourier(n_order=2, prior=prior) + periodicity = seasonality(n_order=2, prior=prior) coords = {"additional_dim": range(4)} - prior = yearly.sample_prior(samples=10, coords=coords) - curve = yearly.sample_curve(prior) + prior = periodicity.sample_prior(samples=10, coords=coords) + curve = periodicity.sample_curve(prior) subplot_kwargs = {"ncols": 2} - fig, axes = yearly.plot_curve(curve, subplot_kwargs=subplot_kwargs) + fig, axes = periodicity.plot_curve(curve, subplot_kwargs=subplot_kwargs) assert isinstance(fig, plt.Figure) assert axes.shape == (2, 2) @pytest.mark.parametrize("n_order", [0, -1, -100]) -def test_bad_negative_order(n_order) -> None: +@pytest.mark.parametrize( + argnames="seasonality", + argvalues=[YearlyFourier, MonthlyFourier, WeeklyFourier], + ids=[ + "yearly", + "monthly", + "weekly", + ], +) +def test_bad_negative_order(n_order, seasonality) -> None: with pytest.raises( ValueError, - match="1 validation error for YearlyFourier\\nn_order\\n Input should be greater than 0", + match=f"1 validation error for {seasonality.__name__}\\nn_order\\n Input should be greater than 0", ): - YearlyFourier(n_order=n_order) + seasonality(n_order=n_order) @pytest.mark.parametrize( @@ -172,12 +295,21 @@ def test_bad_negative_order(n_order) -> None: argvalues=[2.5, 100.001, "m", None], ids=["neg_float", "neg_float_2", "str", "None"], ) -def test_bad_non_integer_order(n_order) -> None: +@pytest.mark.parametrize( + argnames="seasonality", + argvalues=[YearlyFourier, MonthlyFourier, WeeklyFourier], + ids=[ + "yearly", + "monthly", + "weekly", + ], +) +def test_bad_non_integer_order(n_order, seasonality) -> None: with pytest.raises( ValueError, - match="1 validation error for YearlyFourier\nn_order\n Input should be a valid integer", + match=f"1 validation error for {seasonality.__name__}\nn_order\n Input should be a valid integer", ): - YearlyFourier(n_order=n_order) + seasonality(n_order=n_order) @pytest.mark.parametrize( @@ -188,7 +320,16 @@ def test_bad_non_integer_order(n_order) -> None: (np.ones(shape=1), 1, (1, 1 * 2)), ], ) -def test_fourier_modes_shape(periods, n_order, expected_shape) -> None: +@pytest.mark.parametrize( + argnames="seasonality", + argvalues=[YearlyFourier, MonthlyFourier, WeeklyFourier], + ids=[ + "yearly", + "monthly", + "weekly", + ], +) +def test_fourier_modes_shape(periods, n_order, expected_shape, seasonality) -> None: result = generate_fourier_modes(periods, n_order) assert result.eval().shape == expected_shape @@ -246,9 +387,18 @@ def test_fourier_modes_pythagoras(periods, n_order): assert (abs(norm - 1) < 1e-10).all() -def test_apply_result_callback() -> None: +@pytest.mark.parametrize( + argnames="seasonality", + argvalues=[YearlyFourier, MonthlyFourier, WeeklyFourier], + ids=[ + "yearly", + "monthly", + "weekly", + ], +) +def test_apply_result_callback(seasonality) -> None: n_order = 3 - fourier = YearlyFourier(n_order=n_order) + fourier = seasonality(n_order=n_order) def result_callback(x): pm.Deterministic( @@ -268,21 +418,48 @@ def result_callback(x): assert model["components"].eval().shape == (365, n_order * 2) -def test_error_with_prefix_and_variable_name() -> None: +@pytest.mark.parametrize( + argnames="seasonality", + argvalues=[YearlyFourier, MonthlyFourier, WeeklyFourier], + ids=[ + "yearly", + "monthly", + "weekly", + ], +) +def test_error_with_prefix_and_variable_name(seasonality) -> None: name = "variable_name" with pytest.raises(ValueError, match="Variable name cannot"): - YearlyFourier(n_order=2, prefix=name, variable_name=name) + seasonality(n_order=2, prefix=name, variable_name=name) -def test_change_name() -> None: +@pytest.mark.parametrize( + argnames="seasonality", + argvalues=[YearlyFourier, MonthlyFourier, WeeklyFourier], + ids=[ + "yearly", + "monthly", + "weekly", + ], +) +def test_change_name(seasonality) -> None: variable_name = "variable_name" - fourier = YearlyFourier(n_order=2, variable_name=variable_name) + fourier = seasonality(n_order=2, variable_name=variable_name) prior = fourier.sample_prior(samples=10) assert variable_name in prior -def test_serialization_to_json() -> None: - fourier = YearlyFourier(n_order=2) +@pytest.mark.parametrize( + argnames="seasonality", + argvalues=[YearlyFourier, MonthlyFourier, WeeklyFourier], + ids=[ + "yearly", + "monthly", + "weekly", + ], +) +def test_serialization_to_json(seasonality) -> None: + fourier = seasonality(n_order=2) fourier.model_dump_json() @@ -298,6 +475,12 @@ def monthly_fourier() -> MonthlyFourier: return MonthlyFourier(n_order=2, prior=prior) +@pytest.fixture +def weekly_fourier() -> WeeklyFourier: + prior = Prior("Laplace", mu=0, b=1, dims="fourier") + return WeeklyFourier(n_order=2, prior=prior) + + def test_get_default_start_date_none_yearly(yearly_fourier: YearlyFourier): current_year = datetime.datetime.now().year expected_start_date = datetime.datetime(year=current_year, month=1, day=1) @@ -312,6 +495,15 @@ def test_get_default_start_date_none_monthly(monthly_fourier: MonthlyFourier): assert actual_start_date == expected_start_date +def test_get_default_start_date_none_weekly(weekly_fourier: WeeklyFourier): + now = datetime.datetime.now() + expected_start_date = datetime.datetime.fromisocalendar( + year=now.year, week=now.isocalendar().week, day=1 + ) + actual_start_date = weekly_fourier.get_default_start_date() + assert actual_start_date == expected_start_date + + def test_get_default_start_date_str_yearly(yearly_fourier: YearlyFourier): start_date_str = "2023-02-01" actual_start_date = yearly_fourier.get_default_start_date(start_date=start_date_str) @@ -356,6 +548,27 @@ def test_get_default_start_date_invalid_type_monthly(monthly_fourier: MonthlyFou ) +def test_get_default_start_date_str_weekly(weekly_fourier: WeeklyFourier): + start_date_str = "2023-06-15" + actual_start_date = weekly_fourier.get_default_start_date(start_date=start_date_str) + assert actual_start_date == start_date_str + + +def test_get_default_start_date_datetime_weekly(weekly_fourier: WeeklyFourier): + start_date_dt = datetime.datetime(2023, 7, 1) + actual_start_date = weekly_fourier.get_default_start_date(start_date=start_date_dt) + assert actual_start_date == start_date_dt + + +def test_get_default_start_date_invalid_type_weekly(weekly_fourier: WeeklyFourier): + invalid_start_date = [2023, 1, 1] + with pytest.raises(TypeError) as exc_info: + weekly_fourier.get_default_start_date(start_date=invalid_start_date) + assert "start_date must be a datetime.datetime object, a string, or None" in str( + exc_info.value + ) + + def test_fourier_base_instantiation(): with pytest.raises(TypeError) as exc_info: FourierBase( @@ -373,9 +586,18 @@ def create_variable(self, name: str): return pm.Normal(name, dims=self.dims) -def test_fourier_arbitrary_prior() -> None: +@pytest.mark.parametrize( + argnames="seasonality", + argvalues=[YearlyFourier, MonthlyFourier, WeeklyFourier], + ids=[ + "yearly", + "monthly", + "weekly", + ], +) +def test_fourier_arbitrary_prior(seasonality) -> None: prior = ArbitraryCode(dims=("fourier",)) - fourier = YearlyFourier(n_order=4, prior=prior) + fourier = seasonality(n_order=4, prior=prior) x = np.arange(10) with pm.Model(): @@ -384,7 +606,16 @@ def test_fourier_arbitrary_prior() -> None: assert y.eval().shape == (10,) -def test_fourier_dims_modified() -> None: +@pytest.mark.parametrize( + argnames="seasonality", + argvalues=[YearlyFourier, MonthlyFourier, WeeklyFourier], + ids=[ + "yearly", + "monthly", + "weekly", + ], +) +def test_fourier_dims_modified(seasonality) -> None: prior = ArbitraryCode(dims=()) YearlyFourier(n_order=4, prior=prior) assert prior.dims == "fourier" @@ -413,8 +644,9 @@ def test_fourier_serializable_arbitrary_prior() -> None: [ ("YearlyFourier", YearlyFourier, 365.25), ("MonthlyFourier", MonthlyFourier, 30.4375), + ("WeeklyFourier", WeeklyFourier, 7), ], - ids=["YearlyFourier", "MonthlyFourier"], + ids=["YearlyFourier", "MonthlyFourier", "WeeklyFourier"], ) def test_fourier_to_dict(name, cls, days_in_period) -> None: fourier = cls(n_order=4) @@ -451,8 +683,9 @@ def serialization() -> None: [ ("YearlyFourier", YearlyFourier), ("MonthlyFourier", MonthlyFourier), + ("WeeklyFourier", WeeklyFourier), ], - ids=["YearlyFourier", "MonthlyFourier"], + ids=["YearlyFourier", "MonthlyFourier", "WeeklyFourier"], ) def test_fourier_deserialization(serialization, name, cls) -> None: data = {