Quay lại Blog
Data PlatformCập nhật: 27 tháng 5, 202519 phút đọc

Demand Forecasting with ML: Optimize Inventory và Supply Chain

Hướng dẫn xây dựng demand forecasting models với Prophet, ARIMA, XGBoost, và LSTM. So sánh algorithms, feature engineering, và case study retail chain giảm inventory cost 20% nhờ accurate forecasting. Production-ready implementation.

Đặng Quỳnh Hương

Đặng Quỳnh Hương

Senior Data Scientist

Biểu đồ time series forecasting với historical sales data, ML predictions và inventory optimization
#Demand Forecasting#Inventory Optimization#Time Series#Prophet#XGBoost#Machine Learning#Supply Chain

Trong retail và manufacturing, hai vấn đề tốn kém nhất là: (1) Overstock - hàng tồn kho ứ đọng, chiếm vốn, có thể hết hạn/lỗi thời, và (2) Stockout - hết hàng khi khách cần, mất doanh thu, khách không hài lòng. Theo nghiên cứu, overstock cost ~25-30% inventory value/year (vốn, storage, waste), còn stockout cost ~4-8% lost revenue.

Demand forecasting - dự đoán nhu cầu tương lai - là key để balance inventory: Order đúng lượng, đúng thời điểm. Traditional methods (moving averages, gut feeling) cho MAPE ~35-45% (Mean Absolute Percentage Error). Machine Learning có thể giảm xuống 18-25% MAPE, translating to 15-25% inventory cost reduction.

Theo khảo sát của Carptech với 40+ retailers và manufacturers tại Việt Nam, 72% vẫn dùng Excel cho forecasting, chỉ 15% đã deploy ML models. Missed opportunity: Hàng tỷ đồng trapped in inventory hoặc lost sales.

Bài viết này sẽ hướng dẫn end-to-end demand forecasting với ML: time series fundamentals, algorithms comparison (ARIMA, Prophet, XGBoost, LSTM), feature engineering, evaluation metrics, và production deployment. Kèm case study retail chain giảm inventory 1.2B VND/year.

TL;DR - Key Takeaways

  • Problem: Overstock (capital tied, waste) vs Stockout (lost sales) - forecasting balances both
  • Algorithms: Prophet (easiest, good for seasonality), XGBoost (best accuracy with features), LSTM (complex, large data)
  • Accuracy: ML reduces MAPE from 35-45% (traditional) to 18-25%, = 15-25% inventory cost savings
  • Features: Historical sales, seasonality, promotions, weather, holidays, events - 15-25 features typical
  • Implementation: Start with Prophet (2-4 weeks), scale to XGBoost if need better accuracy
  • ROI: 15-25% inventory reduction, 90-95% service level (in-stock rate)

Why Demand Forecasting Matters: Business Impact

The Inventory Balancing Act

Too much inventory (Overstock):

Costs:
- Capital tied up: 100M VND inventory × 12% interest rate = 12M VND/year
- Storage: Warehouse rent, handling, insurance (~8-10% inventory value/year)
- Obsolescence: Fashion (50% markdown), Electronics (20% depreciation/year), Food (spoilage)
- Total cost: 25-30% of inventory value/year

Too little inventory (Stockout):

Costs:
- Lost sales: Customer buys from competitor (4-8% revenue lost)
- Customer dissatisfaction: May never return (LTV impact)
- Emergency orders: Rush shipping (2-3x normal cost)

Optimal inventory: Balance costs

Goal: Minimize (Holding cost + Stockout cost)

Demand forecasting enables:
- Order right quantity
- Order at right time
- Achieve target service level (e.g., 95% in-stock rate)

ROI Example: Retail Chain

Scenario:

  • 100 stores, 1,000 SKUs/store
  • Current inventory: 500M VND average
  • Current MAPE: 40% (traditional forecasting)
  • Current stockout rate: 15%, overstock rate: 25%

