Python & Data Science
Time Series Under review

Why Your Forecast Fails on Christmas: Handling Multiple Seasonalities and Holiday Spikes

Last time, Nora turned to Prophet for the structural break when the bakery chain opened a new store. Its automatic changepoint detection bent the trend to the new sales reality; ARIMA kept dragging forecasts back toward the old baseline.

The ‘Ice Cream at Midnight’ Problem

This is Nora’s flagship problem. Every December, her bakery chain’s forecast falls apart as holiday pastry demand collides with the store’s normal daily, weekly, and yearly rhythms. Say you run a coffee shop. If I asked you to predict how many lattes you’ll sell next Tuesday at 8:00 AM, you wouldn’t just look at yesterday’s sales. You’d instinctively check three “heartbeats” in your data. The daily cycle — you’re busier at 8:00 AM than at 8:00 PM. The weekly cycle: Tuesdays are usually slower than Saturdays. And the yearly cycle: more hot lattes in December than in July.

Data doesn’t have one rhythm; it has many overlapping heartbeats. Most simple models get confused because they read these overlaps as random noise. A model that only tracks the daily trend won’t grasp why a Monday morning in December is three times busier than a Monday morning in June. The goal is to teach the model to read the calendar the way a human does.

So let’s build a dataset that mimics this complexity. We’ll create a signal with a daily rhythm, a weekly bump, and a holiday ramp into Christmas.

import pandas as pd
import numpy as np
import matplotlib.pyplot as plt
from prophet import Prophet

np.random.seed(42)

# 1. Generate synthetic data: 2 years of hourly sales
dates = pd.date_range(start='2021-01-01', end='2022-12-31', freq='H')
n = len(dates)

# Daily heartbeat (busy at 8am, with a smaller bump in the early evening)
daily = 10 + 5 * np.sin(2 * np.pi * (dates.hour - 4) / 24) + 5 * np.sin(4 * np.pi * (dates.hour - 4) / 24)

# Weekly heartbeat (busier on weekends)
weekly = -5 * np.sin(2 * np.pi * dates.dayofweek / 7)

# Yearly heartbeat (busier in winter)
yearly = 10 * np.cos(2 * np.pi * dates.dayofyear / 365)

# The Christmas ramp: a lead-up into Dec 25, a peak, and a short tail --
# a real "halo," not a single flat-day spike
holiday_spike = np.zeros(n)
holiday_spike[(dates.month == 12) & (dates.day == 23)] = 15
holiday_spike[(dates.month == 12) & (dates.day == 24)] = 30
holiday_spike[(dates.month == 12) & (dates.day == 25)] = 50
holiday_spike[(dates.month == 12) & (dates.day == 26)] = 10

# Combine everything with some random noise
y = daily + weekly + yearly + holiday_spike + np.random.normal(0, 2, n)

df = pd.DataFrame({'ds': dates, 'y': y})

plt.figure(figsize=(12, 6))
plt.plot(df['ds'], df['y'], alpha=0.5)
plt.title("The Overlapping Heartbeats of Sales Data")
plt.show()
  • np.random.seed(42) — fixes the random number generator so the noise added below is reproducible; without a seed, every re-run would shuffle the noise and any reading you take off the components plot later would be unrepeatable.
  • from prophet import Prophet — imports Prophet from the prophet package (the fbprophet name was retired years ago and no longer installs on modern Python); this is the corrected import.
  • pd.date_range(start='2021-01-01', end='2022-12-31', freq='H') — generates a DatetimeIndex of hourly timestamps spanning exactly two full years, giving the model enough yearly cycles to learn the Christmas pattern.
  • 5 * np.sin(2 * np.pi * (dates.hour - 4) / 24) — a sine wave that completes one full cycle every 24 hours, shifted so its peak lands on hour 8 instead of the unshifted wave’s hour 4; this is what puts the morning rush at 8am.
  • 5 * np.sin(4 * np.pi * (dates.hour - 4) / 24) — the second harmonic (double frequency) with the same shift; it adds a secondary, smaller bump in the early evening (around 6pm) on top of the morning peak, and deepens the overnight trough.
  • -5 * np.sin(2 * np.pi * dates.dayofweek / 7) — a weekly wave where 7 days map to one rotation; the leading minus sign puts the peak on Saturday (pandas numbers Monday as 0, so an unshifted sin peaks midweek) — this is what makes weekends busier than weekdays, matching Nora’s coffee-shop intuition above.
  • 10 * np.cos(2 * np.pi * dates.dayofyear / 365) — a yearly wave using cosine (shifted 90° from sine) so the peak lands in winter (December) rather than spring.
  • holiday_spike[...] = 15 / 30 / 50 / 10 — builds a 4-day ramp into and out of Christmas (Dec 23 → 24 → 25 → 26) instead of a single-day flat spike, so there’s an actual lead-up for the holiday “halo” window (introduced next) to fit.
  • np.random.normal(0, 2, n) — adds Gaussian noise with mean 0 and standard deviation 2 to every timestamp, so the patterns aren’t perfectly clean and the model has to do real work.

