Repository: nilearn/nilearn

Issue:
Pass binary mask to `sample_masks` instead of indices
> Just wondering: wouldn't it be more handy to give  a binary vector with length `n_scans` for  `sample_mask` ?

_Originally posted by @bthirion in https://github.com/nilearn/nilearn/pull/3193#discussion_r838897961_

I did some archaeology and found out why I implemented index, rather than binary vector. I think it was for staying consistent to the OG `sample_mask`:

>I had a look at the relevant code of `sample_mask` application for resolving #2777. Currently, the timeseries censoring is done in the `base_masker` module:
https://github.com/nilearn/nilearn/blob/4cdd8fe8aad085d21e5d8367ad5b94d8efbe5151/nilearn/input_data/base_masker.py#L71-L73

_Originally posted by @htwangtw in https://github.com/nilearn/nilearn/issues/1012#issuecomment-847064092_

And the old doc of `sample_mask`:
https://github.com/nilearn/nilearn/blob/4cdd8fe8aad085d21e5d8367ad5b94d8efbe5151/nilearn/input_data/nifti_masker.py#L172-L177

Here's all the back ground info I can find. We can change it to binary vector as we no longer use the `index_img` function, and is using `signal.clean` to handle things. Let me know how you think we should move forward, given these info. 

cc @pbellec as I know you raised the same question before.


Discussion:
My concern here is with naming. 
I think indices or binary masks are both fine.
The name `sample_mask` does imply the later though, and I find it counter intuitive it stores indices. 

Accepting "Any type compatible with numpy-array indexing" would be ideal IMHO. 
There's no emergency, it is easy to switch from one representation to the other.
Hi @htwangtw is this still feasible for you to do this week? If not just let me know, thanks!
Yes! should be ready to review by next week :) 

Resolving patch:
diff --git a/nilearn/_utils/glm.py b/nilearn/_utils/glm.py
--- a/nilearn/_utils/glm.py
+++ b/nilearn/_utils/glm.py
@@ -341,11 +341,24 @@ def _check_run_sample_masks(n_runs, sample_masks):
         raise TypeError(
             f"sample_mask has an unhandled type: {sample_masks.__class__}"
         )
-    if not isinstance(sample_masks, (list, tuple)):
+
+    if isinstance(sample_masks, np.ndarray):
         sample_masks = (sample_masks, )
-    if len(sample_masks) != n_runs:
+
+    checked_sample_masks = [_convert_bool2index(sm) for sm in sample_masks]
+
+    if len(checked_sample_masks) != n_runs:
         raise ValueError(
-            f"Number of sample_mask ({len(sample_masks)}) not matching "
-            f"number of runs ({n_runs})."
+            f"Number of sample_mask ({len(checked_sample_masks)}) not "
+            f"matching number of runs ({n_runs})."
         )
-    return sample_masks
+    return checked_sample_masks
+
+
+def _convert_bool2index(sample_mask):
+    """Convert boolean to index. """
+    check_boolean = [type(i) is bool or type(i) is np.bool_
+                     for i in sample_mask]
+    if all(check_boolean):
+        sample_mask = np.where(sample_mask)[0]
+    return sample_mask
diff --git a/nilearn/signal.py b/nilearn/signal.py
--- a/nilearn/signal.py
+++ b/nilearn/signal.py
@@ -528,11 +528,14 @@ def clean(signals, runs=None, detrend=True, standardize='zscore',
         signal, as if all were in the same array.
         Default is None.
 
-    sample_mask : None, :class:`numpy.ndarray`, :obj:`list`,\
-    :obj:`tuple`, or :obj:`list` of
-        shape: (number of scans - number of volumes removed, )
+    sample_mask : None, Any type compatible with numpy-array indexing, \
+        or :obj:`list` of
+        shape: (number of scans - number of volumes removed, ) for explicit \
+            index, or (number of scans, ) for binary mask
         Masks the niimgs along time/fourth dimension to perform scrubbing
         (remove volumes with high motion) and/or non-steady-state volumes.
+        When passing binary mask with boolean values, ``True`` refers to
+        volumes kept, and ``False`` for volumes removed.
         This masking step is applied before signal cleaning. When supplying run
         information, sample_mask must be a list containing sets of indexes for
         each run.


Test patch:
diff --git a/nilearn/tests/test_signal.py b/nilearn/tests/test_signal.py
--- a/nilearn/tests/test_signal.py
+++ b/nilearn/tests/test_signal.py
@@ -785,13 +785,20 @@ def test_sample_mask():
     sample_mask = np.arange(signals.shape[0])
     scrub_index = [2, 3, 6, 7, 8, 30, 31, 32]
     sample_mask = np.delete(sample_mask, scrub_index)
+    sample_mask_binary = np.full(signals.shape[0], True)
+    sample_mask_binary[scrub_index] = False
 
     scrub_clean = clean(signals, confounds=confounds, sample_mask=sample_mask)
     assert scrub_clean.shape[0] == sample_mask.shape[0]
 
+    # test the binary mask
+    scrub_clean_bin = clean(signals, confounds=confounds,
+                            sample_mask=sample_mask_binary)
+    np.testing.assert_equal(scrub_clean_bin, scrub_clean)
+
     # list of sample_mask for each run
     runs = np.ones(signals.shape[0])
-    runs[0:signals.shape[0] // 2] = 0
+    runs[:signals.shape[0] // 2] = 0
     sample_mask_sep = [np.arange(20), np.arange(20)]
     scrub_index = [[6, 7, 8], [10, 11, 12]]
     sample_mask_sep = [np.delete(sm, si)
@@ -800,6 +807,15 @@ def test_sample_mask():
                            sample_mask=sample_mask_sep, runs=runs)
     assert scrub_sep_mask.shape[0] == signals.shape[0] - 6
 
+    # test for binary mask per run
+    sample_mask_sep_binary = [np.full(signals.shape[0] // 2, True),
+                              np.full(signals.shape[0] // 2, True)]
+    sample_mask_sep_binary[0][scrub_index[0]] = False
+    sample_mask_sep_binary[1][scrub_index[1]] = False
+    scrub_sep_mask = clean(signals, confounds=confounds,
+                           sample_mask=sample_mask_sep_binary, runs=runs)
+    assert scrub_sep_mask.shape[0] == signals.shape[0] - 6
+
     # 1D sample mask with runs labels
     with pytest.raises(ValueError,
                        match=r'Number of sample_mask \(\d\) not matching'):