nltools 0.6.0.dev0__py3-none-any.whl

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