Inventory Decisions#

Source Files
  • twiga/decision/inventory.py - order_up_to_levels, order_up_to_from_samples, order_up_to_frame, simulate_inventory, critical_ratio, quantile_function

  • twiga/core/metrics/inventory.py - inventory_scores, inventory_costs, fill_rate, cycle_service_level

A demand forecast is ultimately judged by the stock decisions it drives. twiga.decision turns quantile forecasts or sample paths into replenishment orders, replays them against actual demand, and scores the outcome by cost and service, the setting of the VN2 inventory challenge.

Theory#

Under a periodic-review order-up-to policy \((R, S)\):

  1. the stock is reviewed every \(R\) periods;

  2. an order is placed that raises the inventory position (on hand plus on order, minus backorders) to the level \(S\);

  3. the order arrives after a lead time of \(L\) periods.

The order must therefore cover demand over the protection period of \(L + R\) periods. With holding cost \(h\) per unit left in stock and shortage cost \(p\) per unit short, the cost-minimising level is the newsvendor solution: the critical-fractile quantile of protection-period demand,

\[ S = F^{-1}_{D_{L+R}}\!\left(\frac{p}{p + h}\right). \]

This is exact for backorders and the standard approximation under lost sales (Silver, Pyke and Thomas, 2017). VN2 has \(L = 2\) weeks, weekly review (\(R = 1\)), \(h = 0.2\) and \(p = 1.0\), so the critical ratio is \(0.833\).

A forecast gives quantiles per period, but \(S\) needs a quantile of their sum. How the periods combine has to be assumed:

dependence

Assumption

Quantile of the sum

"comonotonic"

periods move together (perfect rank correlation)

sum of the period quantiles: the largest, most cautious level

"independent"

periods are independent

Monte Carlo over each period’s quantile function (inverse-CDF sampling)

sample paths

whatever the model’s paths carry

empirical quantile of the summed paths (order_up_to_from_samples)

Real demand lies between the two assumptions; comparing them shows how much the choice matters. Quantile functions are interpolated linearly between the forecast levels and held at the outermost quantile beyond them, so forecast the levels the critical ratio needs (a 0.95 cap understates the tail at a 0.97 service level).

Usage#

from twiga.core.metrics import inventory_scores
from twiga.decision import order_up_to_frame, simulate_inventory

# Order-up-to levels for every series and forecast origin of a backtest's quantile forecasts.
levels = order_up_to_frame(
    quantiles,  # long: id, forecast_origin, timestamp, q_level, quantile_forecast
    id_column="id",
    lead_time=2,
    review_period=1,
    holding_cost=0.2,
    shortage_cost=1.0,
    dependence="independent",
)

# Replay the policy against actual demand: arrays (n_series, T), one level per review period.
simulation = simulate_inventory(demand, order_up_to, lead_time=2, review_period=1, initial_on_hand=on_hand)
scores = inventory_scores(simulation, holding_cost=0.2, shortage_cost=1.0, ids=series_ids)
scores[["total_cost", "fill_rate", "cycle_service_level"]].mean()

The simulation#

Each period, in order:

  1. Review: at a review period, an order raises the inventory position to that period’s level. It arrives lead_time periods later; with lead_time=0, before this period’s demand.

  2. Arrivals: the units due this period arrive.

  3. Demand: stock serves the backlog first, then this period’s demand. Demand not met is lost (lost_sales=True, as in VN2) or joins the backlog.

Score

Definition

holding_cost

\(h \times\) units on hand at the end of each period

shortage_cost

\(p \times\) units lost (lost sales), or \(p \times\) backordered units per period (backorders)

fill_rate

share of demand met from stock in the period it occurred (type-II service)

cycle_service_level

share of review cycles without any shortage (type-I service)

stockout_periods

share of periods with unmet demand

References#

  • Silver, E. A., Pyke, D. F. and Thomas, D. J. (2017). Inventory and Production Management in Supply Chains, 4th ed. CRC Press.

  • Petropoulos, F. et al. (2022). Forecasting: theory and practice. International Journal of Forecasting, 38(3), 705–871 (section on inventory forecasting).

API Reference#

Inventory decisions from probabilistic demand forecasts: the periodic-review order-up-to policy.

Under a periodic-review order-up-to policy \((R, S)\), every \(R\) periods the stock is reviewed and an order is placed that raises the inventory position (on hand plus on order, minus backorders) to the level \(S\). The order arrives after a lead time of \(L\) periods, so it must cover demand over the protection period of \(L + R\) periods. With a holding cost \(h\) per unit left in stock and a shortage cost \(p\) per unit short, the cost-minimising level is the newsvendor solution: the critical-fractile quantile of protection-period demand,

\[S = F^{-1}_{D_{L+R}}\!\left(\frac{p}{p + h}\right).\]

This is exact for backorders and the standard approximation under lost sales (Silver, Pyke and Thomas, 2017, ch. 7). The VN2 inventory challenge (lead time 2 weeks, weekly review, holding 0.2, shortage 1.0 per unit) is this setting, with a critical ratio of 0.833.

