Repository: nilearn/nilearn

Issue:
[ENH] integrate `load_confounds` into `first_level_from_bids`
### Is there an existing issue for this?

- [X] I have searched the existing issues

### Describe your proposed enhancement in detail.

- Relates to https://github.com/nilearn/nilearn/issues/2425

Currently one can point `first_level_from_bids` to a bids dataset and get
- model object
- images paths
- events files content
- confounds files content

The whole of the confounds are loaded, it is left to the user to confound wrangling before they can fit their model.

Alternatively they can use the load_confounds function and pass it images paths to load the confounds with one of the strategy (scrubbing, motion...) it accepts.

It may be easier to pass directly some extra argument to `first_level_from_bids` and have it take care of call load_confounds returning only the subset of confounds requested.

### Benefits to the change

make it simpler to load a given set of confounds from a BIDS dataset when running a GLM

### Pseudocode for the new behavior, if applicable

currently

```python
from nilearn.glm.first_level import first_level_from_bids
from nilearn.interfaces.fmriprep import load_confounds

task_label = "stopsignal"
space_label = "MNI152NLin2009cAsym"
data_dir = "/home/bob/data/stop_signal_fmri/data"
derivatives_folder = "derivatives/fmriprep"

(
    models,
    models_run_imgs,
    models_events,
    _,
) = first_level_from_bids(
    data_dir,
    task_label,
    space_label,
    smoothing_fwhm=5.0,
    derivatives_folder=derivatives_folder,
)

# the following would actually have to be done for every subject
models_confounds = load_confounds(
    models_run_imgs[0],
    strategy=("motion", "wm_csf"),
    motion="full",
    scrub=1,
    fd_threshold=0.2,
    wm_csf="basic")
```

what I suggest

```python
from nilearn.glm.first_level import first_level_from_bids

task_label = "stopsignal"
space_label = "MNI152NLin2009cAsym"
data_dir = "/home/bob/data/stop_signal_fmri/data"
derivatives_folder = "derivatives/fmriprep"

(
    models,
    models_run_imgs,
    models_events,
    models_confounds,
) = first_level_from_bids(
    data_dir,
    task_label,
    space_label,
    smoothing_fwhm=5.0,
    derivatives_folder=derivatives_folder,
    strategy=("motion", "wm_csf"),
    motion="full",
    scrub=1,
    fd_threshold=0.2,
    wm_csf="basic")
```


Discussion:
@htwangtw any thought?
This looks great. How much work does it represent ?
I would just be careful that the number of parameters of `first_level_from_bids` don't explode. But if the benefits outweigh this one downside I see then seems like a good idea.
Was thinking of using **kwargs.
Any policy against that? 
I second @ymzayek's concern and would be in favour of using `**kwargs`
> How much work does it represent ?

Given how modular the code is, I would expect it would be relatively easy. #FamousLastWords 
I would be cautious not to hide the API behind kwargs. How shall a user know what kwargs are allowed ?
I would explicitly mention it in the doc.
If we do not do that we are going to duplicate both arguments and doc strings in 2 places.
Can we consider to adopt the way `clean_img` handles keyword arguments?
https://nilearn.github.io/stable/modules/generated/nilearn.image.clean_img.html

