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
3 changes: 3 additions & 0 deletions CLAUDE.md
Original file line number Diff line number Diff line change
Expand Up @@ -118,6 +118,9 @@ Shared fixtures available in all test files:
- `assert` is allowed in tests (`S101` ignored for `tests/*`)
- `E501` (line length), `E731` (lambda assignment), and `TRY003` (long exception messages) are globally ignored
- Docstrings follow Google style (Args/Returns sections)
- Docstring inline code uses **single backticks** (Markdown-native, what
mkdocstrings expects): write `` `Foo.bar` ``, not `` ``Foo.bar`` ``.
Cross-references are plain text — no RST `:meth:` / `:class:` directives.

## PyMC / Sampling Gotchas

Expand Down
4 changes: 2 additions & 2 deletions docs/tutorials/stochastic-volatility.md
Original file line number Diff line number Diff line change
Expand Up @@ -761,7 +761,7 @@ print(h.dims, h.shape)

## Time-varying impulse responses with `at=`

When the volatility is time-varying, the structural impact matrix is too. The `at=` parameter on `impulse_response`, `forecast_error_variance_decomposition`, and `historical_decomposition` selects which date’s Cholesky factor (and hence which structural impact matrix) the computation uses:
When the volatility is time-varying, the structural impact matrix is too. The `at=` parameter on `impulse_response`, `fevd`, and `historical_decomposition` selects which date’s Cholesky factor (and hence which structural impact matrix) the computation uses:

- `at="last"` — most recent in-sample period (the default when the volatility is stochastic)
- `at=t` — a specific integer index into the lag-trimmed sample
Expand Down Expand Up @@ -808,7 +808,7 @@ print(irf_all_arr.dims)
(2, 500, 732, 13, 3, 3)
('chain', 'draw', 'time', 'horizon', 'response', 'shock')

The same `at=` argument is accepted by `forecast_error_variance_decomposition` and `historical_decomposition` with the same semantics.
The same `at=` argument is accepted by `fevd` and `historical_decomposition` with the same semantics.

## References

Expand Down
4 changes: 2 additions & 2 deletions docs/tutorials/stochastic-volatility.qmd
Original file line number Diff line number Diff line change
Expand Up @@ -286,7 +286,7 @@ print(h.dims, h.shape)

## Time-varying impulse responses with `at=`

When the volatility is time-varying, the structural impact matrix is too. The `at=` parameter on `impulse_response`, `forecast_error_variance_decomposition`, and `historical_decomposition` selects which date's Cholesky factor (and hence which structural impact matrix) the computation uses:
When the volatility is time-varying, the structural impact matrix is too. The `at=` parameter on `impulse_response`, `fevd`, and `historical_decomposition` selects which date's Cholesky factor (and hence which structural impact matrix) the computation uses:

