Mathew K Analytics

Lesson 24 · Supply Chain Operations Analytics

Forecast Accuracy Evaluation and Monitoring in Supply Chain Operations

In this lesson, we will tackle how to measure and monitor the accuracy of demand forecasts using real-world operational datasets. Accurate forecasting is…

⬇ Download notebookOpen in Colab ↗

📓 Full notebook

Download .ipynb

Forecast Accuracy Evaluation and Monitoring in Supply Chain Operations#

  • In this lesson, we will tackle how to measure and monitor the accuracy of demand forecasts using real-world operational datasets.
  • Accurate forecasting is crucial for inventory planning, minimizing costs, and ensuring product availability in supply chain management.
  • You will learn to use Python to calculate forecast accuracy metrics, visualize errors, detect bias, and set up ongoing monitoring.
  • By the end, you will be able to identify common forecasting pitfalls and calculate meaningful metrics for real business data.
import pandas as pd
import numpy as np
import matplotlib.pyplot as plt
import warnings
warnings.filterwarnings('ignore')

What Data Will We Use and Why?#

  • We will use real retail demand data from the UCI Online Retail II dataset.
  • This dataset includes product transactions with timestamps and quantities sold.
  • Each record represents an item sold at a point in time, ideal for time series forecasting.
  • Beginners often confuse sales transactions with aggregated demand over time.
  • Always check date columns and missing values when working with demand data.
# Load data: UCI Online Retail II dataset
url = 'https://archive.ics.uci.edu/ml/machine-learning-databases/00502/online_retail_II.xlsx'
df = pd.read_excel(url, sheet_name='Year 2010-2011')
df['InvoiceDate'] = pd.to_datetime(df['InvoiceDate'])
print(df.shape)
print(df.head(3))
(541910, 8)
  Invoice StockCode                         Description  Quantity  \
0  536365    85123A  WHITE HANGING HEART T-LIGHT HOLDER         6   
1  536365     71053                 WHITE METAL LANTERN         6   
2  536365    84406B      CREAM CUPID HEARTS COAT HANGER         8   

          InvoiceDate  Price  Customer ID         Country  
0 2010-12-01 08:26:00   2.55      17850.0  United Kingdom  
1 2010-12-01 08:26:00   3.39      17850.0  United Kingdom  
2 2010-12-01 08:26:00   2.75      17850.0  United Kingdom  

Understanding Forecasts and Actuals#

  • In real operations, the team creates forecasts for demand before actual sales happen.
  • Actuals are the recorded sales after the period ends.
  • Comparing forecasts and actuals helps spot over- or under-estimating demand.
  • Common mistake: forgetting to align forecast and actual dates correctly.
# Aggregate by week and item for forecasting example
df['week'] = df['InvoiceDate'].dt.to_period('W').apply(lambda r: r.start_time)
weekly = df.groupby(['week', 'StockCode'])['Quantity'].sum().reset_index()
weekly.rename(columns={'Quantity': 'actual_demand'}, inplace=True)
print(weekly.head())
        week StockCode  actual_demand
0 2010-11-29     10002             70
1 2010-11-29     10120              3
2 2010-11-29     10125              2
3 2010-11-29     10133             20
4 2010-11-29     10135             23
# Simulate a naive forecast: previous week's sales-> forecast for next week
weekly['forecast'] = weekly.groupby('StockCode')['actual_demand'].shift(1)
print(weekly.head(10))
        week StockCode  actual_demand  forecast
0 2010-11-29     10002             70       NaN
1 2010-11-29     10120              3       NaN
2 2010-11-29     10125              2       NaN
3 2010-11-29     10133             20       NaN
4 2010-11-29     10135             23       NaN
5 2010-11-29     11001             18       NaN
6 2010-11-29     15034              2       NaN
7 2010-11-29     15036             75       NaN
8 2010-11-29     15039             15       NaN
9 2010-11-29     16010             12       NaN

Beginner Example 1: Calculating Forecast Errors#

  • The simplest way to check accuracy is to compute the forecast error: actual - forecast.
  • Large errors can lead to excess stock or missed sales in operations.
