Repository: nilearn/nilearn

Issue:
Allow for subject as a file filter in first_level_from_bids
<!--Provide a brief description what you would like changes and why.-->

It would be nice to be able to use subject as a filter variable for first_level_from_bids, to have it set up a first level GLM for just that subject. Often we run one subject at a time, as they are collected.  In addition, I like to send a job for each subject to our cluster, so they can be run in parallel, so that would help with this as well.

<!--Please fill in the following information, to the best of your ability.-->
### Benefits to the change

More easily allow people to run one subject at a time, so subjects can be analyzed and evaluated as data is collected, to make it easier to split jobs per subject for cluster computing, and to allow for selecting one subject to reprocess if necessary.

### Pseudocode for the new behavior, if applicable

```python

```



Discussion:
I'm +1 for that. Should not be too hard.
Seems to have some overlap with https://github.com/nilearn/nilearn/issues/3068

Resolving patch:
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
@@ -794,6 +794,7 @@ def _get_voxelwise_model_attribute(self, attribute,
 
 
 def first_level_from_bids(dataset_path, task_label, space_label=None,
+                          sub_labels=None,
                           img_filters=None, t_r=None, slice_time_ref=0.,
                           hrf_model='glover', drift_model='cosine',
                           high_pass=.01, drift_order=1, fir_delays=[0],
@@ -824,6 +825,11 @@ def first_level_from_bids(dataset_path, task_label, space_label=None,
         Specifies the space label of the preprocessed bold.nii images.
         As they are specified in the file names like _space-<space_label>_.
 
+    sub_labels : list of str, optional
+        Specifies the subset of subject labels to model.
+        If 'None', will model all subjects in the dataset.
+        .. versionadded:: 0.9.3.dev
+
     img_filters : list of tuples (str, str), optional
         Filters are of the form (field, label). Only one filter per field
         allowed. A file that does not match a filter will be discarded.
@@ -857,6 +863,7 @@ def first_level_from_bids(dataset_path, task_label, space_label=None,
 
     """
     # check arguments
+    sub_labels = sub_labels if sub_labels else []
     img_filters = img_filters or []
     if not isinstance(dataset_path, str):
         raise TypeError(
@@ -870,6 +877,9 @@ def first_level_from_bids(dataset_path, task_label, space_label=None,
     if space_label is not None and not isinstance(space_label, str):
         raise TypeError('space_label must be a string, instead %s was given' %
                         type(space_label))
+    if not isinstance(sub_labels, list):
+        raise TypeError('sub_labels must be a list, instead %s was given' %
+                        type(sub_labels))
     if not isinstance(img_filters, list):
         raise TypeError('img_filters must be a list, instead %s was given' %
                         type(img_filters))
@@ -936,9 +946,20 @@ def first_level_from_bids(dataset_path, task_label, space_label=None,
                      img_specs[0])
 
     # Infer subjects in dataset
-    sub_folders = glob.glob(os.path.join(derivatives_path, 'sub-*/'))
-    sub_labels = [os.path.basename(s[:-1]).split('-')[1] for s in sub_folders]
-    sub_labels = sorted(list(set(sub_labels)))
+    if not sub_labels:
+        sub_folders = glob.glob(os.path.join(derivatives_path, 'sub-*/'))
+        sub_labels = [
+            os.path.basename(s[:-1]).split('-')[1] for s in sub_folders
+        ]
+        sub_labels = sorted(list(set(sub_labels)))
+
+    sub_labels_exist = []
+    for this_label in sub_labels:
+        if os.path.exists(os.path.join(derivatives_path, f"sub-{this_label}")):
+            sub_labels_exist.append(this_label)
+        else:
+            warn(f'Subject label {this_label} is not present in the'
+                    ' dataset and cannot be processed.')
 
     # Build fit_kwargs dictionaries to pass to their respective models fit
     # Events and confounds files must match number of imgs (runs)
@@ -946,7 +967,7 @@ def first_level_from_bids(dataset_path, task_label, space_label=None,
     models_run_imgs = []
     models_events = []
     models_confounds = []
-    for sub_label in sub_labels:
+    for sub_label in sub_labels_exist:
         # Create model
         model = FirstLevelModel(
             t_r=t_r, slice_time_ref=slice_time_ref, hrf_model=hrf_model,


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
@@ -649,6 +649,11 @@ def test_first_level_from_bids():
             first_level_from_bids(bids_path, 'main',
                                   space_label=42)
 
+        with pytest.raises(TypeError,
+                           match="sub_labels must be a list"):
+            first_level_from_bids(bids_path, 'main',
+                                  sub_labels="foo")
+
         with pytest.raises(TypeError,
                            match="img_filters must be a list"):
             first_level_from_bids(bids_path, 'main',
@@ -666,7 +671,7 @@ def test_first_level_from_bids():
 
         # test output is as expected
         models, m_imgs, m_events, m_confounds = first_level_from_bids(
-            bids_path, 'main', 'MNI', [('desc', 'preproc')])
+            bids_path, 'main', 'MNI', img_filters=[('desc', 'preproc')])
         assert len(models) == len(m_imgs)
         assert len(models) == len(m_events)
         assert len(models) == len(m_confounds)
@@ -720,6 +725,23 @@ def test_first_level_from_bids():
                 bids_path, 'main', 'T1w')  # desc not specified
 
 
+def test_first_level_from_bids_with_subject_labels():
+    with InTemporaryDirectory():
+        bids_path = create_fake_bids_dataset(n_sub=10, n_ses=2,
+                                             tasks=['localizer', 'main'],
+                                             n_runs=[1, 3])
+        warning_message = ('Subject label foo is not present in'
+                           ' the dataset and cannot be processed')
+        # check that the incorrect label `foo` raises a warning
+        with pytest.warns(UserWarning, match=warning_message):
+            models, m_imgs, m_events, m_confounds = first_level_from_bids(
+                                  bids_path, 'main',
+                                  sub_labels=["foo", "01"],
+                                  space_label='MNI',
+                                  img_filters=[('desc', 'preproc')])
+            # check that the correct label `01` gets a model
+            assert models[0].subject_label == '01'
+
 def test_first_level_with_scaling():
     shapes, rk = [(3, 1, 1, 2)], 1
     fmri_data = list()