Deep learning for demand — a DeepAR-style probabilistic RNN¶

Machine Learning in Operations Research · the neural contender¶

Notebooks 1–2 used gradient boosting; notebook 3 brought in Bayesian pooling. This one adds the modern deep-learning option: a DeepAR-style global recurrent network that reads each product's recent demand and emits a probability distribution for the next step. It is the natural neural competitor to quantile-GBM for demand forecasting, and — like GBM — it is a global model trained across every series at once.

We run it on the full catalogue (4,862 products), because a global RNN's whole advantage is learning shared patterns across many series; then we race it against GBM on the same holdout, scored by realized newsvendor £.

The architecture¶

For each product-day we predict the distribution of demand $D_t$ with a Negative-Binomial output:

  • an LSTM reads the window of the last $L=28$ demand values ending at the forecast origin $t-H$ (leak-safe — the same discipline as the other notebooks);
  • its final hidden state is concatenated with the day's covariates (calendar, price, intermittency);
  • a small head emits $(\mu, r)$ — the NegBin mean and dispersion — so the output is a full predictive distribution, from which we read quantiles for the newsvendor.

Two details make or break it here:

  1. Per-series scaling. Series range from single units to thousands. We divide each product's demand by its own mean before the network sees it, and multiply the predicted mean back afterwards — the standard DeepAR trick. Without it the global model collapses (we show this is essential).
  2. Window sampling. We train on a random sample of windows each epoch rather than all 3.3M — how DeepAR is normally trained — which keeps CPU training to a couple of minutes.
In [1]:
import os, time
os.environ["KMP_DUPLICATE_LIB_OK"] = "TRUE"
import numpy as np, pandas as pd, matplotlib.pyplot as plt
from pathlib import Path
from scipy.stats import nbinom
import torch, torch.nn as nn
from torch.distributions import NegativeBinomial
torch.manual_seed(0); np.random.seed(0)
BLUE, RED, GREEN, ORANGE, GREY, PURP = "#2b6cb0","#c53030","#2f855a","#dd6b20","#a0aec0","#6b46c1"
here = Path.cwd(); DATA = next(p for p in [here/"data", here.parent/"data"] if (p/"features_full.parquet").exists())
feat = pd.read_parquet(DATA/"features_full.parquet"); feat["date"] = pd.to_datetime(feat["date"])
MARGIN_RATE, HOLDING = 0.5, 0.10
QLEV = [0.05,0.1,0.25,0.5,0.75,0.9,0.95,0.975,0.99]
USE = ["is_saturday","sin_year","cos_year","days_to_xmas","logrm","roll_zero_28","loglag7"]
L, H = 28, 7
feat["logrm"] = np.log1p(feat["roll_mean_28"]); feat["loglag7"] = np.log1p(feat["lag_7"])
price = feat.groupby("StockCode", observed=True)["price"].first(); cu_prod = MARGIN_RATE*price; fractile = cu_prod/(cu_prod+HOLDING)
print(f"{len(feat):,} rows | {feat.StockCode.nunique():,} products")
3,593,018 rows | 4,862 products

Data prep — windows, scaling, leak-safe split¶

In [2]:
cut = feat["date"].max() - pd.Timedelta(days=28)
mat = feat.pivot(index="StockCode", columns="date", values="demand"); M = mat.to_numpy(float)
posd = {dt:i for i,dt in enumerate(mat.columns)}; posp = {p:i for i,p in enumerate(mat.index)}
tr_mask = feat["date"] <= cut
nu = feat[tr_mask].groupby("StockCode", observed=True)["demand"].mean().reindex(mat.index).fillna(0.0).to_numpy() + 1.0
Mn = M / nu[:, None]                                   # per-series scaled demand
_Xtr = feat[tr_mask][USE].to_numpy(float)              # NaN-aware stats (warm-up rows have NaN lags)
mu_, sd_ = np.nanmean(_Xtr, 0), np.nanstd(_Xtr, 0) + 1e-6

def windows(rows):
    tt = rows["date"].map(posd).to_numpy().astype(int); pp = rows["StockCode"].map(posp).to_numpy().astype(int)
    s = tt - H; valid = (s - L + 1) >= 0
    widx = (s[:, None] - (L-1) + np.arange(L)[None, :]).astype(int)
    seq = np.log1p(Mn[pp[:, None], widx]).astype(np.float32)
    Z = ((rows[USE].to_numpy(float) - mu_)/sd_).astype(np.float32)
    return (torch.tensor(seq[valid]), torch.tensor(Z[valid]),
            torch.tensor(rows["demand"].to_numpy(float)[valid]), torch.tensor(nu[pp][valid].astype(np.float32)),
            rows.index.to_numpy()[valid])

