Repository: nilearn/nilearn

Issue:
Generalising img_to_signal_labels to reduce with funcs other than the mean?
What would you like changed/added and why?

I've been working with statistical images using NiftiLabelsMasker. For a personal need, I had to generalise the `img_to_signal_labels` to reduce ROIs to a scalar value using the max and the std, instead of the mean.
In essence, [this part of the function here had to change to](https://github.com/nilearn/nilearn/blob/master/nilearn/regions/signal_extraction.py#L107):

```python
for n, img in enumerate(np.rollaxis(data, -1)):
    signals[n] = np.asarray(measurement_func(img,
                                             labels=labels_data,
                                             index=labels))
```
where `measurement_func` is checked previously as:

```python
# Check func
acceptable_funcs = ('mean', 'minimum', 'maximum', 'standard_deviation',
                    'variance', 'median')
if func in acceptable_funcs:
    measurement_func = getattr(ndimage.measurements, func)
else:
    raise ValueError("func not in %s" % (acceptable_funcs,))
```

I was wondering if this would merit a PR at all, and that's why I opened the issue. And if there is interest, should the user also be allowed to pass his/her own functions, as long as they obey the same signature as `ndimage.measurements.mean(img, labels=labels_data, index=labels)`?

What would be the benefit? Does the change make something easier to use?

I can think of all sorts of applications. One of them would be using the max and the standard_deviation to look at spikes within ROIs, comparing ROIs in general, or for studying the effects of regressing out confounds, i.e. general Q.A.



Discussion:
I think that this deserves a PR.  Thx for proposing.

+1


Resolving patch:
diff --git a/nilearn/input_data/nifti_labels_masker.py b/nilearn/input_data/nifti_labels_masker.py
--- a/nilearn/input_data/nifti_labels_masker.py
+++ b/nilearn/input_data/nifti_labels_masker.py
@@ -19,16 +19,17 @@ class _ExtractionFunctor(object):
 
     func_name = 'nifti_labels_masker_extractor'
 
-    def __init__(self, _resampled_labels_img_, background_label):
+    def __init__(self, _resampled_labels_img_, background_label, strategy):
         self._resampled_labels_img_ = _resampled_labels_img_
         self.background_label = background_label
+        self.strategy = strategy
 
     def __call__(self, imgs):
         from ..regions import signal_extraction
 
         return signal_extraction.img_to_signals_labels(
             imgs, self._resampled_labels_img_,
-            background_label=self.background_label)
+            background_label=self.background_label, strategy=self.strategy)
 
 
 class NiftiLabelsMasker(BaseMasker, CacheMixin):
@@ -107,6 +108,11 @@ class NiftiLabelsMasker(BaseMasker, CacheMixin):
     verbose: integer, optional
         Indicate the level of verbosity. By default, nothing is printed
 
+    strategy: str
+        The name of a valid function to reduce the region with.
+        Must be one of: sum, mean, median, mininum, maximum, variance,
+        standard_deviation
+
     See also
     --------
     nilearn.input_data.NiftiMasker
@@ -118,7 +124,7 @@ def __init__(self, labels_img, background_label=0, mask_img=None,
                  low_pass=None, high_pass=None, t_r=None, dtype=None,
                  resampling_target="data",
                  memory=Memory(cachedir=None, verbose=0), memory_level=1,
-                 verbose=0):
+                 verbose=0, strategy="mean"):
         self.labels_img = labels_img
         self.background_label = background_label
         self.mask_img = mask_img
@@ -142,6 +148,19 @@ def __init__(self, labels_img, background_label=0, mask_img=None,
         self.memory_level = memory_level
         self.verbose = verbose
 
+        available_reduction_strategies = {'mean', 'median', 'sum',
+                                          'minimum', 'maximum',
+                                          'standard_deviation', 'variance'}
+
+        if strategy not in available_reduction_strategies:
+            raise ValueError(str.format(
+                "Invalid strategy '{}'. Valid strategies are {}.",
+                strategy,
+                available_reduction_strategies
+            ))
+
+        self.strategy = strategy
+
         if resampling_target not in ("labels", "data", None):
             raise ValueError("invalid value for 'resampling_target' "
                              "parameter: " + str(resampling_target))
@@ -264,7 +283,7 @@ def transform_single_imgs(self, imgs, confounds=None):
                 ignore=['verbose', 'memory', 'memory_level'])(
             # Images
             imgs, _ExtractionFunctor(self._resampled_labels_img_,
-                                     self.background_label),
+                                     self.background_label, self.strategy),
             # Pre-processing
             params,
             confounds=confounds,
