Coverage for nilearn/maskers/base_masker.py: 19%
245 statements
« prev ^ index » next coverage.py v7.9.1, created at 2025-06-20 10:58 +0200
« prev ^ index » next coverage.py v7.9.1, created at 2025-06-20 10:58 +0200
1"""Transformer used to apply basic transformations on :term:`fMRI` data."""
3import abc
4import contextlib
5import itertools
6import warnings
7from collections.abc import Iterable
8from copy import deepcopy
9from pathlib import Path
11import numpy as np
12import pandas as pd
13from joblib import Memory
14from sklearn.base import BaseEstimator, TransformerMixin
15from sklearn.utils.estimator_checks import check_is_fitted
16from sklearn.utils.validation import check_array
18from nilearn._utils import logger
19from nilearn._utils.cache_mixin import CacheMixin, cache
20from nilearn._utils.docs import fill_doc
21from nilearn._utils.helpers import (
22 rename_parameters,
23 stringify_path,
24)
25from nilearn._utils.logger import find_stack_level, log
26from nilearn._utils.masker_validation import (
27 check_compatibility_mask_and_images,
28)
29from nilearn._utils.niimg import repr_niimgs, safe_get_data
30from nilearn._utils.niimg_conversions import check_niimg
31from nilearn._utils.numpy_conversions import csv_to_array
32from nilearn._utils.tags import SKLEARN_LT_1_6
33from nilearn.image import (
34 concat_imgs,
35 high_variance_confounds,
36 new_img_like,
37 resample_img,
38 smooth_img,
39)
40from nilearn.masking import load_mask_img, unmask
41from nilearn.signal import clean
42from nilearn.surface.surface import SurfaceImage, at_least_2d, check_surf_img
43from nilearn.surface.utils import check_polymesh_equal
46def filter_and_extract(
47 imgs,
48 extraction_function,
49 parameters,
50 memory_level=0,
51 memory=None,
52 verbose=0,
53 confounds=None,
54 sample_mask=None,
55 copy=True,
56 dtype=None,
57):
58 """Extract representative time series using given function.
60 Parameters
61 ----------
62 imgs : 3D/4D Niimg-like object
63 Images to be masked. Can be 3-dimensional or 4-dimensional.
65 extraction_function : function
66 Function used to extract the time series from 4D data. This function
67 should take images as argument and returns a tuple containing a 2D
68 array with masked signals along with a auxiliary value used if
69 returning a second value is needed.
70 If any other parameter is needed, a functor or a partial
71 function must be provided.
73 For all other parameters refer to NiftiMasker documentation
75 Returns
76 -------
77 signals : 2D numpy array
78 Signals extracted using the extraction function. It is a scikit-learn
79 friendly 2D array with shape n_samples x n_features.
81 """
82 if memory is None:
83 memory = Memory(location=None)
84 # If we have a string (filename), we won't need to copy, as
85 # there will be no side effect
86 imgs = stringify_path(imgs)
87 if isinstance(imgs, str):
88 copy = False
90 log(
91 f"Loading data from {repr_niimgs(imgs, shorten=False)}",
92 verbose=verbose,
93 )
95 # Convert input to niimg to check shape.
96 # This must be repeated after the shape check because check_niimg will
97 # coerce 5D data to 4D, which we don't want.
98 temp_imgs = check_niimg(imgs)
100 imgs = check_niimg(imgs, atleast_4d=True, ensure_ndim=4, dtype=dtype)
102 target_shape = parameters.get("target_shape")
103 target_affine = parameters.get("target_affine")
104 if target_shape is not None or target_affine is not None:
105 log("Resampling images")
107 imgs = cache(
108 resample_img,
109 memory,
110 func_memory_level=2,
111 memory_level=memory_level,
112 ignore=["copy"],
113 )(
114 imgs,
115 interpolation="continuous",
116 target_shape=target_shape,
117 target_affine=target_affine,
118 copy=copy,
119 copy_header=True,
120 force_resample=False, # set to True in 0.13.0
121 )
123 smoothing_fwhm = parameters.get("smoothing_fwhm")
124 if smoothing_fwhm is not None:
125 log("Smoothing images", verbose=verbose)
127 imgs = cache(
128 smooth_img,
129 memory,
130 func_memory_level=2,
131 memory_level=memory_level,
132 )(imgs, parameters["smoothing_fwhm"])
134 log("Extracting region signals", verbose=verbose)
136 region_signals, aux = cache(
137 extraction_function,
138 memory,
139 func_memory_level=2,
140 memory_level=memory_level,
141 )(imgs)
143 # Temporal
144 # --------
145 # Detrending (optional)
146 # Filtering
147 # Confounds removing (from csv file or numpy array)
148 # Normalizing
150 log("Cleaning extracted signals", verbose=verbose)
152 runs = parameters.get("runs", None)
153 region_signals = cache(
154 clean,
155 memory=memory,
156 func_memory_level=2,
157 memory_level=memory_level,
158 )(
159 region_signals,
160 detrend=parameters["detrend"],
161 standardize=parameters["standardize"],
162 standardize_confounds=parameters["standardize_confounds"],
163 t_r=parameters["t_r"],
164 low_pass=parameters["low_pass"],
165 high_pass=parameters["high_pass"],
166 confounds=confounds,
167 sample_mask=sample_mask,
168 runs=runs,
169 **parameters["clean_kwargs"],
170 )
172 if temp_imgs.ndim == 3:
173 region_signals = region_signals.squeeze()
175 return region_signals, aux
178def prepare_confounds_multimaskers(masker, imgs_list, confounds):
179 """Check and prepare confounds for multimaskers."""
180 if confounds is None:
181 confounds = list(itertools.repeat(None, len(imgs_list)))
182 elif len(confounds) != len(imgs_list):
183 raise ValueError(
184 f"number of confounds ({len(confounds)}) unequal to "
185 f"number of images ({len(imgs_list)})."
186 )
188 if masker.high_variance_confounds:
189 for i, img in enumerate(imgs_list):
190 hv_confounds = masker._cache(high_variance_confounds)(img)
192 if confounds[i] is None:
193 confounds[i] = hv_confounds
194 elif isinstance(confounds[i], list):
195 confounds[i] += hv_confounds
196 elif isinstance(confounds[i], np.ndarray):
197 confounds[i] = np.hstack([confounds[i], hv_confounds])
198 elif isinstance(confounds[i], pd.DataFrame):
199 confounds[i] = np.hstack(
200 [confounds[i].to_numpy(), hv_confounds]
201 )
202 elif isinstance(confounds[i], (str, Path)):
203 c = csv_to_array(confounds[i])
204 if np.isnan(c.flat[0]):
205 # There may be a header
206 c = csv_to_array(confounds[i], skip_header=1)
207 confounds[i] = np.hstack([c, hv_confounds])
208 else:
209 confounds[i].append(hv_confounds)
211 return confounds
214@fill_doc
215class BaseMasker(TransformerMixin, CacheMixin, BaseEstimator):
216 """Base class for NiftiMaskers."""
218 @abc.abstractmethod
219 @fill_doc
220 def transform_single_imgs(
221 self, imgs, confounds=None, sample_mask=None, copy=True
222 ):
223 """Extract signals from a single niimg.
225 Parameters
226 ----------
227 imgs : 3D/4D Niimg-like object
228 See :ref:`extracting_data`.
229 Images to process.
231 %(confounds)s
233 %(sample_mask)s
235 .. versionadded:: 0.8.0
237 copy : :obj:`bool`, default=True
238 Indicates whether a copy is returned or not.
240 Returns
241 -------
242 %(signals_transform_nifti)s
244 """
245 raise NotImplementedError()
247 def _more_tags(self):
248 """Return estimator tags.
250 TODO remove when bumping sklearn_version > 1.5
251 """
252 return self.__sklearn_tags__()
254 def __sklearn_tags__(self):
255 """Return estimator tags.
257 See the sklearn documentation for more details on tags
258 https://scikit-learn.org/1.6/developers/develop.html#estimator-tags
259 """
260 # TODO
261 # get rid of if block
262 # bumping sklearn_version > 1.5
263 if SKLEARN_LT_1_6:
264 from nilearn._utils.tags import tags
266 return tags(masker=True)
268 from nilearn._utils.tags import InputTags
270 tags = super().__sklearn_tags__()
271 tags.input_tags = InputTags(masker=True)
272 return tags
274 def fit(self, imgs=None, y=None):
275 """Present only to comply with sklearn estimators checks."""
276 ...
278 def _load_mask(self, imgs):
279 """Load and validate mask if one passed at init.
281 Returns
282 -------
283 mask_img_ : None or 3D binary nifti
284 """
285 if self.mask_img is None:
286 # in this case
287 # (Multi)Niftimasker will infer one from imaged to fit
288 # other nifti maskers are OK with None
289 return None
291 repr = repr_niimgs(self.mask_img, shorten=(not self.verbose))
292 msg = f"loading mask from {repr}"
293 log(msg=msg, verbose=self.verbose)
295 # ensure that the mask_img_ is a 3D binary image
296 tmp = check_niimg(self.mask_img, atleast_4d=True)
297 mask = safe_get_data(tmp, ensure_finite=True)
298 mask = mask.astype(bool).all(axis=3)
299 mask_img_ = new_img_like(self.mask_img, mask)
301 # Just check that the mask is valid
302 load_mask_img(mask_img_)
303 if imgs is not None:
304 check_compatibility_mask_and_images(self.mask_img, imgs)
306 return mask_img_
308 @fill_doc
309 def transform(self, imgs, confounds=None, sample_mask=None):
310 """Apply mask, spatial and temporal preprocessing.
312 Parameters
313 ----------
314 imgs : 3D/4D Niimg-like object
315 See :ref:`extracting_data`.
316 Images to process.
317 If a 3D niimg is provided, a 1D array is returned.
319 %(confounds)s
321 %(sample_mask)s
323 .. versionadded:: 0.8.0
325 Returns
326 -------
327 %(signals_transform_nifti)s
328 """
329 check_is_fitted(self)
331 if confounds is None and not self.high_variance_confounds:
332 return self.transform_single_imgs(
333 imgs, confounds=confounds, sample_mask=sample_mask
334 )
336 # Compute high variance confounds if requested
337 all_confounds = []
338 if self.high_variance_confounds:
339 hv_confounds = self._cache(high_variance_confounds)(imgs)
340 all_confounds.append(hv_confounds)
341 if confounds is not None:
342 if isinstance(confounds, list):
343 all_confounds += confounds
344 else:
345 all_confounds.append(confounds)
347 return self.transform_single_imgs(
348 imgs, confounds=all_confounds, sample_mask=sample_mask
349 )
351 @fill_doc
352 @rename_parameters(replacement_params={"X": "imgs"}, end_version="0.13.2")
353 def fit_transform(
354 self, imgs, y=None, confounds=None, sample_mask=None, **fit_params
355 ):
356 """Fit to data, then transform it.
358 Parameters
359 ----------
360 imgs : Niimg-like object
361 See :ref:`extracting_data`.
363 y : numpy array of shape [n_samples], default=None
364 Target values.
366 %(confounds)s
368 %(sample_mask)s
370 .. versionadded:: 0.8.0
372 Returns
373 -------
374 %(signals_transform_nifti)s
376 """
377 # non-optimized default implementation; override when a better
378 # method is possible for a given clustering algorithm
379 if y is None:
380 # fit method of arity 1 (unsupervised transformation)
381 if self.mask_img is None:
382 return self.fit(imgs, **fit_params).transform(
383 imgs, confounds=confounds, sample_mask=sample_mask
384 )
386 return self.fit(**fit_params).transform(
387 imgs, confounds=confounds, sample_mask=sample_mask
388 )
390 # fit method of arity 2 (supervised transformation)
391 if self.mask_img is None:
392 return self.fit(imgs, y, **fit_params).transform(
393 imgs, confounds=confounds, sample_mask=sample_mask
394 )
396 warnings.warn(
397 f"[{self.__class__.__name__}.fit] "
398 "Generation of a mask has been"
399 " requested (y != None) while a mask was"
400 " given at masker creation. Given mask"
401 " will be used.",
402 stacklevel=find_stack_level(),
403 )
404 return self.fit(**fit_params).transform(
405 imgs, confounds=confounds, sample_mask=sample_mask
406 )
408 @fill_doc
409 def inverse_transform(self, X):
410 """Transform the data matrix back to an image in brain space.
412 This step only performs spatial unmasking,
413 without inverting any additional processing performed by ``transform``,
414 such as temporal filtering or smoothing.
416 Parameters
417 ----------
418 %(x_inv_transform)s
420 Returns
421 -------
422 %(img_inv_transform_nifti)s
424 """
425 check_is_fitted(self)
427 # do not run sklearn_check as they may cause some failure
428 # with some GLM inputs
429 X = self._check_array(X, sklearn_check=False)
431 img = self._cache(unmask)(X, self.mask_img_)
432 # Be robust again memmapping that will create read-only arrays in
433 # internal structures of the header: remove the memmaped array
434 with contextlib.suppress(Exception):
435 img._header._structarr = np.array(img._header._structarr).copy()
436 return img
438 def _check_array(
439 self, signals: np.ndarray, sklearn_check: bool = True
440 ) -> np.ndarray:
441 """Check array to inverse transform.
443 Parameters
444 ----------
445 signals : :obj:`numpy.ndarray`
447 sklearn_check : :obj:`bool`
448 Run scikit learn check on input
449 """
450 signals = np.atleast_1d(signals)
452 if sklearn_check:
453 signals = check_array(signals, ensure_2d=False)
455 assert signals.ndim <= 2
457 expected_shape = (
458 (self.n_elements_,)
459 if signals.ndim == 1
460 else (signals.shape[0], self.n_elements_)
461 )
463 if signals.shape != expected_shape:
464 raise ValueError(
465 "Input to 'inverse_transform' has wrong shape.\n"
466 f"Expected {expected_shape}.\n"
467 f"Got {signals.shape}."
468 )
470 return signals
472 def set_output(self, *, transform=None):
473 """Set the output container when ``"transform"`` is called.
475 .. warning::
477 This has not been implemented yet.
478 """
479 raise NotImplementedError()
481 def _sanitize_cleaning_parameters(self):
482 """Make sure that cleaning parameters are passed via clean_args.
484 TODO remove when bumping to nilearn >0.13
485 """
486 if hasattr(self, "clean_kwargs"):
487 if self.clean_kwargs:
488 tmp = [", ".join(list(self.clean_kwargs))]
489 warnings.warn(
490 f"You passed some kwargs to {self.__class__.__name__}: "
491 f"{tmp}. "
492 "This behavior is deprecated "
493 "and will be removed in version >0.13.",
494 DeprecationWarning,
495 stacklevel=find_stack_level(),
496 )
497 if self.clean_args:
498 raise ValueError(
499 "Passing arguments via 'kwargs' "
500 "is mutually exclusive with using 'clean_args'"
501 )
502 self.clean_kwargs_ = {
503 k[7:]: v
504 for k, v in self.clean_kwargs.items()
505 if k.startswith("clean__")
506 }
509class _BaseSurfaceMasker(TransformerMixin, CacheMixin, BaseEstimator):
510 """Class from which all surface maskers should inherit."""
512 def _more_tags(self):
513 """Return estimator tags.
515 TODO remove when bumping sklearn_version > 1.5
516 """
517 return self.__sklearn_tags__()
519 def __sklearn_tags__(self):
520 """Return estimator tags.
522 See the sklearn documentation for more details on tags
523 https://scikit-learn.org/1.6/developers/develop.html#estimator-tags
524 """
525 # TODO
526 # get rid of if block
527 if SKLEARN_LT_1_6:
528 from nilearn._utils.tags import tags
530 return tags(surf_img=True, niimg_like=False, masker=True)
532 from nilearn._utils.tags import InputTags
534 tags = super().__sklearn_tags__()
535 tags.input_tags = InputTags(
536 surf_img=True, niimg_like=False, masker=True
537 )
538 return tags
540 def _check_imgs(self, imgs) -> None:
541 if not (
542 isinstance(imgs, SurfaceImage)
543 or (
544 hasattr(imgs, "__iter__")
545 and all(isinstance(x, SurfaceImage) for x in imgs)
546 )
547 ):
548 raise TypeError(
549 "'imgs' should be a SurfaceImage or "
550 "an iterable of SurfaceImage."
551 f"Got: {imgs.__class__.__name__}"
552 )
554 def _load_mask(self, imgs):
555 """Load and validate mask if one passed at init.
557 Returns
558 -------
559 mask_img_ : None or 1D binary SurfaceImage
560 """
561 if self.mask_img is None:
562 return None
564 mask_img_ = deepcopy(self.mask_img)
566 logger.log(
567 msg=f"loading mask from {mask_img_.__repr__()}",
568 verbose=self.verbose,
569 )
571 mask_img_ = at_least_2d(mask_img_)
572 mask = {}
573 for part, v in mask_img_.data.parts.items():
574 mask[part] = v
575 non_finite_mask = np.logical_not(np.isfinite(mask[part]))
576 if non_finite_mask.any():
577 warnings.warn(
578 "Non-finite values detected. "
579 "These values will be replaced with zeros.",
580 stacklevel=find_stack_level(),
581 )
582 mask[part][non_finite_mask] = 0
583 mask[part] = mask[part].astype(bool).all(axis=1)
585 mask_img_ = new_img_like(self.mask_img, mask)
587 # Just check that the mask is valid
588 load_mask_img(mask_img_)
589 if imgs is not None:
590 check_compatibility_mask_and_images(mask_img_, imgs)
591 if not isinstance(imgs, Iterable):
592 imgs = [imgs]
593 for x in imgs:
594 check_surf_img(x)
595 check_polymesh_equal(mask_img_.mesh, x.mesh)
597 return mask_img_
599 @rename_parameters(
600 replacement_params={"img": "imgs"}, end_version="0.13.2"
601 )
602 @fill_doc
603 def transform(self, imgs, confounds=None, sample_mask=None):
604 """Apply mask, spatial and temporal preprocessing.
606 Parameters
607 ----------
608 imgs : :obj:`~nilearn.surface.SurfaceImage` object or \
609 iterable of :obj:`~nilearn.surface.SurfaceImage`
610 Images to process.
612 %(confounds)s
614 %(sample_mask)s
616 Returns
617 -------
618 %(signals_transform_surface)s
619 """
620 check_is_fitted(self)
621 self._check_imgs(imgs)
623 return_1D = isinstance(imgs, SurfaceImage) and len(imgs.shape) < 2
625 if not isinstance(imgs, list):
626 imgs = [imgs]
627 imgs = concat_imgs(imgs)
628 check_surf_img(imgs)
630 check_compatibility_mask_and_images(self.mask_img_, imgs)
632 if self.smoothing_fwhm is not None:
633 warnings.warn(
634 "Parameter smoothing_fwhm "
635 "is not yet supported for surface data",
636 UserWarning,
637 stacklevel=find_stack_level(),
638 )
639 self.smoothing_fwhm = None
641 if self.reports:
642 self._reporting_data["images"] = imgs
644 if confounds is None and not self.high_variance_confounds:
645 signals = self.transform_single_imgs(
646 imgs, confounds=confounds, sample_mask=sample_mask
647 )
648 return signals.squeeze() if return_1D else signals
650 # Compute high variance confounds if requested
651 all_confounds = []
653 if self.high_variance_confounds:
654 hv_confounds = self._cache(high_variance_confounds)(imgs)
655 all_confounds.append(hv_confounds)
657 if confounds is not None:
658 if isinstance(confounds, list):
659 all_confounds += confounds
660 else:
661 all_confounds.append(confounds)
663 signals = self.transform_single_imgs(
664 imgs, confounds=all_confounds, sample_mask=sample_mask
665 )
667 return signals.squeeze() if return_1D else signals
669 @abc.abstractmethod
670 def transform_single_imgs(self, imgs, confounds=None, sample_mask=None):
671 """Extract signals from a single surface image."""
672 # implemented in children classes
673 raise NotImplementedError()
675 @rename_parameters(
676 replacement_params={"img": "imgs"}, end_version="0.13.2"
677 )
678 @fill_doc
679 def fit_transform(self, imgs, y=None, confounds=None, sample_mask=None):
680 """Prepare and perform signal extraction from regions.
682 Parameters
683 ----------
684 imgs : :obj:`~nilearn.surface.SurfaceImage` object or \
685 :obj:`list` of :obj:`~nilearn.surface.SurfaceImage` or \
686 :obj:`tuple` of :obj:`~nilearn.surface.SurfaceImage`
687 Mesh and data for both hemispheres. The data for each hemisphere \
688 is of shape (n_vertices_per_hemisphere, n_timepoints).
690 y : None
691 This parameter is unused.
692 It is solely included for scikit-learn compatibility.
694 %(confounds)s
696 %(sample_mask)s
699 Returns
700 -------
701 %(signals_transform_surface)s
702 """
703 del y
704 return self.fit(imgs).transform(imgs, confounds, sample_mask)
706 def _check_array(
707 self, signals: np.ndarray, sklearn_check: bool = True
708 ) -> np.ndarray:
709 """Check array to inverse transform.
711 Parameters
712 ----------
713 signals : :obj:`numpy.ndarray`
715 sklearn_check : :obj:`bool`
716 Run scikit learn check on input
717 """
718 signals = np.atleast_2d(signals)
720 if sklearn_check:
721 signals = check_array(signals, ensure_2d=False)
723 if signals.shape[-1] != self.n_elements_:
724 raise ValueError(
725 "Input to 'inverse_transform' has wrong shape.\n"
726 f"Last dimension should be {self.n_elements_}.\n"
727 f"Got {signals.shape[-1]}."
728 )
730 return signals
732 def set_output(self, *, transform=None):
733 """Set the output container when ``"transform"`` is called.
735 .. warning::
737 This has not been implemented yet.
738 """
739 raise NotImplementedError()