Back to Deep Learning
AI/ML · Deep Learning

DeepAR

Instead of predicting tomorrow’s demand, predict the parameters of a distribution over tomorrow’s demand — a mean and a shape — and train by maximizing the likelihood of what actually happened. To forecast further than one step, sample a value from that distribution, feed it back as the next input, and continue; do that a few hundred times and you have a cloud of possible futures. Any quantile you need is then just a percentile of those trajectories, and crucially the uncertainty compounds correctly across the horizon because each step’s sample genuinely conditions the next.

SupervisedLSTM / GRURegressionSequence modelingDensity estimationAnomaly detection

Supervised in the ordinary sense, but the target is a distribution rather than a value: the network outputs likelihood parameters and is trained by maximum likelihood, which is why density-estimation belongs in its task types alongside regression. That framing is the whole point of the model and is what separates it from every point forecaster in this category.

Also known as: DeepAR, Autoregressive RNN forecaster, Probabilistic global forecaster

Intuition

What it actually does

Instead of predicting tomorrow’s demand, predict the parameters of a distribution over tomorrow’s demand — a mean and a shape — and train by maximizing the likelihood of what actually happened. To forecast further than one step, sample a value from that distribution, feed it back as the next input, and continue; do that a few hundred times and you have a cloud of possible futures. Any quantile you need is then just a percentile of those trajectories, and crucially the uncertainty compounds correctly across the horizon because each step’s sample genuinely conditions the next.

Assumptions it makes
  • The chosen likelihood family genuinely describes the data — the load-bearing assumption, and the one most often wrong on count data
  • Series are comparable after scaling by their own mean, which is what allows a single global model across many orders of magnitude
  • Enough related series exist to fit a shared model; on a single series this is the wrong tool by a wide margin
  • Covariates are known across the whole forecast horizon, since the decoder consumes them at every sampled step
  • The autoregressive feedback is stable enough that a sampled trajectory stays in a plausible region of state space for the whole horizon
Objective

Likelihood

A plain negative log-likelihood, and the substance is entirely in which likelihood you chose. Gaussian gives you a mean and a standard deviation and permits negative values, which for demand data is wrong in a way that quietly ruins the lower quantiles. Negative binomial handles counts with overdispersion, which is what retail demand actually looks like. The second thing to notice is the feedback term: the model conditions on the previous observation, which is what makes it autoregressive and is also the source of its characteristic failure — during training that input is the true value, and at inference it is the model’s own sample.

the chosen likelihood — Gaussian for real values, negative binomial for counts; a modelling decision, not a default
the distribution parameters the network emits, one set per timestep
the recurrent state; the entire memory of the series so far
the previous observation fed back as input — teacher-forced in training, sampled at inference
covariates, which must be known for the whole horizon since the decoder consumes them
Optimization

How it is minimized

Adam on the negative log-likelihood with teacher forcing, then ancestral sampling at inference

the per-series scale factor: the mean of its own history. The single most important detail in the model
emitted distribution parameters, both multiplied by the scale so the network works in normalized space
softplus, guaranteeing a positive scale parameter — a hard constraint the architecture must enforce
the projection heads; everything else is shared across every series in the panel

The scale handling is the detail that makes a global model work at all, and it is easy to underrate. Real panels span many orders of magnitude — a warehouse item selling three units a week alongside one selling thirty thousand — and if the network has to represent that range directly, the large series dominate the gradient and the small ones are noise. Dividing the input by the series mean and multiplying the emitted parameters back by it means the network only ever sees normalized values, and the same trick is used to weight sampling so that high-volume series are not over-represented in the batches. Beyond that the training is ordinary: teacher forcing, so the recurrence is unrolled against true observations and the whole sequence is one parallel graph. Inference is where the cost appears, since each of a few hundred sample paths must be rolled forward step by step.

Hyperparameters
  • likelihood family

    Gaussian, negative binomial or Student-t. A modelling decision that determines whether the lower quantiles are even coherent (Gaussian / negative binomial)

  • hidden size

    Recurrent state width; the dominant capacity and cost lever (30 to 120)

  • layers

    Stacked recurrence; rarely worth more than two or three (1 to 3)

  • context length

    History used to warm the state before the forecast window begins (1 to 3 seasonal cycles)

  • sample paths

    Trajectories drawn at inference. Too few and the tail quantiles are pure noise (100 to 1000)

  • scaling strategy

    Mean-scaling by default; the choice that makes a global model across mixed magnitudes viable at all (mean / none)

  • gradient clip norm

    The recurrence still explodes without it, likelihood loss notwithstanding (1.0 to 10.0)

Convergence & cost

Trains stably with teacher forcing and clipping, but carries three characteristic failures that are all about the likelihood rather than the optimizer. The first is exposure bias, which is structural: training always conditions on true observations while inference conditions on the model’s own samples, so an early mistake puts the recurrence in a state it never saw during training and the error compounds across the horizon. The second is a misspecified likelihood, where a Gaussian head on count data produces negative lower quantiles and an intermittent-demand series gets a confidently wrong interval — the loss will look fine throughout. The third is a variance collapse in which the softplus scale head drifts toward zero because a narrow distribution scores well on easy steps, giving over-confident intervals that only calibration testing reveals. None of these shows up in the training curve.

O(T · H²) for training, sequential in time and parallel across the batch. Inference is O(S · H · H²) for S sample paths rolled forward step by step, which is the dominant operational cost and roughly two orders of magnitude more expensive than a point forecaster emitting the horizon in one pass.

Applications

How, where, and why it is used

Time-Series Forecasting

Primary fit
How