Look at this plot and you see a messy cloud. But hidden inside are predictable patterns. If we don’t tell our model about these specific cycles, it will average them out — producing a forecast that is “roughly right” but specifically wrong every single morning and every single Christmas.

The Calendar is a Liar: Why Holidays are Hard

Holidays aren’t just extra busy days — they’re irregular shocks. Some stay put, like Christmas on December 25th. Others, like Labor Day or Easter, are moving targets that shift from year to year.

What’s happening here is a phenomenon called the Halo Effect. People don’t just shop on Black Friday. They start browsing on Tuesday and keep buying through Cyber Monday. Mark only the holiday itself, and the model treats those Wednesday and Thursday sales spikes as unexplained noise.

So we treat a holiday as a “special event” regressor. We tell the model: this window of time follows a different rulebook.

import holidays

# Create a holiday lookup table
us_holidays = holidays.US(years=[2021, 2022])

holiday_list = []
for date, name in sorted(us_holidays.items()):
    if 'observed' in name.lower():
        continue  # skip the shifted-weekend duplicate; see walkthrough below
    holiday_list.append({
        'holiday': name,
        'ds': pd.to_datetime(date),
        'lower_window': -2, # The 'Halo' starts 2 days before
        'upper_window': 1   # And lasts 1 day after
    })

holidays_df = pd.DataFrame(holiday_list)
print(holidays_df.head())
  • import holidays — imports the holidays Python package, a library that provides ready-made lists of public holidays for dozens of countries.
  • holidays.US(years=[2021, 2022]) — creates a dict-like object mapping date objects to US federal holiday names for the specified years; calling .items() yields (date, name) pairs.
  • sorted(us_holidays.items()) — sorts the holiday dates chronologically so the DataFrame rows are in calendar order.
  • if 'observed' in name.lower(): continue — the holidays library also emits a separate row for the shifted weekend-observance of a holiday (e.g. both “Christmas Day” on Dec 25 and “Christmas Day (observed)” on the nearest weekday), one day apart. With lower_window/upper_window on both, their halo windows overlap almost completely and Prophet has to split the true Christmas effect across two near-collinear regressors instead of capturing it cleanly in one. Filtering the “(observed)” rows out leaves the real holiday date to carry its own window.
  • 'lower_window': -2 — tells Prophet that the holiday’s effect begins 2 days before the actual holiday date; this is the “halo” that captures pre-holiday shopping rushes.
  • 'upper_window': 1 — extends the holiday effect 1 day after the official date, capturing post-holiday tail demand.
  • pd.DataFrame(holiday_list) — converts the list of dicts into a DataFrame with columns holiday, ds, lower_window, and upper_window — the exact schema Prophet expects.

Adding a lower_window tells the model that the “Christmas effect” starts on December 23rd. That way it isn’t caught off guard by the pre-holiday rush.

Holidays as simple date flags vs. as a halo-window regressor — when is the extra complexity worth it?

ApproachHow it worksWhen to use itWhen it’s overkill
Simple date flagMark only the holiday day itself (e.g., Dec 25) as a binary 1/0 column — the model sees a single-day bumpThe holiday effect is truly a one-day spike with no meaningful lead-up or tail (e.g., a one-day local festival or promotion)When customers start buying days or weeks before the event, or when post-event clearance sales extend the effect
Halo-window regressor (lower_window / upper_window)Mark the holiday plus a window of days before and after; Prophet applies the holiday effect across the entire windowThe holiday has a multi-day buildup (Christmas gift shopping, Thanksgiving meal prep) or a post-event tail (Cyber Monday after Black Friday)When the holiday is genuinely a one-day event and the extra window just adds noise — the model may attribute random variation to the “halo” and hallucinate a lead-up that doesn’t exist

The key decision: the halo window is worth the extra complexity when you can see in your data that sales start climbing days before the official holiday date. If the spike is truly one day and flat the rest of the week, a simple date flag is cleaner and less likely to overfit. For Nora’s bakery, where holiday pastry pre-orders start rolling in by December 20th, the halo window is essential — without it, the model treats the pre-Christmas rush as noise and under-forecasts the busiest week of the year.

Decomposing the Signal (The ‘Hardest Part’)

Here’s the hardest part of this kind of seasonal forecasting: Fourier Series. Think of it like tuning a radio. A station broadcasts at a specific frequency. Fourier terms are smooth waves the model uses to approximate the bumps in your data.

Say you have a complex 4-day sales cycle—maybe you run a music festival that happens every 4 days. You can’t just use “Day of Week.” You need to tell the model to look for a wave that repeats every 96 hours.

