Skip to content

test(predictive): shape statistics in the posterior-predictive drift fence - #236

Open
thomaspinder wants to merge 4 commits into
feat/56-predictive-apisfrom
test/232-drift-fence-tail
Open

test(predictive): shape statistics in the posterior-predictive drift fence#236
thomaspinder wants to merge 4 commits into
feat/56-predictive-apisfrom
test/232-drift-fence-tail

Conversation

@thomaspinder

Copy link
Copy Markdown
Owner

Summary

Follow-up on the predictive-APIs branch (#230), stacked on feat/56-predictive-apis:

The NumPy-vs-PyMC drift fence compared first and second moments only, so a future matched-variance Student-t likelihood (#168 will merge) would pass while the replicate law silently diverged. Premise confirmed empirically: injected matched-variance t(7) replicates pass the old fence's covariance/SD assertions in all three seed configurations (sd ratios 1.001–1.007).

The fence now also compares, on innovations standardised per draw by the model's own L (a common invertible map — cannot manufacture or hide a difference, but strips the across-draw scale mixture that would swamp the signal) and pooled to N≈80k:

  • Excess kurtosis, bound 6·√(48/N) + 0.02 = 0.167 — note √(48/N), the SE of a difference of two pools, not the single-pool √(24/N): at the latter the 200-replication null simulation's worst case reached 62% of the bound, too close for a never-flake claim. As committed: worst null 39% of bound, weakest injected t(7) signal 11× over it.
  • Two-sample KS (p > 1e-6). Complementarity measured: ν=15 trips kurtosis but not KS; the two cover different shape-drift families.

ADR-0011's cost paragraph updated: the fence now covers first, second, and fourth moments plus the KS statistic, and states what it still cannot catch.

Closes #232

Gates

Fast predictive tests 17 passed; slow fence suite 4 passed (43s); ruff/ty clean.

🤖 Generated with Claude Code

https://claude.ai/code/session_01Egjd7ToFeb9TQqFnfRQZxV

@codecov-commenter

Copy link
Copy Markdown

Codecov Report

✅ All modified and coverable lines are covered by tests.
✅ Project coverage is 93.8%. Comparing base (66d9346) to head (ffd092d).

Additional details and impacted files
@@                   Coverage Diff                   @@
##           feat/56-predictive-apis    #236   +/-   ##
=======================================================
  Coverage                     93.8%   93.8%           
=======================================================
  Files                           41      41           
  Lines                         2534    2534           
  Branches                       300     300           
=======================================================
  Hits                          2378    2378           
  Misses                         112     112           
  Partials                        44      44           

☔ View full report in Codecov by Harness.
📢 Have feedback on the report? Share it here.

🚀 New features to boost your workflow:
  • ❄️ Test Analytics: Detect flaky tests, report on failures, and find test suite problems.

@thomaspinder
thomaspinder force-pushed the feat/56-predictive-apis branch 2 times, most recently from 1eebac5 to 42f6757 Compare July 29, 2026 21:16
thomaspinder and others added 4 commits July 29, 2026 23:53
…uction) (#218)

* feat(identification): ZeroSignRestriction — combined zero-and-sign restrictions (#144)

Implements the recursive orthogonalisation of Arias, Rubio-Ramirez &
Waggoner (2018). Writing P = L Q, an impact zero restriction "variable i
does not respond to shock j" is the linear condition e_i' L q_j = 0 on
the j-th column of Q. Columns are built one at a time, each drawn
uniformly from the unit sphere of the null space of

    R_k = [ Z_k L ; q_1' ; ... ; q_{k-1}' ]     (m = z_k + (k-1) rows)

so the zeros hold exactly (to SVD precision) and orthogonality to the
earlier columns holds by construction. Only the sign restrictions need
accept/reject.

Details worth flagging for review:

- Shocks are ordered internally by zero count descending (stable, so ties
  keep user order and the unidentified_* padding stays last); columns are
  permuted back to shock_names order before returning, and rows are never
  permuted. The Rubio-Ramirez, Waggoner & Zha (2010) rank condition
  z_j <= n - j is checked on that sorted padded sequence at identify()
  entry, before any sampling.
- On an early impact-sign failure the WHOLE candidate is abandoned and
  the recursion restarts from column 1. Redrawing only the offending
  column would be a distribution bug — q_k's law is conditional on
  q_1..q_{k-1}. Documented in a code comment at the failure site.
- Failed draws become NaN with one summary warning; there is no fallback
  to L, which would silently violate the zero guarantee. on_failure="raise"
  is opt-in. This diverges from SignRestriction deliberately.
- Diagnostics land on _last_diagnostics under a zero_sign_ prefix, not on
  _last_acceptance_rate, which the pipeline surfaces under the misleading
  name sign_restriction_acceptance_rate.
- Candidates are unweighted: no volume-element correction for the ARW
  uniform-conditional prior. Called out in the class docstring; the two
  test-anchored regimes (no zeros -> Haar; exact identification -> point
  up to signs) are weight-free.

Anchor test: with full triangular zeros every null space is
one-dimensional, so P must be lower triangular with P P' = Sigma, i.e.
the Cholesky factor up to column signs. Acceptance is 1.0 and |P|
reproduces |cholesky(Sigma)| to 0.0 (bit-exact — the SVD returns the
canonical basis vectors for that structure). Adding "+" diagonal signs
pins P == cholesky(Sigma) exactly.

SignRestriction is untouched.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Egjd7ToFeb9TQqFnfRQZxV

* docs(identification): zero-and-sign restrictions — reference, explanation, how-to (#144)

- reference/identification.md: ZeroSignRestriction in the autosummary.
- explanation/identification.md: new section covering the ARW recursive
  construction (labelled equations for the zero condition, the recursion,
  and the rank condition), the exact-vs-set spectrum with Cholesky at one
  end and pure sign restrictions at the other, and a warning admonition
  stating the three distributional caveats honestly: draws are unweighted
  (no volume-element correction for the ARW uniform-conditional prior),
  retries are per-theta rather than joint, and sampling is over O(n)
  rather than SO(n).
- how-to/zero-sign-restrictions.md: climate worked example — an activity
  shock has zero contemporaneous impact on the temperature anomaly, which
  is a physical-lag exclusion rather than a sign — plus the admissible-zero
  counting rule, the diagnostics attrs, and a warning admonition spelling
  out that failed draws are NaN and never fall back to Cholesky.
- references.bib: ariasRubioRamirezWaggoner2018, rubioRamirezWaggonerZha2010.
- CONTEXT.md: "Zero-and-sign restrictions (ARW construction)" entry, with
  "penalty-function zeros" listed under _Avoid_ (that is a different,
  approximate construction); IdentificationScheme adapter list refreshed.

Sphinx build with warnings-as-errors reports no warnings from any of these
pages; equations, citations, and both new pages render.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Egjd7ToFeb9TQqFnfRQZxV

* refactor(identification): drop unused n_vars parameter from _require_coefficients (#144)

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Egjd7ToFeb9TQqFnfRQZxV

* style: normalise blank lines at rebase keep-both junctions

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Egjd7ToFeb9TQqFnfRQZxV

* fix(docs): restore closing brace lost resolving the references.bib rebase conflict

The rebase conflict resolution merged the new bibliography entry into the
blanchardQuah1989 entry, dropping its closing brace. sphinxcontrib-bibtex
then failed to parse references.bib, breaking build-docs and docs-linkcheck.

---------

Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
…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>
…unts (#223)

Closes #187, closes #188.

#187: `counterfactual` and `structural_scenario` are linear solves in the
structural shock matrix P. With NaN draws — reachable today via
`LongRunRestriction(on_undefined="nan")`, and soon via other schemes —
the in-sample engine inverted P and returned an all-NaN counterfactual
silently, while the forecast engine died inside LAPACK as "SVD did not
converge" from `matrix_rank`. Neither named the cause. A single guard,
`_require_finite_shock_matrix`, now runs where P enters each engine
(`structural_shock_context` and `_forecast_shock_matrices`), counts the
NaN draws, names the `on_undefined="nan"` policy as the likely cause, and
points at `on_undefined="raise"` to catch it at identification time.

#188: `from_zero_restrictions` rejected genuinely recursive patterns whose
`shock_names` were not pre-sorted, though the order is recoverable. The
variable ordering already came from the restriction counts; the same count
read down the other axis gives the shock order — in a triangular pattern
the variable at position i is restricted by the shocks at positions
i+1..n-1, so the shock at position k appears in exactly k restriction
lists. Ties (non-recursive patterns) sort stably and are still caught by
the unchanged exact per-position set check, so the "not recursive" error
paths keep their messages. The caller's `shock_names` order is now
irrelevant to validity; the names still label the columns.


Claude-Session: https://claude.ai/code/session_01Egjd7ToFeb9TQqFnfRQZxV

Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
The ADR-0011 fence compared means, SDs and the innovation covariance
only, so a Student-t observation likelihood rescaled to matched variance
(branch #168) would have passed it while the replicate law silently
diverged — confirmed against the real pipeline: an injected t(7) pool
clears the existing covariance assertion outright.

Standardise the innovations by each draw's own L (an invertible per-draw
map applied identically to both samples), pool over (chain, draw, time),
and compare excess kurtosis plus a two-sample KS statistic. Both bounds
are Monte Carlo-error calibrated: at N = 79,600 the pools are iid
standard normal under the null, so the kurtosis difference has
SE = sqrt(48 / N) and the bound is 6 sigma + 0.02 slack (0.167); the KS
p-value is uniform, so 1e-6 is a 1e-6 flake budget.

Measured over three seeds x two variables: null |dkurt| <= 0.065 and KS
p >= 0.15; the injected t(7) alternative gives |dkurt| 1.86-2.06 and KS
p ~ 1e-22..1e-32 — an 11x margin at the bound.

ADR-0011's cost paragraph now records that the fence covers moments 1, 2
and 4 plus the full standardised marginal, and what it still cannot
catch (anything matching every compared statistic — notably serial
dependence across t, to which a pooled marginal is blind).

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Egjd7ToFeb9TQqFnfRQZxV
@thomaspinder
thomaspinder force-pushed the test/232-drift-fence-tail branch from ffd092d to db05473 Compare July 29, 2026 21:58
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants