Source code for statsmodels.stats.multivariate_tools

'''
Tools for multivariate analysis

Author : Josef Perktold
License : BSD-3

TODO:

- names of functions, currently just "working titles"

'''
from dataclasses import dataclass

import numpy as np

from statsmodels.tools.validation import array_like


@dataclass(frozen=True, slots=True)
class PartialProjectResult:
    """
    Result of :func:`partial_project`.

    Parameters
    ----------
    params : ndarray
        OLS parameter estimates from the projection of endog on exog.
    fittedvalues : ndarray
        Predicted values of endog given exog.
    resid : ndarray
        Residuals of the regression, i.e. the values of endog with the
        effect of exog partialled out.
    """

    params: np.ndarray
    fittedvalues: np.ndarray
    resid: np.ndarray


def partial_project(endog, exog):
    """
    Helper function to get linear projection or partialling out of variables

    endog variables are projected on exog variables

    Parameters
    ----------
    endog : array_like
        Array of variables where the effect of exog is partialled out.
    exog : array_like
        Array of variables on which the endog variables are projected.

    Returns
    -------
    PartialProjectResult
        Result instance with attributes ``params``, ``fittedvalues`` and
        ``resid``. See :class:`PartialProjectResult` for details.

    Notes
    -----
    This is no-frills mainly for internal calculations. ``endog`` and
    ``exog`` are converted to ndarrays, but no other error
    checking, such as verifying that both arrays have the same number of
    observations, is performed.
    """
    x1 = array_like(endog, "endog", ndim=2)
    x2 = array_like(exog, "exog", ndim=2)
    params = np.linalg.pinv(x2).dot(x1)
    predicted = x2.dot(params)
    residual = x1 - predicted

    return PartialProjectResult(params=params, fittedvalues=predicted, resid=residual)


