Repository: nilearn/nilearn

Issue:
Allow pandas.Series as valid input for second_level_input in SecondLevelModel class
It would be nice, if a pandas.Series would also be allowed as valid input for `second_level_input` in `SecondLevelModel`. Currently, I have to convert my series containing the NIFTI filepaths to a list. However, I guess most of the time you work with a pandas.DataFrame containing information about your subjects (ID, age, filepaths, etc.) and you would like to provide `second_level_input` directly with the column containing the filepaths (e.g. `SecondLevelModel.fit(second_level_input=my_dataframe['filepaths')`


Discussion:
Agreed !
For that one, I think we're going to implement in nilearn.stats (note that nistats will be frozen soonish, see e.g. https://github.com/nilearn/nilearn/pull/2299).
Best,


Resolving patch:
diff --git a/nilearn/_utils/docs.py b/nilearn/_utils/docs.py
--- a/nilearn/_utils/docs.py
+++ b/nilearn/_utils/docs.py
@@ -795,8 +795,10 @@ def custom_function(vertices):
     "second_level_input"
 ] = """
 second_level_input : :obj:`list` of \
-:class:`~nilearn.glm.first_level.FirstLevelModel` objects \
-or :class:`pandas.DataFrame` or :obj:`list` of Niimg-like objects.
+    :class:`~nilearn.glm.first_level.FirstLevelModel` objects or \
+    :class:`pandas.DataFrame` or \
+    :obj:`list` of Niimg-like objects or \
+    :obj:`pandas.Series` of Niimg-like objects.
 
     - Giving :class:`~nilearn.glm.first_level.FirstLevelModel` objects
       will allow to easily compute the second level contrast of arbitrary first
diff --git a/nilearn/glm/second_level/second_level.py b/nilearn/glm/second_level/second_level.py
--- a/nilearn/glm/second_level/second_level.py
+++ b/nilearn/glm/second_level/second_level.py
@@ -48,6 +48,8 @@ def _check_input_type(second_level_input):
     """Determine the type of input provided."""
     if isinstance(second_level_input, pd.DataFrame):
         return "df_object"
+    if isinstance(second_level_input, pd.Series):
+        return "pd_series"
     if isinstance(second_level_input, (str, Nifti1Image)):
         return "nii_object"
     if isinstance(second_level_input, list):
@@ -56,6 +58,7 @@ def _check_input_type(second_level_input):
         "second_level_input must be "
         "either a pandas DataFrame, "
         "a Niimg-like object, "
+        "a pandas Series of Niimg-like object, "
         "a list of Niimg-like object or "
         "a list of FirstLevelModel objects. "
         f"Got {_return_type(second_level_input)} instead"
@@ -105,6 +108,9 @@ def _check_input_as_type(
 ):
     if input_type == "flm_object":
         _check_input_as_first_level_model(second_level_input, none_confounds)
+    elif input_type == "pd_series":
+        second_level_input = second_level_input.to_list()
+        _check_input_as_nifti_images(second_level_input, none_design_matrix)
     elif input_type == "nii_object":
         _check_input_as_nifti_images(second_level_input, none_design_matrix)
     else:
@@ -180,7 +186,7 @@ def _check_input_as_dataframe(second_level_input):
             raise ValueError(
                 "second_level_input DataFrame must have"
                 " columns subject_label, map_name and"
-                " effects_map_path"
+                " effects_map_path."
             )
     if not all(
         isinstance(_, str)


Test patch:
diff --git a/nilearn/glm/tests/test_second_level.py b/nilearn/glm/tests/test_second_level.py
--- a/nilearn/glm/tests/test_second_level.py
+++ b/nilearn/glm/tests/test_second_level.py
@@ -624,6 +624,26 @@ def test_fmri_inputs(tmp_path, rng):
     SecondLevelModel().fit(niimg_4d, None, sdes)
 
 
+def test_fmri_pandas_series_as_input(tmp_path, rng):
+    # prepare correct input dataframe and lists
+    p, q = 80, 10
+    X = rng.standard_normal(size=(p, q))
+    shapes = (SHAPE,)
+    _, FUNCFILE, _ = write_fake_fmri_data_and_design(
+        shapes, file_path=tmp_path
+    )
+    FUNCFILE = FUNCFILE[0]
+
+    # dataframes as input
+    sdes = pd.DataFrame(X[:3, :3], columns=["intercept", "b", "c"])
+    niidf = pd.DataFrame({"filepaths": [FUNCFILE, FUNCFILE, FUNCFILE]})
+    SecondLevelModel().fit(
+        second_level_input=niidf["filepaths"],
+        confounds=None,
+        design_matrix=sdes,
+    )
+
+
 def test_fmri_inputs_errors(tmp_path):
     # Test processing of FMRI inputs
     # prepare fake data
@@ -658,17 +678,6 @@ def test_fmri_inputs_errors(tmp_path):
     with pytest.raises(TypeError, match="at least two"):
         SecondLevelModel().fit([flm])
 
-    # test dataframe requirements
-    dfcols = ["subject_label", "map_name", "effects_map_path"]
-    dfrows = [
-        ["01", "a", FUNCFILE],
-        ["02", "a", FUNCFILE],
-        ["03", "a", FUNCFILE],
-    ]
-    niidf = pd.DataFrame(dfrows, columns=dfcols)
-    with pytest.raises(TypeError, match="second_level_input must be"):
-        SecondLevelModel().fit(niidf["subject_label"])
-
     confounds = pd.DataFrame(
         [["01", 1], ["02", 2], ["03", 3]],
         columns=["subject_label", "conf1"],
@@ -693,6 +702,36 @@ def test_fmri_inputs_errors(tmp_path):
         SecondLevelModel().fit(flms, None, [])
 
 
+def test_fmri_inputs_pandas_errors():
+    # test wrong input for list and pandas requirements
+    nii_img = ["01", "02", "03"]
+    with pytest.raises(ValueError, match="File not found: "):
+        SecondLevelModel().fit(nii_img)
+
+    nii_series = pd.Series(nii_img)
+    with pytest.raises(ValueError, match="File not found: "):
+        SecondLevelModel().fit(nii_series)
+
+    # test dataframe requirements
+    dfcols = [
+        "not_the_right_column_name",
+    ]
+    dfrows = [
+        ["01"],
+        ["02"],
+        ["03"],
+    ]
+    niidf = pd.DataFrame(dfrows, columns=dfcols)
+    with pytest.raises(
+        ValueError,
+        match=(
+            "second_level_input DataFrame must have "
+            "columns subject_label, map_name and effects_map_path."
+        ),
+    ):
+        SecondLevelModel().fit(niidf)
+
+
 def test_fmri_inputs_for_non_parametric_inference_errors(tmp_path, rng):
     # Test processing of FMRI inputs