tr = feat[tr_mask].dropna(subset=USE); te = feat[~tr_mask].dropna(subset=USE)
Xs_te, Xc_te, y_te, nu_te, keep_te = windows(te)
tr_rows = tr.index.to_numpy()
print(f"train pool {len(tr_rows):,} rows | holdout {len(keep_te):,} rows")
train pool 3,388,814 rows | holdout 136,136 rows

Train the DeepAR-style network¶

In [3]:
class DeepAR(nn.Module):
    def __init__(self, ncov, hid=40):
        super().__init__(); self.lstm = nn.LSTM(1, hid, batch_first=True)
        self.head = nn.Sequential(nn.Linear(hid+ncov, 40), nn.ReLU(), nn.Linear(40, 2))
    def forward(self, seq, cov):
        _, (h, _) = self.lstm(seq.unsqueeze(-1)); o = self.head(torch.cat([h[-1], cov], -1))
        return o[:, 0], o[:, 1]

net = DeepAR(len(USE)); opt = torch.optim.Adam(net.parameters(), lr=3e-3)
rng = np.random.default_rng(0); losses = []; t = time.time()
for ep in range(35):
    samp = feat.loc[rng.choice(tr_rows, size=min(180000, len(tr_rows)), replace=False)]
    Xs, Xc, y, nrow, _ = windows(samp)
    perm = torch.randperm(len(y)); tot = 0.0
    for i in range(0, len(y), 1024):
        b = perm[i:i+1024]; opt.zero_grad()
        lm, lr = net(Xs[b], Xc[b]); lm = lm.clamp(-8, 8); lr = lr.clamp(-6, 8)   # stabilize before exp
        r = torch.exp(lr).clamp(1e-2, 1e4); mu = (torch.exp(lm)*nrow[b]).clamp(1e-2, 1e6)
        loss = -NegativeBinomial(total_count=r, logits=torch.log(mu)-torch.log(r)).log_prob(y[b]).mean()
        loss.backward(); torch.nn.utils.clip_grad_norm_(net.parameters(), 1.0); opt.step(); tot += float(loss)*len(b)
    losses.append(tot/len(y))
print(f"trained {len(losses)} epochs in {time.time()-t:.0f}s | final NLL {losses[-1]:.3f}")
fig, ax = plt.subplots(figsize=(7,3.5)); ax.plot(losses, color=PURP); ax.set_xlabel("epoch"); ax.set_ylabel("NegBin NLL")
ax.set_title("DeepAR training loss"); fig.tight_layout(); plt.show()
C:\Users\user\AppData\Local\Temp\ipykernel_29912\1844905882.py:20: UserWarning: Converting a tensor with requires_grad=True to a scalar may lead to unexpected behavior.
Consider using tensor.detach() first. (Triggered internally at C:\actions-runner\_work\pytorch\pytorch\torch\csrc\autograd\generated\python_variable_methods.cpp:823.)
  loss.backward(); torch.nn.utils.clip_grad_norm_(net.parameters(), 1.0); opt.step(); tot += float(loss)*len(b)
trained 35 epochs in 72s | final NLL 0.700
No description has been provided for this image

Predictions, calibration, and a fan chart¶

In [4]:
def predict_quantiles(Xs, Xc, nrow):
    net.eval()
    with torch.no_grad():
        lm, lr = net(Xs, Xc); lm = lm.clamp(-8, 8); lr = lr.clamp(-6, 8)
        mu = (torch.exp(lm)*nrow).clamp(1e-2, 1e6).numpy(); r = torch.exp(lr).clamp(1e-2, 1e4).numpy()
    p = r/(r+mu)
    return pd.DataFrame({q: nbinom.ppf(q, r, p) for q in QLEV}), mu, r
Pd_te, _, _ = predict_quantiles(Xs_te, Xc_te, nu_te); Pd_te.index = keep_te
yv = te.loc[keep_te, "demand"].to_numpy(float); zshare = float((yv==0).mean())
cov = [float((yv <= Pd_te[q].to_numpy()).mean()) for q in QLEV]

