Day 2: Demand Forecasting for Antibiotics

WISE Workshop | Addis Ababa, Feb 2026

In this notebook, you’ll build machine learning models to forecast demand for three classes of antibiotics across 100 community health centers. This scenario demonstrates real-world supply chain forecasting with:

Setup

# Import packages
import pandas as pd
import numpy as np
import matplotlib.pyplot as plt
import seaborn as sns

from sklearn.model_selection import cross_val_score
from sklearn.preprocessing import LabelEncoder
from sklearn.linear_model import LinearRegression, Ridge
from sklearn.ensemble import RandomForestRegressor, GradientBoostingRegressor
from sklearn.metrics import mean_squared_error, mean_absolute_error, r2_score

import warnings
warnings.filterwarnings('ignore')

# Set random seed for reproducibility
np.random.seed(42)

print("Packages loaded!")

Part 1: Generate Synthetic Data

We’ll create realistic synthetic data representing: - 100 community health centers (CHC_001 to CHC_100) - 60 months of historical data (Jan 2019 - Dec 2023) - 3 antibiotic classes with distinct demand patterns: 1. Penicillins (e.g., Amoxicillin) - high volume, seasonal 2. Macrolides (e.g., Azithromycin) - medium volume, respiratory season peaks 3. Fluoroquinolones (e.g., Ciprofloxacin) - lower volume, more stable

# Define parameters
n_facilities = 100
n_months = 60  # Jan 2019 - Dec 2023
antibiotic_classes = ['Penicillins', 'Macrolides', 'Fluoroquinolones']

# Create facility IDs
facility_ids = [f'CHC_{i:03d}' for i in range(1, n_facilities + 1)]

# Assign population served to each facility (varies from 5,000 to 50,000)
facility_populations = {fid: np.random.randint(5000, 50001) for fid in facility_ids}

# Create date range
dates = pd.date_range('2019-01-01', periods=n_months, freq='MS')

print(f"Facilities: {n_facilities}")
print(f"Time period: {dates[0].strftime('%b %Y')} - {dates[-1].strftime('%b %Y')}")
print(f"Antibiotic classes: {antibiotic_classes}")
# Define antibiotic class characteristics
class_params = {
    'Penicillins': {
        'base_demand_per_1000': 15,  # High volume
        'seasonal_amplitude': 0.3,   # Strong seasonality
        'peak_month': 1,             # Peak in January (cold/flu season)
        'trend': 0.02                # 2% annual growth
    },
    'Macrolides': {
        'base_demand_per_1000': 8,   # Medium volume
        'seasonal_amplitude': 0.4,   # Very seasonal (respiratory)
        'peak_month': 12,            # Peak in December
        'trend': 0.03                # 3% annual growth
    },
    'Fluoroquinolones': {
        'base_demand_per_1000': 4,   # Lower volume
        'seasonal_amplitude': 0.1,   # More stable
        'peak_month': 7,             # Slight peak in rainy season
        'trend': 0.01                # 1% annual growth
    }
}

# Define rainy season months (June-September in Ethiopia)
rainy_months = [6, 7, 8, 9]

print("Antibiotic class parameters defined")
# Generate the dataset
data_rows = []

for facility_id in facility_ids:
    population = facility_populations[facility_id]
    
    for date in dates:
        month = date.month
        year = date.year
        
        # Determine season
        season = 'Rainy' if month in rainy_months else 'Dry'
        
        # Simulate occasional disease outbreaks (5% chance per facility-month)
        outbreak = np.random.random() < 0.05
        
        for abx_class in antibiotic_classes:
            params = class_params[abx_class]
            
            # Base demand scaled by population
            base = params['base_demand_per_1000'] * (population / 1000)
            
            # Seasonal effect (sinusoidal with class-specific peak)
            seasonal = params['seasonal_amplitude'] * np.sin(
                2 * np.pi * (month - params['peak_month']) / 12
            )
            
            # Year-over-year trend
            years_from_start = (year - 2019) + (month - 1) / 12
            trend = 1 + params['trend'] * years_from_start
            
            # Outbreak effect (doubles demand for Penicillins and Macrolides)
            outbreak_multiplier = 1.0
            if outbreak and abx_class in ['Penicillins', 'Macrolides']:
                outbreak_multiplier = 2.0
            
            # Calculate demand with noise
            demand = base * (1 + seasonal) * trend * outbreak_multiplier
            noise = np.random.normal(0, demand * 0.15)  # 15% noise
            demand = max(1, int(demand + noise))  # Ensure positive integer
            
            data_rows.append({
                'facility_id': facility_id,
                'date': date,
                'year': year,
                'month': month,
                'antibiotic_class': abx_class,
                'population_served': population,
                'season': season,
                'disease_outbreak': int(outbreak),
                'demand': demand
            })

