pygranger 2.0.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.
- pygc/__init__.py +24 -0
- pygc/_jax_backend.py +200 -0
- pygc/ar_model.py +81 -0
- pygc/granger.py +493 -0
- pygc/misc/__init__.py +1 -0
- pygc/misc/smooth_spectra.py +15 -0
- pygc/non_parametric.py +107 -0
- pygc/output.py +115 -0
- pygc/parametric.py +130 -0
- pygc/spectral_analysis/__init__.py +24 -0
- pygc/spectral_analysis/fourier.py +79 -0
- pygc/spectral_analysis/time_frequency.py +196 -0
- pygranger-2.0.0.dist-info/METADATA +239 -0
- pygranger-2.0.0.dist-info/RECORD +17 -0
- pygranger-2.0.0.dist-info/WHEEL +5 -0
- pygranger-2.0.0.dist-info/licenses/LICENSE +21 -0
- pygranger-2.0.0.dist-info/top_level.txt +1 -0
pygc/granger.py
ADDED
|
@@ -0,0 +1,493 @@
|
|
|
1
|
+
########################################################################################
|
|
2
|
+
# Module with functions to compute GC
|
|
3
|
+
########################################################################################
|
|
4
|
+
import logging
|
|
5
|
+
from typing import Callable
|
|
6
|
+
import numpy as np
|
|
7
|
+
from joblib import Parallel, delayed
|
|
8
|
+
from itertools import combinations
|
|
9
|
+
from .output import build_granger_dataset, build_conditional_gc_dataset, build_conditional_spec_gc_dataset
|
|
10
|
+
|
|
11
|
+
logger = logging.getLogger(__name__)
|
|
12
|
+
|
|
13
|
+
_VALID_BACKENDS = ('numpy', 'jax')
|
|
14
|
+
_VALID_SPECTRAL_METHODS = ('fourier', 'morlet', 'welch', 'multitaper')
|
|
15
|
+
_VALID_MODELS = ('nonparametric', 'parametric')
|
|
16
|
+
|
|
17
|
+
|
|
18
|
+
def _get_factorization_fn(backend: str) -> Callable[..., tuple]:
|
|
19
|
+
"""Return the Wilson factorization callable for *backend* ('numpy' or 'jax')."""
|
|
20
|
+
if backend == 'numpy':
|
|
21
|
+
from .non_parametric import wilson_factorization
|
|
22
|
+
return wilson_factorization
|
|
23
|
+
if backend == 'jax':
|
|
24
|
+
from ._jax_backend import wilson_factorization_jax, JAX_AVAILABLE
|
|
25
|
+
if not JAX_AVAILABLE:
|
|
26
|
+
raise ImportError(
|
|
27
|
+
"JAX is not installed. Install it with: pip install jax"
|
|
28
|
+
)
|
|
29
|
+
return wilson_factorization_jax
|
|
30
|
+
raise ValueError(f"Unknown backend {backend!r}. Choose from {_VALID_BACKENDS}.")
|
|
31
|
+
|
|
32
|
+
|
|
33
|
+
def _compute_csd(X, fs, spectral_method, spectral_params):
|
|
34
|
+
"""Compute cross-spectral matrix and frequency axis from raw data.
|
|
35
|
+
|
|
36
|
+
Parameters
|
|
37
|
+
----------
|
|
38
|
+
X : ndarray
|
|
39
|
+
'fourier'/'morlet' : (nvars, N)
|
|
40
|
+
'welch' : (trials, nvars, N) or (nvars, N) for 1 trial
|
|
41
|
+
fs : float — sampling rate.
|
|
42
|
+
spectral_method : str — one of _VALID_SPECTRAL_METHODS.
|
|
43
|
+
spectral_params : dict or None — extra kwargs forwarded to the spectral estimator.
|
|
44
|
+
'morlet' requires 'freqs'; supports 'n_cycles' (default 7.0).
|
|
45
|
+
'welch' supports 'window', 'nfft', 'scaling', 'n_jobs'.
|
|
46
|
+
|
|
47
|
+
Returns
|
|
48
|
+
-------
|
|
49
|
+
S : ndarray (nvars, nvars, n_freq) — cross-spectral matrix.
|
|
50
|
+
f : ndarray (n_freq,) — frequency axis (Hz).
|
|
51
|
+
"""
|
|
52
|
+
params = spectral_params or {}
|
|
53
|
+
|
|
54
|
+
if spectral_method == 'fourier':
|
|
55
|
+
from .spectral_analysis import compute_freq, csd_fourier
|
|
56
|
+
if X.ndim == 2:
|
|
57
|
+
X = X[np.newaxis] # treat as 1 trial: (1, nvars, N)
|
|
58
|
+
if X.ndim != 3:
|
|
59
|
+
raise ValueError("'fourier' expects X of shape (nvars, N) or (trials, nvars, N).")
|
|
60
|
+
trials, nvars, N = X.shape
|
|
61
|
+
f = compute_freq(N, fs)
|
|
62
|
+
S = np.zeros((nvars, nvars, len(f)), dtype=complex)
|
|
63
|
+
for i in range(nvars):
|
|
64
|
+
for j in range(nvars):
|
|
65
|
+
for t in range(trials):
|
|
66
|
+
S[i, j] += csd_fourier(X[t, i], X[t, j], f, fs)
|
|
67
|
+
S[i, j] /= trials
|
|
68
|
+
return S, f
|
|
69
|
+
|
|
70
|
+
if spectral_method == 'morlet':
|
|
71
|
+
from .spectral_analysis import morlet_csd
|
|
72
|
+
if X.ndim == 2:
|
|
73
|
+
X = X[np.newaxis] # treat as 1 trial: (1, nvars, N)
|
|
74
|
+
if X.ndim != 3:
|
|
75
|
+
raise ValueError("'morlet' expects X of shape (nvars, N) or (trials, nvars, N).")
|
|
76
|
+
freqs = params.get('freqs')
|
|
77
|
+
if freqs is None:
|
|
78
|
+
raise ValueError("spectral_params must include 'freqs' for 'morlet'.")
|
|
79
|
+
n_cycles = params.get('n_cycles', 7.0)
|
|
80
|
+
trials, nvars, _ = X.shape
|
|
81
|
+
f = np.asarray(freqs, dtype=float)
|
|
82
|
+
S = np.zeros((nvars, nvars, len(f)), dtype=complex)
|
|
83
|
+
for i in range(nvars):
|
|
84
|
+
for j in range(nvars):
|
|
85
|
+
for t in range(trials):
|
|
86
|
+
csd_tf = morlet_csd(X[t, i], X[t, j], freqs=f, Fs=fs, n_cycles=n_cycles)
|
|
87
|
+
S[i, j] += np.mean(csd_tf, axis=0)
|
|
88
|
+
S[i, j] /= trials
|
|
89
|
+
return S, f
|
|
90
|
+
|
|
91
|
+
if spectral_method == 'welch':
|
|
92
|
+
from .spectral_analysis import welch_spectrum
|
|
93
|
+
from scipy import signal as _scipy_signal
|
|
94
|
+
if X.ndim == 2:
|
|
95
|
+
X = X[np.newaxis]
|
|
96
|
+
window = params.get('window', 'hann')
|
|
97
|
+
nperseg = params.get('nperseg', None)
|
|
98
|
+
nfft = params.get('nfft', None)
|
|
99
|
+
scaling = params.get('scaling', 'density')
|
|
100
|
+
n_jobs = params.get('n_jobs', 1)
|
|
101
|
+
S = welch_spectrum(data=X, fs=fs, window=window, nperseg=nperseg,
|
|
102
|
+
nfft=nfft, scaling=scaling, n_jobs=n_jobs)
|
|
103
|
+
# scipy.signal.csd uses conj(X)*Y; conjugate to match csd_fourier's X*conj(Y)
|
|
104
|
+
S = np.conj(S)
|
|
105
|
+
f, _ = _scipy_signal.csd(X[0, 0], X[0, 0], fs,
|
|
106
|
+
window=window, nperseg=nperseg, nfft=nfft,
|
|
107
|
+
scaling=scaling)
|
|
108
|
+
return S, f
|
|
109
|
+
|
|
110
|
+
if spectral_method == 'multitaper':
|
|
111
|
+
from .spectral_analysis import multitaper_spectrum
|
|
112
|
+
if X.ndim == 2:
|
|
113
|
+
X = X[np.newaxis]
|
|
114
|
+
if X.ndim != 3:
|
|
115
|
+
raise ValueError("'multitaper' expects X of shape (nvars, N) or (trials, nvars, N).")
|
|
116
|
+
S, f = multitaper_spectrum(
|
|
117
|
+
data=X, fs=fs,
|
|
118
|
+
bandwidth=params.get('bandwidth', None),
|
|
119
|
+
adaptive=params.get('adaptive', False),
|
|
120
|
+
low_bias=params.get('low_bias', True),
|
|
121
|
+
fmin=params.get('fmin', 0),
|
|
122
|
+
fmax=params.get('fmax', np.inf),
|
|
123
|
+
n_fft=params.get('n_fft', None),
|
|
124
|
+
n_jobs=params.get('n_jobs', 1),
|
|
125
|
+
)
|
|
126
|
+
return S, f
|
|
127
|
+
|
|
128
|
+
raise ValueError(
|
|
129
|
+
f"Unknown spectral_method {spectral_method!r}. Choose from {_VALID_SPECTRAL_METHODS}."
|
|
130
|
+
)
|
|
131
|
+
|
|
132
|
+
|
|
133
|
+
def _full_estimate(X, fs, model, spectral_method, backend, Niterations, tol,
|
|
134
|
+
verbose, spectral_params, ensure_stability, order, f):
|
|
135
|
+
if model == 'nonparametric':
|
|
136
|
+
S, f = _compute_csd(X, fs, spectral_method, spectral_params)
|
|
137
|
+
factorize = _get_factorization_fn(backend)
|
|
138
|
+
_, H, Z = factorize(S, f, fs, Niterations, tol, verbose, ensure_stability)
|
|
139
|
+
return S, H, Z, f
|
|
140
|
+
|
|
141
|
+
if model == 'parametric':
|
|
142
|
+
from .parametric import YuleWalker, YuleWalker_multitrial, compute_transfer_function
|
|
143
|
+
if order is None:
|
|
144
|
+
raise ValueError("`order` (VAR model order) is required when model='parametric'.")
|
|
145
|
+
|
|
146
|
+
if X.ndim == 2:
|
|
147
|
+
N = X.shape[1]
|
|
148
|
+
AR, eps = YuleWalker(X, order)
|
|
149
|
+
elif X.ndim == 3:
|
|
150
|
+
N = X.shape[2]
|
|
151
|
+
AR, eps = YuleWalker_multitrial(X, order)
|
|
152
|
+
else:
|
|
153
|
+
raise ValueError(
|
|
154
|
+
"model='parametric' expects X of shape (nvars, N) or (Ntrials, nvars, N); "
|
|
155
|
+
f"got ndim={X.ndim}."
|
|
156
|
+
)
|
|
157
|
+
|
|
158
|
+
if f is None:
|
|
159
|
+
from .spectral_analysis import compute_freq
|
|
160
|
+
f = compute_freq(N, fs)
|
|
161
|
+
H, S = compute_transfer_function(AR, eps, f, fs)
|
|
162
|
+
Z = eps
|
|
163
|
+
return S, H, Z, f
|
|
164
|
+
|
|
165
|
+
raise ValueError(f"Unknown model {model!r}. Choose from {_VALID_MODELS}.")
|
|
166
|
+
|
|
167
|
+
|
|
168
|
+
def _reduced_estimate(X_full, S_full, j, fs, model, spectral_method, backend,
|
|
169
|
+
Niterations, tol, spectral_params, ensure_stability,
|
|
170
|
+
order, f):
|
|
171
|
+
if model == 'nonparametric':
|
|
172
|
+
S_aux = np.delete(np.delete(S_full, j, 0), j, 1)
|
|
173
|
+
factorize = _get_factorization_fn(backend)
|
|
174
|
+
_, H, Z = factorize(S_aux, f, fs, Niterations, tol, verbose=False,
|
|
175
|
+
ensure_stability=ensure_stability)
|
|
176
|
+
return H, np.diag(Z)
|
|
177
|
+
|
|
178
|
+
if model == 'parametric':
|
|
179
|
+
from .parametric import YuleWalker, YuleWalker_multitrial, compute_transfer_function
|
|
180
|
+
# channel axis is 0 for single-trial (nvars, N), 1 for multi-trial (Ntrials, nvars, N)
|
|
181
|
+
channel_axis = 0 if X_full.ndim == 2 else 1
|
|
182
|
+
X_aux = np.delete(X_full, j, axis=channel_axis)
|
|
183
|
+
if X_full.ndim == 2:
|
|
184
|
+
AR_j, eps_j = YuleWalker(X_aux, order)
|
|
185
|
+
else:
|
|
186
|
+
AR_j, eps_j = YuleWalker_multitrial(X_aux, order)
|
|
187
|
+
H, _ = compute_transfer_function(AR_j, eps_j, f, fs)
|
|
188
|
+
return H, np.diag(eps_j)
|
|
189
|
+
|
|
190
|
+
raise ValueError(f"Unknown model {model!r}. Choose from {_VALID_MODELS}.")
|
|
191
|
+
|
|
192
|
+
|
|
193
|
+
def _directional_gc(H_ii, Z_ii, Z_jj, Z_ij, H_ij):
|
|
194
|
+
"""One directional term of Geweke's frequency-domain GC decomposition."""
|
|
195
|
+
intrinsic = H_ii * Z_ii * np.conj(H_ii)
|
|
196
|
+
causal = H_ij * (Z_jj - Z_ij ** 2 / Z_ii) * np.conj(H_ij)
|
|
197
|
+
return np.log((intrinsic + causal) / intrinsic)
|
|
198
|
+
|
|
199
|
+
|
|
200
|
+
def _gc(S, H, Z, x_s, x_t):
|
|
201
|
+
"""Pairwise frequency-domain Granger Causality from a Wilson factorization.
|
|
202
|
+
|
|
203
|
+
Pure post-factorization step: takes the already-estimated cross-spectral
|
|
204
|
+
density and its factorization and computes GC spectra for one channel
|
|
205
|
+
pair. Knows nothing about how S/H/Z were produced.
|
|
206
|
+
|
|
207
|
+
Parameters
|
|
208
|
+
----------
|
|
209
|
+
S : ndarray, shape (n_channels, n_channels, n_freq) — cross-spectral density.
|
|
210
|
+
H : ndarray, shape (n_channels, n_channels, n_freq) — transfer function.
|
|
211
|
+
Z : ndarray, shape (n_channels, n_channels) — innovations (noise) covariance.
|
|
212
|
+
x_s : int — source channel index.
|
|
213
|
+
x_t : int — target channel index.
|
|
214
|
+
|
|
215
|
+
Returns
|
|
216
|
+
-------
|
|
217
|
+
Ix2y, Iy2x, Ixy : ndarray (n_freq,) each — real-valued GC spectra.
|
|
218
|
+
"""
|
|
219
|
+
if x_s == x_t:
|
|
220
|
+
raise ValueError(f"x_s and x_t must differ, got x_s=x_t={x_s}")
|
|
221
|
+
n = S.shape[0]
|
|
222
|
+
if not (0 <= x_s < n and 0 <= x_t < n):
|
|
223
|
+
raise ValueError(f"x_s={x_s}, x_t={x_t} out of bounds for {n} channels")
|
|
224
|
+
|
|
225
|
+
Hxx, Hxy = H[x_s, x_s], H[x_s, x_t]
|
|
226
|
+
Hyx, Hyy = H[x_t, x_s], H[x_t, x_t]
|
|
227
|
+
Zxx, Zxy = Z[x_s, x_s], Z[x_s, x_t]
|
|
228
|
+
Zyx, Zyy = Z[x_t, x_s], Z[x_t, x_t]
|
|
229
|
+
|
|
230
|
+
# Wilson-rotated transfer functions removing instantaneous mixing
|
|
231
|
+
Hxx_tilda = Hxx + (Zxy / Zxx) * Hxy
|
|
232
|
+
Hyy_circf = Hyy + (Zyx / Zyy) * Hyx
|
|
233
|
+
|
|
234
|
+
Ix2y = _directional_gc(Hyy_circf, Zyy, Zxx, Zyx, Hyx)
|
|
235
|
+
Iy2x = _directional_gc(Hxx_tilda, Zxx, Zyy, Zxy, Hxy)
|
|
236
|
+
|
|
237
|
+
intrinsic_x = (Hxx_tilda * Zxx * np.conj(Hxx_tilda)).real
|
|
238
|
+
intrinsic_y = (Hyy_circf * Zyy * np.conj(Hyy_circf)).real
|
|
239
|
+
det_S = np.linalg.det(S.transpose(2, 0, 1)).real
|
|
240
|
+
Ixy = np.log(intrinsic_x * intrinsic_y / det_S)
|
|
241
|
+
|
|
242
|
+
return Ix2y.real, Iy2x.real, Ixy.real
|
|
243
|
+
|
|
244
|
+
|
|
245
|
+
def _gc_pairs(S, H, Z, pairs, n_jobs=-1, backend='loky'):
|
|
246
|
+
"""Compute GC for a list of channel pairs in parallel via joblib.
|
|
247
|
+
|
|
248
|
+
Parameters
|
|
249
|
+
----------
|
|
250
|
+
S, H, Z : see `_gc`.
|
|
251
|
+
pairs : list of (x_s, x_t) index tuples.
|
|
252
|
+
n_jobs : int — passed to joblib.Parallel (-1 = all cores).
|
|
253
|
+
backend : str — joblib backend ('loky', 'threading', ...).
|
|
254
|
+
|
|
255
|
+
Returns
|
|
256
|
+
-------
|
|
257
|
+
results : list of (Ix2y, Iy2x, Ixy) tuples, aligned with `pairs`.
|
|
258
|
+
"""
|
|
259
|
+
return Parallel(n_jobs=n_jobs, backend=backend)(
|
|
260
|
+
delayed(_gc)(S, H, Z, i, j) for i, j in pairs
|
|
261
|
+
)
|
|
262
|
+
|
|
263
|
+
|
|
264
|
+
def _pair_time_domain_gc(X, fs, i, j, model, spectral_method, backend, Niterations,
|
|
265
|
+
tol, spectral_params, ensure_stability, order, f):
|
|
266
|
+
""" Bivariate (pairwise) time-domain GC for one channel pair (i, j). """
|
|
267
|
+
if X.ndim == 2:
|
|
268
|
+
Xij = X[[i, j], :]
|
|
269
|
+
elif X.ndim == 3:
|
|
270
|
+
Xij = X[:, [i, j], :]
|
|
271
|
+
else:
|
|
272
|
+
raise ValueError(f"X must be 2D or 3D, got ndim={X.ndim}")
|
|
273
|
+
|
|
274
|
+
S, _, Z, f_ = _full_estimate(Xij, fs, model, spectral_method, backend, Niterations,
|
|
275
|
+
tol, False, spectral_params, ensure_stability, order, f)
|
|
276
|
+
LSIG = np.log(np.diag(Z)) # [var_i | j, var_j | i] from the bivariate model
|
|
277
|
+
|
|
278
|
+
_, Z_i_alone = _reduced_estimate(Xij, S, 1, fs, model, spectral_method, backend,
|
|
279
|
+
Niterations, tol, spectral_params, ensure_stability,
|
|
280
|
+
order, f_)
|
|
281
|
+
|
|
282
|
+
_, Z_j_alone = _reduced_estimate(Xij, S, 0, fs, model, spectral_method, backend,
|
|
283
|
+
Niterations, tol, spectral_params, ensure_stability,
|
|
284
|
+
order, f_)
|
|
285
|
+
|
|
286
|
+
Ix2y = np.log(Z_j_alone[0]) - LSIG[1] # i -> j
|
|
287
|
+
Iy2x = np.log(Z_i_alone[0]) - LSIG[0] # j -> i
|
|
288
|
+
return Ix2y, Iy2x
|
|
289
|
+
|
|
290
|
+
|
|
291
|
+
def granger_causality(X, fs, pairs=None, model='nonparametric', order=None, f=None,
|
|
292
|
+
spectral_method='fourier', backend='numpy', Niterations=100,
|
|
293
|
+
tol=1e-12, spectral_params=None, ensure_stability=True,
|
|
294
|
+
n_jobs=-1, joblib_backend='loky', channel_names=None):
|
|
295
|
+
"""Pairwise (bivariate) time-domain Granger Causality.
|
|
296
|
+
|
|
297
|
+
Unlike `granger_causality`, which returns a GC *spectrum* per pair via
|
|
298
|
+
Wilson factorization + `_gc`, this returns one scalar per direction per
|
|
299
|
+
pair — the broadband/time-domain GC, obtained the same way
|
|
300
|
+
`conditional_granger_causality` gets its scalar F matrix, except each
|
|
301
|
+
pair is fit on its own 2-channel subset rather than conditioning on the
|
|
302
|
+
full channel set. Set-wise conditioning is what makes
|
|
303
|
+
`conditional_granger_causality` "conditional"; here there's nothing to
|
|
304
|
+
condition on since only two channels are ever considered at once.
|
|
305
|
+
|
|
306
|
+
Returns
|
|
307
|
+
-------
|
|
308
|
+
ds : xarray.Dataset — same shape/convention as
|
|
309
|
+
`build_conditional_gc_dataset`, i.e. an (nvars, nvars) F matrix
|
|
310
|
+
with F[i, j] = GC from i -> j. Entries for pairs not in `pairs`
|
|
311
|
+
are left as NaN.
|
|
312
|
+
"""
|
|
313
|
+
nvars = X.shape[-2]
|
|
314
|
+
channel_names = list(channel_names) if channel_names is not None else list(range(nvars))
|
|
315
|
+
if pairs is None:
|
|
316
|
+
pairs = list(combinations(range(nvars), 2))
|
|
317
|
+
pairs = list(pairs)
|
|
318
|
+
|
|
319
|
+
results = Parallel(n_jobs=n_jobs, backend=joblib_backend)(
|
|
320
|
+
delayed(_pair_time_domain_gc)(X, fs, i, j, model, spectral_method, backend,
|
|
321
|
+
Niterations, tol, spectral_params, ensure_stability,
|
|
322
|
+
order, f)
|
|
323
|
+
for i, j in pairs
|
|
324
|
+
)
|
|
325
|
+
|
|
326
|
+
F = np.full([nvars, nvars], np.nan)
|
|
327
|
+
for (i, j), (Ix2y, Iy2x) in zip(pairs, results):
|
|
328
|
+
F[i, j] = Ix2y
|
|
329
|
+
F[j, i] = Iy2x
|
|
330
|
+
|
|
331
|
+
return build_conditional_gc_dataset(F, channel_names)
|
|
332
|
+
|
|
333
|
+
|
|
334
|
+
def spectral_granger_causality(X, fs, pairs=None, model='nonparametric', order=None, f=None,
|
|
335
|
+
spectral_method='fourier', backend='numpy', Niterations=100,
|
|
336
|
+
tol=1e-12, verbose=False, spectral_params=None,
|
|
337
|
+
ensure_stability=True, n_jobs=-1, joblib_backend='loky'):
|
|
338
|
+
"""Frequency-domain Granger Causality for one or more channel pairs.
|
|
339
|
+
|
|
340
|
+
Parameters
|
|
341
|
+
----------
|
|
342
|
+
... (existing params unchanged) ...
|
|
343
|
+
model : {'nonparametric', 'parametric'} — estimation family for S, H, Z.
|
|
344
|
+
'nonparametric' uses `_compute_csd` + Wilson factorization
|
|
345
|
+
(existing behaviour, default). 'parametric' fits a VAR model via
|
|
346
|
+
Yule-Walker and derives H, S analytically — `spectral_method`,
|
|
347
|
+
`backend`, `Niterations`, `tol`, `ensure_stability` are ignored
|
|
348
|
+
in this case.
|
|
349
|
+
order : int or None — VAR model order, required when model='parametric'.
|
|
350
|
+
f : ndarray or None — frequency axis to evaluate on when
|
|
351
|
+
model='parametric'. If None, derived from data length via
|
|
352
|
+
`compute_freq`. Ignored when model='nonparametric' (f comes
|
|
353
|
+
from the spectral estimator instead).
|
|
354
|
+
|
|
355
|
+
Returns
|
|
356
|
+
-------
|
|
357
|
+
ds : xarray.Dataset — see `build_granger_dataset`.
|
|
358
|
+
"""
|
|
359
|
+
S, H, Z, f = _full_estimate(X, fs, model, spectral_method, backend, Niterations,
|
|
360
|
+
tol, verbose, spectral_params, ensure_stability, order, f)
|
|
361
|
+
|
|
362
|
+
if pairs is None:
|
|
363
|
+
pairs = list(combinations(range(S.shape[0]), 2))
|
|
364
|
+
pairs = list(pairs)
|
|
365
|
+
|
|
366
|
+
if len(pairs) == 1:
|
|
367
|
+
i, j = pairs[0]
|
|
368
|
+
results = [_gc(S, H, Z, i, j)]
|
|
369
|
+
else:
|
|
370
|
+
results = _gc_pairs(S, H, Z, pairs, n_jobs=n_jobs, backend=joblib_backend)
|
|
371
|
+
|
|
372
|
+
Ix2y, Iy2x, Ixy = (np.stack(vals) for vals in zip(*results))
|
|
373
|
+
|
|
374
|
+
return build_granger_dataset(Ix2y, Iy2x, Ixy, pairs, f)
|
|
375
|
+
|
|
376
|
+
|
|
377
|
+
def conditional_granger_causality(X, fs, targets=None, channel_names=None,
|
|
378
|
+
model='nonparametric', order=None, f=None,
|
|
379
|
+
spectral_method='fourier', backend='numpy',
|
|
380
|
+
Niterations=100, tol=1e-12, verbose=True,
|
|
381
|
+
n_jobs=1, spectral_params=None,
|
|
382
|
+
ensure_stability=True):
|
|
383
|
+
"""Conditional Granger Causality (time-domain summary).
|
|
384
|
+
|
|
385
|
+
Parameters
|
|
386
|
+
----------
|
|
387
|
+
... (existing params unchanged) ...
|
|
388
|
+
model : {'nonparametric', 'parametric'} — see `granger_causality`. For
|
|
389
|
+
'parametric', each reduced model is refit from raw X with the
|
|
390
|
+
target channel removed (slicing AR coefficients would not give
|
|
391
|
+
the correct reduced VAR model), so `n_jobs` parallelism still
|
|
392
|
+
applies but is comparatively more expensive per target.
|
|
393
|
+
order : int or None — VAR model order, required when model='parametric'.
|
|
394
|
+
f : ndarray or None — frequency axis for the parametric transfer
|
|
395
|
+
function evaluation; not otherwise used by this function's
|
|
396
|
+
output (F is frequency-independent) but affects numerical
|
|
397
|
+
conditioning of `compute_transfer_function`. If None, derived
|
|
398
|
+
from data length.
|
|
399
|
+
|
|
400
|
+
Returns
|
|
401
|
+
-------
|
|
402
|
+
ds : xarray.Dataset — see `build_conditional_gc_dataset`.
|
|
403
|
+
"""
|
|
404
|
+
S, _, Znew, f = _full_estimate(X, fs, model, spectral_method, backend, Niterations,
|
|
405
|
+
tol, verbose, spectral_params, ensure_stability, order, f)
|
|
406
|
+
|
|
407
|
+
nvars = S.shape[0]
|
|
408
|
+
targets = range(nvars) if targets is None else list(targets)
|
|
409
|
+
channel_names = list(channel_names) if channel_names is not None else list(range(nvars))
|
|
410
|
+
if len(channel_names) != nvars:
|
|
411
|
+
raise ValueError(f"channel_names has length {len(channel_names)}, expected {nvars}")
|
|
412
|
+
|
|
413
|
+
LSIG = np.log(np.diag(Znew))
|
|
414
|
+
|
|
415
|
+
def _reduced(j):
|
|
416
|
+
_, Z_diag_j = _reduced_estimate(X, S, j, fs, model, spectral_method, backend,
|
|
417
|
+
Niterations, tol, spectral_params,
|
|
418
|
+
ensure_stability, order, f)
|
|
419
|
+
return j, np.log(Z_diag_j)
|
|
420
|
+
|
|
421
|
+
results: list = Parallel(n_jobs=n_jobs, prefer='threads')( # type: ignore[assignment]
|
|
422
|
+
delayed(_reduced)(j) for j in targets
|
|
423
|
+
)
|
|
424
|
+
|
|
425
|
+
F = np.zeros([nvars, nvars])
|
|
426
|
+
for j, LSIGj in results:
|
|
427
|
+
j0 = np.concatenate((np.arange(0, j), np.arange(j + 1, nvars)))
|
|
428
|
+
for ii, i in enumerate(j0):
|
|
429
|
+
F[i, j] = LSIGj[ii] - LSIG[i]
|
|
430
|
+
return build_conditional_gc_dataset(F, channel_names)
|
|
431
|
+
|
|
432
|
+
|
|
433
|
+
def spectral_conditional_granger_causality(X, fs, targets=None, channel_names=None,
|
|
434
|
+
model='nonparametric', order=None, f=None,
|
|
435
|
+
spectral_method='fourier', backend='numpy',
|
|
436
|
+
Niterations=100, tol=1e-12, verbose=True,
|
|
437
|
+
n_jobs=1, spectral_params=None,
|
|
438
|
+
ensure_stability=True):
|
|
439
|
+
"""Conditional spectral Granger Causality.
|
|
440
|
+
|
|
441
|
+
Parameters
|
|
442
|
+
----------
|
|
443
|
+
... (existing params unchanged) ...
|
|
444
|
+
model : {'nonparametric', 'parametric'} — see `granger_causality`.
|
|
445
|
+
order : int or None — VAR model order, required when model='parametric'.
|
|
446
|
+
f : ndarray or None — frequency axis, required to be consistent
|
|
447
|
+
between full and reduced models. If None, derived from data
|
|
448
|
+
length for model='parametric'.
|
|
449
|
+
|
|
450
|
+
Returns
|
|
451
|
+
-------
|
|
452
|
+
ds : xarray.Dataset — see `build_conditional_spec_gc_dataset`.
|
|
453
|
+
"""
|
|
454
|
+
S, Hnew, Znew, f = _full_estimate(X, fs, model, spectral_method, backend, Niterations,
|
|
455
|
+
tol, verbose, spectral_params, ensure_stability, order, f)
|
|
456
|
+
|
|
457
|
+
nvars = S.shape[0]
|
|
458
|
+
targets = range(nvars) if targets is None else list(targets)
|
|
459
|
+
channel_names = list(channel_names) if channel_names is not None else list(range(nvars))
|
|
460
|
+
if len(channel_names) != nvars:
|
|
461
|
+
raise ValueError(f"channel_names has length {len(channel_names)}, expected {nvars}")
|
|
462
|
+
|
|
463
|
+
def _reduced(j):
|
|
464
|
+
Hij, SIGj = _reduced_estimate(X, S, j, fs, model, spectral_method, backend,
|
|
465
|
+
Niterations, tol, spectral_params,
|
|
466
|
+
ensure_stability, order, f)
|
|
467
|
+
return j, Hij, SIGj
|
|
468
|
+
|
|
469
|
+
results: list = Parallel(n_jobs=n_jobs, prefer='threads')( # type: ignore[assignment]
|
|
470
|
+
delayed(_reduced)(j) for j in targets
|
|
471
|
+
)
|
|
472
|
+
|
|
473
|
+
GC = np.zeros([nvars, nvars, len(f)])
|
|
474
|
+
for j, Hij, SIGj in results:
|
|
475
|
+
logger.debug('j = %d', j)
|
|
476
|
+
j0 = np.concatenate((np.arange(0, j), np.arange(j + 1, nvars)))
|
|
477
|
+
|
|
478
|
+
G = np.zeros([nvars, nvars, len(f)], dtype=complex)
|
|
479
|
+
for i in range(len(f)):
|
|
480
|
+
aux = np.insert(Hij[:, :, i], j, np.zeros(nvars - 1), axis=1)
|
|
481
|
+
aux = np.insert(aux, j, np.zeros(nvars), axis=0)
|
|
482
|
+
G[:, :, i] = aux
|
|
483
|
+
G[j, j, :] = 1
|
|
484
|
+
|
|
485
|
+
G_T = G.transpose(2, 0, 1)
|
|
486
|
+
Hnew_T = Hnew.transpose(2, 0, 1)
|
|
487
|
+
Q_T = np.linalg.inv(G_T) @ Hnew_T
|
|
488
|
+
|
|
489
|
+
for ii, i in enumerate(j0):
|
|
490
|
+
div = Q_T[:, i, i] * Znew[i, i] * np.conj(Q_T[:, i, i])
|
|
491
|
+
GC[j, i, :] = np.log(SIGj[ii] / np.abs(div))
|
|
492
|
+
|
|
493
|
+
return build_conditional_spec_gc_dataset(GC, f, channel_names)
|
pygc/misc/__init__.py
ADDED
|
@@ -0,0 +1 @@
|
|
|
1
|
+
from . import smooth_spectra
|
|
@@ -0,0 +1,15 @@
|
|
|
1
|
+
import scipy.signal
|
|
2
|
+
import numpy as np
|
|
3
|
+
|
|
4
|
+
def smooth_spectra(spectra, win_time, win_freq, fft=False, axes = 0):
|
|
5
|
+
if len(spectra.shape) == 2:
|
|
6
|
+
kernel = np.ones([win_freq, win_time])
|
|
7
|
+
elif len(spectra.shape) == 3:
|
|
8
|
+
kernel = np.ones([1, win_freq, win_time])
|
|
9
|
+
|
|
10
|
+
#print(kernel.shape)
|
|
11
|
+
|
|
12
|
+
if fft == True:
|
|
13
|
+
return scipy.signal.fftconvolve(spectra, kernel, mode='same', axes= axes)
|
|
14
|
+
else:
|
|
15
|
+
return scipy.signal.convolve2d( spectra, kernel, mode='same', axes = axes)
|
pygc/non_parametric.py
ADDED
|
@@ -0,0 +1,107 @@
|
|
|
1
|
+
########################################################################################
|
|
2
|
+
# Module with functions for non-parametric estimation of GC
|
|
3
|
+
########################################################################################
|
|
4
|
+
import numpy as np
|
|
5
|
+
|
|
6
|
+
|
|
7
|
+
def wilson_factorization(S, freq, fs, Niterations=100, tol=1e-12, verbose=True, ensure_stability=True):
|
|
8
|
+
"""Wilson spectral factorization of the cross-spectral matrix.
|
|
9
|
+
|
|
10
|
+
All frequency-indexed loops are replaced with batched NumPy operations
|
|
11
|
+
(batch `linalg.inv`, batch `matmul`, axis-wise FFT) for a ~16× speedup
|
|
12
|
+
over the original Python-loop implementation.
|
|
13
|
+
|
|
14
|
+
Parameters
|
|
15
|
+
----------
|
|
16
|
+
S : ndarray (m, m, N+1) — one-sided cross-spectral matrix.
|
|
17
|
+
freq : ndarray (N+1,) — frequency axis.
|
|
18
|
+
fs : float — sampling rate.
|
|
19
|
+
Niterations : int — maximum number of iterations.
|
|
20
|
+
tol : float — convergence tolerance on the matrix 1-norm.
|
|
21
|
+
verbose : bool — print error at each iteration.
|
|
22
|
+
|
|
23
|
+
Returns
|
|
24
|
+
-------
|
|
25
|
+
Snew : ndarray (m, m, N+1) — reconstructed spectral matrix.
|
|
26
|
+
Hnew : ndarray (m, m, N+1) — transfer function.
|
|
27
|
+
Znew : ndarray (m, m) — noise covariance.
|
|
28
|
+
"""
|
|
29
|
+
m = S.shape[0]
|
|
30
|
+
N = freq.shape[0] - 1
|
|
31
|
+
freq_len = len(freq) # N+1
|
|
32
|
+
|
|
33
|
+
# ------------------------------------------------------------------ #
|
|
34
|
+
# Build double-length Hermitian spectrum Sarr of shape (m, m, 2N) #
|
|
35
|
+
# ------------------------------------------------------------------ #
|
|
36
|
+
Sarr = np.zeros([m, m, 2 * N], dtype=complex)
|
|
37
|
+
Sarr[:, :, :N + 1] = S
|
|
38
|
+
for k in range(1, N):
|
|
39
|
+
Sarr[:, :, 2 * N - k] = S[:, :, k].T # Hermitian mirror
|
|
40
|
+
|
|
41
|
+
# ------------------------------------------------------------------ #
|
|
42
|
+
# Diagonal Regularization Fix #
|
|
43
|
+
# ------------------------------------------------------------------ #
|
|
44
|
+
# Add a microscopic regularization term to the diagonal to prevent
|
|
45
|
+
# singular matrices across all BLAS/LAPACK backends.
|
|
46
|
+
if ensure_stability:
|
|
47
|
+
eps = np.finfo(float).eps * np.abs(Sarr).max() * 100
|
|
48
|
+
for i in range(m):
|
|
49
|
+
Sarr[i, i, :] += eps
|
|
50
|
+
|
|
51
|
+
# ------------------------------------------------------------------ #
|
|
52
|
+
# Initialise psi from Cholesky of gam0 #
|
|
53
|
+
# ------------------------------------------------------------------ #
|
|
54
|
+
gam0 = np.fft.ifft(Sarr, axis=2).real[:, :, 0] # (m, m)
|
|
55
|
+
h = np.linalg.cholesky(gam0).T # upper triangular
|
|
56
|
+
psi = np.tile(h[:, :, np.newaxis], (1, 1, 2 * N)).astype(complex)
|
|
57
|
+
|
|
58
|
+
I = np.eye(m)
|
|
59
|
+
Sarr_T = Sarr.transpose(2, 0, 1) # (2N, m, m) — precomputed
|
|
60
|
+
|
|
61
|
+
# ------------------------------------------------------------------ #
|
|
62
|
+
# Iteration loop #
|
|
63
|
+
# ------------------------------------------------------------------ #
|
|
64
|
+
for _ in range(Niterations):
|
|
65
|
+
# Batch: psi^{-1} @ Sarr @ conj(psi^{-1}).H + I for all freqs
|
|
66
|
+
psi_T = psi.transpose(2, 0, 1) # (2N, m, m)
|
|
67
|
+
psi_inv = np.linalg.inv(psi_T) # (2N, m, m)
|
|
68
|
+
g_T = psi_inv @ Sarr_T @ np.conj(psi_inv).swapaxes(-1, -2) + I
|
|
69
|
+
g = g_T.transpose(1, 2, 0) # (m, m, 2N)
|
|
70
|
+
|
|
71
|
+
# Plus operator — single batch FFT/IFFT
|
|
72
|
+
gam = np.fft.ifft(g, axis=2)
|
|
73
|
+
gamp = gam.copy()
|
|
74
|
+
gamp[:, :, 0] = np.triu(0.5 * gam[:, :, 0])
|
|
75
|
+
gamp[:, :, freq_len:] = 0
|
|
76
|
+
gp = np.fft.fft(gamp, axis=2) # (m, m, 2N)
|
|
77
|
+
|
|
78
|
+
# Update psi and measure convergence (matrix 1-norm averaged over freqs)
|
|
79
|
+
gp_T = gp.transpose(2, 0, 1) # (2N, m, m)
|
|
80
|
+
new_psi_T = psi_T @ gp_T # (2N, m, m)
|
|
81
|
+
diff = new_psi_T - psi_T
|
|
82
|
+
psierr = np.abs(diff).sum(axis=-2).max(axis=-1).mean()
|
|
83
|
+
psi = new_psi_T.transpose(1, 2, 0) # (m, m, 2N)
|
|
84
|
+
|
|
85
|
+
if psierr < tol:
|
|
86
|
+
break
|
|
87
|
+
if verbose:
|
|
88
|
+
print(f'Err = {psierr}')
|
|
89
|
+
|
|
90
|
+
# ------------------------------------------------------------------ #
|
|
91
|
+
# Compute outputs — fully vectorised #
|
|
92
|
+
# ------------------------------------------------------------------ #
|
|
93
|
+
psi_T = psi.transpose(2, 0, 1) # (2N, m, m)
|
|
94
|
+
|
|
95
|
+
# Reconstructed spectrum
|
|
96
|
+
Snew = (psi_T[:N + 1] @ np.conj(psi_T[:N + 1]).swapaxes(-1, -2)).transpose(1, 2, 0)
|
|
97
|
+
|
|
98
|
+
# Noise covariance
|
|
99
|
+
gamtmp = np.fft.ifft(psi, axis=2).real # (m, m, 2N)
|
|
100
|
+
A0 = gamtmp[:, :, 0]
|
|
101
|
+
A0inv = np.linalg.inv(A0)
|
|
102
|
+
Znew = (A0 @ A0.T).real
|
|
103
|
+
|
|
104
|
+
# Transfer function
|
|
105
|
+
Hnew = (psi_T[:N + 1] @ A0inv[np.newaxis]).transpose(1, 2, 0)
|
|
106
|
+
|
|
107
|
+
return Snew, Hnew, Znew
|