Train one global recurrent model across the whole panel, conditioning each step on the previous observation and on covariates, and emit likelihood parameters rather than values. At inference, roll the recurrence forward drawing a sample at each step and feeding it back, repeat a few hundred times, and read any quantile off the resulting trajectory cloud.

Where
  • Retail and supply-chain demand forecasting across large catalogues, which is what it was built for
  • Intermittent and count-valued demand, where a negative binomial head is the correct model and a Gaussian one is not
  • Cold-start forecasting for new items, where static categorical embeddings let a new SKU borrow its category’s learned dynamics
  • Any setting needing the joint distribution over the horizon rather than marginal quantiles — cumulative demand over a lead time, for instance
Why

Two genuine advantages over quantile regression. It models counts properly, which matters enormously in retail where a Gaussian interval on a slow-moving item is not merely imprecise but incoherent. And because trajectories are sampled autoregressively, the horizon quantiles are jointly consistent — the distribution of total demand over a two-week lead time is directly available, which a model emitting independent marginal quantiles per horizon cannot give you. Against it: sampling makes inference two orders of magnitude more expensive; the likelihood choice is a real modelling commitment that is wrong by default on count data; and exposure bias means long horizons degrade in a way the training loss never reveals. On a single series it loses to exponential smoothing as usual.

Featurization
  • Scale each series by its own mean and let the network work in normalized space — this is what makes one model span six orders of magnitude
  • Choose the likelihood to match the data: negative binomial for counts, Gaussian only for genuinely continuous quantities
  • Weight the sampling of training windows by series scale, or high-volume series dominate the batches
  • Encode item and category identity as static embeddings, which is the mechanism behind the cold-start behaviour
  • Ensure covariates are available for the whole horizon, since the decoder consumes them at every sampled step
Evaluation

Weighted quantile loss across levels, plus coverage of the nominal intervals on held-out data, with rolling-origin backtesting. Calibration is the metric that matters here and it is separate from accuracy: an over-confident model can have excellent median error and cause systematic stockouts. Report the number of sample paths alongside any quantile, since a tail quantile from a hundred paths is mostly noise.

Pitfalls
  • A Gaussian likelihood on count data, giving negative lower quantiles that are silently meaningless
  • Too few sample paths, making tail quantiles unstable between runs of the same model
  • Exposure bias degrading long horizons with no signal in the training loss
  • Forgetting that scaling is part of the model, so refitting it at inference changes every forecast

Anomaly Detection

Viable
How

The model gives a full predictive distribution at every step, so the anomaly score is the exact quantity you want: the likelihood of the observation under it, or equivalently its position in the predicted CDF. That is more principled than a residual threshold, because it accounts for the fact that a large deviation on a volatile series is unremarkable while a small one on a stable series is not.

Where
  • Demand and inventory monitoring, distinguishing a genuine spike from ordinary volatility on a noisy item
  • Count-valued metric monitoring where a Gaussian assumption would produce nonsense thresholds
  • Multi-step anomaly detection using the joint trajectory distribution rather than a per-step threshold
  • Monitoring across a large panel with heterogeneous scales, where a shared threshold is meaningless and a likelihood is comparable
Why

A likelihood is a better anomaly score than a residual for a specific and defensible reason: it is already normalized by predicted uncertainty, so scores are comparable across series of different volatility without any per-series calibration. On count data the negative binomial tail is also genuinely right, where a Gaussian threshold over-alarms on low-volume items. The honest caveats are that the model optimizes forecast likelihood and nothing about anomalies, so a well-modelled recurring anomaly is simply absorbed into the distribution; that a misspecified likelihood makes every score wrong in a way nothing surfaces; and that sampling cost makes this a batch detector rather than a streaming one.

Featurization
  • Score by predictive log-likelihood or CDF position rather than by raw residual, which is the whole advantage here
  • Verify the likelihood family against the data before trusting any score — a wrong family makes every score wrong uniformly
  • Use enough sample paths that the tail of the predictive distribution is estimated rather than guessed
  • Exclude known event periods from training, or the model learns them as part of normal and stops flagging them
Evaluation

Precision and recall on labelled incidents with a detection window, plus a calibration check on known-normal data — if the nominal 1st percentile fires far more often than one percent of the time, the detector’s false-positive rate is untethered from its threshold and precision-recall alone will not show it.

Pitfalls
  • A misspecified likelihood making every score wrong with nothing to indicate it
  • Variance collapse producing over-confident intervals and therefore over-alarming
  • Sampling cost making per-step streaming detection impractical
  • Recurring anomalies being absorbed into the learned distribution and ceasing to be anomalies at all

Optimization

Viable
How

Sampled trajectories are exactly what a stochastic program consumes. Rather than plugging point forecasts or marginal quantiles into a deterministic optimizer, the sample paths become scenarios in a scenario-based stochastic optimization — inventory over a lead time, capacity over a shift pattern — and because the paths are jointly sampled they preserve the temporal correlation that independent marginal quantiles destroy.

Where
  • Scenario-based stochastic programming for inventory and replenishment, with sampled paths as the scenario set
  • Lead-time demand aggregation, where the distribution of a SUM over the horizon is needed and marginals cannot give it
  • Service-level and safety-stock setting directly from the predictive distribution rather than from an assumed error model
  • Chance-constrained planning, where the constraint is on a probability the sample paths estimate directly
Why

The joint-versus-marginal distinction is the point and it is routinely missed. Total demand over a ten-day lead time is not the sum of ten marginal medians, and its distribution depends on the correlation between days — which sampled trajectories capture and independent quantile forecasts cannot. For any decision aggregating over time, that difference is large and usually in the unsafe direction. The limits are computational and statistical: a few hundred paths estimate the body of the distribution well and the far tail poorly, and the optimization on top has to be scenario-based, which is a substantially heavier apparatus than a closed-form fractile.

