ergodicDocs
Products: switch from ergodic to another product
Get started

ReferenceForecasting

Forecasting

Forecasting: Bayesian trajectories with causal structure in them. Import from ergodic.forecast.

forecast() is the entry point: a typed time series and a horizon in, a ForecastResult out, with a posterior behind every step rather than a point and an error bar bolted on. Underneath it, a TemporalMechanism per series holds that series' structural equation. StructuralMechanism is the workhorse, a damped local linear trend with a season and a regression on parents, composed from those pieces so a fitted forecast can be taken apart again; CausalOU puts the drivers in the long-run attractor instead, and Bass is the S-curve for a launch with no history to extrapolate.

Give forecast() a window graph and the series read each other. Staged fitting runs one node at a time in the order schedule() lays out, handing each node's whole forecast posterior down to its children rather than a path; a small graph is fitted jointly instead, so the nodes keep their joint posterior. A driver whose future is known arrives through scenario=, and one with neither a mechanism nor a scenario is refused rather than quietly frozen.

The rest of the pillar needs no sampler: the result object every model returns, the metrics, the naive baselines that give a backtest an honest floor, the rolling-origin harness itself, the window-graph builder, the staged-fitting plan, the reconciliation menu, and the per-node scalers.

Two ideas hold it together. Predictive structure is a plain DAG over lagged node names, which is exactly what temporal discovery already returns, so a discovered graph feeds a forecast with no translation. And any object that fits a series and forecasts a horizon (ForecastModel) can be backtested, so a baseline, the Bayesian forecaster, and somebody else's model all land in one table.

pymc is imported inside the methods that sample, so the bare install never loads it; pip install 'ergodic[forecast]' adds it. Sampling runs through PyMC's JAX backend when jax and numpyro are installed, which measured about four times faster than the C backend on this model.

A note on the name, because this pillar is the one place in the package where a name is shadowed. ergodic.forecast at the top level is the function: from ergodic import forecast gives something callable. This subpackage is reached with from ergodic.forecast import backtest, which goes through the import system and is unaffected. But import ergodic.forecast as mod binds the function rather than the module, because Python prefers the attribute when one exists.

Also exported from ergodic: backtest, forecast.

AUTO_JOINT_MAX_NODES#

python
AUTO_JOINT_MAX_NODES: int = 4

fit="auto" fits a graph jointly at or below this many modeled nodes. The joint model keeps the posterior correlation between nodes, and it costs one sampler over everything at once. Four is where that stops being the cheaper trade on the models this pillar fits; past it, staging is what makes a graph affordable, and what it drops is the between-node correlation rather than the handoff.

FIT_STRATEGIES#

python
FIT_STRATEGIES: tuple = ('auto', 'independent', 'staged', 'joint')

Every value fit= takes.

PROPORTION_RULES#

python
PROPORTION_RULES: tuple = ('historical', 'forecast')

How top_down and middle_out split a parent between its children.

RECONCILE_METHODS#

python
RECONCILE_METHODS: tuple = ('bottom_up', 'top_down', 'middle_out', 'mint', 'none')

The strategy menu, in the order the module docstring introduces it.

SEASON#

python
SEASON: str = 'season'

The factor key for seasonality in the attribution contract. Driver factors are named by their design key, so a driver column called "season" would collide with this one; factors() refuses that rather than silently merging the two bars.

WEIGHT_RULES#

python
WEIGHT_RULES: tuple = ('posterior', 'mint_shrink')

Where mint gets the diagonal of its weight matrix.

Attribution#

python
class Attribution(
    nodes: tuple[NodeAttribution, ...],
    method: str = 'sequential',
    reduce: str = 'sum',
) -> None

Every node and window one explain() call asked for.

Field Type Default
nodes tuple[NodeAttribution, ...] required
method str 'sequential'
reduce str 'sum'

Attribution.node_names#

python
property Attribution.node_names: tuple[str, ...]

Every node explained, in order, without repeats.

Attribution.windows#

python
property Attribution.windows: tuple[str, ...]

Every window label, in order, without repeats.

Attribution.to_frame#

python
def Attribution.to_frame(level: float = 0.9) -> pd.DataFrame

Every bar of every node and window, one row each.

BacktestFold#

python
class BacktestFold(
    index: int,
    cutoff: int,
    horizon: int,
    metrics: Mapping[str, Mapping[str, float]],
    forecasts: Mapping[str, npt.NDArray[np.float64]],
    actuals: Mapping[str, npt.NDArray[np.float64]],
    fit_seed: int = 0,
    forecast_seed: int = 0,
    nodes_unscored: tuple[str, ...] = (),
    diagnostics: FoldDiagnostics | None = None,
) -> None

One train-test split: where it was cut, what was forecast, how it scored.

Field Type Default
index int required
cutoff int required
horizon int required
metrics Mapping[str, Mapping[str, float]] required
forecasts Mapping[str, npt.NDArray[np.float64]] required
actuals Mapping[str, npt.NDArray[np.float64]] required
fit_seed int 0
forecast_seed int 0
nodes_unscored tuple[str, ...] ()
diagnostics FoldDiagnostics | None None

BacktestFold.nodes#

python
property BacktestFold.nodes: tuple[str, ...]

The scored series, in forecast order.

BacktestReport#

python
class BacktestReport(
    model: str,
    mode: str,
    test_size: int,
    level: float,
    folds: tuple[BacktestFold, ...],
    nodes: tuple[str, ...],
    metrics: tuple[str, ...],
    nodes_unscored: tuple[str, ...] = (),
) -> None

Every fold of one backtest, plus the tables that read across them.

Field Type Default
model str required
mode str required
test_size int required
level float required
folds tuple[BacktestFold, ...] required
nodes tuple[str, ...] required
metrics tuple[str, ...] required
nodes_unscored tuple[str, ...] ()

BacktestReport.cutoffs#

python
property BacktestReport.cutoffs: tuple[int, ...]

The training-set end position of each fold.

BacktestReport.values#

python
def BacktestReport.values(node: str, metric: str) -> tuple[float, ...]

One metric for one node, fold by fold. NaN where the fold did not carry it.

BacktestReport.summary#

python
def BacktestReport.summary() -> pd.DataFrame

Mean metric per node across folds: nodes down, metrics across.

BacktestReport.to_frame#

python
def BacktestReport.to_frame() -> pd.DataFrame

A tidy frame: one row per fold, node, and metric.

BacktestReport.diagnostics#

python
def BacktestReport.diagnostics() -> pd.DataFrame

One row per fold: how the sampler behaved where the score came from.

Empty for a model that does no sampling. Read it beside summary(): a fold whose divergence share is high or whose effective sample size is small is a fold whose metrics are worth less, and that is invisible in the scores themselves.

BacktestReport.compare#

python
def BacktestReport.compare(other: BacktestReport) -> pd.DataFrame

This report against another, metric by metric, with the direction applied.

delta is this report's mean minus other's. improved reads the direction of each metric: lower is better for the error measures, higher for the accuracy family, and closer to the nominal level for coverage. Comparing folds cut at different origins is on the caller; run both models through the same split to keep it fair.

A metric only one side carries (MASE, say, when one report was scored without a training window) still gets a row, with NaN on the side that lacks it and improved False. A one-sided metric is not a comparison.

Bass#

python
class Bass(
    launch_index: int = 0,
    p_prior: tuple[float, float] = (2.0, 50.0),
    q_prior: tuple[float, float] = (2.0, 5.0),
    m_prior: tuple[float, float] | None = None,
    scaler: Scaler | None = None,
) -> None

The Bass diffusion curve for a new product's adoption.

Options:

Parents are not read. A driver that moves adoption is a real thing to want and it is a different model (the hybrid correction the plan defers), so passing one raises rather than being ignored.

Sites, since the naming contract is load-bearing: the free variables are {node}_p, {node}_q, {node}_M and {node}_sigma_obs, and a forecasting model also records two deterministics, {node}_mu (the incremental adoption path, which is the mean the likelihood reads) and {node}_cumulative (the share of the market adopted by each step, which is what the recursion actually carries). Nothing else registers a cumulative; it is named here because anything reading a trace by name, an attribution replay above all, has to know what is in it.

Field Type Default
launch_index int 0
p_prior tuple[float, float] (2.0, 50.0)
q_prior tuple[float, float] (2.0, 5.0)
m_prior tuple[float, float] | None None
scaler Scaler | None None

Bass.scaler_for#

python
def Bass.scaler_for(values: Array) -> Scaler

Divide by the series' own mean, not its median.

The automatic choice would take the median of the positives, and on a diffusion curve that is the wrong anchor by an order of magnitude or two: most of a completed S-curve is tail, so its median sits near zero and dividing by it would put the peak in the hundreds and the market size in the thousands. The mean is the anchor that matches what the parameter means, because the quantity of interest here is the area under the curve: divide by the mean and the market size lands near the number of steps, whatever the series is measured in.

The floor comes off too. A ratio scaler normally clips from below to keep a positive likelihood away from zero, and a diffusion tail genuinely is zero, so clipping it would invent adoption that never happened.

Bass.baseline_label#

python
def Bass.baseline_label() -> str

Nothing here is a trend: the whole path is the diffusion curve.

Bass.factors#

python
def Bass.factors(
    node: str,
    components: Mapping[str, Array],
) -> tuple[str, ...] | None

No factors at all, which is a real answer rather than a refusal.

Pure diffusion has no drivers and no season, so the whole mean path is baseline. Saying so beats falling through to replay, which would spend a sampler to discover the same thing.

Bass.compose#

python
def Bass.compose(
    node: str,
    components: Mapping[str, Array],
    posterior: Mapping[str, Array],
    *,
    active: Collection[str],
) -> Array

The adoption path itself, whatever is switched on, since nothing can be off.

Bass.peak_step#

python
def Bass.peak_step(p: float, q: float) -> float

Where the incremental-adoption curve peaks: ln(q / p) / (p + q), plus the launch.

Bass.build#

python
def Bass.build(
    name: str,
    *,
    obs: Array | None,
    parents: Mapping[str, Array],
    T: int,
    horizon: int,
    mask: npt.NDArray[np.bool_],
) -> Any

Register p, q, M and return mu, the incremental-adoption path.

Bass.site#

python
def Bass.site(node: str, site: str) -> str

The name of one site: "sales" and "phi" give "sales_phi".

Every mechanism registers through this, and nothing renames a site afterwards. Posterior replay, frozen-parent injection, and attribution zeroing all match by name.

Bass.predictive_site#

