Home/Blog

Supply Chain Predictive Analytics: A Practical Build Guide

How to build predictive analytics for a supply chain: censored demand data, forecast baselines, quantile models, reorder points and policy backtests.

Supply Chain Predictive Analytics: A Practical Build Guide

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.

DecisionPrediction neededHorizonGranularityRefresh
Replenishment ordersDemand distribution over the protection period; lead time distributionLead time plus review periodSKU by locationDaily or weekly
Safety stock targetsVariability of demand and lead timeSame as aboveSKU by locationWeekly or monthly
Allocation between warehousesRegional demandDays to weeksSKU by regionDaily
Purchase order expeditingProbability each open PO line arrives lateUntil due datePO lineDaily
Labor and capacity planningUnits to pick, pack or shipWeeksSite by dayWeekly
Sales and operations planningAggregate demandMonthsProduct family by monthMonthly

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.

MetricFormulaUse it forWatch out for
WAPESum of absolute errors divided by sum of actualsHeadline accuracy, volume weightedDominated by high-volume items
BiasSum of (forecast minus actual) divided by sum of actualsSystematic over or under forecastingCancels out across items; check by segment
MASEError scaled by a seasonal naive forecast's errorComparing items of different volumeNeeds enough history to compute the scale
Pinball lossAsymmetric loss at each quantileEvaluating P50 and P90 forecastsNot intuitive to business readers
MAPEMean of absolute percentage errorsAvoid for items with zero daysDivides 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 df

Promotion 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 level

It 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 level

Two 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.