A forecast gives per-period quantiles, but \(S\) needs a quantile of their sum. Sample paths give it directly (order_up_to_from_samples()). From per-period quantiles (order_up_to_levels()) the dependence across periods must be assumed:

  • "comonotonic": periods move together (perfect rank correlation), so the quantile of the sum is the sum of the quantiles; the largest, most cautious level;

  • "independent": periods are independent; the sum’s quantile comes from inverse-CDF samples of each period’s quantile function. Probabilities beyond the outermost forecast levels are held at the outermost quantile, so very high service levels are understated when the forecast stops at, say, the 95 % quantile; forecast the levels the critical ratio needs.

Real demand lies in between; comparing the two shows how much the choice matters. simulate_inventory() replays a policy against actual demand, period by period, and the metrics in twiga.core.metrics.inventory score it.

class twiga.decision.inventory.InventorySimulation(demand, sales, shortage, on_hand, orders, arrivals, backlog, review_period, lost_sales)#

Bases: object

Period-by-period outcome of an inventory policy, arrays of shape (n_series, T).

Variables:
  • demand – Demand in each period.

  • sales – Demand met from stock in the period it occurred (on time).

  • shortage – Demand not met on time in its period (lost under lost sales, otherwise added to the backlog).

  • on_hand – Stock on hand at the end of each period (after demand); holding cost is charged on it.

  • orders – Units ordered at each review (0 between reviews).

  • arrivals – Units received at the start of each period.

  • backlog – Backordered units waiting at the end of each period (0 under lost sales); backorder cost is charged on it, per unit and period.

  • review_period – Periods between reviews, for cycle-based metrics.

  • lost_sales – Whether unmet demand was lost (True) or backordered (False).

arrivals: ndarray#
backlog: ndarray#
demand: ndarray#
lost_sales: bool#
on_hand: ndarray#
orders: ndarray#
review_period: int#
sales: ndarray#
shortage: ndarray#
twiga.decision.inventory.critical_ratio(holding_cost, shortage_cost)#

The newsvendor critical fractile p / (p + h): the service level that minimises cost.

Parameters:
  • holding_cost (float) – Cost per unit left in stock at the end of a period (h).

  • shortage_cost (float) – Cost per unit of unmet demand (p).

Return type:

float

Returns:

The quantile level of protection-period demand to stock up to.

twiga.decision.inventory.order_quantity(order_up_to, on_hand, on_order=0.0)#

The order that raises the inventory position to the order-up-to level.

Parameters:
  • order_up_to (ndarray | float) – Order-up-to level S.

  • on_hand (ndarray | float) – Stock on hand (negative for backorders).

  • on_order (ndarray | float) – Units ordered but not yet received.

Return type:

ndarray

Returns:

max(0, S - (on_hand + on_order)).

twiga.decision.inventory.order_up_to_frame(quantiles, *, id_column, lead_time, review_period=1, holding_cost, shortage_cost, dependence='independent', origin_column='forecast_origin', date_column='timestamp', level_column='q_level', quantile_column='quantile_forecast', group_columns=(), n_samples=2000, seed=0)#

Order-up-to levels for every series and forecast origin of a long quantile frame.

Parameters:
  • quantiles (DataFrame) – Long quantile forecasts (one row per series, origin, timestamp and level), e.g. ForecastResult.to_dataframe(fmt="long") in panel mode with an origin column.

  • id_column (str) – Series identifier column.

  • lead_time (int) – Periods between placing and receiving an order.

  • review_period (int) – Periods between reviews.

  • holding_cost (float) – Cost per unit in stock at the end of a period.

  • shortage_cost (float) – Cost per unit of unmet demand.

  • dependence (Literal['comonotonic', 'independent']) – See order_up_to_levels().

  • origin_column (str) – Forecast-origin column (one review per origin).

  • date_column (str) – Forecast timestamp column.

  • level_column (str) – Quantile level column.

  • quantile_column (str) – Predicted quantile column.

  • group_columns (Sequence[str]) – Further columns computed separately (e.g. Model, Folds).

  • n_samples (int) – Samples per series for "independent".

  • seed (int) – Random seed.

Return type:

DataFrame

Returns:

[*group_columns, id_column, origin_column, "order_up_to"].

twiga.decision.inventory.order_up_to_from_samples(samples, *, lead_time, review_period=1, holding_cost, shortage_cost)#

Order-up-to levels from sample paths of demand (any dependence across periods kept).

Parameters:
  • samples (ndarray) – (n_series, n_samples, horizon) demand paths.

  • lead_time (int) – Periods between placing and receiving an order.

  • review_period (int) – Periods between reviews.

  • holding_cost (float) – Cost per unit in stock at the end of a period.

  • shortage_cost (float) – Cost per unit of unmet demand.

Return type:

ndarray

Returns:

(n_series,) order-up-to levels.

twiga.decision.inventory.order_up_to_levels(quantiles, levels, *, lead_time, review_period=1, holding_cost, shortage_cost, dependence='independent', n_samples=2000, seed=0, chunk_size=2000)#

Order-up-to levels from per-period quantile forecasts.

