Mathew K Analytics

Lesson 5 · Real-World Data Analytics

Python Data Analytics #05: Sales Forecasting for Retail Demand in Python

Video five of the hundred-video real-world data analytics series. Building a real weekly revenue forecast, tested honestly against real held-out weeks the…

What you'll learn

Datasets used in this lesson

Save these next to the notebook. In Google Colab, upload them with the 📁 icon on the left first.

📓 Full notebook

Download .ipynb

Data Analytics 100, Video 5: Sales Forecasting for Retail Demand#

  • Video five of the hundred-video real-world data analytics series.
  • Building a real weekly revenue forecast, tested honestly against real held-out weeks the model never saw.
  • Let's get into it.

Part 1: Why Forecasting Needs a Real Honest Test#

import pandas as pd
import numpy as np
import matplotlib.pyplot as plt
clean = pd.read_csv('online_retail_clean.csv', parse_dates=['InvoiceDate'])
clean.shape
(391150, 9)

Part 2: Real Daily Revenue Series#

clean['DateOnly'] = clean['InvoiceDate'].dt.date
daily = clean.groupby('DateOnly')['Revenue'].sum()
daily.index = pd.to_datetime(daily.index)
daily.shape[0]
305

Part 3: Filling Real Gaps with Zero-Revenue Days#

full_range = pd.date_range(daily.index.min(), daily.index.max(), freq='D')
daily = daily.reindex(full_range, fill_value=0)
daily.shape[0]
374

Part 4: Real Day-of-Week Pattern#

dow_avg = daily.groupby(daily.index.dayofweek).mean()
dow_avg.round(0)
0    25028.0
1    31556.0
2    28879.0
3    35912.0
4    27033.0
5        0.0
6    14712.0
Name: Revenue, dtype: float64

Part 5: Confirming the Real Saturday Gap#

saturday_avg = dow_avg[5]
saturday_avg == 0
np.True_

Part 6: Aggregating to Real Weekly Revenue#

weekly = daily.resample('W').sum()
weekly.shape[0]
54

Part 7: Trimming Real Partial Weeks#

weekly = weekly.iloc[1:-1]
weekly.shape[0]
weekly.head(3)
2010-12-12    210678.74
2010-12-19    161598.80
2010-12-26     45285.91
Freq: W-SUN, Name: Revenue, dtype: float64

Part 8: Visualizing the Real Weekly Revenue History#

plt.figure(figsize=(11, 5))
plt.plot(weekly.index, weekly.values, marker='o', color='navy')
plt.xlabel('Real Week')
plt.ylabel('Real Weekly Revenue (GBP)')
plt.title('Real Weekly Retail Revenue History')
plt.xticks(rotation=45)
plt.tight_layout()
plt.savefig('weekly_revenue_history.png', dpi=120)
plt.close()

Part 9: The Real Train/Test Split#

train = weekly.iloc[:-8]
test = weekly.iloc[-8:]
len(train), len(test)
(44, 8)

Part 10: Real Error Metric Helpers#

def mae(actual, forecast):
    return np.mean(np.abs(np.asarray(actual) - np.asarray(forecast)))
def rmse(actual, forecast):
    return np.sqrt(np.mean((np.asarray(actual) - np.asarray(forecast)) ** 2))

Part 11: Real Baseline 1, the Naive Forecast#

naive_forecast = pd.Series([train.iloc[-1]] * len(test), index=test.index)
naive_mae, naive_rmse = mae(test, naive_forecast), rmse(test, naive_forecast)
round(naive_mae, 0), round(naive_rmse, 0)
(np.float64(47079.0), np.float64(51439.0))

Part 12: Real Baseline 2, Moving Average#

ma_value = train.iloc[-4:].mean()
ma_forecast = pd.Series([ma_value] * len(test), index=test.index)
ma_mae, ma_rmse = mae(test, ma_forecast), rmse(test, ma_forecast)
round(ma_mae, 0), round(ma_rmse, 0)
(np.float64(15407.0), np.float64(21369.0))

Part 13: Real Model, Holt Exponential Smoothing with Trend#

