Repository: nilearn/nilearn

Issue:
`explained_variance` yields spurious results when `per_component`=False
Due to this, score functions of canica and dict_learning are useless: they most often return 0.
Can be checked after running 
https://nilearn.github.io/auto_examples/03_connectivity/plot_compare_decomposition.html#sphx-glr-auto-examples-03-connectivity-plot-compare-decomposition-py
 


Discussion:

Resolving patch:
diff --git a/examples/03_connectivity/plot_compare_decomposition.py b/examples/03_connectivity/plot_compare_decomposition.py
--- a/examples/03_connectivity/plot_compare_decomposition.py
+++ b/examples/03_connectivity/plot_compare_decomposition.py
@@ -129,6 +129,25 @@
     plot_stat_map(cur_img, display_mode="z", title="Comp %d" % i,
                   cut_coords=1, colorbar=False)
 
+###############################################################################
+# Estimate explained variance per component and plot using matplotlib
+#
+# The fitted object `dict_learning` can be used to calculate the score per component
+scores = dict_learning.score(func_filenames, per_component=True)
+
+# Plot the scores
+import numpy as np
+from matplotlib import pyplot as plt
+from matplotlib.ticker import FormatStrFormatter
+
+plt.figure(figsize=(4, 4))
+positions = np.arange(len(scores))
+plt.barh(positions, scores)
+plt.ylabel('Component #', size=12)
+plt.xlabel('Explained variance', size=12)
+plt.yticks(np.arange(20))
+plt.gca().xaxis.set_major_formatter(FormatStrFormatter('%.3f'))
+plt.tight_layout()
 
 show()
 ################################################################################
@@ -138,4 +157,3 @@
 #     created using Dictionary Learning, see :ref:`example Regions 
 #     extraction using Dictionary Learning and functional connectomes
 #     <sphx_glr_auto_examples_03_connectivity_plot_extract_regions_dictlearning_maps.py>`.
-
diff --git a/nilearn/decomposition/base.py b/nilearn/decomposition/base.py
--- a/nilearn/decomposition/base.py
+++ b/nilearn/decomposition/base.py
@@ -23,6 +23,7 @@
 from .._utils.niimg_conversions import _resolve_globbing
 from ..input_data import NiftiMapsMasker
 from ..input_data.masker_validation import check_embedded_nifti_masker
+from ..signal import _row_sum_of_squares
 
 
 def fast_svd(X, n_components, random_state=None):
@@ -493,7 +494,7 @@ def _raw_score(self, data, per_component=True):
         return self._cache(explained_variance)(data, self.components_,
                                                per_component=per_component)
 
-    def score(self, imgs, confounds=None):
+    def score(self, imgs, confounds=None, per_component=False):
         """Score function based on explained variance on imgs.
 
         Should only be used by DecompositionEstimator derived classes
@@ -508,6 +509,10 @@ def score(self, imgs, confounds=None):
             This parameter is passed to nilearn.signal.clean. Please see the
             related documentation for details
 
+        per_component: bool, default False
+            Specify whether the explained variance ratio is desired for each
+            map or for the global set of components
+
         Returns
         -------
         score: float,
@@ -515,9 +520,11 @@ def score(self, imgs, confounds=None):
             if per_component is True. First dimension
             is squeezed if the number of subjects is one
         """
+        self._check_components_()
         data = mask_and_reduce(self.masker_, imgs, confounds,
-                               reduction_ratio=1.)
-        return self._raw_score(data, per_component=False)
+                               reduction_ratio=1.,
+                               random_state=self.random_state)
+        return self._raw_score(data, per_component=per_component)
 
 
 def explained_variance(X, components, per_component=True):
@@ -550,11 +557,12 @@ def explained_variance(X, components, per_component=True):
             res = X - np.outer(projected_data[i],
                                components[i])
             res_var[i] = np.var(res)
+            # Free some memory
+            del res
         return np.maximum(0., 1. - res_var / full_var)
     else:
         lr = LinearRegression(fit_intercept=True)
         lr.fit(components.T, X.T)
-        res_var = X - lr.coef_.dot(components)
-        res_var **= 2
-        res_var = np.sum(res_var)
-        return np.maximum(0., 1. - res_var / full_var)
+        res = X - lr.coef_.dot(components)
+        res_var = _row_sum_of_squares(res).sum()
+        return np.maximum(0., 1. - res_var / _row_sum_of_squares(X).sum())
diff --git a/nilearn/signal.py b/nilearn/signal.py
--- a/nilearn/signal.py
+++ b/nilearn/signal.py
@@ -120,6 +120,38 @@ def _mean_of_squares(signals, n_batches=20):
     return var
 
 
