Repository: nilearn/nilearn

Issue:
signal clean should not use population standard deviation
Nilearn version: 0.9.2

In signal.clean, the calculation simply uses numpy.std, this method by default uses N / ddf=0 for calculation, indicating population std. For fMRI studies, especially when std are calculated along time axis, the data is usually a sample of the population. The method should use sample std instead.

### Expected behavior
Given dataframe

1  4  7
2  5  8
3  6  9

z-scored sample should be:

(anp - anp.mean(axis=0))/anp.std(axis=0, ddof=1)
array([[-1., -1., -1.],
       [ 0.,  0.,  0.],
       [ 1.,  1.,  1.]])

### Actual behavior
nilearn.signal.clean(anp)
array([[-1.2247448, -1.2247448, -1.2247448],
       [ 0.       ,  0.       ,  0.       ],
       [ 1.2247448,  1.2247448,  1.2247448]], dtype=float32)

### Steps and code to reproduce bug

I think it would be beneficial if we change the default behavior.
However, given the scope of this change, I think it's worth adding the ddof as an option instead of changing the default action.

Comments:

Patch:
diff --git a/nilearn/signal.py b/nilearn/signal.py
index 5f5b85c6a..0de6716fc 100644
--- a/nilearn/signal.py
+++ b/nilearn/signal.py
@@ -37,11 +37,14 @@ def _standardize(signals, detrend=False, standardize='zscore'):
         If detrending of timeseries is requested.
         Default=False.
 
-    standardize : {'zscore', 'psc', True, False}, optional
+    standardize : {'zscore_sample', 'zscore', 'psc', True, False}, optional
         Strategy to standardize the signal:
 
+            - 'zscore_sample': The signal is z-scored. Timeseries are shifted
+              to zero mean and scaled to unit variance. Uses sample std.
             - 'zscore': The signal is z-scored. Timeseries are shifted
-              to zero mean and scaled to unit variance.
+              to zero mean and scaled to unit variance. Uses population std
+              by calling default :obj:`numpy.std` with N - ``ddof=0``.
             - 'psc':  Timeseries are shifted to zero mean value and scaled
               to percent signal change (as compared to original mean signal).
             - True: The signal is z-scored (same as option `zscore`).