Parameters:
  • quantiles (ndarray) – (n_series, horizon, Q) quantile forecasts of demand, one row per series, starting at the first period after the review.

  • levels (Sequence[float]) – (Q,) quantile levels of the last axis.

  • lead_time (int) – Periods between placing and receiving an order (L).

  • review_period (int) – Periods between reviews (R).

  • holding_cost (float) – Cost per unit in stock at the end of a period.

  • shortage_cost (float) – Cost per unit of unmet demand.

  • dependence (Literal['comonotonic', 'independent']) – How periods combine: "comonotonic" (sum of quantiles) or "independent" (Monte Carlo over each period’s quantile function).

  • n_samples (int) – Samples per series for "independent".

  • seed (int) – Random seed for "independent".

  • chunk_size (int) – Series sampled at a time for "independent", bounding memory to chunk_size x protection x n_samples draws.

Return type:

ndarray

Returns:

(n_series,) order-up-to levels, never negative.

twiga.decision.inventory.quantile_function(levels, values, probabilities)#

Evaluate piecewise-linear quantile functions at given probabilities.

Quantile forecasts give the inverse CDF at a few levels; between them it is interpolated linearly, and beyond the outermost levels it is held at the outermost quantile (no extrapolation into the tails). Quantiles are sorted first, so crossing quantiles cannot produce a decreasing function.

Parameters:
  • levels (Sequence[float]) – (Q,) increasing quantile levels in (0, 1).

  • values (ndarray) – (..., Q) quantile forecasts, last axis matching levels.

  • probabilities (ndarray) – Probabilities broadcastable to values[..., 0] (e.g. (..., k) with values reshaped to (..., 1, Q)).

Return type:

ndarray

Returns:

The interpolated quantiles, shaped like the broadcast of probabilities.

twiga.decision.inventory.simulate_inventory(demand, order_up_to, *, lead_time, review_period=1, initial_on_hand=0.0, initial_pipeline=None, lost_sales=True)#

Replay an order-up-to policy against actual demand.

Each period, in order:

  1. at a review (every review_period periods, starting with the first), an order raises the inventory position (on hand plus pipeline, minus backlog) to that period’s order-up-to level; it arrives lead_time periods later (lead_time=0: this period);

  2. the units due this period arrive;

  3. stock serves the backlog first (backorders), then this period’s demand; demand not met is lost (lost_sales=True, as in VN2) or joins the backlog.

Parameters:
  • demand (ndarray) – (n_series, T) actual demand.

  • order_up_to (ndarray) – (n_series, T) order-up-to level used at each period’s review (only the review periods are read; NaN there places no order), e.g. from a rolling forecast made at each review.

  • lead_time (int) – Periods between placing and receiving an order.

  • review_period (int) – Periods between reviews.

  • initial_on_hand (ndarray | float) – Stock on hand before the first period, per series or one value.

  • initial_pipeline (ndarray | None) – (n_series, lead_time) units already ordered, arriving in periods 0 .. lead_time - 1; none when None.

  • lost_sales (bool) – Lose unmet demand (True) or backorder it (False).

Return type:

InventorySimulation

Returns:

The InventorySimulation.

Scores of an inventory policy: cost, fill rate and cycle service level.

These score the outcome of a policy replayed against actual demand (twiga.decision.inventory.simulate_inventory()), the measure a forecast for replenishment is ultimately judged by (as in the VN2 challenge):

  • Cost: holding cost \(h\) per unit on hand at the end of each period, plus shortage cost \(p\) per unit of demand lost (lost sales) or per backordered unit and period (backorders).

  • Fill rate (type-II service): the share of demand met from stock in the period it occurred.

  • Cycle service level (type-I service): the share of review cycles without any shortage.

Pure numpy and pandas; they take the simulation’s arrays and need no import of the decision module.

twiga.core.metrics.inventory.cycle_service_level(simulation)#

Share of review cycles with no shortage (type-I service), per series.

A cycle is the review_period periods starting at a review; a trailing partial cycle counts.

Parameters:

simulation (InventorySimulation) – Outcome of simulate_inventory().

Return type:

ndarray

Returns:

(n_series,) cycle service levels.

twiga.core.metrics.inventory.fill_rate(simulation)#

Share of demand met from stock in the period it occurred (type-II service), per series.

Parameters:

simulation (InventorySimulation) – Outcome of simulate_inventory().

Return type:

ndarray

Returns:

(n_series,) fill rates; NaN for a series without demand.

twiga.core.metrics.inventory.inventory_costs(simulation, holding_cost, shortage_cost)#

Holding, shortage and total cost per series.

Parameters:
Return type:

DataFrame

Returns:

holding_cost, shortage_cost and total_cost per series (one row each).

twiga.core.metrics.inventory.inventory_scores(simulation, holding_cost, shortage_cost, ids=None)#

Every inventory score per series.

Parameters:
Return type:

DataFrame

Returns:

Per series – holding_cost, shortage_cost, total_cost, fill_rate, cycle_service_level, mean_on_hand and stockout_periods (the share of periods with unmet demand).