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,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
|