Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

[ENH] Add new returns to NiftiLabelsMasker's transform output #3761

Merged
merged 28 commits into from
Nov 29, 2023
Merged
Show file tree
Hide file tree
Changes from 20 commits
Commits
Show all changes
28 commits
Select commit Hold shift + click to select a range
d3dec6d
add new outputs to labels_masker
mtorabi59 Jun 15, 2023
87edd69
add new variables as attributes
mtorabi59 Jun 21, 2023
121d14a
flake8
mtorabi59 Jun 21, 2023
63b6b1f
add get_masked_labels_img
mtorabi59 Jul 20, 2023
bfc06cd
Merge remote-tracking branch 'upstream/main' into add_regions_labels
mtorabi59 Jul 20, 2023
bdc5d6b
add return_masked_atlas
mtorabi59 Aug 8, 2023
e594a5d
minor change
mtorabi59 Aug 8, 2023
c911019
fix doc warning
Remi-Gau Aug 11, 2023
79f41e0
semantic line break
Remi-Gau Aug 11, 2023
83d664e
fix doc error
mtorabi59 Aug 14, 2023
3c03362
Merge branch 'add_regions_labels' of github.com:mtorabi59/nilearn int…
mtorabi59 Aug 14, 2023
ad507f6
separate return_masked_atlas tests
mtorabi59 Sep 4, 2023
6dbf9e8
Merge branch 'main' into add_regions_labels
mtorabi59 Sep 4, 2023
da32364
Merge remote-tracking branch 'upstream/main' into add_regions_labels
mtorabi59 Oct 13, 2023
d08d15d
add _img_to_signals_labels_with_masked_atlas
mtorabi59 Oct 13, 2023
c58411e
add test_region_names
mtorabi59 Oct 14, 2023
7627582
minor fix
mtorabi59 Oct 14, 2023
ec205dc
minor fix
mtorabi59 Oct 17, 2023
bce6b47
Merge branch 'main' into add_regions_labels
mtorabi59 Oct 17, 2023
1932b5f
add deprecation warning
mtorabi59 Oct 31, 2023
27ca550
correct formatting
mtorabi59 Nov 7, 2023
fc90887
reformat
mtorabi59 Nov 7, 2023
a5f6d47
remove _img_to_signals_labels_with_masked_atlas
mtorabi59 Nov 8, 2023
bc991e8
isort
mtorabi59 Nov 8, 2023
bf0a8b6
Merge remote-tracking branch 'upstream/main' into add_regions_labels
mtorabi59 Nov 14, 2023
c5247d7
apply minor changes
mtorabi59 Nov 14, 2023
1a265c7
Merge remote-tracking branch 'upstream/main' into add_regions_labels
mtorabi59 Nov 27, 2023
50e57fc
minor changes and add the new test test
mtorabi59 Nov 27, 2023
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Jump to
Jump to file
Failed to load files.
Diff view
Diff view
2 changes: 2 additions & 0 deletions doc/changes/latest.rst
Original file line number Diff line number Diff line change
Expand Up @@ -23,3 +23,5 @@ Enhancements

Changes
-------

