dfc-kit 1.0.0__py3-none-any.whl
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- dfc_kit-1.0.0.dist-info/METADATA +184 -0
- dfc_kit-1.0.0.dist-info/RECORD +71 -0
- dfc_kit-1.0.0.dist-info/WHEEL +5 -0
- dfc_kit-1.0.0.dist-info/entry_points.txt +2 -0
- dfc_kit-1.0.0.dist-info/licenses/LICENSE +29 -0
- dfc_kit-1.0.0.dist-info/top_level.txt +1 -0
- dfckit/__init__.py +11 -0
- dfckit/_arrays.py +13 -0
- dfckit/_preprocessing.py +46 -0
- dfckit/_validation.py +87 -0
- dfckit/artifacts/__init__.py +36 -0
- dfckit/artifacts/_fields.py +67 -0
- dfckit/artifacts/_json.py +92 -0
- dfckit/artifacts/_numpy.py +55 -0
- dfckit/artifacts/models.py +624 -0
- dfckit/artifacts/state_alignment.py +106 -0
- dfckit/artifacts/state_results.py +353 -0
- dfckit/artifacts/state_scoring.py +509 -0
- dfckit/artifacts/state_stability.py +207 -0
- dfckit/cli.py +105 -0
- dfckit/commands/__init__.py +1 -0
- dfckit/commands/parser.py +354 -0
- dfckit/commands/reporting.py +146 -0
- dfckit/commands/source.py +245 -0
- dfckit/commands/stability.py +288 -0
- dfckit/commands/states.py +266 -0
- dfckit/connectivity/__init__.py +68 -0
- dfckit/connectivity/_edge_products.py +130 -0
- dfckit/connectivity/correlation.py +61 -0
- dfckit/connectivity/instantaneous.py +253 -0
- dfckit/connectivity/leida.py +204 -0
- dfckit/connectivity/lowrank.py +384 -0
- dfckit/connectivity/partition.py +291 -0
- dfckit/connectivity/windows.py +83 -0
- dfckit/data.py +229 -0
- dfckit/inference/__init__.py +55 -0
- dfckit/inference/endpoints.py +194 -0
- dfckit/inference/hc3.py +207 -0
- dfckit/inference/matching.py +300 -0
- dfckit/inference/multiple_testing.py +52 -0
- dfckit/inference/nbs.py +586 -0
- dfckit/inference/paired.py +157 -0
- dfckit/inference/state_metrics.py +277 -0
- dfckit/information/__init__.py +45 -0
- dfckit/information/_artifact.py +440 -0
- dfckit/information/estimators.py +536 -0
- dfckit/information/fixed.py +660 -0
- dfckit/information/summary.py +89 -0
- dfckit/io/__init__.py +23 -0
- dfckit/io/xcpd.py +470 -0
- dfckit/reference.py +350 -0
- dfckit/segments.py +44 -0
- dfckit/states/__init__.py +97 -0
- dfckit/states/alignment.py +364 -0
- dfckit/states/cap.py +62 -0
- dfckit/states/cross_validation.py +114 -0
- dfckit/states/data.py +387 -0
- dfckit/states/hmm.py +387 -0
- dfckit/states/interpretation.py +273 -0
- dfckit/states/kmeans.py +340 -0
- dfckit/states/metrics.py +94 -0
- dfckit/states/scoring.py +114 -0
- dfckit/states/selection.py +516 -0
- dfckit/states/stability.py +183 -0
- dfckit/states/streaming.py +859 -0
- dfckit/states/streaming_hmm.py +436 -0
- dfckit/storage/__init__.py +29 -0
- dfckit/storage/_statistics.py +80 -0
- dfckit/storage/builders.py +330 -0
- dfckit/storage/store.py +672 -0
- dfckit/storage/summary.py +140 -0
|
@@ -0,0 +1,253 @@
|
|
|
1
|
+
"""Instantaneous edge products from ETS and MTD samples.
|
|
2
|
+
|
|
3
|
+
ETS and MTD differ only in how they construct their time-aligned ROI rows.
|
|
4
|
+
After that step both methods produce the same edge result, summaries, state
|
|
5
|
+
sequences, and chunked stores. The row representation is deliberately
|
|
6
|
+
private: callers normally need the edge result, not another intermediate
|
|
7
|
+
object to carry through the pipeline.
|
|
8
|
+
"""
|
|
9
|
+
|
|
10
|
+
from __future__ import annotations
|
|
11
|
+
|
|
12
|
+
from collections.abc import Sequence
|
|
13
|
+
from dataclasses import dataclass
|
|
14
|
+
|
|
15
|
+
import numpy as np
|
|
16
|
+
from numpy.typing import NDArray
|
|
17
|
+
|
|
18
|
+
from .._arrays import readonly_copy as _readonly
|
|
19
|
+
from .._preprocessing import _segment_standardized_samples
|
|
20
|
+
from ..data import TimeSeriesRun
|
|
21
|
+
|
|
22
|
+
|
|
23
|
+
@dataclass(frozen=True)
|
|
24
|
+
class _InstantaneousRows:
|
|
25
|
+
values: NDArray[np.float64]
|
|
26
|
+
starts: NDArray[np.int64]
|
|
27
|
+
ends: NDArray[np.int64]
|
|
28
|
+
segments: NDArray[np.int64]
|
|
29
|
+
roi_names: tuple[str, ...]
|
|
30
|
+
subject: str | None
|
|
31
|
+
session: str | None
|
|
32
|
+
sample_kind: str
|
|
33
|
+
contract: str
|
|
34
|
+
tr: float | None
|
|
35
|
+
acquisition_id: str | None
|
|
36
|
+
|
|
37
|
+
|
|
38
|
+
class _InstantaneousEstimator:
|
|
39
|
+
"""Small shared implementation for the two public estimators."""
|
|
40
|
+
|
|
41
|
+
def _rows(self, run: TimeSeriesRun) -> _InstantaneousRows:
|
|
42
|
+
raise NotImplementedError
|
|
43
|
+
|
|
44
|
+
def transform(self, run: TimeSeriesRun) -> InstantaneousEdgeResult:
|
|
45
|
+
return _result_from_rows(self._rows(run), materialize_edges=True)
|
|
46
|
+
|
|
47
|
+
def rss(self, run: TimeSeriesRun) -> InstantaneousEdgeResult:
|
|
48
|
+
return _result_from_rows(self._rows(run), materialize_edges=False)
|
|
49
|
+
|
|
50
|
+
|
|
51
|
+
class ETS(_InstantaneousEstimator):
|
|
52
|
+
"""Instantaneous edge products from segment-standardized BOLD values."""
|
|
53
|
+
|
|
54
|
+
def _rows(self, run: TimeSeriesRun) -> _InstantaneousRows:
|
|
55
|
+
values, indices, segments = _segment_standardized_samples(run, method_name="ETS")
|
|
56
|
+
return _InstantaneousRows(
|
|
57
|
+
values=values,
|
|
58
|
+
starts=indices,
|
|
59
|
+
ends=indices,
|
|
60
|
+
segments=segments,
|
|
61
|
+
roi_names=run.roi_names,
|
|
62
|
+
subject=run.subject,
|
|
63
|
+
session=run.session,
|
|
64
|
+
sample_kind="frame",
|
|
65
|
+
contract="ets:normalization=segment",
|
|
66
|
+
tr=run.tr,
|
|
67
|
+
acquisition_id=run.acquisition_id,
|
|
68
|
+
)
|
|
69
|
+
|
|
70
|
+
|
|
71
|
+
def _standardized_derivatives(
|
|
72
|
+
run: TimeSeriesRun,
|
|
73
|
+
) -> tuple[NDArray[np.float64], NDArray[np.int64], NDArray[np.int64], NDArray[np.int64]]:
|
|
74
|
+
"""Return run-standardized derivatives without crossing censor gaps."""
|
|
75
|
+
values: list[NDArray[np.float64]] = []
|
|
76
|
+
starts: list[NDArray[np.int64]] = []
|
|
77
|
+
ends: list[NDArray[np.int64]] = []
|
|
78
|
+
segments: list[NDArray[np.int64]] = []
|
|
79
|
+
for segment_id, positions in enumerate(run.segments()):
|
|
80
|
+
if len(positions) < 2:
|
|
81
|
+
continue
|
|
82
|
+
values.append(np.diff(run.values[positions], axis=0))
|
|
83
|
+
original = run.original_indices[positions]
|
|
84
|
+
starts.append(original[:-1])
|
|
85
|
+
ends.append(original[1:])
|
|
86
|
+
segments.append(np.full(len(positions) - 1, segment_id, dtype=np.int64))
|
|
87
|
+
if not values:
|
|
88
|
+
raise ValueError("MTD requires at least one valid within-segment derivative")
|
|
89
|
+
derivatives = np.concatenate(values, axis=0)
|
|
90
|
+
scale = derivatives.std(axis=0, ddof=0)
|
|
91
|
+
invalid = np.flatnonzero((~np.isfinite(scale)) | (scale < 1e-8))
|
|
92
|
+
if len(invalid):
|
|
93
|
+
raise ValueError(f"MTD derivative scale is undefined for ROI indices {invalid.tolist()}")
|
|
94
|
+
standardized = (derivatives - derivatives.mean(axis=0)) / scale
|
|
95
|
+
if not np.isfinite(standardized).all():
|
|
96
|
+
raise ValueError("standardized MTD derivatives contain non-finite values")
|
|
97
|
+
return standardized, np.concatenate(starts), np.concatenate(ends), np.concatenate(segments)
|
|
98
|
+
|
|
99
|
+
|
|
100
|
+
class MTD(_InstantaneousEstimator):
|
|
101
|
+
"""Instantaneous edge products from standardized within-segment derivatives."""
|
|
102
|
+
|
|
103
|
+
def _rows(self, run: TimeSeriesRun) -> _InstantaneousRows:
|
|
104
|
+
values, starts, ends, segments = _standardized_derivatives(run)
|
|
105
|
+
return _InstantaneousRows(
|
|
106
|
+
values=values,
|
|
107
|
+
starts=starts,
|
|
108
|
+
ends=ends,
|
|
109
|
+
segments=segments,
|
|
110
|
+
roi_names=run.roi_names,
|
|
111
|
+
subject=run.subject,
|
|
112
|
+
session=run.session,
|
|
113
|
+
sample_kind="interval",
|
|
114
|
+
contract="mtd:difference=within-segment;normalization=run",
|
|
115
|
+
tr=run.tr,
|
|
116
|
+
acquisition_id=run.acquisition_id,
|
|
117
|
+
)
|
|
118
|
+
|
|
119
|
+
|
|
120
|
+
@dataclass(frozen=True)
|
|
121
|
+
class InstantaneousEdgeResult:
|
|
122
|
+
"""One frame- or interval-aligned matrix of instantaneous edge products."""
|
|
123
|
+
|
|
124
|
+
features: NDArray[np.float64] | None
|
|
125
|
+
rss: NDArray[np.float64]
|
|
126
|
+
sample_start_frames: NDArray[np.int64]
|
|
127
|
+
sample_end_frames: NDArray[np.int64]
|
|
128
|
+
segment_ids: NDArray[np.int64]
|
|
129
|
+
edge_i: NDArray[np.int64]
|
|
130
|
+
edge_j: NDArray[np.int64]
|
|
131
|
+
roi_names: tuple[str, ...]
|
|
132
|
+
subject: str | None
|
|
133
|
+
session: str | None
|
|
134
|
+
sample_kind: str
|
|
135
|
+
source_contract: str
|
|
136
|
+
tr: float | None = None
|
|
137
|
+
acquisition_id: str | None = None
|
|
138
|
+
|
|
139
|
+
def __post_init__(self) -> None:
|
|
140
|
+
from .correlation import edge_index
|
|
141
|
+
|
|
142
|
+
features = None if self.features is None else np.asarray(self.features, dtype=float)
|
|
143
|
+
rss = np.asarray(self.rss, dtype=float)
|
|
144
|
+
starts = np.asarray(self.sample_start_frames, dtype=np.int64)
|
|
145
|
+
ends = np.asarray(self.sample_end_frames, dtype=np.int64)
|
|
146
|
+
segments = np.asarray(self.segment_ids, dtype=np.int64)
|
|
147
|
+
left = np.asarray(self.edge_i, dtype=np.int64)
|
|
148
|
+
right = np.asarray(self.edge_j, dtype=np.int64)
|
|
149
|
+
names = tuple(str(name) for name in self.roi_names)
|
|
150
|
+
if len(names) < 2 or len(set(names)) != len(names) or any(not name.strip() for name in names):
|
|
151
|
+
raise ValueError("roi_names must contain at least two unique non-empty names")
|
|
152
|
+
if rss.ndim != 1 or not len(rss) or not np.isfinite(rss).all():
|
|
153
|
+
raise ValueError("rss must be a non-empty finite one-dimensional array")
|
|
154
|
+
if any(array.ndim != 1 or len(array) != len(rss) for array in (starts, ends, segments)):
|
|
155
|
+
raise ValueError("sample metadata must align with rss")
|
|
156
|
+
if np.any(ends < starts) or np.any(segments < 0):
|
|
157
|
+
raise ValueError("sample intervals and segment IDs must be non-negative")
|
|
158
|
+
if len(starts) > 1 and np.any(np.diff(starts) <= 0):
|
|
159
|
+
raise ValueError("sample start frames must be strictly increasing")
|
|
160
|
+
expected_left, expected_right = edge_index(len(names))
|
|
161
|
+
if not np.array_equal(left, expected_left) or not np.array_equal(right, expected_right):
|
|
162
|
+
raise ValueError("edge endpoints must be the unique upper-triangular ROI order")
|
|
163
|
+
if features is not None:
|
|
164
|
+
if features.ndim != 2 or features.shape != (len(rss), len(left)):
|
|
165
|
+
raise ValueError("features must align with samples and edge endpoints")
|
|
166
|
+
if not np.isfinite(features).all():
|
|
167
|
+
raise ValueError("edge features contain non-finite samples")
|
|
168
|
+
if self.sample_kind not in {"frame", "interval"}:
|
|
169
|
+
raise ValueError("sample_kind must be 'frame' or 'interval'")
|
|
170
|
+
if self.sample_kind == "frame" and np.any(starts != ends):
|
|
171
|
+
raise ValueError("frame samples must have equal start and end frames")
|
|
172
|
+
if not str(self.source_contract).strip():
|
|
173
|
+
raise ValueError("source_contract must be non-empty")
|
|
174
|
+
if self.subject is not None and not str(self.subject).strip():
|
|
175
|
+
raise ValueError("subject cannot be empty")
|
|
176
|
+
if self.session is not None and not str(self.session).strip():
|
|
177
|
+
raise ValueError("session cannot be empty")
|
|
178
|
+
if self.tr is not None and (not np.isfinite(self.tr) or self.tr <= 0):
|
|
179
|
+
raise ValueError("tr must be finite and positive")
|
|
180
|
+
if self.acquisition_id is not None and not str(self.acquisition_id).strip():
|
|
181
|
+
raise ValueError("acquisition_id cannot be empty")
|
|
182
|
+
object.__setattr__(self, "features", None if features is None else _readonly(features))
|
|
183
|
+
object.__setattr__(self, "rss", _readonly(rss))
|
|
184
|
+
object.__setattr__(self, "sample_start_frames", _readonly(starts))
|
|
185
|
+
object.__setattr__(self, "sample_end_frames", _readonly(ends))
|
|
186
|
+
object.__setattr__(self, "segment_ids", _readonly(segments))
|
|
187
|
+
object.__setattr__(self, "edge_i", _readonly(left))
|
|
188
|
+
object.__setattr__(self, "edge_j", _readonly(right))
|
|
189
|
+
object.__setattr__(self, "roi_names", names)
|
|
190
|
+
object.__setattr__(self, "subject", None if self.subject is None else str(self.subject))
|
|
191
|
+
object.__setattr__(self, "session", None if self.session is None else str(self.session))
|
|
192
|
+
object.__setattr__(self, "source_contract", str(self.source_contract))
|
|
193
|
+
object.__setattr__(self, "acquisition_id", None if self.acquisition_id is None else str(self.acquisition_id))
|
|
194
|
+
|
|
195
|
+
@property
|
|
196
|
+
def n_samples(self) -> int:
|
|
197
|
+
return len(self.rss)
|
|
198
|
+
|
|
199
|
+
@property
|
|
200
|
+
def n_edges(self) -> int:
|
|
201
|
+
return len(self.edge_i)
|
|
202
|
+
|
|
203
|
+
@property
|
|
204
|
+
def n_rois(self) -> int:
|
|
205
|
+
return len(self.roi_names)
|
|
206
|
+
|
|
207
|
+
@property
|
|
208
|
+
def feature_keys(self) -> tuple[tuple[str, str], ...]:
|
|
209
|
+
return tuple(
|
|
210
|
+
(self.roi_names[int(left)], self.roi_names[int(right)])
|
|
211
|
+
for left, right in zip(self.edge_i, self.edge_j, strict=True)
|
|
212
|
+
)
|
|
213
|
+
|
|
214
|
+
def require_features(self) -> NDArray[np.float64]:
|
|
215
|
+
if self.features is None:
|
|
216
|
+
raise ValueError("this result contains RSS only and no materialized edge features")
|
|
217
|
+
return self.features
|
|
218
|
+
|
|
219
|
+
def cross_block(self, left: Sequence[int], right: Sequence[int]) -> NDArray[np.float64]:
|
|
220
|
+
from ._edge_products import cross_block_edge_summary
|
|
221
|
+
|
|
222
|
+
return cross_block_edge_summary(self, left, right)
|
|
223
|
+
|
|
224
|
+
def within_block(self, nodes: Sequence[int]) -> NDArray[np.float64]:
|
|
225
|
+
from ._edge_products import within_block_edge_summary
|
|
226
|
+
|
|
227
|
+
return within_block_edge_summary(self, nodes)
|
|
228
|
+
|
|
229
|
+
|
|
230
|
+
def _result_from_rows(rows: _InstantaneousRows, *, materialize_edges: bool) -> InstantaneousEdgeResult:
|
|
231
|
+
from ._edge_products import edge_products, edge_rss
|
|
232
|
+
from .correlation import edge_index
|
|
233
|
+
|
|
234
|
+
left, right = edge_index(len(rows.roi_names))
|
|
235
|
+
return InstantaneousEdgeResult(
|
|
236
|
+
features=edge_products(rows.values) if materialize_edges else None,
|
|
237
|
+
rss=edge_rss(rows.values),
|
|
238
|
+
sample_start_frames=rows.starts,
|
|
239
|
+
sample_end_frames=rows.ends,
|
|
240
|
+
segment_ids=rows.segments,
|
|
241
|
+
edge_i=left,
|
|
242
|
+
edge_j=right,
|
|
243
|
+
roi_names=rows.roi_names,
|
|
244
|
+
subject=rows.subject,
|
|
245
|
+
session=rows.session,
|
|
246
|
+
sample_kind=rows.sample_kind,
|
|
247
|
+
source_contract=rows.contract,
|
|
248
|
+
tr=rows.tr,
|
|
249
|
+
acquisition_id=rows.acquisition_id,
|
|
250
|
+
)
|
|
251
|
+
|
|
252
|
+
|
|
253
|
+
__all__ = ["ETS", "MTD", "InstantaneousEdgeResult"]
|
|
@@ -0,0 +1,204 @@
|
|
|
1
|
+
"""Censor-aware Leading Eigenvector Dynamics Analysis (LEiDA)."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
from collections.abc import Iterable
|
|
6
|
+
from dataclasses import dataclass
|
|
7
|
+
|
|
8
|
+
import numpy as np
|
|
9
|
+
from numpy.typing import ArrayLike, NDArray
|
|
10
|
+
|
|
11
|
+
from .._arrays import readonly_copy as _readonly
|
|
12
|
+
from .._validation import validated_roi_indices
|
|
13
|
+
from ..data import TimeSeriesRun
|
|
14
|
+
|
|
15
|
+
|
|
16
|
+
def _validated_phase(values: ArrayLike) -> NDArray[np.float64]:
|
|
17
|
+
phase = np.asarray(values, dtype=float)
|
|
18
|
+
if phase.ndim != 2 or not len(phase) or phase.shape[1] < 2:
|
|
19
|
+
raise ValueError("phase must be a non-empty frames-by-ROI array with at least two ROIs")
|
|
20
|
+
if not np.isfinite(phase).all():
|
|
21
|
+
raise ValueError("phase contains non-finite values")
|
|
22
|
+
return phase
|
|
23
|
+
|
|
24
|
+
|
|
25
|
+
@dataclass(frozen=True)
|
|
26
|
+
class LEiDAResult:
|
|
27
|
+
"""Instantaneous phase and leading phase-coherence geometry for one run."""
|
|
28
|
+
|
|
29
|
+
phase: NDArray[np.float64]
|
|
30
|
+
leading_vectors: NDArray[np.float64]
|
|
31
|
+
leading_eigenvalues: NDArray[np.float64]
|
|
32
|
+
original_indices: NDArray[np.int64]
|
|
33
|
+
segment_ids: NDArray[np.int64]
|
|
34
|
+
roi_names: tuple[str, ...]
|
|
35
|
+
subject: str | None
|
|
36
|
+
session: str | None
|
|
37
|
+
tr: float | None
|
|
38
|
+
minimum_segment_length: int
|
|
39
|
+
orientation: str
|
|
40
|
+
implementation: str
|
|
41
|
+
acquisition_id: str | None = None
|
|
42
|
+
|
|
43
|
+
|
|
44
|
+
def analytic_phase(values: ArrayLike) -> NDArray[np.float64]:
|
|
45
|
+
"""Return Hilbert phase after centering each ROI over one segment."""
|
|
46
|
+
data = np.asarray(values, dtype=float)
|
|
47
|
+
if data.ndim != 2 or data.shape[0] < 3 or data.shape[1] < 2:
|
|
48
|
+
raise ValueError("values must have at least three frames and two ROIs")
|
|
49
|
+
if not np.isfinite(data).all():
|
|
50
|
+
raise ValueError("values contain non-finite samples")
|
|
51
|
+
scale = data.std(axis=0, ddof=0)
|
|
52
|
+
invalid = np.flatnonzero((~np.isfinite(scale)) | (scale <= 1e-12))
|
|
53
|
+
if len(invalid):
|
|
54
|
+
raise ValueError(f"Hilbert phase is undefined for constant ROI indices {invalid.tolist()}")
|
|
55
|
+
try:
|
|
56
|
+
from scipy.signal import hilbert
|
|
57
|
+
except ModuleNotFoundError as error:
|
|
58
|
+
raise ModuleNotFoundError(
|
|
59
|
+
"LEiDA phase extraction requires the 'phase' extra: "
|
|
60
|
+
"pip install 'dfc-kit[phase]'"
|
|
61
|
+
) from error
|
|
62
|
+
centered = data - data.mean(axis=0)
|
|
63
|
+
return _readonly(np.angle(hilbert(centered, axis=0)))
|
|
64
|
+
|
|
65
|
+
|
|
66
|
+
def leading_phase_eigenvectors(
|
|
67
|
+
phase: ArrayLike,
|
|
68
|
+
) -> tuple[NDArray[np.float64], NDArray[np.float64]]:
|
|
69
|
+
"""Return oriented leading eigenvectors of instantaneous phase coherence.
|
|
70
|
+
|
|
71
|
+
The matrix with entries ``cos(phase_i - phase_j)`` has rank at most two.
|
|
72
|
+
Solving its equivalent two-dimensional eigensystem avoids materializing a
|
|
73
|
+
frames-by-ROI-by-ROI tensor.
|
|
74
|
+
"""
|
|
75
|
+
angles = _validated_phase(phase)
|
|
76
|
+
cosine = np.cos(angles)
|
|
77
|
+
sine = np.sin(angles)
|
|
78
|
+
cosine_energy = np.sum(cosine**2, axis=1)
|
|
79
|
+
sine_energy = np.sum(sine**2, axis=1)
|
|
80
|
+
cross = np.sum(cosine * sine, axis=1)
|
|
81
|
+
direction = 0.5 * np.arctan2(
|
|
82
|
+
2.0 * cross,
|
|
83
|
+
cosine_energy - sine_energy,
|
|
84
|
+
)
|
|
85
|
+
vectors = (
|
|
86
|
+
cosine * np.cos(direction)[:, None]
|
|
87
|
+
+ sine * np.sin(direction)[:, None]
|
|
88
|
+
)
|
|
89
|
+
norm = np.linalg.norm(vectors, axis=1)
|
|
90
|
+
if np.any(~np.isfinite(norm)) or np.any(norm <= 1e-12):
|
|
91
|
+
raise ValueError("leading phase eigenvector has undefined norm")
|
|
92
|
+
vectors /= norm[:, None]
|
|
93
|
+
vectors *= np.where(vectors.sum(axis=1, keepdims=True) < 0.0, -1.0, 1.0)
|
|
94
|
+
|
|
95
|
+
discriminant = np.sqrt(
|
|
96
|
+
(cosine_energy - sine_energy) ** 2 + 4.0 * cross**2
|
|
97
|
+
)
|
|
98
|
+
eigenvalues = (cosine_energy + sine_energy + discriminant) / 2.0
|
|
99
|
+
return _readonly(vectors), _readonly(eigenvalues)
|
|
100
|
+
|
|
101
|
+
|
|
102
|
+
def cross_block_phase_coherence(
|
|
103
|
+
phase: ArrayLike,
|
|
104
|
+
left: Iterable[int],
|
|
105
|
+
right: Iterable[int],
|
|
106
|
+
) -> NDArray[np.float64]:
|
|
107
|
+
"""Return mean instantaneous phase coherence between two disjoint blocks."""
|
|
108
|
+
angles = _validated_phase(phase)
|
|
109
|
+
left_nodes = validated_roi_indices(
|
|
110
|
+
left,
|
|
111
|
+
n_rois=angles.shape[1],
|
|
112
|
+
label="left block",
|
|
113
|
+
minimum=1,
|
|
114
|
+
)
|
|
115
|
+
right_nodes = validated_roi_indices(
|
|
116
|
+
right,
|
|
117
|
+
n_rois=angles.shape[1],
|
|
118
|
+
label="right block",
|
|
119
|
+
minimum=1,
|
|
120
|
+
)
|
|
121
|
+
overlap = sorted(set(left_nodes.tolist()).intersection(right_nodes.tolist()))
|
|
122
|
+
if overlap:
|
|
123
|
+
raise ValueError(f"cross-block phase coherence requires disjoint blocks; overlap={overlap}")
|
|
124
|
+
values = np.cos(
|
|
125
|
+
angles[:, left_nodes, None] - angles[:, None, right_nodes]
|
|
126
|
+
).mean(axis=(1, 2))
|
|
127
|
+
return _readonly(values)
|
|
128
|
+
|
|
129
|
+
|
|
130
|
+
def within_block_phase_coherence(
|
|
131
|
+
phase: ArrayLike,
|
|
132
|
+
nodes: Iterable[int],
|
|
133
|
+
) -> NDArray[np.float64]:
|
|
134
|
+
"""Return off-diagonal mean instantaneous phase coherence within a block."""
|
|
135
|
+
angles = _validated_phase(phase)
|
|
136
|
+
selected = validated_roi_indices(
|
|
137
|
+
nodes,
|
|
138
|
+
n_rois=angles.shape[1],
|
|
139
|
+
label="within block",
|
|
140
|
+
minimum=2,
|
|
141
|
+
)
|
|
142
|
+
left, right = np.triu_indices(len(selected), k=1)
|
|
143
|
+
values = np.cos(
|
|
144
|
+
angles[:, selected[left]] - angles[:, selected[right]]
|
|
145
|
+
).mean(axis=1)
|
|
146
|
+
return _readonly(values)
|
|
147
|
+
|
|
148
|
+
|
|
149
|
+
class LEiDA:
|
|
150
|
+
"""Extract leading phase-coherence vectors inside retained segments."""
|
|
151
|
+
|
|
152
|
+
def __init__(self, minimum_segment_length: int = 20) -> None:
|
|
153
|
+
if isinstance(minimum_segment_length, (bool, np.bool_)) or not isinstance(
|
|
154
|
+
minimum_segment_length, (int, np.integer)
|
|
155
|
+
):
|
|
156
|
+
raise TypeError("minimum_segment_length must be an integer")
|
|
157
|
+
if minimum_segment_length < 3:
|
|
158
|
+
raise ValueError("minimum_segment_length must be at least three")
|
|
159
|
+
self.minimum_segment_length = int(minimum_segment_length)
|
|
160
|
+
|
|
161
|
+
def transform(self, run: TimeSeriesRun) -> LEiDAResult:
|
|
162
|
+
phases: list[NDArray[np.float64]] = []
|
|
163
|
+
vectors: list[NDArray[np.float64]] = []
|
|
164
|
+
eigenvalues: list[NDArray[np.float64]] = []
|
|
165
|
+
original_indices: list[NDArray[np.int64]] = []
|
|
166
|
+
segment_ids: list[NDArray[np.int64]] = []
|
|
167
|
+
|
|
168
|
+
for segment_id, positions in enumerate(run.segments()):
|
|
169
|
+
if len(positions) < self.minimum_segment_length:
|
|
170
|
+
continue
|
|
171
|
+
segment_phase = analytic_phase(run.values[positions])
|
|
172
|
+
segment_vectors, segment_eigenvalues = leading_phase_eigenvectors(segment_phase)
|
|
173
|
+
phases.append(segment_phase)
|
|
174
|
+
vectors.append(segment_vectors)
|
|
175
|
+
eigenvalues.append(segment_eigenvalues)
|
|
176
|
+
original_indices.append(run.original_indices[positions])
|
|
177
|
+
segment_ids.append(np.full(len(positions), segment_id, dtype=np.int64))
|
|
178
|
+
|
|
179
|
+
if not phases:
|
|
180
|
+
raise ValueError(
|
|
181
|
+
"no retained segment meets the requested minimum_segment_length"
|
|
182
|
+
)
|
|
183
|
+
try:
|
|
184
|
+
import scipy
|
|
185
|
+
except ModuleNotFoundError as error: # pragma: no cover - analytic_phase fails first
|
|
186
|
+
raise ModuleNotFoundError(
|
|
187
|
+
"LEiDA phase extraction requires the 'phase' extra: "
|
|
188
|
+
"pip install 'dfc-kit[phase]'"
|
|
189
|
+
) from error
|
|
190
|
+
return LEiDAResult(
|
|
191
|
+
phase=_readonly(np.concatenate(phases, axis=0)),
|
|
192
|
+
leading_vectors=_readonly(np.concatenate(vectors, axis=0)),
|
|
193
|
+
leading_eigenvalues=_readonly(np.concatenate(eigenvalues)),
|
|
194
|
+
original_indices=_readonly(np.concatenate(original_indices)),
|
|
195
|
+
segment_ids=_readonly(np.concatenate(segment_ids)),
|
|
196
|
+
roi_names=run.roi_names,
|
|
197
|
+
subject=run.subject,
|
|
198
|
+
session=run.session,
|
|
199
|
+
acquisition_id=run.acquisition_id,
|
|
200
|
+
tr=run.tr,
|
|
201
|
+
minimum_segment_length=self.minimum_segment_length,
|
|
202
|
+
orientation="positive-vector-sum",
|
|
203
|
+
implementation=f"scipy {scipy.__version__} signal.hilbert",
|
|
204
|
+
)
|