Automatic Identification of Demand

The greybox.aid module classifies a time series into one of six demand types and flags stockouts, new products, and obsolete products. It is a one-to-one Python port of R’s greybox::aid() and greybox::aidCat().

aid()

greybox.aid.aid(y, ic: str = 'AICc', level: float = 0.99, loss: str = 'likelihood', **alm_kwargs: Any) AidResult[source]

Automatic identification of demand type.

Classifies a time series into one of six demand types and flags stockouts, new products, and obsolete products. This is a one-to-one Python port of R’s greybox::aid().

The algorithm runs in two stages:

  1. Stockout detection. Inter-demand intervals are smoothed with lowess() and fitted with an ALM Geometric model. Cumulative probabilities above level are flagged as potential stockouts, with leading/trailing zero runs interpreted as new or obsolete products.

  2. Demand type classification. Smoothed regressors built with supsmu() are used to fit up to four candidate models (rectified normal, normal + Bernoulli occurrence, negative binomial, negative binomial + Bernoulli occurrence). The model with the lowest information criterion names the demand type.

Parameters:
  • y (array_like) – The time series. NaN values are replaced by zero.

  • ic ({"AICc", "AIC", "BICc", "BIC"}, default="AICc") – Information criterion used to choose between candidate models.

  • level (float, default=0.99) – Confidence level for stockout identification.

  • loss (str, default="likelihood") – Loss function passed to ALM.

  • **alm_kwargs – Extra keyword arguments forwarded to ALM. Useful for tuning the optimiser, e.g. nlopt_kargs={"maxeval": 1000}.

Returns:

Object containing the identified demand type, fitted models, stockout indices, and new/obsolete flags.

Return type:

AidResult

Notes

The six possible demand types are:

  • regular count

  • regular fractional

  • smooth intermittent count

  • smooth intermittent fractional

  • lumpy intermittent count

  • lumpy intermittent fractional

Examples

Classify an intermittent count series:

>>> import numpy as np
>>> from greybox import aid
>>> rng = np.random.default_rng(42)
>>> y = rng.poisson(0.7, 120).astype(float)
>>> res = aid(y)
>>> res.name in {
...     "smooth intermittent count", "lumpy intermittent count"
... }
True

Inspect detected stockouts:

>>> y2 = rng.poisson(3, 100).astype(float)
>>> y2[40:50] = 0  # injected stockout
>>> res2 = aid(y2)
>>> res2.stockouts["start"]  # 1-based, matches R
array([41])

See also

aid_cat

Apply aid() to multiple series.

Example:

import numpy as np
from greybox import aid

rng = np.random.default_rng(42)

# Intermittent count demand: Poisson(0.7) gives many zeros
y = rng.poisson(0.7, 120).astype(float)
result = aid(y)
print(result)
# The provided time series is smooth intermittent count

# Detect an injected stockout
y2 = rng.poisson(3, 100).astype(float)
y2[40:50] = 0
result2 = aid(y2)
print(result2.stockouts.start, result2.stockouts.end)
# [41] [50]  (1-based positions, matches R)

Accessing the result

The type and stockouts fields are typed dataclasses (AidType / Stockouts) that support both R-style attribute access and dict-style subscript access:

result2.stockouts.start    # numpy.ndarray of 1-based positions
result2.stockouts["start"] # same data, dict-style fallback
result2.type.type1         # "count" or "fractional"

Plotting

AidResult.plot() mirrors R’s plot.aid() — line plot of the series with each stockout shaded in light grey and bracketed by a solid red (start) / dashed green (end) vertical line. Leading-zero runs flagged as a new product get a light-blue shading, trailing-zero runs flagged as obsolete get a light-orange shading:

import matplotlib.pyplot as plt

ax = result2.plot()
plt.show()

aid_cat()

greybox.aid.aid_cat(data, **aid_kwargs: Any) AidCatResult[source]

Apply aid() to multiple series and summarise the result.

One-to-one port of R’s greybox::aidCat(). Each column of data is treated as a separate series and passed through aid(); the per-series demand types are then tallied into a 2x3 demand-category table plus a count of anomalies (new, stockouts, obsolete) across the collection.

Parameters:
  • data (dict, pandas.DataFrame, or 2-D numpy.ndarray) – The series to categorise. Each column / dict entry is one series.

  • **aid_kwargs – Keyword arguments forwarded to aid() (for example ic="BIC" or level=0.95).

Returns:

Object with the per-series categories, the 2x3 type matrix, the anomaly counts, and the individual AidResult objects.

Return type:

AidCatResult

Examples

Apply to a small dictionary of series:

>>> import numpy as np
>>> from greybox import aid_cat
>>> rng = np.random.default_rng(1)
>>> series = {
...     "a": rng.poisson(1, 80).astype(float),
...     "b": rng.poisson(5, 80).astype(float),
...     "c": rng.normal(10, 2, 80),
... }
>>> result = aid_cat(series)
>>> result.types.shape
(2, 3)

See also

aid

Apply the classification to a single series.

Example:

import numpy as np
from greybox import aid_cat

rng = np.random.default_rng(1)
series = {
    "a": rng.poisson(1, 80).astype(float),
    "b": rng.poisson(5, 80).astype(float),
    "c": rng.normal(10, 2, 80),
}
result = aid_cat(series)

print(result.types)        # 2x3 demand-type counts
print(result.anomalies)    # New / Stockouts / Old counts
print(result.categories)   # categorical vector per series

