pygranger 2.0.0__tar.gz

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.
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2019 Vinícius Lima
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in all
13
+ copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21
+ SOFTWARE.
@@ -0,0 +1,239 @@
1
+ Metadata-Version: 2.4
2
+ Name: pygranger
3
+ Version: 2.0.0
4
+ Summary: Granger Causality in the frequency domain — parametric and non-parametric estimation
5
+ Author-email: Vinicius Lima <vinicius.lima.cordeiro@gmail.com>
6
+ License-Expression: MIT
7
+ Project-URL: Homepage, https://github.com/ViniciusLima94/pyGC
8
+ Project-URL: Source, https://github.com/ViniciusLima94/pyGC
9
+ Project-URL: Issues, https://github.com/ViniciusLima94/pyGC/issues
10
+ Requires-Python: >=3.10
11
+ Description-Content-Type: text/markdown
12
+ License-File: LICENSE
13
+ Requires-Dist: numpy>=1.24
14
+ Requires-Dist: scipy>=1.10
15
+ Requires-Dist: matplotlib>=3.7
16
+ Requires-Dist: mne>=1.4
17
+ Requires-Dist: joblib>=1.3
18
+ Requires-Dist: h5py>=3.9
19
+ Requires-Dist: xarray>=2025.4.0
20
+ Provides-Extra: dev
21
+ Requires-Dist: pytest>=7.4; extra == "dev"
22
+ Requires-Dist: pytest-cov>=4.1; extra == "dev"
23
+ Provides-Extra: notebooks
24
+ Requires-Dist: jupyter>=1.0; extra == "notebooks"
25
+ Requires-Dist: ipykernel>=6.25; extra == "notebooks"
26
+ Requires-Dist: h5netcdf>=1.0; extra == "notebooks"
27
+ Provides-Extra: jax
28
+ Requires-Dist: jax>=0.4; extra == "jax"
29
+ Dynamic: license-file
30
+
31
+ # pyGC — Granger Causality in the Frequency Domain
32
+
33
+ [![PyPI](https://img.shields.io/pypi/v/pygranger.svg)](https://pypi.org/project/pygranger/)
34
+ [![License: MIT](https://img.shields.io/badge/License-MIT-yellow.svg)](https://opensource.org/licenses/MIT)
35
+ [![Python 3.10+](https://img.shields.io/badge/python-3.10%2B-blue.svg)](https://www.python.org/)
36
+
37
+ A Python package for estimating Granger Causality (GC) in the frequency domain, supporting multiple spectral estimators (Fourier, Welch, Morlet, multitaper) and both NumPy and JAX/XLA backends.
38
+
39
+ If you use this package, please cite:
40
+
41
+ ---
42
+
43
+ ## Installation
44
+
45
+ The package is published on PyPI as `pygranger` and imported as `pygc`:
46
+
47
+ ```bash
48
+ pip install pygranger
49
+ ```
50
+
51
+ ```python
52
+ import pygc
53
+ ```
54
+
55
+ Optional extras:
56
+
57
+ ```bash
58
+ pip install "pygranger[jax]" # JAX/XLA GPU-accelerated backend
59
+ pip install "pygranger[notebooks]" # Jupyter notebooks
60
+ ```
61
+
62
+ For development, install from a clone in editable mode:
63
+
64
+ ```bash
65
+ git clone https://github.com/ViniciusLima94/pyGC.git
66
+ cd pyGC
67
+ pip install -e ".[dev]" # pytest + coverage
68
+ ```
69
+
70
+ ---
71
+
72
+ ## Package structure
73
+
74
+ ```
75
+ pygc/
76
+ ├── __init__.py # Public API
77
+ ├── parametric.py # Yule-Walker VAR fitting + transfer function
78
+ ├── non_parametric.py # Wilson spectral factorization
79
+ ├── granger.py # Bivariate GC, conditional GC (time + spectral)
80
+ ├── output.py # Packages results into labeled xarray.Dataset objects
81
+ ├── ar_model.py # Synthetic AR benchmarks (Dhamala, Baccalá)
82
+ ├── _jax_backend.py # Optional JAX/XLA Wilson factorization
83
+ ├── spectral_analysis/
84
+ │ ├── fourier.py # compute_freq, CSD, Morlet transforms (MNE)
85
+ │ └── time_frequency.py # Welch, multitaper, wavelet/Gabor transforms
86
+ └── misc/
87
+ └── smooth_spectra.py # smooth_spectra, downsample utilities
88
+ ```
89
+
90
+ ---
91
+
92
+ ## Usage
93
+
94
+ All main functions accept raw signal data `X` and a sampling rate `fs`. The cross-spectral density is computed internally via the chosen `spectral_method`. Every GC function returns an `xarray.Dataset` (see `output.py`) rather than raw arrays, so results come back labeled and ready to index by name.
95
+
96
+ > **Data shape convention:** the GC functions expect `X` as `(trials, nvars, N)` (or `(nvars, N)` for a single trial).
97
+
98
+ ### Bivariate GC
99
+
100
+ ```python
101
+ import numpy as np
102
+ from pygc import spectral_granger_causality
103
+ from pygc import ar_model
104
+
105
+ Fs = 200
106
+ # Dhamala benchmark: Y drives X at 40 Hz
107
+ data = ar_model.ar_model_dhamala(N=5000, Trials=20, Fs=Fs, C=0.25)
108
+ # data shape: (2, Trials, N) -> transpose to (Trials, 2, N) for the GC functions
109
+ data = np.transpose(data, (1, 0, 2))
110
+
111
+ # Non-parametric (Fourier CSD + Wilson factorization) — default
112
+ ds = spectral_granger_causality(data, fs=Fs, spectral_method='fourier', pairs=[(0, 1)])
113
+ # ds.y2x peaks at ~40 Hz; ds.x2y is near zero
114
+ peak_freq = ds.freq.values[ds.y2x.values[0].argmax()]
115
+ ```
116
+
117
+ `spectral_granger_causality` returns a frequency-resolved `Dataset` with `x2y`, `y2x`, and `xy` variables (dims `pairs`, `freq`). For a single scalar per direction per pair instead (the broadband/time-domain summary), use `granger_causality`, which returns a `Dataset` with a single `F` variable (dims `source`, `target`):
118
+
119
+ ```python
120
+ from pygc import granger_causality
121
+
122
+ ds_f = granger_causality(data, fs=Fs, spectral_method='fourier')
123
+ # ds_f.F[1, 0] -> broadband GC from channel 1 (Y) to channel 0 (X)
124
+ ```
125
+
126
+ Available spectral methods: `'fourier'` (default), `'welch'`, `'morlet'`, `'multitaper'`.
127
+
128
+ ```python
129
+ # Welch CSD
130
+ ds = spectral_granger_causality(
131
+ data, fs=Fs, spectral_method='welch',
132
+ spectral_params={'nperseg': 512}
133
+ )
134
+
135
+ # Morlet CSD (requires explicit frequency axis)
136
+ freqs = np.linspace(1, 80, 80)
137
+ ds = spectral_granger_causality(
138
+ data, fs=Fs, spectral_method='morlet',
139
+ spectral_params={'freqs': freqs, 'n_cycles': 7.0}
140
+ )
141
+
142
+ # Multitaper CSD
143
+ ds = spectral_granger_causality(
144
+ data, fs=Fs, spectral_method='multitaper',
145
+ spectral_params={'bandwidth': 4.0}
146
+ )
147
+ ```
148
+
149
+ ### Parametric estimation (Yule-Walker)
150
+
151
+ Use `YuleWalker` and `compute_transfer_function` to obtain the VAR-based cross-spectral matrix:
152
+
153
+ ```python
154
+ import numpy as np
155
+ from pygc import YuleWalker, compute_transfer_function
156
+ from pygc.spectral_analysis import compute_freq
157
+ from pygc import ar_model
158
+
159
+ Fs = 200
160
+ data = ar_model.ar_model_dhamala(N=5000, Trials=20, Fs=Fs, C=0.25)
161
+ # data shape: (2, Trials, N) — YuleWalker takes (nvars, N) per trial, so no
162
+ # transpose is needed here (unlike the GC functions above).
163
+
164
+ f = compute_freq(data.shape[2], Fs)
165
+ m = 2 # VAR model order
166
+ AR = np.zeros((m, 2, 2))
167
+ SIG = np.zeros((2, 2))
168
+
169
+ for trial in range(data.shape[1]):
170
+ a, s = YuleWalker(data[:, trial, :], m)
171
+ AR += a / data.shape[1]
172
+ SIG += s / data.shape[1]
173
+
174
+ H, S = compute_transfer_function(AR, SIG, f, Fs)
175
+ # H: (2, 2, n_freq) transfer function
176
+ # S: (2, 2, n_freq) cross-spectral matrix
177
+ ```
178
+
179
+ `YuleWalker_multitrial` is also available if you'd rather not average the per-trial fits by hand.
180
+
181
+ ### Conditional GC (multivariate, p ≥ 3)
182
+
183
+ ```python
184
+ import numpy as np
185
+ from pygc import conditional_granger_causality, spectral_conditional_granger_causality
186
+ from pygc import ar_model
187
+
188
+ Fs = 200
189
+ data = ar_model.ar_model_baccala(nvars=5, N=3000, ntrials=20)
190
+ # data shape: (nvars, N, trials) -> transpose to (trials, nvars, N)
191
+ data = np.transpose(data, (2, 0, 1))
192
+
193
+ # Time-domain conditional GC — Dataset with an (nvars, nvars) 'F' matrix
194
+ ds_f = conditional_granger_causality(data, fs=Fs, n_jobs=-1)
195
+
196
+ # Spectral conditional GC — Dataset with an (nvars, nvars, n_freq) 'GC' array
197
+ ds_gc = spectral_conditional_granger_causality(data, fs=Fs, n_jobs=-1)
198
+ ```
199
+
200
+ Both functions support the same `spectral_method` and `spectral_params` arguments as `spectral_granger_causality`.
201
+
202
+ ### JAX/XLA accelerated backend
203
+
204
+ ```python
205
+ from pygc import spectral_granger_causality, JAX_AVAILABLE
206
+
207
+ if JAX_AVAILABLE:
208
+ ds = spectral_granger_causality(data, fs=Fs, backend='jax')
209
+ ```
210
+
211
+ ### `ensure_stability` parameter
212
+
213
+ The GC functions accept `ensure_stability=True` (default), which clips near-zero or negative diagonal entries of the noise covariance after Wilson factorization to improve numerical stability.
214
+
215
+ ---
216
+
217
+ ## Testing
218
+
219
+ ```bash
220
+ pytest # run all 38 tests
221
+ pytest --cov=pygc # with coverage report
222
+ ```
223
+
224
+ ---
225
+
226
+ ## Example notebooks
227
+
228
+ See `notebooks/` for end-to-end worked examples:
229
+
230
+ - `01_basic_granger_causality.ipynb` — bivariate GC on the Dhamala benchmark
231
+ - `02_spectral_analysis.ipynb` — cross-spectral and time-frequency estimation (Fourier, Welch, multitaper, Morlet)
232
+ - `03_conditional_granger_causality.ipynb` — time-domain and spectral conditional GC on the 5-variable Baccalá model
233
+ - `04_benchmarks.ipynb` — performance benchmarks across backends and model sizes
234
+ - `05_spectral_methods_comparison.ipynb` — comparison of Fourier, Welch, Morlet, and multitaper estimators
235
+ - `06_example_with_eletrophysiological_data.ipynb` — GC applied to real electrophysiological recordings
236
+
237
+ ## Hardware used to run notebooks
238
+
239
+ Apple M4 Pro with 48 GB RAM
@@ -0,0 +1,209 @@
1
+ # pyGC — Granger Causality in the Frequency Domain
2
+
3
+ [![PyPI](https://img.shields.io/pypi/v/pygranger.svg)](https://pypi.org/project/pygranger/)
4
+ [![License: MIT](https://img.shields.io/badge/License-MIT-yellow.svg)](https://opensource.org/licenses/MIT)
5
+ [![Python 3.10+](https://img.shields.io/badge/python-3.10%2B-blue.svg)](https://www.python.org/)
6
+
7
+ A Python package for estimating Granger Causality (GC) in the frequency domain, supporting multiple spectral estimators (Fourier, Welch, Morlet, multitaper) and both NumPy and JAX/XLA backends.
8
+
9
+ If you use this package, please cite:
10
+
11
+ ---
12
+
13
+ ## Installation
14
+
15
+ The package is published on PyPI as `pygranger` and imported as `pygc`:
16
+
17
+ ```bash
18
+ pip install pygranger
19
+ ```
20
+
21
+ ```python
22
+ import pygc
23
+ ```
24
+
25
+ Optional extras:
26
+
27
+ ```bash
28
+ pip install "pygranger[jax]" # JAX/XLA GPU-accelerated backend
29
+ pip install "pygranger[notebooks]" # Jupyter notebooks
30
+ ```
31
+
32
+ For development, install from a clone in editable mode:
33
+
34
+ ```bash
35
+ git clone https://github.com/ViniciusLima94/pyGC.git
36
+ cd pyGC
37
+ pip install -e ".[dev]" # pytest + coverage
38
+ ```
39
+
40
+ ---
41
+
42
+ ## Package structure
43
+
44
+ ```
45
+ pygc/
46
+ ├── __init__.py # Public API
47
+ ├── parametric.py # Yule-Walker VAR fitting + transfer function
48
+ ├── non_parametric.py # Wilson spectral factorization
49
+ ├── granger.py # Bivariate GC, conditional GC (time + spectral)
50
+ ├── output.py # Packages results into labeled xarray.Dataset objects
51
+ ├── ar_model.py # Synthetic AR benchmarks (Dhamala, Baccalá)
52
+ ├── _jax_backend.py # Optional JAX/XLA Wilson factorization
53
+ ├── spectral_analysis/
54
+ │ ├── fourier.py # compute_freq, CSD, Morlet transforms (MNE)
55
+ │ └── time_frequency.py # Welch, multitaper, wavelet/Gabor transforms
56
+ └── misc/
57
+ └── smooth_spectra.py # smooth_spectra, downsample utilities
58
+ ```
59
+
60
+ ---
61
+
62
+ ## Usage
63
+
64
+ All main functions accept raw signal data `X` and a sampling rate `fs`. The cross-spectral density is computed internally via the chosen `spectral_method`. Every GC function returns an `xarray.Dataset` (see `output.py`) rather than raw arrays, so results come back labeled and ready to index by name.
65
+
66
+ > **Data shape convention:** the GC functions expect `X` as `(trials, nvars, N)` (or `(nvars, N)` for a single trial).
67
+
68
+ ### Bivariate GC
69
+
70
+ ```python
71
+ import numpy as np
72
+ from pygc import spectral_granger_causality
73
+ from pygc import ar_model
74
+
75
+ Fs = 200
76
+ # Dhamala benchmark: Y drives X at 40 Hz
77
+ data = ar_model.ar_model_dhamala(N=5000, Trials=20, Fs=Fs, C=0.25)
78
+ # data shape: (2, Trials, N) -> transpose to (Trials, 2, N) for the GC functions
79
+ data = np.transpose(data, (1, 0, 2))
80
+
81
+ # Non-parametric (Fourier CSD + Wilson factorization) — default
82
+ ds = spectral_granger_causality(data, fs=Fs, spectral_method='fourier', pairs=[(0, 1)])
83
+ # ds.y2x peaks at ~40 Hz; ds.x2y is near zero
84
+ peak_freq = ds.freq.values[ds.y2x.values[0].argmax()]
85
+ ```
86
+
87
+ `spectral_granger_causality` returns a frequency-resolved `Dataset` with `x2y`, `y2x`, and `xy` variables (dims `pairs`, `freq`). For a single scalar per direction per pair instead (the broadband/time-domain summary), use `granger_causality`, which returns a `Dataset` with a single `F` variable (dims `source`, `target`):
88
+
89
+ ```python
90
+ from pygc import granger_causality
91
+
92
+ ds_f = granger_causality(data, fs=Fs, spectral_method='fourier')
93
+ # ds_f.F[1, 0] -> broadband GC from channel 1 (Y) to channel 0 (X)
94
+ ```
95
+
96
+ Available spectral methods: `'fourier'` (default), `'welch'`, `'morlet'`, `'multitaper'`.
97
+
98
+ ```python
99
+ # Welch CSD
100
+ ds = spectral_granger_causality(
101
+ data, fs=Fs, spectral_method='welch',
102
+ spectral_params={'nperseg': 512}
103
+ )
104
+
105
+ # Morlet CSD (requires explicit frequency axis)
106
+ freqs = np.linspace(1, 80, 80)
107
+ ds = spectral_granger_causality(
108
+ data, fs=Fs, spectral_method='morlet',
109
+ spectral_params={'freqs': freqs, 'n_cycles': 7.0}
110
+ )
111
+
112
+ # Multitaper CSD
113
+ ds = spectral_granger_causality(
114
+ data, fs=Fs, spectral_method='multitaper',
115
+ spectral_params={'bandwidth': 4.0}
116
+ )
117
+ ```
118
+
119
+ ### Parametric estimation (Yule-Walker)
120
+
121
+ Use `YuleWalker` and `compute_transfer_function` to obtain the VAR-based cross-spectral matrix:
122
+
123
+ ```python
124
+ import numpy as np
125
+ from pygc import YuleWalker, compute_transfer_function
126
+ from pygc.spectral_analysis import compute_freq
127
+ from pygc import ar_model
128
+
129
+ Fs = 200
130
+ data = ar_model.ar_model_dhamala(N=5000, Trials=20, Fs=Fs, C=0.25)
131
+ # data shape: (2, Trials, N) — YuleWalker takes (nvars, N) per trial, so no
132
+ # transpose is needed here (unlike the GC functions above).
133
+
134
+ f = compute_freq(data.shape[2], Fs)
135
+ m = 2 # VAR model order
136
+ AR = np.zeros((m, 2, 2))
137
+ SIG = np.zeros((2, 2))
138
+
139
+ for trial in range(data.shape[1]):
140
+ a, s = YuleWalker(data[:, trial, :], m)
141
+ AR += a / data.shape[1]
142
+ SIG += s / data.shape[1]
143
+
144
+ H, S = compute_transfer_function(AR, SIG, f, Fs)
145
+ # H: (2, 2, n_freq) transfer function
146
+ # S: (2, 2, n_freq) cross-spectral matrix
147
+ ```
148
+
149
+ `YuleWalker_multitrial` is also available if you'd rather not average the per-trial fits by hand.
150
+
151
+ ### Conditional GC (multivariate, p ≥ 3)
152
+
153
+ ```python
154
+ import numpy as np
155
+ from pygc import conditional_granger_causality, spectral_conditional_granger_causality
156
+ from pygc import ar_model
157
+
158
+ Fs = 200
159
+ data = ar_model.ar_model_baccala(nvars=5, N=3000, ntrials=20)
160
+ # data shape: (nvars, N, trials) -> transpose to (trials, nvars, N)
161
+ data = np.transpose(data, (2, 0, 1))
162
+
163
+ # Time-domain conditional GC — Dataset with an (nvars, nvars) 'F' matrix
164
+ ds_f = conditional_granger_causality(data, fs=Fs, n_jobs=-1)
165
+
166
+ # Spectral conditional GC — Dataset with an (nvars, nvars, n_freq) 'GC' array
167
+ ds_gc = spectral_conditional_granger_causality(data, fs=Fs, n_jobs=-1)
168
+ ```
169
+
170
+ Both functions support the same `spectral_method` and `spectral_params` arguments as `spectral_granger_causality`.
171
+
172
+ ### JAX/XLA accelerated backend
173
+
174
+ ```python
175
+ from pygc import spectral_granger_causality, JAX_AVAILABLE
176
+
177
+ if JAX_AVAILABLE:
178
+ ds = spectral_granger_causality(data, fs=Fs, backend='jax')
179
+ ```
180
+
181
+ ### `ensure_stability` parameter
182
+
183
+ The GC functions accept `ensure_stability=True` (default), which clips near-zero or negative diagonal entries of the noise covariance after Wilson factorization to improve numerical stability.
184
+
185
+ ---
186
+
187
+ ## Testing
188
+
189
+ ```bash
190
+ pytest # run all 38 tests
191
+ pytest --cov=pygc # with coverage report
192
+ ```
193
+
194
+ ---
195
+
196
+ ## Example notebooks
197
+
198
+ See `notebooks/` for end-to-end worked examples:
199
+
200
+ - `01_basic_granger_causality.ipynb` — bivariate GC on the Dhamala benchmark
201
+ - `02_spectral_analysis.ipynb` — cross-spectral and time-frequency estimation (Fourier, Welch, multitaper, Morlet)
202
+ - `03_conditional_granger_causality.ipynb` — time-domain and spectral conditional GC on the 5-variable Baccalá model
203
+ - `04_benchmarks.ipynb` — performance benchmarks across backends and model sizes
204
+ - `05_spectral_methods_comparison.ipynb` — comparison of Fourier, Welch, Morlet, and multitaper estimators
205
+ - `06_example_with_eletrophysiological_data.ipynb` — GC applied to real electrophysiological recordings
206
+
207
+ ## Hardware used to run notebooks
208
+
209
+ Apple M4 Pro with 48 GB RAM
@@ -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
+ ]
@@ -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
+ )