# Create DataFrame
df = pd.DataFrame(data_rows)

print(f"Dataset shape: {df.shape}")
print(f"Expected: {n_facilities} facilities × {n_months} months × {len(antibiotic_classes)} classes = {n_facilities * n_months * len(antibiotic_classes)} rows")
df.head(10)
# Summary statistics
print("Dataset Summary:")
print(f"\nDate range: {df['date'].min()} to {df['date'].max()}")
print(f"\nFacilities: {df['facility_id'].nunique()}")
print(f"\nAntibiotic classes: {df['antibiotic_class'].unique().tolist()}")
print(f"\nDemand statistics:")
df.groupby('antibiotic_class')['demand'].describe().round(1)

Part 2: Exploratory Data Analysis

# Demand distribution by antibiotic class
fig, axes = plt.subplots(1, 3, figsize=(14, 4))

for ax, abx_class in zip(axes, antibiotic_classes):
    class_data = df[df['antibiotic_class'] == abx_class]['demand']
    ax.hist(class_data, bins=50, edgecolor='black', alpha=0.7)
    ax.set_xlabel('Monthly Demand (units)')
    ax.set_ylabel('Frequency')
    ax.set_title(f'{abx_class}\nMean: {class_data.mean():.0f}, Median: {class_data.median():.0f}')
    ax.axvline(class_data.mean(), color='red', linestyle='--', label='Mean')

plt.tight_layout()
plt.show()
# Seasonal patterns by antibiotic class
monthly_demand = df.groupby(['month', 'antibiotic_class'])['demand'].mean().reset_index()

fig, ax = plt.subplots(figsize=(10, 5))

for abx_class in antibiotic_classes:
    class_data = monthly_demand[monthly_demand['antibiotic_class'] == abx_class]
    ax.plot(class_data['month'], class_data['demand'], marker='o', label=abx_class, linewidth=2)

ax.set_xlabel('Month')
ax.set_ylabel('Average Monthly Demand')
ax.set_title('Seasonal Patterns by Antibiotic Class')
ax.set_xticks(range(1, 13))
ax.set_xticklabels(['Jan', 'Feb', 'Mar', 'Apr', 'May', 'Jun', 
                    'Jul', 'Aug', 'Sep', 'Oct', 'Nov', 'Dec'])
ax.legend()
ax.grid(True, alpha=0.3)

# Highlight rainy season
ax.axvspan(6, 9, alpha=0.2, color='blue', label='Rainy Season')

plt.tight_layout()
plt.show()
# Time series trends
monthly_total = df.groupby(['date', 'antibiotic_class'])['demand'].sum().reset_index()

fig, ax = plt.subplots(figsize=(12, 5))

for abx_class in antibiotic_classes:
    class_data = monthly_total[monthly_total['antibiotic_class'] == abx_class]
    ax.plot(class_data['date'], class_data['demand'], label=abx_class, linewidth=1.5, alpha=0.8)

ax.set_xlabel('Date')
ax.set_ylabel('Total Monthly Demand (all facilities)')
ax.set_title('Demand Trends Over Time')
ax.legend()
ax.grid(True, alpha=0.3)

plt.tight_layout()
plt.show()
# Facility-level variation: demand vs population
facility_summary = df.groupby(['facility_id', 'antibiotic_class']).agg({
    'demand': 'mean',
    'population_served': 'first'
}).reset_index()

fig, ax = plt.subplots(figsize=(10, 6))

for abx_class in antibiotic_classes:
    class_data = facility_summary[facility_summary['antibiotic_class'] == abx_class]
    ax.scatter(class_data['population_served'], class_data['demand'], 
               alpha=0.6, label=abx_class, s=30)

ax.set_xlabel('Population Served')
ax.set_ylabel('Average Monthly Demand')
ax.set_title('Demand vs. Facility Population by Antibiotic Class')
ax.legend()
ax.grid(True, alpha=0.3)

plt.tight_layout()
plt.show()

Part 3: Feature Engineering

# Create a copy for feature engineering
df_model = df.copy()

# Time features
df_model['quarter'] = df_model['date'].dt.quarter

# Cyclical encoding for month (captures circular nature of months)
df_model['month_sin'] = np.sin(2 * np.pi * df_model['month'] / 12)
df_model['month_cos'] = np.cos(2 * np.pi * df_model['month'] / 12)

# Encode categorical variables
le_facility = LabelEncoder()
le_class = LabelEncoder()
le_season = LabelEncoder()

