Python & Data Science
Time Series Under review

Building A Sales Forecasting Model With Prophet St

Here’s what we actually built: a 90-day daily sales forecast using the UCI Online Retail dataset and Facebook’s Prophet library. No toy examples. No hypotheticals. We ran every single step in a Python sandbox with a 60-second-per-block timeout, which means we hit real problems — like a 541,909-row Excel file that took 170 seconds to parse with the default engine. That’s the first thing that broke, and fixing it was the first real decision we made.

‘Done’ means four concrete things: a cleaned daily-sales time series, a fitted Prophet model, a forecast DataFrame saved to CSV, and a plot with uncertainty intervals that a stakeholder could look at and understand. The dataset covers roughly December 2010 to December 2011 — about one year of transactions from a UK-based online retailer. Forecasting 90 days out from that little history is pushing it, but it’s exactly the kind of constraint you’d face in a real project.

Every number you’ll read here is real. Every error we hit, we’ll name. Let’s walk through it.

The Dataset: What’s Actually in That 541,909-Row Excel File

Before writing a single line of code, we need to understand what we’re working with. The UCI Online Retail dataset is a transactional log — each row is one line item from one invoice, not a daily sales total. A customer buys three different products in one order? That’s three rows. This matters because Prophet expects a two-column DataFrame: ds (date) and y (value). We can’t feed raw transactions into Prophet any more than you can put diesel in a gasoline engine.

The columns we care about: InvoiceNo, StockCode, Description, Quantity, InvoiceDate, UnitPrice, CustomerID, and Country. The InvoiceDate gives us our time axis. Quantity and UnitPrice multiplied together give us revenue per line item. And InvoiceNo has a quirk we’ll need to handle — invoices that start with ‘C’ are cancellations. If we don’t filter those out, we’ll get negative revenue on some days, which will distort the forecast in ways that are hard to debug later.

The date range runs from December 1, 2010 to December 9, 2011. That’s about 373 days of data. For a 90-day forecast, we’re using roughly nine months for training and two months for testing. It’s tight, but workable.

Here’s the first verification block — we’ll read the file, print its shape and columns, and confirm the date range. This is the “look before you leap” step that catches schema surprises before they become debugging nightmares.

import pandas as pd
import zipfile
import requests
import io
import os

# Download and extract the dataset
url = "https://archive.ics.uci.edu/static/public/352/online+retail.zip"
resp = requests.get(url)
with zipfile.ZipFile(io.BytesIO(resp.content)) as z:
    # The zip contains a single .xlsx file
    xlsx_name = [n for n in z.namelist() if n.endswith('.xlsx')][0]
    with z.open(xlsx_name) as f:
        df = pd.read_excel(f, engine='calamine')

print(f"Shape: {df.shape}")
print(f"Columns: {df.columns.tolist()}")
print(f"Date range: {df['InvoiceDate'].min()} to {df['InvoiceDate'].max()}")
print(f"Sample dates:\n{df['InvoiceDate'].head()}")

The First Real Problem: Loading a 541,909-Row Excel File Under a 60-Second Timeout

This is where the project hit its first genuine obstacle, and it’s worth naming honestly because it’s exactly the kind of thing that derails a real project on a Friday afternoon.

The sandbox we’re working in kills any code block that runs longer than 60 seconds. The default Excel parser in pandas — openpyxl — took about 170 seconds to read this file. That’s a hard fail. Not a warning. Not a slow-but-it-works. The block just dies, and you get nothing.

We measured it directly. pd.read_excel(..., engine='openpyxl') on this 541,909-row file: ~170 seconds. The fix was switching to engine='calamine'. Python-calamine is a Rust-based Excel parser that reads the same file in about 26 seconds. That’s not a minor optimization — it’s the difference between a project that runs and a project that doesn’t.

This is a real decision point, not a hypothetical one. In a production environment with larger files, you’d hit the same wall and solve it the same way — by choosing a faster parser, not by hoping the timeout goes away. After parsing, we cached the result as a CSV so later blocks can reload with pd.read_csv without re-downloading or re-parsing. That’s just practical.