# Compute forecast error
weekly['error'] = weekly['actual_demand'] - weekly['forecast']
print(weekly[['week','StockCode','actual_demand','forecast','error']].head(10))
        week StockCode  actual_demand  forecast  error
0 2010-11-29     10002             70       NaN    NaN
1 2010-11-29     10120              3       NaN    NaN
2 2010-11-29     10125              2       NaN    NaN
3 2010-11-29     10133             20       NaN    NaN
4 2010-11-29     10135             23       NaN    NaN
5 2010-11-29     11001             18       NaN    NaN
6 2010-11-29     15034              2       NaN    NaN
7 2010-11-29     15036             75       NaN    NaN
8 2010-11-29     15039             15       NaN    NaN
9 2010-11-29     16010             12       NaN    NaN

Beginner Example 2: Visualizing Forecast Errors Over Time#

  • Visualizations help spot trends like continual over- or under-forecasting.
  • Sharp spikes may signal operational problems or special events.
# Plot errors for an example item
item = weekly['StockCode'].unique()[0]
item_data = weekly[weekly['StockCode']==item]
plt.figure(figsize=(10,4))
plt.plot(item_data['week'], item_data['error'], marker='o')
plt.title(f'Forecast Error for Item {item}')
plt.xlabel('Week')
plt.ylabel('Error (Actual - Forecast)')
plt.axhline(0, color='gray', linestyle='--')
plt.tight_layout()
plt.show()
No description has been provided for this image

Beginner Example 3: Mean Absolute Error (MAE)#

  • MAE is a common metric to summarize how far off forecasts are on average.
  • It is preferred in operations because it is simple to explain.
# Calculate MAE for each item
mae = weekly.dropna().groupby('StockCode').apply(lambda g: np.mean(np.abs(g['error']))).reset_index(name='MAE')
print(mae.head())
  StockCode        MAE
0     10002  69.947368
1     10080  37.666667
2     10120  11.000000
3     10125  42.571429
4     10133  46.285714

Intermediate Example 1: Calculating MAPE (Mean Absolute Percentage Error)#

  • MAPE expresses error as a percentage of actual demand.
  • This makes it easy to compare accuracy across items with different sales volumes.
# Calculate MAPE for each item, avoiding division by zero
def mape(y_true, y_pred):
    non_zero = y_true != 0
    return np.mean(np.abs((y_true[non_zero] - y_pred[non_zero]) / y_true[non_zero])) * 100
mapes = weekly.dropna().groupby('StockCode').apply(lambda g: mape(g['actual_demand'].values, g['forecast'].values)).reset_index(name='MAPE')
print(mapes.head())
  StockCode         MAPE
0     10002  1260.264085
1     10080   133.520450
2     10120   197.074164
3     10125   498.147935
4     10133   312.356270

Intermediate Example 2: Bias and Forecast Monitoring#

  • Forecast bias happens when the model systematically over- or underestimates demand.
  • The mean error shows whether bias exists.
  • Businesses monitor bias to avoid running out of stock (negative bias) or overstocking (positive bias).
# Calculate mean forecast bias for each item
bias = weekly.dropna().groupby('StockCode').apply(lambda g: np.mean(g['error'])).reset_index(name='Bias')
print(bias.head())
  StockCode      Bias
0     10002 -3.842105
1     10080  1.666667
2     10120  0.368421
3     10125  0.514286
4     10133 -2.914286

Intermediate Example 3: Tracking Accuracy Over Time for Continuous Monitoring#

  • In operations, teams track rolling forecast accuracy to react quickly to changes.
  • Setting up time-based KPIs helps spot drift and emerging issues.
# Compute rolling 6-week MAE for a specific item
item_eg = weekly['StockCode'].unique()[0]
item_data = weekly[weekly['StockCode']==item_eg].dropna()
item_data = item_data.sort_values('week')
item_data['MAE_6w'] = item_data['error'].abs().rolling(window=6).mean()
plt.figure(figsize=(10,4))
plt.plot(item_data['week'], item_data['MAE_6w'], label='6-week MAE')
plt.title(f'Rolling 6-week MAE for Item {item_eg}')
plt.xlabel('Week')
plt.ylabel('MAE')
plt.legend()
plt.tight_layout()
plt.show()
No description has been provided for this image

