Repository: nilearn/nilearn

Issue:
Ensure finite data (replace NaNs with zeros) in NiftiLabelsMasker
<!--Provide a brief description what you would like changes and why.-->
`NiftiMapsMasker` handles NaNs implicitly, while `NiftiLabelsMasker` does not. I think it just comes down to the following line:

https://github.com/nilearn/nilearn/blob/02e915accc1dd29ee8af0e92004eabb4f0393958/nilearn/regions/signal_extraction.py#L118

<!--Please fill in the following information, to the best of your ability.-->
### Benefits to the change

I think the main benefit would be ensuring consistent behavior across Maskers, but it would also be helpful to retain signal from regions with some NaNs in the data.

### Pseudocode for the new behavior, if applicable

```python
import nibabel as nib
import numpy as np
from nilearn import datasets, input_data

img = datasets.load_mni152_brain_mask()
data = img.get_fdata()
# add NaN for test
data[52, 53, 71] = np.nan
# make 4d
data = data[..., None]
img_nan = nib.Nifti1Image(data, img.affine, img.header)

labels_atlas = datasets.fetch_atlas_aal()["maps"]

# Apply masker and check voxel of interest
labels_masker = input_data.NiftiLabelsMasker(labels_atlas)
labels_values = labels_masker.fit_transform(img_nan)
print(labels_values[0, :5])
```

> [0.94469654 0.90653653 0.95470964 0.96079882 0.99480789]

Instead of 

> [       nan 0.90653653 0.95470964 0.96079882 0.99480789]


Discussion:

Resolving patch:
diff --git a/nilearn/_utils/niimg.py b/nilearn/_utils/niimg.py
--- a/nilearn/_utils/niimg.py
+++ b/nilearn/_utils/niimg.py
@@ -7,12 +7,14 @@
 import copy
 import gc
 import collections.abc
+from warnings import warn
 
 import numpy as np
 import nibabel
 
 from pathlib import Path
 
+
 def _get_data(img):
     # copy-pasted from https://github.com/nipy/nibabel/blob/de44a105c1267b07ef9e28f6c35b31f851d5a005/nibabel/dataobj_images.py#L204
     # get_data is removed from nibabel because:
@@ -55,7 +57,11 @@ def _safe_get_data(img, ensure_finite=False, copy_data=False):
     data = _get_data(img)
     if ensure_finite:
         non_finite_mask = np.logical_not(np.isfinite(data))
-        if non_finite_mask.sum() > 0: # any non_finite_mask values?
+        if non_finite_mask.sum() > 0:  # any non_finite_mask values?
+            warn(
+                "Non-finite values detected. "
+                "These values will be replaced with zeros."
+            )
             data[non_finite_mask] = 0
 
     return data
@@ -132,7 +138,7 @@ def load_niimg(niimg, dtype=None):
         if niimg.header is not None:
             niimg = new_img_like(niimg, _get_data(niimg).astype(dtype),
                                 niimg.affine, copy_header=True)
-            niimg.header.set_data_dtype(dtype)        
+            niimg.header.set_data_dtype(dtype)
         else:
             niimg = new_img_like(niimg, _get_data(niimg).astype(dtype),
                                 niimg.affine)