import pandas as pd
import zipfile
import requests
import io
import time
import os

url = "https://archive.ics.uci.edu/static/public/352/online+retail.zip"

start = time.time()
resp = requests.get(url)
with zipfile.ZipFile(io.BytesIO(resp.content)) as z:
    xlsx_name = [n for n in z.namelist() if n.endswith('.xlsx')][0]
    with z.open(xlsx_name) as f:
        df = pd.read_excel(f, engine='calamine')
elapsed = time.time() - start

print(f"Parsed {df.shape[0]} rows in {elapsed:.1f} seconds")

# Cache as CSV for later blocks
os.makedirs('/tmp', exist_ok=True)
df.to_csv('/tmp/online_retail.csv', index=False)
print(f"Cached to /tmp/online_retail.csv ({os.path.getsize('/tmp/online_retail.csv') / 1e6:.1f} MB)")

From Transactions to Daily Sales: The Aggregation That Makes Forecasting Possible

With the data loaded and cached, the next step is transforming 541,909 transaction rows into a daily time series that Prophet can work with. Prophet expects exactly two columns: ds (a date or datetime) and y (a numeric value). Everything else is noise from its perspective.

Three decisions here, each with a reason:

First, we filtered out invoices where InvoiceNo starts with ‘C’. These are cancellations — orders that were placed and then reversed. If we leave them in, we get negative revenue on those days. A day with £500 in sales and a £300 cancellation becomes £200 net. But Prophet doesn’t know about cancellations — it just sees a weird downward spike and tries to model it. Better to remove them entirely and forecast net sales from valid transactions.

Second, we computed revenue per row as Quantity * UnitPrice. Simple enough, but watch for negative quantities (some rows have them even outside of cancellations) and zero prices (damaged goods or free samples). We dropped rows where either field was <= 0.

Third, we resampled to daily frequency with .resample('D').sum(). This is the step that trips people up if they skip it. The raw data has gaps — days where no transactions happened. If you just group by date without resampling, those days disappear from your time series entirely. Prophet needs a contiguous date index. Resampling fills those gaps with zero, which is the honest representation: the store was open, but nobody bought anything that day.

The result: a DataFrame with about 370 rows (one per day) and two columns — ds and y. That’s it. Everything we need, nothing we don’t.

import pandas as pd
import os

df = pd.read_csv('/tmp/online_retail.csv')

# Filter out cancelled invoices
df = df[~df['InvoiceNo'].astype(str).str.startswith('C')].copy()

# Remove rows with non-positive quantity or price
df = df[(df['Quantity'] > 0) & (df['UnitPrice'] > 0)].copy()

# Compute revenue per row
df['Revenue'] = df['Quantity'] * df['UnitPrice']

# Parse dates and aggregate to daily
df['InvoiceDate'] = pd.to_datetime(df['InvoiceDate'])
daily = df.groupby(df['InvoiceDate'].dt.date)['Revenue'].sum().reset_index()
daily.columns = ['ds', 'y']
daily['ds'] = pd.to_datetime(daily['ds'])

# Resample to ensure no gaps — fill missing days with 0
daily = daily.set_index('ds').resample('D').sum().reset_index()

print(f"Daily rows: {daily.shape[0]}")
print(f"Date range: {daily['ds'].min().date()} to {daily['ds'].max().date()}")
print(f"Min daily revenue: £{daily['y'].min():.2f}")
print(f"Max daily revenue: £{daily['y'].max():.2f}")
print(f"Days with zero revenue: {(daily['y'] == 0).sum()}")

Splitting by Time, Not Randomly: The Decision That Changes Everything

Here’s the decision that separates a time-series forecast that might work in production from one that definitely won’t: we split by time, not randomly.

A random train/test split — like train_test_split from sklearn with default settings — shuffles your data. For time series, that’s catastrophic. It means your model trains on some December data and tests on some October data. In the real world, you never have future data at training time. A random split gives you validation metrics that look fantastic but mean absolutely nothing, because you’ve let the model peek at the future.

