Source code for statsmodels.stats.gof

'''
Extra statistical function and helper functions

contains:

* goodness-of-fit tests
  - powerdiscrepancy
  - gof_chisquare_discrete
  - gof_binning_discrete



Author: Josef Perktold
License : BSD-3

changes
-------
2013-02-25 : add chisquare_power, effectsize and "value"

'''
from typing import NamedTuple

import numpy as np
from scipy import stats

from statsmodels.tools.validation import int_like, string_like


# copied from regression/stats.utils
[docs] def powerdiscrepancy(observed, expected, lambd=0.0, axis=0, ddof=0): r""" Calculates power discrepancy, a class of goodness-of-fit tests as a measure of discrepancy between observed and expected data. This contains several goodness-of-fit tests as special cases, see the description of lambd, the exponent of the power discrepancy. The pvalue is based on the asymptotic chi-square distribution of the test statistic. freeman_tukey: :math:`D(x|\theta) = \sum_j (\sqrt{x_j} - \sqrt{e_j})^2` Parameters ---------- observed : array_like Observed values expected : array_like Expected values lambd : float or {'loglikeratio', 'freeman_tukey', 'pearson', 'modified_loglikeratio', 'cressie_read'}, optional * float : exponent `a` for power discrepancy * 'loglikeratio': a = 0 * 'freeman_tukey': a = -0.5 * 'pearson': a = 1 (standard chisquare test statistic) * 'modified_loglikeratio': a = -1 * 'cressie_read': a = 2/3 axis : int, optional axis for observations of one series ddof : int, optional degrees of freedom correction, Returns ------- D_obs : float or ndarray Discrepancy of observed values. pvalue : float or ndarray The p-value. References ---------- Cressie, Noel and Timothy R. C. Read, Multinomial Goodness-of-Fit Tests, Journal of the Royal Statistical Society. Series B (Methodological), Vol. 46, No. 3 (1984), pp. 440-464 Campbell B. Read: Freeman-Tukey chi-squared goodness-of-fit statistics, Statistics & Probability Letters 18 (1993) 271-278 Nobuhiro Taneichi, Yuri Sekiya, Akio Suzukawa, Asymptotic Approximations for the Distributions of the Multinomial Goodness-of-Fit Statistics under Local Alternatives, Journal of Multivariate Analysis 81, 335-359 (2002) Steele, M. 1,2, C. Hurst 3 and J. Chaseling, Simulated Power of Discrete Goodness-of-Fit Tests for Likert Type Data Examples -------- >>> observed = np.array([ 2., 4., 2., 1., 1.]) >>> expected = np.array([ 0.2, 0.2, 0.2, 0.2, 0.2]) for checking correct dimension with multiple series >>> powerdiscrepancy(np.column_stack((observed,observed)).T, 10*expected, lambd='freeman_tukey',axis=1) (array([[ 2.745166, 2.745166]]), array([[ 0.6013346, 0.6013346]])) >>> powerdiscrepancy(np.column_stack((observed,observed)).T, 10*expected,axis=1) (array([[ 2.77258872, 2.77258872]]), array([[ 0.59657359, 0.59657359]])) >>> powerdiscrepancy(np.column_stack((observed,observed)).T, 10*expected, lambd=0,axis=1) (array([[ 2.77258872, 2.77258872]]), array([[ 0.59657359, 0.59657359]])) >>> powerdiscrepancy(np.column_stack((observed,observed)).T, 10*expected, lambd=1,axis=1) (array([[ 3., 3.]]), array([[ 0.5578254, 0.5578254]])) >>> powerdiscrepancy(np.column_stack((observed,observed)).T, 10*expected, lambd=2/3.0,axis=1) (array([[ 2.89714546, 2.89714546]]), array([[ 0.57518277, 0.57518277]])) >>> powerdiscrepancy(np.column_stack((observed,observed)).T, expected, lambd=2/3.0,axis=1) (array([[ 2.89714546, 2.89714546]]), array([[ 0.57518277, 0.57518277]])) >>> powerdiscrepancy(np.column_stack((observed,observed)), expected, lambd=2/3.0, axis=0) (array([[ 2.89714546, 2.89714546]]), array([[ 0.57518277, 0.57518277]])) each random variable can have different total count/sum >>> powerdiscrepancy(np.column_stack((observed,2*observed)), expected, lambd=2/3.0, axis=0) (array([[ 2.89714546, 5.79429093]]), array([[ 0.57518277, 0.21504648]])) >>> powerdiscrepancy(np.column_stack((observed,2*observed)), expected, lambd=2/3.0, axis=0) (array([[ 2.89714546, 5.79429093]]), array([[ 0.57518277, 0.21504648]])) >>> powerdiscrepancy(np.column_stack((2*observed,2*observed)), expected, lambd=2/3.0, axis=0) (array([[ 5.79429093, 5.79429093]]), array([[ 0.21504648, 0.21504648]])) >>> powerdiscrepancy(np.column_stack((2*observed,2*observed)), 20*expected, lambd=2/3.0, axis=0) (array([[ 5.79429093, 5.79429093]]), array([[ 0.21504648, 0.21504648]])) >>> powerdiscrepancy(np.column_stack((observed,2*observed)), np.column_stack((10*expected,20*expected)), lambd=2/3.0, axis=0) (array([[ 2.89714546, 5.79429093]]), array([[ 0.57518277, 0.21504648]])) >>> powerdiscrepancy(np.column_stack((observed,2*observed)), np.column_stack((10*expected,20*expected)), lambd=-1, axis=0) (array([[ 2.77258872, 5.54517744]]), array([[ 0.59657359, 0.2357868 ]])) """ o = np.array(observed) e = np.array(expected) if isinstance(lambd, str): lambd = string_like( lambd, "lambd", options=( "loglikeratio", "freeman_tukey", "pearson", "modified_loglikeratio", "cressie_read", ), lower=False, ) if not isinstance(lambd, str): a = lambd elif lambd == "loglikeratio": a = 0 elif lambd == "freeman_tukey": a = -0.5 elif lambd == "pearson": a = 1 elif lambd == "modified_loglikeratio": a = -1 else: # lambd == "cressie_read" a = 2/3.0 n = np.sum(o, axis=axis) nt = n if n.size > 1: n = np.atleast_2d(n) if axis == 1: nt = n.T # need both for 2d, n and nt for broadcasting if e.ndim == 1: e = np.atleast_2d(e) if axis == 0: e = e.T if np.allclose(np.sum(e, axis=axis), 1, rtol=1e-8, atol=0): e = nt * e # Could add this to return later if someone cares about it # p = e elif not np.allclose(np.sum(e, axis=axis), n, rtol=1e-8, atol=0): raise ValueError("observed and expected need to have the same " "number of observations, or e needs to add to 1") # p in the other case, if added to return later # else: # p = e/(1.0*nt) k = o.shape[axis] if e.shape[axis] != k: raise ValueError("observed and expected need to have the same " "number of bins") # Note: taken from formulas, to simplify cancel n if a == 0: # log likelihood ratio D_obs = 2*n * np.sum(o/(1.0*nt) * np.log(o/e), axis=axis) elif a == -1: # modified log likelihood ratio D_obs = 2*n * np.sum(e/(1.0*nt) * np.log(e/o), axis=axis) else: D_obs = 2*n/a/(a+1) * np.sum(o/(1.0*nt) * ((o/e)**a - 1), axis=axis) return D_obs, stats.chi2.sf(D_obs, k-1-ddof)
# TODO: need also binning for continuous distribution # and separated binning function to be used for powerdiscrepancy
[docs] def gof_chisquare_discrete(distfn, arg, rvs, alpha, msg): """ Perform chisquare test for random sample of a discrete distribution Parameters ---------- distfn : distribution instance Discrete distribution function to be tested; needs ``support``, ``ppf`` and ``cdf`` methods. arg : sequence parameters of distribution rvs : array_like random sample from the distribution to be tested alpha : float significance level, threshold for p-value msg : str identifying message that is included in the returned result string Returns ------- chis : float chisquare statistic of the test pval : float p-value of the test based on the chisquare distribution truefalse : bool True if the test passes (``pval > alpha``), False if it fails outstr : str summary string describing the test and its result Notes ----- originally written for scipy.stats test suite, still needs to be checked for standalone usage, insufficient input checking may not run yet (after copy/paste) The bins are computed by :func:`gof_binning_discrete` with its default number of bins. Observations outside the support of the distribution are dropped. The last bin can have a very small expected count, which makes the chisquare approximation of the p-value unreliable. .. versionchanged:: 0.15.1 The support of the distribution is taken from ``distfn.support(*arg)`` instead of a fixed window of integers from -1000 to 1000. The bins now follow loc shifts and large means. refactor: maybe a class, check returns, or separate binning from test results """ freq, expfreq, _ = gof_binning_discrete(rvs, distfn, arg) chis, pval = stats.chisquare(freq, expfreq) return ( chis, pval, (pval > alpha), f"chisquare - test for {msg} at arg = {arg!s} with pval = {pval!s}", )
def _discrete_ppf_from_cdf(distfn, q, arg, lower, upper, start): """ Smallest integer-spaced point with ``cdf >= q`` using only ``distfn.cdf`` Fallback for distributions where the ``ppf`` method fails. The cdf of a distribution on an integer support is monotone, so the quantile is found by an exponential search for a bracket followed by bisection. Parameters ---------- distfn : distribution instance Discrete distribution with a ``cdf`` method. q : float Probability, ``0 < q < 1``. arg : sequence Parameters of the distribution, shape parameters and loc, which is assumed to be an integer. lower, upper : float Support of the distribution, possibly infinite. start : float Starting point for the search, e.g. the previous quantile. It is clipped to the support. Returns ------- float The smallest integer ``k`` in the support, shifted by loc, such that ``cdf(k) >= q``. """ max_expansions = 1100 def reached(k): # nan compares as False, which is treated as not reached return distfn.cdf(k, *arg) >= q if not np.isfinite(start): start = 0.0 start = np.floor(min(max(start, lower), upper)) lo = lower - 1 if np.isfinite(lower) else None hi = upper if np.isfinite(upper) else None step = 1.0 if reached(start): hi = start if lo is None: for _ in range(max_expansions): pos = hi - step step *= 2 if not reached(pos): lo = pos break hi = pos else: lo = start if hi is None: for _ in range(max_expansions): pos = lo + step step *= 2 if reached(pos): hi = pos break lo = pos if lo is None or hi is None: raise RuntimeError("could not bracket the quantile using the cdf") while hi - lo > 1: mid = np.floor((lo + hi) / 2) if reached(mid): hi = mid else: lo = mid return hi
[docs] def gof_binning_discrete(rvs, distfn, arg, nsupp=20): """ Get bins for chisquare type gof tests for a discrete distribution Parameters ---------- rvs : array_like One-dimensional sample data. distfn : distribution instance Discrete distribution function to be tested; needs ``support``, ``ppf`` and ``cdf`` methods. arg : sequence parameters of distribution nsupp : int, optional number of bins, at least 1. The default is 20. The algorithm tries to find bins with equal weights. depending on the distribution, the actual number of bins can be smaller. Returns ------- freq : ndarray empirical frequencies for sample; not normalized, adds up to sample size if all observations are in the support expfreq : ndarray theoretical frequencies according to distribution histsupp : ndarray bin boundaries for histogram. The first is the lower end of the support. The others are the upper bin edges moved to the next representable float, so that the bins are right-half-open. Raises ------ ValueError If ``nsupp`` is not an integer greater than or equal to 1. Notes ----- The results can be used for a chisquare test :: (chis,pval) = stats.chisquare(freq, expfreq) The bins are quantile based and the expected count of each bin is at least ``len(rvs) / nsupp``, except for the last (tail) bin, which can have a very small expected count. Observations outside the support of the distribution are dropped, so that ``freq`` does not add up to the sample size in that case. If the ``ppf`` method of the distribution raises a ``RuntimeError``, the quantiles are found by a search using only the ``cdf`` method. Both methods assume that the support is integer valued and that loc is an integer. .. versionchanged:: 0.15.1 The support of the distribution is taken from ``distfn.support(*arg)`` instead of a fixed window of integers from -1000 to 1000. The bins now follow loc shifts and large means. The upper boundaries in ``histsupp`` are the next representable float above the bin edges instead of the edges plus 1e-8. originally written for scipy.stats test suite, still needs to be checked for standalone usage, insufficient input checking may not run yet (after copy/paste) refactor: maybe a class, check returns, or separate binning from test results todo : optimal number of bins ? (check easyfit), recommendation in literature at least 5 expected observations in each bin """ nsupp = int_like(nsupp, "nsupp") if nsupp < 1: raise ValueError(f"nsupp must be an integer >= 1, got {nsupp}") # define parameters for test n = len(rvs) wsupp = 1.0/nsupp # Construct intervals with minimum mass 1/nsupp, apart from the tail. # Use quantiles rather than scanning a fixed range of integers. lower, upper = distfn.support(*arg) last = 0 distsupp = [lower] distmass = [] use_cdf_search = False while last < 1 - wsupp: # Retain the tolerance used for comparing the bin mass to 1/nsupp. q = last + wsupp - 1e-14 if not use_cdf_search: try: ii = distfn.ppf(q, *arg) except RuntimeError: # The generic ppf of SciPy can fail to converge, e.g. for # skellam with large parameters. Use the monotone cdf instead. use_cdf_search = True if use_cdf_search: ii = _discrete_ppf_from_cdf(distfn, q, arg, lower, upper, distsupp[-1]) current = distfn.cdf(ii, *arg) distsupp.append(ii) distmass.append(current - last) last = current if distsupp[-1] < upper: distsupp.append(upper) distmass.append(1-last) distsupp = np.array(distsupp) distmass = np.array(distmass) # convert intervals to right-half-open as required by histogram. The # support is integer valued, so the next representable float is exact, # unlike adding a fixed epsilon which is lost for large values. histsupp = np.nextafter(distsupp, np.inf) histsupp[0] = lower # find sample frequencies and perform chisquare test freq, hsupp = np.histogram(rvs, histsupp) return np.array(freq), n*distmass, histsupp
# -*- coding: utf-8 -*- """ Extension to chisquare goodness-of-fit test Created on Mon Feb 25 13:46:53 2013 Author: Josef Perktold License: BSD-3 """
[docs] class ChisquareResult(NamedTuple): """ Result of :func:`chisquare` when ``return_basic`` is False. Parameters ---------- chisq : float The chisquare test statistic. pvalue : float The p-value based on the chisquare distribution if ``value`` is zero, or on the noncentral chisquare distribution otherwise. df : int Degrees of freedom of the (potentially noncentral) chisquare distribution used to compute ``pvalue``, equal to ``n_bins - 1 - ddof``. distribution : {"chi2", "ncx2"} The name of the distribution used to compute ``pvalue``: ``"chi2"`` when ``value`` is 0, otherwise ``"ncx2"`` (the noncentral chisquare distribution). """ chisq: float pvalue: float df: int distribution: str
[docs] def chisquare(f_obs, f_exp=None, value=0, ddof=0, return_basic=True): ''' chisquare goodness-of-fit test The null hypothesis is that the distance between the expected distribution and the observed frequencies is ``value``. The alternative hypothesis is that the distance is larger than ``value``. ``value`` is normalized in terms of effect size. The standard chisquare test has the null hypothesis that ``value=0``, that is the distributions are the same. Parameters ---------- f_obs : array_like Observed frequencies. f_exp : array_like, optional Expected frequencies. If None, then the observed frequencies are assumed to follow a uniform distribution over the bins. value : float, optional Value of the effect size under the null hypothesis. ddof : int, optional Degrees of freedom correction. return_basic : bool, optional If True, return only the chisquare statistic and the p-value as a plain tuple. If False, return a :class:`ChisquareResult` NamedTuple that additionally reports the degrees of freedom and the name of the distribution used to compute the p-value. Returns ------- chisq : float The chisquare test statistic. pvalue : float The p-value based on the chisquare distribution if ``value`` is zero, or on the noncentral chisquare distribution otherwise. If ``return_basic`` is False, a :class:`ChisquareResult` NamedTuple is returned instead, with fields ``chisq``, ``pvalue``, ``df``, and ``distribution``. Notes ----- The case with value greater than zero is similar to an equivalence test, that the exact null hypothesis is replaced by an approximate hypothesis. However, TOST "reverses" null and alternative hypothesis, while here the alternative hypothesis is that the distance (divergence) is larger than a threshold. References ---------- McLaren, ... Drost,... See Also -------- powerdiscrepancy scipy.stats.chisquare ''' f_obs = np.asarray(f_obs) n_bins = len(f_obs) nobs = f_obs.sum(0) if f_exp is None: # uniform distribution f_exp = np.empty(n_bins, float) f_exp.fill(nobs / float(n_bins)) f_exp = np.asarray(f_exp, float) df = n_bins - 1 - ddof chisq = ((f_obs - f_exp)**2 / f_exp).sum(0) if value == 0: pvalue = stats.chi2.sf(chisq, df) distribution = "chi2" else: pvalue = stats.ncx2.sf(chisq, df, value**2 * nobs) distribution = "ncx2" if return_basic: return chisq, pvalue else: return ChisquareResult( chisq=chisq, pvalue=pvalue, df=df, distribution=distribution )
def chisquare_power(effect_size, nobs, n_bins, alpha=0.05, ddof=0): """ Power of chisquare goodness of fit test effect size is sqrt of chisquare statistic divided by nobs Parameters ---------- effect_size : float or array_like This is the deviation from the Null of the normalized chi_square statistic. This follows Cohen's definition (sqrt). nobs : float or array_like number of observations n_bins : float or array_like number of bins, or points in the discrete distribution alpha : float or array_like, optional significance level of the test, default alpha=0.05 ddof : int, optional degrees of freedom correction, default 0 Returns ------- power : float power of the test at given significance level at effect size Notes ----- This function also works vectorized if all arguments broadcast. This can also be used to calculate the power for power divergence test. However, for the range of more extreme values of the power divergence parameter, this power is not a very good approximation for samples of small to medium size (Drost et al. 1989) References ---------- Drost, ... See Also -------- chisquare_effectsize statsmodels.stats.GofChisquarePower """ if not np.all(np.greater(nobs, 0)): raise ValueError("nobs must be positive") if not np.all(np.greater_equal(n_bins, 1)): raise ValueError("n_bins must be at least 1") if not np.all(np.greater(alpha, 0) & np.less(alpha, 1)): raise ValueError(f"alpha must be in the range (0, 1), got {alpha}") crit = stats.chi2.isf(alpha, n_bins - 1 - ddof) power = stats.ncx2.sf(crit, n_bins - 1 - ddof, effect_size**2 * nobs) return power
[docs] def chisquare_effectsize(probs0, probs1, correction=None, cohen=True, axis=0): """ Effect size for a chisquare goodness-of-fit test Parameters ---------- probs0 : array_like probabilities or cell frequencies under the Null hypothesis probs1 : array_like probabilities or cell frequencies under the Alternative hypothesis probs0 and probs1 need to have the same length in the ``axis`` dimension. and broadcast in the other dimensions Both probs0 and probs1 are normalized to add to one (in the ``axis`` dimension). correction : None or tuple, optional If None, then the effect size is the chisquare statistic divide by the number of observations. If the correction is a tuple (nobs, df), then the effectsize is corrected to have less bias and a smaller variance. However, the correction can make the effectsize negative. In that case, the effectsize is set to zero. Pederson and Johnson (1990) as referenced in McLaren et all. (1994) cohen : bool, optional If True, then the square root is returned as in the definition of the effect size by Cohen (1977), If False, then the original effect size is returned. axis : int, optional If the probability arrays broadcast to more than 1 dimension, then this is the axis over which the sums are taken. Returns ------- effectsize : float effect size of chisquare test """ probs0 = np.asarray(probs0, float) probs1 = np.asarray(probs1, float) if np.any(probs0 < 0) or np.any(probs1 < 0): raise ValueError("probs0 and probs1 must be non-negative") probs0 = probs0 / probs0.sum(axis) probs1 = probs1 / probs1.sum(axis) d2 = ((probs1 - probs0)**2 / probs0).sum(axis) if correction is not None: nobs, df = correction diff = ((probs1 - probs0) / probs0).sum(axis) d2 = np.maximum((d2 * nobs - diff - df) / (nobs - 1.), 0) if cohen: return np.sqrt(d2) else: return d2