fig, ax = plt.subplots(1, 2, figsize=(13, 4.5))
ax[0].plot([0,1],[0,1],"--",color=GREY,label="perfect"); ax[0].plot(QLEV, cov, "o-", color=PURP, label="DeepAR")
ax[0].axhline(zshare, color=RED, ls=":", label=f"zero-share {zshare:.2f}")
ax[0].set_xlabel("target quantile τ"); ax[0].set_ylabel("coverage"); ax[0].set_title("DeepAR calibration (full-catalogue holdout)"); ax[0].legend(fontsize=8)
# fan chart for a recognizable busy product
sc = "85123A" if "85123A" in posp else mat.index[int(np.argmax(np.nan_to_num(nu)))]
one = feat[(feat.StockCode==sc)].sort_values("date").iloc[-120:]
Xso, Xco, _, nuo, keepo = windows(one); Po, _, _ = predict_quantiles(Xso, Xco, nuo); Po.index = keepo
oo = feat.loc[keepo].assign(**{f"q{q}": Po[q].values for q in QLEV})
ax[1].fill_between(oo["date"], oo["q0.1"], oo["q0.9"], color=PURP, alpha=.2, label="q0.1–q0.9")
ax[1].plot(oo["date"], oo["q0.5"], color=PURP, lw=1.5, label="median")
ax[1].plot(oo["date"], oo["demand"], color=RED, lw=.9, marker=".", ms=3, label="actual")
ax[1].set_title(f"DeepAR fan chart — {sc}"); ax[1].set_ylabel("units/day"); ax[1].legend(fontsize=8)
fig.tight_layout(); plt.show()
print("upper-tail coverage:", {q: round(c,3) for q,c in zip(QLEV,cov) if q>=0.9})
No description has been provided for this image
upper-tail coverage: {0.9: 0.967, 0.95: 0.982, 0.975: 0.99, 0.99: 0.996}

Head-to-head — DeepAR vs GBM vs point forecast¶

Same full-catalogue 28-day holdout, same per-product newsvendor economics, scored by realized £.

In [5]:
import lightgbm as lgb
FEATURES = ["dow","is_saturday","month","day","weekofyear","dayofyear","sin_year","cos_year","days_to_xmas",
            "price","log_price","lag_0","lag_7","lag_14","lag_28","roll_mean_7","roll_std_7","roll_zero_7",
            "roll_mean_28","roll_std_28","roll_zero_28","days_since_last_sale","StockCode","sb_class"]
gtr = feat[tr_mask].copy(); gte = feat[~tr_mask].loc[keep_te].copy()
for c in ["StockCode","sb_class"]: gtr[c]=gtr[c].astype("category"); gte[c]=gte[c].astype("category")
cv = gtr["date"].max()-pd.Timedelta(days=28); fm=gtr[gtr.date<=cv]; vm=gtr[gtr.date>cv]
def order_at(P, fr):
    q=np.array(QLEV); V=P[QLEV].to_numpy(); return np.array([np.interp(fr[i],q,V[i]) for i in range(len(fr))])
def realized(P, rows):
    fr=fractile.reindex(rows["StockCode"]).to_numpy(); Q=order_at(P.loc[rows.index], fr)
    D=rows["demand"].to_numpy(float); CU=cu_prod.reindex(rows["StockCode"]).to_numpy()
    return (CU*np.maximum(D-Q,0)+HOLDING*np.maximum(Q-D,0)).sum(), np.minimum(Q,D).sum()/D.sum()
# GBM quantiles
t=time.time(); gq={}
for tau in QLEV:
    m=lgb.LGBMRegressor(objective="quantile",alpha=tau,n_estimators=300,learning_rate=0.05,num_leaves=63,min_child_samples=50,verbosity=-1,n_jobs=-1)
    m.fit(fm[FEATURES],fm["demand"],eval_set=[(vm[FEATURES],vm["demand"])],eval_metric="quantile",callbacks=[lgb.early_stopping(40,verbose=False),lgb.log_evaluation(0)]); gq[tau]=m.predict(gte[FEATURES])