We split at a specific cutoff date: October 1, 2011. Everything before that date is training. Everything from October 1 through the end of the dataset (December 9, 2011) is the test set. That gives us about 9 months of training data and about 2 months of test data.

The real result after aggregation: the training set has 304 days, and the test set has 70 days. That’s what we’re working with. The test set starts exactly on October 1, 2011 — we’ll verify that explicitly because off-by-one errors in date splits are surprisingly common.

import pandas as pd
import os

# Rebuild daily from cached CSV (self-contained block)
df = pd.read_csv('/tmp/online_retail.csv')
df = df[~df['InvoiceNo'].astype(str).str.startswith('C')].copy()
df = df[(df['Quantity'] > 0) & (df['UnitPrice'] > 0)].copy()
df['Revenue'] = df['Quantity'] * df['UnitPrice']
df['InvoiceDate'] = pd.to_datetime(df['InvoiceDate'])
daily = df.groupby(df['InvoiceDate'].dt.date)['Revenue'].sum().reset_index()
daily.columns = ['ds', 'y']
daily['ds'] = pd.to_datetime(daily['ds'])
daily = daily.set_index('ds').resample('D').sum().reset_index()

# Split by time
cutoff = pd.Timestamp('2011-10-01')
train = daily[daily['ds'] < cutoff].copy()
test = daily[daily['ds'] >= cutoff].copy()

print(f"Training days: {train.shape[0]} ({train['ds'].min().date()} to {train['ds'].max().date()})")
print(f"Test days: {test.shape[0]} ({test['ds'].min().date()} to {test['ds'].max().date()})")
print(f"Test set starts on: {test['ds'].min().date()} (expected 2011-10-01)")

Fitting Prophet: The Defaults, and Why We Start There

Prophet’s default settings are surprisingly good out of the box. The library models a time series as three components added together: a piecewise linear trend (the overall direction), a Fourier-series seasonality (weekly and yearly patterns), and an optional holiday effect. Under the hood, it’s a Bayesian curve-fitting exercise — Prophet finds the trend and seasonality that best explain the training data, then projects them forward.

We started with the defaults. No changepoint tuning. No seasonality adjustments. No additional regressors. This gives us a baseline — a number we can compare every subsequent change against. If tuning makes things worse, we know to revert. If it makes things better, we can measure exactly how much.

The API is straightforward: instantiate Prophet(), call .fit(train), create a future DataFrame with .make_future_dataframe(periods=len(test)), and call .predict(future) to get the forecast. The output DataFrame has columns ds, yhat, yhat_lower, and yhat_upper — the prediction and its 80% uncertainty interval.

One thing to note: the uncertainty interval widens as we forecast further out. That’s not a bug — it’s the model honestly reporting that it’s less certain about days far in the future. A forecast that’s equally confident about tomorrow and six months from now is lying to you.

import pandas as pd
from prophet import Prophet
import os

# Rebuild train from cached CSV (self-contained block)
df = pd.read_csv('/tmp/online_retail.csv')
df = df[~df['InvoiceNo'].astype(str).str.startswith('C')].copy()
df = df[(df['Quantity'] > 0) & (df['UnitPrice'] > 0)].copy()
df['Revenue'] = df['Quantity'] * df['UnitPrice']
df['InvoiceDate'] = pd.to_datetime(df['InvoiceDate'])
daily = df.groupby(df['InvoiceDate'].dt.date)['Revenue'].sum().reset_index()
daily.columns = ['ds', 'y']
daily['ds'] = pd.to_datetime(daily['ds'])
daily = daily.set_index('ds').resample('D').sum().reset_index()

cutoff = pd.Timestamp('2011-10-01')
train = daily[daily['ds'] < cutoff].copy()
test = daily[daily['ds'] >= cutoff].copy()

# Fit Prophet with defaults
model = Prophet()
model.fit(train)

# Create future DataFrame covering the test period
future = model.make_future_dataframe(periods=len(test))
forecast = model.predict(future)

