[ENH]: Further Aggregation methods for SphereAggregation Marker #102

Merged
synchon merged 15 commits from update/custom-sphere-aggregation into main 2022-11-15 07:34:50 +00:00
8 changed files with 681 additions and 55 deletions

View file

@ -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
~~~~
@ -97,7 +99,3 @@ Bugs
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.

View file

@ -16,5 +16,6 @@ from . import (
stats,
storage,
utils,
external,
)
from ._version import __version__

4
junifer/external/__init__.py vendored Normal file
View file

@ -0,0 +1,4 @@
"""Provide imports for external sub-package."""
# Authors: Synchon Mandal <s.mandal@fz-juelich.de>
# License: AGPL

6
junifer/external/nilearn/__init__.py vendored Normal file
View file

@ -0,0 +1,6 @@
"""Provide imports for custom nilearn objects sub-package."""
# Authors: Synchon Mandal <s.mandal@fz-juelich.de>
# License: AGPL
from .junifer_nifti_spheres_masker import JuniferNiftiSpheresMasker

View file

@ -0,0 +1,290 @@
"""Provide JuniferNiftiSpheresMasker class."""
# Authors: Synchon Mandal <s.mandal@fz-juelich.de>
fraimondo commented 2022-11-14 08:53:52 +00:00 (Migrated from github.com)

Is this code from Nilearn? If so, can you adjust the license/info on this file to reflect that?

Is this code from Nilearn? If so, can you adjust the license/info on this file to reflect that?
synchon commented 2022-11-14 09:00:42 +00:00 (Migrated from github.com)

Yeah from nilearn, okay will add the information.

Yeah from nilearn, okay will add the information.
synchon commented 2022-11-14 16:09:28 +00:00 (Migrated from github.com)

Okay two doubts:

  • nilearn has a different style of module docstrings, so it will look way different when it renders.
  • According to BSD license (which nilearn uses), needs the license to be there in anything that redistributes the code.
Okay two doubts: - nilearn has a different [style](https://github.com/nilearn/nilearn/blob/c3a9390af2349e3263fd0b2991c17a927a142f6c/nilearn/maskers/nifti_spheres_masker.py#L1) of module docstrings, so it will look way different when it renders. - According to BSD license (which nilearn uses), needs the [license](https://github.com/nilearn/nilearn/blob/main/LICENSE) to be there in anything that redistributes the code.
fraimondo commented 2022-11-14 16:13:09 +00:00 (Migrated from github.com)
  1. Change the docstrings
  2. Keep the header of the file where it says MIT License, including the authors and stuff
1) Change the docstrings 2) Keep the header of the file where it says MIT License, including the authors and stuff
fraimondo commented 2022-11-14 16:14:24 +00:00 (Migrated from github.com)
https://github.com/nilearn/nilearn/blob/98a3ee060b55aa073118885d11cc6a1cecd95059/LICENSE#L4-L35
synchon commented 2022-11-14 16:19:59 +00:00 (Migrated from github.com)
  • Change the docstrings

You mean make it nilearn style?

  • Keep the header of the file where it says MIT License, including the authors and stuff

They have BSD but I get your point.

> * Change the docstrings You mean make it nilearn style? > * Keep the header of the file where it says MIT License, including the authors and stuff They have BSD but I get your point.
synchon commented 2022-11-14 16:23:16 +00:00 (Migrated from github.com)

github.com/nilearn/nilearn@98a3ee060b/LICENSE (L4-L35)

Shouldn't it be the latest license? Licensing is always tricky.

> https://github.com/nilearn/nilearn/blob/98a3ee060b55aa073118885d11cc6a1cecd95059/LICENSE#L4-L35 Shouldn't it be the latest license? Licensing is always tricky.
fraimondo commented 2022-11-14 17:00:16 +00:00 (Migrated from github.com)
  • Change the docstrings

You mean make it nilearn style?

Make them our style

github.com/nilearn/nilearn@98a3ee060b/LICENSE (L4-L35)

Shouldn't it be the latest license? Licensing is always tricky.

No, the first is nilearn license. The rest are software incorporated in nilearn.

> > * Change the docstrings > > You mean make it nilearn style? Make them our style > > https://github.com/nilearn/nilearn/blob/98a3ee060b55aa073118885d11cc6a1cecd95059/LICENSE#L4-L35 > > Shouldn't it be the latest license? Licensing is always tricky. No, the first is nilearn license. The rest are software incorporated in nilearn.
synchon commented 2022-11-14 17:03:13 +00:00 (Migrated from github.com)

I meant that the link you referenced has 2007-2015 and the one I referenced has 2007-2022. So, should we go for the new one?

I meant that the link you referenced has `2007-2015` and the one I referenced has `2007-2022`. So, should we go for the new one?
fraimondo commented 2022-11-14 17:04:13 +00:00 (Migrated from github.com)
check the main branch: https://github.com/nilearn/nilearn/blob/main/LICENSE
# 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,
)

View file

@ -0,0 +1,334 @@
"""Provide tests for JuniferNiftiSpheresMasker class."""
# Authors: Synchon Mandal <s.mandal@fz-juelich.de>
fraimondo commented 2022-11-14 08:54:06 +00:00 (Migrated from github.com)

Same here, how much is it nilearn's code?

Same here, how much is it nilearn's code?
synchon commented 2022-11-14 09:01:17 +00:00 (Migrated from github.com)

The whole of it. I removed the tests for inverse_transform.

The whole of it. I removed the tests for `inverse_transform`.
fraimondo commented 2022-11-14 09:02:00 +00:00 (Migrated from github.com)

then keep nilearn's header then.

then keep nilearn's header then.
synchon commented 2022-11-14 16:20:52 +00:00 (Migrated from github.com)

This will also need the license, I believe?

This will also need the license, I believe?
fraimondo commented 2022-11-14 17:00:25 +00:00 (Migrated from github.com)

Yes.

Yes.
# 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)

View file

@ -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"

View file

@ -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)