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,758 @@
1
+ #!/usr/bin/env python
2
+ # coding: latin-1
3
+
4
+ """Shared Response Model (SRM) for multi-subject fMRI alignment.
5
+
6
+ SRM factorizes each subject's data as `X_i ≈ W_i S`: a shared low-dimensional
7
+ response `S` common to all subjects plus a subject-specific orthogonal
8
+ transform `W_i`. `_SRM` is the probabilistic model fit by
9
+ expectation-maximization; `_DetSRM` is the deterministic variant fit by block
10
+ coordinate descent.
11
+
12
+ **Algorithm.** Initialize each `W_i` as a random orthogonal matrix (QR of a
13
+ random matrix), then iterate: update the shared response `S` from the current
14
+ transforms, update each `W_i` by solving an orthogonal Procrustes problem
15
+ (`_SRM` also re-estimates the per-subject noise variance `rho_i^2` and the
16
+ shared-response covariance), for `n_iter` iterations.
17
+
18
+ **Performance.** Time is O(n_iter × (V T K + V K^2 + K^3)) and memory O(V T),
19
+ with V the total voxels across subjects, T samples, and K features (typically
20
+ V ≫ T ≫ K).
21
+
22
+ **When to use.** Cross-subject analyses that need a shared response space and
23
+ tolerate dimension reduction. Use `align(method='procrustes')` when spatial
24
+ structure and full dimensionality must be preserved.
25
+
26
+ **References.** Chen, P. H. C., Chen, J., Yeshurun, Y., Hasson, U., Haxby, J.,
27
+ & Ramadge, P. J. (2015). A reduced-dimension fMRI shared response model.
28
+ *Advances in Neural Information Processing Systems*, 460-468. Anderson, M. J.,
29
+ Capota, M., Turek, J. S., Zhu, X., Willke, T. L., Wang, Y., & Norman, K. A.
30
+ (2016). Enabling factor analysis on thousand-subject neuroimaging datasets.
31
+ *2016 IEEE International Conference on Big Data*, 1151-1160.
32
+
33
+ Copyright 2016 Intel Corporation
34
+
35
+ Licensed under the Apache License, Version 2.0 (the "License");
36
+ you may not use this file except in compliance with the License.
37
+ You may obtain a copy of the License at
38
+ http://www.apache.org/licenses/LICENSE-2.0
39
+
40
+ Unless required by applicable law or agreed to in writing, software
41
+ distributed under the License is distributed on an "AS IS" BASIS,
42
+ WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
43
+ See the License for the specific language governing permissions and
44
+ limitations under the License.
45
+
46
+ """
47
+
48
+ # Authors: Po-Hsuan Chen (Princeton Neuroscience Institute) and Javier Turek
49
+ # (Intel Labs), 2015
50
+ import logging
51
+
52
+ import numpy as np
53
+ import scipy
54
+ from typing import Any
55
+ from sklearn.base import BaseEstimator, TransformerMixin
56
+ from sklearn.utils import assert_all_finite
57
+ from sklearn.exceptions import NotFittedError
58
+ import sys
59
+
60
+
61
+ logger = logging.getLogger(__name__)
62
+
63
+
64
+ def _init_w_transforms(
65
+ data: list[np.ndarray], n_features: int, random_states: list[Any]
66
+ ) -> tuple[list[np.ndarray | None], np.ndarray]:
67
+ """Initialize the mappings $W_i$ for the SRM with random orthogonal matrices.
68
+
69
+ Each subject's transform is the Q factor of the QR decomposition of a
70
+ random (voxels_i, n_features) matrix drawn from that subject's own
71
+ `RandomState`, so the initial transforms are orthogonal and independent
72
+ across subjects. Subjects whose data is None get a None transform and a
73
+ voxel count of 0.
74
+
75
+ Args:
76
+ data (list[np.ndarray | None]): One (voxels_i, samples) array per subject.
77
+ n_features (int): Number of features in the model.
78
+ random_states (list[np.random.RandomState]): One generator per subject.
79
+
80
+ Returns:
81
+ tuple[list[np.ndarray | None], np.ndarray]: `(w, voxels)` — the initial
82
+ orthogonal transforms, element i of shape (voxels_i, n_features), and
83
+ an integer array with the number of voxels per subject.
84
+ """
85
+ w = []
86
+ subjects = len(data)
87
+ voxels = np.empty(subjects, dtype=int)
88
+
89
+ # Set Wi to a random orthogonal voxels by n_features matrix
90
+ # QR decomposition ensures orthogonality: Q is orthogonal, R is upper triangular
91
+ # This initialization strategy enables efficient Procrustes optimization later
92
+ for subject in range(subjects):
93
+ if data[subject] is not None:
94
+ voxels[subject] = data[subject].shape[0]
95
+ rnd_matrix = random_states[subject].random_sample(
96
+ (voxels[subject], n_features)
97
+ )
98
+ q, r = np.linalg.qr(rnd_matrix)
99
+ w.append(q)
100
+ else:
101
+ voxels[subject] = 0
102
+ w.append(None)
103
+
104
+ return w, voxels
105
+
106
+
107
+ class _SRM(BaseEstimator, TransformerMixin):
108
+ """Probabilistic Shared Response Model (SRM).
109
+
110
+ Factorizes multi-subject data as a shared response S plus one orthogonal
111
+ transform W per subject, so that for every subject i
112
+
113
+ $$
114
+ X_i \\approx W_i S, \\forall i=1 \\dots N
115
+ $$
116
+
117
+ The model is fit by the expectation-maximization algorithm of Chen et al.
118
+ (2015) with the optimizations of Anderson et al. (2016). Subjects may have
119
+ different numbers of voxels but must have the same number of samples. Run
120
+ time is
121
+ $O(I (V T K + V K^2 + K^3))$ and memory $O(V T)$, with I iterations, V the
122
+ sum of voxels across subjects, T samples, and K features (typically
123
+ $V \\gg T \\gg K$).
124
+
125
+ Args:
126
+ n_iter (int): Number of EM iterations. Defaults to 10.
127
+ n_features (int): Number of shared features to compute. Defaults to 50.
128
+ random_state (int): Seed for the random initialization. Defaults to 0.
129
+
130
+ Attributes:
131
+ w_ (list[np.ndarray]): Per-subject orthogonal transforms, element i of
132
+ shape (voxels_i, n_features).
133
+ s_ (np.ndarray): The shared response, shape (n_features, samples).
134
+ sigma_s_ (np.ndarray): Covariance of the shared response's Normal
135
+ distribution, shape (n_features, n_features).
136
+ mu_ (list[np.ndarray]): Per-subject voxel means over samples, element i
137
+ of shape (voxels_i,).
138
+ rho2_ (np.ndarray): Estimated noise variance $\\rho_i^2$ per subject,
139
+ shape (subjects,).
140
+ random_state_ (np.random.RandomState): Generator seeded from `random_state`.
141
+
142
+ Examples:
143
+ ```python
144
+ import numpy as np
145
+ from nltools.algorithms import SRM
146
+
147
+ data = [np.random.randn(100, 50) for _ in range(3)] # 3 subjects
148
+
149
+ srm = _SRM(n_iter=10, n_features=50)
150
+ srm.fit(data)
151
+ shared_responses = srm.transform(data) # list of (50, 50) arrays
152
+
153
+ w = srm.w_ # subject-specific transforms
154
+ s = srm.s_ # shared response
155
+ ```
156
+ """
157
+
158
+ def __init__(
159
+ self, *, n_iter: int = 10, n_features: int = 50, random_state: int = 0
160
+ ) -> None:
161
+ self.n_iter = n_iter
162
+ self.n_features = n_features
163
+ self.random_state = random_state
164
+ return
165
+
166
+ def fit(self, X: list[np.ndarray], y: Any | None = None) -> "_SRM":
167
+ """Compute the probabilistic Shared Response Model.
168
+
169
+ Args:
170
+ X (list[np.ndarray]): One (voxels_i, samples) array per subject; all
171
+ subjects must have the same number of samples.
172
+ y (Any | None): Ignored; present for scikit-learn compatibility.
173
+
174
+ Returns:
175
+ _SRM: Fitted model (`self`).
176
+ """
177
+ logger.info("Starting Probabilistic SRM")
178
+
179
+ # Check the number of subjects
180
+ if len(X) <= 1:
181
+ raise ValueError(
182
+ f"There are not enough subjects ({len(X):d}) to train the model."
183
+ )
184
+
185
+ # Check for input data sizes
186
+ if X[0].shape[1] < self.n_features:
187
+ raise ValueError(
188
+ "There are not enough samples to train the model with "
189
+ f"{self.n_features:d} features."
190
+ )
191
+
192
+ # Check if all subjects have same number of samples
193
+ sample_counts = [subj.shape[1] for subj in X]
194
+ number_subjects = len(X)
195
+ if len(set(sample_counts)) > 1:
196
+ raise ValueError(
197
+ f"Different number of samples between subjects: {sample_counts}."
198
+ )
199
+
200
+ # Validate all data is finite
201
+ for subject in range(number_subjects):
202
+ if X[subject] is not None:
203
+ assert_all_finite(X[subject])
204
+
205
+ # Run SRM
206
+ self.sigma_s_, self.w_, self.mu_, self.rho2_, self.s_ = self._srm(X)
207
+
208
+ return self
209
+
210
+ def transform(
211
+ self, X: list[np.ndarray], y: Any | None = None
212
+ ) -> list[np.ndarray | None]:
213
+ """Project each subject's data into the shared response space.
214
+
215
+ Args:
216
+ X (list[np.ndarray | None]): One (voxels_i, samples_i) array per
217
+ fitted subject, in the same order as `fit`; voxel and sample
218
+ counts may vary across subjects. A None entry yields None.
219
+ y (Any | None): Ignored; present for scikit-learn compatibility.
220
+
221
+ Returns:
222
+ list[np.ndarray | None]: Shared responses, element i of shape
223
+ (n_features, samples_i).
224
+ """
225
+
226
+ # Check if the model exist
227
+ if hasattr(self, "w_") is False:
228
+ raise NotFittedError("The model fit has not been run yet.")
229
+
230
+ # Check the number of subjects
231
+ if len(X) != len(self.w_):
232
+ raise ValueError(
233
+ "The number of subjects does not match the one in the model."
234
+ )
235
+
236
+ s: list[np.ndarray | None] = [None] * len(X)
237
+ for subject in range(len(X)):
238
+ if X[subject] is not None:
239
+ s[subject] = self.w_[subject].T.dot(X[subject])
240
+
241
+ return s
242
+
243
+ def _init_structures(self, data, subjects):
244
+ """Initialize the EM data structures and demean the data.
245
+
246
+ Removes each subject's voxel means (subject-specific baselines), sets
247
+ the initial noise variance to 1.0, and precomputes $||X_i||_F^2$ for
248
+ the likelihood computation.
249
+
250
+ Args:
251
+ data (list[np.ndarray | None]): One (voxels_i, samples) array per
252
+ subject.
253
+ subjects (int): Number of subjects in `data`.
254
+
255
+ Returns:
256
+ tuple[list[np.ndarray], list[np.ndarray], np.ndarray, np.ndarray]: `(x,
257
+ mu, rho2, trace_xtx)` — the demeaned data per subject (element i of
258
+ shape (voxels_i, samples)), the voxel means per subject (element i
259
+ of shape (voxels_i,)), the initial noise variance $\\rho^2$ per
260
+ subject (shape (subjects,)), and the squared Frobenius norm of
261
+ each subject's data (shape (subjects,)).
262
+ """
263
+ x = []
264
+ mu = []
265
+ rho2 = np.zeros(subjects)
266
+
267
+ trace_xtx = np.zeros(subjects)
268
+
269
+ # Initialize noise variance to 1.0 (unit variance assumption)
270
+ # This will be updated during EM iterations
271
+ for subject in range(subjects):
272
+ rho2[subject] = 1
273
+ if data[subject] is not None:
274
+ mu.append(np.mean(data[subject], 1))
275
+ trace_xtx[subject] = np.sum(data[subject] ** 2)
276
+ x.append(data[subject] - mu[subject][:, np.newaxis])
277
+ else:
278
+ mu.append(None)
279
+ trace_xtx[subject] = 0
280
+ x.append(None)
281
+
282
+ return x, mu, rho2, trace_xtx
283
+
284
+ def _likelihood(
285
+ self,
286
+ chol_sigma_s_rhos,
287
+ log_det_psi,
288
+ chol_sigma_s,
289
+ trace_xt_invsigma2_x,
290
+ inv_sigma_s_rhos,
291
+ wt_invpsi_x,
292
+ samples,
293
+ ):
294
+ """Calculate the log-likelihood (up to a constant) for convergence logging.
295
+
296
+ Log-determinants come from the Cholesky factors rather than explicit
297
+ inverses, for numerical stability.
298
+
299
+ Args:
300
+ chol_sigma_s_rhos (np.ndarray): Cholesky factor of
301
+ $(\\Sigma_S + \\sum_i(1/\\rho_i^2) I)$, shape (n_features, n_features).
302
+ log_det_psi (float): Log-determinant of the diagonal matrix Psi
303
+ (each $\\rho_i^2$ repeated voxels_i times).
304
+ chol_sigma_s (np.ndarray): Cholesky factor of $\\Sigma_S$, shape
305
+ (n_features, n_features).
306
+ trace_xt_invsigma2_x (float): $\\sum_i ||X_i||_F^2 / \\rho_i^2$.
307
+ inv_sigma_s_rhos (np.ndarray): Inverse of
308
+ $(\\Sigma_S + \\sum_i(1/\\rho_i^2) I)$, shape (n_features, n_features).
309
+ wt_invpsi_x (np.ndarray): $\\sum_i W_i^T X_i / \\rho_i^2$, shape
310
+ (n_features, samples).
311
+ samples (int): Number of samples in the data.
312
+
313
+ Returns:
314
+ float: The log-likelihood value.
315
+ """
316
+ # Compute log-determinant using Cholesky factors (numerically stable)
317
+ log_det = (
318
+ np.log(np.diag(chol_sigma_s_rhos) ** 2).sum()
319
+ + log_det_psi
320
+ + np.log(np.diag(chol_sigma_s) ** 2).sum()
321
+ )
322
+ # Log-likelihood: -0.5 * (determinant terms + trace terms) + quadratic form
323
+ loglikehood = -0.5 * samples * log_det - 0.5 * trace_xt_invsigma2_x
324
+ loglikehood += 0.5 * np.trace(
325
+ wt_invpsi_x.T.dot(inv_sigma_s_rhos).dot(wt_invpsi_x)
326
+ )
327
+ # + const --> -0.5*nTR*nvoxel*subjects*math.log(2*math.pi)
328
+
329
+ return loglikehood
330
+
331
+ @staticmethod
332
+ def _update_transform_subject(Xi, S):
333
+ """Update the mapping $W_i$ for one subject.
334
+
335
+ Solves the orthogonal Procrustes problem
336
+ $\\min ||X_i - W_i S||_F^2$ subject to $W_i^T W_i = I$: with the SVD
337
+ $U \\Sigma V^T = X_i S^T$, the optimum is $W_i = U V^T$.
338
+
339
+ Args:
340
+ Xi (np.ndarray): The subject's data $X_i$, shape (voxels, timepoints).
341
+ S (np.ndarray): The shared response, shape (n_features, timepoints).
342
+
343
+ Returns:
344
+ np.ndarray: The orthogonal transform $W_i$, shape (voxels, n_features).
345
+ """
346
+ # Compute cross-covariance: X_i S^T
347
+ A = Xi.dot(S.T)
348
+ # Solve the Procrustes problem via SVD
349
+ # Optimal orthogonal transform: W_i = U V^T where A = U Σ V^T
350
+ U, _, V = np.linalg.svd(A, full_matrices=False)
351
+ return U.dot(V)
352
+
353
+ def transform_subject(self, X: np.ndarray) -> np.ndarray:
354
+ """Transform a new subject using the existing model.
355
+
356
+ The subject is assumed to have received equivalent stimulation.
357
+
358
+ Args:
359
+ X (np.ndarray): The new subject's data, shape (voxels, timepoints);
360
+ the timepoints must match the fitted shared response.
361
+
362
+ Returns:
363
+ np.ndarray: Orthogonal mapping $W_{new}$ for the new subject, shape
364
+ (voxels, n_features).
365
+ """
366
+ # Check if the model exist
367
+ if hasattr(self, "w_") is False:
368
+ raise NotFittedError("The model fit has not been run yet.")
369
+
370
+ # Check the number of TRs in the subject
371
+ if X.shape[1] != self.s_.shape[1]:
372
+ raise ValueError(
373
+ "The number of timepoints(TRs) does not match the one in the model."
374
+ )
375
+
376
+ w = self._update_transform_subject(X, self.s_)
377
+
378
+ return w
379
+
380
+ def _srm(self, data):
381
+ """Expectation-maximization algorithm for fitting the probabilistic SRM.
382
+
383
+ Args:
384
+ data (list[np.ndarray | None]): One (voxels_i, samples) array per
385
+ subject.
386
+
387
+ Returns:
388
+ tuple[np.ndarray, list[np.ndarray], list[np.ndarray], np.ndarray, np.ndarray]:
389
+ `(sigma_s, w, mu, rho2, s)` — the shared-response covariance
390
+ $\\Sigma_s$ (shape (n_features, n_features)), the per-subject
391
+ orthogonal transforms $W_i$ (element i of shape (voxels_i,
392
+ n_features)), the per-subject voxel means $\\mu_i$ (element i of
393
+ shape (voxels_i,)), the per-subject noise variance $\\rho_i^2$
394
+ (shape (subjects,)), and the shared response (shape (n_features,
395
+ samples)).
396
+ """
397
+
398
+ samples = min([d.shape[1] for d in data if d is not None], default=sys.maxsize)
399
+ subjects = len(data)
400
+ self.random_state_ = np.random.RandomState(self.random_state)
401
+ random_states = [
402
+ np.random.RandomState(self.random_state_.randint(2**32 - 1, dtype=np.int64))
403
+ for i in range(len(data))
404
+ ]
405
+
406
+ # Initialization step: initialize the outputs with initial values,
407
+ # voxels with the number of voxels in each subject, and trace_xtx with
408
+ # the ||X_i||_F^2 of each subject.
409
+ w, voxels = _init_w_transforms(data, self.n_features, random_states)
410
+ x, mu, rho2, trace_xtx = self._init_structures(data, subjects)
411
+ shared_response = np.zeros((self.n_features, samples))
412
+ sigma_s = np.identity(self.n_features)
413
+
414
+ # Main loop of the algorithm (EM iterations)
415
+ # E-step: Update shared response S given current transforms W_i
416
+ # M-step: Update transforms W_i and noise variances rho_i^2 given S
417
+ for iteration in range(self.n_iter):
418
+ logger.info("Iteration %d", iteration + 1)
419
+
420
+ # E-step: Update shared response S
421
+
422
+ # Sum the inverted the rho2 elements for computing W^T * Psi^-1 * W
423
+ rho0 = (1 / rho2).sum()
424
+
425
+ # Invert Sigma_s using Cholesky factorization
426
+ (chol_sigma_s, lower_sigma_s) = scipy.linalg.cho_factor(
427
+ sigma_s, check_finite=False
428
+ )
429
+ inv_sigma_s = scipy.linalg.cho_solve(
430
+ (chol_sigma_s, lower_sigma_s),
431
+ np.identity(self.n_features),
432
+ check_finite=False,
433
+ )
434
+
435
+ # Invert (Sigma_s + rho_0 * I) using Cholesky factorization
436
+ sigma_s_rhos = inv_sigma_s + np.identity(self.n_features) * rho0
437
+ chol_sigma_s_rhos, lower_sigma_s_rhos = scipy.linalg.cho_factor(
438
+ sigma_s_rhos, check_finite=False
439
+ )
440
+ inv_sigma_s_rhos = scipy.linalg.cho_solve(
441
+ (chol_sigma_s_rhos, lower_sigma_s_rhos),
442
+ np.identity(self.n_features),
443
+ check_finite=False,
444
+ )
445
+
446
+ # Compute the sum of W_i^T * rho_i^-2 * X_i, and the sum of traces
447
+ # of X_i^T * rho_i^-2 * X_i
448
+ wt_invpsi_x = np.zeros((self.n_features, samples))
449
+ trace_xt_invsigma2_x = 0.0
450
+ for subject in range(subjects):
451
+ if data[subject] is not None:
452
+ wt_invpsi_x += (w[subject].T.dot(x[subject])) / rho2[subject]
453
+ trace_xt_invsigma2_x += trace_xtx[subject] / rho2[subject]
454
+
455
+ log_det_psi = np.sum(np.log(rho2) * voxels)
456
+
457
+ # Update the shared response S (E-step)
458
+ # Weighted average of transformed data: S = Σ_s (I - rho0 * inv(Σ_s + rho0*I)) @ W^T @ Psi^{-1} @ X
459
+ shared_response = sigma_s.dot(
460
+ np.identity(self.n_features) - rho0 * inv_sigma_s_rhos
461
+ ).dot(wt_invpsi_x)
462
+
463
+ # M-step: Update transforms W_i and noise variances rho_i^2
464
+
465
+ # Update Sigma_s and compute its trace
466
+ sigma_s = (
467
+ inv_sigma_s_rhos + shared_response.dot(shared_response.T) / samples
468
+ )
469
+ trace_sigma_s = samples * np.trace(sigma_s)
470
+
471
+ # Update each subject's mapping transform W_i and error variance rho_i^2
472
+ # Each subject's transform is updated independently via Procrustes optimization
473
+ # Noise variance is updated based on residual error after transform update
474
+ for subject in range(subjects):
475
+ if x[subject] is not None:
476
+ a_subject = x[subject].dot(shared_response.T)
477
+ perturbation = np.zeros(a_subject.shape)
478
+ np.fill_diagonal(perturbation, 0.001)
479
+ u_subject, s_subject, v_subject = np.linalg.svd(
480
+ a_subject + perturbation, full_matrices=False
481
+ )
482
+ w[subject] = u_subject.dot(v_subject)
483
+ rho2[subject] = trace_xtx[subject]
484
+ rho2[subject] += -2 * np.sum(w[subject] * a_subject)
485
+ rho2[subject] += trace_sigma_s
486
+ rho2[subject] /= samples * voxels[subject]
487
+ else:
488
+ rho2[subject] = 0
489
+ if logger.isEnabledFor(logging.INFO):
490
+ # Calculate and log the current log-likelihood for checking
491
+ # convergence
492
+ loglike = self._likelihood(
493
+ chol_sigma_s_rhos,
494
+ log_det_psi,
495
+ chol_sigma_s,
496
+ trace_xt_invsigma2_x,
497
+ inv_sigma_s_rhos,
498
+ wt_invpsi_x,
499
+ samples,
500
+ )
501
+ logger.info(f"Objective function {loglike:f}")
502
+
503
+ return sigma_s, w, mu, rho2, shared_response
504
+
505
+
506
+ class _DetSRM(BaseEstimator, TransformerMixin):
507
+ """Deterministic Shared Response Model (DetSRM).
508
+
509
+ Factorizes multi-subject data as a shared response S plus one orthogonal
510
+ transform W per subject, so that for every subject i
511
+
512
+ $$
513
+ X_i \\approx W_i S, \\forall i=1 \\dots N
514
+ $$
515
+
516
+ The model is fit by the block coordinate descent algorithm of Chen et al.
517
+ (2015). Subjects may have different numbers of voxels but must have the
518
+ same number of samples. Run time is $O(I (V T K + V K^2))$ and memory
519
+ $O(V T)$, with I iterations, V the sum of voxels across subjects, T
520
+ samples, and K features (typically $V \\gg T \\gg K$).
521
+
522
+ Args:
523
+ n_iter (int): Number of coordinate-descent iterations. Defaults to 10.
524
+ n_features (int): Number of shared features to compute. Defaults to 50.
525
+ random_state (int): Seed for the random initialization. Defaults to 0.
526
+
527
+ Attributes:
528
+ w_ (list[np.ndarray]): Per-subject orthogonal transforms, element i of
529
+ shape (voxels_i, n_features).
530
+ s_ (np.ndarray): The shared response, shape (n_features, samples).
531
+ random_state_ (np.random.RandomState): Generator seeded from `random_state`.
532
+
533
+ Examples:
534
+ ```python
535
+ import numpy as np
536
+ from nltools.algorithms import DetSRM
537
+
538
+ data = [np.random.randn(100, 50) for _ in range(3)] # 3 subjects
539
+
540
+ detsrm = _DetSRM(n_iter=10, n_features=50)
541
+ detsrm.fit(data)
542
+ shared_responses = detsrm.transform(data) # list of (50, 50) arrays
543
+
544
+ w = detsrm.w_ # subject-specific transforms
545
+ s = detsrm.s_ # shared response
546
+ ```
547
+ """
548
+
549
+ def __init__(
550
+ self, *, n_iter: int = 10, n_features: int = 50, random_state: int = 0
551
+ ) -> None:
552
+ self.n_iter = n_iter
553
+ self.n_features = n_features
554
+ self.random_state = random_state
555
+
556
+ def fit(self, X: list[np.ndarray], y: Any | None = None) -> "_DetSRM":
557
+ """Compute the Deterministic Shared Response Model.
558
+
559
+ Args:
560
+ X (list[np.ndarray]): One (voxels_i, samples) array per subject; all
561
+ subjects must have the same number of samples.
562
+ y (Any | None): Ignored; present for scikit-learn compatibility.
563
+
564
+ Returns:
565
+ _DetSRM: Fitted model (`self`).
566
+ """
567
+ logger.info("Starting Deterministic SRM")
568
+
569
+ # Check the number of subjects
570
+ if len(X) <= 1:
571
+ raise ValueError(
572
+ f"There are not enough subjects ({len(X):d}) to train the model."
573
+ )
574
+
575
+ # Check for input data sizes
576
+ if X[0].shape[1] < self.n_features:
577
+ raise ValueError(
578
+ "There are not enough samples to train the model with "
579
+ f"{self.n_features:d} features."
580
+ )
581
+
582
+ # Check if all subjects have same number of TRs
583
+ number_trs = X[0].shape[1]
584
+ number_subjects = len(X)
585
+ for subject in range(number_subjects):
586
+ assert_all_finite(X[subject])
587
+ if X[subject].shape[1] != number_trs:
588
+ raise ValueError("Different number of samples between subjects.")
589
+
590
+ # Run SRM
591
+ self.w_, self.s_ = self._srm(X)
592
+
593
+ return self
594
+
595
+ def transform(self, X: list[np.ndarray], y: Any | None = None) -> list[np.ndarray]:
596
+ """Project each subject's data into the shared response subspace.
597
+
598
+ Args:
599
+ X (list[np.ndarray]): One (voxels_i, samples_i) array per fitted
600
+ subject, in the same order as `fit`; voxel and sample counts may
601
+ vary across subjects.
602
+ y (Any | None): Ignored; present for scikit-learn compatibility.
603
+
604
+ Returns:
605
+ list[np.ndarray]: Shared responses, element i of shape
606
+ (n_features, samples_i).
607
+ """
608
+
609
+ # Check if the model exist
610
+ if hasattr(self, "w_") is False:
611
+ raise NotFittedError("The model fit has not been run yet.")
612
+
613
+ # Check the number of subjects
614
+ if len(X) != len(self.w_):
615
+ raise ValueError(
616
+ "The number of subjects does not match the one in the model."
617
+ )
618
+
619
+ return [self.w_[subject].T.dot(X[subject]) for subject in range(len(X))]
620
+
621
+ def _objective_function(self, data, w, s):
622
+ """Calculate the objective function (mean squared reconstruction error).
623
+
624
+ Args:
625
+ data (list[np.ndarray]): One (voxels_i, samples) array per subject.
626
+ w (list[np.ndarray]): Per-subject orthogonal transforms $W_i$, element
627
+ i of shape (voxels_i, n_features).
628
+ s (np.ndarray): The shared response, shape (n_features, samples).
629
+
630
+ Returns:
631
+ float: $\\frac{1}{2T} \\sum_i ||X_i - W_i S||_F^2$.
632
+ """
633
+ subjects = len(data)
634
+ objective = 0.0
635
+ for m in range(subjects):
636
+ objective += np.linalg.norm(data[m] - w[m].dot(s), "fro") ** 2
637
+
638
+ return objective * 0.5 / data[0].shape[1]
639
+
640
+ def _compute_shared_response(self, data, w):
641
+ """Compute the shared response S as the mean of $W_i^T X_i$ over subjects.
642
+
643
+ Args:
644
+ data (list[np.ndarray]): One (voxels_i, samples) array per subject.
645
+ w (list[np.ndarray]): Per-subject orthogonal transforms $W_i$, element
646
+ i of shape (voxels_i, n_features).
647
+
648
+ Returns:
649
+ np.ndarray: The shared response, shape (n_features, samples).
650
+ """
651
+ s = np.zeros((w[0].shape[1], data[0].shape[1]))
652
+ for m in range(len(w)):
653
+ s = s + w[m].T.dot(data[m])
654
+ s /= len(w)
655
+
656
+ return s
657
+
658
+ @staticmethod
659
+ def _update_transform_subject(Xi, S):
660
+ """Update the mapping $W_i$ for one subject.
661
+
662
+ Solves the orthogonal Procrustes problem
663
+ $\\min ||X_i - W_i S||_F^2$ subject to $W_i^T W_i = I$: with the SVD
664
+ $U \\Sigma V^T = X_i S^T$, the optimum is $W_i = U V^T$.
665
+
666
+ Args:
667
+ Xi (np.ndarray): The subject's data $X_i$, shape (voxels, timepoints).
668
+ S (np.ndarray): The shared response, shape (n_features, timepoints).
669
+
670
+ Returns:
671
+ np.ndarray: The orthogonal transform $W_i$, shape (voxels, n_features).
672
+ """
673
+ # Compute cross-covariance: X_i S^T
674
+ A = Xi.dot(S.T)
675
+ # Solve the Procrustes problem via SVD
676
+ # Optimal orthogonal transform: W_i = U V^T where A = U Σ V^T
677
+ U, _, V = np.linalg.svd(A, full_matrices=False)
678
+ return U.dot(V)
679
+
680
+ def transform_subject(self, X: np.ndarray) -> np.ndarray:
681
+ """Transform a new subject using the existing model.
682
+
683
+ The subject is assumed to have received equivalent stimulation.
684
+
685
+ Args:
686
+ X (np.ndarray): The new subject's data, shape (voxels, timepoints);
687
+ the timepoints must match the fitted shared response.
688
+
689
+ Returns:
690
+ np.ndarray: Orthogonal mapping $W_{new}$ for the new subject, shape
691
+ (voxels, n_features).
692
+ """
693
+ # Check if the model exist
694
+ if hasattr(self, "w_") is False:
695
+ raise NotFittedError("The model fit has not been run yet.")
696
+
697
+ # Check the number of TRs in the subject
698
+ if X.shape[1] != self.s_.shape[1]:
699
+ raise ValueError(
700
+ "The number of timepoints(TRs) does not match the one in the model."
701
+ )
702
+
703
+ w = self._update_transform_subject(X, self.s_)
704
+
705
+ return w
706
+
707
+ def _srm(self, data):
708
+ """Block coordinate descent algorithm for fitting the deterministic SRM.
709
+
710
+ Args:
711
+ data (list[np.ndarray]): One (voxels_i, samples) array per subject.
712
+
713
+ Returns:
714
+ tuple[list[np.ndarray], np.ndarray]: `(w, s)` — the per-subject
715
+ orthogonal transforms $W_i$ (element i of shape (voxels_i,
716
+ n_features)) and the shared response (shape (n_features, samples)).
717
+ """
718
+
719
+ subjects = len(data)
720
+
721
+ self.random_state_ = np.random.RandomState(self.random_state)
722
+ random_states = [
723
+ np.random.RandomState(self.random_state_.randint(2**32 - 1, dtype=np.int64))
724
+ for i in range(len(data))
725
+ ]
726
+
727
+ # Initialization step: initialize the outputs with initial values,
728
+ # voxels with the number of voxels in each subject.
729
+ w, _ = _init_w_transforms(data, self.n_features, random_states)
730
+ shared_response = self._compute_shared_response(data, w)
731
+ if logger.isEnabledFor(logging.INFO):
732
+ # Calculate the current objective function value
733
+ objective = self._objective_function(data, w, shared_response)
734
+ logger.info(f"Objective function {objective:f}")
735
+
736
+ # Main loop of the algorithm
737
+ for iteration in range(self.n_iter):
738
+ logger.info("Iteration %d", iteration + 1)
739
+
740
+ # Update each subject's mapping transform W_i:
741
+ for subject in range(subjects):
742
+ a_subject = data[subject].dot(shared_response.T)
743
+ perturbation = np.zeros(a_subject.shape)
744
+ np.fill_diagonal(perturbation, 0.001)
745
+ u_subject, _, v_subject = np.linalg.svd(
746
+ a_subject + perturbation, full_matrices=False
747
+ )
748
+ w[subject] = u_subject.dot(v_subject)
749
+
750
+ # Update the shared response:
751
+ shared_response = self._compute_shared_response(data, w)
752
+
753
+ if logger.isEnabledFor(logging.INFO):
754
+ # Calculate the current objective function value
755
+ objective = self._objective_function(data, w, shared_response)
756
+ logger.info(f"Objective function {objective:f}")
757
+
758
+ return w, shared_response