python
def Bass.predictive_site(node: str) -> str

The site holding the horizon's predictive draws in a forecasting model.

Bass.mean_site#

python
def Bass.mean_site(node: str) -> str

The deterministic holding the mean path, length T + horizon.

Registered by a forecasting model only, never by a fitting one, which is why in-sample fitted values are read off the forecast model's first T rows rather than out of the trace.

CausalOU#

python
class CausalOU(
    period: int | None = None,
    seasonality: str | None = 'auto',
    n_harmonics: int = 2,
    robust: bool = False,
    reg_scale: float = 0.2,
    scaler: Scaler | None = None,
) -> None

A mean-reverting level whose long-run attractor the drivers set.

Options:

The priors, on the scaled series: kappa ~ Beta(2, 2), mu0 ~ Normal(1, 0.5), sigma_level ~ HalfNormal(0.3), sigma_obs ~ HalfNormal(0.5), level0 ~ Normal(1, 0.5).

Field Type Default
period int | None None
seasonality str | None 'auto'
n_harmonics int 2
robust bool False
reg_scale float 0.2
scaler Scaler | None None

CausalOU.seasonal_form#

python
def CausalOU.seasonal_form() -> tuple[str | None, int | None]

The seasonal form actually used, as (kind, period). Same rule as S2's.

CausalOU.scaler_for#

python
def CausalOU.scaler_for(values: Array) -> Scaler

The fitted scaler: the override when given, else the automatic choice.

CausalOU.baseline_label#

python
def CausalOU.baseline_label() -> str

A mean-reverting level has no slope, so calling it a trend would mislead.

CausalOU.factors#

python
def CausalOU.factors(
    node: str,
    components: Mapping[str, Array],
) -> tuple[str, ...] | None

Seasonality first, then one key per driver, same as the structural workhorse.

CausalOU.compose#

python
def CausalOU.compose(
    node: str,
    components: Mapping[str, Array],
    posterior: Mapping[str, Array],
    *,
    active: Collection[str],
) -> Array

The mean path with only active factors on, per draw.

A driver here does not add to the level, it moves the attractor the level walks toward, so its contribution reaches the series through the whole reversion recursion rather than landing in one step. That recursion is linear in the attractor, which is what makes the decomposition exact:

python
level = own_dynamics + sum_b pull(reg_b)

where pull is the same first-order filter the scan runs (B_t = (1 - kappa) B_{t-1} + kappa reg_t, starting at zero) and own_dynamics is everything else (the initial level, the baseline attractor mu0, and the innovations). Switching a driver off subtracts its filtered pull from the recorded level, and the parts add back up to the level the model actually walked.

Writing that filter here restates one line of build() in numpy, which is a real risk (the two could drift apart). It is guarded rather than trusted: composing with every factor on has to reproduce the model's own mu, and explain() measures exactly that gap as closure_error.

CausalOU.build#

python
def CausalOU.build(
    name: str,
    *,
    obs: Array | None,
    parents: Mapping[str, Array],
    T: int,
    horizon: int,
    mask: npt.NDArray[np.bool_],
) -> Any

Register every site and return mu, the mean path of length T + horizon.

CausalOU.site#

python
def CausalOU.site(node: str, site: str) -> str

The name of one site: "sales" and "phi" give "sales_phi".

Every mechanism registers through this, and nothing renames a site afterwards. Posterior replay, frozen-parent injection, and attribution zeroing all match by name.

CausalOU.predictive_site#

python
def CausalOU.predictive_site(node: str) -> str

The site holding the horizon's predictive draws in a forecasting model.

CausalOU.mean_site#

python
def CausalOU.mean_site(node: str) -> str

The deterministic holding the mean path, length T + horizon.

Registered by a forecasting model only, never by a fitting one, which is why in-sample fitted values are read off the forecast model's first T rows rather than out of the trace.

Drift#

python
class Drift(*, n_draws: int = 500) -> None

Extend the line from the first observation to the last.

The slope is (y_T - y_1) / (T - 1), which is what a random walk with drift estimates. The interval widens with sqrt(h (1 + h / (T - 1))): the random-walk term plus the uncertainty in the slope itself.

Field Type Default
n_draws int 500

Drift.fit#

python
def Drift.fit(data: TimeSeriesData, *, seed: int = 0) -> Self

Read the series off data; the fitted copy comes back, self is untouched.

Drift.forecast#

python
def Drift.forecast(horizon: int, *, seed: int = 0) -> ForecastResult

Draw n_draws sample paths horizon steps past the fitted data.

Drift.fitted#

python
property Drift.fitted: bool

True once fit() has run.

FactorContribution#

python
class FactorContribution(name: str, kind: str, draws: Array) -> None

One bar: how much of the window's total this piece of the model accounts for.

draws is the per-draw contribution, so the bar carries a posterior rather than a point. value is its mean, which is what makes the bars close: means add up even when medians do not.

Field Type Default
name str required
kind str required
draws Array required

FactorContribution.value#

python
property FactorContribution.value: float

The posterior mean of this bar.

FactorContribution.credible_interval#

python
def FactorContribution.credible_interval(
    level: float = 0.9,
) -> tuple[float, float]

The equal-tailed interval of this bar at level.

FitStrategy#

python
class FitStrategy(
    strategy: str,
    requested: str = 'auto',
    n_mcmc_nodes: int = 0,
    auto_threshold: int | None = None,
) -> None

How a forecast was fitted, and why that one.

strategy is what ran: "independent" (one model per series, nothing crossing between them), "staged" (one model per node, posteriors handed down the graph), or "joint" (one model over every node, so their posteriors stay correlated). requested is what the caller asked for, which is "auto" most of the time.

n_mcmc_nodes and auto_threshold are the arithmetic behind an "auto" choice: a graph with no more modeled nodes than the threshold is fitted jointly, and anything larger is staged. Carrying both means the choice can be read off a result rather than reasoned about.

Field Type Default
strategy str required
requested str 'auto'
n_mcmc_nodes int 0
auto_threshold int | None None

FoldDiagnostics#

python
class FoldDiagnostics(
    n_nodes: int,
    divergences: int,
    max_divergence_share: float,
    min_ess: float,
    worst_node: str,
) -> None

What the sampler reported over one fold, reduced to the two numbers that matter.

A backtest refits everything at every origin, so it is the one place a sampling problem can hide behind a decent-looking score: an early fold that barely converged still contributes its metrics. Carrying the worst divergence share and the smallest effective sample size across the fold's nodes puts that where it can be read, which is what diagnostics is for. It is information rather than a verdict: nothing here fails a fold, exactly as a single fit warns and continues.

Field Type Default
n_nodes int required
divergences int required
max_divergence_share float required
min_ess float required
worst_node str required

ForecastModel#

python
class ForecastModel(*args, **kwargs)

A model that fits a time series and forecasts forward from its end.

ForecastModel.fit#

python
def ForecastModel.fit(
    data: TimeSeriesData,
    *,
    seed: int = 0,
) -> ForecastModel

Fit on data and return the fitted model (a new object, not self).

ForecastModel.forecast#

python
def ForecastModel.forecast(horizon: int, *, seed: int = 0) -> ForecastResult

Forecast horizon steps past the end of the fitted data.

ForecastResult#

python
class ForecastResult(
    series: Mapping[str, npt.NDArray[np.float64]],
    horizon: int,
    kind: str = 'draws',
    start: Any | None = None,
    mechanisms: Mapping[str, Any] | None = None,
    graph: MixedGraph | None = None,
    schedule: Any | None = None,
    strategy: FitStrategy | None = None,
    fits: Mapping[str, Any] | None = None,
    views: Mapping[str, Any] | None = None,
    index: tuple[Any, ...] | None = None,
    diagnostics: Mapping[str, Any] | None = None,
    reconciliation: str | None = None,
    coherent: bool | None = None,
    residual_var: Mapping[str, float] | None = None,
) -> None

Forecast draws for one or more series.

series maps a node name to its draws, shape (S, H): S sample paths of length horizon. Every node carries the same S and the same H. kind records where the draws came from ("draws" for a sampled forecast). start is the label of the first forecast step when the data made it computable, else None.

Each array is copied on the way in and frozen, so the result owns its draws: the caller's array stays writable, and nothing the caller does to it afterwards can reach inside the result.

Draws are permissive about NaN: a masked observation window can legitimately leave one, and a metric that meets it says so rather than the constructor.

Field Type Default
series Mapping[str, npt.NDArray[np.float64]] required
horizon int required
kind str 'draws'
start Any | None None
mechanisms Mapping[str, Any] | None None
graph MixedGraph | None None
schedule Any | None None
strategy FitStrategy | None None
fits Mapping[str, Any] | None None
views Mapping[str, Any] | None None
index tuple[Any, ...] | None None
diagnostics Mapping[str, Any] | None None
reconciliation str | None None
coherent bool | None None
residual_var Mapping[str, float] | None None

ForecastResult.nodes#

python
property ForecastResult.nodes: tuple[str, ...]

The forecast series names, in insertion order.

ForecastResult.n_draws#

python
property ForecastResult.n_draws: int

The number of sample paths per node.

ForecastResult.draws#

python
def ForecastResult.draws(node: str | None = None) -> npt.NDArray[np.float64]

The raw draws for one node, shape (S, H).

ForecastResult.median#

python
def ForecastResult.median(
    node: str | None = None,
) -> npt.NDArray[np.float64]

The per-step posterior median, shape (H,).

ForecastResult.mean#

python
def ForecastResult.mean(node: str | None = None) -> npt.NDArray[np.float64]

The per-step posterior mean, shape (H,).

ForecastResult.interval#

python
def ForecastResult.interval(
    node: str | None = None,
    level: float = 0.9,
) -> tuple[npt.NDArray[np.float64], npt.NDArray[np.float64]]

The equal-tailed interval at level, two arrays of shape (H,).

ForecastResult.explain#

python
def ForecastResult.explain(
    *,
    windows: Any | None = None,
    nodes: str | Sequence[str] | None = None,
    method: str = 'sequential',
    reduce: str = 'sum',
    max_replays: int = 200,
) -> Attribution

Take the forecast apart: baseline, seasonality, and one bar per driver.

Returns an Attribution, one NodeAttribution per node and window, each a waterfall whose bars carry posteriors and add up to the window's total.