df_model['facility_encoded'] = le_facility.fit_transform(df_model['facility_id'])
df_model['class_encoded'] = le_class.fit_transform(df_model['antibiotic_class'])
df_model['season_encoded'] = le_season.fit_transform(df_model['season'])

print("Features created:")
print(df_model[['facility_id', 'month', 'month_sin', 'month_cos', 'quarter', 
                'antibiotic_class', 'class_encoded', 'season', 'season_encoded']].head())
# Create lag features (previous month demand)
# Sort by facility, class, and date first
df_model = df_model.sort_values(['facility_id', 'antibiotic_class', 'date'])

# Create lag features within each facility-class group
df_model['demand_lag1'] = df_model.groupby(['facility_id', 'antibiotic_class'])['demand'].shift(1)
df_model['demand_lag3'] = df_model.groupby(['facility_id', 'antibiotic_class'])['demand'].shift(3)

# Rolling average (3-month)
df_model['demand_rolling_3'] = df_model.groupby(['facility_id', 'antibiotic_class'])['demand'].transform(
    lambda x: x.shift(1).rolling(window=3, min_periods=1).mean()
)

# Drop rows with missing lag values (first few months per facility-class)
df_model = df_model.dropna(subset=['demand_lag1', 'demand_lag3'])

print(f"Shape after adding lag features: {df_model.shape}")
print(f"Rows removed due to lag: {len(df) - len(df_model)}")
# Define feature columns
feature_cols = [
    'month', 'month_sin', 'month_cos', 'quarter', 'year',
    'facility_encoded', 'class_encoded', 'season_encoded',
    'population_served', 'disease_outbreak',
    'demand_lag1', 'demand_lag3', 'demand_rolling_3'
]

print("Feature columns:")
for col in feature_cols:
    print(f"  - {col}")

Part 4: Train/Test Split (Temporal)

Important: For time series forecasting, we use a temporal split to prevent data leakage: - Training data: 2019-2022 (48 months) - Test data: 2023 (12 months) - simulates forecasting the next year

# Temporal train/test split
train_data = df_model[df_model['year'] <= 2022]
test_data = df_model[df_model['year'] == 2023]

X_train = train_data[feature_cols]
y_train = train_data['demand']

X_test = test_data[feature_cols]
y_test = test_data['demand']

print(f"Training period: {train_data['date'].min().strftime('%b %Y')} - {train_data['date'].max().strftime('%b %Y')}")
print(f"Training samples: {len(X_train):,}")
print(f"\nTest period: {test_data['date'].min().strftime('%b %Y')} - {test_data['date'].max().strftime('%b %Y')}")
print(f"Test samples: {len(X_test):,}")

Part 5: Train Models

# Initialize models
models = {
    'Linear Regression': LinearRegression(),
    'Ridge Regression': Ridge(alpha=1.0),
    'Random Forest': RandomForestRegressor(n_estimators=100, max_depth=15, random_state=42, n_jobs=-1),
    'Gradient Boosting': GradientBoostingRegressor(n_estimators=100, max_depth=5, random_state=42)
}

# Train and evaluate each model
results = []
predictions = {}

for name, model in models.items():
    print(f"Training {name}...")
    model.fit(X_train, y_train)
    
    # Predictions
    y_pred = model.predict(X_test)
    predictions[name] = y_pred
    
    # Metrics
    rmse = np.sqrt(mean_squared_error(y_test, y_pred))
    mae = mean_absolute_error(y_test, y_pred)
    r2 = r2_score(y_test, y_pred)
    
    results.append({
        'Model': name,
        'RMSE': rmse,
        'MAE': mae,
        'R²': r2
    })
    
    print(f"  RMSE: {rmse:.2f}, MAE: {mae:.2f}, R²: {r2:.3f}")

print("\nAll models trained!")

Part 6: Model Comparison

# Results summary
results_df = pd.DataFrame(results)
results_df = results_df.sort_values('R²', ascending=False)

print("Model Comparison (sorted by R²):")
display(results_df)
# Visualize model performance
fig, axes = plt.subplots(1, 3, figsize=(14, 4))

metrics = ['RMSE', 'MAE', 'R²']
colors = ['steelblue', 'darkorange', 'green']

for ax, metric, color in zip(axes, metrics, colors):
    bars = ax.barh(results_df['Model'], results_df[metric], color=color, alpha=0.7)
    ax.set_xlabel(metric)
    ax.set_title(f'{metric} by Model')
    
    # Add value labels
    for bar, val in zip(bars, results_df[metric]):
        ax.text(val, bar.get_y() + bar.get_height()/2, f'{val:.2f}', 
                va='center', ha='left', fontsize=9)

