Source code for statsmodels.emplike.aft_el

"""
Accelerated Failure Time (AFT) Model with empirical likelihood inference

AFT regression analysis is applicable when the researcher has access
to a randomly right censored dependent variable, a matrix of exogenous
variables and an indicator variable (delta) that takes a value of 0 if the
observation is censored and 1 otherwise.

References
----------
Stute, W. (1993). "Consistent Estimation Under Random Censorship when
Covariables are Present." Journal of Multivariate Analysis.
Vol. 45. Iss. 1. 89-103

Zhou, Kim And Bathke. "Empirical Likelihood Analysis for the Heteroskedastic
Accelerated Failure Time Model." Manuscript:
URL: www.ms.uky.edu/~mai/research/CasewiseEL20080724.pdf

Zhou, M. (2005). Empirical Likelihood Ratio with Arbitrarily Censored/
Truncated Data by EM Algorithm.  Journal of Computational and Graphical
Statistics. 14:3, 643-656.
"""

import warnings

import numpy as np

# from elregress import ElReg
from scipy import optimize
from scipy.stats import chi2

from statsmodels.regression.linear_model import OLS, WLS
from statsmodels.tools import add_constant
from statsmodels.tools.sm_exceptions import IterationLimitWarning

from .descriptive import _OptFuncts


class OptAFT(_OptFuncts):
    """
    Provides optimization functions used in estimating and conducting
    inference in an AFT model

    Methods
    -------
    _opt_wtd_nuis_regress
        Function optimized over nuisance parameters to compute
        the profile likelihood

    _EM_test
        Uses the modified EM algorithm of Zhou 2005 to maximize the
        likelihood of a parameter vector.
    """

    def __init__(self):
        pass

    def _opt_wtd_nuis_regress(self, test_vals):
        """
        A function that is optimized over nuisance parameters to conduct a
        hypothesis test for the parameters of interest

        Parameters
        ----------
        test_vals : 1d array
            The regression coefficients of the model.  This includes the
            nuisance and parameters of interest.

        Returns
        -------
        llr : float
            -2 times the log likelihood of the nuisance parameters and the
            hypothesized value of the parameter(s) of interest.
        """
        test_params = test_vals.reshape(self.model.nvar, 1)
        est_vect = self.model.uncens_exog * (
            self.model.uncens_endog - np.dot(self.model.uncens_exog, test_params)
        )
        eta_star = self._modif_newton(
            np.zeros(self.model.nvar), est_vect, self.model._fit_weights
        )
        denom = np.sum(self.model._fit_weights) + np.dot(eta_star, est_vect.T)
        self.new_weights = self.model._fit_weights / denom
        return -1 * np.sum(np.log(self.new_weights))

    def _EM_test(
        self,
        nuisance_params,
        params=None,
        param_nums=None,
        b0_vals=None,
        F=None,
        survidx=None,
        uncens_nobs=None,
        numcensbelow=None,
        km=None,
        uncensored=None,
        censored=None,
        maxiter=None,
        ftol=None,
    ):
        """
        Uses EM algorithm to compute the maximum likelihood of a test

        Parameters
        ----------
        nuisance_params : ndarray
            Vector of values to be used as nuisance params.
        params : ndarray, optional
            Full vector of regression parameters.  The elements at
            ``param_nums`` are replaced by ``b0_vals`` and the remaining
            elements are replaced by ``nuisance_params``.
        param_nums : list, optional
            Indices of the parameters being tested at ``b0_vals``.
        b0_vals : list, optional
            Hypothesized values for the parameters at ``param_nums``.
        F : ndarray, optional
            Kaplan-Meier type weights for the uncensored observations used
            to initialize the E-step.
        survidx : ndarray, optional
            Index array used to select the survival probabilities that
            correspond to censored observations.
        uncens_nobs : int, optional
            Number of uncensored observations.
        numcensbelow : ndarray, optional
            Cumulative number of censored observations at or below each
            observation.
        km : ndarray, optional
            Kaplan-Meier estimator for all observations, used to compute the
            unconstrained (maximum) likelihood.
        uncensored : ndarray, optional
            Boolean array indicating which observations are uncensored.
        censored : ndarray, optional
            Boolean array indicating which observations are censored.
        maxiter : int, optional
            Number of iterations in the EM algorithm for a parameter vector.
        ftol : float, optional
            Function tolerance used to determine convergence of the EM
            algorithm.

        Returns
        -------
        llr : float
            -2 times the log likelihood ratio at the hypothesized values and
            nuisance params.

        Notes
        -----
        Optional parameters are provided by the test_beta function.
        """
        iters = 0
        params[param_nums] = b0_vals

        nuis_param_index = np.int_(np.delete(np.arange(self.model.nvar), param_nums))
        params[nuis_param_index] = nuisance_params
        to_test = params.reshape(self.model.nvar, 1)
        opt_res = np.inf
        diff = np.inf
        while iters < maxiter and diff > ftol:
            F = F.flatten()
            death = np.cumsum(F[::-1])
            survivalprob = death[::-1]
            surv_point_mat = np.dot(
                F.reshape(-1, 1), 1.0 / survivalprob[survidx].reshape(1, -1)
            )
            surv_point_mat = add_constant(surv_point_mat)
            summed_wts = np.cumsum(surv_point_mat, axis=1)
            wts = summed_wts[np.int_(np.arange(uncens_nobs)), numcensbelow[uncensored]]
            # ^E step
            # See Zhou 2005, section 3.
            self.model._fit_weights = wts
            new_opt_res = self._opt_wtd_nuis_regress(to_test)
            # ^ Uncensored weights' contribution to likelihood value.
            F = self.new_weights
            # ^ M step
            diff = np.abs(new_opt_res - opt_res)
            opt_res = new_opt_res
            iters = iters + 1
        death = np.cumsum(F.flatten()[::-1])
        survivalprob = death[::-1]
        llike = -opt_res + np.sum(np.log(survivalprob[survidx]))
        wtd_km = km.flatten() / np.sum(km)
        survivalmax = np.cumsum(wtd_km[::-1])[::-1]
        llikemax = np.sum(np.log(wtd_km[uncensored])) + np.sum(
            np.log(survivalmax[censored])
        )
        if iters == maxiter:
            warnings.warn(
                "The EM reached the maximum number of iterations",
                IterationLimitWarning,
                stacklevel=2,
            )
        return -2 * (llike - llikemax)

    def _ci_limits_beta(self, b0, param_num=None):
        """
        Returns the difference between the log likelihood for a
        parameter and some critical value

        Parameters
        ----------
        b0 : float
            Value of a regression parameter
        param_num : int
            Parameter index of b0

        Returns
        -------
        diff : float
            The difference between the log likelihood ratio at b0 and a
            pre-specified value.
        """
        return self.test_beta([b0], [param_num])[0] - self.r0


