diff --git a/python/prophet/forecaster.py b/python/prophet/forecaster.py index 261776c85..8af6202a4 100644 --- a/python/prophet/forecaster.py +++ b/python/prophet/forecaster.py @@ -1200,8 +1200,41 @@ def set_auto_seasonalities(self) -> None: # Yearly seasonality yearly_disable = last - first < pd.Timedelta(days=730) + # A seasonality is only identifiable up to the Nyquist limit of the + # sampling interval. Harmonics above period / (2 * min_dt) alias onto + # lower ones, so their coefficients are set by the prior rather than by + # the data, and the fitted shape then moves a long way for a small + # change in the input. Weekly and daily seasonality already decline to + # turn on once min_dt reaches their period; yearly keeps the component + # and lowers the order instead, so monthly data still gets a yearly + # shape. An explicitly requested order is left alone. + default_yearly_order = 10 + if pd.notna(min_dt) and min_dt > pd.Timedelta(0): # pyrefly:ignore[unsupported-operation] + default_yearly_order = min( + default_yearly_order, + int(365.25 / (2 * (min_dt / pd.Timedelta(days=1)))), # pyrefly:ignore[unsupported-operation] + ) fourier_order = self.parse_seasonality_args( - 'yearly', self.yearly_seasonality, yearly_disable, 10) + 'yearly', self.yearly_seasonality, yearly_disable, + default_yearly_order) + order_was_chosen_for_us = ( + self.yearly_seasonality == 'auto' or self.yearly_seasonality is True + ) + if default_yearly_order < 10 and order_was_chosen_for_us: + logger.info( + 'Yearly seasonality {outcome} by the {spacing} spacing between ' + 'dates in the history; harmonics above that are not ' + 'identifiable from this sampling rate. Pass ' + 'yearly_seasonality= to override.' + .format( + outcome=( + 'disabled' if default_yearly_order == 0 + else 'fourier order limited to {0}'.format( + default_yearly_order) + ), + spacing=min_dt, + ) + ) if fourier_order > 0 and yearly_disable: logger.warning( 'Yearly seasonality is enabled with less than 730 days ' diff --git a/python/prophet/tests/test_prophet.py b/python/prophet/tests/test_prophet.py index 0c5e9d0e2..e84e7d80a 100644 --- a/python/prophet/tests/test_prophet.py +++ b/python/prophet/tests/test_prophet.py @@ -534,6 +534,46 @@ def test_auto_yearly_seasonality(self, daily_univariate_ts, backend): "condition_name": None, } + def test_auto_yearly_seasonality_order_follows_sampling_rate(self, backend): + """Yearly harmonics above the Nyquist limit of the spacing are not identifiable. + + Monthly data carries 12 points a year, so only 6 harmonics can be + resolved. The default order of 10 left four of them fixed by the prior + rather than by the data, which is what allowed the same series shifted + by one year to move the 12-month forecast total by -25% (#2677). + Weekly and daily seasonality already decline to turn on once the + spacing reaches their period; yearly keeps the component instead and + lowers the order, so monthly data still gets a yearly shape. + """ + + def auto_order(freq, periods, yearly_seasonality="auto"): + m = Prophet( + yearly_seasonality=yearly_seasonality, + weekly_seasonality=False, + daily_seasonality=False, + stan_backend=backend, + ) + m.history = pd.DataFrame({ + "ds": pd.date_range("2015-01-01", periods=periods, freq=freq), + "y": np.arange(periods, dtype=float), + }) + m.set_auto_seasonalities() + return m.seasonalities.get("yearly", {}).get("fourier_order", 0) + + # Well below the limit, so the default order is untouched. + assert auto_order("h", 24 * 800) == 10 + assert auto_order("D", 900) == 10 + assert auto_order("W", 160) == 10 + # Past it, the order is only what the spacing can support. + assert auto_order("MS", 48) == 6 + assert auto_order("QS", 20) == 2 + # Annual sampling resolves no harmonics at all, so it is dropped. + assert auto_order("YS", 8) == 0 + # An explicit order is the caller's decision and is left alone. + assert auto_order("MS", 48, yearly_seasonality=10) == 10 + # True asks for the component, not for one particular order. + assert auto_order("MS", 48, yearly_seasonality=True) == 6 + def test_yearly_seasonality_warns_on_short_history(self, caplog, backend): m = Prophet( yearly_seasonality=True,