Repository: nilearn/nilearn

Issue:
Output residuals from SecondLevelModel
<!--Provide a brief description what you would like changes and why.-->
`nilearn.glm.first_level.FirstLevelModel` have `residuals()` that can output the residuals after fitting. I wonder if the `nilearn.glm.second_level.SecondLevelModel` can get the same method that output the residuals.

<!--Please fill in the following information, to the best of your ability.-->
### Benefits to the change
To be in line with my previous workflow, I would like to use `3dClustSim` to do cluster-level inference. To do that, we need to first estimate data smoothness. One of the approach is estimating smoothness from second-level residuals. Adding the feature could greatly facilitate the progress.

### Pseudocode for the new behavior, if applicable

```python
## copy from [9.6.7. Example of generic design in second-level models]
from nilearn.glm.second_level import SecondLevelModel
model = SecondLevelModel(smoothing_fwhm=5.0, minimize_memory=False) #minimize_memory might require
model.fit(contrast_map_filenames, design_matrix=design_matrix)
resid_img = concat_imgs(model.residuals)
resid_img.to_filename('./grplvl_resid.nii.gz')
```



Discussion:
This sounds like a perfectly reasonable request ! 
Are you willing to implement it ?

I'm glad to hear this feature is reasonable and is possible to implement in future. However, I'm sorry that my python programming skill is not capable to implement the new feature. To me, it will take a very long time to figure out each line and learn advanced python skills (e.g. class inheritance and decorator). I appreciate if anyone else who are willing to implement this new feature. 

Resolving patch:
diff --git a/nilearn/glm/_base.py b/nilearn/glm/_base.py
--- a/nilearn/glm/_base.py
+++ b/nilearn/glm/_base.py
@@ -1,8 +1,55 @@
 from sklearn.base import BaseEstimator, TransformerMixin
 from nilearn._utils import CacheMixin
+from nibabel.onetime import auto_attr
 
 
 class BaseGLM(BaseEstimator, TransformerMixin, CacheMixin):