Featurization
  • Draw enough paths that the quantile the decision actually uses is estimated stably, and check that by re-running with a different seed
  • Aggregate over the horizon within each path before taking quantiles, never the reverse — that ordering is the entire joint-distribution advantage
  • Match the likelihood family to the decision variable, since an incoherent lower tail produces an infeasible plan
  • Persist the sample paths rather than just their quantiles if a downstream optimizer needs scenarios
Evaluation

Realized decision cost against the hindsight optimum, and separately the coverage of the aggregated quantity rather than of the per-step marginals — aggregated coverage is what the decision depends on and it can be badly wrong while the per-step numbers look fine.

Pitfalls
  • Summing marginal quantiles across the horizon, which is wrong whenever steps are correlated and is the standard error
  • Too few paths for the tail the decision uses, giving a plan that changes between runs
  • Treating sampled scenarios as exhaustive when the far tail is barely sampled at all
Also applied in

Control & Operations

Viable
How

The sampled trajectory cloud feeds an operational plan directly as a scenario set: replenishment, staffing and dispatch decisions are made against the paths rather than against a point, and service levels come out as probabilities the paths estimate.

Where
  • Replenishment and inventory planning against a sampled lead-time demand distribution
  • Workforce scheduling where the cost of under- and over-staffing differ and the decision spans several periods
  • Capacity reservation under a chance constraint estimated from sample paths
Why

The pairing is natural because operational decisions aggregate over time and the model produces jointly consistent trajectories, which is exactly what that aggregation needs. The operational objections are real: inference cost is orders of magnitude above a point forecaster, which matters when the panel is large and the planning cycle is short, and the plan changes the system so the model is trained on operations the deployment then alters. Neither is fatal, but both should be sized before committing.

Featurization
  • Match the sampled horizon to the decision lead time exactly, since paths are rolled step by step and extra horizon is extra cost
  • Feed planned actions as covariates where known, or the model attributes their effect to something else
  • Budget inference explicitly — hundreds of paths across a large panel is the dominant serving cost and it is easy to under-plan
Evaluation

Regret against the hindsight-optimal plan plus realized service level against target. Forecast likelihood is a diagnostic here, not the objective.

Pitfalls
  • Sampling cost breaking the planning cycle on a large panel
  • The plan shifting the distribution the model was trained on, which backtesting cannot reveal
  • Exposure bias degrading exactly the long horizons that planning depends on

Risk & Fraud

Adapted
How

Applied to risk aggregates rather than to individual events: forecasting claim counts, transaction volumes or loss frequencies where the tail of the predictive distribution drives a reserve or capacity decision, and where count data makes the negative binomial head the correct choice.

Where
  • Claim and loss frequency forecasting for reserving, where counts and overdispersion are the norm
  • Transaction volume forecasting for fraud-review capacity, where the upper tail sizes the team
  • Operational loss-event forecasting, where the quantity is a count and the decision is a buffer
Why

Count-valued risk aggregates are exactly where a negative binomial likelihood earns its place, and the sampled joint distribution over a reserving period is the right object for a reserve decision. But this is aggregate forecasting and not entity-level risk scoring: the model has no notion of an individual claim or transaction and must never be pointed at one. It also estimates far tails poorly from a few hundred paths, which is awkward precisely where risk work lives.

Featurization
  • Use a count likelihood; a Gaussian one on claim counts produces negative reserves at the lower quantiles
  • Forecast at the level the reserve decision is made at, since entity-level noise swamps the signal
  • Draw substantially more paths than the default when a far tail quantile drives the decision
Evaluation

Quantile loss at the decision-relevant levels plus empirical exceedance against nominal, backtested strictly by time. A reserving model validated on a random split is meaningless.

Pitfalls
  • Mistaking aggregate forecasting for entity-level risk scoring, which this cannot do
  • Estimating a far tail from too few sample paths, giving a reserve that moves with the seed
  • A regime change invalidating the learned dynamics with no warning in the likelihood
Deployment

What it costs to run

Training

Hours on a single GPU for a large panel. The recurrence is sequential in time so it parallelizes across series but not across steps, and teacher forcing means the whole training sequence is one graph — training is comfortably cheaper than inference here, which is unusual.

Inference

The expensive half, and by a wide margin. Each sample path is rolled forward one step at a time with a draw at every step, so a few hundred paths over a fourteen-step horizon is thousands of sequential recurrent steps per series. Two orders of magnitude above a point forecaster, and the single most under-planned line item when this model is adopted.

Retraining

Monthly to quarterly, and immediately when the catalogue changes materially, since static embeddings are indexed by item and category identity.

Drift & monitoring
  • Track interval coverage against nominal per quantile; drifting coverage precedes accuracy degradation and is the earliest signal available
  • Watch the emitted scale parameter for a downward drift, which is variance collapse and shows as over-confidence rather than as error
  • Monitor error by horizon separately — exposure bias degrades the far horizons first and the aggregate hides it entirely
  • Alert on series whose scale factor has shifted materially since training, since the normalization they were fitted under no longer holds
Production gotchas
  • Per-series scale factors are part of the model artifact. Recomputing them at inference from a different window silently changes every forecast
  • Sampling is stochastic, so the same input gives different quantiles on different runs. The seed must be pinned for reproducibility and the path count logged with every forecast
  • The likelihood family is baked in. Switching from Gaussian to negative binomial is a retrain, not a configuration change
  • Covariates must be available for the full horizon at every sampled step; a missing one does not fail, it produces a confident forecast built on defaults
  • A new item with no static embedding needs an explicit cold-start path — usually its category embedding — rather than a zero vector
