Repository: nilearn/nilearn

Issue:
[BUG] first_level_from_bids confused by "res" filter when looking for confounds.tsv
### Is there an existing issue for this?

- [X] I have searched the existing issues

### Operating system

- [X] Linux
- [ ] Mac
- [ ] Windows

### Operating system version

CentOS Linux release 7.6.1810

### Python version

- [ ] 3.11
- [X] 3.10
- [ ] 3.9
- [ ] 3.8
- [ ] 3.7

### nilearn version

0.10.0

### Expected behavior

When running the command below one can expect the model_confounds to be found in a bids-valid directory.

(models,
    models_run_imgs,
    models_events,
    models_confounds,) = first_level_from_bids(data_dir, task_label, derivatives_folder=derivatives_folder,space_label=space_label,img_filters=[("desc", "preproc"),("res","2")], smoothing_fwhm=6, slice_time_ref=0.481, n_jobs=-1, signal_scaling=(0,1))

### Current behavior & error messages

This is what I got:
models_confounds were al None.

What I did to fix it was simply add an extra line in /nilearn/glm/first_level/first_level.py for removing "res" from the filters:

    supported_filters.remove("desc")
    **supported_filters.remove("res")**
    
    filters = _make_bids_files_filter(
        task_label=task_label,
        supported_filters=supported_filters,
        extra_filter=img_filters,
        verbose=verbose,
    )
    confounds = get_bids_files(
        derivatives_path,
        modality_folder="func",
        file_tag="desc-confounds*",
        file_type="tsv",
        sub_label=sub_label,
        filters=filters,
    )

### Steps and code to reproduce bug

```python
# Paste your code here


```



Discussion:
that seems like a bug indeed.

in theory fmriprep should give you a single confounds file:

https://github.com/nipreps/fmriprep/blob/39038a7071670d9ac0fc3d28cb7ae084a1138e72/.circleci/ds054_outputs.txt#L71

even if you have several `res` or `space` files

https://github.com/nipreps/fmriprep/blob/39038a7071670d9ac0fc3d28cb7ae084a1138e72/.circleci/ds054_outputs.txt#L73
https://github.com/nipreps/fmriprep/blob/39038a7071670d9ac0fc3d28cb7ae084a1138e72/.circleci/ds054_outputs.txt#LL82C5-L82C5
https://github.com/nipreps/fmriprep/blob/39038a7071670d9ac0fc3d28cb7ae084a1138e72/.circleci/ds054_outputs.txt#LL87C3-L87C3

I suspect this should also apply to the `den` entity that is mostly for surface outputs.
So maybe easier to not allow any of the derivatives entities and only add desc.
Yes you're right!

On a related note: getting a warning for runs with missing confounds file would be cool too! I think there is something for events, but I didn't get anything for missing confounds
There is a bit of sanity check around this: 
https://github.com/nilearn/nilearn/blob/6085633bc3ffed18feea47ff2e39c9f5fbebcfd5/nilearn/glm/first_level/first_level.py#L1366

But if no confounds is found then it does indeed not not do anything.

We could add a warning when no confound is found.
Minimal code to reproduce

```python
from pathlib import Path

from nilearn._utils.data_gen import (
    create_fake_bids_dataset,
)
from nilearn.glm.first_level import (
    first_level_from_bids,
)

n_sub = 1
n_ses = 1
tasks = ["main"]
n_runs = [1]

bids_path = create_fake_bids_dataset(
    base_dir=Path().cwd(),
    n_sub=n_sub,
    n_ses=n_ses,
    tasks=tasks,
    n_runs=n_runs,
    entities={"res": ["A", "B"]},
)

models, m_imgs, m_events, m_confounds = first_level_from_bids(
    dataset_path=bids_path,
    task_label="main",
    space_label="MNI",
    img_filters=[("desc", "preproc"), ("res", "A")],
    slice_time_ref=None,
)

print(models)
print(m_confounds)
```
turns out I had added tests for this in the test suite but it was just checking that 

```
    assert len(models) == len(m_confounds)
```

and not checking the actual content of m_confounds...

will make the test more stringent

