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.
Files changed (95) hide show
  1. nltools/__init__.py +55 -0
  2. nltools/algorithms/__init__.py +90 -0
  3. nltools/algorithms/alignment/__init__.py +21 -0
  4. nltools/algorithms/alignment/procrustes.py +565 -0
  5. nltools/algorithms/alignment/srm.py +758 -0
  6. nltools/algorithms/backends.py +1059 -0
  7. nltools/algorithms/corrections.py +177 -0
  8. nltools/algorithms/decoding.py +327 -0
  9. nltools/algorithms/inference/__init__.py +50 -0
  10. nltools/algorithms/inference/bootstrap.py +1386 -0
  11. nltools/algorithms/inference/correlation.py +373 -0
  12. nltools/algorithms/inference/intersubject.py +422 -0
  13. nltools/algorithms/inference/isc.py +1554 -0
  14. nltools/algorithms/inference/matrix.py +602 -0
  15. nltools/algorithms/inference/one_sample.py +288 -0
  16. nltools/algorithms/inference/random.py +122 -0
  17. nltools/algorithms/inference/timeseries.py +347 -0
  18. nltools/algorithms/inference/two_sample.py +212 -0
  19. nltools/algorithms/inference/utils.py +58 -0
  20. nltools/algorithms/inference/validation.py +282 -0
  21. nltools/algorithms/neighborhoods.py +207 -0
  22. nltools/algorithms/outliers.py +308 -0
  23. nltools/algorithms/regression.py +83 -0
  24. nltools/algorithms/signal.py +303 -0
  25. nltools/algorithms/similarity.py +234 -0
  26. nltools/algorithms/validation.py +151 -0
  27. nltools/cross_validation.py +72 -0
  28. nltools/data/__init__.py +30 -0
  29. nltools/data/adjacency/__init__.py +875 -0
  30. nltools/data/adjacency/io.py +111 -0
  31. nltools/data/adjacency/modeling.py +569 -0
  32. nltools/data/adjacency/plotting.py +174 -0
  33. nltools/data/adjacency/state.py +349 -0
  34. nltools/data/adjacency/stats.py +596 -0
  35. nltools/data/adjacency/utils.py +79 -0
  36. nltools/data/atlases/__init__.py +23 -0
  37. nltools/data/atlases/labeling.py +158 -0
  38. nltools/data/atlases/loading.py +76 -0
  39. nltools/data/atlases/registry.py +96 -0
  40. nltools/data/atlases/reporting.py +456 -0
  41. nltools/data/braindata/__init__.py +2170 -0
  42. nltools/data/braindata/analysis.py +1381 -0
  43. nltools/data/braindata/bootstrap.py +398 -0
  44. nltools/data/braindata/io.py +896 -0
  45. nltools/data/braindata/modeling.py +594 -0
  46. nltools/data/braindata/plotting.py +501 -0
  47. nltools/data/braindata/prediction.py +1250 -0
  48. nltools/data/braindata/utils.py +348 -0
  49. nltools/data/braindata/validation.py +197 -0
  50. nltools/data/braindata/viewer.js +266 -0
  51. nltools/data/braindata/viewer.py +770 -0
  52. nltools/data/combine.py +27 -0
  53. nltools/data/designmatrix/__init__.py +1032 -0
  54. nltools/data/designmatrix/append.py +518 -0
  55. nltools/data/designmatrix/diagnostics.py +248 -0
  56. nltools/data/designmatrix/io.py +356 -0
  57. nltools/data/designmatrix/plotting.py +291 -0
  58. nltools/data/designmatrix/regressors.py +463 -0
  59. nltools/data/designmatrix/transforms.py +200 -0
  60. nltools/data/designmatrix/utils.py +350 -0
  61. nltools/data/ownership.py +129 -0
  62. nltools/data/results.py +291 -0
  63. nltools/data/roc/__init__.py +398 -0
  64. nltools/data/simulator/__init__.py +927 -0
  65. nltools/data/simulator/haxby.py +124 -0
  66. nltools/data/validation.py +83 -0
  67. nltools/datasets.py +218 -0
  68. nltools/io/__init__.py +10 -0
  69. nltools/io/events.py +67 -0
  70. nltools/io/h5.py +246 -0
  71. nltools/mask.py +403 -0
  72. nltools/models/__init__.py +11 -0
  73. nltools/models/glm.py +543 -0
  74. nltools/models/results.py +49 -0
  75. nltools/models/ridge.py +1303 -0
  76. nltools/models/validation.py +26 -0
  77. nltools/plotting/__init__.py +32 -0
  78. nltools/plotting/adjacency.py +421 -0
  79. nltools/plotting/brain.py +669 -0
  80. nltools/plotting/decomposition.py +111 -0
  81. nltools/plotting/prediction.py +110 -0
  82. nltools/resources/covariates_example.csv +161 -0
  83. nltools/resources/onsets_example.csv +40 -0
  84. nltools/templates/__init__.py +51 -0
  85. nltools/templates/config.py +144 -0
  86. nltools/templates/fetch.py +260 -0
  87. nltools/templates/matching.py +183 -0
  88. nltools/templates/paths.py +106 -0
  89. nltools/templates/registry.py +25 -0
  90. nltools/utils.py +230 -0
  91. nltools/version.py +13 -0
  92. nltools-0.6.0.dev0.dist-info/METADATA +95 -0
  93. nltools-0.6.0.dev0.dist-info/RECORD +95 -0
  94. nltools-0.6.0.dev0.dist-info/WHEEL +4 -0
  95. 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]