Trade-offs

Pros and cons, in context

Strengths
  • Probabilistic by construction, not by bolt-on

    The network emits distribution parameters and is trained by likelihood, so the uncertainty is modelled rather than assumed. Decisive wherever the decision needs a tail, and it is a genuinely different thing from fitting an error distribution around a point forecast.

  • Jointly consistent trajectories across the horizon

    Sampled paths preserve temporal correlation, so the distribution of a SUM over a lead time is directly available. Independent marginal quantiles cannot give this, and for any aggregating decision the difference is large and usually unsafe.

  • Count likelihoods handle intermittent demand properly

    A negative binomial head is the correct model for slow-moving retail items, where a Gaussian interval is not merely imprecise but incoherent — it puts mass on negative demand. This is where most of the practical advantage over quantile regression lies.

  • Mean-scaling makes one global model span the whole catalogue

    Normalizing by each series’ own mean lets a model cover six orders of magnitude without the large series dominating the gradient. An unglamorous detail that is the actual reason the approach works at scale.

  • Static embeddings give a usable cold start

    A new item inherits its category’s learned dynamics rather than having no forecast at all. Rare among forecasters and operationally valuable in a growing catalogue.

Limitations
  • Inference is two orders of magnitude more expensive

    Hundreds of sample paths rolled step by step, against one forward pass for a point forecaster. The most consistently under-planned cost when this model is adopted, and it can dominate a serving budget entirely.

  • The likelihood family is a real modelling commitment

    Gaussian on count data gives negative lower quantiles and nothing in the training loss objects. Choosing it is a decision requiring knowledge of the data, and changing it later is a retrain.

  • Exposure bias degrades long horizons

    Training conditions on true values and inference on the model’s own samples, so an early error compounds. Structural to autoregressive decoding, invisible in the training loss, and it bites hardest at exactly the horizons planning depends on.

  • Forecasts are not reproducible without a pinned seed

    Sampling means the same input gives different quantiles each run. Manageable, but it surprises people and it makes debugging a specific bad forecast considerably harder.

  • Variance collapse is silent

    The scale head can drift toward zero because narrow distributions score well on easy steps, producing over-confident intervals. Only calibration monitoring catches it — accuracy metrics look fine throughout.

Implementation

Intuitive, idiomatic, optimized

The same algorithm in three languages, each progressing through Kent Beck's three stages. Every optimization names the lever it pulls and the trade-off it accepts — cost profiles are illustrative, not measured benchmarks.

1. Intuitive

The math, transcribed literally. No dependencies.

"""DeepAR, transcribed the way the paper reads.

The model emits DISTRIBUTION PARAMETERS, not values:

    h_t    = LSTM(h_{t-1}, y_{t-1} / nu, x_t)      recurrent state
    mu_t   = nu * (w_mu . h_t)                      location
    sigma_t= nu * softplus(w_sigma . h_t)           scale, forced positive
    loss   = -log N(y_t | mu_t, sigma_t)            maximum likelihood

nu is the series' own mean. Dividing the input by it and multiplying the
outputs back is the unglamorous detail that lets one model span six orders of
magnitude - without it the large series dominate the gradient entirely.

Plain loops, one LSTM cell written out, Gaussian likelihood.
"""

import math
import random

SEED = 7


def sigmoid(value):
    if value >= 0.0:
        return 1.0 / (1.0 + math.exp(-value))
    positive = math.exp(value)
    return positive / (1.0 + positive)


def softplus(value):
    """log(1 + e^x), which is positive everywhere.

    The scale parameter of a distribution MUST be positive, and that is a
    hard constraint the architecture has to enforce rather than hope for.
    """
    if value > 30.0:
        return value  # e^x overflows and log1p(e^x) is x to full precision
    return math.log1p(math.exp(value))


def matvec(weight, vector, bias):
    return [
        bias[i] + sum(w * v for w, v in zip(row, vector))
        for i, row in enumerate(weight)
    ]


def lstm_cell(inputs, hidden, cell, params):
    """One LSTM step. Four gates over the concatenated input and state."""
    joined = list(inputs) + list(hidden)

    forget = [sigmoid(v) for v in matvec(params['wf'], joined, params['bf'])]
    write = [sigmoid(v) for v in matvec(params['wi'], joined, params['bi'])]
    candidate = [math.tanh(v) for v in matvec(params['wc'], joined, params['bc'])]
    expose = [sigmoid(v) for v in matvec(params['wo'], joined, params['bo'])]

    new_cell = [f * c + i * g for f, c, i, g in zip(forget, cell, write, candidate)]
    new_hidden = [o * math.tanh(c) for o, c in zip(expose, new_cell)]
    return new_hidden, new_cell


def series_scale(history):
    """nu = 1 + mean(history). The plus one keeps an all-zero series usable."""
    return 1.0 + sum(history) / len(history)


def gaussian_log_likelihood(value, mu, sigma):
    """log N(value | mu, sigma). This IS the loss, negated."""
    z = (value - mu) / sigma
    return -0.5 * z * z - math.log(sigma) - 0.5 * math.log(2.0 * math.pi)


def negative_binomial_log_likelihood(count, mu, alpha):
    """The likelihood that is actually right for demand data.

    Gaussian on counts puts probability mass on negative demand, which makes
    every lower quantile meaningless. Nothing in the training loss objects to
    this - it simply produces a confidently incoherent interval.
    """
    shape = 1.0 / alpha
    return (
        math.lgamma(count + shape)
        - math.lgamma(shape)
        - math.lgamma(count + 1.0)
        + shape * math.log(shape / (shape + mu))
        + count * math.log(mu / (shape + mu))
    )


