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.
Files changed (85) hide show
  1. dirconn/__init__.py +130 -0
  2. dirconn/_csd.py +244 -0
  3. dirconn/_greedy.py +290 -0
  4. dirconn/_ksg.py +253 -0
  5. dirconn/_resampling.py +109 -0
  6. dirconn/analytic.py +401 -0
  7. dirconn/bands.py +25 -0
  8. dirconn/baseline.py +149 -0
  9. dirconn/benchmark.py +562 -0
  10. dirconn/bids.py +707 -0
  11. dirconn/bootstrap.py +271 -0
  12. dirconn/channels.py +63 -0
  13. dirconn/coherency.py +211 -0
  14. dirconn/contrast.py +375 -0
  15. dirconn/correction.py +94 -0
  16. dirconn/datasets.py +107 -0
  17. dirconn/emptyroom.py +114 -0
  18. dirconn/estimator.py +534 -0
  19. dirconn/evaluation.py +909 -0
  20. dirconn/exceptions.py +10 -0
  21. dirconn/granger.py +243 -0
  22. dirconn/headmodel.py +346 -0
  23. dirconn/inverse.py +373 -0
  24. dirconn/leakage.py +278 -0
  25. dirconn/multitaper_granger.py +287 -0
  26. dirconn/mvar.py +539 -0
  27. dirconn/network.py +453 -0
  28. dirconn/noise_study.py +137 -0
  29. dirconn/nonstationary.py +864 -0
  30. dirconn/order_stability.py +170 -0
  31. dirconn/pcmci.py +265 -0
  32. dirconn/plotting.py +411 -0
  33. dirconn/protocol.py +1570 -0
  34. dirconn/provenance.py +340 -0
  35. dirconn/py.typed +0 -0
  36. dirconn/regions.py +230 -0
  37. dirconn/report.py +671 -0
  38. dirconn/result.py +637 -0
  39. dirconn/rng.py +51 -0
  40. dirconn/scenario.py +585 -0
  41. dirconn/scenarios/band_alpha.yaml +18 -0
  42. dirconn/scenarios/band_beta.yaml +18 -0
  43. dirconn/scenarios/band_gamma.yaml +18 -0
  44. dirconn/scenarios/band_theta.yaml +18 -0
  45. dirconn/scenarios/bidirectional.yaml +22 -0
  46. dirconn/scenarios/burst.yaml +22 -0
  47. dirconn/scenarios/chain.yaml +20 -0
  48. dirconn/scenarios/collider.yaml +20 -0
  49. dirconn/scenarios/common_driver.yaml +20 -0
  50. dirconn/scenarios/delay_1.yaml +17 -0
  51. dirconn/scenarios/delay_5.yaml +17 -0
  52. dirconn/scenarios/delay_zero_osc.yaml +17 -0
  53. dirconn/scenarios/direction_flip.yaml +24 -0
  54. dirconn/scenarios/distance_far.yaml +17 -0
  55. dirconn/scenarios/distance_mid.yaml +17 -0
  56. dirconn/scenarios/distance_near.yaml +17 -0
  57. dirconn/scenarios/feedback_loop.yaml +22 -0
  58. dirconn/scenarios/hidden_common_input.yaml +24 -0
  59. dirconn/scenarios/leakage_adjacent.yaml +20 -0
  60. dirconn/scenarios/random_10.yaml +179 -0
  61. dirconn/scenarios/random_20.yaml +339 -0
  62. dirconn/scenarios/random_5.yaml +101 -0
  63. dirconn/scenarios/stimulation_artifact.yaml +28 -0
  64. dirconn/scenarios/switch_off.yaml +22 -0
  65. dirconn/scenarios/switch_on.yaml +23 -0
  66. dirconn/significance.py +153 -0
  67. dirconn/significance_study.py +411 -0
  68. dirconn/simulation.py +1057 -0
  69. dirconn/spectral.py +480 -0
  70. dirconn/spectral_estimators.py +357 -0
  71. dirconn/surrogate_test.py +264 -0
  72. dirconn/surrogates.py +394 -0
  73. dirconn/sweep.py +444 -0
  74. dirconn/time_reversal.py +215 -0
  75. dirconn/timeseries.py +330 -0
  76. dirconn/timevarying.py +325 -0
  77. dirconn/transfer_entropy.py +373 -0
  78. dirconn/tvmvar.py +938 -0
  79. dirconn/validation.py +590 -0
  80. dirconn/viz.py +420 -0
  81. dirconn/windowed.py +291 -0
  82. dirconn-0.1.0.dist-info/METADATA +212 -0
  83. dirconn-0.1.0.dist-info/RECORD +85 -0
  84. dirconn-0.1.0.dist-info/WHEEL +4 -0
  85. 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))