Advanced Example 1: Monitoring Forecasts for Multiple SKUs#

  • Real operations must monitor many products at once.
  • Dashboards plot accuracy metrics for top products or categories.
  • Monitoring helps prioritize analyst attention where errors are growing.
# Plot rolling MAE for 5 random SKUs
np.random.seed(42)
sample_items = np.random.choice(weekly['StockCode'].unique(), 5, replace=False)
plt.figure(figsize=(12,6))
for code in sample_items:
    itemd = weekly[weekly['StockCode']==code].dropna().sort_values('week')
    itemd['MAE_6w'] = itemd['error'].abs().rolling(window=6).mean()
    plt.plot(itemd['week'], itemd['MAE_6w'], label=f'SKU {code}')
plt.title('Rolling 6-week MAE for 5 Sample SKUs')
plt.xlabel('Week')
plt.ylabel('6-week MAE')
plt.legend()
plt.tight_layout()
plt.show()
No description has been provided for this image

Advanced Example 2: Detecting Outlier Forecast Errors#

  • Outlier errors may indicate special causes like stockouts or promotions.
  • Operational teams flag large errors for investigation.
# Identify potential error outliers
weekly['abs_error'] = weekly['error'].abs()
threshold = weekly['abs_error'].mean() + 3*weekly['abs_error'].std()
outliers = weekly[weekly['abs_error'] > threshold]
print(f'Total outlier weeks: {outliers.shape[0]}')
print(outliers[['week','StockCode','actual_demand','forecast','error']].head())
Total outlier weeks: 827
           week StockCode  actual_demand  forecast   error
2039 2010-12-06     16014           1001      14.0   987.0
2056 2010-12-06     17021              2     600.0  -598.0
2501 2010-12-06     21623           1018      16.0  1002.0
2686 2010-12-06     21915            239    1660.0 -1421.0
2835 2010-12-06     22154            724     122.0   602.0

Error Handling Example: Missing Weeks in Demand Data#

  • When data is missing for certain weeks, forecasts and accuracy metrics can be misleading.
  • Always check for missing weeks and fill gaps before evaluating performance.
# Ensure each item has all weeks (fill missing periods with 0 actuals)
all_weeks = pd.date_range(weekly['week'].min(), weekly['week'].max(), freq='W')
all_items = weekly['StockCode'].unique()
index = pd.MultiIndex.from_product([all_weeks, all_items], names=['week','StockCode'])
full = weekly.set_index(['week','StockCode']).reindex(index, fill_value=0).reset_index()
print(f'Filled data shape: {full.shape}')
print(full.head())
Filled data shape: (215710, 6)
        week StockCode  actual_demand  forecast  error  abs_error
0 2010-12-05     10002              0       0.0    0.0        0.0
1 2010-12-05     10120              0       0.0    0.0        0.0
2 2010-12-05     10125              0       0.0    0.0        0.0
3 2010-12-05     10133              0       0.0    0.0        0.0
4 2010-12-05     10135              0       0.0    0.0        0.0

Error Handling Example: Incorrect Forecast Alignment#

  • Aligning forecast and actual data incorrectly changes the meaning of errors.
  • Always check that the forecast for week X matches the actual for week X (not X+1 by mistake).
# Example of misaligned forecasts: shifting in the wrong direction
full['misaligned_forecast'] = full.groupby('StockCode')['actual_demand'].shift(-1)
full['misaligned_error'] = full['actual_demand'] - full['misaligned_forecast']
print(full[['week','StockCode','actual_demand','misaligned_forecast','misaligned_error']].head(10))
        week StockCode  actual_demand  misaligned_forecast  misaligned_error
