nltools 0.6.0.dev0__py3-none-any.whl
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- nltools/__init__.py +55 -0
- nltools/algorithms/__init__.py +90 -0
- nltools/algorithms/alignment/__init__.py +21 -0
- nltools/algorithms/alignment/procrustes.py +565 -0
- nltools/algorithms/alignment/srm.py +758 -0
- nltools/algorithms/backends.py +1059 -0
- nltools/algorithms/corrections.py +177 -0
- nltools/algorithms/decoding.py +327 -0
- nltools/algorithms/inference/__init__.py +50 -0
- nltools/algorithms/inference/bootstrap.py +1386 -0
- nltools/algorithms/inference/correlation.py +373 -0
- nltools/algorithms/inference/intersubject.py +422 -0
- nltools/algorithms/inference/isc.py +1554 -0
- nltools/algorithms/inference/matrix.py +602 -0
- nltools/algorithms/inference/one_sample.py +288 -0
- nltools/algorithms/inference/random.py +122 -0
- nltools/algorithms/inference/timeseries.py +347 -0
- nltools/algorithms/inference/two_sample.py +212 -0
- nltools/algorithms/inference/utils.py +58 -0
- nltools/algorithms/inference/validation.py +282 -0
- nltools/algorithms/neighborhoods.py +207 -0
- nltools/algorithms/outliers.py +308 -0
- nltools/algorithms/regression.py +83 -0
- nltools/algorithms/signal.py +303 -0
- nltools/algorithms/similarity.py +234 -0
- nltools/algorithms/validation.py +151 -0
- nltools/cross_validation.py +72 -0
- nltools/data/__init__.py +30 -0
- nltools/data/adjacency/__init__.py +875 -0
- nltools/data/adjacency/io.py +111 -0
- nltools/data/adjacency/modeling.py +569 -0
- nltools/data/adjacency/plotting.py +174 -0
- nltools/data/adjacency/state.py +349 -0
- nltools/data/adjacency/stats.py +596 -0
- nltools/data/adjacency/utils.py +79 -0
- nltools/data/atlases/__init__.py +23 -0
- nltools/data/atlases/labeling.py +158 -0
- nltools/data/atlases/loading.py +76 -0
- nltools/data/atlases/registry.py +96 -0
- nltools/data/atlases/reporting.py +456 -0
- nltools/data/braindata/__init__.py +2170 -0
- nltools/data/braindata/analysis.py +1381 -0
- nltools/data/braindata/bootstrap.py +398 -0
- nltools/data/braindata/io.py +896 -0
- nltools/data/braindata/modeling.py +594 -0
- nltools/data/braindata/plotting.py +501 -0
- nltools/data/braindata/prediction.py +1250 -0
- nltools/data/braindata/utils.py +348 -0
- nltools/data/braindata/validation.py +197 -0
- nltools/data/braindata/viewer.js +266 -0
- nltools/data/braindata/viewer.py +770 -0
- nltools/data/combine.py +27 -0
- nltools/data/designmatrix/__init__.py +1032 -0
- nltools/data/designmatrix/append.py +518 -0
- nltools/data/designmatrix/diagnostics.py +248 -0
- nltools/data/designmatrix/io.py +356 -0
- nltools/data/designmatrix/plotting.py +291 -0
- nltools/data/designmatrix/regressors.py +463 -0
- nltools/data/designmatrix/transforms.py +200 -0
- nltools/data/designmatrix/utils.py +350 -0
- nltools/data/ownership.py +129 -0
- nltools/data/results.py +291 -0
- nltools/data/roc/__init__.py +398 -0
- nltools/data/simulator/__init__.py +927 -0
- nltools/data/simulator/haxby.py +124 -0
- nltools/data/validation.py +83 -0
- nltools/datasets.py +218 -0
- nltools/io/__init__.py +10 -0
- nltools/io/events.py +67 -0
- nltools/io/h5.py +246 -0
- nltools/mask.py +403 -0
- nltools/models/__init__.py +11 -0
- nltools/models/glm.py +543 -0
- nltools/models/results.py +49 -0
- nltools/models/ridge.py +1303 -0
- nltools/models/validation.py +26 -0
- nltools/plotting/__init__.py +32 -0
- nltools/plotting/adjacency.py +421 -0
- nltools/plotting/brain.py +669 -0
- nltools/plotting/decomposition.py +111 -0
- nltools/plotting/prediction.py +110 -0
- nltools/resources/covariates_example.csv +161 -0
- nltools/resources/onsets_example.csv +40 -0
- nltools/templates/__init__.py +51 -0
- nltools/templates/config.py +144 -0
- nltools/templates/fetch.py +260 -0
- nltools/templates/matching.py +183 -0
- nltools/templates/paths.py +106 -0
- nltools/templates/registry.py +25 -0
- nltools/utils.py +230 -0
- nltools/version.py +13 -0
- nltools-0.6.0.dev0.dist-info/METADATA +95 -0
- nltools-0.6.0.dev0.dist-info/RECORD +95 -0
- nltools-0.6.0.dev0.dist-info/WHEEL +4 -0
- nltools-0.6.0.dev0.dist-info/licenses/LICENSE +21 -0
|
@@ -0,0 +1,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
|