[docs] class emplikeAFT: """ Class for estimating and conducting inference in an AFT model Parameters ---------- endog : nx1 array Response variables that are subject to random censoring exog : nxk array Matrix of covariates censors : nx1 array Array with entries 0 or 1. 0 indicates a response was censored. Attributes ---------- nobs : float Number of observations endog : ndarray Endog array exog : ndarray Exogenous variable matrix censors Censors array but sets the max(endog) to uncensored nvar : float Number of exogenous variables uncens_nobs : float Number of uncensored observations uncens_endog : ndarray Uncensored response variables uncens_exog : ndarray Exogenous variables of the uncensored observations Methods ------- fit Fits the model and returns an AFTResults instance, whose ``params`` and ``test_beta`` methods fit model parameters and test if beta = b0 for any vector b0, respectively. predict Returns the linear predictor, params multiplied by endog. Notes ----- The data is immediately sorted in order of increasing endogenous variables The last observation is assumed to be uncensored which makes estimation and inference possible. """ def __init__(self, endog, exog, censors): self.nobs = np.shape(exog)[0] self.endog = endog.reshape(self.nobs, 1) self.exog = exog.reshape(self.nobs, -1) self.censors = np.asarray(censors).reshape(self.nobs, 1) self.nvar = self.exog.shape[1] idx = np.lexsort((-self.censors[:, 0], self.endog[:, 0])) self.endog = self.endog[idx] self.exog = self.exog[idx] self.censors = self.censors[idx] self.censors[-1] = 1 # Sort in init, not in function self.uncens_nobs = int(np.sum(self.censors)) mask = self.censors.ravel().astype(bool) self.uncens_endog = self.endog[mask, :].reshape(-1, 1) self.uncens_exog = self.exog[mask, :] def _is_tied(self, endog, censors): """ Indicate if an observation takes the same value as the next ordered observation Parameters ---------- endog : ndarray Model's endogenous variable censors : ndarray Array indicating censored observations Returns ------- indic_ties : ndarray ties[i]=1 if endog[i]==endog[i+1] and censors[i]=censors[i+1] """ nobs = int(self.nobs) endog_idx = endog[np.arange(nobs - 1)] == (endog[np.arange(nobs - 1) + 1]) censors_idx = censors[np.arange(nobs - 1)] == (censors[np.arange(nobs - 1) + 1]) indic_ties = endog_idx * censors_idx # Both true return np.int_(indic_ties) def _km_w_ties(self, tie_indic, untied_km): """ Computes KM estimator value at each observation, taking into account ties in the data Parameters ---------- tie_indic : 1d array Indicates if the i'th observation is the same as the ith +1 untied_km : 1d array Km estimates at each observation assuming no ties. Returns ------- km : ndarray The Kaplan-Meier estimates at each observation, adjusted so that tied observations share the same estimate. """ # TODO: Vectorize, even though it is only 1 pass through for any # function call num_same = 1 idx_nums = [] for obs_num in np.arange(int(self.nobs - 1))[::-1]: if tie_indic[obs_num] == 1: idx_nums.append(obs_num) num_same = num_same + 1 untied_km[obs_num] = untied_km[obs_num + 1] elif tie_indic[obs_num] == 0 and num_same > 1: idx_nums.append(max(idx_nums) + 1) idx_nums = np.asarray(idx_nums) untied_km[idx_nums] = untied_km[idx_nums] num_same = 1 idx_nums = [] return untied_km.reshape(self.nobs, 1) def _make_km(self, endog, censors): """ Computes the Kaplan-Meier estimate for the weights in the AFT model Parameters ---------- endog : nx1 array Array of response variables censors : nx1 array Censor-indicating variable Returns ------- weights : ndarray The Kaplan-Meier estimate for each observation Notes ----- This function makes calls to _is_tied and km_w_ties to handle ties in the data. If a censored observation and an uncensored observation have the same value, it is assumed that the uncensored observation happened first. """ nobs = self.nobs num = nobs - (np.arange(nobs) + 1.0) denom = nobs - (np.arange(nobs) + 1.0) + 1.0 km = (num / denom).reshape(nobs, 1) km = km ** np.abs(censors - 1.0) km = np.cumprod(km) # If no ties, this is kaplan-meier tied = self._is_tied(endog, censors) wtd_km = self._km_w_ties(tied, km) return (censors / wtd_km).reshape(nobs, 1)
[docs] def fit(self): """ Fits an AFT model and returns results instance Returns ------- AFTResults Results instance for the fitted AFT model. Notes ----- To avoid dividing by zero, max(endog) is assumed to be uncensored. """ return AFTResults(self)
[docs] def predict(self, params, endog=None): """ Return the linear predictor, params multiplied by endog Parameters ---------- params : ndarray Regression coefficients used to form the prediction. endog : ndarray, optional Values of the response variable at which to form the prediction. If None, the model's endog is used. Default is None. Returns ------- ndarray The predicted values, endog dot params. """ if endog is None: endog = self.endog return np.dot(endog, params)
class AFTResults(OptAFT): def __init__(self, model): self.model = model def params(self): """ Fits an AFT model and returns parameters Returns ------- ndarray The fitted regression parameters. Notes ----- To avoid dividing by zero, max(endog) is assumed to be uncensored. """ self.model.modif_censors = np.copy(self.model.censors) self.model.modif_censors[-1] = 1 wts = self.model._make_km(self.model.endog, self.model.modif_censors) res = WLS(self.model.endog, self.model.exog, wts).fit() params = res.params return params def test_beta(self, b0_vals, param_nums, ftol=10**-5, maxiter=30): """ Returns the profile log likelihood for regression parameters 'param_nums' at 'b0_vals' Parameters ---------- b0_vals : list The value of parameters to be tested param_nums : list Which parameters to be tested maxiter : int, optional How many iterations to use in the EM algorithm. Default is 30 ftol : float, optional The function tolerance for the EM optimization. Default is ``10**-5`` Returns ------- test_results : tuple The log-likelihood and p-value of the test. Notes ----- The function will warn if the EM reaches the maxiter. However, when optimizing over nuisance parameters, it is possible to reach a maximum number of inner iterations for a specific value for the nuisance parameters while the results of the function are still valid. This usually occurs when the optimization over the nuisance parameters selects parameter values that yield a log-likelihood ratio close to infinity. Examples -------- >>> import statsmodels.api as sm >>> import numpy as np # Test parameter is .05 in one regressor no intercept model >>> data=sm.datasets.heart.load() >>> y = np.log10(data.endog) >>> x = data.exog >>> cens = data.censors >>> model = sm.emplike.emplikeAFT(y, x, cens) >>> res=model.test_beta([0], [0]) >>> res (1.4657739632606308, 0.22601365256959183) # Test slope is 0 in a model with intercept >>> data=sm.datasets.heart.load() >>> y = np.log10(data.endog) >>> x = data.exog >>> cens = data.censors >>> model = sm.emplike.emplikeAFT(y, sm.add_constant(x), cens) >>> res = model.test_beta([0], [1]) >>> res (4.623487775078047, 0.031537049752572731) """ censors = self.model.censors endog = self.model.endog exog = self.model.exog uncensored = (censors == 1).flatten() censored = (censors == 0).flatten() uncens_endog = endog[uncensored] uncens_exog = exog[uncensored, :] reg_model = OLS(uncens_endog, uncens_exog).fit() llr, pval, new_weights = reg_model.el_test( b0_vals, param_nums, return_weights=True, use_namedtuple=False ) # Needs to be changed km = self.model._make_km(endog, censors).flatten() # when merged uncens_nobs = self.model.uncens_nobs F = np.asarray(new_weights).reshape(uncens_nobs) # Step 0 ^ params = self.params() survidx = np.where(censors == 0) survidx = survidx[0] - np.arange(len(survidx[0])) numcensbelow = np.int_(np.cumsum(1 - censors)) if len(param_nums) == len(params): llr = self._EM_test( [], F=F, params=params, param_nums=param_nums, b0_vals=b0_vals, survidx=survidx, uncens_nobs=uncens_nobs, numcensbelow=numcensbelow, km=km, uncensored=uncensored, censored=censored, ftol=ftol, maxiter=25, ) return llr, chi2.sf(llr, self.model.nvar) else: x0 = np.delete(params, param_nums) try: res = optimize.fmin( self._EM_test, x0, ( params, param_nums, b0_vals, F, survidx, uncens_nobs, numcensbelow, km, uncensored, censored, maxiter, ftol, ), full_output=1, disp=0, ) llr = res[1] return llr, chi2.sf(llr, len(param_nums)) except np.linalg.LinAlgError: return np.inf, 0 def ci_beta(self, param_num, beta_high, beta_low, sig=0.05): """ Returns the confidence interval for a regression parameter in the AFT model. Parameters ---------- param_num : int Parameter number of interest beta_high : float Upper bound for the confidence interval beta_low : float Lower bound for the confidence interval sig : float, optional Significance level. Default is .05 Returns ------- Interval : tuple Lower and upper confidence limit Notes ----- If the function returns f(a) and f(b) must have different signs, consider widening the search area by adjusting beta_low and beta_high. Also note that this process is computationally intensive. There are 4 levels of optimization/solving. From outer to inner: 1) Solving so that llr-critical value = 0 2) maximizing over nuisance parameters 3) Using EM at each value of nuisance parameters 4) Using the _modif_newton optimizer at each iteration of the EM algorithm. Also, for very unlikely nuisance parameters, it is possible for the EM algorithm to not converge. This is not an indicator that the solver did not find the correct solution. It just means for a specific iteration of the nuisance parameters, the optimizer was unable to converge. If the user desires to verify the success of the optimization, it is recommended to test the limits using test_beta. """ params = self.params() self.r0 = chi2.ppf(1 - sig, 1) ll = optimize.brentq( self._ci_limits_beta, beta_low, params[param_num], (param_num) ) ul = optimize.brentq( self._ci_limits_beta, params[param_num], beta_high, (param_num) ) return ll, ul