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