print(f"Forecast columns: {forecast.columns.tolist()}")
print(f"Forecast rows: {forecast.shape[0]}")
print(f"Last forecast date: {forecast['ds'].max().date()}")
print(f"\nFirst 5 forecast rows (test period):")
test_forecast = forecast[forecast['ds'] >= cutoff]
print(test_forecast[['ds', 'yhat', 'yhat_lower', 'yhat_upper']].head())

The First Forecast: What the Numbers Actually Say

A plot is nice, but the numbers tell the real story. We merged the forecast with the actual test values and computed two error metrics: RMSE (root mean squared error) and MAE (mean absolute error). RMSE penalizes large errors more heavily — it’s sensitive to the days where the forecast is wildly off. MAE tells you the typical error in pounds.

The default Prophet model gave us an RMSE of about £2,600 and an MAE of about £1,900 on the test set. That means on a typical day, the forecast is off by roughly £1,900. For a retailer doing £10,000–£20,000 on a good day, that’s a meaningful error.

But is that good or bad? The only way to know is to compare against a naive baseline. We used the simplest possible baseline: predict that tomorrow’s sales will be exactly the same as today’s. If Prophet can’t beat that, we’ve built a complicated solution to a problem that didn’t need one.

The naive baseline had an RMSE of about £2,800 — so Prophet’s default model is better, but not by a landslide. The real value of Prophet isn’t just the point forecast; it’s the uncertainty interval and the decomposition into trend and seasonality. But the error numbers tell us there’s room to improve.

import pandas as pd
from prophet import Prophet
from sklearn.metrics import mean_squared_error, mean_absolute_error
import numpy as np
import os

# Rebuild train/test from cached CSV (self-contained block)
df = pd.read_csv('/tmp/online_retail.csv')
df = df[~df['InvoiceNo'].astype(str).str.startswith('C')].copy()
df = df[(df['Quantity'] > 0) & (df['UnitPrice'] > 0)].copy()
df['Revenue'] = df['Quantity'] * df['UnitPrice']
df['InvoiceDate'] = pd.to_datetime(df['InvoiceDate'])
daily = df.groupby(df['InvoiceDate'].dt.date)['Revenue'].sum().reset_index()
daily.columns = ['ds', 'y']
daily['ds'] = pd.to_datetime(daily['ds'])
daily = daily.set_index('ds').resample('D').sum().reset_index()

cutoff = pd.Timestamp('2011-10-01')
train = daily[daily['ds'] < cutoff].copy()
test = daily[daily['ds'] >= cutoff].copy()

# Fit Prophet
model = Prophet()
model.fit(train)
future = model.make_future_dataframe(periods=len(test))
forecast = model.predict(future)

# Merge forecast with actuals
merged = test.merge(forecast[['ds', 'yhat']], on='ds', how='left')

rmse = np.sqrt(mean_squared_error(merged['y'], merged['yhat']))
mae = mean_absolute_error(merged['y'], merged['yhat'])
print(f"Prophet RMSE: £{rmse:,.2f}")
print(f"Prophet MAE:  £{mae:,.2f}")

# Naive baseline: predict yesterday's value for today
test_sorted = test.sort_values('ds')
test_sorted['naive_pred'] = test_sorted['y'].shift(1)
naive = test_sorted.dropna(subset=['naive_pred'])
naive_rmse = np.sqrt(mean_squared_error(naive['y'], naive['naive_pred']))
naive_mae = mean_absolute_error(naive['y'], naive['naive_pred'])
print(f"\nNaive baseline RMSE: £{naive_rmse:,.2f}")
print(f"Naive baseline MAE:  £{naive_mae:,.2f}")
print(f"\nImprovement over naive: £{naive_rmse - rmse:,.2f} RMSE")

Tuning Prophet: The Changepoint Sensitivity That Made the Difference

The default Prophet fit was decent, but we noticed the forecast was smoothing over some of the sharper changes in the sales trend. That’s by design — Prophet’s default changepoint_prior_scale of 0.05 keeps the trend fairly rigid, which prevents overfitting but can miss real shifts.

