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
| Algorithm | Data Size | Seasonality | External Features | Ease of Use | MAPE | Recommendation |
|---|---|---|---|---|---|---|
| Moving Avg | Any | ❌ No | ❌ No | ⭐⭐⭐⭐⭐ | 35-45% | Baseline only |
| ARIMA | 100-1000 | ✅ Single | ❌ No | ⭐⭐ | 25-35% | Small data, simple |
| Prophet | 100-10K | ✅✅ Multiple | ⚠️ Limited | ⭐⭐⭐⭐ | 20-30% | Start here |
| XGBoost | 1K-100K | ✅ (via features) | ✅✅ Yes | ⭐⭐⭐ | 18-25% | Best for most |
| LSTM | 10K+ | ✅✅ Multiple | ✅ Yes | ⭐ | 15-22% | Large scale, complex |
Carptech recommendation:
- Start with Prophet (quick, good results, 2-4 weeks implementation)
- Upgrade to XGBoost if you have promotions/weather/events data (4-8 weeks)
- 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:
- lag_7 (last week sales): 22%
- is_promotion: 18%
- rolling_mean_14: 15%
- month: 12%
- 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:
| Metric | Before | After | Change |
|---|---|---|---|
| MAPE | 42% | 21% | -50% improvement |
| Overstock rate (>90 days old) | 28% | 12% | -57% |
| Stockout rate | 18% | 9% | -50% |
| Average inventory | 60B VND | 48B VND | -20% (12B freed) |
| Inventory turnover | 6x/year | 7.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í.




