Repository: nilearn/nilearn

Issue:
More diverse avg_method for plot_surf to handle sparse matrices
<!--Provide a brief description what you would like changes and why.-->

Currently, the `avg_method` parameter in `plotting.plot_surf` only takes `mean` or `median` as arguments.
Having the possibility `min`, `max` or other functions would be useful to me, in the sense that sometimes I have some sparse surface matrices, therefore using `mean` or `median` kind of kills my plots :smile: 

<!--Please fill in the following information, to the best of your ability.-->
### Benefits to the change

It would allow for a better representation of sparse matrices on the surface.

### Pseudocode for the new behavior, if applicable

```python
import matplotlib.pyplot as plt
from nilearn import datasets, plotting
import numpy as np

fsaverage = datasets.fetch_surf_fsaverage()
n_voxels = 10242

surface = np.zeros(n_voxels)
n = 100
surface[:n] = np.ones(n)

plotting.plot_surf(
    fsaverage.pial_left,
    surface,
    avg_method="min"
)
plt.show()
```



Discussion:
Here is the part of the code implementing these:

https://github.com/nilearn/nilearn/blob/b08d1fea1857f988b0b916e6176235b1912bcaf5/nilearn/plotting/surf_plotting.py#L268-L273

I think having the possibility of specifying `min` or `max` would be a good addition indeed :+1: 

