dirconn 0.1.0__py3-none-any.whl
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- dirconn/__init__.py +130 -0
- dirconn/_csd.py +244 -0
- dirconn/_greedy.py +290 -0
- dirconn/_ksg.py +253 -0
- dirconn/_resampling.py +109 -0
- dirconn/analytic.py +401 -0
- dirconn/bands.py +25 -0
- dirconn/baseline.py +149 -0
- dirconn/benchmark.py +562 -0
- dirconn/bids.py +707 -0
- dirconn/bootstrap.py +271 -0
- dirconn/channels.py +63 -0
- dirconn/coherency.py +211 -0
- dirconn/contrast.py +375 -0
- dirconn/correction.py +94 -0
- dirconn/datasets.py +107 -0
- dirconn/emptyroom.py +114 -0
- dirconn/estimator.py +534 -0
- dirconn/evaluation.py +909 -0
- dirconn/exceptions.py +10 -0
- dirconn/granger.py +243 -0
- dirconn/headmodel.py +346 -0
- dirconn/inverse.py +373 -0
- dirconn/leakage.py +278 -0
- dirconn/multitaper_granger.py +287 -0
- dirconn/mvar.py +539 -0
- dirconn/network.py +453 -0
- dirconn/noise_study.py +137 -0
- dirconn/nonstationary.py +864 -0
- dirconn/order_stability.py +170 -0
- dirconn/pcmci.py +265 -0
- dirconn/plotting.py +411 -0
- dirconn/protocol.py +1570 -0
- dirconn/provenance.py +340 -0
- dirconn/py.typed +0 -0
- dirconn/regions.py +230 -0
- dirconn/report.py +671 -0
- dirconn/result.py +637 -0
- dirconn/rng.py +51 -0
- dirconn/scenario.py +585 -0
- dirconn/scenarios/band_alpha.yaml +18 -0
- dirconn/scenarios/band_beta.yaml +18 -0
- dirconn/scenarios/band_gamma.yaml +18 -0
- dirconn/scenarios/band_theta.yaml +18 -0
- dirconn/scenarios/bidirectional.yaml +22 -0
- dirconn/scenarios/burst.yaml +22 -0
- dirconn/scenarios/chain.yaml +20 -0
- dirconn/scenarios/collider.yaml +20 -0
- dirconn/scenarios/common_driver.yaml +20 -0
- dirconn/scenarios/delay_1.yaml +17 -0
- dirconn/scenarios/delay_5.yaml +17 -0
- dirconn/scenarios/delay_zero_osc.yaml +17 -0
- dirconn/scenarios/direction_flip.yaml +24 -0
- dirconn/scenarios/distance_far.yaml +17 -0
- dirconn/scenarios/distance_mid.yaml +17 -0
- dirconn/scenarios/distance_near.yaml +17 -0
- dirconn/scenarios/feedback_loop.yaml +22 -0
- dirconn/scenarios/hidden_common_input.yaml +24 -0
- dirconn/scenarios/leakage_adjacent.yaml +20 -0
- dirconn/scenarios/random_10.yaml +179 -0
- dirconn/scenarios/random_20.yaml +339 -0
- dirconn/scenarios/random_5.yaml +101 -0
- dirconn/scenarios/stimulation_artifact.yaml +28 -0
- dirconn/scenarios/switch_off.yaml +22 -0
- dirconn/scenarios/switch_on.yaml +23 -0
- dirconn/significance.py +153 -0
- dirconn/significance_study.py +411 -0
- dirconn/simulation.py +1057 -0
- dirconn/spectral.py +480 -0
- dirconn/spectral_estimators.py +357 -0
- dirconn/surrogate_test.py +264 -0
- dirconn/surrogates.py +394 -0
- dirconn/sweep.py +444 -0
- dirconn/time_reversal.py +215 -0
- dirconn/timeseries.py +330 -0
- dirconn/timevarying.py +325 -0
- dirconn/transfer_entropy.py +373 -0
- dirconn/tvmvar.py +938 -0
- dirconn/validation.py +590 -0
- dirconn/viz.py +420 -0
- dirconn/windowed.py +291 -0
- dirconn-0.1.0.dist-info/METADATA +212 -0
- dirconn-0.1.0.dist-info/RECORD +85 -0
- dirconn-0.1.0.dist-info/WHEEL +4 -0
- dirconn-0.1.0.dist-info/licenses/LICENSE +14 -0
dirconn/__init__.py
ADDED
|
@@ -0,0 +1,130 @@
|
|
|
1
|
+
"""dirconn: directed functional connectivity for neurophysiology signals.
|
|
2
|
+
|
|
3
|
+
The core package depends on numpy, scipy, pandas, xarray, netcdf4, mne and mne-bids. MNE is
|
|
4
|
+
imported lazily by the modules that need it; tigramite, statsmodels, mne-connectivity and
|
|
5
|
+
plotting libraries are optional extras.
|
|
6
|
+
"""
|
|
7
|
+
|
|
8
|
+
from importlib import metadata
|
|
9
|
+
|
|
10
|
+
from dirconn import datasets, viz
|
|
11
|
+
from dirconn.analytic import (
|
|
12
|
+
AnalyticTestResult,
|
|
13
|
+
AsymptoticTestResult,
|
|
14
|
+
analytic_gc_test,
|
|
15
|
+
analytic_pdc_test,
|
|
16
|
+
analytic_rpdc_test,
|
|
17
|
+
)
|
|
18
|
+
from dirconn.bands import BANDS
|
|
19
|
+
from dirconn.baseline import baseline_normalise
|
|
20
|
+
from dirconn.bids import (
|
|
21
|
+
SimulationRecord,
|
|
22
|
+
read_bids,
|
|
23
|
+
read_simulation_bids,
|
|
24
|
+
write_roi_bids,
|
|
25
|
+
write_simulation_bids,
|
|
26
|
+
)
|
|
27
|
+
from dirconn.bootstrap import BootstrapResult, bootstrap
|
|
28
|
+
from dirconn.channels import ChannelMeta
|
|
29
|
+
from dirconn.coherency import PSI, ImCoh, imcoh, psi
|
|
30
|
+
from dirconn.contrast import ContrastResult, contrast_test
|
|
31
|
+
from dirconn.correction import adjust_pvalues
|
|
32
|
+
from dirconn.estimator import Estimator, MethodInfo
|
|
33
|
+
from dirconn.exceptions import DirconnWarning
|
|
34
|
+
from dirconn.granger import GC, gc
|
|
35
|
+
from dirconn.multitaper_granger import MTGC, mtgc
|
|
36
|
+
from dirconn.mvar import OrderSelection, VarFit, fit_var, select_order
|
|
37
|
+
from dirconn.network import Edge, Network, direct_node, dk_node
|
|
38
|
+
from dirconn.pcmci import PCMCI, PCMCITestResult, pcmci, pcmci_test
|
|
39
|
+
from dirconn.provenance import AssumptionCheck, AssumptionChecklist, Provenance
|
|
40
|
+
from dirconn.result import Connectivity
|
|
41
|
+
from dirconn.significance import significance_test
|
|
42
|
+
from dirconn.simulation import NodeDynamics, SimulatedData, coupled_nonlinear, simulate
|
|
43
|
+
from dirconn.spectral_estimators import DTF, GPDC, PDC, RPDC, dtf, gpdc, pdc, rpdc
|
|
44
|
+
from dirconn.surrogate_test import SurrogateTestResult, surrogate_test
|
|
45
|
+
from dirconn.time_reversal import TRGC, TimeReversalAsymmetry, time_reversal_asymmetry, trgc
|
|
46
|
+
from dirconn.timeseries import TimeSeries
|
|
47
|
+
from dirconn.timevarying import ADTF, TPDC, adtf, tpdc
|
|
48
|
+
from dirconn.transfer_entropy import TE, te
|
|
49
|
+
from dirconn.windowed import WDTF, wdtf
|
|
50
|
+
|
|
51
|
+
__version__: str = metadata.version("dirconn")
|
|
52
|
+
|
|
53
|
+
__all__ = [
|
|
54
|
+
"ADTF",
|
|
55
|
+
"BANDS",
|
|
56
|
+
"DTF",
|
|
57
|
+
"GPDC",
|
|
58
|
+
"MTGC",
|
|
59
|
+
"PCMCI",
|
|
60
|
+
"PCMCITestResult",
|
|
61
|
+
"PDC",
|
|
62
|
+
"PSI",
|
|
63
|
+
"RPDC",
|
|
64
|
+
"TPDC",
|
|
65
|
+
"TRGC",
|
|
66
|
+
"WDTF",
|
|
67
|
+
"AnalyticTestResult",
|
|
68
|
+
"AsymptoticTestResult",
|
|
69
|
+
"AssumptionCheck",
|
|
70
|
+
"AssumptionChecklist",
|
|
71
|
+
"BootstrapResult",
|
|
72
|
+
"ChannelMeta",
|
|
73
|
+
"ContrastResult",
|
|
74
|
+
"Connectivity",
|
|
75
|
+
"DirconnWarning",
|
|
76
|
+
"Edge",
|
|
77
|
+
"Estimator",
|
|
78
|
+
"GC",
|
|
79
|
+
"ImCoh",
|
|
80
|
+
"MethodInfo",
|
|
81
|
+
"Network",
|
|
82
|
+
"NodeDynamics",
|
|
83
|
+
"OrderSelection",
|
|
84
|
+
"Provenance",
|
|
85
|
+
"SimulatedData",
|
|
86
|
+
"SimulationRecord",
|
|
87
|
+
"SurrogateTestResult",
|
|
88
|
+
"TE",
|
|
89
|
+
"TimeReversalAsymmetry",
|
|
90
|
+
"TimeSeries",
|
|
91
|
+
"VarFit",
|
|
92
|
+
"__version__",
|
|
93
|
+
"datasets",
|
|
94
|
+
"adjust_pvalues",
|
|
95
|
+
"adtf",
|
|
96
|
+
"analytic_gc_test",
|
|
97
|
+
"analytic_pdc_test",
|
|
98
|
+
"analytic_rpdc_test",
|
|
99
|
+
"baseline_normalise",
|
|
100
|
+
"bootstrap",
|
|
101
|
+
"contrast_test",
|
|
102
|
+
"coupled_nonlinear",
|
|
103
|
+
"direct_node",
|
|
104
|
+
"dk_node",
|
|
105
|
+
"dtf",
|
|
106
|
+
"fit_var",
|
|
107
|
+
"gc",
|
|
108
|
+
"gpdc",
|
|
109
|
+
"imcoh",
|
|
110
|
+
"mtgc",
|
|
111
|
+
"pcmci",
|
|
112
|
+
"pcmci_test",
|
|
113
|
+
"pdc",
|
|
114
|
+
"psi",
|
|
115
|
+
"read_bids",
|
|
116
|
+
"read_simulation_bids",
|
|
117
|
+
"rpdc",
|
|
118
|
+
"select_order",
|
|
119
|
+
"significance_test",
|
|
120
|
+
"simulate",
|
|
121
|
+
"surrogate_test",
|
|
122
|
+
"te",
|
|
123
|
+
"time_reversal_asymmetry",
|
|
124
|
+
"tpdc",
|
|
125
|
+
"trgc",
|
|
126
|
+
"viz",
|
|
127
|
+
"wdtf",
|
|
128
|
+
"write_roi_bids",
|
|
129
|
+
"write_simulation_bids",
|
|
130
|
+
]
|
dirconn/_csd.py
ADDED
|
@@ -0,0 +1,244 @@
|
|
|
1
|
+
"""Multitaper cross-spectra shared by the spectral methods (imcoh, PSI, multitaper Granger).
|
|
2
|
+
|
|
3
|
+
The spectra are an own multitaper transform on non-overlapping segments of ``segment_length``
|
|
4
|
+
seconds, with the tapers, weights and scaling of :func:`mne.time_frequency.csd_array_multitaper`
|
|
5
|
+
(``tests/test_csd.py`` checks equality on every frequency MNE returns). MNE is not called at run
|
|
6
|
+
time because it never returns 0 Hz, which the spectral factorisation of the multitaper Granger
|
|
7
|
+
causality needs (:func:`two_sided_spectra`). Continuous data must give ``segment_length``;
|
|
8
|
+
epoched data may (every trial is then cut on its own) or use every trial as one segment. No
|
|
9
|
+
segment crosses a trial boundary, and the tail that does not fill a segment is dropped and
|
|
10
|
+
recorded. The mean of every channel is removed within every segment, as MNE always does.
|
|
11
|
+
:func:`cross_spectra` returns the native FFT bins of the segment length between ``fmin`` and
|
|
12
|
+
``fmax`` without 0 Hz, as MNE does.
|
|
13
|
+
|
|
14
|
+
Convention: ``values[i, j, f] = <X_i(f) conj(X_j(f))>``, averaged over tapers and segments. When
|
|
15
|
+
channel ``j`` is channel ``i`` delayed by ``d`` samples, the phase of ``values[i, j, f]`` is
|
|
16
|
+
``+2 pi f d / sfreq`` (measured in ``tests/test_csd.py``).
|
|
17
|
+
"""
|
|
18
|
+
|
|
19
|
+
from __future__ import annotations
|
|
20
|
+
|
|
21
|
+
import math
|
|
22
|
+
from dataclasses import dataclass
|
|
23
|
+
from typing import Any
|
|
24
|
+
|
|
25
|
+
import numpy as np
|
|
26
|
+
|
|
27
|
+
from dirconn.timeseries import TimeSeries
|
|
28
|
+
from dirconn.tvmvar import seconds_to_samples
|
|
29
|
+
|
|
30
|
+
DEFAULT_TIME_HALFBANDWIDTH = 4.0
|
|
31
|
+
"""Default time-half-bandwidth product ``NW`` (MNE's default as well); about ``2 NW - 1`` tapers."""
|
|
32
|
+
|
|
33
|
+
_LOW_BIAS_RATIO = 0.9
|
|
34
|
+
_BACKEND = "own multitaper transform (scipy DPSS; tapers, weights and scaling as in MNE)"
|
|
35
|
+
|
|
36
|
+
|
|
37
|
+
@dataclass(frozen=True)
|
|
38
|
+
class CrossSpectra:
|
|
39
|
+
"""Cross-spectral density matrices on a frequency grid.
|
|
40
|
+
|
|
41
|
+
Attributes
|
|
42
|
+
----------
|
|
43
|
+
values : ndarray of complex, shape (n_channels, n_channels, n_freqs)
|
|
44
|
+
``values[i, j, f] = <X_i(f) conj(X_j(f))>``; Hermitian in ``(i, j)`` at every frequency.
|
|
45
|
+
freqs : ndarray, shape (n_freqs,)
|
|
46
|
+
Frequencies in Hz.
|
|
47
|
+
record : dict
|
|
48
|
+
Segments, tapers and bandwidth, for the provenance of the result that uses the spectra.
|
|
49
|
+
"""
|
|
50
|
+
|
|
51
|
+
values: np.ndarray
|
|
52
|
+
freqs: np.ndarray
|
|
53
|
+
record: dict[str, Any]
|
|
54
|
+
|
|
55
|
+
|
|
56
|
+
def _number(value: Any, name: str) -> float:
|
|
57
|
+
if (
|
|
58
|
+
isinstance(value, bool)
|
|
59
|
+
or not isinstance(value, (int, float, np.integer, np.floating))
|
|
60
|
+
or not math.isfinite(value)
|
|
61
|
+
):
|
|
62
|
+
raise ValueError(f"{name} must be a finite number, got {value!r}.")
|
|
63
|
+
return float(value)
|
|
64
|
+
|
|
65
|
+
|
|
66
|
+
def _segments(data: TimeSeries, segment_length: Any) -> tuple[np.ndarray, int, int]:
|
|
67
|
+
"""Return ``(segments, segment_samples, discarded_samples)``; segments are (n, ch, times)."""
|
|
68
|
+
if segment_length is None:
|
|
69
|
+
if data.segmentation == "epoched":
|
|
70
|
+
return np.asarray(data.data), data.n_times, 0
|
|
71
|
+
raise ValueError(
|
|
72
|
+
"segment_length is required for continuous data: it sets the frequency resolution "
|
|
73
|
+
"(1 / segment_length Hz), so it has no default."
|
|
74
|
+
)
|
|
75
|
+
seg = seconds_to_samples(
|
|
76
|
+
_number(segment_length, "segment_length"), data.sfreq, "segment_length"
|
|
77
|
+
)
|
|
78
|
+
per_trial = data.n_times // seg
|
|
79
|
+
if per_trial < 1:
|
|
80
|
+
where = "a trial" if data.segmentation == "epoched" else "the data"
|
|
81
|
+
raise ValueError(
|
|
82
|
+
f"segment_length ({segment_length!r} s, {seg} samples) is longer than {where} "
|
|
83
|
+
f"({data.n_times} samples)."
|
|
84
|
+
)
|
|
85
|
+
used = per_trial * seg
|
|
86
|
+
x = np.asarray(data.data)[:, :, :used]
|
|
87
|
+
# (trial, channel, segment, sample) -> (trial, segment, channel, sample): trials never mix
|
|
88
|
+
split = x.reshape(data.n_trials, data.n_channels, per_trial, seg).transpose(0, 2, 1, 3)
|
|
89
|
+
segments = split.reshape(data.n_trials * per_trial, data.n_channels, seg)
|
|
90
|
+
return segments, seg, data.n_trials * (data.n_times - used)
|
|
91
|
+
|
|
92
|
+
|
|
93
|
+
def _tapers(n_times: int, half_nbw: float) -> tuple[np.ndarray, np.ndarray]:
|
|
94
|
+
"""DPSS tapers and their eigenvalues, chosen as MNE does (``sym=False``, eigenvalue > 0.9)."""
|
|
95
|
+
from scipy.signal.windows import dpss
|
|
96
|
+
|
|
97
|
+
tapers, ratios = dpss(
|
|
98
|
+
n_times, half_nbw, Kmax=max(int(2 * half_nbw), 1), sym=False, norm=2, return_ratios=True
|
|
99
|
+
)
|
|
100
|
+
tapers, ratios = np.atleast_2d(tapers), np.atleast_1d(ratios)
|
|
101
|
+
keep = ratios > _LOW_BIAS_RATIO
|
|
102
|
+
return tapers[keep], ratios[keep]
|
|
103
|
+
|
|
104
|
+
|
|
105
|
+
def _prepare(
|
|
106
|
+
data: TimeSeries, segment_length: Any, time_halfbandwidth: Any
|
|
107
|
+
) -> tuple[np.ndarray, int, int, float, np.ndarray, np.ndarray]:
|
|
108
|
+
segments, seg, discarded = _segments(data, segment_length)
|
|
109
|
+
nw = _number(time_halfbandwidth, "time_halfbandwidth")
|
|
110
|
+
if nw < 1:
|
|
111
|
+
raise ValueError(f"time_halfbandwidth must be at least 1, got {time_halfbandwidth!r}.")
|
|
112
|
+
tapers, eigenvalues = _tapers(seg, nw)
|
|
113
|
+
if tapers.shape[0] < 1:
|
|
114
|
+
raise ValueError(f"time_halfbandwidth={nw:g} leaves no taper with low bias.")
|
|
115
|
+
return segments, seg, discarded, nw, tapers, eigenvalues
|
|
116
|
+
|
|
117
|
+
|
|
118
|
+
def _periodogram(
|
|
119
|
+
segments: np.ndarray,
|
|
120
|
+
tapers: np.ndarray,
|
|
121
|
+
eigenvalues: np.ndarray,
|
|
122
|
+
sfreq: float,
|
|
123
|
+
n_fft: int | None = None,
|
|
124
|
+
) -> np.ndarray:
|
|
125
|
+
"""Two-sided multitaper cross-periodogram on every rfft bin, averaged over segments.
|
|
126
|
+
|
|
127
|
+
``P[i, j, f] = sum_k w_k X_ik(f) conj(X_jk(f)) / sfreq`` with ``w_k`` the eigenvalues divided by
|
|
128
|
+
their sum (MNE's non-adaptive weights), after removing the mean of every segment. ``n_fft``
|
|
129
|
+
zero-pads the transform (default: the segment length).
|
|
130
|
+
"""
|
|
131
|
+
x = segments - segments.mean(axis=-1, keepdims=True)
|
|
132
|
+
tapered = x[:, :, np.newaxis, :] * tapers[np.newaxis, np.newaxis]
|
|
133
|
+
spectra = np.fft.rfft(tapered, n=n_fft, axis=-1)
|
|
134
|
+
weights = eigenvalues / eigenvalues.sum()
|
|
135
|
+
total = np.einsum("k,sikf,sjkf->ijf", weights, spectra, np.conj(spectra))
|
|
136
|
+
# exactly Hermitian, as MNE's upper-triangle computation is: otherwise rounding makes
|
|
137
|
+
# |imcoh[i, j]| and |imcoh[j, i]| differ in the last bit and breaks ties when scoring
|
|
138
|
+
total = 0.5 * (total + np.conj(np.swapaxes(total, 0, 1)))
|
|
139
|
+
return total / (segments.shape[0] * sfreq)
|
|
140
|
+
|
|
141
|
+
|
|
142
|
+
def _record(
|
|
143
|
+
data: TimeSeries, segments: np.ndarray, seg: int, discarded: int, nw: float, n_tapers: int
|
|
144
|
+
) -> dict[str, Any]:
|
|
145
|
+
return {
|
|
146
|
+
"backend": _BACKEND,
|
|
147
|
+
"segmentation": data.segmentation,
|
|
148
|
+
"segment_length_s": seg / data.sfreq,
|
|
149
|
+
"segment_samples": seg,
|
|
150
|
+
"n_segments": int(segments.shape[0]),
|
|
151
|
+
"discarded_samples": int(discarded),
|
|
152
|
+
"time_halfbandwidth": nw,
|
|
153
|
+
"bandwidth_hz": 2 * nw * data.sfreq / seg,
|
|
154
|
+
"n_tapers": n_tapers,
|
|
155
|
+
"frequency_resolution_hz": data.sfreq / seg,
|
|
156
|
+
"mean_removed_per_segment": True,
|
|
157
|
+
}
|
|
158
|
+
|
|
159
|
+
|
|
160
|
+
def cross_spectra(
|
|
161
|
+
data: TimeSeries,
|
|
162
|
+
*,
|
|
163
|
+
segment_length: float | None = None,
|
|
164
|
+
time_halfbandwidth: float = DEFAULT_TIME_HALFBANDWIDTH,
|
|
165
|
+
fmin: float = 0.0,
|
|
166
|
+
fmax: float | None = None,
|
|
167
|
+
) -> CrossSpectra:
|
|
168
|
+
"""Multitaper cross-spectra of all channel pairs, averaged over segments.
|
|
169
|
+
|
|
170
|
+
One-sided densities with the conventions of :func:`mne.time_frequency.csd_array_multitaper`
|
|
171
|
+
(equal to it on every frequency it returns, which ``tests/test_csd.py`` checks): 0 Hz is left
|
|
172
|
+
out and the Nyquist bin of an even segment is not doubled.
|
|
173
|
+
|
|
174
|
+
Parameters
|
|
175
|
+
----------
|
|
176
|
+
data : TimeSeries
|
|
177
|
+
Continuous or epoched input.
|
|
178
|
+
segment_length : float, optional
|
|
179
|
+
Segment length in seconds, a multiple of the sampling interval. Required for continuous
|
|
180
|
+
data; for epoched data every trial is cut on its own, and without it every trial is one
|
|
181
|
+
segment.
|
|
182
|
+
time_halfbandwidth : float
|
|
183
|
+
Time-half-bandwidth product ``NW`` (at least 1); the full bandwidth in Hz is
|
|
184
|
+
``2 NW / segment duration``.
|
|
185
|
+
fmin, fmax : float
|
|
186
|
+
Frequency range in Hz, inclusive; ``fmax`` defaults to the Nyquist frequency.
|
|
187
|
+
|
|
188
|
+
Returns
|
|
189
|
+
-------
|
|
190
|
+
CrossSpectra
|
|
191
|
+
"""
|
|
192
|
+
segments, seg, discarded, nw, tapers, eigenvalues = _prepare(
|
|
193
|
+
data, segment_length, time_halfbandwidth
|
|
194
|
+
)
|
|
195
|
+
nyquist = data.sfreq / 2
|
|
196
|
+
lo = _number(fmin, "fmin")
|
|
197
|
+
hi = nyquist if fmax is None else _number(fmax, "fmax")
|
|
198
|
+
if lo < 0:
|
|
199
|
+
raise ValueError(f"fmin must be non-negative, got {fmin!r}.")
|
|
200
|
+
if hi > nyquist:
|
|
201
|
+
raise ValueError(f"fmax ({fmax!r} Hz) is above the Nyquist frequency ({nyquist:g} Hz).")
|
|
202
|
+
if lo >= hi:
|
|
203
|
+
raise ValueError(f"fmin ({fmin!r}) must be below fmax ({hi!r}).")
|
|
204
|
+
bins = np.fft.rfftfreq(seg, 1.0 / data.sfreq)
|
|
205
|
+
keep = (bins > 0) & (bins >= lo) & (bins <= hi)
|
|
206
|
+
if not keep.any():
|
|
207
|
+
raise ValueError(
|
|
208
|
+
f"There is no frequency bin between fmin={lo:g} and fmax={hi:g} Hz at a resolution "
|
|
209
|
+
f"of {data.sfreq / seg:g} Hz (segment of {seg} samples)."
|
|
210
|
+
)
|
|
211
|
+
one_sided = 2 * _periodogram(segments, tapers, eigenvalues, data.sfreq)
|
|
212
|
+
if seg % 2 == 0:
|
|
213
|
+
one_sided[:, :, -1] /= 2 # the Nyquist bin has no mirror image, as in MNE
|
|
214
|
+
record = _record(data, segments, seg, discarded, nw, tapers.shape[0])
|
|
215
|
+
record.update({"fmin": lo, "fmax": hi, "two_sided": False})
|
|
216
|
+
return CrossSpectra(values=one_sided[:, :, keep], freqs=bins[keep], record=record)
|
|
217
|
+
|
|
218
|
+
|
|
219
|
+
def two_sided_spectra(
|
|
220
|
+
data: TimeSeries,
|
|
221
|
+
*,
|
|
222
|
+
segment_length: float | None = None,
|
|
223
|
+
time_halfbandwidth: float = DEFAULT_TIME_HALFBANDWIDTH,
|
|
224
|
+
pad_factor: int = 1,
|
|
225
|
+
) -> CrossSpectra:
|
|
226
|
+
"""Two-sided multitaper cross-spectra on every bin from 0 Hz to Nyquist, for factorisation.
|
|
227
|
+
|
|
228
|
+
``values[..., f]`` is the two-sided density at ``freqs[f]`` (half the one-sided one away from
|
|
229
|
+
0 Hz and Nyquist); the negative frequencies are its complex conjugate. Same segments and
|
|
230
|
+
tapers as :func:`cross_spectra`. ``pad_factor`` zero-pads every segment to that many times its
|
|
231
|
+
length, which samples the same spectrum on a finer grid (every ``pad_factor``-th bin is a
|
|
232
|
+
native bin); the record keeps ``n_fft``.
|
|
233
|
+
"""
|
|
234
|
+
if isinstance(pad_factor, bool) or not isinstance(pad_factor, int) or pad_factor < 1:
|
|
235
|
+
raise ValueError(f"pad_factor must be a positive integer, got {pad_factor!r}.")
|
|
236
|
+
segments, seg, discarded, nw, tapers, eigenvalues = _prepare(
|
|
237
|
+
data, segment_length, time_halfbandwidth
|
|
238
|
+
)
|
|
239
|
+
n_fft = seg * pad_factor
|
|
240
|
+
values = _periodogram(segments, tapers, eigenvalues, data.sfreq, n_fft)
|
|
241
|
+
record = _record(data, segments, seg, discarded, nw, tapers.shape[0])
|
|
242
|
+
record.update({"two_sided": True, "n_fft": n_fft})
|
|
243
|
+
freqs = np.fft.rfftfreq(n_fft, 1.0 / data.sfreq)
|
|
244
|
+
return CrossSpectra(values=values, freqs=freqs, record=record)
|
dirconn/_greedy.py
ADDED
|
@@ -0,0 +1,290 @@
|
|
|
1
|
+
"""Greedy selection of past variables with permutation tests, for multivariate transfer entropy.
|
|
2
|
+
|
|
3
|
+
For one target ``j`` the candidates are past samples ``(channel, lag)``: the target's own past at
|
|
4
|
+
``target_lags`` and every other channel at ``source_lags``. The selection follows the
|
|
5
|
+
multivariate transfer entropy of IDTxl (Lizier and Rubinov 2012; Novelli et al. 2019):
|
|
6
|
+
|
|
7
|
+
1. **Target past.** Add the candidate with the largest ``I(candidate; j_t | selected)`` while a
|
|
8
|
+
maximum-statistic permutation test finds it significant. When none is, the most recent target
|
|
9
|
+
sample is kept so the target's own memory is always conditioned on.
|
|
10
|
+
2. **Sources.** The same greedy loop over the source candidates, conditioning on everything
|
|
11
|
+
selected so far.
|
|
12
|
+
3. **Pruning.** Remove the selected source variable with the smallest
|
|
13
|
+
``I(variable; j_t | all other selected)`` while a minimum-statistic test finds it not
|
|
14
|
+
significant.
|
|
15
|
+
4. **Omnibus test.** ``I(all selected source variables; j_t | selected target past)`` against
|
|
16
|
+
permutations of the whole source set; when it fails, no source is kept.
|
|
17
|
+
5. **Sequential test per source.** For every source channel, the information of its selected
|
|
18
|
+
variables given everything else selected, tested in decreasing order against the ranked
|
|
19
|
+
maxima of the permutations (step-down, so the p-values never decrease along the order).
|
|
20
|
+
|
|
21
|
+
A permutation replaces the tested variables by their values at permuted rows. With enough trials
|
|
22
|
+
(``n_trials! >= n_permutations``) whole trials are permuted, otherwise every trial is shifted
|
|
23
|
+
circularly by an amount drawn between 10 % and 90 % of its rows. One permutation is shared by all
|
|
24
|
+
candidates of a test, so a maximum (or minimum) over candidates is a maximum over one surrogate
|
|
25
|
+
dataset. p-values are ``(count(null >= observed) + 1) / (n_permutations + 1)``; a test passes at
|
|
26
|
+
``p <= alpha``.
|
|
27
|
+
"""
|
|
28
|
+
|
|
29
|
+
from __future__ import annotations
|
|
30
|
+
|
|
31
|
+
import math
|
|
32
|
+
from collections.abc import Sequence
|
|
33
|
+
from dataclasses import dataclass
|
|
34
|
+
from typing import Any
|
|
35
|
+
|
|
36
|
+
import numpy as np
|
|
37
|
+
|
|
38
|
+
from dirconn._ksg import ksg_cmi_many
|
|
39
|
+
|
|
40
|
+
|
|
41
|
+
@dataclass(frozen=True)
|
|
42
|
+
class GreedySettings:
|
|
43
|
+
"""Everything the selection of one target needs, picklable for joblib."""
|
|
44
|
+
|
|
45
|
+
target_lags: tuple[int, ...]
|
|
46
|
+
source_lags: tuple[int, ...]
|
|
47
|
+
alpha: float
|
|
48
|
+
n_perm_max_stat: int
|
|
49
|
+
n_perm_min_stat: int
|
|
50
|
+
n_perm_omnibus: int
|
|
51
|
+
n_perm_max_seq: int
|
|
52
|
+
k: int
|
|
53
|
+
workers: int
|
|
54
|
+
device: str | None
|
|
55
|
+
dtype: str
|
|
56
|
+
|
|
57
|
+
@property
|
|
58
|
+
def start(self) -> int:
|
|
59
|
+
"""First usable sample of every trial: the longest lag of any candidate."""
|
|
60
|
+
return max(max(self.target_lags), max(self.source_lags))
|
|
61
|
+
|
|
62
|
+
|
|
63
|
+
Variable = tuple[int, int]
|
|
64
|
+
"""A past sample: ``(channel, lag)``."""
|
|
65
|
+
|
|
66
|
+
|
|
67
|
+
class _Problem:
|
|
68
|
+
"""The embedded data of one target and the CMI and permutation helpers."""
|
|
69
|
+
|
|
70
|
+
def __init__(
|
|
71
|
+
self, series: np.ndarray, target: int, settings: GreedySettings, rng: np.random.Generator
|
|
72
|
+
) -> None:
|
|
73
|
+
self.series = series
|
|
74
|
+
self.settings = settings
|
|
75
|
+
self.rng = rng
|
|
76
|
+
self.n_trials = series.shape[0]
|
|
77
|
+
self.rows = series.shape[-1] - settings.start
|
|
78
|
+
self.present = self.column((target, 0))
|
|
79
|
+
self._cache: dict[Variable, np.ndarray] = {}
|
|
80
|
+
|
|
81
|
+
def column(self, variable: Variable) -> np.ndarray:
|
|
82
|
+
channel, lag = variable
|
|
83
|
+
start, n_times = self.settings.start, self.series.shape[-1]
|
|
84
|
+
return self.series[:, channel, start - lag : n_times - lag].reshape(-1)
|
|
85
|
+
|
|
86
|
+
def values(self, variables: Sequence[Variable]) -> np.ndarray | None:
|
|
87
|
+
if not variables:
|
|
88
|
+
return None
|
|
89
|
+
out = []
|
|
90
|
+
for v in variables:
|
|
91
|
+
if v not in self._cache:
|
|
92
|
+
self._cache[v] = self.column(v)
|
|
93
|
+
out.append(self._cache[v])
|
|
94
|
+
return np.stack(out, axis=1)
|
|
95
|
+
|
|
96
|
+
def cmi(self, xs: Sequence[np.ndarray], condition: Sequence[Variable]) -> np.ndarray:
|
|
97
|
+
s = self.settings
|
|
98
|
+
return ksg_cmi_many(
|
|
99
|
+
xs,
|
|
100
|
+
self.present,
|
|
101
|
+
self.values(condition),
|
|
102
|
+
k=s.k,
|
|
103
|
+
workers=s.workers,
|
|
104
|
+
device=s.device,
|
|
105
|
+
dtype=s.dtype,
|
|
106
|
+
)
|
|
107
|
+
|
|
108
|
+
def permutations(self, n_perm: int) -> list[np.ndarray]:
|
|
109
|
+
rows, n_trials = self.rows, self.n_trials
|
|
110
|
+
offsets = np.arange(n_trials)[:, None] * rows
|
|
111
|
+
within = np.arange(rows)[None, :]
|
|
112
|
+
out = []
|
|
113
|
+
by_trial = n_trials > 1 and _factorial_at_least(n_trials, n_perm)
|
|
114
|
+
low, high = max(1, rows // 10), max(2, rows - rows // 10)
|
|
115
|
+
for _ in range(n_perm):
|
|
116
|
+
if by_trial:
|
|
117
|
+
order = self.rng.permutation(n_trials)[:, None]
|
|
118
|
+
out.append((order * rows + within).reshape(-1))
|
|
119
|
+
else:
|
|
120
|
+
shift = self.rng.integers(low, high, size=n_trials)[:, None]
|
|
121
|
+
out.append((offsets + (within + shift) % rows).reshape(-1))
|
|
122
|
+
return out
|
|
123
|
+
|
|
124
|
+
def scheme(self, n_perm: int) -> str:
|
|
125
|
+
if self.n_trials > 1 and _factorial_at_least(self.n_trials, n_perm):
|
|
126
|
+
return "trials"
|
|
127
|
+
return "circular"
|
|
128
|
+
|
|
129
|
+
|
|
130
|
+
def _factorial_at_least(n: int, bound: int) -> bool:
|
|
131
|
+
value = 1
|
|
132
|
+
for i in range(2, n + 1):
|
|
133
|
+
value *= i
|
|
134
|
+
if value >= bound:
|
|
135
|
+
return True
|
|
136
|
+
return value >= bound
|
|
137
|
+
|
|
138
|
+
|
|
139
|
+
def _p_value(null: np.ndarray, observed: float) -> float:
|
|
140
|
+
return float((np.count_nonzero(null >= observed) + 1) / (null.size + 1))
|
|
141
|
+
|
|
142
|
+
|
|
143
|
+
def _include(
|
|
144
|
+
problem: _Problem, candidates: list[Variable], selected: list[Variable]
|
|
145
|
+
) -> tuple[list[Variable], list[dict[str, Any]]]:
|
|
146
|
+
"""Greedy inclusion with the maximum-statistic test; returns the added variables."""
|
|
147
|
+
s = problem.settings
|
|
148
|
+
added: list[Variable] = []
|
|
149
|
+
steps: list[dict[str, Any]] = []
|
|
150
|
+
remaining = list(candidates)
|
|
151
|
+
while remaining:
|
|
152
|
+
condition = selected + added
|
|
153
|
+
columns = problem.values(remaining)
|
|
154
|
+
assert columns is not None
|
|
155
|
+
stats = problem.cmi(list(columns.T), condition)
|
|
156
|
+
best = int(np.argmax(stats))
|
|
157
|
+
perms = problem.permutations(s.n_perm_max_stat)
|
|
158
|
+
null = np.full(len(perms), -np.inf)
|
|
159
|
+
for c in range(len(remaining)):
|
|
160
|
+
surrogate = problem.cmi([columns[p, c] for p in perms], condition)
|
|
161
|
+
null = np.maximum(null, surrogate)
|
|
162
|
+
p = _p_value(null, float(stats[best]))
|
|
163
|
+
steps.append({"variable": list(remaining[best]), "cmi": float(stats[best]), "p": p})
|
|
164
|
+
if p > s.alpha:
|
|
165
|
+
break
|
|
166
|
+
added.append(remaining.pop(best))
|
|
167
|
+
return added, steps
|
|
168
|
+
|
|
169
|
+
|
|
170
|
+
def _prune(
|
|
171
|
+
problem: _Problem, target_past: list[Variable], sources: list[Variable]
|
|
172
|
+
) -> tuple[list[Variable], list[dict[str, Any]]]:
|
|
173
|
+
"""Remove source variables with the minimum-statistic test."""
|
|
174
|
+
s = problem.settings
|
|
175
|
+
kept = list(sources)
|
|
176
|
+
steps: list[dict[str, Any]] = []
|
|
177
|
+
while kept:
|
|
178
|
+
perms = problem.permutations(s.n_perm_min_stat)
|
|
179
|
+
stats = np.empty(len(kept))
|
|
180
|
+
null = np.full(len(perms), np.inf)
|
|
181
|
+
for i, v in enumerate(kept):
|
|
182
|
+
condition = target_past + [u for u in kept if u != v]
|
|
183
|
+
column = problem.values([v])
|
|
184
|
+
assert column is not None
|
|
185
|
+
stats[i] = problem.cmi([column], condition)[0]
|
|
186
|
+
null = np.minimum(null, problem.cmi([column[p] for p in perms], condition))
|
|
187
|
+
worst = int(np.argmin(stats))
|
|
188
|
+
p = _p_value(null, float(stats[worst]))
|
|
189
|
+
steps.append({"variable": list(kept[worst]), "cmi": float(stats[worst]), "p": p})
|
|
190
|
+
if p <= s.alpha:
|
|
191
|
+
break
|
|
192
|
+
kept.pop(worst)
|
|
193
|
+
return kept, steps
|
|
194
|
+
|
|
195
|
+
|
|
196
|
+
def _omnibus(
|
|
197
|
+
problem: _Problem, target_past: list[Variable], sources: list[Variable]
|
|
198
|
+
) -> dict[str, Any]:
|
|
199
|
+
if not sources:
|
|
200
|
+
return {"te": 0.0, "p": 1.0, "significant": False}
|
|
201
|
+
s = problem.settings
|
|
202
|
+
joint = problem.values(sources)
|
|
203
|
+
assert joint is not None
|
|
204
|
+
observed = float(problem.cmi([joint], target_past)[0])
|
|
205
|
+
perms = problem.permutations(s.n_perm_omnibus)
|
|
206
|
+
null = problem.cmi([joint[p] for p in perms], target_past)
|
|
207
|
+
p = _p_value(null, observed)
|
|
208
|
+
return {"te": observed, "p": p, "significant": p <= s.alpha}
|
|
209
|
+
|
|
210
|
+
|
|
211
|
+
def _sequential(
|
|
212
|
+
problem: _Problem, target_past: list[Variable], sources: list[Variable]
|
|
213
|
+
) -> dict[int, tuple[float, float]]:
|
|
214
|
+
"""Per source channel: (TE of its selected variables, step-down p-value)."""
|
|
215
|
+
s = problem.settings
|
|
216
|
+
channels = sorted({c for c, _ in sources})
|
|
217
|
+
perms = problem.permutations(s.n_perm_max_seq)
|
|
218
|
+
stats = np.empty(len(channels))
|
|
219
|
+
table = np.empty((len(channels), len(perms)))
|
|
220
|
+
for i, channel in enumerate(channels):
|
|
221
|
+
group = [v for v in sources if v[0] == channel]
|
|
222
|
+
condition = target_past + [v for v in sources if v[0] != channel]
|
|
223
|
+
joint = problem.values(group)
|
|
224
|
+
assert joint is not None
|
|
225
|
+
stats[i] = problem.cmi([joint], condition)[0]
|
|
226
|
+
table[i] = problem.cmi([joint[p] for p in perms], condition)
|
|
227
|
+
order = np.argsort(-stats, kind="stable").tolist()
|
|
228
|
+
ranked_null = -np.sort(-table, axis=0) # every permutation's values in decreasing order
|
|
229
|
+
out: dict[int, tuple[float, float]] = {}
|
|
230
|
+
running = 0.0
|
|
231
|
+
for rank, i in enumerate(order):
|
|
232
|
+
running = max(running, _p_value(ranked_null[rank], float(stats[i])))
|
|
233
|
+
out[channels[i]] = (float(stats[i]), running)
|
|
234
|
+
return out
|
|
235
|
+
|
|
236
|
+
|
|
237
|
+
def select_target(
|
|
238
|
+
series: np.ndarray, target: int, settings: GreedySettings, seed: np.random.SeedSequence
|
|
239
|
+
) -> dict[str, Any]:
|
|
240
|
+
"""Run the five steps for one target; the result is JSON-able."""
|
|
241
|
+
problem = _Problem(series, target, settings, np.random.default_rng(seed))
|
|
242
|
+
n_channels = series.shape[1]
|
|
243
|
+
target_candidates = [(target, lag) for lag in settings.target_lags]
|
|
244
|
+
source_candidates = [
|
|
245
|
+
(c, lag) for c in range(n_channels) if c != target for lag in settings.source_lags
|
|
246
|
+
]
|
|
247
|
+
target_past, target_steps = _include(problem, target_candidates, [])
|
|
248
|
+
if not target_past:
|
|
249
|
+
target_past = [(target, min(settings.target_lags))]
|
|
250
|
+
sources, source_steps = _include(problem, source_candidates, target_past)
|
|
251
|
+
sources, prune_steps = _prune(problem, target_past, sources)
|
|
252
|
+
omnibus = _omnibus(problem, target_past, sources)
|
|
253
|
+
per_source = _sequential(problem, target_past, sources) if omnibus["significant"] else {}
|
|
254
|
+
kept = [v for v in sources if v[0] in per_source and per_source[v[0]][1] <= settings.alpha]
|
|
255
|
+
return {
|
|
256
|
+
"target_past": [list(v) for v in sorted(target_past, key=lambda v: v[1])],
|
|
257
|
+
"sources_tested": [list(v) for v in sources],
|
|
258
|
+
"sources": [list(v) for v in sorted(kept)],
|
|
259
|
+
"source_te": {str(c): te for c, (te, _) in per_source.items()},
|
|
260
|
+
"source_p": {str(c): p for c, (_, p) in per_source.items()},
|
|
261
|
+
"omnibus": omnibus,
|
|
262
|
+
"steps": {"target": target_steps, "sources": source_steps, "prune": prune_steps},
|
|
263
|
+
"n_rows": int(problem.present.size),
|
|
264
|
+
}
|
|
265
|
+
|
|
266
|
+
|
|
267
|
+
def select_all(
|
|
268
|
+
series: np.ndarray, settings: GreedySettings, seed: int, n_jobs: int
|
|
269
|
+
) -> list[dict[str, Any]]:
|
|
270
|
+
"""Run :func:`select_target` for every target, in parallel over targets when ``n_jobs != 1``.
|
|
271
|
+
|
|
272
|
+
Each target gets its own child of ``SeedSequence(seed)``, so the result does not depend on
|
|
273
|
+
``n_jobs``.
|
|
274
|
+
"""
|
|
275
|
+
n_channels = series.shape[1]
|
|
276
|
+
seeds = np.random.SeedSequence(seed).spawn(n_channels)
|
|
277
|
+
if n_jobs != 1:
|
|
278
|
+
from joblib import Parallel, delayed
|
|
279
|
+
|
|
280
|
+
return list(
|
|
281
|
+
Parallel(n_jobs=n_jobs)(
|
|
282
|
+
delayed(select_target)(series, j, settings, seeds[j]) for j in range(n_channels)
|
|
283
|
+
)
|
|
284
|
+
)
|
|
285
|
+
return [select_target(series, j, settings, seeds[j]) for j in range(n_channels)]
|
|
286
|
+
|
|
287
|
+
|
|
288
|
+
def min_permutations(alpha: float) -> int:
|
|
289
|
+
"""Smallest number of permutations whose smallest p-value reaches ``alpha``."""
|
|
290
|
+
return max(1, math.ceil(1.0 / alpha - 1.0 - 1e-12))
|