@@ -55,7 +58,7 @@ def _standardize(signals, detrend=False, standardize='zscore'):
     std_signals : :class:`numpy.ndarray`
         Copy of signals, standardized.
     """
-    if standardize not in [True, False, 'psc', 'zscore']:
+    if standardize not in [True, False, 'psc', 'zscore', 'zscore_sample']:
         raise ValueError('{} is no valid standardize strategy.'
                          .format(standardize))
 
@@ -70,13 +73,36 @@ def _standardize(signals, detrend=False, standardize='zscore'):
                           'would lead to zero values. Skipping.')
             return signals
 
+        elif (standardize == 'zscore_sample'):
+            if not detrend:
+                # remove mean if not already detrended
+                signals = signals - signals.mean(axis=0)
+
+            std = signals.std(axis=0, ddof=1)
+            # avoid numerical problems
+            std[std < np.finfo(np.float64).eps] = 1.
+            signals /= std
+
         elif (standardize == 'zscore') or (standardize is True):
+            std_strategy_default = (
+                "The default strategy for standardize is currently 'zscore' "
+                "which incorrectly uses population std to calculate sample "
+                "zscores. The new strategy 'zscore_sample' corrects this "
+                "behavior by using the sample std. In release 0.13, the "
+                "default strategy will be replaced by the new strategy and "
+                "the 'zscore' option will be removed. Please use "
+                "'zscore_sample' instead."
+            )
+            warnings.warn(category=FutureWarning,
+                          message=std_strategy_default,
+                          stacklevel=3)
             if not detrend:
                 # remove mean if not already detrended
                 signals = signals - signals.mean(axis=0)
 
             std = signals.std(axis=0)
-            std[std < np.finfo(np.float64).eps] = 1.  # avoid numerical problems
+            # avoid numerical problems
+            std[std < np.finfo(np.float64).eps] = 1.
             signals /= std
 
         elif standardize == 'psc':
@@ -609,11 +635,14 @@ def clean(signals, runs=None, detrend=True, standardize='zscore',
 
     %(high_pass)s
     %(detrend)s
-    standardize : {'zscore', 'psc', False}, optional
+    standardize : {'zscore_sample', 'zscore', 'psc', True, False}, optional
         Strategy to standardize the signal:
 
+            - 'zscore_sample': The signal is z-scored. Timeseries are shifted
+              to zero mean and scaled to unit variance. Uses sample std.
             - 'zscore': The signal is z-scored. Timeseries are shifted
-              to zero mean and scaled to unit variance.
+              to zero mean and scaled to unit variance. Uses population std
+              by calling default :obj:`numpy.std` with N - ``ddof=0``.
             - 'psc':  Timeseries are shifted to zero mean value and scaled
               to percent signal change (as compared to original mean signal).
             - True: The signal is z-scored (same as option `zscore`).


Test patch:
diff --git a/doc/changes/latest.rst b/doc/changes/latest.rst
index c33b0a80b..1eeb02a23 100644
--- a/doc/changes/latest.rst
+++ b/doc/changes/latest.rst
@@ -17,9 +17,11 @@ Fixes
 
 - :func:`~nilearn.interfaces.fmriprep.load_confounds` can support searching preprocessed data in native space. (:gh:`3531` by `Hao-Ting Wang`_)
 
+- Add correct "zscore_sample" strategy to ``signal._standardize`` which will replace the default "zscore" strategy in release 0.13  (:gh:`3474` by `Yasmin Mzayek`_).
 
 Enhancements
 ------------
+
 - Updated example :ref:`sphx_glr_auto_examples_02_decoding_plot_haxby_frem.py` to include section on plotting a confusion matrix from a decoder family object (:gh:`3483` by `Michelle Wang`_).
 
 - Surface plotting methods no longer automatically rescale background maps, which, among other things, allows to use curvature sign as a background map (:gh:`3173` by `Alexis Thual`_).
diff --git a/nilearn/tests/test_signal.py b/nilearn/tests/test_signal.py
index 3149326e2..d8274981e 100644
--- a/nilearn/tests/test_signal.py
+++ b/nilearn/tests/test_signal.py
@@ -251,6 +251,14 @@ def test_standardize():
     a = rng.random_sample((n_samples, n_features))
     a += np.linspace(0, 2., n_features)
 
+    # Test raise error when strategy is not valid option
+    with pytest.raises(ValueError, match="no valid standardize strategy"):
+        nisignal._standardize(a, standardize="foo")
+
+    # test warning for strategy that will be removed
+    with pytest.warns(FutureWarning, match="default strategy for standardize"):
+        nisignal._standardize(a, standardize="zscore")
+
     # transpose array to fit _standardize input.
     # Without trend removal
     b = nisignal._standardize(a, standardize='zscore')
@@ -258,16 +266,32 @@ def test_standardize():
     np.testing.assert_almost_equal(stds, np.ones(n_features))
     np.testing.assert_almost_equal(b.sum(axis=0), np.zeros(n_features))
 
+    # Repeating test above but for new correct strategy
+    b = nisignal._standardize(a, standardize='zscore_sample')
+    stds = np.std(b)
+    np.testing.assert_almost_equal(stds, np.ones(n_features), decimal=1)
+    np.testing.assert_almost_equal(b.sum(axis=0), np.zeros(n_features))
+
     # With trend removal
     a = np.atleast_2d(np.linspace(0, 2., n_features)).T
     b = nisignal._standardize(a, detrend=True, standardize=False)
     np.testing.assert_almost_equal(b, np.zeros(b.shape))
 
+    b = nisignal._standardize(a, detrend=True, standardize="zscore_sample")
+    np.testing.assert_almost_equal(b, np.zeros(b.shape))
+
     length_1_signal = np.atleast_2d(np.linspace(0, 2., n_features))
     np.testing.assert_array_equal(length_1_signal,
                                   nisignal._standardize(length_1_signal,
                                                         standardize='zscore'))
 
+    # Repeating test above but for new correct strategy
+    length_1_signal = np.atleast_2d(np.linspace(0, 2., n_features))
+    np.testing.assert_array_equal(
+        length_1_signal,
+        nisignal._standardize(length_1_signal, standardize="zscore_sample")
+    )
+
 
 def test_detrend():
     """Test custom detrend implementation."""
@@ -827,9 +851,18 @@ def test_clean_zscore():
                                      length=n_samples)
 
     signals += rng.standard_normal(size=(1, n_features))
-    cleaned_signals = clean(signals, standardize='zscore')
+    cleaned_signals_ = clean(signals, standardize='zscore')
+    np.testing.assert_almost_equal(cleaned_signals_.mean(0), 0)
+    np.testing.assert_almost_equal(cleaned_signals_.std(0), 1)
+
+    # Repeating test above but for new correct strategy
+    cleaned_signals = clean(signals, standardize='zscore_sample')
     np.testing.assert_almost_equal(cleaned_signals.mean(0), 0)
-    np.testing.assert_almost_equal(cleaned_signals.std(0), 1)
+    np.testing.assert_almost_equal(cleaned_signals.std(0), 1, decimal=3)
+
+    # Show outcome from two zscore strategies is not equal
+    with pytest.raises(AssertionError):
+        np.testing.assert_array_equal(cleaned_signals_, cleaned_signals)
 
 
 def test_create_cosine_drift_terms():