Next is the Seasonality Prior Scale. It controls how much the model wiggles. Set it high, and the model chases every tiny bump in the data. Set it low, and it stays smooth to ignore outliers.

Prophet models each seasonal component as a Fourier series — a sum of sine and cosine waves at harmonics of the base period:

s(t)=n=1N(ancos(2πntT)+bnsin(2πntT))s(t) = \sum_{n=1}^{N} \left( a_n \cos\left(\frac{2\pi n t}{T}\right) + b_n \sin\left(\frac{2\pi n t}{T}\right) \right)

Plain EnglishStatistical symbolPython equivalent
Seasonal component (sum of all wave pairs)s(t)s(t)forecast['weekly'], forecast['yearly'], forecast['daily']
Period of one full cycle (e.g., 7 days, 365.25 days)TTperiod=4, period=7, period=365.25
Fourier order — number of sine–cosine pairs usedNNfourier_order=10, fourier_order=3
Cosine coefficient for the nn-th harmonicana_nFitted internally by Prophet (not exposed directly)
Sine coefficient for the nn-th harmonicbnb_nFitted internally by Prophet (not exposed directly)

A higher fourier_order (NN) means more wave pairs are stacked, allowing the seasonal curve to flex into sharper, more jagged shapes — but also increasing the risk of overfitting to noise. A lower NN forces a smoother, simpler wave that may miss sharp peaks.

# Initialize Prophet with our holiday list
model = Prophet(holidays=holidays_df, 
                yearly_seasonality=10, 
                weekly_seasonality=True, 
                daily_seasonality=True)

# Add a custom seasonality for a specific business cycle (e.g., every 4 days)
model.add_seasonality(name='four_day_cycle', period=4, fourier_order=3)

model.fit(df)
forecast = model.predict(df)
  • Prophet(holidays=holidays_df, ...) — creates a Prophet model that will apply the holiday effects from holidays_df on top of its built-in seasonal components.
  • yearly_seasonality=10 — sets the Fourier order for the yearly component to 10 (also the default); this means 10 sine–cosine pairs are used to approximate the shape of the annual cycle, allowing it to capture complex within-year patterns like the Christmas bump.
  • weekly_seasonality=True / daily_seasonality=True — enables Prophet’s built-in weekly (Fourier order 3) and daily (Fourier order 4) seasonalities with their default settings.
  • model.add_seasonality(name='four_day_cycle', period=4, fourier_order=3) — registers a custom seasonal component with period=4. Prophet’s period argument is always a number of days, regardless of how finely your data is sampled — so on this hourly dataset, period=4 defines a cycle that repeats every 4 days (96 hours), matching the music-festival scenario above, not a 4-hour cycle. It uses 3 Fourier pairs to shape the wave; this is how you teach Prophet about non-standard cycles that don’t align with 7-day weeks or 365-day years.
  • model.fit(df) — fits all components (trend, holidays, daily, weekly, yearly, and the custom 4-day cycle) simultaneously on the full DataFrame.
  • model.predict(df) — generates in-sample predictions for the same DataFrame; each row in the output gets component-level breakdowns (yhat, trend, weekly, yearly, holidays, etc.).

In this code, fourier_order=3 sets the wave’s complexity. A higher number allows for more jagged patterns. Keep it low for a smooth sales curve. Turn it up if your data has sharp peaks and valleys.

Reading the Tea Leaves: Interpreting the Components

After the model fits, we need to check whether it actually learned anything. Prophet breaks the forecast into its component pieces.

fig = model.plot_components(forecast)
plt.show()
  • model.plot_components(forecast) — generates a figure with one subplot per component (trend, weekly, daily, yearly, holidays, and any custom seasonalities like four_day_cycle); each subplot shows how much that component adds to or subtracts from the baseline at each point in time.
  • plt.show() — renders the figure in the current notebook or display.

These plots show what your model thinks is driving your sales. Running the fit above on this data and reading the actual component values:

  1. Weekly Plot: The line peaks on Saturday at about +4.5, and sits at roughly −3.5 on Tuesday — confirming weekends really do add to the baseline and midweek days really do sit below it, matching the coffee-shop intuition from the top of the article.
  2. Holidays Plot: The Christmas effect comes out to about +50.3 on Dec 25 — essentially matching the 50-unit ramp we built into the data, which is what it looks like when the model has isolated a real effect instead of absorbing it into the winter trend.
  3. Daily Plot: The 8:00 AM rush shows up clearly as the day’s high point, with a smaller secondary bump in the early evening.

Here’s the real test: compare the fitted holiday component to how far the actual value strayed from what the model would have predicted without the holiday effect. In this run, that gap comes out to about 51 units against a fitted holiday component of about 50 — close enough that the model has clearly separated “Christmas” from ordinary winter noise. If your own holiday component came out to something like 10 while the actual jump was closer to 50, that would be underfitting — the model being too conservative about the holiday impact, usually a sign holidays_prior_scale needs to come up.

