Skip to content

Commit c1756c9

Browse files
committed
feat: Add JIT acceleration for Kalman filter and enhance system building functions
1 parent f9d2444 commit c1756c9

7 files changed

Lines changed: 490 additions & 181 deletions

File tree

README.md

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -6,6 +6,8 @@ This repository captures the core building blocks of a Dynare-style DSGE workflo
66

77
```bash
88
python -m pip install -e ".[dev]"
9+
# Optional JIT acceleration for the Kalman loop:
10+
python -m pip install -e ".[speed]"
911
python -m pytest
1012
python scripts/run_pipeline.py --config configs/tiny_ar1.yaml
1113
python scripts/run_pipeline.py --config configs/nk_full_yaml.yaml --dry-run

pyproject.toml

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -23,6 +23,9 @@ dev = [
2323
"pytest>=7.4",
2424
"pytest-cov>=4.1",
2525
]
26+
speed = [
27+
"numba>=0.59",
28+
]
2629

2730
[tool.setuptools.packages.find]
2831
where = ["."]

src/analysis/impulse_responses.py

Lines changed: 58 additions & 55 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,5 @@
1-
import numpy as np
2-
from src.inference.likelihoods import *
1+
import numpy as np
2+
from src.inference.likelihoods import *
33

44
def compute_irfs(
55
draws_work,
@@ -16,22 +16,22 @@ def compute_irfs(
1616
burn_in=0,
1717
quantiles=(0.16, 0.5, 0.84),
1818
shock_indices=None,
19-
observable_names=None,
20-
shock_names=None,
21-
shock_scale="std",
22-
div=0.0,
23-
steady=None,):
19+
observable_names=None,
20+
shock_names=None,
21+
shock_scale="std",
22+
div=0.0,
23+
steady=None,):
2424

2525
draws = np.asarray(draws_work, dtype=float)
2626
draws = draws[burn_in:] if burn_in > 0 else draws
2727

28-
if horizon < 1:
29-
raise ValueError("horizon debe ser al menos 1.")
30-
31-
n_shocks = len(eps_t)
32-
shock_indices = range(n_shocks) if shock_indices is None else shock_indices
33-
if shock_scale not in {"std", "unit"}:
34-
raise ValueError("shock_scale debe ser 'std' o 'unit'.")
28+
if horizon < 1:
29+
raise ValueError("horizon debe ser al menos 1.")
30+
31+
n_shocks = len(eps_t)
32+
shock_indices = range(n_shocks) if shock_indices is None else shock_indices
33+
if shock_scale not in {"std", "unit"}:
34+
raise ValueError("shock_scale debe ser 'std' o 'unit'.")
3535

3636
if shock_names is None:
3737
shock_names = [str(sym) for sym in eps_t]
@@ -41,24 +41,27 @@ def compute_irfs(
4141
Psi2_example = None
4242
valid_draws = []
4343
for theta in draws:
44-
Theta1, C, Theta0, eu, Psi0, Psi2 = st_sp(theta,
45-
equations,y_t,
46-
y_tp1,
47-
eps_t,
44+
Theta1, C, Theta0, eu, Psi0, Psi2 = st_sp(theta,
45+
equations,y_t,
46+
y_tp1,
47+
eps_t,
4848
registry,
49-
y_tm1=y_tm1,
50-
eta_t=eta_t,
51-
measurement=measurement,
52-
steady=steady,
53-
div=div)
49+
y_tm1=y_tm1,
50+
eta_t=eta_t,
51+
measurement=measurement,
52+
steady=steady,
53+
div=div)
5454

5555
if eu[0] < 1 or eu[1] < 1:
5656
continue
57-
try:
58-
Q = registry.build_Q(theta, n_shocks)
59-
L = np.linalg.cholesky(Q)
60-
except np.linalg.LinAlgError:
61-
continue
57+
if shock_scale == "std":
58+
try:
59+
Q = registry.build_Q(theta, n_shocks)
60+
L = np.linalg.cholesky(Q)
61+
except np.linalg.LinAlgError:
62+
continue
63+
else:
64+
L = None
6265
valid_draws.append((theta, Theta1, Theta0, Psi0, Psi2, L))
6366
if Psi2_example is None:
6467
Psi2_example = Psi2
@@ -74,23 +77,23 @@ def compute_irfs(
7477

7578
irf_store = {shock_names[j]: [] for j in shock_indices}
7679

77-
for theta, Theta1, Theta0, Psi0, Psi2, L in valid_draws:
78-
for j in shock_indices:
79-
eps_path = np.zeros((n_shocks, horizon))
80-
if shock_scale == "std":
81-
# Shock de una desviacion estandar, usando la covarianza estimada Q.
82-
eps_path[:, 0] = L[:, j]
83-
else:
84-
# Shock estructural unitario, como stoch_simul tras fijar var eps_j = 1.
85-
eps_path[j, 0] = 1.0
86-
87-
state = np.zeros((Theta1.shape[0], horizon))
88-
obs = np.zeros((n_obs, horizon))
89-
90-
for h in range(horizon):
91-
previous = state[:, h - 1] if h > 0 else np.zeros(Theta1.shape[0])
92-
state[:, h] = Theta1 @ previous + Theta0 @ eps_path[:, h]
93-
obs[:, h] = Psi2 @ state[:, h]
80+
for theta, Theta1, Theta0, Psi0, Psi2, L in valid_draws:
81+
for j in shock_indices:
82+
eps_path = np.zeros((n_shocks, horizon))
83+
if shock_scale == "std":
84+
# Shock de una desviacion estandar, usando la covarianza estimada Q.
85+
eps_path[:, 0] = L[:, j]
86+
else:
87+
# Shock estructural unitario, como stoch_simul tras fijar var eps_j = 1.
88+
eps_path[j, 0] = 1.0
89+
90+
state = np.zeros((Theta1.shape[0], horizon))
91+
obs = np.zeros((n_obs, horizon))
92+
93+
for h in range(horizon):
94+
previous = state[:, h - 1] if h > 0 else np.zeros(Theta1.shape[0])
95+
state[:, h] = Theta1 @ previous + Theta0 @ eps_path[:, h]
96+
obs[:, h] = Psi2 @ state[:, h]
9497

9598
irf_store[shock_names[j]].append(obs)
9699

@@ -100,19 +103,19 @@ def compute_irfs(
100103

101104
arr = np.stack(paths, axis=0)
102105
q_values = np.quantile(arr, quant_array, axis=0)
103-
results[shock_name] = {
104-
"observables": observable_names,
105-
"horizon": horizon,
106-
"quantiles": quant_array,
107-
"shock_scale": shock_scale,
108-
"summary": q_values,
109-
"raw": arr,}
106+
results[shock_name] = {
107+
"observables": observable_names,
108+
"horizon": horizon,
109+
"quantiles": quant_array,
110+
"shock_scale": shock_scale,
111+
"summary": q_values,
112+
"raw": arr,}
110113

111114
return results
112115

113116

114-
def plot_irf_bands(irf_dict, shocks=None, start=0, figsize=(7, 4), colors=None):
115-
import matplotlib.pyplot as plt
117+
def plot_irf_bands(irf_dict, shocks=None, start=0, figsize=(7, 4), colors=None):
118+
import matplotlib.pyplot as plt
116119

117120
shocks = shocks or list(irf_dict.keys())
118121
colors = colors or {
@@ -152,4 +155,4 @@ def plot_irf_bands(irf_dict, shocks=None, start=0, figsize=(7, 4), colors=None):
152155

153156
axes[-1].set_xlabel("Horizonte")
154157
plt.tight_layout()
155-
plt.show()
158+
plt.show()

src/inference/kalman_fast.py

Lines changed: 61 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,61 @@
1+
from __future__ import annotations
2+
3+
try:
4+
from numba import njit
5+
6+
HAS_NUMBA = True
7+
except Exception: # pragma: no cover - depends on optional dependency
8+
njit = None
9+
HAS_NUMBA = False
10+
11+
12+
if HAS_NUMBA: # pragma: no cover - exercised only when numba is installed
13+
import numpy as np
14+
15+
@njit(cache=True)
16+
def _kalman_loglike_numba(y, Theta1, C, Theta0, Q, H, Psi0, Psi2, s_bar, P_bar):
17+
T = y.shape[0]
18+
n_y = y.shape[1]
19+
n_s = Theta1.shape[0]
20+
const = n_y * np.log(2.0 * np.pi)
21+
ll_sum = 0.0
22+
eye = np.eye(n_s)
23+
24+
for t in range(T):
25+
s_hat = C + Theta1 @ s_bar
26+
P_hat = Theta1 @ P_bar @ Theta1.T + Theta0 @ Q @ Theta0.T
27+
P_hat = 0.5 * (P_hat + P_hat.T)
28+
29+
e_t = y[t, :] - Psi0 - Psi2 @ s_hat
30+
S_t = Psi2 @ P_hat @ Psi2.T + H
31+
32+
try:
33+
L = np.linalg.cholesky(S_t)
34+
except Exception:
35+
return -np.inf
36+
37+
logdet = 0.0
38+
for j in range(n_y):
39+
logdet += 2.0 * np.log(L[j, j])
40+
41+
z = np.linalg.solve(L, e_t)
42+
Sinv_e = np.linalg.solve(L.T, z)
43+
44+
ZP = Psi2 @ P_hat.T
45+
tmp = np.linalg.solve(L, ZP)
46+
K = np.linalg.solve(L.T, tmp).T
47+
48+
s_bar = s_hat + K @ e_t
49+
I_KZ = eye - K @ Psi2
50+
P_bar = I_KZ @ P_hat @ I_KZ.T + K @ H @ K.T
51+
P_bar = 0.5 * (P_bar + P_bar.T)
52+
53+
ll_sum += -0.5 * (const + logdet + e_t @ Sinv_e)
54+
55+
return ll_sum
56+
57+
58+
def kalman_loglike_numba(y, Theta1, C, Theta0, Q, H, Psi0, Psi2, s_bar, P_bar):
59+
if not HAS_NUMBA:
60+
return None
61+
return float(_kalman_loglike_numba(y, Theta1, C, Theta0, Q, H, Psi0, Psi2, s_bar, P_bar))

0 commit comments

Comments
 (0)