Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion CONTEXT.md
Original file line number Diff line number Diff line change
Expand Up @@ -142,7 +142,7 @@ An endogenous variable with no data, generated inside the model from its own VAR
_Avoid_: "missing data" or "unobserved column": nothing is imputed, and a latent series is never passed in. Avoid "state" (state-space vocabulary for a model Impulso does not fit).

**Latent block / stationarity constraint**:
The latent-on-latent part of the lag coefficients: the latent equations' coefficients on latent lags, across all lags. The observed series are data, so this block alone decides whether a generated path explodes. The stationarity constraint restricts it to the region where its companion matrix has spectral radius below 1. The posterior is the prior truncated to that region, and prior predictive sampling ignores the constraint.
The latent-on-latent part of the lag coefficients: the latent equations' coefficients on latent lags, across all lags. The observed series are data, so this block alone decides whether a generated path explodes. The stationarity constraint restricts it to the region where its companion matrix has spectral radius below 1, and the posterior is the prior truncated to that region. For one latent series with one lag the block is the latent own lag alone and the region is the interval (−1, 1), so the constraint is a truncated own-lag prior, which prior predictive sampling respects. In every other case it is a Potential, which prior predictive sampling ignores (ADR-0017).
_Avoid_: "stationary VAR" for the constraint: the observed equations are unconstrained. Avoid "stationarity pretest", which is the unrelated frequentist test run before specification.

**Model evidence (`ModelEvidence`)**:
Expand Down
4 changes: 4 additions & 0 deletions docs/adr/0012-exog-prior-scales-with-data.md
Original file line number Diff line number Diff line change
Expand Up @@ -31,3 +31,7 @@ The default `exog_prior_scale = 100.0` is deliberately **loose, not tight**. Exo
- **Not fixed here**: the prior scales but does not centre. An uncentred trend still gives NUTS a strong prior-mean/slope correlation, which costs sampling efficiency even though the inference is now correct. Internal centring for geometry is a separate change, and should be weighed against the same unstandardisation blast radius rejected above.
- **Not fixed here**: `intercept ~ Normal(0, 1)` on the PyMC path has exactly the same units problem, on the term the conjugate engine is most diffuse about. Tracked separately.
- Keying off `sigma_i` newly exposes the PyMC path to degenerate *endogenous* columns: a constant series gives `sigma_i ≈ 0`, which collapses that whole row of the prior towards zero. `VARData` does not reject constant endogenous columns, and `MinnesotaPrior` does not share the exposure precisely because it ignores the data. The conjugate path has always had it through `minnesota_dummies`. Not guarded here — the same guard belongs with the `MinnesotaPrior` scaling follow-up, where it can be applied once for both terms.

## Amendment (0.1.4): a per-equation scale

`exog_prior_scale` also accepts a tuple with one entry per endogenous equation, in `endog_names` order, mirroring `MinnesotaPrior.own_lag_mean`; the formula becomes `sd[i,j] = exog_prior_scale_i * sigma_i / s_j_eff`. The reason is the embedded path (ADR-0016). A latent endogenous series has no data of its own to pull a loose prior back, so the default of 100 lets one prior standard deviation of a regressor's coefficient move the latent series by 100 of its residual standard deviations: an MMM's latent sales baseline drew prior-predictive paths two orders of magnitude larger than the target. A single scalar could only tighten that equation by tightening every observed equation with it. The tuple's length is checked when the exogenous prior is built, because that is where the number of endogenous variables is known; every entry, like the scalar, must be positive and finite. What did not change: the belief is still stated in contribution space, the scalar form applies to every equation, its default stays at 100, and the scalar path gives the same numbers as before.
8 changes: 4 additions & 4 deletions docs/adr/0016-embeddable-var-path.md
Original file line number Diff line number Diff line change
Expand Up @@ -35,9 +35,9 @@ The reason is the prototype's funnel. The first design passed a free latent colu

**Stationarity of the latent block.** An explosive draw of the latent coefficients makes the generated path explode over the sample, and in the prototype that froze a chain (step size about 1e−278). Three things guard against it:

