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,602 @@
|
|
|
1
|
+
"""Permutation tests and dependence measures for square matrices.
|
|
2
|
+
|
|
3
|
+
`matrix_permutation_test` is the Mantel test: it asks whether two square matrices
|
|
4
|
+
(e.g. two representational dissimilarity matrices, or a brain and a model
|
|
5
|
+
similarity matrix) are correlated, building the null by permuting the rows and
|
|
6
|
+
columns of one matrix together. `distance_correlation` measures multivariate
|
|
7
|
+
dependence (linear or not) between two arrays, with `_double_center` and
|
|
8
|
+
`_u_center` as the centering steps it is built on. Permutations run on joblib
|
|
9
|
+
workers; `n_jobs` sets how many, and a given `random_state` gives the same
|
|
10
|
+
result at any worker count.
|
|
11
|
+
|
|
12
|
+
`_extract_triangle_elements` pulls the upper or lower triangle of a matrix into a
|
|
13
|
+
vector; `_permute_matrix_symmetric` reorders rows and columns together, the
|
|
14
|
+
operation at the heart of the matrix permutation tests.
|
|
15
|
+
"""
|
|
16
|
+
|
|
17
|
+
import numpy as np
|
|
18
|
+
from scipy.stats import pearsonr, spearmanr, kendalltau
|
|
19
|
+
from scipy.spatial.distance import squareform, pdist
|
|
20
|
+
from scipy.stats import t as t_dist
|
|
21
|
+
|
|
22
|
+
from .utils import _maybe_tqdm
|
|
23
|
+
from .validation import (
|
|
24
|
+
_validate_how_parameter,
|
|
25
|
+
_validate_metric_parameter,
|
|
26
|
+
_validate_same_shape,
|
|
27
|
+
_validate_square_matrix,
|
|
28
|
+
)
|
|
29
|
+
from ..validation import _compute_pvalue, _validate_tail_parameter
|
|
30
|
+
|
|
31
|
+
|
|
32
|
+
# Maximum integer for random seed generation
|
|
33
|
+
MAX_INT = np.iinfo(np.int32).max
|
|
34
|
+
|
|
35
|
+
|
|
36
|
+
def _extract_triangle_elements(
|
|
37
|
+
matrix: np.ndarray,
|
|
38
|
+
triangle: str = "upper",
|
|
39
|
+
include_diag: bool = False,
|
|
40
|
+
) -> np.ndarray:
|
|
41
|
+
"""Extract the off-diagonal triangle of a square matrix as a vector.
|
|
42
|
+
|
|
43
|
+
Args:
|
|
44
|
+
matrix (np.ndarray): Square matrix, shape (n, n).
|
|
45
|
+
triangle (str): 'upper', 'lower', or 'full' (upper then lower). Defaults to 'upper'.
|
|
46
|
+
include_diag (bool): With `triangle='full'`, return every element
|
|
47
|
+
(`matrix.ravel()`) instead of excluding the diagonal. Defaults to False.
|
|
48
|
+
|
|
49
|
+
Returns:
|
|
50
|
+
np.ndarray: The selected elements as a 1D array.
|
|
51
|
+
|
|
52
|
+
Examples:
|
|
53
|
+
```python
|
|
54
|
+
matrix = np.arange(16).reshape(4, 4)
|
|
55
|
+
_extract_triangle_elements(matrix, triangle="upper")
|
|
56
|
+
# → array([ 1, 2, 3, 6, 7, 11])
|
|
57
|
+
```
|
|
58
|
+
"""
|
|
59
|
+
if triangle == "upper":
|
|
60
|
+
return matrix[np.triu_indices(matrix.shape[0], k=1)]
|
|
61
|
+
if triangle == "lower":
|
|
62
|
+
return matrix[np.tril_indices(matrix.shape[0], k=-1)]
|
|
63
|
+
if triangle == "full":
|
|
64
|
+
if include_diag:
|
|
65
|
+
return matrix.ravel()
|
|
66
|
+
# Concatenate upper and lower triangles (exclude diagonal)
|
|
67
|
+
upper = matrix[np.triu_indices(matrix.shape[0], k=1)]
|
|
68
|
+
lower = matrix[np.tril_indices(matrix.shape[0], k=-1)]
|
|
69
|
+
return np.concatenate([upper, lower])
|
|
70
|
+
raise ValueError(f"triangle must be 'upper', 'lower', or 'full', got {triangle}")
|
|
71
|
+
|
|
72
|
+
|
|
73
|
+
def _permute_matrix_symmetric(
|
|
74
|
+
matrix: np.ndarray,
|
|
75
|
+
permutation: np.ndarray,
|
|
76
|
+
) -> np.ndarray:
|
|
77
|
+
"""Permute the rows and columns of a square matrix together.
|
|
78
|
+
|
|
79
|
+
Reordering both axes with the same permutation relabels the items while
|
|
80
|
+
preserving the matrix's internal structure, which is what breaks the
|
|
81
|
+
correspondence between two matrices in a matrix permutation test.
|
|
82
|
+
|
|
83
|
+
Args:
|
|
84
|
+
matrix (np.ndarray): Square matrix, shape (n, n).
|
|
85
|
+
permutation (np.ndarray): Permutation of `range(n)`.
|
|
86
|
+
|
|
87
|
+
Returns:
|
|
88
|
+
np.ndarray: The permuted matrix, shape (n, n).
|
|
89
|
+
|
|
90
|
+
Examples:
|
|
91
|
+
```python
|
|
92
|
+
matrix = np.arange(9).reshape(3, 3)
|
|
93
|
+
perm = np.array([2, 0, 1])
|
|
94
|
+
_permute_matrix_symmetric(matrix, perm)
|
|
95
|
+
# → array([[8, 6, 7],
|
|
96
|
+
# [2, 0, 1],
|
|
97
|
+
# [5, 3, 4]])
|
|
98
|
+
```
|
|
99
|
+
"""
|
|
100
|
+
return matrix[permutation][:, permutation]
|
|
101
|
+
|
|
102
|
+
|
|
103
|
+
def _extract_matrix_elements(
|
|
104
|
+
matrix: np.ndarray,
|
|
105
|
+
how: str = "upper",
|
|
106
|
+
include_diag: bool = False,
|
|
107
|
+
) -> np.ndarray:
|
|
108
|
+
"""Extract elements from a square matrix (wrapper for `_extract_triangle_elements`).
|
|
109
|
+
|
|
110
|
+
Args:
|
|
111
|
+
matrix (np.ndarray): Square matrix (n×n).
|
|
112
|
+
how (str): Which elements to extract, one of 'upper', 'lower', or 'full'.
|
|
113
|
+
include_diag (bool): Include the diagonal (only for 'full').
|
|
114
|
+
|
|
115
|
+
Returns:
|
|
116
|
+
np.ndarray: 1D array of extracted elements.
|
|
117
|
+
"""
|
|
118
|
+
return _extract_triangle_elements(matrix, triangle=how, include_diag=include_diag)
|
|
119
|
+
|
|
120
|
+
|
|
121
|
+
def _compute_matrix_correlation(
|
|
122
|
+
matrix1: np.ndarray,
|
|
123
|
+
matrix2: np.ndarray,
|
|
124
|
+
how: str = "upper",
|
|
125
|
+
include_diag: bool = False,
|
|
126
|
+
metric: str = "pearson",
|
|
127
|
+
) -> float:
|
|
128
|
+
"""Compute the correlation between the elements of two square matrices.
|
|
129
|
+
|
|
130
|
+
Args:
|
|
131
|
+
matrix1 (np.ndarray): First square matrix (n×n).
|
|
132
|
+
matrix2 (np.ndarray): Second square matrix (n×n).
|
|
133
|
+
how (str): Which elements to compare, one of 'upper', 'lower', or 'full'.
|
|
134
|
+
include_diag (bool): Include the diagonal (only for `how='full'`).
|
|
135
|
+
metric (str): Correlation type, one of 'pearson', 'spearman', or 'kendall'.
|
|
136
|
+
|
|
137
|
+
Returns:
|
|
138
|
+
float: Correlation coefficient.
|
|
139
|
+
|
|
140
|
+
Examples:
|
|
141
|
+
```python
|
|
142
|
+
m1 = np.eye(3)
|
|
143
|
+
m2 = np.eye(3)
|
|
144
|
+
_compute_matrix_correlation(m1, m2, metric="pearson") # → 1.0
|
|
145
|
+
```
|
|
146
|
+
"""
|
|
147
|
+
# Extract elements from both matrices
|
|
148
|
+
elements1 = _extract_matrix_elements(matrix1, how=how, include_diag=include_diag)
|
|
149
|
+
elements2 = _extract_matrix_elements(matrix2, how=how, include_diag=include_diag)
|
|
150
|
+
|
|
151
|
+
# Compute correlation
|
|
152
|
+
_validate_metric_parameter(
|
|
153
|
+
metric, ["pearson", "spearman", "kendall"], name="metric"
|
|
154
|
+
)
|
|
155
|
+
if metric == "pearson":
|
|
156
|
+
r, _ = pearsonr(elements1, elements2)
|
|
157
|
+
elif metric == "spearman":
|
|
158
|
+
r, _ = spearmanr(elements1, elements2)
|
|
159
|
+
elif metric == "kendall":
|
|
160
|
+
r, _ = kendalltau(elements1, elements2)
|
|
161
|
+
|
|
162
|
+
return r
|
|
163
|
+
|
|
164
|
+
|
|
165
|
+
def _compute_cross_correlation(matrix1: np.ndarray, matrix2: np.ndarray) -> np.ndarray:
|
|
166
|
+
"""Compute cross-correlation between columns (features) of two matrices.
|
|
167
|
+
|
|
168
|
+
This function computes the correlation between each column of matrix1
|
|
169
|
+
with each column of matrix2. Useful for computing connectivity matrices
|
|
170
|
+
such as in intersubject functional connectivity (ISFC).
|
|
171
|
+
|
|
172
|
+
Args:
|
|
173
|
+
matrix1 (np.ndarray): First matrix, shape (n_observations, n_features1).
|
|
174
|
+
matrix2 (np.ndarray): Second matrix, shape (n_observations, n_features2).
|
|
175
|
+
|
|
176
|
+
Returns:
|
|
177
|
+
np.ndarray: Cross-correlation matrix, shape (n_features1, n_features2),
|
|
178
|
+
where element [i, j] is the correlation between `matrix1[:, i]` and
|
|
179
|
+
`matrix2[:, j]`.
|
|
180
|
+
|
|
181
|
+
Examples:
|
|
182
|
+
```python
|
|
183
|
+
matrix1 = np.random.randn(100, 5) # 100 observations, 5 features
|
|
184
|
+
matrix2 = np.random.randn(100, 3) # 100 observations, 3 features
|
|
185
|
+
corr = _compute_cross_correlation(matrix1, matrix2)
|
|
186
|
+
corr.shape # → (5, 3)
|
|
187
|
+
```
|
|
188
|
+
|
|
189
|
+
Note:
|
|
190
|
+
Computed as the off-diagonal block of `np.corrcoef` over the concatenated
|
|
191
|
+
columns.
|
|
192
|
+
"""
|
|
193
|
+
if matrix1.shape[0] != matrix2.shape[0]:
|
|
194
|
+
raise ValueError(
|
|
195
|
+
f"Matrices must have same number of rows (observations), "
|
|
196
|
+
f"got {matrix1.shape[0]} and {matrix2.shape[0]}"
|
|
197
|
+
)
|
|
198
|
+
|
|
199
|
+
# Compute full correlation matrix of concatenated matrices
|
|
200
|
+
# Shape: (n_features1 + n_features2, n_features1 + n_features2)
|
|
201
|
+
full_corr = np.corrcoef(matrix1.T, matrix2.T)
|
|
202
|
+
|
|
203
|
+
# Extract cross-correlation block: correlations between matrix1 columns and matrix2 columns
|
|
204
|
+
# This is the top-right block of the full correlation matrix
|
|
205
|
+
# Block [0:n_features1, n_features1:] gives correlations between matrix1 columns and matrix2 columns
|
|
206
|
+
cross_corr = full_corr[: matrix1.shape[1], matrix1.shape[1] :]
|
|
207
|
+
|
|
208
|
+
return cross_corr
|
|
209
|
+
|
|
210
|
+
|
|
211
|
+
def _matrix_permutation_cpu_parallel(
|
|
212
|
+
data1: np.ndarray,
|
|
213
|
+
data2: np.ndarray,
|
|
214
|
+
*,
|
|
215
|
+
n_permute: int,
|
|
216
|
+
metric: str,
|
|
217
|
+
how: str,
|
|
218
|
+
include_diag: bool,
|
|
219
|
+
tail: int,
|
|
220
|
+
return_null: bool,
|
|
221
|
+
n_jobs: int,
|
|
222
|
+
random_state: int | None,
|
|
223
|
+
progress_bar: bool = False,
|
|
224
|
+
) -> dict:
|
|
225
|
+
"""Matrix permutation test parallelized across CPU workers with joblib.
|
|
226
|
+
|
|
227
|
+
Seeds are pre-generated from `random_state` and one permutation runs per seed,
|
|
228
|
+
so results are identical regardless of `n_jobs`. Typical speedup is 4-8× on an
|
|
229
|
+
8-core machine.
|
|
230
|
+
|
|
231
|
+
Args:
|
|
232
|
+
data1 (np.ndarray): First square matrix (n×n).
|
|
233
|
+
data2 (np.ndarray): Second square matrix (n×n).
|
|
234
|
+
n_permute (int): Number of permutations.
|
|
235
|
+
metric (str): Correlation metric, one of 'pearson', 'spearman', or 'kendall'.
|
|
236
|
+
how (str): Which elements to compare, one of 'upper', 'lower', or 'full'.
|
|
237
|
+
include_diag (bool): Include the diagonal (only for `how='full'`).
|
|
238
|
+
tail (int | str): `2` or `'two'` for two-tailed; `1` or `'one'` for one-tailed.
|
|
239
|
+
return_null (bool): Whether to return the null distribution.
|
|
240
|
+
n_jobs (int): Number of parallel workers (-1 = all cores).
|
|
241
|
+
random_state (int | None): Random seed for reproducibility.
|
|
242
|
+
progress_bar (bool): Show a progress bar over permutations.
|
|
243
|
+
|
|
244
|
+
Returns:
|
|
245
|
+
dict: Keys 'correlation' (float), 'p' (float), and 'null_dist'
|
|
246
|
+
(np.ndarray) when `return_null=True`.
|
|
247
|
+
"""
|
|
248
|
+
from joblib import Parallel, delayed
|
|
249
|
+
|
|
250
|
+
# Validate inputs
|
|
251
|
+
_validate_same_shape(data1, data2, name1="data1", name2="data2")
|
|
252
|
+
_validate_square_matrix(data1, name="data1")
|
|
253
|
+
_validate_square_matrix(data2, name="data2")
|
|
254
|
+
|
|
255
|
+
# Pre-generate seeds (deterministic)
|
|
256
|
+
rng = np.random.RandomState(random_state)
|
|
257
|
+
seeds = rng.randint(MAX_INT, size=n_permute)
|
|
258
|
+
|
|
259
|
+
# Compute observed correlation
|
|
260
|
+
obs_corr = _compute_matrix_correlation(
|
|
261
|
+
data1, data2, how=how, include_diag=include_diag, metric=metric
|
|
262
|
+
)
|
|
263
|
+
|
|
264
|
+
# Define worker function
|
|
265
|
+
def _compute_one_perm(seed):
|
|
266
|
+
"""Compute correlation for one permutation."""
|
|
267
|
+
perm_rng = np.random.RandomState(seed)
|
|
268
|
+
perm = perm_rng.permutation(data1.shape[0])
|
|
269
|
+
permuted_matrix = _permute_matrix_symmetric(data1, perm)
|
|
270
|
+
return _compute_matrix_correlation(
|
|
271
|
+
permuted_matrix, data2, how=how, include_diag=include_diag, metric=metric
|
|
272
|
+
)
|
|
273
|
+
|
|
274
|
+
# Execute in parallel with progress bar
|
|
275
|
+
null_dist = Parallel(n_jobs=n_jobs)(
|
|
276
|
+
delayed(_compute_one_perm)(seeds[i])
|
|
277
|
+
for i in _maybe_tqdm(
|
|
278
|
+
range(n_permute),
|
|
279
|
+
progress_bar=progress_bar,
|
|
280
|
+
desc="Matrix permutation",
|
|
281
|
+
unit="perm",
|
|
282
|
+
)
|
|
283
|
+
)
|
|
284
|
+
null_dist = np.array(null_dist)
|
|
285
|
+
|
|
286
|
+
# Compute p-value
|
|
287
|
+
p_value = _compute_pvalue(obs_corr, null_dist, tail=tail)
|
|
288
|
+
# _compute_pvalue returns array, extract scalar for single correlation
|
|
289
|
+
if isinstance(p_value, np.ndarray):
|
|
290
|
+
p_value = float(p_value[0])
|
|
291
|
+
|
|
292
|
+
# Build result
|
|
293
|
+
result = {
|
|
294
|
+
"correlation": obs_corr,
|
|
295
|
+
"p": p_value,
|
|
296
|
+
}
|
|
297
|
+
|
|
298
|
+
if return_null:
|
|
299
|
+
result["null_dist"] = null_dist
|
|
300
|
+
|
|
301
|
+
return result
|
|
302
|
+
|
|
303
|
+
|
|
304
|
+
def matrix_permutation_test(
|
|
305
|
+
data1: np.ndarray,
|
|
306
|
+
data2: np.ndarray,
|
|
307
|
+
*,
|
|
308
|
+
n_permute: int = 5000,
|
|
309
|
+
metric: str = "pearson",
|
|
310
|
+
how: str = "upper",
|
|
311
|
+
include_diag: bool = False,
|
|
312
|
+
tail: int | str = 2,
|
|
313
|
+
return_null: bool = False,
|
|
314
|
+
n_jobs: int = -1,
|
|
315
|
+
random_state: int | None = None,
|
|
316
|
+
progress_bar: bool = False,
|
|
317
|
+
) -> dict:
|
|
318
|
+
"""Matrix permutation test (Mantel test) for correlating two square matrices.
|
|
319
|
+
|
|
320
|
+
Tests whether the correlation between the elements of two matrices is
|
|
321
|
+
significant by permuting the rows and columns of one matrix together
|
|
322
|
+
(`data1[perm][:, perm]`) while keeping the other fixed. Each permutation
|
|
323
|
+
preserves the matrix's structure (including symmetry) but destroys its
|
|
324
|
+
relationship to `data2`; the p-value is the fraction of permuted correlations
|
|
325
|
+
at least as extreme as the observed one. Assumes both matrices are square and
|
|
326
|
+
the same size, and that row/column ordering is exchangeable under the null.
|
|
327
|
+
|
|
328
|
+
Args:
|
|
329
|
+
data1 (np.ndarray): First square matrix (n×n).
|
|
330
|
+
data2 (np.ndarray): Second square matrix (n×n).
|
|
331
|
+
n_permute (int): Number of permutations. Defaults to 5000.
|
|
332
|
+
metric (str): Correlation metric, one of 'pearson', 'spearman', or
|
|
333
|
+
'kendall'. Defaults to 'pearson'.
|
|
334
|
+
how (str): Which elements to compare: 'upper' (upper triangle; assumes
|
|
335
|
+
symmetric matrices), 'lower' (lower triangle), or 'full' (all elements,
|
|
336
|
+
see `include_diag`). Defaults to 'upper'.
|
|
337
|
+
include_diag (bool): Include diagonal elements (only when `how='full'`).
|
|
338
|
+
Defaults to False.
|
|
339
|
+
tail (int | str): `2` or `'two'` for a two-tailed test (r != 0); `1` or
|
|
340
|
+
`'one'` for a one-tailed test of r > 0 (negate one matrix for the other
|
|
341
|
+
direction). Defaults to 2.
|
|
342
|
+
return_null (bool): Also return the null distribution. Defaults to False.
|
|
343
|
+
n_jobs (int): Number of joblib workers, -1 = all cores. Defaults to -1.
|
|
344
|
+
Results are identical at every worker count.
|
|
345
|
+
random_state (int | None): Random seed for reproducibility.
|
|
346
|
+
progress_bar (bool): Show a progress bar over permutations. Defaults to False.
|
|
347
|
+
|
|
348
|
+
Returns:
|
|
349
|
+
dict: Keys 'correlation' (float, observed correlation), 'p' (float,
|
|
350
|
+
Phipson-Smyth corrected p-value), and 'null_dist' (np.ndarray) when
|
|
351
|
+
`return_null=True`.
|
|
352
|
+
|
|
353
|
+
References:
|
|
354
|
+
Chen, G. et al. (2016). Untangling the relatedness among correlations,
|
|
355
|
+
part I: nonparametric approaches to inter-subject correlation analysis
|
|
356
|
+
at the group level. NeuroImage, 142, 248-259.
|
|
357
|
+
|
|
358
|
+
Mantel, N. (1967). The detection of disease clustering and a generalized
|
|
359
|
+
regression approach. Cancer Research, 27(2), 209-220.
|
|
360
|
+
|
|
361
|
+
Examples:
|
|
362
|
+
```python
|
|
363
|
+
import numpy as np
|
|
364
|
+
from nltools.algorithms.inference import matrix_permutation_test
|
|
365
|
+
|
|
366
|
+
# Two 20×20 similarity matrices sharing a common pattern
|
|
367
|
+
rng = np.random.default_rng(42)
|
|
368
|
+
pattern = rng.standard_normal((20, 10))
|
|
369
|
+
data1 = np.corrcoef(pattern + rng.standard_normal((20, 10)) * 0.5)
|
|
370
|
+
data2 = np.corrcoef(pattern + rng.standard_normal((20, 10)) * 0.5)
|
|
371
|
+
|
|
372
|
+
result = matrix_permutation_test(data1, data2, n_permute=1000)
|
|
373
|
+
print(f"Correlation: {result['correlation']:.3f}, p = {result['p']:.4f}")
|
|
374
|
+
```
|
|
375
|
+
"""
|
|
376
|
+
# Input validation
|
|
377
|
+
if not isinstance(data1, np.ndarray) or not isinstance(data2, np.ndarray):
|
|
378
|
+
raise TypeError("data1 and data2 must be numpy arrays")
|
|
379
|
+
|
|
380
|
+
data1 = np.asarray(data1)
|
|
381
|
+
data2 = np.asarray(data2)
|
|
382
|
+
|
|
383
|
+
_validate_same_shape(data1, data2, name1="data1", name2="data2")
|
|
384
|
+
_validate_square_matrix(data1, name="data1")
|
|
385
|
+
_validate_square_matrix(data2, name="data2")
|
|
386
|
+
_validate_metric_parameter(
|
|
387
|
+
metric, ["pearson", "spearman", "kendall"], name="metric"
|
|
388
|
+
)
|
|
389
|
+
_validate_how_parameter(how)
|
|
390
|
+
_validate_tail_parameter(tail)
|
|
391
|
+
|
|
392
|
+
return _matrix_permutation_cpu_parallel(
|
|
393
|
+
data1=data1,
|
|
394
|
+
data2=data2,
|
|
395
|
+
n_permute=n_permute,
|
|
396
|
+
metric=metric,
|
|
397
|
+
how=how,
|
|
398
|
+
include_diag=include_diag,
|
|
399
|
+
tail=tail,
|
|
400
|
+
return_null=return_null,
|
|
401
|
+
n_jobs=n_jobs,
|
|
402
|
+
random_state=random_state,
|
|
403
|
+
progress_bar=progress_bar,
|
|
404
|
+
)
|
|
405
|
+
|
|
406
|
+
|
|
407
|
+
# ============================================================================
|
|
408
|
+
# Matrix Utility Functions (moved from nltools.algorithms)
|
|
409
|
+
# ============================================================================
|
|
410
|
+
|
|
411
|
+
|
|
412
|
+
def _double_center(mat: np.ndarray) -> np.ndarray:
|
|
413
|
+
"""Double center a 2d array.
|
|
414
|
+
|
|
415
|
+
Double-centering subtracts row means, column means, and adds the grand mean.
|
|
416
|
+
This centers both rows and columns around zero.
|
|
417
|
+
|
|
418
|
+
Args:
|
|
419
|
+
mat (np.ndarray): 2d numpy array.
|
|
420
|
+
|
|
421
|
+
Returns:
|
|
422
|
+
np.ndarray: Double-centered version of the input.
|
|
423
|
+
|
|
424
|
+
Raises:
|
|
425
|
+
ValueError: If input is not 2D.
|
|
426
|
+
|
|
427
|
+
Examples:
|
|
428
|
+
```python
|
|
429
|
+
mat = np.array([[1, 2, 3], [4, 5, 6], [7, 8, 9]], dtype=float)
|
|
430
|
+
result = _double_center(mat)
|
|
431
|
+
np.allclose(result.mean(axis=0), 0) # → True
|
|
432
|
+
np.allclose(result.mean(axis=1), 0) # → True
|
|
433
|
+
```
|
|
434
|
+
"""
|
|
435
|
+
if len(mat.shape) != 2:
|
|
436
|
+
raise ValueError("Array should be 2d")
|
|
437
|
+
|
|
438
|
+
# keepdims ensures that row/column means are not incorrectly broadcast during subtraction
|
|
439
|
+
row_mean = mat.mean(axis=0, keepdims=True)
|
|
440
|
+
col_mean = mat.mean(axis=1, keepdims=True)
|
|
441
|
+
grand_mean = mat.mean()
|
|
442
|
+
return mat - row_mean - col_mean + grand_mean
|
|
443
|
+
|
|
444
|
+
|
|
445
|
+
def _u_center(mat: np.ndarray) -> np.ndarray:
|
|
446
|
+
"""U-center a 2d array.
|
|
447
|
+
|
|
448
|
+
U-centering is a bias-corrected form of double-centering: it corrects for the
|
|
449
|
+
bias that grows with the number of dimensions under plain double-centering.
|
|
450
|
+
The diagonal is explicitly set to zero.
|
|
451
|
+
|
|
452
|
+
Args:
|
|
453
|
+
mat (np.ndarray): 2d numpy array.
|
|
454
|
+
|
|
455
|
+
Returns:
|
|
456
|
+
np.ndarray: U-centered version of the input.
|
|
457
|
+
|
|
458
|
+
Raises:
|
|
459
|
+
ValueError: If input is not 2D.
|
|
460
|
+
|
|
461
|
+
Examples:
|
|
462
|
+
```python
|
|
463
|
+
mat = np.random.randn(5, 5)
|
|
464
|
+
result = _u_center(mat)
|
|
465
|
+
np.allclose(np.diag(result), 0) # → True
|
|
466
|
+
```
|
|
467
|
+
"""
|
|
468
|
+
if len(mat.shape) != 2:
|
|
469
|
+
raise ValueError("Array should be 2d")
|
|
470
|
+
|
|
471
|
+
dim = mat.shape[0]
|
|
472
|
+
u_mu = mat.sum() / ((dim - 1) * (dim - 2))
|
|
473
|
+
sum_cols = mat.sum(axis=0, keepdims=True)
|
|
474
|
+
sum_rows = mat.sum(axis=1, keepdims=True)
|
|
475
|
+
u_mu_cols = np.ones((dim, 1)).dot(sum_cols / (dim - 2))
|
|
476
|
+
u_mu_rows = (sum_rows / (dim - 2)).dot(np.ones((1, dim)))
|
|
477
|
+
out = np.copy(mat)
|
|
478
|
+
# Do one operation at a time, to improve broadcasting memory usage.
|
|
479
|
+
out -= u_mu_rows
|
|
480
|
+
out -= u_mu_cols
|
|
481
|
+
out += u_mu
|
|
482
|
+
# The diagonal is zero
|
|
483
|
+
out[np.eye(dim, dtype=bool)] = 0
|
|
484
|
+
return out
|
|
485
|
+
|
|
486
|
+
|
|
487
|
+
def distance_correlation(
|
|
488
|
+
x: np.ndarray,
|
|
489
|
+
y: np.ndarray,
|
|
490
|
+
bias_corrected: bool = True,
|
|
491
|
+
ttest: bool = False,
|
|
492
|
+
) -> dict:
|
|
493
|
+
"""Compute the distance correlation between two arrays to test for multivariate dependence.
|
|
494
|
+
|
|
495
|
+
Distance correlation detects linear and non-linear dependence. The arrays must
|
|
496
|
+
match on their first dimension. Prefer the bias-corrected version (the default),
|
|
497
|
+
which can also perform a t-test; that test operates on a statistic that is
|
|
498
|
+
approximately the squared distance correlation, which is also returned.
|
|
499
|
+
|
|
500
|
+
Distance correlation is the normalized covariance of two centered Euclidean
|
|
501
|
+
distance matrices. Each distance matrix holds the distances between rows (if x
|
|
502
|
+
or y is 2d) or scalars (if 1d). Each matrix is centered before the covariance
|
|
503
|
+
is computed, either by double-centering or by U-centering, which corrects the
|
|
504
|
+
bias that grows with the number of dimensions. U-centering is almost always
|
|
505
|
+
preferable and also permits a one-tailed directional t-test on the normalized
|
|
506
|
+
covariance (Szekely & Rizzo, 2013). Distance correlation is normally bounded
|
|
507
|
+
between 0 and 1, but U-centering can produce negative estimates, which are
|
|
508
|
+
never significant.
|
|
509
|
+
|
|
510
|
+
Validated against `dcor` and `dcor.ttest` in the R package *energy* and
|
|
511
|
+
`dcor.distance_correlation`, `dcor.u_distance_correlation_sqr`, and
|
|
512
|
+
`dcor.independence.distance_correlation_t_test` in the Python package *dcor*.
|
|
513
|
+
|
|
514
|
+
Args:
|
|
515
|
+
x (np.ndarray): 1d or 2d array of observations by features.
|
|
516
|
+
y (np.ndarray): 1d or 2d array of observations by features.
|
|
517
|
+
bias_corrected (bool): If True, U-center the distance matrices; if False,
|
|
518
|
+
double-center them, which gives a biased estimate that converges to 1
|
|
519
|
+
as the number of dimensions grows. Must be True when `ttest=True`.
|
|
520
|
+
Defaults to True.
|
|
521
|
+
ttest (bool): Perform a t-test on the bias-corrected distance correlation.
|
|
522
|
+
Defaults to False.
|
|
523
|
+
|
|
524
|
+
Returns:
|
|
525
|
+
dict: Key 'dcorr' (float, distance correlation); with `bias_corrected=True`
|
|
526
|
+
also 'dcorr_squared' (float, the U-centered statistic, which can be
|
|
527
|
+
negative); with `ttest=True` also 't', 'p', and 'df'.
|
|
528
|
+
|
|
529
|
+
Raises:
|
|
530
|
+
ValueError: If arrays are not 1d or 2d, or if `ttest=True` and
|
|
531
|
+
`bias_corrected=False`.
|
|
532
|
+
|
|
533
|
+
Examples:
|
|
534
|
+
```python
|
|
535
|
+
import numpy as np
|
|
536
|
+
|
|
537
|
+
x = np.random.randn(20, 3)
|
|
538
|
+
y = x + np.random.randn(20, 3) * 0.1 # strongly dependent
|
|
539
|
+
result = distance_correlation(x, y, bias_corrected=True)
|
|
540
|
+
"dcorr" in result # → True
|
|
541
|
+
0 <= result["dcorr"] <= 1 # → True
|
|
542
|
+
```
|
|
543
|
+
"""
|
|
544
|
+
if len(x.shape) > 2 or len(y.shape) > 2:
|
|
545
|
+
raise ValueError("Both arrays must be 1d or 2d")
|
|
546
|
+
|
|
547
|
+
if (not bias_corrected) and ttest:
|
|
548
|
+
raise ValueError("bias_corrected must be true to perform ttest!")
|
|
549
|
+
|
|
550
|
+
# 1 compute euclidean distances between pairs of value in each array
|
|
551
|
+
if len(x.shape) == 1:
|
|
552
|
+
_x = x[:, np.newaxis]
|
|
553
|
+
else:
|
|
554
|
+
_x = x
|
|
555
|
+
if len(y.shape) == 1:
|
|
556
|
+
_y = y[:, np.newaxis]
|
|
557
|
+
else:
|
|
558
|
+
_y = y
|
|
559
|
+
|
|
560
|
+
x_dist = squareform(pdist(_x))
|
|
561
|
+
y_dist = squareform(pdist(_y))
|
|
562
|
+
|
|
563
|
+
# 2 center each matrix
|
|
564
|
+
if bias_corrected:
|
|
565
|
+
# U-centering
|
|
566
|
+
x_dist_cent = _u_center(x_dist)
|
|
567
|
+
y_dist_cent = _u_center(y_dist)
|
|
568
|
+
# Compute covariances using N*(N-3) in denominator
|
|
569
|
+
adjusted_n = _x.shape[0] * (_x.shape[0] - 3)
|
|
570
|
+
xy = np.multiply(x_dist_cent, y_dist_cent).sum() / adjusted_n
|
|
571
|
+
xx = np.multiply(x_dist_cent, x_dist_cent).sum() / adjusted_n
|
|
572
|
+
yy = np.multiply(y_dist_cent, y_dist_cent).sum() / adjusted_n
|
|
573
|
+
else:
|
|
574
|
+
# double-centering
|
|
575
|
+
x_dist_cent = _double_center(x_dist)
|
|
576
|
+
y_dist_cent = _double_center(y_dist)
|
|
577
|
+
# Compute covariances using N^2 in denominator
|
|
578
|
+
xy = np.multiply(x_dist_cent, y_dist_cent).mean()
|
|
579
|
+
xx = np.multiply(x_dist_cent, x_dist_cent).mean()
|
|
580
|
+
yy = np.multiply(y_dist_cent, y_dist_cent).mean()
|
|
581
|
+
|
|
582
|
+
# 3 Normalize to get correlation
|
|
583
|
+
denom = np.sqrt(xx * yy)
|
|
584
|
+
dcor = xy / denom
|
|
585
|
+
out = {}
|
|
586
|
+
|
|
587
|
+
if dcor < 0:
|
|
588
|
+
# This will only apply in the bias_corrected case as values can be < 0
|
|
589
|
+
out["dcorr"] = 0
|
|
590
|
+
else:
|
|
591
|
+
out["dcorr"] = np.sqrt(dcor)
|
|
592
|
+
if bias_corrected:
|
|
593
|
+
out["dcorr_squared"] = dcor
|
|
594
|
+
if ttest:
|
|
595
|
+
dof = (adjusted_n / 2) - 1
|
|
596
|
+
t = np.sqrt(dof) * (dcor / np.sqrt(1 - dcor**2))
|
|
597
|
+
p = 1 - t_dist.cdf(t, dof)
|
|
598
|
+
out["t"] = t
|
|
599
|
+
out["p"] = p
|
|
600
|
+
out["df"] = dof
|
|
601
|
+
|
|
602
|
+
return out
|