diff --git a/docs/changes/latest.inc b/docs/changes/latest.inc index ab910e434..262ffeaf9 100644 --- a/docs/changes/latest.inc +++ b/docs/changes/latest.inc @@ -85,6 +85,8 @@ Enhancements - Refactor the :class:`junifer.datagrabber.BaseDataGrabber` class to allow for easier subclassing (:gh:`123` by `Fede Raimondo`_). +- Allow custom aggregation method for :class:`junifer.markers.SphereAggregation` (:gh:`102` by `Synchon Mandal`_). + Bugs ~~~~ @@ -96,8 +98,4 @@ Bugs - Fix a bug in which a datalad dataset could remove a user-cloned dataset (:gh:`53` by `Fede Raimondo`_) API changes -~~~~~~~~~~~ - -- Add new argument ``check_file_existence`` for PatternDataGrabber which allows skipping actual file existence check. - This is needed for datagrabbers which extend from PatternDataGrabber and DataladDataGrabber and have one or more - Datalad subdatasets inside the dataset, for example, DataladHCP1200. \ No newline at end of file +~~~~~~~~~~~ \ No newline at end of file diff --git a/junifer/__init__.py b/junifer/__init__.py index 45a195a3f..9ae266782 100644 --- a/junifer/__init__.py +++ b/junifer/__init__.py @@ -16,5 +16,6 @@ from . import ( stats, storage, utils, + external, ) from ._version import __version__ diff --git a/junifer/external/__init__.py b/junifer/external/__init__.py new file mode 100644 index 000000000..0d705b570 --- /dev/null +++ b/junifer/external/__init__.py @@ -0,0 +1,4 @@ +"""Provide imports for external sub-package.""" + +# Authors: Synchon Mandal +# License: AGPL diff --git a/junifer/external/nilearn/__init__.py b/junifer/external/nilearn/__init__.py new file mode 100644 index 000000000..99a7630b6 --- /dev/null +++ b/junifer/external/nilearn/__init__.py @@ -0,0 +1,6 @@ +"""Provide imports for custom nilearn objects sub-package.""" + +# Authors: Synchon Mandal +# License: AGPL + +from .junifer_nifti_spheres_masker import JuniferNiftiSpheresMasker diff --git a/junifer/external/nilearn/junifer_nifti_spheres_masker.py b/junifer/external/nilearn/junifer_nifti_spheres_masker.py new file mode 100644 index 000000000..8787925e0 --- /dev/null +++ b/junifer/external/nilearn/junifer_nifti_spheres_masker.py @@ -0,0 +1,290 @@ +"""Provide JuniferNiftiSpheresMasker class.""" + +# Authors: Synchon Mandal +# License: AGPL + +from typing import TYPE_CHECKING, Callable, List, Optional, Tuple, Union + +import numpy as np +from nilearn._utils.class_inspect import get_params +from nilearn._utils.niimg import img_data_dtype +from nilearn._utils.niimg_conversions import check_niimg_4d +from nilearn.maskers import NiftiSpheresMasker +from nilearn.maskers.base_masker import _filter_and_extract +from nilearn.maskers.nifti_spheres_masker import _iter_signals_from_spheres + +from ...utils import raise_error + + +if TYPE_CHECKING: + from pathlib import Path + + from nibabel import Nifti1Image, Nifti2Image + from numpy.typing import ArrayLike, DTypeLike + from pandas import DataFrame + + +""" +New BSD License + +Copyright (c) 2007 - 2022 The nilearn developers. +All rights reserved. + + +Redistribution and use in source and binary forms, with or without +modification, are permitted provided that the following conditions are met: + + a. Redistributions of source code must retain the above copyright notice, + this list of conditions and the following disclaimer. + b. Redistributions in binary form must reproduce the above copyright + notice, this list of conditions and the following disclaimer in the + documentation and/or other materials provided with the distribution. + c. Neither the name of the nilearn developers nor the names of + its contributors may be used to endorse or promote products + derived from this software without specific prior written + permission. + + +THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS" +AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE +IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE +ARE DISCLAIMED. IN NO EVENT SHALL THE REGENTS OR CONTRIBUTORS BE LIABLE FOR +ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR CONSEQUENTIAL +DAMAGES (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR +SERVICES; LOSS OF USE, DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER +CAUSED AND ON ANY THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT +LIABILITY, OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY +OUT OF THE USE OF THIS SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH +DAMAGE. +""" + + +class _JuniferExtractionFunctor: + """Functor to extract signals from spheres. + + Parameters + ---------- + seeds_ : list of triple of coordinates in native space + Seed definitions. List of coordinates of the seeds in the same space + as the images (typically MNI or TAL). + radius : float + Indicates, in millimeters, the radius for the sphere around the seed. + mask_img : Niimg-like object + Mask to apply to regions before extracting signals. + agg_func : callable + The function to aggregate signals using. + allow_overlap : bool + If False, an error is raised if the maps overlap. + dtype : any type that can be coerced into a numpy dtype or "auto" + The dtype for the extraction. If "auto", the data will be converted to + int32 if dtype is discrete and float32 if it is continuous. + + """ + + func_name = "junifer_nifti_spheres_masker_extractor" + + def __init__( + self, + seeds_: "ArrayLike", + radius: Optional[float], + mask_img: Union["Nifti1Image", "Nifti2Image", None], + agg_func: Callable, + allow_overlap: bool, + dtype: Union["DTypeLike", str, None], + ) -> None: + self.seeds_ = seeds_ + self.radius = radius + self.mask_img = mask_img + self.agg_func = agg_func + self.allow_overlap = allow_overlap + self.dtype = dtype + + def __call__( + self, + imgs: Union["Nifti1Image", "Nifti2Image"], + ) -> Tuple["ArrayLike", None]: + """Implement function call overloading. + + Parameters + ---------- + imgs : 4D Niimg-like object + If ``imgs`` is an iterable, checks if data is really 4D. Then, + considering that it is a list of ``img``, load them one by one. + If ``img`` is a string, consider it as a path to Nifti image and + call :func:`nibabel.load` on it. + If it is an object, check if the affine attribute is present and + that :func:`nilearn.image.get_data` returns a result, eval raise + TypeError. + + Raises + ------ + TypeError + If ``img`` in ``imgs`` is an object without the affine attribute. + + Returns + ------- + tuple of numpy.ndarray and None + The numpy.ndarray returned is the array of extracted signals. + + """ + n_seeds = len(self.seeds_) + imgs = check_niimg_4d(imgs, dtype=self.dtype) + + signals = np.empty( + (imgs.shape[3], n_seeds), dtype=img_data_dtype(imgs) + ) + for i, sphere in enumerate( + _iter_signals_from_spheres( + seeds=self.seeds_, + niimg=imgs, + radius=self.radius, + allow_overlap=self.allow_overlap, + mask_img=self.mask_img, + ) + ): + signals[:, i] = self.agg_func(sphere, axis=1) + return signals, None + + +class JuniferNiftiSpheresMasker(NiftiSpheresMasker): + """Class for custom NiftiSpheresMasker. + + Parameters + ---------- + seeds : list of triplet of coordinates in native space + Seed definitions. List of coordinates of the seeds in the same space + as the images (typically MNI or TAL). + radius : float, optional + Indicates, in millimeters, the radius for the sphere around the seed. + If None, signal is extracted on a single voxel (default None). + mask_img : Niimg-like object, optional + Mask to apply to regions before extracting signals (default None). + agg_func : callable, optional + The function to aggregate signals using (default numpy.mean). + allow_overlap : bool, optional + If False, an error is raised if the maps overlap (default None). + dtype : any type that can be coerced into a numpy dtype or "auto", optional + The dtype for the extraction. If "auto", the data will be converted to + int32 if dtype is discrete and float32 if it is continuous + (default None). + **kwargs + Keyword arguments are passed to the + :func:`nilearn.maskers.NiftiSpheresMasker`. + + """ + + def __init__( + self, + seeds: "ArrayLike", + radius: Optional[float] = None, + mask_img: Union["Nifti1Image", "Nifti2Image", None] = None, + agg_func: Callable = np.mean, + allow_overlap: bool = False, + dtype: Union["DTypeLike", str, None] = None, + **kwargs, # TODO: to keep or not? + ) -> None: + self.agg_func = agg_func + super().__init__( + seeds=seeds, + radius=radius, + mask_img=mask_img, + allow_overlap=allow_overlap, + **kwargs, + ) + + def transform_single_imgs( + self, + imgs: Union["Nifti1Image", "Nifti2Image"], + confounds: Union[ + str, "Path", "ArrayLike", "DataFrame", List, None + ] = None, + sample_mask: Union["ArrayLike", List, Tuple, None] = None, + ) -> "ArrayLike": + """Extract signals from a single 4D niimg. + + Parameters + ---------- + imgs : 3D/4D Niimg-like object + Images to process. + If a 3D niimg is provided, a singleton dimension will be added to + the output to represent the single scan in the niimg. + confounds : CSV file or array-like or pandas.DataFrame, optional + This parameter is passed to :func:`nilearn.signal.clean`. + Please see the related documentation for details. + shape: (number of scans, number of confounds) + sample_mask : Any type compatible with numpy-array indexing, optional + Masks the niimgs along time/fourth dimension to perform scrubbing + (remove volumes with high motion) and/or non-steady-state volumes. + This parameter is passed to :func:`nilearn.signal.clean`. + shape: (number of scans - number of volumes removed, ) + + Returns + ------- + region_signals : 2D numpy.ndarray + Signal for each sphere. + shape: (number of scans, number of spheres) + + Warns + ----- + DeprecationWarning + If a 3D niimg input is provided, the current behavior + (adding a singleton dimension to produce a 2D array) is deprecated. + Starting in version 0.12, a 1D array will be returned for 3D + inputs. + + """ + self._check_fitted() + + params = get_params(NiftiSpheresMasker, self) + + signals, _ = self._cache( + _filter_and_extract, ignore=["verbose", "memory", "memory_level"] + )( + imgs, + _JuniferExtractionFunctor( + seeds_=self.seeds_, + radius=self.radius, + mask_img=self.mask_img, + agg_func=self.agg_func, + allow_overlap=self.allow_overlap, + dtype=self.dtype, + ), + # Pre-processing + params, + confounds=confounds, + sample_mask=sample_mask, + dtype=self.dtype, + # Caching + memory=self.memory, + memory_level=self.memory_level, + # kwargs + verbose=self.verbose, + ) + return signals + + def inverse_transform(self, region_signals: "ArrayLike") -> "Nifti1Image": + """Compute voxel signals from spheres signals. + + Parameters + ---------- + region_signals : 1D/2D numpy.ndarray + Signal for each region. + If a 1D array is provided, then the shape should be + (number of elements,), and a 3D img will be returned. + If a 2D array is provided, then the shape should be + (number of scans, number of elements), and a 4D img will be + returned. + + Returns + ------- + voxel_signals : nibabel.nifti1.Nifti1Image + Signal for each sphere. + shape: (mask_img, number of scans). + + """ + raise_error( + msg="As this class allows multiple methods of aggregation while " + "transforming, some of which are non-reversible, " + "inverse_transform() should not be implemented.", + klass=NotImplementedError, + ) diff --git a/junifer/external/nilearn/tests/test_junifer_nifti_spheres_masker.py b/junifer/external/nilearn/tests/test_junifer_nifti_spheres_masker.py new file mode 100644 index 000000000..f771d3db5 --- /dev/null +++ b/junifer/external/nilearn/tests/test_junifer_nifti_spheres_masker.py @@ -0,0 +1,334 @@ +"""Provide tests for JuniferNiftiSpheresMasker class.""" + +# Authors: Synchon Mandal +# License: AGPL + +import warnings + +import nibabel +import numpy as np +import pytest +from nilearn._utils import data_gen +from nilearn.image import get_data +from numpy.testing import assert_array_equal + +from junifer.external.nilearn import JuniferNiftiSpheresMasker + + +""" +New BSD License + +Copyright (c) 2007 - 2022 The nilearn developers. +All rights reserved. + + +Redistribution and use in source and binary forms, with or without +modification, are permitted provided that the following conditions are met: + + a. Redistributions of source code must retain the above copyright notice, + this list of conditions and the following disclaimer. + b. Redistributions in binary form must reproduce the above copyright + notice, this list of conditions and the following disclaimer in the + documentation and/or other materials provided with the distribution. + c. Neither the name of the nilearn developers nor the names of + its contributors may be used to endorse or promote products + derived from this software without specific prior written + permission. + + +THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS" +AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE +IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE +ARE DISCLAIMED. IN NO EVENT SHALL THE REGENTS OR CONTRIBUTORS BE LIABLE FOR +ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR CONSEQUENTIAL +DAMAGES (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR +SERVICES; LOSS OF USE, DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER +CAUSED AND ON ANY THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT +LIABILITY, OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY +OUT OF THE USE OF THIS SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH +DAMAGE. +""" + + +def test_seed_extraction() -> None: + """Test seed extraction.""" + data = np.random.RandomState(42).random_sample((3, 3, 3, 5)) + img = nibabel.Nifti1Image(data, np.eye(4)) + masker = JuniferNiftiSpheresMasker(seeds=[(1, 1, 1)]) + # Test the fit + masker.fit() + # Test the transform + s = masker.transform(img) + assert_array_equal(s[:, 0], data[1, 1, 1]) + + +def test_sphere_extraction() -> None: + """Test sphere extraction.""" + data = np.random.RandomState(42).random_sample((3, 3, 3, 5)) + img = nibabel.Nifti1Image(data, np.eye(4)) + masker = JuniferNiftiSpheresMasker(seeds=[(1, 1, 1)], radius=1) + + # Check attributes defined at fit + assert not hasattr(masker, "seeds_") + assert not hasattr(masker, "n_elements_") + + masker.fit() + + # Check attributes defined at fit + assert hasattr(masker, "seeds_") + assert hasattr(masker, "n_elements_") + assert masker.n_elements_ == 1 + + # Test the fit + masker.fit() + + # Test the transform + s = masker.transform(img) + mask = np.zeros((3, 3, 3), dtype=bool) + mask[:, 1, 1] = True + mask[1, :, 1] = True + mask[1, 1, :] = True + assert_array_equal(s[:, 0], np.mean(data[mask], axis=0)) + + # Now with a mask + mask_img = np.zeros((3, 3, 3)) + mask_img[1, :, :] = 1 + mask_img = nibabel.Nifti1Image(mask_img, np.eye(4)) + masker = JuniferNiftiSpheresMasker( + seeds=[(1, 1, 1)], + radius=1, + mask_img=mask_img, + ) + masker.fit() + s = masker.transform(img) + assert_array_equal( + s[:, 0], + np.mean( + data[np.logical_and(mask, get_data(mask_img))], + axis=0, + ), + ) + + +def test_anisotropic_sphere_extraction() -> None: + """Test anisotropic sphere extraction.""" + data = np.random.RandomState(42).random_sample((3, 3, 3, 5)) + affine = np.eye(4) + affine[0, 0] = 2 + affine[2, 2] = 2 + img = nibabel.Nifti1Image(data, affine) + masker = JuniferNiftiSpheresMasker(seeds=[(2, 1, 2)], radius=1) + # Test the fit + masker.fit() + # Test the transform + s = masker.transform(img) + mask = np.zeros((3, 3, 3), dtype=bool) + mask[1, :, 1] = True + assert_array_equal(s[:, 0], np.mean(data[mask], axis=0)) + # Now with a mask + mask_img = np.zeros((3, 2, 3)) + mask_img[1, 0, 1] = 1 + affine_2 = affine.copy() + affine_2[0, 0] = 4 + mask_img = nibabel.Nifti1Image(mask_img, affine=affine_2) + masker = JuniferNiftiSpheresMasker( + seeds=[(2, 1, 2)], + radius=1, + mask_img=mask_img, + ) + + masker.fit() + s = masker.transform(img) + assert_array_equal(s[:, 0], data[1, 0, 1]) + + +def test_errors() -> None: + """Test errors.""" + masker = JuniferNiftiSpheresMasker(seeds=([1, 2]), radius=0.2) + with pytest.raises(ValueError, match="Seeds must be a list .+"): + masker.fit() + + +def test_nifti_spheres_masker_overlap() -> None: + """Test overlapping sphere extraction.""" + # Test resampling in NiftiMapsMasker + affine = np.eye(4) + shape = (5, 5, 5) + + data = np.random.RandomState(42).random_sample(shape + (5,)) + fmri_img = nibabel.Nifti1Image(data, affine) + + seeds = [(0, 0, 0), (2, 2, 2)] + + overlapping_masker = JuniferNiftiSpheresMasker( + seeds, + radius=1, + allow_overlap=True, + ) + overlapping_masker.fit_transform(fmri_img) + overlapping_masker = JuniferNiftiSpheresMasker( + seeds, + radius=2, + allow_overlap=True, + ) + overlapping_masker.fit_transform(fmri_img) + + noverlapping_masker = JuniferNiftiSpheresMasker( + seeds, + radius=1, + allow_overlap=False, + ) + noverlapping_masker.fit_transform(fmri_img) + noverlapping_masker = JuniferNiftiSpheresMasker( + seeds, + radius=2, + allow_overlap=False, + ) + with pytest.raises(ValueError, match="Overlap detected"): + noverlapping_masker.fit_transform(fmri_img) + + +def test_small_radius() -> None: + """Test sphere extraction with small radius.""" + affine = np.eye(4) + shape = (3, 3, 3) + + data = np.random.RandomState(42).random_sample(shape) + mask = np.zeros(shape) + mask[1, 1, 1] = 1 + mask[2, 2, 2] = 1 + affine = np.eye(4) * 1.2 + seed = (1.4, 1.4, 1.4) + + masker = JuniferNiftiSpheresMasker( + seeds=[seed], + radius=0.1, + mask_img=nibabel.Nifti1Image(mask, affine), + ) + masker.fit_transform(nibabel.Nifti1Image(data, affine)) + + # Test if masking is taken into account + mask[1, 1, 1] = 0 + mask[1, 1, 0] = 1 + + masker = JuniferNiftiSpheresMasker( + seeds=[seed], + radius=0.1, + mask_img=nibabel.Nifti1Image(mask, affine), + ) + with pytest.raises(ValueError, match="These spheres are empty"): + masker.fit_transform(nibabel.Nifti1Image(data, affine)) + + masker = JuniferNiftiSpheresMasker( + seeds=[seed], + radius=1.6, + mask_img=nibabel.Nifti1Image(mask, affine), + ) + masker.fit_transform(nibabel.Nifti1Image(data, affine)) + + +def test_is_nifti_spheres_masker_give_nans() -> None: + """Test NaN interaction with masker.""" + affine = np.eye(4) + + data_with_nans = np.zeros((10, 10, 10), dtype=np.float32) + data_with_nans[:, :, :] = np.nan + + data_without_nans = np.random.RandomState(42).random_sample((9, 9, 9)) + indices = np.nonzero(data_without_nans) + + # Leaving nans outside of some data + data_with_nans[indices] = data_without_nans[indices] + img = nibabel.Nifti1Image(data_with_nans, affine) + seed = [(7, 7, 7)] + + # Interaction of seed with nans + masker = JuniferNiftiSpheresMasker(seeds=seed, radius=2.0) + assert not np.isnan(np.sum(masker.fit_transform(img))) + + mask = np.ones((9, 9, 9)) + mask_img = nibabel.Nifti1Image(mask, affine) + # When mask_img is provided, the seed interacts within the brain, so no nan + masker = JuniferNiftiSpheresMasker( + seeds=seed, + radius=2.0, + mask_img=mask_img, + ) + assert not np.isnan(np.sum(masker.fit_transform(img))) + + +def test_standardization() -> None: + """Test standardization.""" + data = np.random.RandomState(42).random_sample((3, 3, 3, 5)) + img = nibabel.Nifti1Image(data, np.eye(4)) + + # test zscore + masker = JuniferNiftiSpheresMasker(seeds=[(1, 1, 1)], standardize="zscore") + # Test the fit + s = masker.fit_transform(img) + + np.testing.assert_almost_equal(s.mean(), 0) + np.testing.assert_almost_equal(s.std(), 1) + + # test psc + masker = JuniferNiftiSpheresMasker(seeds=[(1, 1, 1)], standardize="psc") + # Test the fit + s = masker.fit_transform(img) + + np.testing.assert_almost_equal(s.mean(), 0) + np.testing.assert_almost_equal( + s.ravel(), + data[1, 1, 1] / data[1, 1, 1].mean() * 100 - 100, + ) + + +def test_nifti_spheres_masker_inverse_transform() -> None: + """Test inverse transform.""" + data = np.random.RandomState(42).random_sample((3, 3, 3, 5)) + masker = JuniferNiftiSpheresMasker(seeds=[(1, 1, 1)], radius=1) + with pytest.raises( + NotImplementedError, + match="some of which are non-reversible", + ): + masker.inverse_transform(data[0, 0, 0, :]) + + +def test_nifti_spheres_masker_io_shapes() -> None: + """Ensure that masker handles 1D/2D/3D/4D data appropriately. + + transform(4D image) --> 2D output, no warning + transform(3D image) --> 2D output, DeprecationWarning + + """ + n_regions, n_volumes = 2, 5 + shape_3d = (10, 11, 12) + shape_4d = (10, 11, 12, n_volumes) + affine = np.eye(4) + + img_4d, mask_img = data_gen.generate_random_img( + shape_4d, + affine=affine, + ) + img_3d, _ = data_gen.generate_random_img(shape_3d, affine=affine) + + masker = JuniferNiftiSpheresMasker( + seeds=[(1, 1, 1), (4, 4, 4)], # number of tuples equal to n_regions + radius=1, + mask_img=mask_img, + ) + masker.fit() + + # DeprecationWarning *should* be raised for 3D inputs + with pytest.warns(DeprecationWarning, match="Starting in version 0.12"): + test_data = masker.transform(img_3d) + assert test_data.shape == (1, n_regions) + + # DeprecationWarning should *not* be raised for 4D inputs + with warnings.catch_warnings(): + warnings.filterwarnings( + "error", + message="Starting in version 0.12", + category=DeprecationWarning, + ) + test_data = masker.transform(img_4d) + assert test_data.shape == (n_volumes, n_regions) diff --git a/junifer/markers/sphere_aggregation.py b/junifer/markers/sphere_aggregation.py index 897053185..ba3cff9ef 100644 --- a/junifer/markers/sphere_aggregation.py +++ b/junifer/markers/sphere_aggregation.py @@ -6,11 +6,11 @@ from typing import TYPE_CHECKING, Any, Dict, List, Optional, Union -from nilearn.maskers import NiftiSpheresMasker - from ..api.decorators import register_marker from ..data import load_coordinates -from ..utils import logger, raise_error +from ..external.nilearn import JuniferNiftiSpheresMasker +from ..stats import get_aggfunc_by_name +from ..utils import logger from .base import BaseMarker if TYPE_CHECKING: @@ -27,21 +27,24 @@ class SphereAggregation(BaseMarker): The name of the coordinates list to use. See :func:`junifer.data.coordinates.list_coordinates` for options. radius : float, optional - The radius of the sphere in mm. If None, the signal will be extracted - from a single voxel. See :class:`nilearn.maskers.NiftiSpheresMasker` - for more information (default None). + The radius of the sphere in millimeters. If None, the signal will be + extracted from a single voxel. See + :class:`nilearn.maskers.NiftiSpheresMasker` for more information + (default None). method : str, optional The aggregation method to use. See :func:`junifer.stats.get_aggfunc_by_name` for more information (default "mean"). method_params : dict, optional The parameters to pass to the aggregation method (default None). - on : list of str, optional - The kind of data to apply the marker to. By default, will work on all + on : {"T1w", "BOLD", "VBM_GM", "VBM_WM", "fALFF", "GCOR", "LCOR"} or \ + list of the options, optional + The data types to apply the marker to. If None, will work on all available data (default None). name : str, optional The name of the marker. By default, it will use KIND_SphereAggregation where KIND is the kind of data it was applied to (default None). + """ def __init__( @@ -49,21 +52,12 @@ class SphereAggregation(BaseMarker): coords: str, radius: Optional[float] = None, method: str = "mean", - method_params: Optional[Dict] = None, - on: Optional[Union[List[str], str]] = None, + method_params: Optional[Dict[str, Any]] = None, + on: Union[List[str], str, None] = None, name: Optional[str] = None, ) -> None: self.coords = coords self.radius = radius - - if method != "mean": - raise_error( - "Only mean aggregation is supported for sphere aggregation. " - "If you need other aggregation methods, please open an issue " - "on `junifer github`_.", - NotImplementedError, - ) - self.method = method self.method_params = {} if method_params is None else method_params super().__init__(on=on, name=name) @@ -90,7 +84,7 @@ class SphereAggregation(BaseMarker): Returns ------- list of str - The kind of output. + The list of storage kinds. """ outputs = [] @@ -159,17 +153,21 @@ class SphereAggregation(BaseMarker): """ t_input = input["data"] logger.debug(f"Sphere aggregation using {self.method}") - # agg_func = get_aggfunc_by_name( - # self.method, func_params=self.method_params - # ) - coords, out_labels = load_coordinates(self.coords) - masker = NiftiSpheresMasker( + # Get aggregation function + agg_func = get_aggfunc_by_name( + self.method, func_params=self.method_params + ) + # Get seeds and labels + coords, out_labels = load_coordinates(name=self.coords) + masker = JuniferNiftiSpheresMasker( seeds=coords, radius=self.radius, mask_img=None, # TODO: support this (needs #79) + agg_func=agg_func, ) - + # Fit and transform the marker on the data out_values = masker.fit_transform(t_input) + # Format the output out = {"data": out_values, "columns": out_labels} if out_values.shape[0] > 1: out["row_names"] = "scan" diff --git a/junifer/markers/tests/test_sphere_aggregation.py b/junifer/markers/tests/test_sphere_aggregation.py index 9b5d1a0f0..23af8584f 100644 --- a/junifer/markers/tests/test_sphere_aggregation.py +++ b/junifer/markers/tests/test_sphere_aggregation.py @@ -16,11 +16,15 @@ from junifer.data import load_coordinates from junifer.markers.sphere_aggregation import SphereAggregation from junifer.storage import SQLiteFeatureStorage +# Define common variables +COORDS = "DMNBuckner" +RADIUS = 8 + def test_SphereAggregation_input_output() -> None: """Test SphereAggregation input and output types.""" marker = SphereAggregation( - coords="DMNBuckner", method="mean", radius=8, on="VBM_GM" + coords=COORDS, method="mean", radius=RADIUS, on="VBM_GM" ) output = marker.get_output_kind(["VBM_GM", "BOLD"]) @@ -33,9 +37,7 @@ def test_SphereAggregation_input_output() -> None: def test_SphereAggregation_3D() -> None: """Test SphereAggregation object on 3D images.""" # Get the testing coordinates (for nilearn) - coord_names = "DMNBuckner" - radius = 8 - coordinates, labels = load_coordinates(coord_names) + coordinates, labels = load_coordinates(COORDS) # Get the oasis VBM data oasis_dataset = datasets.fetch_oasis_vbm(n_subjects=1) @@ -43,12 +45,12 @@ def test_SphereAggregation_3D() -> None: img = nib.load(vbm) # Create NiftiLabelsMasker - nifti_masker = NiftiSpheresMasker(seeds=coordinates, radius=radius) + nifti_masker = NiftiSpheresMasker(seeds=coordinates, radius=RADIUS) auto4d = nifti_masker.fit_transform(img) # Create SphereAggregation object marker = SphereAggregation( - coords=coord_names, method="mean", radius=radius, on="VBM_GM" + coords=COORDS, method="mean", radius=RADIUS, on="VBM_GM" ) input = {"VBM_GM": {"data": img}} jun_values4d = marker.fit_transform(input)["VBM_GM"]["data"] @@ -59,37 +61,30 @@ def test_SphereAggregation_3D() -> None: meta = marker.get_meta("VBM_GM")["marker"] assert meta["method"] == "mean" - assert meta["coords"] == coord_names - assert meta["radius"] == radius + assert meta["coords"] == COORDS + assert meta["radius"] == RADIUS assert meta["name"] == "VBM_GM_SphereAggregation" assert meta["class"] == "SphereAggregation" assert meta["kind"] == "VBM_GM" assert meta["method_params"] == {} - with pytest.raises(NotImplementedError, match="mean aggregation"): - marker = SphereAggregation( - coords=coord_names, method="std", radius=radius, on="BOLD" - ) - def test_SphereAggregation_4D() -> None: """Test SphereAggregation object on 4D images.""" # Get the testing coordinates (for nilearn) - coord_names = "DMNBuckner" - radius = 8 - coordinates, labels = load_coordinates(coord_names) + coordinates, labels = load_coordinates(COORDS) - # Get the SPM auditory data: + # Get the SPM auditory data subject_data = datasets.fetch_spm_auditory() fmri_img = concat_imgs(subject_data.func) # type: ignore # Create NiftiLabelsMasker - nifti_masker = NiftiSpheresMasker(seeds=coordinates, radius=radius) + nifti_masker = NiftiSpheresMasker(seeds=coordinates, radius=RADIUS) auto4d = nifti_masker.fit_transform(fmri_img) # Create SphereAggregation object marker = SphereAggregation( - coords=coord_names, method="mean", radius=radius + coords=COORDS, method="mean", radius=RADIUS ) input = {"BOLD": {"data": fmri_img}} jun_values4d = marker.fit_transform(input)["BOLD"]["data"] @@ -100,8 +95,8 @@ def test_SphereAggregation_4D() -> None: meta = marker.get_meta("BOLD")["marker"] assert meta["method"] == "mean" - assert meta["coords"] == coord_names - assert meta["radius"] == radius + assert meta["coords"] == COORDS + assert meta["radius"] == RADIUS assert meta["name"] == "BOLD_SphereAggregation" assert meta["class"] == "SphereAggregation" assert meta["kind"] == "BOLD" @@ -133,7 +128,7 @@ def test_SphereAggregation_storage(tmp_path: Path) -> None: } input = {"VBM_GM": {"data": img}, "meta": meta} marker = SphereAggregation( - coords="DMNBuckner", method="mean", radius=8, on="VBM_GM" + coords=COORDS, method="mean", radius=RADIUS, on="VBM_GM" ) marker.fit_transform(input, storage=storage) @@ -143,12 +138,12 @@ def test_SphereAggregation_storage(tmp_path: Path) -> None: "version": "0.0.1", "marker": {"name": "BOLD_fcname"}, } - # Get the SPM auditory data: + # Get the SPM auditory data subject_data = datasets.fetch_spm_auditory() fmri_img = concat_imgs(subject_data.func) # type: ignore input = {"BOLD": {"data": fmri_img}, "meta": meta} marker = SphereAggregation( - coords="DMNBuckner", method="mean", radius=8, on="BOLD" + coords=COORDS, method="mean", radius=RADIUS, on="BOLD" ) marker.fit_transform(input, storage=storage)