- **Initial values.** The model's initial point starts every latent equation's own first lag at its prior mean when that is inside the stationary region (|δᵢ| < 1), and at 0.5 otherwise, with every other coefficient in the latent rows at 0. PyMC's default start for a `Normal` is its mean. When every latent δᵢ is inside the region and the prior puts mean 0 on the rest of the latent rows, as the Minnesota prior does, the prior mean is already the stationary start, and `build_in_model` sets no initial value. Otherwise it sets one for `B` as a whole, so the observed rows start at their prior mean, which is PyMC's default start anyway. **PyMC cannot clone a model with a non-default initial value.** `Model.copy()` raises `NotImplementedError`, and so do `pm.do` and `pm.observe`, which clone the model. A latent own-lag prior mean outside the region, the default of 1 included, therefore leaves the host model unclonable. The sampler's jitter moves this start, and the Potential below is what rejects a jittered start that lands outside the region.
- **The `latent_stationarity` Potential.** It is 0 when the spectral radius of the companion matrix of the latent-on-latent block (latent rows, latent lags, all `n_lags`) is below 1, and −∞ otherwise. The observed series are data, so this block alone decides whether the path explodes. `B` stays a single `Normal`, so **the posterior is the prior truncated to the stationary region of the latent block.** A jittered start outside the region has −∞ log density, and PyMC redraws it. The Potential is piecewise constant, so its gradient is zero. PyTensor cannot differentiate the complex `eig`, and PyMC's rewrites strip `disconnected_grad`. The radius is therefore an `OpFromGraph` whose gradient is overridden with zeros (through `pullback`, or `lop_overrides` on older PyTensor). `eig` raises on a non-finite matrix, which would abort the sampler rather than record a divergence. A non-finite companion matrix is therefore swapped for `2·I` first, so the Potential is −∞.
- **What it does not do.** A Potential is not a random variable, so `sample_prior_predictive` ignores `latent_stationarity`. Prior-predictive latent paths come from the untruncated prior and can still explode. If the latent own-lag prior mean is near 1 and the data want persistence near 1, the posterior presses against the boundary and diverges there. On the 09b test setup with the default mean of 1, 212–290 of 400 draws diverged. That is one more reason to set δᵢ = 0 for a stationary deviation.
- **Initial values.** The model's initial point starts every latent equation's own first lag at its prior mean when that is inside the stationary region (|δᵢ| < 1), and at 0.5 otherwise, with every other coefficient in the latent rows at 0. PyMC's default start for a `Normal` is its mean. When every latent δᵢ is inside the region and the prior puts mean 0 on the rest of the latent rows, as the Minnesota prior does, the prior mean is already the stationary start, and `build_in_model` sets no initial value. Otherwise it sets one for `B` as a whole, so the observed rows start at their prior mean, which is PyMC's default start anyway. **PyMC cannot clone a model with a non-default initial value.** `Model.copy()` raises `NotImplementedError`, and so do `pm.do` and `pm.observe`, which clone the model. A latent own-lag prior mean outside the region, the default of 1 included, therefore leaves the host model unclonable. The sampler's jitter moves this start, and the Potential below is what rejects a jittered start that lands outside the region. For one latent series with `n_lags = 1` no initial value is set: the own lag is a truncated Normal that starts at 0 ([ADR-0017](0017-truncated-own-lag-for-one-latent-ar1.md)).
- **The `latent_stationarity` Potential.** It is 0 when the spectral radius of the companion matrix of the latent-on-latent block (latent rows, latent lags, all `n_lags`) is below 1, and −∞ otherwise. The observed series are data, so this block alone decides whether the path explodes. `B` stays a single `Normal`, so **the posterior is the prior truncated to the stationary region of the latent block.** A jittered start outside the region has −∞ log density, and PyMC redraws it. The Potential is piecewise constant, so its gradient is zero. PyTensor cannot differentiate the complex `eig`, and PyMC's rewrites strip `disconnected_grad`. The radius is therefore an `OpFromGraph` whose gradient is overridden with zeros (through `pullback`, or `lop_overrides` on older PyTensor). `eig` raises on a non-finite matrix, which would abort the sampler rather than record a divergence. A non-finite companion matrix is therefore swapped for `2·I` first, so the Potential is −∞. For one latent series with `n_lags = 1` there is no Potential: the own lag is a Normal truncated to (−1, 1) instead ([ADR-0017](0017-truncated-own-lag-for-one-latent-ar1.md)).
- **What it does not do.** A Potential is not a random variable, so `sample_prior_predictive` ignores `latent_stationarity`. Prior-predictive latent paths come from the untruncated prior and can still explode. If the latent own-lag prior mean is near 1 and the data want persistence near 1, the posterior presses against the boundary and diverges there. On the 09b test setup with the default mean of 1, 212–290 of 400 draws diverged. That is one more reason to set δᵢ = 0 for a stationary deviation. For one latent series with `n_lags = 1` neither applies: the truncated own lag is drawn by `sample_prior_predictive` and gives no boundary divergences ([ADR-0017](0017-truncated-own-lag-for-one-latent-ar1.md)).