Resolving patch:
diff --git a/nilearn/plotting/surf_plotting.py b/nilearn/plotting/surf_plotting.py
--- a/nilearn/plotting/surf_plotting.py
+++ b/nilearn/plotting/surf_plotting.py
@@ -72,9 +72,18 @@ def plot_surf(surf_mesh, surf_map=None, bg_map=None,
     colorbar : bool, optional
         If True, a colorbar of surf_map is displayed. Default=False.
 
-    avg_method : {'mean', 'median'}, optional
-        How to average vertex values to derive the face value, mean results
-        in smooth, median in sharp boundaries. Default='mean'.
+    avg_method : {'mean', 'median', 'min', 'max', custom function}, optional
+        How to average vertex values to derive the face value,
+        mean results in smooth, median in sharp boundaries,
+        min or max for sparse matrices.
+        You can also pass a custom function which will be
+        executed though `numpy.apply_along_axis`.
+        Here is an example of a custom function:
+        ```
+        def custom_function(vertices):
+            return vertices[0] * vertices[1] * vertices[2]
+        ```.
+        Default='mean'.
 
     threshold : a number or None, default is None.
         If None is given, the image is not thresholded.
@@ -272,6 +281,43 @@ def plot_surf(surf_mesh, surf_map=None, bg_map=None,
             surf_map_faces = np.mean(surf_map_data[faces], axis=1)
         elif avg_method == 'median':
             surf_map_faces = np.median(surf_map_data[faces], axis=1)
+        elif avg_method == 'min':
+            surf_map_faces = np.min(surf_map_data[faces], axis=1)
+        elif avg_method == 'max':
+            surf_map_faces = np.max(surf_map_data[faces], axis=1)
+        elif callable(avg_method):
+            surf_map_faces = np.apply_along_axis(
+                avg_method, 1, surf_map_data[faces]
+            )
+
+            ## check that surf_map_faces has the same length as face_colors
+            if surf_map_faces.shape != (face_colors.shape[0],):
+                raise ValueError(
+                    'Array computed with the custom function '
+                    'from avg_method does not have the correct shape: '
+                    '{} != {}'.format(
+                        surf_map_faces.shape[0],
+                        face_colors.shape[0]
+                    )
+                )
+
+            ## check that dtype is either int or float
+            if not (
+                "int" in str(surf_map_faces.dtype) or
+                "float" in str(surf_map_faces.dtype)
+            ):
+                raise ValueError(
+                    'Array computed with the custom function '
+                    'from avg_method should be an array of numbers '
+                    '(int or float)'
+                )
+
+        else:
+            raise ValueError(
+                "avg_method should be either "
+                "['mean', 'median', 'max', 'min'] "
+                "or a custom function"
+            )
 
         # if no vmin/vmax are passed figure them out from data
         if vmin is None:


Test patch:
diff --git a/nilearn/plotting/tests/test_surf_plotting.py b/nilearn/plotting/tests/test_surf_plotting.py
--- a/nilearn/plotting/tests/test_surf_plotting.py
+++ b/nilearn/plotting/tests/test_surf_plotting.py
@@ -3,6 +3,7 @@
 import nibabel
 import matplotlib.pyplot as plt
 import pytest
+import re
 import tempfile
 
 from nilearn.plotting.img_plotting import MNI152TEMPLATE
@@ -10,7 +11,9 @@
                                             plot_surf_roi, plot_img_on_surf,
                                             plot_surf_contours)
 from nilearn.datasets import fetch_surf_fsaverage
+from nilearn.surface import load_surf_mesh
 from nilearn.surface.testing_utils import generate_surf
+from numpy.testing import assert_array_equal
 
 
 def test_plot_surf():
@@ -36,6 +39,41 @@ def test_plot_surf():
     plot_surf(mesh, bg_map=bg, colorbar=True, cbar_vmin=0,
               cbar_vmax=150, cbar_tick_format="%i")
 
+    # Plot with avg_method
+    ## Test all built-in methods and check
+    mapp = rng.standard_normal(size=mesh[0].shape[0])
+    mesh_ = load_surf_mesh(mesh)
+    coords, faces = mesh_[0], mesh_[1]
+
+    for method in ['mean', 'median', 'min', 'max']:
+        display = plot_surf(mesh, surf_map=mapp, avg_method=method)
+        if method == 'mean':
+            agg_faces = np.mean(mapp[faces], axis=1)
+        elif method == 'median':
+            agg_faces = np.median(mapp[faces], axis=1)
+        elif method == 'min':
+            agg_faces = np.min(mapp[faces], axis=1)
+        elif method == 'max':
+            agg_faces = np.max(mapp[faces], axis=1)
+        vmin = np.min(agg_faces)
+        vmax = np.max(agg_faces)
+        agg_faces -= vmin
+        agg_faces /= (vmax - vmin)
+        cmap = plt.cm.get_cmap(plt.rcParamsDefault['image.cmap'])
+        assert_array_equal(
+            cmap(agg_faces),
+            display._axstack.as_list()[0].collections[0]._facecolors
+        )
+
+    ## Try custom avg_method
+    def custom_avg_function(vertices):
+        return vertices[0] * vertices[1] * vertices[2]
+    plot_surf(
+        mesh,
+        surf_map=rng.standard_normal(size=mesh[0].shape[0]),
+        avg_method=custom_avg_function
+    )
+
     # Save execution time and memory
     plt.close()
 
@@ -71,6 +109,43 @@ def test_plot_surf_error():
             mesh, surf_map=rng.standard_normal(size=(mesh[0].shape[0], 2))
         )
 
+    with pytest.raises(
+        ValueError,
+        match=(
+            "Array computed with the custom "
+            "function from avg_method does "
+            "not have the correct shape"
+        )
+    ):
+        def custom_avg_function(vertices):
+            return [vertices[0] * vertices[1], vertices[2]]
+
+        plot_surf(mesh, surf_map=rng.standard_normal(size=mesh[0].shape[0]), avg_method=custom_avg_function)
+
+    with pytest.raises(
+        ValueError,
+        match=re.escape(
+            "avg_method should be either "
+            "['mean', 'median', 'max', 'min'] "
+            "or a custom function"
+        )
+    ):
+        custom_avg_function = dict()
+
+        plot_surf(mesh, surf_map=rng.standard_normal(size=mesh[0].shape[0]), avg_method=custom_avg_function)
+
+    with pytest.raises(
+        ValueError,
+        match=re.escape(
+            "Array computed with the custom function "
+            "from avg_method should be an array of "
+            "numbers (int or float)"
+        )
+    ):
+        def custom_avg_function(vertices):
+            return "string"
+
+        plot_surf(mesh, surf_map=rng.standard_normal(size=mesh[0].shape[0]), avg_method=custom_avg_function)
 
 def test_plot_surf_stat_map():
     mesh = generate_surf()