Repository: nilearn/nilearn

Issue:
[BUG]  Can't use MultiNiftiMapsMasker with a list of sample_mask
### Is there an existing issue for this?

- [X] I have searched the existing issues

### Operating system

- [X] Linux
- [ ] Mac
- [ ] Windows

### Operating system version

Linux Ubuntu 20.04

### Python version

- [ ] 3.11
- [ ] 3.10
- [ ] 3.9
- [X] 3.8
- [ ] 3.7

### nilearn version

0.10.1

### Expected behavior

I am using `MultiNiftiMapsMasker` with multiple sample_mask (obtained from `load_confounds` with multiple input files) and I believe it should "distribute" the `sample_mask` to the appropriate parallel call of `transform_single_imgs`. However, it appears to only iterate over `niimg_iter` and `confounds`.

### Current behavior & error messages

This is what I got:
The parallel calls of `transform_single_imgs` always get the same `sample_mask` which is the same as the input of the `fit_transform` call (a list in this case) and raise an error. I believe (and confirm that) an iteration over the `sample_mask` (similar to the iteration over `niimg_iter` and `confounds`, [here](https://github.com/nilearn/nilearn/blob/0.10.1/nilearn/maskers/multi_nifti_maps_masker.py#L183C2-L186)) can fix this.


```bash
Traceback (most recent call last):
  File "nilearn_error.py", line 28, in <module>
    )
  File "/home/acionca/.local/lib/python3.8/site-packages/sklearn/utils/_set_output.py", line 140, in wrapped
    data_to_wrap = f(self, X, *args, **kwargs)
  File "/home/acionca/.local/lib/python3.8/site-packages/nilearn/maskers/nifti_maps_masker.py", line 421, in fit_transform
    return self.fit().transform(imgs, confounds=confounds,
  File "/home/acionca/.local/lib/python3.8/site-packages/sklearn/utils/_set_output.py", line 140, in wrapped
    data_to_wrap = f(self, X, *args, **kwargs)
  File "/home/acionca/.local/lib/python3.8/site-packages/nilearn/maskers/multi_nifti_maps_masker.py", line 209, in transform
    return self.transform_imgs(imgs, confounds, n_jobs=self.n_jobs,
  File "/home/acionca/.local/lib/python3.8/site-packages/nilearn/maskers/multi_nifti_maps_masker.py", line 183, in transform_imgs
    region_signals = Parallel(n_jobs=n_jobs)(
  File "/home/acionca/.local/lib/python3.8/site-packages/joblib/parallel.py", line 1952, in __call__
    return output if self.return_generator else list(output)
  File "/home/acionca/.local/lib/python3.8/site-packages/joblib/parallel.py", line 1595, in _get_outputs
    yield from self._retrieve()
  File "/home/acionca/.local/lib/python3.8/site-packages/joblib/parallel.py", line 1699, in _retrieve
    self._raise_error_fast()
  File "/home/acionca/.local/lib/python3.8/site-packages/joblib/parallel.py", line 1734, in _raise_error_fast
    error_job.get_result(self.timeout)
  File "/home/acionca/.local/lib/python3.8/site-packages/joblib/parallel.py", line 736, in get_result
    return self._return_or_raise()
  File "/home/acionca/.local/lib/python3.8/site-packages/joblib/parallel.py", line 754, in _return_or_raise
    raise self._result
ValueError: Number of sample_mask (2) not matching number of runs (1).
```


### Steps and code to reproduce bug

```python
from nilearn.datasets import fetch_atlas_difumo, fetch_development_fmri
from nilearn.interfaces.fmriprep import load_confounds
from nilearn.maskers import MultiNiftiMapsMasker

data = fetch_development_fmri(n_subjects=2, reduce_confounds=True)

fmri_filenames = data.func

atlas = fetch_atlas_difumo()
atlas_file = atlas.maps

confounds, sample_mask = load_confounds(
    fmri_filenames,
    strategy=["motion", "scrub"],
    motion="basic",
    scrub=5,
    fd_threshold=0.3,
)

masker = MultiNiftiMapsMasker(
    maps_img=atlas_file,
    standardize="zscore_sample",
    n_jobs=2,
)

time_series = masker.fit_transform(
    fmri_filenames, confounds=confounds, sample_mask=sample_mask
)
```



Discussion:
Indeed. Thx for reporting !
Do you think you can contributed a fix ?
Best,
Sure - are there any particular things to consider other than opening a PR with a fix ?
Thx ! An additional thing to do is to add a non-regression test that fails with the current code and passes after the fix.
You can also take a look at  https://nilearn.github.io/stable/development.html

Resolving patch:
diff --git a/nilearn/maskers/multi_nifti_labels_masker.py b/nilearn/maskers/multi_nifti_labels_masker.py
--- a/nilearn/maskers/multi_nifti_labels_masker.py
+++ b/nilearn/maskers/multi_nifti_labels_masker.py
@@ -188,11 +188,14 @@ def transform_imgs(
         if confounds is None:
             confounds = itertools.repeat(None, len(imgs_list))
 
+        if sample_mask is None:
+            sample_mask = itertools.repeat(None, len(imgs_list))
+
         func = self._cache(self.transform_single_imgs)
 
         region_signals = Parallel(n_jobs=n_jobs)(
-            delayed(func)(imgs=imgs, confounds=cfs, sample_mask=sample_mask)
-            for imgs, cfs in zip(niimg_iter, confounds)
+            delayed(func)(imgs=imgs, confounds=cfs, sample_mask=sms)
+            for imgs, cfs, sms in zip(niimg_iter, confounds, sample_mask)
         )
         return region_signals
 
diff --git a/nilearn/maskers/multi_nifti_maps_masker.py b/nilearn/maskers/multi_nifti_maps_masker.py
--- a/nilearn/maskers/multi_nifti_maps_masker.py
+++ b/nilearn/maskers/multi_nifti_maps_masker.py
@@ -182,11 +182,14 @@ def transform_imgs(
         if confounds is None:
             confounds = itertools.repeat(None, len(imgs_list))
 
+        if sample_mask is None:
+            sample_mask = itertools.repeat(None, len(imgs_list))
+
         func = self._cache(self.transform_single_imgs)
 
         region_signals = Parallel(n_jobs=n_jobs)(
-            delayed(func)(imgs=imgs, confounds=cfs, sample_mask=sample_mask)
-            for imgs, cfs in zip(niimg_iter, confounds)
+            delayed(func)(imgs=imgs, confounds=cfs, sample_mask=sms)
+            for imgs, cfs, sms in zip(niimg_iter, confounds, sample_mask)
         )
         return region_signals
 


Test patch:
diff --git a/nilearn/maskers/tests/test_multi_nifti_labels_masker.py b/nilearn/maskers/tests/test_multi_nifti_labels_masker.py
--- a/nilearn/maskers/tests/test_multi_nifti_labels_masker.py
+++ b/nilearn/maskers/tests/test_multi_nifti_labels_masker.py
@@ -353,3 +353,38 @@ def test_multi_nifti_labels_masker_resampling():
         np.testing.assert_array_equal(
             get_data(compressed_img), get_data(compressed_img2)
         )
+
+
+def test_multi_nifti_labels_masker_list_of_sample_mask():
+    """Tests MultiNiftiLabelsMasker.fit_transform with a list of "sample_mask".
+
+    "sample_mask" was directly sent as input to the parallel calls of
+    "transform_single_imgs" instead of sending iterations.
+    See https://github.com/nilearn/nilearn/issues/3967 for more details.
+    """
+    shape1 = (13, 11, 12)
+    affine1 = np.eye(4)
+
+    n_regions = 9
+    length = 6
+    n_scrub1 = 3
+    n_scrub2 = 2
+
+    fmri11_img, mask11_img = data_gen.generate_fake_fmri(
+        shape1, affine=affine1, length=length
+    )
+
+    labels11_img = data_gen.generate_labeled_regions(
+        shape1, affine=affine1, n_regions=n_regions
+    )
+    sample_mask1 = np.arange(length - n_scrub1)
+    sample_mask2 = np.arange(length - n_scrub2)
+
+    masker = MultiNiftiLabelsMasker(labels11_img)
+    ts_list = masker.fit_transform(
+        [fmri11_img, fmri11_img], sample_mask=[sample_mask1, sample_mask2]
+    )
+
+    assert len(ts_list) == 2
+    for ts, n_scrub in zip(ts_list, [n_scrub1, n_scrub2]):
+        assert ts.shape == (length - n_scrub, n_regions)
diff --git a/nilearn/maskers/tests/test_multi_nifti_maps_masker.py b/nilearn/maskers/tests/test_multi_nifti_maps_masker.py
--- a/nilearn/maskers/tests/test_multi_nifti_maps_masker.py
+++ b/nilearn/maskers/tests/test_multi_nifti_maps_masker.py
@@ -271,3 +271,36 @@ def test_multi_nifti_maps_masker_resampling():
             fmri11_img_r.affine, masker.maps_img_.affine
         )
         assert fmri11_img_r.shape == (masker.maps_img_.shape[:3] + (length,))
+
+
+def test_multi_nifti_maps_masker_list_of_sample_mask():
+    """Tests MultiNiftiMapsMasker.fit_transform with a list of "sample_mask".
+
+    "sample_mask" was directly sent as input to the parallel calls of
+    "transform_single_imgs" instead of sending iterations.
+    See https://github.com/nilearn/nilearn/issues/3967 for more details.
+    """
+    shape1 = (13, 11, 12)
+    affine1 = np.eye(4)
+
+    n_regions = 9
+    length = 6
+    n_scrub1 = 3
+    n_scrub2 = 2
+
+    fmri11_img, mask11_img = data_gen.generate_fake_fmri(
+        shape1, affine=affine1, length=length
+    )
+
+    maps11_img, _ = data_gen.generate_maps(shape1, n_regions, affine=affine1)
+    sample_mask1 = np.arange(length - n_scrub1)
+    sample_mask2 = np.arange(length - n_scrub2)
+
+    masker = MultiNiftiMapsMasker(maps11_img)
+    ts_list = masker.fit_transform(
+        [fmri11_img, fmri11_img], sample_mask=[sample_mask1, sample_mask2]
+    )
+
+    assert len(ts_list) == 2
+    for ts, n_scrub in zip(ts_list, [n_scrub1, n_scrub2]):
+        assert ts.shape == (length - n_scrub, n_regions)