dscc 0.1.0__py3-none-any.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
dscc/__init__.py ADDED
@@ -0,0 +1,45 @@
1
+ """dscc -- Dynamic Spectral Conditional Correlations in Python.
2
+
3
+ Implements Abadir and Rockinger (2026, Econometrics Journal,
4
+ doi:10.1093/ectj/utag017) and the explicit minimal representation of variance
5
+ matrices of Abadir (2023, Econometrics Journal 26, 88-104, doi:10.1093/ectj/utac023).
6
+ """
7
+ from ._filters import dsh_filter, g_abs
8
+ from ._model import DSCC, DSCCForecast, DSCCResults, default_start, param_names
9
+ from ._representation import (
10
+ compose_sigma,
11
+ correlation_from_cov,
12
+ decompose_sigma,
13
+ orthogonal_from_data,
14
+ permutation_matrix,
15
+ skew_from_vec,
16
+ vec_from_skew,
17
+ xi_from_data,
18
+ )
19
+
20
+ from ._colors import (bluered_colors, get_cmap, matlab_jet_colors, parula_colors, resolve_colorscale,
21
+ sinha_colors, turbo_colors)
22
+ from . import backtest, datasets, dcc, elliptope, portfolio
23
+ from ._plots import (plot_correlation_heatmap, plot_coverage_grid, plot_elliptope, plot_slice_coverage,
24
+ plot_wealth, save_figure)
25
+ from .backtest import DCCForecaster, DSCCForecaster, EqualWeight, SampleCovForecaster, rolling_backtest
26
+ from .dcc import DCC
27
+ from .portfolio import gmv_weights
28
+
29
+ __version__ = "0.1.0"
30
+ __author__ = "Dr Merwan Roudane"
31
+ __email__ = "merwanroudane920@gmail.com"
32
+
33
+ __all__ = [
34
+ "DSCC", "DSCCResults", "DSCCForecast", "default_start", "param_names",
35
+ "dsh_filter", "g_abs",
36
+ "compose_sigma", "decompose_sigma", "correlation_from_cov", "orthogonal_from_data",
37
+ "permutation_matrix", "skew_from_vec", "vec_from_skew", "xi_from_data",
38
+ "parula_colors", "matlab_jet_colors", "turbo_colors", "bluered_colors", "sinha_colors",
39
+ "resolve_colorscale", "get_cmap",
40
+ "plot_elliptope", "plot_slice_coverage", "plot_coverage_grid", "plot_wealth",
41
+ "plot_correlation_heatmap", "save_figure",
42
+ "DCC", "gmv_weights", "rolling_backtest", "DSCCForecaster", "DCCForecaster",
43
+ "SampleCovForecaster", "EqualWeight",
44
+ "backtest", "datasets", "dcc", "elliptope", "portfolio",
45
+ ]
dscc/_colors.py ADDED
@@ -0,0 +1,95 @@
1
+ """Colour maps: MATLAB R2014b Parula (default), Jet, Turbo, Blue-Red, Sinha.
2
+
3
+ The 64 Parula stops are the same as in the author's R packages (mqqr, qqkrls, mqqcause).
4
+
5
+ Author: Dr Merwan Roudane <merwanroudane920@gmail.com>
6
+ """
7
+ from __future__ import annotations
8
+
9
+ import numpy as np
10
+ from matplotlib.colors import LinearSegmentedColormap, to_hex
11
+
12
+ __all__ = ["parula_colors", "matlab_jet_colors", "turbo_colors", "bluered_colors",
13
+ "sinha_colors", "resolve_colorscale", "get_cmap", "PARULA_STOPS"]
14
+
15
+ PARULA_STOPS = np.array([
16
+ [0.2422, 0.1504, 0.6603], [0.2504, 0.1650, 0.7076], [0.2578, 0.1818, 0.7511], [0.2647, 0.1978, 0.7952],
17
+ [0.2706, 0.2147, 0.8364], [0.2751, 0.2342, 0.8710], [0.2783, 0.2559, 0.8991], [0.2803, 0.2782, 0.9221],
18
+ [0.2813, 0.3006, 0.9414], [0.2810, 0.3228, 0.9579], [0.2795, 0.3447, 0.9717], [0.2760, 0.3667, 0.9829],
19
+ [0.2699, 0.3892, 0.9906], [0.2602, 0.4123, 0.9952], [0.2440, 0.4358, 0.9988], [0.2206, 0.4603, 0.9973],
20
+ [0.1963, 0.4847, 0.9892], [0.1834, 0.5074, 0.9798], [0.1786, 0.5289, 0.9682], [0.1764, 0.5499, 0.9520],
21
+ [0.1687, 0.5703, 0.9359], [0.1540, 0.5902, 0.9218], [0.1460, 0.6091, 0.9079], [0.1380, 0.6276, 0.8973],
22
+ [0.1248, 0.6459, 0.8883], [0.1113, 0.6635, 0.8763], [0.0952, 0.6798, 0.8598], [0.0689, 0.6948, 0.8394],
23
+ [0.0297, 0.7082, 0.8163], [0.0036, 0.7203, 0.7917], [0.0067, 0.7312, 0.7660], [0.0433, 0.7411, 0.7394],
24
+ [0.0964, 0.7500, 0.7120], [0.1408, 0.7584, 0.6842], [0.1717, 0.7670, 0.6554], [0.1938, 0.7758, 0.6251],
25
+ [0.2161, 0.7843, 0.5923], [0.2470, 0.7918, 0.5567], [0.2906, 0.7973, 0.5188], [0.3406, 0.8008, 0.4789],
26
+ [0.3909, 0.8029, 0.4354], [0.4456, 0.8024, 0.3909], [0.5044, 0.7993, 0.3480], [0.5616, 0.7942, 0.3045],
27
+ [0.6174, 0.7876, 0.2612], [0.6720, 0.7793, 0.2227], [0.7242, 0.7698, 0.1910], [0.7738, 0.7598, 0.1646],
28
+ [0.8203, 0.7498, 0.1535], [0.8634, 0.7406, 0.1596], [0.9035, 0.7330, 0.1774], [0.9393, 0.7288, 0.2100],
29
+ [0.9728, 0.7298, 0.2394], [0.9956, 0.7434, 0.2371], [0.9970, 0.7659, 0.2199], [0.9952, 0.7893, 0.2028],
30
+ [0.9892, 0.8129, 0.1885], [0.9786, 0.8360, 0.1766], [0.9676, 0.8587, 0.1643], [0.9610, 0.8806, 0.1537],
31
+ [0.9597, 0.9023, 0.1423], [0.9628, 0.9234, 0.1330], [0.9691, 0.9438, 0.1241], [0.9769, 0.9839, 0.0805],
32
+ ])
33
+
34
+ _ANCHORS = {
35
+ "jet": ["#000090", "#0000FF", "#00FFFF", "#FFFF00", "#FF0000", "#800000"],
36
+ "turbo": ["#30123B", "#4145AB", "#4675ED", "#39A2FC", "#1BCFD4", "#24EAA7", "#61FC6C", "#A4FC3C",
37
+ "#D1E834", "#F3C63A", "#FE9B2D", "#F36315", "#D93806", "#A91201", "#7A0403"],
38
+ "bluered": ["#053061", "#2166AC", "#92C5DE", "#F7F7F7", "#F4A582", "#B2182B", "#67001F"],
39
+ "sinha": ["#000000", "#4A0E00", "#B22222", "#FF8C00", "#FFFF66"],
40
+ }
41
+
42
+
43
+ def _ramp(anchors, n: int) -> list[str]:
44
+ cm = LinearSegmentedColormap.from_list("_tmp", anchors, N=max(int(n), 2))
45
+ return [to_hex(cm(i / (n - 1))) if n > 1 else to_hex(cm(0.0)) for i in range(n)]
46
+
47
+
48
+ def parula_colors(n: int = 256) -> list[str]:
49
+ """MATLAB R2014b Parula as ``n`` hex colours (interpolated from the 64 stops)."""
50
+ if n == len(PARULA_STOPS):
51
+ return [to_hex(c) for c in PARULA_STOPS]
52
+ return _ramp([tuple(c) for c in PARULA_STOPS], n)
53
+
54
+
55
+ def matlab_jet_colors(n: int = 256) -> list[str]:
56
+ return _ramp(_ANCHORS["jet"], n)
57
+
58
+
59
+ def turbo_colors(n: int = 256) -> list[str]:
60
+ return _ramp(_ANCHORS["turbo"], n)
61
+
62
+
63
+ def bluered_colors(n: int = 256) -> list[str]:
64
+ return _ramp(_ANCHORS["bluered"], n)
65
+
66
+
67
+ def sinha_colors(n: int = 256) -> list[str]:
68
+ return _ramp(_ANCHORS["sinha"], n)
69
+
70
+
71
+ _FUN = {"parula": parula_colors, "jet": matlab_jet_colors, "turbo": turbo_colors,
72
+ "bluered": bluered_colors, "blue_red": bluered_colors, "sinha": sinha_colors,
73
+ "red_yellow_black": sinha_colors}
74
+
75
+
76
+ def get_cmap(name: str = "Parula"):
77
+ """Matplotlib colormap for a scale name (Parula by default); falls back to matplotlib's names."""
78
+ key = str(name).lower()
79
+ if key in _FUN:
80
+ return LinearSegmentedColormap.from_list(name, _FUN[key](256), N=256)
81
+ import matplotlib
82
+ return matplotlib.colormaps[name]
83
+
84
+
85
+ def resolve_colorscale(name="Parula", n_breaks: int = 32):
86
+ """Plotly colorscale ``[[value, hex], ...]`` for a scale name (Parula by default)."""
87
+ if isinstance(name, (list, tuple)):
88
+ return list(name)
89
+ key = str(name).lower()
90
+ if key in _FUN:
91
+ cols = _FUN[key](256)
92
+ n_breaks = max(2, int(n_breaks))
93
+ idx = np.round(np.linspace(0, len(cols) - 1, n_breaks)).astype(int)
94
+ return [[float(v), cols[i]] for v, i in zip(np.linspace(0, 1, n_breaks), idx)]
95
+ return name
dscc/_fast.py ADDED
@@ -0,0 +1,152 @@
1
+ """Optional numba kernels for the three hot loops (DSH filter, GARCH(1,1), DCC likelihood).
2
+
3
+ If numba is not installed, or ``DSCC_NO_NUMBA=1`` is set, the pure-NumPy code paths
4
+ are used; both paths give identical results (tested).
5
+
6
+ Author: Dr Merwan Roudane <merwanroudane920@gmail.com>
7
+ """
8
+ from __future__ import annotations
9
+
10
+ import os
11
+
12
+ import numpy as np
13
+
14
+ HAVE_NUMBA = False
15
+ if os.environ.get("DSCC_NO_NUMBA", "0") != "1":
16
+ try:
17
+ from numba import njit
18
+ HAVE_NUMBA = True
19
+ except Exception: # pragma: no cover
20
+ HAVE_NUMBA = False
21
+
22
+ __all__ = ["HAVE_NUMBA", "dsh_loop", "garch11_h", "dcc_nll"]
23
+
24
+
25
+ def _dsh_loop_py(X, Xd, expU, D0, D11, D21, a, c, egarch, delta_fixed):
26
+ T, k = X.shape
27
+ state = np.zeros((T, k)); delta = np.zeros(T, np.int64); contrib = np.zeros(T)
28
+ for t in range(1, T):
29
+ xp = Xd[t - 1] if delta[t - 1] else X[t - 1]
30
+ z = expU[t - 1].T @ xp
31
+ if egarch:
32
+ zs = z / np.sqrt(np.exp(state[t - 1]))
33
+ d = zs - c
34
+ state[t] = D0 + D11 * state[t - 1] + D21 * (np.abs(d) - a * d)
35
+ lam = np.exp(state[t]); logdet = state[t].sum()
36
+ else:
37
+ state[t] = D0 + D11 * state[t - 1] + D21 * z * z
38
+ lam = state[t]
39
+ if np.any(lam <= 0):
40
+ return state, delta, contrib, False
41
+ logdet = np.log(lam).sum()
42
+ E = expU[t]
43
+ w0 = E.T @ X[t]; w1 = E.T @ Xd[t]
44
+ f0 = np.sum(w0 * w0 / lam); f1 = np.sum(w1 * w1 / lam)
45
+ if delta_fixed[0] >= 0:
46
+ delta[t] = delta_fixed[t]
47
+ contrib[t] = logdet + (f1 if delta[t] else f0)
48
+ elif f1 < f0:
49
+ delta[t] = 1; contrib[t] = logdet + f1
50
+ else:
51
+ contrib[t] = logdet + f0
52
+ return state, delta, contrib, True
53
+
54
+
55
+ def _garch11_h_py(u, w, a, b, h0):
56
+ T = u.size
57
+ h = np.empty(T); h[0] = h0
58
+ for t in range(1, T):
59
+ h[t] = w + a * u[t - 1] ** 2 + b * h[t - 1]
60
+ return h
61
+
62
+
63
+ def _dcc_nll_py(e, Qbar, a, b):
64
+ T, k = e.shape
65
+ Q = Qbar.copy()
66
+ nll = 0.0
67
+ for t in range(T):
68
+ if t > 0:
69
+ Q = (1 - a - b) * Qbar + a * np.outer(e[t - 1], e[t - 1]) + b * Q
70
+ d = 1.0 / np.sqrt(np.diag(Q))
71
+ R = Q * np.outer(d, d)
72
+ L = np.linalg.cholesky(R)
73
+ y = np.linalg.solve(L, e[t])
74
+ nll += 2.0 * np.log(np.diag(L)).sum() + y @ y - e[t] @ e[t]
75
+ return 0.5 * nll
76
+
77
+
78
+ if HAVE_NUMBA:
79
+ @njit(cache=True, error_model="numpy")
80
+ def _dsh_loop_nb(X, Xd, expU, D0, D11, D21, a, c, egarch, delta_fixed):
81
+ T, k = X.shape
82
+ state = np.zeros((T, k)); delta = np.zeros(T, np.int64); contrib = np.zeros(T)
83
+ z = np.empty(k)
84
+ for t in range(1, T):
85
+ use_d = delta[t - 1] == 1
86
+ for i in range(k):
87
+ s = 0.0
88
+ for j in range(k):
89
+ s += expU[t - 1, j, i] * (Xd[t - 1, j] if use_d else X[t - 1, j])
90
+ z[i] = s
91
+ logdet = 0.0
92
+ for i in range(k):
93
+ if egarch:
94
+ zs = z[i] / np.sqrt(np.exp(state[t - 1, i]))
95
+ dd = zs - c[i]
96
+ state[t, i] = D0[i] + D11[i] * state[t - 1, i] + D21[i] * (abs(dd) - a[i] * dd)
97
+ logdet += state[t, i]
98
+ else:
99
+ state[t, i] = D0[i] + D11[i] * state[t - 1, i] + D21[i] * z[i] * z[i]
100
+ if state[t, i] <= 0.0:
101
+ return state, delta, contrib, False
102
+ logdet += np.log(state[t, i])
103
+ f0 = 0.0; f1 = 0.0
104
+ for i in range(k):
105
+ lam = np.exp(state[t, i]) if egarch else state[t, i]
106
+ s0 = 0.0; s1 = 0.0
107
+ for j in range(k):
108
+ s0 += expU[t, j, i] * X[t, j]
109
+ s1 += expU[t, j, i] * Xd[t, j]
110
+ f0 += s0 * s0 / lam; f1 += s1 * s1 / lam
111
+ if delta_fixed[0] >= 0:
112
+ delta[t] = delta_fixed[t]
113
+ contrib[t] = logdet + (f1 if delta[t] == 1 else f0)
114
+ elif f1 < f0:
115
+ delta[t] = 1; contrib[t] = logdet + f1
116
+ else:
117
+ contrib[t] = logdet + f0
118
+ return state, delta, contrib, True
119
+
120
+ @njit(cache=True, error_model="numpy")
121
+ def _garch11_h_nb(u, w, a, b, h0):
122
+ T = u.size
123
+ h = np.empty(T); h[0] = h0
124
+ for t in range(1, T):
125
+ h[t] = w + a * u[t - 1] ** 2 + b * h[t - 1]
126
+ return h
127
+
128
+ @njit(cache=True, error_model="numpy")
129
+ def _dcc_nll_nb(e, Qbar, a, b):
130
+ T, k = e.shape
131
+ Q = Qbar.copy()
132
+ nll = 0.0
133
+ for t in range(T):
134
+ if t > 0:
135
+ for i in range(k):
136
+ for j in range(k):
137
+ Q[i, j] = (1 - a - b) * Qbar[i, j] + a * e[t - 1, i] * e[t - 1, j] + b * Q[i, j]
138
+ R = np.empty((k, k))
139
+ for i in range(k):
140
+ for j in range(k):
141
+ R[i, j] = Q[i, j] / np.sqrt(Q[i, i] * Q[j, j])
142
+ L = np.linalg.cholesky(R)
143
+ y = np.linalg.solve(L, e[t])
144
+ ld = 0.0
145
+ for i in range(k):
146
+ ld += 2.0 * np.log(L[i, i])
147
+ nll += ld + np.dot(y, y) - np.dot(e[t], e[t])
148
+ return 0.5 * nll
149
+
150
+ dsh_loop, garch11_h, dcc_nll = _dsh_loop_nb, _garch11_h_nb, _dcc_nll_nb
151
+ else: # pragma: no cover
152
+ dsh_loop, garch11_h, dcc_nll = _dsh_loop_py, _garch11_h_py, _dcc_nll_py
dscc/_filters.py ADDED
@@ -0,0 +1,221 @@
1
+ """Likelihood filters for the DSH / DSCC class.
2
+
3
+ One pass through the data computes, for every t, the dynamic skew-symmetric
4
+ component Upsilon_t (eq. 2.9), the eigenvalues Lambda_t (GARCH eq. 2.5 or
5
+ EGARCH eq. 2.6-2.7), the in-sample MLE of delta_t, and the Gaussian
6
+ log-likelihood (2.13) of Abadir and Rockinger (2026) / (3.11) of Abadir (2023).
7
+
8
+ Parameter-vector layout (identical to the authors' replication code, so their
9
+ stored estimates load unchanged), with k variables and l = k(k-1)/2:
10
+
11
+ GARCH : [D0 (k), D11 (k), D21 (k), mu (l), alpha (l), beta (l)] 3k + 3l
12
+ EGARCH : [D0 (k), D11 (k), D21 (k), mu (l), alpha (l), beta (l), a (k), c (k)] 5k + 3l
13
+
14
+ Timing conventions (verified line by line against the authors' code):
15
+
16
+ * t = 1 is a dead start-up: Lambda_1 = O (GARCH) or log Lambda_1 = O (EGARCH),
17
+ Upsilon_1 = O, delta_1 = 0, and t = 1 does not enter the likelihood
18
+ (Abadir 2023, after eq. 4.8).
19
+ * Lambda_t uses z_{t-1} = e^{-Upsilon_{t-1}} P^{delta_{t-1}} x_{t-1} (eq. 2.4-2.5).
20
+ * delta_t is chosen sequentially: given Lambda_t and Upsilon_t, the t-th summand
21
+ of (2.13) is maximised over delta_t in {0, 1}; ties keep delta_t = 0.
22
+ (Abadir 2023, Section 4.1: "each of the summands ... maximized with respect
23
+ to delta_t"; 2T likelihood evaluations instead of 2^T.)
24
+
25
+ Author: Dr Merwan Roudane <merwanroudane920@gmail.com>
26
+ """
27
+ from __future__ import annotations
28
+
29
+ from dataclasses import dataclass
30
+
31
+ import numpy as np
32
+
33
+ from ._fast import HAVE_NUMBA, dsh_loop
34
+ from ._representation import n_offdiag
35
+
36
+ __all__ = ["FilterOutput", "unpack_theta", "n_params", "dsh_filter", "g_abs"]
37
+
38
+
39
+ @dataclass
40
+ class FilterOutput:
41
+ """Output of one filtering pass (all arrays are time-major)."""
42
+
43
+ negloglik: float # 0.5 * sum_t [ sum_i log lam_it + x_t' Sigma_t^{-1} x_t ]
44
+ contributions: np.ndarray # (T,) the per-t terms (before the 0.5 factor)
45
+ delta: np.ndarray # (T,) int, in-sample MLE of delta_t
46
+ lam: np.ndarray # (T, k) eigenvalues Lambda_t (levels)
47
+ ups: np.ndarray # (T, l) free elements of Upsilon_t
48
+ expU: np.ndarray # (T, k, k) e^{Upsilon_t}
49
+
50
+
51
+ def n_params(k: int, dynamics: str) -> int:
52
+ l = n_offdiag(k)
53
+ return 3 * k + 3 * l + (2 * k if dynamics == "egarch" else 0)
54
+
55
+
56
+ def unpack_theta(theta: np.ndarray, k: int, dynamics: str) -> dict:
57
+ theta = np.asarray(theta, dtype=float).ravel()
58
+ l = n_offdiag(k)
59
+ if theta.size != n_params(k, dynamics):
60
+ raise ValueError(
61
+ f"theta has {theta.size} elements; {dynamics} with k={k} needs {n_params(k, dynamics)}"
62
+ )
63
+ out = {
64
+ "D0": theta[0:k],
65
+ "D11": theta[k:2 * k],
66
+ "D21": theta[2 * k:3 * k],
67
+ "mu": theta[3 * k:3 * k + l],
68
+ "alpha": theta[3 * k + l:3 * k + 2 * l],
69
+ "beta": theta[3 * k + 2 * l:3 * k + 3 * l],
70
+ }
71
+ if dynamics == "egarch":
72
+ out["a"] = theta[3 * k + 3 * l:4 * k + 3 * l]
73
+ out["c"] = theta[4 * k + 3 * l:5 * k + 3 * l]
74
+ return out
75
+
76
+
77
+ def g_abs(v: np.ndarray, a: np.ndarray, c: np.ndarray) -> np.ndarray:
78
+ """Asymmetric absolute-value function g_i(v) = |v_i - c_i| - a_i (v_i - c_i), eq. (2.7)."""
79
+ d = v - c
80
+ return np.abs(d) - a * d
81
+
82
+
83
+ def dsh_filter(
84
+ theta: np.ndarray,
85
+ X: np.ndarray,
86
+ xi: np.ndarray,
87
+ dynamics: str = "garch",
88
+ store_expU: bool = True,
89
+ delta_fixed: np.ndarray | None = None,
90
+ use_fast: bool = True,
91
+ ) -> FilterOutput:
92
+ """Run the DSH filter and return the negative log-likelihood and paths.
93
+
94
+ Parameters
95
+ ----------
96
+ theta : parameter vector (layout in the module docstring).
97
+ X : (T, k) de-meaned data x_t.
98
+ xi : (T, l) data innovations xi_t = vec(log Q_t), eq. (2.11).
99
+ dynamics : {"garch", "egarch"} for eq. (2.5) or eq. (2.6).
100
+ store_expU : keep e^{Upsilon_t} for every t (needed for forecasting).
101
+ delta_fixed : optional (T,) 0/1 path. When given, delta_t is held at these
102
+ values instead of being maximised; the likelihood is then smooth in theta,
103
+ which is what the profile-likelihood standard errors use.
104
+
105
+ Returns
106
+ -------
107
+ FilterOutput
108
+
109
+ Examples
110
+ --------
111
+ >>> import numpy as np
112
+ >>> from dscc._representation import xi_from_data
113
+ >>> rng = np.random.default_rng(0); X = rng.standard_normal((30, 2))
114
+ >>> theta = np.r_[[0.02]*2, [0.85]*2, [0.10]*2, -1.0, 0.1, -0.02]
115
+ >>> out = dsh_filter(theta, X, xi_from_data(X))
116
+ >>> bool(np.isfinite(out.negloglik))
117
+ True
118
+ """
119
+ X = np.asarray(X, dtype=float)
120
+ T, k = X.shape
121
+ l = n_offdiag(k)
122
+ p = unpack_theta(theta, k, dynamics)
123
+ D0, D11, D21 = p["D0"], p["D11"], p["D21"]
124
+ mu, alpha, beta = p["mu"], p["alpha"], p["beta"]
125
+ const_v = mu * (1.0 - alpha)
126
+ egarch = dynamics == "egarch"
127
+ if egarch:
128
+ a, c = p["a"], p["c"]
129
+
130
+ Xd = X.copy()
131
+ Xd[:, [0, 1]] = X[:, [1, 0]] # P x_t (P swaps the first two variates)
132
+
133
+ # (2.9): upsilon_t = (1-alpha) mu + alpha upsilon_{t-1} + beta xi_{t-1}, upsilon_1 = 0.
134
+ # Upsilon_t depends neither on Lambda nor on delta, so the whole path and all
135
+ # matrix exponentials are computed once, before the Lambda/delta recursion.
136
+ v = ups_path(const_v, alpha, beta, xi, T)
137
+ expU = expm_skew_batch(v, k)
138
+
139
+ if use_fast and HAVE_NUMBA:
140
+ dfix = (np.full(1, -1, np.int64) if delta_fixed is None
141
+ else np.ascontiguousarray(delta_fixed, dtype=np.int64))
142
+ zk = np.zeros(k)
143
+ state, delta, contrib, ok = dsh_loop(
144
+ np.ascontiguousarray(X), np.ascontiguousarray(Xd), expU,
145
+ np.ascontiguousarray(D0, float), np.ascontiguousarray(D11, float),
146
+ np.ascontiguousarray(D21, float),
147
+ np.ascontiguousarray(a, float) if egarch else zk,
148
+ np.ascontiguousarray(c, float) if egarch else zk, egarch, dfix)
149
+ if not ok:
150
+ return FilterOutput(np.inf, np.full(T, np.inf), delta, state, v, expU)
151
+ lam = np.exp(state) if egarch else state
152
+ return FilterOutput(0.5 * contrib.sum(), contrib, delta, lam, v,
153
+ expU if store_expU else None)
154
+
155
+ state = np.zeros((T, k)) # lam_t (garch) or log lam_t (egarch)
156
+ delta = np.zeros(T, dtype=int)
157
+ contrib = np.zeros(T)
158
+
159
+ for t in range(1, T):
160
+ # z_{t-1} = e^{-Upsilon_{t-1}} P^{delta_{t-1}} x_{t-1}
161
+ x_prev = Xd[t - 1] if delta[t - 1] else X[t - 1]
162
+ z = expU[t - 1].T @ x_prev
163
+ if egarch:
164
+ # (2.6): log Lambda_t = D0 + D11 log Lambda_{t-1} + D21 g(Lambda_{t-1}^{-1/2} z_{t-1})
165
+ zs = z / np.sqrt(np.exp(state[t - 1]))
166
+ state[t] = D0 + D11 * state[t - 1] + D21 * g_abs(zs, a, c)
167
+ lam_t = np.exp(state[t])
168
+ logdet = state[t].sum()
169
+ else:
170
+ # (2.5): Lambda_t = D0 + D11 Lambda_{t-1} + D21 dg(z_{t-1} z_{t-1}')
171
+ state[t] = D0 + D11 * state[t - 1] + D21 * z * z
172
+ lam_t = state[t]
173
+ if np.any(lam_t <= 0):
174
+ contrib[t:] = np.inf
175
+ return FilterOutput(np.inf, contrib, delta, state, v, expU)
176
+ logdet = np.log(lam_t).sum()
177
+
178
+ E = expU[t]
179
+ # quadratic forms x' Sigma_t^{-1} x for delta = 0 and delta = 1
180
+ w0 = E.T @ X[t]
181
+ w1 = E.T @ Xd[t]
182
+ f0 = np.sum(w0 * w0 / lam_t)
183
+ f1 = np.sum(w1 * w1 / lam_t)
184
+ if delta_fixed is not None:
185
+ delta[t] = int(delta_fixed[t])
186
+ contrib[t] = logdet + (f1 if delta[t] else f0)
187
+ elif f1 < f0:
188
+ delta[t] = 1
189
+ contrib[t] = logdet + f1
190
+ else:
191
+ contrib[t] = logdet + f0
192
+
193
+ lam = np.exp(state) if egarch else state
194
+ return FilterOutput(0.5 * contrib.sum(), contrib, delta, lam, v,
195
+ expU if store_expU else None)
196
+
197
+
198
+ def ups_path(const_v, alpha, beta, xi, T: int) -> np.ndarray:
199
+ """Path of the free elements of Upsilon_t from eq. (2.9), with upsilon_1 = 0."""
200
+ l = np.size(alpha)
201
+ v = np.zeros((T, l))
202
+ for t in range(1, T):
203
+ v[t] = const_v + alpha * v[t - 1] + beta * xi[t - 1]
204
+ return v
205
+
206
+
207
+ def expm_skew_batch(v: np.ndarray, k: int) -> np.ndarray:
208
+ """e^{Upsilon_t} for a batch of skew-symmetric matrices.
209
+
210
+ For skew-symmetric U, H = iU is Hermitian: H = V diag(w) V^H, hence
211
+ e^U = V diag(e^{-iw}) V^H (exact; Lemma 2.1 of Abadir 2023). One batched
212
+ Hermitian eigendecomposition replaces T calls to a general expm.
213
+ """
214
+ T = v.shape[0]
215
+ U = np.zeros((T, k, k))
216
+ i, j = np.triu_indices(k, 1)
217
+ U[:, i, j] = v
218
+ U[:, j, i] = -v
219
+ w, V = np.linalg.eigh(1j * U)
220
+ E = (V * np.exp(-1j * w)[:, None, :]) @ np.conj(np.swapaxes(V, 1, 2))
221
+ return E.real