Resolving patch:
diff --git a/nilearn/_utils/bids.py b/nilearn/_utils/bids.py
new file mode 100644
--- /dev/null
+++ b/nilearn/_utils/bids.py
@@ -0,0 +1,39 @@
+from nilearn.interfaces.bids._utils import _bids_entities
+
+
+def create_bids_filename(fields, entities_to_include=None):
+    """Create BIDS filename from dictionary of entity-label pairs.
+
+    Parameters
+    ----------
+    fields : :obj:`dict` of :obj:`str`
+        Dictionary of entity-label pairs, for example:
+
+        {
+         "suffix": "T1w",
+         "extension": "nii.gz",
+         "entities": {"acq":  "ap",
+                      "desc": "preproc"}
+        }.
+
+    Returns
+    -------
+    BIDS filename : :obj:`str`
+
+    """
+    if entities_to_include is None:
+        entities_to_include = _bids_entities()["raw"]
+
+    filename = ""
+
+    for key in entities_to_include:
+        if key in fields["entities"]:
+            value = fields["entities"][key]
+            if value not in (None, ""):
+                filename += f"{key}-{value}_"
+    if "suffix" in fields:
+        filename += f"{fields['suffix']}"
+    if "extension" in fields:
+        filename += f".{fields['extension']}"
+
+    return filename
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
@@ -15,7 +15,9 @@
 
 from nilearn import datasets, image, maskers, masking
 from nilearn._utils import as_ndarray, logger
+from nilearn._utils.bids import create_bids_filename
 from nilearn.interfaces.bids._utils import _bids_entities, _check_bids_label
+from nilearn.interfaces.fmriprep.tests._testing import get_legal_confound
 
 
 def generate_mni_space_img(n_scans=1, res=30, random_state=0, mask_dilation=2):
@@ -736,9 +738,15 @@ def _basic_confounds(length, random_state=0):
 
     """
     rand_gen = np.random.default_rng(random_state)
-    columns = ['csf', 'white_matter', 'global_signal',
-               'rot_x', 'rot_y', 'rot_z',
-               'trans_x', 'trans_y', 'trans_z']
+    columns = ['csf',
+               'white_matter',
+               'global_signal',
+               'rot_x',
+               'rot_y',
+               'rot_z',
+               'trans_x',
+               'trans_y',
+               'trans_z']
     data = rand_gen.random((length, len(columns)))
     confounds = pd.DataFrame(data, columns=columns)
     return confounds
@@ -1200,46 +1208,6 @@ def _listify(n):
     return [""] if n <= 0 else [f"{label:02}" for label in range(1, n + 1)]
 
 
-def _create_bids_filename(
-    fields, entities_to_include=None
-):
-    """Create BIDS filename from dictionary of entity-label pairs.
-
-    Parameters
-    ----------
-    fields : :obj:`dict` of :obj:`str`
-        Dictionary of entity-label pairs, for example:
-
-        {
-         "suffix": "T1w",
-         "extension": "nii.gz",
-         "entities": {"acq":  "ap",
-                      "desc": "preproc"}
-        }.
-
-    Returns
-    -------
-    BIDS filename : :obj:`str`
-
-    """
-    if entities_to_include is None:
-        entities_to_include = _bids_entities()["raw"]
-
-    filename = ""
-
-    for key in entities_to_include:
-        if key in fields["entities"]:
-            value = fields["entities"][key]
-            if value not in (None, ""):
-                filename += f"{key}-{value}_"
-    if "suffix" in fields:
-        filename += f"{fields['suffix']}"
-    if "extension" in fields:
-        filename += f".{fields['extension']}"
-
-    return filename
-
-
 def _init_fields(subject,
                  session,
                  task,
@@ -1267,7 +1235,7 @@ def _init_fields(subject,
 
     See Also
     --------
-    _create_bids_filename
+    create_bids_filename
 
     """
     fields = {
@@ -1304,7 +1272,7 @@ def _write_bids_raw_anat(subses_dir, subject, session) -> None:
         "extension": "nii.gz",
         "entities": {"sub": subject, "ses": session},
     }