ax = result.plot()         # 2x3 demand-category panel

Result classes

class greybox.aid.AidResult(y: ndarray, models: dict, name: str, type: AidType | dict, stockouts: Stockouts | dict, new: bool, obsolete: bool, ic: str = 'AICc')[source]

Bases: object

Result of aid(). Mirrors R’s aid S3 class.

The nested type and stockouts fields are typed dataclasses (AidType and Stockouts) that support both attribute access and dict-style access. The two patterns below are equivalent:

result.stockouts.start    # Python idiom
result.stockouts["start"] # R-list / dict idiom
y

The original time series with NaN values replaced by 0.

Type:

numpy.ndarray

models

All fitted candidate models, keyed by demand-type name. If stockout detection ran, the stockout model is exposed under the key "stockout".

Type:

dict[str, ALM]

name

Name of the selected demand type (e.g. "smooth intermittent count").

Type:

str

type

Sub-categorisation of name with fields type1, type2, type2a.

Type:

AidType

stockouts

Stockout / new / obsolete metadata. start and end are 1-based positions matching R; dummy / new / obsolete are binary numpy arrays of length len(y).

Type:

Stockouts

new

True if the series starts with anomalous zeros (a new product).

Type:

bool

obsolete

True if the series ends with anomalous zeros (a discontinued product).

Type:

bool

ic

Information criterion used to select between candidates.

Type:

str

plot(ax=None, **kwargs)[source]

Plot the series with stockout / new / obsolete overlays.

Mirrors R’s plot.aid():

  • Line plot of y against 1-based positions.

  • Solid red vertical line at each stockout start, dashed green vertical line at each stockout end.

  • Light-grey shaded rectangle covering each stockout span.

  • Light-blue shaded rectangle covering leading zeros when the series is flagged as a new product.

  • Light-orange shaded rectangle covering trailing zeros when the series is flagged as obsolete.

Parameters:
  • ax (matplotlib.axes.Axes, optional) – Axes to draw on. A new figure is created if None.

  • **kwargs – Forwarded to matplotlib.axes.Axes.plot() for the main series line.

Returns:

The axes containing the plot.

Return type:

matplotlib.axes.Axes

class greybox.aid.AidCatResult(categories: Categorical, types: DataFrame, anomalies: Series, results: list[AidResult] | None = None)[source]

Bases: object

Result of aid_cat(). Mirrors R’s aidCat S3 class.

categories

Categorical vector of demand types, one entry per input series, with the six possible levels: regular count, smooth intermittent count, lumpy intermittent count, regular fractional, smooth intermittent fractional, lumpy intermittent fractional.

Type:

pandas.Categorical

types

2x3 frequency table with rows ["Count", "Fractional"] and columns ["Regular", "Smooth Intermittent", "Lumpy Intermittent"].

Type:

pandas.DataFrame

anomalies

Counts of "New", "Stockouts", and "Old" (obsolete) across the input series.

Type:

pandas.Series

results

The individual AidResult objects, in the same order as the input columns.

Type:

list[AidResult]

plot(ax=None, **kwargs)[source]

Plot the 2x3 demand-category panel.

Mirrors R’s plot.aidCat() layout: six cells, one per demand category, each annotated with the count of series falling in it. Row marginals (Count / Fractional totals) are drawn along the right edge, column marginals (Regular / Smooth Intermittent / Lumpy Intermittent totals) along the bottom edge, and the grand total at the bottom-right.

Parameters:
  • ax (matplotlib.axes.Axes, optional) – Axes to draw on. A new figure is created if None.

  • **kwargs – Forwarded to matplotlib.pyplot.subplots() when ax is None.

Returns:

The axes containing the plot.

Return type:

matplotlib.axes.Axes

Nested dataclasses

class greybox.aid.AidType(type1: str | None, type2: str | None, type2a: str | None)[source]

Bases: object

Sub-categorisation of an AID classification (mirrors R’s $type).

Both attribute access (aid_type.type1) and dict-style access (aid_type["type1"]) work; the latter exists for backward compatibility with code written against the earlier dict-based API.

type1

"count" or "fractional".

Type:

str or None

type2

"regular" or "intermittent".

Type:

str or None

type2a

"smooth" or "lumpy" when intermittent, None otherwise.

Type:

str or None

class greybox.aid.Stockouts(start: ndarray | None, end: ndarray | None, dummy: ndarray, new: ndarray, obsolete: ndarray)[source]

Bases: object

Stockout / new / obsolete metadata (mirrors R’s $stockouts).

Both attribute access (stockouts.start) and dict-style access (stockouts["start"]) work; the latter exists for backward compatibility with code written against the earlier dict-based API.

start

1-based positions where each stockout begins. None if the original series had no zeros at all; an empty array if zeros were present but none were flagged as stockouts.

Type:

numpy.ndarray or None

end

1-based positions where each stockout ends. Same length as start.

Type:

numpy.ndarray or None

dummy

Length-n binary indicator: 1 on observations inside a stockout window, 0 elsewhere.

Type:

numpy.ndarray

new

Length-n binary indicator: 1 over the leading-zeros block when the series is flagged as a new product.

Type:

numpy.ndarray

obsolete

Length-n binary indicator: 1 over the trailing-zeros block when the series is flagged as obsolete.

Type:

numpy.ndarray