+    """Implementation of a base class for the
+    :term:`General Linear Model<GLM>`.
+    """
+    @auto_attr
+    def residuals(self):
+        """Transform voxelwise residuals to the same shape
+        as the input Nifti1Image(s)
+
+        Returns
+        -------
+        output : list
+            A list of Nifti1Image(s).
+
+        """
+        return self._get_voxelwise_model_attribute('resid',
+                                                   result_as_time_series=True)
+
+    @auto_attr
+    def predicted(self):
+        """Transform voxelwise predicted values to the same shape
+        as the input Nifti1Image(s)
+
+        Returns
+        -------
+        output : list
+            A list of Nifti1Image(s).
+
+        """
+        return self._get_voxelwise_model_attribute('predicted',
+                                                   result_as_time_series=True)
+
+    @auto_attr
+    def r_square(self):
+        """Transform voxelwise r-squared values to the same shape
+        as the input Nifti1Image(s)
+
+        Returns
+        -------
+        output : list
+            A list of Nifti1Image(s).
+
+        """
+        return self._get_voxelwise_model_attribute('r_square',
+                                                   result_as_time_series=False
+                                                   )
+
     def generate_report(
             self, contrasts, title=None, bg_img="MNI152TEMPLATE",
             threshold=3.09, alpha=0.001, cluster_threshold=0,
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
@@ -19,7 +19,6 @@
 import pandas as pd
 from joblib import Memory, Parallel, delayed
 from nibabel import Nifti1Image
-from nibabel.onetime import auto_attr
 from sklearn.base import clone
 from sklearn.cluster import KMeans
 
@@ -755,49 +754,6 @@ def _get_voxelwise_model_attribute(self, attribute,
 
         return output
 
-    @auto_attr
-    def residuals(self):
-        """Transform voxelwise residuals to the same shape
-        as the input Nifti1Image(s)
-
-        Returns
-        -------
-        output : list
-            A list of Nifti1Image(s).
-
-        """
-        return self._get_voxelwise_model_attribute('resid',
-                                                   result_as_time_series=True)
-
-    @auto_attr
-    def predicted(self):
-        """Transform voxelwise predicted values to the same shape
-        as the input Nifti1Image(s)
-
-        Returns
-        -------
-        output : list
-            A list of Nifti1Image(s).
-
-        """
-        return self._get_voxelwise_model_attribute('predicted',
-                                                   result_as_time_series=True)
-
-    @auto_attr
-    def r_square(self):
-        """Transform voxelwise r-squared values to the same shape
-        as the input Nifti1Image(s)
-
-        Returns
-        -------
-        output : list
-            A list of Nifti1Image(s).
-
-        """
-        return self._get_voxelwise_model_attribute('r_square',
-                                                   result_as_time_series=False
-                                                   )
-
 
 def first_level_from_bids(dataset_path, task_label, space_label=None,
                           img_filters=None, t_r=None, slice_time_ref=0.,
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
@@ -26,7 +26,7 @@
 from nilearn.glm.first_level import FirstLevelModel, run_glm
 from nilearn.glm.first_level.design_matrix import \
     make_second_level_design_matrix
-from nilearn.glm.regression import SimpleRegressionResults
+from nilearn.glm.regression import RegressionResults, SimpleRegressionResults
 from nilearn.image import mean_img
 from nilearn.mass_univariate import permuted_ols
 from nilearn.glm._base import BaseGLM
@@ -293,6 +293,8 @@ def __init__(self, mask_img=None, target_affine=None, target_shape=None,
         self.minimize_memory = minimize_memory
         self.second_level_input_ = None
         self.confounds_ = None
+        self.labels_ = None
+        self.results_ = None
 
     def fit(self, second_level_input, confounds=None, design_matrix=None):
         """ Fit the second-level GLM
@@ -528,6 +530,71 @@ def compute_contrast(self, second_level_contrast=None,
 
         return outputs if output_type == 'all' else output
 
+    def _get_voxelwise_model_attribute(self, attribute,
+                                       result_as_time_series):
+        """Transform RegressionResults instances within a dictionary
+        (whose keys represent the autoregressive coefficient under the 'ar1'
+        noise model or only 0.0 under 'ols' noise_model and values are the
+        RegressionResults instances) into input nifti space.
+
+        Parameters
+        ----------
+        attribute : str
+            an attribute of a RegressionResults instance.
+            possible values include: 'resid', 'norm_resid', 'predicted',
+            SSE, r_square, MSE.
+
+        result_as_time_series : bool
+            whether the RegressionResult attribute has a value
+            per timepoint of the input nifti image.
+
+        Returns
+        -------
+        output : list
+            A list of Nifti1Image(s).
+
+        """
+        # check if valid attribute is being accessed.
+        all_attributes = dict(vars(RegressionResults)).keys()
+        possible_attributes = [prop
+                               for prop in all_attributes
+                               if '__' not in prop
+                               ]
+        if attribute not in possible_attributes:
+            msg = ("attribute must be one of: "
+                   "{attr}".format(attr=possible_attributes)
+                   )
+            raise ValueError(msg)
+
+        if self.minimize_memory:
+            raise ValueError(
+                'To access voxelwise attributes like '
+                'R-squared, residuals, and predictions, '
+                'the `SecondLevelModel`-object needs to store '
+                'there attributes. '
+                'To do so, set `minimize_memory` to `False` '
+                'when initializing the `SecondLevelModel`-object.')
+
+        if self.labels_ is None or self.results_ is None:
+            raise ValueError("The model has no results. This could be "
+                             "because the model has not been fitted yet "
+                             "or because no contrast has been computed "
+                             "already.")
+
+        if result_as_time_series:
+            voxelwise_attribute = np.zeros(
+                (self.design_matrix_.shape[0], len(self.labels_))
+            )
+        else:
+            voxelwise_attribute = np.zeros((1, len(self.labels_)))
+
+        for label_ in self.results_:
+            label_mask = self.labels_ == label_
+            voxelwise_attribute[:, label_mask] = getattr(
+                self.results_[label_], attribute
+            )
+        return self.masker_.inverse_transform(voxelwise_attribute)
+
 
 @fill_doc
 def non_parametric_inference(second_level_input, confounds=None,


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
@@ -14,6 +14,7 @@
 from nibabel.tmpdirs import InTemporaryDirectory
 from numpy.testing import (assert_almost_equal,
                            assert_array_equal,
+                           assert_array_almost_equal,
                            )
 
 from nilearn._utils.data_gen import (write_fake_fmri_data_and_design,
@@ -448,6 +449,61 @@ def test_second_level_glm_computation():
         del func_img, FUNCFILE, model, X, Y
 
 
+@pytest.mark.parametrize("attribute", ["residuals", "predicted", "r_square"])
+def test_second_level_voxelwise_attribute_errors(attribute):
+    """Tests that an error is raised when trying to access
+    voxelwise attributes before fitting the model, before
+    computing a contrast, and when not setting
+    ``minimize_memory`` to ``True``.
+    """
+    shapes = ((7, 8, 9, 1),)
+    mask, fmri_data, _ = generate_fake_fmri_data_and_design(shapes)
+    model = SecondLevelModel(mask_img=mask, minimize_memory=False)
+    with pytest.raises(ValueError, match="The model has no results."):
+        getattr(model, attribute)
+    Y = fmri_data * 4
+    X = pd.DataFrame([[1]] * 4, columns=['intercept'])
+    model.fit(Y, design_matrix=X)
+    with pytest.raises(ValueError, match="The model has no results."):
+        getattr(model, attribute)
+    with pytest.raises(ValueError, match="attribute must be one of"):
+        model._get_voxelwise_model_attribute("foo", True)
+    model = SecondLevelModel(mask_img=mask, minimize_memory=True)
+    model.fit(Y, design_matrix=X)
+    model.compute_contrast()
+    with pytest.raises(ValueError,
+                       match="To access voxelwise attributes"):
+        getattr(model, attribute)
+
+
+@pytest.mark.parametrize("attribute", ["residuals", "predicted", "r_square"])
+def test_second_level_voxelwise_attribute(attribute):
+    """Smoke test for voxelwise attributes for SecondLevelModel."""
+    shapes = ((7, 8, 9, 1),)
+    mask, fmri_data, _ = generate_fake_fmri_data_and_design(shapes)
+    model = SecondLevelModel(mask_img=mask, minimize_memory=False)
+    Y = fmri_data * 4
+    X = pd.DataFrame([[1]] * 4, columns=['intercept'])
+    model.fit(Y, design_matrix=X)
+    model.compute_contrast()
+    getattr(model, attribute)
+
+
+def test_second_level_residuals():
+    """Tests residuals computation for SecondLevelModel."""
+    shapes = ((7, 8, 9, 1),)
+    mask, fmri_data, _ = generate_fake_fmri_data_and_design(shapes)
+    model = SecondLevelModel(mask_img=mask, minimize_memory=False)
+    Y = fmri_data * 4
+    X = pd.DataFrame([[1]] * 4, columns=['intercept'])
+    model.fit(Y, design_matrix=X)
+    model.compute_contrast()
+    assert isinstance(model.residuals, Nifti1Image)
+    assert model.residuals.shape == (7, 8, 9, 4)
+    mean_residuals = model.masker_.transform(model.residuals).mean(0)
+    assert_array_almost_equal(mean_residuals, 0)
+
+
 def test_non_parametric_inference_permutation_computation():
     with InTemporaryDirectory():
         shapes = ((7, 8, 9, 1),)