def train_sequence(history, covariates, params, scale):
    """Teacher forcing: the recurrence is unrolled against TRUE observations.

    That is what makes training one parallel graph - and it is also the
    source of exposure bias, because inference conditions on the model's own
    samples instead and the recurrence has never seen that state.
    """
    hidden = [0.0] * params['hidden_size']
    cell = [0.0] * params['hidden_size']
    total_log_likelihood = 0.0

    for t in range(1, len(history)):
        previous = history[t - 1] / scale
        inputs = [previous] + list(covariates[t])
        hidden, cell = lstm_cell(inputs, hidden, cell, params)

        mu = scale * sum(w * h for w, h in zip(params['w_mu'], hidden))
        sigma = scale * softplus(sum(w * h for w, h in zip(params['w_sigma'], hidden)))
        total_log_likelihood += gaussian_log_likelihood(history[t], mu, sigma)

    return -total_log_likelihood, hidden, cell


def sample_path(hidden, cell, last_value, future_covariates, params, scale, rng):
    """One trajectory: draw, feed back, repeat.

    This is the whole reason DeepAR gives a JOINT distribution over the
    horizon rather than independent per-step quantiles. Each step's sample
    genuinely conditions the next, so uncertainty compounds correctly.
    """
    path = []
    previous = last_value

    for t in range(len(future_covariates)):
        inputs = [previous / scale] + list(future_covariates[t])
        hidden, cell = lstm_cell(inputs, hidden, cell, params)

        mu = scale * sum(w * h for w, h in zip(params['w_mu'], hidden))
        sigma = scale * softplus(sum(w * h for w, h in zip(params['w_sigma'], hidden)))

        drawn = rng.gauss(mu, sigma)
        path.append(drawn)
        previous = drawn  # the sample becomes the next input

    return path


def forecast(history, covariates, future_covariates, params, num_paths=200):
    """Warm the state on history, then draw many trajectories."""
    rng = random.Random(SEED)
    scale = series_scale(history)

    _, hidden, cell = train_sequence(history, covariates, params, scale)

    return [
        sample_path(hidden, cell, history[-1], future_covariates, params, scale, rng)
        for _ in range(num_paths)
    ]


def quantile_from_paths(paths, horizon, level):
    """Any quantile is just a percentile of the sampled trajectories."""
    values = sorted(path[horizon] for path in paths)
    index = min(int(level * len(values)), len(values) - 1)
    return values[index]


def lead_time_quantile(paths, level):
    """The joint advantage, made concrete.

    Total demand over a lead time is NOT the sum of per-step medians. Summing
    WITHIN each path first and taking the quantile afterwards preserves the
    correlation between steps; the other order destroys it, and the error is
    usually in the unsafe direction.
    """
    totals = sorted(sum(path) for path in paths)
    index = min(int(level * len(totals)), len(totals) - 1)
    return totals[index]

O(T * H^2) for training and O(S * H_steps * H^2) for S sample paths at inference. Illustrative, not a measured benchmark: with 200 paths over a 14-step horizon that is 2,800 sequential cell evaluations per series, which is why inference rather than training is the operational cost here.

2. Idiomatic

How this language actually wants it written.

"""The same model, with the likelihood as a type and the scale as a contract.

Two things change. The likelihood family becomes a protocol with its own
parameter constraints and sampler, because choosing it is a modelling
decision rather than a flag - and a Gaussian head on count data produces
negative quantiles that nothing in the training loss objects to. And the
per-series scale becomes an object carried with the model, because
recomputing it at inference silently changes every forecast.
"""

from __future__ import annotations

import math
import random
from dataclasses import dataclass
from typing import NamedTuple, Protocol, Sequence


class ShapeMismatch(ValueError):
    """Raised on a shape violation instead of broadcasting past it."""


class LikelihoodMisspecified(ValueError):
    """Raised when the data cannot have come from the chosen family.

    Its own type because the failure is silent otherwise: a Gaussian head on
    count data trains happily and emits negative lower quantiles, and no
    metric in the training loop will mention it.
    """


class UnscaledSeries(ValueError):
    """Raised when a forecast is attempted without the fitted scale.

    The scale is part of the model artifact. Recomputing it at inference from
    a different window changes every forecast, with nothing logged.
    """


def softplus(value: float) -> float:
    """log(1 + e^x), positive everywhere, with the overflow branch.

    Above about 30 the exponential overflows while log1p(exp(x)) equals x to
    full double precision, so the branch is correctness rather than speed.
    """
    if value > 30.0:
        return value
    return math.log1p(math.exp(value))


class Likelihood(Protocol):
    """A distribution family the network emits parameters for.

    Made a protocol rather than an enum because each family owns three
    genuinely different things: how many parameters it needs, how to
    constrain them, and how to sample from it.
    """

    @property
    def parameter_count(self) -> int: ...

    def constrain(self, raw: Sequence[float], scale: float) -> tuple[float, ...]: ...

    def log_probability(self, value: float, parameters: tuple[float, ...]) -> float: ...

    def sample(self, parameters: tuple[float, ...], rng: random.Random) -> float: ...

    def validate_data(self, values: Sequence[float]) -> None: ...


