Chapter 6 Project 4: Demand Forecasting — Prophet + XGBoost Ensemble

Accurate demand forecasting drives inventory decisions, supply chain optimization, and revenue planning. In this chapter, we build an ensemble forecasting system that combines Facebook Prophet’s ability to model seasonality and trends with XGBoost’s power to capture complex feature interactions.

6.1 Business Context

We need to forecast daily unit sales for each SKU-warehouse combination 30 days ahead. The system must:

  • Handle multiple seasonalities (weekly, yearly, holiday effects)
  • Incorporate promotional calendars
  • Account for stockouts (missing data that isn’t truly zero demand)
  • Provide safety stock recommendations at 95% service level

6.2 Architecture Overview

Sales Data (3 years)
    │
    ├──► Prophet ──► Trend + Seasonality ──┐
    │                                        ├──► Weighted Ensemble ──► Forecast + Safety Stock
    └──► XGBoost ──► Lag/Rolling Features ──┘

6.3 Data Pipeline

library(prophet)
library(xgboost)
library(timetk)
library(modeltime)
library(tidyverse)
library(lubridate)

get_sales_data <- function(con, sku_id = NULL, warehouse_id = NULL) {
  query <- tbl(con, "fact_daily_sales") %>%
    filter(date >= Sys.Date() - years(3))

  if (!is.null(sku_id)) query <- query %>% filter(sku_id == !!sku_id)
  if (!is.null(warehouse_id)) query <- query %>% filter(warehouse_id == !!warehouse_id)

  query %>%
    select(date, sku_id, warehouse_id, units_sold, revenue, 
           promotion_flag, stockout_flag) %>%
    collect() %>%
    mutate(date = as.Date(date))
}

6.4 Feature Engineering for Time Series

XGBoost requires carefully constructed time-based features. We create lag features, rolling statistics, and cyclical encodings.

build_ts_features <- function(df) {
  df %>%
    group_by(sku_id, warehouse_id) %>%
    arrange(date) %>%
    mutate(
      # Lag features: capture recent sales momentum
      sales_lag_1 = lag(units_sold, 1),
      sales_lag_7 = lag(units_sold, 7),    # Same day last week
      sales_lag_14 = lag(units_sold, 14),  # Two weeks ago
      sales_lag_28 = lag(units_sold, 28),  # Four weeks ago

      # Rolling statistics: smooth out noise
      sales_roll_mean_7 = zoo::rollmean(units_sold, 7, fill = NA, align = "right"),
      sales_roll_mean_14 = zoo::rollmean(units_sold, 14, fill = NA, align = "right"),
      sales_roll_mean_30 = zoo::rollmean(units_sold, 30, fill = NA, align = "right"),
      sales_roll_sd_7 = zoo::rollapply(units_sold, 7, sd, fill = NA, align = "right"),

      # Seasonality features
      day_of_week = wday(date, label = TRUE),
      month = month(date, label = TRUE),
      quarter = quarter(date),
      is_month_start = as.integer(day(date) <= 5),
      is_month_end = as.integer(day(date) >= 25),

      # Cyclical encoding: maps linear features to circles
      # This prevents the model from thinking January (1) and December (12) are far apart
      sin_day = sin(2 * pi * wday(date) / 7),
      cos_day = cos(2 * pi * wday(date) / 7),
      sin_month = sin(2 * pi * month(date) / 12),
      cos_month = cos(2 * pi * month(date) / 12),

      # Promo interaction: days since last promotion
      days_since_promo = as.numeric(date - lag(date[promotion_flag == 1], 1)),

      # Target
      units_sold = units_sold
    ) %>%
    ungroup() %>%
    drop_na()
}

Tip: Cyclical encoding is crucial for time features. Without it, a model might learn that December (12) is “greater than” January (1), missing that they’re adjacent months.

6.5 Prophet Model

Prophet decomposes time series into trend, seasonality, and holiday effects.

fit_prophet <- function(df) {
  # Define holidays with impact windows
  holidays <- tibble(
    holiday = c("Black Friday", "Cyber Monday", "Christmas", "New Year"),
    ds = as.Date(c("2023-11-24", "2023-11-27", "2023-12-25", "2024-01-01")),
    lower_window = c(-1, -1, -2, -1),
    upper_window = c(1, 1, 2, 1)
  )

  prophet_df <- df %>%
    select(ds = date, y = units_sold, promotion_flag) %>%
    mutate(promotion_flag = as.numeric(promotion_flag))

  m <- prophet(
    yearly.seasonality = TRUE,
    weekly.seasonality = TRUE,
    daily.seasonality = FALSE,
    changepoint.prior.scale = 0.05,    # Flexibility of trend changes
    seasonality.prior.scale = 10,       # Strength of seasonality
    holidays = holidays
  )

  m <- add_regressor(m, "promotion_flag")
  m <- fit.prophet(m, prophet_df)

  # Generate future dataframe
  future <- make_future_dataframe(m, periods = 30)
  future$promotion_flag <- get_promotion_calendar(future$ds)

  forecast <- predict(m, future)
  list(model = m, forecast = forecast)
}