Parameters windows One Window or several. The default is a single window over the whole horizon. A window's bounds are integer steps or labels on the result's own time axis. nodes Which series to explain; every modelled one by default. A node that is an aggregate of its children, or whose path was supplied through scenario=, has no model of its own and is refused by name. method "sequential" switches the factors on one at a time in composition order, so each bar is what that factor added given the ones before it. "loo" switches each one off from the full model instead and books what no single factor owns as an Interaction bar. Both close on the total; they answer different questions. reduce "sum" for a flow (units, revenue), "mean" for a rate. Applied per draw over the window. max_replays The ceiling on posterior-predictive passes, which only a mechanism that cannot decompose itself ever spends.

Notes The bars explain the model's mean path. The result's own draws also carry observation noise, and on a reconciled result they carry whatever the reconciliation moved, so total and forecast_total are both reported rather than assumed equal.

ForecastResult.coherence_error#

python
def ForecastResult.coherence_error(
    hierarchy: Hierarchy,
    *,
    relative: bool = True,
) -> float

The worst gap between a parent and its children, over every draw and step.

Zero means the forecast keeps the hierarchy's arithmetic promise exactly. The default reading is relative: the gap divided by the size of the parent it belongs to, so the number means the same thing on a series in units and on the same series in billions. That is the honest contract, because the residual reconciliation leaves behind is rounding, and rounding is relative: adding four float64 numbers near 1e9 cannot do better than about 1e-7 absolute, and calling that incoherent would be wrong. Anything a strategy leaves should read as machine epsilon here, around 1e-16, whatever the series is measured in.

Pass relative=False for the gap in the units of the series, which is what to look at when a real disagreement needs sizing.

Every node of the tree has to be in the result, since a missing one is a gap that cannot be measured rather than a gap of zero.

ForecastResult.to_frame#

python
def ForecastResult.to_frame(level: float = 0.9) -> pd.DataFrame

A tidy frame: one row per node and step, with the median and the band.

Forecaster#

python
class Forecaster(
    options: Mapping[str, Any] = <factory>,
    name: str = 'Forecaster',
    data: TimeSeriesData | None = None,
    seed: int = 0,
) -> None

The facade as a model the backtester can drive.

forecast() fits and forecasts in one call, and ForecastModel splits those in two, so this holds the configuration and the training data between them. Everything forecast() accepts except data and horizon goes in options and is passed through unchanged.

The seed discipline lines up with the harness's own by construction. A fold fits at seed + fold and forecasts at seed + 1000 + fold; this reads the fit seed, and the facade derives each node's predictive seed as that node's fit seed plus a thousand. So a single-series backtest's recorded forecast_seed is exactly the seed the predictive pass ran on, and a multi-series one keeps the same offset per node.

Field Type Default
options Mapping[str, Any] factory
name str 'Forecaster'
data TimeSeriesData | None None
seed int 0

Forecaster.fit#

python
def Forecaster.fit(data: TimeSeriesData, *, seed: int = 0) -> Forecaster

Take the training window and the fold's seed; nothing samples yet.

Forecaster.forecast#

python
def Forecaster.forecast(horizon: int, *, seed: int = 0) -> ForecastResult

Fit the stored window and continue it horizon steps.

The seed argument is part of the protocol and is deliberately not read: this model does its fitting here, so the seed that matters is the one fit() was given, and the predictive pass runs at that seed plus a thousand per node, which is the same convention the harness uses.

FrozenParent#

python
class FrozenParent(
    base: str,
    draws: Array,
    history: Array,
    columns: tuple[tuple[str, int], ...],
) -> None

A parent whose horizon arrives as its own posterior draws.

This is the staged handoff. The child's forecast model declares one fresh site {base}_fc of length H; the parent's stored forecast draws, transformed into the parent's scaled space, are written into a copy of the child's trace under that name; and the posterior-predictive pass replays them by name like any other fitted site. Nothing collapses to a point: every draw of the child sees the matching draw of its parent, so the parent's whole forecast distribution reaches the child's band.

draws is (S, H) on the parent's scaled space, with S equal to the child's own draw count. columns lists the design keys that read this parent and the lag each reads it at.

Field Type Default
base str required
draws Array required
history Array required
columns tuple[tuple[str, int], ...] required

FrozenParent.site#

python
property FrozenParent.site: str

The name of the fresh site the child's forecast model declares.

IdentityScaler#

python
class IdentityScaler() -> None

Leave the data alone. For anything already on a natural scale, like an index.

IdentityScaler.fit#

python
def IdentityScaler.fit(x: npt.NDArray[Any] | Any) -> Self

Learn the scale from x and return the fitted scaler.

IdentityScaler.transform#

python
def IdentityScaler.transform(
    x: npt.NDArray[Any] | Any,
) -> npt.NDArray[np.float64]

Map data onto the model scale.

IdentityScaler.inverse#

python
def IdentityScaler.inverse(
    x: npt.NDArray[Any] | Any,
) -> npt.NDArray[np.float64]

Map model-scale values back to the data's own units.

JointFit#

python
class JointFit(
    nodes: tuple[JointNode, ...],
    trace: Any,
    sites: Mapping[str, tuple[str, ...]],
    diagnostics: Mapping[str, NodeDiagnostics],
) -> None

One posterior over every node of a joint model.

sites maps each node to the names it registered, recorded while the model was built rather than guessed from a name prefix, so two nodes whose names share a prefix are still told apart exactly.

Field Type Default
nodes tuple[JointNode, ...] required
trace Any required
sites Mapping[str, tuple[str, ...]] required
diagnostics Mapping[str, NodeDiagnostics] required

JointFit.n_obs#

python
property JointFit.n_obs: int

The number of time steps the model was built over.

JointFit.node_of#

python
def JointFit.node_of(name: str) -> JointNode

One node's slot, by name.

JointNode#

python
class JointNode(
    node: str,
    mechanism: TemporalMechanism,
    scaler: Scaler,
    obs_scaled: Array,
    mask: BoolArray,
    parents: tuple[ParentColumn, ...] = (),
) -> None

One node's slot in a joint model: what to build and what it reads.

Field Type Default
node str required
mechanism TemporalMechanism required
scaler Scaler required
obs_scaled Array required
mask BoolArray required
parents tuple[ParentColumn, ...] ()

LogScaler#

python
class LogScaler(offset: float = 1.0) -> None

log(x + offset), for a positive series with a long right tail.

offset keeps the log away from zero. A value at or below -offset has no log, so transform() refuses it rather than quietly returning negative infinity or NaN.

The round trip is exact to floating point only where the log is well conditioned. Far below the offset, exp(log(x + offset)) - offset loses the small difference between two large numbers, so relative accuracy decays to roughly 1e-7 rather than the 1e-12 the other scalers hold.

Field Type Default
offset float 1.0

LogScaler.transform#

python
def LogScaler.transform(
    x: npt.NDArray[Any] | Any,
) -> npt.NDArray[np.float64]

Map data onto the model scale.

LogScaler.inverse#

python
def LogScaler.inverse(x: npt.NDArray[Any] | Any) -> npt.NDArray[np.float64]

Map model-scale values back to the data's own units.

LogScaler.fit#

python
def LogScaler.fit(x: npt.NDArray[Any] | Any) -> Self

Learn the scale from x and return the fitted scaler.

Naive#

python
class Naive(*, n_draws: int = 500) -> None

Repeat the last observation. The random-walk forecast, and the honest floor.

The interval widens with sqrt(h): a random walk's uncertainty grows with the number of steps taken.

Field Type Default
n_draws int 500

Naive.fit#

python
def Naive.fit(data: TimeSeriesData, *, seed: int = 0) -> Self

Read the series off data; the fitted copy comes back, self is untouched.

Naive.forecast#

python
def Naive.forecast(horizon: int, *, seed: int = 0) -> ForecastResult

Draw n_draws sample paths horizon steps past the fitted data.

Naive.fitted#

python
property Naive.fitted: bool

True once fit() has run.

NodeAttribution#

python
class NodeAttribution(
    node: str,
    window: Window,
    steps: tuple[int, int],
    method: str,
    reduce: str,
    engine: str,
    baseline: FactorContribution,
    factors: tuple[FactorContribution, ...],
    total_draws: Array,
    forecast_draws: Array,
    n_replays: int = 0,
) -> None

One node's waterfall over one window.

