Repository: nilearn/nilearn

Issue:
`non_parametric_inference` does not accept DataFrames while documentation says it should
The documentation for `non_parametric_inference` says that the parameter `second_level_input ` can be a DataFrame:

https://github.com/nilearn/nilearn/blob/2fd6665664f27dfbf1f10aeeba660adc9b560361/nilearn/glm/second_level/second_level.py#L546-L550

 but explicitly checks the opposite here:

https://github.com/nilearn/nilearn/blob/2fd6665664f27dfbf1f10aeeba660adc9b560361/nilearn/glm/second_level/second_level.py#L630-L631

Example code:

```python
import pandas as pd
from nilearn.glm.second_level import non_parametric_inference

df = pd.DataFrame(columns=["subject_label", "map_name", "effects_map_path"])
non_parametric_inference(df)
```
```
---------------------------------------------------------------------------
ValueError                                Traceback (most recent call last)
<ipython-input-5-48e22b1e0923> in <module>
      3 
      4 df = pd.DataFrame(columns=["subject_label", "map_name", "effects_map_path"])
----> 5 non_parametric_inference(df)

~/GitRepos/nilearn-fork/nilearn/glm/second_level/second_level.py in non_parametric_inference(second_level_input, confounds, design_matrix, second_level_contrast, mask, smoothing_fwhm, model_intercept, n_perm, two_sided_test, random_state, n_jobs, verbose)
    623 
    624     """
--> 625     _check_second_level_input(second_level_input, design_matrix,
    626                               flm_object=False, df_object=False)
    627     _check_confounds(confounds)

~/GitRepos/nilearn-fork/nilearn/glm/second_level/second_level.py in _check_second_level_input(second_level_input, design_matrix, confounds, flm_object, df_object)
     99                              'was provided' % type(second_level_input))
    100         else:
--> 101             raise ValueError('second_level_input must be'
    102                              ' a list Niimg-like objects. Instead %s '
    103                              'was provided' % type(second_level_input))

ValueError: second_level_input must be a list Niimg-like objects. Instead <class 'pandas.core.frame.DataFrame'> was provided
```


Discussion:

Resolving patch:
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
@@ -223,6 +223,55 @@ def _is_contrast_def(x):
     return effect_maps
 
 