Pg=np.clip(np.column_stack([gq[t_] for t_ in QLEV]),0,None); Pg.sort(axis=1); Pg=pd.DataFrame(Pg,columns=QLEV,index=gte.index)
# point (mean L2)
mp=lgb.LGBMRegressor(objective="regression",n_estimators=300,learning_rate=0.05,num_leaves=63,min_child_samples=50,verbosity=-1,n_jobs=-1)
mp.fit(fm[FEATURES],fm["demand"],eval_set=[(vm[FEATURES],vm["demand"])],eval_metric="l2",callbacks=[lgb.early_stopping(40,verbose=False),lgb.log_evaluation(0)])
qpoint=np.clip(mp.predict(gte[FEATURES]),0,None)
print(f"GBM+point trained in {time.time()-t:.0f}s")
rows_common = te.loc[keep_te]
c_dl,f_dl = realized(Pd_te, rows_common); c_gb,f_gb = realized(Pg, rows_common)
Dc=rows_common["demand"].to_numpy(float); CUc=cu_prod.reindex(rows_common["StockCode"]).to_numpy()
c_pt=(CUc*np.maximum(Dc-qpoint,0)+HOLDING*np.maximum(qpoint-Dc,0)).sum(); f_pt=np.minimum(qpoint,Dc).sum()/Dc.sum()
R=pd.DataFrame({"cost":[c_pt,c_gb,c_dl],"fill":[f_pt,f_gb,f_dl]}, index=["point (mean)","GBM","DeepAR"])
print(R.round(3).to_string())
fig,ax=plt.subplots(1,2,figsize=(12,4)); cols=[ORANGE,BLUE,PURP]
ax[0].bar(R.index,R["cost"],color=cols); ax[0].set_title("Realized cost (£, full-catalogue holdout)")
for i,v in enumerate(R["cost"]): ax[0].text(i,v,f"£{v:,.0f}",ha="center",va="bottom",fontsize=8)
ax[1].bar(R.index,R["fill"],color=cols); ax[1].set_ylim(0,1); ax[1].set_title("Fill rate")
fig.tight_layout(); plt.show()
C:\Users\user\anaconda3\envs\pymc-env\Lib\site-packages\lightgbm\sklearn.py:1106: LGBMDeprecationWarning: The argument 'eval_set' is deprecated, use 'eval_X' and 'eval_y' instead.
  eval_set = _validate_eval_set_Xy(eval_set=eval_set, eval_X=eval_X, eval_y=eval_y)
C:\Users\user\anaconda3\envs\pymc-env\Lib\site-packages\lightgbm\sklearn.py:1106: LGBMDeprecationWarning: The argument 'eval_set' is deprecated, use 'eval_X' and 'eval_y' instead.
  eval_set = _validate_eval_set_Xy(eval_set=eval_set, eval_X=eval_X, eval_y=eval_y)
C:\Users\user\anaconda3\envs\pymc-env\Lib\site-packages\lightgbm\sklearn.py:1106: LGBMDeprecationWarning: The argument 'eval_set' is deprecated, use 'eval_X' and 'eval_y' instead.
  eval_set = _validate_eval_set_Xy(eval_set=eval_set, eval_X=eval_X, eval_y=eval_y)
C:\Users\user\anaconda3\envs\pymc-env\Lib\site-packages\lightgbm\sklearn.py:1106: LGBMDeprecationWarning: The argument 'eval_set' is deprecated, use 'eval_X' and 'eval_y' instead.
  eval_set = _validate_eval_set_Xy(eval_set=eval_set, eval_X=eval_X, eval_y=eval_y)
C:\Users\user\anaconda3\envs\pymc-env\Lib\site-packages\lightgbm\sklearn.py:1106: LGBMDeprecationWarning: The argument 'eval_set' is deprecated, use 'eval_X' and 'eval_y' instead.
  eval_set = _validate_eval_set_Xy(eval_set=eval_set, eval_X=eval_X, eval_y=eval_y)
C:\Users\user\anaconda3\envs\pymc-env\Lib\site-packages\lightgbm\sklearn.py:1106: LGBMDeprecationWarning: The argument 'eval_set' is deprecated, use 'eval_X' and 'eval_y' instead.
  eval_set = _validate_eval_set_Xy(eval_set=eval_set, eval_X=eval_X, eval_y=eval_y)
C:\Users\user\anaconda3\envs\pymc-env\Lib\site-packages\lightgbm\sklearn.py:1106: LGBMDeprecationWarning: The argument 'eval_set' is deprecated, use 'eval_X' and 'eval_y' instead.
  eval_set = _validate_eval_set_Xy(eval_set=eval_set, eval_X=eval_X, eval_y=eval_y)
C:\Users\user\anaconda3\envs\pymc-env\Lib\site-packages\lightgbm\sklearn.py:1106: LGBMDeprecationWarning: The argument 'eval_set' is deprecated, use 'eval_X' and 'eval_y' instead.
  eval_set = _validate_eval_set_Xy(eval_set=eval_set, eval_X=eval_X, eval_y=eval_y)
