fix(spec): scale the B_exog prior to the data instead of pinning sigma=1 - #237
Merged
Conversation
A constant exog column is perfectly collinear with the intercept every VAR carries, so its coefficient is not identified — the likelihood cannot split the level between the two, and the split is decided entirely by the priors. It also has zero sample spread, so the scale-adaptive B_exog prior that follows in this stack has nothing to key off. VARData now rejects such columns at construction, naming every offender and pointing at the fix (drop it, or encode a level shift as a dummy that changes value within the sample). Non-finite columns still fall through to the finiteness check, whose message names the real problem. Refs #192 Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01Egjd7ToFeb9TQqFnfRQZxV
`VAR.fit` built the exogenous coefficients as `Normal(0, 1)` regardless of
the data. But `B_exog[i, j]` converts regressor `j`'s units into variable
`i`'s, so a unit prior encodes a wildly different belief for every dataset:
it crushes coefficients on small-scale regressors and leaves coefficients on
large-scale ones effectively unrestricted, with no warning either way.
Measured on a 2-var VAR(1) with a regressor of sd 0.01 and a true coefficient
of 50 (contribution sd 0.5 against a shock sd of 0.1 — a 5-sigma signal), OLS
recovers 49.04 while the old prior returned a posterior mean of 3.94 with a
94% HDI of [2.18, 5.73]. The truth was excluded by an order of magnitude and
nothing in the output said so.
The prior now lives in contribution space:
sd[i, j] = exog_prior_scale * sigma_i / s_j
with `sigma_i` the AR(1) residual sd of variable `i` (the scale the Minnesota
prior already uses) and `s_j` the sample sd of the lag-trimmed regressor. One
prior sd of `B_exog[i, j]` then moves variable `i` by `exog_prior_scale` of
its own residual sds per one sd of the regressor. `s_j` is floored at 1e-3 of
the column's peak magnitude so a numerically flat column cannot send the prior
to infinity, and a column that is identically zero after lag-trimming raises
rather than dividing by zero.
The new `VAR.exog_prior_scale` field (default 100.0, loose by design — cf. the
conjugate engine's `Vc = 10e6` on the intercept) is a `VAR` field rather than a
`Prior`-protocol extension: `B_exog` sits outside that protocol, and widening
it would break third-party priors for a term they do not model.
BREAKING: every posterior fitted through `VAR.fit` with exogenous regressors
changes. Pre-v0.1.
Refs #192
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Egjd7ToFeb9TQqFnfRQZxV
ADR-0012 states the formula, why the default is loose rather than tight (the conjugate engine's Vc = 10e6 precedent), why the knob is a VAR field and not a Prior-protocol extension, why internal standardisation was rejected on blast radius, and what this change deliberately leaves alone — uncentred trend geometry, the intercept's identical units problem, and degenerate endogenous columns. Refs #192 Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01Egjd7ToFeb9TQqFnfRQZxV
…level The all-zero guard was too narrow. A dummy that switches inside the initial conditions — 0 for rows 0-1, 1 thereafter, fitted with lags=4 — passes VARData's whole-sample check and then arrives at the prior as a column of ones. Its standard deviation is zero, so the 1e-3 floor engaged off the column's *level* and handed it a prior sd of 1e5 * sigma_i, on a coefficient exactly collinear with the intercept over the estimation sample. That is the same silently-unidentified failure this stack exists to close, reintroduced by the safety net. The check now runs on the raw standard deviation, before the floor, and fires for a constant column at any level. The floor keeps its job: taming columns with tiny-but-real variation, not manufacturing a scale for columns with none. The remedy in the message is now "reduce `lags`" — extending the sample only helps if it is extended backwards. ADR-0012 gains the matching consequence, and drops an incorrect claim: on the PyMC path `sigma_i` is *not* the scale MinnesotaPrior uses, because `build_priors(n_vars, n_lags)` never receives the data. That scale is the conjugate path's, via `minnesota_dummies`. The same correction narrows the degenerate-endogenous-column note, which MinnesotaPrior does not in fact share. Refs #192 Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01Egjd7ToFeb9TQqFnfRQZxV
Codecov Report✅ All modified and coverable lines are covered by tests. Additional details and impacted files@@ Coverage Diff @@
## main #237 +/- ##
=======================================
+ Coverage 93.3% 93.8% +0.4%
=======================================
Files 41 41
Lines 2506 2526 +20
Branches 298 300 +2
=======================================
+ Hits 2339 2370 +31
+ Misses 119 113 -6
+ Partials 48 43 -5 ☔ View full report in Codecov by Harness. 🚀 New features to boost your workflow:
|
thomaspinder
added a commit
that referenced
this pull request
Jul 29, 2026
#237 made VARData reject exog columns that are constant within the sample, so the all-ones "const" column this guard used no longer constructs. Swap it for a draw from the shared `rng` fixture; the test only needs the model to carry *some* exogenous data. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01Egjd7ToFeb9TQqFnfRQZxV
thomaspinder
added a commit
that referenced
this pull request
Jul 29, 2026
#237 made VARData reject exog columns that are constant within the sample, so the all-ones "x" column the `_fitted`/`_holdout` helpers and the name-mismatch test used no longer constructs. Swap it for a deterministic ramp; B_exog is pinned to zero in these fixtures, so the exog values never enter the predictive mean — only their presence, count and names matter to the assertions. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01Egjd7ToFeb9TQqFnfRQZxV
thomaspinder
added a commit
that referenced
this pull request
Jul 29, 2026
… columns) Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01Egjd7ToFeb9TQqFnfRQZxV
thomaspinder
added a commit
that referenced
this pull request
Jul 29, 2026
…135) Main gained a scale-adaptive prior on B_exog (#237): the prior standard deviation is now `exog_prior_scale * sigma_i / s_j`, not a fixed `Normal(0, 1)` in coefficient space. Both the `Trend` docstring and the how-to justified `scale` by the old fixed prior, which is no longer true. `Trend.scale` is still worth setting — it fixes the coefficient's units ("change per decade") and keeps the design column O(1) for the sampler — so the guidance stands; only its reason changes. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01Egjd7ToFeb9TQqFnfRQZxV
thomaspinder
added a commit
that referenced
this pull request
Jul 29, 2026
…ph seam (#56) Rebasing onto main brought in #237, which computes the `B_exog` prior standard deviation from the data (`_exog_prior_sigma`) inside the region this branch extracted into `VAR._build_pymc_model`. The extraction is what makes the merge safe: the scaled prior lives in the shared seam, so `prior_predictive` draws from the same graph `fit` samples. Had it stayed in `fit`, a prior-predictive check would have described a unit-scale model that was never estimated — silently. Nothing pinned that, so: * `TestExogPriorWiredIntoModel` gains two graph tests mirroring the existing `dist_params`-based fit-path ones — the `_build_pymc_model` graph's `B_exog` sigma equals `_exog_prior_sigma(...)`, honours `exog_prior_scale`, and is identical to the sigma the fit path builds. * `TestPriorPredictive` gains an end-to-end check that the simulated `B_exog` draws actually spread at that sigma rather than at 1. Also refreshes the two docstrings the merge made stale: `exog_prior_scale` no longer claims to apply to `fit` alone, and `_build_pymc_model` now states that every prior — the exog one included — lives in the seam. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01Egjd7ToFeb9TQqFnfRQZxV
thomaspinder
added a commit
that referenced
this pull request
Jul 29, 2026
#237 made VARData reject exog columns that are constant within the sample, so the all-ones "const" column this guard used no longer constructs. Swap it for a draw from the shared `rng` fixture; the test only needs the model to carry *some* exogenous data. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01Egjd7ToFeb9TQqFnfRQZxV
thomaspinder
added a commit
that referenced
this pull request
Jul 29, 2026
#237 made VARData reject exog columns that are constant within the sample, so the all-ones "x" column the `_fitted`/`_holdout` helpers and the name-mismatch test used no longer constructs. Swap it for a deterministic ramp; B_exog is pinned to zero in these fixtures, so the exog values never enter the predictive mean — only their presence, count and names matter to the assertions. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01Egjd7ToFeb9TQqFnfRQZxV
thomaspinder
added a commit
that referenced
this pull request
Jul 29, 2026
…135) Main gained a scale-adaptive prior on B_exog (#237): the prior standard deviation is now `exog_prior_scale * sigma_i / s_j`, not a fixed `Normal(0, 1)` in coefficient space. Both the `Trend` docstring and the how-to justified `scale` by the old fixed prior, which is no longer true. `Trend.scale` is still worth setting — it fixes the coefficient's units ("change per decade") and keeps the design column O(1) for the sampler — so the guidance stands; only its reason changes. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01Egjd7ToFeb9TQqFnfRQZxV
thomaspinder
added a commit
that referenced
this pull request
Jul 29, 2026
…ph seam (#56) Rebasing onto main brought in #237, which computes the `B_exog` prior standard deviation from the data (`_exog_prior_sigma`) inside the region this branch extracted into `VAR._build_pymc_model`. The extraction is what makes the merge safe: the scaled prior lives in the shared seam, so `prior_predictive` draws from the same graph `fit` samples. Had it stayed in `fit`, a prior-predictive check would have described a unit-scale model that was never estimated — silently. Nothing pinned that, so: * `TestExogPriorWiredIntoModel` gains two graph tests mirroring the existing `dist_params`-based fit-path ones — the `_build_pymc_model` graph's `B_exog` sigma equals `_exog_prior_sigma(...)`, honours `exog_prior_scale`, and is identical to the sigma the fit path builds. * `TestPriorPredictive` gains an end-to-end check that the simulated `B_exog` draws actually spread at that sigma rather than at 1. Also refreshes the two docstrings the merge made stale: `exog_prior_scale` no longer claims to apply to `fit` alone, and `_build_pymc_model` now states that every prior — the exog one included — lives in the seam. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01Egjd7ToFeb9TQqFnfRQZxV
thomaspinder
added a commit
that referenced
this pull request
Jul 29, 2026
… columns) Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01Egjd7ToFeb9TQqFnfRQZxV
thomaspinder
added a commit
that referenced
this pull request
Jul 29, 2026
…ttedVAR (#230) * refactor(spec): extract _build_pymc_model and fitted_values seams Behaviour-neutral extraction ahead of the predictive APIs (#56): * `VAR._build_pymc_model(data) -> (pm.Model, n_lags)` lifts the graph construction out of `VAR.fit` verbatim (the `sv/spec.py` precedent). `fit` now builds, samples, and wraps; `prior_predictive` will build and draw from the same graph. * `_residuals.fitted_values(posterior, data, n_lags)` lifts the conditional-mean computation out of `reduced_form_residuals`, which is now `y_obs - fitted_values(...)`. The same seam feeds `posterior_predictive`. No graph, no posterior, and no numerical output changes. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01Egjd7ToFeb9TQqFnfRQZxV * feat(predictive): VAR.prior_predictive (#56) `VAR.prior_predictive(data, *, draws=500, random_seed=None)` builds the same graph `fit` builds via `_build_pymc_model` and calls `pymc.sample_prior_predictive` on it, so the simulated prior is by construction the prior that gets sampled — no hand-rolled simulator to drift out of sync (the anti-pattern the issue calls out). Semantics: the simulated `obs` paths are one-step-ahead given the OBSERVED lags, `y_t = c + B x_t^obs (+ B_exog z_t) + L_t eps_t`, because the design matrices are baked into the graph. That is what `az.plot_ppc(..., group="prior")` expects and what puts the prior on the data's own time axis. Returns PyMC's InferenceData as-is: `prior`, `prior_predictive` (dims `(chain, draw, time, var)`, chain=1) and `observed_data`. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01Egjd7ToFeb9TQqFnfRQZxV * feat(predictive): FittedVAR.posterior_predictive (#56) `FittedVAR.posterior_predictive(*, simulate_innovations=True, seed=None)` replicates the estimation sample from the posterior: y_rep[t] = intercept + B x_t^obs (+ B_exog z_t) + L_t eps_t Two deliberate readings, both asserted by tests: * One-step-ahead conditioned on the OBSERVED lags — the object `pm.sample_posterior_predictive` returns on the fitted graph and the one `az.plot_ppc` consumes. Not an iterated simulated path; that is `forecast()`. * `L_t` comes from the volatility seam (`cholesky_path`), so the innovations use the model's own Sigma — per-draw and per-t under SV. The issue's "residual covariance of each posterior draw" wording would flatten exactly the heteroscedasticity an SV fit exists to capture. Computed in NumPy, not on a PyMC graph: ConjugateVAR posteriors (which have no graph) get the method for free, `simulate_innovations=False` becomes a plain mean, and there is no backend divergence. The drift risk that buys is fenced by a slow moment-comparison against `pm.sample_posterior_predictive` on a fit with strongly correlated shocks (a near-diagonal Sigma would let a Cholesky-orientation bug through). `self.idata` is never mutated — a fresh InferenceData with `posterior_predictive` and `observed_data` comes back, and the docstring shows the `fitted.idata.extend(ppc)` recipe. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01Egjd7ToFeb9TQqFnfRQZxV * docs(predictive): how-to page, ADR-0011 and CONTEXT entries (#56) * `docs/how-to/predictive-checks.md` (unexecuted MyST, zero CI cost): prior check via `az.plot_ppc(..., group="prior")`, posterior check plus a coverage snippet, mean mode for residual diagnostics, the `idata.extend` recipe, the memory note, and a table separating the two checks from `forecast` / `conditional_forecast`. * ADR-0011 records why the posterior predictive is NumPy rather than `pm.sample_posterior_predictive` (ConjugateVAR parity, mean mode, backend independence, the volatility seam) and names the slow test that fences the resulting drift risk. * CONTEXT.md gains a "Predictive check" term, a relationship line and a dialogue entry, all insisting these are estimation-window objects and not forecasts. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01Egjd7ToFeb9TQqFnfRQZxV * fix(predictive): keep the new annotations beartype-resolvable (#56) `impulso.enable_runtime_checks()` beartype-wraps the public classes, and beartype resolves return annotations on call. It cannot resolve a DOTTED forward reference whose module is TYPE_CHECKING-only: `-> tuple["pm.Model", int]` on `_build_pymc_model` raised BeartypeCallHintForwardRefException on the first `prior_predictive` call under checks. Nothing in the suite exercised that combination. * `_build_pymc_model` now returns `tuple[Any, int]` — the same reason `FittedVAR.pymc_model` is `Any`, and PyMC stays lazily imported. * `spec.py` imports arviz at module level (it is already loaded by the time `import impulso` returns, so this costs nothing) and annotates `prior_predictive` with the real `az.InferenceData`. * New slow test drives both methods in a throwaway interpreter with checks enabled, and asserts a bad argument is still flagged. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01Egjd7ToFeb9TQqFnfRQZxV * test(predictive): pin the scale-adaptive exog prior to the shared graph seam (#56) Rebasing onto main brought in #237, which computes the `B_exog` prior standard deviation from the data (`_exog_prior_sigma`) inside the region this branch extracted into `VAR._build_pymc_model`. The extraction is what makes the merge safe: the scaled prior lives in the shared seam, so `prior_predictive` draws from the same graph `fit` samples. Had it stayed in `fit`, a prior-predictive check would have described a unit-scale model that was never estimated — silently. Nothing pinned that, so: * `TestExogPriorWiredIntoModel` gains two graph tests mirroring the existing `dist_params`-based fit-path ones — the `_build_pymc_model` graph's `B_exog` sigma equals `_exog_prior_sigma(...)`, honours `exog_prior_scale`, and is identical to the sigma the fit path builds. * `TestPriorPredictive` gains an end-to-end check that the simulated `B_exog` draws actually spread at that sigma rather than at 1. Also refreshes the two docstrings the merge made stale: `exog_prior_scale` no longer claims to apply to `fit` alone, and `_build_pymc_model` now states that every prior — the exog one included — lives in the seam. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01Egjd7ToFeb9TQqFnfRQZxV * fix(spec): keep the sampling tail in fit and rethread error_dist after rebase The rebase onto main duplicated fit's sampling tail into _build_pymc_model and dropped error_dist from fit's model_construct call. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01Egjd7ToFeb9TQqFnfRQZxV --------- Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
thomaspinder
added a commit
that referenced
this pull request
Jul 29, 2026
… columns) Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01Egjd7ToFeb9TQqFnfRQZxV
thomaspinder
added a commit
that referenced
this pull request
Jul 29, 2026
…ss (#226) * feat(granger): posterior causal-strength query on FittedVAR (#154) `FittedVAR.granger_causality(cause, effect)` reports the posterior of the Euclidean norm of the tested lag coefficients of `cause` in the `effect` equation, plus the per-lag posteriors behind it. A magnitude, not a test statistic: nothing divides through by the posterior covariance, so a small effect stays distinguishable from an imprecise one. An optional `rope` adds `p_rope = P(||b|| < rope | data)` — practical negligibility at a threshold the analyst names, deliberately with no default. It is not the probability of no causality: `b = 0` has probability zero under continuous coefficient priors, so that quantity needs a spike-and-slab prior Impulso does not fit. The `GrangerCausalityResult` docstring carries the full statement. The engine lives in a new private `_granger.py` (the `conditional_forecast` delegation precedent), and its extraction is pinned by tests against a hand-built posterior with every entry distinct, cross-checked against `lag_matrices` so the lag-major layout cannot drift between the two consumers. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01Egjd7ToFeb9TQqFnfRQZxV * feat(granger): toda_yamamoto() lag-augmented mode (#154) `toda_yamamoto(data, cause, effect)` runs the Toda-Yamamoto (1995) procedure for possibly-integrated systems: fit the VAR in levels with `p + d` lags, test only the first `p`. The augmented lags are never tested and the reported test lag order is never silently changed to match the fit — the result carries `n_lags_tested` and `n_lags_fitted` separately, with `augmentation` and `augmentation_source` recording where the extra lags came from. `d` comes from `integration_order` unless the caller pins it. Honouring the consumer contract frozen by #140/#197: when the diagnostics leave anything in `inconclusive`, `d_max` is a floor rather than a finding, so this refuses to run — naming the variables, pointing at `.summary()` and at the `d=` override — rather than under-augmenting silently. An explicit `d` skips the diagnostics entirely, so the route also works without statsmodels installed. The fit uses the closed-form conjugate estimator, since augmentation inflates the lag order; exogenous regressors it cannot consume are refused with a message naming the manual `VAR(lags=p + d)` route. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01Egjd7ToFeb9TQqFnfRQZxV * docs(granger): how-to, reference, CONTEXT terms, and ADR-0010 (#154) New how-to covering the fitted-model query, how to read `summary()`, why there is no probability of no causality, the Toda-Yamamoto happy path plus its refusal and the `d=` override, the manual NUTS route, and a worked carbon-dioxide/temperature example with an explicit statement of what it does and does not license (predictive precedence not intervention; omitted forcings; bidirectional physical coupling; annual aggregation). Cross-links with the climate-pitfalls page both ways. New reference page for `toda_yamamoto`; `GrangerCausalityResult` added to the results page. CONTEXT gains three terms — Granger causality, ROPE, Toda-Yamamoto augmentation — plus the two relationships that place the query on `FittedVAR` and wire the augmentation to the integration-order contract. ADR-0010 records the decision: the norm of the tested coefficients as the headline with an analyst-supplied ROPE, against the rejected alternatives (a Wald quadratic headline, a fixed default epsilon, spike-and-slab, and Savage-Dickey). Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01Egjd7ToFeb9TQqFnfRQZxV * test(granger): pin the posterior dim-realignment fallbacks (#154) A transposed posterior must be realigned by its canonical dim names, and an unlabelled one must fall back to the positional (chain, draw, var, coeff) convention — the same contract `dynamic_multiplier` relies on. Neither path was exercised. Takes `_granger.py` to full branch coverage. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01Egjd7ToFeb9TQqFnfRQZxV * docs(granger): state p_rope as the probability of the event, not the event The honesty paragraph's one imprecise sentence read the probability as an assertion of practical negligibility; a p_rope of 0.02 says no such thing. Refs #154 Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01Egjd7ToFeb9TQqFnfRQZxV * test(granger): use a non-constant exog fixture (#237 rejects constant columns) Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01Egjd7ToFeb9TQqFnfRQZxV --------- Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
Merged
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
Summary
The most severe open defect in the tracker:
B_exog ~ Normal(0, 1)regardless of regressor scale — silently wrong inference on the NUTS+exog path. Measured demonstration (2-var VAR(1), T=200, tiny-scale regressorz ~ N(0, 0.01²)with true coefficient 50):Normal(0,1)priorA 12.4× crush, with the truth ~46 posterior sds outside the reported interval and no warning anywhere.
The fix:
sd(B_exog[i,j]) = exog_prior_scale · σᵢ / sⱼwhereσᵢis the conjugate path's canonical AR(1) residual scale andsⱼthe exog column's sd over the lag-trimmed rows the likelihood actually sees, floored against near-constants. Defaultexog_prior_scale = 100— deliberately loose-and-scaled, following the library's own diffuse-deterministic tradition (the conjugate intercept'sVc = 10e6); the asymmetry of failure modes decides it (too-tight recreates this bug; too-loose costs only mild shrinkage), and the knob is a documentedVARfield. ADR-0012 records the formula, the loose-vs-tight argument, and why internal standardisation was rejected (6-module blast radius for the same inferential fix).Guards added (the silent-unidentifiability class this PR closes): exactly-constant exog columns rejected at
VARDataconstruction (they duplicate the always-present intercept), and columns left constant at any level by lag-trimming rejected at fit time with a "reducelags" remedy — the latter found by review (a post-initial-conditions dummy previously drew a 1e5·σ prior on an intercept-collinear coefficient).Breaking changes (pre-v0.1)
Merge-order note: whichever of this PR and #191 (
feat/135-deterministic-regressors) merges second must updatedocs/how-to/deterministic-regressors.mdand theTrenddocstring — both describe the old fixedNormal(0, 1)prior;Trend.scale's rationale softens to interpretable-units + sampler geometry.Closes #192
Review
Planned by a Fable-tier planner; independently reviewed (verdict: approve after two minor P2s, both fixed). The reviewer re-derived all three ADR sanity numbers to the digit, verified the OLS-agreement invariant (shrinkage factor 0.99999998 — the redesigned slow test asserts what a near-flat prior actually promises), confirmed the
dist_paramsgraph accessor is the durable API, and endorsedc=100explicitly. The reviewer also caught the ADR misattributing the σᵢ scale toMinnesotaPrioron the PyMC path — corrected, with the knock-on consequence rewritten honestly.Tests
+36 (formula/floor/dummy-edge units, graph-wiring via
dist_params, knob validation, both constant-column rejection layers end-to-end, and the slow recovery demonstration verified across{pymc, nutpie} × 3 seeds). Fast suite: 637 passed, 30 deselected; slow recovery green; ruff/ty clean.🤖 Generated with Claude Code
https://claude.ai/code/session_01Egjd7ToFeb9TQqFnfRQZxV