From 65b2bb0c12c69695e91c2d313a5e6d96c93bf27c Mon Sep 17 00:00:00 2001 From: Nishchay Mahor Date: Thu, 29 Oct 2026 16:40:23 -0700 Subject: [PATCH 1/2] fix: cap the auto yearly seasonality order at what the sampling rate supports A seasonality is only identifiable up to the Nyquist limit of the spacing between observations. Monthly data carries 12 points a year, so 6 harmonics can be resolved, but the auto order was 10 regardless of frequency. The top four harmonics alias onto lower ones and their coefficients are set by the prior rather than by the data, so the fitted shape moves a long way for a small change in the input. On the series in #2677, shifting the same 48 monthly values by one calendar year moved the 12-month forecast total by -25.1% and one point by -104.5%, flipping it negative because the multiplicative term reached -1.05. With the order capped to 6 the same shift moves the total by -0.3% and nothing goes negative. Weekly and daily seasonality already decline to turn on once the spacing reaches their period. This applies the same reasoning to yearly, keeping the component and lowering the order so monthly data still gets a yearly shape. Hourly, daily and weekly sampling are unaffected, and an explicitly requested order is left alone. --- python/prophet/forecaster.py | 35 +++++++++++++++++++++++- python/prophet/tests/test_prophet.py | 40 ++++++++++++++++++++++++++++ 2 files changed, 74 insertions(+), 1 deletion(-) diff --git a/python/prophet/forecaster.py b/python/prophet/forecaster.py index 261776c85..d342678f7 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): + default_yearly_order = min( + default_yearly_order, + int(365.25 / (2 * (min_dt / pd.Timedelta(days=1)))), + ) 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, From e0997692f8ae17546337c36f517ab771597ff48c Mon Sep 17 00:00:00 2001 From: Nishchay Mahor Date: Fri, 30 Oct 2026 21:00:06 -0700 Subject: [PATCH 2/2] fix: silence pyrefly on the new Timedelta comparisons The weekly and daily guards already carry this suppression for the same false positive on min_dt. --- python/prophet/forecaster.py | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/python/prophet/forecaster.py b/python/prophet/forecaster.py index d342678f7..8af6202a4 100644 --- a/python/prophet/forecaster.py +++ b/python/prophet/forecaster.py @@ -1209,10 +1209,10 @@ def set_auto_seasonalities(self) -> None: # 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): + 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)))), + 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,