nltools 0.6.0.dev0__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.
- nltools/__init__.py +55 -0
- nltools/algorithms/__init__.py +90 -0
- nltools/algorithms/alignment/__init__.py +21 -0
- nltools/algorithms/alignment/procrustes.py +565 -0
- nltools/algorithms/alignment/srm.py +758 -0
- nltools/algorithms/backends.py +1059 -0
- nltools/algorithms/corrections.py +177 -0
- nltools/algorithms/decoding.py +327 -0
- nltools/algorithms/inference/__init__.py +50 -0
- nltools/algorithms/inference/bootstrap.py +1386 -0
- nltools/algorithms/inference/correlation.py +373 -0
- nltools/algorithms/inference/intersubject.py +422 -0
- nltools/algorithms/inference/isc.py +1554 -0
- nltools/algorithms/inference/matrix.py +602 -0
- nltools/algorithms/inference/one_sample.py +288 -0
- nltools/algorithms/inference/random.py +122 -0
- nltools/algorithms/inference/timeseries.py +347 -0
- nltools/algorithms/inference/two_sample.py +212 -0
- nltools/algorithms/inference/utils.py +58 -0
- nltools/algorithms/inference/validation.py +282 -0
- nltools/algorithms/neighborhoods.py +207 -0
- nltools/algorithms/outliers.py +308 -0
- nltools/algorithms/regression.py +83 -0
- nltools/algorithms/signal.py +303 -0
- nltools/algorithms/similarity.py +234 -0
- nltools/algorithms/validation.py +151 -0
- nltools/cross_validation.py +72 -0
- nltools/data/__init__.py +30 -0
- nltools/data/adjacency/__init__.py +875 -0
- nltools/data/adjacency/io.py +111 -0
- nltools/data/adjacency/modeling.py +569 -0
- nltools/data/adjacency/plotting.py +174 -0
- nltools/data/adjacency/state.py +349 -0
- nltools/data/adjacency/stats.py +596 -0
- nltools/data/adjacency/utils.py +79 -0
- nltools/data/atlases/__init__.py +23 -0
- nltools/data/atlases/labeling.py +158 -0
- nltools/data/atlases/loading.py +76 -0
- nltools/data/atlases/registry.py +96 -0
- nltools/data/atlases/reporting.py +456 -0
- nltools/data/braindata/__init__.py +2170 -0
- nltools/data/braindata/analysis.py +1381 -0
- nltools/data/braindata/bootstrap.py +398 -0
- nltools/data/braindata/io.py +896 -0
- nltools/data/braindata/modeling.py +594 -0
- nltools/data/braindata/plotting.py +501 -0
- nltools/data/braindata/prediction.py +1250 -0
- nltools/data/braindata/utils.py +348 -0
- nltools/data/braindata/validation.py +197 -0
- nltools/data/braindata/viewer.js +266 -0
- nltools/data/braindata/viewer.py +770 -0
- nltools/data/combine.py +27 -0
- nltools/data/designmatrix/__init__.py +1032 -0
- nltools/data/designmatrix/append.py +518 -0
- nltools/data/designmatrix/diagnostics.py +248 -0
- nltools/data/designmatrix/io.py +356 -0
- nltools/data/designmatrix/plotting.py +291 -0
- nltools/data/designmatrix/regressors.py +463 -0
- nltools/data/designmatrix/transforms.py +200 -0
- nltools/data/designmatrix/utils.py +350 -0
- nltools/data/ownership.py +129 -0
- nltools/data/results.py +291 -0
- nltools/data/roc/__init__.py +398 -0
- nltools/data/simulator/__init__.py +927 -0
- nltools/data/simulator/haxby.py +124 -0
- nltools/data/validation.py +83 -0
- nltools/datasets.py +218 -0
- nltools/io/__init__.py +10 -0
- nltools/io/events.py +67 -0
- nltools/io/h5.py +246 -0
- nltools/mask.py +403 -0
- nltools/models/__init__.py +11 -0
- nltools/models/glm.py +543 -0
- nltools/models/results.py +49 -0
- nltools/models/ridge.py +1303 -0
- nltools/models/validation.py +26 -0
- nltools/plotting/__init__.py +32 -0
- nltools/plotting/adjacency.py +421 -0
- nltools/plotting/brain.py +669 -0
- nltools/plotting/decomposition.py +111 -0
- nltools/plotting/prediction.py +110 -0
- nltools/resources/covariates_example.csv +161 -0
- nltools/resources/onsets_example.csv +40 -0
- nltools/templates/__init__.py +51 -0
- nltools/templates/config.py +144 -0
- nltools/templates/fetch.py +260 -0
- nltools/templates/matching.py +183 -0
- nltools/templates/paths.py +106 -0
- nltools/templates/registry.py +25 -0
- nltools/utils.py +230 -0
- nltools/version.py +13 -0
- nltools-0.6.0.dev0.dist-info/METADATA +95 -0
- nltools-0.6.0.dev0.dist-info/RECORD +95 -0
- nltools-0.6.0.dev0.dist-info/WHEEL +4 -0
- nltools-0.6.0.dev0.dist-info/licenses/LICENSE +21 -0
|
@@ -0,0 +1,565 @@
|
|
|
1
|
+
"""Data alignment — SRM, Procrustes, and state alignment."""
|
|
2
|
+
|
|
3
|
+
import numpy as np
|
|
4
|
+
from joblib import Parallel, delayed
|
|
5
|
+
from scipy.linalg import orthogonal_procrustes
|
|
6
|
+
from scipy.optimize import linear_sum_assignment
|
|
7
|
+
from scipy.spatial import procrustes as procrust
|
|
8
|
+
from sklearn.metrics import pairwise_distances
|
|
9
|
+
from sklearn.utils import check_random_state
|
|
10
|
+
|
|
11
|
+
from ..validation import _compute_pvalue, _validate_tail_parameter
|
|
12
|
+
from .srm import _SRM, _DetSRM
|
|
13
|
+
|
|
14
|
+
|
|
15
|
+
def _hyperalign(data, n_iter):
|
|
16
|
+
"""Build a common template by iterative Procrustes refinement.
|
|
17
|
+
|
|
18
|
+
Hyperalignment (Haxby et al., 2011) runs in three stages: seed a template
|
|
19
|
+
from the first subject and grow it by incrementally aligning and averaging
|
|
20
|
+
the rest, refine it over `n_iter` rounds of align-and-average, then align
|
|
21
|
+
every subject to the refined template. Subjects must share a sample count;
|
|
22
|
+
the feature axis is zero-padded up to the largest subject so no subject's
|
|
23
|
+
features are dropped.
|
|
24
|
+
|
|
25
|
+
Args:
|
|
26
|
+
data (list[np.ndarray]): One (n_features, n_samples) array per subject.
|
|
27
|
+
Feature counts may differ; sample counts may not.
|
|
28
|
+
n_iter (int): Number of template refinement rounds.
|
|
29
|
+
|
|
30
|
+
Returns:
|
|
31
|
+
tuple[list[np.ndarray], list[np.ndarray], np.ndarray, list[float], list[float]]:
|
|
32
|
+
`(aligned, transformation_matrix, template, disparity, scale)` —
|
|
33
|
+
the aligned subjects (each (n_features, n_samples)), the per-subject
|
|
34
|
+
transforms in the `aligned = original @ T` orientation (each
|
|
35
|
+
(n_features, n_features)), the common template
|
|
36
|
+
((n_samples, n_features)), and the per-subject disparities and
|
|
37
|
+
scale factors.
|
|
38
|
+
|
|
39
|
+
Raises:
|
|
40
|
+
TypeError: If `data` is not a list of numpy arrays.
|
|
41
|
+
ValueError: If `data` is empty, an element is not 2-dimensional, or the
|
|
42
|
+
subjects do not share a sample count.
|
|
43
|
+
"""
|
|
44
|
+
if not isinstance(data, list):
|
|
45
|
+
raise TypeError("Data must be a list of arrays")
|
|
46
|
+
if len(data) == 0:
|
|
47
|
+
raise ValueError("Data list cannot be empty")
|
|
48
|
+
for i, x in enumerate(data):
|
|
49
|
+
if not isinstance(x, np.ndarray):
|
|
50
|
+
raise TypeError(f"Element {i} is not a numpy array")
|
|
51
|
+
if x.ndim != 2:
|
|
52
|
+
raise ValueError(f"Element {i} must be 2-dimensional")
|
|
53
|
+
|
|
54
|
+
n_samples = data[0].shape[1]
|
|
55
|
+
for i, x in enumerate(data):
|
|
56
|
+
if x.shape[1] != n_samples:
|
|
57
|
+
raise ValueError(
|
|
58
|
+
f"All matrices must have same number of samples (columns). "
|
|
59
|
+
f"Element 0 has {n_samples}, element {i} has {x.shape[1]}"
|
|
60
|
+
)
|
|
61
|
+
|
|
62
|
+
# Zero-pad every subject's feature axis (rows) up to the LARGEST feature
|
|
63
|
+
# count, so no subject's features are dropped (F001).
|
|
64
|
+
n_features = max(x.shape[0] for x in data)
|
|
65
|
+
padded = []
|
|
66
|
+
for x in data:
|
|
67
|
+
missing = n_features - x.shape[0]
|
|
68
|
+
if missing > 0:
|
|
69
|
+
x = np.vstack([x, np.zeros((missing, x.shape[1]), dtype=x.dtype)])
|
|
70
|
+
padded.append(np.asarray(x, dtype=float).copy())
|
|
71
|
+
|
|
72
|
+
# Stage 1: seed the template from the first subject, then incrementally
|
|
73
|
+
# align and accumulate the rest. Incremental averaging keeps the template
|
|
74
|
+
# from being dominated by whichever subject came first.
|
|
75
|
+
template = None
|
|
76
|
+
for i, x in enumerate(padded):
|
|
77
|
+
if i == 0:
|
|
78
|
+
template = np.copy(x.T)
|
|
79
|
+
else:
|
|
80
|
+
_, trans, _, _, _ = procrustes(template / i, x.T)
|
|
81
|
+
template += trans
|
|
82
|
+
template /= len(padded)
|
|
83
|
+
|
|
84
|
+
# Stage 2: refine the template by aligning every subject to it and
|
|
85
|
+
# re-averaging in the aligned space.
|
|
86
|
+
for _ in range(n_iter):
|
|
87
|
+
common = np.zeros(template.shape)
|
|
88
|
+
for x in padded:
|
|
89
|
+
_, trans, _, _, _ = procrustes(template, x.T)
|
|
90
|
+
common += trans
|
|
91
|
+
template = common / len(padded)
|
|
92
|
+
|
|
93
|
+
# Stage 3: align every subject to the refined template.
|
|
94
|
+
aligned = []
|
|
95
|
+
transformation_matrix = []
|
|
96
|
+
disparity = []
|
|
97
|
+
scale = []
|
|
98
|
+
for x in padded:
|
|
99
|
+
_, transformed, subject_disparity, rotation, subject_scale = procrustes(
|
|
100
|
+
template, x.T
|
|
101
|
+
)
|
|
102
|
+
aligned.append(transformed.T)
|
|
103
|
+
# `procrustes` computes `transformed = original @ rotation.T`, so the
|
|
104
|
+
# matrix callers back-project with is the transpose of the rotation.
|
|
105
|
+
transformation_matrix.append(rotation.T)
|
|
106
|
+
disparity.append(subject_disparity)
|
|
107
|
+
scale.append(subject_scale)
|
|
108
|
+
|
|
109
|
+
return aligned, transformation_matrix, template, disparity, scale
|
|
110
|
+
|
|
111
|
+
|
|
112
|
+
def align(
|
|
113
|
+
data,
|
|
114
|
+
method="deterministic_srm",
|
|
115
|
+
n_features=None,
|
|
116
|
+
axis=0,
|
|
117
|
+
*,
|
|
118
|
+
n_iter=10,
|
|
119
|
+
random_state=0,
|
|
120
|
+
):
|
|
121
|
+
"""Align subject data into a common response model.
|
|
122
|
+
|
|
123
|
+
Aligns a group of subjects either by Procrustes-based hyperalignment
|
|
124
|
+
(Haxby et al., 2011) or by the Shared Response Model (Chen et al., 2015),
|
|
125
|
+
the latter through `_SRM`/`_DetSRM`.
|
|
126
|
+
The common model is the shared response (SRM) or the centered group template
|
|
127
|
+
(Procrustes). Transformed data can be projected back into each subject's
|
|
128
|
+
original space with its transformation matrix. To align a single `BrainData`
|
|
129
|
+
to another, use `BrainData.align` instead.
|
|
130
|
+
|
|
131
|
+
Args:
|
|
132
|
+
data (list[BrainData] | list[np.ndarray]): Subjects to align; all elements
|
|
133
|
+
must be the same type. Arrays are observations x features.
|
|
134
|
+
method (str): One of `'probabilistic_srm'`, `'deterministic_srm'`, or
|
|
135
|
+
`'procrustes'`. Defaults to `'deterministic_srm'`.
|
|
136
|
+
n_features (int | None): Number of features in the common space (SRM only).
|
|
137
|
+
None uses the number of voxels. Must be None for `'procrustes'`.
|
|
138
|
+
axis (int): Axis to align on: 0 aligns timepoints (ISC computed per voxel),
|
|
139
|
+
1 aligns voxels (ISC computed per timepoint). Defaults to 0.
|
|
140
|
+
n_iter (int): Number of `_SRM`/`_DetSRM` iterations; ignored by
|
|
141
|
+
`method='procrustes'`. Defaults to 10.
|
|
142
|
+
random_state (int): Seed forwarded to the constructed `_SRM`/`_DetSRM`;
|
|
143
|
+
ignored by `method='procrustes'`. Defaults to 0.
|
|
144
|
+
|
|
145
|
+
Returns:
|
|
146
|
+
dict: Keys `'transformed'` (list of aligned subject data, same type as the
|
|
147
|
+
input), `'transformation_matrix'` (per-subject transforms, in the
|
|
148
|
+
`transformed = original @ T` orientation on every input type, so
|
|
149
|
+
back-projection is `transformed @ T.T`), `'common_model'` (shared
|
|
150
|
+
response or group template), and `'isc'` (dict mapping each aligned
|
|
151
|
+
unit to its mean intersubject correlation). With `method='procrustes'`
|
|
152
|
+
also `'disparity'` and `'scale'`.
|
|
153
|
+
|
|
154
|
+
Raises:
|
|
155
|
+
ValueError: If `data` is not a same-typed list, `method` or `axis` is
|
|
156
|
+
unknown, or `method='procrustes'` is combined with `axis=1` on
|
|
157
|
+
`BrainData` input — that transform spans images on both axes and has
|
|
158
|
+
no voxel axis to be returned on.
|
|
159
|
+
|
|
160
|
+
Examples:
|
|
161
|
+
```python
|
|
162
|
+
# Hyperalign using procrustes transform
|
|
163
|
+
out = align(data, method='procrustes')
|
|
164
|
+
|
|
165
|
+
# Align using shared response model
|
|
166
|
+
out = align(data, method='probabilistic_srm', n_features=None)
|
|
167
|
+
|
|
168
|
+
# Project aligned data back into original data space
|
|
169
|
+
original_data = [
|
|
170
|
+
np.dot(t.data, tm.T)
|
|
171
|
+
for t, tm in zip(out['transformed'], out['transformation_matrix'])
|
|
172
|
+
]
|
|
173
|
+
```
|
|
174
|
+
"""
|
|
175
|
+
|
|
176
|
+
from nltools.data import BrainData, Adjacency
|
|
177
|
+
|
|
178
|
+
if not isinstance(data, list):
|
|
179
|
+
raise ValueError("Make sure you are inputting data is a list.")
|
|
180
|
+
if len({type(x) for x in data}) > 1:
|
|
181
|
+
raise ValueError("Make sure all objects in the list are the same type.")
|
|
182
|
+
if method not in ["probabilistic_srm", "deterministic_srm", "procrustes"]:
|
|
183
|
+
raise ValueError(
|
|
184
|
+
"Method must be ['probabilistic_srm','deterministic_srm','procrustes']"
|
|
185
|
+
)
|
|
186
|
+
|
|
187
|
+
if isinstance(data[0], BrainData):
|
|
188
|
+
from nltools.data.braindata.utils import _result_from_array
|
|
189
|
+
|
|
190
|
+
data_type = "BrainData"
|
|
191
|
+
sources = data.copy()
|
|
192
|
+
data = [np.array(x.data.T, copy=True) for x in data]
|
|
193
|
+
elif isinstance(data[0], np.ndarray):
|
|
194
|
+
data_type = "numpy"
|
|
195
|
+
data = [np.array(x.T, copy=True) for x in data]
|
|
196
|
+
else:
|
|
197
|
+
raise ValueError(f"Type {type(data[0])} is not implemented yet.")
|
|
198
|
+
|
|
199
|
+
# Align over time or voxels
|
|
200
|
+
if axis == 1:
|
|
201
|
+
if data_type == "BrainData" and method == "procrustes":
|
|
202
|
+
# The axis=1 Procrustes transform spans images on both of its axes,
|
|
203
|
+
# so it cannot be returned on the source's voxel axis.
|
|
204
|
+
raise ValueError(
|
|
205
|
+
"procrustes alignment supports axis=0 only for BrainData input."
|
|
206
|
+
)
|
|
207
|
+
data = [x.T for x in data]
|
|
208
|
+
elif axis != 0:
|
|
209
|
+
raise ValueError("axis must be 0 or 1.")
|
|
210
|
+
|
|
211
|
+
out = {}
|
|
212
|
+
if method in ["deterministic_srm", "probabilistic_srm"]:
|
|
213
|
+
if n_features is None:
|
|
214
|
+
n_features = int(data[0].shape[0])
|
|
215
|
+
if method == "deterministic_srm":
|
|
216
|
+
srm = _DetSRM(
|
|
217
|
+
n_features=n_features, n_iter=n_iter, random_state=random_state
|
|
218
|
+
)
|
|
219
|
+
elif method == "probabilistic_srm":
|
|
220
|
+
srm = _SRM(n_features=n_features, n_iter=n_iter, random_state=random_state)
|
|
221
|
+
srm.fit(data)
|
|
222
|
+
out["transformed"] = list(srm.transform(data))
|
|
223
|
+
out["common_model"] = srm.s_.T
|
|
224
|
+
out["transformation_matrix"] = srm.w_
|
|
225
|
+
|
|
226
|
+
elif method == "procrustes":
|
|
227
|
+
if n_features is not None:
|
|
228
|
+
raise NotImplementedError(
|
|
229
|
+
"Currently must use all voxels."
|
|
230
|
+
"Eventually will add a PCA reduction,"
|
|
231
|
+
"must do this manually for now."
|
|
232
|
+
)
|
|
233
|
+
|
|
234
|
+
# `data` is already [features, samples] here (see the .T applied to each
|
|
235
|
+
# input above). n_iter=1 is the three-stage loop v0.5.1 ran; align()'s
|
|
236
|
+
# public n_iter is SRM-only.
|
|
237
|
+
(
|
|
238
|
+
out["transformed"],
|
|
239
|
+
out["transformation_matrix"],
|
|
240
|
+
out["common_model"],
|
|
241
|
+
out["disparity"],
|
|
242
|
+
out["scale"],
|
|
243
|
+
) = _hyperalign(data, n_iter=1)
|
|
244
|
+
|
|
245
|
+
if axis == 1:
|
|
246
|
+
out["transformed"] = [x.T for x in out["transformed"]]
|
|
247
|
+
out["common_model"] = out["common_model"].T
|
|
248
|
+
|
|
249
|
+
if data_type == "BrainData":
|
|
250
|
+
out["transformation_matrix"] = [x.T for x in out["transformation_matrix"]]
|
|
251
|
+
|
|
252
|
+
if data_type == "BrainData":
|
|
253
|
+
if method == "procrustes":
|
|
254
|
+
out["transformed"] = [
|
|
255
|
+
_result_from_array(source, values.T, rows="preserve")
|
|
256
|
+
for source, values in zip(sources, out["transformed"])
|
|
257
|
+
]
|
|
258
|
+
out["common_model"] = _result_from_array(
|
|
259
|
+
sources[0], out["common_model"], rows="clear"
|
|
260
|
+
)
|
|
261
|
+
# `_hyperalign` already returns these in the
|
|
262
|
+
# `transformed = original @ T` orientation, and they are square on
|
|
263
|
+
# the voxel axis, so unlike the SRM matrices they are wrapped as-is.
|
|
264
|
+
out["transformation_matrix"] = [
|
|
265
|
+
_result_from_array(source, values, rows="clear")
|
|
266
|
+
for source, values in zip(sources, out["transformation_matrix"])
|
|
267
|
+
]
|
|
268
|
+
else:
|
|
269
|
+
out["transformed"] = [x.T for x in out["transformed"]]
|
|
270
|
+
out["transformation_matrix"] = [
|
|
271
|
+
_result_from_array(source, values.T, rows="clear")
|
|
272
|
+
for source, values in zip(sources, out["transformation_matrix"])
|
|
273
|
+
]
|
|
274
|
+
|
|
275
|
+
# Calculate Intersubject Correlation (ISC) on final transformed data
|
|
276
|
+
# ISC measures correlation along the aligned dimension:
|
|
277
|
+
# axis=0 (align timepoints): ISC per voxel (temporal correlation)
|
|
278
|
+
# axis=1 (align voxels): ISC per timepoint (spatial correlation)
|
|
279
|
+
#
|
|
280
|
+
# Final shapes after all formatting:
|
|
281
|
+
# BrainData: (timepoints, voxels)
|
|
282
|
+
# numpy: (voxels, timepoints)
|
|
283
|
+
|
|
284
|
+
a = Adjacency()
|
|
285
|
+
|
|
286
|
+
if data_type == "BrainData":
|
|
287
|
+
# BrainData transformed shape: (timepoints, voxels)
|
|
288
|
+
# For procrustes, transformed contains BrainData objects; extract .data
|
|
289
|
+
# For SRM methods, transformed contains numpy arrays after the .T
|
|
290
|
+
transformed_arrays = [
|
|
291
|
+
x.data if isinstance(x, BrainData) else x for x in out["transformed"]
|
|
292
|
+
]
|
|
293
|
+
if axis == 0:
|
|
294
|
+
# Aligned timepoints → ISC per voxel (correlation over time)
|
|
295
|
+
n_isc = transformed_arrays[0].shape[1] # n_voxels
|
|
296
|
+
for v in range(n_isc):
|
|
297
|
+
# Extract timecourse for voxel v from each subject
|
|
298
|
+
isc_data = np.array([x[:, v] for x in transformed_arrays])
|
|
299
|
+
a = a.append(
|
|
300
|
+
Adjacency(
|
|
301
|
+
1 - pairwise_distances(isc_data, metric="correlation"),
|
|
302
|
+
matrix_type="similarity",
|
|
303
|
+
)
|
|
304
|
+
)
|
|
305
|
+
else: # axis == 1
|
|
306
|
+
# Aligned voxels → ISC per timepoint (spatial correlation)
|
|
307
|
+
n_isc = transformed_arrays[0].shape[0] # n_timepoints
|
|
308
|
+
for t in range(n_isc):
|
|
309
|
+
# Extract spatial pattern at timepoint t from each subject
|
|
310
|
+
isc_data = np.array([x[t, :] for x in transformed_arrays])
|
|
311
|
+
a = a.append(
|
|
312
|
+
Adjacency(
|
|
313
|
+
1 - pairwise_distances(isc_data, metric="correlation"),
|
|
314
|
+
matrix_type="similarity",
|
|
315
|
+
)
|
|
316
|
+
)
|
|
317
|
+
else: # numpy
|
|
318
|
+
# numpy transformed shape: (voxels, timepoints)
|
|
319
|
+
if axis == 0:
|
|
320
|
+
# Aligned timepoints → ISC per voxel (correlation over time)
|
|
321
|
+
n_isc = out["transformed"][0].shape[0] # n_voxels
|
|
322
|
+
for v in range(n_isc):
|
|
323
|
+
# Extract timecourse for voxel v from each subject
|
|
324
|
+
isc_data = np.array([x[v, :] for x in out["transformed"]])
|
|
325
|
+
a = a.append(
|
|
326
|
+
Adjacency(
|
|
327
|
+
1 - pairwise_distances(isc_data, metric="correlation"),
|
|
328
|
+
matrix_type="similarity",
|
|
329
|
+
)
|
|
330
|
+
)
|
|
331
|
+
else: # axis == 1
|
|
332
|
+
# Aligned voxels → ISC per timepoint (spatial correlation)
|
|
333
|
+
n_isc = out["transformed"][0].shape[1] # n_timepoints
|
|
334
|
+
for t in range(n_isc):
|
|
335
|
+
# Extract spatial pattern at timepoint t from each subject
|
|
336
|
+
isc_data = np.array([x[:, t] for x in out["transformed"]])
|
|
337
|
+
a = a.append(
|
|
338
|
+
Adjacency(
|
|
339
|
+
1 - pairwise_distances(isc_data, metric="correlation"),
|
|
340
|
+
matrix_type="similarity",
|
|
341
|
+
)
|
|
342
|
+
)
|
|
343
|
+
|
|
344
|
+
out["isc"] = dict(zip(np.arange(n_isc), a.mean(axis=1)))
|
|
345
|
+
|
|
346
|
+
return out
|
|
347
|
+
|
|
348
|
+
|
|
349
|
+
def procrustes(data1, data2):
|
|
350
|
+
"""Perform a Procrustes similarity analysis on two data sets.
|
|
351
|
+
|
|
352
|
+
For multi-subject Procrustes-based alignment, use `align()` instead.
|
|
353
|
+
|
|
354
|
+
Each input matrix is a set of points or vectors (the rows of the matrix).
|
|
355
|
+
The dimension of the space is the number of columns of each matrix. Given
|
|
356
|
+
two identically sized matrices, procrustes standardizes both so that
|
|
357
|
+
$tr(AA^{T}) = 1$ and both sets of points are centered around the origin.
|
|
358
|
+
It then applies the optimal transform to the second matrix (including
|
|
359
|
+
scaling/dilation, rotations, and reflections) to minimize
|
|
360
|
+
$M^{2}=\\sum(data1-data2)^{2}$, the sum of squared pointwise differences
|
|
361
|
+
between the two datasets. Both inputs must have the same number of rows;
|
|
362
|
+
if they differ in the number of columns, the narrower one is padded with
|
|
363
|
+
columns of zeros.
|
|
364
|
+
|
|
365
|
+
Args:
|
|
366
|
+
data1 (np.ndarray): Matrix whose n rows represent points in k (columns)
|
|
367
|
+
space. `data1` is the reference data; after it is standardized, the
|
|
368
|
+
data from `data2` will be transformed to fit the pattern in `data1`
|
|
369
|
+
(must have >1 unique points).
|
|
370
|
+
data2 (np.ndarray): n rows of data in k space to be fit to `data1`. Must
|
|
371
|
+
have the same number of rows as `data1` (must have >1 unique points).
|
|
372
|
+
|
|
373
|
+
Returns:
|
|
374
|
+
tuple[np.ndarray, np.ndarray, float, np.ndarray, float]: `(mtx1, mtx2,
|
|
375
|
+
disparity, R, scale)` — `mtx1` is a standardized version of `data1`;
|
|
376
|
+
`mtx2` is the orientation of `data2` that best fits `data1` (centered,
|
|
377
|
+
but not necessarily $tr(AA^{T}) = 1$); `disparity` is $M^{2}$ as defined
|
|
378
|
+
above; `R` is the `(N, N)` matrix solution of the orthogonal Procrustes
|
|
379
|
+
problem, minimizing the Frobenius norm of `dot(data1, R) - data2` subject
|
|
380
|
+
to `dot(R.T, R) == I`; `scale` is the sum of the singular values of
|
|
381
|
+
`dot(data1.T, data2)`.
|
|
382
|
+
"""
|
|
383
|
+
|
|
384
|
+
mtx1 = np.array(data1, dtype=np.double, copy=True)
|
|
385
|
+
mtx2 = np.array(data2, dtype=np.double, copy=True)
|
|
386
|
+
|
|
387
|
+
if mtx1.ndim != 2 or mtx2.ndim != 2:
|
|
388
|
+
raise ValueError("Input matrices must be two-dimensional")
|
|
389
|
+
if mtx1.shape[0] != mtx2.shape[0]:
|
|
390
|
+
raise ValueError("Input matrices must have same number of rows.")
|
|
391
|
+
if mtx1.size == 0:
|
|
392
|
+
raise ValueError("Input matrices must be >0 rows and >0 cols")
|
|
393
|
+
if mtx1.shape[1] != mtx2.shape[1]:
|
|
394
|
+
# Pad with zeros
|
|
395
|
+
if mtx1.shape[1] > mtx2.shape[1]:
|
|
396
|
+
mtx2 = np.append(
|
|
397
|
+
mtx2, np.zeros((mtx1.shape[0], mtx1.shape[1] - mtx2.shape[1])), axis=1
|
|
398
|
+
)
|
|
399
|
+
else:
|
|
400
|
+
mtx1 = np.append(
|
|
401
|
+
mtx1, np.zeros((mtx1.shape[0], mtx2.shape[1] - mtx1.shape[1])), axis=1
|
|
402
|
+
)
|
|
403
|
+
|
|
404
|
+
# translate all the data to the origin
|
|
405
|
+
mtx1 -= np.mean(mtx1, 0)
|
|
406
|
+
mtx2 -= np.mean(mtx2, 0)
|
|
407
|
+
|
|
408
|
+
norm1 = np.linalg.norm(mtx1)
|
|
409
|
+
norm2 = np.linalg.norm(mtx2)
|
|
410
|
+
|
|
411
|
+
if norm1 == 0 or norm2 == 0:
|
|
412
|
+
raise ValueError("Input matrices must contain >1 unique points")
|
|
413
|
+
|
|
414
|
+
# change scaling of data (in rows) such that trace(mtx*mtx') = 1
|
|
415
|
+
mtx1 /= norm1
|
|
416
|
+
mtx2 /= norm2
|
|
417
|
+
|
|
418
|
+
# transform mtx2 to minimize disparity
|
|
419
|
+
R, s = orthogonal_procrustes(mtx1, mtx2)
|
|
420
|
+
mtx2 = np.dot(mtx2, R.T) * s
|
|
421
|
+
|
|
422
|
+
# measure the dissimilarity between the two datasets
|
|
423
|
+
disparity = np.sum(np.square(mtx1 - mtx2))
|
|
424
|
+
|
|
425
|
+
return mtx1, mtx2, disparity, R, s
|
|
426
|
+
|
|
427
|
+
|
|
428
|
+
def procrustes_distance(
|
|
429
|
+
mat1, mat2, *, n_permute=5000, tail=2, n_jobs=-1, random_state=None
|
|
430
|
+
):
|
|
431
|
+
"""Test matrix similarity using Procrustes superposition.
|
|
432
|
+
|
|
433
|
+
Matrices need to match in size on their first dimension only, as the smaller
|
|
434
|
+
matrix on the second dimension will be padded with zeros. After aligning two
|
|
435
|
+
matrices using the Procrustes transformation, use the computed disparity
|
|
436
|
+
between them (sum of squared error of elements) as a similarity metric.
|
|
437
|
+
Shuffle the rows of one of the matrices and recompute the disparity to perform
|
|
438
|
+
inference (Peres-Neto & Jackson, 2001).
|
|
439
|
+
|
|
440
|
+
Args:
|
|
441
|
+
mat1 (np.ndarray): 1d or 2d array; must have the same number of rows as
|
|
442
|
+
`mat2`.
|
|
443
|
+
mat2 (np.ndarray): 1d or 2d array; must have the same number of rows as
|
|
444
|
+
`mat1`.
|
|
445
|
+
n_permute (int): Number of permutation iterations. Defaults to 5000.
|
|
446
|
+
tail (int | str): `2` or `'two'` for a two-tailed test (default); `1` or
|
|
447
|
+
`'one'` for one-tailed (similarity greater than chance).
|
|
448
|
+
n_jobs (int): Number of CPUs for the permutations; -1 (default) uses all.
|
|
449
|
+
random_state (int | np.random.RandomState | None): Seed or generator for
|
|
450
|
+
the row shuffling. Defaults to None.
|
|
451
|
+
|
|
452
|
+
Returns:
|
|
453
|
+
dict: Keys `'similarity'` (float in [0, 1], one minus the Procrustes
|
|
454
|
+
disparity) and `'p'` (permutation p-value).
|
|
455
|
+
"""
|
|
456
|
+
|
|
457
|
+
# raise NotImplementedError("procrustes distance is not currently implemented")
|
|
458
|
+
if mat1.shape[0] != mat2.shape[0]:
|
|
459
|
+
raise ValueError("Both arrays must match on their first dimension")
|
|
460
|
+
|
|
461
|
+
random_state = check_random_state(random_state)
|
|
462
|
+
|
|
463
|
+
# Make sure both matrices are 2d and the same dimension via padding
|
|
464
|
+
_validate_tail_parameter(tail)
|
|
465
|
+
if len(mat1.shape) < 2:
|
|
466
|
+
mat1 = mat1[:, np.newaxis]
|
|
467
|
+
if len(mat2.shape) < 2:
|
|
468
|
+
mat2 = mat2[:, np.newaxis]
|
|
469
|
+
if mat1.shape[1] > mat2.shape[1]:
|
|
470
|
+
mat2 = np.pad(mat2, ((0, 0), (0, mat1.shape[1] - mat2.shape[1])), "constant")
|
|
471
|
+
elif mat2.shape[1] > mat1.shape[1]:
|
|
472
|
+
mat1 = np.pad(mat1, ((0, 0), (0, mat2.shape[1] - mat1.shape[1])), "constant")
|
|
473
|
+
|
|
474
|
+
# `procrust` (scipy.spatial.procrustes) returns a disparity in [0, 1] where
|
|
475
|
+
# LOWER means more similar. Convert to a similarity (higher = more similar)
|
|
476
|
+
# so the reported statistic matches the documented "similarity between 0 and
|
|
477
|
+
# 1" and, critically, so the observed value and the permutation null live on
|
|
478
|
+
# the SAME scale. Previously the observed disparity was compared against a
|
|
479
|
+
# null of similarities, inverting the scales and yielding p ~ 1 for
|
|
480
|
+
# near-identical matrices.
|
|
481
|
+
_, _, disparity = procrust(mat1, mat2)
|
|
482
|
+
observed_similarity = 1 - disparity
|
|
483
|
+
|
|
484
|
+
null_disparities = Parallel(n_jobs=n_jobs)(
|
|
485
|
+
delayed(procrust)(random_state.permutation(mat1), mat2)
|
|
486
|
+
for _ in range(n_permute)
|
|
487
|
+
)
|
|
488
|
+
null_similarity = [1 - x[2] for x in null_disparities]
|
|
489
|
+
|
|
490
|
+
# Use _compute_pvalue from inference module (signature: obs_stat, null_dist, tail)
|
|
491
|
+
stats = {"similarity": float(observed_similarity)}
|
|
492
|
+
stats["p"] = float(
|
|
493
|
+
_compute_pvalue(
|
|
494
|
+
np.array(observed_similarity), np.array(null_similarity), tail=tail
|
|
495
|
+
)[0]
|
|
496
|
+
)
|
|
497
|
+
|
|
498
|
+
return stats
|
|
499
|
+
|
|
500
|
+
|
|
501
|
+
def align_states(
|
|
502
|
+
reference,
|
|
503
|
+
target,
|
|
504
|
+
*,
|
|
505
|
+
metric="correlation",
|
|
506
|
+
return_index=False,
|
|
507
|
+
replace_zero_variance=False,
|
|
508
|
+
):
|
|
509
|
+
"""Align state weight maps by minimizing pairwise distance between group states.
|
|
510
|
+
|
|
511
|
+
This function uses the Hungarian algorithm for state alignment, which is
|
|
512
|
+
different from aligning multiple subjects' data.
|
|
513
|
+
|
|
514
|
+
Args:
|
|
515
|
+
reference (np.ndarray): Reference pattern x state matrix.
|
|
516
|
+
target (np.ndarray): Target pattern x state matrix to align to `reference`;
|
|
517
|
+
must have the same shape.
|
|
518
|
+
metric (str): Distance metric passed to `sklearn.metrics.pairwise_distances`.
|
|
519
|
+
Defaults to `'correlation'`.
|
|
520
|
+
return_index (bool): If True return the remapping index instead of the
|
|
521
|
+
reordered data. Defaults to False.
|
|
522
|
+
replace_zero_variance (bool): Replace zero-variance columns with uniform
|
|
523
|
+
random numbers before computing distances; avoids NaNs with the
|
|
524
|
+
correlation metric. Defaults to False.
|
|
525
|
+
|
|
526
|
+
Returns:
|
|
527
|
+
np.ndarray: If `return_index=False` (default), `target[:, remapping]` — the
|
|
528
|
+
target's columns reordered to match the reference, oriented pattern x
|
|
529
|
+
state (same shape as `target`). If `return_index=True`, the remapping
|
|
530
|
+
index array that reorders the target's state columns.
|
|
531
|
+
"""
|
|
532
|
+
if reference.shape != target.shape:
|
|
533
|
+
raise ValueError("reference and target must be the same size")
|
|
534
|
+
|
|
535
|
+
reference = np.array(reference)
|
|
536
|
+
target = np.array(target)
|
|
537
|
+
|
|
538
|
+
def replace_zero_variance_columns(data):
|
|
539
|
+
"""Replace zero-variance columns with random uniform noise.
|
|
540
|
+
|
|
541
|
+
Prevents NaN values when correlation-based distance metrics encounter
|
|
542
|
+
constant columns.
|
|
543
|
+
|
|
544
|
+
Args:
|
|
545
|
+
data (np.ndarray): 2-D array whose columns are checked for zero variance.
|
|
546
|
+
|
|
547
|
+
Returns:
|
|
548
|
+
np.ndarray: Array with zero-variance columns replaced by U(0, 1) values.
|
|
549
|
+
"""
|
|
550
|
+
if np.any(data.std(axis=0) == 0):
|
|
551
|
+
for i in np.where(data.std(axis=0) == 0)[0]:
|
|
552
|
+
data[:, i] = np.random.uniform(low=0, high=1, size=data.shape[0])
|
|
553
|
+
return data
|
|
554
|
+
|
|
555
|
+
if replace_zero_variance:
|
|
556
|
+
reference = replace_zero_variance_columns(reference)
|
|
557
|
+
target = replace_zero_variance_columns(target)
|
|
558
|
+
|
|
559
|
+
remapping = linear_sum_assignment(
|
|
560
|
+
pairwise_distances(reference.T, target.T, metric=metric)
|
|
561
|
+
)[1]
|
|
562
|
+
|
|
563
|
+
if return_index:
|
|
564
|
+
return remapping
|
|
565
|
+
return target[:, remapping]
|