from statsmodels.tsa.holtwinters import ExponentialSmoothing
hw_model = ExponentialSmoothing(train, trend='add', seasonal=None).fit()
hw_forecast = hw_model.forecast(len(test))
hw_forecast.round(0)
2011-10-16    211716.0
2011-10-23    218059.0
2011-10-30    224402.0
2011-11-06    230745.0
2011-11-13    237088.0
2011-11-20    243431.0
2011-11-27    249775.0
2011-12-04    256118.0
Freq: W-SUN, dtype: float64

Part 14: Scoring the Real Holt-Winters Model#

hw_mae, hw_rmse = mae(test, hw_forecast), rmse(test, hw_forecast)
round(hw_mae, 0), round(hw_rmse, 0)
(np.float64(16324.0), np.float64(18995.0))

Part 15: Comparing All Three Real Models#

comparison = pd.DataFrame({
    'Model': ['Naive', 'Moving Average', 'Holt-Winters'],
    'MAE': [naive_mae, ma_mae, hw_mae],
    'RMSE': [naive_rmse, ma_rmse, hw_rmse]
})
comparison.round(0)
Model MAE RMSE
0 Naive 47079.0 51439.0
1 Moving Average 15407.0 21369.0
2 Holt-Winters 16324.0 18995.0

Part 16: How Much Better Is the Real Winning Model#

best_model_mae = comparison['MAE'].min()
improvement_pct = round((naive_mae - best_model_mae) / naive_mae * 100, 1)
print(f'The best real model cuts forecast error by {improvement_pct}% versus the real naive baseline.')
The best real model cuts forecast error by 67.3% versus the real naive baseline.

Part 17: Visualizing Real Forecasts Against Real Actuals#

plt.figure(figsize=(11, 6))
plt.plot(train.index[-12:], train.values[-12:], label='Real Training History', color='gray')
plt.plot(test.index, test.values, label='Real Actual', color='black', marker='o', linewidth=2)
plt.plot(test.index, naive_forecast.values, label='Real Naive Forecast', linestyle='--')
plt.plot(test.index, hw_forecast.values, label='Real Holt-Winters Forecast', linestyle='--')
plt.xlabel('Real Week')
plt.ylabel('Real Weekly Revenue (GBP)')
plt.title('Real Forecasts vs Real Actual Held-Out Weeks')
plt.legend()
plt.xticks(rotation=45)
plt.tight_layout()
plt.savefig('forecast_vs_actual.png', dpi=120)
plt.close()

Part 18: Refitting on All Real Available Data#

final_model = ExponentialSmoothing(weekly, trend='add', seasonal=None).fit()
future_forecast = final_model.forecast(6)
future_forecast.round(0)
2011-12-11    270317.0
2011-12-18    276555.0
2011-12-25    282792.0
2012-01-01    289030.0
2012-01-08    295268.0
2012-01-15    301505.0
Freq: W-SUN, dtype: float64

Part 19: Visualizing the Real Future Forecast#

plt.figure(figsize=(11, 6))
plt.plot(weekly.index, weekly.values, label='Real Historical Revenue', color='navy')
plt.plot(future_forecast.index, future_forecast.values, label='Real Future Forecast', color='crimson', marker='o', linestyle='--')
plt.xlabel('Real Week')
plt.ylabel('Real Weekly Revenue (GBP)')
plt.title('Real Historical Revenue Plus Six-Week Forecast')
plt.legend()
plt.xticks(rotation=45)
plt.tight_layout()
plt.savefig('future_revenue_forecast.png', dpi=120)
plt.close()

Part 20: Real Residual Check#

residuals = train - hw_model.fittedvalues
residuals.mean().round(1), residuals.std().round(1)
(np.float64(9411.5), np.float64(47785.6))

Part 21: Saving the Real Forecast Results#

forecast_output = pd.DataFrame({'Week': future_forecast.index, 'ForecastedRevenue': future_forecast.values.round(2)})
forecast_output.to_csv('weekly_revenue_forecast.csv', index=False)
reloaded_forecast = pd.read_csv('weekly_revenue_forecast.csv')
len(reloaded_forecast) == len(future_forecast)
True