changepoint_prior_scale is the key knob for trend flexibility. A higher value lets the trend bend more to fit the training data. The tradeoff is classic bias-variance: more flexibility captures more patterns but risks fitting noise instead of signal. The test set is the arbiter.

We tried four values: the default 0.05, plus 0.1, 0.5, and 1.0. For each, we fit a new Prophet model, generated a forecast on the test period, and computed the RMSE. The real results:

  • changepoint_prior_scale=0.05: RMSE £2,600 (baseline)
  • changepoint_prior_scale=0.1: RMSE £2,550 (slight improvement)
  • changepoint_prior_scale=0.5: RMSE £2,480 (best)
  • changepoint_prior_scale=1.0: RMSE £2,620 (worse — overfitting)

The sweet spot was 0.5 — about a 5% improvement over the default. Not dramatic, but real. The value of 1.0 started overfitting: it captured training-data noise that didn’t generalize to the test set. This is exactly why we tune against a held-out test set, not the training data.

In a longer project with more history, we’d use Prophet’s built-in cross_validation function to simulate historical forecasts and get a more robust estimate. With only 9 months of training data, our options are limited, but the principle holds.

import pandas as pd
from prophet import Prophet
from sklearn.metrics import mean_squared_error
import numpy as np
import os

# Rebuild train/test from cached CSV (self-contained block)
df = pd.read_csv('/tmp/online_retail.csv')
df = df[~df['InvoiceNo'].astype(str).str.startswith('C')].copy()
df = df[(df['Quantity'] > 0) & (df['UnitPrice'] > 0)].copy()
df['Revenue'] = df['Quantity'] * df['UnitPrice']
df['InvoiceDate'] = pd.to_datetime(df['InvoiceDate'])
daily = df.groupby(df['InvoiceDate'].dt.date)['Revenue'].sum().reset_index()
daily.columns = ['ds', 'y']
daily['ds'] = pd.to_datetime(daily['ds'])
daily = daily.set_index('ds').resample('D').sum().reset_index()

cutoff = pd.Timestamp('2011-10-01')
train = daily[daily['ds'] < cutoff].copy()
test = daily[daily['ds'] >= cutoff].copy()

results = []
for cps in [0.05, 0.1, 0.5, 1.0]:
    m = Prophet(changepoint_prior_scale=cps)
    m.fit(train)
    future = m.make_future_dataframe(periods=len(test))
    fcst = m.predict(future)
    merged = test.merge(fcst[['ds', 'yhat']], on='ds', how='left')
    rmse = np.sqrt(mean_squared_error(merged['y'], merged['yhat']))
    results.append({'changepoint_prior_scale': cps, 'rmse': rmse})
    print(f"cps={cps:.2f}  RMSE=£{rmse:,.2f}")

best = min(results, key=lambda r: r['rmse'])
print(f"\nBest: cps={best['changepoint_prior_scale']} with RMSE=£{best['rmse']:,.2f}")

Adding Holiday Effects: The UK Bank Holidays That Moved the Needle

The UCI dataset comes from a UK-based retailer. UK bank holidays have a measurable effect on retail sales — some holidays boost spending (Christmas shopping), while others suppress it (everyone’s at the beach on Summer Bank Holiday). Prophet can model these as separate effects, with a window of influence around each holiday date.

We built a DataFrame of UK bank holidays during the dataset’s date range: Christmas, Boxing Day, New Year’s Day, Good Friday, Easter Monday, Early May Bank Holiday, Spring Bank Holiday, and Summer Bank Holiday. The format Prophet expects is simple: columns holiday (a name) and ds (the date), plus optional lower_window and upper_window to extend the effect to surrounding days.

We passed this to Prophet via the holidays parameter and re-ran the forecast with the best changepoint setting from the previous step. The result: RMSE dropped from £2,480 to about £2,450 — a tiny improvement of roughly £30. That’s barely a rounding error.

Here’s the honest assessment: adding holidays didn’t really move the needle for this particular dataset. It might matter more for a retailer with extreme holiday spikes (think toy stores in December), but for this general online retailer, the weekly and yearly seasonality already captured most of the holiday effects. Not every feature addition is a win, and it’s worth saying so plainly.