+def _process_second_level_input(second_level_input):
+    """Helper function to process second_level_input."""
+    if isinstance(second_level_input, pd.DataFrame):
+        return _process_second_level_input_as_dataframe(second_level_input)
+    elif(hasattr(second_level_input, "__iter__")
+         and isinstance(second_level_input[0], FirstLevelModel)):
+        return _process_second_level_input_as_firstlevelmodels(
+            second_level_input
+        )
+    else:
+        return mean_img(second_level_input), None
+
+
+def _process_second_level_input_as_dataframe(second_level_input):
+    """Helper function to process second_level_input provided
+    as a pandas DataFrame.
+    """
+    sample_map = second_level_input['effects_map_path'][0]
+    labels = second_level_input['subject_label']
+    subjects_label = labels.values.tolist()
+    return sample_map, subjects_label
+
+
+def _sort_input_dataframe(second_level_input):
+    """This function sorts the pandas dataframe by subject_label to
+    avoid inconsistencies with the design matrix row order when
+    automatically extracting maps.
+    """
+    columns = second_level_input.columns.tolist()
+    column_index = columns.index('subject_label')
+    sorted_matrix = sorted(
+        second_level_input.values, key=lambda x: x[column_index]
+    )
+    return pd.DataFrame(sorted_matrix, columns=columns)
+
+
+def _process_second_level_input_as_firstlevelmodels(second_level_input):
+    """Helper function to process second_level_input provided
+    as a list of FirstLevelModel objects.
+    """
+    sample_model = second_level_input[0]
+    sample_condition = sample_model.design_matrices_[0].columns[0]
+    sample_map = sample_model.compute_contrast(
+        sample_condition, output_type='effect_size'
+    )
+    labels = [model.subject_label for model in second_level_input]
+    return sample_map, labels
+
+
 @fill_doc
 class SecondLevelModel(BaseGLM):
     """ Implementation of the General Linear Model for multiple subject
@@ -350,18 +399,13 @@ def fit(self, second_level_input, confounds=None, design_matrix=None):
         # check design matrix
         _check_design_matrix(design_matrix)
 
-        # sort a pandas dataframe by subject_label to avoid inconsistencies
-        # with the design matrix row order when automatically extracting maps
         if isinstance(second_level_input, pd.DataFrame):
-            columns = second_level_input.columns.tolist()
-            column_index = columns.index('subject_label')
-            sorted_matrix = sorted(
-                second_level_input.values, key=lambda x: x[column_index])
-            sorted_input = pd.DataFrame(sorted_matrix, columns=columns)
-            second_level_input = sorted_input
-
+            second_level_input = _sort_input_dataframe(second_level_input)
         self.second_level_input_ = second_level_input
         self.confounds_ = confounds
+        sample_map, subjects_label = _process_second_level_input(
+            second_level_input
+        )
 
         # Report progress
         t0 = time.time()
@@ -369,24 +413,6 @@ def fit(self, second_level_input, confounds=None, design_matrix=None):
             sys.stderr.write("Fitting second level model. "
                              "Take a deep breath\r")
 
-        # Select sample map for masker fit and get subjects_label for design
-        if isinstance(second_level_input, pd.DataFrame):
-            sample_map = second_level_input['effects_map_path'][0]
-            labels = second_level_input['subject_label']
-            subjects_label = labels.values.tolist()
-        elif isinstance(second_level_input, Nifti1Image):
-            sample_map = mean_img(second_level_input)
-        elif isinstance(second_level_input[0], FirstLevelModel):
-            sample_model = second_level_input[0]
-            sample_condition = sample_model.design_matrices_[0].columns[0]
-            sample_map = sample_model.compute_contrast(
-                sample_condition, output_type='effect_size')
-            labels = [model.subject_label for model in second_level_input]
-            subjects_label = labels
-        else:
-            # In this case design matrix had to be provided
-            sample_map = mean_img(second_level_input)
-
         # Create and set design matrix, if not given
         if design_matrix is None:
             design_matrix = make_second_level_design_matrix(subjects_label,
@@ -532,6 +558,7 @@ def compute_contrast(self, second_level_contrast=None,
 @fill_doc
 def non_parametric_inference(second_level_input, confounds=None,
                              design_matrix=None, second_level_contrast=None,
+                             first_level_contrast=None,
                              mask=None, smoothing_fwhm=None,
                              model_intercept=True, n_perm=10000,
                              two_sided_test=False, random_state=None,
@@ -578,6 +605,13 @@ def non_parametric_inference(second_level_input, confounds=None,
         applied; when the design matrix has multiple columns, an error is
         raised.
 
+    first_level_contrast : str, optional
+        In case a pandas DataFrame was provided as second_level_input this
+        is the map name to extract from the pandas dataframe map_name column.
+        It has to be a 't' contrast.
+
+        .. versionadded:: 0.8.2
+
     mask : Niimg-like, NiftiMasker or MultiNiftiMasker object, optional
         Mask to be used on data. If an instance of masker is passed,
         then its mask will be used. If no mask is given,
@@ -623,18 +657,20 @@ def non_parametric_inference(second_level_input, confounds=None,
 
     """
     _check_second_level_input(second_level_input, design_matrix,
-                              flm_object=False, df_object=False)
+                              flm_object=False, df_object=True)
     _check_confounds(confounds)
     _check_design_matrix(design_matrix)
 
+    if isinstance(second_level_input, pd.DataFrame):
+        second_level_input = _sort_input_dataframe(second_level_input)
+    sample_map, _ = _process_second_level_input(
+        second_level_input
+    )
     # Report progress
     t0 = time.time()
     if verbose > 0:
         sys.stderr.write("Fitting second level model...")
 