diff --git a/nilearn/regions/signal_extraction.py b/nilearn/regions/signal_extraction.py
--- a/nilearn/regions/signal_extraction.py
+++ b/nilearn/regions/signal_extraction.py
@@ -18,7 +18,7 @@
 
 # FIXME: naming scheme is not really satisfying. Any better idea appreciated.
 def img_to_signals_labels(imgs, labels_img, mask_img=None,
-                          background_label=0, order="F"):
+                          background_label=0, order="F", strategy='mean'):
     """Extract region signals from image.
 
     This function is applicable to regions defined by labels.
@@ -48,6 +48,11 @@ def img_to_signals_labels(imgs, labels_img, mask_img=None,
     order: str
         ordering of output array ("C" or "F"). Defaults to "F".
 
+    strategy: str
+        The name of a valid function to reduce the region with.
+        Must be one of: sum, mean, median, mininum, maximum, variance,
+        standard_deviation
+
     Returns
     -------
     signals: numpy.ndarray
@@ -64,7 +69,7 @@ def img_to_signals_labels(imgs, labels_img, mask_img=None,
     --------
     nilearn.regions.signals_to_img_labels
     nilearn.regions.img_to_signals_maps
-    nilearn.input_data.NiftiLabelsMasker : Signal extraction on labels images 
+    nilearn.input_data.NiftiLabelsMasker : Signal extraction on labels images
         e.g. clusters
     """
 
@@ -76,6 +81,16 @@ def img_to_signals_labels(imgs, labels_img, mask_img=None,
     target_affine = imgs.affine
     target_shape = imgs.shape[:3]
 
+    available_reduction_strategies = {'mean', 'median', 'sum',
+                                      'minimum', 'maximum',
+                                      'standard_deviation', 'variance'}
+    if strategy not in available_reduction_strategies:
+        raise ValueError(str.format(
+            "Invalid strategy '{}'. Valid strategies are {}.",
+            strategy,
+            available_reduction_strategies
+        ))
+
     # Check shapes and affines.
     if labels_img.shape != target_shape:
         raise ValueError("labels_img and imgs shapes must be identical.")
@@ -103,10 +118,12 @@ def img_to_signals_labels(imgs, labels_img, mask_img=None,
     data = _safe_get_data(imgs)
     signals = np.ndarray((data.shape[-1], len(labels)), order=order,
                          dtype=data.dtype)
+
+    reduction_function = getattr(ndimage.measurements, strategy)
     for n, img in enumerate(np.rollaxis(data, -1)):
-        signals[n] = np.asarray(ndimage.measurements.mean(img,
-                                                          labels=labels_data,
-                                                          index=labels))
+        signals[n] = np.asarray(reduction_function(img,
+                                                   labels=labels_data,
+                                                   index=labels))
     # Set to zero signals for missing labels. Workaround for Scipy behaviour
     missing_labels = set(labels) - set(np.unique(labels_data))
     labels_index = dict([(l, n) for n, l in enumerate(labels)])


Test patch:
diff --git a/nilearn/input_data/tests/test_nifti_labels_masker.py b/nilearn/input_data/tests/test_nifti_labels_masker.py
--- a/nilearn/input_data/tests/test_nifti_labels_masker.py
+++ b/nilearn/input_data/tests/test_nifti_labels_masker.py
@@ -6,6 +6,7 @@
 """
 
 from nose.tools import assert_raises, assert_equal, assert_true
+import pytest
 import numpy as np
 
 import nibabel
@@ -125,6 +126,49 @@ def test_nifti_labels_masker_with_nans_and_infs():
     assert_true(np.all(np.isfinite(sig)))
 
 
+def test_nifti_labels_masker_reduction_strategies():
+    """Tests:
+    1. whether the usage of different reduction strategies work.
+    2. whether unrecognised strategies raise a ValueError
+    3. whether the default option is backwards compatible (calls "mean")
+    """
+    test_values = [-2., -1., 0., 1., 2]
+
+    img_data = np.array([[test_values,
+                          test_values]])
+
+    labels_data = np.array([[[0, 0, 0, 0, 0],
+                             [1, 1, 1, 1, 1]]], dtype=np.int8)
+
+    affine = np.eye(4)
+    img = nibabel.Nifti1Image(img_data, affine)
+    labels = nibabel.Nifti1Image(labels_data, affine)
+
+    # What NiftiLabelsMasker should return for each reduction strategy?
+    expected_results = {"mean": np.mean(test_values),
+                        "median": np.median(test_values),
+                        "sum": np.sum(test_values),
+                        "minimum": np.min(test_values),
+                        "maximum": np.max(test_values),
+                        "standard_deviation": np.std(test_values),
+                        "variance": np.var(test_values)}
+
+    for strategy, expected_result in expected_results.items():
+        masker = NiftiLabelsMasker(labels, strategy=strategy)
+        # Here passing [img] within a list because it's a 3D object.
+        result = masker.fit_transform([img]).squeeze()
+        assert result == expected_result
+
+    with pytest.raises(ValueError, match="Invalid strategy 'TESTRAISE'"):
+        NiftiLabelsMasker(
+            labels,
+            strategy="TESTRAISE"
+        )
+
+    default_masker = NiftiLabelsMasker(labels)
+    assert default_masker.strategy == "mean"
+
+
 def test_nifti_labels_masker_resampling():
     # Test resampling in NiftiLabelsMasker
     shape1 = (10, 11, 12)