The bars are baseline (the node's own dynamics, whatever the mechanism calls them) followed by factors, in the order they were composed. They add up to total, the model's mean path aggregated over the window; closure_error is what is left over, and it should be rounding.

forecast_total is the same window read off the result's own draws. It differs from total by the observation noise (mean zero, so the gap is Monte Carlo scale) and, on a reconciled result, by whatever reconciliation moved. A decomposition explains a model, and reconciliation happens after the model.

Field Type Default
node str required
window Window required
steps tuple[int, int] required
method str required
reduce str required
engine str required
baseline FactorContribution required
factors tuple[FactorContribution, ...] required
total_draws Array required
forecast_draws Array required
n_replays int 0

NodeAttribution.bars#

python
property NodeAttribution.bars: tuple[FactorContribution, ...]

Every bar of the waterfall, the baseline first.

NodeAttribution.total#

python
property NodeAttribution.total: float

The window's total under the model's own mean path.

NodeAttribution.forecast_total#

python
property NodeAttribution.forecast_total: float

The same window read off the result's forecast draws.

NodeAttribution.closure_error#

python
property NodeAttribution.closure_error: float

total minus the bars, in the units of the series.

Reported always. Read it against total, or use relative_closure_error, which does that for you: a residual left by floating-point addition scales with the numbers being added, so an absolute threshold means different things on a series in units and the same series in millions.

NodeAttribution.relative_closure_error#

python
property NodeAttribution.relative_closure_error: float

closure_error against the size of the total, floored at one.

NodeAttribution.interval#

python
def NodeAttribution.interval(level: float = 0.9) -> tuple[float, float]

The window total's own credible interval.

NodeAttribution.to_frame#

python
def NodeAttribution.to_frame(level: float = 0.9) -> pd.DataFrame

One row per bar: name, kind, value, band, and share of the total.

NodeDiagnostics#

python
class NodeDiagnostics(
    node: str,
    divergences: int,
    n_draws: int,
    min_ess: float,
    max_rhat: float | None = None,
    ess_by_site: Mapping[str, float] = <factory>,
) -> None

What the sampler reported about one node's fit.

Carried on every result, warned about on breach, never raised on: a backtest that refits over dozens of folds has to be able to finish, and a diagnostic is information about a fit rather than a verdict on it.

Field Type Default
node str required
divergences int required
n_draws int required
min_ess float required
max_rhat float | None None
ess_by_site Mapping[str, float] factory

NodeDiagnostics.divergence_share#

python
property NodeDiagnostics.divergence_share: float

Divergences as a fraction of the draws.

NodeDiagnostics.healthy#

python
property NodeDiagnostics.healthy: bool

True when nothing crossed the warning thresholds.

NodeFit#

python
class NodeFit(
    node: str,
    mechanism: TemporalMechanism,
    scaler: Scaler,
    trace: Any,
    diagnostics: NodeDiagnostics,
    obs_scaled: Array,
    mask: BoolArray,
    parents: Mapping[str, Array],
) -> None

One fitted node: its mechanism, its scaler, its posterior, its diagnostics.

Field Type Default
node str required
mechanism TemporalMechanism required
scaler Scaler required
trace Any required
diagnostics NodeDiagnostics required
obs_scaled Array required
mask BoolArray required
parents Mapping[str, Array] required

NodeFit.n_obs#

python
property NodeFit.n_obs: int

The number of time steps the model was built over, masked ones included.

NodeForecast#

python
class NodeForecast(
    node: str,
    draws: Array,
    components: Mapping[str, Array],
    replay: ReplayRecipe | None = None,
) -> None

One node's horizon: predictive draws in the data's own units, plus the pieces.

draws is (S, H) on the original scale. components holds the model's deterministics (the trend, the season, the regression, one array per driver) as they came out of the posterior-predictive pass, on the scaled space the mechanism works in. explain() reads them, which is what makes an attribution cost no sampling on the structural path. replay is the recipe for running this same forecast again, which is what the fallback path needs.

Field Type Default
node str required
draws Array required
components Mapping[str, Array] required
replay ReplayRecipe | None None

NodeRole#

python
class NodeRole(enum.Enum)

What a node is, which decides how the schedule treats it.

Member Value
MODEL 'model'
SCENARIO 'scenario'
AGGREGATE 'aggregate'

NodeState#

python
class NodeState(
    node: str,
    role: NodeRole,
    scaler: Scaler,
    history: Array | None = None,
    forecast: Array | None = None,
    path: Array | None = None,
    fit: NodeFit | None = None,
    view: NodeForecast | None = None,
) -> None

Everything the stage loop knows about one node.

history is the node's own training series in the data's own units, and scaler is the scale a child reads it on. forecast is the horizon as (S, H) draws, also in the data's own units. path is set only when the horizon is a single pinned path rather than a distribution, which is what a scenario is; a child reading a pinned parent needs no injected site at all, and that is exactly why a pinned driver gives a narrower band than a forecast one.

Field Type Default
node str required
role NodeRole required
scaler Scaler required
history Array | None None
forecast Array | None None
path Array | None None
fit NodeFit | None None
view NodeForecast | None None

NodeState.resolved#

python
property NodeState.resolved: bool

True once this node has a forecast to hand down.

ParentColumn#

python
class ParentColumn(
    key: str,
    base: str,
    lag: int,
    history: Array | None = None,
    future: Array | None = None,
    upstream: bool = False,
) -> None

One regressor of one node: which variable, at which lag, under which name.

key is the design name the mechanism sees, and therefore the beta_{key} site it registers. It is the parent's plain variable name when that parent drives this node at a single lag, which is the ordinary case and keeps the S2 naming contract intact; when the same parent drives at several lags the shortest keeps the plain name and the others read {base}_lag{k}.

history and future are on the parent's scaled space, which is the space the child's regression reads it in. future is None for a parent whose horizon arrives as posterior draws instead (see FrozenParent) and for a parent that is another node of a joint model.

Field Type Default
key str required
base str required
lag int required
history Array | None None
future Array | None None
upstream bool False

PosteriorStore#

python
class PosteriorStore(states: Mapping[str, NodeState] | None = None) -> None

The stage loop's working memory: one NodeState per node.

Deliberately mutable, and deliberately not a value object. It is the ledger a schedule fills in as it runs, and the thing a child asks for its parents' posteriors. Every state inside it is frozen, so an entry is replaced rather than edited.

PosteriorStore.nodes#

python
property PosteriorStore.nodes: tuple[str, ...]

Every node in the store, sorted.

PosteriorStore.put#

python
def PosteriorStore.put(state: NodeState) -> None

Record (or replace) one node's state.

PosteriorStore.update#

python
def PosteriorStore.update(node: str, **changes: Any) -> NodeState

Replace one node's state with a changed copy and return it.

RatioScaler#

python
class RatioScaler(floor: float = 0.01, median: float = 1.0) -> None

Divide by the median of the positive values, so the series sits near one.

Zeros and negatives are ignored when the median is taken, so a series that starts with a run of zeros does not end up with a tiny divisor. floor clips the transformed values from below, which keeps a positive-only likelihood away from zero; a value at or below floor * median therefore does not survive a round trip.

Two magnitude boundaries worth knowing. The guard against a zero divisor is absolute (a fitted median at or below 1e-8 falls back to 1.0), so a series genuinely living at that scale is left unscaled rather than blown up. And the floor is relative to the median, so a series with a huge dynamic range loses its smallest values to the floor.

Field Type Default
floor float 0.01
median float 1.0

RatioScaler.fit#

python
def RatioScaler.fit(x: npt.NDArray[Any] | Any) -> Self

Learn the scale from x and return the fitted scaler.

RatioScaler.transform#

python
def RatioScaler.transform(
    x: npt.NDArray[Any] | Any,
) -> npt.NDArray[np.float64]

Map data onto the model scale.

RatioScaler.inverse#

python
def RatioScaler.inverse(
    x: npt.NDArray[Any] | Any,
) -> npt.NDArray[np.float64]

Map model-scale values back to the data's own units.

ReplayRecipe#

python
class ReplayRecipe(
    horizon: int,
    seed: int,
    parents: Mapping[str, Array] = <factory>,
    frozen: tuple[FrozenParent, ...] = (),
) -> None

Everything forecast_node() needs to run one forecast again, unchanged.

A replay attribution zeroes a factor's sites in a copy of the trace and asks for the same forecast back, so it has to be able to reproduce the call: the same horizon, the same driver paths, the same handoffs, and above all the same seed. With the seed held the fresh innovations come out identical, so differencing two replays leaves the factor's effect and nothing else, rather than the factor's effect plus a new draw of the noise.

None on a NodeForecast means the forecast cannot be reproduced on its own, which is what a joint fit is: one predictive pass produces every node at once, and there is no per-node call to repeat.

Field Type Default
horizon int required
seed int required
parents Mapping[str, Array] factory
frozen tuple[FrozenParent, ...] ()

Scaler#

python
class Scaler() -> None

The scaler contract: fit returns a fitted copy, transform and inverse undo.

Scaler.fit#

python
def Scaler.fit(x: npt.NDArray[Any] | Any) -> Self

Learn the scale from x and return the fitted scaler.

Scaler.transform#

python
def Scaler.transform(x: npt.NDArray[Any] | Any) -> npt.NDArray[np.float64]

Map data onto the model scale.

Scaler.inverse#

python
def Scaler.inverse(x: npt.NDArray[Any] | Any) -> npt.NDArray[np.float64]

Map model-scale values back to the data's own units.

ScenarioMechanism#

python
class ScenarioMechanism(values: tuple[float, ...] | None = None) -> None

A node whose future is given rather than modeled.

This is the resolved role: nothing is sampled and nothing is fitted, because the path comes from outside. An exogenous driver with a known future (a published rate, a planned price schedule, a calendar) is the case it exists for. The scaler is the identity, since a supplied path arrives in the units the caller means.

values may pin the future path at construction time. Leaving it None means the path arrives at forecast time through scenario=, which the driver sprint wires up.

Field Type Default
values tuple[float, ...] | None None

ScenarioMechanism.path#

python
def ScenarioMechanism.path() -> Array

The pinned future path as an array; raises when nothing was pinned.

ScenarioMechanism.scaler_for#

python
def ScenarioMechanism.scaler_for(values: Array) -> Scaler

The identity: a supplied path is already on the scale the caller means.

ScenarioMechanism.build#

python
def ScenarioMechanism.build(
    name: str,
    *,
    obs: Array | None,
    parents: Mapping[str, Array],
    T: int,
    horizon: int,
    mask: npt.NDArray[np.bool_],
) -> Any

Refuse: a resolved node has no model to build.

ScenarioMechanism.site#

python
def ScenarioMechanism.site(node: str, site: str) -> str

The name of one site: "sales" and "phi" give "sales_phi".

Every mechanism registers through this, and nothing renames a site afterwards. Posterior replay, frozen-parent injection, and attribution zeroing all match by name.

ScenarioMechanism.predictive_site#

python
def ScenarioMechanism.predictive_site(node: str) -> str

The site holding the horizon's predictive draws in a forecasting model.

ScenarioMechanism.mean_site#

python
def ScenarioMechanism.mean_site(node: str) -> str

The deterministic holding the mean path, length T + horizon.

Registered by a forecasting model only, never by a fitting one, which is why in-sample fitted values are read off the forecast model's first T rows rather than out of the trace.

ScenarioMechanism.baseline_label#

python
def ScenarioMechanism.baseline_label() -> str

What to call the part of the forecast that is nobody's driver.

ScenarioMechanism.factors#

python
def ScenarioMechanism.factors(
    node: str,
    components: Mapping[str, Array],
) -> tuple[str, ...] | None

The switchable pieces of this node's mean path, in composition order.

None, the default, means this mechanism cannot be taken apart from its own deterministics, so explain() falls back to the replay engine: it zeroes a factor's sites in a copy of the trace and forecasts again. A mechanism that can be decomposed returns its factor keys instead, which costs no sampling at all.

The keys are SEASON for the seasonal term and the design key for each driver, ordered seasonality first and then drivers by name. The order is what method="sequential" walks. An empty tuple is a real answer: it says the whole mean path is baseline, which is what a pure diffusion curve is.

Read the components rather than the mechanism's own settings, because the question is what this forecast recorded, and a subclass that hides its deterministics has to fall through to replay.

ScenarioMechanism.compose#

python
def ScenarioMechanism.compose(
    node: str,
    components: Mapping[str, Array],
    posterior: Mapping[str, Array],
    *,
    active: Collection[str],
) -> Array

The mean path with only active factors switched on, (S, T + H).

The identity elements are the pinned parameterization: a season switched off is a factor of exactly one and an additive term of exactly zero, and a driver switched off contributes zero. compose with every factor active must reproduce the model's own mu deterministic, which is not an assumption: explain() measures the gap and reports it as closure_error, so a composition rule that drifts away from build shows up as a number rather than as silence.

posterior carries the fitted sites for a mechanism whose composition needs a parameter as well as the recorded pieces (the reversion rate of a mean-reverting level, say).

SeasonalNaive#

python
class SeasonalNaive(period: int, *, n_draws: int = 500) -> None

Repeat the last full season. The floor for anything with a calendar in it.

period is the season length in rows (12 for monthly data with a yearly cycle). The interval is flat within a season and steps up by sqrt(k + 1) once the forecast has to reuse a season it has already reused k times.

Field Type Default
n_draws int 500
period int required

SeasonalNaive.fit#

python
def SeasonalNaive.fit(data: TimeSeriesData, *, seed: int = 0) -> Self

Read the series off data; the fitted copy comes back, self is untouched.

SeasonalNaive.forecast#

python
def SeasonalNaive.forecast(horizon: int, *, seed: int = 0) -> ForecastResult

Draw n_draws sample paths horizon steps past the fitted data.

SeasonalNaive.fitted#

python
property SeasonalNaive.fitted: bool

True once fit() has run.

Stage#

python
class Stage(
    index: int,
    nodes: tuple[str, ...],
    strategy: StageStrategy,
    parent_edges: Mapping[str, tuple[tuple[str, int], ...]] = <factory>,
    hierarchy_children: Mapping[str, tuple[str, ...]] = <factory>,
) -> None

One wave of the schedule.

parent_edges maps a node to the (variable, lag) pairs it reads from earlier stages, which is exactly the set of posteriors that has to be handed in when the stage runs. Parents inside the same stage never appear, because same-wave nodes have no edges between them.

Field Type Default
index int required
nodes tuple[str, ...] required
strategy StageStrategy required
parent_edges Mapping[str, tuple[tuple[str, int], ...]] factory
hierarchy_children Mapping[str, tuple[str, ...]] factory

StageStrategy#

python
class StageStrategy(enum.Enum)

How one stage's nodes get resolved.

Member Value
RESOLVED 'resolved'
MCMC 'mcmc'
AGGREGATE 'aggregate'

StructuralMechanism#

python
class StructuralMechanism(
    trend: str = 'damped_llt',
    seasonality: str | None = 'auto',
    period: int | None = None,
    n_harmonics: int = 2,
    robust: bool = False,
    reg_scale: float = 1.0,
    scaler: Scaler | None = None,
) -> None

A damped local linear trend with seasonality and regression on parents.

Options:

The priors are written for the ratio scale, where the series sits near one: phi ~ Beta(8, 2) (damping near but below one), sigma_level ~ HalfNormal(0.5)`, `sigma_slope ~ HalfNormal(0.05) (a slope moves slowly or it is not a trend), sigma_obs ~ HalfNormal(0.5), level0 ~ Normal(1, 0.5)`, `slope0 ~ Normal(0, 0.1)`, and `season_raw ~ Normal(0, 0.5).

Field Type Default
trend str 'damped_llt'
seasonality str | None 'auto'
period int | None None
n_harmonics int 2
robust bool False
reg_scale float 1.0
scaler Scaler | None None

StructuralMechanism.seasonal_form#

python
def StructuralMechanism.seasonal_form() -> tuple[str | None, int | None]

The seasonal form actually used, as (kind, period).

kind is "monthly", "fourier", or None. This is what the result's provenance reports, so a caller can see which season the data's own index bought them.

StructuralMechanism.scaler_for#

python
def StructuralMechanism.scaler_for(values: Array) -> Scaler

The fitted scaler: the override when given, else the automatic choice.

StructuralMechanism.factors#

python
def StructuralMechanism.factors(
    node: str,
    components: Mapping[str, Array],
) -> tuple[str, ...] | None

Seasonality first, then one key per driver. None when nothing was recorded.

A forecast made by this mechanism records every piece it composed, so the decomposition is exact and costs no sampling. A subclass that suppresses the deterministics lands here with nothing to read and falls through to the replay engine, which is the intended escape hatch rather than a failure.

StructuralMechanism.compose#

python
def StructuralMechanism.compose(
    node: str,
    components: Mapping[str, Array],
    posterior: Mapping[str, Array],
    *,
    active: Collection[str],
) -> Array

(trend + active drivers + season) * season_factor, per draw.

Switching a piece off sets it to its neutral value rather than renormalizing anything: the additive terms go to zero and the multiplicative factor to one. That is why the monthly form is written as 12 * softmax(raw), which is exactly one when raw is zero.

StructuralMechanism.build#

python
def StructuralMechanism.build(
    name: str,
    *,
    obs: Array | None,
    parents: Mapping[str, Array],
    T: int,
    horizon: int,
    mask: npt.NDArray[np.bool_],
) -> Any

Register every site and return mu, the mean path of length T + horizon.

Runs inside a pm.Model context. When horizon is positive the model also carries the fresh forecast innovations (z_level_fc, z_slope_fc) and the predictive site y_fc, which is what the posterior-predictive pass reads back.

StructuralMechanism.site#

python
def StructuralMechanism.site(node: str, site: str) -> str

The name of one site: "sales" and "phi" give "sales_phi".

Every mechanism registers through this, and nothing renames a site afterwards. Posterior replay, frozen-parent injection, and attribution zeroing all match by name.

StructuralMechanism.predictive_site#

python
def StructuralMechanism.predictive_site(node: str) -> str

The site holding the horizon's predictive draws in a forecasting model.

StructuralMechanism.mean_site#

python
def StructuralMechanism.mean_site(node: str) -> str

The deterministic holding the mean path, length T + horizon.

Registered by a forecasting model only, never by a fitting one, which is why in-sample fitted values are read off the forecast model's first T rows rather than out of the trace.

StructuralMechanism.baseline_label#

python
def StructuralMechanism.baseline_label() -> str

What to call the part of the forecast that is nobody's driver.

TemporalMechanism#

python
class TemporalMechanism() -> None

The contract: own a scale, build a mean path, register named sites.

Subclasses implement build(), which runs inside a pm.Model context and returns the mean path as a tensor of length T + horizon. The arguments it reads:

Every registered name goes through site(), so the naming contract has exactly one implementation.

TemporalMechanism.site#

python
def TemporalMechanism.site(node: str, site: str) -> str

The name of one site: "sales" and "phi" give "sales_phi".

Every mechanism registers through this, and nothing renames a site afterwards. Posterior replay, frozen-parent injection, and attribution zeroing all match by name.

TemporalMechanism.predictive_site#

python
def TemporalMechanism.predictive_site(node: str) -> str

The site holding the horizon's predictive draws in a forecasting model.

TemporalMechanism.mean_site#

python
def TemporalMechanism.mean_site(node: str) -> str

The deterministic holding the mean path, length T + horizon.

Registered by a forecasting model only, never by a fitting one, which is why in-sample fitted values are read off the forecast model's first T rows rather than out of the trace.

TemporalMechanism.scaler_for#

python
def TemporalMechanism.scaler_for(values: Array) -> Scaler

The fitted scaler this mechanism wants for values.

TemporalMechanism.build#

python
def TemporalMechanism.build(
    name: str,
    *,
    obs: Array | None,
    parents: Mapping[str, Array],
    T: int,
    horizon: int,
    mask: npt.NDArray[np.bool_],
) -> Any

Register this node's sites and return its mean path, length T + horizon.

TemporalMechanism.baseline_label#

python
def TemporalMechanism.baseline_label() -> str

What to call the part of the forecast that is nobody's driver.

TemporalMechanism.factors#

python
def TemporalMechanism.factors(
    node: str,
    components: Mapping[str, Array],
) -> tuple[str, ...] | None

The switchable pieces of this node's mean path, in composition order.

None, the default, means this mechanism cannot be taken apart from its own deterministics, so explain() falls back to the replay engine: it zeroes a factor's sites in a copy of the trace and forecasts again. A mechanism that can be decomposed returns its factor keys instead, which costs no sampling at all.

The keys are SEASON for the seasonal term and the design key for each driver, ordered seasonality first and then drivers by name. The order is what method="sequential" walks. An empty tuple is a real answer: it says the whole mean path is baseline, which is what a pure diffusion curve is.

Read the components rather than the mechanism's own settings, because the question is what this forecast recorded, and a subclass that hides its deterministics has to fall through to replay.

TemporalMechanism.compose#

python
def TemporalMechanism.compose(
    node: str,
    components: Mapping[str, Array],
    posterior: Mapping[str, Array],
    *,
    active: Collection[str],
) -> Array

The mean path with only active factors switched on, (S, T + H).

The identity elements are the pinned parameterization: a season switched off is a factor of exactly one and an additive term of exactly zero, and a driver switched off contributes zero. compose with every factor active must reproduce the model's own mu deterministic, which is not an assumption: explain() measures the gap and reports it as closure_error, so a composition rule that drifts away from build shows up as a number rather than as silence.

posterior carries the fitted sites for a mechanism whose composition needs a parameter as well as the recorded pieces (the reversion rate of a mean-reverting level, say).

TrainingSchedule#

python
class TrainingSchedule(
    stages: tuple[Stage, ...],
    unoriented_dropped: tuple[tuple[str, str], ...] = (),
) -> None

An ordered list of stages. Build it with from_graph().

unoriented_dropped holds the same-slice pairs the graph left unoriented, which is what the schedule could not use. It is a record, not an error: the fix is to orient them with domain knowledge and build the schedule again.

Field Type Default
stages tuple[Stage, ...] required
unoriented_dropped tuple[tuple[str, str], ...] ()

TrainingSchedule.from_graph#

python
classmethod TrainingSchedule.from_graph(
    graph: MixedGraph | None,
    hierarchy: Hierarchy | None,
    roles: Mapping[str, NodeRole],
    *,
    max_batch_size: int = 1,
    staged: bool = True,
) -> TrainingSchedule

Derive the schedule from the window graph, the hierarchy, and the roles.

Parameters graph The window graph over lagged node names, or None when no node reads another. Only the edges into a node's present-time copy count as dependencies, and only under the two rules in the module docstring. Every variable the graph names needs a role. hierarchy The aggregation tree, or None when there is none. roles One NodeRole per variable. The names are plain variable names, not lagged ones. max_batch_size How many independent nodes may share one MCMC stage. One (the default) gives each its own stage, which keeps each sampler small. A large number puts every node of a wave in one stage. staged Whether this plan will be run one node at a time. It is only the hierarchy rule that turns on it: staging adds the aggregates up last, so a hierarchy parent that wants its own model has no wave it could run in, while one model over every node has no wave order to violate and can fit a parent and its children together. Everything else in the schedule reads the same either way, because a joint fit still needs the topological order.

Raises ValueError When a hierarchy parent carries a role other than AGGREGATE and staged is true, when an AGGREGATE node has no children to add up, when the graph names a variable with no role, when a model node reads an aggregate, or when the modeled nodes drive each other in a loop.

Warns UserWarning When the graph leaves a same-slice pair unoriented, so the schedule cannot tell which way the dependency runs.

TrainingSchedule.mcmc_stages#

python
property TrainingSchedule.mcmc_stages: tuple[Stage, ...]

The stages that actually run a sampler.

TrainingSchedule.num_mcmc_stages#

python
property TrainingSchedule.num_mcmc_stages: int

How many sampler runs the schedule costs.

TrainingSchedule.n_unoriented_dropped#

python
property TrainingSchedule.n_unoriented_dropped: int

How many same-slice pairs the graph left unoriented, so the schedule skipped them.

TrainingSchedule.all_nodes#

python
property TrainingSchedule.all_nodes: tuple[str, ...]

Every node, in schedule order.

TrainingSchedule.stage_of#

python
def TrainingSchedule.stage_of(node: str) -> Stage | None

The stage holding node, or None when it is not scheduled.

TrainingSchedule.summary#

python
def TrainingSchedule.summary() -> str

A printable plan, one line per stage.

Window#

python
class Window(label: str, start: Any = 0, end: Any | None = None) -> None

A named, half-open slice of the horizon.

start and end are either integer positions into the forecast steps (zero-based, end exclusive, None meaning "to the end of the horizon") or labels on the result's own time axis, which is what makes Window("Q1", "2026-01-01", "2026-04-01") mean what it looks like. A label resolves to the first step at or after it, so a window that starts between two steps starts at the later one.

Field Type Default
label str required
start Any 0
end Any | None None

Window.resolve#

python
def Window.resolve(
    horizon: int,
    index: Sequence[Any] | None = None,
) -> tuple[int, int]

The (start, end) positions this window covers, as integers.

ZScoreScaler#

python
class ZScoreScaler(mean: float = 0.0, std: float = 1.0) -> None

Center and standardize. The default for a series that can go negative.

Field Type Default
mean float 0.0
std float 1.0

ZScoreScaler.fit#

python
def ZScoreScaler.fit(x: npt.NDArray[Any] | Any) -> Self

Learn the scale from x and return the fitted scaler.

ZScoreScaler.transform#

python
def ZScoreScaler.transform(
    x: npt.NDArray[Any] | Any,
) -> npt.NDArray[np.float64]

Map data onto the model scale.

ZScoreScaler.inverse#

python
def ZScoreScaler.inverse(
    x: npt.NDArray[Any] | Any,
) -> npt.NDArray[np.float64]

Map model-scale values back to the data's own units.

accuracy#

python
def accuracy(
    actual: npt.NDArray[Any] | Any,
    samples: npt.NDArray[Any] | Any,
) -> float

1 - clip(relative error, 0, 1), averaged over the non-zero actuals.

One is a perfect path, zero is every step off by a full 100 percent or more. NaN when every actual is zero.

accuracy_full#

python
def accuracy_full(
    actual: npt.NDArray[Any] | Any,
    samples: npt.NDArray[Any] | Any,
) -> float

rolling_accuracy() over the whole horizon: accuracy on the total.

align_draws#

python
def align_draws(draws: Array, n_target: int, *, seed: int) -> Array

Make a parent's (S, H) draws match the child's draw count.

Equal by construction whenever one draws= sets the budget for the whole graph, which is the normal path and costs nothing. When they differ (a parent fitted separately, at another budget) the parent is resampled with replacement under an explicit seed, which keeps the handoff a sample from the parent's posterior rather than a reshaped one.

assign_mechanisms#

python
def assign_mechanisms(
    data: TimeSeriesData,
    mechanisms: Mapping[str, TemporalMechanism] | None = None,
    *,
    robust: bool = False,
) -> dict[str, TemporalMechanism]

One mechanism per modeled column: the caller's choice, else the schema's.

A column named in mechanisms keeps the mechanism it was given, whatever the schema says. Every other continuous column gets a StructuralMechanism with the period infer_period() read off the time index. A column that is not continuous raises: counts and categories need their own mechanisms, and a Gaussian trend model fitted to a count series is worse than a refusal.

robust reaches the auto-assigned mechanisms only. A mechanism the caller built keeps its own settings, because an explicit object outranks a flag.

attribute#

python
def attribute(
    result: ForecastResult,
    *,
    windows: Window | Sequence[Window] | None = None,
    nodes: str | Sequence[str] | None = None,
    method: str = 'sequential',
    reduce: str = 'sum',
    max_replays: int = 200,
) -> Attribution

Decompose a fitted forecast into baseline, seasonality, and one bar per driver.

The engine is chosen per node: structural when the node's mechanism can rebuild its own mean path from what it recorded, replay when it cannot. A result may therefore come back with both, and each record says which it got.

auto_scaler#

python
def auto_scaler(values: Array) -> Scaler

Pick and fit a scaler for one series: ratio when positive, z-score when signed.

A series that never goes at or below zero is multiplicative in character, so dividing by its median puts it near one and leaves the proportions intact. A series that changes sign has no such median, so it gets centered and standardized instead. Only the finite entries count, which is what keeps a masked step from moving the scale.

backtest#

python
def backtest(
    model: ForecastModel,
    data: TimeSeriesData,
    *,
    mode: str = 'expanding',
    test_size: int,
    n_splits: int = 3,
    min_train_size: int | None = None,
    seed: int = 0,
    level: float = 0.9,
    m: int = 1,
) -> BacktestReport

Score model on data by refitting at each rolling origin.

Parameters model An unfitted model satisfying ForecastModel. It is deep-copied per fold, so it comes back untouched. data The history, as a TimeSeriesData or a HierarchicalTSData. Rows are already in time order. Any forecast series that is not a column of data cannot be scored and is recorded in nodes_unscored rather than dropped silently. mode "expanding" for several folds, each training on everything before its origin, or "single" for one split at the end. test_size The held-out window, which is also the forecast horizon. n_splits How many folds in expanding mode. min_train_size The shortest training prefix allowed, 2 * test_size by default. seed The base seed. Fold i fits with seed + i and forecasts with seed + 1000 + i, so no two folds share a random state. level The nominal level for the coverage metric and the interval it reads. m The season length for the MASE scale. One is the plain naive scale, which is the right choice when the series carries no seasonality.

Returns BacktestReport

bases#

python
def bases(graph: MixedGraph) -> tuple[str, ...]

The variables behind the window nodes, sorted and deduplicated.

compute_metrics#

python
def compute_metrics(
    actual: npt.NDArray[Any] | Any,
    samples: npt.NDArray[Any] | Any,
    *,
    coverage_level: float = 0.9,
    train: npt.NDArray[Any] | Any | None = None,
    m: int = 1,
) -> dict[str, float]

Every metric for one node and one fold, keyed by name.

train is the in-sample history the fold was fitted on; give it and the table carries mase at period m, leave it out and the key is absent.

constraint_matrix#

python
def constraint_matrix(
    hierarchy: Hierarchy,
    nodes: tuple[str, ...] | None = None,
) -> Array

The constraint matrix A: one row per parent, reading parent - sum(children) = 0.

A coherent forecast is exactly a vector x over nodes with A x = 0. Multi-level trees need no extra rows: the direct-parent rows imply every grandparent identity, because a parent that equals its children and children that equal theirs leaves nothing to add.

coverage#

python
def coverage(
    actual: npt.NDArray[Any] | Any,
    samples: npt.NDArray[Any] | Any,
    level: float = 0.9,
) -> float

The share of actuals inside the central level interval of the draws.

NaN when the band cannot be formed anywhere it is needed, which is what the error metrics already do with a NaN in the same place. Returning zero there would read as "the forecast missed every step" and quietly drag an average down, when the truth is that there was nothing to score.

crps#

python
def crps(
    actual: npt.NDArray[Any] | Any,
    samples: npt.NDArray[Any] | Any,
) -> float

Continuous ranked probability score, averaged over the horizon.

The sample energy form E|X - y| - 0.5 E|X - X'| per step, with the second term read off the sorted draws so the cost is S log S rather than S^2. Lower is better, and it collapses to the absolute error when the forecast is a point.

design_keys#

python
def design_keys(
    edges: Sequence[tuple[str, int]],
) -> dict[tuple[str, int], str]

Name each (parent, lag) regressor, keeping the plain name for the common case.

A parent that drives a node at one lag keeps its own variable name, so the coefficient reads y_beta_pmi and the S2 naming contract is untouched. A parent that drives at several lags keeps the plain name for the shortest (the one a reader means when they say "the pmi coefficient") and suffixes the rest as {base}_lag{k}. The rule is deterministic and does not depend on what else is in the graph.

A collision (a real column named pmi_lag2 alongside pmi at lag two) raises rather than quietly merging two regressors.

fit_joint#

python
def fit_joint(
    nodes: Sequence[JointNode],
    *,
    draws: int = 1000,
    warmup: int = 500,
    chains: int = 1,
    seed: int = 0,
    target_accept: float = 0.99,
    nuts_sampler: str | None = None,
) -> JointFit

Fit every node of a graph in one model, so their posteriors stay correlated.

nodes arrives in topological order: a node's parents are built before it, and a parent that is another node of this model feeds the child through its own mean path rather than through its observed series. That is the whole difference from staged fitting. The child's likelihood therefore says something about the parent's latent states, and the parent's uncertainty reaches the child as a joint posterior instead of as a handoff, which is what "the correlation staging drops" means concretely.

It costs what one sampler over everything costs, which is why the facade keeps it for small graphs.

fit_node#

python
def fit_node(
    node: str,
    values: Array,
    mechanism: TemporalMechanism,
    *,
    mask: BoolArray | None = None,
    parents: Mapping[str, Array] | None = None,
    draws: int = 1000,
    warmup: int = 500,
    chains: int = 1,
    seed: int = 0,
    target_accept: float = 0.99,
    nuts_sampler: str | None = None,
) -> NodeFit

Fit one node's mechanism to values and return the posterior with diagnostics.

mask defaults to the finite entries of values, which is how a series with holes in it declares them. Pass one explicitly to mask a step whose recorded value is present but not to be believed. The scaler is fitted on the observed steps alone, so a masked sentinel cannot move the scale.

parents are scaled, lag-aligned driver paths of length T; at fit time there is no horizon to cover. Seeds are explicit and reach both the sampler and, later, the forecast.

forecast#

python
def forecast(
    data: TimeSeriesData,
    horizon: int,
    *,
    graph: MixedGraph | None = None,
    mechanisms: Mapping[str, TemporalMechanism] | None = None,
    reconcile: str | None = None,
    proportions: str | None = None,
    level: int | None = None,
    weights: str | None = None,
    trust: Mapping[str, float] | None = None,
    scenario: Mapping[str, Any] | None = None,
    fit: str = 'auto',
    robust: bool = False,
    draws: int = 1000,
    warmup: int = 500,
    chains: int = 1,
    seed: int = 0,
    target_accept: float = 0.99,
) -> ForecastResult

Forecast every series in data horizon steps forward.

Each column gets its own mechanism (a damped local linear trend with the season its time index implies) and its own NUTS fit, and each is continued by replaying its posterior and simulating only the horizon. The result carries the draws, the mechanisms that produced them, and the sampler diagnostics.

Given a HierarchicalTSData, every level is forecast the same way. A node with a column gets its own model, leaf or total alike, and a node that exists only as an aggregate is summed from its children. The levels are then made to agree by the strategy reconcile names, and the unreconciled version stays available through reconcile="none".

Given a graph, the series read each other. Each node's parents enter its regression as lag-shifted columns of their own history, and when the node is forecast, a parent that was itself forecast hands its whole posterior across rather than a path: draw for matching draw, so the child's band carries the driver's uncertainty. schedule() shows the order this will run in before any of it runs.

Parameters data: A TimeSeriesData, or a HierarchicalTSData for the hierarchical path. horizon: How many steps past the end of the data to forecast. graph: A window graph over lagged node names (pmi[t-2] -> sales[t]), saying which series drive which. Build one with window_graph(), or hand over the .graph of a temporal discovery result unchanged. Every variable it names has to be a column of the data. mechanisms: Per-column overrides. A column named here keeps the mechanism it is given; everything else is assigned from the schema. A ScenarioMechanism marks a column as known-future, which means it is never fitted and scenario= has to supply its path. reconcile: The reconciliation strategy: "bottom_up", "top_down", "middle_out", "mint", or "none". Unset means bottom-up once a hierarchy exists and nothing at all on flat data, where naming a strategy raises rather than pretending there was something to reconcile. proportions: "historical" or "forecast", how top_down and middle_out split a parent between its children. level: Which depth middle_out believes, the root being 0. weights: "posterior" or "mint_shrink", where mint gets its weights. The shrink rule reads each node's in-sample residuals, which this facade computes from the fits it just ran. trust: {node: weight} for mint: a node trusted ten times as much moves about a tenth as far when the levels are squared up. scenario: Pinned future paths, {node: path}, where a path is a length-horizon array, a scalar, or the string "hold" (repeat the last observed value). It makes no causal claim: it pins a path and trusts the fitted regressions downstream, which is honest for a node whose future is exogenous or already known and quietly wrong for one the graph says is driven, so the second case is refused by name. fit: "independent" fits each series alone. "staged" fits one node at a time and hands each posterior down the graph. "joint" fits every node in one model, keeping their posteriors correlated. "auto" is independent without a graph, and with one is joint at or under AUTO_JOINT_MAX_NODES modeled nodes and staged above it. The strategy that ran, and the arithmetic behind an automatic choice, are on the result as strategy. robust: Fit a Student-t likelihood rather than a Normal one, so a few outliers do not drag the level. Reaches the auto-assigned mechanisms only; a mechanism you built keeps its own setting. draws, warmup, chains, seed: The sampler budget and the seed. Each node's seed is seed plus its position in sorted node order, so a node's fit does not move when a sibling is added. target_accept: NUTS target acceptance. The default is high on purpose: a non-centered state-space model with a tight innovation scale funnels, and 0.99 is what keeps the divergence count near zero without slowing the fit much.

Returns ForecastResult Draws of shape (draws * chains, horizon) per series, in the units the data arrived in. From a hierarchy it also carries reconciliation and coherent, and its series run top of the tree downward.

forecast_joint#

python
def forecast_joint(
    fit: JointFit,
    horizon: int,
    *,
    seed: int = 0,
) -> dict[str, NodeForecast]

Continue every node of a joint fit at once, in one predictive pass.

The horizon's driver values are the drivers' own forecasts, computed inside the same model from the same posterior draw, so a child never sees a parent path that its own parameters did not come from.

forecast_model#

python
def forecast_model(
    *,
    name: str = 'Forecaster',
    **options: Any,
) -> Forecaster

The Bayesian forecaster as a backtestable model: configuration now, data later.

backtest(forecast_model(mechanisms=..., draws=300), data, test_size=6) is the whole pattern. Every keyword forecast() takes except data and horizon is accepted here and passed through, and a keyword it does not take is refused now rather than at the first fold, which matters when a fold costs a NUTS run.

name is what the backtest report calls it, so two configurations of the same model can be told apart in one table.

forecast_node#

python
def forecast_node(
    fit: NodeFit,
    horizon: int,
    *,
    parents: Mapping[str, Array] | None = None,
    frozen: Iterable[FrozenParent] | None = None,
    seed: int = 0,
) -> NodeForecast

Continue a fitted node horizon steps, replaying its posterior.

Builds a second model over T + horizon steps, checks that every site the trace has to supply is there and that every site meant to be simulated is not, then runs the posterior-predictive pass. The returned draws come back through the node's own scaler, so they are in the units the data arrived in.

parents are the full T + horizon driver paths, on each parent's own scaled space, for parents whose future is known outright. frozen carries the parents whose future is a posterior instead: each declares a fresh {base}_fc site here, gets its draws written into a copy of this node's trace, and is replayed from there, so the parent's uncertainty propagates into this node's band rather than collapsing to a path.

historical_shares#

python
def historical_shares(
    data: HierarchicalTSData,
    hierarchy: Hierarchy | None = None,
) -> dict[str, float]

Each child's average share of its parent over the training window.

The share is the mean of the ratio, step by step, rather than the ratio of the means: a month where the parent was small counts as much as a month where it was large, which is what "share" usually means to the person asking. The shares in a sibling set are then renormalized to sum to one, so an allocation can never leak or invent volume.

A node's series is its own column when it has one and aggregate() when it does not, which is the same definition the data layer's coherence check uses.

in_sample_median#

python
def in_sample_median(
    fit: NodeFit,
    view: NodeForecast | None = None,
    *,
    seed: int = 0,
) -> Array

The fitted mean path over the training window, (T,) in the data's own units.

Where the fitted values come from is a design decision worth stating, because the obvious route is closed. A fitting model registers no deterministics at all (S2's finding: a deterministic sitting in the trace is read back from the trace by the posterior-predictive pass rather than recomputed, so a fit-time mu of length T would be poured into a forecast-time slot of length T + H), so there is no mu in the trace to read.

But the forecast model already computes one. Its mu runs over T + horizon steps, and the first T of them are a pure replay: they depend only on parameters and innovations that came out of the trace, never on the fresh horizon innovations, because the recursion runs forward in time. So the in-sample fitted path is the forecast model's own mu[:T], free of charge when a forecast has already been made, and one small predictive pass when it has not.

Pass view when forecast_node() has already run for this fit; the two agree exactly, since the horizon seed cannot reach the training rows.

infer_period#

python
def infer_period(data: TimeSeriesData) -> int | None

The seasonal period implied by the time index, or None when unclear.

Monthly spacing gives 12, quarterly 4, weekly 52. Anything else, including a plain integer step index, gives None, which means no seasonal term unless the caller names a period. Guessing a period from data that does not carry one is how a forecast grows a season it never had.

inject_futures#

python
def inject_futures(trace: Any, futures: Mapping[str, Array]) -> Any

Write frozen parent futures into a copy of a trace, ready to be replayed.

Returns an augmented posterior as an xarray Dataset, which is one of the shapes pm.sample_posterior_predictive reads. The trace handed in is never touched: a fitted posterior is a record, and one node's handoff must not change what another node replays.

It reads a full sampler result or a bare posterior Dataset, through _posterior_of(), and both shapes really occur: the ordinary staged handoff passes the trace the fit returned, while an attribution replay passes the Dataset zero_sites() handed back. Everything else on this path (_audit_sites(), posterior_draws()) already took both, so taking only one here made a node with a frozen parent unexplainable by replay.

Each (S, H) array is folded back onto the trace's own (chain, draw) grid in the order _flatten_draws() unfolded it, so draw s of the parent lands on draw s of the child. Under staged fitting those two posteriors are independent by construction, so the pairing is a coupling of two exchangeable samples rather than a joint draw, and any pairing would do. What matters is that there is one: the child's forecast is then evaluated once per parent draw, which is what carries the parent's whole distribution into the child's band instead of averaging it away.

lag_align#

python
def lag_align(
    history: Array,
    future: Any | None,
    lag: int,
    horizon: int,
) -> Any

One parent column, shifted lag steps right and padded at the front.

An edge x[t-k] -> y[t] says row t of the child's design reads the parent at t - k. The first k rows reach back before the series starts, so they are front-padded with the parent's first observed value. That choice is worth stating because it is an assumption: it says the driver was where it started before we began watching, which is the mildest thing to assume and is wrong in exactly one direction, understating the trend over the first k steps. The alternative, masking the first max_lag steps out of the likelihood, is a real option and is deliberately not taken: with k small against T the padded rows are a fraction of a percent of the information, and dropping rows makes two nodes at different lags fit different windows.

history is the parent over the training window, (T,), on the parent's own scaled space. future is the parent over the horizon, (H,), either a numpy array (a known future) or a tensor of the model being built (a frozen parent's {base}_fc site, or an upstream node's mean path). The result runs T + horizon steps, and it is a tensor exactly when the future it needed was one.

A lag at or past the horizon never reads the future at all: every horizon row still falls inside the training window, so the column comes back pure numpy.

mae#

python
def mae(
    actual: npt.NDArray[Any] | Any,
    samples: npt.NDArray[Any] | Any,
) -> float

Mean absolute error of the median path.

mape#

python
def mape(
    actual: npt.NDArray[Any] | Any,
    samples: npt.NDArray[Any] | Any,
) -> float

Mean absolute percentage error, in percent, over the non-zero actuals.

NaN when every actual is zero, since there is nothing to take a percentage of.

mape_full#

python
def mape_full(
    actual: npt.NDArray[Any] | Any,
    samples: npt.NDArray[Any] | Any,
) -> float

rolling_mape() over the whole horizon: the error on the total.

mase#

python
def mase(
    actual: npt.NDArray[Any] | Any,
    samples: npt.NDArray[Any] | Any,
    train: npt.NDArray[Any] | Any,
    m: int = 1,
) -> float

Mean absolute scaled error against the in-sample seasonal naive.

The scale is the mean absolute error a seasonal naive would have made on the training data at period m (m=1 is the plain naive, the right choice with no seasonality). One means the forecast matched that floor, below one beat it. NaN when the training series is too short for one seasonal difference, or when that difference is flat, since the scale would be zero.

max_lag#

python
def max_lag(graph: MixedGraph) -> int

The deepest lag any node reaches back to, zero for a purely contemporaneous graph.

mcmc_node_count#

python
def mcmc_node_count(roles: Mapping[str, NodeRole]) -> int

How many nodes a sampler will have to fit, which is what fit="auto" reads.

Read off the roles rather than off the schedule, because the schedule needs to know which strategy is running before it can be built (a joint plan lifts the hierarchy rule that a staged one enforces) and the count cannot wait for it. Every MODEL node lands in exactly one MCMC stage, so the two agree.

mint_weights#

python
def mint_weights(
    result: ForecastResult,
    hierarchy: Hierarchy,
    *,
    weights: str = 'posterior',
    residual_var: Mapping[str, float] | None = None,
    trust: Mapping[str, float] | None = None,
) -> tuple[tuple[str, ...], Array]

The diagonal of W, one row per forecast step: (nodes, (H, N)).

Two rules, both diagonal. "posterior" reads each node's own predictive variance at that step straight off its unreconciled draws, so a node that already says it is unsure gets moved further and nothing extra has to be estimated. "mint_shrink" reads the node's in-sample one-step residual second moment (the mean of the squared residuals, uncentered, which is the E[e e'] MinT is written against, so a biased fit pays for its bias), the classic MinT weighting. It is flat across the horizon and comes either from residual_var or from the result's own residual_var, since it is a property of the fit rather than of the draws.

trust divides a node's entry, so a node you trust ten times as much moves about a tenth as far. That is the ForecastBelief.trust idea from the source, kept.

node_roles#

python
def node_roles(
    data: TimeSeriesData,
    graph: MixedGraph | None,
    mechanisms: Mapping[str, TemporalMechanism],
    *,
    given: Mapping[str, TemporalMechanism] | None = None,
    scenario: Mapping[str, Any] | None = None,
    hierarchy: Hierarchy | None = None,
) -> dict[str, NodeRole]

One NodeRole per node, from the mechanisms and the graph.

Three rules, in this order:

given is the caller's own mechanisms= mapping, which is what makes the second rule readable: naming a node in scenario= pins its future, and unless you also handed it a mechanism there is no reason to spend a sampler on it. Hand it both and it stays a MODEL whose forecast segment is pinned, which is the variant that still costs a fit.

posterior_draws#

python
def posterior_draws(trace: Any) -> dict[str, Array]

Every posterior site as (S, ...) draws, the chains stacked onto one axis.

The flat view a decomposition reads: a mechanism whose composition rule needs a fitted parameter as well as the recorded pieces looks it up here by site name.

reconcile#

python
def reconcile(
    result: ForecastResult,
    hierarchy: Hierarchy,
    *,
    method: str = 'bottom_up',
    data: HierarchicalTSData | None = None,
    proportions: str | None = None,
    level: int | None = None,
    weights: str | None = None,
    trust: Mapping[str, float] | None = None,
    residual_var: Mapping[str, float] | None = None,
) -> ForecastResult

Make the levels of result agree, and say how it was done.

Works on any draws-shaped result whose nodes cover the tree, so a set of forecasts can be reconciled several ways without refitting anything, and the unreconciled version stays available through method="none".

Comparing strategies therefore means looping over methods with their own options, because an option a strategy does not read raises rather than being ignored:

python
recipes = [
    ("bottom_up", {}),
    ("top_down", {"data": data}),
    ("mint", {"weights": "mint_shrink"}),
]
table = {
    method: reconcile(unreconciled, tree, method=method, **options)
    for method, options in recipes
}

One shared option dict across every method is the thing that will not work, and the refusal says which option the strategy did not read.

Parameters result The unreconciled forecast: every node of the hierarchy, (S, H) draws each, all sharing one draw axis. Nodes outside the hierarchy (a driver, say) ride along untouched. hierarchy The tree. Sum aggregation only. method One of RECONCILE_METHODS. data The training data, which top_down and middle_out read for the historical shares. Required for those two, refused for the others. proportions "historical" (the default) or "forecast", for the two strategies that split a parent downward. level Which depth middle_out believes, with the root at 0. Required there, refused elsewhere. weights "posterior" (the default) or "mint_shrink", for mint. trust {node: weight}, read by mint only. A node with weight 10 moves about a tenth as far as it otherwise would. residual_var In-sample one-step residual variance per node, which weights="mint_shrink" needs.

Returns ForecastResult A new result carrying the reconciled draws, reconciliation naming the method and its options, and coherent set by measuring rather than by assuming.

regression_keys#

python
def regression_keys(
    node: str,
    components: Mapping[str, Array],
) -> tuple[str, ...]

The design keys behind a node's {node}_reg_{key} deterministics, sorted.

This is the naming contract read backwards: a forecast model registers one such deterministic per driver, so the drivers of a fitted node can be recovered from what it recorded, without asking the graph again.

resolve_strategy#

python
def resolve_strategy(
    requested: str,
    graph: MixedGraph | None,
    n_mcmc: int,
) -> str

Turn fit= into the strategy that will actually run.

"auto" is independent without a graph (with nothing shared, a joint model is a bigger sampler for the same answer), and with one it is joint at or under AUTO_JOINT_MAX_NODES modeled nodes and staged above it.

rmse#

python
def rmse(
    actual: npt.NDArray[Any] | Any,
    samples: npt.NDArray[Any] | Any,
) -> float

Root mean squared error of the median path.

rolling_accuracy#

python
def rolling_accuracy(
    actual: npt.NDArray[Any] | Any,
    samples: npt.NDArray[Any] | Any,
    window: int,
) -> float

accuracy() on rolling window sums. NaN when the horizon is too short.

rolling_accuracy_3#

python
def rolling_accuracy_3(
    actual: npt.NDArray[Any] | Any,
    samples: npt.NDArray[Any] | Any,
) -> float

rolling_accuracy() over three-step windows.

rolling_accuracy_6#

python
def rolling_accuracy_6(
    actual: npt.NDArray[Any] | Any,
    samples: npt.NDArray[Any] | Any,
) -> float

rolling_accuracy() over six-step windows.

rolling_mape#

python
def rolling_mape(
    actual: npt.NDArray[Any] | Any,
    samples: npt.NDArray[Any] | Any,
    window: int,
) -> float

MAPE of rolling window sums, in percent.

Each window position sums the actuals and the forecast over window steps and scores the two sums; the result averages those. NaN when the horizon is shorter than the window or every window of actuals sums to zero.

rolling_mape_3#

python
def rolling_mape_3(
    actual: npt.NDArray[Any] | Any,
    samples: npt.NDArray[Any] | Any,
) -> float

rolling_mape() over three-step windows.

rolling_mape_6#

python
def rolling_mape_6(
    actual: npt.NDArray[Any] | Any,
    samples: npt.NDArray[Any] | Any,
) -> float

rolling_mape() over six-step windows.

schedule#

python
def schedule(
    data: TimeSeriesData,
    graph: MixedGraph | None = None,
    mechanisms: Mapping[str, TemporalMechanism] | None = None,
    *,
    scenario: Mapping[str, Any] | None = None,
    fit: str = 'auto',
) -> TrainingSchedule

The plan forecast() would follow, without fitting anything.

Reads the same three things the forecast reads (the mechanisms, the graph, the hierarchy) and returns the TrainingSchedule: which nodes are given, which are fitted and in what order, which are summed at the end, and what each fitted node reads from the waves before it. Print it before spending a sampler on it.

It refuses everything the forecast would refuse about the structure: a graph naming a column that is not there, a hierarchy parent that wants its own model under staged fitting, a model node reading an aggregate, nodes that drive each other in a loop. What it cannot see is anything about the horizon, so a missing scenario path is still the forecast's to catch.

fit matters here for one reason: a joint plan may carry a hierarchy parent that models itself and a staged plan may not, so passing the same fit the forecast will get is what makes this the plan that actually runs.

smape#

python
def smape(
    actual: npt.NDArray[Any] | Any,
    samples: npt.NDArray[Any] | Any,
) -> float

Symmetric mean absolute percentage error on the 0 to 200 scale.

split_lag#

python
def split_lag(node: str) -> tuple[str, int]

Split a window node into its variable and its lag: "x[t-2]" to ("x", 2).

validate_window_graph#

python
def validate_window_graph(graph: MixedGraph) -> MixedGraph

Check graph is a window graph and return it unchanged.

Three things have to hold. Every node name parses as "v[t]" or "v[t-k]". No edge points backward in time, meaning no arrowhead lands on the earlier of two nodes at different lags. And the directed edges are acyclic, which for nodes inside one time slice is the ordinary requirement and across slices is time's own doing.

window_graph#

python
def window_graph(edges: Iterable[tuple[str, str, int]]) -> MixedGraph

Build a window graph from (source, target, lag) triples.

Each triple becomes the edge source[t-lag] -> target[t]. Lag zero is a contemporaneous edge, and a variable may point at itself at lag one or more (self-memory), which is a legal edge because the two lagged copies are different nodes.

window_graph([("pmi", "sales", 2), ("sales", "sales", 1)]) gives pmi[t-2] -> sales[t] and sales[t-1] -> sales[t].

zero_sites#

python
def zero_sites(trace: Any, names: Iterable[str]) -> Any

Set the named sites to zero in a copy of a trace, leaving everything else alone.

This is how the replay engine switches a factor off. Zero is the neutral value by construction under the pinned parameterization: a coefficient of zero contributes nothing, and a zeroed season_raw gives 12 * softmax(0), which is exactly one, so the seasonal factor lands on its identity rather than near it. The Fourier form is additive, so zeroing its coefficients removes the term outright.

The trace handed in is never touched. A name that is not in the posterior raises, because silently zeroing nothing would hand back a bar of zero for a factor that is still fully switched on.

Suggest an edit
Documentation

Search ergodic 0.2.1

Search ergodic by task, function or error.