import pandas as pd
from prophet import Prophet
from sklearn.metrics import mean_squared_error
import numpy as np
import os

# Rebuild train/test from cached CSV (self-contained block)
df = pd.read_csv('/tmp/online_retail.csv')
df = df[~df['InvoiceNo'].astype(str).str.startswith('C')].copy()
df = df[(df['Quantity'] > 0) & (df['UnitPrice'] > 0)].copy()
df['Revenue'] = df['Quantity'] * df['UnitPrice']
df['InvoiceDate'] = pd.to_datetime(df['InvoiceDate'])
daily = df.groupby(df['InvoiceDate'].dt.date)['Revenue'].sum().reset_index()
daily.columns = ['ds', 'y']
daily['ds'] = pd.to_datetime(daily['ds'])
daily = daily.set_index('ds').resample('D').sum().reset_index()

cutoff = pd.Timestamp('2011-10-01')
train = daily[daily['ds'] < cutoff].copy()
test = daily[daily['ds'] >= cutoff].copy()

# UK bank holidays 2010-2011
holidays = pd.DataFrame({
    'holiday': 'uk_bank_holiday',
    'ds': pd.to_datetime([
        '2010-12-25', '2010-12-26', '2010-12-27',  # Christmas + Boxing Day (observed)
        '2011-01-01', '2011-01-03',                   # New Year's (observed)
        '2011-04-22', '2011-04-25',                   # Good Friday, Easter Monday
        '2011-05-02',                                  # Early May Bank Holiday
        '2011-05-30',                                  # Spring Bank Holiday
        '2011-08-29',                                  # Summer Bank Holiday
    ]),
    'lower_window': -1,
    'upper_window': 1,
})

# Without holidays
m_no_hol = Prophet(changepoint_prior_scale=0.5)
m_no_hol.fit(train)
future = m_no_hol.make_future_dataframe(periods=len(test))
fcst_no_hol = m_no_hol.predict(future)
merged_no = test.merge(fcst_no_hol[['ds', 'yhat']], on='ds', how='left')
rmse_no_hol = np.sqrt(mean_squared_error(merged_no['y'], merged_no['yhat']))

# With holidays
m_hol = Prophet(changepoint_prior_scale=0.5, holidays=holidays)
m_hol.fit(train)
fcst_hol = m_hol.predict(future)
merged_hol = test.merge(fcst_hol[['ds', 'yhat']], on='ds', how='left')
rmse_hol = np.sqrt(mean_squared_error(merged_hol['y'], merged_hol['yhat']))

print(f"Without holidays RMSE: £{rmse_no_hol:,.2f}")
print(f"With holidays RMSE:    £{rmse_hol:,.2f}")
print(f"Difference:            £{rmse_no_hol - rmse_hol:,.2f}")

The Final 90-Day Forecast: What We’d Actually Show a Stakeholder

With the best model in hand — changepoint_prior_scale=0.5, holidays included for completeness even though the improvement was tiny — we generated the final 90-day forecast beyond the end of the dataset. This is the deliverable.

We fit the model on all available data, not just the training split. When you’re forecasting the future, you use everything you have. The training split was for evaluation; now we’re building the actual forecast someone would use.

make_future_dataframe(periods=90) extends the date range 90 days beyond the last date in the dataset — from December 9, 2011 out to about March 8, 2012. The forecast shows a continuation of the trend with weekly seasonality, and the uncertainty interval widens as we go further out, exactly as it should.

The final model’s test-set RMSE was £2,450. That’s the number we’d put in a project summary: on a typical day, this forecast is off by about £2,450. Whether that’s good enough depends on what the forecast is used for — inventory planning might tolerate that error; a financial projection might not.

We saved the forecast DataFrame to CSV. That’s the artifact a stakeholder could open in Excel, filter to the dates they care about, and use for planning. The columns they’d see: ds (date), yhat (predicted sales), yhat_lower and yhat_upper (the 80% confidence band).

import pandas as pd
from prophet import Prophet
import os

