Skip to content

Commit 478033f

Browse files
committed
feat: add bayesian estimation
1 parent aacb096 commit 478033f

12 files changed

Lines changed: 1252 additions & 1 deletion

File tree

pyproject.toml

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -27,6 +27,12 @@ dependencies = [
2727
"scipy>=1.12",
2828
]
2929

30+
[project.optional-dependencies]
31+
bayesian = [
32+
"numpyro>=0.15",
33+
"arviz>=0.18",
34+
]
35+
3036
[project.urls]
3137
Homepage = "https://github.com/quant-sci/dynaris"
3238
Repository = "https://github.com/quant-sci/dynaris"
@@ -88,6 +94,8 @@ module = [
8894
"pandas",
8995
"pandas.*",
9096
"scipy.*",
97+
"numpyro.*",
98+
"arviz.*",
9199
]
92100
ignore_missing_imports = true
93101

src/dynaris/__init__.py

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -22,6 +22,7 @@
2222
Regression,
2323
Seasonal,
2424
)
25+
from dynaris.estimation.bayesian import BayesianResult, fit_bayesian
2526
from dynaris.filters import (
2627
ExtendedKalmanFilter,
2728
HamiltonFilter,
@@ -48,6 +49,7 @@
4849
"DLM",
4950
"SSM",
5051
"Autoregressive",
52+
"BayesianResult",
5153
"BearingsTracking",
5254
"Cycle",
5355
"ExtendedKalmanFilter",
@@ -75,6 +77,7 @@
7577
"UnscentedKalmanFilter",
7678
"__version__",
7779
"ekf_filter",
80+
"fit_bayesian",
7881
"hamilton_filter",
7982
"kalman_filter",
8083
"kim_smooth",

src/dynaris/estimation/__init__.py

Lines changed: 26 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,22 +1,47 @@
1-
"""Parameter estimation: MLE, EM, and model diagnostics."""
1+
"""Parameter estimation: MLE, EM, Bayesian, and model diagnostics."""
22

3+
from dynaris.estimation.bayesian import BayesianResult, fit_bayesian
4+
from dynaris.estimation.comparison import compute_loo, compute_waic, to_arviz
35
from dynaris.estimation.diagnostics import acf, ljung_box, pacf, standardized_residuals
46
from dynaris.estimation.em import EMResult, fit_em
57
from dynaris.estimation.mle import MLEResult, fit_mle
68
from dynaris.estimation.model_selection import switching_aic, switching_bic
9+
from dynaris.estimation.predictive import (
10+
posterior_predictive_check,
11+
posterior_predictive_forecast,
12+
prior_predictive,
13+
)
14+
from dynaris.estimation.priors import (
15+
combine_priors,
16+
half_normal_log_prior,
17+
inverse_gamma_log_prior,
18+
normal_log_prior,
19+
)
720
from dynaris.estimation.transforms import inverse_softplus, softplus
821

922
__all__ = [
23+
"BayesianResult",
1024
"EMResult",
1125
"MLEResult",
1226
"acf",
27+
"combine_priors",
28+
"compute_loo",
29+
"compute_waic",
30+
"fit_bayesian",
1331
"fit_em",
1432
"fit_mle",
33+
"half_normal_log_prior",
34+
"inverse_gamma_log_prior",
1535
"inverse_softplus",
1636
"ljung_box",
37+
"normal_log_prior",
1738
"pacf",
39+
"posterior_predictive_check",
40+
"posterior_predictive_forecast",
41+
"prior_predictive",
1842
"softplus",
1943
"standardized_residuals",
2044
"switching_aic",
2145
"switching_bic",
46+
"to_arviz",
2247
]

src/dynaris/estimation/bayesian.py

Lines changed: 176 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,176 @@
1+
"""Bayesian estimation for state-space models via MCMC.
2+
3+
Uses NumPyro's NUTS sampler to draw posterior samples of model parameters,
4+
with the Kalman filter log-likelihood as the data term.
5+
6+
Requires the ``bayesian`` extra: ``pip install dynaris[bayesian]``
7+
"""
8+
9+
from __future__ import annotations
10+
11+
from dataclasses import dataclass
12+
from typing import Any
13+
14+
import jax
15+
import jax.numpy as jnp
16+
import numpy as np
17+
from jax import Array
18+
19+
from dynaris.core.results import FilterResult
20+
from dynaris.core.state_space import StateSpaceModel
21+
from dynaris.estimation.priors import LogPriorFn
22+
from dynaris.filters.kalman import kalman_filter
23+
24+
ModelFactory = Any # Callable[[Array], StateSpaceModel]
25+
26+
27+
def _require_numpyro() -> Any:
28+
try:
29+
import numpyro
30+
31+
return numpyro
32+
except ImportError:
33+
msg = "Bayesian estimation requires numpyro. Install with: pip install dynaris[bayesian]"
34+
raise ImportError(msg) from None
35+
36+
37+
@dataclass(frozen=True)
38+
class BayesianResult:
39+
"""Result of Bayesian MCMC estimation.
40+
41+
Attributes:
42+
samples: Posterior samples (unconstrained), shape (n_samples, n_params).
43+
log_likelihood_samples: Log-likelihood at each sample, shape (n_samples,).
44+
model: StateSpaceModel at the posterior mean parameters.
45+
filter_result: FilterResult from the posterior-mean model.
46+
param_names: Optional parameter labels.
47+
info: Sampler diagnostics (divergences, acceptance rate, etc.).
48+
"""
49+
50+
samples: Array
51+
log_likelihood_samples: Array
52+
model: StateSpaceModel
53+
filter_result: FilterResult
54+
param_names: tuple[str, ...] | None = None
55+
info: dict[str, Any] | None = None
56+
57+
58+
def _flat_prior(params: Array) -> Array:
59+
"""Flat (improper) prior: constant zero log-density."""
60+
return jnp.array(0.0)
61+
62+
63+
def fit_bayesian(
64+
model_fn: ModelFactory,
65+
observations: Array,
66+
init_params: Array,
67+
log_prior_fn: LogPriorFn | None = None,
68+
n_warmup: int = 500,
69+
n_samples: int = 1000,
70+
key: Array | None = None,
71+
param_names: tuple[str, ...] | None = None,
72+
) -> BayesianResult:
73+
"""Fit a state-space model via Bayesian MCMC (NUTS).
74+
75+
Uses NumPyro's NUTS sampler with automatic warmup adaptation.
76+
The log-posterior is the Kalman filter log-likelihood plus the
77+
log-prior.
78+
79+
Args:
80+
model_fn: Maps unconstrained parameter vector to a
81+
:class:`StateSpaceModel`. Same pattern as :func:`fit_mle`.
82+
observations: Observation sequence, shape (T, obs_dim).
83+
init_params: Initial (unconstrained) parameter vector.
84+
log_prior_fn: Log-prior function. Defaults to flat prior.
85+
n_warmup: Number of NUTS warmup steps.
86+
n_samples: Number of posterior samples to draw.
87+
key: JAX PRNG key. Defaults to ``PRNGKey(0)``.
88+
param_names: Optional names for each parameter dimension.
89+
90+
Returns:
91+
BayesianResult with posterior samples and fitted model.
92+
93+
Example::
94+
95+
import jax.numpy as jnp
96+
from dynaris import LocalLevel
97+
from dynaris.estimation import fit_bayesian
98+
from dynaris.estimation.priors import inverse_gamma_log_prior
99+
100+
def model_fn(params):
101+
return LocalLevel(
102+
sigma_level=jnp.exp(params[0]),
103+
sigma_obs=jnp.exp(params[1]),
104+
)
105+
106+
result = fit_bayesian(
107+
model_fn, observations, jnp.zeros(2),
108+
log_prior_fn=inverse_gamma_log_prior(shape=2.0, scale=1.0),
109+
)
110+
"""
111+
numpyro = _require_numpyro()
112+
from numpyro.infer import MCMC, NUTS
113+
114+
observations = jnp.asarray(observations)
115+
init_params = jnp.asarray(init_params)
116+
if key is None:
117+
key = jax.random.PRNGKey(0)
118+
if log_prior_fn is None:
119+
log_prior_fn = _flat_prior
120+
121+
n_params = init_params.shape[0]
122+
123+
# Define the log-density for the sampler
124+
@jax.jit
125+
def _log_density(params: Array) -> Array:
126+
model = model_fn(params)
127+
fr = kalman_filter(model, observations)
128+
return fr.log_likelihood + log_prior_fn(params)
129+
130+
# NumPyro model: sample unconstrained params, factor by log-density
131+
def _numpyro_model() -> None:
132+
params = numpyro.sample(
133+
"params",
134+
numpyro.distributions.Normal(0.0, 100.0).expand([n_params]).to_event(1),
135+
)
136+
log_density = _log_density(params)
137+
numpyro.factor("log_density", log_density)
138+
139+
# Run MCMC
140+
kernel = NUTS(
141+
_numpyro_model, init_strategy=numpyro.infer.init_to_value(values={"params": init_params})
142+
)
143+
mcmc = MCMC(kernel, num_warmup=n_warmup, num_samples=n_samples, progress_bar=False)
144+
mcmc.run(key)
145+
146+
samples = mcmc.get_samples()["params"] # (n_samples, n_params)
147+
148+
# Compute per-sample log-likelihoods
149+
@jax.jit
150+
def _compute_ll(params: Array) -> Array:
151+
model = model_fn(params)
152+
return kalman_filter(model, observations).log_likelihood
153+
154+
log_lls = jax.vmap(_compute_ll)(samples)
155+
156+
# Build posterior-mean model
157+
mean_params = jnp.mean(samples, axis=0)
158+
mean_model = model_fn(mean_params)
159+
mean_fr = kalman_filter(mean_model, observations)
160+
161+
# Diagnostics
162+
info: dict[str, Any] = {}
163+
extra_fields = mcmc.get_extra_fields()
164+
if "diverging" in extra_fields:
165+
info["n_divergences"] = int(np.sum(np.asarray(extra_fields["diverging"])))
166+
if "accept_prob" in extra_fields:
167+
info["mean_accept_prob"] = float(np.mean(np.asarray(extra_fields["accept_prob"])))
168+
169+
return BayesianResult(
170+
samples=samples,
171+
log_likelihood_samples=log_lls,
172+
model=mean_model,
173+
filter_result=mean_fr,
174+
param_names=param_names,
175+
info=info,
176+
)

0 commit comments

Comments
 (0)