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 ADDED
@@ -0,0 +1,24 @@
1
+ from .parametric import YuleWalker, YuleWalker_multitrial, compute_transfer_function
2
+ from .non_parametric import wilson_factorization
3
+ from .granger import granger_causality, spectral_granger_causality, conditional_granger_causality, spectral_conditional_granger_causality #noqa
4
+ from ._jax_backend import JAX_AVAILABLE, JAX_FLOAT64, wilson_factorization_jax
5
+ from .output import build_granger_dataset, build_conditional_gc_dataset, build_conditional_spec_gc_dataset
6
+
7
+ __version__ = "2.0.0"
8
+
9
+ __all__ = [
10
+ "YuleWalker",
11
+ "YuleWalker_multitrial",
12
+ "compute_transfer_function",
13
+ "wilson_factorization",
14
+ "wilson_factorization_jax",
15
+ "JAX_AVAILABLE",
16
+ "JAX_FLOAT64",
17
+ "granger_causality",
18
+ "spectral_granger_causality",
19
+ "conditional_granger_causality",
20
+ "spectral_conditional_granger_causality",
21
+ "build_granger_dataset",
22
+ "build_conditional_gc_dataset",
23
+ "build_conditional_spec_gc_dataset"
24
+ ]
pygc/_jax_backend.py ADDED
@@ -0,0 +1,200 @@
1
+ """
2
+ Optional JAX backend for pygc.
3
+
4
+ Provides a JIT-compiled Wilson spectral factorization that uses XLA kernels
5
+ (CPU or GPU) for batched matrix operations. All loops — including the
6
+ convergence-checked iteration — are compiled with ``jax.lax.while_loop``
7
+ so no Python overhead remains after the first (tracing) call.
8
+
9
+ GPU support
10
+ -----------
11
+ * **Apple Silicon (Metal)**: ``pip install jax-metal``
12
+ Metal does not support float64, so the computation runs in complex64
13
+ (float32 precision). Accuracy is lower but throughput is higher.
14
+ * **NVIDIA (CUDA)**: install the appropriate ``jax[cuda]`` wheel.
15
+ Full float64 / complex128 precision is available.
16
+
17
+ Usage
18
+ -----
19
+ from pygc._jax_backend import wilson_factorization_jax, JAX_AVAILABLE, JAX_FLOAT64
20
+ """
21
+ from __future__ import annotations
22
+
23
+ import functools
24
+ from typing import Any
25
+ import numpy as np
26
+
27
+ # Pre-bind to None so the names are always defined; typed as Any so that
28
+ # attribute access inside `if JAX_AVAILABLE:` blocks is not flagged.
29
+ jax: Any = None
30
+ jnp: Any = None
31
+ lax: Any = None
32
+
33
+ try:
34
+ import jax
35
+ jax.config.update("jax_enable_x64", True) # no-op on Metal, active on CPU/CUDA
36
+ import jax.numpy as jnp
37
+ from jax import lax
38
+ JAX_AVAILABLE: bool = True
39
+
40
+ # Metal GPU supports only float32; CPU and CUDA support float64.
41
+ # Metal raises ValueError on float64 ops instead of silently downcasting.
42
+ try:
43
+ _probe = jnp.ones(1, dtype=jnp.float64)
44
+ JAX_FLOAT64: bool = bool(_probe.dtype == jnp.float64)
45
+ del _probe
46
+ except (ValueError, TypeError):
47
+ JAX_FLOAT64 = False
48
+
49
+ # Choose working dtypes based on device capability.
50
+ _NP_CDTYPE = np.complex128 if JAX_FLOAT64 else np.complex64
51
+ _JNP_CDTYPE = jnp.complex128 if JAX_FLOAT64 else jnp.complex64
52
+
53
+ except ImportError: # pragma: no cover
54
+ JAX_AVAILABLE = False
55
+ JAX_FLOAT64 = False
56
+ _NP_CDTYPE = None
57
+ _JNP_CDTYPE = None
58
+
59
+
60
+ # ---------------------------------------------------------------------------
61
+ # Internal helpers (only defined when JAX is present)
62
+ # ---------------------------------------------------------------------------
63
+
64
+ if JAX_AVAILABLE:
65
+
66
+ def _plus_op(g: Any, freq_len: int) -> Any:
67
+ """Plus-operator: batch IFFT → zero causal half → batch FFT."""
68
+ gam = jnp.fft.ifft(g, axis=2)
69
+ gamp = (gam
70
+ .at[:, :, 0].set(jnp.triu(0.5 * gam[:, :, 0]))
71
+ .at[:, :, freq_len:].set(0.0 + 0.0j))
72
+ return jnp.fft.fft(gamp, axis=2)
73
+
74
+ @functools.partial(jax.jit, static_argnames=["freq_len", "Niterations"])
75
+ def _wilson_loop(
76
+ psi: Any,
77
+ Sarr: Any,
78
+ I: Any,
79
+ freq_len: int,
80
+ Niterations: int,
81
+ tol: float,
82
+ ) -> Any:
83
+ """Full Wilson iteration loop compiled to XLA via lax.while_loop."""
84
+
85
+ def body(carry: tuple) -> tuple:
86
+ psi, _, itr = carry
87
+ psi_T = psi.transpose(2, 0, 1) # (2N, m, m)
88
+ psi_inv = jnp.linalg.inv(psi_T)
89
+ g_T = psi_inv @ Sarr.transpose(2, 0, 1) @ jnp.conj(psi_inv).swapaxes(-1, -2) + I
90
+ g = g_T.transpose(1, 2, 0)
91
+
92
+ gp = _plus_op(g, freq_len)
93
+
94
+ new_psi_T = psi_T @ gp.transpose(2, 0, 1)
95
+ diff = new_psi_T - psi_T
96
+ psierr = jnp.abs(diff).sum(axis=-2).max(axis=-1).mean()
97
+
98
+ return new_psi_T.transpose(1, 2, 0), psierr, itr + 1
99
+
100
+ def cond(carry: tuple) -> Any:
101
+ _, psierr, itr = carry
102
+ return (psierr >= tol) & (itr < Niterations)
103
+
104
+ init = (psi, jnp.array(jnp.inf), jnp.int32(0))
105
+ psi_final, _, _ = lax.while_loop(cond, body, init)
106
+ return psi_final
107
+
108
+ def wilson_factorization_jax(
109
+ S: np.ndarray,
110
+ freq: np.ndarray,
111
+ fs: float,
112
+ Niterations: int = 100,
113
+ tol: float = 1e-12,
114
+ verbose: bool = False,
115
+ ensure_stability: bool = True,
116
+ ) -> tuple[np.ndarray, np.ndarray, np.ndarray]:
117
+ """Wilson spectral factorization using a JAX JIT-compiled loop.
118
+
119
+ The first call incurs XLA compilation time (~1–5 s); subsequent calls
120
+ with the same array shapes reuse the compiled kernel.
121
+
122
+ Precision is chosen automatically:
123
+ - **complex128** on CPU and NVIDIA GPU (float64 available).
124
+ - **complex64** on Apple Metal GPU (float64 not supported).
125
+
126
+ Parameters
127
+ ----------
128
+ S : ndarray (m, m, N+1) — cross-spectral matrix.
129
+ freq : ndarray (N+1,) — frequency axis.
130
+ fs : float — sampling rate (unused; kept for API compat).
131
+ Niterations : int.
132
+ tol : float.
133
+ verbose : bool — print JAX device / precision info.
134
+
135
+ Returns
136
+ -------
137
+ Snew, Hnew : ndarray (m, m, N+1)
138
+ Znew : ndarray (m, m)
139
+ """
140
+ m = S.shape[0]
141
+ N = freq.shape[0] - 1
142
+ freq_len = len(freq)
143
+
144
+ if verbose:
145
+ prec = "complex128 (float64)" if JAX_FLOAT64 else "complex64 (float32, Metal GPU)"
146
+ print(f'[JAX] devices: {jax.devices()} precision: {prec}')
147
+
148
+ # Build Hermitian-extended Sarr (numpy; done once outside JIT)
149
+ Sarr_np = np.zeros([m, m, 2 * N], dtype=_NP_CDTYPE)
150
+ Sarr_np[:, :, :N + 1] = S
151
+ for k in range(1, N):
152
+ Sarr_np[:, :, 2 * N - k] = S[:, :, k].T
153
+
154
+ # Diagonal regularization fix
155
+ if ensure_stability:
156
+ eps = np.finfo(float).eps * np.abs(Sarr_np).max() * 100
157
+ for i in range(m):
158
+ Sarr_np[i, i, :] += eps
159
+
160
+ # Initialise psi from Cholesky of gam0.
161
+ # A small relative regularisation guards against float32 rounding that
162
+ # can make gam0 appear numerically indefinite on Metal GPU.
163
+ gam0 = np.fft.ifft(Sarr_np, axis=2).real[:, :, 0]
164
+ eps = np.finfo(gam0.dtype).eps * np.abs(np.diag(gam0)).max() * 10
165
+ gam0 += np.eye(m) * eps
166
+ h = np.linalg.cholesky(gam0).T
167
+ psi_np = np.tile(h[:, :, np.newaxis], (1, 1, 2 * N)).astype(_NP_CDTYPE)
168
+
169
+ # Move to JAX arrays and run compiled loop
170
+ psi = jnp.array(psi_np)
171
+ Sarr = jnp.array(Sarr_np)
172
+ I_jax = jnp.eye(m, dtype=_JNP_CDTYPE)[jnp.newaxis] # (1, m, m)
173
+
174
+ psi = _wilson_loop(psi, Sarr, I_jax, freq_len, Niterations, tol)
175
+ psi = np.array(psi) # back to NumPy for output
176
+
177
+ # Compute outputs (NumPy; trivially fast)
178
+ psi_T = psi.transpose(2, 0, 1)
179
+ Snew = (psi_T[:N + 1] @ np.conj(psi_T[:N + 1]).swapaxes(-1, -2)).transpose(1, 2, 0)
180
+ gamtmp = np.fft.ifft(psi, axis=2).real
181
+ A0 = gamtmp[:, :, 0]
182
+ A0inv = np.linalg.inv(A0)
183
+ Znew = (A0 @ A0.T).real
184
+ Hnew = (psi_T[:N + 1] @ A0inv[np.newaxis]).transpose(1, 2, 0)
185
+
186
+ return Snew, Hnew, Znew
187
+
188
+ else: # JAX not available — stub that raises a helpful error # pragma: no cover
189
+
190
+ def wilson_factorization_jax( # type: ignore[misc]
191
+ S: np.ndarray,
192
+ freq: np.ndarray,
193
+ fs: float,
194
+ Niterations: int = 100,
195
+ tol: float = 1e-12,
196
+ verbose: bool = False,
197
+ ) -> tuple[np.ndarray, np.ndarray, np.ndarray]:
198
+ raise ImportError(
199
+ "JAX is not installed. Install it with: pip install jax"
200
+ )
pygc/ar_model.py ADDED
@@ -0,0 +1,81 @@
1
+ import numpy as np
2
+
3
+
4
+ def ar_model_dhamala(N=5000, Trials=10, Fs=200, C=0.2, t_start=0, t_stop=None, cov=None):
5
+ """
6
+ Bivariate AR model from Dhamala et al. (2008).
7
+
8
+ Y drives X with coupling strength C in the interval [t_start, t_stop].
9
+ GC(Y->X) > 0, GC(X->Y) ≈ 0.
10
+
11
+ Parameters
12
+ ----------
13
+ N : int — number of time samples per trial.
14
+ Trials : int — number of independent trials.
15
+ Fs : float — sampling rate (Hz).
16
+ C : float — coupling coefficient from Y to X.
17
+ t_start, t_stop : float — time interval (seconds) during which coupling is active.
18
+ cov : ndarray, shape (2,2) — noise covariance matrix.
19
+
20
+ Returns
21
+ -------
22
+ Z : ndarray, shape (2, Trials, N)
23
+ """
24
+ if cov is None:
25
+ cov = np.eye(2)
26
+
27
+ T = N / Fs
28
+ time = np.linspace(0, T, N)
29
+
30
+ X = np.random.random([Trials, N])
31
+ Y = np.random.random([Trials, N])
32
+
33
+ def _active(t):
34
+ if t_stop is None:
35
+ return t >= t_start
36
+ return (t >= t_start) & (t <= t_stop)
37
+
38
+ for i in range(Trials):
39
+ E = np.random.multivariate_normal(np.zeros(2), cov, size=(N,))
40
+ for t in range(2, N):
41
+ X[i, t] = (0.55 * X[i, t - 1] - 0.8 * X[i, t - 2]
42
+ + _active(time[t]) * C * Y[i, t - 1] + E[t, 0])
43
+ Y[i, t] = 0.55 * Y[i, t - 1] - 0.8 * Y[i, t - 2] + E[t, 1]
44
+
45
+ Z = np.zeros([2, Trials, N])
46
+ Z[0] = X
47
+ Z[1] = Y
48
+ return Z
49
+
50
+
51
+ def ar_model_baccala(nvars=5, N=1000, ntrials=1):
52
+ """
53
+ Five-variable AR model from Baccalá & Sameshima (2001).
54
+
55
+ Connectivity: X1 → X2, X1 → X3, X1 → X4, X1 → X5 (via X4).
56
+
57
+ Parameters
58
+ ----------
59
+ nvars : int — number of variables (must be 5).
60
+ N : int — number of time samples per trial.
61
+ ntrials : int — number of trials.
62
+
63
+ Returns
64
+ -------
65
+ Y : ndarray, shape (nvars, N, ntrials)
66
+ """
67
+ Y = np.random.uniform(size=(nvars, N, ntrials))
68
+ w = np.random.normal(0, 1, size=(nvars, N, ntrials))
69
+
70
+ for i in range(ntrials):
71
+ for t in range(5, N):
72
+ Y[0, t, i] = (0.95 * np.sqrt(2.0) * Y[0, t - 1, i]
73
+ - 0.9025 * Y[0, t - 2, i] + w[0, t, i])
74
+ Y[1, t, i] = 0.5 * Y[0, t - 2, i] + w[1, t, i]
75
+ Y[2, t, i] = -0.4 * Y[0, t - 3, i] + w[2, t, i]
76
+ Y[3, t, i] = (-0.5 * Y[0, t - 2, i]
77
+ + 0.25 * np.sqrt(2.0) * Y[3, t - 1, i]
78
+ + 0.25 * np.sqrt(2.0) * Y[4, t - 1, i] + w[3, t, i])
79
+ Y[4, t, i] = (-0.25 * np.sqrt(2.0) * Y[3, t - 1, i]
80
+ + 0.25 * np.sqrt(2.0) * Y[4, t - 1, i] + w[4, t, i])
81
+ return Y