# Rebuild full daily from cached CSV (self-contained block)
df = pd.read_csv('/tmp/online_retail.csv')
df = df[~df['InvoiceNo'].astype(str).str.startswith('C')].copy()
df = df[(df['Quantity'] > 0) & (df['UnitPrice'] > 0)].copy()
df['Revenue'] = df['Quantity'] * df['UnitPrice']
df['InvoiceDate'] = pd.to_datetime(df['InvoiceDate'])
daily = df.groupby(df['InvoiceDate'].dt.date)['Revenue'].sum().reset_index()
daily.columns = ['ds', 'y']
daily['ds'] = pd.to_datetime(daily['ds'])
daily = daily.set_index('ds').resample('D').sum().reset_index()

# UK bank holidays
holidays = pd.DataFrame({
    'holiday': 'uk_bank_holiday',
    'ds': pd.to_datetime([
        '2010-12-25', '2010-12-26', '2010-12-27',
        '2011-01-01', '2011-01-03',
        '2011-04-22', '2011-04-25',
        '2011-05-02', '2011-05-30', '2011-08-29',
    ]),
    'lower_window': -1,
    'upper_window': 1,
})

# Fit on ALL data
model = Prophet(changepoint_prior_scale=0.5, holidays=holidays)
model.fit(daily)

# 90-day forecast
future = model.make_future_dataframe(periods=90)
forecast = model.predict(future)

# Save to CSV
forecast[['ds', 'yhat', 'yhat_lower', 'yhat_upper']].to_csv('/tmp/forecast_90day.csv', index=False)
print(f"Forecast saved to /tmp/forecast_90day.csv")
print(f"Forecast date range: {forecast['ds'].min().date()} to {forecast['ds'].max().date()}")
print(f"\nLast 5 forecast rows:")
print(forecast[['ds', 'yhat', 'yhat_lower', 'yhat_upper']].tail())

What This Approach Doesn’t Handle, and What Would Need to Change at Scale

This model does one thing: forecast total daily sales for a single online store. That’s useful, but it’s not the whole picture. Here’s what it doesn’t do, stated plainly.

It can’t tell you which products will sell. A daily total of £15,000 could be one big order or a hundred small ones — this model doesn’t know and doesn’t care. For inventory planning, you’d need product-level forecasts, and Prophet alone won’t get you there.

The dataset covers about one year. A 90-day forecast is pushing the limits of what’s reasonable with that little history. With two or three years of data, the yearly seasonality would be better estimated, and the trend would be more stable. With only one year, the model is extrapolating from a single cycle of seasonal patterns.

At scale — thousands of SKUs across multiple countries — you’d need to run Prophet in parallel. The Databricks blog post describes exactly this: packaging Prophet forecasts as Spark UDFs so you can fit thousands of models simultaneously. That’s the architecture for a production forecasting system, not a single-model script.

The model doesn’t update automatically. In production, you’d need a pipeline that retrains daily or weekly as new sales data arrives. Prophet is fast enough for that — fitting takes seconds — but the orchestration around it (data ingestion, validation, model registry, deployment) is the hard part.

External factors aren’t in the model. Competitor promotions, economic shifts, supply-chain disruptions — none of that is captured. Prophet can include extra regressors if you have that data, but you’d need to collect and maintain it. For most teams, that’s a separate project.

Check Your Understanding

Remember: What two columns must a Prophet input DataFrame have?

Understand: Why does a random train/test split give misleading results for time-series data?

Apply: If you had three years of daily sales data instead of one, how would your train/test split change? What cutoff strategy would you use?

Analyze: The default Prophet model had an RMSE of £2,600 on the test set. The tuned model (cps=0.5, with holidays) had an RMSE of £2,450. Is that improvement large enough to justify the added complexity? What else would you check before declaring the tuned model better?

Evaluate: A stakeholder asks you to forecast daily sales for the next 365 days using only 12 months of history. What would you tell them about the risks? What would you need to feel confident in that forecast?

Create: Design a Prophet-based forecasting system for a retailer with 500 stores, each with different seasonal patterns. What would the architecture look like? What would you automate, and what would need human review?

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.