Part 22: Real Percentage Growth Implied by the Forecast#

last_actual_week = weekly.iloc[-1]
first_forecast_week = future_forecast.iloc[0]
growth_pct = round((first_forecast_week - last_actual_week) / last_actual_week * 100, 1)
print(f"The model expects next week's real revenue to move {growth_pct}% versus the most recent actual real week.")
The model expects next week's real revenue to move 9.5% versus the most recent actual real week.

Part 23: Real Rolling Backtest#

backtest_errors = []
for cutoff in [-20, -16, -12, -8]:
    fold_train, fold_test = weekly.iloc[:cutoff], weekly.iloc[cutoff:cutoff+4]
    fold_model = ExponentialSmoothing(fold_train, trend='add', seasonal=None).fit()
    fold_forecast = fold_model.forecast(len(fold_test))
    backtest_errors.append(mae(fold_test, fold_forecast))
[round(e, 0) for e in backtest_errors]
[np.float64(29268.0),
 np.float64(24092.0),
 np.float64(110298.0),
 np.float64(16628.0)]

Part 24: Real Backtest Stability#

round(np.mean(backtest_errors), 0), round(np.std(backtest_errors), 0)
(np.float64(45071.0), np.float64(37926.0))

Part 25: Real Baseline 4, Simple Linear Trend#

x = np.arange(len(train))
slope, intercept = np.polyfit(x, train.values, 1)
future_x = np.arange(len(train), len(train) + len(test))
linear_forecast = pd.Series(slope * future_x + intercept, index=test.index)
linear_mae, linear_rmse = mae(test, linear_forecast), rmse(test, linear_forecast)
round(linear_mae, 0), round(linear_rmse, 0)
(np.float64(47703.0), np.float64(51075.0))

Part 26: Real Total Forecasted Revenue#

total_forecast_revenue = future_forecast.sum()
print(f'Total real forecasted revenue for the next six weeks: {round(total_forecast_revenue, 0):,.0f} GBP')
Total real forecasted revenue for the next six weeks: 1,715,468 GBP

Part 27: Real Largest Single-Week Forecast Miss#

errors_by_week = (test - hw_forecast).abs()
worst_week = errors_by_week.idxmax()
print(f'Largest real miss: week of {worst_week.date()}, real actual {test[worst_week]:,.0f} vs real forecast {hw_forecast[worst_week]:,.0f}')
Largest real miss: week of 2011-10-23, real actual 245,694 vs real forecast 218,059

Part 28: Real Final Model Comparison Including Linear Trend#

full_comparison = pd.concat([comparison, pd.DataFrame({'Model': ['Linear Trend'], 'MAE': [linear_mae], 'RMSE': [linear_rmse]})], ignore_index=True)
full_comparison.sort_values('MAE').round(0)
full_comparison.to_csv('forecast_model_comparison.csv', index=False)

Part 29: Real Weekly Average Sanity Check#

manual_weekly_total = daily.loc[weekly.index[0] - pd.Timedelta(days=6):weekly.index[0]].sum()
abs(manual_weekly_total - weekly.iloc[0]) < 0.01
np.True_

Part 30: Real Recap Print#

print(f'Forecasted {len(future_forecast)} real future weeks using Holt-Winters, beating the naive baseline by {improvement_pct}% on the real held-out test.')
Forecasted 6 real future weeks using Holt-Winters, beating the naive baseline by 67.3% on the real held-out test.

Wrap-Up: What You Learned#

  • Forecasting starts with one real evenly-spaced timeline; missing real days are genuinely zero, not gaps to drop.
  • A real honest test hides real recent weeks and scores forecasts against them, never against data the model already saw.
  • Naive and moving-average forecasts are real fast, genuinely useful floors that any smarter model has to actually beat.
  • Holt's exponential smoothing tracks a real trend, letting the forecast keep moving instead of staying flat.
  • A real production forecast gets refit on every real available week once the method has proven itself honestly.
  • Next video: real price and discount impact analysis, looking at how real markdowns actually move real demand.

Found this useful?

All lessons, notebooks and datasets here are free. If they helped you, a coffee keeps new lessons coming.