Source code for statsmodels.stats.multicollinearity

"""
Measures and diagnostics for multicollinearity of explanatory variables

The functions work on a data matrix of explanatory variables or on a
covariance or correlation matrix.

Created on Tue Apr 28 15:09:45 2015

Author: Josef Perktold
License: BSD-3
"""

import numpy as np
import pandas as pd

from statsmodels.stats.moment_helpers import cov2corr
from statsmodels.tools.validation import array_like, string_like

_EPS = np.finfo(float).eps


def _corr_nonconstant(data, is_moment, has_constant):
    """
    Correlation matrix of the columns of data that are not constant

    Parameters
    ----------
    data : array_like
        Data matrix or, if `is_moment` is True, covariance or correlation
        matrix.
    is_moment : bool
        Indicates whether `data` is a moment matrix.
    has_constant : {"raise", "skip"}
        How to treat constant columns.

    Returns
    -------
    corr : ndarray
        Correlation matrix of the columns that are not constant.
    is_const : ndarray of bool
        Indicator for the constant columns of `data`, one element for each
        column.
    """
    has_constant = string_like(has_constant, "has_constant", options=("raise", "skip"))
    x = array_like(data, "data", ndim=2)
    if not np.isfinite(x).all():
        raise ValueError("data must not contain missing or infinite values")

    if is_moment:
        if x.shape[0] != x.shape[1]:
            raise ValueError("a moment matrix must be square")
        if np.abs(x - x.T).max() > 1e-8 * np.abs(x).max():
            raise ValueError("a moment matrix must be symmetric")
        is_const = np.diag(x) == 0
        if (np.diag(x) < 0).any():
            raise ValueError(
                "a moment matrix must not have a negative diagonal element"
            )
    else:
        is_const = np.ptp(x, axis=0) == 0

    if is_const.any() and has_constant == "raise":
        raise ValueError(
            "data has constant columns at positions "
            f"{np.nonzero(is_const)[0].tolist()}. VIF is defined for a "
            "regression that includes a constant, remove the constant "
            "columns or use has_constant='skip'."
        )

    keep = ~is_const
    if is_moment:
        corr = cov2corr(x[np.ix_(keep, keep)])
    else:
        z = x[:, keep] - x[:, keep].mean(0)
        z /= np.sqrt((z**2).mean(0))
        corr = z.T @ z / z.shape[0]
    return corr, is_const


def _eigh_corr(corr):
    """
    Eigenvalue decomposition of a correlation matrix

    Raises a ValueError if the matrix is not positive semi-definite.
    """
    if corr.shape[0] == 0:
        return np.empty(0), np.empty((0, 0))
    evals, evecs = np.linalg.eigh(corr)
    if evals[0] < -np.sqrt(_EPS):
        raise ValueError("the moment matrix is not positive semi-definite")
    return evals, evecs


def _inv_diag(evals, evecs):
    """
    Diagonal of the inverse of a correlation matrix

    Variables that are exactly collinear with other variables have an
    infinite value, as is the case if the matrix is numerically singular.

    Parameters
    ----------
    evals : ndarray
        Eigenvalues in ascending order.
    evecs : ndarray
        Eigenvectors in the columns.
    """
    k_vars = evals.shape[0]
    if k_vars < 2:
        return np.ones(k_vars)

    # numerical rank, using the same type of tolerance as matrix_rank
    singular = evals <= evals[-1] * k_vars * _EPS
    evecs2 = evecs**2
    res = (evecs2[:, ~singular] / evals[~singular]).sum(1)
    if singular.any():
        # A variable is collinear with the others if it has a nonzero
        # weight in the null space. All other variables have a finite VIF
        # that is given by the pseudo inverse. The weight of the other
        # variables in the numerical null space is not exactly zero. It is
        # of the order of the squared relative error of the eigenvectors,
        # plus a term that is proportional to the size of the numerically
        # zero eigenvalues, which are not exactly zero.
        gap = evals[~singular].min()
        spill = np.abs(evals[singular]).max()
        threshold = (10 * _EPS * evals[-1] / gap) ** 2 + spill / gap
        res[evecs2[:, singular].sum(1) > threshold] = np.inf
    # VIF is larger than one by definition, remove floating point noise
    return np.maximum(res, 1.0)