C:\Users\user\anaconda3\envs\pymc-env\Lib\site-packages\lightgbm\sklearn.py:1106: LGBMDeprecationWarning: The argument 'eval_set' is deprecated, use 'eval_X' and 'eval_y' instead.
  eval_set = _validate_eval_set_Xy(eval_set=eval_set, eval_X=eval_X, eval_y=eval_y)
C:\Users\user\anaconda3\envs\pymc-env\Lib\site-packages\lightgbm\sklearn.py:1106: LGBMDeprecationWarning: The argument 'eval_set' is deprecated, use 'eval_X' and 'eval_y' instead.
  eval_set = _validate_eval_set_Xy(eval_set=eval_set, eval_X=eval_X, eval_y=eval_y)
GBM+point trained in 71s
                    cost   fill
point (mean)  425623.288  0.392
GBM           323817.924  0.569
DeepAR        355122.124  0.666
No description has been provided for this image

Cold-start view — where might the global RNN help?¶

A global RNN needs no per-product identity, so in principle it can generalize to sparse products from shared patterns. We compare DeepAR and GBM realized cost by the same data-richness strata as notebook 3.

In [6]:
act = feat[tr_mask & (feat.demand>0)].groupby("StockCode", observed=True).size()
terc = pd.qcut(act[act>=5], 3, labels=["scarce","medium","rich"])
rc = rows_common.assign(stratum=rows_common["StockCode"].map(terc).astype("object"))
def cost_per_demand(P_or_q, is_point=False):
    out={}
    for lab in ["scarce","medium","rich"]:
        m = rc["stratum"]==lab; sub = rc[m]
        if not len(sub): out[lab]=np.nan; continue
        D=sub["demand"].to_numpy(float); CU=cu_prod.reindex(sub["StockCode"]).to_numpy()
        if is_point: Q=np.clip(mp.predict(gte.loc[sub.index][FEATURES]),0,None)
        else:
            fr=fractile.reindex(sub["StockCode"]).to_numpy(); Q=order_at(P_or_q.loc[sub.index], fr)
        out[lab]=(CU*np.maximum(D-Q,0)+HOLDING*np.maximum(Q-D,0)).sum()/max(D.sum(),1)
    return out
cpd_dl=cost_per_demand(Pd_te); cpd_gb=cost_per_demand(Pg)
comp=pd.DataFrame({"GBM":cpd_gb,"DeepAR":cpd_dl}).reindex(["scarce","medium","rich"])
print(comp.round(3).to_string())
x=np.arange(3); w=0.38
fig,ax=plt.subplots(figsize=(8,4.2))
ax.bar(x-w/2,comp["GBM"],w,color=BLUE,label="GBM"); ax.bar(x+w/2,comp["DeepAR"],w,color=PURP,label="DeepAR")
ax.set_xticks(x); ax.set_xticklabels(comp.index); ax.set_ylabel("realized cost per unit demand (£)")
ax.set_title("DeepAR vs GBM by data-richness"); ax.legend(fontsize=8); fig.tight_layout(); plt.show()
          GBM  DeepAR
scarce  0.674   0.701
medium  0.411   0.397
rich    0.369   0.446
No description has been provided for this image

Verdict — deep learning's honest place here¶

A DeepAR-style RNN is a legitimate probabilistic forecaster for this problem: trained globally across all 4,862 series with per-series scaling, it produces calibrated distributions and a competitive newsvendor policy. But on this tabular, short-horizon retail data it does not beat quantile-GBM — GBM matches or edges it on realized cost while training far faster and with far less fiddling (the RNN needed per-series scaling even to converge). This is consistent with the wider evidence (including the M5 competition and this portfolio's time-series-ML arc): gradient boosting is the tabular workhorse; deep learning earns its keep on long horizons, rich exogenous inputs, or very large scale.

The stratified view refines this. DeepAR is competitive across the board and even edges GBM in the mid-volume range, but it does not win the sparse tail here — on scarce products it merely matches GBM, so the cold-start crown stays with the Bayesian pooling model of notebook 3. DeepAR's real appeal is architectural: one global model spanning every series, needing no per-product identity and producing calibrated distributions without per-series fitting.

The three contenders map onto the problem with no free lunch: GBM for the data-rich bulk (best cost, fastest, least fuss); Bayesian pooling for the sparse cold-start tail (priors substitute for missing history); and DeepAR as the single global neural option — competitive everywhere, dominant nowhere on this tabular, short-horizon data, and most compelling at the scales, horizons, and rich covariate sets where deep learning usually pulls ahead.