6.6 XGBoost with Time Series Cross-Validation

Standard k-fold CV leaks future information into training. Time series CV respects temporal ordering.

fit_xgboost_ts <- function(df) {
  feature_cols <- c(
    "sales_lag_1", "sales_lag_7", "sales_lag_14", "sales_lag_28",
    "sales_roll_mean_7", "sales_roll_mean_14", "sales_roll_mean_30",
    "sales_roll_sd_7", "sin_day", "cos_day", "sin_month", "cos_month",
    "is_month_start", "is_month_end", "promotion_flag"
  )

  # Time-series cross-validation
  tscv <- time_series_cv(
    data = df,
    date_var = date,
    initial = "2 years",
    assess = "30 days",
    skip = "30 days",
    cumulative = FALSE
  )

  dtrain <- xgb.DMatrix(
    data = as.matrix(df %>% select(all_of(feature_cols))),
    label = df$units_sold
  )

  params <- list(
    objective = "reg:squarederror",
    eta = 0.05,
    max_depth = 8,
    subsample = 0.8,
    colsample_bytree = 0.8,
    eval_metric = "mae"
  )

  xgb_cv <- xgb.cv(
    params = params,
    data = dtrain,
    nrounds = 500,
    nfold = 5,
    early_stopping_rounds = 20,
    print_every_n = 50
  )

  final_model <- xgb.train(
    params = params,
    data = dtrain,
    nrounds = xgb_cv$best_iteration,
    watchlist = list(train = dtrain)
  )

  list(model = final_model, features = feature_cols)
}

Warning: Never use random k-fold CV for time series. It creates data leakage by training on future periods and testing on past periods, producing optimistically biased metrics.

6.7 Ensemble Forecast

We combine Prophet and XGBoost predictions, weighting by historical performance.

ensemble_forecast <- function(prophet_fc, xgb_fc, weights = c(0.4, 0.6)) {
  ensemble <- weights[1] * prophet_fc + weights[2] * xgb_fc

  # Safety stock at 95% service level
  # Assumes forecast errors are normally distributed
  forecast_std <- sd(c(prophet_fc, xgb_fc))
  safety_stock <- qnorm(0.95) * forecast_std

  tibble(
    date = seq.Date(Sys.Date() + 1, by = "day", length.out = 30),
    point_forecast = round(pmax(ensemble, 0)),  # No negative sales
    safety_stock = round(safety_stock),
    reorder_point = round(pmax(ensemble + safety_stock - current_inventory, 0))
  )
}

6.8 Full Pipeline Function

forecast_sku <- function(con, sku_id, warehouse_id) {
  df <- get_sales_data(con, sku_id, warehouse_id) %>% 
    build_ts_features()

  # Prophet forecast
  prophet_result <- fit_prophet(df)
  prophet_fc <- tail(prophet_result$forecast$yhat, 30)

  # XGBoost forecast
  xgb_result <- fit_xgboost_ts(df)
  xgb_fc <- predict(
    xgb_result$model,
    xgb.DMatrix(as.matrix(df %>% tail(30) %>% select(all_of(xgb_result$features))))
  )

  # Ensemble
  ensemble_forecast(prophet_fc, xgb_fc)
}

6.9 Model Monitoring

Track forecast accuracy with rolling metrics:

calculate_forecast_accuracy <- function(actual, forecast) {
  tibble(
    mae = mean(abs(actual - forecast)),
    rmse = sqrt(mean((actual - forecast)^2)),
    mape = mean(abs((actual - forecast) / actual)) * 100,
    bias = mean(forecast - actual)  # Positive = over-forecasting
  )
}

6.10 Exercises

  1. Implement dynamic weights for the ensemble based on recent model performance (e.g., weight by inverse MAE of last 30 days).
  2. Add external regressors to Prophet for weather data or competitor pricing.
  3. Build a modeltime workflow that compares Prophet, XGBoost, ARIMA, and ETS automatically.
  4. Implement a stockout correction mechanism that imputes likely demand during stockout periods.

Next, we’ll segment customers using RFM analysis and K-Means clustering to enable targeted marketing strategies.