@dataclass(frozen=True)
class Gaussian:
    """Correct for genuinely continuous quantities, and only those."""

    @property
    def parameter_count(self) -> int:
        return 2

    def constrain(self, raw: Sequence[float], scale: float) -> tuple[float, ...]:
        # Both parameters are multiplied back by the series scale, so the
        # network only ever works in normalized space.
        return scale * raw[0], scale * softplus(raw[1])

    def log_probability(self, value: float, parameters: tuple[float, ...]) -> float:
        mu, sigma = parameters
        z = (value - mu) / sigma
        return -0.5 * z * z - math.log(sigma) - 0.5 * math.log(2.0 * math.pi)

    def sample(self, parameters: tuple[float, ...], rng: random.Random) -> float:
        mu, sigma = parameters
        return rng.gauss(mu, sigma)

    def validate_data(self, values: Sequence[float]) -> None:
        """Guard clause for the failure with no other symptom."""
        if all(float(value).is_integer() and value >= 0.0 for value in values):
            raise LikelihoodMisspecified(
                'every observation is a non-negative integer; a Gaussian head will '
                'emit negative lower quantiles. Use NegativeBinomial.'
            )


@dataclass(frozen=True)
class NegativeBinomial:
    """The right choice for count and intermittent demand data."""

    @property
    def parameter_count(self) -> int:
        return 2

    def constrain(self, raw: Sequence[float], scale: float) -> tuple[float, ...]:
        # Mean scales with the series; dispersion does NOT, since it is a
        # shape parameter and scaling it would change the distribution family
        # rather than its location.
        return scale * softplus(raw[0]), softplus(raw[1])

    def log_probability(self, value: float, parameters: tuple[float, ...]) -> float:
        mu, alpha = parameters
        shape = 1.0 / alpha
        return (
            math.lgamma(value + shape)
            - math.lgamma(shape)
            - math.lgamma(value + 1.0)
            + shape * math.log(shape / (shape + mu))
            + value * math.log(mu / (shape + mu))
        )

    def sample(self, parameters: tuple[float, ...], rng: random.Random) -> float:
        # Gamma-Poisson mixture: the standard construction, and the reason
        # the family handles overdispersion that Poisson cannot.
        mu, alpha = parameters
        shape = 1.0 / alpha
        rate = rng.gammavariate(shape, mu / shape)
        return float(_poisson(rate, rng))

    def validate_data(self, values: Sequence[float]) -> None:
        if any(value < 0.0 for value in values):
            raise LikelihoodMisspecified('a count likelihood cannot model negative values')


def _poisson(rate: float, rng: random.Random) -> int:
    """Knuth's method. Fine for the small rates demand data produces."""
    threshold = math.exp(-rate)
    count, product = 0, 1.0
    while product > threshold:
        product *= rng.random()
        count += 1
        if count > 10_000:  # bounded: a huge rate would otherwise spin forever
            break
    return count - 1


@dataclass(frozen=True)
class SeriesScale:
    """The per-series normalizer, carried WITH the model.

    Frozen and explicit because recomputing it at inference from a different
    window silently changes every forecast, and that is one of the most
    common production errors with this model.
    """

    value: float

    @staticmethod
    def from_history(history: Sequence[float]) -> SeriesScale:
        if not history:
            raise ShapeMismatch('cannot scale an empty series')
        # The plus one keeps an all-zero series usable rather than dividing
        # by zero, which real catalogues contain in quantity.
        return SeriesScale(1.0 + math.fsum(history) / len(history))


class ForecastPaths(NamedTuple):
    """Trajectories, not quantiles.

    The paths come back rather than summary statistics because the joint
    structure is the whole point: a quantile of a SUM over the horizon cannot
    be recovered from per-step quantiles.
    """

    paths: list[list[float]]
    scale: SeriesScale
    seed: int

    def marginal_quantile(self, horizon: int, level: float) -> float:
        values = sorted(path[horizon] for path in self.paths)
        return values[min(int(level * len(values)), len(values) - 1)]

    def aggregate_quantile(self, level: float) -> float:
        """Sum WITHIN each path, then take the quantile.

        This ordering is the joint-distribution advantage. Reversing it -
        summing per-step quantiles - destroys the correlation between steps
        and is wrong in the unsafe direction.
        """
        totals = sorted(math.fsum(path) for path in self.paths)
        return totals[min(int(level * len(totals)), len(totals) - 1)]


def forecast(
    history: Sequence[float],
    future_covariates: Sequence[Sequence[float]],
    step: object,
    likelihood: Likelihood,
    scale: SeriesScale | None = None,
    num_paths: int = 200,
    seed: int = 7,
) -> ForecastPaths:
    """Warm the state on history, then draw trajectories.

    `scale` must be the one fitted with the model. Passing None is refused
    rather than silently recomputed, because a recomputed scale is a
    different model.
    """
    if scale is None:
        raise UnscaledSeries(
            'the fitted per-series scale must be supplied; recomputing it here '
            'would silently change every forecast'
        )
    if num_paths < 50:
        raise ShapeMismatch(
            f'{num_paths} paths cannot estimate a tail quantile; use at least 50'
        )

    likelihood.validate_data(history)
    rng = random.Random(seed)
    paths: list[list[float]] = []

    for _ in range(num_paths):
        state = getattr(step, 'warm')(history, scale.value)
        previous = history[-1]
        path: list[float] = []
        for covariate_row in future_covariates:
            state, raw = getattr(step, 'advance')(state, previous / scale.value, covariate_row)
            parameters = likelihood.constrain(raw, scale.value)
            drawn = likelihood.sample(parameters, rng)
            path.append(drawn)
            previous = drawn  # the sample becomes the next input
        paths.append(path)

    return ForecastPaths(paths=paths, scale=scale, seed=seed)