diff --git a/nilearn/regions/signal_extraction.py b/nilearn/regions/signal_extraction.py
--- a/nilearn/regions/signal_extraction.py
+++ b/nilearn/regions/signal_extraction.py
@@ -115,7 +115,7 @@ def img_to_signals_labels(imgs, labels_img, mask_img=None,
         labels_data = labels_data.copy()
         labels_data[np.logical_not(mask_data)] = background_label
 
-    data = _safe_get_data(imgs)
+    data = _safe_get_data(imgs, ensure_finite=True)
     target_datatype = np.float32 if data.dtype == np.float32 else np.float64
     # Nilearn issue: 2135, PR: 2195 for why this is necessary.
     signals = np.ndarray((data.shape[-1], len(labels)), order=order,


Test patch:
diff --git a/nilearn/input_data/tests/test_nifti_labels_masker.py b/nilearn/input_data/tests/test_nifti_labels_masker.py
--- a/nilearn/input_data/tests/test_nifti_labels_masker.py
+++ b/nilearn/input_data/tests/test_nifti_labels_masker.py
@@ -109,6 +109,40 @@ def test_nifti_labels_masker():
 
 
 def test_nifti_labels_masker_with_nans_and_infs():
+    """Apply a NiftiLabelsMasker containing NaNs and infs.
+
+    The masker should replace those NaNs and infs with zeros,
+    while raising a warning.
+    """
+    length = 3
+    n_regions = 9
+    fmri_img, mask_img = generate_random_img((13, 11, 12),
+                                             affine=np.eye(4), length=length)
+    labels_img = data_gen.generate_labeled_regions((13, 11, 12),
+                                                   affine=np.eye(4),
+                                                   n_regions=n_regions)
+    # Introduce nans with data type float
+    # See issue: https://github.com/nilearn/nilearn/issues/2580
+    labels_data = get_data(labels_img).astype(np.float32)
+    labels_data[:, :, 7] = np.nan
+    labels_data[:, :, 4] = np.inf
+    labels_img = nibabel.Nifti1Image(labels_data, np.eye(4))
+
+    masker = NiftiLabelsMasker(labels_img, mask_img=mask_img)
+
+    with pytest.warns(UserWarning, match="Non-finite values detected."):
+        sig = masker.fit_transform(fmri_img)
+
+    assert sig.shape == (length, n_regions)
+    assert np.all(np.isfinite(sig))
+
+
+def test_nifti_labels_masker_with_nans_and_infs_in_mask():
+    """Apply a NiftiLabelsMasker with a mask containing NaNs and infs.
+
+    The masker should replace those NaNs and infs with zeros,
+    while raising a warning.
+    """
     length = 3
     n_regions = 9
     fmri_img, mask_img = generate_random_img((13, 11, 12),
@@ -124,7 +158,41 @@ def test_nifti_labels_masker_with_nans_and_infs():
     mask_img = nibabel.Nifti1Image(mask_data, np.eye(4))
 
     masker = NiftiLabelsMasker(labels_img, mask_img=mask_img)
-    sig = masker.fit_transform(fmri_img)
+
+    with pytest.warns(UserWarning, match="Non-finite values detected."):
+        sig = masker.fit_transform(fmri_img)
+
+    assert sig.shape == (length, n_regions)
+    assert np.all(np.isfinite(sig))
+
+
+def test_nifti_labels_masker_with_nans_and_infs_in_data():
+    """Apply a NiftiLabelsMasker to 4D data containing NaNs and infs.
+
+    The masker should replace those NaNs and infs with zeros,
+    while raising a warning.
+    """
+    length = 3
+    n_regions = 9
+    fmri_img, mask_img = generate_random_img((13, 11, 12),
+                                             affine=np.eye(4), length=length)
+    labels_img = data_gen.generate_labeled_regions((13, 11, 12),
+                                                   affine=np.eye(4),
+                                                   n_regions=n_regions)
+    # Introduce nans with data type float
+    # See issues:
+    # - https://github.com/nilearn/nilearn/issues/2580 (why floats)
+    # - https://github.com/nilearn/nilearn/issues/2711 (why test)
+    fmri_data = get_data(fmri_img).astype(np.float32)
+    fmri_data[:, :, 7, :] = np.nan
+    fmri_data[:, :, 4, 0] = np.inf
+    fmri_img = nibabel.Nifti1Image(fmri_data, np.eye(4))
+
+    masker = NiftiLabelsMasker(labels_img, mask_img=mask_img)
+
+    with pytest.warns(UserWarning, match="Non-finite values detected."):
+        sig = masker.fit_transform(fmri_img)
+
     assert sig.shape == (length, n_regions)
     assert np.all(np.isfinite(sig))
 
diff --git a/nilearn/input_data/tests/test_nifti_maps_masker.py b/nilearn/input_data/tests/test_nifti_maps_masker.py
--- a/nilearn/input_data/tests/test_nifti_maps_masker.py
+++ b/nilearn/input_data/tests/test_nifti_maps_masker.py
@@ -122,7 +122,51 @@ def test_nifti_maps_masker():
                                   affine2)
 
 
-def test_nifti_maps_masker_with_nans():
+def test_nifti_maps_masker_with_nans_and_infs():
+    """Apply a NiftiMapsMasker containing NaNs and infs.
+
+    The masker should replace those NaNs and infs with zeros,
+    without raising a warning.
+    """
+    length = 3
+    n_regions = 8
+    fmri_img, mask_img = generate_random_img((13, 11, 12),
+                                             affine=np.eye(4), length=length)
+    maps_img, maps_mask_img = data_gen.generate_maps((13, 11, 12), n_regions,
+                                                     affine=np.eye(4))
+
+    # Add NaNs and infs to atlas
+    maps_data = get_data(maps_img).astype(np.float32)
+    mask_data = get_data(mask_img).astype(np.float32)
+    maps_data = maps_data * mask_data[..., None]
+
+    # Choose a good voxel from the first label
+    vox_idx = np.where(maps_data[..., 0] > 0)
+    i1, j1, k1 = vox_idx[0][0], vox_idx[1][0], vox_idx[2][0]
+    i2, j2, k2 = vox_idx[0][1], vox_idx[1][1], vox_idx[2][1]
+
+    maps_data[:, :, :, 0] = np.nan
+    maps_data[i2, j2, k2, 0] = np.inf
+    maps_data[i1, j1, k1, 0] = 1
+
+    maps_img = nibabel.Nifti1Image(maps_data, np.eye(4))
+
+    # No warning, because maps_img is run through clean_img
+    # *before* _safe_get_data.
+    masker = NiftiMapsMasker(maps_img, mask_img=mask_img)
+
+    sig = masker.fit_transform(fmri_img)
+
+    assert sig.shape == (length, n_regions)
+    assert np.all(np.isfinite(sig))
+
+
+def test_nifti_maps_masker_with_nans_and_infs_in_mask():
+    """Apply a NiftiMapsMasker with a mask containing NaNs and infs.
+
+    The masker should replace those NaNs and infs with zeros,
+    while raising a warning.
+    """
     length = 3
     n_regions = 8
     fmri_img, mask_img = generate_random_img((13, 11, 12),
@@ -130,21 +174,49 @@ def test_nifti_maps_masker_with_nans():
     maps_img, maps_mask_img = data_gen.generate_maps((13, 11, 12), n_regions,
                                                      affine=np.eye(4))
 
-    # nans
-    maps_data = get_data(maps_img)
-    mask_data = np.array(get_data(mask_img),
-                         dtype=np.float64)
+    # Add NaNs and infs to mask
+    mask_data = np.array(get_data(mask_img), dtype=np.float64)
 
-    maps_data[:, 9, 9] = np.nan
-    maps_data[:, 5, 5] = np.inf
     mask_data[:, :, 7] = np.nan
     mask_data[:, :, 5] = np.inf
 
-    maps_img = nibabel.Nifti1Image(maps_data, np.eye(4))
     mask_img = nibabel.Nifti1Image(mask_data, np.eye(4))
 
     masker = NiftiMapsMasker(maps_img, mask_img=mask_img)
-    sig = masker.fit_transform(fmri_img)
+
+    with pytest.warns(UserWarning, match="Non-finite values detected."):
+        sig = masker.fit_transform(fmri_img)
+
+    assert sig.shape == (length, n_regions)
+    assert np.all(np.isfinite(sig))
+
+
+def test_nifti_maps_masker_with_nans_and_infs_in_data():
+    """Apply a NiftiMapsMasker to 4D data containing NaNs and infs.
+
+    The masker should replace those NaNs and infs with zeros,
+    while raising a warning.
+    """
+    length = 3
+    n_regions = 8
+    fmri_img, mask_img = generate_random_img((13, 11, 12),
+                                             affine=np.eye(4), length=length)
+    maps_img, maps_mask_img = data_gen.generate_maps((13, 11, 12), n_regions,
+                                                     affine=np.eye(4))
+
+    # Add NaNs and infs to data
+    fmri_data = get_data(fmri_img)
+
+    fmri_data[:, 9, 9, :] = np.nan
+    fmri_data[:, 5, 5, :] = np.inf
+
+    fmri_img = nibabel.Nifti1Image(fmri_data, np.eye(4))
+
+    masker = NiftiMapsMasker(maps_img, mask_img=mask_img)
+
+    with pytest.warns(UserWarning, match="Non-finite values detected."):
+        sig = masker.fit_transform(fmri_img)
+
     assert sig.shape == (length, n_regions)
     assert np.all(np.isfinite(sig))
 
diff --git a/nilearn/regions/tests/test_signal_extraction.py b/nilearn/regions/tests/test_signal_extraction.py
--- a/nilearn/regions/tests/test_signal_extraction.py
+++ b/nilearn/regions/tests/test_signal_extraction.py
@@ -336,13 +336,13 @@ def test_signal_extraction_with_maps_and_labels():
         maps_signals, maps_img, mask_img=mask_img)
     assert maps_img_r.shape == shape + (length,)
 
-    # Check that NaNs in regions inside mask are preserved
+    # Check that NaNs in regions inside mask are replaced with zeros
     region1 = labels_data == 2
     indices = [ind[:1] for ind in np.where(region1)]
-    get_data(fmri_img)[indices + [slice(None)]] = float('nan')
+    get_data(fmri_img)[indices + [slice(None)]] = np.nan
     labels_signals, labels_labels = signal_extraction.img_to_signals_labels(
         fmri_img, labels_img, mask_img=mask_img)
-    assert np.all(np.isnan(labels_signals[:, labels_labels.index(2)]))
+    assert np.all(labels_signals[:, labels_labels.index(2)] == 0.)
 
 
 def test_generate_maps():