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.
- pygranger-2.0.0/LICENSE +21 -0
- pygranger-2.0.0/PKG-INFO +239 -0
- pygranger-2.0.0/README.md +209 -0
- pygranger-2.0.0/pygc/__init__.py +24 -0
- pygranger-2.0.0/pygc/_jax_backend.py +200 -0
- pygranger-2.0.0/pygc/ar_model.py +81 -0
- pygranger-2.0.0/pygc/granger.py +493 -0
- pygranger-2.0.0/pygc/misc/__init__.py +1 -0
- pygranger-2.0.0/pygc/misc/smooth_spectra.py +15 -0
- pygranger-2.0.0/pygc/non_parametric.py +107 -0
- pygranger-2.0.0/pygc/output.py +115 -0
- pygranger-2.0.0/pygc/parametric.py +130 -0
- pygranger-2.0.0/pygc/spectral_analysis/__init__.py +24 -0
- pygranger-2.0.0/pygc/spectral_analysis/fourier.py +79 -0
- pygranger-2.0.0/pygc/spectral_analysis/time_frequency.py +196 -0
- pygranger-2.0.0/pygranger.egg-info/PKG-INFO +239 -0
- pygranger-2.0.0/pygranger.egg-info/SOURCES.txt +25 -0
- pygranger-2.0.0/pygranger.egg-info/dependency_links.txt +1 -0
- pygranger-2.0.0/pygranger.egg-info/requires.txt +19 -0
- pygranger-2.0.0/pygranger.egg-info/top_level.txt +1 -0
- pygranger-2.0.0/pyproject.toml +49 -0
- pygranger-2.0.0/setup.cfg +4 -0
- pygranger-2.0.0/tests/test_granger.py +99 -0
- pygranger-2.0.0/tests/test_jax_backend.py +49 -0
- pygranger-2.0.0/tests/test_non_parametric.py +50 -0
- pygranger-2.0.0/tests/test_parametric.py +67 -0
- pygranger-2.0.0/tests/test_spectral.py +127 -0
pygranger-2.0.0/LICENSE
ADDED
|
@@ -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.
|
pygranger-2.0.0/PKG-INFO
ADDED
|
@@ -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
|
+
[](https://pypi.org/project/pygranger/)
|
|
34
|
+
[](https://opensource.org/licenses/MIT)
|
|
35
|
+
[](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
|
+
[](https://pypi.org/project/pygranger/)
|
|
4
|
+
[](https://opensource.org/licenses/MIT)
|
|
5
|
+
[](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
|
+
)
|