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#
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#
FIT_STRATEGIES: tuple = ('auto', 'independent', 'staged', 'joint')
Every value fit= takes.
PROPORTION_RULES#
PROPORTION_RULES: tuple = ('historical', 'forecast')
How top_down and middle_out split a parent between its children.
RECONCILE_METHODS#
RECONCILE_METHODS: tuple = ('bottom_up', 'top_down', 'middle_out', 'mint', 'none')
The strategy menu, in the order the module docstring introduces it.
SEASON#
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#
WEIGHT_RULES: tuple = ('posterior', 'mint_shrink')
Where mint gets the diagonal of its weight matrix.
Attribution#
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#
property Attribution.node_names: tuple[str, ...]
Every node explained, in order, without repeats.
Attribution.windows#
property Attribution.windows: tuple[str, ...]
Every window label, in order, without repeats.
Attribution.to_frame#
def Attribution.to_frame(level: float = 0.9) -> pd.DataFrame
Every bar of every node and window, one row each.
BacktestFold#
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#
property BacktestFold.nodes: tuple[str, ...]
The scored series, in forecast order.
BacktestReport#
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#
property BacktestReport.cutoffs: tuple[int, ...]
The training-set end position of each fold.
BacktestReport.values#
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#
def BacktestReport.summary() -> pd.DataFrame
Mean metric per node across folds: nodes down, metrics across.
BacktestReport.to_frame#
def BacktestReport.to_frame() -> pd.DataFrame
A tidy frame: one row per fold, node, and metric.
BacktestReport.diagnostics#
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#
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#
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:
launch_index: the step the product launches at. Everything before it is exactly zero adoption, not a small one, so a series that carries a run of pre-launch zeros is modeled rather than fought.p_prior,q_prior:Betaparameters for innovation and imitation. The defaults sit where the diffusion literature's meta-analyses put them: innovation small (Beta(2, 50), mean about 0.04) and imitation an order of magnitude larger (Beta(2, 5), mean about 0.29), which is what makes the curve S-shaped rather than exponential-decaying.m_prior:(mu, sigma)of the LogNormal on the market size, on the scaled series. Left unset it is anchored at the total already adopted, withsigma = 1around it, which is a factor of seven either way at two standard deviations: enough that the likelihood decides, and anchored so the prior means the same thing for a series in units and one in millions.scaler: override the automatic choice.
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#
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#
def Bass.baseline_label() -> str
Nothing here is a trend: the whole path is the diffusion curve.
Bass.factors#
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#
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#
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#
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#
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#
def Bass.predictive_site(node: str) -> str
The site holding the horizon's predictive draws in a forecasting model.
Bass.mean_site#
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#
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:
period: the season length, as inStructuralMechanism.Noneis no season.seasonality:"auto"(read the period),"monthly","fourier", orNone.n_harmonics: how many sine and cosine pairs the Fourier form uses.robust: a Student-t likelihood rather than a Normal one.reg_scale: the prior scale on each attractor coefficient, on the scaled series. Smaller than the structural mechanism's by default (0.2 against 1.0), because a coefficient here moves the equilibrium of a series that is scaled to sit near one, and an equilibrium that swings by a whole unit of the series is a very large claim.scaler: override the automatic choice.
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#
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#
def CausalOU.scaler_for(values: Array) -> Scaler
The fitted scaler: the override when given, else the automatic choice.
CausalOU.baseline_label#
def CausalOU.baseline_label() -> str
A mean-reverting level has no slope, so calling it a trend would mislead.
CausalOU.factors#
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#
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:
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#
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#
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#
def CausalOU.predictive_site(node: str) -> str
The site holding the horizon's predictive draws in a forecasting model.
CausalOU.mean_site#
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#
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#
def Drift.fit(data: TimeSeriesData, *, seed: int = 0) -> Self
Read the series off data; the fitted copy comes back, self is untouched.
Drift.forecast#
def Drift.forecast(horizon: int, *, seed: int = 0) -> ForecastResult
Draw n_draws sample paths horizon steps past the fitted data.
Drift.fitted#
property Drift.fitted: bool
True once fit() has run.
FactorContribution#
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#
property FactorContribution.value: float
The posterior mean of this bar.
FactorContribution.credible_interval#
def FactorContribution.credible_interval(
level: float = 0.9,
) -> tuple[float, float]
The equal-tailed interval of this bar at level.
FitStrategy#
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#
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#
class ForecastModel(*args, **kwargs)
A model that fits a time series and forecasts forward from its end.
ForecastModel.fit#
def ForecastModel.fit(
data: TimeSeriesData,
*,
seed: int = 0,
) -> ForecastModel
Fit on data and return the fitted model (a new object, not self).
ForecastModel.forecast#
def ForecastModel.forecast(horizon: int, *, seed: int = 0) -> ForecastResult
Forecast horizon steps past the end of the fitted data.
ForecastResult#
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#
property ForecastResult.nodes: tuple[str, ...]
The forecast series names, in insertion order.
ForecastResult.n_draws#
property ForecastResult.n_draws: int
The number of sample paths per node.
ForecastResult.draws#
def ForecastResult.draws(node: str | None = None) -> npt.NDArray[np.float64]
The raw draws for one node, shape (S, H).
ForecastResult.median#
def ForecastResult.median(
node: str | None = None,
) -> npt.NDArray[np.float64]
The per-step posterior median, shape (H,).
ForecastResult.mean#
def ForecastResult.mean(node: str | None = None) -> npt.NDArray[np.float64]
The per-step posterior mean, shape (H,).
ForecastResult.interval#
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#
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#
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#
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#
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#
def Forecaster.fit(data: TimeSeriesData, *, seed: int = 0) -> Forecaster
Take the training window and the fold's seed; nothing samples yet.
Forecaster.forecast#
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#
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#
property FrozenParent.site: str
The name of the fresh site the child's forecast model declares.
IdentityScaler#
class IdentityScaler() -> None
Leave the data alone. For anything already on a natural scale, like an index.
IdentityScaler.fit#
def IdentityScaler.fit(x: npt.NDArray[Any] | Any) -> Self
Learn the scale from x and return the fitted scaler.
IdentityScaler.transform#
def IdentityScaler.transform(
x: npt.NDArray[Any] | Any,
) -> npt.NDArray[np.float64]
Map data onto the model scale.
IdentityScaler.inverse#
def IdentityScaler.inverse(
x: npt.NDArray[Any] | Any,
) -> npt.NDArray[np.float64]
Map model-scale values back to the data's own units.
JointFit#
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#
property JointFit.n_obs: int
The number of time steps the model was built over.
JointFit.node_of#
def JointFit.node_of(name: str) -> JointNode
One node's slot, by name.
JointNode#
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#
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#
def LogScaler.transform(
x: npt.NDArray[Any] | Any,
) -> npt.NDArray[np.float64]
Map data onto the model scale.
LogScaler.inverse#
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#
def LogScaler.fit(x: npt.NDArray[Any] | Any) -> Self
Learn the scale from x and return the fitted scaler.
Naive#
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#
def Naive.fit(data: TimeSeriesData, *, seed: int = 0) -> Self
Read the series off data; the fitted copy comes back, self is untouched.
Naive.forecast#
def Naive.forecast(horizon: int, *, seed: int = 0) -> ForecastResult
Draw n_draws sample paths horizon steps past the fitted data.
Naive.fitted#
property Naive.fitted: bool
True once fit() has run.
NodeAttribution#
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#
property NodeAttribution.bars: tuple[FactorContribution, ...]
Every bar of the waterfall, the baseline first.
NodeAttribution.total#
property NodeAttribution.total: float
The window's total under the model's own mean path.
NodeAttribution.forecast_total#
property NodeAttribution.forecast_total: float
The same window read off the result's forecast draws.
NodeAttribution.closure_error#
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#
property NodeAttribution.relative_closure_error: float
closure_error against the size of the total, floored at one.
NodeAttribution.interval#
def NodeAttribution.interval(level: float = 0.9) -> tuple[float, float]
The window total's own credible interval.
NodeAttribution.to_frame#
def NodeAttribution.to_frame(level: float = 0.9) -> pd.DataFrame
One row per bar: name, kind, value, band, and share of the total.
NodeDiagnostics#
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#
property NodeDiagnostics.divergence_share: float
Divergences as a fraction of the draws.
NodeDiagnostics.healthy#
property NodeDiagnostics.healthy: bool
True when nothing crossed the warning thresholds.
NodeFit#
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#
property NodeFit.n_obs: int
The number of time steps the model was built over, masked ones included.
NodeForecast#
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#
class NodeRole(enum.Enum)
What a node is, which decides how the schedule treats it.
| Member | Value |
|---|---|
MODEL |
'model' |
SCENARIO |
'scenario' |
AGGREGATE |
'aggregate' |
NodeState#
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#
property NodeState.resolved: bool
True once this node has a forecast to hand down.
ParentColumn#
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#
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#
property PosteriorStore.nodes: tuple[str, ...]
Every node in the store, sorted.
PosteriorStore.put#
def PosteriorStore.put(state: NodeState) -> None
Record (or replace) one node's state.
PosteriorStore.update#
def PosteriorStore.update(node: str, **changes: Any) -> NodeState
Replace one node's state with a changed copy and return it.
RatioScaler#
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#
def RatioScaler.fit(x: npt.NDArray[Any] | Any) -> Self
Learn the scale from x and return the fitted scaler.
RatioScaler.transform#
def RatioScaler.transform(
x: npt.NDArray[Any] | Any,
) -> npt.NDArray[np.float64]
Map data onto the model scale.
RatioScaler.inverse#
def RatioScaler.inverse(
x: npt.NDArray[Any] | Any,
) -> npt.NDArray[np.float64]
Map model-scale values back to the data's own units.
ReplayRecipe#
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#
class Scaler() -> None
The scaler contract: fit returns a fitted copy, transform and inverse undo.
Scaler.fit#
def Scaler.fit(x: npt.NDArray[Any] | Any) -> Self
Learn the scale from x and return the fitted scaler.
Scaler.transform#
def Scaler.transform(x: npt.NDArray[Any] | Any) -> npt.NDArray[np.float64]
Map data onto the model scale.
Scaler.inverse#
def Scaler.inverse(x: npt.NDArray[Any] | Any) -> npt.NDArray[np.float64]
Map model-scale values back to the data's own units.
ScenarioMechanism#
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#
def ScenarioMechanism.path() -> Array
The pinned future path as an array; raises when nothing was pinned.
ScenarioMechanism.scaler_for#
def ScenarioMechanism.scaler_for(values: Array) -> Scaler
The identity: a supplied path is already on the scale the caller means.
ScenarioMechanism.build#
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#
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#
def ScenarioMechanism.predictive_site(node: str) -> str
The site holding the horizon's predictive draws in a forecasting model.
ScenarioMechanism.mean_site#
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#
def ScenarioMechanism.baseline_label() -> str
What to call the part of the forecast that is nobody's driver.
ScenarioMechanism.factors#
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#
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#
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#
def SeasonalNaive.fit(data: TimeSeriesData, *, seed: int = 0) -> Self
Read the series off data; the fitted copy comes back, self is untouched.
SeasonalNaive.forecast#
def SeasonalNaive.forecast(horizon: int, *, seed: int = 0) -> ForecastResult
Draw n_draws sample paths horizon steps past the fitted data.
SeasonalNaive.fitted#
property SeasonalNaive.fitted: bool
True once fit() has run.
Stage#
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#
class StageStrategy(enum.Enum)
How one stage's nodes get resolved.
| Member | Value |
|---|---|
RESOLVED |
'resolved' |
MCMC |
'mcmc' |
AGGREGATE |
'aggregate' |
StructuralMechanism#
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:
trend:"damped_llt", the only form so far.seasonality:"auto","monthly","fourier", orNone."auto"reads the period: twelve gives the monthly factors, any other period gives Fourier terms, and no period gives no season.period: the season length. Monthly means twelve by definition.n_harmonics: how many sine and cosine pairs the Fourier form uses.robust: swap the Normal likelihood for a Student-t, which lets a few wild points stay wild instead of dragging the level after them.reg_scale: the prior scale on each parent coefficient, on the scaled series.scaler: override the automatic choice (positive series to ratio, signed series to z-score). The mechanism fits whatever it is given.
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#
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#
def StructuralMechanism.scaler_for(values: Array) -> Scaler
The fitted scaler: the override when given, else the automatic choice.
StructuralMechanism.factors#
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#
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#
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#
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#
def StructuralMechanism.predictive_site(node: str) -> str
The site holding the horizon's predictive draws in a forecasting model.
StructuralMechanism.mean_site#
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#
def StructuralMechanism.baseline_label() -> str
What to call the part of the forecast that is nobody's driver.
TemporalMechanism#
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:
obs: the scaled observations, shape(T,), with whatever the masked steps happen to hold. A mechanism must index down to the observed steps before it writes a likelihood; masked steps never enter, and nothing is imputed on the way in.parents: design name to scaled, lag-aligned path of lengthT + horizon. Either a numpy array (a driver whose future is known) or a tensor of this model (a frozen parent's injected draws, or an upstream node's own mean path in a joint fit).parent_path()validates both and a mechanism should route every parent through it. The design name is the parent's variable name when it drives this node at one lag, and{base}_lag{k}for the extra lags when it drives at several.mask: boolean(T,), True where the series was observed.horizon: zero for a fitting model,Hfor a forecasting one. A forecasting model adds fresh innovation sites whose names end in_fc, which is how the posterior-predictive pass tells replay from simulation.
Every registered name goes through site(), so the naming contract has
exactly one implementation.
TemporalMechanism.site#
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#
def TemporalMechanism.predictive_site(node: str) -> str
The site holding the horizon's predictive draws in a forecasting model.
TemporalMechanism.mean_site#
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#
def TemporalMechanism.scaler_for(values: Array) -> Scaler
The fitted scaler this mechanism wants for values.
TemporalMechanism.build#
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#
def TemporalMechanism.baseline_label() -> str
What to call the part of the forecast that is nobody's driver.
TemporalMechanism.factors#
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#
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#
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#
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#
property TrainingSchedule.mcmc_stages: tuple[Stage, ...]
The stages that actually run a sampler.
TrainingSchedule.num_mcmc_stages#
property TrainingSchedule.num_mcmc_stages: int
How many sampler runs the schedule costs.
TrainingSchedule.n_unoriented_dropped#
property TrainingSchedule.n_unoriented_dropped: int
How many same-slice pairs the graph left unoriented, so the schedule skipped them.
TrainingSchedule.all_nodes#
property TrainingSchedule.all_nodes: tuple[str, ...]
Every node, in schedule order.
TrainingSchedule.stage_of#
def TrainingSchedule.stage_of(node: str) -> Stage | None
The stage holding node, or None when it is not scheduled.
TrainingSchedule.summary#
def TrainingSchedule.summary() -> str
A printable plan, one line per stage.
Window#
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#
def Window.resolve(
horizon: int,
index: Sequence[Any] | None = None,
) -> tuple[int, int]
The (start, end) positions this window covers, as integers.
ZScoreScaler#
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#
def ZScoreScaler.fit(x: npt.NDArray[Any] | Any) -> Self
Learn the scale from x and return the fitted scaler.
ZScoreScaler.transform#
def ZScoreScaler.transform(
x: npt.NDArray[Any] | Any,
) -> npt.NDArray[np.float64]
Map data onto the model scale.
ZScoreScaler.inverse#
def ZScoreScaler.inverse(
x: npt.NDArray[Any] | Any,
) -> npt.NDArray[np.float64]
Map model-scale values back to the data's own units.
accuracy#
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#
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#
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#
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#
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#
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#
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#
def bases(graph: MixedGraph) -> tuple[str, ...]
The variables behind the window nodes, sorted and deduplicated.
compute_metrics#
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#
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#
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#
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#
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#
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#
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#
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#
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#
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#
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#
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#
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#
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#
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#
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#
def mae(
actual: npt.NDArray[Any] | Any,
samples: npt.NDArray[Any] | Any,
) -> float
Mean absolute error of the median path.
mape#
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#
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#
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#
def max_lag(graph: MixedGraph) -> int
The deepest lag any node reaches back to, zero for a purely contemporaneous graph.
mcmc_node_count#
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#
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#
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:
- A node the hierarchy holds but the data has no column for is an
AGGREGATE: nothing models it, it is the sum of its children. - A node carrying a
ScenarioMechanism, or named inscenario=without the caller having named a mechanism for it, is aSCENARIO: its future is given, so it is never fitted. - Everything else with a column is a
MODEL.
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#
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#
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:
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#
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#
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#
def rmse(
actual: npt.NDArray[Any] | Any,
samples: npt.NDArray[Any] | Any,
) -> float
Root mean squared error of the median path.
rolling_accuracy#
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#
def rolling_accuracy_3(
actual: npt.NDArray[Any] | Any,
samples: npt.NDArray[Any] | Any,
) -> float
rolling_accuracy() over three-step windows.
rolling_accuracy_6#
def rolling_accuracy_6(
actual: npt.NDArray[Any] | Any,
samples: npt.NDArray[Any] | Any,
) -> float
rolling_accuracy() over six-step windows.
rolling_mape#
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#
def rolling_mape_3(
actual: npt.NDArray[Any] | Any,
samples: npt.NDArray[Any] | Any,
) -> float
rolling_mape() over three-step windows.
rolling_mape_6#
def rolling_mape_6(
actual: npt.NDArray[Any] | Any,
samples: npt.NDArray[Any] | Any,
) -> float
rolling_mape() over six-step windows.
schedule#
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#
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#
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#
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#
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#
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.