-    (anat_path / _create_bids_filename(fields)).write_text("")
+    (anat_path / create_bids_filename(fields)).write_text("")
 
 
 def _write_bids_raw_func(
@@ -1331,8 +1299,9 @@ def _write_bids_raw_func(
         Random number generator.
 
     """
-    n_time_points = 100
-    bold_path = func_path / _create_bids_filename(fields)
+    n_time_points = 30
+    bold_path = func_path / create_bids_filename(fields)
+
     write_fake_bold_img(
         bold_path,
         [n_voxels, n_voxels, n_voxels, n_time_points],
@@ -1341,12 +1310,12 @@ def _write_bids_raw_func(
 
     repetition_time = 1.5
     fields["extension"] = "json"
-    param_path = func_path / _create_bids_filename(fields)
+    param_path = func_path / create_bids_filename(fields)
     param_path.write_text(json.dumps({"RepetitionTime": repetition_time}))
 
     fields["suffix"] = "events"
     fields["extension"] = "tsv"
-    events_path = func_path / _create_bids_filename(fields)
+    events_path = func_path / create_bids_filename(fields)
     basic_paradigm().to_csv(events_path, sep="\t", index=None)
 
 
@@ -1387,17 +1356,20 @@ def _write_bids_derivative_func(
         or "desc-confounds_regressors".
 
     """
-    n_time_points = 100
+    n_time_points = 30
 
     if confounds_tag is not None:
         fields["suffix"] = confounds_tag
         fields["extension"] = "tsv"
-        confounds_path = func_path / _create_bids_filename(
+        confounds_path = func_path / create_bids_filename(
             fields=fields, entities_to_include=_bids_entities()["raw"]
         )
-        _basic_confounds(length=n_time_points, random_state=rand_gen).to_csv(
-            confounds_path, sep="\t", index=None
+        confounds, metadata = get_legal_confound()
+        confounds.to_csv(
+            confounds_path, sep="\t", index=None, encoding="utf-8"
         )
+        with open(confounds_path.with_suffix(".json"), "w") as f:
+            json.dump(metadata, f)
 
     fields["suffix"] = "bold"
     fields["extension"] = "nii.gz"
@@ -1418,7 +1390,7 @@ def _write_bids_derivative_func(
             fields["entities"]["space"] = space
             fields["entities"]["desc"] = desc
 
-            bold_path = func_path / _create_bids_filename(
+            bold_path = func_path / create_bids_filename(
                 fields=fields, entities_to_include=entities_to_include
             )
             write_fake_bold_img(bold_path, shape=shape, random_state=rand_gen)
@@ -1428,7 +1400,7 @@ def _write_bids_derivative_func(
     fields["entities"].pop("desc")
     for hemi in ["L", "R"]:
         fields["entities"]["hemi"] = hemi
-        gifti_path = func_path / _create_bids_filename(
+        gifti_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
@@ -48,6 +48,7 @@
     _infer_repetition_time_from_dataset,
     _infer_slice_timing_start_time_from_dataset,
 )
+from nilearn.interfaces.fmriprep.load_confounds import load_confounds
 
 
 def mean_scaling(Y, axis=0):
@@ -957,6 +958,7 @@ def first_level_from_bids(
     n_jobs=1,
     minimize_memory=True,
     derivatives_folder="derivatives",
+    **kwargs,
 ):
     """Create FirstLevelModel objects and fit arguments \
        from a :term:`BIDS` dataset.
@@ -1015,6 +1017,86 @@ def first_level_from_bids(
     The subject label of the model will be determined directly
     from the :term:`BIDS` dataset.
 
+    kwargs: :obj:`dict`
+
+        .. versionadded:: 0.11.0
+
+    Keyword arguments to be passed to functions called within this function.
+
+    Kwargs prefixed with ``confound_``
+    will be passed to :func:`~nilearn.interfaces.fmriprep.load_confounds`.
+    This allows ``first_level_from_bids`` to return
+    a specific set of confounds by relying on confound loading strategies
+    defined in :func:`~nilearn.interfaces.fmriprep.load_confounds`.
+    If no kwargs are passed, ``first_level_from_bids`` will return
+    all the confounds available in the confounds TSV files.
+
+    Examples
+    --------
+    If you want to only load
+    the rotation and translation motion parameters confounds:
+
+    .. code-block:: python
+
+        models, imgs, events, confounds = first_level_from_bids(
+            dataset_path=path_to_a_bids_dataset,
+            task_label="TaskName",
+            space_label="MNI",
+            img_filters=[("desc", "preproc")],
+            confounds_strategy=("motion"),
+            confounds_motion="basic",
+        )
+
+    If you want to load the motion parameters confounds
+    with their derivatives:
+
+    .. code-block:: python
+
+        models, imgs, events, confounds = first_level_from_bids(
+            dataset_path=path_to_a_bids_dataset,
+            task_label="TaskName",
+            space_label="MNI",
+            img_filters=[("desc", "preproc")],
+            confounds_strategy=("motion"),
+            confounds_motion="derivatives",
+        )
+
+    If you additionally want to load
+    the confounds with CSF and white matter signal:
+
+    .. code-block:: python
+
+        models, imgs, events, confounds = first_level_from_bids(
+            dataset_path=path_to_a_bids_dataset,
+            task_label="TaskName",
+            space_label="MNI",
+            img_filters=[("desc", "preproc")],
+            confounds_strategy=("motion", "wm_csf"),
+            confounds_motion="derivatives",
+            confounds_wm_csf="basic",
+        )
+
+    If you also want to scrub high-motion timepoints:
+
+    .. code-block:: python
+
+        models, imgs, events, confounds = first_level_from_bids(
+            dataset_path=path_to_a_bids_dataset,
+            task_label="TaskName",
+            space_label="MNI",
+            img_filters=[("desc", "preproc")],
+            confounds_strategy=("motion", "wm_csf", "scrub"),
+            confounds_motion="derivatives",
+            confounds_wm_csf="basic",
+            confounds_scrub=1,
+            confounds_fd_threshold=0.2,
+            confounds_std_dvars_threshold=0,
+        )
+
+    Please refer to the documentation
+    of :func:`~nilearn.interfaces.fmriprep.load_confounds`
+    for more details on the confounds loading strategies.
+
     Returns
     -------
     models : list of `FirstLevelModel` objects
@@ -1050,6 +1132,23 @@ def first_level_from_bids(
         derivatives_folder=derivatives_folder,
     )
 
+    kwargs_load_confounds = _check_kwargs_load_confounds(**kwargs)
+
+    if drift_model is not None and kwargs_load_confounds is not None:
+        if "high_pass" in kwargs_load_confounds.get("strategy"):
+            if drift_model == "cosine":
+                verb = "duplicate"
+            if drift_model == "polynomial":
+                verb = "conflict with"
+
+            warn(
+                f"""Confounds will contain a high pass filter,
+ that may {verb} the {drift_model} one used in the model.
+ Remember to visualize your design matrix before fitting your model
+ to check that your model is not overspecified.""",
+                UserWarning,
+            )
+
     derivatives_path = Path(dataset_path) / derivatives_folder
 
     # Get metadata for models.
@@ -1216,11 +1315,8 @@ def first_level_from_bids(
             img_filters=img_filters,
             imgs=imgs,
             verbose=verbose,
+            kwargs_load_confounds=kwargs_load_confounds,
         )
-        if confounds:
-            confounds = [
-                pd.read_csv(c, sep="\t", index_col=None) for c in confounds
-            ]
         models_confounds.append(confounds)
 
     return models, models_run_imgs, models_events, models_confounds
@@ -1433,6 +1529,7 @@ def _get_confounds(
     img_filters,
     imgs,
     verbose,
+    kwargs_load_confounds,
 ):
     """Get confounds.tsv files for a given subject, task and filters.
 
@@ -1462,8 +1559,7 @@ def _get_confounds(
 
     Returns
     -------
-    confounds : :obj:`list` of :obj:`str` or None
-        List of fullpath to the confounds.tsv files
+    confounds : :obj:`list` of :class:`pandas.DataFrame`
 
     """
     filters = _make_bids_files_filter(
@@ -1488,7 +1584,17 @@ def _get_confounds(
             filters=filters,
         )
     _check_confounds_list(confounds=confounds, imgs=imgs)
-    return confounds or None
+
+    if confounds:
+        if kwargs_load_confounds is None:
+            confounds = [
+                pd.read_csv(c, sep="\t", index_col=None) for c in confounds
+            ]
+            return confounds or None
+
+        confounds, _ = load_confounds(img_files=imgs, **kwargs_load_confounds)
+
+        return confounds
 
 
 def _check_confounds_list(confounds, imgs):
@@ -1613,6 +1719,35 @@ def _check_args_first_level_from_bids(
         _check_bids_label(filter_[1])
 
 
+def _check_kwargs_load_confounds(**kwargs):
+    # reuse the default from nilearn.interface.fmriprep.load_confounds
+    defaults = {
+        "strategy": ("motion", "high_pass", "wm_csf"),
+        "motion": "full",
+        "scrub": 5,
+        "fd_threshold": 0.2,
+        "std_dvars_threshold": 3,
+        "wm_csf": "basic",
+        "global_signal": "basic",
+        "compcor": "anat_combined",
+        "n_compcor": "all",
+        "ica_aroma": "full",
+        "demean": True,
+    }
+
+    if kwargs.get("confounds_strategy") is None:
+        return None
+
+    kwargs_load_confounds = {
+        key: defaults[key]
+        if f"confounds_{key}" not in kwargs
+        else kwargs[f"confounds_{key}"]
+        for key in defaults
+    }
+
+    return kwargs_load_confounds
+
+
 def _make_bids_files_filter(
     task_label,
     space_label=None,


Test patch:
diff --git a/nilearn/_utils/tests/test_data_gen.py b/nilearn/_utils/tests/test_data_gen.py
--- a/nilearn/_utils/tests/test_data_gen.py
+++ b/nilearn/_utils/tests/test_data_gen.py
@@ -211,8 +211,8 @@ def test_fake_bids_derivatives_with_session_and_runs(
         )
 
     all_files = list(bids_path.glob("derivatives/sub-*/ses-*/*/*"))
-    # per subject: (1 confound + 3 bold + 2 gifti) per run per session
-    n_derivatives_files_expected = n_sub * (6 * sum(n_runs) * n_ses)
+    # per subject: (2 confound + 3 bold + 2 gifti) per run per session
+    n_derivatives_files_expected = n_sub * (7 * sum(n_runs) * n_ses)
     assert len(all_files) == n_derivatives_files_expected
 
 
@@ -375,10 +375,10 @@ def test_fake_bids_extra_raw_entity(tmp_path):
             )
 
     all_files = list(bids_path.glob("derivatives/sub-*/ses-*/*/*"))
-    # per subject: (1 confound + 3 bold + 2 gifti)
+    # per subject: (2 confound + 3 bold + 2 gifti)
     #              per run per session per entity
     n_derivatives_files_expected = (
-        n_sub * (6 * sum(n_runs) * n_ses) * len(entities["acq"])
+        n_sub * (7 * sum(n_runs) * n_ses) * len(entities["acq"])
     )
     assert len(all_files) == n_derivatives_files_expected
 
@@ -420,7 +420,7 @@ def test_fake_bids_extra_derivative_entity(tmp_path):
     # 1 confound per run per session
     # + (3 bold + 2 gifti) per run per session per entity
     n_derivatives_files_expected = n_sub * (
-        1 * sum(n_runs) * n_ses
+        2 * sum(n_runs) * n_ses
         + 5 * sum(n_runs) * n_ses * len(entities["res"])
     )
     assert len(all_files) == n_derivatives_files_expected
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
@@ -1325,7 +1325,7 @@ def test_first_level_from_bids(
         base_dir=tmp_path, n_sub=n_sub, n_ses=n_ses, tasks=tasks, n_runs=n_runs
     )
 
-    models, m_imgs, m_events, m_confounds = first_level_from_bids(
+    models, imgs, events, confounds = first_level_from_bids(
         dataset_path=bids_path,
         task_label=tasks[task_index],
         space_label=space_label,
@@ -1333,9 +1333,7 @@ def test_first_level_from_bids(
         slice_time_ref=None,
     )
 
-    _check_output_first_level_from_bids(
-        n_sub, models, m_imgs, m_events, m_confounds
-    )
+    _check_output_first_level_from_bids(n_sub, models, imgs, events, confounds)
 
     n_imgs_expected = n_ses * n_runs[task_index]
 
@@ -1349,13 +1347,13 @@ def test_first_level_from_bids(
     elif no_run_entity:
         n_imgs_expected = n_ses
 
-    assert len(m_imgs[0]) == n_imgs_expected
+    assert len(imgs[0]) == n_imgs_expected
 
 
 def test_first_level_from_bids_select_one_run_per_session(bids_dataset):
     n_sub, n_ses, *_ = _inputs_for_new_bids_dataset()
 
-    models, m_imgs, m_events, m_confounds = first_level_from_bids(
+    models, imgs, events, confounds = first_level_from_bids(
         dataset_path=bids_dataset,
         task_label="main",
         space_label="MNI",
@@ -1363,18 +1361,16 @@ def test_first_level_from_bids_select_one_run_per_session(bids_dataset):
         slice_time_ref=None,
     )
 
-    _check_output_first_level_from_bids(
-        n_sub, models, m_imgs, m_events, m_confounds
-    )
+    _check_output_first_level_from_bids(n_sub, models, imgs, events, confounds)
 
     n_imgs_expected = n_ses
-    assert len(m_imgs[0]) == n_imgs_expected
+    assert len(imgs[0]) == n_imgs_expected
 
 
 def test_first_level_from_bids_select_all_runs_of_one_session(bids_dataset):
     n_sub, _, _, n_runs = _inputs_for_new_bids_dataset()
 
-    models, m_imgs, m_events, m_confounds = first_level_from_bids(
+    models, imgs, events, confounds = first_level_from_bids(
         dataset_path=bids_dataset,
         task_label="main",
         space_label="MNI",
@@ -1382,12 +1378,10 @@ def test_first_level_from_bids_select_all_runs_of_one_session(bids_dataset):
         slice_time_ref=None,
     )
 
-    _check_output_first_level_from_bids(
-        n_sub, models, m_imgs, m_events, m_confounds
-    )
+    _check_output_first_level_from_bids(n_sub, models, imgs, events, confounds)
 
     n_imgs_expected = n_runs[0]
-    assert len(m_imgs[0]) == n_imgs_expected
+    assert len(imgs[0]) == n_imgs_expected
 
 
 @pytest.mark.parametrize("verbose", [0, 1])
@@ -1426,7 +1420,7 @@ def test_first_level_from_bids_several_labels_per_entity(tmp_path, entity):
         entities={entity: ["A", "B"]},
     )
 
-    models, m_imgs, m_events, m_confounds = first_level_from_bids(
+    models, imgs, events, confounds = first_level_from_bids(
         dataset_path=bids_path,
         task_label="main",
         space_label="MNI",
@@ -1434,32 +1428,28 @@ def test_first_level_from_bids_several_labels_per_entity(tmp_path, entity):
         slice_time_ref=None,
     )
 
-    _check_output_first_level_from_bids(
-        n_sub, models, m_imgs, m_events, m_confounds
-    )
+    _check_output_first_level_from_bids(n_sub, models, imgs, events, confounds)
     n_imgs_expected = n_ses * n_runs[0]
-    assert len(m_imgs[0]) == n_imgs_expected
+    assert len(imgs[0]) == n_imgs_expected
 
 
 def _check_output_first_level_from_bids(
-    n_sub, models, m_imgs, m_events, m_confounds
+    n_sub, models, imgs, events, 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)
-    for confounds in m_confounds:
-        assert isinstance(confounds, list)
-        assert all(
-            isinstance(confound_, pd.DataFrame) for confound_ in confounds
-        )
+    assert len(models) == len(imgs)
+    for img_ in imgs:
+        assert isinstance(img_, list)
+        assert all(Path(x).exists() for x in img_)
+    assert len(models) == len(events)
+    for event_ in events:
+        assert isinstance(event_, list)
+        assert all(isinstance(x, pd.DataFrame) for x in event_)
+    assert len(models) == len(confounds)
+    for confound_ in confounds:
+        assert isinstance(confound_, list)
+        assert all(isinstance(x, pd.DataFrame) for x in confound_)
 
 
 def test_first_level_from_bids_with_subject_labels(bids_dataset):
@@ -1722,7 +1712,7 @@ def test_first_level_from_bids_all_confounds_missing(tmp_path_factory):
     for f in confound_files:
         os.remove(f)
 
-    models, m_imgs, m_events, m_confounds = first_level_from_bids(
+    models, imgs, events, confounds = first_level_from_bids(
         dataset_path=bids_dataset,
         task_label="main",
         space_label="MNI",
@@ -1731,10 +1721,10 @@ def test_first_level_from_bids_all_confounds_missing(tmp_path_factory):
         slice_time_ref=None,
     )
 
-    assert len(models) == len(m_imgs)
-    assert len(models) == len(m_events)
-    assert len(models) == len(m_confounds)
-    for condounds_ in m_confounds:
+    assert len(models) == len(imgs)
+    assert len(models) == len(events)
+    assert len(models) == len(confounds)
+    for condounds_ in confounds:
         assert condounds_ is None
 
 
@@ -1860,3 +1850,94 @@ def test_missing_trial_type_column_warning(tmp_path_factory):
             "No column named 'trial_type' found" in r.message.args[0]
             for r in record
         )
+
+
+from itertools import product
+
+
+def test_first_level_from_bids_load_confounds(tmp_path):
+    """Test that only a subset of confounds can be loaded."""
+    n_sub = 2
+
+    bids_path = create_fake_bids_dataset(
+        base_dir=tmp_path, n_sub=n_sub, n_ses=2, tasks=["main"], n_runs=[2]
+    )
+
+    _, _, _, confounds = first_level_from_bids(
+        dataset_path=bids_path,
+        task_label="main",
+        space_label="MNI",
+        img_filters=[("desc", "preproc")],
+    )
+
+    assert len(confounds[0][0].columns) == 189
+
+    models, imgs, events, confounds = first_level_from_bids(
+        dataset_path=bids_path,
+        task_label="main",
+        space_label="MNI",
+        img_filters=[("desc", "preproc")],
+        confounds_strategy=("motion", "wm_csf"),
+        confounds_motion="full",
+        confounds_wm_csf="basic",
+    )
+
+    _check_output_first_level_from_bids(n_sub, models, imgs, events, confounds)
+
+    assert len(confounds[0][0].columns) == 26
+
+    assert all(x in confounds[0][0].columns for x in ["csf", "white_matter"])
+    for dir, motion, der, power in product(
+        ["x", "y", "z"],
+        ["rot", "trans"],
+        ["", "_derivative1"],
+        ["", "_power2"],
+    ):
+        assert f"{motion}_{dir}{der}{power}" in confounds[0][0].columns
+
+
+def test_first_level_from_bids_load_confounds_warnings(tmp_path):
+    """Throw warning when incompatible confound loading strategy are used."""
+    n_sub = 2
+
+    bids_path = create_fake_bids_dataset(
+        base_dir=tmp_path, n_sub=n_sub, n_ses=2, tasks=["main"], n_runs=[2]
+    )
+
+    # high pass is loaded from the confounds: no warning
+    first_level_from_bids(
+        dataset_path=bids_path,
+        task_label="main",
+        space_label="MNI",
+        img_filters=[("desc", "preproc")],
+        drift_model=None,
+        confounds_strategy=("high_pass",),
+    )
+
+    with pytest.warns(
+        UserWarning, match=("duplicate .*the cosine one used in the model.")
+    ):
+        # cosine loaded from confounds may duplicate
+        # the one created during model specification
+        first_level_from_bids(
+            dataset_path=bids_path,
+            task_label="main",
+            space_label="MNI",
+            img_filters=[("desc", "preproc")],
+            drift_model="cosine",
+            confounds_strategy=("high_pass",),
+        )
+
+    with pytest.warns(
+        UserWarning, match=("conflict .*the polynomial one used in the model.")
+    ):
+        # cosine loaded from confounds may conflict
+        # the one created during model specification
+        first_level_from_bids(
+            dataset_path=bids_path,
+            task_label="main",
+            space_label="MNI",
+            img_filters=[("desc", "preproc")],
+            drift_model="polynomial",
+            confounds_strategy=("high_pass",),
+        )
diff --git a/nilearn/interfaces/fmriprep/tests/_testing.py b/nilearn/interfaces/fmriprep/tests/_testing.py
--- a/nilearn/interfaces/fmriprep/tests/_testing.py
+++ b/nilearn/interfaces/fmriprep/tests/_testing.py
@@ -4,7 +4,7 @@
 
 import pandas as pd
 
-from nilearn._utils.data_gen import _create_bids_filename
+from nilearn._utils.bids import create_bids_filename
 from nilearn.interfaces.bids._utils import _bids_entities
 from nilearn.interfaces.fmriprep import load_confounds_utils
 
@@ -103,7 +103,7 @@ def create_tmp_filepath(
     bids_fields["entities"]["desc"] = "confounds"
     bids_fields["suffix"] = derivative
     bids_fields["extension"] = "tsv"
-    confounds_filename = _create_bids_filename(
+    confounds_filename = create_bids_filename(
         fields=bids_fields, entities_to_include=entities_to_include
     )
     tmp_conf = base_path / confounds_filename
@@ -116,7 +116,7 @@ def create_tmp_filepath(
 
     if copy_json:
         bids_fields["extension"] = "json"
-        confounds_sidecar = _create_bids_filename(
+        confounds_sidecar = create_bids_filename(
             fields=bids_fields, entities_to_include=entities_to_include
         )
         tmp_meta = base_path / confounds_sidecar
@@ -129,7 +129,7 @@ def create_tmp_filepath(
     img_file_patterns_type = img_file_patterns[image_type]
     if type(img_file_patterns_type) is dict:
         bids_fields = update_bids_fields(bids_fields, img_file_patterns_type)
-        tmp_img = _create_bids_filename(
+        tmp_img = create_bids_filename(
             fields=bids_fields, entities_to_include=entities_to_include
         )
         tmp_img = base_path / tmp_img
@@ -139,7 +139,7 @@ def create_tmp_filepath(
         tmp_img = []
         for root in img_file_patterns_type:
             bids_fields = update_bids_fields(bids_fields, root)
-            tmp_gii = _create_bids_filename(
+            tmp_gii = create_bids_filename(
                 fields=bids_fields, entities_to_include=entities_to_include
             )
             tmp_gii = base_path / tmp_gii
diff --git a/nilearn/interfaces/tests/test_bids.py b/nilearn/interfaces/tests/test_bids.py
--- a/nilearn/interfaces/tests/test_bids.py
+++ b/nilearn/interfaces/tests/test_bids.py
@@ -269,7 +269,7 @@ def test_get_bids_files(tmp_path):
         os.path.join(bids_path, "derivatives"),
         file_tag="desc-confounds_timeseries",
     )
-    assert len(selection) == 80
+    assert len(selection) == 160
 
     bids_path = create_fake_bids_dataset(
         base_dir=tmp_path,
@@ -284,7 +284,7 @@ def test_get_bids_files(tmp_path):
         os.path.join(bids_path, "derivatives"),
         file_tag="desc-confounds_regressors",
     )
-    assert len(selection) == 80
+    assert len(selection) == 160
 
 
 def test_parse_bids_filename():