Suppose that we sell one product and that we have to decide today how many units to order for each of the next \(15\) days. This problem has two layers of uncertainty. The first one is the future demand, which we do not know. The second one follows from the first: since we do not know the demand, we do not know the best order quantities either.
The number of units to order is the decision that we have to make under this uncertainty. If we order too much, the extra units sit on the shelf and cost money, and if we order too little, we run out and customers buy elsewhere (and even worse, we lose customers’ trust).
A demand forecast is the natural starting point, but it does not make the decision for us. In this notebook, we show some techniques to turn a demand forecast into a decision. We also illustrate how a probabilistic forecast model is key for optimal decision-making to account for the uncertainty and risk. To understand this point, we need to understand that the “two ways of being wrong” have asymmetric costs. For example, a unit left over costs a small holding charge per day (overstocking), while a unit we could not sell costs the whole margin (understocking). Of course, this asymmetry changes from business to business, and understanding this asymmetry is key for optimal decision-making. If we were to define an ordering policy based on a point forecast, say the mean, it would ignore the asymmetry and we would be leaving money on the table by completely ignoring the width and the shape of the uncertainty distribution.
Probabilistic forecasting gives us the whole distribution of future demand. This distribution encodes the uncertainty about the future, and as the asymmetry of the costs almost always exists, ignoring this distribution is suboptimal and naive. To turn this forecast distribution into an ordering policy we need two ingredients:
A loss function that sets the two costs on the two errors: the cost of ordering too little and the cost of ordering too much.
A decision rule that turns the forecast distribution into one objective to minimize or one constraint to satisfy. For example, minimize the expected loss, bound the probability of running out, or put extra weight on the worst outcomes. Each rule gives a different plan, and the choice between them is a business decision.
In this notebook we work through these ingredients on a synthetic example, with the forecast in NumPyro and the optimization in JAX. The ideas are motivated by four case studies:
The VN2 Inventory Planning Challenge asked participants to place weekly orders for \(599\) store and product pairs under shortage and holding costs. The winning entry of Szabłowski (2026) is a two-stage pipeline: a global forecasting model first, then a cost-aware order rule that targets the demand quantile implied by the costs.
On the optimization side, Saunders et al. (2026) at PyMC Labs show how a PyMC forecast of electricity demand enters a chance-constrained plan and a conditional value at risk (CVaR) plan in FICO Xpress, and how the two plans differ in what they control.
Caron (2023) models the demand of a rental product that is out of stock a large part of the time. The post fits a NumPyro demand model that treats the rentals on a stockout day as a lower bound of the demand (censored demand), simulates the stock dynamics of rentals and returns with
scanfrom the posterior draws, and reads the reorder quantity off the simulated stockout probability. It is a simulator of the inventory dynamics driven by posterior draws, which is what we build here.Saunders (2025) shows the PyTensor side of the workflow: the quantity to optimize is written as a PyTensor graph, the gradient comes from
pt.grad, and one compiled function serves the optimizer. We follow this pattern in JAX: the simulator of the inventory dynamics is a function of the orders,jax.value_and_gradgives its value and its gradient, andjax.jitcompiles the solve.
We keep the forecasting model simple on purpose. You can find many forecasting examples in the NumPyro Forecast Example Gallery 😉. Here we focus on the decision step. We generate daily demand from a known model with a drifting level, a weekly pattern and price promotions, and we fit a Bayesian forecasting model with numpyro_forecast. The posterior predictive draws become a matrix of demand scenarios. That is, a posterior predictive draw is a path of daily demand, and a matrix of them is a set of possible futures.
From the same scenarios we build four order plans: a plan against the mean forecast, a chance-constrained plan, an expected cost plan and a conditional value at risk (CVaR) plan. We solve the last two with a projected gradient method in JAX, score every plan on held-out scenarios and on the true future, and check the two solves against a linear program in cvxpy. Because the data are simulated, we know the truth and we can see which plan does what.
The notebook assumes that you have written a NumPyro model before and that you know what a convex optimization problem is. It does not assume any background in inventory theory or stochastic programming.
This notebook writes the forecast in NumPyro and the simulator and the optimizer in JAX. We also provide a PyMC implementation of the same workflow in forecast_to_decision_pymc.ipynb, with the forecast in the functional API of pymc_forecast and the simulator and the optimizer in PyTensor. The Appendix of this notebook reproduces the key ingredients of that implementation on the data of this notebook, so you can compare the two side by side.
Here is the outline:
Prepare Notebook. Magics, imports and plotting defaults.
The Decision Problem. The inventory dynamics, the loss and the costs, with a plot of the daily cost and profit.
Decision Rules. The four ways of turning demand scenarios into a plan, with plots of the newsvendor trade-off and the tail risk.
Generate Data. Daily demand and a promotion calendar from a NumPyro generative model.
Forecasting Model. A local level plus weekly seasonality plus price model with a negative binomial likelihood, with priors elicited with PreliZ.
Prior Predictive Checks. Draws from the priors on the training window against the data.
Model Fitting. NUTS, diagnostics and parameter recovery.
Demand Scenarios. Joint posterior predictive draws over the horizon.
Inventory Simulator. The loss function as a
jax.lax.scan, vectorized over scenarios and tested on the mean forecast plan.Decision Plans. The three remaining plans, a myopic newsvendor reference plan and the JAX solver.
Out of Sample Evaluation. Cost and service level on held-out scenarios and on the true future.
Pareto Frontier. The price of tail risk reduction.
Conclusion. What we learned.
Next Steps. Extensions of the simulator, the solver and the forecast.
Appendix. The same optimization problems as a linear program in cvxpy, as a cross check of the JAX solver, and the key ingredients of the PyMC and PyTensor implementation on the same data.
Prepare Notebook
import warnings
from collections.abc import Callable
from enum import StrEnum
from typing import NamedTuple, Protocol, runtime_checkable
import arviz as az
import cvxpy as cp
import jax
import jax.numpy as jnp
import matplotlib.pyplot as plt
import numpy as np
import numpyro
import numpyro.distributions as dist
import optax
import polars as pl
import preliz as pz
import seaborn as sns
import xarray as xr
from jax import random
from jax.scipy.stats import norm
from jaxtyping import Array, Bool, Float, Int, PRNGKeyArray, ScalarLike
from matplotlib.ticker import MaxNLocator, PercentFormatter
from numpyro.distributions import Distribution
from numpyro.handlers import do
from numpyro.infer import MCMC, NUTS, Predictive
from numpyro.infer.reparam import LocScaleReparam
from numpyro_forecast import Horizon, innovations, predict, to_datatree
from numpyro_forecast.features import fourier_features
from numpyro_forecast.metrics import crps_empirical
numpyro.set_host_device_count(4)
seed: int = 42
rng_key: PRNGKeyArray = random.PRNGKey(seed)
CI_PROB = 0.94
az.style.use("arviz-darkgrid")
az.rcParams["stats.ci_kind"] = "eti"
az.rcParams["stats.ci_prob"] = CI_PROB
plt.rcParams["figure.figsize"] = [10, 6]
plt.rcParams["figure.dpi"] = 100
plt.rcParams["figure.facecolor"] = "white"
%load_ext autoreload
%autoreload 2
%load_ext jaxtyping
%jaxtyping.typechecker beartype.beartype
%config InlineBackend.figure_format = "retina"
The Decision Problem
We plan the orders of one product for the next \(15\) days. Let \(H\) be the number of days in the planning horizon and let \(D_t\) be the demand on day \(t = 1, \dots, H\).
At the start of day \(t\) we receive \(a_t \geq 0\) units that we ordered in advance. The vector \(a = (a_1, \dots, a_H)\) is the action, the decision of this notebook. We start with \(I_0\) units on hand, the initial stock.
Let \(I_{t-1}\) be the stock left at the end of the previous day, so that \(y_t = I_{t-1} + a_t\) is the stock available on day \(t\). Customers buy what is there and go elsewhere for the rest. The sales \(S_t\), the lost sales \(U_t\) (the unmet demand) and the stock on hand \(I_t\) at the end of day \(t\) are
\[S_t = \min(y_t, D_t), \qquad U_t = (D_t - y_t)^{+}, \qquad I_t = (y_t - D_t)^{+},\]
where \((u)^{+} = \max(u, 0)\) is the positive part of a number \(u\). In this case, we assume that unmet demand is lost, not backlogged, as in retail and in the VN2 challenge. It is easy to adapt the techniques below to the backlogging case.
Two unit costs define the loss:
Let \(h\) be the holding cost of one unit that sits on the shelf at the end of a day, for capital, space and handling.
Let \(p\) be the lost margin, the margin we lose on one unit of demand that we cannot serve.
They play the role of the \(c_h\) and \(c_s\) of Szabłowski (2026). The loss of the plan \(a\) against the demand path \(D = (D_1, \dots, D_H)\) is the sum of both costs over the horizon:
\[L(a, D) = \sum_{t=1}^{H} \left[ h \, I_t + p \, U_t \right].\]
To make these formulas concrete, we store the costs and the initial stock in an InventoryConfig tuple that every function below receives, and we plot the cost of a single day as a function of the gap between the stock available and the demand, \(y_t - D_t\).
Let \(m\) be the margin of one unit, the price minus the purchase cost. The profit of day \(t\) is the margin on the units sold minus the holding charge on the leftovers, \(m \, S_t - h \, I_t\), and since \(S_t = D_t - U_t\) this is \(m \, D_t - (h \, I_t + m \, U_t)\): the margin on the whole demand minus the daily cost with \(p = m\). For the plot we fix a demand of \(100\) units and a margin of \(m = 2\) per unit.
class InventoryConfig(NamedTuple):
"""Costs and initial stock of the inventory problem."""
holding_cost: float
lost_sales_cost: float
initial_inventory: float
config = InventoryConfig(holding_cost=0.1, lost_sales_cost=2.0, initial_inventory=50.0)
gap_grid = np.linspace(-100, 100, 201)
daily_cost = config.holding_cost * np.maximum(
gap_grid, 0.0
) + config.lost_sales_cost * np.maximum(-gap_grid, 0.0)
demand_example = 100.0
# margin on the whole demand minus the daily cost, with p equal to the margin m
daily_profit = config.lost_sales_cost * demand_example - daily_cost
fig, axes = plt.subplots(ncols=2, figsize=(12, 5))
ax = axes[0]
ax.plot(gap_grid, daily_cost, color="C0", linewidth=2)
ax.axvline(0.0, color="black", linewidth=1)
ax.text(-60, 160, f"$p = {config.lost_sales_cost:g}$ per unit short")
ax.text(25, 30, f"$h = {config.holding_cost:g}$ per unit left over")
ax.set(
xlabel="stock available minus demand, $y_t - D_t$",
ylabel="cost on day $t$",
title="Daily cost",
)
ax = axes[1]
ax.plot(gap_grid, daily_profit, color="C2", linewidth=2)
ax.axvline(0.0, color="black", linewidth=1)
ax.axhline(
config.lost_sales_cost * demand_example,
color="black",
linestyle="--",
linewidth=1,
label="profit if the stock matches the demand, $m D_t$",
)
ax.set(
xlabel="stock available minus demand, $y_t - D_t$",
ylabel="profit on day $t$",
title=f"Daily profit for $D_t = {demand_example:g}$ "
f"and $m = {config.lost_sales_cost:g}$",
)
fig.legend(loc="outside lower center")
fig.suptitle("Daily Cost and Profit", fontsize=18, fontweight="bold");
The daily cost is zero when the stock matches the demand and grows linearly on both sides, by \(p = 2\) per unit short and by \(h = 0.1\) per unit left over, so one unit short costs twenty times as much as one unit too many on the day itself. The two sides differ in one more way: a lost sale is charged once, while a leftover unit stays on the shelf and is charged \(h\) on every later day until a customer buys it.
Note that with \(h = 0.1\) and \(m = 2\), a unit that waits \(20\) days on the shelf costs as much as its whole margin. We chose this high holding cost so that the trade-off shows within a \(15\)-day horizon.
The right panel shows that the profit of a day is the margin on the whole demand minus the daily cost, so maximizing the profit is the same as minimizing the loss once we set \(p\) to the margin. We set \(p\) to the margin and not to the price because a lost sale loses the revenue of the unit but saves its purchase cost, while a leftover unit keeps the value we paid for it and we can still sell it after the horizon.
For a perishable product, or at the end of a season, the units left on the last day lose part of their value, and we would add that markdown to the loss as a terminal term proportional to \(I_H\). We do not include it here.
The forecasting model gives us the posterior predictive distribution of the demand path \(D\). We represent it by \(N\) joint draws \(D^{(1)}, \dots, D^{(N)}\), one path of \(H\) days each. We call these paths scenarios and \(N\) the number of scenarios. The plan \(a\) must be fixed before we observe any demand, so one plan has to hold up across all of these scenarios. We approximate the expected loss of a plan by its sample average over the scenarios:
\[\text{E}[L(a, D)] \approx \frac{1}{N} \sum_{n=1}^{N} L(a, D^{(n)}).\]
Decision Rules
We compare four ways of turning the scenarios into a plan. All of them use the same scenarios and are scored with the same loss, and they differ in what they ask of the scenarios: a cost to minimize, a stockout probability to bound, or a tail to penalize.
Mean Forecast Plan
We replace the distribution by the mean path of the scenarios, \(\bar{D} = \frac{1}{N} \sum_{n} D^{(n)}\), and solve the deterministic problem
\[\min_{a \geq 0} \; L(a, \bar{D}).\]
This is the plan that a point forecast gives. Against a single path, the cheapest plan brings the stock available on each day to the demand of that day and leaves nothing on the shelf, so the orders follow the mean path once the initial stock is used up.
Chance-Constrained Plan
The chance-constrained plan does not use the costs. It fixes a service level instead, which is how often we are willing to run out. A stockout on day \(t\) is the event \(D_t > y_t\), and the fraction of scenarios in which it happens is our estimate of the stockout probability \(\text{P}(D_t > y_t)\). We choose a small tail probability \(\alpha\), here \(\alpha = 0.05\), and require
\[\text{P}(D_t > y_t) \leq \alpha \quad \text{for every } t = 1, \dots, H,\]
and we look for the plan with the smallest cumulative orders that satisfies it.
Note that the constraint holds day by day. It bounds the chance of a lost sale on any single day, not the chance of at least one lost sale somewhere in the \(15\) days. That probability is larger, because the plan has \(15\) chances to fail: with independent days it would be \(1 - (1 - \alpha)^{15} \approx 54\%\) for \(\alpha = 0.05\).
The days are not independent, so we report both numbers in the evaluation: the stockout probability on a given day, which the constraint controls, and the probability of at least one stockout in the horizon, which it does not.
We build the plan with a forward pass over the days of the horizon, inside all the scenarios at once. Given the orders of days \(1\) to \(t - 1\), every scenario \(n\) carries a simulated stock \(I_{t-1}^{(n)}\) into day \(t\), and the shortfall that the order has to cover in that scenario is \(D_t^{(n)} - I_{t-1}^{(n)}\). The smallest order that leaves at most a fraction \(\alpha\) of the scenarios short is the \((1 - \alpha)\) quantile of that shortfall across the scenarios,
\[a_t = \left( Q_{1-\alpha}\!\left(D_t - I_{t-1}\right) \right)^{+}.\]
Here is an important observation: the forward pass does not place an order each day after observing the demand. It walks through the horizon inside the scenarios, which we have today, and it never sees the real demand. Its output is the full vector \(a\), and we place all \(15\) orders today, as the problem statement requires.
One forward pass over the days builds the whole plan, and no optimizer is needed. The constraint counts how often we run out and ignores by how much.
Remark: This plan leaves the costs out, but the next two plans depend on them, so they need a good estimate of the cost of a lost sale. In many cases, not serving the demand is more costly than having leftovers. For example, there is a cost of customers having a bad experience, and therefore churning to competitors and never coming back. In real applications, it is important to account for this asymmetry in the cost function with estimates coming from customer surveys and other sources (or causal models).
Expected Cost Plan
We minimize the sample average of the loss,
\[\min_{a \geq 0} \; \frac{1}{N} \sum_{n=1}^{N} L(a, D^{(n)}),\]
which is the sample average approximation of the Bayes decision, see Shapiro, Dentcheva and Ruszczyński (2021) for the general theory. To see what this plan does, let’s look at one day in isolation. The stock available is \(y_t\), the demand is \(D_t\), a unit left over costs \(h\) and a unit short costs \(p\). This single-period problem has a name, the newsvendor problem, after the vendor who buys newspapers in the morning without knowing how many will sell. Its solution goes back to Arrow, Harris and Marschak (1951).
Its solution has a simple form. Suppose we raise the stock by one unit. With probability \(\text{P}(D_t \leq y_t)\) the unit is left over and costs \(h\), and with the remaining probability it covers a unit of demand and saves \(p\). The expected cost stops decreasing when the two balance:
\[h \, \text{P}(D_t \leq y_t) = p \, \left[1 - \text{P}(D_t \leq y_t)\right] \quad \Longleftrightarrow \quad \text{P}(D_t \leq y_t) = \frac{p}{p + h}.\]
The optimal stock is the \(p / (p + h)\) quantile of the demand distribution, and the ratio \(p / (p + h)\) is called the critical fractile.
Note that with \(p = h\) the critical fractile is \(1 / 2\), so the optimal stock is the median of the demand. For a symmetric demand distribution, such as the Normal of the example below, the median is the mean. So with equal costs and a symmetric demand the mean forecast is the optimal single-period stock. With our costs, \(p = 2\) and \(h = 0.1\), the fractile is \(0.952\), so the optimal stock is a high quantile of the demand, far above the mean.
Let’s work a simple example with a Normal demand with mean \(\mu = 60\) and standard deviation \(\sigma = 15\). Let \(\Phi\) and \(\varphi\) be the distribution function and the density of the standard Normal. We write the demand as \(D = \mu + \sigma Z\) with \(Z\) standard Normal.
The stock \(q\) is a number that we choose, not a random variable, and we let \(z = (q - \mu) / \sigma\) be the stock in standard units. The gap between the stock and the demand is then \(q - D = (q - \mu) - \sigma Z = \sigma \, (z - Z)\), so the leftover is \((q - D)^{+} = \sigma \, (z - Z)^{+}\) and the expected leftover is \(\sigma \, \text{E}[(z - Z)^{+}]\), which is the integral of \((z - u) \, \varphi(u)\) over \(u \leq z\):
\[\text{E}[(q - D)^{+}] = \sigma \int_{-\infty}^{z} (z - u) \, \varphi(u) \, du = \sigma \left[ z \, \Phi(z) + \varphi(z) \right],\]
where we used \(\varphi'(u) = -u \, \varphi(u)\) for the second term.
The expected shortage follows from the identity \((D - q)^{+} = (q - D)^{+} + (D - q)\). Its expectation is the expected leftover minus \(q - \mu = \sigma z\):
\[\text{E}[(D - q)^{+}] = \sigma \left[ \varphi(z) - z \, \left(1 - \Phi(z)\right) \right].\]
We compute both with jax.scipy.stats.norm, add the two parts of the expected cost and plot it as a function of the stock, with the critical fractile of our costs.
critical_fractile = config.lost_sales_cost / (
config.lost_sales_cost + config.holding_cost
)
demand_mean, demand_sd = 60.0, 15.0
stock_grid = jnp.linspace(20, 120, 201)
z = (stock_grid - demand_mean) / demand_sd
expected_leftover = demand_sd * (z * norm.cdf(z) + norm.pdf(z))
expected_shortage = demand_sd * (norm.pdf(z) - z * (1 - norm.cdf(z)))
holding_part = config.holding_cost * expected_leftover
lost_sales_part = config.lost_sales_cost * expected_shortage
optimal_stock = demand_mean + demand_sd * norm.ppf(critical_fractile)
fig, ax = plt.subplots()
ax.plot(
stock_grid,
holding_part + lost_sales_part,
color="C0",
linewidth=2,
label="expected cost",
)
ax.plot(stock_grid, holding_part, color="C2", linestyle=":", label="holding part")
ax.plot(stock_grid, lost_sales_part, color="C3", linestyle=":", label="lost sales part")
ax.axvline(demand_mean, color="black", linewidth=1, label="mean demand")
ax.axvline(
optimal_stock,
color="C1",
linewidth=2,
label=f"{critical_fractile:.3f} quantile (critical fractile)",
)
fig.legend(loc="outside lower center", ncol=3)
ax.set(xlabel="stock $q$", ylabel="expected cost of one period")
fig.suptitle("The Newsvendor Trade-Off", fontsize=18, fontweight="bold");
The expected cost has its minimum at the \(0.952\) quantile, about \(85\) units, and not at the mean of \(60\). The lost sales part makes the curve steep on the left and the holding part makes it flat on the right, so a stock above the mean is cheaper than the mean itself.
Our problem has \(15\) days, and the days are linked by the stock that carries over. The single-period rule charges a leftover unit one day of holding, while in the multi-period problem the unit pays \(h\) on every later day until it is sold. It is also left over in the scenarios where day \(t\) was slow, which carry more stock into day \(t + 1\) and tend to have a low level, so the unit can take several days to sell.
So the marginal unit costs more than the single-period rule assumes. The plan that minimizes the expected loss keeps the probability \(\text{P}(D_t \leq y_t)\) of covering the demand of day \(t\) below \(p / (p + h)\) on every day but the last, so the single-period rule, applied day by day, overstocks. We check this in the Out of Sample Evaluation section, where the expected cost plan runs out on \(10.4\%\) of the days against the single-period \(4.8\%\).
The general lost-sales problem, with delivery lead times and orders that react to the stock, is hard, see Zipkin (2008) and the review of Bijvank and Vis (2011). Our plan is fixed in advance, and this makes the problem a convex program: the loss is a convex function of the orders \(a\), so every local minimum is the global one and a gradient method cannot get trapped in a local minimum.
The loss is convex because it can be written as a positive combination of the daily stocks plus a term that is linear in the orders, and each stock is a convex piecewise linear function of the orders. We show this in the Appendix, and we solve the problem numerically.
We keep the single-period rule as a reference, the myopic newsvendor plan: on each day, order up to the \(p / (p + h)\) quantile of the shortfall \(D_t - I_{t-1}\), with the same forward pass as the chance-constrained plan.
The expected cost plan has no service target of its own, i.e. we do not set a stockout probability. The stockout frequency it accepts is a consequence of the costs, and we read it from the outputs below. On the last day of the horizon the problem is a single-period one, since a leftover there is charged holding only once, so the optimal stockout probability on that day is \(h / (p + h)\).
The myopic rule uses the same critical fractile as the order rule of the winning VN2 entry, where Szabłowski (2026) maps \(c_s / (c_s + c_h)\) to a Normal quantile around a point forecast and re-plans every week on the observed inventory. We read the quantile from the scenarios instead, which the report itself recommends:
In production, \(q^\star\) would be implemented via empirical quantiles or a probabilistic forecast, avoiding any normality assumption.
CVaR Plan
The CVaR plan adds a penalty on the worst outcomes. Let \(\text{VaR}_\alpha(L) = Q_{1-\alpha}(L)\) be the \((1 - \alpha)\) quantile of the loss, the value at risk. For a continuous loss, the conditional value at risk is the expected loss beyond that quantile:
\[\text{CVaR}_\alpha(L) = \text{E}\left[ L \mid L \geq \text{VaR}_\alpha(L) \right].\]
With \(N\) equally weighted scenarios, the sample estimate is the mean of the \(k = \alpha N\) largest losses, and this is what the function cvar computes below.
Again, let’s work a simple example. We draw the two quantities on a skewed loss density, a LogNormal with log-mean \(\mu = 5\) and log-standard deviation \(\sigma = 0.6\), for the tail probability \(\alpha = 0.05\). If \(L\) is LogNormal, then \(W = \log L\) is \(\text{Normal}(\mu, \sigma)\), and every quantity we need has a closed form. Let’s derive them one at a time.
First, let’s derive the density. Let \(F\) be the distribution function of \(L\) and \(f = F'\) its density. For \(x > 0\) the event \(L \leq x\) is the event \(W \leq \log x\), so
\[F(x) = \text{P}(L \leq x) = \text{P}(W \leq \log x) = \Phi\!\left(\frac{\log x - \mu}{\sigma}\right).\]
The density is the derivative of \(F\), and the chain rule gives
\[f(x) = \frac{1}{\sigma x} \, \varphi\!\left(\frac{\log x - \mu}{\sigma}\right).\]
Next, we turn to the value at risk. Let \(z_{1-\alpha} = \Phi^{-1}(1 - \alpha)\) be the \((1 - \alpha)\) quantile of the standard Normal. The value at risk is the \(x\) with \(F(x) = 1 - \alpha\), so \((\log x - \mu) / \sigma = z_{1-\alpha}\) and
\[\text{VaR}_\alpha(L) = \exp\left(\mu + \sigma \, z_{1-\alpha}\right).\]
Now we compute the mean. We write \(W = \mu + \sigma Z\) with \(Z\) standard Normal, so
\[\text{E}[L] = \text{E}\left[e^{W}\right] = \int_{-\infty}^{\infty} e^{\mu + \sigma z} \, \varphi(z) \, dz.\]
Completing the square in the exponent, \(\sigma z - z^2 / 2 = \sigma^2 / 2 - (z - \sigma)^2 / 2\), so the integrand is \(e^{\mu + \sigma^2 / 2} \, \varphi(z - \sigma)\), and the density \(\varphi(z - \sigma)\) integrates to one:
\[\text{E}[L] = \exp\left(\mu + \sigma^2 / 2\right).\]
Finally, we compute the conditional value at risk. It is the same integral restricted to the tail \(Z \geq z_{1-\alpha}\), divided by the probability \(\alpha\) of that tail:
\[\text{CVaR}_\alpha(L) = \frac{1}{\alpha} \int_{z_{1-\alpha}}^{\infty} e^{\mu + \sigma z} \, \varphi(z) \, dz = \frac{e^{\mu + \sigma^2 / 2}}{\alpha} \int_{z_{1-\alpha}}^{\infty} \varphi(z - \sigma) \, dz.\]
The last integral is \(1 - \Phi(z_{1-\alpha} - \sigma) = \Phi(\sigma - z_{1-\alpha})\), so
\[\text{CVaR}_\alpha(L) = \frac{1}{\alpha} \exp\left(\mu + \sigma^2 / 2\right) \Phi\left(\sigma - z_{1-\alpha}\right).\]
In the code, dist.LogNormal from NumPyro gives the density, the quantile and the mean, and we compute the conditional value at risk with the last formula. We fix \(\alpha\) here, since the chance constraint and the CVaR share it.
ALPHA = 0.05 # tail probability of the chance constraint and of the CVaR
log_mean, log_sd = 5.0, 0.6
lognormal = dist.LogNormal(log_mean, log_sd)
loss_grid = jnp.linspace(1, 1_000, 1_000)
loss_density = jnp.exp(lognormal.log_prob(loss_grid))
z_alpha = norm.ppf(1 - ALPHA)
expected_loss_example = lognormal.mean
var_example = lognormal.icdf(1 - ALPHA)
cvar_example = expected_loss_example * norm.cdf(log_sd - z_alpha) / ALPHA
fig, ax = plt.subplots()
ax.plot(loss_grid, loss_density, color="C0", linewidth=2, label="loss density")
ax.fill_between(
loss_grid,
loss_density,
where=loss_grid >= var_example,
color="C3",
alpha=0.3,
label=f"worst {ALPHA:.0%} of outcomes",
)
ax.axvline(expected_loss_example, color="black", linewidth=1, label="expected loss")
ax.axvline(var_example, color="C1", linewidth=2, label="VaR")
ax.axvline(cvar_example, color="C3", linewidth=2, label="CVaR")
fig.legend(loc="outside lower center", ncol=3)
ax.set(xlabel="loss", ylabel="density", yticks=[])
fig.suptitle(
"Value at Risk and Conditional Value at Risk", fontsize=18, fontweight="bold"
);
The value at risk is the point that the loss exceeds with probability \(5\%\). The conditional value at risk is the average loss in the shaded region beyond it, so it sits further to the right, and well above the expected loss because the density has a long right tail.
Having understood the intuition of the CVaR, we can write the objective that the CVaR plan minimizes in practice:
\[\min_{a \geq 0} \; \text{E}[L(a, D)] + \lambda \, \text{CVaR}_\alpha(L(a, D)),\]
where \(\lambda \geq 0\) is the tail weight, the weight we put on the worst \(\alpha\) fraction of the outcomes. With \(\lambda = 0\) we recover the expected cost plan. The CVaR term counts how bad the worst outcomes are, not how often they happen.
Now that we have these concepts in place, let’s put them into practice on a synthetic data set.
Generate Data
We simulate daily demand from a generative model with a slowly drifting level, a weekly pattern, price promotions and negative binomial noise.
We write the model in NumPyro
Fix the true parameters with the
dohandlerTake one draw with
Predictive.
We include the price as a covariate because the retailer sets it, so its future values are known: the promotion calendar of the horizon is fixed in advance. A covariate helps the forecast only if we know its future values, and weather or competitor actions would need a forecast of their own. Here are the specs of this promotion feature: a promotion lasts one calendar week and cuts the list price by \(30\%\). In the history, each week is a promotion week with probability \(0.1\). In the horizon, the second week (days \(8\) to \(14\)) is a promotion week. We pass this calendar to the model data generator as an input.
To store this configuration we use a Setup named tuple as data structure.
class Setup(NamedTuple):
"""Length of the history and the horizon, and the promotion settings."""
t_obs: int = 364 # one year of daily history
horizon: int = 15 # days to plan
discount: float = 0.3 # price cut in a promotion week
promo_prob: float = 0.1 # chance that a given week of the history is a promotion
@property
def duration(self) -> int:
return self.t_obs + self.horizon
setup = Setup()
# Fourier features to generate weekly seasonality
fourier = fourier_features(setup.duration, period=7.0, num_terms=3)
# the retailer's plan for the horizon: a promotion in its second week
promo_future = jnp.zeros(setup.horizon).at[7:14].set(1.0)
Now, let’s specify the data generating process. Let \(\ell_t\) be the latent level, \(\delta_t\) be the daily increment of the level, \(x_t\) the vector of Fourier features of the weekly cycle, \(r_t\) the price relative to the list price and \(\mu_t\) the expected demand on day \(t\):
\[\begin{aligned} D_t &\sim \text{NegativeBinomial}(\mu_t, \phi), \\ \log \mu_t &= \beta_0 + \ell_t + x_t^\top \beta + \gamma \log r_t, \\ \ell_t &= \sum_{\tau \leq t} \delta_\tau, \\ \delta_t &\sim \text{Normal}(0, \sigma_\delta), \end{aligned}\]
where \(\beta_0\) is the intercept, \(\beta\) the Fourier coefficients beta, \(\gamma\) the price elasticity, \(\sigma_\delta\) the drift_scale and \(\phi\) the concentration of the negative binomial (a larger \(\phi\) is closer to a Poisson). The price enters as a log, so \(\gamma\) is an elasticity. Taking exponentials,
\[\mu_t = \exp\left(\beta_0 + \ell_t + x_t^\top \beta\right) \cdot r_t^{\gamma},\]
so a relative price \(r_t\) multiplies the demand by \(r_t^{\gamma}\). In a promotion week \(r_t = 0.7\), and with \(\gamma = -1.5\) the demand is multiplied by \(0.7^{-1.5} \approx 1.71\).
We proceed by implementing this process in a numpyro model. The priors inside the generator play no role, since do fixes every parameter, and we choose the priors of the forecasting model in the next section. The promotion weeks of the history are Bernoulli draws under a week plate, repeated over the seven days of each week. The log_price site is deterministic so that we can read the price path back from the draw.
We keep the demand and the price path, and we split the demand into the observed history and the true future. The covariate matrix of the forecasting model is the Fourier design plus the log relative price as a last column.
def demand_generator(
fourier: Float[Array, "duration features"],
promo_future: Float[Array, " horizon"],
setup: Setup,
) -> None:
"""Daily demand with a drifting level, a weekly pattern and price promotions."""
duration, num_features = fourier.shape
intercept = numpyro.sample("intercept", dist.Normal(4.0, 1.0))
beta = numpyro.sample(
"beta", dist.Normal(0.0, 0.3).expand([num_features]).to_event(1)
)
elasticity = numpyro.sample("elasticity", dist.Normal(0.0, 1.0))
drift_scale = numpyro.sample("drift_scale", dist.HalfNormal(0.05))
concentration = numpyro.sample("concentration", dist.Gamma(20.0, 1.0))
# one Bernoulli per week of history, repeated over its seven days
with numpyro.plate("week", setup.t_obs // 7):
promo_week = numpyro.sample("promo_week", dist.Bernoulli(setup.promo_prob))
promo = jnp.concatenate([jnp.repeat(promo_week, 7), promo_future])
log_price = numpyro.deterministic(
"log_price", promo * jnp.log(1.0 - setup.discount)
)
with numpyro.plate("time", duration):
drift = numpyro.sample("drift", dist.Normal(0.0, drift_scale))
level = jnp.cumsum(drift)
log_rate = intercept + level + fourier @ beta + elasticity * log_price
numpyro.sample("y", dist.NegativeBinomial2(jnp.exp(log_rate), concentration))
# Data generating process
numpyro.render_model(
demand_generator,
model_args=(fourier, promo_future, setup),
render_distributions=True,
render_params=True,
)
We now fix the true parameter values that we intervene on, and take one draw.
true_params: dict[str, Array] = {
"intercept": jnp.log(60.0),
"beta": jnp.array([0.15, 0.05, -0.08, 0.10, 0.03, -0.02]),
"elasticity": jnp.array(-1.5),
"drift_scale": jnp.array(0.01),
"concentration": jnp.array(30.0),
}
rng_key, key_data = random.split(rng_key)
generator = Predictive(do(demand_generator, data=true_params), num_samples=1)
draw = generator(key_data, fourier, promo_future, setup)
demand_full = draw["y"][0]
log_price = draw["log_price"][0]
covariates = jnp.concatenate([fourier, log_price[:, None]], axis=1)
# time at axis -2, one observation dim at -1; the simulator works in floats
data = demand_full[: setup.t_obs, None]
demand_future_true = demand_full[setup.t_obs :].astype(jnp.float32)
days = np.arange(setup.duration)
days_future = days[setup.t_obs :]
promo_days_future = days_future[promo_future > 0]
We plot the demand series and the price path below it. The dashed line marks the start of the planning horizon.
fig, axes = plt.subplots(
nrows=2,
ncols=1,
figsize=(12, 7),
sharex=True,
sharey=False,
height_ratios=[3, 1],
layout="constrained",
)
ax = axes[0]
ax.plot(
days[: setup.t_obs],
data[:, 0],
color="C0",
label="Observed history",
)
ax.plot(
days_future,
demand_future_true,
color="C1",
label="True future (held out)",
)
ax.axvline(setup.t_obs - 0.5, color="black", linestyle="--", label="Planning horizon")
ax.set(ylabel="units", ylim=(0, 1.2 * float(demand_full.max())))
ax = axes[1]
ax.step(days, np.exp(log_price), where="mid", color="C3")
ax.axvline(setup.t_obs - 0.5, color="black", linestyle="--", linewidth=1)
ax.set(xlabel="day", ylabel="price / list price", ylim=(0.6, 1.05))
fig.legend(loc="outside lower center", ncol=3)
fig.suptitle("Daily Demand and Price", fontsize=18, fontweight="bold");
We see a weekly cycle on top of a level that drifts over the year, and a block of high demand in every promotion week. The blocks are the part of the series that the price covariate explains. The true future appears in the plots as a reference, and we score the plans on it at the end of the evaluation.
Forecasting Model
The forecasting model has the same structure as the generator, the same latent level, covariates and likelihood, so it is well specified. Nevertheless, we need to reason about the priors and specify them ourselves.
Prior Elicitation
The model has five global parameters. For three of them we state what we believe on the scale of the data and use PreliZ to turn the statement into a distribution: pz.maxent returns the member of a family that puts a given mass between two bounds (the one with maximum entropy, so it adds nothing beyond our constraints). Here is the reasoning for each of the three.
intercept: \(\exp(\beta_0)\) is the demand per day at the start of the history at the list price, with the weekly pattern averaged out on the log scale (the Fourier terms sum to zero over a week). We believe it lies between \(10\) and \(300\) units, a wide range for a product that sells tens of units per day. So we ask for a Normal on \(\beta_0\) with \(94\%\) of its mass between \(\log 10\) and \(\log 300\).drift_scale: the level is a random walk, so after \(T\) days the standard deviation of the log level is \(\sigma_\delta \sqrt{T}\), and \(\exp(\sigma_\delta \sqrt{T})\) is the typical factor by which the level has moved. Over the \(364\) days of history we believe that factor lies between \(1.04\) and \(2\), that is, from a \(4\%\) move to a doubling. So we ask for a LogNormal on \(\sigma_\delta\) with \(94\%\) of its mass between \(\log(1.04) / \sqrt{T}\) and \(\log(2) / \sqrt{T}\). The LogNormal density vanishes at zero, which fits a level that moves at least a little over a year.concentration: the negative binomial adds \(\mu_t^2 / \phi\) to the Poisson variance, so the extra day-to-day noise is a fraction \(1 / \sqrt{\phi}\) of the level. We believe that fraction lies between a tenth and a half, which is \(\phi\) between \(4\) and \(100\). So we ask for a Gamma with \(94\%\) of its mass in that range.
The other two priors we state directly.
Each Fourier coefficient in
betagets a \(\text{Normal}(0, 0.3)\), so two standard deviations multiply the demand by \(e^{\pm 0.6}\), between \(0.55\) and \(1.8\).The
elasticitygets a \(\text{Normal}(-1, 1)\): a higher price should lower the demand, so we center the prior on a negative value, and the standard deviation of \(1\) leaves a positive value possible if the data ask for it.
We run the three elicitations and plot the densities in two rows. The top row is the parameter scale, with the elicited density and the two bounds we asked for. The bottom row is the data scale: the density of the quantity we reasoned about (demand per day, level multiplier after one year, noise fraction), with the bounds mapped through the same transform.
The bottom row needs no draws. If \(\psi = g(\theta)\) is a monotone transform of the parameter \(\theta\) with density \(f\), the density of \(\psi\) is \(f(g^{-1}(\psi)) \, |dg^{-1} / d\psi|\) by the change of variables formula. We write the inverse transforms in jax.numpy and take the derivative with jax.grad.
intercept_bounds = (float(np.log(10)), float(np.log(300)))
drift_bounds = (
float(np.log(1.04) / np.sqrt(setup.t_obs)),
float(np.log(2) / np.sqrt(setup.t_obs)),
)
concentration_bounds = (4.0, 100.0)
# the prior mass between the bounds, fixed here and not tied to the plotted bands
ELICITATION_MASS = 0.94
intercept_prior = pz.maxent(
pz.Normal(), *intercept_bounds, mass=ELICITATION_MASS, plot=False
)
drift_prior = pz.maxent(
pz.LogNormal(), *drift_bounds, mass=ELICITATION_MASS, plot=False
)
concentration_prior = pz.maxent(
pz.Gamma(), *concentration_bounds, mass=ELICITATION_MASS, plot=False
)
priors = {
"intercept": (intercept_prior, intercept_bounds),
"drift_scale": (drift_prior, drift_bounds),
"concentration": (concentration_prior, concentration_bounds),
}
# the quantity on the data scale that each prior is about, with the inverse transform
data_scale = {
"intercept": ("demand per day at the list price", jnp.exp, jnp.log),
"drift_scale": (
"level multiplier after one year",
lambda x: jnp.exp(x * jnp.sqrt(setup.t_obs)),
lambda y: jnp.log(y) / jnp.sqrt(setup.t_obs),
),
"concentration": (
"extra noise as a fraction of the level",
lambda x: 1 / jnp.sqrt(x),
lambda y: 1 / y**2,
),
}
def density_on_data_scale(
prior: pz.distributions.distributions.Continuous,
inverse: Callable[[Float[Array, " grid"]], Float[Array, " grid"]],
grid: Float[Array, " grid"],
) -> Float[Array, " grid"]:
"""Density of a monotone transform of the prior, by change of variables."""
jacobian = jnp.abs(jax.vmap(jax.grad(inverse))(grid))
return prior.pdf(inverse(grid)) * jacobian
fig, axes = plt.subplots(nrows=2, ncols=3, figsize=(15, 8), layout="constrained")
for j, (name, (prior, bounds)) in enumerate(priors.items()):
label, transform, inverse = data_scale[name]
# parameter scale: the elicited density and the bounds
ax = axes[0, j]
x = np.linspace(*prior.ppf([0.001, 0.999]), 500)
ax.plot(x, prior.pdf(x), color="C0", label="elicited density")
ax.axvline(bounds[0], color="black", linestyle="--", label="elicited bounds")
ax.axvline(bounds[1], color="black", linestyle="--")
ax.set(title=f"{name}\n{prior}", yticks=[])
# data scale: the same density after the transform, from near zero to the
# larger of the 99th percentile and the upper bound
ax = axes[1, j]
bounds_data = transform(jnp.asarray(bounds))
upper = 1.05 * max(
float(transform(prior.ppf([0.01, 0.99])).max()), float(bounds_data.max())
)
grid = jnp.linspace(upper / 500, upper, 500)
ax.plot(grid, density_on_data_scale(prior, inverse, grid), color="C0")
for bound in bounds_data:
ax.axvline(bound, color="black", linestyle="--")
ax.set(title=label, yticks=[])
axes[0, 0].set(ylabel="parameter scale")
axes[1, 0].set(ylabel="data scale")
fig.legend(*axes[0, 0].get_legend_handles_labels(), loc="outside lower center", ncol=2)
fig.suptitle("Elicited Priors", fontsize=18, fontweight="bold");
The elicited priors are a \(\text{Normal}(4.0, 0.9)\) for the intercept, a \(\text{LogNormal}(-4.3, 0.6)\) for the drift scale and a \(\text{Gamma}\) with shape \(2.8\) and rate \(0.06\) for the concentration.
The top row shows the three elicited densities with their bounds, and the mass between the bounds is \(94\%\) in each panel. The bottom row shows what these priors say on the data scale. The demand per day spreads between a few units and a few hundred, with most of the mass below \(100\) units. The level multiplier after one year sits between no move and a doubling, with a tail beyond. The noise fraction sits mostly between \(0.1\) and \(0.3\), inside its bounds of \(0.1\) and \(0.5\).
Note that pz.maxent fixes the total mass between the bounds and not the two tails, so the Gamma puts almost all of its remaining \(6\%\) above \(100\), which is the tail below \(0.1\) in the bottom right panel. The LogNormal does the same: almost all of its remaining \(6\%\) sits above the upper bound, beyond a doubling of the level. The true values of the generator, \(\log 60 \approx 4.1\), \(0.01\) and \(30\), sit inside all three priors.
Model Specification
Recall that a numpyro_forecast model is a plain function with signature (covariates, data=None). In addition, it has the following components:
Horizon.from_datareads the observed length fromdataand the full duration fromcovariates.innovationssamples the level increments under atimeplate over the history and atime_futureplate over the horizon.predictregistersobsover the training window andforecastover the horizon with the negative binomial likelihood.
We define the model with these priors and render it on the training window to see its structure. The last column of covariates is the log relative price.
def demand_model(
covariates: Float[Array, "duration features"],
data: Int[Array, "t_obs 1"] | None = None,
) -> None:
"""Local level plus weekly seasonality plus price, negative binomial likelihood."""
horizon = Horizon.from_data(covariates, data)
fourier, log_price = covariates[..., :-1], covariates[..., -1]
num_features = fourier.shape[-1]
intercept = numpyro.sample(
"intercept", dist.Normal(intercept_prior.mu, intercept_prior.sigma)
)
beta = numpyro.sample(
"beta", dist.Normal(0.0, 0.3).expand([num_features]).to_event(1)
)
elasticity = numpyro.sample("elasticity", dist.Normal(-1.0, 1.0))
drift_scale = numpyro.sample(
"drift_scale", dist.LogNormal(drift_prior.mu, drift_prior.sigma)
)
concentration = numpyro.sample(
"concentration",
dist.Gamma(concentration_prior.alpha, concentration_prior.beta),
)
# non-centered increments: the sampler sees standardized drifts
drift = innovations(
horizon, "drift", dist.Normal(0.0, drift_scale), reparam=LocScaleReparam(0)
)
level = jnp.cumsum(drift, axis=-2)
regression = (fourier @ beta + elasticity * log_price)[..., None]
log_rate = intercept + level + regression
def likelihood(eta: Float[Array, "..."]) -> Distribution:
return dist.NegativeBinomial2(jnp.exp(eta), concentration)
predict(horizon, likelihood, log_rate)
numpyro.render_model(
demand_model,
model_args=(covariates[: setup.t_obs], data),
render_distributions=True,
render_params=True,
)
Inside the time plate we see drift_decentered, the standardized increments that the LocScaleReparam samples, and the deterministic drift that rescales them by drift_scale. On the training window there is no horizon, so the time_future plate does not appear.
Prior Predictive Checks
Before we fit the model, we draw demand paths from the priors alone and compare them with the training data. The check tells us whether the priors put the demand on the right scale, with room for what the data could show and without absurd values. The model is a plain NumPyro function, so Predictive handles it directly, and with no data it samples the obs site over the whole training window.
We plot the \(50\%\) and \(94\%\) equal-tailed intervals (ETI) of the draws and the observed series on top, on a log axis because the log link gives the prior a long upper tail. An equal-tailed interval runs between two percentiles, for example from the \(3\)rd to the \(97\)th percentile for the \(94\%\) band. We use it throughout the notebook instead of the highest density interval, because later on we read the band edges as percentiles.
def lighten_bands(pc: az.PlotCollection, x: str) -> None:
"""Set the alpha of the 50% and 94% credible bands of a `plot_lm` collection."""
bands = pc.viz["ci_band"][x]
for prob, alpha in [(0.5, 0.5), (CI_PROB, 0.25)]:
for band in bands.sel(prob=prob).values.flat:
band.set_alpha(alpha)
rng_key, key_prior = random.split(rng_key)
prior_obs = Predictive(demand_model, num_samples=1_000, return_sites=["obs"])(
key_prior, covariates[: setup.t_obs]
)["obs"][..., 0]
prior_tree = az.from_dict(
{
"prior_predictive": {"obs": prior_obs[None]},
"observed_data": {"obs": data[:, 0]},
"constant_data": {"day": days[: setup.t_obs]},
},
coords={"time": days[: setup.t_obs]},
dims={"obs": ["time"], "day": ["time"]},
)
pc = az.plot_lm(
prior_tree,
x="day",
y="obs",
y_obs="obs",
group="prior_predictive",
smooth=False,
ci_prob=(0.5, CI_PROB),
visuals={
"ci_band": {"color": "C0"},
"observed_scatter": False,
"pe_line": False,
},
figure_kwargs={"figsize": (12, 6)},
)
lighten_bands(pc, "day")
ax = pc.viz["plot"]["day"].item()
pc.viz["ci_band"]["day"].sel(prob=CI_PROB).item().set_label(f"{CI_PROB:.0%} ETI")
pc.viz["ci_band"]["day"].sel(prob=0.5).item().set_label("50% ETI")
ax.plot(
days[: setup.t_obs],
data[:, 0],
color="black",
label="Observed",
)
ax.set(xlabel="day", ylabel="units", yscale="log")
fig = pc.viz["figure"].item()
fig.legend(loc="outside lower center", ncol=3)
fig.suptitle("Prior Predictive Check", fontsize=18, fontweight="bold");
The priors put the demand on the right scale and not much more. The pooled draws have a median of \(56\) units against \(66\) for the data, the \(94\%\) band runs from \(5\) to \(11\) units at the bottom to between \(340\) and \(780\) units at the top depending on the day, and the observed series, between \(28\) and \(168\) units, stays inside the inner band almost always.
The bands widen only a little over the year because the uncertainty about the starting level dominates the drift of the random walk. Nothing here is implausible for a product that sells tens of units per day, so we move on to the fit.
Model Fitting
We now fit the model with NUTS.
%%time
rng_key, key_fit = random.split(rng_key)
mcmc = MCMC(
NUTS(demand_model, target_accept_prob=0.9),
num_warmup=1_000,
num_samples=1_000,
num_chains=4,
)
mcmc.run(key_fit, covariates[: setup.t_obs], data)
CPU times: user 4.15 s, sys: 189 ms, total: 4.34 s
Wall time: 1.82 s
The function to_datatree turns the posterior into an xarray.DataTree in the ArviZ schema, and because covariates extends beyond data, the tree also carries the in-sample posterior predictive draws and the forecast draws over the horizon.
posterior = mcmc.get_samples()
rng_key, key_tree = random.split(rng_key)
idata = to_datatree(key_tree, demand_model, posterior, data, covariates, num_chains=4)
idata
<xarray.DataTree>
Group: /
│ Attributes:
│ inference_library: numpyro
│ creation_library: numpyro_forecast
│ sample_dims: ['chain', 'draw']
├── Group: /posterior
│ Dimensions: (chain: 4, draw: 1000, beta_dim_0: 6,
│ drift_dim_0: 364, drift_dim_1: 1,
│ drift_decentered_dim_0: 364,
│ drift_decentered_dim_1: 1)
│ Coordinates:
│ * chain (chain) int64 32B 0 1 2 3
│ * draw (draw) int64 8kB 0 1 2 3 4 5 ... 995 996 997 998 999
│ * beta_dim_0 (beta_dim_0) int64 48B 0 1 2 3 4 5
│ * drift_dim_0 (drift_dim_0) int64 3kB 0 1 2 3 ... 360 361 362 363
│ * drift_dim_1 (drift_dim_1) int64 8B 0
│ * drift_decentered_dim_0 (drift_decentered_dim_0) int64 3kB 0 1 2 ... 362 363
│ * drift_decentered_dim_1 (drift_decentered_dim_1) int64 8B 0
│ Data variables:
│ beta (chain, draw, beta_dim_0) float32 96kB 0.1504 ......
│ concentration (chain, draw) float32 16kB 33.36 26.57 ... 27.25
│ drift (chain, draw, drift_dim_0, drift_dim_1) float32 6MB ...
│ drift_decentered (chain, draw, drift_decentered_dim_0, drift_decentered_dim_1) float32 6MB ...
│ drift_scale (chain, draw) float32 16kB 0.009467 ... 0.005351
│ elasticity (chain, draw) float32 16kB -1.556 -1.458 ... -1.673
│ intercept (chain, draw) float32 16kB 4.029 4.057 ... 4.041
│ Attributes:
│ created_at: 2026-11-04T10:04:07.968353+00:00
│ creation_library: ArviZ
│ creation_library_version: 1.3.1
│ creation_library_language: Python
│ sample_dims: ['chain', 'draw']
├── Group: /posterior_predictive
│ Dimensions: (chain: 4, draw: 1000, time: 364, obs_dim: 1)
│ Coordinates:
│ * chain (chain) int64 32B 0 1 2 3
│ * draw (draw) int64 8kB 0 1 2 3 4 5 6 7 ... 993 994 995 996 997 998 999
│ * time (time) int64 3kB 0 1 2 3 4 5 6 7 ... 357 358 359 360 361 362 363
│ * obs_dim (obs_dim) int64 8B 0
│ Data variables:
│ obs (chain, draw, time, obs_dim) int32 6MB 119 73 108 77 ... 51 41 58
│ Attributes:
│ created_at: 2026-11-04T10:04:08.635184+00:00
│ creation_library: ArviZ
│ creation_library_version: 1.3.1
│ creation_library_language: Python
│ sample_dims: ['chain', 'draw']
├── Group: /observed_data
│ Dimensions: (time: 364, obs_dim: 1)
│ Coordinates:
│ * time (time) int64 3kB 0 1 2 3 4 5 6 7 ... 357 358 359 360 361 362 363
│ * obs_dim (obs_dim) int64 8B 0
│ Data variables:
│ obs (time, obs_dim) int32 1kB 100 168 107 94 84 92 ... 87 61 52 30 73
│ Attributes:
│ created_at: 2026-11-04T10:04:08.635476+00:00
│ creation_library: ArviZ
│ creation_library_version: 1.3.1
│ creation_library_language: Python
│ sample_dims: []
├── Group: /constant_data
│ Dimensions: (time: 364, covariate_dim: 7)
│ Coordinates:
│ * time (time) int64 3kB 0 1 2 3 4 5 6 ... 358 359 360 361 362 363
│ * covariate_dim (covariate_dim) int64 56B 0 1 2 3 4 5 6
│ Data variables:
│ covariates (time, covariate_dim) float32 10kB 0.0 0.0 ... -0.901 -0.0
│ Attributes:
│ created_at: 2026-11-04T10:04:08.635706+00:00
│ creation_library: ArviZ
│ creation_library_version: 1.3.1
│ creation_library_language: Python
│ sample_dims: []
├── Group: /predictions
│ Dimensions: (chain: 4, draw: 1000, time: 15, obs_dim: 1)
│ Coordinates:
│ * chain (chain) int64 32B 0 1 2 3
│ * draw (draw) int64 8kB 0 1 2 3 4 5 6 7 ... 993 994 995 996 997 998 999
│ * time (time) int64 120B 364 365 366 367 368 369 ... 374 375 376 377 378
│ * obs_dim (obs_dim) int64 8B 0
│ Data variables:
│ obs (chain, draw, time, obs_dim) int32 240kB 78 68 80 62 ... 91 71 72
│ Attributes:
│ created_at: 2026-11-04T10:04:09.353766+00:00
│ creation_library: ArviZ
│ creation_library_version: 1.3.1
│ creation_library_language: Python
│ sample_dims: ['chain', 'draw']
└── Group: /predictions_constant_data
Dimensions: (time: 15, covariate_dim: 7)
Coordinates:
* time (time) int64 120B 364 365 366 367 368 ... 374 375 376 377 378
* covariate_dim (covariate_dim) int64 56B 0 1 2 3 4 5 6
Data variables:
covariates (time, covariate_dim) float32 420B 4.152e-05 ... -0.0
Attributes:
created_at: 2026-11-04T10:04:09.354014+00:00
creation_library: ArviZ
creation_library_version: 1.3.1
creation_library_language: Python
sample_dims: []
The tree holds the posterior, the in-sample posterior predictive, the observed data and the forecast draws as separate groups. Next, we check the sampler diagnostics for the global parameters.
global_params = ["intercept", "beta", "elasticity", "drift_scale", "concentration"]
az.summary(idata, var_names=global_params)
| mean | sd | eti94_lb | eti94_ub | ess_bulk | ess_tail | r_hat | mcse_mean | mcse_sd | |
|---|---|---|---|---|---|---|---|---|---|
| intercept | 4.045 | 0.053 | 3.9 | 4.1 | 3133 | 2415 | 1.00 | 0.00095 | 0.00089 |
| beta[0] | 0.1581 | 0.0162 | 0.13 | 0.19 | 6090 | 3030 | 1.00 | 0.00021 | 0.00027 |
| beta[1] | 0.0203 | 0.0164 | -0.011 | 0.052 | 6768 | 3135 | 1.00 | 0.0002 | 0.00027 |
| beta[2] | -0.0589 | 0.0164 | -0.089 | -0.029 | 7214 | 2686 | 1.00 | 0.00019 | 0.0003 |
| beta[3] | 0.1054 | 0.0163 | 0.075 | 0.14 | 6982 | 2966 | 1.00 | 0.00019 | 0.0003 |
| beta[4] | 0.0149 | 0.0163 | -0.016 | 0.046 | 8202 | 2988 | 1.00 | 0.00018 | 0.00029 |
| beta[5] | -0.0113 | 0.0165 | -0.042 | 0.02 | 6269 | 2709 | 1.00 | 0.00021 | 0.0003 |
| elasticity | -1.495 | 0.085 | -1.7 | -1.3 | 4561 | 2947 | 1.00 | 0.0013 | 0.0012 |
| drift_scale | 0.012 | 0.0041 | 0.0057 | 0.021 | 1176 | 2243 | 1.01 | 0.00012 | 7.4e-05 |
| concentration | 29.81 | 3.3 | 24 | 36 | 4812 | 3172 | 1.00 | 0.048 | 0.056 |
The \(\hat{R}\) values are at most \(1.01\) and the effective sample sizes are above \(1{,}000\) for every parameter, which is good! Let’s also look at the traces.
num_divergences = int(mcmc.get_extra_fields()["diverging"].sum())
pc = az.plot_trace_dist(
idata, var_names=global_params, figure_kwargs={"figsize": (12, 10)}
)
pc.viz["figure"].item().suptitle(
f"Posterior Traces ({num_divergences} divergent transitions)",
fontsize=18,
fontweight="bold",
);
The chains mix well, and the title shows that the sampler had no divergent transitions. Next, we check that the posterior recovers the parameters we fixed in the generator. The vertical lines are the true values.
true_values = xr.Dataset(
{
"intercept": float(true_params["intercept"]),
"beta": (idata["posterior"]["beta"].dims[-1], true_params["beta"]),
"elasticity": float(true_params["elasticity"]),
"drift_scale": float(true_params["drift_scale"]),
"concentration": float(true_params["concentration"]),
}
)
pc = az.plot_dist(
idata,
var_names=global_params,
kind="kde",
visuals={
"point_estimate": False,
"point_estimate_text": False,
"credible_interval": False,
},
col_wrap=3,
figure_kwargs={"figsize": (14, 11)},
)
az.add_lines(
pc,
values=true_values,
aes_by_visuals={"ref_line": []},
visuals={
"ref_line": {"color": "C3", "linestyle": "-", "linewidth": 2, "zorder": 2}
},
)
pc.viz["figure"].item().suptitle(
"Posterior Against True Parameters", fontsize=18, fontweight="bold"
);
The true values sit inside the posterior mass for every parameter. The promotion weeks in the history identify the elasticity, so the price covariate is informative over the horizon.
Next, we compare the in-sample posterior predictive distribution with the observed series over the last \(90\) days of history. We also compute the continuous ranked probability score (CRPS) of the posterior predictive against the observed series over the whole training window, with crps_empirical from numpyro_forecast. The CRPS generalizes the absolute error to a predictive distribution. It is in the units of the data, and lower is better.
ppc_draws = idata["posterior_predictive"]["obs"].sel(obs_dim=0).values
crps_in_sample = crps_empirical(
jnp.asarray(ppc_draws.reshape(-1, setup.t_obs), dtype=jnp.float32),
data[:, 0].astype(jnp.float32),
).mean()
last_quarter = slice(setup.t_obs - 90, setup.t_obs)
ppc_tree = az.from_dict(
{
"posterior_predictive": {"obs": ppc_draws[:, :, last_quarter]},
"observed_data": {"obs": data[last_quarter, 0]},
"constant_data": {"day": days[last_quarter].astype(float)},
},
coords={"time": days[last_quarter]},
dims={"obs": ["time"], "day": ["time"]},
)
pc = az.plot_lm(
ppc_tree,
x="day",
y="obs",
y_obs="obs",
smooth=False,
ci_prob=(0.5, CI_PROB),
point_estimate="mean",
visuals={
"ci_band": {"color": "C0"},
"pe_line": {"color": "C0", "alpha": 1.0},
"observed_scatter": False,
},
figure_kwargs={"figsize": (12, 6)},
)
lighten_bands(pc, "day")
ax = pc.viz["plot"]["day"].item()
pc.viz["ci_band"]["day"].sel(prob=CI_PROB).item().set_label(f"{CI_PROB:.0%} ETI")
pc.viz["ci_band"]["day"].sel(prob=0.5).item().set_label("50% ETI")
pc.viz["pe_line"]["day"].item().set_label("Posterior predictive mean")
ax.plot(
days[last_quarter],
data[last_quarter, 0],
"o-",
markersize=3,
color="black",
label="Observed",
)
ax.set(xlabel="day", ylabel="units", title="")
fig = pc.viz["figure"].item()
fig.legend(loc="outside lower center", ncol=4)
fig.suptitle(
f"Posterior Predictive Check (in-sample CRPS {crps_in_sample:.1f} units)",
fontsize=18,
fontweight="bold",
);
The bands follow the weekly cycle and the promotion weeks, and the wide band covers \(85\) of the \(90\) observations, close to the nominal \(94\%\). The average CRPS of \(8.5\) units per day is small against a posterior predictive mean between \(44\) and \(132\) units. The model is a reasonable description of the history, so let’s move to the horizon.
Demand Scenarios
Now that we have a fitted model with the corresponding posterior predictive distribution, we can analyze the demand scenarios.
Data Preparation
The function to_datatree stores the forecast draws as obs in the predictions group, shaped (chain, draw, time, obs_dim). We flatten the sample dimensions into a scenario matrix of shape \((N, H)\), where each row is one joint draw of the \(15\) days. These rows are the scenarios of the Decision Problem section.
We then shuffle the rows and split them into two sets. The first \(1{,}000\) scenarios are the optimization set, and every plan is built on them. The remaining \(3{,}000\) scenarios are the evaluation set, which no plan sees.
We need the split because a plan fitted to a finite set of scenarios adapts to that set, so its loss on the same scenarios is usually optimistic. The held-out scenarios tell us how the plan does on new draws from the same forecast. We also keep the mean demand path of the optimization set, which several figures use.
Note that the plots keep the day index of the series, so the horizon runs from day \(364\) to day \(378\). This corresponds to the \(15\)-day horizon.
N_SCENARIOS_OPT = 1_000
scenarios = jnp.asarray(
idata["predictions"]["obs"].values.reshape(-1, setup.horizon), dtype=jnp.float32
)
rng_key, key_split = random.split(rng_key)
scenarios = scenarios[random.permutation(key_split, scenarios.shape[0])]
demand_opt, demand_eval = scenarios[:N_SCENARIOS_OPT], scenarios[N_SCENARIOS_OPT:]
mean_demand_opt = demand_opt.mean(axis=0)
Scenario Visualization
We plot the forecast as a fan over the horizon, next to the last \(60\) days of history. The shaded column is the promotion week in the horizon.
def shade_promotion(ax: plt.Axes) -> None:
"""Shade the promotion days of the horizon."""
ax.axvspan(
promo_days_future[0] - 0.5,
promo_days_future[-1] + 0.5,
color="C5",
alpha=0.25,
label="Promotion week",
)
forecast_tree = az.from_dict(
{
"posterior_predictive": {
"obs": idata["predictions"]["obs"].sel(obs_dim=0).values
},
"observed_data": {"obs": demand_future_true},
"constant_data": {"day": days_future},
},
coords={"time": days_future},
dims={"obs": ["time"], "day": ["time"]},
)
pc = az.plot_lm(
forecast_tree,
x="day",
y="obs",
y_obs="obs",
smooth=False,
ci_prob=(0.5, CI_PROB),
point_estimate="mean",
visuals={
"ci_band": {"color": "C2"},
"pe_line": {"color": "C2", "alpha": 1.0, "linewidth": 2},
"observed_scatter": False,
},
figure_kwargs={"figsize": (12, 6)},
)
lighten_bands(pc, "day")
ax = pc.viz["plot"]["day"].item()
pc.viz["ci_band"]["day"].sel(prob=CI_PROB).item().set_label(f"{CI_PROB:.0%} ETI")
pc.viz["ci_band"]["day"].sel(prob=0.5).item().set_label("50% ETI")
pc.viz["pe_line"]["day"].item().set_label("Forecast mean")
last_two_months = slice(setup.t_obs - 60, setup.t_obs)
ax.plot(
days[last_two_months],
data[last_two_months, 0],
"o-",
markersize=3,
color="C0",
label="Observed history",
)
ax.plot(
days_future,
demand_future_true,
"o-",
markersize=3,
color="C1",
label="True future",
)
ax.axvline(setup.t_obs - 0.5, color="black", linestyle="--", linewidth=1)
shade_promotion(ax)
ax.set(xlabel="day", ylabel="units", title="")
fig = pc.viz["figure"].item()
fig.legend(loc="outside lower center", ncol=3)
fig.suptitle("Forecast Fan for the Planning Horizon", fontsize=18, fontweight="bold");
The fan follows the weekly cycle and jumps in the promotion week because the model knows the price path. It widens slowly with the horizon because the level keeps drifting. The true future stays inside the wide band. Next, we look at \(30\) individual scenarios from the optimization set, the paths that the optimizer sees.
fig, ax = plt.subplots(figsize=(12, 6))
lines = ax.plot(days_future, demand_opt[:30].T, color="C2", alpha=0.3, linewidth=1)
lines[0].set_label("Scenario")
ax.plot(
days_future,
mean_demand_opt,
color="C2",
linewidth=3,
label="Forecast mean",
)
shade_promotion(ax)
fig.legend(loc="outside lower center", ncol=3)
ax.set(xlabel="day", ylabel="units")
fig.suptitle("Joint Demand Scenarios", fontsize=18, fontweight="bold");
Each path is one joint draw of the \(15\) days. A plan is scored against each path, and the decision rule aggregates the scores.
The paths have to be joint draws for two reasons. First, the stock that a scenario carries into day \(t\) is what is left of the earlier days of the same scenario. Second, the days of one path share the same level and the same parameters.
Two consecutive days are only weakly linked, but the stock carried into day \(t\) depends on the demand of all the earlier days, and a path with high demand on the earlier days tends to have high demand on day \(t\) too. In the Out of Sample Evaluation section we check what happens to a plan built on scenarios without this link.
Inventory Simulator
We now turn the loss \(L(a, D)\) and the plans into code. In this section we write the forward pass of the Decision Rules section, with an order rule for each plan that the forward pass builds, and the simulator.
The forward pass carries the stock of every scenario through the days of the horizon and asks an order rule for the order of each day. On each day, the rule of the chance-constrained plan returns the order that covers the shortfall in a given fraction of the scenarios. The rule of the mean forecast plan returns the shortfall of the mean path.
The simulator runs a plan against a demand path and returns the loss.
To make these more tangible, we build the mean forecast plan and test the simulator on it.
Let’s start with the forward pass. The function forward_pass walks through the days of the horizon inside all the scenarios at once. On day \(t\) it computes the shortfall \(D_t - I_{t-1}\) of every scenario, hands the vector of shortfalls to the order rule, clips the order at zero, and carries the leftover stock of every scenario into the next day. Recall that this is a computation on the scenarios, not a sequence of decisions on observed sales: the function returns the full vector of \(15\) orders, and we place them all today.
The order rule is a function that maps the shortfalls across the scenarios to one order, and the OrderRule protocol fixes this signature. We write two rules. The function quantile_shortfall(prob) returns the rule that orders the prob quantile of the shortfall, which gives the chance-constrained plan at prob = 1 - ALPHA and the myopic newsvendor plan at the critical fractile. The function follow_path orders the shortfall of a single path, which gives the mean forecast plan on the mean path.
Remark: The function forward_pass is in essence a “for loop”. In JAX, we use jax.lax.scan to make this computation more efficient. For details about scan, see the blog post Notes on Exponential Smoothing with NumPyro. Note that there is no benefit in decorating the inner step function with jax.jit: scan traces step once and runs the whole loop as a single compiled computation, so an inner jit is absorbed into it. The same holds for the simulator below, which we compile as a whole with jax.jit after vectorizing it over scenarios.
We can now reason about this process for the mean forecast plan. Observe that the mean forecast plan is simply the forward pass with the rule follow_path on a single path, the mean of the optimization scenarios. Against the mean path the plan leaves nothing on the shelf: the initial stock of \(50\) units covers part of day one, the first order covers the rest, and from day two on each order equals the mean demand of its day.
@runtime_checkable
class OrderRule(Protocol):
"""Maps the shortfall D_t - I_{t-1} of every scenario to the order of day t."""
def __call__(self, shortfall: Float[Array, " scenarios"]) -> ScalarLike: ...
def follow_path(shortfall: Float[Array, " 1"]) -> ScalarLike:
"""Order the shortfall of a single demand path, here the mean path."""
return shortfall[0]
def quantile_shortfall(prob: float) -> OrderRule:
"""The rule that orders the `prob` quantile of the shortfall across the scenarios.
The `method="inverted_cdf"` returns the smallest value with at least a fraction
`prob` of the scenarios at or below it, so on every day the plan covers the
shortfall of at least a fraction `prob` of the scenarios with the smallest
possible order.
"""
def rule(shortfall: Float[Array, " scenarios"]) -> ScalarLike:
return jnp.quantile(shortfall, prob, method="inverted_cdf")
return rule
def forward_pass(
demand: Float[Array, "scenarios horizon"],
order_rule: OrderRule,
config: InventoryConfig,
) -> Float[Array, " horizon"]:
"""Daily orders from an order rule applied to the shortfall of every day.
Parameters
----------
demand: Float[Array, "scenarios horizon"]
The demand path of the scenarios.
order_rule: OrderRule
Maps the shortfall D_t - I_{t-1} across the scenarios to the order of day t.
config: InventoryConfig
The inventory configuration.
Returns
-------
orders: Float[Array, "horizon"]
The orders of the plan.
"""
def step(
inventory: Float[Array, " scenarios"], demand_t: Float[Array, " scenarios"]
) -> tuple[Float[Array, " scenarios"], ScalarLike]:
shortfall = demand_t - inventory
order_t = jnp.maximum(order_rule(shortfall), 0.0)
inventory = jnp.maximum(inventory + order_t - demand_t, 0.0)
return inventory, order_t
initial_inventory = jnp.full(
demand.shape[0], config.initial_inventory, dtype=demand.dtype
)
_, orders = jax.lax.scan(step, initial_inventory, demand.T)
return orders
orders_mean = forward_pass(mean_demand_opt[None, :], follow_path, config)
assert orders_mean.shape == (setup.horizon,)
Next, let’s write the simulator. Each step sells what it can, records the lost sales and the leftover, and charges the two costs. The scan returns a Simulation tuple with the loss, the stock path and the lost sales path, since the evaluation needs all three.
To make the computation more efficient, we vectorize over scenarios with jax.vmap with in_axes=(None, 0, None): the plan is shared, the demand path is batched.
We test the simulator by scoring the mean forecast plan on the optimization scenarios. On the mean path itself the loss of this plan is zero by construction, as we said above: the stock ends every day at zero and no sale is lost. The mean path is one path, and we score the plan on each of the \(1{,}000\) scenarios one at a time. In any one scenario the demand of a day is above or below the mean demand of that day. If it is below, part of the order sized for the mean is left over and pays holding cost. If it is above, the plan can run out and pay the margin on the lost sales. So the loss is positive in every scenario, and so is its average over the scenarios.
class Simulation(NamedTuple):
"""Loss, stock path and lost sales path of one plan against one demand path."""
loss: ScalarLike
inventory: Float[Array, " horizon"]
lost_sales: Float[Array, " horizon"]
def simulate(
orders: Float[Array, " horizon"],
demand: Float[Array, " horizon"],
config: InventoryConfig,
) -> Simulation:
"""Run the inventory dynamics of one plan against one demand path."""
def step(
inventory: ScalarLike, inputs: tuple[ScalarLike, ScalarLike]
) -> tuple[ScalarLike, tuple[ScalarLike, ScalarLike, ScalarLike]]:
order_t, demand_t = inputs
available = inventory + order_t # y_t
sales_t = jnp.minimum(available, demand_t)
lost_t = demand_t - sales_t
inventory = available - sales_t
cost_t = config.holding_cost * inventory + config.lost_sales_cost * lost_t
return inventory, (inventory, lost_t, cost_t)
initial_inventory = jnp.asarray(config.initial_inventory, dtype=orders.dtype)
_, (inventory_path, lost_path, cost_path) = jax.lax.scan(
step, initial_inventory, (orders, demand)
)
return Simulation(cost_path.sum(), inventory_path, lost_path)
# one plan against a batch of demand paths
simulate_scenarios = jax.jit(
jax.vmap(simulate, in_axes=(None, 0, None)), static_argnames="config"
)
losses_mean_plan = simulate_scenarios(orders_mean, demand_opt, config).loss
We summarize the loss of a plan over the scenarios with the two quantities of the Decision Rules section, the expected loss and the conditional value at risk.
The expected loss is a plain mean.
The conditional value at risk needs a helper function: with \(N\) equally weighted scenarios it is the mean of the \(k = \alpha N\) largest losses, which
jax.lax.top_kselects in one call.
We use cvar to score plans here and inside the objective of the CVaR plan in the next section.
def tail_size(num_scenarios: int) -> int:
"""The integer k = ALPHA * N, static because top_k needs a Python integer."""
return max(1, round(ALPHA * num_scenarios))
def cvar(losses: Float[Array, " scenarios"]) -> ScalarLike:
"""Mean of the k largest losses."""
worst, _ = jax.lax.top_k(losses, tail_size(losses.shape[0]))
return worst.mean()
Let’s plot the distribution of the loss of the mean forecast plan and mark the expected loss, the value at risk and the conditional value at risk on it.
pc = az.plot_dist(
xr.Dataset({"loss": (("sample",), losses_mean_plan)}),
sample_dims=["sample"],
kind="kde",
visuals={
"point_estimate": False,
"point_estimate_text": False,
"credible_interval": False,
},
figure_kwargs={"figsize": (10, 5)},
)
ax = pc.viz["plot"]["loss"].item()
ax.axvline(
float(losses_mean_plan.mean()), color="black", linewidth=2, label="expected loss"
)
ax.axvline(
float(jnp.quantile(losses_mean_plan, 1 - ALPHA, method="inverted_cdf")),
color="C1",
linewidth=2,
label="VaR",
)
ax.axvline(float(cvar(losses_mean_plan)), color="C3", linewidth=2, label="CVaR")
ax.set(xlabel="loss on one scenario", title="")
fig = pc.viz["figure"].item()
fig.legend(loc="outside lower center", ncol=3)
fig.suptitle("Loss of the Mean Forecast Plan", fontsize=18, fontweight="bold");
The loss distribution has the shape of the concept plot above: most scenarios cost little, and a long right tail of high-demand scenarios pushes the tail average far beyond the expected loss. The plan that costs nothing on the mean path has an expected loss of \(170\) over the scenarios, a value at risk of \(408\) and a conditional value at risk of \(523\), three times the expected loss.
Decision Plans
We now build the three remaining plans. The chance-constrained plan comes from the forward pass with the quantile level \(1 - \alpha\). The expected cost plan and the CVaR plan have no closed form, so we write a solver in JAX. Along the way we build the myopic newsvendor plan as a reference for the expected cost plan.
Chance-Constrained Orders
We run the forward pass on the joint scenarios with the quantile level \(1 - \alpha\). Then we check the guarantee of the quantile on the \(N = 1{,}000\) optimization scenarios: on every day, the number of scenarios with a lost sale is at most \(\alpha N = 50\).
Demand comes in whole units, while the orders of the expected cost plan below land up to a few ten-thousandths of a unit away from whole numbers. Without a tolerance, a day where the demand matches the intended stock can count as a stockout, so we count a stockout only when the lost sales exceed \(10^{-3}\) units.
STOCKOUT_TOL = 1e-3 # lost sales below this are rounding noise of the solver
def is_stockout(sim: Simulation) -> Bool[Array, "*batch horizon"]:
"""Days with lost sales above the tolerance."""
return sim.lost_sales > STOCKOUT_TOL
orders_cc = forward_pass(demand_opt, quantile_shortfall(1 - ALPHA), config)
stockout_scenarios_cc = is_stockout(
simulate_scenarios(orders_cc, demand_opt, config)
).sum(axis=0)
assert int(stockout_scenarios_cc.max()) <= tail_size(N_SCENARIOS_OPT)
Expected Cost Orders
The expected cost plan and the CVaR plan minimize the same objective, \(\text{E}[L] + \lambda \, \text{CVaR}_\alpha(L)\), with \(\lambda = 0\) for the first and \(\lambda = 1\) for the second, so one solver covers both. The objective is convex in \(a\), which we show in the Appendix, and the only constraint is \(a \geq 0\).
We use projected gradient descent with Adam. Each step moves the orders a step down the gradient of the objective and then clips them at zero, which is the Euclidean projection onto the feasible set. The loop is a jax.lax.scan inside jax.jit, so later on a sweep over several values of \(\lambda\) is a single jax.vmap.
The objective is piecewise linear, so its gradient is a subgradient, and a step size that does not shrink keeps crossing the kinks. We use a cosine decay schedule that takes the step size from one unit to zero over the \(1{,}500\) steps of the run, so the early steps move fast and the late steps settle the iterates.
The function returns the orders, the trace of the objective and the step size at every step. We start from the mean forecast plan and solve with \(\lambda = 0\).
def objective(
orders: Float[Array, " horizon"],
demand: Float[Array, "scenarios horizon"],
lam: ScalarLike,
config: InventoryConfig,
) -> ScalarLike:
"""Expected loss plus lam times the CVaR of the loss over the scenarios."""
losses = simulate_scenarios(orders, demand, config).loss
return losses.mean() + lam * cvar(losses)
def solve(
demand: Float[Array, "scenarios horizon"],
lam: ScalarLike,
orders_init: Float[Array, " horizon"],
config: InventoryConfig,
n_steps: int = 1_500,
learning_rate: float = 1.0,
) -> tuple[
Float[Array, " horizon"], Float[Array, " n_steps"], Float[Array, " n_steps"]
]:
"""Projected Adam on the expected cost plus lam times CVaR, as a lax.scan."""
# the step size decays from learning_rate to zero along a cosine curve
schedule = optax.cosine_decay_schedule(
init_value=learning_rate, decay_steps=n_steps
)
optimizer = optax.adam(learning_rate=schedule)
value_and_grad = jax.value_and_grad(objective)
def step(
carry: tuple[Float[Array, " horizon"], optax.OptState], _: None
) -> tuple[tuple[Float[Array, " horizon"], optax.OptState], ScalarLike]:
orders, opt_state = carry
value, grads = value_and_grad(orders, demand, lam, config)
updates, opt_state = optimizer.update(grads, opt_state, orders)
orders = jnp.maximum(orders + updates, 0.0) # projection onto a >= 0
return (orders, opt_state), value
(orders, _), values = jax.lax.scan(
step, (orders_init, optimizer.init(orders_init)), None, length=n_steps
)
step_sizes = jax.vmap(schedule)(jnp.arange(n_steps))
return orders, values, step_sizes
solve_jit = jax.jit(solve, static_argnames=("config", "n_steps"))
orders_saa, trace_saa, step_sizes_saa = solve_jit(demand_opt, 0.0, orders_mean, config)
We also build the myopic newsvendor plan with the forward pass at the critical fractile and compare the two plans.
orders_newsvendor = forward_pass(
demand_opt, quantile_shortfall(critical_fractile), config
)
cost_saa = objective(orders_saa, demand_opt, 0.0, config)
cost_newsvendor = objective(orders_newsvendor, demand_opt, 0.0, config)
fig, ax = plt.subplots()
ax.plot(
days_future,
orders_saa,
"o-",
color="C2",
label=f"Expected cost plan, in-sample expected loss {cost_saa:.0f}",
)
ax.plot(
days_future,
orders_newsvendor,
"s--",
color="black",
label=f"Myopic newsvendor plan, in-sample expected loss {cost_newsvendor:.0f}",
)
shade_promotion(ax)
fig.legend(loc="outside lower center", ncol=2)
ax.set(xlabel="day", ylabel="units ordered")
fig.suptitle(
"Expected Cost Plan Against the Myopic Newsvendor Plan",
fontsize=18,
fontweight="bold",
);
The two plans follow the same shape, and the myopic plan orders more on \(12\) of the \(15\) days and in total, \(1{,}410\) units against \(1{,}355\). The extra stock costs \(11\%\) in expected loss, \(158\) against \(143\) on the optimization scenarios.
Here is the intuition for the larger orders of the myopic plan. The single-period rule charges a leftover unit one day of holding cost. In the \(15\)-day problem a leftover unit stays on the shelf and pays \(h\) on every later day until it sells. It is also left over exactly in the slow scenarios, where the level tends to be low, so it takes several days to sell. The optimizer sees this extra cost and orders less.
The optimizer orders more than the myopic rule on the last two days only, by \(5\) and \(7\) units. There, few later days remain, so both plans want a similar stock available, and the optimizer carries less stock into those days.
CVaR Orders
Now we solve the CVaR plan with \(\lambda = 1\): one unit of average loss in the worst \(5\%\) of scenarios counts as much as one unit of expected loss.
orders_cvar, trace_cvar, _ = solve_jit(demand_opt, 1.0, orders_mean, config)
Here are the optimizer traces for both solves: the objective on top and the step size of the schedule below it.
steps = np.arange(1, trace_saa.shape[0] + 1)
fig, axes = plt.subplots(nrows=2, figsize=(10, 7), sharex=True, layout="constrained")
ax = axes[0]
ax.plot(steps, trace_saa, color="C2", label=r"$\lambda = 0$ (expected cost)")
ax.plot(steps, trace_cvar, color="C3", label=r"$\lambda = 1$ (expected cost + CVaR)")
ax.set(ylabel="objective", xscale="log")
ax = axes[1]
ax.plot(steps, step_sizes_saa, color="black") # the schedule is the same for both
ax.set(xlabel="step", ylabel="step size (both solves)")
fig.legend(loc="outside lower center", ncol=2)
fig.suptitle("Optimizer Traces", fontsize=18, fontweight="bold");
Both objectives drop within the first \(50\) steps and change by less than \(1\%\) after that, settling at \(143\) for the expected cost and \(469\) for the expected cost plus CVaR. The step size decays to zero over the run, so the iterates stop moving while the objective is flat, and the Appendix checks that these values are the optima.
We now have the four plans, so let’s collect them in a list of Plan tuples and compare them. We fix one color per plan and use it in every figure that follows. The shaded column is again the promotion week.
class Plan(NamedTuple):
"""One order plan with its name."""
name: str
orders: Float[Array, " horizon"]
plans = [
Plan("Mean forecast", orders_mean),
Plan("Chance constrained", orders_cc),
Plan("Expected cost", orders_saa),
Plan("CVaR", orders_cvar),
]
plan_names = [plan.name for plan in plans]
PLAN_COLORS = {name: f"C{i}" for i, name in enumerate(plan_names)}
orders_long = pl.DataFrame(
{
"plan": np.repeat(plan_names, setup.horizon),
"day": np.tile(days_future, len(plans)),
"orders": np.concatenate([plan.orders for plan in plans]),
}
)
fig, ax = plt.subplots(figsize=(12, 7))
sns.lineplot(
data=orders_long,
x="day",
y="orders",
hue="plan",
hue_order=plan_names,
palette=PLAN_COLORS,
marker="o",
ax=ax,
)
ax.plot(
days_future,
mean_demand_opt,
color="black",
linestyle="--",
label="Mean demand",
)
shade_promotion(ax)
ax.get_legend().remove()
fig.legend(loc="outside lower center", ncol=3)
ax.set(xlabel="day", ylabel="units ordered")
fig.suptitle("Order Plans", fontsize=18, fontweight="bold");
Here is what we see.
Mean forecast. The plan follows the mean demand. Its first order is \(23\) units, because the \(50\) units of initial stock cover most of the mean demand of \(73\) on day one.
Buffers. The three other plans order a buffer above the mean demand, and every plan orders more in the promotion week. On day one the three plans order \(44\) to \(54\) units, which is below the mean demand of \(73\) and looks like no buffer at all. The buffer is in the stock available, which is the initial stock plus the order: \(94\) to \(104\) units on day one, so \(21\) to \(31\) units above the mean demand. On day one the initial stock provides part of the buffer. From day two on, the chance-constrained plan orders above the mean demand on every day, the expected cost and CVaR plans do so on all days but one, and the stock carried over adds to the buffer.
Chance constrained. This plan orders the most in the first week and carries the largest cumulative order through day \(11\).
Expected cost. This plan orders the least of the three buffered plans, \(1{,}355\) units against \(1{,}406\) and \(1{,}431\), and stays closest to the mean demand.
CVaR. This plan orders the most in the second week, where the high-demand scenarios of the tail separate from the rest, and from day \(12\) on its cumulative orders are the largest of the four. Its path is less smooth than the chance-constrained one, because the \(50\) scenarios of the tail shape it.
Out of Sample Evaluation
We score every plan on the \(3{,}000\) held-out scenarios with four metrics:
Expected loss. This is the mean of the loss over the held-out scenarios, the first quantity of the Decision Rules section.
CVaR of the loss. This is the second quantity of that section. Recall that it is the mean of the worst \(5\%\) of the scenario losses, the tail average. The expected loss says nothing about how bad the bad scenarios are. This is the number to look at when the worst weeks matter more than the average week.
Stockout probability per day. This is the fraction of scenario and day pairs with a lost sale, which is what the chance constraint controls.
Probability of a stockout in the horizon. This is the fraction of scenarios with at least one lost sale in the \(15\) days.
We also record a fifth quantity for the Pareto Frontier section: the share of the worst \(5\%\) of scenarios whose total demand is below the median, which tells us whether the tail is made of high-demand or of low-demand scenarios.
We name the metrics in a StrEnum, so that the same strings serve as dictionary keys, column names and axis labels.
class Metric(StrEnum):
"""Names of the held-out scores."""
EXPECTED_LOSS = "expected loss"
CVAR = f"CVaR of loss (worst {ALPHA:.0%})"
STOCKOUT = "stockout probability per day"
HORIZON = "probability of a stockout in the horizon"
TAIL_SHARE = "low-demand share of the tail"
def summarize(
sim: Simulation, demand: Float[Array, "scenarios horizon"]
) -> dict[Metric, float]:
"""Expected loss, CVaR, stockout frequencies and tail composition of one plan."""
stockout = is_stockout(sim) # a day with lost sales above the tolerance
_, worst = jax.lax.top_k(sim.loss, tail_size(sim.loss.shape[0]))
total_demand = demand.sum(axis=1)
return {
Metric.EXPECTED_LOSS: float(sim.loss.mean()),
Metric.CVAR: float(cvar(sim.loss)),
Metric.STOCKOUT: float(stockout.mean()),
Metric.HORIZON: float(stockout.any(axis=1).mean()),
Metric.TAIL_SHARE: float(
(total_demand[worst] < jnp.median(total_demand)).mean()
),
}
scores = {
plan.name: simulate_scenarios(plan.orders, demand_eval, config) for plan in plans
}
results = pl.DataFrame(
[{"plan": name, **summarize(sim, demand_eval)} for name, sim in scores.items()]
)
metrics = [Metric.EXPECTED_LOSS, Metric.CVAR, Metric.STOCKOUT, Metric.HORIZON]
# the metrics that are probabilities or shares, which get percent ticks in the figures
PROBABILITY_METRICS = {Metric.STOCKOUT, Metric.HORIZON, Metric.TAIL_SHARE}
Each of these scores is a single number per plan, an average over the \(3{,}000\) held-out scenarios. Recall that every scenario is one draw from the posterior predictive distribution of the demand path, so the loss of a plan over the scenarios is the posterior predictive distribution of its loss. Before we compress each plan into averages, let’s look at that distribution, which tells us how bad a bad scenario is under each plan.
The next figure shows, for each plan, the mean and the central \(50\%\) and \(94\%\) of the loss over the held-out scenarios.
losses_by_plan = {
name: xr.Dataset({"loss": (("sample",), sim.loss)}) for name, sim in scores.items()
}
pc = az.plot_forest(
losses_by_plan,
sample_dims=["sample"],
color=[PLAN_COLORS[name] for name in plan_names],
labels=[],
figure_kwargs={"figsize": (10, 5)},
)
pc.viz["plot"].sel(column="labels").item().remove() # the empty label column
ax = pc.viz["plot"].sel(column="forest").item()
ax.set_subplotspec(ax.get_gridspec()[0, :]) # let the forest span the full width
ax.set(xlabel="loss on one scenario", yticks=[])
fig = pc.viz["figure"].item()
pc.add_legend("model", title="", loc="outside lower center", ncol=4)
fig.suptitle(
"Loss Distributions on Held-Out Scenarios", fontsize=18, fontweight="bold"
);
The dots are the means and the thick and thin bars the \(50\%\) and \(94\%\) intervals. Here is what we see.
Mean forecast. The plan has the highest mean and by far the highest upper end, \(454\) at the \(97\)th percentile against \(322\) or less for the other three.
Chance constrained. The plan has a lower end of \(58\) and an upper end of \(301\), the second highest lower end and the second lowest upper end of the four. Most of its loss is holding cost, \(134\) of the \(156\) on average, since it carries the largest stock through day \(11\).
Expected cost. The plan has the lowest mean, \(142\), and a lower end of \(49\), close to the \(47\) of the mean forecast plan. Its \(97\)th percentile, \(322\), is the highest of the three buffered plans.
CVaR. The plan has the lowest upper end, \(285\), and pays for it with the highest lower end, \(60\) at the \(3\)rd percentile, since it carries the most stock in the last days.
The upper ends sit much further from the means than the lower ends, for two reasons.
Cost asymmetry. This is the daily cost plot of the Decision Problem section. A scenario with demand above the plan pays about \(p = 2\) per unit short. A scenario with the same gap below the plan pays \(h = 0.1\) per unit left over and per day. So the loss grows much faster on the high-demand side of the plan than on the low-demand side.
Right skew of the scenarios. The forecasting model is log-linear: the expected demand is \(\mu_t = \exp(\beta_0 + \ell_t + \dots)\), so the level \(\ell_t\) multiplies the demand instead of adding to it. The uncertainty about the level is symmetric on the log scale, and the exponential turns it into a right-skewed uncertainty in units. For example, a level \(0.2\) above its center multiplies the demand by \(e^{0.2} \approx 1.22\), while a level \(0.2\) below it multiplies the demand by \(e^{-0.2} \approx 0.82\). So the high-demand scenarios sit further from the central path than the low-demand ones.
Let’s confirm this by plotting the loss of every held-out scenario against its total demand over the horizon.
scatter_long = pl.DataFrame(
{
"plan": np.repeat(plan_names, demand_eval.shape[0]),
"total demand": np.tile(demand_eval.sum(axis=1), len(plans)),
"loss": np.concatenate([sim.loss for sim in scores.values()]),
}
)
fig, ax = plt.subplots()
sns.scatterplot(
data=scatter_long,
x="total demand",
y="loss",
hue="plan",
hue_order=plan_names,
palette=PLAN_COLORS,
s=8,
alpha=0.3,
ax=ax,
)
ax.get_legend().remove()
legend = fig.legend(loc="outside lower center", ncol=4, markerscale=3)
for handle in legend.legend_handles:
handle.set_alpha(1.0)
ax.set(xlabel="total demand over the horizon (held-out scenario)", ylabel="loss")
fig.suptitle("Loss Against Total Demand", fontsize=18, fontweight="bold");
Each plan traces a V shape. On the left arm we pay holding costs on stock that is never sold, with a slope of about \(0.6\) per unit of missing demand. On the right arm we pay the margin on lost sales, with a slope of about \(1.7\) to \(1.8\) per unit of excess demand, a little below \(p = 2\) because part of the excess lands on days with stock left over.
The buffered plans move the bottom of the V to the right, so fewer scenarios reach the steep arm. The mean forecast plan has its bottom at the mean demand, so about half of the scenarios are on the steep arm.
Next, let’s look at the four scores of each plan in one figure. The dashed line is the myopic newsvendor plan of the Decision Plans section, which we score on the same held-out scenarios as a reference.
plan_labels = [name.replace(" ", "\n") for name in plan_names]
# the myopic newsvendor plan of the Decision Plans section, as a reference line
newsvendor_scores = summarize(
simulate_scenarios(orders_newsvendor, demand_eval, config), demand_eval
)
fig, axes = plt.subplots(
nrows=2, ncols=2, figsize=(12, 8), sharex=True, layout="constrained"
)
for ax, metric in zip(axes.flat, metrics, strict=True):
sns.barplot(
data=results,
x="plan",
y=metric,
hue="plan",
hue_order=plan_names,
palette=PLAN_COLORS,
legend=False,
ax=ax,
)
reference = ax.axhline(
newsvendor_scores[metric],
color="black",
linestyle="--",
label="Myopic newsvendor plan",
)
if metric in PROBABILITY_METRICS:
ax.yaxis.set_major_formatter(PercentFormatter(xmax=1, decimals=0))
ax.set(title=metric, xlabel="", ylabel="")
for ax in axes[-1]:
ax.set_xticks(range(len(plans)), plan_labels)
fig.legend(handles=[reference], loc="outside lower center")
fig.suptitle("Held-Out Scores by Plan", fontsize=18, fontweight="bold");
Here are three important remarks.
Point forecast. The mean forecast plan has the worst expected loss (\(172\)) and the worst tail (\(508\)), and it loses sales on a quarter of the days. Every plan that uses the distribution beats it on every metric.
Single-period fractile. The expected cost plan runs out on \(10.4\%\) of the days, more than twice the \(1 - p / (p + h) = 4.8\%\) of the single-period rule. This is the multi-period effect of the Decision Rules section: the optimum holds less stock than the newsvendor rule. The myopic newsvendor plan costs \(11\%\) more in expected loss on the held-out scenarios, \(157\) against \(142\), and runs out on \(4.4\%\) of the days.
Service level and tail. The chance-constrained plan lands at \(4.7\%\) per day on the held-out scenarios, close to its \(5\%\) target. Its probability of at least one stockout in the horizon is \(25\%\), well below the \(54\%\) of independent days. The CVaR plan has the lowest tail loss of the four, \(313\), at an expected loss of \(153\) and a stockout probability per day of \(7.0\%\).
The chance constraint holds day by day, so we look at the stockout probability on each day of the horizon. The dashed line is the target \(\alpha = 0.05\).
stockout_long = pl.DataFrame(
{
"plan": np.repeat(plan_names, setup.horizon),
"day": np.tile(days_future, len(plans)),
"stockout probability": np.concatenate(
[is_stockout(sim).mean(axis=0) for sim in scores.values()]
),
}
)
fig, ax = plt.subplots(figsize=(12, 6))
sns.lineplot(
data=stockout_long,
x="day",
y="stockout probability",
hue="plan",
hue_order=plan_names,
palette=PLAN_COLORS,
marker="o",
ax=ax,
)
ax.axhline(ALPHA, color="black", linestyle="--", label=r"target $\alpha$")
shade_promotion(ax)
ax.get_legend().remove()
fig.legend(loc="outside lower center", ncol=3)
ax.yaxis.set_major_formatter(PercentFormatter(xmax=1, decimals=0))
ax.set(xlabel="day", ylabel="stockout probability")
fig.suptitle("Stockout Probability by Day", fontsize=18, fontweight="bold");
Here is what we see, plan by plan.
Mean forecast. The plan loses sales in \(44\%\) of the scenarios on day one and in \(16\%\) to \(35\%\) of them afterwards, as the leftovers of the days with low demand build up a small buffer.
Chance constrained. The plan stays between \(3.1\%\) and \(5.9\%\) on every day, close to the target on each of them. The quantile guarantees at most \(5\%\) only on the optimization scenarios. With \(1{,}000\) of them, the in-sample frequency of a day estimates the stockout probability with a standard deviation of \(\sqrt{0.05 \cdot 0.95 / 1{,}000} \approx 0.007\), about \(0.7\) percentage points, so held-out values up to about \(6\%\) are within the sampling error.
Expected cost. The plan sits between \(6\%\) and \(15\%\) until the last day, where it drops to \(5.0\%\), close to the single-period level of \(4.8\%\).
CVaR. The plan runs out in \(9\%\) to \(13\%\) of the scenarios on the first six days and in \(2\%\) to \(3\%\) on the last four. The tail objective looks at the high-demand horizons, which separate from the rest only after several days, so the plan adds its stock late.
Next, let’s see why the scenarios have to be joint draws. In practice a forecast is often delivered as a distribution for each day on its own, for example as per-day quantiles. One could build scenarios from such a forecast by drawing each day from its own distribution. Those scenarios have the right distribution on every single day, but no link between the days. In the simulator the days are linked by the stock that carries over, so the question is what a plan loses when the scenarios lack that link.
We can produce such scenarios from the joint ones. We shuffle each day of the optimization matrix independently across scenarios, which keeps the distribution of every single day and removes the correlation across days. We build the chance-constrained plan on the shuffled scenarios and score it on the held-out scenarios, next to the plan built on the joint ones.
rng_key, key_shuffle = random.split(rng_key)
shuffle_each_day = jax.vmap(random.permutation, in_axes=(0, 1), out_axes=1)
demand_independent = shuffle_each_day(
random.split(key_shuffle, setup.horizon), demand_opt
)
orders_cc_independent = forward_pass(
demand_independent, quantile_shortfall(1 - ALPHA), config
)
sim_cc_independent = simulate_scenarios(orders_cc_independent, demand_eval, config)
sim_cc_joint = simulate_scenarios(orders_cc, demand_eval, config)
fig, ax = plt.subplots(figsize=(12, 6))
ax.plot(
days_future,
is_stockout(sim_cc_joint).mean(axis=0),
"o-",
color="C1",
label="Built on the joint scenarios",
)
ax.plot(
days_future,
is_stockout(sim_cc_independent).mean(axis=0),
"s--",
color="C4",
label="Built on days shuffled independently",
)
ax.axhline(ALPHA, color="black", linestyle="--", label=r"target $\alpha$")
shade_promotion(ax)
fig.legend(loc="outside lower center", ncol=2)
ax.yaxis.set_major_formatter(PercentFormatter(xmax=1, decimals=0))
ax.set(xlabel="day", ylabel="stockout probability")
fig.suptitle(
"Chance-Constrained Plan With and Without Joint Scenarios",
fontsize=18,
fontweight="bold",
);
The plan built on the shuffled scenarios sits above the target on every day but the first, between \(5\%\) and \(7\%\) in the first week, between \(7\%\) and \(10\%\) in the promotion week, and at \(10\%\) on the last day, for an average of \(7.1\%\) against \(4.7\%\).
It orders \(41\) units less over the horizon. The shuffle breaks the link between the past demand of a path, which sets the stock it carries into day \(t\), and its demand on day \(t\). So the shortfall \(D_t - I_{t-1}\) varies less across the shuffled scenarios, its \(95\%\) quantile is lower, and the gap to the target grows with the day, since the stock carried into a later day depends on more past days.
Next, let’s look at the stock on hand, the units left on the shelf at the end of each day (\(I_t\)), under each plan. We draw one panel per plan, with the \(50\%\) and \(94\%\) bands of the stock over the held-out scenarios. The black line is the stock that each plan leaves at the end of each day when the demand is the true future path, the \(15\) days we held out when we generated the data. We score the plans on that path right after this figure.
One feature of these bands needs an explanation. On a given day the shelf is empty in some of the scenarios, and in those the stock on hand is exactly zero. The share of such scenarios lies between \(2\%\) and \(44\%\), depending on the plan and the day. So the distribution of the stock on a given day is a mixture of a point mass at zero, from the scenarios with an empty shelf, and a continuous part, from the scenarios with leftovers. The point mass sits at the lower end of the distribution, so it pulls the lower edges of the bands down to zero, while the upper edges follow the scenarios with leftovers.
The lower edge of the \(94\%\) band is the \(3\)rd percentile of the stock. When more than \(3\%\) of the scenarios have an empty shelf on a day, that edge is zero on that day.
realized_paths = {
plan.name: simulate(plan.orders, demand_future_true, config) for plan in plans
}
inventory_tree = az.from_dict(
{
"posterior_predictive": {
"inventory": np.stack([sim.inventory for sim in scores.values()], axis=1)[
None
]
},
"observed_data": {
"inventory": np.stack([sim.inventory for sim in realized_paths.values()])
},
"constant_data": {"day": np.tile(days_future.astype(float), (len(plans), 1))},
},
coords={"time": days_future, "plan": plan_names},
dims={"inventory": ["plan", "time"], "day": ["plan", "time"]},
)
pc = az.plot_lm(
inventory_tree,
x="day",
y="inventory",
y_obs="inventory",
plot_dim="time",
smooth=False,
ci_prob=(0.5, CI_PROB),
point_estimate="mean",
aes={"color": ["plan"]},
aes_by_visuals={"pe_line": ["color"]},
color=[PLAN_COLORS[name] for name in plan_names],
visuals={
"pe_line": {"alpha": 1.0},
"observed_scatter": False,
"xlabel": False,
"ylabel": False,
},
col_wrap=2,
figure_kwargs={"figsize": (12, 8), "sharex": True, "sharey": True},
)
lighten_bands(pc, "day")
for i, name in enumerate(plan_names):
ax = pc.get_target("day", {"plan": name})
bands = pc.viz["ci_band"]["day"].sel(plan=name)
bands.sel(prob=0.5).item().set_label("50% ETI")
bands.sel(prob=CI_PROB).item().set_label(f"{CI_PROB:.0%} ETI")
pc.viz["pe_line"]["day"].sel(plan=name).item().set_label("Mean")
ax.plot(
days_future,
realized_paths[name].inventory,
"o-",
markersize=3,
color="black",
label="Stock under the true future demand",
)
ax.set(
title=name,
xlabel="day" if i >= 2 else "",
ylabel="stock on hand" if i % 2 == 0 else "",
)
ax.legend(loc="upper left")
fig = pc.viz["figure"].item()
fig.suptitle("Stock on Hand Under Each Plan", fontsize=18, fontweight="bold");
The bands are the \(50\%\) and \(94\%\) equal-tailed intervals of the stock on hand over the held-out scenarios, and the colored line is the mean. Under the mean forecast plan the lower edge of the narrow band sits at most at \(9\) units on every day, because the shelf is empty in a quarter of the scenario and day pairs. The other plans lift the narrow band off the axis on every day.
The wide band separates the CVaR plan from the other two buffered plans. Its lower edge is the \(3\)rd percentile of the stock, and it lifts off the axis only under the CVaR plan, on the last four days, where that plan runs out in less than \(3\%\) of the scenarios.
The mean stock grows over the horizon under every plan, and so does the width of the bands. There are two reasons for this.
The clip at zero. In a scenario where the demand is below the stock available, the leftover carries over to the next day. In a scenario where the demand is above it, the stock is zero and the lost sales do not make it negative. So a surplus accumulates and a shortfall does not, and the leftovers pile up in the scenarios with low demand. The plan is fixed in advance, so it cannot cut a later order when the stock piles up.
Orders above the mean demand. From day two on, the three buffered plans order at or above the mean demand on almost every day, as we saw in the Decision Plans section, because the forecast fan widens with the horizon and a plan that protects a tail has to carry more stock on the later days. In the typical scenario that extra stock stays on the shelf.
The buffered plans end the horizon with \(110\) to \(175\) units on the shelf on average. This is the cost of a plan fixed in advance, which cannot react to the demand it observes.
Next, let’s score the plans on the one true future path we held out at the start. Keep in mind that the held-out scores of the previous figures are averages over \(3{,}000\) demand paths, while here we have one path. The realized loss of a plan on one path can be far from its expected loss, and the order of the plans on one path need not match the order of their expected losses. So we use this path to check that the plans behave as the scores suggest, not to pick one. We show the realized loss and the number of days with lost sales.
realized = pl.DataFrame(
[
{
"plan": name,
"realized loss": float(sim.loss),
"days with lost sales": int(is_stockout(sim).sum()),
}
for name, sim in realized_paths.items()
]
)
fig, axes = plt.subplots(
nrows=2, ncols=1, figsize=(10, 8), sharex=True, layout="constrained"
)
for ax, metric in zip(axes, ["realized loss", "days with lost sales"], strict=True):
sns.barplot(
data=realized,
x="plan",
y=metric,
hue="plan",
hue_order=plan_names,
palette=PLAN_COLORS,
legend=False,
ax=ax,
)
ax.set(title=metric, xlabel="", ylabel="")
axes[1].yaxis.set_major_locator(MaxNLocator(integer=True))
fig.suptitle("Realized Loss on the Held-Out Future", fontsize=18, fontweight="bold");
The true path ran above the forecast mean on \(8\) of the \(15\) days and by about \(2\%\) in total. The mean forecast plan lost sales on three days: \(21\) units on day one, where the true demand of \(94\) units met an order of \(23\) units plus the initial stock, and \(25\) units across days \(13\) and \(14\), so it paid the most together with the chance-constrained plan.
The three buffered plans never ran out, and the expected cost plan paid the least because it held the smallest buffer. The chance-constrained plan paid as much as the mean forecast plan, all of it in holding cost.
Pareto Frontier
We have two objectives, the expected loss and the tail loss (the CVaR), and no single plan is the best on both. A plan is Pareto efficient if no other plan improves one of the objectives without worsening the other. The set of Pareto efficient plans is the Pareto frontier. The frontier shows the price of one objective in terms of the other, and the choice of a point on it is a business decision.
The CVaR objective \(\text{E}[L] + \lambda \, \text{CVaR}_\alpha(L)\) traces this frontier. Each weight \(\lambda\) gives one plan, which is Pareto efficient on the optimization scenarios, and we check what survives on the held-out ones. The weight \(\lambda\) has no natural scale, so we sweep over it: for each \(\lambda\) we solve the problem, score the plan out of sample, and plot the expected loss against the tail loss and against the stockout frequency. Because solve is a pure function, the whole sweep is one jax.vmap over the vector of weights, compiled once. Let’s run the sweep and score every plan on the held-out scenarios.
lambdas = jnp.array([0.0, 0.1, 0.25, 0.5, 1.0, 2.0, 8.0])
solve_frontier = jax.jit(
jax.vmap(solve, in_axes=(None, 0, None, None)),
static_argnames=("config", "n_steps"),
)
orders_frontier, _, _ = solve_frontier(demand_opt, lambdas, orders_mean, config)
frontier = pl.DataFrame(
[
{
"lambda": float(lam),
**summarize(simulate_scenarios(orders, demand_eval, config), demand_eval),
}
for lam, orders in zip(lambdas, orders_frontier, strict=True)
]
)
We plot the frontier, with the composition of the tail as a third panel, and mark the three plans on it. The mean forecast plan is far outside the panels, so we leave it out. The horizontal axis is the expected loss, so the \(\lambda\) points are not evenly spaced, and the labels of the upper panel apply to all three.
frontier_metrics = [Metric.CVAR, Metric.STOCKOUT, Metric.TAIL_SHARE]
fig, axes = plt.subplots(nrows=3, figsize=(10, 13), sharex=True, layout="constrained")
for ax, metric in zip(axes, frontier_metrics, strict=True):
ax.plot(
frontier[Metric.EXPECTED_LOSS],
frontier[metric],
"o-",
color="black",
label=r"CVaR frontier over $\lambda$",
)
# the mean forecast plan is far outside the panels, so we leave it out
for row in results.filter(pl.col("plan") != plans[0].name).iter_rows(named=True):
ax.scatter(
row[Metric.EXPECTED_LOSS],
row[metric],
s=120,
color=PLAN_COLORS[row["plan"]],
edgecolor="black",
zorder=5,
label=row["plan"],
)
ax.set(ylabel=metric)
if metric in PROBABILITY_METRICS:
ax.yaxis.set_major_formatter(PercentFormatter(xmax=1, decimals=0))
ax.margins(x=0.08)
for j, row in enumerate(frontier.iter_rows(named=True)):
axes[0].annotate(
rf"$\lambda={row['lambda']:g}$",
(row[Metric.EXPECTED_LOSS], row[Metric.CVAR]),
textcoords="offset points",
xytext=(6, 6) if j % 2 == 0 else (-6, -14),
ha="left" if j % 2 == 0 else "right",
)
axes[-1].set(xlabel=Metric.EXPECTED_LOSS)
fig.legend(*axes[0].get_legend_handles_labels(), loc="outside lower center", ncol=4)
fig.suptitle("Cost Versus Risk on Held-Out Scenarios", fontsize=18, fontweight="bold");
The expected cost plan sits at \(\lambda = 0\) and the CVaR plan at \(\lambda = 1\). Here is what the three panels show.
Shape of the frontier. Moving along the frontier to the right lowers the tail loss and raises the expected loss, and the held-out scores keep that order up to \(\lambda = 1\). The frontier is steep on the left and flat on the right.
Price of tail loss. \(\lambda = 0.1\) cuts the tail loss by \(9\%\) at a \(0.5\%\) cost in expected loss, \(\lambda = 1\) cuts it by \(15\%\) for \(8\%\) more expected loss, and beyond \(\lambda = 1\) the tail loss hardly moves while the expected loss rises by another \(7\%\). The curve tells us how much expected loss one unit of tail loss costs.
Chance-constrained plan. It sits above the frontier in the upper panel: the \(\lambda = 0.5\) plan beats it on both metrics, \(148\) against \(156\) in expected loss and \(316\) against \(330\) in tail loss. In the middle panel it sits far below the curve, at \(4.7\%\) against \(7.0\%\) at best. The chance-constrained plan protects every day of the horizon at the same level, while the CVaR plan protects the second week more than the first (its stockout probability is \(9\%\) to \(13\%\) on the first six days), and the per-day stockout probability counts every day the same.
Tail composition. The stockout frequency stops improving beyond \(\lambda = 1\), and the lower panel explains why. At \(\lambda = 0\) the worst \(5\%\) of the held-out scenarios are mostly high-demand horizons (\(12\%\) have a below-median total demand). As \(\lambda\) grows the plan carries more stock, so low-demand horizons that pay holding cost on the whole buffer enter the tail, up to \(74\%\) of it at \(\lambda = 8\). The chance-constrained plan sits close to the curve in this panel, with \(66\%\) of its tail in low-demand horizons, since it also carries a large buffer.
Conclusion
Key Findings
Rule and forecast. The forecast fixes the scenarios, and the decision rule applied to them is a separate choice: expected cost, a service level or a weight on the worst outcomes. Plans built from the same scenarios under different rules differ by tens of percent in cost and in tail loss.
Distribution, not point. The loss has a kink at the stock, so the cost on the mean path underestimates the expected cost, and when the two errors have different prices the best stock is not the mean. A plan built on a point forecast is the most expensive plan on every metric we report, and only a probabilistic forecast gives us the alternatives.
Multi-period dynamics. Carried stock couples the days, so single-period formulas applied day by day miss the multi-period optimum (here they overstock, at an \(11\%\) higher expected loss on the held-out scenarios). A loss written as a simulator and optimized directly does not depend on closed forms that hold only for one period.
Frequency and severity. A chance constraint controls how often we run out, and CVaR controls how bad the worst outcomes are. A service level needs the constraint, a bound on bad outcomes needs the tail weight, and the frontier over \(\lambda\) shows the price of one in terms of the other. Beyond some weight the stockout frequency stops improving, because the tail fills with low-demand scenarios that pay holding cost.
Joint scenarios. Anything with a term that depends on the demand of several days or products at once (carried stock, or a CVaR or a chance constraint on the total loss) needs joint draws, not per-day quantiles. A posterior predictive gives them directly, and a plan built on shuffled days landed at \(7\%\) per day against a \(5\%\) target.
Out-of-sample scores. Held-out scenarios score a plan on draws it was not fitted to, which is the fair comparison between plans built with different rules. A cross check against an exact solver, in the Appendix, confirms that the gradient solver reaches the optimum.
Model Limitations
No recourse. All orders are fixed at the start of the horizon. A rolling horizon that re-forecasts and re-solves each day, and commits only the next order, is the practical version of this problem.
No lead time. Orders arrive on the day we planned them for. The VN2 setting has a two-week delivery lead time, which moves the decision to the stock projected at the delivery date.
Unconstrained demand history. The forecasting model sees the demand itself. In a real lost-sales setting the history is sales, which are censored on the days the shelf was empty. The VN2 report handles this with stockout-aware features, Caron (2023) with a censored likelihood, and the censored demand example of
numpyro_forecastshows the same likelihood in a forecasting model.Same posterior throughout. The held-out scenarios come from the posterior that produced the optimization set, so they check the plans against new draws from the same forecast (the expected cost plan scores \(143\) in sample and \(142\) held out) but not against model misspecification. A backtest on real data would show it.
Linear costs. A lost sale costs the same margin on every day, and leftovers keep their value at the end of the horizon. Perishable goods, order minimums and fixed ordering costs change the simulator, and integer order quantities change the solver.
Small tail sample. With \(\alpha = 0.05\) and \(1{,}000\) scenarios, the CVaR objective is the mean of \(50\) losses, and those \(50\) scenarios shape the plan. A real application needs more scenarios, or a check of the plan across resampled scenario sets.
Recommendations
Elicit the costs. The holding cost and the margin set the critical fractile and every cost-based plan with it. Agree on them with the business before any optimization, and state what a leftover is worth at the end of the horizon.
Pick from the frontier. Do not fix a service level or a tail weight by default. Report the expected loss, the tail loss and the stockout frequency of several plans and let the business pick the point.
Backtest the plan. Score the forecast and the plan together on a rolling window of real data, with the same loss, so that model misspecification shows up as cost.
Next Steps
Rolling horizon. Wrap the solve in
numpyro_forecast.backtestso that each window re-forecasts, re-solves and commits one order.Lead time. Add a pipeline of in-transit orders to the scan carry, as in the VN2 setting, so that an order placed today arrives in two weeks. Only the simulator changes.
Many products. Fit a hierarchical forecast,
vmapthe simulator over the product axis, and replace the clip at zero by the projection onto non-negative orders within a shared budget, which needs a one-dimensional search for the budget multiplier.Distributionally robust variant. Let an adversary reweight the scenarios, within a bound on how far the new weights may move from the uniform weights \(1 / N\) (measured by a divergence such as the Kullback-Leibler divergence), and minimize the worst-case weighted loss. This shows how much of the frontier survives when we do not trust the tails of the forecast.
Appendix
Convex Solver Cross Check
The projected gradient solver is convenient because it stays in JAX, but it is a first-order method on a non-smooth objective. The loss has a kink wherever a positive part switches, that is, wherever the stock available equals the demand of a day in one of the scenarios. The gradient jumps at these kinks, so a first-order method can stop short of the exact optimum. In this appendix we confirm that it does not, with a solver that reaches the exact optimum of a linear program. Two things make the objective a linear program:
The loss is convex and piecewise linear in the plan, since it is a positive combination of the daily stocks plus a linear term, and each stock is a maximum of affine functions of the plan.
The CVaR has a representation with a threshold variable: an extra decision variable \(\zeta\), a loss threshold, such that the CVaR is the minimum over \(\zeta\) of a convex piecewise linear function of \(\zeta\). Its positive parts become linear constraints once we add one auxiliary variable per scenario.
We start with the second one. The representation of the conditional value at risk of Rockafellar and Uryasev (2000) is
\[\text{CVaR}_\alpha(L) = \min_{\zeta} \left\{ \zeta + \frac{1}{\alpha} \, \text{E}\left[(L - \zeta)^{+}\right] \right\},\]
where a minimizing \(\zeta\) is the value at risk. Let’s verify this representation on the losses of the expected cost plan over the optimization scenarios, in three steps.
We write the function inside the braces as
ru_objective, with the expectation replaced by the average over the scenarios.We evaluate it on a grid of thresholds, with
jax.vmapover the grid.We compare its minimum with the value that
cvarcomputes from the \(k\) largest losses, and the location of the minimum with the value at risk.
def ru_objective(
threshold: ScalarLike, losses: Float[Array, " scenarios"]
) -> ScalarLike:
"""The threshold zeta plus the scenario average of (L - zeta)^+ over alpha."""
return threshold + jnp.maximum(losses - threshold, 0.0).mean() / ALPHA
losses_saa_opt = simulate_scenarios(orders_saa, demand_opt, config).loss
threshold_grid = jnp.linspace(0.0, losses_saa_opt.max(), 400)
ru_values = jax.vmap(ru_objective, in_axes=(0, None))(threshold_grid, losses_saa_opt)
k = tail_size(N_SCENARIOS_OPT)
fig, ax = plt.subplots(figsize=(10, 5))
ax.plot(
threshold_grid,
ru_values,
color="C0",
linewidth=2,
label=r"$\zeta + \text{E}[(L - \zeta)^+] / \alpha$",
)
ax.axhline(
float(cvar(losses_saa_opt)),
color="C3",
linewidth=2,
label=f"CVaR (mean of the {k} largest losses)",
)
ax.axvline(
float(jnp.quantile(losses_saa_opt, 1 - ALPHA, method="inverted_cdf")),
color="C1",
linewidth=2,
label="VaR",
)
fig.legend(loc="outside lower center", ncol=3)
ax.set(
xlabel=r"threshold $\zeta$",
ylabel="value",
ylim=(0, 3 * float(cvar(losses_saa_opt))),
)
fig.suptitle(
"The Rockafellar and Uryasev Representation", fontsize=18, fontweight="bold"
);
The curve is convex in \(\zeta\), its minimum sits at the value at risk, and the minimum value is the conditional value at risk that cvar computes with top_k. So the linear program minimizes the same objective as solve. It minimizes over \(\zeta\) and over the plan at the same time, with one auxiliary variable \(u_n \geq (L_n - \zeta)^{+}\) per scenario, where \(L_n = L(a, D^{(n)})\) is the loss of scenario \(n\).
Now we turn to the loss. The lost sales of a day are the demand minus the stock available, plus the leftover:
\[U_t = (D_t - y_t)^{+} = D_t - y_t + (y_t - D_t)^{+} = D_t - y_t + I_t.\]
With \(y_t = I_{t-1} + a_t\), we sum \(h \, I_t + p \, U_t\) over the days. The stocks \(I_{t-1}\) and \(I_t\) telescope, and we get
\[L(a, D) = h \sum_{t=1}^{H} I_t + p \, I_H + p \left( C_H - I_0 - A_H \right),\]
where \(A_H = \sum_t a_t\) is the total order and \(C_H = \sum_t D_t\) the total demand. The last term is linear in the plan. Each stock is the positive part of an affine function of the previous stock and the order,
\[I_t = \max\left(I_{t-1} + a_t - D_t, \, 0\right),\]
so by induction on \(t\) it is convex in \(a\), and the loss is convex in \(a\). The mean of the \(k\) largest losses is convex and non-decreasing in the losses, so the objective is convex too.
In the linear program we replace every stock \(I_t^{(n)}\) by a variable with two constraints,
\[I_t^{(n)} \geq I_{t-1}^{(n)} + a_t - D_t^{(n)}, \qquad I_t^{(n)} \geq 0.\]
Every stock variable has a positive coefficient in the objective, so the solver pushes each one down to the larger of the two bounds, which is the positive part we want. We write the program in three pieces: stock_constraints builds the stock bounds, scenario_losses the loss of every scenario, and cvar_epigraph the CVaR term with its auxiliary variables and constraint. Then solve_lp assembles them and hands the problem, with \(N \times H\) stock variables, to HiGHS through cvxpy, and we solve it for the two weights.
def stock_constraints(
stock: cp.Variable,
orders: cp.Variable,
demand: Float[np.ndarray, "scenarios horizon"],
config: InventoryConfig,
) -> list[cp.Constraint]:
"""I_t >= I_{t-1} + a_t - D_t for every scenario and day, tight at the optimum."""
horizon = demand.shape[1]
first_day = stock[:, 0] >= config.initial_inventory + orders[0] - demand[:, 0]
later_days = [
stock[:, t] >= stock[:, t - 1] + orders[t] - demand[:, t]
for t in range(1, horizon)
]
return [first_day, *later_days]
def scenario_losses(
stock: cp.Variable,
orders: cp.Variable,
demand: Float[np.ndarray, "scenarios horizon"],
config: InventoryConfig,
) -> cp.Expression:
"""The loss h sum_t I_t + p I_H + p (C_H - I_0 - A_H) of every scenario."""
return (
config.holding_cost * cp.sum(stock, axis=1)
+ config.lost_sales_cost * stock[:, -1]
+ config.lost_sales_cost
* (demand.sum(axis=1) - config.initial_inventory - cp.sum(orders))
)
def cvar_epigraph(
losses: cp.Expression, num_scenarios: int
) -> tuple[cp.Expression, list[cp.Constraint]]:
"""The Rockafellar and Uryasev term zeta + sum_n u_n / k with its constraint."""
threshold = cp.Variable() # zeta
excess = cp.Variable(num_scenarios, nonneg=True) # u_n >= (L_n - zeta)^+
# the same integer tail size as `cvar`, so both objectives agree
term = threshold + cp.sum(excess) / tail_size(num_scenarios)
return term, [excess >= losses - threshold]
def solve_lp(
demand: Float[Array, "scenarios horizon"], lam: float, config: InventoryConfig
) -> Float[Array, " horizon"]:
"""The same objective as `solve`, as a linear program."""
demand = np.asarray(demand, dtype=np.float64)
num_scenarios, horizon = demand.shape
orders = cp.Variable(horizon, nonneg=True) # the plan a
stock = cp.Variable((num_scenarios, horizon), nonneg=True) # I_t of every scenario
# `cp.sum` checks its shape by summing an uninitialized array, which can warn.
# Such atoms are built here and again when `solve` canonicalizes the problem.
with warnings.catch_warnings():
warnings.filterwarnings(
"ignore",
message=".*encountered in reduce",
category=RuntimeWarning,
)
constraints = stock_constraints(stock, orders, demand, config)
losses = scenario_losses(stock, orders, demand, config)
cvar_term, cvar_constraints = cvar_epigraph(losses, num_scenarios)
lp = cp.Problem(
cp.Minimize(cp.sum(losses) / num_scenarios + lam * cvar_term),
[*constraints, *cvar_constraints],
)
lp.solve(solver=cp.HIGHS)
assert lp.status == cp.OPTIMAL, lp.status
return jnp.asarray(orders.value, dtype=jnp.float32)
orders_lp_saa = solve_lp(demand_opt, 0.0, config)
orders_lp_cvar = solve_lp(demand_opt, 1.0, config)
We overlay the order plans of both solvers and evaluate objective at both solutions, so that the two values come from the same function. The panel titles carry the relative gap and the largest difference between the two order vectors.
fig, axes = plt.subplots(ncols=2, figsize=(14, 5), sharey=True, layout="constrained")
for ax, (name, lam, orders_jax, orders_lp, color) in zip(
axes,
[
("Expected cost", 0.0, orders_saa, orders_lp_saa, "C2"),
("CVaR", 1.0, orders_cvar, orders_lp_cvar, "C3"),
],
strict=True,
):
value_jax = float(objective(orders_jax, demand_opt, lam, config))
value_lp = float(objective(orders_lp, demand_opt, lam, config))
relative_gap = value_jax / value_lp - 1
max_order_diff = float(jnp.abs(orders_jax - orders_lp).max())
ax.plot(
days_future,
orders_jax,
color=color,
linewidth=6,
alpha=0.5,
label="Projected Adam (JAX)",
)
ax.plot(
days_future,
orders_lp,
"o",
color="black",
markersize=4,
label="Linear program (cvxpy)",
)
shade_promotion(ax)
ax.set(
xlabel="day",
title=f"{name}\nobjective gap {relative_gap:.1e}, "
f"max order difference {max_order_diff:.1f} units",
)
ax.legend(loc="upper left")
axes[0].set(ylabel="units ordered")
fig.suptitle("JAX Solver Against the Linear Program", fontsize=18, fontweight="bold");
The two solvers agree! The relative objective gap is \(1 \times 10^{-7}\) for the expected cost plan and zero to single precision for the CVaR plan, and the orders differ by at most \(0.11\) units on orders between \(44\) and \(152\) units, so the two solvers land on the same plan.
The LP orders of the expected cost plan are whole numbers on every day, so requiring whole-unit orders would not change that plan. We use the JAX solver to vectorize over weights or over products, and a linear or mixed-integer program when the problem gains coupling constraints or integer decisions.
PyMC and PyTensor Implementation
In this appendix we reproduce the held-out scores of the Out of Sample Evaluation section with a second implementation: the forecast with the functional API of pymc_forecast, and the forward pass, the simulator and the solver in PyTensor, with the optimizers of pytensor-ml. We use the same data, the same priors, the same costs and the same split into \(1{,}000\) optimization and \(3{,}000\) evaluation scenarios. We score the plans with the summarize function of this notebook and check the solver against its linear program.
The concepts are the ones of the main text, so we do not repeat them here. The full walkthrough of this implementation, with the prior and posterior predictive checks, the parameter recovery and every figure of the main text, is in the notebook forecast_to_decision_pymc.ipynb. Here we show the key ingredients only.
We import the PyMC stack here and not at the top of the notebook, so that the main text runs without it. We also silence the sampler logs, since we read the diagnostics from the outputs below. The pymc_forecast functions Horizon, innovations and predict share their names with the numpyro_forecast ones, so we keep the module prefix pmf.
import logging
import math
import pymc as pm
import pymc_forecast as pmf
import pytensor
import pytensor.tensor as pt
from pymc_forecast.data import FUTURE_DIM, TIME_DIM
from pytensor_ml.optim import adam, chain, compile_train, cosine_schedule, to_updates
from pytensor_ml.params import trainable
logging.getLogger("pymc").setLevel(logging.ERROR)
logging.getLogger("pytensor").setLevel(logging.ERROR)
rng = np.random.default_rng(seed=seed)
A pymc_forecast model takes its data and covariates as labeled xarray arrays with a time dim, so we wrap the arrays of the Generate Data section. The covariate matrix keeps the Fourier columns and the log relative price as the last column.
FOURIER_LABELS = [f"fourier_{i}" for i in range(covariates.shape[1] - 1)]
covariates_pymc = xr.DataArray(
np.asarray(covariates, dtype="float64"),
dims=(TIME_DIM, "covariate"),
coords={TIME_DIM: days, "covariate": [*FOURIER_LABELS, "log_price"]},
)
covariates_train_pymc = covariates_pymc.isel({TIME_DIM: slice(None, setup.t_obs)})
data_pymc = xr.DataArray(
np.asarray(data[:, 0], dtype="float64"),
dims=(TIME_DIM,),
coords={TIME_DIM: days[: setup.t_obs]},
)
The model is a plain function (covariates, data=None) with the same structure and the same priors as the NumPyro model of the Forecasting Model section. Three functions of pymc_forecast do the same job as their numpyro_forecast namesakes: pmf.Horizon.from_data reads the observed length from data and the full duration from covariates, pmf.innovations registers the level increments over the history and the horizon, and pmf.predict registers the observed and the forecast variables with the negative binomial likelihood.
def demand_model_pymc(
covariates: xr.DataArray, data: xr.DataArray | None = None
) -> None:
"""Local level plus weekly seasonality plus price, negative binomial likelihood."""
h = pmf.Horizon.from_data(covariates, data)
fourier_design = np.ascontiguousarray(
covariates.sel(covariate=FOURIER_LABELS).values
)
log_price = covariates.sel(covariate="log_price").values
pm.modelcontext(None).add_coord("fourier", FOURIER_LABELS)
intercept = pm.Normal("intercept", intercept_prior.mu, intercept_prior.sigma)
beta = pm.Normal("beta", 0.0, 0.3, dims="fourier")
elasticity = pm.Normal("elasticity", -1.0, 1.0)
drift_scale = pm.LogNormal("drift_scale", drift_prior.mu, drift_prior.sigma)
concentration = pm.Gamma(
"concentration", alpha=concentration_prior.alpha, beta=concentration_prior.beta
)
# non-centered increments: the sampler sees standardized drifts
drift_raw = pmf.innovations(h, "drift_raw", pm.Normal.dist(0.0, 1.0))
level = pt.cumsum(drift_raw * drift_scale)
log_rate = intercept + level + pt.dot(fourier_design, beta) + elasticity * log_price
def likelihood(name: str, eta: pt.TensorVariable, dims: tuple[str, ...], observed):
return pm.NegativeBinomial(
name, mu=pt.exp(eta), alpha=concentration, dims=dims, observed=observed
)
pmf.predict(h, likelihood, log_rate, expected_observation=pt.exp(log_rate))
pm.model_to_graphviz(
pmf.build_model(demand_model_pymc, data_pymc, covariates_train_pymc)
)
We fit the model with NUTS through pmf.fit_mcmc, with the same number of chains and draws as the NumPyro fit. Three keyword arguments go to pm.sample through sample_kwargs. We raise the target acceptance rate to \(0.95\), since the PyMC sampler reports tens of divergent transitions on this model at the \(0.9\) of the NumPyro fit. We start the chains in fresh processes (mp_ctx="forkserver") because the JAX runtime of the main text is already active in this kernel and must not be forked. We also turn off the convergence messages of the sampler (compute_convergence_checks=False), because we read the summary table and the divergence count below.
%%time
fit_pymc = pmf.fit_mcmc(
demand_model_pymc,
data_pymc,
covariates_train_pymc,
draws=1_000,
tune=1_000,
chains=4,
random_seed=rng,
sample_kwargs={
"target_accept": 0.95,
"mp_ctx": "forkserver",
"compute_convergence_checks": False,
},
)
idata_pymc = fit_pymc.idata
CPU times: user 1.25 s, sys: 254 ms, total: 1.5 s
Wall time: 12.1 s
Let’s check the sampler diagnostics of the global parameters and the number of divergent transitions.
az.summary(idata_pymc, var_names=global_params)
| mean | sd | eti94_lb | eti94_ub | ess_bulk | ess_tail | r_hat | mcse_mean | mcse_sd | |
|---|---|---|---|---|---|---|---|---|---|
| intercept | 4.044 | 0.054 | 3.9 | 4.2 | 2942 | 2545 | 1.00 | 0.001 | 0.00091 |
| beta[fourier_0] | 0.1582 | 0.0165 | 0.13 | 0.19 | 6487 | 2735 | 1.00 | 0.0002 | 0.0003 |
| beta[fourier_1] | 0.0199 | 0.0164 | -0.011 | 0.05 | 5012 | 2848 | 1.00 | 0.00023 | 0.00029 |
| beta[fourier_2] | -0.0593 | 0.0168 | -0.091 | -0.027 | 4272 | 3120 | 1.00 | 0.00026 | 0.00027 |
| beta[fourier_3] | 0.1057 | 0.0168 | 0.074 | 0.14 | 4957 | 2929 | 1.00 | 0.00024 | 0.00026 |
| beta[fourier_4] | 0.015 | 0.0168 | -0.017 | 0.047 | 8402 | 2357 | 1.00 | 0.00018 | 0.00036 |
| beta[fourier_5] | -0.0111 | 0.0162 | -0.042 | 0.019 | 6024 | 2998 | 1.00 | 0.00021 | 0.00026 |
| elasticity | -1.497 | 0.085 | -1.7 | -1.3 | 5164 | 2607 | 1.00 | 0.0012 | 0.0014 |
| drift_scale | 0.0117 | 0.0041 | 0.0055 | 0.021 | 1415 | 2014 | 1.00 | 0.00011 | 9.4e-05 |
| concentration | 29.75 | 3.2 | 24 | 36 | 4814 | 2751 | 1.00 | 0.047 | 0.056 |
int(idata_pymc["sample_stats"]["diverging"].sum())
3
The \(\hat{R}\) values are \(1.00\) and the effective sample sizes are above \(1{,}000\) for every parameter, and the sampler reports \(3\) divergent transitions in \(4{,}000\) draws. The non-centered random walk has \(364\) standardized increments, and at target_accept=0.95 the sampler reports a handful of divergences, between \(1\) and \(4\) across the seeds we tried. The posterior means agree with the NumPyro fit of the Model Fitting section to two significant figures on every parameter, and the standard deviations are within a few percent of each other, for example an elasticity of \(-1.50 \pm 0.09\) in both.
The function pmf.forecast rebuilds the model on the full covariates and draws the horizon from every posterior draw. We flatten the draws into the scenario matrix, shuffle the rows and split them as in the Demand Scenarios section.
forecast_pymc = pmf.forecast(
demand_model_pymc, idata_pymc, data_pymc, covariates_pymc, random_seed=rng
)
scenarios_pymc = (
forecast_pymc["predictions"]["forecast"]
.stack(sample=("chain", "draw"))
.transpose("sample", FUTURE_DIM)
.values.astype("float64")
)
scenarios_pymc = scenarios_pymc[rng.permutation(scenarios_pymc.shape[0])]
demand_opt_pymc, demand_eval_pymc = (
scenarios_pymc[:N_SCENARIOS_OPT],
scenarios_pymc[N_SCENARIOS_OPT:],
)
mean_demand_opt_pymc = demand_opt_pymc.mean(axis=0)
Next, we write the simulator of the Inventory Simulator section as a PyTensor graph. The function simulation_graph is a pytensor.scan over the days with the stock of every scenario as the carry, so the loop is vectorized over scenarios by construction. We compile it once with pytensor.function, with the demand matrix as an input, and wrap the three outputs in the Simulation tuple of the main text as JAX arrays, so that summarize scores the PyTensor plans with the same code as the JAX plans.
def simulation_graph(
orders: pt.TensorVariable, demand: pt.TensorVariable, config: InventoryConfig
) -> tuple[pt.TensorVariable, pt.TensorVariable, pt.TensorVariable]:
"""The inventory dynamics of one plan against every scenario, as a graph."""
def step(
order_t: pt.TensorVariable,
demand_t: pt.TensorVariable,
inventory: pt.TensorVariable,
) -> tuple:
available = inventory + order_t # y_t
sales_t = pt.minimum(available, demand_t)
lost_t = demand_t - sales_t
inventory = available - sales_t
cost_t = config.holding_cost * inventory + config.lost_sales_cost * lost_t
return inventory, lost_t, cost_t
initial_inventory = pt.full(
(demand.shape[0],), config.initial_inventory, dtype="float64"
)
inventory_path, lost_path, cost_path = pytensor.scan(
step,
sequences=[orders, demand.T],
outputs_info=[initial_inventory, None, None],
return_updates=False,
)
return cost_path.sum(axis=0), inventory_path.T, lost_path.T
orders_sym = pt.dvector("orders")
demand_sym = pt.dmatrix("demand")
simulate_fn = pytensor.function(
[orders_sym, demand_sym], list(simulation_graph(orders_sym, demand_sym, config))
)
def simulate_scenarios_pt(orders: np.ndarray, demand: np.ndarray) -> Simulation:
"""Run the compiled simulator of one plan against a matrix of demand paths."""
loss, inventory, lost_sales = simulate_fn(
np.asarray(orders, dtype="float64"), np.asarray(demand, dtype="float64")
)
return Simulation(
jnp.asarray(loss), jnp.asarray(inventory), jnp.asarray(lost_sales)
)
The forward pass of the Decision Rules section is a second pytensor.scan, over the days, with an order rule applied to the shortfall of every scenario. The quantile rule reads the ceil(prob * N)-th smallest shortfall off pt.sort, which is the inverted_cdf quantile of the JAX version.
def follow_path_pt(shortfall: pt.TensorVariable) -> pt.TensorVariable:
"""Order the shortfall of a single demand path, here the mean path."""
return shortfall[0]
def quantile_shortfall_pt(prob: float, num_scenarios: int) -> OrderRule:
"""The rule that orders the `prob` quantile of the shortfall over the scenarios."""
index = math.ceil(prob * num_scenarios) - 1
def rule(shortfall: pt.TensorVariable) -> pt.TensorVariable:
return pt.sort(shortfall)[index]
return rule
def forward_pass_pt(
demand: np.ndarray, order_rule: OrderRule, config: InventoryConfig
) -> np.ndarray:
"""Daily orders from an order rule applied to the shortfall of every day."""
demand_sym = pt.dmatrix("demand")
def step(demand_t: pt.TensorVariable, inventory: pt.TensorVariable) -> tuple:
shortfall = demand_t - inventory
order_t = pt.maximum(order_rule(shortfall), 0.0)
inventory = pt.maximum(inventory + order_t - demand_t, 0.0)
return inventory, order_t
initial_inventory = pt.full(
(demand_sym.shape[0],), config.initial_inventory, dtype="float64"
)
_, orders = pytensor.scan(
step,
sequences=[demand_sym.T],
outputs_info=[initial_inventory, None],
return_updates=False,
)
return pytensor.function([demand_sym], orders)(np.asarray(demand, dtype="float64"))
The objective of the Decision Plans section is the same graph with the mean of the losses plus \(\lambda\) times the mean of the \(k\) largest ones, which pt.sort picks out. The solver is projected Adam from pytensor-ml: the orders are a trainable shared variable, adam with a cosine_schedule returns the updates that move them, the transform project_nonnegative clips the updated orders at zero, and compile_train compiles the loss, its gradient and the updates into one training step. The function build_solver resets the orders and the optimizer state before each run and loops over the \(1{,}500\) steps.
def objective_pt(
orders: pt.TensorVariable,
demand: pt.TensorVariable,
lam: pt.TensorVariable,
config: InventoryConfig,
k: int,
) -> pt.TensorVariable:
"""Expected loss plus lam times the CVaR of the loss over the scenarios."""
losses, _, _ = simulation_graph(orders, demand, config)
return losses.mean() + lam * pt.sort(losses)[-k:].mean()
lam_sym = pt.dscalar("lam")
objective_fn_pt = pytensor.function(
[orders_sym, demand_sym, lam_sym],
objective_pt(orders_sym, demand_sym, lam_sym, config, tail_size(N_SCENARIOS_OPT)),
)
def project_nonnegative(loss_gradients_or_updates, parameters: list) -> dict:
"""Clip the updated parameters at zero, the projection onto a >= 0."""
updates = to_updates(loss_gradients_or_updates, parameters)
return updates.replacing({p: pt.maximum(updates[p], 0.0) for p in parameters})
def build_solver(
config: InventoryConfig, k: int, n_steps: int = 1_500, learning_rate: float = 1.0
) -> Callable[[np.ndarray, np.ndarray, float], tuple[np.ndarray, np.ndarray]]:
"""Compile projected Adam on the expected cost plus lam times CVaR."""
orders = trainable(np.zeros(setup.horizon), name="orders")
demand = pt.dmatrix("demand")
lam = pt.dscalar("lam")
loss = objective_pt(orders, demand, lam, config, k)
schedule = cosine_schedule(learning_rate, total_steps=n_steps)
updates = chain(adam(learning_rate=schedule), project_nonnegative)(loss, [orders])
# the updates carry the Adam moments and the step counter of the schedule
initial_state = {
var: var.get_value().copy() for var in updates if var is not orders
}
step = compile_train(loss, updates, parameters=[orders], inputs=[demand, lam])
def solve(
orders_init: np.ndarray, demand: np.ndarray, lam: float
) -> tuple[np.ndarray, np.ndarray]:
orders.set_value(np.asarray(orders_init, dtype="float64"))
for var, value in initial_state.items():
var.set_value(value)
demand = np.asarray(demand, dtype="float64")
values = np.array([step(demand, lam) for _ in range(n_steps)])
return orders.get_value(), values
return solve
solve_pt = build_solver(config, tail_size(N_SCENARIOS_OPT))
We build the four plans and the myopic newsvendor reference as in the Decision Plans section, and we check the two solves against the linear program solve_lp of the previous appendix.
orders_mean_pymc = forward_pass_pt(
mean_demand_opt_pymc[None, :], follow_path_pt, config
)
orders_cc_pymc = forward_pass_pt(
demand_opt_pymc, quantile_shortfall_pt(1 - ALPHA, N_SCENARIOS_OPT), config
)
orders_saa_pymc, _ = solve_pt(orders_mean_pymc, demand_opt_pymc, 0.0)
orders_cvar_pymc, _ = solve_pt(orders_mean_pymc, demand_opt_pymc, 1.0)
orders_newsvendor_pymc = forward_pass_pt(
demand_opt_pymc, quantile_shortfall_pt(critical_fractile, N_SCENARIOS_OPT), config
)
plans_pymc = [
Plan("Mean forecast", orders_mean_pymc),
Plan("Chance constrained", orders_cc_pymc),
Plan("Expected cost", orders_saa_pymc),
Plan("CVaR", orders_cvar_pymc),
]
gaps_pymc = []
for orders_pt, lam in [(orders_saa_pymc, 0.0), (orders_cvar_pymc, 1.0)]:
orders_lp = np.asarray(solve_lp(jnp.asarray(demand_opt_pymc), lam, config))
value_pt = float(objective_fn_pt(orders_pt, demand_opt_pymc, lam))
value_lp = float(objective_fn_pt(orders_lp, demand_opt_pymc, lam))
gaps_pymc.append(value_pt / value_lp - 1)
assert max(abs(gap) for gap in gaps_pymc) < 1e-5
gaps_pymc
[3.5033775880322082e-09, 2.931576685849535e-07]
Finally, we score every plan on the held-out scenarios with the summarize function of the main text and draw the held-out scores figure of the Out of Sample Evaluation section.
demand_eval_pymc_jax = jnp.asarray(demand_eval_pymc)
scores_pymc = {
plan.name: simulate_scenarios_pt(plan.orders, demand_eval_pymc)
for plan in plans_pymc
}
results_pymc = pl.DataFrame(
[
{"plan": name, **summarize(sim, demand_eval_pymc_jax)}
for name, sim in scores_pymc.items()
]
)
newsvendor_scores_pymc = summarize(
simulate_scenarios_pt(orders_newsvendor_pymc, demand_eval_pymc),
demand_eval_pymc_jax,
)
fig, axes = plt.subplots(
nrows=2, ncols=2, figsize=(12, 8), sharex=True, layout="constrained"
)
for ax, metric in zip(axes.flat, metrics, strict=True):
sns.barplot(
data=results_pymc,
x="plan",
y=metric,
hue="plan",
hue_order=plan_names,
palette=PLAN_COLORS,
legend=False,
ax=ax,
)
reference = ax.axhline(
newsvendor_scores_pymc[metric],
color="black",
linestyle="--",
label="Myopic newsvendor plan",
)
if metric in PROBABILITY_METRICS:
ax.yaxis.set_major_formatter(PercentFormatter(xmax=1, decimals=0))
ax.set(title=metric, xlabel="", ylabel="")
for ax in axes[-1]:
ax.set_xticks(range(len(plans_pymc)), plan_labels)
fig.legend(handles=[reference], loc="outside lower center")
fig.suptitle(
"Held-Out Scores by Plan (PyMC and PyTensor)", fontsize=18, fontweight="bold"
);
The PyMC and PyTensor pipeline reproduces the figure of the main text. Here is the comparison, plan by plan, with the main text numbers in parentheses.
Mean forecast. Expected loss \(170\) (\(172\)), tail loss \(508\) (\(508\)), stockout probability per day \(24.6\%\) (\(24.8\%\)) and in the horizon \(84\%\) (\(82\%\)).
Chance constrained. Expected loss \(152\) (\(156\)), tail loss \(321\) (\(330\)), stockout probability per day \(5.0\%\) (\(4.7\%\)) and in the horizon \(27\%\) (\(25\%\)).
Expected cost. Expected loss \(140\) (\(142\)), tail loss \(364\) (\(370\)), stockout probability per day \(10.5\%\) (\(10.4\%\)) and in the horizon \(47\%\) (\(47\%\)).
CVaR. Expected loss \(148\) (\(153\)), tail loss \(312\) (\(313\)), stockout probability per day \(7.8\%\) (\(7.0\%\)) and in the horizon \(42\%\) (\(39\%\)).
Myopic newsvendor. Expected loss \(153\) (\(157\)) and stockout probability per day \(4.8\%\) (\(4.4\%\)).
The order of the plans is the same on every metric, the myopic newsvendor line sits next to the chance-constrained bar on every panel, and the PyTensor solver lands on the linear program optimum with relative gaps of \(4 \times 10^{-9}\) and \(3 \times 10^{-7}\). The losses differ by at most \(4\%\) and the stockout probabilities by at most \(3\) percentage points, because the posterior draws and the scenario split differ between the two pipelines, not the model or the decision rules.
References
Szabłowski, B. (2026). One Global Model, Many Behaviors: Stockout-Aware Feature Engineering and Dynamic Scaling for Multi-Horizon Retail Demand Forecasting with a Cost-Aware Ordering Policy (VN2 Winner Report). arXiv:2601.18919.
Saunders, D., Viera, B., Laramore, J. and Wimpey, E. (2026). Designing Optimal Energy Generation Plans with PyMC and FICO Xpress. PyMC Labs blog.
Caron, K. (2023). Modeling Anything With First Principles: Demand under extreme stockouts. Personal blog.
Saunders, D. (2025). A Bayesian decision theory workflow. Imagination Machine blog.
Arrow, K. J., Harris, T. and Marschak, J. (1951). Optimal Inventory Policy. Econometrica, 19(3), 250-272.
Zipkin, P. (2008). Old and New Methods for Lost-Sales Inventory Systems. Operations Research, 56(5), 1256-1263.
Bijvank, M. and Vis, I. F. A. (2011). Lost-sales inventory theory: A review. European Journal of Operational Research, 215(1), 1-13.
Rockafellar, R. T. and Uryasev, S. (2000). Optimization of conditional value-at-risk. Journal of Risk, 2(3), 21-41.
Shapiro, A., Dentcheva, D. and Ruszczyński, A. (2021). Lectures on Stochastic Programming: Modeling and Theory, third edition. SIAM.
Birge, J. R. and Louveaux, F. (2011). Introduction to Stochastic Programming, second edition. Springer.
FICO Xpress Community. Stochastic Energy Planning: PyMC + FICO Xpress Optimization.
Orduz, J. NumPyro Forecast documentation.
PyMC Labs. PyMC-Forecast documentation.
PyTensor developers. PyTensor documentation.
PyMC developers. pytensor-ml: optimizers, layers and training loops for PyTensor.
Diamond, S. and Boyd, S. (2016). CVXPY: A Python-embedded modeling language for convex optimization. Journal of Machine Learning Research, 17(83), 1-5.
DeepMind. Optax documentation.