[docs] def cancorr(x1, x2, demean=True, standardize=False): """ Canonical correlation coefficient between 2 arrays Parameters ---------- x1, x2 : array_like, 2-D Two 2-dimensional data arrays, observations in rows, variables in columns. demean : bool, optional If demean is true, then the mean is subtracted from each variable. standardize : bool, optional If standardize is true, then each variable is demeaned and divided by its standard deviation. Rescaling does not change the canonical correlation coefficients. Returns ------- ccorr : ndarray, 1-D Canonical correlation coefficients, sorted from largest to smallest. Note, that these are the square root of the eigenvalues. See Also -------- cc_ranktest Rank tests based on the smallest canonical correlations. cc_stats MANOVA statistics based on the canonical correlations. CCA Not yet implemented. Notes ----- This is a helper function for other statistical functions. It only calculates the canonical correlation coefficients and does not do a full canonical correlation analysis. The canonical correlation coefficient is calculated with the generalized matrix inverse and does not raise an exception if one of the data arrays have less than full column rank. The eigenvalues underlying the canonical correlations are mathematically guaranteed to be real and non-negative, but the generalized eigenvalue problem is solved numerically and can return eigenvalues with a small spurious complex part or a small negative real part. Such numerical noise is clipped to zero before taking the square root, so ``ccorr`` is always real-valued. """ x1 = array_like(x1, "x1", ndim=2) x2 = array_like(x2, "x2", ndim=2) if demean or standardize: x1 = x1 - x1.mean(0) x2 = x2 - x2.mean(0) if standardize: # std does not make a difference to canonical correlation coefficients x1 = x1 / x1.std(0) x2 = x2 / x2.std(0) t1 = np.linalg.pinv(x1).dot(x2) t2 = np.linalg.pinv(x2).dot(x1) m = t1.dot(t2) # eigvals of m are theoretically real and non-negative (they equal the # squared canonical correlations), but the generalized eigenvalue # solver can return a complex dtype with a negligible imaginary part, # or a tiny negative real part from floating point noise. ``.real`` # is a no-op when the dtype is already real, so this is safe either way. eigval = np.clip(np.linalg.eigvals(m).real, 0, None) cc = np.sqrt(eigval) cc = np.sort(cc)[::-1] cc = cc[:min(x1.shape[1], x2.shape[1])] return cc
[docs] @dataclass(frozen=True, slots=True) class CCRankTestResult: """ Result of :func:`cc_ranktest`. Only returned if ``return_object=True`` is used, otherwise :func:`cc_ranktest` returns the equivalent plain tuple ``(statistic, pvalue, df, ccorr, wald_statistic, wald_pvalue)``. Parameters ---------- statistic : float or ndarray Value of the LM (Anderson canonical correlations) test statistic. pvalue : float or ndarray P-value for the LM test statistic, based on the chi-square distribution. df : int or ndarray Degrees of freedom for the chi-square distribution in the hypothesis test. ccorr : ndarray, 1-D All canonical correlation coefficients sorted from largest to smallest. wald_statistic : float or ndarray Value of the Wald (Cragg-Donald) test statistic. wald_pvalue : float or ndarray P-value for the Wald test statistic, based on the chi-square distribution. """ statistic: float | np.ndarray pvalue: float | np.ndarray df: int | np.ndarray ccorr: np.ndarray wald_statistic: float | np.ndarray wald_pvalue: float | np.ndarray
[docs] def cc_ranktest(x1, x2, demean=True, fullrank=False, return_object=False): """ Rank tests based on smallest canonical correlation coefficients Anderson canonical correlations test (LM test) and Cragg-Donald test (Wald test) Assumes homoskedasticity and independent observations, overrejects if there is heteroscedasticity or autocorrelation. The Null Hypothesis is that the rank is k - 1, the alternative hypothesis is that the rank is at least k. Parameters ---------- x1, x2 : array_like, 2-D Two 2-dimensional data arrays, observations in rows, variables in columns. demean : bool, optional If demean is true, then the mean is subtracted from each variable. fullrank : bool, optional If true, then only the test that the matrix has full rank is returned. If false, the test for all possible ranks are returned. However, the p-values are not corrected for the multiplicity of tests. return_object : bool, optional If False (default), the results are returned as a plain tuple ``(statistic, pvalue, df, ccorr, wald_statistic, wald_pvalue)``, the legacy return format of this function. If True, a :class:`CCRankTestResult` instance is returned instead, which exposes the same values as named attributes and still unpacks as the same 6-tuple. Returns ------- tuple or CCRankTestResult By default, the tuple ``(statistic, pvalue, df, ccorr, wald_statistic, wald_pvalue)``. If ``return_object`` is True, a :class:`CCRankTestResult` with the same values as attributes. See Also -------- cancorr Canonical correlation coefficients used by this test. cc_stats MANOVA statistics based on the canonical correlations. Notes ----- Degrees of freedom for the distribution of the test statistic are based on number of columns of x1 and x2 and not on their matrix rank. (I'm not sure yet what the interpretation of the test is if x1 or x2 are of reduced rank.) References ---------- Anderson, T. W. 1951. "Estimating Linear Restrictions on Regression Coefficients for Multivariate Normal Distributions." The Annals of Mathematical Statistics 22 (3): 327-51. Cragg, John G., and Stephen G. Donald. 1993. "Testing Identifiability and Specification in Instrumental Variable Models." Econometric Theory 9 (2): 222-40. """ from scipy import stats x1 = array_like(x1, "x1", ndim=2) x2 = array_like(x2, "x2", ndim=2) nobs1, k1 = x1.shape nobs2, k2 = x2.shape cc = cancorr(x1, x2, demean=demean) cc2 = cc * cc if fullrank: df = np.abs(k1 - k2) + 1 value = nobs1 * cc2[-1] w_value = nobs1 * (cc2[-1] / (1. - cc2[-1])) pvalue = stats.chi2.sf(value, df) w_pvalue = stats.chi2.sf(w_value, df) else: r = np.arange(min(k1, k2))[::-1] df = (k1 - r) * (k2 - r) value = nobs1 * cc2[::-1].cumsum() w_value = nobs1 * (cc2 / (1. - cc2))[::-1].cumsum() pvalue = stats.chi2.sf(value, df) w_pvalue = stats.chi2.sf(w_value, df) if return_object: return CCRankTestResult( statistic=value, pvalue=pvalue, df=df, ccorr=cc, wald_statistic=w_value, wald_pvalue=w_pvalue, ) return value, pvalue, df, cc, w_value, w_pvalue
[docs] @dataclass(frozen=True, slots=True) class CCStatsResult: """ Result of :func:`cc_stats`. Only returned if ``return_object=True`` is used, otherwise :func:`cc_stats` returns the equivalent legacy dict. Parameters ---------- ccorr : ndarray, 1-D Canonical correlation coefficients, sorted from largest to smallest. eigenvalues : ndarray, 1-D Eigenvalues corresponding to ``ccorr ** 2 / (1 - ccorr ** 2)``. pillai_trace : float Pillai's Trace statistic. wilks_lambda : float Wilk's Lambda statistic. hotelling_trace : float Hotelling's Trace statistic. roys_largest_root : float Roy's Largest Root statistic. df_resid : float Residual degrees of freedom. df_model : float Model (hypothesis) degrees of freedom. """ ccorr: np.ndarray eigenvalues: np.ndarray pillai_trace: float wilks_lambda: float hotelling_trace: float roys_largest_root: float df_resid: float df_model: float
[docs] def cc_stats(x1, x2, demean=True, return_object=False): """ MANOVA statistics based on canonical correlation coefficient Calculates Pillai's Trace, Wilk's Lambda, Hotelling's Trace and Roy's Largest Root. Parameters ---------- x1, x2 : array_like, 2-D Two 2-dimensional data arrays, observations in rows, variables in columns. demean : bool, optional If demean is true, then the mean is subtracted from each variable. return_object : bool, optional If False (default), the results are returned as a dict with the legacy keys used by this function ("canonical correlation coefficient", "eigenvalues", "Pillai's Trace", "Wilk's Lambda", "Hotelling's Trace", "Roy's Largest Root", "df_resid", "df_m"). If True, a :class:`CCStatsResult` instance is returned instead, which exposes the same values as named attributes. Returns ------- dict or CCStatsResult By default, a dict with the legacy keys described above. If ``return_object`` is True, a :class:`CCStatsResult` with the same values as attributes. See Also -------- cancorr : Canonical correlation coefficients used by these statistics. cc_ranktest : Rank tests based on the smallest canonical correlations. Notes ----- Same as `canon` in Stata. Missing: F-statistics and p-values. Can produce nans, for example, if x1 and x2 are (numerically) singular or perfectly correlated, in which case a canonical correlation equals one and ``Hotelling's Trace`` and the underlying ``eigenvalues`` are infinite or not a number. """ x1 = array_like(x1, "x1", ndim=2) x2 = array_like(x2, "x2", ndim=2) nobs1, k1 = x1.shape # endogenous ? nobs2, k2 = x2.shape cc = cancorr(x1, x2, demean=demean) cc2 = cc**2 lam = (cc2 / (1 - cc2)) # what if max cc2 is 1 ? # Problem: ccr might not care if x1 or x2 are reduced rank, # but df will depend on rank df_model = k1 * k2 # df_hypothesis (we do not include mean in x1, x2) df_resid = k1 * (nobs1 - k2 - demean) m = 0.5 * (df_model - k1) pt_value = cc2.sum() # Pillai's trace wl_value = np.prod(1 / (1 + lam)) # Wilk's Lambda ht_value = lam.sum() # Hotelling's Trace rm_value = lam.max() # Roy's largest root # from scipy import stats # what's the distribution, the test statistic ? if return_object: return CCStatsResult( ccorr=cc, eigenvalues=lam, pillai_trace=pt_value, wilks_lambda=wl_value, hotelling_trace=ht_value, roys_largest_root=rm_value, df_resid=df_resid, df_model=m, ) res = {} res["canonical correlation coefficient"] = cc res["eigenvalues"] = lam res["Pillai's Trace"] = pt_value res["Wilk's Lambda"] = wl_value res["Hotelling's Trace"] = ht_value res["Roy's Largest Root"] = rm_value res["df_resid"] = df_resid res["df_m"] = m return res