`latent_init`, `latent_innovations` and `latent` have no dims. A latent coordinate would be unprefixed and would collide between two VARs in one model, so the names travel on `handles.latent_names` instead, in the path's column order.

Expand All @@ -55,7 +55,7 @@ The reason is the prototype's funnel. The first design passed a free latent colu

- **Centred latent series with a `Potential` likelihood** — rejected. The caller passes a free latent column as symbolic `endog`, and the VAR logp constrains it. The prototype's evidence rules it out. It is a funnel in the latent innovation scale, with divergences on every seed. A zero-avoiding scale prior did not remove it.
- **The conjugate route** — not needed. A closed-form marginal likelihood as a `Potential`, using `ConjugateVAR`'s machinery, would remove the coefficients from the sampler. The PRD set it as the remedy for *coupling*, meaning poor mixing along coefficient/latent ridges. The failure was a funnel. Divergent draws were displaced in log σ_b by −1.9 SD but in the loading by only +0.35 SD. corr(loading, own-lag) was −0.98 in every variant, and divergences did not sit along that ridge. The ridge does limit ESS (about 40–135 in the non-centred runs) and is the thing to watch if mixing becomes the problem.
- **A bounded own-lag variable** (e.g. `tanh` or a stationarity-respecting transform of the latent block) — rejected. For one latent series at VAR(1) a `tanh` bound on the own lag does bound the spectral radius, so there the rejection rests on cost alone. With cross-lags between latent series or `n_lags > 1`, bounding each own lag no longer bounds the radius, and only a full stationarity-respecting transform of the latent block would. Either way the costs are real. The latent rows' prior is no longer the Minnesota `Normal` that every other row gets, and the transform adds free variables. `B` could be reassembled as a `pm.Deterministic("B", dims=("var", "coeff"))`, so the posterior schema would survive. But `handles.B` would then be a Deterministic, and PyMC's initial point covers only free variables, so a `set_initval` on it would be silently ignored. The stationary start would have to move onto the underlying variables. A single `Normal` truncated by a Potential avoids all of this. It costs boundary divergences only when the posterior sits near the boundary, which the default own-lag mean of 1 makes likely.
- **A bounded own-lag variable** (e.g. `tanh` or a stationarity-respecting transform of the latent block) — rejected. For one latent series at VAR(1) a `tanh` bound on the own lag does bound the spectral radius, so there the rejection rests on cost alone. With cross-lags between latent series or `n_lags > 1`, bounding each own lag no longer bounds the radius, and only a full stationarity-respecting transform of the latent block would. Either way the costs are real. The latent rows' prior is no longer the Minnesota `Normal` that every other row gets, and the transform adds free variables. `B` could be reassembled as a `pm.Deterministic("B", dims=("var", "coeff"))`, so the posterior schema would survive. But `handles.B` would then be a Deterministic, and PyMC's initial point covers only free variables, so a `set_initval` on it would be silently ignored. The stationary start would have to move onto the underlying variables. A single `Normal` truncated by a Potential avoids all of this. It costs boundary divergences only when the posterior sits near the boundary, which the default own-lag mean of 1 makes likely. [ADR-0017](0017-truncated-own-lag-for-one-latent-ar1.md) adopts a bounded own lag for one latent series with `n_lags = 1`, as a truncated Normal that keeps the Minnesota prior on the region.
- **Sampler-only guidance** — rejected. It means documenting that callers should initialise inside the stationary region, and doing nothing in the graph. Under nutpie's default initialisation the jitter started a chain outside the stationary region. The chain froze at an own-lag of 1.58 with 1000 divergences and R-hat 1.59, and the loading HDI excluded the truth. Guidance does not reach the caller who never reads it, and `build_in_model` knows exactly which rows need a stationary start.

## Consequences
Expand Down
Loading
Loading