Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
35 changes: 34 additions & 1 deletion python/prophet/forecaster.py
Original file line number Diff line number Diff line change
Expand Up @@ -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=<order> 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 '
Expand Down
40 changes: 40 additions & 0 deletions python/prophet/tests/test_prophet.py
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down
Loading