Repository: nilearn/nilearn

Issue:
Move helper functions in `mass_univariate` to associated `_utils`
<!--Provide a brief description what you would like changes and why.-->
With recent expansions to `permuted_ols` (e.g., #3181 and #3196), we have created a new `nilearn.mass_univariate._utils` module with associated helper functions. There are a few functions currently in `nilearn.mass_univariate.permuted_least_squares` that could be moved into the new `_utils` file, including `_normalize_matrix_on_axis`, `_orthonormalize_matrix`, `_t_score_with_covars_and_normalized_design`, and _possibly_ `_permuted_ols_on_chunk`.

<!--Please fill in the following information, to the best of your ability.-->
### Benefits to the change
The main file, `permuted_least_squares.py`, would only contain the public function `permuted_ols`, and would be more navigable.



Discussion:

Resolving patch:
diff --git a/nilearn/mass_univariate/_utils.py b/nilearn/mass_univariate/_utils.py
--- a/nilearn/mass_univariate/_utils.py
+++ b/nilearn/mass_univariate/_utils.py
@@ -1,6 +1,6 @@
 """Utility functions for the permuted_least_squares module."""
 import numpy as np
-from scipy import ndimage
+from scipy import ndimage, linalg
 
 
 def _null_to_p(test_values, null_array, alternative='two-sided'):
@@ -151,3 +151,135 @@ def _calculate_cluster_measures(
         max_sizes[i_regressor], max_masses[i_regressor] = max_size, max_mass
 
     return max_sizes, max_masses
+
+
+def _normalize_matrix_on_axis(m, axis=0):
+    """ Normalize a 2D matrix on an axis.
+
+    Parameters
+    ----------
+    m : numpy 2D array,
+        The matrix to normalize.
+
+    axis : integer in {0, 1}, optional
+        A valid axis to normalize across.
+        Default=0.
+
+    Returns
+    -------
+    ret : numpy array, shape = m.shape
+        The normalized matrix
+
+    Examples
+    --------
+    >>> import numpy as np
+    >>> from nilearn.mass_univariate.permuted_least_squares import (
+    ...     _normalize_matrix_on_axis)
+    >>> X = np.array([[0, 4], [1, 0]])
+    >>> _normalize_matrix_on_axis(X)
+    array([[0., 1.],
+           [1., 0.]])
+    >>> _normalize_matrix_on_axis(X, axis=1)
+    array([[0., 1.],
+           [1., 0.]])
+
+    """
+    if m.ndim > 2:
+        raise ValueError('This function only accepts 2D arrays. '
+                         'An array of shape %r was passed.' % m.shape)
+
+    if axis == 0:
+        # array transposition preserves the contiguity flag of that array
+        ret = (m.T / np.sqrt(np.sum(m ** 2, axis=0))[:, np.newaxis]).T
+    elif axis == 1:
+        ret = _normalize_matrix_on_axis(m.T).T
+    else:
+        raise ValueError('axis(=%d) out of bounds' % axis)
+    return ret
+
+
+def _orthonormalize_matrix(m, tol=1.e-12):
+    """ Orthonormalize a matrix.
+
+    Uses a Singular Value Decomposition.
+    If the input matrix is rank-deficient, then its shape is cropped.
+
+    Parameters
+    ----------
+    m : numpy array,
+        The matrix to orthonormalize.
+
+    tol: float, optional
+        Tolerance parameter for nullity. Default=1e-12.
+
+    Returns
+    -------
+    ret : numpy array, shape = m.shape
+        The orthonormalized matrix.
+
+    Examples
+    --------
+    >>> import numpy as np
+    >>> from nilearn.mass_univariate.permuted_least_squares import (
+    ...     _orthonormalize_matrix)
+    >>> X = np.array([[1, 2], [0, 1], [1, 1]])
+    >>> _orthonormalize_matrix(X)
+    array([[-0.81049889, -0.0987837 ],
+           [-0.31970025, -0.75130448],
+           [-0.49079864,  0.65252078]])
+    >>> X = np.array([[0, 1], [4, 0]])
+    >>> _orthonormalize_matrix(X)
+    array([[ 0., -1.],
+           [-1.,  0.]])
+
+    """
+    U, s, _ = linalg.svd(m, full_matrices=False)
+    n_eig = np.count_nonzero(s > tol)
+    return np.ascontiguousarray(U[:, :n_eig])
+
+
+def _t_score_with_covars_and_normalized_design(tested_vars, target_vars,
+                                               covars_orthonormalized=None):
+    """t-score in the regression of tested variates against target variates
+
+    Covariates are taken into account (if not None).
+    The normalized_design case corresponds to the following assumptions:
+    - tested_vars and target_vars are normalized
+    - covars_orthonormalized are orthonormalized
+    - tested_vars and covars_orthonormalized are orthogonal
+      (np.dot(tested_vars.T, covars) == 0)
+
+    Parameters
+    ----------
+    tested_vars : array-like, shape=(n_samples, n_tested_vars)
+        Explanatory variates.
+
+    target_vars : array-like, shape=(n_samples, n_target_vars)
+        Targets variates. F-ordered is better for efficient computation.
+
+    covars_orthonormalized : array-like, shape=(n_samples, n_covars) or None, \
+            optional
+        Confounding variates.
+
+    Returns
+    -------
+    score : numpy.ndarray, shape=(n_target_vars, n_tested_vars)
+        t-scores associated with the tests of each explanatory variate against
+        each target variate (in the presence of covars).
+
+    """
+    if covars_orthonormalized is None:
+        lost_dof = 0
+    else:
+        lost_dof = covars_orthonormalized.shape[1]
+    # Tested variates are fitted independently,
+    # so lost_dof is unrelated to n_tested_vars.
+    dof = target_vars.shape[0] - lost_dof
+    beta_targetvars_testedvars = np.dot(target_vars.T, tested_vars)
+    if covars_orthonormalized is None:
+        rss = (1 - beta_targetvars_testedvars ** 2)
+    else:
+        beta_targetvars_covars = np.dot(target_vars.T, covars_orthonormalized)
+        a2 = np.sum(beta_targetvars_covars ** 2, 1)
+        rss = (1 - a2[:, np.newaxis] - beta_targetvars_testedvars ** 2)
+    return beta_targetvars_testedvars * np.sqrt((dof - 1.) / rss)
diff --git a/nilearn/mass_univariate/permuted_least_squares.py b/nilearn/mass_univariate/permuted_least_squares.py
--- a/nilearn/mass_univariate/permuted_least_squares.py
+++ b/nilearn/mass_univariate/permuted_least_squares.py
@@ -11,142 +11,16 @@
 import nibabel as nib
 import numpy as np
 from nilearn.masking import apply_mask
-from scipy import linalg, ndimage, stats
+from scipy import ndimage, stats
 from sklearn.utils import check_random_state
 
-from ._utils import _calculate_cluster_measures, _null_to_p
-
-
-def _normalize_matrix_on_axis(m, axis=0):
-    """ Normalize a 2D matrix on an axis.
-
-    Parameters
-    ----------
-    m : numpy 2D array,
-        The matrix to normalize.
-
-    axis : integer in {0, 1}, optional
-        A valid axis to normalize across.
-        Default=0.
-
-    Returns
-    -------
-    ret : numpy array, shape = m.shape
-        The normalized matrix
-
-    Examples
-    --------
-    >>> import numpy as np
-    >>> from nilearn.mass_univariate.permuted_least_squares import (
-    ...     _normalize_matrix_on_axis)
-    >>> X = np.array([[0, 4], [1, 0]])
-    >>> _normalize_matrix_on_axis(X)
-    array([[0., 1.],
-           [1., 0.]])
-    >>> _normalize_matrix_on_axis(X, axis=1)
-    array([[0., 1.],
-           [1., 0.]])
-
-    """
-    if m.ndim > 2:
-        raise ValueError('This function only accepts 2D arrays. '
-                         'An array of shape %r was passed.' % m.shape)
-
-    if axis == 0:
-        # array transposition preserves the contiguity flag of that array
-        ret = (m.T / np.sqrt(np.sum(m ** 2, axis=0))[:, np.newaxis]).T
-    elif axis == 1:
-        ret = _normalize_matrix_on_axis(m.T).T
-    else:
-        raise ValueError('axis(=%d) out of bounds' % axis)
-    return ret
-
-
-def _orthonormalize_matrix(m, tol=1.e-12):
-    """ Orthonormalize a matrix.
-
-    Uses a Singular Value Decomposition.
-    If the input matrix is rank-deficient, then its shape is cropped.
-
-    Parameters
-    ----------
-    m : numpy array,
-        The matrix to orthonormalize.
-
-    tol: float, optional
-        Tolerance parameter for nullity. Default=1e-12.
-
-    Returns
-    -------
-    ret : numpy array, shape = m.shape
-        The orthonormalized matrix.
-
-    Examples
-    --------
-    >>> import numpy as np
-    >>> from nilearn.mass_univariate.permuted_least_squares import (
-    ...     _orthonormalize_matrix)
-    >>> X = np.array([[1, 2], [0, 1], [1, 1]])
-    >>> _orthonormalize_matrix(X)
-    array([[-0.81049889, -0.0987837 ],
-           [-0.31970025, -0.75130448],
-           [-0.49079864,  0.65252078]])
-    >>> X = np.array([[0, 1], [4, 0]])
-    >>> _orthonormalize_matrix(X)
-    array([[ 0., -1.],
-           [-1.,  0.]])
-
-    """
-    U, s, _ = linalg.svd(m, full_matrices=False)
-    n_eig = np.count_nonzero(s > tol)
-    return np.ascontiguousarray(U[:, :n_eig])
-
-
-def _t_score_with_covars_and_normalized_design(tested_vars, target_vars,
-                                               covars_orthonormalized=None):
-    """t-score in the regression of tested variates against target variates
-
-    Covariates are taken into account (if not None).
-    The normalized_design case corresponds to the following assumptions:
-    - tested_vars and target_vars are normalized
-    - covars_orthonormalized are orthonormalized
-    - tested_vars and covars_orthonormalized are orthogonal
-      (np.dot(tested_vars.T, covars) == 0)
-
-    Parameters
-    ----------
-    tested_vars : array-like, shape=(n_samples, n_tested_vars)
-        Explanatory variates.
-
-    target_vars : array-like, shape=(n_samples, n_target_vars)
-        Targets variates. F-ordered is better for efficient computation.
-
-    covars_orthonormalized : array-like, shape=(n_samples, n_covars) or None, \
-            optional
-        Confounding variates.
-
-    Returns
-    -------
-    score : numpy.ndarray, shape=(n_target_vars, n_tested_vars)
-        t-scores associated with the tests of each explanatory variate against
-        each target variate (in the presence of covars).
-
-    """
-    if covars_orthonormalized is None:
-        lost_dof = 0
-    else:
-        lost_dof = covars_orthonormalized.shape[1]
-    # Tested variates are fitted independently,
-    # so lost_dof is unrelated to n_tested_vars.
-    dof = target_vars.shape[0] - lost_dof
-    beta_targetvars_testedvars = np.dot(target_vars.T, tested_vars)
-    if covars_orthonormalized is None:
-        rss = (1 - beta_targetvars_testedvars ** 2)
-    else:
-        beta_targetvars_covars = np.dot(target_vars.T, covars_orthonormalized)
-        a2 = np.sum(beta_targetvars_covars ** 2, 1)
-        rss = (1 - a2[:, np.newaxis] - beta_targetvars_testedvars ** 2)
-    return beta_targetvars_testedvars * np.sqrt((dof - 1.) / rss)
+from nilearn.mass_univariate._utils import (
+    _calculate_cluster_measures,
+    _normalize_matrix_on_axis,
+    _null_to_p,
+    _orthonormalize_matrix,
+    _t_score_with_covars_and_normalized_design,
+)
 
 
 def _permuted_ols_on_chunk(


Test patch:
diff --git a/nilearn/mass_univariate/tests/test__utils.py b/nilearn/mass_univariate/tests/test__utils.py
--- a/nilearn/mass_univariate/tests/test__utils.py
+++ b/nilearn/mass_univariate/tests/test__utils.py
@@ -1,10 +1,16 @@
+"""Tests for nilearn.mass_univariate._utils."""
 import math
 
 import numpy as np
 import pytest
+from numpy.testing import assert_array_almost_equal
 from scipy import ndimage
+from sklearn.utils import check_random_state
 
 from nilearn.mass_univariate import _utils
+from nilearn.mass_univariate.tests.utils import (
+    get_tvalue_with_alternative_library,
+)
 
 
 def test_null_to_p_float():
@@ -153,3 +159,50 @@ def test_calculate_cluster_measures():
     )
     assert test_size[0] == true_size
     assert test_mass[0] == true_mass
+
+
+def test_t_score_with_covars_and_normalized_design_nocovar(random_state=0):
+    """Test t-scores computation without covariates."""
+    rng = check_random_state(random_state)
+
+    # Normalized data
+    n_samples = 50
+
+    # generate data
+    var1 = np.ones((n_samples, 1)) / np.sqrt(n_samples)
+    var2 = rng.randn(n_samples, 1)
+    var2 = var2 / np.sqrt(np.sum(var2 ** 2, 0))  # normalize
+
+    # compute t-scores with nilearn routine
+    t_val_own = _utils._t_score_with_covars_and_normalized_design(var1, var2)
+
+    # compute t-scores with linalg or statsmodels
+    t_val_alt = get_tvalue_with_alternative_library(var1, var2)
+    assert_array_almost_equal(t_val_own, t_val_alt)
+
+
+def test_t_score_with_covars_and_normalized_design_withcovar(random_state=0):
+    """Test t-scores computation with covariates."""
+    rng = check_random_state(random_state)
+
+    # Normalized data
+    n_samples = 50
+
+    # generate data
+    var1 = np.ones((n_samples, 1)) / np.sqrt(n_samples)  # normalized
+    var2 = rng.randn(n_samples, 1)
+    var2 = var2 / np.sqrt(np.sum(var2 ** 2, 0))  # normalize
+    covars = np.eye(n_samples, 3)  # covars is orthogonal
+    covars[3] = -1  # covars is orthogonal to var1
+    covars = _utils._orthonormalize_matrix(covars)
+
+    # nilearn t-score
+    own_score = _utils._t_score_with_covars_and_normalized_design(
+        var1,
+        var2,
+        covars,
+    )
+
+    # compute t-scores with linalg or statmodels
+    ref_score = get_tvalue_with_alternative_library(var1, var2, covars)
+    assert_array_almost_equal(own_score, ref_score)
diff --git a/nilearn/mass_univariate/tests/test_permuted_least_squares.py b/nilearn/mass_univariate/tests/test_permuted_least_squares.py
--- a/nilearn/mass_univariate/tests/test_permuted_least_squares.py
+++ b/nilearn/mass_univariate/tests/test_permuted_least_squares.py
@@ -12,130 +12,9 @@
 import pytest
 
 from nilearn.mass_univariate import permuted_ols
-from nilearn.mass_univariate.permuted_least_squares import (
-    _t_score_with_covars_and_normalized_design, _orthonormalize_matrix)
-
-
-def get_tvalue_with_alternative_library(tested_vars, target_vars, covars=None):
-    """Utility function to compute tvalues with linalg or statsmodels
-
-    Massively univariate linear model (= each target is considered
-    independently).
-
-    Parameters
-    ----------
-    tested_vars: array-like, shape=(n_samples, n_regressors)
-      Tested variates, the associated coefficient of which are to be tested
-      independently with a t-test, resulting in as many t-values.
-
-    target_vars: array-like, shape=(n_samples, n_descriptors)
-      Target variates, to be approximated with a linear combination of
-      the tested variates and the confounding variates.
-
-    covars: array-like, shape=(n_samples, n_confounds)
-      Confounding variates, to be fitted but not to be tested
-
-    Returns
-    -------
-    t-values: np.ndarray, shape=(n_regressors, n_descriptors)
-
-    """
-    ### set up design
-    n_samples, n_regressors = tested_vars.shape
-    n_descriptors = target_vars.shape[1]
-    if covars is not None:
-        n_covars = covars.shape[1]
-        design_matrix = np.hstack((tested_vars, covars))
-    else:
-        n_covars = 0
-        design_matrix = tested_vars
-    mask_covars = np.ones(n_regressors + n_covars, dtype=bool)
-    mask_covars[:n_regressors] = False
-    test_matrix = np.array([[1.] + [0.] * n_covars])
-
-    ### t-values computation
-    try:  # try with statsmodels if available (more concise)
-        from statsmodels.regression.linear_model import OLS
-        t_values = np.empty((n_descriptors, n_regressors))
-        for i in range(n_descriptors):
-            current_target = target_vars[:, i].reshape((-1, 1))
-            for j in range(n_regressors):
-                current_tested_mask = mask_covars.copy()
-                current_tested_mask[j] = True
-                current_design_matrix = design_matrix[:, current_tested_mask]
-                ols_fit = OLS(current_target, current_design_matrix).fit()
-                t_values[i, j] = np.ravel(ols_fit.t_test(test_matrix).tvalue)
-    except:  # use linalg if statsmodels is not available
-        from numpy import linalg
-        lost_dof = n_covars + 1  # fit all tested variates independently
-        t_values = np.empty((n_descriptors, n_regressors))
-        for i in range(n_regressors):
-            current_tested_mask = mask_covars.copy()
-            current_tested_mask[i] = True
-            current_design_matrix = design_matrix[:, current_tested_mask]
-            invcov = linalg.pinv(current_design_matrix)
-            normalized_cov = np.dot(invcov, invcov.T)
-            t_val_denom_aux = np.diag(
-                np.dot(test_matrix, np.dot(normalized_cov, test_matrix.T)))
-            t_val_denom_aux = t_val_denom_aux.reshape((-1, 1))
-            for j in range(n_descriptors):
-                current_target = target_vars[:, j].reshape((-1, 1))
-                res_lstsq = linalg.lstsq(current_design_matrix, current_target)
-                residuals = (current_target
-                             - np.dot(current_design_matrix, res_lstsq[0]))
-                t_val_num = np.dot(test_matrix, res_lstsq[0])
-                t_val_denom = np.sqrt(
-                    np.sum(residuals ** 2, 0) / float(n_samples - lost_dof)
-                    * t_val_denom_aux)
-                t_values[j, i] = np.ravel(t_val_num / t_val_denom)
-    t_values = t_values.T
-    assert t_values.shape == (n_regressors, n_descriptors)
-    return t_values
-
-
-### Tests t-scores computation ################################################
-def test_t_score_with_covars_and_normalized_design_nocovar(random_state=0):
-    rng = check_random_state(random_state)
-
-    ### Normalized data
-    n_samples = 50
-
-    # generate data
-    var1 = np.ones((n_samples, 1)) / np.sqrt(n_samples)
-    var2 = rng.randn(n_samples, 1)
-    var2 = var2 / np.sqrt(np.sum(var2 ** 2, 0))  # normalize
-
-    # compute t-scores with nilearn routine
-    t_val_own = _t_score_with_covars_and_normalized_design(var1, var2)
-
-    # compute t-scores with linalg or statsmodels
-    t_val_alt = get_tvalue_with_alternative_library(var1, var2)
-    assert_array_almost_equal(t_val_own, t_val_alt)
-
-
-def test_t_score_with_covars_and_normalized_design_withcovar(random_state=0):
-    """
-
-    """
-    rng = check_random_state(random_state)
-
-    ### Normalized data
-    n_samples = 50
-
-    # generate data
-    var1 = np.ones((n_samples, 1)) / np.sqrt(n_samples)  # normalized
-    var2 = rng.randn(n_samples, 1)
-    var2 = var2 / np.sqrt(np.sum(var2 ** 2, 0))  # normalize
-    covars = np.eye(n_samples, 3)  # covars is orthogonal
-    covars[3] = -1  # covars is orthogonal to var1
-    covars = _orthonormalize_matrix(covars)
-
-    # nilearn t-score
-    own_score = _t_score_with_covars_and_normalized_design(var1, var2, covars)
-
-    # compute t-scores with linalg or statmodels
-    ref_score = get_tvalue_with_alternative_library(var1, var2, covars)
-    assert_array_almost_equal(own_score, ref_score)
+from nilearn.mass_univariate.tests.utils import (
+    get_tvalue_with_alternative_library,
+)
 
 
 ### General tests for permuted_ols function ###################################
diff --git a/nilearn/mass_univariate/tests/utils.py b/nilearn/mass_univariate/tests/utils.py
new file mode 100644
--- /dev/null
+++ b/nilearn/mass_univariate/tests/utils.py
@@ -0,0 +1,84 @@
+"""Utility functions for the mass_univariate module's test suite."""
+import numpy as np
+
+
+def get_tvalue_with_alternative_library(tested_vars, target_vars, covars=None):
+    """Utility function to compute tvalues with linalg or statsmodels
+
+    Massively univariate linear model (= each target is considered
+    independently).
+
+    Parameters
+    ----------
+    tested_vars: array-like, shape=(n_samples, n_regressors)
+      Tested variates, the associated coefficient of which are to be tested
+      independently with a t-test, resulting in as many t-values.
+
+    target_vars: array-like, shape=(n_samples, n_descriptors)
+      Target variates, to be approximated with a linear combination of
+      the tested variates and the confounding variates.
+
+    covars: array-like, shape=(n_samples, n_confounds)
+      Confounding variates, to be fitted but not to be tested
+
+    Returns
+    -------
+    t-values: np.ndarray, shape=(n_regressors, n_descriptors)
+
+    """
+    # set up design
+    n_samples, n_regressors = tested_vars.shape
+    n_descriptors = target_vars.shape[1]
+    if covars is not None:
+        n_covars = covars.shape[1]
+        design_matrix = np.hstack((tested_vars, covars))
+    else:
+        n_covars = 0
+        design_matrix = tested_vars
+    mask_covars = np.ones(n_regressors + n_covars, dtype=bool)
+    mask_covars[:n_regressors] = False
+    test_matrix = np.array([[1.] + [0.] * n_covars])
+
+    # t-values computation
+    try:  # try with statsmodels if available (more concise)
+        from statsmodels.regression.linear_model import OLS
+
+        t_values = np.empty((n_descriptors, n_regressors))
+        for i in range(n_descriptors):
+            current_target = target_vars[:, i].reshape((-1, 1))
+            for j in range(n_regressors):
+                current_tested_mask = mask_covars.copy()
+                current_tested_mask[j] = True
+                current_design_matrix = design_matrix[:, current_tested_mask]
+                ols_fit = OLS(current_target, current_design_matrix).fit()
+                t_values[i, j] = np.ravel(ols_fit.t_test(test_matrix).tvalue)
+
+    except ImportError:  # use linalg if statsmodels is not available
+        from numpy import linalg
+
+        lost_dof = n_covars + 1  # fit all tested variates independently
+        t_values = np.empty((n_descriptors, n_regressors))
+        for i in range(n_regressors):
+            current_tested_mask = mask_covars.copy()
+            current_tested_mask[i] = True
+            current_design_matrix = design_matrix[:, current_tested_mask]
+            invcov = linalg.pinv(current_design_matrix)
+            normalized_cov = np.dot(invcov, invcov.T)
+            t_val_denom_aux = np.diag(
+                np.dot(test_matrix, np.dot(normalized_cov, test_matrix.T)))
+            t_val_denom_aux = t_val_denom_aux.reshape((-1, 1))
+
+            for j in range(n_descriptors):
+                current_target = target_vars[:, j].reshape((-1, 1))
+                res_lstsq = linalg.lstsq(current_design_matrix, current_target)
+                residuals = (current_target
+                             - np.dot(current_design_matrix, res_lstsq[0]))
+                t_val_num = np.dot(test_matrix, res_lstsq[0])
+                t_val_denom = np.sqrt(
+                    np.sum(residuals ** 2, 0) / float(n_samples - lost_dof)
+                    * t_val_denom_aux)
+                t_values[j, i] = np.ravel(t_val_num / t_val_denom)
+
+    t_values = t_values.T
+    assert t_values.shape == (n_regressors, n_descriptors)
+    return t_values