[docs] def vif(data, *, is_moment=False, has_constant="raise"): r""" Variance inflation factors of all variables The variance inflation factor, VIF, of a variable is :math:`1 / (1 - R^2)`, where :math:`R^2` is the coefficient of determination of the regression of the variable on all other variables and a constant. It measures by how much the variance of the estimate of the coefficient of the variable is inflated by the correlation with the other variables. Parameters ---------- data : array_like The explanatory variables with observations in rows and variables in columns. The data should not include a constant, see `has_constant`. If `is_moment` is True, then this is a square covariance or correlation matrix of the variables. is_moment : bool, optional If True, then `data` is a symmetric, positive semi-definite moment matrix of the variables instead of the observations. Only the correlations are used, so the matrix can be a covariance or a correlation matrix. The default is False. has_constant : {"raise", "skip"}, optional How to treat constant columns, which have zero variance. If "raise", the default, then a ValueError is raised. If "skip", then the constant columns are ignored and the corresponding elements of the result are NaN. The other variables are not affected because the regressions always include a constant. Returns ------- ndarray or Series The variance inflation factors, one for each column of `data`. This is a pandas Series with the column labels as index if `data` is a pandas DataFrame. The VIF of a variable that is a linear combination of other variables is infinite. Raises ------ ValueError If `data` contains missing or infinite values, if `data` has constant columns and `has_constant` is "raise", or if `is_moment` is True and `data` is not a valid moment matrix. See Also -------- statsmodels.stats.outliers_influence.variance_inflation_factor VIF of a single variable, estimated by OLS. vif_ridge VIF of the parameter estimates of ridge regression. collinear_index Columns that are perfectly collinear with preceding columns. Notes ----- The VIF of all variables are the diagonal elements of the inverse of the correlation matrix of the variables. This needs one matrix decomposition instead of one regression for each variable, which is much faster than calling :func:`~statsmodels.stats.outliers_influence.variance_inflation_factor` for each column. Up to numerical precision, the results are the same as those of that function with its default ``standardize=True``. The latter limits the VIF to :math:`10^{15}` and warns if the design matrix is ill-conditioned, while this function returns ``inf`` for variables that are collinear with other variables. **Constants** The VIF requires a constant in the regressions. The variables are demeaned, which is equivalent to including a constant, and the constant column of a design matrix, for example ``res.model.exog``, has to be removed or skipped. A set of dummy variables that sums to one is an implicit constant. If the design matrix does not include another constant, then it has to be dropped, because the dummy variables are perfectly collinear after demeaning and their VIF is infinite. If `is_moment` is True and the moment matrix is not centered, for example the cross-product matrix ``x.T @ x``, then the VIF are those of regressions without a constant. **Singular and nearly singular data** The computation uses the eigenvalues of the correlation matrix. The VIF is infinite for variables that are numerically perfectly collinear with other variables, i.e. if the correlation matrix is singular up to floating point precision. A variable that is not part of such a linear relationship has a finite VIF. VIFs above about :math:`10^{12}` should be read as indicating that the variable is collinear because they are affected by floating point noise. Floating point precision of the data matters: a linear combination of single precision (float32) variables is collinear only up to the precision of the single precision data, and is not detected as perfectly collinear. References ---------- .. [1] Belsley, D. A., E. Kuh, and R. E. Welsch. 1980. Regression Diagnostics: Identifying Influential Data and Sources of Collinearity. Wiley. Examples -------- The VIF of the explanatory variables of the Longley data, which are highly collinear. >>> import statsmodels.api as sm >>> from statsmodels.stats.api import vif >>> exog = sm.datasets.longley.load_pandas().exog >>> vif(exog).round(1) GNPDEFL 135.5 GNP 1788.5 UNEMP 33.6 ARMED 3.6 POP 399.2 YEAR 759.0 dtype: float64 The design matrix of a model that is estimated with a formula has a constant, which is skipped. >>> import statsmodels.formula.api as smf >>> mod = smf.ols("TOTEMP ~ GNPDEFL + GNP + UNEMP + ARMED + POP + YEAR", ... data=sm.datasets.longley.load_pandas().data) >>> vif(mod.data.orig_exog, has_constant="skip").round(1) Intercept NaN GNPDEFL 135.5 GNP 1788.5 UNEMP 33.6 ARMED 3.6 POP 399.2 YEAR 759.0 dtype: float64 """ corr, is_const = _corr_nonconstant(data, is_moment, has_constant) res = np.full(is_const.shape[0], np.nan) res[~is_const] = _inv_diag(*_eigh_corr(corr)) if isinstance(data, pd.DataFrame): res = pd.Series(res, index=data.columns) return res
[docs] def vif_ridge(data, pen_factors, *, is_moment=False, has_constant="raise"): r""" Variance inflation factors for ridge regression The penalization is on the standardized variables, so the VIF depend on the correlation matrix of the variables. The ridge factors are added to the diagonal of the correlation matrix. Parameters ---------- data : array_like The explanatory variables with observations in rows and variables in columns. The data should not include a constant, see `has_constant`. If `is_moment` is True, then this is a square covariance or correlation matrix of the variables. pen_factors : array_like One-dimensional array of non-negative ridge penalization factors. is_moment : bool, optional If True, then `data` is a symmetric, positive semi-definite moment matrix of the variables instead of the observations. The default is False. has_constant : {"raise", "skip"}, optional How to treat constant columns, which have zero variance. If "raise", the default, then a ValueError is raised. If "skip", then the constant columns are ignored and the corresponding elements of the result are NaN. Returns ------- ndarray or DataFrame The variance inflation factors with the penalization factors in rows and the variables in columns. This is a pandas DataFrame with the penalization factors as index and the column labels of `data` as columns if `data` is a pandas DataFrame. Raises ------ ValueError If `data` contains missing or infinite values, if `data` has constant columns and `has_constant` is "raise", if a penalization factor is negative, or if `is_moment` is True and `data` is not a valid moment matrix. See Also -------- vif : VIF of regression without penalization. Notes ----- With the correlation matrix :math:`R` and the penalization factor :math:`k`, the VIF of ridge regression are the diagonal elements of :math:`(R + k I)^{-1} R (R + k I)^{-1}`. The result for :math:`k=0` is the same as that of :func:`vif`. The correlation matrix is singular if some variables are exactly collinear, in which case the penalization factors have to be positive. A factor of zero is handled as in :func:`vif`. All penalization factors are computed with one eigenvalue decomposition of the correlation matrix. References ---------- .. [1] Marquardt, D. W. 1970. Generalized inverses, ridge regression, biased linear estimation, and nonlinear estimation. Technometrics 12 (3): 591-612. Examples -------- The ridge penalization removes most of the collinearity of the Longley data. The penalization factors are added to the diagonal of the correlation matrix, so they are on the scale of standardized variables. The columns are standardized first to make this explicit, the result is the same as for the original data, which `vif_ridge` standardizes through the correlation matrix. The row with a penalization factor of zero contains the VIF without penalization. >>> import statsmodels.api as sm >>> from statsmodels.stats.api import vif_ridge >>> exog = sm.datasets.longley.load_pandas().exog >>> exog_std = (exog - exog.mean()) / exog.std() >>> vif_ridge(exog_std, [0, 0.01, 0.1]).round(1) GNPDEFL GNP UNEMP ARMED POP YEAR 0.00 135.5 1788.5 33.6 3.6 399.2 759.0 0.01 17.2 3.5 2.7 2.1 13.8 10.0 0.10 0.8 0.3 1.5 1.2 0.6 0.2 """ corr, is_const = _corr_nonconstant(data, is_moment, has_constant) pen = array_like(pen_factors, "pen_factors", ndim=1) if not np.isfinite(pen).all() or (pen < 0).any(): raise ValueError("pen_factors must be finite and non-negative") evals, evecs = _eigh_corr(corr) evals_pos = np.maximum(evals, 0) # diagonal of V diag(w / (w + k)^2) V' for all penalization factors k with np.errstate(divide="ignore", invalid="ignore"): scale = evals_pos[:, None] / (evals_pos[:, None] + pen) ** 2 vif_pen = ((evecs**2) @ scale).T zero = pen == 0 if zero.any(): vif_pen[zero] = _inv_diag(evals, evecs) res = np.full((pen.shape[0], is_const.shape[0]), np.nan) res[:, ~is_const] = vif_pen if isinstance(data, pd.DataFrame): res = pd.DataFrame(res, index=pen, columns=data.columns) return res
[docs] def collinear_index(data, tol=None): """ Find the columns that are perfectly collinear with preceding columns The columns are processed in sequence. A column is collinear if it is in the space spanned by the preceding columns that are not collinear. Parameters ---------- data : array_like The design matrix with observations in rows and variables in columns. The matrix is not demeaned and the columns are not standardized. If a constant is part of the design, then it should be in the first column, for example by using :func:`~statsmodels.tools.tools.add_constant`. tol : float, optional A column is collinear if the norm of its residuals in the regression on the preceding columns is less than or equal to `tol` times the norm of the column. The default is ``max(nobs, k_vars)`` times the machine epsilon, which is the default of :func:`numpy.linalg.matrix_rank`. Returns ------- idx_collinear : ndarray or Index Position of the columns that are collinear with preceding columns. This is an Index with the column labels if `data` is a pandas DataFrame. idx_keep : ndarray or Index Position of the columns that are not collinear with preceding columns. This is an Index with the column labels if `data` is a pandas DataFrame. See Also -------- vif : VIF for all columns, which is infinite for collinear columns. Notes ----- Removing the collinear columns, i.e. selecting `idx_keep`, gives a design matrix of full column rank. Which of the columns that are part of a collinear relationship is flagged depends on the order of the columns, only the last column of each relationship is flagged. The residuals are computed with Gram-Schmidt orthogonalization of the columns with reorthogonalization. A column that is flagged does not contribute to the orthogonal basis for the columns that follow it. Examples -------- A constant and a full set of dummy variables are collinear. >>> import numpy as np >>> from statsmodels.stats.api import collinear_index >>> dummies = np.tile(np.eye(3), (4, 1)) >>> x = np.column_stack((np.ones(12), dummies)) >>> idx_collinear, idx_keep = collinear_index(x) >>> idx_collinear array([3]) >>> idx_keep array([0, 1, 2]) """ x = array_like(data, "data", ndim=2) if not np.isfinite(x).all(): raise ValueError("data must not contain missing or infinite values") nobs, k_vars = x.shape if tol is None: tol = max(nobs, k_vars) * _EPS norm = np.linalg.norm(x, axis=0) basis = np.empty((nobs, k_vars)) n_basis = 0 is_collinear = np.zeros(k_vars, dtype=bool) for j in range(k_vars): resid = x[:, j] for _ in range(2): # two passes of classical Gram-Schmidt for orthogonality b = basis[:, :n_basis] resid = resid - b @ (b.T @ resid) resid_norm = np.linalg.norm(resid) if resid_norm <= tol * norm[j]: is_collinear[j] = True else: basis[:, n_basis] = resid / resid_norm n_basis += 1 idx_collinear = np.nonzero(is_collinear)[0] idx_keep = np.nonzero(~is_collinear)[0] if isinstance(data, pd.DataFrame): return data.columns[idx_collinear], data.columns[idx_keep] return idx_collinear, idx_keep