Repository: nilearn/nilearn

Issue:
nilearn.signal PSC standardise option
<!--Provide a brief description what you would like changes and why.-->

While working on https://github.com/SIMEXP/load_confounds/pull/96, the new test data led to a test for denoising using PSC strategy failed https://github.com/SIMEXP/load_confounds/issues/105. Our test data contain confounds with negative mean. The current implementation of `standardise="psc"` in `nilearn.signal` sets confound variables with a mean of 0 or less.
https://github.com/nilearn/nilearn/blob/02e915accc1dd29ee8af0e92004eabb4f0393958/nilearn/signal.py#L77-L81
@pbellec and I believe the change after the warning only need to apply to variable with mean of 0.

<!--Please fill in the following information, to the best of your ability.-->
### Benefits to the change
There's no reason why (that I know of) you cannot calculate percent signal change with a mean signal below zero.
PSC standardise option is essentially a mean centred metric with scaling since we can rewrite the current code from:
https://github.com/nilearn/nilearn/blob/02e915accc1dd29ee8af0e92004eabb4f0393958/nilearn/signal.py#L74-L75
to
```python
signals = ( (signals - mean_signal) / mean_signal ) * 100
```
The divisor should be changed to the absolute value of `mean_signal` to account for `mean_signal` < 0.
And the `invalid_ix` should check for value of zero or close to zero.

### Pseudocode for the new behavior, if applicable
Change this part
https://github.com/nilearn/nilearn/blob/02e915accc1dd29ee8af0e92004eabb4f0393958/nilearn/signal.py#L73-L75
to this
```python
invalid_ix = np.absolute(mean_signal) < np.finfo(np.float).eps
signals = (signals - mean_signal) / np.absolute(mean_signal)
signals *= 100
```
and update the user warning message in L78.


Discussion:
Hi @htwangtw 
Thanks for pointing this out. I don't see any reason either why we couldn't compute psc with negative mean. 
I think the fix you propose is good. 
Do you want to open a PR?
Sure I am happy to!

Resolving patch:
diff --git a/nilearn/signal.py b/nilearn/signal.py
--- a/nilearn/signal.py
+++ b/nilearn/signal.py
@@ -70,14 +70,14 @@ def _standardize(signals, detrend=False, standardize='zscore'):
 
         elif standardize == 'psc':
             mean_signal = signals.mean(axis=0)
-            invalid_ix = mean_signal < np.finfo(np.float).eps
-            signals = (signals / mean_signal) * 100
-            signals -= 100
+            invalid_ix = np.absolute(mean_signal) < np.finfo(np.float).eps
+            signals = (signals - mean_signal) / np.absolute(mean_signal)
+            signals *= 100
 
             if np.any(invalid_ix):
                 warnings.warn('psc standardization strategy is meaningless '
-                              'for features that have a mean of 0 or '
-                              'less. These time series are set to 0.')
+                              'for features that have a mean of 0. '
+                              'These time series are set to 0.')
                 signals[:, invalid_ix] = 0
 
     return signals


Test patch:
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
@@ -629,16 +629,35 @@ def test_clean_psc():
 
     signals, _, _ = generate_signals(n_features=n_features,
                                      length=n_samples)
-    means = rng.randn(1, n_features)
-    signals += means
-
-    cleaned_signals = clean(signals, standardize='psc')
-    np.testing.assert_almost_equal(cleaned_signals.mean(0), 0)
 
-    cleaned_signals.std(axis=0)
-    np.testing.assert_almost_equal(cleaned_signals.mean(0), 0)
-    np.testing.assert_almost_equal(cleaned_signals,
-                                   signals / signals.mean(0) * 100 - 100)
+    # positive mean signal
+    means = rng.randn(1, n_features)
+    signals_pos_mean = signals + means
+
+    # a mix of pos and neg mean signal
+    signals_mixed_mean = signals + np.append(means[:, :-3], -1 * means[:, -3:])
+
+    # both types should pass
+    for s in [signals_pos_mean, signals_mixed_mean]:
+        cleaned_signals = clean(s, standardize='psc')
+        np.testing.assert_almost_equal(cleaned_signals.mean(0), 0)
+
+        cleaned_signals.std(axis=0)
+        np.testing.assert_almost_equal(cleaned_signals.mean(0), 0)
+
+        tmp = (s - s.mean(0)) / np.abs(s.mean(0))
+        tmp *= 100
+        np.testing.assert_almost_equal(cleaned_signals, tmp)
+
+    # leave out the last 3 columns with a mean of zero to test user warning
+    signals_w_zero = signals + np.append(means[:, :-3], np.zeros((1, 3)))
+    cleaned_w_zero = clean(signals_w_zero, standardize='psc')
+    with pytest.warns(UserWarning) as records:
+        cleaned_w_zero = clean(signals_w_zero, standardize='psc')
+    psc_warning = sum('psc standardization strategy' in str(r.message)
+                         for r in records)
+    assert psc_warning == 1
+    np.testing.assert_equal(cleaned_w_zero[:, -3:].mean(0), 0)
 
 
 def test_clean_zscore():