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,1554 @@
|
|
|
1
|
+
"""Intersubject correlation (ISC) with permutation and bootstrap inference.
|
|
2
|
+
|
|
3
|
+
Computes leave-one-out and pairwise ISC and tests it with the subject-wise
|
|
4
|
+
bootstrap of Chen et al. (2016) or surrogate time series (circular shift,
|
|
5
|
+
phase randomization), plus a two-group ISC difference test. Resamples run on
|
|
6
|
+
joblib workers; `n_jobs` sets how many, and a given `random_state` gives the
|
|
7
|
+
same result at any worker count. Pairwise correlations are stored in condensed
|
|
8
|
+
(upper-triangle) form.
|
|
9
|
+
|
|
10
|
+
Leave-one-out and pairwise ISC are monotonically related but statistically
|
|
11
|
+
different: leave-one-out is O(n_subjects) and gives an unbiased subject-level
|
|
12
|
+
estimate; pairwise captures the full correlation structure but is
|
|
13
|
+
O(n_subjects²).
|
|
14
|
+
|
|
15
|
+
References:
|
|
16
|
+
Chen, G., Shin, Y. W., Taylor, P. A., Glen, D. R., Reynolds, R. C.,
|
|
17
|
+
Israel, R. B., & Cox, R. W. (2016). Untangling the relatedness among
|
|
18
|
+
correlations, part I: nonparametric approaches to inter-subject
|
|
19
|
+
correlation analysis at the group level. NeuroImage, 142, 248-259.
|
|
20
|
+
"""
|
|
21
|
+
|
|
22
|
+
import numpy as np
|
|
23
|
+
from typing import Literal, Any
|
|
24
|
+
from scipy.spatial.distance import squareform
|
|
25
|
+
from scipy.stats import rankdata
|
|
26
|
+
from sklearn.utils import check_random_state
|
|
27
|
+
from sklearn.metrics import pairwise_distances
|
|
28
|
+
|
|
29
|
+
from .utils import EPSILON, _maybe_tqdm
|
|
30
|
+
from ..validation import _compute_pvalue, _validate_tail_parameter
|
|
31
|
+
|
|
32
|
+
|
|
33
|
+
# ============================================================================
|
|
34
|
+
# Phase 1: Leave-One-Out (LOO) ISC Computation
|
|
35
|
+
# ============================================================================
|
|
36
|
+
|
|
37
|
+
|
|
38
|
+
def _compute_loo_isc(data):
|
|
39
|
+
"""Compute leave-one-out intersubject correlation.
|
|
40
|
+
|
|
41
|
+
For each subject, correlates their data with the mean of all other
|
|
42
|
+
subjects. This provides an unbiased estimate of subject-level ISC
|
|
43
|
+
and is computationally efficient (O(n_subjects) vs O(n_subjects²)).
|
|
44
|
+
|
|
45
|
+
Args:
|
|
46
|
+
data (np.ndarray): Shape `(n_observations, n_subjects)` for a single
|
|
47
|
+
feature or `(n_observations, n_subjects, n_voxels)` for voxel-wise
|
|
48
|
+
data.
|
|
49
|
+
|
|
50
|
+
Returns:
|
|
51
|
+
np.ndarray: Leave-one-out ISC values, shape `(n_subjects,)` for a single
|
|
52
|
+
feature or `(n_subjects, n_voxels)` for voxel-wise data.
|
|
53
|
+
|
|
54
|
+
Raises:
|
|
55
|
+
ValueError: If `data` is neither 2-D nor 3-D.
|
|
56
|
+
|
|
57
|
+
Examples:
|
|
58
|
+
```python
|
|
59
|
+
data = np.random.randn(100, 10) # 100 timepoints, 10 subjects
|
|
60
|
+
_compute_loo_isc(data).shape # → (10,)
|
|
61
|
+
```
|
|
62
|
+
"""
|
|
63
|
+
if data.ndim == 2:
|
|
64
|
+
# Single feature: (n_observations, n_subjects)
|
|
65
|
+
n_obs, n_subjects = data.shape
|
|
66
|
+
loo_values = np.zeros(n_subjects)
|
|
67
|
+
|
|
68
|
+
for i in range(n_subjects):
|
|
69
|
+
# Mean of all subjects except i
|
|
70
|
+
others_mean = data[:, np.arange(n_subjects) != i].mean(axis=1)
|
|
71
|
+
# Correlation between subject i and others' mean
|
|
72
|
+
loo_values[i] = np.corrcoef(data[:, i], others_mean)[0, 1]
|
|
73
|
+
|
|
74
|
+
return loo_values
|
|
75
|
+
|
|
76
|
+
if data.ndim == 3:
|
|
77
|
+
# Voxel-wise: (n_observations, n_subjects, n_voxels)
|
|
78
|
+
n_obs, n_subjects, n_voxels = data.shape
|
|
79
|
+
loo_values = np.zeros((n_subjects, n_voxels))
|
|
80
|
+
|
|
81
|
+
for v in range(n_voxels):
|
|
82
|
+
voxel_data = data[:, :, v]
|
|
83
|
+
for i in range(n_subjects):
|
|
84
|
+
others_mean = voxel_data[:, np.arange(n_subjects) != i].mean(axis=1)
|
|
85
|
+
loo_values[i, v] = np.corrcoef(voxel_data[:, i], others_mean)[0, 1]
|
|
86
|
+
|
|
87
|
+
return loo_values
|
|
88
|
+
|
|
89
|
+
raise ValueError(f"data must be 2D or 3D, got shape {data.shape}")
|
|
90
|
+
|
|
91
|
+
|
|
92
|
+
def _compute_pairwise_isc(data, metric="correlation"):
|
|
93
|
+
"""Compute pairwise intersubject correlation (condensed form).
|
|
94
|
+
|
|
95
|
+
Computes all n×(n-1)/2 pairwise correlations between subjects and
|
|
96
|
+
stores in condensed upper-triangle format for memory efficiency.
|
|
97
|
+
|
|
98
|
+
`'correlation'`, `'spearman'` (rank-transform then `np.corrcoef`),
|
|
99
|
+
`'cosine'` (normalized dot products), and `'euclidean'` (vectorized
|
|
100
|
+
squared distances) take fast vectorized paths; any other metric falls back
|
|
101
|
+
to the slower `sklearn.metrics.pairwise_distances`.
|
|
102
|
+
|
|
103
|
+
Args:
|
|
104
|
+
data (np.ndarray): Shape `(n_observations, n_subjects)` for a single
|
|
105
|
+
feature or `(n_observations, n_subjects, n_voxels)` for voxel-wise
|
|
106
|
+
data.
|
|
107
|
+
metric (str): `'correlation'` (Pearson, default), `'spearman'`,
|
|
108
|
+
`'cosine'`, `'euclidean'` (1 - distance), or any metric accepted by
|
|
109
|
+
`sklearn.metrics.pairwise_distances`.
|
|
110
|
+
|
|
111
|
+
Returns:
|
|
112
|
+
np.ndarray: Pairwise similarities in condensed upper-triangle form,
|
|
113
|
+
shape `(n_pairs,)` for a single feature (`n_pairs = n*(n-1)/2`) or
|
|
114
|
+
`(n_pairs, n_voxels)` for voxel-wise data.
|
|
115
|
+
|
|
116
|
+
Examples:
|
|
117
|
+
```python
|
|
118
|
+
data = np.random.randn(100, 5) # 5 subjects
|
|
119
|
+
_compute_pairwise_isc(data).shape # → (10,) (5*4/2 pairs)
|
|
120
|
+
```
|
|
121
|
+
"""
|
|
122
|
+
if metric == "correlation":
|
|
123
|
+
# Fast path: use np.corrcoef (optimized C implementation)
|
|
124
|
+
if data.ndim == 2:
|
|
125
|
+
# Single feature: (n_observations, n_subjects)
|
|
126
|
+
corr_matrix = np.corrcoef(data.T)
|
|
127
|
+
return squareform(corr_matrix, checks=False)
|
|
128
|
+
if data.ndim == 3:
|
|
129
|
+
# Voxel-wise: (n_observations, n_subjects, n_voxels)
|
|
130
|
+
n_obs, n_subjects, n_voxels = data.shape
|
|
131
|
+
n_pairs = n_subjects * (n_subjects - 1) // 2
|
|
132
|
+
pairwise_all = np.zeros((n_pairs, n_voxels))
|
|
133
|
+
for v in range(n_voxels):
|
|
134
|
+
corr_matrix = np.corrcoef(data[:, :, v].T)
|
|
135
|
+
pairwise_all[:, v] = squareform(corr_matrix, checks=False)
|
|
136
|
+
return pairwise_all
|
|
137
|
+
raise ValueError(f"data must be 2D or 3D, got shape {data.shape}")
|
|
138
|
+
if metric == "spearman":
|
|
139
|
+
# Spearman correlation: rank-transform then use fast np.corrcoef path
|
|
140
|
+
# Spearman = Pearson correlation of rank-transformed data
|
|
141
|
+
if data.ndim == 2:
|
|
142
|
+
# Single feature: (n_observations, n_subjects)
|
|
143
|
+
# Rank-transform each subject's time series
|
|
144
|
+
data_ranked = np.array(
|
|
145
|
+
[rankdata(data[:, i], method="average") for i in range(data.shape[1])]
|
|
146
|
+
).T
|
|
147
|
+
corr_matrix = np.corrcoef(data_ranked.T)
|
|
148
|
+
return squareform(corr_matrix, checks=False)
|
|
149
|
+
if data.ndim == 3:
|
|
150
|
+
# Voxel-wise: (n_observations, n_subjects, n_voxels)
|
|
151
|
+
n_obs, n_subjects, n_voxels = data.shape
|
|
152
|
+
n_pairs = n_subjects * (n_subjects - 1) // 2
|
|
153
|
+
pairwise_all = np.zeros((n_pairs, n_voxels))
|
|
154
|
+
|
|
155
|
+
# Rank-transform data per voxel, then use fast corrcoef path
|
|
156
|
+
for v in range(n_voxels):
|
|
157
|
+
# Rank-transform each subject's time series for this voxel
|
|
158
|
+
data_ranked = np.array(
|
|
159
|
+
[
|
|
160
|
+
rankdata(data[:, s, v], method="average")
|
|
161
|
+
for s in range(n_subjects)
|
|
162
|
+
]
|
|
163
|
+
).T
|
|
164
|
+
corr_matrix = np.corrcoef(data_ranked.T)
|
|
165
|
+
pairwise_all[:, v] = squareform(corr_matrix, checks=False)
|
|
166
|
+
return pairwise_all
|
|
167
|
+
raise ValueError(f"data must be 2D or 3D, got shape {data.shape}")
|
|
168
|
+
if metric == "cosine":
|
|
169
|
+
# Cosine similarity: normalized dot products
|
|
170
|
+
# Cosine similarity = dot(a, b) / (||a|| * ||b||)
|
|
171
|
+
# Optimized: normalize vectors, then compute dot product matrix
|
|
172
|
+
if data.ndim == 2:
|
|
173
|
+
# Single feature: (n_observations, n_subjects)
|
|
174
|
+
# Normalize each subject's time series
|
|
175
|
+
norms = np.linalg.norm(data, axis=0, keepdims=True)
|
|
176
|
+
data_norm = data / (norms + EPSILON) # Avoid division by zero
|
|
177
|
+
|
|
178
|
+
# Compute cosine similarity matrix: data_norm.T @ data_norm
|
|
179
|
+
sim_matrix = data_norm.T @ data_norm
|
|
180
|
+
return squareform(sim_matrix, checks=False)
|
|
181
|
+
if data.ndim == 3:
|
|
182
|
+
# Voxel-wise: (n_observations, n_subjects, n_voxels)
|
|
183
|
+
n_obs, n_subjects, n_voxels = data.shape
|
|
184
|
+
n_pairs = n_subjects * (n_subjects - 1) // 2
|
|
185
|
+
pairwise_all = np.zeros((n_pairs, n_voxels))
|
|
186
|
+
|
|
187
|
+
# Normalize and compute cosine similarity per voxel
|
|
188
|
+
for v in range(n_voxels):
|
|
189
|
+
# Normalize each subject's time series for this voxel
|
|
190
|
+
norms = np.linalg.norm(data[:, :, v], axis=0, keepdims=True)
|
|
191
|
+
data_norm = data[:, :, v] / (norms + EPSILON)
|
|
192
|
+
|
|
193
|
+
# Compute cosine similarity matrix
|
|
194
|
+
sim_matrix = data_norm.T @ data_norm
|
|
195
|
+
pairwise_all[:, v] = squareform(sim_matrix, checks=False)
|
|
196
|
+
return pairwise_all
|
|
197
|
+
raise ValueError(f"data must be 2D or 3D, got shape {data.shape}")
|
|
198
|
+
if metric == "euclidean":
|
|
199
|
+
# Euclidean distance: optimized using squared-distance formula
|
|
200
|
+
# ||a - b||^2 = ||a||^2 + ||b||^2 - 2*<a, b>
|
|
201
|
+
# Then: distance = sqrt(squared_distance), similarity = 1 - distance
|
|
202
|
+
if data.ndim == 2:
|
|
203
|
+
# Single feature: (n_observations, n_subjects)
|
|
204
|
+
# Compute squared norms for each subject
|
|
205
|
+
norms_sq = np.sum(data**2, axis=0) # (n_subjects,)
|
|
206
|
+
|
|
207
|
+
# Compute dot products: data.T @ data
|
|
208
|
+
dot_products = data.T @ data # (n_subjects, n_subjects)
|
|
209
|
+
|
|
210
|
+
# Compute squared distances: norms_sq[i] + norms_sq[j] - 2*dot_products[i, j]
|
|
211
|
+
# Broadcasting: (n_subjects, 1) + (1, n_subjects) - 2*dot_products
|
|
212
|
+
distances_sq = norms_sq[:, None] + norms_sq[None, :] - 2 * dot_products
|
|
213
|
+
|
|
214
|
+
# Compute distances (handle numerical errors with max(0))
|
|
215
|
+
distances = np.sqrt(np.maximum(distances_sq, 0))
|
|
216
|
+
|
|
217
|
+
# Convert to similarity: similarity = 1 - distance
|
|
218
|
+
sim_matrix = 1 - distances
|
|
219
|
+
return squareform(sim_matrix, checks=False)
|
|
220
|
+
if data.ndim == 3:
|
|
221
|
+
# Voxel-wise: (n_observations, n_subjects, n_voxels)
|
|
222
|
+
n_obs, n_subjects, n_voxels = data.shape
|
|
223
|
+
n_pairs = n_subjects * (n_subjects - 1) // 2
|
|
224
|
+
pairwise_all = np.zeros((n_pairs, n_voxels))
|
|
225
|
+
|
|
226
|
+
# Compute euclidean similarity per voxel
|
|
227
|
+
for v in range(n_voxels):
|
|
228
|
+
# Compute squared norms for each subject
|
|
229
|
+
norms_sq = np.sum(data[:, :, v] ** 2, axis=0) # (n_subjects,)
|
|
230
|
+
|
|
231
|
+
# Compute dot products
|
|
232
|
+
dot_products = (
|
|
233
|
+
data[:, :, v].T @ data[:, :, v]
|
|
234
|
+
) # (n_subjects, n_subjects)
|
|
235
|
+
|
|
236
|
+
# Compute squared distances
|
|
237
|
+
distances_sq = norms_sq[:, None] + norms_sq[None, :] - 2 * dot_products
|
|
238
|
+
|
|
239
|
+
# Compute distances
|
|
240
|
+
distances = np.sqrt(np.maximum(distances_sq, 0))
|
|
241
|
+
|
|
242
|
+
# Convert to similarity
|
|
243
|
+
sim_matrix = 1 - distances
|
|
244
|
+
pairwise_all[:, v] = squareform(sim_matrix, checks=False)
|
|
245
|
+
return pairwise_all
|
|
246
|
+
raise ValueError(f"data must be 2D or 3D, got shape {data.shape}")
|
|
247
|
+
# General path: use pairwise_distances for other metrics
|
|
248
|
+
# Convert distance to similarity: similarity = 1 - distance
|
|
249
|
+
if data.ndim == 2:
|
|
250
|
+
# Single feature: (n_observations, n_subjects)
|
|
251
|
+
dist_matrix = pairwise_distances(data.T, metric=metric)
|
|
252
|
+
sim_matrix = 1 - dist_matrix
|
|
253
|
+
return squareform(sim_matrix, checks=False)
|
|
254
|
+
if data.ndim == 3:
|
|
255
|
+
# Voxel-wise: (n_observations, n_subjects, n_voxels)
|
|
256
|
+
n_obs, n_subjects, n_voxels = data.shape
|
|
257
|
+
n_pairs = n_subjects * (n_subjects - 1) // 2
|
|
258
|
+
pairwise_all = np.zeros((n_pairs, n_voxels))
|
|
259
|
+
for v in range(n_voxels):
|
|
260
|
+
dist_matrix = pairwise_distances(data[:, :, v].T, metric=metric)
|
|
261
|
+
sim_matrix = 1 - dist_matrix
|
|
262
|
+
pairwise_all[:, v] = squareform(sim_matrix, checks=False)
|
|
263
|
+
return pairwise_all
|
|
264
|
+
raise ValueError(f"data must be 2D or 3D, got shape {data.shape}")
|
|
265
|
+
|
|
266
|
+
|
|
267
|
+
def _compute_isc_group_difference(
|
|
268
|
+
group1,
|
|
269
|
+
group2,
|
|
270
|
+
summary="median",
|
|
271
|
+
summary_statistic="pairwise",
|
|
272
|
+
metric="correlation",
|
|
273
|
+
):
|
|
274
|
+
"""Compute ISC difference between two groups.
|
|
275
|
+
|
|
276
|
+
Computes intersubject correlation for each group separately, then takes
|
|
277
|
+
the difference (group1 ISC - group2 ISC). Supports both pairwise and
|
|
278
|
+
leave-one-out ISC computation methods.
|
|
279
|
+
|
|
280
|
+
Args:
|
|
281
|
+
group1 (np.ndarray): First group, shape `(n_observations, n_subjects1)`
|
|
282
|
+
for a single feature or `(n_observations, n_subjects1, n_voxels)`
|
|
283
|
+
for voxel-wise data.
|
|
284
|
+
group2 (np.ndarray): Second group, shape `(n_observations, n_subjects2)`
|
|
285
|
+
or `(n_observations, n_subjects2, n_voxels)`.
|
|
286
|
+
summary (str): How ISC values are aggregated: `'median'` (default,
|
|
287
|
+
robust to outliers) or `'mean'` (Fisher z-transformed mean).
|
|
288
|
+
summary_statistic (str): `'pairwise'` (default; summarize all pairwise
|
|
289
|
+
correlations) or `'leave-one-out'` (correlate each subject with
|
|
290
|
+
the mean of the others).
|
|
291
|
+
metric (str): Similarity metric for pairwise ISC. Defaults to
|
|
292
|
+
`'correlation'`.
|
|
293
|
+
|
|
294
|
+
Returns:
|
|
295
|
+
np.ndarray: `group1 ISC - group2 ISC`, shape `()` for a single feature
|
|
296
|
+
or `(n_voxels,)` for voxel-wise data.
|
|
297
|
+
|
|
298
|
+
Examples:
|
|
299
|
+
```python
|
|
300
|
+
group1 = np.random.randn(100, 5) # 5 subjects
|
|
301
|
+
group2 = np.random.randn(100, 5)
|
|
302
|
+
_compute_isc_group_difference(group1, group2).shape # → ()
|
|
303
|
+
|
|
304
|
+
# Voxel-wise
|
|
305
|
+
group1_voxels = np.random.randn(100, 5, 1000)
|
|
306
|
+
group2_voxels = np.random.randn(100, 5, 1000)
|
|
307
|
+
_compute_isc_group_difference(group1_voxels, group2_voxels).shape # → (1000,)
|
|
308
|
+
```
|
|
309
|
+
"""
|
|
310
|
+
# Input validation
|
|
311
|
+
group1 = np.asarray(group1)
|
|
312
|
+
group2 = np.asarray(group2)
|
|
313
|
+
|
|
314
|
+
if group1.shape[0] != group2.shape[0]:
|
|
315
|
+
raise ValueError(
|
|
316
|
+
"group1 and group2 must have the same number of observations. "
|
|
317
|
+
f"Got group1.shape[0]={group1.shape[0]}, group2.shape[0]={group2.shape[0]}"
|
|
318
|
+
)
|
|
319
|
+
|
|
320
|
+
if group1.ndim != group2.ndim:
|
|
321
|
+
raise ValueError(
|
|
322
|
+
"group1 and group2 must have the same number of dimensions. "
|
|
323
|
+
f"Got group1.ndim={group1.ndim}, group2.ndim={group2.ndim}"
|
|
324
|
+
)
|
|
325
|
+
|
|
326
|
+
if group1.ndim not in [2, 3]:
|
|
327
|
+
raise ValueError(
|
|
328
|
+
f"group1 and group2 must be 2D or 3D, got shapes {group1.shape}, {group2.shape}"
|
|
329
|
+
)
|
|
330
|
+
|
|
331
|
+
if summary not in ["median", "mean"]:
|
|
332
|
+
raise ValueError(f"summary must be 'median' or 'mean', got {summary}")
|
|
333
|
+
|
|
334
|
+
if summary_statistic not in ["pairwise", "leave-one-out"]:
|
|
335
|
+
raise ValueError(
|
|
336
|
+
f"summary_statistic must be 'pairwise' or 'leave-one-out', got {summary_statistic}"
|
|
337
|
+
)
|
|
338
|
+
|
|
339
|
+
# Compute ISC for each group
|
|
340
|
+
if summary_statistic == "pairwise":
|
|
341
|
+
# Pairwise ISC: compute condensed correlation matrices
|
|
342
|
+
isc1_values = _compute_pairwise_isc(group1, metric=metric)
|
|
343
|
+
isc2_values = _compute_pairwise_isc(group2, metric=metric)
|
|
344
|
+
|
|
345
|
+
# Handle single feature vs voxel-wise
|
|
346
|
+
if isc1_values.ndim == 1:
|
|
347
|
+
# Single feature: (n_pairs,)
|
|
348
|
+
axis = None
|
|
349
|
+
else:
|
|
350
|
+
# Voxel-wise: (n_pairs, n_voxels)
|
|
351
|
+
axis = 0
|
|
352
|
+
|
|
353
|
+
# Compute summary statistic
|
|
354
|
+
if summary == "median":
|
|
355
|
+
isc1 = np.nanmedian(isc1_values, axis=axis)
|
|
356
|
+
isc2 = np.nanmedian(isc2_values, axis=axis)
|
|
357
|
+
elif summary == "mean":
|
|
358
|
+
# Fisher z-transform
|
|
359
|
+
z1 = np.arctanh(np.clip(isc1_values, -0.9999, 0.9999))
|
|
360
|
+
z2 = np.arctanh(np.clip(isc2_values, -0.9999, 0.9999))
|
|
361
|
+
isc1 = np.tanh(np.nanmean(z1, axis=axis))
|
|
362
|
+
isc2 = np.tanh(np.nanmean(z2, axis=axis))
|
|
363
|
+
|
|
364
|
+
else: # leave-one-out
|
|
365
|
+
# LOO ISC: compute LOO values for each subject
|
|
366
|
+
loo1_values = _compute_loo_isc(group1)
|
|
367
|
+
loo2_values = _compute_loo_isc(group2)
|
|
368
|
+
|
|
369
|
+
# Handle single feature vs voxel-wise
|
|
370
|
+
if loo1_values.ndim == 1:
|
|
371
|
+
# Single feature: (n_subjects,)
|
|
372
|
+
axis = 0
|
|
373
|
+
else:
|
|
374
|
+
# Voxel-wise: (n_subjects, n_voxels)
|
|
375
|
+
axis = 0
|
|
376
|
+
|
|
377
|
+
# Compute summary statistic
|
|
378
|
+
if summary == "median":
|
|
379
|
+
isc1 = np.median(loo1_values, axis=axis)
|
|
380
|
+
isc2 = np.median(loo2_values, axis=axis)
|
|
381
|
+
elif summary == "mean":
|
|
382
|
+
# Fisher z-transform
|
|
383
|
+
z1 = np.arctanh(np.clip(loo1_values, -0.9999, 0.9999))
|
|
384
|
+
z2 = np.arctanh(np.clip(loo2_values, -0.9999, 0.9999))
|
|
385
|
+
isc1 = np.tanh(np.mean(z1, axis=axis))
|
|
386
|
+
isc2 = np.tanh(np.mean(z2, axis=axis))
|
|
387
|
+
|
|
388
|
+
# Compute difference
|
|
389
|
+
isc_diff = isc1 - isc2
|
|
390
|
+
|
|
391
|
+
return isc_diff
|
|
392
|
+
|
|
393
|
+
|
|
394
|
+
# ============================================================================
|
|
395
|
+
# Phase 2.6: ISC Group Permutation (Subject-wise Permutation)
|
|
396
|
+
# ============================================================================
|
|
397
|
+
|
|
398
|
+
|
|
399
|
+
def _permute_isc_group_numpy(
|
|
400
|
+
group1,
|
|
401
|
+
group2,
|
|
402
|
+
summary="median",
|
|
403
|
+
summary_statistic="pairwise",
|
|
404
|
+
random_state=None,
|
|
405
|
+
metric="correlation",
|
|
406
|
+
):
|
|
407
|
+
"""Single permutation: permute group labels and compute ISC difference.
|
|
408
|
+
|
|
409
|
+
Implements the subject-wise permutation method from Chen et al. (2016).
|
|
410
|
+
Combines the two groups, permutes group labels, then computes ISC difference
|
|
411
|
+
for the permuted groups.
|
|
412
|
+
|
|
413
|
+
Args:
|
|
414
|
+
group1 (np.ndarray): First group, shape `(n_observations, n_subjects1)`
|
|
415
|
+
or `(n_observations, n_subjects1, n_voxels)`.
|
|
416
|
+
group2 (np.ndarray): Second group, shape `(n_observations, n_subjects2)`
|
|
417
|
+
or `(n_observations, n_subjects2, n_voxels)`.
|
|
418
|
+
summary (str): `'median'` (default) or `'mean'`.
|
|
419
|
+
summary_statistic (str): `'pairwise'` (default) or `'leave-one-out'`.
|
|
420
|
+
random_state (int | np.random.RandomState | None): Random state for
|
|
421
|
+
reproducibility.
|
|
422
|
+
metric (str): Similarity metric for pairwise ISC. Defaults to
|
|
423
|
+
`'correlation'`.
|
|
424
|
+
|
|
425
|
+
Returns:
|
|
426
|
+
np.ndarray: Permuted ISC difference, shape `()` or `(n_voxels,)`.
|
|
427
|
+
"""
|
|
428
|
+
from sklearn.utils import check_random_state
|
|
429
|
+
|
|
430
|
+
rng = check_random_state(random_state)
|
|
431
|
+
|
|
432
|
+
# Combine groups
|
|
433
|
+
combined = np.concatenate([group1, group2], axis=1)
|
|
434
|
+
n_subjects1 = group1.shape[1]
|
|
435
|
+
|
|
436
|
+
# Create group labels
|
|
437
|
+
n_subjects_total = combined.shape[1]
|
|
438
|
+
group_labels = np.array([1] * n_subjects1 + [2] * (n_subjects_total - n_subjects1))
|
|
439
|
+
|
|
440
|
+
# Permute group labels
|
|
441
|
+
permuted_labels = rng.permutation(group_labels)
|
|
442
|
+
|
|
443
|
+
# Split back into groups based on permuted labels
|
|
444
|
+
group1_id, group2_id = 1, 2
|
|
445
|
+
group1_perm = combined[:, permuted_labels == group1_id]
|
|
446
|
+
group2_perm = combined[:, permuted_labels == group2_id]
|
|
447
|
+
|
|
448
|
+
# Compute ISC difference for permuted groups
|
|
449
|
+
isc_diff = _compute_isc_group_difference(
|
|
450
|
+
group1_perm,
|
|
451
|
+
group2_perm,
|
|
452
|
+
summary=summary,
|
|
453
|
+
summary_statistic=summary_statistic,
|
|
454
|
+
metric=metric,
|
|
455
|
+
)
|
|
456
|
+
|
|
457
|
+
return isc_diff
|
|
458
|
+
|
|
459
|
+
|
|
460
|
+
def _permute_isc_group_cpu_parallel(
|
|
461
|
+
group1,
|
|
462
|
+
group2,
|
|
463
|
+
*,
|
|
464
|
+
n_permute=5000,
|
|
465
|
+
summary="median",
|
|
466
|
+
summary_statistic="pairwise",
|
|
467
|
+
n_jobs=-1,
|
|
468
|
+
random_state=None,
|
|
469
|
+
progress_bar=False,
|
|
470
|
+
metric="correlation",
|
|
471
|
+
max_memory_gb=None,
|
|
472
|
+
):
|
|
473
|
+
"""CPU-parallel permutation for ISC group difference.
|
|
474
|
+
|
|
475
|
+
Efficiently parallelizes permutation resampling across CPU cores using joblib.
|
|
476
|
+
Uses deterministic seed generation for reproducibility.
|
|
477
|
+
Automatically limits workers based on available memory if n_jobs=-1.
|
|
478
|
+
|
|
479
|
+
Args:
|
|
480
|
+
group1 (np.ndarray): First group, shape `(n_observations, n_subjects1)`
|
|
481
|
+
or `(n_observations, n_subjects1, n_voxels)`.
|
|
482
|
+
group2 (np.ndarray): Second group, shape `(n_observations, n_subjects2)`
|
|
483
|
+
or `(n_observations, n_subjects2, n_voxels)`.
|
|
484
|
+
n_permute (int): Number of permutations. Defaults to 5000.
|
|
485
|
+
summary (str): `'median'` (default) or `'mean'`.
|
|
486
|
+
summary_statistic (str): `'pairwise'` (default) or `'leave-one-out'`.
|
|
487
|
+
n_jobs (int): CPU cores; -1 (default) picks the worker count from
|
|
488
|
+
available memory.
|
|
489
|
+
random_state (int | None): Random seed for reproducibility.
|
|
490
|
+
progress_bar (bool): Show a progress bar. Defaults to False.
|
|
491
|
+
metric (str): Similarity metric for pairwise ISC. Defaults to
|
|
492
|
+
`'correlation'`.
|
|
493
|
+
max_memory_gb (float | None): Memory budget in GB for the `n_jobs=-1`
|
|
494
|
+
auto-detection; None measures the machine.
|
|
495
|
+
|
|
496
|
+
Returns:
|
|
497
|
+
np.ndarray: Permuted ISC differences, shape `(n_permute,)` for a single
|
|
498
|
+
feature or `(n_permute, n_voxels)` for voxel-wise data.
|
|
499
|
+
"""
|
|
500
|
+
from joblib import Parallel, delayed
|
|
501
|
+
from nltools.algorithms.backends import _auto_n_jobs_cpu, _estimate_data_size_mb
|
|
502
|
+
|
|
503
|
+
# Auto-detect optimal n_jobs based on memory if n_jobs=-1
|
|
504
|
+
# Estimate memory for combined groups
|
|
505
|
+
if n_jobs == -1:
|
|
506
|
+
combined_size_mb = _estimate_data_size_mb(group1) + _estimate_data_size_mb(
|
|
507
|
+
group2
|
|
508
|
+
)
|
|
509
|
+
n_jobs = _auto_n_jobs_cpu(
|
|
510
|
+
data_size_mb=combined_size_mb,
|
|
511
|
+
n_permute=n_permute,
|
|
512
|
+
max_memory_gb=max_memory_gb,
|
|
513
|
+
)
|
|
514
|
+
|
|
515
|
+
rng = check_random_state(random_state)
|
|
516
|
+
MAX_INT = 2**31 - 1
|
|
517
|
+
seeds = rng.randint(MAX_INT, size=n_permute)
|
|
518
|
+
|
|
519
|
+
# Parallelize
|
|
520
|
+
iterator = _maybe_tqdm(
|
|
521
|
+
range(n_permute), progress_bar=progress_bar, desc="Permute ISC Group"
|
|
522
|
+
)
|
|
523
|
+
|
|
524
|
+
permutations = Parallel(n_jobs=n_jobs)(
|
|
525
|
+
delayed(_permute_isc_group_numpy)(
|
|
526
|
+
group1,
|
|
527
|
+
group2,
|
|
528
|
+
summary=summary,
|
|
529
|
+
summary_statistic=summary_statistic,
|
|
530
|
+
random_state=np.random.RandomState(seeds[i]),
|
|
531
|
+
metric=metric,
|
|
532
|
+
)
|
|
533
|
+
for i in iterator
|
|
534
|
+
)
|
|
535
|
+
|
|
536
|
+
return np.array(permutations)
|
|
537
|
+
|
|
538
|
+
|
|
539
|
+
# ============================================================================
|
|
540
|
+
# Phase 2.7: ISC Group Bootstrap (Subject-wise Bootstrap)
|
|
541
|
+
# ============================================================================
|
|
542
|
+
|
|
543
|
+
|
|
544
|
+
def _bootstrap_isc_group_numpy(
|
|
545
|
+
group1,
|
|
546
|
+
group2,
|
|
547
|
+
observed_diff,
|
|
548
|
+
summary="median",
|
|
549
|
+
summary_statistic="pairwise",
|
|
550
|
+
exclude_self_corr=True,
|
|
551
|
+
random_state=None,
|
|
552
|
+
metric="correlation",
|
|
553
|
+
):
|
|
554
|
+
"""Single bootstrap: resample subjects within each group and compute ISC difference.
|
|
555
|
+
|
|
556
|
+
Implements the subject-wise bootstrap method from Chen et al. (2016).
|
|
557
|
+
Bootstraps each group independently, then computes ISC difference.
|
|
558
|
+
Centers by subtracting observed difference: (boot1 - boot2) - observed_diff.
|
|
559
|
+
|
|
560
|
+
Args:
|
|
561
|
+
group1 (np.ndarray): First group, shape `(n_observations, n_subjects1)`
|
|
562
|
+
or `(n_observations, n_subjects1, n_voxels)`.
|
|
563
|
+
group2 (np.ndarray): Second group, shape `(n_observations, n_subjects2)`
|
|
564
|
+
or `(n_observations, n_subjects2, n_voxels)`.
|
|
565
|
+
observed_diff (float | np.ndarray): Observed ISC difference, subtracted
|
|
566
|
+
to center the draw.
|
|
567
|
+
summary (str): `'median'` (default) or `'mean'`.
|
|
568
|
+
summary_statistic (str): `'pairwise'` (default) or `'leave-one-out'`.
|
|
569
|
+
exclude_self_corr (bool): Mask the perfect correlations a duplicated
|
|
570
|
+
subject produces (pairwise only). Defaults to True.
|
|
571
|
+
random_state (int | np.random.RandomState | None): Random state for
|
|
572
|
+
reproducibility.
|
|
573
|
+
metric (str): Similarity metric for pairwise ISC. Defaults to
|
|
574
|
+
`'correlation'`.
|
|
575
|
+
|
|
576
|
+
Returns:
|
|
577
|
+
np.ndarray: Centered bootstrap difference,
|
|
578
|
+
`(boot1 - boot2) - observed_diff`, shape `()` or `(n_voxels,)`.
|
|
579
|
+
"""
|
|
580
|
+
from sklearn.utils import check_random_state
|
|
581
|
+
|
|
582
|
+
rng = check_random_state(random_state)
|
|
583
|
+
|
|
584
|
+
if summary_statistic == "pairwise":
|
|
585
|
+
# Pairwise bootstrap: resample subjects, recompute pairwise ISC
|
|
586
|
+
n_subjects1 = group1.shape[1]
|
|
587
|
+
n_subjects2 = group2.shape[1]
|
|
588
|
+
|
|
589
|
+
# Bootstrap subjects for each group
|
|
590
|
+
boot_indices1 = rng.choice(n_subjects1, size=n_subjects1, replace=True)
|
|
591
|
+
boot_indices2 = rng.choice(n_subjects2, size=n_subjects2, replace=True)
|
|
592
|
+
|
|
593
|
+
# Resample data
|
|
594
|
+
if group1.ndim == 2:
|
|
595
|
+
group1_boot = group1[:, boot_indices1]
|
|
596
|
+
group2_boot = group2[:, boot_indices2]
|
|
597
|
+
else:
|
|
598
|
+
group1_boot = group1[:, boot_indices1, :]
|
|
599
|
+
group2_boot = group2[:, boot_indices2, :]
|
|
600
|
+
|
|
601
|
+
# Compute pairwise ISC for bootstrapped groups
|
|
602
|
+
pairwise1_boot = _compute_pairwise_isc(group1_boot, metric=metric)
|
|
603
|
+
pairwise2_boot = _compute_pairwise_isc(group2_boot, metric=metric)
|
|
604
|
+
|
|
605
|
+
# Handle exclude_self_corr: mask perfect correlations from duplicate subjects
|
|
606
|
+
if exclude_self_corr:
|
|
607
|
+
# Mask correlations >= 0.99999 (perfect correlations from duplicates)
|
|
608
|
+
pairwise1_boot = np.where(
|
|
609
|
+
np.abs(pairwise1_boot) >= 0.99999, np.nan, pairwise1_boot
|
|
610
|
+
)
|
|
611
|
+
pairwise2_boot = np.where(
|
|
612
|
+
np.abs(pairwise2_boot) >= 0.99999, np.nan, pairwise2_boot
|
|
613
|
+
)
|
|
614
|
+
|
|
615
|
+
# Handle single feature vs voxel-wise
|
|
616
|
+
if pairwise1_boot.ndim == 1:
|
|
617
|
+
axis = None
|
|
618
|
+
else:
|
|
619
|
+
axis = 0
|
|
620
|
+
|
|
621
|
+
# Compute summary statistic
|
|
622
|
+
if summary == "median":
|
|
623
|
+
isc1_boot = np.nanmedian(pairwise1_boot, axis=axis)
|
|
624
|
+
isc2_boot = np.nanmedian(pairwise2_boot, axis=axis)
|
|
625
|
+
elif summary == "mean":
|
|
626
|
+
z1 = np.arctanh(np.clip(pairwise1_boot, -0.9999, 0.9999))
|
|
627
|
+
z2 = np.arctanh(np.clip(pairwise2_boot, -0.9999, 0.9999))
|
|
628
|
+
isc1_boot = np.tanh(np.nanmean(z1, axis=axis))
|
|
629
|
+
isc2_boot = np.tanh(np.nanmean(z2, axis=axis))
|
|
630
|
+
|
|
631
|
+
else: # leave-one-out
|
|
632
|
+
# LOO bootstrap: resample pre-computed LOO values
|
|
633
|
+
loo1_values = _compute_loo_isc(group1)
|
|
634
|
+
loo2_values = _compute_loo_isc(group2)
|
|
635
|
+
|
|
636
|
+
# Bootstrap LOO values
|
|
637
|
+
isc1_boot = _bootstrap_loo_numpy(loo1_values, summary=summary, random_state=rng)
|
|
638
|
+
# Use different seed for group2 to ensure independence
|
|
639
|
+
rng2 = check_random_state(rng.randint(0, 2**31 - 1))
|
|
640
|
+
isc2_boot = _bootstrap_loo_numpy(
|
|
641
|
+
loo2_values, summary=summary, random_state=rng2
|
|
642
|
+
)
|
|
643
|
+
|
|
644
|
+
# Compute difference and center
|
|
645
|
+
boot_diff = (isc1_boot - isc2_boot) - observed_diff
|
|
646
|
+
|
|
647
|
+
return boot_diff
|
|
648
|
+
|
|
649
|
+
|
|
650
|
+
def _bootstrap_isc_group_cpu_parallel(
|
|
651
|
+
group1,
|
|
652
|
+
group2,
|
|
653
|
+
observed_diff,
|
|
654
|
+
*,
|
|
655
|
+
n_permute=5000,
|
|
656
|
+
summary="median",
|
|
657
|
+
summary_statistic="pairwise",
|
|
658
|
+
exclude_self_corr=True,
|
|
659
|
+
n_jobs=-1,
|
|
660
|
+
random_state=None,
|
|
661
|
+
progress_bar=False,
|
|
662
|
+
metric="correlation",
|
|
663
|
+
max_memory_gb=None,
|
|
664
|
+
):
|
|
665
|
+
"""CPU-parallel bootstrap for ISC group difference.
|
|
666
|
+
|
|
667
|
+
Efficiently parallelizes bootstrap resampling across CPU cores using joblib.
|
|
668
|
+
Uses deterministic seed generation for reproducibility.
|
|
669
|
+
Automatically limits workers based on available memory if n_jobs=-1.
|
|
670
|
+
|
|
671
|
+
Args:
|
|
672
|
+
group1 (np.ndarray): First group, shape `(n_observations, n_subjects1)`
|
|
673
|
+
or `(n_observations, n_subjects1, n_voxels)`.
|
|
674
|
+
group2 (np.ndarray): Second group, shape `(n_observations, n_subjects2)`
|
|
675
|
+
or `(n_observations, n_subjects2, n_voxels)`.
|
|
676
|
+
observed_diff (float | np.ndarray): Observed ISC difference, subtracted
|
|
677
|
+
to center each draw.
|
|
678
|
+
n_permute (int): Number of bootstrap iterations. Defaults to 5000.
|
|
679
|
+
summary (str): `'median'` (default) or `'mean'`.
|
|
680
|
+
summary_statistic (str): `'pairwise'` (default) or `'leave-one-out'`.
|
|
681
|
+
exclude_self_corr (bool): Mask the perfect correlations a duplicated
|
|
682
|
+
subject produces (pairwise only). Defaults to True.
|
|
683
|
+
n_jobs (int): CPU cores; -1 (default) picks the worker count from
|
|
684
|
+
available memory.
|
|
685
|
+
random_state (int | None): Random seed for reproducibility.
|
|
686
|
+
progress_bar (bool): Show a progress bar. Defaults to False.
|
|
687
|
+
metric (str): Similarity metric for pairwise ISC. Defaults to
|
|
688
|
+
`'correlation'`.
|
|
689
|
+
max_memory_gb (float | None): Memory budget in GB for the `n_jobs=-1`
|
|
690
|
+
auto-detection; None measures the machine.
|
|
691
|
+
|
|
692
|
+
Returns:
|
|
693
|
+
np.ndarray: Centered bootstrap differences, shape `(n_permute,)` for a
|
|
694
|
+
single feature or `(n_permute, n_voxels)` for voxel-wise data.
|
|
695
|
+
"""
|
|
696
|
+
from joblib import Parallel, delayed
|
|
697
|
+
from nltools.algorithms.backends import _auto_n_jobs_cpu, _estimate_data_size_mb
|
|
698
|
+
|
|
699
|
+
# Auto-detect optimal n_jobs based on memory if n_jobs=-1
|
|
700
|
+
# Estimate memory for combined groups
|
|
701
|
+
if n_jobs == -1:
|
|
702
|
+
combined_size_mb = _estimate_data_size_mb(group1) + _estimate_data_size_mb(
|
|
703
|
+
group2
|
|
704
|
+
)
|
|
705
|
+
n_jobs = _auto_n_jobs_cpu(
|
|
706
|
+
data_size_mb=combined_size_mb,
|
|
707
|
+
n_permute=n_permute,
|
|
708
|
+
max_memory_gb=max_memory_gb,
|
|
709
|
+
)
|
|
710
|
+
|
|
711
|
+
rng = check_random_state(random_state)
|
|
712
|
+
MAX_INT = 2**31 - 1
|
|
713
|
+
seeds = rng.randint(MAX_INT, size=n_permute)
|
|
714
|
+
|
|
715
|
+
# Parallelize
|
|
716
|
+
iterator = _maybe_tqdm(
|
|
717
|
+
range(n_permute), progress_bar=progress_bar, desc="Bootstrap ISC Group"
|
|
718
|
+
)
|
|
719
|
+
|
|
720
|
+
bootstraps = Parallel(n_jobs=n_jobs)(
|
|
721
|
+
delayed(_bootstrap_isc_group_numpy)(
|
|
722
|
+
group1,
|
|
723
|
+
group2,
|
|
724
|
+
observed_diff=observed_diff,
|
|
725
|
+
summary=summary,
|
|
726
|
+
summary_statistic=summary_statistic,
|
|
727
|
+
exclude_self_corr=exclude_self_corr,
|
|
728
|
+
random_state=np.random.RandomState(seeds[i]),
|
|
729
|
+
metric=metric,
|
|
730
|
+
)
|
|
731
|
+
for i in iterator
|
|
732
|
+
)
|
|
733
|
+
|
|
734
|
+
return np.array(bootstraps)
|
|
735
|
+
|
|
736
|
+
|
|
737
|
+
# ============================================================================
|
|
738
|
+
# Phase 2.8: Main ISC Group Permutation Test Function
|
|
739
|
+
# ============================================================================
|
|
740
|
+
|
|
741
|
+
|
|
742
|
+
def _isc_group_permutation_test(
|
|
743
|
+
group1: np.ndarray,
|
|
744
|
+
group2: np.ndarray,
|
|
745
|
+
*,
|
|
746
|
+
n_permute: int = 5000,
|
|
747
|
+
summary: Literal["median", "mean"] = "median",
|
|
748
|
+
method: Literal["permute", "bootstrap"] = "permute",
|
|
749
|
+
summary_statistic: Literal["leave-one-out", "pairwise"] = "pairwise",
|
|
750
|
+
ci_percentile: float = 95,
|
|
751
|
+
tail: int | str = 2,
|
|
752
|
+
n_jobs: int = -1,
|
|
753
|
+
random_state: int | None = None,
|
|
754
|
+
return_null: bool = False,
|
|
755
|
+
progress_bar: bool = False,
|
|
756
|
+
exclude_self_corr: bool = True,
|
|
757
|
+
metric: str = "correlation",
|
|
758
|
+
) -> dict[str, Any]:
|
|
759
|
+
"""Test the difference in intersubject correlation between two groups.
|
|
760
|
+
|
|
761
|
+
Computes ISC within each group, takes `group1 - group2`, and builds a null
|
|
762
|
+
distribution by either subject-wise permutation (pool the subjects and
|
|
763
|
+
reshuffle the group labels — the Chen et al. 2016 recommendation) or
|
|
764
|
+
subject-wise bootstrap (resample subjects within each group; the bootstrap
|
|
765
|
+
draws are centered on the observed difference before the p-value is
|
|
766
|
+
computed). The confidence interval brackets the observed difference for
|
|
767
|
+
`method='bootstrap'` and describes the null spread for `method='permute'`.
|
|
768
|
+
|
|
769
|
+
Args:
|
|
770
|
+
group1 (np.ndarray): First group, shape `(n_observations, n_subjects1)`
|
|
771
|
+
for a single feature or `(n_observations, n_subjects1, n_voxels)`
|
|
772
|
+
for voxel-wise data.
|
|
773
|
+
group2 (np.ndarray): Second group, shape `(n_observations, n_subjects2)`
|
|
774
|
+
or `(n_observations, n_subjects2, n_voxels)`; `n_observations` must
|
|
775
|
+
match `group1`.
|
|
776
|
+
n_permute (int): Number of permutations or bootstrap draws. Defaults to
|
|
777
|
+
5000.
|
|
778
|
+
summary (str): How ISC values are aggregated: `'median'` (default,
|
|
779
|
+
robust to outliers) or `'mean'` (Fisher z-transformed mean).
|
|
780
|
+
method (str): `'permute'` (default; pool subjects and permute labels) or
|
|
781
|
+
`'bootstrap'` (resample subjects within each group).
|
|
782
|
+
summary_statistic (str): `'pairwise'` (default; summarize all pairwise
|
|
783
|
+
correlations) or `'leave-one-out'` (correlate each subject with the
|
|
784
|
+
mean of the others).
|
|
785
|
+
ci_percentile (float): Confidence-interval width in percent (95 gives a
|
|
786
|
+
95% CI). Defaults to 95.
|
|
787
|
+
tail (int | str): `2` or `'two'` (default) for a two-tailed p-value;
|
|
788
|
+
`1` or `'one'` for one-tailed (group1 > group2).
|
|
789
|
+
n_jobs (int): Number of joblib workers for the resamples. -1 (default)
|
|
790
|
+
picks the worker count from available memory. Results are identical
|
|
791
|
+
at every worker count.
|
|
792
|
+
random_state (int | None): Random seed for reproducibility.
|
|
793
|
+
return_null (bool): If True, include the null distribution in the
|
|
794
|
+
result. Defaults to False.
|
|
795
|
+
progress_bar (bool): Show a progress bar over the resamples. Defaults to
|
|
796
|
+
False.
|
|
797
|
+
exclude_self_corr (bool): In the bootstrap, mask the perfect
|
|
798
|
+
correlations a duplicated subject produces (pairwise only).
|
|
799
|
+
Defaults to True.
|
|
800
|
+
metric (str): Similarity metric for pairwise ISC; any metric accepted by
|
|
801
|
+
`sklearn.metrics.pairwise_distances`. Ignored for
|
|
802
|
+
`summary_statistic='leave-one-out'`. Defaults to `'correlation'`.
|
|
803
|
+
|
|
804
|
+
Returns:
|
|
805
|
+
dict: Keys `'isc_group_difference'` (float or np.ndarray, observed
|
|
806
|
+
difference), `'p'` (float or np.ndarray, p-value with the
|
|
807
|
+
`(count + 1) / (n + 1)` correction), `'ci'` (tuple
|
|
808
|
+
`(lower, upper)`), and — when `return_null=True` — `'null_dist'`
|
|
809
|
+
(np.ndarray).
|
|
810
|
+
|
|
811
|
+
Examples:
|
|
812
|
+
```python
|
|
813
|
+
# Single-feature comparison
|
|
814
|
+
group1 = np.random.randn(100, 10) # 10 subjects
|
|
815
|
+
group2 = np.random.randn(100, 10)
|
|
816
|
+
result = _isc_group_permutation_test(group1, group2, n_permute=1000)
|
|
817
|
+
result["isc_group_difference"], result["p"]
|
|
818
|
+
|
|
819
|
+
# Voxel-wise comparison
|
|
820
|
+
group1_voxels = np.random.randn(100, 10, 5000) # 5K voxels
|
|
821
|
+
group2_voxels = np.random.randn(100, 10, 5000)
|
|
822
|
+
result = _isc_group_permutation_test(
|
|
823
|
+
group1_voxels,
|
|
824
|
+
group2_voxels,
|
|
825
|
+
summary_statistic="leave-one-out",
|
|
826
|
+
n_permute=5000,
|
|
827
|
+
)
|
|
828
|
+
(result["p"] < 0.05).sum() # → number of significant voxels
|
|
829
|
+
```
|
|
830
|
+
|
|
831
|
+
References:
|
|
832
|
+
Chen, G., Shin, Y. W., Taylor, P. A., Glen, D. R., Reynolds, R. C.,
|
|
833
|
+
Israel, R. B., & Cox, R. W. (2016). Untangling the relatedness among
|
|
834
|
+
correlations, part I: nonparametric approaches to inter-subject
|
|
835
|
+
correlation analysis at the group level. NeuroImage, 142, 248-259.
|
|
836
|
+
"""
|
|
837
|
+
# Input validation
|
|
838
|
+
_validate_tail_parameter(tail)
|
|
839
|
+
group1 = np.asarray(group1)
|
|
840
|
+
group2 = np.asarray(group2)
|
|
841
|
+
|
|
842
|
+
if group1.shape[0] != group2.shape[0]:
|
|
843
|
+
raise ValueError(
|
|
844
|
+
"group1 and group2 must have the same number of observations. "
|
|
845
|
+
f"Got group1.shape[0]={group1.shape[0]}, group2.shape[0]={group2.shape[0]}"
|
|
846
|
+
)
|
|
847
|
+
|
|
848
|
+
if group1.ndim != group2.ndim:
|
|
849
|
+
raise ValueError(
|
|
850
|
+
"group1 and group2 must have the same number of dimensions. "
|
|
851
|
+
f"Got group1.ndim={group1.ndim}, group2.ndim={group2.ndim}"
|
|
852
|
+
)
|
|
853
|
+
|
|
854
|
+
if group1.ndim not in [2, 3]:
|
|
855
|
+
raise ValueError(
|
|
856
|
+
f"group1 and group2 must be 2D or 3D, got shapes {group1.shape}, {group2.shape}"
|
|
857
|
+
)
|
|
858
|
+
|
|
859
|
+
if summary not in ["median", "mean"]:
|
|
860
|
+
raise ValueError(f"summary must be 'median' or 'mean', got {summary}")
|
|
861
|
+
|
|
862
|
+
if method not in ["permute", "bootstrap"]:
|
|
863
|
+
raise ValueError(f"method must be 'permute' or 'bootstrap', got {method}")
|
|
864
|
+
|
|
865
|
+
if summary_statistic not in ["pairwise", "leave-one-out"]:
|
|
866
|
+
raise ValueError(
|
|
867
|
+
f"summary_statistic must be 'pairwise' or 'leave-one-out', got {summary_statistic}"
|
|
868
|
+
)
|
|
869
|
+
|
|
870
|
+
# Phase 1: Compute observed ISC difference
|
|
871
|
+
observed_diff = _compute_isc_group_difference(
|
|
872
|
+
group1,
|
|
873
|
+
group2,
|
|
874
|
+
summary=summary,
|
|
875
|
+
summary_statistic=summary_statistic,
|
|
876
|
+
metric=metric,
|
|
877
|
+
)
|
|
878
|
+
|
|
879
|
+
# Phase 2: Bootstrap/Permutation (run n_permute times)
|
|
880
|
+
if method == "permute":
|
|
881
|
+
null_dist = _permute_isc_group_cpu_parallel(
|
|
882
|
+
group1,
|
|
883
|
+
group2,
|
|
884
|
+
n_permute=n_permute,
|
|
885
|
+
summary=summary,
|
|
886
|
+
summary_statistic=summary_statistic,
|
|
887
|
+
n_jobs=n_jobs,
|
|
888
|
+
random_state=random_state,
|
|
889
|
+
progress_bar=progress_bar,
|
|
890
|
+
metric=metric,
|
|
891
|
+
)
|
|
892
|
+
else: # bootstrap
|
|
893
|
+
null_dist = _bootstrap_isc_group_cpu_parallel(
|
|
894
|
+
group1,
|
|
895
|
+
group2,
|
|
896
|
+
observed_diff=observed_diff,
|
|
897
|
+
n_permute=n_permute,
|
|
898
|
+
summary=summary,
|
|
899
|
+
summary_statistic=summary_statistic,
|
|
900
|
+
exclude_self_corr=exclude_self_corr,
|
|
901
|
+
n_jobs=n_jobs,
|
|
902
|
+
random_state=random_state,
|
|
903
|
+
progress_bar=progress_bar,
|
|
904
|
+
metric=metric,
|
|
905
|
+
max_memory_gb=None, # Auto-detect
|
|
906
|
+
)
|
|
907
|
+
|
|
908
|
+
# Handle NaN values (from exclude_self_corr masking)
|
|
909
|
+
# For single feature: remove all NaN values
|
|
910
|
+
# For voxel-wise: keep NaN per voxel (they represent valid bootstrap samples)
|
|
911
|
+
if null_dist.ndim == 1:
|
|
912
|
+
# Single feature: filter out NaN values
|
|
913
|
+
null_dist = null_dist[~np.isnan(null_dist)]
|
|
914
|
+
# For voxel-wise, keep NaN values (they're handled by nanpercentile)
|
|
915
|
+
|
|
916
|
+
# Phase 3: Compute p-value and confidence interval
|
|
917
|
+
# Handle scalar vs array observed_diff
|
|
918
|
+
if isinstance(observed_diff, np.ndarray) and observed_diff.ndim > 0:
|
|
919
|
+
# Voxel-wise: (n_voxels,)
|
|
920
|
+
if null_dist.ndim == 1:
|
|
921
|
+
# This shouldn't happen - voxel-wise should produce 2D null_dist
|
|
922
|
+
raise ValueError("Voxel-wise data should produce 2D null_dist")
|
|
923
|
+
# null_dist shape: (n_permute, n_voxels)
|
|
924
|
+
p_values = _compute_pvalue(observed_diff, null_dist, tail=tail)
|
|
925
|
+
else:
|
|
926
|
+
# Single feature: scalar observed_diff
|
|
927
|
+
if null_dist.ndim == 1:
|
|
928
|
+
# null_dist shape: (n_permute,)
|
|
929
|
+
p_values = _compute_pvalue(
|
|
930
|
+
np.array([observed_diff]), null_dist.reshape(-1, 1), tail=tail
|
|
931
|
+
)[0]
|
|
932
|
+
else:
|
|
933
|
+
# Shouldn't happen for single feature
|
|
934
|
+
raise ValueError("Single feature should produce 1D null_dist")
|
|
935
|
+
|
|
936
|
+
# Compute confidence intervals.
|
|
937
|
+
# For method='bootstrap' the draws in null_dist are CENTERED
|
|
938
|
+
# (boot_estimate - observed_diff) so that the permutation-style p-value tests
|
|
939
|
+
# against H0: difference == 0. A confidence interval, however, must bracket
|
|
940
|
+
# the ESTIMATE, so we re-add observed_diff to recover the uncentered
|
|
941
|
+
# bootstrap distribution before taking percentiles (matching
|
|
942
|
+
# _isc_permutation_test). For method='permute' the null is a label-permutation
|
|
943
|
+
# band around zero, not a bootstrap of the estimate; its percentiles describe
|
|
944
|
+
# the null spread and are left uncentered.
|
|
945
|
+
ci_source = null_dist + observed_diff if method == "bootstrap" else null_dist
|
|
946
|
+
if ci_source.ndim == 1:
|
|
947
|
+
ci_lower = np.percentile(ci_source, (100 - ci_percentile) / 2)
|
|
948
|
+
ci_upper = np.percentile(ci_source, ci_percentile + (100 - ci_percentile) / 2)
|
|
949
|
+
else:
|
|
950
|
+
# Voxel-wise: compute CI per voxel
|
|
951
|
+
ci_lower = np.nanpercentile(ci_source, (100 - ci_percentile) / 2, axis=0)
|
|
952
|
+
ci_upper = np.nanpercentile(
|
|
953
|
+
ci_source, ci_percentile + (100 - ci_percentile) / 2, axis=0
|
|
954
|
+
)
|
|
955
|
+
|
|
956
|
+
# Build result dictionary
|
|
957
|
+
result = {
|
|
958
|
+
"isc_group_difference": observed_diff,
|
|
959
|
+
"p": p_values,
|
|
960
|
+
"ci": (ci_lower, ci_upper),
|
|
961
|
+
}
|
|
962
|
+
|
|
963
|
+
if return_null:
|
|
964
|
+
result["null_dist"] = null_dist
|
|
965
|
+
|
|
966
|
+
return result
|
|
967
|
+
|
|
968
|
+
|
|
969
|
+
# ============================================================================
|
|
970
|
+
# Phase 3: Leave-One-Out Bootstrap
|
|
971
|
+
# ============================================================================
|
|
972
|
+
|
|
973
|
+
|
|
974
|
+
def _bootstrap_loo_numpy(loo_values, summary="median", random_state=None):
|
|
975
|
+
"""Bootstrap LOO ISC by resampling subjects.
|
|
976
|
+
|
|
977
|
+
Implements the subject-wise bootstrap method from Chen et al. (2016).
|
|
978
|
+
Resamples the pre-computed LOO values (not raw data) for efficiency.
|
|
979
|
+
|
|
980
|
+
Args:
|
|
981
|
+
loo_values (np.ndarray): Pre-computed LOO values, shape `(n_subjects,)`
|
|
982
|
+
for a single feature or `(n_subjects, n_voxels)` for voxel-wise data.
|
|
983
|
+
summary (str): `'median'` (default) or `'mean'` (Fisher z-transformed:
|
|
984
|
+
arctanh → mean → tanh).
|
|
985
|
+
random_state (int | np.random.RandomState | None): Random state for
|
|
986
|
+
reproducibility.
|
|
987
|
+
|
|
988
|
+
Returns:
|
|
989
|
+
np.ndarray: Bootstrap summary statistic, shape `()` or `(n_voxels,)`.
|
|
990
|
+
"""
|
|
991
|
+
rng = check_random_state(random_state)
|
|
992
|
+
n_subjects = loo_values.shape[0]
|
|
993
|
+
|
|
994
|
+
# Sample subjects with replacement
|
|
995
|
+
indices = rng.choice(n_subjects, size=n_subjects, replace=True)
|
|
996
|
+
|
|
997
|
+
# Resample LOO values
|
|
998
|
+
if loo_values.ndim == 1:
|
|
999
|
+
boot_values = loo_values[indices]
|
|
1000
|
+
else:
|
|
1001
|
+
# Voxel-wise: (n_subjects, n_voxels)
|
|
1002
|
+
boot_values = loo_values[indices, :]
|
|
1003
|
+
|
|
1004
|
+
# Compute summary statistic
|
|
1005
|
+
if summary == "median":
|
|
1006
|
+
return np.median(boot_values, axis=0)
|
|
1007
|
+
if summary == "mean":
|
|
1008
|
+
# Fisher z-transform for unbiased mean
|
|
1009
|
+
z = np.arctanh(np.clip(boot_values, -0.9999, 0.9999))
|
|
1010
|
+
return np.tanh(np.mean(z, axis=0))
|
|
1011
|
+
raise ValueError(f"summary must be 'median' or 'mean', got {summary}")
|
|
1012
|
+
|
|
1013
|
+
|
|
1014
|
+
def _bootstrap_loo_cpu_parallel(
|
|
1015
|
+
loo_values,
|
|
1016
|
+
*,
|
|
1017
|
+
n_permute=5000,
|
|
1018
|
+
summary="median",
|
|
1019
|
+
n_jobs=-1,
|
|
1020
|
+
random_state=None,
|
|
1021
|
+
progress_bar=False,
|
|
1022
|
+
max_memory_gb=None,
|
|
1023
|
+
):
|
|
1024
|
+
"""CPU-parallel LOO bootstrap using joblib.
|
|
1025
|
+
|
|
1026
|
+
Efficiently parallelizes bootstrap resampling across CPU cores.
|
|
1027
|
+
Uses deterministic seed generation for reproducibility.
|
|
1028
|
+
Automatically limits workers based on available memory if n_jobs=-1.
|
|
1029
|
+
|
|
1030
|
+
Args:
|
|
1031
|
+
loo_values (np.ndarray): Pre-computed LOO values, shape `(n_subjects,)`
|
|
1032
|
+
or `(n_subjects, n_voxels)`.
|
|
1033
|
+
n_permute (int): Number of bootstrap iterations. Defaults to 5000.
|
|
1034
|
+
summary (str): `'median'` (default) or `'mean'`.
|
|
1035
|
+
n_jobs (int): CPU cores; -1 (default) picks the worker count from
|
|
1036
|
+
available memory.
|
|
1037
|
+
random_state (int | None): Random seed for reproducibility.
|
|
1038
|
+
progress_bar (bool): Show a progress bar. Defaults to False.
|
|
1039
|
+
max_memory_gb (float | None): Memory budget in GB for the `n_jobs=-1`
|
|
1040
|
+
auto-detection; None measures the machine.
|
|
1041
|
+
|
|
1042
|
+
Returns:
|
|
1043
|
+
np.ndarray: Bootstrap distribution, shape `(n_permute,)` or
|
|
1044
|
+
`(n_permute, n_voxels)`.
|
|
1045
|
+
"""
|
|
1046
|
+
from joblib import Parallel, delayed
|
|
1047
|
+
from nltools.algorithms.backends import _auto_n_jobs_cpu, _estimate_data_size_mb
|
|
1048
|
+
|
|
1049
|
+
# Auto-detect optimal n_jobs based on memory if n_jobs=-1
|
|
1050
|
+
if n_jobs == -1:
|
|
1051
|
+
data_size_mb = _estimate_data_size_mb(loo_values)
|
|
1052
|
+
n_jobs = _auto_n_jobs_cpu(
|
|
1053
|
+
data_size_mb=data_size_mb,
|
|
1054
|
+
n_permute=n_permute,
|
|
1055
|
+
max_memory_gb=max_memory_gb,
|
|
1056
|
+
)
|
|
1057
|
+
|
|
1058
|
+
# Pre-generate seeds for deterministic parallelization
|
|
1059
|
+
rng = check_random_state(random_state)
|
|
1060
|
+
seeds = rng.randint(0, 2**31 - 1, size=n_permute)
|
|
1061
|
+
|
|
1062
|
+
# Parallelize with independent RandomState per permutation
|
|
1063
|
+
iterator = _maybe_tqdm(
|
|
1064
|
+
range(n_permute), progress_bar=progress_bar, desc="Bootstrap LOO"
|
|
1065
|
+
)
|
|
1066
|
+
|
|
1067
|
+
bootstraps = Parallel(n_jobs=n_jobs)(
|
|
1068
|
+
delayed(_bootstrap_loo_numpy)(
|
|
1069
|
+
loo_values,
|
|
1070
|
+
summary=summary,
|
|
1071
|
+
random_state=np.random.RandomState(seeds[i]),
|
|
1072
|
+
)
|
|
1073
|
+
for i in iterator
|
|
1074
|
+
)
|
|
1075
|
+
|
|
1076
|
+
return np.array(bootstraps)
|
|
1077
|
+
|
|
1078
|
+
|
|
1079
|
+
# ============================================================================
|
|
1080
|
+
# Phase 4: Pairwise Bootstrap
|
|
1081
|
+
# ============================================================================
|
|
1082
|
+
|
|
1083
|
+
|
|
1084
|
+
def _bootstrap_pairwise_numpy(
|
|
1085
|
+
pairwise_condensed,
|
|
1086
|
+
summary="median",
|
|
1087
|
+
bootstrap_subjects=None,
|
|
1088
|
+
n_subjects=None,
|
|
1089
|
+
random_state=None,
|
|
1090
|
+
exclude_self_corr=True,
|
|
1091
|
+
):
|
|
1092
|
+
"""Bootstrap pairwise ISC by subject-wise matrix indexing.
|
|
1093
|
+
|
|
1094
|
+
Implements the correct bootstrap procedure for correlation matrices
|
|
1095
|
+
(Chen et al. 2016): resample subjects, extract submatrix, mask
|
|
1096
|
+
same-subject pairs (self-correlations from duplicates).
|
|
1097
|
+
|
|
1098
|
+
A subject drawn twice correlates perfectly with itself; with
|
|
1099
|
+
`exclude_self_corr=True` those entries are masked as NaN before the summary,
|
|
1100
|
+
as Chen et al. (2016) recommend.
|
|
1101
|
+
|
|
1102
|
+
Args:
|
|
1103
|
+
pairwise_condensed (np.ndarray): Pre-computed pairwise correlations in
|
|
1104
|
+
condensed form, shape `(n_pairs,)` for a single feature or
|
|
1105
|
+
`(n_pairs, n_voxels)` for voxel-wise data.
|
|
1106
|
+
summary (str): `'median'` (default) or `'mean'`.
|
|
1107
|
+
bootstrap_subjects (np.ndarray | None): Pre-drawn subject indices (for
|
|
1108
|
+
testing); drawn from `random_state` when None.
|
|
1109
|
+
n_subjects (int | None): Number of subjects; required when
|
|
1110
|
+
`bootstrap_subjects` is None.
|
|
1111
|
+
random_state (int | np.random.RandomState | None): Random state for
|
|
1112
|
+
sampling.
|
|
1113
|
+
exclude_self_corr (bool): Mask self-correlations as NaN. Defaults to
|
|
1114
|
+
True.
|
|
1115
|
+
|
|
1116
|
+
Returns:
|
|
1117
|
+
np.ndarray: Bootstrap summary statistic, shape `()` or `(n_voxels,)`.
|
|
1118
|
+
"""
|
|
1119
|
+
if bootstrap_subjects is None:
|
|
1120
|
+
if n_subjects is None:
|
|
1121
|
+
raise ValueError("Must provide either bootstrap_subjects or n_subjects")
|
|
1122
|
+
rng = check_random_state(random_state)
|
|
1123
|
+
bootstrap_subjects = rng.choice(n_subjects, size=n_subjects, replace=True)
|
|
1124
|
+
else:
|
|
1125
|
+
n_subjects = len(bootstrap_subjects)
|
|
1126
|
+
|
|
1127
|
+
# Handle single feature vs voxel-wise
|
|
1128
|
+
if pairwise_condensed.ndim == 1:
|
|
1129
|
+
# Single feature
|
|
1130
|
+
# Reconstruct correlation matrix from condensed form
|
|
1131
|
+
corr_matrix = squareform(pairwise_condensed, force="tomatrix")
|
|
1132
|
+
np.fill_diagonal(corr_matrix, 1.0)
|
|
1133
|
+
|
|
1134
|
+
# Index by bootstrap subjects (symmetric: rows and columns)
|
|
1135
|
+
boot_matrix = corr_matrix[bootstrap_subjects, :][:, bootstrap_subjects]
|
|
1136
|
+
|
|
1137
|
+
# Mask self-correlations if requested
|
|
1138
|
+
if exclude_self_corr:
|
|
1139
|
+
boot_matrix[boot_matrix >= 0.99999] = np.nan
|
|
1140
|
+
|
|
1141
|
+
# Extract upper triangle (excluding diagonal)
|
|
1142
|
+
boot_condensed = squareform(boot_matrix, checks=False)
|
|
1143
|
+
|
|
1144
|
+
else:
|
|
1145
|
+
# Voxel-wise: (n_pairs, n_voxels)
|
|
1146
|
+
n_pairs, n_voxels = pairwise_condensed.shape
|
|
1147
|
+
|
|
1148
|
+
# Vectorized approach: process all voxels at once using matrix operations
|
|
1149
|
+
# Instead of looping over voxels, we can vectorize the squareform operations
|
|
1150
|
+
# by building all matrices at once and using advanced indexing
|
|
1151
|
+
|
|
1152
|
+
# Build all correlation matrices at once: (n_subjects, n_subjects, n_voxels)
|
|
1153
|
+
# This is more memory-intensive but much faster
|
|
1154
|
+
corr_matrices = np.zeros(
|
|
1155
|
+
(n_subjects, n_subjects, n_voxels), dtype=pairwise_condensed.dtype
|
|
1156
|
+
)
|
|
1157
|
+
for v in range(n_voxels):
|
|
1158
|
+
corr_matrix = squareform(pairwise_condensed[:, v], force="tomatrix")
|
|
1159
|
+
np.fill_diagonal(corr_matrix, 1.0)
|
|
1160
|
+
corr_matrices[:, :, v] = corr_matrix
|
|
1161
|
+
|
|
1162
|
+
# Index by bootstrap subjects for all voxels at once
|
|
1163
|
+
# Shape: (n_subjects, n_subjects, n_voxels)
|
|
1164
|
+
boot_matrices = corr_matrices[bootstrap_subjects, :, :][
|
|
1165
|
+
:, bootstrap_subjects, :
|
|
1166
|
+
]
|
|
1167
|
+
|
|
1168
|
+
# Mask self-correlations if requested (vectorized across all voxels)
|
|
1169
|
+
if exclude_self_corr:
|
|
1170
|
+
boot_matrices[boot_matrices >= 0.99999] = np.nan
|
|
1171
|
+
|
|
1172
|
+
# Extract upper triangle for all voxels
|
|
1173
|
+
boot_condensed = np.zeros((n_pairs, n_voxels), dtype=pairwise_condensed.dtype)
|
|
1174
|
+
for v in range(n_voxels):
|
|
1175
|
+
boot_condensed[:, v] = squareform(boot_matrices[:, :, v], checks=False)
|
|
1176
|
+
|
|
1177
|
+
# Compute summary (ignoring NaNs from masked pairs)
|
|
1178
|
+
axis = 0 if boot_condensed.ndim > 1 else None
|
|
1179
|
+
|
|
1180
|
+
if summary == "median":
|
|
1181
|
+
return np.nanmedian(boot_condensed, axis=axis)
|
|
1182
|
+
if summary == "mean":
|
|
1183
|
+
# Fisher z-transform
|
|
1184
|
+
z = np.arctanh(np.clip(boot_condensed, -0.9999, 0.9999))
|
|
1185
|
+
return np.tanh(np.nanmean(z, axis=axis))
|
|
1186
|
+
raise ValueError(f"summary must be 'median' or 'mean', got {summary}")
|
|
1187
|
+
|
|
1188
|
+
|
|
1189
|
+
def _bootstrap_pairwise_cpu_parallel(
|
|
1190
|
+
pairwise_condensed,
|
|
1191
|
+
*,
|
|
1192
|
+
n_permute=5000,
|
|
1193
|
+
n_subjects=None,
|
|
1194
|
+
summary="median",
|
|
1195
|
+
n_jobs=-1,
|
|
1196
|
+
random_state=None,
|
|
1197
|
+
progress_bar=False,
|
|
1198
|
+
exclude_self_corr=True,
|
|
1199
|
+
max_memory_gb=None,
|
|
1200
|
+
):
|
|
1201
|
+
"""CPU-parallel pairwise bootstrap using joblib.
|
|
1202
|
+
|
|
1203
|
+
Same pattern as LOO bootstrap, but operates on pairwise correlation
|
|
1204
|
+
matrices with subject-wise indexing.
|
|
1205
|
+
Automatically limits workers based on available memory if n_jobs=-1.
|
|
1206
|
+
|
|
1207
|
+
Args:
|
|
1208
|
+
pairwise_condensed (np.ndarray): Pre-computed pairwise correlations,
|
|
1209
|
+
shape `(n_pairs,)` or `(n_pairs, n_voxels)`.
|
|
1210
|
+
n_permute (int): Number of bootstrap iterations. Defaults to 5000.
|
|
1211
|
+
n_subjects (int): Number of subjects in the original data.
|
|
1212
|
+
summary (str): `'median'` (default) or `'mean'`.
|
|
1213
|
+
n_jobs (int): CPU cores; -1 (default) picks the worker count from
|
|
1214
|
+
available memory.
|
|
1215
|
+
random_state (int | None): Random seed for reproducibility.
|
|
1216
|
+
progress_bar (bool): Show a progress bar. Defaults to False.
|
|
1217
|
+
exclude_self_corr (bool): Mask self-correlations as NaN. Defaults to
|
|
1218
|
+
True.
|
|
1219
|
+
max_memory_gb (float | None): Memory budget in GB for the `n_jobs=-1`
|
|
1220
|
+
auto-detection; None measures the machine.
|
|
1221
|
+
|
|
1222
|
+
Returns:
|
|
1223
|
+
np.ndarray: Bootstrap distribution, shape `(n_permute,)` or
|
|
1224
|
+
`(n_permute, n_voxels)`.
|
|
1225
|
+
"""
|
|
1226
|
+
from joblib import Parallel, delayed
|
|
1227
|
+
from nltools.algorithms.backends import _auto_n_jobs_cpu, _estimate_data_size_mb
|
|
1228
|
+
|
|
1229
|
+
if n_subjects is None:
|
|
1230
|
+
raise ValueError("n_subjects is required for pairwise bootstrap")
|
|
1231
|
+
|
|
1232
|
+
# Auto-detect optimal n_jobs based on memory if n_jobs=-1
|
|
1233
|
+
if n_jobs == -1:
|
|
1234
|
+
data_size_mb = _estimate_data_size_mb(pairwise_condensed)
|
|
1235
|
+
n_jobs = _auto_n_jobs_cpu(
|
|
1236
|
+
data_size_mb=data_size_mb,
|
|
1237
|
+
n_permute=n_permute,
|
|
1238
|
+
max_memory_gb=max_memory_gb,
|
|
1239
|
+
)
|
|
1240
|
+
|
|
1241
|
+
# Pre-generate seeds
|
|
1242
|
+
rng = check_random_state(random_state)
|
|
1243
|
+
seeds = rng.randint(0, 2**31 - 1, size=n_permute)
|
|
1244
|
+
|
|
1245
|
+
# Parallelize
|
|
1246
|
+
iterator = _maybe_tqdm(
|
|
1247
|
+
range(n_permute), progress_bar=progress_bar, desc="Bootstrap Pairwise"
|
|
1248
|
+
)
|
|
1249
|
+
|
|
1250
|
+
bootstraps = Parallel(n_jobs=n_jobs)(
|
|
1251
|
+
delayed(_bootstrap_pairwise_numpy)(
|
|
1252
|
+
pairwise_condensed,
|
|
1253
|
+
summary=summary,
|
|
1254
|
+
n_subjects=n_subjects,
|
|
1255
|
+
random_state=np.random.RandomState(seeds[i]),
|
|
1256
|
+
exclude_self_corr=exclude_self_corr,
|
|
1257
|
+
)
|
|
1258
|
+
for i in iterator
|
|
1259
|
+
)
|
|
1260
|
+
|
|
1261
|
+
return np.array(bootstraps)
|
|
1262
|
+
|
|
1263
|
+
|
|
1264
|
+
def _isc_permutation_test(
|
|
1265
|
+
# Required
|
|
1266
|
+
data: np.ndarray,
|
|
1267
|
+
*,
|
|
1268
|
+
# Optional algorithm parameters
|
|
1269
|
+
n_permute: int = 5000,
|
|
1270
|
+
summary: Literal["median", "mean"] = "median",
|
|
1271
|
+
summary_statistic: Literal["leave-one-out", "pairwise"] = "pairwise",
|
|
1272
|
+
method: Literal["bootstrap", "circle_shift", "phase_randomize"] = "bootstrap",
|
|
1273
|
+
ci_percentile: float = 95,
|
|
1274
|
+
tail: int | str = 2,
|
|
1275
|
+
return_null: bool = False,
|
|
1276
|
+
progress_bar: bool = False,
|
|
1277
|
+
exclude_self_corr: bool = True,
|
|
1278
|
+
metric: str = "correlation",
|
|
1279
|
+
# Backend parameters (grouped)
|
|
1280
|
+
n_jobs: int = -1,
|
|
1281
|
+
# Random state (last)
|
|
1282
|
+
random_state: int | None = None,
|
|
1283
|
+
) -> dict[str, Any]:
|
|
1284
|
+
"""Compute intersubject correlation with bootstrap or permutation inference.
|
|
1285
|
+
|
|
1286
|
+
Summarizes how similarly subjects respond over time — either leave-one-out
|
|
1287
|
+
(each subject against the mean of the others; O(n_subjects), unbiased) or
|
|
1288
|
+
pairwise (all subject pairs; O(n_subjects²), full correlation structure).
|
|
1289
|
+
The two are monotonically but non-linearly related and statistically
|
|
1290
|
+
different (Chen et al. 2016, Figure 3). The null distribution comes from a
|
|
1291
|
+
subject-wise bootstrap (centered on the observed ISC, so the p-value tests
|
|
1292
|
+
H0: ISC = 0) or from surrogate time series that preserve each subject's
|
|
1293
|
+
autocorrelation (circular shift) or power spectrum (phase randomization).
|
|
1294
|
+
|
|
1295
|
+
Args:
|
|
1296
|
+
data (np.ndarray): Shape `(n_observations, n_subjects)` for a single
|
|
1297
|
+
feature or `(n_observations, n_subjects, n_voxels)` for voxel-wise
|
|
1298
|
+
ISC.
|
|
1299
|
+
n_permute (int): Number of bootstrap draws or permutations. Defaults to
|
|
1300
|
+
5000.
|
|
1301
|
+
summary (str): How ISC values are aggregated: `'median'` (default,
|
|
1302
|
+
robust to outliers) or `'mean'` (Fisher z-transformed mean).
|
|
1303
|
+
summary_statistic (str): `'pairwise'` (default) or `'leave-one-out'`.
|
|
1304
|
+
method (str): `'bootstrap'` (default; subject-wise bootstrap, Chen et
|
|
1305
|
+
al. 2016), `'circle_shift'` (circular time-series shift), or
|
|
1306
|
+
`'phase_randomize'` (FFT phase randomization).
|
|
1307
|
+
ci_percentile (float): Confidence-interval width in percent (95 gives a
|
|
1308
|
+
95% CI). Defaults to 95.
|
|
1309
|
+
tail (int | str): `2` or `'two'` (default) for a two-tailed p-value;
|
|
1310
|
+
`1` or `'one'` for one-tailed (ISC > 0).
|
|
1311
|
+
return_null (bool): If True, include the null distribution in the
|
|
1312
|
+
result. Defaults to False.
|
|
1313
|
+
progress_bar (bool): Show a progress bar over the resamples. Defaults to
|
|
1314
|
+
False.
|
|
1315
|
+
exclude_self_corr (bool): In the pairwise bootstrap, mask the perfect
|
|
1316
|
+
correlations a duplicated subject produces as NaN. Defaults to True.
|
|
1317
|
+
metric (str): Similarity metric for pairwise ISC; any metric accepted by
|
|
1318
|
+
`sklearn.metrics.pairwise_distances` (`'correlation'`,
|
|
1319
|
+
`'spearman'`, `'cosine'`, and `'euclidean'` take fast paths). Ignored
|
|
1320
|
+
for `summary_statistic='leave-one-out'`. Defaults to
|
|
1321
|
+
`'correlation'`.
|
|
1322
|
+
n_jobs (int): Number of joblib workers for the resamples. -1 (default)
|
|
1323
|
+
picks the worker count from available memory. Results are identical
|
|
1324
|
+
at every worker count.
|
|
1325
|
+
random_state (int | None): Random seed for reproducibility.
|
|
1326
|
+
|
|
1327
|
+
Returns:
|
|
1328
|
+
dict: Keys `'isc'` (float or np.ndarray, observed ISC), `'p'` (float or
|
|
1329
|
+
np.ndarray, p-value with the `(count + 1) / (n + 1)` correction),
|
|
1330
|
+
`'ci'` (tuple `(lower, upper)` percentiles of the resamples), and
|
|
1331
|
+
— when `return_null=True` — `'null_dist'` (np.ndarray).
|
|
1332
|
+
|
|
1333
|
+
Examples:
|
|
1334
|
+
```python
|
|
1335
|
+
# Single-feature ISC
|
|
1336
|
+
data = np.random.randn(100, 10) # 100 timepoints, 10 subjects
|
|
1337
|
+
result = _isc_permutation_test(data, n_permute=1000)
|
|
1338
|
+
result["isc"], result["p"]
|
|
1339
|
+
|
|
1340
|
+
# Voxel-wise leave-one-out ISC
|
|
1341
|
+
data_voxels = np.random.randn(100, 50, 5000) # 5K voxels
|
|
1342
|
+
result = _isc_permutation_test(
|
|
1343
|
+
data_voxels,
|
|
1344
|
+
summary_statistic="leave-one-out",
|
|
1345
|
+
n_permute=5000,
|
|
1346
|
+
)
|
|
1347
|
+
(result["p"] < 0.05).sum() # → number of significant voxels
|
|
1348
|
+
|
|
1349
|
+
# Leave-one-out vs pairwise
|
|
1350
|
+
result_loo = _isc_permutation_test(data, summary_statistic="leave-one-out")
|
|
1351
|
+
result_pair = _isc_permutation_test(data, summary_statistic="pairwise")
|
|
1352
|
+
```
|
|
1353
|
+
|
|
1354
|
+
References:
|
|
1355
|
+
Chen, G., Shin, Y. W., Taylor, P. A., Glen, D. R., Reynolds, R. C.,
|
|
1356
|
+
Israel, R. B., & Cox, R. W. (2016). Untangling the relatedness among
|
|
1357
|
+
correlations, part I: nonparametric approaches to inter-subject
|
|
1358
|
+
correlation analysis at the group level. NeuroImage, 142, 248-259.
|
|
1359
|
+
"""
|
|
1360
|
+
# Input validation
|
|
1361
|
+
_validate_tail_parameter(tail)
|
|
1362
|
+
data = np.asarray(data)
|
|
1363
|
+
if data.ndim not in [2, 3]:
|
|
1364
|
+
raise ValueError(f"data must be 2D or 3D, got shape {data.shape}")
|
|
1365
|
+
|
|
1366
|
+
if summary_statistic not in ["leave-one-out", "pairwise"]:
|
|
1367
|
+
raise ValueError(
|
|
1368
|
+
f"summary_statistic must be 'leave-one-out' or 'pairwise', "
|
|
1369
|
+
f"got {summary_statistic}"
|
|
1370
|
+
)
|
|
1371
|
+
|
|
1372
|
+
if method not in ["bootstrap", "circle_shift", "phase_randomize"]:
|
|
1373
|
+
raise ValueError(
|
|
1374
|
+
f"method must be 'bootstrap', 'circle_shift', or 'phase_randomize', "
|
|
1375
|
+
f"got {method}"
|
|
1376
|
+
)
|
|
1377
|
+
|
|
1378
|
+
# Phase 1: Compute ISC (run once)
|
|
1379
|
+
if summary_statistic == "leave-one-out":
|
|
1380
|
+
# Compute leave-one-out values
|
|
1381
|
+
loo_values = _compute_loo_isc(data)
|
|
1382
|
+
|
|
1383
|
+
# Compute observed summary statistic
|
|
1384
|
+
if summary == "median":
|
|
1385
|
+
observed_isc = np.median(loo_values, axis=0)
|
|
1386
|
+
elif summary == "mean":
|
|
1387
|
+
z = np.arctanh(np.clip(loo_values, -0.9999, 0.9999))
|
|
1388
|
+
observed_isc = np.tanh(np.mean(z, axis=0))
|
|
1389
|
+
else:
|
|
1390
|
+
raise ValueError(f"summary must be 'median' or 'mean', got {summary}")
|
|
1391
|
+
|
|
1392
|
+
else: # pairwise
|
|
1393
|
+
# Compute pairwise correlation matrix (condensed form)
|
|
1394
|
+
pairwise_condensed = _compute_pairwise_isc(data, metric=metric)
|
|
1395
|
+
n_subjects = data.shape[1]
|
|
1396
|
+
|
|
1397
|
+
# Compute observed summary statistic
|
|
1398
|
+
if summary == "median":
|
|
1399
|
+
observed_isc = np.nanmedian(pairwise_condensed, axis=0)
|
|
1400
|
+
elif summary == "mean":
|
|
1401
|
+
z = np.arctanh(np.clip(pairwise_condensed, -0.9999, 0.9999))
|
|
1402
|
+
observed_isc = np.tanh(np.nanmean(z, axis=0))
|
|
1403
|
+
else:
|
|
1404
|
+
raise ValueError(f"summary must be 'median' or 'mean', got {summary}")
|
|
1405
|
+
|
|
1406
|
+
# Phase 2: Bootstrap/permutation (run n_permute times)
|
|
1407
|
+
if method == "bootstrap":
|
|
1408
|
+
if summary_statistic == "leave-one-out":
|
|
1409
|
+
# LOO bootstrap: resample pre-computed values
|
|
1410
|
+
bootstraps = _bootstrap_loo_cpu_parallel(
|
|
1411
|
+
loo_values,
|
|
1412
|
+
n_permute=n_permute,
|
|
1413
|
+
summary=summary,
|
|
1414
|
+
n_jobs=n_jobs,
|
|
1415
|
+
random_state=random_state,
|
|
1416
|
+
progress_bar=progress_bar,
|
|
1417
|
+
max_memory_gb=None, # Auto-detect
|
|
1418
|
+
)
|
|
1419
|
+
else: # pairwise
|
|
1420
|
+
# Pairwise bootstrap: subject-wise matrix indexing
|
|
1421
|
+
bootstraps = _bootstrap_pairwise_cpu_parallel(
|
|
1422
|
+
pairwise_condensed,
|
|
1423
|
+
n_permute=n_permute,
|
|
1424
|
+
n_subjects=n_subjects,
|
|
1425
|
+
summary=summary,
|
|
1426
|
+
n_jobs=n_jobs,
|
|
1427
|
+
random_state=random_state,
|
|
1428
|
+
progress_bar=progress_bar,
|
|
1429
|
+
exclude_self_corr=exclude_self_corr,
|
|
1430
|
+
max_memory_gb=None, # Auto-detect
|
|
1431
|
+
)
|
|
1432
|
+
|
|
1433
|
+
# Center bootstrap distribution by subtracting observed (Chen et al. 2016)
|
|
1434
|
+
null_distribution = bootstraps - observed_isc
|
|
1435
|
+
|
|
1436
|
+
elif method == "circle_shift":
|
|
1437
|
+
# Import timeseries utilities
|
|
1438
|
+
from .timeseries import circle_shift
|
|
1439
|
+
|
|
1440
|
+
# Permute data and recompute ISC
|
|
1441
|
+
rng = check_random_state(random_state)
|
|
1442
|
+
seeds = rng.randint(0, 2**31 - 1, size=n_permute)
|
|
1443
|
+
|
|
1444
|
+
bootstraps = []
|
|
1445
|
+
for i in range(n_permute):
|
|
1446
|
+
# Circle shift the data
|
|
1447
|
+
# For 3D data (n_obs, n_subjects, n_voxels), apply per subject
|
|
1448
|
+
if data.ndim == 3:
|
|
1449
|
+
perm_rng = np.random.RandomState(seeds[i])
|
|
1450
|
+
data_permuted = np.empty_like(data)
|
|
1451
|
+
for subj in range(data.shape[1]):
|
|
1452
|
+
data_permuted[:, subj, :] = circle_shift(
|
|
1453
|
+
data[:, subj, :], random_state=perm_rng
|
|
1454
|
+
)
|
|
1455
|
+
else:
|
|
1456
|
+
data_permuted = circle_shift(
|
|
1457
|
+
data, random_state=np.random.RandomState(seeds[i])
|
|
1458
|
+
)
|
|
1459
|
+
|
|
1460
|
+
# Recompute ISC
|
|
1461
|
+
if summary_statistic == "leave-one-out":
|
|
1462
|
+
loo_perm = _compute_loo_isc(data_permuted)
|
|
1463
|
+
if summary == "median":
|
|
1464
|
+
isc_perm = np.median(loo_perm, axis=0)
|
|
1465
|
+
else:
|
|
1466
|
+
z = np.arctanh(np.clip(loo_perm, -0.9999, 0.9999))
|
|
1467
|
+
isc_perm = np.tanh(np.mean(z, axis=0))
|
|
1468
|
+
else: # pairwise
|
|
1469
|
+
pair_perm = _compute_pairwise_isc(data_permuted, metric=metric)
|
|
1470
|
+
if summary == "median":
|
|
1471
|
+
isc_perm = np.nanmedian(pair_perm, axis=0)
|
|
1472
|
+
else:
|
|
1473
|
+
z = np.arctanh(np.clip(pair_perm, -0.9999, 0.9999))
|
|
1474
|
+
isc_perm = np.tanh(np.nanmean(z, axis=0))
|
|
1475
|
+
|
|
1476
|
+
bootstraps.append(isc_perm)
|
|
1477
|
+
|
|
1478
|
+
bootstraps = np.array(bootstraps)
|
|
1479
|
+
null_distribution = bootstraps # Already centered for permutation methods
|
|
1480
|
+
|
|
1481
|
+
elif method == "phase_randomize":
|
|
1482
|
+
# Import timeseries utilities
|
|
1483
|
+
from .timeseries import phase_randomize
|
|
1484
|
+
|
|
1485
|
+
# Similar to circle_shift but with phase randomization
|
|
1486
|
+
rng = check_random_state(random_state)
|
|
1487
|
+
seeds = rng.randint(0, 2**31 - 1, size=n_permute)
|
|
1488
|
+
|
|
1489
|
+
bootstraps = []
|
|
1490
|
+
for i in range(n_permute):
|
|
1491
|
+
# Phase randomize the data
|
|
1492
|
+
# For 3D data (n_obs, n_subjects, n_voxels), apply per subject
|
|
1493
|
+
if data.ndim == 3:
|
|
1494
|
+
perm_rng = np.random.RandomState(seeds[i])
|
|
1495
|
+
data_permuted = np.empty_like(data)
|
|
1496
|
+
for subj in range(data.shape[1]):
|
|
1497
|
+
data_permuted[:, subj, :] = phase_randomize(
|
|
1498
|
+
data[:, subj, :], random_state=perm_rng
|
|
1499
|
+
)
|
|
1500
|
+
else:
|
|
1501
|
+
data_permuted = phase_randomize(
|
|
1502
|
+
data, random_state=np.random.RandomState(seeds[i])
|
|
1503
|
+
)
|
|
1504
|
+
|
|
1505
|
+
# Recompute ISC
|
|
1506
|
+
if summary_statistic == "leave-one-out":
|
|
1507
|
+
loo_perm = _compute_loo_isc(data_permuted)
|
|
1508
|
+
if summary == "median":
|
|
1509
|
+
isc_perm = np.median(loo_perm, axis=0)
|
|
1510
|
+
else:
|
|
1511
|
+
z = np.arctanh(np.clip(loo_perm, -0.9999, 0.9999))
|
|
1512
|
+
isc_perm = np.tanh(np.mean(z, axis=0))
|
|
1513
|
+
else: # pairwise
|
|
1514
|
+
pair_perm = _compute_pairwise_isc(data_permuted, metric=metric)
|
|
1515
|
+
if summary == "median":
|
|
1516
|
+
isc_perm = np.nanmedian(pair_perm, axis=0)
|
|
1517
|
+
else:
|
|
1518
|
+
z = np.arctanh(np.clip(pair_perm, -0.9999, 0.9999))
|
|
1519
|
+
isc_perm = np.tanh(np.nanmean(z, axis=0))
|
|
1520
|
+
|
|
1521
|
+
bootstraps.append(isc_perm)
|
|
1522
|
+
|
|
1523
|
+
bootstraps = np.array(bootstraps)
|
|
1524
|
+
null_distribution = bootstraps
|
|
1525
|
+
|
|
1526
|
+
# Compute p-value (Phipson-Smyth correction)
|
|
1527
|
+
# NOTE: _compute_pvalue signature is (obs_stat, null_dist, tail)
|
|
1528
|
+
p_value = _compute_pvalue(observed_isc, null_distribution, tail=tail)
|
|
1529
|
+
|
|
1530
|
+
# Compute confidence interval
|
|
1531
|
+
ci_lower = (100 - ci_percentile) / 2
|
|
1532
|
+
ci_upper = ci_percentile + ci_lower
|
|
1533
|
+
|
|
1534
|
+
if observed_isc.ndim == 0 or observed_isc.shape == ():
|
|
1535
|
+
# Single value
|
|
1536
|
+
ci = (np.percentile(bootstraps, ci_lower), np.percentile(bootstraps, ci_upper))
|
|
1537
|
+
else:
|
|
1538
|
+
# Per-voxel
|
|
1539
|
+
ci = (
|
|
1540
|
+
np.percentile(bootstraps, ci_lower, axis=0),
|
|
1541
|
+
np.percentile(bootstraps, ci_upper, axis=0),
|
|
1542
|
+
)
|
|
1543
|
+
|
|
1544
|
+
# Build result dictionary
|
|
1545
|
+
result = {
|
|
1546
|
+
"isc": observed_isc,
|
|
1547
|
+
"p": p_value,
|
|
1548
|
+
"ci": ci,
|
|
1549
|
+
}
|
|
1550
|
+
|
|
1551
|
+
if return_null:
|
|
1552
|
+
result["null_dist"] = null_distribution
|
|
1553
|
+
|
|
1554
|
+
return result
|