plt.tight_layout()
plt.show()
# Predictions vs Actual for each model
fig, axes = plt.subplots(2, 2, figsize=(12, 10))
axes = axes.flatten()

for ax, (name, y_pred) in zip(axes, predictions.items()):
    ax.scatter(y_test, y_pred, alpha=0.3, s=10)
    
    # Perfect prediction line
    max_val = max(y_test.max(), max(y_pred))
    ax.plot([0, max_val], [0, max_val], 'r--', lw=2, label='Perfect Prediction')
    
    ax.set_xlabel('Actual Demand')
    ax.set_ylabel('Predicted Demand')
    ax.set_title(f'{name}')
    ax.legend(loc='upper left')

plt.tight_layout()
plt.show()

Part 7: Error Analysis by Antibiotic Class

# Use best model (Gradient Boosting typically)
best_model_name = results_df.iloc[0]['Model']
best_pred = predictions[best_model_name]

# Add predictions to test data
test_data_eval = test_data.copy()
test_data_eval['predicted'] = best_pred
test_data_eval['error'] = test_data_eval['predicted'] - test_data_eval['demand']
test_data_eval['abs_error'] = np.abs(test_data_eval['error'])
test_data_eval['pct_error'] = 100 * test_data_eval['abs_error'] / test_data_eval['demand']

# Metrics by antibiotic class
class_metrics = test_data_eval.groupby('antibiotic_class').agg({
    'demand': 'mean',
    'abs_error': 'mean',
    'pct_error': 'mean'
}).round(2)
class_metrics.columns = ['Avg Demand', 'MAE', 'MAPE (%)']

print(f"Error Analysis by Antibiotic Class ({best_model_name}):")
display(class_metrics)
# Error distribution by class
fig, axes = plt.subplots(1, 3, figsize=(14, 4))

for ax, abx_class in zip(axes, antibiotic_classes):
    class_errors = test_data_eval[test_data_eval['antibiotic_class'] == abx_class]['error']
    ax.hist(class_errors, bins=40, edgecolor='black', alpha=0.7)
    ax.axvline(0, color='red', linestyle='--', linewidth=2)
    ax.set_xlabel('Prediction Error')
    ax.set_ylabel('Frequency')
    ax.set_title(f'{abx_class}\nMean Error: {class_errors.mean():.1f}')

plt.tight_layout()
plt.show()

Part 8: Feature Importance

# Feature importance from Random Forest and Gradient Boosting
rf_importance = pd.DataFrame({
    'Feature': feature_cols,
    'Random Forest': models['Random Forest'].feature_importances_
})

gb_importance = pd.DataFrame({
    'Feature': feature_cols,
    'Gradient Boosting': models['Gradient Boosting'].feature_importances_
})

importance_df = rf_importance.merge(gb_importance, on='Feature')
importance_df['Average'] = (importance_df['Random Forest'] + importance_df['Gradient Boosting']) / 2
importance_df = importance_df.sort_values('Average', ascending=False)

print("Feature Importance:")
display(importance_df.round(4))
# Visualize feature importance
fig, axes = plt.subplots(1, 2, figsize=(14, 6))

# Random Forest
rf_sorted = importance_df.sort_values('Random Forest', ascending=True)
axes[0].barh(rf_sorted['Feature'], rf_sorted['Random Forest'], color='steelblue', alpha=0.7)
axes[0].set_xlabel('Importance')
axes[0].set_title('Random Forest Feature Importance')

# Gradient Boosting
gb_sorted = importance_df.sort_values('Gradient Boosting', ascending=True)
axes[1].barh(gb_sorted['Feature'], gb_sorted['Gradient Boosting'], color='darkorange', alpha=0.7)
axes[1].set_xlabel('Importance')
axes[1].set_title('Gradient Boosting Feature Importance')

plt.tight_layout()
plt.show()

Part 9: Forecast Visualization

# Aggregate predictions by month and antibiotic class
forecast_summary = test_data_eval.groupby(['date', 'antibiotic_class']).agg({
    'demand': 'sum',
    'predicted': 'sum'
}).reset_index()

# Also get historical data for context
historical_summary = train_data.groupby(['date', 'antibiotic_class'])['demand'].sum().reset_index()

print("Forecast summary prepared")
# Plot 12-month forecast vs actual for each antibiotic class
fig, axes = plt.subplots(3, 1, figsize=(12, 10))