def empirical_coverage(
    paths_per_series: Sequence[ForecastPaths],
    actuals: Sequence[Sequence[float]],
    level: float,
) -> float:
    """What fraction of actuals fall below the nominal quantile.

    Calibration is a separate question from accuracy and it is the one that
    matters operationally: an over-confident model can have excellent median
    error and still cause systematic stockouts.
    """
    hits = 0
    total = 0
    for forecast_paths, actual in zip(paths_per_series, actuals):
        for horizon, observed in enumerate(actual):
            if observed <= forecast_paths.marginal_quantile(horizon, level):
                hits += 1
            total += 1
    if total == 0:
        raise ShapeMismatch('no observations to measure coverage against')
    return hits / total

What changed: Two things change and both are about failures that are otherwise silent. The likelihood family becomes a protocol rather than a flag, because each family genuinely owns three different things — its parameter count, how those parameters must be constrained, and how to sample from it — and because choosing it is a modelling commitment rather than a configuration detail. It also gains a data validator: a Gaussian head on count data trains happily and emits negative lower quantiles, so the family refuses data that is plainly integer and non-negative rather than letting the incoherence reach production. Note the asymmetry in the negative binomial constraint, which is easy to get wrong: the mean scales with the series and the dispersion does not, because scaling a shape parameter changes the family rather than its location. The per-series scale becomes a frozen object carried with the model and is refused rather than recomputed, since a scale computed from a different window at inference is a different model. Forecasts return trajectories rather than quantiles, with the sum-within-path ordering made explicit, because that ordering is the entire joint-distribution advantage.

Explicit type hints on every public signatureDataclasses or NamedTuples over ad-hoc dictsRaise specific exceptions, never bare exceptGuard clauses over nested conditionalsNo mutable default arguments

Same asymptotics; the change is in what fails loudly. Illustrative, not a measured benchmark: the likelihood dispatch adds an indirect call per step, which against a hidden-state matrix product is immeasurable.

3. OptimizedNumPy

Faster, and the justification for each change.

"""Sampling is the cost, so sampling is what batches.

The structural observation: training is teacher-forced and therefore one
parallel graph, but inference rolls S paths forward step by step and that is
two orders of magnitude more work. So the paths become a BATCH DIMENSION -
all S trajectories take step t together, and the Python loop is over the
horizon (tens) rather than over the paths (hundreds).

Three changes:
  1. The four LSTM gates share ONE weight with 4H columns, so the joined
     input is read once rather than four times.
  2. Sample paths batch. (paths, hidden) advances as one GEMM per step.
  3. Series batch on top of that, so the whole panel and every path advance
     in a single (series * paths, hidden) product per step.
"""

from __future__ import annotations

import numpy as np
from numpy.typing import NDArray

FLOAT = np.float32


def softplus_(values: NDArray[np.float32]) -> NDArray[np.float32]:
    """In-place, with the overflow branch as a select rather than a loop.

    Above about 30 the exponential overflows while log1p(exp(x)) equals x to
    full precision, so the large branch is correctness and not speed.
    """
    large = values > 30.0
    safe = np.where(large, 0.0, values)
    np.exp(safe, out=safe)
    np.log1p(safe, out=safe)
    return np.where(large, values, safe)


class BatchedLstm:
    """One cell, evaluated for an arbitrary leading batch.

    The four gates share a single weight of 4H columns. Four separate
    products would read the joined input four times; one reads it once, and
    the split afterwards is a view rather than a copy.
    """

    def __init__(self, input_size: int, hidden_size: int) -> None:
        self._hidden = hidden_size
        self._weight = np.zeros((input_size + hidden_size, 4 * hidden_size), dtype=FLOAT)
        self._bias = np.zeros(4 * hidden_size, dtype=FLOAT)

    def step(
        self,
        inputs: NDArray[np.float32],
        hidden: NDArray[np.float32],
        cell: NDArray[np.float32],
    ) -> tuple[NDArray[np.float32], NDArray[np.float32]]:
        """inputs (batch, input_size), hidden/cell (batch, hidden_size)."""
        joined = np.concatenate((inputs, hidden), axis=1)
        gates = joined @ self._weight
        gates += self._bias

        h = self._hidden
        forget, write, candidate, expose = (
            gates[:, :h], gates[:, h:2 * h], gates[:, 2 * h:3 * h], gates[:, 3 * h:],
        )

        # Three sigmoids and a tanh, all in place over the gates buffer, so
        # the activated gates never exist as separate arrays.
        for block in (forget, write, expose):
            np.negative(block, out=block)
            np.exp(block, out=block)
            block += 1.0
            np.reciprocal(block, out=block)
        np.tanh(candidate, out=candidate)

        cell *= forget
        cell += write * candidate
        new_hidden = np.tanh(cell) * expose
        return new_hidden, cell