+def _row_sum_of_squares(signals, n_batches=20):
+    """Compute sum of squares for each signal.
+    This function is equivalent to
+
+        signals **= 2
+        signals = signals.sum(axis=0)
+
+    but uses a lot less memory.
+
+    Parameters
+    ----------
+    signals : numpy.ndarray, shape (n_samples, n_features)
+        signal whose sum of squares must be computed.
+
+    n_batches : int, optional
+        number of batches to use in the computation. Tweaking this value
+        can lead to variation of memory usage and computation time. The higher
+        the value, the lower the memory consumption.
+
+    """
+    # No batching for small arrays
+    if signals.shape[1] < 500:
+        n_batches = 1
+
+    # Fastest for C order
+    var = np.empty(signals.shape[1])
+    for batch in gen_even_slices(signals.shape[1], n_batches):
+        var[batch] = np.sum(signals[:, batch] ** 2, 0)
+
+    return var
+
+
 def _detrend(signals, inplace=False, type="linear", n_batches=10):
     """Detrend columns of input array.
 


Test patch:
diff --git a/nilearn/decomposition/tests/test_canica.py b/nilearn/decomposition/tests/test_canica.py
--- a/nilearn/decomposition/tests/test_canica.py
+++ b/nilearn/decomposition/tests/test_canica.py
@@ -234,3 +234,22 @@ def test_with_globbing_patterns_with_multi_subjects():
         # n_components = 3
         check_shape = data[0].shape[:3] + (3,)
         assert components_img.shape, check_shape
+
+
+def test_canica_score():
+    # Multi subjects
+    imgs, mask_img, _, _ = _make_canica_test_data(n_subjects=3)
+    n_components = 10
+    canica = CanICA(n_components=10, mask=mask_img, random_state=0)
+    canica.fit(imgs)
+
+    # One score for all components
+    scores = canica.score(imgs, per_component=False)
+    assert scores <= 1
+    assert 0 <= scores
+
+    # Per component score
+    scores = canica.score(imgs, per_component=True)
+    assert scores.shape, (n_components,)
+    assert np.all(scores <= 1)
+    assert np.all(0 <= scores)
diff --git a/nilearn/decomposition/tests/test_dict_learning.py b/nilearn/decomposition/tests/test_dict_learning.py
--- a/nilearn/decomposition/tests/test_dict_learning.py
+++ b/nilearn/decomposition/tests/test_dict_learning.py
@@ -149,3 +149,30 @@ def test_with_globbing_patterns_with_multi_subjects():
         # n_components = 3
         check_shape = data[0].shape[:3] + (3,)
         assert components_img.shape, check_shape
+
+
+def test_dictlearning_score():
+    # Multi subjects
+    imgs, mask_img, _, _ = _make_canica_test_data(n_subjects=3)
+    n_components = 10
+    dictlearn = DictLearning(n_components=10, mask=mask_img, random_state=0)
+
+    # Test error before object fit
+    with pytest.raises(
+            ValueError,
+            match="Object has no components_ attribute. "
+                  "This is probably because fit has not been called"):
+        dictlearn.score(imgs, per_component=False)
+
+    dictlearn.fit(imgs)
+
+    # One score for all components
+    scores = dictlearn.score(imgs, per_component=False)
+    assert scores <= 1
+    assert 0 <= scores
+
+    # Per component score
+    scores = dictlearn.score(imgs, per_component=True)
+    assert scores.shape, (n_components,)
+    assert np.all(scores <= 1)
+    assert np.all(0 <= scores)
diff --git a/nilearn/tests/test_signal.py b/nilearn/tests/test_signal.py
--- a/nilearn/tests/test_signal.py
+++ b/nilearn/tests/test_signal.py
@@ -266,6 +266,22 @@ def test_mean_of_squares():
     np.testing.assert_almost_equal(var1, var2)
 
 
+def test_row_sum_of_squares():
+    """Test _row_sum_of_squares."""
+    n_samples = 11
+    n_features = 501  # Higher than 500 required
+    signals, _, _ = generate_signals(n_features=n_features,
+                                     length=n_samples,
+                                     same_variance=True)
+    # Reference computation
+    var1 = signals ** 2
+    var1 = var1.sum(axis=0)
+
+    var2 = nisignal._row_sum_of_squares(signals)
+
+    np.testing.assert_almost_equal(var1, var2)
+
+
 # This test is inspired from Scipy docstring of detrend function
 def test_clean_detrending():
     n_samples = 21