When the Model Hallucinates: Common Pitfalls

Here’s the catch: models can be a little too clever. This is the Super Bowl Trap. If you only have one year of data and you tell the model that Super Bowl Sunday is a holiday, it sees a spike and decides that specific Sunday is always special. It doesn’t know why.

With very little data, a high seasonality_prior_scale makes the model “hallucinate” patterns that aren’t there. It might decide every Tuesday is special because one Tuesday last month was weirdly busy.

# Example of Overfitting: Setting prior scale too high with limited data
overfit_model = Prophet(holidays=holidays_df, 
                        holidays_prior_scale=20.0) # Dangerously high!
overfit_model.fit(df.iloc[:365*24]) # Only give it one year
  • holidays_prior_scale=20.0 — sets the prior scale for holiday effects to 20 (default is 10); a higher value tells Prophet to trust the data more and apply larger holiday effects, which risks the model chasing noise as if it were a real holiday spike.
  • df.iloc[:365*24] — slices the DataFrame to only the first 8,760 rows (365 days × 24 hours), giving the model just one year of data; with only one Christmas in the training set, the model can’t distinguish a recurring annual pattern from a one-time fluke.

So your model is only as smart as the history you feed it. If you want to forecast Christmas 2023, you need to show it Christmas 2021 and 2022. Without two points of reference, the model can’t tell the difference between a yearly trend and a one-time fluke.

Summary Checklist

  • Identify the heartbeats: Does your data move by the hour, day, or month? Use add_seasonality for anything non-standard.
  • Use windows: Don’t just mark the holiday; mark the days leading up to it with lower_window.
  • Check the components: Run plot_components and confirm the model’s story matches your business intuition.
  • Mind the priors: If the model over-reacts to one-off events, lower holidays_prior_scale.

Now that the calendar’s quirks are accounted for, your forecasts should hold up through the holiday rush. Try varying fourier_order and watch how it changes your curve’s flexibility. Nora’s forecast finally handles Christmas correctly. But she just realized her teammate has been validating these same bakery models with plain K-Fold cross-validation — which silently lets future data leak into the training set.

Check Your Understanding

Questions below move from simple recall up to open-ended design, roughly following Bloom’s Taxonomy.

Remember What are the three overlapping “heartbeats” the article says a coffee shop’s sales data contains, and what time scale does each one operate on?

Understand Explain in your own words what lower_window=-2 and upper_window=1 do in the holidays dataframe, and why the Halo Effect makes those windows necessary.

Apply Your model’s holiday component shows a spike of 10 for Christmas, but actual sales jumped by 50. Using the “Reading the Tea Leaves” section, what does this gap tell you about the model, and which parameter would you adjust to fix it?

Analyze The article warns that a high seasonality_prior_scale with limited data causes the model to “hallucinate” patterns. Walk through why one unusually busy Tuesday, combined with a high prior, leads the model to treat every Tuesday as special rather than as noise.

Evaluate The article recommends having two Christmases of history before forecasting a third. Critique that rule of thumb: describe a business scenario where two years of data would still produce a misleading holiday forecast, and what extra information you’d want.

Create Design a Prophet configuration for a business with a weekly “every 4 days” music-festival cycle, a major fixed-date holiday (Christmas), and a moving-date holiday (Easter). List the add_seasonality, holidays, and window choices you’d make, and justify each one.


References & Further reading

  • Taylor, S. J., & Letham, B. (2018). “Forecasting at Scale.” The American Statistician, 72(1), 37–45. doi.org/10.1080/00031305.2017.1380080 — the foundational Prophet paper; covers the additive decomposition model (trend + seasonality + holidays), Fourier-series seasonality terms, and the holiday regressor framework with lower_window/upper_window halo effects used throughout this article.
  • holidays Python library documentation: pypi.org/project/holidays/ — the holidays package provides ready-made lists of public holidays for 100+ countries; the API used here (holidays.US(years=[...]), .items()) is documented on PyPI.
  • prophet library documentation: facebook.github.io/prophet/ — official docs covering add_seasonality, fourier_order, seasonality_prior_scale, holidays_prior_scale, and the plot_components API.
  • Kaggle “Store Sales — Time Series Forecasting” competition: kaggle.com/competitions/store-sales-time-series-forecasting — a real-world retail forecasting benchmark with daily store-level sales, holidays, and multiple seasonalities; directly relevant for testing the halo-window holiday approach on data structurally similar to Nora’s bakery chain.

Apply What You Learned is for Supporter and Insider subscribers.

Subscribe to unlock the exercises on this post.

See plans

Looking for something else?

Search every article by title, summary or topic.