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,1381 @@
1
+ """Analysis operations on `BrainData`.
2
+
3
+ Functions for similarity, distance, masking, ROI extraction, filtering,
4
+ thresholding, decomposition, alignment, smoothing, and related operations.
5
+ Each takes a `BrainData` as its first argument; the corresponding
6
+ `BrainData` methods delegate here.
7
+ """
8
+
9
+ from pathlib import Path
10
+
11
+ import numpy as np
12
+ import polars as pl
13
+
14
+ from .utils import _result_from_array, _result_from_rows, _result_with_mask
15
+
16
+
17
+ def _subset_mask(bd, columns):
18
+ """Build a spatial mask for selected columns of a masked array."""
19
+ from nilearn.masking import unmask
20
+
21
+ return unmask(np.asarray(columns, dtype=np.uint8), bd.mask)
22
+
23
+
24
+ def _mask_support(bd):
25
+ """Count the voxels a BrainData object's mask keeps."""
26
+ return int(np.count_nonzero(bd.mask.get_fdata() > 0))
27
+
28
+
29
+ def _brain_result(source, values, name, *, rows):
30
+ """Wrap an alignment value as a `BrainData` once its voxel axis checks out.
31
+
32
+ An alignment value may only become a `BrainData` when its columns are a
33
+ voxel axis that matches the mask it will carry. Checking here keeps a
34
+ mismatch from becoming an object whose `to_nifti` fails much later.
35
+
36
+ Args:
37
+ source (BrainData): Object supplying the mask and metadata.
38
+ values (np.ndarray): Result data whose last axis must be voxels.
39
+ name (str): Result key, used in the error message.
40
+ rows (str): Row-metadata policy, ``'preserve'`` or ``'clear'``.
41
+
42
+ Returns:
43
+ BrainData: Independently owned result carrying ``source``'s mask.
44
+
45
+ Raises:
46
+ ValueError: If the column count differs from the mask support.
47
+ """
48
+ values = np.asarray(values)
49
+ support = _mask_support(source)
50
+ if values.shape[-1] != support:
51
+ raise ValueError(
52
+ f"align() cannot return {name!r} as a BrainData: its shape is "
53
+ f"{values.shape}, so it has {values.shape[-1]} columns, but the "
54
+ f"mask supports {support} voxels. Align data that shares the "
55
+ f"source voxel axis."
56
+ )
57
+ return _result_from_array(source, values, rows=rows)
58
+
59
+
60
+ def _aligned_array(value):
61
+ """Return the array behind an alignment value that may be a `BrainData`."""
62
+ return value if isinstance(value, np.ndarray) else value.data
63
+
64
+
65
+ def _check_masks(bd, image):
66
+ """Ensure two datasets use compatible masks, creating a union mask if needed.
67
+
68
+ Args:
69
+ bd (BrainData): Reference dataset.
70
+ image (BrainData): Dataset whose mask is compared with ``bd``'s.
71
+
72
+ Returns:
73
+ tuple[np.ndarray, np.ndarray]: ``(data, image_data)`` arrays sampled on
74
+ a shared mask.
75
+ """
76
+ from nilearn.masking import apply_mask, intersect_masks
77
+
78
+ if np.sum(bd.mask.get_fdata() == 1) != np.sum(image.mask.get_fdata() == 1):
79
+ new_mask = intersect_masks(
80
+ [bd.mask, image.mask],
81
+ threshold=1,
82
+ connected=False,
83
+ )
84
+ data2 = apply_mask(bd.to_nifti(), new_mask)
85
+ image2 = apply_mask(image.to_nifti(), new_mask)
86
+ else:
87
+ data2 = bd.data
88
+ image2 = image.data
89
+ return data2, image2
90
+
91
+
92
+ def _similarity(bd, image, metric="correlation"):
93
+ """Calculate similarity to a single BrainData or nibabel image.
94
+
95
+ Args:
96
+ bd (BrainData): Dataset to compare.
97
+ image (BrainData | Nifti1Image): Image to evaluate similarity against.
98
+ metric (str): Similarity metric, one of ``'correlation'``, ``'pearson'``,
99
+ ``'rank_correlation'``, ``'spearman'``, ``'dot_product'``, or
100
+ ``'cosine'``.
101
+
102
+ Returns:
103
+ np.ndarray: Similarity values.
104
+ """
105
+ from nltools.algorithms.similarity import compute_similarity
106
+ from .utils import _check_brain_data
107
+
108
+ supported_metrics = [
109
+ "correlation",
110
+ "pearson",
111
+ "rank_correlation",
112
+ "spearman",
113
+ "dot_product",
114
+ "cosine",
115
+ ]
116
+ if metric not in supported_metrics:
117
+ raise ValueError(f"metric must be one of {supported_metrics}")
118
+
119
+ image = _check_brain_data(image)
120
+ data2, image2 = _check_masks(bd, image)
121
+
122
+ # Delegate to functional core (stats.py)
123
+ return compute_similarity(data2, image2, metric=metric)
124
+
125
+
126
+ def _distance( # nosemgrep: kwargs-internal-forwarding # forwards to scipy.spatial.distance.cdist
127
+ bd,
128
+ metric="euclidean",
129
+ *,
130
+ spatial_scale: str = "whole_brain",
131
+ roi_mask=None,
132
+ radius: float = 10.0,
133
+ **kwargs,
134
+ ):
135
+ """Calculate distance between images within a BrainData() instance.
136
+
137
+ Args:
138
+ bd (BrainData): Dataset whose images are compared.
139
+ metric (str): Any distance metric supported by
140
+ ``scipy.spatial.distance.cdist`` (e.g. ``'euclidean'``,
141
+ ``'cityblock'``, ``'cosine'``, ``'correlation'``, ``'hamming'``,
142
+ ``'jaccard'``).
143
+ spatial_scale (str): ``'whole_brain'`` (default), ``'roi'``, or
144
+ ``'searchlight'``. See `BrainData.distance`.
145
+ roi_mask (BrainData | Nifti1Image | str | None): Atlas for
146
+ ``spatial_scale='roi'``.
147
+ radius (float): Searchlight radius for ``spatial_scale='searchlight'``.
148
+ **kwargs (dict): Forwarded to ``scipy.spatial.distance.cdist``.
149
+
150
+ Returns:
151
+ Adjacency: Whole-brain pairwise distance matrix, or an ordinary stack.
152
+ ROI matrices follow sorted nonzero atlas labels present in the source
153
+ mask after resampling; searchlights follow source-mask voxel order.
154
+ """
155
+ valid = {"whole_brain", "roi", "searchlight"}
156
+ if spatial_scale not in valid:
157
+ raise ValueError(
158
+ f"spatial_scale must be one of {sorted(valid)}, got {spatial_scale!r}"
159
+ )
160
+
161
+ if spatial_scale == "whole_brain":
162
+ from scipy.spatial.distance import cdist
163
+
164
+ from nltools.data import Adjacency
165
+
166
+ dist_matrix = cdist(bd.data, bd.data, metric=metric, **kwargs)
167
+ return Adjacency(dist_matrix, matrix_type="Distance")
168
+
169
+ if spatial_scale == "searchlight":
170
+ return _distance_searchlight(bd, metric=metric, radius=radius, **kwargs)
171
+
172
+ # spatial_scale == "roi"
173
+ return _distance_roi(bd, metric=metric, roi_mask=roi_mask, **kwargs)
174
+
175
+
176
+ def _resolve_atlas_label_vec(bd, roi_mask):
177
+ """Resolve an atlas image + label vector aligned with bd.mask.
178
+
179
+ Coerces ``roi_mask`` (BrainData / Nifti / path) to a Nifti, resamples
180
+ to ``bd.mask`` (nearest-neighbor) if needed, and returns
181
+ ``(atlas_img, label_vec, unique_labels)`` for use by per-parcel
182
+ operations.
183
+ """
184
+ from pathlib import Path
185
+
186
+ import nibabel as nib
187
+ from nilearn.image import resample_to_img
188
+ from nilearn.masking import apply_mask
189
+
190
+ from nltools.data import BrainData
191
+
192
+ if roi_mask is None:
193
+ raise ValueError("roi_mask is required when spatial_scale='roi'.")
194
+ if isinstance(roi_mask, BrainData):
195
+ roi_img = roi_mask.to_nifti()
196
+ elif isinstance(roi_mask, (str, Path)):
197
+ roi_img = nib.load(str(roi_mask))
198
+ else:
199
+ roi_img = roi_mask
200
+
201
+ if roi_img.shape != bd.mask.shape or not np.allclose(
202
+ roi_img.affine, bd.mask.affine
203
+ ):
204
+ roi_img = resample_to_img(
205
+ roi_img,
206
+ bd.mask,
207
+ interpolation="nearest",
208
+ force_resample=True,
209
+ copy_header=True,
210
+ )
211
+
212
+ label_vec = apply_mask(roi_img, bd.mask).astype(np.int64)
213
+ unique_labels = np.unique(label_vec)
214
+ unique_labels = unique_labels[unique_labels != 0]
215
+ if unique_labels.size == 0:
216
+ raise ValueError("roi_mask has no nonzero labels in the BrainData mask space.")
217
+ return roi_img, label_vec, unique_labels
218
+
219
+
220
+ def _align_per_roi(bd, target, *, method, axis, roi_mask):
221
+ """Per-parcel functional alignment + voxel-space reassembly.
222
+
223
+ For each atlas parcel, runs ``align()`` on the slice of ``bd`` and
224
+ ``target`` restricted to that parcel's voxels and collects results.
225
+ The ``transformed`` field is reassembled into a single
226
+ `BrainData` of the same shape as the input (each voxel filled
227
+ with its parcel's transformed value per image; voxels outside any
228
+ parcel = NaN). Per-parcel transform matrices and common models stay
229
+ keyed by atlas label, since matrices over different voxel subsets
230
+ cannot be painted into one image.
231
+
232
+ Args:
233
+ bd (BrainData): Source data to align.
234
+ target (BrainData | np.ndarray): Alignment target (a `BrainData` for
235
+ ``'procrustes'``; a common-model array for the SRM methods).
236
+ method (str): ``'procrustes'``, ``'probabilistic_srm'``, or
237
+ ``'deterministic_srm'``.
238
+ axis (int): Axis to align over; see `align`.
239
+ roi_mask (BrainData | Nifti1Image | str): Integer-labeled atlas defining
240
+ the parcels.
241
+
242
+ Returns:
243
+ dict: ``'transformed'`` (one stitched `BrainData` on the source voxel
244
+ axis), ``'transformation_matrix'`` and ``'common_model'`` (dicts
245
+ keyed by atlas label), and ``'roi_labels'``, plus the per-parcel
246
+ arrays ``'disparity'`` and ``'scale'`` for ``'procrustes'``. Each
247
+ parcel value follows `align`'s rule: a `BrainData` carrying that
248
+ parcel's mask where its columns are that parcel's voxels, and a raw
249
+ `np.ndarray` where they are the model's features or images.
250
+
251
+ Raises:
252
+ ValueError: If a parcel's aligned data cannot be painted back onto that
253
+ parcel's voxels, which happens when an SRM common model has a
254
+ different feature count from the parcel's voxel count.
255
+ """
256
+ roi_img, label_vec, unique_labels = _resolve_atlas_label_vec(bd, roi_mask)
257
+
258
+ if method == "procrustes":
259
+ # Need a target BrainData to slice.
260
+ from .utils import _check_brain_data
261
+
262
+ target_bd = _check_brain_data(target)
263
+ if target_bd.shape[-1] != bd.shape[-1]:
264
+ raise ValueError(
265
+ "For spatial_scale='roi' procrustes, target must share "
266
+ "the BrainData voxel axis."
267
+ )
268
+ elif method in ("probabilistic_srm", "deterministic_srm"):
269
+ target_bd = None # target stays a numpy array; we don't slice it
270
+ else:
271
+ raise ValueError(
272
+ "method must be ['procrustes','probabilistic_srm','deterministic_srm']"
273
+ )
274
+
275
+ transforms = {}
276
+ common_models = {}
277
+ disparities = []
278
+ scales = []
279
+ transformed_per_parcel: list[np.ndarray] = []
280
+
281
+ for label in unique_labels:
282
+ cols = label_vec == label
283
+ sub = _result_with_mask(
284
+ bd, bd.data[:, cols], _subset_mask(bd, cols), rows="preserve"
285
+ )
286
+ if method == "procrustes":
287
+ t_sub = _result_with_mask(
288
+ target_bd,
289
+ target_bd.data[:, cols],
290
+ _subset_mask(target_bd, cols),
291
+ rows="preserve",
292
+ )
293
+ sub_target = t_sub
294
+ else:
295
+ sub_target = target # SRM common model is voxel-agnostic
296
+
297
+ sub_out = _align(sub, sub_target, method=method, axis=axis)
298
+
299
+ # Accumulate: every spatial value is already an owned BrainData.
300
+ transforms[int(label)] = sub_out["transformation_matrix"]
301
+ common_models[int(label)] = sub_out["common_model"]
302
+ if method == "procrustes":
303
+ disparities.append(float(sub_out["disparity"]))
304
+ scales.append(float(sub_out["scale"]))
305
+
306
+ transformed_per_parcel.append(
307
+ np.asarray(_aligned_array(sub_out["transformed"]))
308
+ )
309
+
310
+ # Stitch transformed → (n_images, n_voxels) BrainData.
311
+ n_images = transformed_per_parcel[0].shape[0]
312
+ out_arr = np.full((n_images, label_vec.shape[0]), np.nan, dtype=float)
313
+ for label, parcel_arr in zip(unique_labels, transformed_per_parcel):
314
+ cols = label_vec == label
315
+ n_parcel_voxels = int(cols.sum())
316
+ if parcel_arr.shape[-1] != n_parcel_voxels:
317
+ raise ValueError(
318
+ f"Aligned data for parcel {int(label)} has "
319
+ f"{parcel_arr.shape[-1]} columns but the parcel covers "
320
+ f"{n_parcel_voxels} voxels, so it cannot be painted back onto "
321
+ f"the voxel axis. Use a common model with one feature per "
322
+ f"parcel voxel."
323
+ )
324
+ out_arr[:, cols] = parcel_arr
325
+
326
+ transformed_bd = _result_from_array(bd, out_arr, rows="preserve")
327
+ out = {
328
+ "transformed": transformed_bd,
329
+ "transformation_matrix": transforms,
330
+ "common_model": common_models,
331
+ "roi_labels": unique_labels,
332
+ }
333
+ if method == "procrustes":
334
+ out["disparity"] = np.asarray(disparities, dtype=float)
335
+ out["scale"] = np.asarray(scales, dtype=float)
336
+ return out
337
+
338
+
339
+ def _distance_roi(bd, *, metric, roi_mask, **kwargs):
340
+ """Compute a pairwise distance matrix for each atlas parcel.
341
+
342
+ Return an ordinary stack in sorted nonzero atlas-label order within the source mask.
343
+ """
344
+ from pathlib import Path
345
+
346
+ import nibabel as nib
347
+ from nilearn.image import resample_to_img
348
+ from nilearn.masking import apply_mask
349
+ from scipy.spatial.distance import cdist
350
+
351
+ from nltools.data import Adjacency, BrainData
352
+
353
+ if roi_mask is None:
354
+ raise ValueError("roi_mask is required when spatial_scale='roi'.")
355
+
356
+ # Coerce roi_mask to a Nifti1Image aligned with bd.mask.
357
+ if isinstance(roi_mask, BrainData):
358
+ roi_img = roi_mask.to_nifti()
359
+ elif isinstance(roi_mask, (str, Path)):
360
+ roi_img = nib.load(str(roi_mask))
361
+ else:
362
+ roi_img = roi_mask
363
+
364
+ if roi_img.shape != bd.mask.shape or not np.allclose(
365
+ roi_img.affine, bd.mask.affine
366
+ ):
367
+ roi_img = resample_to_img(
368
+ roi_img,
369
+ bd.mask,
370
+ interpolation="nearest",
371
+ force_resample=True,
372
+ copy_header=True,
373
+ )
374
+
375
+ # Per-mask-voxel atlas labels.
376
+ label_vec = apply_mask(roi_img, bd.mask).astype(np.int64)
377
+ unique_labels = np.unique(label_vec)
378
+ unique_labels = unique_labels[unique_labels != 0]
379
+
380
+ if unique_labels.size == 0:
381
+ raise ValueError("roi_mask has no nonzero labels in the BrainData mask space.")
382
+
383
+ matrices = []
384
+ for label in unique_labels:
385
+ cols = label_vec == label
386
+ matrices.append(
387
+ cdist(bd.data[:, cols], bd.data[:, cols], metric=metric, **kwargs)
388
+ )
389
+
390
+ return Adjacency(matrices, matrix_type="distance")
391
+
392
+
393
+ def _distance_searchlight(bd, *, metric, radius, **kwargs):
394
+ """Compute a pairwise distance matrix for each searchlight center.
395
+
396
+ Return an ordinary stack in source-mask voxel order. Map per-center values
397
+ externally with `nilearn.masking.unmask(values, bd.mask)`.
398
+ """
399
+ from scipy.spatial.distance import cdist
400
+
401
+ from nltools.data import Adjacency
402
+
403
+ from nltools.algorithms.neighborhoods import compute_searchlight_neighborhoods
404
+
405
+ nbrs = compute_searchlight_neighborhoods(bd.mask, radius=radius)
406
+ n_voxels = nbrs.n_voxels
407
+
408
+ matrices = []
409
+ for i in range(n_voxels):
410
+ cols = nbrs.get_neighbors(i)
411
+ matrices.append(
412
+ cdist(bd.data[:, cols], bd.data[:, cols], metric=metric, **kwargs)
413
+ )
414
+
415
+ return Adjacency(matrices, matrix_type="distance")
416
+
417
+
418
+ def _multivariate_similarity(bd, images, tail=2):
419
+ """Predict a BrainData spatial distribution from a linear combination.
420
+
421
+ The predictors may be other BrainData instances or nibabel images.
422
+
423
+ Args:
424
+ bd (BrainData): Single image to be explained.
425
+ images (BrainData | Nifti1Image): Predictor images (weight maps).
426
+ tail (int): ``1`` or ``2`` for one- or two-tailed p-values.
427
+
428
+ Returns:
429
+ dict: Raw regression statistics (numpy arrays/scalars, not BrainData)
430
+ with keys ``'beta'``, ``'t'``, ``'p'``, ``'df'``, ``'sigma'``,
431
+ ``'residual'``.
432
+ """
433
+ # Notes: Should add ridge, and lasso, elastic net options options
434
+ from nltools.algorithms.similarity import _compute_multivariate_similarity
435
+ from .utils import _check_brain_data
436
+
437
+ if len(bd.shape) > 1:
438
+ raise ValueError("This method can only decompose a single brain image.")
439
+
440
+ images = _check_brain_data(images)
441
+ data2, image2 = _check_masks(bd, images)
442
+
443
+ # Prepare data for functional core: y is single image, X is predictors
444
+ # image2 shape: (n_images, n_voxels) -> transpose to (n_voxels, n_images)
445
+ y = data2.squeeze() # Single image: (n_voxels,)
446
+ X = image2.T # Predictors: (n_voxels, n_images)
447
+
448
+ # Delegate to functional core (stats.py)
449
+ return _compute_multivariate_similarity(y, X, tail=tail)
450
+
451
+
452
+ def _mask_image_on_source_grid(bd, mask):
453
+ """Return ``mask`` as a single 3-D image verified to share ``bd``'s grid.
454
+
455
+ Sameness is `_check_space_match`, the one predicate the loader also uses, so
456
+ a mask the constructor would have accepted without resampling is accepted
457
+ here too. Nothing is resampled: a foreign grid is an error, not something to
458
+ fix silently, because resampling either operand would change the voxel axis
459
+ the caller asked to keep. A grid that matches only within that tolerance
460
+ adopts the source's affine verbatim, which moves no data but keeps the
461
+ stricter checks downstream in nilearn from rejecting sub-tolerance drift.
462
+ """
463
+ import nibabel as nib
464
+
465
+ from . import BrainData
466
+ from .io import _check_space_match
467
+
468
+ if isinstance(mask, BrainData):
469
+ mask_img = mask.to_nifti()
470
+ elif isinstance(mask, (str, Path)):
471
+ mask_img = nib.load(str(mask))
472
+ elif isinstance(mask, nib.Nifti1Image):
473
+ mask_img = mask
474
+ else:
475
+ raise TypeError(
476
+ "mask must be a BrainData, nibabel image, or file path. "
477
+ f"Received {type(mask).__name__}"
478
+ )
479
+
480
+ if len(mask_img.shape) != 3:
481
+ raise ValueError("Mask must be a single image")
482
+
483
+ if not _check_space_match(mask_img, bd.mask):
484
+ raise ValueError(
485
+ "apply_mask requires a mask on the same grid as the data: the data "
486
+ f"is {bd.mask.shape} with affine\n{bd.mask.affine}\nand the mask is "
487
+ f"{mask_img.shape} with affine\n{mask_img.affine}\n"
488
+ "Bring them onto a common grid with resample() first."
489
+ )
490
+
491
+ if not np.array_equal(mask_img.affine, bd.mask.affine):
492
+ mask_img = nib.Nifti1Image(mask_img.dataobj, bd.mask.affine)
493
+ return mask_img
494
+
495
+
496
+ def _apply_mask(bd, mask):
497
+ """Restrict BrainData to a mask's support without changing the grid.
498
+
499
+ Support is every voxel of ``mask`` greater than zero. The mask defines the
500
+ result's voxel axis on its own: where it reaches past ``bd``'s current
501
+ support the result gains those voxels with zero values, so a mask larger
502
+ than the data's own mask widens the array rather than intersecting with it.
503
+
504
+ Args:
505
+ bd (BrainData): Data to mask.
506
+ mask (BrainData | Nifti1Image | str | Path): A single 3-D mask on the
507
+ same grid and with the same affine as ``bd``.
508
+
509
+ Returns:
510
+ BrainData: Masked copy of ``bd`` with row metadata preserved.
511
+
512
+ Raises:
513
+ ValueError: If the mask is not a single 3-D image, or its shape or
514
+ affine differs from ``bd``'s. Use ``resample()`` first in that case.
515
+ TypeError: If ``mask`` is not a BrainData, nibabel image, or file path.
516
+
517
+ Note:
518
+ Masking is delegated to ``nilearn.masking.apply_mask``.
519
+ """
520
+ from nilearn.masking import apply_mask as nilearn_apply_mask
521
+
522
+ mask_img = _mask_image_on_source_grid(bd, mask)
523
+
524
+ # Use nilearn's apply_mask for efficient masking (C-optimized, single path, memory efficient)
525
+ masked_data = nilearn_apply_mask(bd.to_nifti(), mask_img)
526
+ masked = _result_with_mask(bd, masked_data, mask_img, rows="preserve")
527
+
528
+ # Preserve 1D output for single images (backward compatibility)
529
+ if (len(masked.shape) > 1) & (masked.shape[0] == 1):
530
+ masked.data = masked.data.flatten()
531
+
532
+ return masked
533
+
534
+
535
+ def _extract_roi(bd, mask, method="mean", n_components=None):
536
+ """Extract activity from a binary mask or a labeled ROI atlas.
537
+
538
+ `extract_roi` is an extraction convenience, not a masking primitive: unlike
539
+ the strict same-grid `apply_mask`, it resamples `mask` onto `bd`'s own grid
540
+ with nearest-neighbor interpolation before extracting, the same way
541
+ nilearn's `NiftiLabelsMasker` resamples labels onto data. A mask already on
542
+ `bd`'s grid is used as given. Labeled atlases (multiple ROIs) are handled
543
+ with nilearn's ``NiftiLabelsMasker``.
544
+
545
+ Args:
546
+ bd (BrainData): Data to extract from.
547
+ mask (BrainData | Nifti1Image | str): A binary mask (extracts from a
548
+ single ROI) or a labeled atlas (extracts from every ROI), on any
549
+ grid.
550
+ method (str): Extraction method: ``'mean'`` (default), ``'median'``, or
551
+ ``'pca'``.
552
+ n_components (int | None): Number of components to return when
553
+ ``method='pca'``.
554
+
555
+ Returns:
556
+ float | np.ndarray: For a binary mask, a scalar (single image) or 1D array
557
+ of values (multiple images). For a labeled atlas, a 1D array with one
558
+ value per ROI (single image), a 2D array of ROIs x images (multiple
559
+ images), or the components array when `method='pca'`.
560
+
561
+ Raises:
562
+ ValueError: If, after resampling onto `bd`'s grid, `mask` has no
563
+ overlap with `bd`.
564
+
565
+ Examples:
566
+ ```python
567
+ # Extract mean from binary mask
568
+ roi_values = brain.extract_roi(binary_mask)
569
+
570
+ # Extract from atlas
571
+ atlas_values = brain.extract_roi(atlas_mask)
572
+
573
+ # PCA extraction
574
+ components = brain.extract_roi(mask, method='pca', n_components=5)
575
+ ```
576
+ """
577
+ from nilearn.maskers import NiftiLabelsMasker
578
+
579
+ from . import BrainData
580
+ from .io import _check_space_match
581
+ from .utils import _check_brain_data_is_single
582
+
583
+ methods = ["mean", "median", "pca"]
584
+ if method not in methods:
585
+ raise NotImplementedError(f"method must be one of {methods}, got {method}")
586
+
587
+ # Coerce mask onto bd's own grid before extracting. A BrainData mask on a
588
+ # foreign grid is resampled explicitly (nearest, so labels survive); any
589
+ # other input (Nifti1Image, path) loads directly against bd's mask, which
590
+ # resamples it implicitly the same way BrainData loading always has.
591
+ if isinstance(mask, BrainData):
592
+ mask_brain = mask
593
+ if not _check_space_match(mask_brain.mask, bd.mask):
594
+ mask_brain = mask_brain.resample(img=bd.mask, interpolation="nearest")
595
+ else:
596
+ mask_brain = BrainData(mask, mask=bd.mask, interpolation="nearest")
597
+
598
+ # Check if binary or labeled mask
599
+ unique_values = np.unique(mask_brain.data)
600
+ n_unique = len(unique_values)
601
+
602
+ if n_unique < 2:
603
+ raise ValueError(
604
+ "No voxels remain after masking - mask may not overlap with data"
605
+ )
606
+
607
+ mask_img = mask_brain.to_nifti()
608
+
609
+ if n_unique == 2:
610
+ # Binary mask - use simple extraction
611
+ masked = _apply_mask(bd, mask_brain)
612
+ is_single = _check_brain_data_is_single(masked)
613
+
614
+ if method == "mean":
615
+ out = masked.mean() if is_single else masked.mean(axis=1)
616
+ elif method == "median":
617
+ out = masked.median() if is_single else masked.median(axis=1)
618
+ elif method == "pca":
619
+ if is_single:
620
+ raise ValueError("Cannot run PCA on a single image")
621
+ output = _decompose(
622
+ masked, method="pca", n_components=n_components, axis="images"
623
+ )
624
+ out = output["weights"].T
625
+
626
+ elif n_unique > 2:
627
+ # Labeled atlas - use NiftiLabelsMasker for efficiency
628
+ # Round values to ensure integer labels (use int32 for nilearn/FSL/SPM
629
+ # compatibility) on a copy, so a caller's mask is never mutated.
630
+ mask_brain = mask_brain.copy()
631
+ mask_brain.data = np.round(mask_brain.data).astype(np.int32)
632
+ mask_img = mask_brain.to_nifti()
633
+
634
+ # Create masker based on method
635
+ if method in ["mean", "median"]:
636
+ # For mean/median, use NiftiLabelsMasker
637
+ strategy = "mean" if method == "mean" else "median"
638
+ labels_masker = NiftiLabelsMasker(
639
+ labels_img=mask_img,
640
+ strategy=strategy,
641
+ mask_img=bd.mask,
642
+ standardize=None, # nilearn >= 0.15 rejects the bool spelling
643
+ resampling_target="data" if hasattr(bd, "mask") else None,
644
+ )
645
+
646
+ # Transform data
647
+ data_4d = bd.to_nifti()
648
+ out = labels_masker.fit_transform(data_4d)
649
+
650
+ # If single image, return 1D array
651
+ if out.shape[0] == 1:
652
+ out = out[0]
653
+ else:
654
+ # For multiple images, transpose to (n_labels, n_images)
655
+ out = out.T
656
+
657
+ elif method == "pca":
658
+ # Extract voxels from the whole atlas once, then slice by label in
659
+ # numpy. This avoids rebuilding the nifti and re-resampling per ROI.
660
+ if _check_brain_data_is_single(bd):
661
+ raise ValueError("Cannot run PCA on a single image")
662
+
663
+ atlas_mask = _result_from_array(
664
+ mask_brain, (mask_brain.data > 0).astype(float), rows="preserve"
665
+ )
666
+ all_masked = _apply_mask(bd, atlas_mask)
667
+
668
+ # apply_mask preserves voxel ordering relative to the mask, so the
669
+ # label vector lines up with the columns of all_masked.data.
670
+ labels_flat = mask_brain.data[mask_brain.data > 0]
671
+ unique_labels = np.unique(labels_flat)
672
+
673
+ out = []
674
+ for label in unique_labels:
675
+ roi = _result_with_mask(
676
+ all_masked,
677
+ all_masked.data[:, labels_flat == label],
678
+ _subset_mask(all_masked, labels_flat == label),
679
+ rows="preserve",
680
+ )
681
+ output = _decompose(
682
+ roi, method="pca", n_components=n_components, axis="images"
683
+ )
684
+ out.append(output["weights"].T)
685
+
686
+ if len(out) > 0:
687
+ out = np.array(out) if n_components == 1 else out
688
+
689
+ else:
690
+ raise ValueError(
691
+ "Mask must be binary (2 unique values) or labeled atlas (>2 unique values)"
692
+ )
693
+
694
+ return out
695
+
696
+
697
+ def _detrend_data(bd, method="linear"):
698
+ """Remove the linear trend from each voxel.
699
+
700
+ Args:
701
+ bd (BrainData): Data to detrend (must hold more than one image).
702
+ method (str): ``'linear'`` (default) or ``'constant'``.
703
+
704
+ Returns:
705
+ BrainData: Detrended copy of ``bd``.
706
+ """
707
+ from scipy.signal import detrend
708
+
709
+ if len(bd.shape) == 1:
710
+ raise ValueError("Make sure there is more than one image in order to detrend.")
711
+
712
+ out = _result_from_array(bd, detrend(bd.data, type=method, axis=0), rows="preserve")
713
+ return out
714
+
715
+
716
+ def _r_to_z(bd):
717
+ """Apply Fisher's r-to-z transformation to each data element.
718
+
719
+ Args:
720
+ bd (BrainData): Correlation values to transform.
721
+
722
+ Returns:
723
+ BrainData: Transformed copy of ``bd``.
724
+ """
725
+ from nltools.algorithms.similarity import fisher_r_to_z
726
+
727
+ out = _result_from_array(bd, fisher_r_to_z(bd.data), rows="preserve")
728
+ return out
729
+
730
+
731
+ def _z_to_r(bd):
732
+ """Convert Fisher z scores back into r values for each data element.
733
+
734
+ Args:
735
+ bd (BrainData): z-scored values to transform.
736
+
737
+ Returns:
738
+ BrainData: Transformed copy of ``bd``.
739
+ """
740
+ from nltools.algorithms.similarity import fisher_z_to_r
741
+
742
+ out = _result_from_array(bd, fisher_z_to_r(bd.data), rows="preserve")
743
+ return out
744
+
745
+
746
+ def _filter_data( # nosemgrep: kwargs-internal-forwarding # forwards to nilearn.signal.clean
747
+ bd, *, sampling_freq=None, high_pass=None, low_pass=None, **kwargs
748
+ ):
749
+ """Apply a Butterworth filter to data (wraps `nilearn.signal.clean`).
750
+
751
+ Does not default to detrending and standardizing like nilearn
752
+ implementation, but this can be overridden using kwargs.
753
+
754
+ Args:
755
+ bd (BrainData): Time series to filter.
756
+ sampling_freq (float | None): Sampling frequency in Hz (i.e. 1 / TR).
757
+ high_pass (float | None): High-pass cutoff frequency in Hz.
758
+ low_pass (float | None): Low-pass cutoff frequency in Hz.
759
+ **kwargs (dict): Forwarded to ``nilearn.signal.clean``. Common options:
760
+ ``confounds`` (confound time series to remove), ``sample_mask``
761
+ (volumes to exclude), ``detrend`` (default ``False``),
762
+ ``standardize`` (``'zscore_sample'``, ``'psc'``, or ``None`` — the
763
+ default; ``True``/``False`` are accepted as aliases for
764
+ ``'zscore_sample'``/``None``), and ``ensure_finite`` (replace
765
+ NaN/inf; default ``False``).
766
+
767
+ Returns:
768
+ BrainData: Filtered copy of ``bd``.
769
+
770
+ See Also:
771
+ ``nilearn.signal.clean`` for all available options.
772
+ """
773
+ from nilearn.signal import clean
774
+
775
+ if sampling_freq is None:
776
+ raise ValueError("Need to provide sampling rate (TR)!")
777
+ if high_pass is None and low_pass is None:
778
+ raise ValueError("high_pass and/or low_pass cutoff must be provided!")
779
+
780
+ # Pop (not get) so these are not also forwarded via **kwargs below;
781
+ # otherwise clean() receives detrend/standardize twice -> TypeError.
782
+ # nilearn >= 0.15 drops boolean `standardize`; translate the aliases here
783
+ # so callers keep the bool spelling without tripping its FutureWarning.
784
+ standardize = kwargs.pop("standardize", None)
785
+ if standardize is True:
786
+ standardize = "zscore_sample"
787
+ elif standardize is False:
788
+ standardize = None
789
+ detrend = kwargs.pop("detrend", False)
790
+
791
+ data = clean(
792
+ bd.data,
793
+ t_r=1.0 / sampling_freq,
794
+ detrend=detrend,
795
+ standardize=standardize,
796
+ high_pass=high_pass,
797
+ low_pass=low_pass,
798
+ **kwargs,
799
+ )
800
+ sample_mask = kwargs.get("sample_mask")
801
+ if sample_mask is None:
802
+ return _result_from_array(bd, data, rows="preserve")
803
+ from .utils import _polars_row_select
804
+
805
+ return _result_from_rows(
806
+ bd,
807
+ data,
808
+ X=_polars_row_select(bd.X, sample_mask),
809
+ Y=_polars_row_select(bd.Y, sample_mask),
810
+ )
811
+
812
+
813
+ def _standardize(bd, *, method="center", axis=0):
814
+ """Standardize data by centering it, optionally scaling to unit variance.
815
+
816
+ Computed in float64 and cast back to the input dtype, so raw float32 BOLD
817
+ (large offsets) stays exact. Constant voxels/observations z-score to 0.
818
+
819
+ Args:
820
+ bd (BrainData): Data to standardize.
821
+ method (str): ``'center'`` subtracts the mean (default); ``'zscore'``
822
+ subtracts the mean and divides by the standard deviation.
823
+ axis (int): ``0`` to standardize each voxel across observations
824
+ (default), ``1`` to standardize each observation across voxels.
825
+
826
+ Returns:
827
+ BrainData: Standardized copy of ``bd``.
828
+
829
+ Raises:
830
+ ValueError: If `method` is neither ``'center'`` nor ``'zscore'``.
831
+ """
832
+ if method not in ("center", "zscore"):
833
+ raise ValueError(f"method must be 'center' or 'zscore', got {method!r}")
834
+ if axis == 1 and len(bd.shape) == 1:
835
+ raise IndexError(
836
+ "BrainData is only 3d but standardization was requested over observations"
837
+ )
838
+
839
+ data = np.asarray(bd.data, dtype=np.float64)
840
+ centered = data - data.mean(axis=axis, keepdims=True)
841
+ if method == "zscore":
842
+ std = centered.std(axis=axis, keepdims=True)
843
+ std[std == 0] = 1.0 # constant along `axis` -> 0, not nan
844
+ centered /= std
845
+
846
+ # The output immediately replaces data, so avoid copying the source buffer.
847
+ out = _result_from_array(
848
+ bd, centered.astype(bd.data.dtype, copy=False), rows="preserve"
849
+ )
850
+ return out
851
+
852
+
853
+ def _scale_data(bd, scale_val=100.0, axis=None):
854
+ """Scale data via mean scaling.
855
+
856
+ Two scaling modes are available:
857
+
858
+ - **Grand-mean scaling** (axis=None, default): Divides all values by the
859
+ global mean across all voxels and timepoints. This is consistent with
860
+ FSL and SPM behavior. Use scale_val=10000 for FSL-style scaling.
861
+
862
+ - **Voxel-wise scaling** (axis=0): Divides each voxel's time-series by
863
+ its own temporal mean. This is AFNI-style scaling and can be useful
864
+ when voxels have very different baseline intensities. Voxels with
865
+ zero or near-zero mean are set to zero to avoid NaN/Inf.
866
+
867
+ When scale_val=100 (default), the result can be interpreted as something
868
+ akin to (but not exactly) "percent signal change."
869
+
870
+ Args:
871
+ bd (BrainData): Data to scale.
872
+ scale_val (float): Target value for the mean after scaling. Default
873
+ ``100``.
874
+ axis (int | None): ``None`` for grand-mean scaling (default, FSL/SPM
875
+ style); ``0`` for voxel-wise scaling (AFNI style, each voxel scaled
876
+ by its own temporal mean).
877
+
878
+ Returns:
879
+ BrainData: Scaled copy of ``bd``.
880
+
881
+ Examples:
882
+ ```python
883
+ # Grand-mean scaling (default)
884
+ scaled = brain.scale(100.0)
885
+
886
+ # Voxel-wise scaling (AFNI style)
887
+ scaled = brain.scale(100.0, axis=0)
888
+ ```
889
+ """
890
+ data = bd.data
891
+
892
+ if axis is None:
893
+ # Grand-mean scaling: divide by global mean
894
+ grand_mean = data.mean()
895
+ if np.abs(grand_mean) < np.finfo(float).eps:
896
+ data = np.zeros_like(data)
897
+ else:
898
+ data = data / grand_mean * scale_val
899
+ elif axis == 0:
900
+ # Voxel-wise scaling: divide each voxel by its temporal mean
901
+ # Compute mean along time axis (axis=0), keeping dims for broadcasting
902
+ voxel_means = data.mean(axis=0, keepdims=True)
903
+
904
+ # Handle zero-mean voxels to avoid NaN/Inf
905
+ # Set zero-mean voxels to 1 temporarily, then zero out result
906
+ zero_mask = np.abs(voxel_means) < np.finfo(float).eps
907
+ voxel_means_safe = np.where(zero_mask, 1.0, voxel_means)
908
+
909
+ # Scale
910
+ data = data / voxel_means_safe * scale_val
911
+
912
+ # Zero out voxels that had zero mean
913
+ if np.any(zero_mask):
914
+ data[:, zero_mask.squeeze()] = 0.0
915
+ else:
916
+ raise ValueError(f"axis must be None or 0, got {axis}")
917
+
918
+ return _result_from_array(bd, data, rows="preserve")
919
+
920
+
921
+ def _threshold_data(
922
+ bd,
923
+ *,
924
+ upper=None,
925
+ lower=None,
926
+ binarize=False,
927
+ coerce_nan=True,
928
+ cluster_threshold=0,
929
+ ):
930
+ """Threshold BrainData instance with optional cluster filtering.
931
+
932
+ Provide upper and lower values or percentages to perform two-sided
933
+ thresholding. Binarize will return a mask image respecting thresholds
934
+ if provided, otherwise respecting every non-zero value.
935
+
936
+ Args:
937
+ bd (BrainData): Data to threshold.
938
+ upper (float | str | None): Upper cutoff. A string like ``'98%'``
939
+ resolves as a percentile over the finite **nonzero** voxels (via
940
+ `_resolve_threshold`; zeros on a masked map are absence of data and
941
+ would skew the percentile). ``None`` for one-sided thresholding.
942
+ lower (float | str | None): Lower cutoff, with the same percentile
943
+ semantics as ``upper``. ``None`` for one-sided thresholding.
944
+ binarize (bool): Return a binary image respecting the thresholds if
945
+ provided, otherwise binarize every non-zero value. Default
946
+ ``False``.
947
+ coerce_nan (bool): Replace NaN values with 0 first. Default ``True``.
948
+ cluster_threshold (int): Minimum cluster size in voxels. If ``> 0``,
949
+ thresholds with ``nilearn.image.threshold_img`` and drops smaller
950
+ clusters; band-pass thresholding (both ``upper`` and ``lower``) is
951
+ not supported in that mode. Default ``0`` (disabled).
952
+
953
+ Returns:
954
+ BrainData: Thresholded copy of ``bd``.
955
+
956
+ Note:
957
+ With ``cluster_threshold=0`` (default) thresholding runs on the data
958
+ array directly and supports band-pass thresholds; with
959
+ ``cluster_threshold>0`` nilearn performs the cluster filtering.
960
+ """
961
+
962
+ if cluster_threshold > 0:
963
+ # Use nilearn for cluster thresholding
964
+ from nilearn.image import threshold_img
965
+ from nilearn.masking import apply_mask as nilearn_apply_mask
966
+
967
+ # Band-pass filtering not supported with cluster thresholding
968
+ if upper is not None and lower is not None:
969
+ raise ValueError(
970
+ "Band-pass filtering (both upper and lower) not supported "
971
+ "with cluster thresholding. Use one threshold only."
972
+ )
973
+
974
+ # Determine threshold value (from whichever is provided)
975
+ threshold_val = upper if upper is not None else lower
976
+ if threshold_val is None:
977
+ raise ValueError("Must provide either upper or lower threshold")
978
+
979
+ # Handle percentile strings
980
+ b = _result_from_array(bd, bd.data, rows="preserve")
981
+ if coerce_nan:
982
+ b.data = np.nan_to_num(b.data)
983
+
984
+ from .utils import _resolve_threshold
985
+
986
+ threshold_val = _resolve_threshold(threshold_val, b.data)
987
+
988
+ # Use nilearn's cluster thresholding
989
+ out = _result_from_array(bd, bd.data, rows="preserve")
990
+ thresholded_img = threshold_img(
991
+ b.to_nifti(),
992
+ threshold=threshold_val,
993
+ cluster_threshold=cluster_threshold,
994
+ two_sided=(upper is not None),
995
+ copy_header=True,
996
+ )
997
+
998
+ # Convert back to data array
999
+ out.data = nilearn_apply_mask(thresholded_img, bd.mask)
1000
+
1001
+ if binarize:
1002
+ out.data = (out.data != 0).astype(float)
1003
+
1004
+ return out
1005
+
1006
+ # Use current efficient implementation (fast path)
1007
+ b = _result_from_array(bd, bd.data, rows="preserve")
1008
+
1009
+ if coerce_nan:
1010
+ b.data = np.nan_to_num(b.data)
1011
+
1012
+ from .utils import _resolve_threshold
1013
+
1014
+ upper = _resolve_threshold(upper, b.data)
1015
+ lower = _resolve_threshold(lower, b.data)
1016
+
1017
+ if upper is not None and lower is not None:
1018
+ b.data[(b.data < upper) & (b.data > lower)] = 0
1019
+ elif upper is not None:
1020
+ b.data[b.data < upper] = 0
1021
+ elif lower is not None:
1022
+ b.data[b.data > lower] = 0
1023
+
1024
+ if binarize:
1025
+ b.data[b.data != 0] = 1
1026
+ return b
1027
+
1028
+
1029
+ def _regions(
1030
+ bd,
1031
+ *,
1032
+ min_region_size=1350,
1033
+ method="local_regions",
1034
+ smoothing_fwhm=6,
1035
+ is_mask=False,
1036
+ ):
1037
+ """Extract brain connected regions into separate regions.
1038
+
1039
+ Args:
1040
+ bd (BrainData): Image to segment.
1041
+ min_region_size (int): Minimum volume in mm³ for a region to be kept.
1042
+ method (str): ``'connected_components'`` labels each connected
1043
+ component directly; ``'local_regions'`` (default) seeds a marker at
1044
+ each component's peak and separates regions with a random-walker
1045
+ segmentation.
1046
+ smoothing_fwhm (float): Smooth the image first to extract sparser
1047
+ regions. Only used for ``method='local_regions'``.
1048
+ is_mask (bool): Treat ``bd`` as a boolean mask and use
1049
+ ``connected_label_regions`` instead. Default ``False``.
1050
+
1051
+ Returns:
1052
+ BrainData: One image per extracted region.
1053
+ """
1054
+ from nilearn.regions import connected_label_regions, connected_regions
1055
+
1056
+ from nltools.data import BrainData
1057
+
1058
+ if is_mask:
1059
+ region_imgs, _ = connected_label_regions(bd.to_nifti())
1060
+ else:
1061
+ region_imgs, _ = connected_regions(
1062
+ bd.to_nifti(), min_region_size, method, smoothing_fwhm
1063
+ )
1064
+
1065
+ return _result_from_array(
1066
+ bd, BrainData(region_imgs, mask=bd.mask).data, rows="clear"
1067
+ )
1068
+
1069
+
1070
+ def _transform_pairwise_data(bd):
1071
+ """Transform BrainData into pairwise comparisons.
1072
+
1073
+ Args:
1074
+ bd (BrainData): Data with a ``Y`` column to compare pairwise.
1075
+
1076
+ Returns:
1077
+ BrainData: Pairwise-difference images with a recoded ``Y``.
1078
+ """
1079
+ from nltools.algorithms.similarity import transform_pairwise
1080
+
1081
+ data, new_Y = transform_pairwise(bd.data, bd.Y.to_numpy())
1082
+ new_Y = np.where(np.asarray(new_Y) == -1, 0, new_Y)
1083
+ return _result_from_rows(bd, data, X=None, Y=pl.DataFrame(new_Y))
1084
+
1085
+
1086
+ def _decompose( # nosemgrep: kwargs-internal-forwarding # forwards to the sklearn decomposition estimator
1087
+ bd, *, method="pca", axis="voxels", n_components=None, **kwargs
1088
+ ):
1089
+ """Decompose a BrainData object.
1090
+
1091
+ Args:
1092
+ bd (BrainData): Data to decompose.
1093
+ method (str): Decomposition algorithm: ``'pca'`` (default), ``'ica'``,
1094
+ ``'nnmf'``, ``'fa'``, ``'dictionary'``, or ``'kernelpca'``.
1095
+ axis (str): Dimension to decompose: ``'voxels'`` (default) or
1096
+ ``'images'``.
1097
+ n_components (int | None): Number of components. ``None`` retains as
1098
+ many as possible.
1099
+ **kwargs (dict): Forwarded to the ``sklearn.decomposition`` estimator.
1100
+
1101
+ Returns:
1102
+ dict: ``'decomposition_object'`` (the fitted sklearn estimator),
1103
+ ``'components'`` (`BrainData`), and ``'weights'`` (array).
1104
+ """
1105
+ import importlib
1106
+
1107
+ _decomposition_algs = {
1108
+ "pca": "sklearn.decomposition.PCA",
1109
+ "ica": "sklearn.decomposition.FastICA",
1110
+ "nnmf": "sklearn.decomposition.NMF",
1111
+ "fa": "sklearn.decomposition.FactorAnalysis",
1112
+ "dictionary": "sklearn.decomposition.DictionaryLearning",
1113
+ "kernelpca": "sklearn.decomposition.KernelPCA",
1114
+ }
1115
+ if method not in _decomposition_algs:
1116
+ raise ValueError(
1117
+ f"Invalid decomposition method '{method}'. "
1118
+ f"Valid options: {list(_decomposition_algs)}"
1119
+ )
1120
+ module_path, class_name = _decomposition_algs[method].rsplit(".", 1)
1121
+ alg_class = getattr(importlib.import_module(module_path), class_name)
1122
+
1123
+ out = {"decomposition_object": alg_class(n_components, **kwargs)}
1124
+
1125
+ if axis == "images":
1126
+ out["decomposition_object"].fit(bd.data.T)
1127
+ out["components"] = _result_from_array(
1128
+ bd, out["decomposition_object"].transform(bd.data.T).T, rows="clear"
1129
+ )
1130
+ out["weights"] = out["decomposition_object"].components_.T
1131
+ elif axis == "voxels":
1132
+ out["decomposition_object"].fit(bd.data)
1133
+ out["weights"] = out["decomposition_object"].transform(bd.data)
1134
+ out["components"] = _result_from_array(
1135
+ bd, out["decomposition_object"].components_, rows="clear"
1136
+ )
1137
+ return out
1138
+
1139
+
1140
+ def _align(bd, target, method="procrustes", axis=0):
1141
+ """Align a BrainData instance to a target using functional alignment.
1142
+
1143
+ Alignment type can be hyperalignment or Shared Response Model. When
1144
+ using hyperalignment, `target` image can be another subject or an
1145
+ already estimated common model. When using SRM, `target` must be a previously
1146
+ estimated common model stored as a numpy array. Transformed data can be back
1147
+ projected to original data using Transformation matrix.
1148
+
1149
+ See `nltools.algorithms.align` for aligning multiple BrainData instances.
1150
+
1151
+ Args:
1152
+ bd (BrainData): Data to align.
1153
+ target (BrainData | np.ndarray): Alignment target — another subject or
1154
+ a fitted common model (array) for the SRM methods.
1155
+ method (str): ``'procrustes'`` (default), ``'probabilistic_srm'``, or
1156
+ ``'deterministic_srm'``.
1157
+ axis (int): Axis to align on. Default ``0``.
1158
+
1159
+ Returns:
1160
+ dict: ``'transformed'``, ``'transformation_matrix'`` and
1161
+ ``'common_model'``, plus the floats ``'disparity'`` and
1162
+ ``'scale'`` for ``'procrustes'``. A value is a `BrainData` when its
1163
+ columns are a voxel axis matching the mask it carries, and a raw
1164
+ `np.ndarray` otherwise. ``'procrustes'`` therefore returns all
1165
+ three as independently owned `BrainData`: ``'transformed'`` on the
1166
+ source voxel axis, ``'common_model'`` on the target's, and
1167
+ ``'transformation_matrix'`` as ``(n_voxels, n_voxels)``. The SRM
1168
+ methods return ``'transformed'`` ``(n_images, n_features)`` and
1169
+ ``'common_model'`` ``(n_model_rows, n_features)`` as raw
1170
+ `np.ndarray`, because both span the common model's feature axis
1171
+ rather than voxels, and ``'transformation_matrix'`` as a
1172
+ `BrainData` of ``n_features`` voxel maps, shape
1173
+ ``(n_features, n_voxels)``. With ``axis=1`` the transformation
1174
+ matrix spans images on its column axis for either method, so it is
1175
+ a raw `np.ndarray` of shape ``(n_images, n_images)`` for
1176
+ ``'procrustes'`` and ``(n_model_rows, n_images)`` for the SRM
1177
+ methods.
1178
+
1179
+ Raises:
1180
+ ValueError: If a value that must be returned as a `BrainData` has a
1181
+ column count other than the mask support. This is what a
1182
+ ``'procrustes'`` target with more voxels than the source produces,
1183
+ since the source data is zero-padded to the target's width.
1184
+
1185
+ Examples:
1186
+ ```python
1187
+ # Hyperalign using procrustes transform
1188
+ out = data.align(target, method='procrustes')
1189
+
1190
+ # Align using shared response model
1191
+ out = data.align(target, method='probabilistic_srm')
1192
+
1193
+ # Project SRM-aligned data back into original voxel space
1194
+ original_data = np.dot(
1195
+ out['transformed'], out['transformation_matrix'].data
1196
+ )
1197
+
1198
+ # Project procrustes-aligned data back into original voxel space
1199
+ original_voxels = np.dot(
1200
+ out['transformed'].data, out['transformation_matrix'].data.T
1201
+ )
1202
+ ```
1203
+ """
1204
+ from nltools.algorithms.alignment import procrustes
1205
+ from .utils import _check_brain_data
1206
+
1207
+ if method not in ["probabilistic_srm", "deterministic_srm", "procrustes"]:
1208
+ raise ValueError(
1209
+ "Method must be ['probabilistic_srm','deterministic_srm','procrustes']"
1210
+ )
1211
+
1212
+ data1 = bd.data.copy()
1213
+
1214
+ if method == "procrustes":
1215
+ target = _check_brain_data(target)
1216
+ data2 = target.data.copy()
1217
+
1218
+ # pad columns if different shapes
1219
+ sizes_1 = [x.shape[1] for x in [data1, data2]]
1220
+ C = max(sizes_1)
1221
+ y = data1[:, 0:C]
1222
+ missing = C - y.shape[1]
1223
+ add = np.zeros((y.shape[0], missing))
1224
+ data1 = np.append(y, add, axis=1)
1225
+ else:
1226
+ data2 = target.copy()
1227
+
1228
+ if axis == 1:
1229
+ data1 = data1.T
1230
+ data2 = data2.T
1231
+
1232
+ out = {}
1233
+ if method in ["deterministic_srm", "probabilistic_srm"]:
1234
+ if not isinstance(target, np.ndarray):
1235
+ raise ValueError(
1236
+ "Common Model must be a numpy array for ['deterministic_srm', 'probabilistic_srm']"
1237
+ )
1238
+
1239
+ if data2.shape[0] != data1.shape[0]:
1240
+ raise ValueError("The number of timepoints(TRs) does not match the model.")
1241
+
1242
+ A = data1.T.dot(data2)
1243
+
1244
+ # # Solve the Procrustes problem
1245
+ U, _, V = np.linalg.svd(A, full_matrices=False)
1246
+
1247
+ transformation = U.dot(V).T
1248
+ transformed = data1.dot(transformation.T)
1249
+ if axis == 1:
1250
+ # Return the aligned data on the source (images, voxels) layout.
1251
+ transformed = transformed.T
1252
+
1253
+ # On axis=1 the transformation spans images, not voxels, so it stays
1254
+ # an array. The transformed data and the common model always live on
1255
+ # the model's feature axis, so they stay arrays as in v0.5.1.
1256
+ out["transformation_matrix"] = (
1257
+ transformation
1258
+ if axis == 1
1259
+ else _brain_result(
1260
+ bd, transformation, "transformation_matrix", rows="clear"
1261
+ )
1262
+ )
1263
+ out["transformed"] = transformed
1264
+ out["common_model"] = np.array(target, copy=True)
1265
+ elif method == "procrustes":
1266
+ _, transformed, out["disparity"], tf_mtx, out["scale"] = procrustes(
1267
+ data2, data1
1268
+ )
1269
+ transformed_brain = _brain_result(
1270
+ bd,
1271
+ transformed.T if axis == 1 else transformed,
1272
+ "transformed",
1273
+ rows="preserve",
1274
+ )
1275
+ out["transformed"] = transformed_brain
1276
+ out["common_model"] = _brain_result(
1277
+ target, target.data, "common_model", rows="clear"
1278
+ )
1279
+ # `procrustes` solves for R with `transformed = original @ R.T`; store
1280
+ # the transpose so back-projection is `transformed @ T.T`, the same
1281
+ # convention as `nltools.algorithms.align`.
1282
+ out["transformation_matrix"] = (
1283
+ tf_mtx.T
1284
+ if axis == 1
1285
+ else _brain_result(
1286
+ transformed_brain, tf_mtx.T, "transformation_matrix", rows="clear"
1287
+ )
1288
+ )
1289
+ return out
1290
+
1291
+
1292
+ def _smooth(bd, fwhm):
1293
+ """Apply spatial smoothing using nilearn's ``smooth_img``.
1294
+
1295
+ Args:
1296
+ bd (BrainData): Data to smooth.
1297
+ fwhm (float): Full width at half maximum of the Gaussian kernel, in mm.
1298
+
1299
+ Returns:
1300
+ BrainData: Smoothed copy of ``bd``.
1301
+ """
1302
+ from nilearn.image import smooth_img
1303
+ from nilearn.masking import apply_mask as nilearn_apply_mask
1304
+
1305
+ from .utils import _check_brain_data_is_single
1306
+
1307
+ # Single conversion: data -> nifti -> smooth -> data
1308
+ nifti = bd.to_nifti()
1309
+ smoothed_nifti = smooth_img(nifti, fwhm)
1310
+ smoothed_data = nilearn_apply_mask(smoothed_nifti, bd.mask)
1311
+
1312
+ # Ensure single images remain 1D
1313
+ if _check_brain_data_is_single(bd):
1314
+ smoothed_data = smoothed_data.flatten()
1315
+
1316
+ out = _result_from_array(bd, smoothed_data, rows="preserve")
1317
+
1318
+ return out
1319
+
1320
+
1321
+ def _find_spikes_data(
1322
+ bd,
1323
+ global_spike_cutoff=3,
1324
+ diff_spike_cutoff=3,
1325
+ *,
1326
+ TR=None,
1327
+ sampling_freq=None,
1328
+ ):
1329
+ """Identify spikes from time-series data; see `find_spikes`."""
1330
+ from nltools.algorithms.outliers import find_spikes
1331
+
1332
+ return find_spikes(
1333
+ bd,
1334
+ global_spike_cutoff=global_spike_cutoff,
1335
+ diff_spike_cutoff=diff_spike_cutoff,
1336
+ TR=TR,
1337
+ sampling_freq=sampling_freq,
1338
+ )
1339
+
1340
+
1341
+ def _temporal_resample(bd, *, sampling_freq=None, target=None, target_type="hz"):
1342
+ """Resample a BrainData time series to a target frequency or sample count.
1343
+
1344
+ Resample BrainData timeseries to a new target frequency or number of samples
1345
+ using Piecewise Cubic Hermite Interpolating Polynomial (PCHIP) interpolation.
1346
+ This function can up- or down-sample data.
1347
+
1348
+ Args:
1349
+ bd (BrainData): Time series to resample.
1350
+ sampling_freq (float | None): Sampling frequency of the data in Hz.
1351
+ target (float | None): Resampling target, interpreted per
1352
+ ``target_type``.
1353
+ target_type (str): Units of ``target``: ``'hz'`` (default),
1354
+ ``'samples'``, or ``'seconds'``.
1355
+
1356
+ Returns:
1357
+ BrainData: Resampled copy of ``bd``.
1358
+
1359
+ Note:
1360
+ This function can use quite a bit of RAM.
1361
+ """
1362
+ from scipy.interpolate import pchip
1363
+
1364
+ if target_type == "samples":
1365
+ n_samples = target
1366
+ elif target_type == "seconds":
1367
+ n_samples = target * sampling_freq
1368
+ elif target_type == "hz":
1369
+ n_samples = float(sampling_freq) / float(target)
1370
+ else:
1371
+ raise ValueError('Make sure target_type is "samples", "seconds", or "hz".')
1372
+
1373
+ orig_spacing = np.arange(0, bd.shape[0], 1)
1374
+ new_spacing = np.arange(0, bd.shape[0], n_samples)
1375
+
1376
+ resampled_data = np.zeros([len(new_spacing), bd.shape[1]])
1377
+ for i in range(bd.shape[1]):
1378
+ interpolate = pchip(orig_spacing, bd.data[:, i])
1379
+ resampled_data[:, i] = interpolate(new_spacing)
1380
+ out = _result_from_rows(bd, resampled_data, X=None, Y=None)
1381
+ return out