- `at="last"` — most recent in-sample period (the default when the volatility is stochastic)
- `at=t` — a specific integer index into the lag-trimmed sample
Expand Down Expand Up @@ -326,7 +326,7 @@ print(irf_all_arr.shape)
print(irf_all_arr.dims)
```

The same `at=` argument is accepted by `forecast_error_variance_decomposition` and `historical_decomposition` with the same semantics.
The same `at=` argument is accepted by `fevd` and `historical_decomposition` with the same semantics.

## References

Expand Down
2 changes: 1 addition & 1 deletion pyproject.toml
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
[project]
name = "impulso"
version = "0.0.5"
version = "0.0.6"
description = "Bayesian Vector Autoregression (VAR) in Python."
authors = [{ name = "Thomas Pinder", email = "tompinder@live.co.uk" }]
readme = "README.md"
Expand Down
20 changes: 10 additions & 10 deletions src/impulso/_ma.py
Original file line number Diff line number Diff line change
Expand Up @@ -6,7 +6,7 @@
Phi_0 = I
Phi_h = sum_{j=1}^{min(h, p)} A_j @ Phi_{h-j}, h >= 1

where ``A_1, ..., A_p`` are the lag coefficient matrices. `compute_ma_phi`
where `A_1, ..., A_p` are the lag coefficient matrices. `compute_ma_phi`
is the single source of truth — both the posterior-batched IRF code in
`identified.py` and the per-rotation sign-restriction check in
`identification.py` delegate here.
Expand All @@ -20,25 +20,25 @@
def compute_ma_phi(A: list[np.ndarray], horizon: int) -> np.ndarray:
"""Compute MA coefficient matrices via the structural recursion.

Numpy's ``@`` operator broadcasts over leading dims, so the helper handles
Numpy's `@` operator broadcasts over leading dims, so the helper handles
both a single rotation and a posterior tensor of draws without branching:
pass each ``A_j`` with shape ``(n, n)`` for a single-draw computation, or
with shape ``(C, D, n, n)`` for a batched one.
pass each `A_j` with shape `(n, n)` for a single-draw computation, or
with shape `(C, D, n, n)` for a batched one.

Args:
A: Lag coefficient matrices ``A_1, ..., A_p`` in lag order. Each entry
has shape ``(..., n, n)`` where the leading dims are arbitrary but
A: Lag coefficient matrices `A_1, ..., A_p` in lag order. Each entry
has shape `(..., n, n)` where the leading dims are arbitrary but
shared across all entries. Must be non-empty.
horizon: Highest MA horizon to compute. The result spans
``Phi_0`` through ``Phi_horizon`` inclusive.
`Phi_0` through `Phi_horizon` inclusive.

Returns:
A stacked array with the horizon axis third-to-last. For a single
draw: shape ``(horizon + 1, n, n)``. For a batched posterior:
shape ``(C, D, horizon + 1, n, n)``.
draw: shape `(horizon + 1, n, n)`. For a batched posterior:
shape `(C, D, horizon + 1, n, n)`.

Raises:
ValueError: If ``A`` is empty or ``horizon`` is negative.
ValueError: If `A` is empty or `horizon` is negative.
"""
if not A:
raise ValueError("A must contain at least one lag coefficient matrix")
Expand Down
21 changes: 10 additions & 11 deletions src/impulso/fitted.py
Original file line number Diff line number Diff line change
Expand Up @@ -4,7 +4,7 @@

import arviz as az
import numpy as np
from pydantic import Field, computed_field
from pydantic import Field

from impulso._base import ImpulsoBaseModel
from impulso.data import VARData
Expand Down Expand Up @@ -34,7 +34,6 @@ class FittedVAR(ImpulsoBaseModel):
var_names: list[str]
volatility: VolatilityProcess

@computed_field
@property
def has_exog(self) -> bool:
"""Whether the model includes exogenous variables."""
Expand All @@ -57,21 +56,21 @@ def sigma(self) -> np.ndarray:
shape depends on whether Σ is time-invariant or time-varying:

* Constant volatility — Σ is shared across time, so the result
has shape ``(chains, draws, n_vars, n_vars)``.
has shape `(chains, draws, n_vars, n_vars)`.
* Stochastic volatility — Σ_t evolves, so the result has shape
``(chains, draws, T, n_vars, n_vars)`` where ``T`` is the
`(chains, draws, T, n_vars, n_vars)` where `T` is the
in-sample length after lag trimming. Callers needing a single
slice should call ``volatility.cholesky_at(posterior, t)`` and
slice should call `volatility.cholesky_at(posterior, t)` and
square the factor themselves.

Note:
**Breaking change vs. v0.0.4 and earlier**: ``sigma`` is now
a method, not a property. Call sites that used ``fitted.sigma``
must be updated to ``fitted.sigma()``.
**Breaking change vs. v0.0.4 and earlier**: `sigma` is now
a method, not a property. Call sites that used `fitted.sigma`
must be updated to `fitted.sigma()`.

Returns:
Posterior draws of Σ (or Σ_t for SV) computed from the
volatility adapter's Cholesky factor as ``L @ L.T``.
volatility adapter's Cholesky factor as `L @ L.T`.
"""
if self.volatility.is_time_varying:
T = self.data.endog.shape[0] - self.n_lags
Expand Down Expand Up @@ -148,8 +147,8 @@ def set_identification_strategy(self, scheme: IdentificationScheme) -> "Identifi
Queries the fitted volatility process for the Cholesky factor of Σ
and passes it to the scheme. For constant volatility, the factor is
the same across all time points; for stochastic volatility (P3),
``cholesky_at(t=None)`` returns the most-recent slice and
downstream IRF/FEVD/HD methods can re-query at other ``at`` values.
`cholesky_at(t=None)` returns the most-recent slice and
downstream IRF/FEVD/HD methods can re-query at other `at` values.

Args:
scheme: An IdentificationScheme protocol instance (e.g. Cholesky,
Expand Down
14 changes: 7 additions & 7 deletions src/impulso/identification.py
Original file line number Diff line number Diff line change
Expand Up @@ -29,8 +29,8 @@ def identify(
) -> np.ndarray:
"""Apply Cholesky identification.

For default variable ordering, ``identify`` is a no-op and returns
``L`` unchanged. When ``self.ordering`` differs from ``var_names``,
For default variable ordering, `identify` is a no-op and returns
`L` unchanged. When `self.ordering` differs from `var_names`,
the underlying covariance is permuted and re-decomposed so the
Cholesky factor reflects the requested causal ordering.

Expand Down Expand Up @@ -96,16 +96,16 @@ def identify(
Args:
L: Lower-triangular Cholesky factor, shape (chains, draws, n_vars, n_vars).
var_names: Variable names in the data's natural order.
posterior: Required when ``self.restriction_horizon > 0`` because
the multi-horizon check needs the VAR coefficients ``B`` from
posterior: Required when `self.restriction_horizon > 0` because
the multi-horizon check needs the VAR coefficients `B` from
the posterior. Ignored for impact-only restrictions
(``restriction_horizon == 0``).
(`restriction_horizon == 0`).

Returns:
Structural shock matrix, shape (chains, draws, n_vars, n_vars).
Per-draw fallback to the supplied ``L`` for draws where no
Per-draw fallback to the supplied `L` for draws where no
rotation satisfies the restrictions. Acceptance rate available
via the ``sign_restriction_acceptance_rate`` attribute on the
via the `sign_restriction_acceptance_rate` attribute on the
wrapping IdentifiedVAR's posterior (set by the pipeline).
"""
from scipy.stats import special_ortho_group
Expand Down
96 changes: 42 additions & 54 deletions src/impulso/identified.py
Original file line number Diff line number Diff line change
Expand Up @@ -28,11 +28,11 @@ class IdentifiedVAR(ImpulsoBaseModel):
data: Original VARData.
var_names: Endogenous variable names.
volatility: Volatility process carried through from the fitted VAR.
Required for ``at=`` queries on impulse_response / fevd /
Required for `at=` queries on impulse_response / fevd /
historical_decomposition (P3), which re-call
``volatility.cholesky_at(at)`` for the requested time slice.
`volatility.cholesky_at(at)` for the requested time slice.
scheme: Identification scheme used to produce the structural shock
matrix. Required for ``at=`` queries so the scheme can be
matrix. Required for `at=` queries so the scheme can be
re-applied to a different Cholesky factor on demand.
"""

Expand All @@ -58,23 +58,23 @@ def _ma_coefficients(self, B_draws: np.ndarray, n_vars: int, n_lags: int, horizo
return compute_ma_phi(A, horizon)

def _resolve_at(self, at: AtParam) -> int | None:
"""Resolve ``at=`` to an integer ``t`` suitable for ``cholesky_at(t)``.
"""Resolve `at=` to an integer `t` suitable for `cholesky_at(t)`.

Returns ``None`` when ``at`` is ``None`` or ``"last"``. ``cholesky_at``
adapters interpret ``t=None`` as "most recent" (SV) or "ignored"
(Constant), so passing ``None`` through is the right default in both
Returns `None` when `at` is `None` or `"last"`. `cholesky_at`
adapters interpret `t=None` as "most recent" (SV) or "ignored"
(Constant), so passing `None` through is the right default in both
cases. Integer values are returned unchanged.

Args:
at: Either ``None``, ``"last"``, or an integer time index.
``"all"`` is not handled here — callers must dispatch to the
at: Either `None`, `"last"`, or an integer time index.
`"all"` is not handled here — callers must dispatch to the
per-t path before calling this helper.

Returns:
An integer time index, or ``None`` for the most-recent default.
An integer time index, or `None` for the most-recent default.

Raises:
ValueError: If ``at`` is not one of the supported forms.
ValueError: If `at` is not one of the supported forms.
"""
if at == "last" or at is None:
return None
Expand All @@ -86,19 +86,19 @@ def _resolve_at(self, at: AtParam) -> int | None:
)

def _identify_per_t(self, L_path: np.ndarray) -> np.ndarray:
"""Apply ``self.scheme.identify`` per time slice.
"""Apply `self.scheme.identify` per time slice.

Iterates the per-t loop in Python — fine for Cholesky (vectorised
internally over draws), but expensive for ``SignRestriction`` at
large ``T`` because rotations are re-sampled per time slice. A
internally over draws), but expensive for `SignRestriction` at
large `T` because rotations are re-sampled per time slice. A
future optimisation could specialise the loop for time-invariant
schemes, but P3 does not need it.

Args:
L_path: ``(C, D, T, n, n)`` Cholesky factor path.
L_path: `(C, D, T, n, n)` Cholesky factor path.

Returns:
``(C, D, T, n, n)`` structural shock matrix path.
`(C, D, T, n, n)` structural shock matrix path.
"""
T = L_path.shape[2]
P_path = np.zeros_like(L_path)
Expand All @@ -115,21 +115,21 @@ def impulse_response(self, horizon: int = 20, at: AtParam = None) -> IRFResult:
horizon: Number of periods.
at: Time index for the structural shock matrix:

- ``None`` (default): for SV, equivalent to ``"last"``;
- `None` (default): for SV, equivalent to `"last"`;
for Constant, ignored entirely.
- ``int``: query the shock matrix at this specific ``t``.
- ``"last"``: most recent time slice.
- ``"all"``: return IRFs for shocks at every ``t`` (adds a
``time`` dim to the result).
- `int`: query the shock matrix at this specific `t`.
- `"last"`: most recent time slice.
- `"all"`: return IRFs for shocks at every `t` (adds a
`time` dim to the result).

Returns:
IRFResult with IRF posterior draws.

Raises:
ValueError: When ``at='all'`` is requested for a non-time-varying
volatility process (``Constant``). Under constant Σ the per-t
ValueError: When `at='all'` is requested for a non-time-varying
volatility process (`Constant`). Under constant Σ the per-t
IRF is identical for every t, so the time axis carries no
information — use ``at=None`` (default) or ``at='last'``.
information — use `at=None` (default) or `at='last'`.
"""
B_draws = self.idata.posterior["B"].values # (C, D, n, n*p)
n_vars = B_draws.shape[2]
Expand Down Expand Up @@ -190,28 +190,28 @@ def impulse_response(self, horizon: int = 20, at: AtParam = None) -> IRFResult:
idata = az.InferenceData(posterior_predictive=xr.Dataset({"irf": irf_da}))
return IRFResult(idata=idata, horizon=horizon, var_names=self.var_names)

def forecast_error_variance_decomposition(self, horizon: int = 20, at: AtParam = None) -> FEVDResult:
def fevd(self, horizon: int = 20, at: AtParam = None) -> FEVDResult:
"""Compute forecast error variance decomposition.

Args:
horizon: Number of periods.
at: Time index for the structural shock matrix:

- ``None`` (default): for SV, equivalent to ``"last"``;
- `None` (default): for SV, equivalent to `"last"`;
for Constant, ignored entirely.
- ``int``: query the shock matrix at this specific ``t``.
- ``"last"``: most recent time slice.
- ``"all"``: return FEVDs for shocks at every ``t`` (adds a
``time`` dim to the result).
- `int`: query the shock matrix at this specific `t`.
- `"last"`: most recent time slice.
- `"all"`: return FEVDs for shocks at every `t` (adds a
`time` dim to the result).

Returns:
FEVDResult with FEVD posterior draws.

Raises:
ValueError: When ``at='all'`` is requested for a non-time-varying
volatility process (``Constant``). Under constant Σ the per-t
ValueError: When `at='all'` is requested for a non-time-varying
volatility process (`Constant`). Under constant Σ the per-t
FEVD is identical for every t, so the time axis carries no
information — use ``at=None`` (default) or ``at='last'``.
information — use `at=None` (default) or `at='last'`.
"""
B_draws = self.idata.posterior["B"].values # (C, D, n, n*p)
n_vars = B_draws.shape[2]
Expand Down Expand Up @@ -280,18 +280,6 @@ def forecast_error_variance_decomposition(self, horizon: int = 20, at: AtParam =
idata = az.InferenceData(posterior_predictive=xr.Dataset({"fevd": fevd_da}))
return FEVDResult(idata=idata, horizon=horizon, var_names=self.var_names)

def fevd(self, horizon: int = 20, at: AtParam = None) -> FEVDResult:
"""Alias for forecast_error_variance_decomposition.

Args:
horizon: Number of periods.
at: See :meth:`forecast_error_variance_decomposition`.

Returns:
FEVDResult.
"""
return self.forecast_error_variance_decomposition(horizon=horizon, at=at)

def historical_decomposition(
self,
start: pd.Timestamp | None = None,
Expand All @@ -302,7 +290,7 @@ def historical_decomposition(
"""Compute historical decomposition of observed series.

Historical decomposition is intrinsically time-indexed: it attributes
each in-sample observation to past structural shocks. The ``at=``
each in-sample observation to past structural shocks. The `at=`
parameter controls which Cholesky factor identifies those shocks.

Args:
Expand All @@ -311,16 +299,16 @@ def historical_decomposition(
cumulative: If True, return cumulative shock contributions.
at: Time index for the structural shock matrix:

- ``None`` (default) or ``"all"``: use ``cholesky_path`` —
identify the shock at each ``t`` with its own ``L_t``.
- `None` (default) or `"all"`: use `cholesky_path` —
identify the shock at each `t` with its own `L_t`.
For stochastic volatility this is the correct structural
decomposition; for constant volatility ``L_t`` is the same
for all ``t`` and the result matches the legacy behaviour.
- ``int`` or ``"last"``: identify every shock with a single
``L`` queried at the supplied time index. Under stochastic
decomposition; for constant volatility `L_t` is the same
for all `t` and the result matches the legacy behaviour.
- `int` or `"last"`: identify every shock with a single
`L` queried at the supplied time index. Under stochastic
volatility this is a non-standard hypothetical ("what if
regime ``t`` had prevailed throughout?") and emits a
``UserWarning``. For constant volatility the result is
regime `t` had prevailed throughout?") and emits a
`UserWarning`. For constant volatility the result is
identical to the default.

Returns:
Expand Down
Loading
Loading