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,596 @@
1
+ """Provide standalone statistical functions for Adjacency matrices.
2
+
3
+ Each function takes an Adjacency instance as its first argument (`adj`).
4
+ """
5
+
6
+ import numpy as np
7
+
8
+
9
+ def _similarity(
10
+ adj,
11
+ data,
12
+ plot=False,
13
+ method="2d",
14
+ n_permute=5000,
15
+ metric="spearman",
16
+ include_diag=False,
17
+ nan_policy="omit",
18
+ tail=2,
19
+ return_null=False,
20
+ n_jobs=-1,
21
+ random_state=None,
22
+ *,
23
+ progress_bar: bool = False,
24
+ ):
25
+ """Calculate similarity between two Adjacency matrices.
26
+
27
+ The default uses Spearman correlation and a permutation test.
28
+
29
+ Args:
30
+ adj (Adjacency): Adjacency instance.
31
+ data (Adjacency | np.ndarray): Adjacency to compare against, or a 1-D array the
32
+ same size as `adj.data`.
33
+ plot (bool): If True, plot stacked adjacency matrices. Default False.
34
+ method (str | None): Permutation scheme, `'1d'`, `'2d'`, or None (no
35
+ permutation test).
36
+ n_permute (int): Number of permutations. Default 5000.
37
+ metric (str): `'spearman'`, `'pearson'`, or `'kendall'`.
38
+ include_diag (bool): Only applies to `'directed'` matrices with `method=None`
39
+ or `method='1d'`. Default False (self-similarity is uninformative).
40
+ Symmetric matrices never store the diagonal, so this flag is a no-op
41
+ for them.
42
+ nan_policy (str): How to handle NaN values on the 1-D paths
43
+ (`method='1d'` or `method=None`): `'omit'` removes NaN pairwise before
44
+ computing the correlation (default), `'propagate'` lets NaN flow
45
+ through, `'raise'` errors if any NaN is present. `method='2d'` raises
46
+ on any NaN whatever the policy.
47
+ tail (int | str): `2`/`'two'` (two-tailed, default) or `1`/`'one'` (one-tailed, positive direction).
48
+ return_null (bool): If True, also return the null distribution. Default False.
49
+ n_jobs (int): Number of parallel jobs. -1 means all cores. Default -1.
50
+ random_state (int, optional): Random seed for reproducibility.
51
+ progress_bar (bool): If True, show a progress bar. Default False.
52
+
53
+ Returns:
54
+ dict | list[dict]: A correlation result dict with keys 'correlation'
55
+ and 'p', or a list of these dicts for a stack.
56
+ """
57
+ from nltools.data.adjacency import Adjacency
58
+ from nltools.algorithms.inference import (
59
+ correlation_permutation_test,
60
+ matrix_permutation_test,
61
+ )
62
+ from nltools.plotting import _plot_stacked_adjacency
63
+
64
+ if nan_policy not in ("omit", "propagate", "raise"):
65
+ raise ValueError(
66
+ f"nan_policy must be 'omit', 'propagate', or 'raise', got {nan_policy!r}"
67
+ )
68
+
69
+ def _handle_nans(arr1, arr2, nan_policy):
70
+ """Apply `nan_policy` to the 1-D inputs; reject NaN outright for 2-D."""
71
+ arr1 = np.asarray(arr1)
72
+ arr2 = np.asarray(arr2)
73
+
74
+ if not np.any(np.isnan(arr1)) and not np.any(np.isnan(arr2)):
75
+ return arr1, arr2
76
+
77
+ if arr1.ndim == 2:
78
+ # The matrix permutation shuffles whole rows and columns together,
79
+ # so no policy makes a 2-D correlation over NaN edges meaningful.
80
+ raise ValueError(
81
+ "Input contains NaN values, which method='2d' cannot handle. "
82
+ "Use method='1d' (or method=None), which masks NaN pairwise, "
83
+ "or remove the NaN values before calling similarity()."
84
+ )
85
+
86
+ if nan_policy == "raise":
87
+ raise ValueError(
88
+ "Input contains NaN values. Use nan_policy='omit' to ignore them "
89
+ "or nan_policy='propagate' to allow NaN in results."
90
+ )
91
+ if nan_policy == "propagate":
92
+ return arr1, arr2
93
+
94
+ mask = ~(np.isnan(arr1) | np.isnan(arr2))
95
+ if not np.any(mask):
96
+ raise ValueError(
97
+ "All values are NaN after pairwise removal. Cannot compute similarity."
98
+ )
99
+ return arr1[mask], arr2[mask]
100
+
101
+ data1 = adj.copy()
102
+ if not isinstance(data, Adjacency):
103
+ data2 = Adjacency(data)
104
+ else:
105
+ data2 = data.copy()
106
+
107
+ if method is None:
108
+ n_permute = 0
109
+ similarity_func = correlation_permutation_test
110
+ elif method == "1d":
111
+ similarity_func = correlation_permutation_test
112
+ elif method == "2d":
113
+ similarity_func = matrix_permutation_test
114
+ else:
115
+ raise ValueError("method must be ['1d','2d', or None']")
116
+
117
+ def _convert_data_similarity(
118
+ data, permutation_method=None, include_diag=include_diag
119
+ ):
120
+ """Convert data to the representation required for similarity."""
121
+ if (permutation_method is None) or (permutation_method == "1d"):
122
+ if not include_diag and (not data.issymmetric):
123
+ d = data.squareform()
124
+ data = d[~np.eye(d.shape[0]).astype(bool)]
125
+ else:
126
+ data = data.data
127
+ elif permutation_method == "2d":
128
+ if not data.issymmetric:
129
+ raise TypeError(
130
+ f"data must be symmetric to do {permutation_method} permutation"
131
+ )
132
+ data = data.squareform()
133
+ else:
134
+ raise ValueError("permutation_method must be ['1d','2d', or None']")
135
+ return data
136
+
137
+ if adj.is_single_matrix:
138
+ if plot:
139
+ _plot_stacked_adjacency(adj, data)
140
+ arr1 = _convert_data_similarity(data1, permutation_method=method)
141
+ arr2 = _convert_data_similarity(data2, permutation_method=method)
142
+ arr1, arr2 = _handle_nans(arr1, arr2, nan_policy)
143
+ return similarity_func(
144
+ arr1,
145
+ arr2,
146
+ metric=metric,
147
+ n_permute=n_permute,
148
+ tail=tail,
149
+ return_null=return_null,
150
+ n_jobs=n_jobs,
151
+ random_state=random_state,
152
+ progress_bar=progress_bar,
153
+ )
154
+ if plot:
155
+ import matplotlib.pyplot as plt
156
+
157
+ _, a = plt.subplots(len(adj))
158
+ for i in a:
159
+ _plot_stacked_adjacency(adj, data, ax=i)
160
+ results = []
161
+ arr2_base = _convert_data_similarity(data2, permutation_method=method)
162
+ for x in adj:
163
+ arr1 = _convert_data_similarity(x, permutation_method=method)
164
+ arr1_clean, arr2_clean = _handle_nans(arr1, arr2_base, nan_policy)
165
+ results.append(
166
+ similarity_func(
167
+ arr1_clean,
168
+ arr2_clean,
169
+ metric=metric,
170
+ n_permute=n_permute,
171
+ tail=tail,
172
+ return_null=return_null,
173
+ n_jobs=n_jobs,
174
+ random_state=random_state,
175
+ progress_bar=progress_bar,
176
+ )
177
+ )
178
+ return results
179
+
180
+
181
+ def _r_to_z(adj):
182
+ """Apply Fisher's r to z transformation to each element of the data object.
183
+
184
+ Args:
185
+ adj (Adjacency): Adjacency instance.
186
+
187
+ Returns:
188
+ Adjacency: New Adjacency with z-transformed values.
189
+ """
190
+ from nltools.algorithms.similarity import fisher_r_to_z
191
+
192
+ out = adj.copy()
193
+ out.data = fisher_r_to_z(out.data)
194
+ return out
195
+
196
+
197
+ def _z_to_r(adj):
198
+ """Convert z score back into r value for each element of data object.
199
+
200
+ Args:
201
+ adj (Adjacency): Adjacency instance.
202
+
203
+ Returns:
204
+ Adjacency: New Adjacency with r values.
205
+ """
206
+ from nltools.algorithms.similarity import fisher_z_to_r
207
+
208
+ out = adj.copy()
209
+ out.data = fisher_z_to_r(out.data)
210
+ return out
211
+
212
+
213
+ def _threshold(adj, *, upper=None, lower=None, binarize=False):
214
+ """Threshold an Adjacency instance.
215
+
216
+ Provide upper and lower values or percentages to perform two-sided
217
+ thresholding. Binarize will return a mask image respecting thresholds if
218
+ provided, otherwise respecting every non-zero value.
219
+
220
+ Args:
221
+ adj (Adjacency): Adjacency instance.
222
+ upper (float | str, optional): Upper cutoff. A string such as `'95%'` is
223
+ interpreted as a percentile; None for one-sided thresholding.
224
+ lower (float | str, optional): Lower cutoff. A string such as `'5%'` is
225
+ interpreted as a percentile; None for one-sided thresholding.
226
+ binarize (bool): Return a binarized matrix respecting the thresholds if
227
+ provided, otherwise binarize on every non-zero value. Default False.
228
+
229
+ Returns:
230
+ Adjacency: Thresholded Adjacency instance.
231
+ """
232
+
233
+ b = adj.copy()
234
+ if isinstance(upper, str) and upper[-1] == "%":
235
+ upper = np.percentile(b.data, float(upper[:-1]))
236
+ if isinstance(lower, str) and lower[-1] == "%":
237
+ lower = np.percentile(b.data, float(lower[:-1]))
238
+
239
+ if upper is not None and lower is not None:
240
+ b.data[(b.data < upper) & (b.data > lower)] = 0
241
+ elif upper is not None:
242
+ b.data[b.data < upper] = 0
243
+ elif lower is not None:
244
+ b.data[b.data > lower] = 0
245
+ if binarize:
246
+ b.data[b.data != 0] = 1
247
+ return b
248
+
249
+
250
+ def _ttest(
251
+ adj,
252
+ *,
253
+ popmean=0.0,
254
+ permutation=False,
255
+ n_permute=5000,
256
+ tail=2,
257
+ return_null=False,
258
+ n_jobs=-1,
259
+ random_state=None,
260
+ progress_bar=False,
261
+ ):
262
+ """Run a one-sample t-test across stacked matrices.
263
+
264
+ Tests every stored edge against `popmean` across the matrices in the stack.
265
+ Delegates the statistics to the shared one-sample contract in
266
+ `nltools.algorithms.inference.one_sample`.
267
+
268
+ Args:
269
+ adj (Adjacency): Stack of two or more matrices with the same node order
270
+ and storage kind.
271
+ popmean (float): Population mean to test against. Default 0.0.
272
+ permutation (bool): If True, take p from a sign-flip permutation test on
273
+ `matrices - popmean`. The reported `t` stays the observed parametric
274
+ statistic. Default False.
275
+ n_permute (int): Number of permutations, used only when
276
+ `permutation=True`. Default 5000.
277
+ tail (int | str): `2`/`'two'` (two-tailed, default) or `1`/`'one'`
278
+ (one-tailed: mean > `popmean`).
279
+ return_null (bool): If True, also return the permutation null. Has no
280
+ effect on the parametric path, which computes no null. Default False.
281
+ n_jobs (int): Number of parallel jobs. Default -1 (all cores).
282
+ random_state (int, optional): Random seed for reproducibility.
283
+ progress_bar (bool): If True, show a progress bar. Default False.
284
+
285
+ Returns:
286
+ dict: `'mean'`, `'t'`, `'z'` and `'p'` as independent single-matrix
287
+ `Adjacency` results that retain the node count, storage kind
288
+ (including directed) and shared node labels, with matrix metadata
289
+ cleared. `'mean'` is the edgewise mean minus `popmean`; `'t'` is the
290
+ observed one-sample t-statistic on both paths; `'p'` is parametric,
291
+ or the empirical sign-flip p-value when `permutation=True`; `'z'` is
292
+ the tail-aware normal score of `p`. With `permutation=True` and
293
+ `return_null=True` the dict also holds `'null_dist'`, an owned
294
+ `(n_permute, n_edges)` array of centered means in flat storage
295
+ order and in the units of `'mean'`. Maps are unthresholded. Apply a
296
+ cutoff or a multiple-comparison correction afterwards.
297
+
298
+ Raises:
299
+ ValueError: If `adj` holds fewer than two matrices.
300
+ """
301
+ import polars as pl
302
+
303
+ from nltools.algorithms.inference.one_sample import _one_sample_statistics
304
+
305
+ from .state import _common_labels, _result
306
+
307
+ if adj.is_single_matrix or adj.data.shape[0] < 2:
308
+ raise ValueError(
309
+ "t-test requires multiple matrices (got fewer than 2). "
310
+ "Stack matrices into a single Adjacency first."
311
+ )
312
+
313
+ stats = _one_sample_statistics(
314
+ adj.data,
315
+ popmean=popmean,
316
+ permutation=permutation,
317
+ n_permute=n_permute,
318
+ tail=tail,
319
+ return_null=return_null,
320
+ n_jobs=n_jobs,
321
+ random_state=random_state,
322
+ progress_bar=progress_bar,
323
+ )
324
+ labels = _common_labels(adj)
325
+ results = {
326
+ key: _result(adj, stats[key], labels=labels, Y=pl.DataFrame())
327
+ for key in ("mean", "t", "z", "p")
328
+ }
329
+ if "null_dist" in stats:
330
+ results["null_dist"] = stats["null_dist"]
331
+ return results
332
+
333
+
334
+ def _label_distance_long(adj, labels):
335
+ """Build long-format within/between distance arrays for a labelled adjacency.
336
+
337
+ Returns:
338
+ dict: Keys ``Distance`` (1-D float array), ``Type`` (1-D object array of
339
+ "Within"/"Between"), ``Group`` (1-D array of label values).
340
+ """
341
+ distance = np.asarray(adj.squareform())
342
+ labels = np.asarray(labels)
343
+ if len(labels) != distance.shape[0]:
344
+ raise ValueError("Labels must be same length as distance matrix")
345
+
346
+ dist_parts, type_parts, group_parts = [], [], []
347
+ for i in np.unique(labels):
348
+ mask_i = labels == i
349
+ sub = distance[np.ix_(mask_i, mask_i)]
350
+ within_vals = sub[np.triu_indices(mask_i.sum(), k=1)]
351
+ between_vals = distance[np.ix_(mask_i, ~mask_i)].ravel()
352
+
353
+ dist_parts.append(within_vals)
354
+ type_parts.append(np.full(within_vals.shape, "Within", dtype=object))
355
+ group_parts.append(np.full(within_vals.shape, i))
356
+
357
+ dist_parts.append(between_vals)
358
+ type_parts.append(np.full(between_vals.shape, "Between", dtype=object))
359
+ group_parts.append(np.full(between_vals.shape, i))
360
+
361
+ return {
362
+ "Distance": np.concatenate(dist_parts),
363
+ "Type": np.concatenate(type_parts),
364
+ "Group": np.concatenate(group_parts),
365
+ }
366
+
367
+
368
+ def _label_distance_inputs(adj, labels):
369
+ """Return the square distance matrix and node labels the label plots take.
370
+
371
+ Both label-distance plots are drawn by `nltools.plotting.adjacency` from a
372
+ square matrix and a label vector, so the squareform, the stored-label
373
+ fallback, and the single-matrix rule live here once.
374
+ """
375
+ from copy import deepcopy
376
+
377
+ if not adj.is_single_matrix:
378
+ raise ValueError("This function only works on single adjacency matrices.")
379
+
380
+ distance = adj.squareform()
381
+ if labels is None:
382
+ labels = np.array(deepcopy(adj.labels))
383
+ labels = np.asarray(labels)
384
+ if len(labels) != distance.shape[0]:
385
+ raise ValueError("Labels must be same length as distance matrix")
386
+ return distance, labels
387
+
388
+
389
+ def _plot_label_distance( # nosemgrep: kwargs-internal-forwarding # forwards to seaborn via _plot_mean_label_distance
390
+ adj, labels=None, ax=None, *, permutation_test=False, n_permute=5000, **kwargs
391
+ ):
392
+ """Create a violin plot of within- and between-label distances.
393
+
394
+ Args:
395
+ adj (Adjacency): Adjacency instance (must be a single matrix).
396
+ labels (np.ndarray, optional): Group label per node; defaults to `adj.labels`.
397
+ ax (matplotlib.axes.Axes, optional): Axis to draw on.
398
+ permutation_test (bool): Run a two-sample permutation test of within
399
+ against between distance for each group. Default False.
400
+ n_permute (int): Number of permutations for the test. Default 5000.
401
+ **kwargs (dict): Forwarded to `seaborn.violinplot`, plus `fontsize` for
402
+ the axis label and title (default 18).
403
+
404
+ Returns:
405
+ pl.DataFrame | tuple[pl.DataFrame, dict]: The long-format frame with
406
+ columns `Distance`, `Type`, `Group`, or `(long_df, stats)` when
407
+ `permutation_test=True`, where `stats` maps each group label to its
408
+ permutation-test result.
409
+ """
410
+ from nltools.plotting import _plot_mean_label_distance
411
+
412
+ distance, labels = _label_distance_inputs(adj, labels)
413
+ return _plot_mean_label_distance(
414
+ distance,
415
+ labels,
416
+ ax=ax,
417
+ permutation_test=permutation_test,
418
+ n_permute=n_permute,
419
+ **kwargs,
420
+ )
421
+
422
+
423
+ def _plot_between_label_distance( # nosemgrep: kwargs-internal-forwarding # forwards to seaborn via plot_between_label_distance
424
+ adj, *, labels=None, ax=None, permutation_test=True, n_permute=5000, **kwargs
425
+ ):
426
+ """Create a heatmap of the average distance between every pair of labels.
427
+
428
+ Args:
429
+ adj (Adjacency): Adjacency instance (must be a single matrix).
430
+ labels (np.ndarray, optional): Group label per node; defaults to `adj.labels`.
431
+ ax (matplotlib.axes.Axes, optional): Axis to draw on.
432
+ permutation_test (bool): Also compute the mean-difference and p-value
433
+ matrices from a two-sample permutation test. Default True.
434
+ n_permute (int): Number of permutations for the test. Default 5000.
435
+ **kwargs (dict): Forwarded to `seaborn.heatmap`.
436
+
437
+ Returns:
438
+ tuple[pl.DataFrame, ...]: `(long_df, within_mean_df)` without the
439
+ permutation test, or `(long_df, within_mean_df, mean_diff_df, p_df)`
440
+ with it.
441
+ """
442
+ from nltools.plotting import _plot_between_label_distance as _plot_between
443
+
444
+ distance, labels = _label_distance_inputs(adj, labels)
445
+ return _plot_between(
446
+ distance,
447
+ labels,
448
+ ax=ax,
449
+ permutation_test=permutation_test,
450
+ n_permute=n_permute,
451
+ **kwargs,
452
+ )
453
+
454
+
455
+ def _stats_label_distance(
456
+ adj, *, labels=None, n_permute=5000, n_jobs=-1, progress_bar=False
457
+ ):
458
+ """Calculate permutation tests on within and between label distance.
459
+
460
+ Args:
461
+ adj (Adjacency): Adjacency instance (must be a single matrix).
462
+ labels (np.ndarray, optional): Group label per node; defaults to `adj.labels`.
463
+ n_permute (int): Number of permutations to run. Default 5000.
464
+ n_jobs (int): Number of parallel jobs. Default -1 (all cores).
465
+ progress_bar (bool): If True, show a progress bar. Default False.
466
+
467
+ Returns:
468
+ dict: Per-group within-vs-between distance differences and p-values, keyed by
469
+ group label.
470
+ """
471
+ from copy import deepcopy
472
+
473
+ from nltools.algorithms.inference import two_sample_permutation_test
474
+
475
+ if not adj.is_single_matrix:
476
+ raise ValueError("This function only works on single adjacency matrices.")
477
+
478
+ if labels is None:
479
+ labels = deepcopy(adj.labels)
480
+
481
+ long = _label_distance_long(adj, labels)
482
+ distances = long["Distance"]
483
+ types = long["Type"]
484
+ groups = long["Group"]
485
+
486
+ stats = {}
487
+ for i in np.unique(groups):
488
+ within = distances[(groups == i) & (types == "Within")]
489
+ between = distances[(groups == i) & (types == "Between")]
490
+ stats[str(i)] = two_sample_permutation_test(
491
+ within,
492
+ between,
493
+ n_permute=n_permute,
494
+ n_jobs=n_jobs,
495
+ progress_bar=progress_bar,
496
+ )
497
+ return stats
498
+
499
+
500
+ def _plot_silhouette(
501
+ adj,
502
+ *,
503
+ labels=None,
504
+ ax=None,
505
+ permutation_test=True,
506
+ n_permute=5000,
507
+ colors=None,
508
+ figsize=(6, 4),
509
+ ):
510
+ """Create a silhouette plot.
511
+
512
+ Args:
513
+ adj (Adjacency): Adjacency instance (must be a single matrix).
514
+ labels (np.ndarray, optional): Cluster/group label per node; defaults to
515
+ `adj.labels`.
516
+ ax (matplotlib.axes.Axes, optional): Axis to draw on.
517
+ permutation_test (bool): Whether to run a permutation test. Default True.
518
+ n_permute (int): Number of permutations for the test. Default 5000.
519
+ colors (list, optional): RGB triplets, one per cluster. Default: seaborn
520
+ `'hls'` palette.
521
+ figsize (tuple): Figure size. Default (6, 4).
522
+
523
+ Returns:
524
+ pl.DataFrame: Columns `label` and `mean_silhouette`, plus `p` when
525
+ `permutation_test=True`.
526
+ """
527
+ from copy import deepcopy
528
+
529
+ from nltools.plotting import _plot_silhouette as _plot_silhouette
530
+
531
+ distance = adj.squareform()
532
+
533
+ if labels is None:
534
+ labels = np.array(deepcopy(adj.labels))
535
+ else:
536
+ if len(labels) != distance.shape[0]:
537
+ raise ValueError("Labels must be same length as distance matrix")
538
+
539
+ return _plot_silhouette(
540
+ distance,
541
+ np.asarray(labels),
542
+ ax=ax,
543
+ permutation_test=permutation_test,
544
+ n_permute=n_permute,
545
+ colors=colors,
546
+ figsize=figsize,
547
+ )
548
+
549
+
550
+ def _cluster_summary(adj, *, clusters=None, summary="mean", scope="within"):
551
+ """Provide summaries of clusters within Adjacency matrices.
552
+
553
+ Computes the mean/median of within- or between-cluster values. Requires a
554
+ list of cluster ids indicating the cluster of each row/column.
555
+
556
+ Args:
557
+ adj (Adjacency): Adjacency instance.
558
+ clusters (list): Cluster label for each row/column.
559
+ summary (str | None): Central tendency, `'mean'` or `'median'`. If None,
560
+ return all values instead of a summary.
561
+ scope (str): Summarize `'within'` cluster or `'between'` clusters.
562
+
563
+ Returns:
564
+ dict: Per-cluster summaries keyed by cluster label.
565
+ """
566
+ if summary not in ["mean", "median", None]:
567
+ raise ValueError("summary must be ['mean','median', None]")
568
+
569
+ distance = np.asarray(adj.squareform())
570
+ clusters = np.asarray(clusters)
571
+
572
+ if len(clusters) != distance.shape[0]:
573
+ raise ValueError("Cluster labels must be same length as distance matrix")
574
+
575
+ out = {}
576
+ for i in list(set(clusters.tolist())):
577
+ mask_i = clusters == i
578
+ if scope == "within":
579
+ within_vals = distance[np.ix_(mask_i, mask_i)][
580
+ np.triu_indices(mask_i.sum(), k=1)
581
+ ]
582
+ if summary == "mean":
583
+ out[i] = float(np.mean(within_vals))
584
+ elif summary == "median":
585
+ out[i] = float(np.median(within_vals))
586
+ else:
587
+ out[i] = within_vals
588
+ elif scope == "between":
589
+ between_block = distance[np.ix_(mask_i, ~mask_i)]
590
+ if summary == "mean":
591
+ out[i] = float(np.mean(between_block))
592
+ elif summary == "median":
593
+ out[i] = float(np.median(between_block))
594
+ else:
595
+ out[i] = between_block
596
+ return out
@@ -0,0 +1,79 @@
1
+ """Shared helpers for Adjacency submodules.
2
+
3
+ These are internal utilities used by the facade and submodules — not part of the
4
+ public API.
5
+ """
6
+
7
+ from copy import deepcopy
8
+
9
+ import numpy as np
10
+ import polars as pl
11
+
12
+
13
+ def _perform_arithmetic(adj, y, op, op_name, reverse=False):
14
+ """Perform arithmetic operation with validation.
15
+
16
+ Args:
17
+ adj: Adjacency instance (left operand unless *reverse* is True).
18
+ y: Operand (scalar or Adjacency).
19
+ op: Callable that performs the operation on arrays.
20
+ op_name: Name of operation for error messages.
21
+ reverse: If True, reverse operand order (y op adj).
22
+
23
+ Returns:
24
+ Adjacency: New instance with result.
25
+ """
26
+ new = deepcopy(adj)
27
+ if isinstance(y, (int, np.integer, float, np.floating)):
28
+ if reverse:
29
+ new.data = op(y, new.data)
30
+ else:
31
+ new.data = op(new.data, y)
32
+ else:
33
+ # Import here to avoid circular import at module level
34
+ from . import Adjacency
35
+
36
+ if isinstance(y, Adjacency):
37
+ from .state import _validate_compatible
38
+
39
+ _validate_compatible(adj, y, labels=True)
40
+ if adj.shape != y.shape:
41
+ raise ValueError(
42
+ "Both Adjacency() instances need to be the same shape."
43
+ )
44
+ if reverse:
45
+ new.data = op(y.data, new.data)
46
+ else:
47
+ new.data = op(new.data, y.data)
48
+ else:
49
+ raise ValueError(f"Can only {op_name} int, float, or Adjacency")
50
+ return new
51
+
52
+
53
+ def _apply_stat(adj, func, axis=0):
54
+ """Apply a statistical function along an axis.
55
+
56
+ Args:
57
+ adj: Adjacency instance.
58
+ func: Numpy function to apply (e.g., np.nanmean).
59
+ axis: Axis along which to apply function. 0 for across matrices,
60
+ 1 for across upper triangle elements.
61
+
62
+ Returns:
63
+ float | Adjacency | np.ndarray: A float for a single matrix; an Adjacency
64
+ when `axis=0` with multiple matrices; an array when `axis=1` with
65
+ multiple matrices.
66
+ """
67
+ if axis not in (0, 1):
68
+ raise ValueError(f"axis must be 0 or 1, got {axis}")
69
+ if adj.is_single_matrix:
70
+ return func(adj.data)
71
+ from .state import _common_labels, _result
72
+
73
+ if axis == 0:
74
+ return _result(
75
+ adj, func(adj.data, axis=axis), labels=_common_labels(adj), Y=pl.DataFrame()
76
+ )
77
+ if axis == 1:
78
+ return func(adj.data, axis=axis)
79
+ raise ValueError(f"axis must be 0 or 1, got {axis}")
@@ -0,0 +1,23 @@
1
+ """Atlas registry, lazy loading, and coordinate labeling.
2
+
3
+ Atlases are hosted at `huggingface.co/datasets/nltools/niftis` under
4
+ `atlases/` and fetched on first use via `fetch_resource`, then cached locally.
5
+
6
+ The labeling logic was adapted from
7
+ [atlasreader](https://github.com/miykael/atlasreader) (BSD-3-Clause); please
8
+ cite it when using these tools.
9
+
10
+ References:
11
+ Notter, M. P., Gale, D., Herholz, P., Markello, R., Notter-Bielser, M.-L., &
12
+ Whittingstall, K. (2019). AtlasReader: A Python package to generate
13
+ coordinate tables, region labels, and informative figures from statistical
14
+ MRI images. *Journal of Open Source Software*, 4(34), 1257.
15
+ https://doi.org/10.21105/joss.01257
16
+ """
17
+
18
+ # Internal package: these imports are re-exports for the rest of nltools, not
19
+ # an advertised surface, so there is no `__all__` to mark them as used.
20
+ from .labeling import label_coords # noqa: F401
21
+ from .loading import _Atlas, load_atlas # noqa: F401
22
+ from .registry import list_atlases # noqa: F401
23
+ from .reporting import _ClusterReport, _cluster_report_data # noqa: F401