After ML forecasting (MAPE 22%):

  • Inventory reduction: 20% (500M → 400M) = 100M VND freed up
    • Capital cost saved: 100M × 12% = 12M VND/year
    • Storage cost saved: 100M × 10% = 10M VND/year
  • Stockout reduction: 15% → 8% = 7% more sales captured
    • Additional revenue: 2B revenue × 7% = 140M VND/year
  • Total benefit: 12M + 10M + 140M = 162M VND/year
  • ML project cost: 50M VND (one-time) + 10M VND/year (maintenance)
  • ROI: 324% first year

Time Series Forecasting Fundamentals

What is Time Series?

Time series: Data points indexed by time (daily sales, hourly temperature, etc.)

Date        Sales
2024-01-01  1,200
2024-01-02  1,350
2024-01-03  1,180
...

Goal: Predict future values based on past patterns

Key Components of Time Series

1. Trend (long-term direction):

  • Increasing: Sales growing over time
  • Decreasing: Declining product
  • Flat: Mature, stable product

2. Seasonality (repeating patterns):

  • Weekly: Weekend vs weekday (retail)
  • Monthly: End-of-month salary (e-commerce)
  • Yearly: Summer vs winter (ice cream, coats)

3. Cyclic patterns (irregular cycles):

  • Economic cycles (recession, boom)
  • Multi-year patterns

4. Irregular/Noise (random fluctuations):

  • Unpredictable events (weather, strikes)
  • Measurement errors

Decomposition example:

from statsmodels.tsa.seasonal import seasonal_decompose
import pandas as pd

# Load sales data
df = pd.read_csv('daily_sales.csv', parse_dates=['date'], index_col='date')

# Decompose
decomposition = seasonal_decompose(df['sales'], model='multiplicative', period=7)  # Weekly seasonality

# Plot components
decomposition.plot()
plt.show()

Output: 4 charts (Observed, Trend, Seasonal, Residual)

Insight: Understanding components helps choose right algorithm

Algorithms Comparison: Traditional vs ML

1. Moving Average (Baseline)

Simple Moving Average (SMA):

# 7-day moving average
df['forecast_sma'] = df['sales'].rolling(window=7).mean().shift(1)

Pros: ✅ Simple, fast Cons: ❌ No trend, no seasonality, lag behind changes MAPE: 35-45%

2. ARIMA (AutoRegressive Integrated Moving Average)

Traditional time series workhorse