for ax, abx_class in zip(axes, antibiotic_classes):
    # Historical data (last 2 years for context)
    hist_class = historical_summary[
        (historical_summary['antibiotic_class'] == abx_class) & 
        (historical_summary['date'] >= '2021-01-01')
    ]
    
    # Forecast period
    forecast_class = forecast_summary[forecast_summary['antibiotic_class'] == abx_class]
    
    # Plot historical
    ax.plot(hist_class['date'], hist_class['demand'], 
            color='steelblue', linewidth=2, label='Historical')
    
    # Plot actual 2023
    ax.plot(forecast_class['date'], forecast_class['demand'], 
            color='green', linewidth=2, label='Actual (2023)', marker='o')
    
    # Plot predicted 2023
    ax.plot(forecast_class['date'], forecast_class['predicted'], 
            color='red', linewidth=2, linestyle='--', label='Forecast', marker='s')
    
    ax.set_ylabel('Total Demand')
    ax.set_title(f'{abx_class}')
    ax.legend(loc='upper left')
    ax.grid(True, alpha=0.3)
    
    # Add vertical line at forecast start
    ax.axvline(pd.Timestamp('2023-01-01'), color='gray', linestyle=':', alpha=0.7)

axes[-1].set_xlabel('Date')
plt.suptitle(f'12-Month Demand Forecast vs Actual ({best_model_name})', fontsize=14, y=1.02)
plt.tight_layout()
plt.show()
# Monthly forecast accuracy
monthly_accuracy = forecast_summary.copy()
monthly_accuracy['error'] = monthly_accuracy['predicted'] - monthly_accuracy['demand']
monthly_accuracy['pct_error'] = 100 * monthly_accuracy['error'] / monthly_accuracy['demand']
monthly_accuracy['month'] = monthly_accuracy['date'].dt.strftime('%b')

# Pivot for display
accuracy_pivot = monthly_accuracy.pivot(index='month', columns='antibiotic_class', values='pct_error')

# Reorder months
month_order = ['Jan', 'Feb', 'Mar', 'Apr', 'May', 'Jun', 'Jul', 'Aug', 'Sep', 'Oct', 'Nov', 'Dec']
accuracy_pivot = accuracy_pivot.reindex(month_order)

print("Monthly Forecast Error (% over/under actual):")
display(accuracy_pivot.round(1))

Part 10: Summary & Key Takeaways

# Final summary
print("="*60)
print("DEMAND FORECASTING SUMMARY")
print("="*60)
print(f"\nDataset:")
print(f"  - Facilities: 100 Community Health Centers")
print(f"  - Time period: Jan 2019 - Dec 2023 (60 months)")
print(f"  - Antibiotic classes: Penicillins, Macrolides, Fluoroquinolones")
print(f"  - Total records: {len(df):,}")

print(f"\nBest Performing Model: {best_model_name}")
best_results = results_df[results_df['Model'] == best_model_name].iloc[0]
print(f"  - RMSE: {best_results['RMSE']:.2f}")
print(f"  - MAE: {best_results['MAE']:.2f}")
print(f"  - R²: {best_results['R²']:.3f}")

print(f"\nTop 5 Most Important Features:")
for i, row in importance_df.head(5).iterrows():
    print(f"  {row['Feature']}: {row['Average']:.3f}")

print(f"\nKey Insights:")
print("  - Lag features (previous demand) are strong predictors")
print("  - Population served drives facility-level variation")
print("  - Seasonal patterns differ by antibiotic class")
print("  - Ensemble methods (RF, GB) outperform linear models")
# Practical implications
print("\n" + "="*60)
print("PRACTICAL IMPLICATIONS FOR SUPPLY CHAIN PLANNING")
print("="*60)

print("""
1. INVENTORY PLANNING
   - Use 12-month forecasts to plan annual procurement
   - Account for seasonal peaks (especially for Penicillins and Macrolides)
   - Buffer stock during outbreak-prone periods

2. FACILITY-LEVEL ALLOCATION
   - Scale distribution by population served
   - Use rolling 3-month averages to smooth allocation
   - Monitor facilities with high forecast errors

3. CLASS-SPECIFIC STRATEGIES
   - Penicillins: High volume, plan for January peak
   - Macrolides: Strong seasonality, respiratory season focus
   - Fluoroquinolones: More stable, can use longer procurement cycles

4. MODEL UPDATES
   - Retrain models quarterly with new data
   - Monitor for emerging resistance patterns
   - Incorporate disease surveillance data when available
""")

Next Steps: - Experiment with additional features (weather, disease surveillance) - Try more advanced models (XGBoost, LSTM for time series) - Build confidence intervals for forecasts - Deploy model for real-time demand monitoring