def sample_paths(
    lstm: BatchedLstm,
    warm_hidden: NDArray[np.float32],
    warm_cell: NDArray[np.float32],
    last_values: NDArray[np.float32],
    future_covariates: NDArray[np.float32],
    mu_weight: NDArray[np.float32],
    sigma_weight: NDArray[np.float32],
    scale: NDArray[np.float32],
    num_paths: int,
    rng: np.random.Generator,
) -> NDArray[np.float32]:
    """Returns (series, paths, horizon).

    Every path of every series advances together. The Python loop is over the
    HORIZON - tens of iterations - rather than over hundreds of paths times
    thousands of series, which is the whole structural change.
    """
    num_series, hidden_size = warm_hidden.shape
    horizon, covariate_width = future_covariates.shape[1], future_covariates.shape[2]
    rows = num_series * num_paths

    # Tile the warmed state across paths. Every path of a series starts from
    # the same state and diverges only through its own draws.
    hidden = np.repeat(warm_hidden, num_paths, axis=0).astype(FLOAT)
    cell = np.repeat(warm_cell, num_paths, axis=0).astype(FLOAT)
    previous = np.repeat(last_values, num_paths).astype(FLOAT)
    row_scale = np.repeat(scale, num_paths).astype(FLOAT)

    out = np.empty((rows, horizon), dtype=FLOAT)
    inputs = np.empty((rows, 1 + covariate_width), dtype=FLOAT)

    for step in range(horizon):
        # Pre-allocated input buffer: the concatenation of the fed-back value
        # and the covariates is written in place every step.
        np.divide(previous, row_scale, out=inputs[:, 0])
        inputs[:, 1:] = np.repeat(future_covariates[:, step, :], num_paths, axis=0)

        hidden, cell = lstm.step(inputs, hidden, cell)

        mu = (hidden @ mu_weight) * row_scale
        sigma = softplus_(hidden @ sigma_weight) * row_scale

        # One vectorized draw for every path of every series at once. This is
        # the operation the whole restructuring exists to batch.
        drawn = rng.normal(mu, sigma).astype(FLOAT)
        out[:, step] = drawn
        previous = drawn  # the sample becomes the next input

    return out.reshape(num_series, num_paths, horizon)


def marginal_quantiles(
    paths: NDArray[np.float32], levels: NDArray[np.float32]
) -> NDArray[np.float32]:
    """(series, levels, horizon) from (series, paths, horizon).

    One partition-based quantile over the path axis, not a sort: the quantile
    needs a selection, and ordering the rest is discarded work.
    """
    return np.quantile(paths, levels, axis=1, method='linear').transpose(1, 0, 2)


def aggregate_quantiles(
    paths: NDArray[np.float32], levels: NDArray[np.float32]
) -> NDArray[np.float32]:
    """Sum WITHIN each path, then take the quantile over paths.

    This ordering is the joint-distribution advantage, and reversing it -
    summing per-step quantiles - destroys the correlation between steps. The
    difference is large for any aggregating decision and usually unsafe.
    """
    totals = paths.sum(axis=2, dtype=FLOAT)
    return np.quantile(totals, levels, axis=1).T


def empirical_coverage(
    paths: NDArray[np.float32],
    actuals: NDArray[np.float32],
    level: float,
) -> float:
    """Fraction of actuals below the nominal quantile, in one pass.

    Calibration is separate from accuracy and it is the operational one: an
    over-confident model can have excellent median error and still cause
    systematic stockouts.
    """
    threshold = np.quantile(paths, level, axis=1)
    return float((actuals <= threshold).mean())


def path_count_for_tail(level: float, relative_error: float = 0.1) -> int:
    """How many paths a tail quantile actually needs.

    The standard error of an empirical quantile scales as
    sqrt(p(1-p)/n) / density, so a far tail needs far more paths than the
    body. Worth computing rather than defaulting to 100 and hoping - a 1st
    percentile from 100 paths is one order statistic and is pure noise.
    """
    tail = min(level, 1.0 - level)
    return int(np.ceil(tail * (1.0 - tail) / (relative_error * tail) ** 2))

What changed: The restructuring follows the profile rather than intuition: training is teacher-forced and already one parallel graph, while inference rolls hundreds of paths forward step by step and is two orders of magnitude more work — so the paths become a batch dimension. Every trajectory of every series takes step t together, which turns the outer Python loop from hundreds of paths times thousands of series into tens of horizon steps. The four LSTM gates share a single weight of four times the hidden width, so the joined input is read once instead of four times for identical arithmetic, and the three sigmoids and the tanh run in place over that buffer so the activated gates never exist separately. The fed-back value and the covariates are written into a pre-allocated input buffer each step rather than concatenated afresh. Quantiles use a partition-based selection rather than a sort, since ordering the discarded paths is wasted work, and the aggregate quantile sums within each path before selecting — the ordering that is the entire joint-distribution advantage. The path-count helper exists because a 1st percentile from a hundred paths is a single order statistic and is pure noise, which is worth computing rather than assuming.

  • Eliminate Python-level loops over samples

    Sample paths and series both become rows of one array, so the only remaining interpreter loop is over the horizon rather than over paths times series.

    Trade-off: All paths for all series are live simultaneously, so peak memory is series times paths times hidden width — on a large panel with a thousand paths that is the binding constraint and forces chunking by series.

  • Fuse adjacent operations so intermediates are never materialized

    The four gates come from one product and their activations run in place over that buffer, and the cell update writes through itself rather than allocating.

    Trade-off: The raw gate pre-activations are destroyed, so diagnosing a saturated forget gate — the classic reason a recurrent forecaster stops using its history — needs an unfused pass.

  • Pre-allocate output arrays and use in-place operations

    The input buffer, the output trajectory array and the cell state are all written into existing memory, so a thousand-step sampling loop never enters the allocator.

    Trade-off: The cell array is mutated by the step function, so a caller that keeps a reference to the warmed state finds it modified — a real hazard when the same warm state seeds several independent sampling runs.

  • Ensure contiguous memory layout and a single dtype

    float32 throughout keeps the per-step product on the BLAS fast path and halves the bandwidth of a state tensor that is re-read every horizon step.

    Trade-off: float32 sampling accumulates drift along a trajectory because each step conditions on the previous draw, so the error compounds down the path rather than cancelling — visible as slightly over-dispersed tails at long horizons.

Sampling becomes O(horizon) array steps instead of O(paths * series * horizon) interpreter steps. Illustrative, not a measured benchmark: with the paths batched the remaining cost is the per-step GEMM, which is small and sequential — so the wall clock is set by horizon length rather than by path count, which is the opposite of the naive version.