-    # Select sample map for masker fit and get subjects_label for design
-    sample_map = mean_img(second_level_input)
-
     # Learn the mask. Assume the first level imgs have been masked.
     if not isinstance(mask, NiftiMasker):
         masker = NiftiMasker(
@@ -656,9 +692,8 @@ def non_parametric_inference(second_level_input, confounds=None,
 
     # Check and obtain the contrast
     contrast = _get_contrast(second_level_contrast, design_matrix)
-
     # Get effect_maps
-    effect_maps = _infer_effect_maps(second_level_input, None)
+    effect_maps = _infer_effect_maps(second_level_input, first_level_contrast)
 
     # Check design matrix and effect maps agree on number of rows
     _check_effect_maps(effect_maps, design_matrix)


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
@@ -29,6 +29,54 @@
 FUNCFILE = os.path.join(BASEDIR, 'functional.nii.gz')
 
 
+@pytest.fixture
+def input_df():
+    """Input DataFrame for testing."""
+    return pd.DataFrame({'effects_map_path': ["foo.nii", "bar.nii", "baz.nii"],
+                         'subject_label': ["foo", "bar", "baz"]})
+
+
+def test_process_second_level_input_as_dataframe(input_df):
+    """Unit tests for function _process_second_level_input_as_dataframe()."""
+    from nilearn.glm.second_level.second_level import _process_second_level_input_as_dataframe  # noqa
+    sample_map, subjects_label = _process_second_level_input_as_dataframe(
+        input_df
+    )
+    assert sample_map == "foo.nii"
+    assert subjects_label == ["foo", "bar", "baz"]
+
+
+def test_sort_input_dataframe(input_df):
+    """Unit tests for function _sort_input_dataframe()."""
+    from nilearn.glm.second_level.second_level import _sort_input_dataframe
+    output_df = _sort_input_dataframe(input_df)
+    assert output_df['subject_label'].values.tolist() == ["bar", "baz", "foo"]
+    assert(
+        output_df['effects_map_path'].values.tolist()
+        == ["bar.nii", "baz.nii", "foo.nii"]
+    )
+
+
+def test_process_second_level_input_as_firstlevelmodels():
+    """Unit tests for function
+    _process_second_level_input_as_firstlevelmodels().
+    """
+    from nilearn.glm.second_level.second_level import _process_second_level_input_as_firstlevelmodels  # noqa
+    shapes, rk = [(7, 8, 9, 15)], 3
+    mask, fmri_data, design_matrices = \
+        generate_fake_fmri_data_and_design(shapes, rk)
+    list_of_flm = [
+        FirstLevelModel(mask_img=mask, subject_label=f"sub-{i}").fit(
+            fmri_data[0], design_matrices=design_matrices[0]
+        ) for i in range(3)
+    ]
+    sample_map, subjects_label =\
+        _process_second_level_input_as_firstlevelmodels(list_of_flm)
+    assert subjects_label == [f"sub-{i}" for i in range(3)]
+    assert isinstance(sample_map, Nifti1Image)
+    assert sample_map.shape == (7, 8, 9)
+
+
 def test_check_second_level_input():
     from nilearn.glm.second_level.second_level import _check_second_level_input
     with pytest.raises(ValueError,
@@ -240,21 +288,32 @@ def test_high_level_non_parametric_inference_with_paths():
         shapes = ((7, 8, 9, 1),)
         mask, FUNCFILE, _ = write_fake_fmri_data_and_design(shapes)
         FUNCFILE = FUNCFILE[0]
+        df_input = pd.DataFrame(
+            {'subject_label': [f'sub-{i}' for i in range(4)],
+             'effects_map_path': [FUNCFILE] * 4,
+             'map_name': [FUNCFILE] * 4}
+        )
         func_img = load(FUNCFILE)
         Y = [func_img] * 4
         X = pd.DataFrame([[1]] * 4, columns=['intercept'])
         c1 = np.eye(len(X.columns))[0]
-        neg_log_pvals_img = non_parametric_inference(Y, design_matrix=X,
-                                                     second_level_contrast=c1,
-                                                     mask=mask, n_perm=n_perm,
-                                                     verbose=1)  # For coverage
-        neg_log_pvals = get_data(neg_log_pvals_img)
-
-        assert isinstance(neg_log_pvals_img, Nifti1Image)
-        assert_array_equal(neg_log_pvals_img.affine, load(mask).affine)
+        neg_log_pvals_imgs = [
+            non_parametric_inference(
+                second_level_input, design_matrix=X, second_level_contrast=c1,
+                first_level_contrast=FUNCFILE, mask=mask,
+                n_perm=n_perm, verbose=1
+            ) for second_level_input in [Y, df_input]
+        ]
+        assert all(
+            [isinstance(img, Nifti1Image) for img in neg_log_pvals_imgs]
+        )
+        for img in neg_log_pvals_imgs:
+            assert_array_equal(img.affine, load(mask).affine)
+        neg_log_pvals_list = [get_data(i) for i in neg_log_pvals_imgs]
+        for neg_log_pvals in neg_log_pvals_list:
+            assert np.all(neg_log_pvals <= - np.log10(1.0 / (n_perm + 1)))
+            assert np.all(0 <= neg_log_pvals)
 
-        assert np.all(neg_log_pvals <= - np.log10(1.0 / (n_perm + 1)))
-        assert np.all(0 <= neg_log_pvals)
         masker = NiftiMasker(mask, smoothing_fwhm=2.0)
         with pytest.warns(UserWarning,
                           match="Parameter smoothing_fwhm "
@@ -265,7 +324,7 @@ def test_high_level_non_parametric_inference_with_paths():
                                      mask=masker, n_perm=n_perm)
         # Delete objects attached to files to avoid WindowsError when deleting
         # temporary directory
-        del X, Y, FUNCFILE, func_img, neg_log_pvals_img
+        del X, Y, FUNCFILE, func_img, neg_log_pvals_imgs
 
 
 def test_fmri_inputs():
@@ -397,9 +456,6 @@ def test_fmri_inputs_for_non_parametric_inference():
         # test list of less than two niimgs
         with pytest.raises(ValueError):
             non_parametric_inference([FUNCFILE])
-        # test dataframe
-        with pytest.raises(ValueError):
-            non_parametric_inference(niidf)
         # test niimgs requirements
         with pytest.raises(ValueError):
             non_parametric_inference(niimgs)