Concept:

  • AR (AutoRegressive): Use past values (e.g., yesterday's sales predict today)
  • I (Integrated): Differencing to remove trend
  • MA (Moving Average): Use past forecast errors

Parameters: ARIMA(p, d, q)

  • p: Number of lag observations (AR order)
  • d: Degree of differencing (I order)
  • q: Size of moving average window (MA order)

Implementation:

from statsmodels.tsa.arima.model import ARIMA

# Fit ARIMA model
model = ARIMA(df['sales'], order=(7, 1, 7))  # p=7, d=1, q=7
fitted_model = model.fit()

# Forecast next 30 days
forecast = fitted_model.forecast(steps=30)
print(forecast)

Pros: ✅ Classic, well-studied, handles trend Cons: ❌ Doesn't handle multiple seasonality well, requires manual parameter tuning MAPE: 25-35%

When to use: Small datasets, simple patterns, need explainability

3. Prophet (Facebook)

Modern, user-friendly time series library

Key features:

  • Automatic seasonality detection: Daily, weekly, yearly
  • Holiday effects: Built-in holiday calendars + custom events
  • Robust to missing data: Handles gaps gracefully
  • Additive or multiplicative seasonality
  • Trend changepoints: Detects when trend shifts

Implementation:

from prophet import Prophet
import pandas as pd

# Prepare data (Prophet requires 'ds' and 'y' columns)
df_prophet = df.rename(columns={'date': 'ds', 'sales': 'y'})

# Initialize model
model = Prophet(
    yearly_seasonality=True,
    weekly_seasonality=True,
    daily_seasonality=False,
    seasonality_mode='multiplicative'  # or 'additive'
)

# Add custom seasonality (e.g., monthly payday effect)
model.add_seasonality(name='monthly', period=30.5, fourier_order=5)

# Add holidays (Vietnamese holidays)
vietnam_holidays = pd.DataFrame({
    'holiday': 'tet',
    'ds': pd.to_datetime(['2024-02-10', '2025-01-29', '2026-02-17']),
    'lower_window': -3,  # 3 days before
    'upper_window': 7    # 7 days after
})
model = Prophet(holidays=vietnam_holidays)

# Fit model
model.fit(df_prophet)

# Forecast
future = model.make_future_dataframe(periods=30)  # Next 30 days
forecast = model.predict(future)

# Plot
model.plot(forecast)
model.plot_components(forecast)  # Trend, seasonality breakdown
plt.show()

Pros: ✅ Easy to use, handles seasonality well, interpretable, robust Cons: ❌ Can't use external features easily (promotions, weather) MAPE: 20-30%

When to use: Quick start, strong seasonality, need interpretability

4. XGBoost (Gradient Boosting)

ML approach: Treat forecasting as regression problem

Concept:

  • Create features from time series (lags, rolling stats, date features)
  • Train XGBoost to predict sales = f(features)

Feature engineering:

import pandas as pd
import numpy as np

def create_time_series_features(df):
    df = df.copy()

    # Date features
    df['day_of_week'] = df.index.dayofweek
    df['day_of_month'] = df.index.day
    df['week_of_year'] = df.index.isocalendar().week
    df['month'] = df.index.month
    df['quarter'] = df.index.quarter
    df['is_weekend'] = (df.index.dayofweek >= 5).astype(int)

    # Lag features (past sales)
    for lag in [1, 7, 14, 30]:
        df[f'lag_{lag}'] = df['sales'].shift(lag)

    # Rolling statistics
    for window in [7, 14, 30]:
        df[f'rolling_mean_{window}'] = df['sales'].rolling(window=window).mean()
        df[f'rolling_std_{window}'] = df['sales'].rolling(window=window).std()

    # Trend
    df['days_since_start'] = (df.index - df.index[0]).days

    return df

df_features = create_time_series_features(df)

Add external features:

# Promotions
df_features['is_promotion'] = df_features.index.isin(promotion_dates).astype(int)

# Weather
df_features = df_features.merge(weather_df, left_index=True, right_on='date', how='left')

# Holidays
df_features['is_holiday'] = df_features.index.isin(vietnam_holidays).astype(int)

Train XGBoost:

from xgboost import XGBRegressor
from sklearn.model_selection import train_test_split
from sklearn.metrics import mean_absolute_percentage_error

# Features and target
feature_cols = [
    'day_of_week', 'day_of_month', 'week_of_year', 'month', 'is_weekend',
    'lag_1', 'lag_7', 'lag_14', 'lag_30',
    'rolling_mean_7', 'rolling_mean_14', 'rolling_std_7',
    'is_promotion', 'temperature', 'is_holiday', 'days_since_start'
]

# Drop rows with NaN (from lag/rolling features)
df_clean = df_features.dropna()

X = df_clean[feature_cols]
y = df_clean['sales']

# Train/test split (temporal - no shuffle!)
split_idx = int(len(df_clean) * 0.8)
X_train, X_test = X[:split_idx], X[split_idx:]
y_train, y_test = y[:split_idx], y[split_idx:]

# Train model
model = XGBRegressor(
    n_estimators=500,
    max_depth=6,
    learning_rate=0.05,
    subsample=0.8,
    colsample_bytree=0.8,
    random_state=42
)

model.fit(X_train, y_train)

# Evaluate
y_pred = model.predict(X_test)
mape = mean_absolute_percentage_error(y_test, y_pred) * 100
print(f"MAPE: {mape:.2f}%")

# Feature importance
feature_importance = pd.DataFrame({
    'feature': feature_cols,
    'importance': model.feature_importances_
}).sort_values('importance', ascending=False)
print(feature_importance.head(10))

Pros: ✅ Best accuracy (with good features), handles external variables, flexible Cons: ❌ Requires feature engineering, less interpretable than Prophet MAPE: 18-25%

When to use: Need best accuracy, have external data (promotions, weather), willing to engineer features

5. LSTM (Long Short-Term Memory Neural Networks)

Deep learning for time series

Concept:

  • RNN variant designed for sequential data
  • Learns long-term dependencies

Implementation:

import tensorflow as tf
from tensorflow import keras
import numpy as np

# Prepare data for LSTM (sequences)
def create_sequences(data, seq_length=30):
    X, y = [], []
    for i in range(len(data) - seq_length):
        X.append(data[i:i+seq_length])
        y.append(data[i+seq_length])
    return np.array(X), np.array(y)

# Normalize data
from sklearn.preprocessing import MinMaxScaler
scaler = MinMaxScaler()
sales_scaled = scaler.fit_transform(df[['sales']])

# Create sequences (use last 30 days to predict next day)
X, y = create_sequences(sales_scaled, seq_length=30)

# Split
split_idx = int(len(X) * 0.8)
X_train, X_test = X[:split_idx], X[split_idx:]
y_train, y_test = y[:split_idx], y[split_idx:]

# Build LSTM model
model = keras.Sequential([
    keras.layers.LSTM(128, activation='relu', return_sequences=True, input_shape=(30, 1)),
    keras.layers.Dropout(0.2),
    keras.layers.LSTM(64, activation='relu'),
    keras.layers.Dropout(0.2),
    keras.layers.Dense(32, activation='relu'),
    keras.layers.Dense(1)
])

model.compile(optimizer='adam', loss='mse', metrics=['mae'])

# Train
history = model.fit(
    X_train, y_train,
    epochs=50,
    batch_size=32,
    validation_split=0.2,
    verbose=1
)

# Predict
y_pred_scaled = model.predict(X_test)
y_pred = scaler.inverse_transform(y_pred_scaled)

# Evaluate
mape = mean_absolute_percentage_error(
    scaler.inverse_transform(y_test), y_pred
) * 100
print(f"MAPE: {mape:.2f}%")

Pros: ✅ Can capture very complex patterns, handles multivariate time series Cons: ❌ Requires large datasets (10K+ data points), slow training, black box, overfitting risk MAPE: 15-22% (if tuned well and large data)

When to use: Very large datasets, complex patterns, have ML engineering resources

Algorithm Selection Guide

AlgorithmData SizeSeasonalityExternal FeaturesEase of UseMAPERecommendation
Moving AvgAny❌ No❌ No⭐⭐⭐⭐⭐35-45%Baseline only
ARIMA100-1000✅ Single❌ No⭐⭐25-35%Small data, simple
Prophet100-10K✅✅ Multiple⚠️ Limited⭐⭐⭐⭐20-30%Start here
XGBoost1K-100K✅ (via features)✅✅ Yes⭐⭐⭐18-25%Best for most
LSTM10K+✅✅ Multiple✅ Yes15-22%Large scale, complex

Carptech recommendation:

  1. Start with Prophet (quick, good results, 2-4 weeks implementation)
  2. Upgrade to XGBoost if you have promotions/weather/events data (4-8 weeks)
  3. Consider LSTM only if >10K SKUs and dedicated ML engineers

Advanced Feature Engineering

Beyond basic lag features, add domain-specific features:

1. Promotion Features

# Binary: Is there a promotion today?
df['is_promotion'] = df.index.isin(promotion_dates).astype(int)

# Discount level
df['discount_percent'] = df['date'].map(promotions_dict)  # {date: discount%}

# Promotion type
df['promo_type'] = df['date'].map(promo_types_dict)  # {date: 'flash_sale', 'bogo', etc.}
df = pd.get_dummies(df, columns=['promo_type'])

# Days until next promotion (forward-looking)
next_promo_dates = sorted(promotion_dates)
def days_until_next_promo(date):
    future_promos = [d for d in next_promo_dates if d > date]
    return (future_promos[0] - date).days if future_promos else 365

df['days_until_promo'] = df.index.map(days_until_next_promo)

Impact: Promotions can increase sales 2-5x → Critical feature (often top 3 importance)

2. Weather Features

# Fetch weather data (OpenWeatherMap API, Visual Crossing)
weather = fetch_weather_data(location='Hanoi', start_date='2024-01-01')

# Merge with sales data
df = df.merge(weather, left_index=True, right_on='date', how='left')

# Features
df['temperature']  # Celsius
df['precipitation']  # mm
df['is_rainy'] = (df['precipitation'] > 1).astype(int)

# Lagged weather (yesterday's weather affects today's sales)
df['temperature_lag1'] = df['temperature'].shift(1)

Examples:

  • Ice cream sales ↑ when hot
  • Raincoats ↑ when rainy
  • Coffee sales ↑ when cold

3. Events & Holidays

import pandas as pd

# Vietnamese holidays
holidays = pd.DataFrame({
    'date': pd.to_datetime([
        '2024-01-01',  # New Year
        '2024-02-10', '2024-02-11', '2024-02-12',  # Tet
        '2024-04-30',  # Reunification Day
        '2024-05-01',  # Labor Day
        '2024-09-02',  # National Day
    ])
})

df['is_holiday'] = df.index.isin(holidays['date']).astype(int)

# Days before/after holiday (shopping surge)
df['days_to_holiday'] = df.index.map(lambda d: min([abs((h - d).days) for h in holidays['date']]))
df['is_pre_holiday'] = (df['days_to_holiday'] <= 3).astype(int)  # 3 days before
df['is_post_holiday'] = df.index.isin(holidays['date'] + pd.Timedelta(days=1)).astype(int)

Impact: Sales spike 2-3 days before Tet, drop during/after

4. Events (Sports, Concerts, etc.)

# Big events affect sales (e.g., World Cup → beer sales ↑)
events = pd.DataFrame({
    'date': ['2024-06-15', '2024-07-15'],  # World Cup matches
    'event': ['vietnam_vs_thailand', 'world_cup_final']
})

df['has_event'] = df.index.isin(pd.to_datetime(events['date'])).astype(int)

5. Store/Product Features (Multi-level Forecasting)

For retail chains (forecast per store × SKU):

# Store features
stores = pd.DataFrame({
    'store_id': ['HN001', 'HCM002', ...],
    'store_size_sqm': [500, 800, ...],
    'location_type': ['mall', 'street', ...],
    'parking_available': [1, 0, ...]
})

# Product features
products = pd.DataFrame({
    'sku': ['SKU001', 'SKU002', ...],
    'category': ['electronics', 'fashion', ...],
    'brand': ['Samsung', 'Nike', ...],
    'price': [15000000, 2000000, ...]
})

# Merge for store × SKU level forecasting
df_multi = sales.merge(stores, on='store_id').merge(products, on='sku')

Evaluation Metrics: Measuring Forecast Accuracy

1. MAPE (Mean Absolute Percentage Error)

Most common metric for business:

from sklearn.metrics import mean_absolute_percentage_error

mape = mean_absolute_percentage_error(y_true, y_pred) * 100
print(f"MAPE: {mape:.2f}%")

Formula:

MAPE = (1/n) × Σ |actual - forecast| / |actual| × 100

Interpretation:

  • MAPE = 10%: Forecast off by 10% on average
  • < 10%: Excellent
  • 10-20%: Good
  • 20-30%: Acceptable
  • > 30%: Poor

Pros: ✅ Easy to interpret, scale-independent (can compare across different products) Cons: ❌ Undefined when actual = 0, biased towards under-forecasting

2. RMSE (Root Mean Squared Error)

from sklearn.metrics import mean_squared_error
import numpy as np

rmse = np.sqrt(mean_squared_error(y_true, y_pred))
print(f"RMSE: {rmse:.2f}")

Formula:

RMSE = √[(1/n) × Σ(actual - forecast)²]

Pros: ✅ Penalizes large errors more (good if big misses are costly) Cons: ❌ Not scale-independent (can't compare across products with different magnitudes)

3. MAE (Mean Absolute Error)

from sklearn.metrics import mean_absolute_error

mae = mean_absolute_error(y_true, y_pred)
print(f"MAE: {mae:.2f}")

Pros: ✅ Simple, robust to outliers Cons: ❌ Not scale-independent

4. Bias (Over-forecasting vs Under-forecasting)

bias = (y_pred - y_true).mean()
print(f"Bias: {bias:.2f}")
# Positive bias = over-forecasting (too much inventory)
# Negative bias = under-forecasting (stockouts)

Business implication:

  • Over-forecasting: Safer (avoid stockouts), but costly (excess inventory)
  • Under-forecasting: Risky (stockouts, lost sales)

Optimal bias: Depends on cost trade-off

# If stockout cost > holding cost → Better to over-forecast slightly
optimal_bias = (stockout_cost - holding_cost) / total_cost

Evaluation by SKU Tier

Not all SKUs equal - evaluate separately:

# Segment SKUs by sales volume
df['sales_tier'] = pd.qcut(df['total_sales'], q=3, labels=['Low', 'Medium', 'High'])

# Evaluate MAPE by tier
for tier in ['Low', 'Medium', 'High']:
    tier_mask = df['sales_tier'] == tier
    mape_tier = mean_absolute_percentage_error(
        y_true[tier_mask], y_pred[tier_mask]
    ) * 100
    print(f"MAPE ({tier}): {mape_tier:.2f}%")

Typical results:

  • High-volume SKUs: MAPE 15-20% (more data, stable patterns)
  • Medium-volume: MAPE 20-30%
  • Low-volume (long-tail): MAPE 40-60% (erratic, hard to forecast)

Strategy: Focus accuracy on high-volume SKUs (80/20 rule)

Production Deployment: From Model to Action

Step 1: Automated Forecasting Pipeline

import schedule
import time
from datetime import datetime

def run_weekly_forecast():
    """
    Generate forecasts for all SKUs
    Run every Sunday at 2 AM
    """
    print(f"[{datetime.now()}] Starting weekly forecast...")

    # 1. Fetch latest sales data
    sales_data = fetch_sales_data(last_days=365)

    # 2. For each SKU, train model and forecast
    forecasts = []
    for sku in sku_list:
        sku_data = sales_data[sales_data['sku'] == sku]

        # Train model (Prophet or XGBoost)
        model = train_forecast_model(sku_data)

        # Forecast next 30 days
        forecast = model.predict(periods=30)

        forecasts.append({
            'sku': sku,
            'forecast_date': datetime.now().date(),
            'predictions': forecast
        })

    # 3. Save forecasts to database
    save_forecasts_to_db(forecasts)

    print(f"[{datetime.now()}] Forecast completed for {len(sku_list)} SKUs")

# Schedule weekly (every Sunday 2 AM)
schedule.every().sunday.at("02:00").do(run_weekly_forecast)

while True:
    schedule.run_pending()
    time.sleep(3600)

Step 2: Inventory Optimization

Use forecasts to calculate optimal order quantities:

def calculate_order_quantity(sku, forecast, current_inventory, lead_time_days=7):
    """
    Calculate how much to order based on forecast
    """
    # Expected demand during lead time + forecast period
    forecast_period = 14  # Order for next 2 weeks
    expected_demand = forecast[:forecast_period].sum()

    # Safety stock (buffer for forecast errors)
    # Rule of thumb: 1.65 × forecast_std × √lead_time (95% service level)
    forecast_std = forecast[:forecast_period].std()
    safety_stock = 1.65 * forecast_std * np.sqrt(lead_time_days)

    # Optimal order quantity
    optimal_inventory = expected_demand + safety_stock
    order_qty = max(0, optimal_inventory - current_inventory)

    return {
        'sku': sku,
        'current_inventory': current_inventory,
        'expected_demand': expected_demand,
        'safety_stock': safety_stock,
        'optimal_inventory': optimal_inventory,
        'order_quantity': order_qty
    }

Step 3: Purchase Order Generation

def generate_purchase_orders():
    """
    Generate POs for all SKUs that need replenishment
    """
    forecasts = load_forecasts_from_db()
    current_inventory = load_current_inventory()

    pos = []
    for sku in sku_list:
        forecast = forecasts[sku]
        inventory = current_inventory[sku]

        order_calc = calculate_order_quantity(sku, forecast, inventory)

        if order_calc['order_quantity'] > 0:
            pos.append({
                'sku': sku,
                'quantity': order_calc['order_quantity'],
                'expected_delivery_date': datetime.now() + timedelta(days=7),
                'reason': f"Forecast demand: {order_calc['expected_demand']:.0f}, Current: {inventory}"
            })

    # Send to procurement system
    if pos:
        send_purchase_orders_to_erp(pos)
        print(f"Generated {len(pos)} purchase orders")
    else:
        print("No replenishment needed")

Step 4: Alerts & Monitoring

def monitor_forecast_accuracy():
    """
    Compare last week's forecast vs actual sales
    Alert if accuracy degrades
    """
    last_week = datetime.now() - timedelta(days=7)

    # Get forecast from 7 days ago
    past_forecasts = load_forecasts_from_db(forecast_date=last_week)

    # Get actual sales
    actual_sales = load_sales_data(start_date=last_week, end_date=datetime.now())

    # Calculate MAPE
    mape = calculate_mape(past_forecasts, actual_sales)

    print(f"Forecast accuracy last 7 days: MAPE = {mape:.2f}%")

    # Alert if degraded
    if mape > 30:  # Threshold
        send_alert(
            subject="Forecast Accuracy Degraded",
            message=f"MAPE increased to {mape:.2f}% (threshold: 30%)"
        )

# Run daily
schedule.every().day.at("08:00").do(monitor_forecast_accuracy)

Case Study: Retail Chain - $1.2M Inventory Reduction

Background:

  • Company: Electronics retail, 50 stores
  • SKUs: 800 products
  • Current approach: Manual forecasting (Excel, gut feeling)
  • Problems:
    • MAPE: 42% (very inaccurate)
    • Overstock: 28% of inventory aged >90 days
    • Stockout: 18% of SKUs out-of-stock weekly

Implementation (16 weeks):

Weeks 1-4: Data preparation

  • Collected 24 months historical sales (store × SKU × day level)
  • Integrated external data:
    • Promotions (from marketing calendar)
    • Holidays (Vietnamese calendar)
    • Weather (Hanoi, HCM historical data)
  • Data quality: Fixed missing data, outliers

Weeks 5-8: Model development

  • Started with Prophet: Quick baseline
    • MAPE: 28% (huge improvement from 42%!)
    • Easy to explain to stakeholders
  • Upgraded to XGBoost: Added promotion/weather features
    • MAPE: 21% (further improvement)
    • Top features:
      1. lag_7 (last week sales): 22%
      2. is_promotion: 18%
      3. rolling_mean_14: 15%
      4. month: 12%
      5. temperature: 8%

Weeks 9-12: Pilot testing

  • Pilot: 10 high-volume SKUs at 5 stores
  • A/B test: ML forecast vs manual forecast
  • Results:
    • ML forecast MAPE: 22%
    • Manual forecast MAPE: 41%
    • Pilot stores reduced overstock from 28% → 15%

Weeks 13-16: Rollout & automation

  • Deployed to all 800 SKUs, 50 stores
  • Automated pipeline: Weekly forecasting, daily order generation
  • Integration with ERP (SAP)

Results after 6 months:

MetricBeforeAfterChange
MAPE42%21%-50% improvement
Overstock rate (>90 days old)28%12%-57%
Stockout rate18%9%-50%
Average inventory60B VND48B VND-20% (12B freed)
Inventory turnover6x/year7.5x/year+25%
Service level (in-stock)82%91%+9pp

Financial impact (annual):

  • Capital freed: 12B VND × 12% interest = 1.44B VND/year
  • Storage cost saved: 12B × 8% = 960M VND/year
  • Reduced markdowns (old inventory): 300M VND/year
  • Additional sales (fewer stockouts): 18% → 9% = 9% more availability
    • Revenue impact: ~100B revenue × 9% × 5% conversion = 450M VND/year
  • Total benefit: 1.44B + 960M + 300M + 450M = 3.15B VND/year
  • ML project cost: 300M VND (one-time) + 50M VND/year
  • ROI: 1,050% first year

Key learnings:

  • Promotion feature = game-changer: Single most important feature (18% importance)
  • High-volume SKUs benefit most: MAPE improved 45% → 18%, low-volume 48% → 35% (still challenging)
  • Seasonal products harder: Air conditioners (summer spike) MAPE 30% vs stable products 15%
  • Weekly retraining important: Monthly retrain → MAPE degraded to 26%, weekly → stable 21%

Surprising finding: Over-forecasting slightly better than under-forecasting

  • Initial model optimized for MAPE (minimize error)
  • But business reality: Stockout costs > holding costs
  • Adjusted: Added bias term (forecast × 1.05) → 5% over-forecast on average
  • Result: Stockout reduced further (9% → 6%), inventory increased slightly but acceptable trade-off

When NOT to Use ML: Rule-Based is Better

ML overkill for:

1. New products (<3 months history):

  • Problem: Insufficient data for ML
  • Solution: Use similar product forecast, or simple heuristics

2. Lumpy/intermittent demand:

  • Example: SKU sells 0, 0, 0, 50, 0, 0, 100, 0... (very erratic)
  • Problem: ML struggles, often just predicts mean (useless)
  • Solution: Rule-based (safety stock = max demand in 90 days)

3. Very stable products:

  • Example: Milk sells 100 ± 5 units every day
  • Problem: ML adds complexity with no gain
  • Solution: Simple moving average (100 units/day)

Rule of thumb: Use ML when coefficient of variation (CV) < 1.5

cv = std(sales) / mean(sales)
# CV < 0.5: Very stable → Moving average
# CV 0.5-1.5: Moderate variability → ML (XGBoost, Prophet)
# CV > 1.5: Erratic → Safety stock rules

Kết Luận: Forecasting = Inventory Goldmine

Demand forecasting với ML không phải rocket science - với tools hiện đại (Prophet, XGBoost), SMEs có thể deploy trong 4-8 tuần và see ROI trong 3-6 tháng.

Key takeaways:

  • Start simple: Prophet baseline (MAPE 20-30%) in 2-4 weeks
  • Upgrade if needed: XGBoost với features (MAPE 18-25%) in 4-8 weeks
  • Focus on high-volume SKUs: 80/20 rule (20% SKUs = 80% revenue)
  • Automate: Weekly forecasting, daily order generation → Free up planners for exceptions
  • Monitor & iterate: Track MAPE weekly, retrain monthly, add new features quarterly

ROI realistic expectations:

  • 15-25% inventory reduction (typical)
  • 30-50% stockout reduction
  • 12-24 months payback period
  • 500-2,000% ROI over 3 years

Next steps:

  • Audit current forecasting accuracy (calculate MAPE)
  • Collect historical data (12+ months sales, + promotions/holidays)
  • Start Prophet pilot (top 20 SKUs, 1 month)
  • Measure impact (inventory, stockout, MAPE)
  • Scale to all SKUs, automate
  • Liên hệ Carptech nếu cần support (carptech.vn/contact)

Tài liệu tham khảo:


Bài viết này là phần 4 của series "Advanced Analytics & AI/ML" tháng 5. Đọc thêm về Analytics Maturity, Churn Prediction, và Recommendation Systems.

Carptech - Data Platform & ML Solutions for Vietnamese Enterprises. Liên hệ tư vấn miễn phí.

Có câu hỏi về Data Platform?

Đội ngũ chuyên gia của Carptech sẵn sàng tư vấn miễn phí về giải pháp phù hợp nhất cho doanh nghiệp của bạn. Đặt lịch tư vấn 60 phút qua Microsoft Teams hoặc gửi form liên hệ.

✓ Miễn phí 100% • ✓ Microsoft Teams • ✓ Không cam kết dài hạn