0 2010-12-05     10002              0                  0.0               0.0
1 2010-12-05     10120              0                  0.0               0.0
2 2010-12-05     10125              0                  0.0               0.0
3 2010-12-05     10133              0                  0.0               0.0
4 2010-12-05     10135              0                  0.0               0.0
5 2010-12-05     11001              0                  0.0               0.0
6 2010-12-05     15034              0                  0.0               0.0
7 2010-12-05     15036              0                  0.0               0.0
8 2010-12-05     15039              0                  0.0               0.0
9 2010-12-05     16010              0                  0.0               0.0

Error Handling Example: Zero Actuals in Percentage Error Calculations#

  • MAPE and similar metrics break when actual demand is zero.
  • Always check for zero actuals before dividing.
# Show how dividing by zero gives problems in MAPE
zeros = full[full['actual_demand']==0]
print(f'Sample weeks with zero demand: {len(zeros)}')
if not zeros.empty:
    print(zeros[['week','StockCode','actual_demand','forecast']].head(3))
Sample weeks with zero demand: 215710
        week StockCode  actual_demand  forecast
0 2010-12-05     10002              0       0.0
1 2010-12-05     10120              0       0.0
2 2010-12-05     10125              0       0.0

Best Practices: Aggregation and KPI Generation#

  • Aggregate demand before calculating forecasting metricsitem and time period must match.
  • Visualize errors by business segment or geography to direct improvement.
  • Use rolling averages to monitor ongoing accuracy and make operational dashboards.
# Example: Aggregate MAE by country (if available)
if 'Country' in df.columns:
    merged = (weekly.merge(df[['StockCode','Country']].drop_duplicates(), on='StockCode', how='left'))
    country_mae = merged.dropna().groupby('Country')['error'].apply(lambda x: np.mean(np.abs(x))).reset_index(name='MAE')
    print(country_mae.sort_values('MAE', ascending=False).head())
      Country        MAE
32     Sweden  99.985770
28        RSA  91.375352
30  Singapore  90.298757
9     Denmark  82.557484
5      Canada  80.326647

Operational Analytics Pattern: Time Series Grouping#

  • Always group and aggregate demand and forecast by the same time grain (week, month, etc).
  • Choosing the wrong time interval leads to misleading accuracy scores.
# Example: Group at monthly instead of weekly level
weekly['month'] = pd.to_datetime(weekly['week']).dt.to_period('M').astype(str)
monthly = weekly.groupby(['month','StockCode'])[['actual_demand','forecast']].sum().reset_index()
monthly['error'] = monthly['actual_demand'] - monthly['forecast']
print(monthly.head())
     month StockCode  actual_demand  forecast  error
0  2010-11     10002             70       0.0   70.0
1  2010-11     10120              3       0.0    3.0
2  2010-11     10125              2       0.0    2.0
3  2010-11     10133             20       0.0   20.0
4  2010-11     10135             23       0.0   23.0

Tiny End-to-End Case: Identifying Items with Declining Forecast Accuracy#

  • Goal: Spot SKUs that need urgent forecast improvements so the team can intervene.
  • Steps:
    1. Aggregate data weekly per item.
    1. Calculate rolling 8-week MAE for each.
    1. Identify SKUs where MAE has grown by 50 percent or more over the last 10 weeks.
# End-to-end: Flagging SKUs with rising MAE
result = []
for code in weekly['StockCode'].unique()[:100]: # limit to 100 for speed
    sku = weekly[weekly['StockCode']==code].dropna().sort_values('week')
    if len(sku) < 18: continue
    sku['MAE_8w'] = sku['error'].abs().rolling(window=8).mean()
    last10 = sku['MAE_8w'].iloc[-10:]
    if len(last10)==10 and last10.iloc[-1] > 1.5*last10.iloc[0]:
        result.append({'StockCode': code, 'Start_MAE_8w': last10.iloc[0], 'End_MAE_8w': last10.iloc[-1]})
df_flagged = pd.DataFrame(result)
print(df_flagged.head())
   StockCode  Start_MAE_8w  End_MAE_8w
0      10125        29.875      66.250
1      10133        47.375      78.375
2      15034       177.125     328.875
3      15039        25.000      57.875
4      16011        43.750      71.375
 

Found this useful?

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