Demand Forecasting

Demand forecasting predicts future customer demand for a product or service from historical data, market insight and statistical or machine-learning models. It exists so that supply can be matched to demand — fewer stockouts, less overstock, less lost revenue — and in supply chain management almost every decision downstream of it, from procurement to staffing to logistics, depends on the number it produces.

It divides two ways. By horizon: short-term over days and weeks drives inventory and staffing, medium-term over months drives production planning and budgeting, long-term over years drives capacity and strategy. By method: qualitative approaches — expert judgment, market research, Delphi — when data is thin, and quantitative ones when it is not, from ARIMA and SARIMA through causal regression to LSTM and gradient boosting.

The standard techniques follow the same ladder. A moving average smooths noise and suits stable demand; exponential smoothing and Holt-Winters weight recent observations more and handle trend and seasonality; ARIMA and SARIMA model autocorrelation directly; Prophet decomposes into trend, seasonality and holidays; causal models bring in price, marketing and weather. Accuracy is then measured with MAE, MSE, RMSE, MAPE, and for skewed data sMAPE or WAPE. A worked example makes the mechanics plain: daily sales of 100, 120, 130, 110, 140 give a three-day moving average forecast of \((130 + 110 + 140)/3 \approx 126.67\) for the next day. If that next actual is 135, actual minus forecast is about \(+8.33\).

That list is the standard account, and two things it leaves out do most of the damage in practice.

Intermittent demand breaks the metrics

The worked example has demand every day. Much of a real catalog does not: spare parts, slow lines, the long tail of SKUs. Once most days are zero, the choice of accuracy metric stops being a reporting decision and starts choosing the forecast.

import numpy as np
from scipy.stats import nbinom, norm

rng = np.random.default_rng(0)
days = 200_000
demand = nbinom.ppf(rng.random(days), 0.35, 0.35 / (0.35 + 0.8)).astype(int)
"""Slow-moving spare parts: most days nothing sells, occasionally several do.
   This is what the long tail of a catalog looks like."""

print(f"  {days:,} days of one slow-moving part")
print(f"    zero-demand days   {np.mean(demand == 0):>10.4f}")
print(f"    mean demand        {demand.mean():>10.4f}")
print(f"    median demand      {np.median(demand):>10.4f}")
print(f"    largest single day {demand.max():>10}")

grid = np.arange(0, 6, 0.01)
err = demand[:, None] - grid[None, :]
print(f"{'metric':>24}{'best constant':>15}{'which is':>22}")
print(f"{'MAE':>24}{grid[np.abs(err).mean(axis=0).argmin()]:>15.2f}{'the median':>22}")
print(f"{'MSE':>24}{grid[(err ** 2).mean(axis=0).argmin()]:>15.2f}{'the mean':>22}")
for a in (0.90, 0.95):
    pin = np.maximum(a * err, (a - 1) * err).mean(axis=0)
    print(f"{f'pinball at {a:.2f}':>24}{grid[pin.argmin()]:>15.2f}{f'the {a:.2f} quantile':>22}")

print(f"\n  MAPE cannot be computed on {np.mean(demand == 0) * 100:.1f}% of these days at all,")
print(f"{'forecast':>10}{'actual':>8}{'MAPE':>9}   {'forecast':>10}{'actual':>8}{'MAPE':>9}")
for f, y in ((50, 100), (90, 100)):
    print(f"{f:>10}{y:>8}{abs(y - f) / y:>9.4f}   {y:>10}{f:>8}{abs(f - y) / f:>9.4f}")

T, k, L = 400_000, 3, 2
d = 100 + 15 * norm.ppf(rng.random(T))
"""Four stages. Each one forecasts with the 3-day moving average the article
   describes, covers a two-period lead time, and orders to close the gap."""

print(f"\n  the bullwhip effect, measured. Moving average of {k}, lead time {L}:")
print(f"{'stage':>22}{'sd of what it sees':>21}{'sd of what it orders':>23}{'vs the shop':>13}")
series = d
base = d.std(ddof=1)
names = ("shop", "distributor", "regional depot", "factory")
for name in names:
    mu = np.convolve(series, np.ones(k) / k, mode="valid")
    up = (L + 1) * mu
    orders = series[k:] + np.diff(up)
    print(f"{name:>22}{series.std(ddof=1):>21.4f}{orders.std(ddof=1):>23.4f}"
          f"{orders.std(ddof=1) / base:>13.2f}x")
    series = orders
print(f"  planning against a signal {series.std(ddof=1) / base:.1f} times as volatile as the real one")
#   200,000 days of one slow-moving part
#     zero-demand days       0.6602
#     mean demand            0.7947
#     median demand          0.0000
#     largest single day         28
#                   metric  best constant              which is
#                      MAE           0.00            the median
#                      MSE           0.79              the mean
#          pinball at 0.90           3.00     the 0.90 quantile
#          pinball at 0.95           4.00     the 0.95 quantile
#
#   MAPE cannot be computed on 66.0% of these days at all,
#   forecast  actual     MAPE     forecast  actual     MAPE
#         50     100   0.5000          100      50   1.0000
#         90     100   0.1000          100      90   0.1111
#
#   the bullwhip effect, measured. Moving average of 3, lead time 2:
#                  stage   sd of what it sees   sd of what it orders  vs the shop
#                   shop              15.0215                33.5851         2.24x
#            distributor              33.5851                86.2632         5.74x
#         regional depot              86.2632               234.9964        15.64x
#                factory             234.9964               657.9174        43.80x
#   planning against a signal 43.8 times as volatile as the real one

Reading the three blocks

The first is the trap. Minimizing MAE on intermittent demand means forecasting the median, and the median is zero, so the metric rewards a forecast of never stocking anything. It is not a bug in MAE — that genuinely is the number minimizing average absolute error. It is a mismatch between what MAE asks and what the warehouse asks, and the fix is to score at the quantile the stocking decision actually uses.

The second is why MAPE is the wrong default for demand. It is undefined on any zero-demand day, which is most of them here, and at a fixed positive actual, equal absolute errors in either direction receive equal penalties. Across uncertain actuals, its inverse-actual weighting gives small outcomes more influence and can favor lower forecasts, which is exactly the direction that causes stockouts. WAPE avoids the division-by-zero problem by dividing one total by another, which is why it is the common choice in retail.

The third block combines a three-day moving-average forecast with an order-up-to policy and two-period lead time. Orders include current demand plus the change in the target stock position. Repeating that ordering policy through four stages produces the illustrated 43.8-fold standard-deviation ratio; smoothing alone does not imply it. Sharing demand information and redesigning replenishment policies can reduce amplification, with results depending on lead times and ordering rules.

What it is worth, and what it cannot fix

A forecast that is accurate in the right units keeps inventory balanced, frees capital that would otherwise sit on shelves, puts the right staff and vehicles in place, and supports decisions about capacity and expansion. Customers find things in stock. None of that requires a sophisticated model; it requires a forecast scored against the decision it feeds.

The standing difficulties are data quality, volatility from promotions and shocks, the amplification measured above, and new products with no history — where forecasts can draw on analogous products, product attributes, expert information, or models shared across products. To those, add the one the metrics section usually hides: a model can improve on the reported error while getting worse at the thing it was bought for. Score it on the quantile the buffer uses, and on a metric that survives a zero.

References

  • SciPy documentation, scipy.stats.nbinom and scipy.stats.norm. docs.scipy.org

Discover more from Insightful Data Lab

Subscribe to get the latest posts sent to your email.

Similar Posts

Questions, corrections, or additional insights?

This site uses Akismet to reduce spam. Learn how your comment data is processed.