- :bdg-danger:`Deprecation` ``img_to_signals_labels`` in :class:`~regions.signal_extraction` will also return ``masked_atlas`` in release 0.15. Meanwhile, use ``return_masked_atlas`` parameter to enable/disable this behavior. (:gh:`3761` by `Mohammad Torabi`_).
ymzayek marked this conversation as resolved.
Show resolved Hide resolved
64 changes: 57 additions & 7 deletions nilearn/maskers/nifti_labels_masker.py
Original file line number Diff line number Diff line change
Expand Up @@ -22,12 +22,18 @@ def __init__(self, _resampled_labels_img_, background_label, strategy,
self.mask_img = mask_img

def __call__(self, imgs):
from ..regions import signal_extraction
from ..regions.signal_extraction import (
_img_to_signals_labels_with_masked_atlas,
)

return signal_extraction.img_to_signals_labels(
imgs, self._resampled_labels_img_,
background_label=self.background_label, strategy=self.strategy,
keep_masked_labels=self.keep_masked_labels, mask_img=self.mask_img)
signals, labels, masked_labels_img =\
_img_to_signals_labels_with_masked_atlas(
ymzayek marked this conversation as resolved.
Show resolved Hide resolved
imgs, self._resampled_labels_img_,
background_label=self.background_label, strategy=self.strategy,
keep_masked_labels=self.keep_masked_labels,
mask_img=self.mask_img,
)
return signals, (labels, masked_labels_img)
ymzayek marked this conversation as resolved.
Show resolved Hide resolved
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

And here since this is a private function we can just add the new return without making part of a tuple

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

@ymzayek no, the tuple is because _filter_and_extract gets only two inputs and we didn't want to change that.

Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Ok and not wanting to change _filter_and_extract is because it is called by other maskers not dealt with in this PR? I think we can leave it like this for this PR but just noting that it seems reasonable to me to change its return behavior if other maskers will be changed to support returning a masked image as well.

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

@ymzayek yes we have to change it's behavior in future



@_utils.fill_doc
Expand Down Expand Up @@ -110,6 +116,33 @@ class NiftiLabelsMasker(BaseMasker, _utils.CacheMixin):

.. versionadded:: 0.9.2

region_ids_ : dict
A dictionary containing the region ids corresponding
to each column in region_signal.
The region id corresponding to ``region_signal[:,i]``
is ``region_ids_[i]``.
``region_ids_['background']`` is the background label.

.. versionadded:: 0.10.2.dev
ymzayek marked this conversation as resolved.
Show resolved Hide resolved
ymzayek marked this conversation as resolved.
Show resolved Hide resolved

region_names_ : dict
A dictionary containing the region names corresponding
to each column in region_signal.
The region names correspond to the labels provided
in labels in input.
The region name corresponding to ``region_signal[:,i]``
is ``region_names_[i]``.

.. versionadded:: 0.10.2.dev
ymzayek marked this conversation as resolved.
Show resolved Hide resolved

region_atlas_ : Niimg-like object
Regions definition as labels.
The labels correspond to the indices in ``region_ids_``.
The region in ``region_atlas_`` that takes the value ``region_ids_[i]``
is used to compute the signal in ``region_signal[:,i]``.

.. versionadded:: 0.10.2.dev
ymzayek marked this conversation as resolved.
Show resolved Hide resolved

See Also
--------
nilearn.maskers.NiftiMasker
Expand Down Expand Up @@ -589,7 +622,7 @@ def transform_single_imgs(self, imgs, confounds=None, sample_mask=None):
params['target_affine'] = target_affine
params['clean_kwargs'] = self.clean_kwargs

region_signals, labels_ = self._cache(
region_signals, (ids, masked_atlas) = self._cache(
_filter_and_extract,
ignore=['verbose', 'memory', 'memory_level'],
)(
Expand All @@ -612,7 +645,24 @@ def transform_single_imgs(self, imgs, confounds=None, sample_mask=None):
verbose=self.verbose,
)

self.labels_ = labels_
self.labels_ = ids

# defining a dictionary containing regions ids
region_ids = {'background': self.background_label}
for i in range(region_signals.shape[1]):
# ids does not include background label
region_ids[i] = ids[i]
ymzayek marked this conversation as resolved.
Show resolved Hide resolved

if self.labels is not None:
self.region_names_ = {
key: self.labels[region_id]
for key, region_id in region_ids.items()
if region_id != self.background_label
}
ymzayek marked this conversation as resolved.
Show resolved Hide resolved
else:
self.region_names_ = None
self.region_ids_ = region_ids
self.region_atlas_ = masked_atlas

return region_signals

Expand Down
34 changes: 34 additions & 0 deletions nilearn/maskers/tests/test_nifti_labels_masker.py
Original file line number Diff line number Diff line change
Expand Up @@ -590,6 +590,40 @@ def test_nifti_labels_masker_with_mask():
masked_signals = masked_masker.fit().transform(fmri_img)
assert np.allclose(signals, masked_signals)

# masker.region_atlas_ should be the same as the masked_labels
# masked_labels is a 4D image with shape (13,11,12,1)
masked_labels_data = get_data(masked_labels)[:, :, :, 0]
assert np.allclose(get_data(masker.region_atlas_), masked_labels_data)


def test_region_names():
"""Test region_names_ attribute in NiftiLabelsMasker"""
shape = (13, 11, 12, 3)
affine = np.eye(4)
fmri_img, mask_img = data_gen.generate_random_img(shape, affine=affine)
ymzayek marked this conversation as resolved.
Show resolved Hide resolved
labels_img = data_gen.generate_labeled_regions(
shape[:3],
affine=affine,
n_regions=7,
)

# define region_names
region_names = ['background'] + ["region_" + str(i + 1) for i in range(7)]
ymzayek marked this conversation as resolved.
Show resolved Hide resolved

masker = NiftiLabelsMasker(
labels_img,
labels=region_names,
resampling_target=None,
)
_ = masker.fit().transform(fmri_img)

region_names_after_fit =\
[masker.region_names_[i] for i in masker.region_names_]
ymzayek marked this conversation as resolved.
Show resolved Hide resolved
region_names_after_fit.sort()
region_names.sort()
region_names.pop(region_names.index('background'))
ymzayek marked this conversation as resolved.
Show resolved Hide resolved
assert region_names_after_fit == region_names

ymzayek marked this conversation as resolved.
Show resolved Hide resolved

def test_3d_images():
# Test that the NiftiLabelsMasker works with 3D images
Expand Down
110 changes: 107 additions & 3 deletions nilearn/regions/signal_extraction.py
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,7 @@
import warnings

import numpy as np
from nibabel import Nifti1Image
from scipy import linalg, ndimage

from .. import _utils, masking
Expand Down Expand Up @@ -238,7 +239,7 @@

# FIXME: naming scheme is not really satisfying. Any better idea appreciated.
@_utils.fill_doc
def img_to_signals_labels(
def _img_to_signals_labels_with_masked_atlas(
imgs,
labels_img,
mask_img=None,
Expand All @@ -247,7 +248,7 @@
strategy="mean",
keep_masked_labels=True,
):
"""Extract region signals from image.
"""Extract region signals from image. Also returns masked atlas.

This function is applicable to regions defined by labels.

Expand Down Expand Up @@ -294,6 +295,9 @@
Corresponding labels for each signal. signal[:, n] was extracted from
the region with label labels[n].

masked_atlas : Niimg-like object
Regions definition as labels after applying the mask.

See Also
--------
nilearn.regions.signals_to_img_labels
Expand Down Expand Up @@ -334,7 +338,107 @@
labels_index = {l: n for n, l in enumerate(labels)}
for this_label in missing_labels:
signals[:, labels_index[this_label]] = 0
return signals, labels

# finding the new labels image
masked_atlas = Nifti1Image(
labels_data.astype(np.int8), labels_img.affine
)
ymzayek marked this conversation as resolved.
Show resolved Hide resolved
return signals, labels, masked_atlas


# FIXME: naming scheme is not really satisfying. Any better idea appreciated.
@_utils.fill_doc
def img_to_signals_labels(
imgs,
labels_img,
mask_img=None,
background_label=0,
order="F",
strategy="mean",
keep_masked_labels=True,
return_masked_atlas=False
ymzayek marked this conversation as resolved.
Show resolved Hide resolved
):
"""Extract region signals from image.

This function is applicable to regions defined by labels.

labels, imgs and mask shapes and affines must fit. This function
performs no resampling.

Parameters
----------
%(imgs)s
Input images.

labels_img : Niimg-like object
See :ref:`extracting_data`.
Regions definition as labels. By default, the label zero is used to
denote an absence of region. Use background_label to change it.

mask_img : Niimg-like object, optional
See :ref:`extracting_data`.
Mask to apply to labels before extracting signals.
Every point outside the mask is considered
as background (i.e. no region).

background_label : number, optional
Number representing background in labels_img. Default=0.

order : :obj:`str`, optional
Ordering of output array ("C" or "F"). Default="F".

strategy : :obj:`str`, optional
The name of a valid function to reduce the region with.
Must be one of: sum, mean, median, minimum, maximum, variance,
standard_deviation. Default="mean".
%(keep_masked_labels)s

return_masked_atlas : :obj:`bool`, optional
If True, the masked atlas is returned. Default=False.
deprecated in version 0.13, to be removed in 0.15.
after 0.13, the masked atlas will always be returned.

Returns
-------
signals : :class:`numpy.ndarray`
Signals extracted from each region. One output signal is the mean
of all input signals in a given region. If some regions are entirely
outside the mask, the corresponding signal is zero.
Shape is: (scan number, number of regions)

labels : :obj:`list` or :obj:`tuple`
Corresponding labels for each signal. signal[:, n] was extracted from
the region with label labels[n].

See Also
--------
nilearn.regions.signals_to_img_labels
nilearn.regions.img_to_signals_maps
nilearn.maskers.NiftiLabelsMasker : Signal extraction on labels images
e.g. clusters

"""
signals, labels, masked_atlas = _img_to_signals_labels_with_masked_atlas(
imgs,
labels_img,
mask_img,
background_label,
order,
strategy,
keep_masked_labels,
)
if return_masked_atlas:
return signals, labels, masked_atlas

Check warning on line 431 in nilearn/regions/signal_extraction.py

View check run for this annotation

Codecov / codecov/patch

nilearn/regions/signal_extraction.py#L431

Added line #L431 was not covered by tests
ymzayek marked this conversation as resolved.
Show resolved Hide resolved
else:
warnings.warn(
'After version 0.13. "img_to_signals_labels" will also return the '
'"masked_atlas". Meanwhile "return_masked_atlas" parameter can be '
"used to toggle this behavior. In version 0.15, "
'"return_masked_atlas" parameter will be removed.',
DeprecationWarning,
stacklevel=1,
)
return signals, labels


def signals_to_img_labels(
Expand Down
69 changes: 69 additions & 0 deletions nilearn/regions/tests/test_signal_extraction.py
Original file line number Diff line number Diff line change
Expand Up @@ -21,6 +21,7 @@
from nilearn.maskers import NiftiLabelsMasker
from nilearn.regions.signal_extraction import (
_check_shape_and_affine_compatibility,
_img_to_signals_labels_with_masked_atlas,
_trim_maps,
img_to_signals_labels,
img_to_signals_maps,
Expand Down Expand Up @@ -365,6 +366,31 @@ def test_signals_extraction_with_labels_without_mask(
assert labels_r == list(range(1, 9))


def test_signals_extraction_with_labels_without_mask_return_masked_atlas(
signals, labels_data, labels_img
ymzayek marked this conversation as resolved.
Show resolved Hide resolved
):
"""Test if the returned masked_atlas is correct in \
conversion between signals and images \
using regions defined by labels."""
ymzayek marked this conversation as resolved.
Show resolved Hide resolved
data_img = signals_to_img_labels(signals=signals, labels_img=labels_img)

# test return_masked_atlas
ymzayek marked this conversation as resolved.
Show resolved Hide resolved
signals_r, labels_r, masked_atlas_r =\
_img_to_signals_labels_with_masked_atlas(
imgs=data_img, labels_img=labels_img,
)
ymzayek marked this conversation as resolved.
Show resolved Hide resolved

labels_data = get_data(labels_img)
labels_data_r = get_data(masked_atlas_r)

# masked_atlas_r should be the same as labels_img
assert_almost_equal(labels_data_r, labels_data)
ymzayek marked this conversation as resolved.
Show resolved Hide resolved

# labels should be the same as before
# the labels_img does not contain background
assert list(np.unique(labels_data_r)) == list(range(1, 9))


def test_signals_extraction_with_labels_with_mask(
signals, labels_img, labels_data, mask_img, shape_3d_default
):
Expand Down Expand Up @@ -411,6 +437,33 @@ def test_signals_extraction_with_labels_with_mask(
assert labels_r == list(range(1, 9))


def test_signals_extraction_with_labels_with_mask_return_masked_atlas(
signals, labels_img, labels_data, mask_img
ymzayek marked this conversation as resolved.
Show resolved Hide resolved
):
"""Test if the returned masked_atlas is correct in \
conversion between signals and images \
using regions defined by labels."""
ymzayek marked this conversation as resolved.
Show resolved Hide resolved
data_img = signals_to_img_labels(
signals=signals, labels_img=labels_img, mask_img=mask_img
)

# test return_masked_atlas
# create a mask_img with only 3 regions
mask_img = _create_mask_with_3_regions_from_labels_data(
get_data(labels_img), labels_img.affine)

signals_r, labels_r, masked_atlas_r =\
_img_to_signals_labels_with_masked_atlas(
imgs=data_img, labels_img=labels_img, mask_img=mask_img,
)
ymzayek marked this conversation as resolved.
Show resolved Hide resolved

labels_data_r = get_data(masked_atlas_r)

# labels should be masked and only contain 3 regions
# and the background
assert list(np.unique(labels_data_r)) == [0, 1, 2, 5]


def test_signal_extraction_with_maps(affine_eye, shape_3d_default, rng):
# Generate signal imgs
maps_img, mask_img = generate_maps(shape_3d_default, N_REGIONS)
Expand Down Expand Up @@ -545,6 +598,22 @@ def test_img_to_signals_labels_warnings(labeled_regions, fmri_img):
assert labels_signals.shape == (N_TIMEPOINTS, 8)
assert len(labels_labels) == 8

# test return_masked_atlas deprecation warning
with pytest.warns(
DeprecationWarning,
match='After version 0.13. "img_to_signals_labels" will also return '
'the "masked_atlas". Meanwhile "return_masked_atlas" parameter can be '
"used to toggle this behavior. In version 0.15, "
'"return_masked_atlas" parameter will be removed.',
):
labels_signals, labels_labels = img_to_signals_labels(
ymzayek marked this conversation as resolved.
Show resolved Hide resolved
imgs=fmri_img,
labels_img=labeled_regions,
mask_img=mask_img,
keep_masked_labels=False,
return_masked_atlas=False,
)


def test_img_to_signals_maps_warnings(
labeled_regions, fmri_img, shape_3d_default
Expand Down