Predictive analytics in a supply chain means forecasting the quantities you plan against (demand, supplier lead times, delay risk) as ranges rather than single numbers, and feeding those ranges into explicit decision rules: reorder points, order quantities, allocation. The value comes from the decision layer and the data underneath it, not from model sophistication. Start with demand at the level where you actually make decisions, correct the history for stockouts, prove the model beats a seasonal naive baseline in a rolling backtest, and only then automate anything.
This guide walks through that build in order, with the SQL and Python for each step and the failure modes that show up once planners depend on it.
Key takeaways
- Forecast for a decision. The decision sets the horizon (lead time plus review period), the granularity (where stock is held) and the refresh rate.
- Sales are not demand when you were out of stock. Training on censored sales teaches the model to under-forecast exactly the items you ran short of.
- Baselines first. A model that cannot beat "same day last week" in a rolling backtest is not ready, however advanced it is.
- Predict quantiles, not points. Safety stock is a question about the upper part of the demand distribution, and a single number cannot answer it.
- Backtest the policy, not just the forecast. Simulate stockouts and inventory under the new rules and the current ones on the same history.
- Measure planner overrides. Forecast value added shows whether each manual change helped or hurt.
Start from the decision
Every prediction should map to a decision someone makes on a schedule. Write the mapping down before building anything.
| Decision | Prediction needed | Horizon | Granularity | Refresh |
|---|---|---|---|---|
| Replenishment orders | Demand distribution over the protection period; lead time distribution | Lead time plus review period | SKU by location | Daily or weekly |
| Safety stock targets | Variability of demand and lead time | Same as above | SKU by location | Weekly or monthly |
| Allocation between warehouses | Regional demand | Days to weeks | SKU by region | Daily |
| Purchase order expediting | Probability each open PO line arrives late | Until due date | PO line | Daily |
| Labor and capacity planning | Units to pick, pack or ship | Weeks | Site by day | Weekly |
| Sales and operations planning | Aggregate demand | Months | Product family by month | Monthly |
The table also tells you what not to build. A beautiful daily SKU-level forecast is wasted if replenishment runs weekly at the category level, and a monthly forecast cannot drive daily allocation.
The data foundation
Most of the work is here. A useful demand history needs, at minimum:
- Order lines with the date demand occurred (order date, not ship date), quantity, location and status.
- Inventory snapshots per SKU and location per day, so you know when you could not sell.
- Purchase orders and receipts, which give you real lead times, including partial and late deliveries.
- Prices and promotions, both planned (known in advance) and actual.
- A product master with hierarchy, attributes, units of measure and lifecycle dates.
- A calendar with holidays, paydays and local events that matter to your demand.
These usually live in different systems (ERP, WMS, TMS, procurement, point of sale), so the first deliverable is an integrated, tested daily table. For how that ingestion layer is typically built, see the modern data engineering stack.
Correct for stockouts
When an item is out of stock, recorded sales are a lower bound on demand, not demand. A model trained on those zeros learns that the item sells less, recommends ordering less, causes more stockouts, and reinforces itself. Flag stockout days explicitly:
WITH daily_sales AS (
SELECT sku, location_id, order_date AS day, SUM(quantity) AS units_sold
FROM order_lines
WHERE status <> 'canceled'
GROUP BY sku, location_id, order_date
),
daily_stock AS (
SELECT sku, location_id, snapshot_date AS day, MIN(on_hand) AS min_on_hand
FROM inventory_snapshots
GROUP BY sku, location_id, snapshot_date
)
SELECT
c.day,
a.sku,
a.location_id,
COALESCE(s.units_sold, 0) AS units_sold, -- a missing row is a real zero only if we were in stock
st.min_on_hand <= 0 AS stocked_out -- NULL when there was no snapshot: unknown, not false
FROM calendar AS c
JOIN sku_locations AS a
ON c.day BETWEEN a.active_from AND COALESCE(a.active_to, CURRENT_DATE)
LEFT JOIN daily_sales AS s
ON s.sku = a.sku AND s.location_id = a.location_id AND s.day = c.day
LEFT JOIN daily_stock AS st
ON st.sku = a.sku AND st.location_id = a.location_id AND st.day = c.day;Joining to a calendar makes zero-sales days explicit; a missing row and a zero are different facts. Then exclude stocked-out and unknown days from the training target, or model them as censored observations. Do not train on them as if they were true demand.
Baselines and metrics first
Pick metrics before models, and write them down so nobody switches metric after seeing results.
| Metric | Formula | Use it for | Watch out for |
|---|---|---|---|
| WAPE | Sum of absolute errors divided by sum of actuals | Headline accuracy, volume weighted | Dominated by high-volume items |
| Bias | Sum of (forecast minus actual) divided by sum of actuals | Systematic over or under forecasting | Cancels out across items; check by segment |
| MASE | Error scaled by a seasonal naive forecast's error | Comparing items of different volume | Needs enough history to compute the scale |
| Pinball loss | Asymmetric loss at each quantile | Evaluating P50 and P90 forecasts | Not intuitive to business readers |
| MAPE | Mean of absolute percentage errors | Avoid for items with zero days | Divides by zero; explodes on small actuals |
The baseline that every model must beat is the seasonal naive forecast: next Monday equals last Monday. Evaluate it, and every candidate, with a rolling-origin backtest that only ever uses data available at each forecast date.
import numpy as np
import pandas as pd
def wape(actual: np.ndarray, forecast: np.ndarray) -> float:
return float(np.abs(actual - forecast).sum() / max(actual.sum(), 1e-9))
def bias(actual: np.ndarray, forecast: np.ndarray) -> float:
return float((forecast - actual).sum() / max(actual.sum(), 1e-9))
def seasonal_naive(history: pd.Series, horizon: int, season: int = 7) -> np.ndarray:
"""Repeat the last full season forward."""
return np.resize(history.iloc[-season:].to_numpy(), horizon)
def backtest(series: pd.Series, forecast_fn, origins, horizon: int = 28) -> pd.DataFrame:
rows = []
for origin in origins:
train = series[series.index < origin]
actual = series[series.index >= origin].iloc[:horizon].to_numpy()
forecast = forecast_fn(train, len(actual))
rows.append({"origin": origin, "wape": wape(actual, forecast), "bias": bias(actual, forecast)})
return pd.DataFrame(rows)Use several origins spread across the year, including peak season and promotions, and compare candidates on the same origins. One origin is an anecdote.
A demand model that holds up
Leakage-safe features
For tabular demand data, a single global gradient boosting model trained across all SKU-location series is a strong default. The trap is leakage: if the forecast is made today for 14 days ahead, a 7-day lag does not exist yet. Shift every lag by at least the horizon.
def make_features(df: pd.DataFrame, horizon: int) -> pd.DataFrame:
"""Features for predicting demand `horizon` days after the forecast date.
Every lag is shifted by at least `horizon`, so nothing uses data that
would not exist on the day the forecast is made.
"""
df = df.sort_values(["sku", "location_id", "day"]).copy()
g = df.groupby(["sku", "location_id"])["units_sold"]
for extra in (0, 7, 14, 21):
df[f"lag_{horizon + extra}"] = g.shift(horizon + extra)
df[f"mean_28_h{horizon}"] = g.transform(
lambda s: s.shift(horizon).rolling(28, min_periods=7).mean()
)
df["dow"] = df["day"].dt.dayofweek
df["week_of_year"] = df["day"].dt.isocalendar().week.astype(int)
df["price_ratio"] = df["planned_price"] / df["regular_price"] # known in advance
df["sku"] = df["sku"].astype("category")
df["location_id"] = df["location_id"].astype("category")
return dfPromotion and price features must be the planned values known at forecast time. Actual promotion data recorded after the fact is a common, quiet source of leakage.
Quantile forecasts
Train one model per quantile you need. The median drives expected volumes; an upper quantile drives protection against stockouts.
import lightgbm as lgb
FEATURES = [c for c in train_df.columns if c.startswith(("lag_", "mean_"))] + [
"dow", "week_of_year", "price_ratio", "sku", "location_id",
]
fit_rows = train_df[train_df["stocked_out"] == False] # censored days excluded
models = {
q: lgb.LGBMRegressor(objective="quantile", alpha=q, n_estimators=600,
learning_rate=0.05, num_leaves=63, min_child_samples=50)
.fit(fit_rows[FEATURES], fit_rows["units_sold"])
for q in (0.5, 0.9)
}The comparison == False deliberately drops unknown days (NULL) as well as stockout days. Other candidates worth a backtest: ETS and ARIMA through libraries such as statsforecast for stable series, deep models such as N-HiTS or TFT when you have many long series, and pretrained time series foundation models. None of them gets a pass on the baseline.
When forecasts exist at several levels (SKU, category, total), hierarchical reconciliation makes them add up, so the warehouse plan and the finance plan agree.
Intermittent demand
Many SKUs sell on only some days. Classify them before modeling, using the Syntetos and Boylan scheme: the average demand interval (ADI) and the squared coefficient of variation of non-zero demand (CV squared), with the published cut-offs of 1.32 and 0.49.
def classify_demand(units: pd.Series) -> str:
nonzero = units[units > 0]
if len(nonzero) < 2:
return "insufficient history"
adi = len(units) / len(nonzero)
cv2 = (nonzero.std() / nonzero.mean()) ** 2
if adi < 1.32:
return "smooth" if cv2 < 0.49 else "erratic"
return "intermittent" if cv2 < 0.49 else "lumpy"Smooth items suit the methods above. Intermittent and lumpy items suit Croston-style methods (SBA, TSB) or direct distribution forecasts, evaluated with scaled metrics.
From forecast to reorder point
The textbook safety stock formula assumes independent, normally distributed daily demand and lead time:
safety_stock = z * sqrt(L * sd_d^2 + d^2 * sd_L^2)
reorder_point = d * L + safety_stock
d, sd_d mean and standard deviation of daily demand
L, sd_L mean and standard deviation of lead time in days
z standard normal quantile for the cycle service levelIt is a reasonable start, and it breaks down when demand is skewed, seasonal or intermittent, or when lead times have a long tail. With quantile forecasts and observed lead times, simulate the demand over the protection period instead:
import numpy as np
from scipy.stats import norm
Z90 = norm.ppf(0.9)
def protection_demand(q50, q90, lead_time_samples, review_days, n=20_000, seed=7):
"""Simulated demand over lead time plus review period.
q50 and q90 are daily forecast quantiles for the coming days (long enough
to cover the longest lead time). Each day is approximated by a lognormal
matched to those two quantiles; lead times are drawn from history.
"""
rng = np.random.default_rng(seed)
q50 = np.maximum(np.asarray(q50, dtype=float), 1e-6)
q90 = np.maximum(np.asarray(q90, dtype=float), q50 * 1.0001)
mu, sigma = np.log(q50), np.log(q90 / q50) / Z90
totals = np.empty(n)
for i, lead_time in enumerate(rng.choice(lead_time_samples, size=n)):
days = int(lead_time) + review_days
totals[i] = rng.lognormal(mu[:days], sigma[:days]).sum()
return totals
totals = protection_demand(q50, q90, observed_lead_times, review_days=7)
reorder_level = float(np.quantile(totals, 0.95)) # 95% cycle service levelTwo simplifications are worth stating to the business. Days are sampled independently, which understates risk when demand is autocorrelated, and "95% cycle service level" means no stockout in 95% of replenishment cycles, which is not the same as filling 95% of units. Agree which service measure the business means before tuning anything. Then apply the real constraints: minimum order quantities, case packs, shelf life, warehouse capacity.
Supplier lead time and delay risk
Lead time is a forecast too. Build empirical lead time distributions per supplier and lane from recent receipts, and feed them into the simulation above. For expediting, a classifier that predicts the probability an open PO line arrives later than its confirmed date is useful when it ranks the work list.
Features that are usually available as of the PO date: the supplier's recent on-time history computed only from earlier POs, lane and mode, order size relative to the supplier's typical order, season, how long the supplier took to acknowledge, and how many open lines that supplier is carrying. Rank by probability of delay times the cost of a shortage on that item, so planners chase the late orders that matter. Split training and validation by PO date, for the same leakage reasons as demand.
Backtest the policy, not just the forecast
A better forecast is only useful if it produces better inventory outcomes. Simulate the replenishment policy on a historical period, with real demand and real lead times, and compare it with the current policy on the same period.
def simulate_policy(demand, reorder_level, order_up_to, lead_times, start_on_hand):
"""Daily simulation of a reorder-level policy; all inputs are per-day arrays."""
on_hand, pipeline = start_on_hand, [] # pipeline holds (arrival_day, qty)
stockout_days = units_short = orders = 0
on_hand_total = 0.0
for day, units in enumerate(demand):
on_hand += sum(q for arrival, q in pipeline if arrival == day)
pipeline = [(arrival, q) for arrival, q in pipeline if arrival != day]
shipped = min(on_hand, units)
if shipped < units:
stockout_days += 1
units_short += units - shipped
on_hand -= shipped
position = on_hand + sum(q for _, q in pipeline)
if position <= reorder_level[day]:
pipeline.append((day + int(lead_times[day]), order_up_to[day] - position))
orders += 1
on_hand_total += on_hand
return {"stockout_days": stockout_days, "units_short": units_short,
"avg_on_hand": on_hand_total / len(demand), "orders": orders}Run both policies over the same SKUs and periods and present stockout days, units short, average on hand and order count side by side. That is the trade-off the business actually decides on, expressed in its own units, and it is far more persuasive than a WAPE improvement.
Putting it into operations
Forecasts only matter when they reach the planning workflow:
- Publish to the planning tool, with recommended orders, the forecast range and the main drivers, on the cadence of the planning cycle.
- Work by exception. Surface items where the forecast moved sharply, data checks failed or delay risk is high, instead of asking planners to review everything.
- Log every override with a reason, and measure forecast value added: compare the accuracy of the model's forecast with the final, overridden forecast by planner and category. Overrides that consistently help are information to feed back into the model; overrides that consistently hurt are a conversation.
- Monitor accuracy and bias by segment over time, plus data freshness and the share of SKUs falling back to defaults.
The pipeline behind this is a standard batch ML pipeline (ingest, validate, build features, train, gate, publish), and the same engineering applies; see building production AI pipelines in Python. If several forecasting and risk models share supplier and product features, a feature store starts to earn its place.
What goes wrong in production
- The model learned stockouts as low demand. Recommended orders shrank, stockouts grew, and the next retrain made it worse. Censor stockout days from the start.
- Lags leaked the future. A 7-day lag in a 14-day-ahead model looked brilliant in the backtest and was impossible in production.
- Actual promotions instead of planned ones. The model used information recorded after the fact. Use what was known at forecast time.
- Accuracy reported at the wrong level. Category-level WAPE looked excellent while SKU-location forecasts, the level that drives orders, were poor. Report at the decision level.
- Units of measure did not match. Cases in one system, each units in another, and a reorder point off by the case pack. Validate units in the integrated table.
- Lead times from the wrong dates. Ship date instead of order date, or first partial receipt instead of full receipt, made suppliers look faster than they are.
- Constraints ignored. Forecast-driven orders that break minimum order quantities, shelf life or warehouse capacity get overridden by hand, and trust goes with them.
- A regime change. A new competitor, a channel shift or a disruption makes history less relevant. Bias monitoring catches it; time-boxed manual adjustments bridge it until the data catches up.
Frequently Asked Questions
What is predictive analytics in supply chain management?
Predictive analytics in supply chain management uses historical and external data to forecast the quantities planners act on, such as demand, supplier lead times and the risk that an order arrives late. The forecasts feed decisions like reorder points, order quantities, allocation between warehouses and which purchase orders to expedite. It is useful only when those decision rules are explicit and the forecasts are expressed as ranges, not single numbers.
Which machine learning model is best for demand forecasting?
No single model wins everywhere, so the honest answer is the one that beats a seasonal naive baseline in a rolling backtest on your own data. Global gradient boosting models such as LightGBM, trained across many SKUs with lag, calendar, price and promotion features, are a strong default for retail and distribution data. Statistical methods like ETS remain competitive for stable series, and intermittent items need methods designed for many zero-demand periods.
How do you measure demand forecast accuracy?
Use WAPE (total absolute error divided by total actual demand) for accuracy and bias (total forecast minus total actual, divided by total actual) to see whether you over or under forecast. MASE, which scales error by a naive forecast, compares fairly across items of different volume, and pinball loss evaluates quantile forecasts. Avoid MAPE for items with zero-demand days, because it divides by zero or explodes on small actuals.
How much historical data do you need for demand forecasting?
Enough to cover the seasonality you need to model, which for annual patterns means at least one full year and preferably two or more. Global models help because they learn shared patterns across many items, so a product with short history borrows strength from similar ones. Just as important as length is quality: stockout flags, promotion history and consistent units matter more than an extra year of messy data.
How does predictive analytics improve inventory management?
It replaces fixed safety stock rules with reorder points computed from forecast distributions of demand and lead time, so items with volatile demand or unreliable suppliers get more protection and stable items get less. The effect should be demonstrated, not assumed, by simulating the new policy and the current one on the same historical period. That backtest shows the trade-off between stockouts and inventory in units the business already uses.
How do you forecast demand for new products?
New products have no history of their own, so models borrow it from similar items through product attributes such as category, price point, brand and launch season. A global model that uses those attributes as features can produce a first forecast, which planners often adjust using analog products they choose by hand. Switch weight toward the item's own sales as soon as a few weeks of clean, uncensored demand exist.
Need forecasting that planners actually use?
We build forecasting and inventory systems from the data layer up: integrated history, honest backtests, policies simulated before they go live, and monitoring once planners rely on them. If we built it, we are on the pager for it, including the night before the planning cycle.