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 +45 -0
- dscc/_colors.py +95 -0
- dscc/_fast.py +152 -0
- dscc/_filters.py +221 -0
- dscc/_model.py +489 -0
- dscc/_plots.py +166 -0
- dscc/_representation.py +201 -0
- dscc/_tables.py +108 -0
- dscc/backtest.py +163 -0
- dscc/datasets.py +105 -0
- dscc/dcc.py +132 -0
- dscc/elliptope.py +245 -0
- dscc/portfolio.py +169 -0
- dscc-0.1.0.dist-info/METADATA +448 -0
- dscc-0.1.0.dist-info/RECORD +17 -0
- dscc-0.1.0.dist-info/WHEEL +4 -0
- dscc-0.1.0.dist-info/licenses/LICENSE +21 -0
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
|