Resolving patch:
diff --git a/nilearn/_utils/data_gen.py b/nilearn/_utils/data_gen.py
--- a/nilearn/_utils/data_gen.py
+++ b/nilearn/_utils/data_gen.py
@@ -1399,6 +1399,11 @@ def _write_bids_derivative_func(
 
     shape = [n_voxels, n_voxels, n_voxels, n_time_points]
 
+    entities_to_include = [
+        *_bids_entities()["raw"],
+        *_bids_entities()["derivatives"]
+    ]
+
     for space in ("MNI", "T1w"):
         for desc in ("preproc", "fmriprep"):
             # Only space 'T1w' include both descriptions.
@@ -1408,10 +1413,6 @@ def _write_bids_derivative_func(
             fields["entities"]["space"] = space
             fields["entities"]["desc"] = desc
 
-            entities_to_include = [
-                *_bids_entities()["raw"], *_bids_entities()["derivatives"]
-            ]
-
             bold_path = func_path / _create_bids_filename(
                 fields=fields, entities_to_include=entities_to_include
             )
diff --git a/nilearn/glm/first_level/first_level.py b/nilearn/glm/first_level/first_level.py
--- a/nilearn/glm/first_level/first_level.py
+++ b/nilearn/glm/first_level/first_level.py
@@ -1334,16 +1334,9 @@ def _get_confounds(
         List of fullpath to the confounds.tsv files
 
     """
-    supported_filters = (
-        _bids_entities()["raw"]
-        + _bids_entities()["derivatives"]
-    )
-    # confounds use a desc-confounds,
-    # so we must remove desc if it was passed as a filter
-    supported_filters.remove("desc")
     filters = _make_bids_files_filter(
         task_label=task_label,
-        supported_filters=supported_filters,
+        supported_filters=_bids_entities()["raw"],
         extra_filter=img_filters,
         verbose=verbose,
     )


Test patch:
diff --git a/nilearn/glm/tests/test_first_level.py b/nilearn/glm/tests/test_first_level.py
--- a/nilearn/glm/tests/test_first_level.py
+++ b/nilearn/glm/tests/test_first_level.py
@@ -1112,10 +1112,11 @@ def test_first_level_from_bids(tmp_path,
         slice_time_ref=None,
     )
 
-    assert len(models) == n_sub
-    assert len(models) == len(m_imgs)
-    assert len(models) == len(m_events)
-    assert len(models) == len(m_confounds)
+    _check_output_first_level_from_bids(n_sub,
+                                        models,
+                                        m_imgs,
+                                        m_events,
+                                        m_confounds)
 
     n_imgs_expected = n_ses * n_runs[task_index]
 
@@ -1144,10 +1145,11 @@ def test_first_level_from_bids_select_one_run_per_session(bids_dataset):
         slice_time_ref=None,
     )
 
-    assert len(models) == n_sub
-    assert len(models) == len(m_imgs)
-    assert len(models) == len(m_events)
-    assert len(models) == len(m_confounds)
+    _check_output_first_level_from_bids(n_sub,
+                                        models,
+                                        m_imgs,
+                                        m_events,
+                                        m_confounds)
 
     n_imgs_expected = n_ses
     assert len(m_imgs[0]) == n_imgs_expected
@@ -1165,10 +1167,11 @@ def test_first_level_from_bids_select_all_runs_of_one_session(bids_dataset):
         slice_time_ref=None,
     )
 
-    assert len(models) == n_sub
-    assert len(models) == len(m_imgs)
-    assert len(models) == len(m_events)
-    assert len(models) == len(m_confounds)
+    _check_output_first_level_from_bids(n_sub,
+                                        models,
+                                        m_imgs,
+                                        m_events,
+                                        m_confounds)
 
     n_imgs_expected = n_runs[0]
     assert len(m_imgs[0]) == n_imgs_expected
@@ -1217,13 +1220,36 @@ def test_first_level_from_bids_several_labels_per_entity(tmp_path, entity):
         img_filters=[("desc", "preproc"), (entity, "A")],
         slice_time_ref=None,
     )
+
+    _check_output_first_level_from_bids(n_sub,
+                                        models,
+                                        m_imgs,
+                                        m_events,
+                                        m_confounds)
+    n_imgs_expected = n_ses * n_runs[0]
+    assert len(m_imgs[0]) == n_imgs_expected
+
+
+def _check_output_first_level_from_bids(n_sub,
+                                        models,
+                                        m_imgs,
+                                        m_events,
+                                        m_confounds):
     assert len(models) == n_sub
+    assert all(isinstance(model, FirstLevelModel) for model in models)
     assert len(models) == len(m_imgs)
+    for imgs in m_imgs:
+        assert isinstance(imgs, list)
+        assert all(Path(img_).exists() for img_ in imgs)
     assert len(models) == len(m_events)
+    for events in m_events:
+        assert isinstance(events, list)
+        assert all(isinstance(event_, pd.DataFrame) for event_ in events)
     assert len(models) == len(m_confounds)
-
-    n_imgs_expected = n_ses * n_runs[0]
-    assert len(m_imgs[0]) == n_imgs_expected
+    for confounds in m_confounds:
+        assert isinstance(confounds, list)
+        assert all(isinstance(confound_, pd.DataFrame)
+                   for confound_ in confounds)
 
 
 def test_first_level_from_bids_with_subject_labels(bids_dataset):
diff --git a/nilearn/plotting/tests/test_matrix_plotting.py b/nilearn/plotting/tests/test_matrix_plotting.py
--- a/nilearn/plotting/tests/test_matrix_plotting.py
+++ b/nilearn/plotting/tests/test_matrix_plotting.py
@@ -96,7 +96,7 @@ def test_sanitize_reorder_error(reorder):
         else:
             param_to_print.append(str(item))
     with pytest.raises(ValueError,
-                       match=("Parameter reorder needs to be one of:\n"
+                       match=("Parameter reorder needs to be one of:\\n"
                               f"{', '.join(param_to_print)}.")):
         _sanitize_reorder(reorder)