Source code for statsmodels.miscmodels.ordinal_model
"""
Created on Sat Aug 22 20:24:42 2015
Author: Josef Perktold
License: BSD-3
"""
import warnings
from statsmodels.compat.pandas import Appender
import numpy as np
import pandas as pd
from pandas.api.types import CategoricalDtype
from scipy import stats
from statsmodels.base.model import (
Model,
LikelihoodModel,
GenericLikelihoodModel,
GenericLikelihoodModelResults,
)
import statsmodels.base.wrapper as wrap
# for results wrapper:
import statsmodels.regression.linear_model as lm
from statsmodels.tools.decorators import cache_readonly
[docs]
class OrderedModel(GenericLikelihoodModel):
"""Ordinal Model based on logistic or normal distribution
The parameterization corresponds to the proportional odds model in the
logistic case.
The model assumes that the endogenous variable is ordered but that the
labels have no numeric interpretation besides the ordering.
The model is based on a latent linear variable, where we observe only a
discretization.
y_latent = X beta + u
The observed variable is defined by the interval
y = {0 if y_latent <= cut_0
1 of cut_0 < y_latent <= cut_1
...
K if cut_K < y_latent
The probability of observing y=k conditional on the explanatory variables
X is given by
prob(y = k | x) = Prob(cut_k < y_latent <= cut_k+1)
= Prob(cut_k - x beta < u <= cut_k+1 - x beta
= F(cut_k+1 - x beta) - F(cut_k - x beta)
Where F is the cumulative distribution of u which is either the normal
or the logistic distribution, but can be set to any other continuous
distribution. We use standardized distributions to avoid identifiability
problems.
Parameters
----------
endog : array_like
Endogenous or dependent ordered categorical variable with k levels.
Labels or values of endog will internally transformed to consecutive
integers, 0, 1, 2, ...
pd.Series with ordered Categorical as dtype should be preferred as it
gives the order relation between the levels.
If endog is not a pandas Categorical, then categories are
sorted in lexicographic order (by numpy.unique).
exog : array_like
Exogenous, explanatory variables. This should not include an intercept.
pd.DataFrame are also accepted.
see Notes about constant when using formulas
offset : array_like
Offset is added to the linear prediction with coefficient equal to 1.
distr : string 'probit' or 'logit', or a distribution instance
The default is currently 'probit' which uses the normal distribution
and corresponds to an ordered Probit model. The distribution is
assumed to have the main methods of scipy.stats distributions, mainly
cdf, pdf and ppf. The inverse cdf, ppf, is only use to calculate
starting values.
Notes
-----
Status: experimental, core results are verified, still subclasses
`GenericLikelihoodModel` which will change in future versions.
The parameterization of OrderedModel requires that there is no constant in
the model, neither explicit nor implicit. The constant is equivalent to
shifting all thresholds and is therefore not separately identified.
Patsy's formula specification does not allow a design matrix without
explicit or implicit constant if there are categorical variables (or maybe
splines) among explanatory variables. As workaround, statsmodels removes an
explicit intercept.
Consequently, there are two valid cases to get a design matrix without
intercept when using formulas:
- specify a model without explicit and implicit intercept which is possible
if there are only numerical variables in the model.
- specify a model with an explicit intercept which statsmodels will remove.
Models with an implicit intercept will be overparameterized, the parameter
estimates will not be fully identified, cov_params will not be invertible
and standard errors might contain nans. The computed results will be
dominated by numerical imprecision coming mainly from convergence tolerance
and numerical derivatives.
The model will raise a ValueError if a remaining constant is detected.
"""
_formula_max_endog = np.inf
def __init__(self, endog, exog, offset=None, distr='probit', **kwds):
if distr == 'probit':
self.distr = stats.norm
elif distr == 'logit':
self.distr = stats.logistic
else:
self.distr = distr
if offset is not None:
offset = np.asarray(offset)
self.offset = offset
endog, labels, is_pandas = self._check_inputs(endog, exog)
super().__init__(endog, exog, **kwds)
k_levels = None # initialize
if not is_pandas:
if self.endog.ndim == 1:
unique, index = np.unique(self.endog, return_inverse=True)
self.endog = index
labels = unique
if np.isnan(labels).any():
msg = ("NaN in dependent variable detected. "
"Missing values need to be removed.")
raise ValueError(msg)
elif self.endog.ndim == 2:
if not hasattr(self, "design_info"):
raise ValueError("2-dim endog not supported")
# this branch is currently only in support of from_formula
# we need to initialize k_levels correctly for df_resid
k_levels = self.endog.shape[1]
labels = []
# Note: Doing the following here would break from_formula
# self.endog = self.endog.argmax(1)
if self.k_constant > 0:
raise ValueError("There should not be a constant in the model")
self._initialize_labels(labels, k_levels=k_levels)
# adjust df
self.k_extra = self.k_levels - 1
self.df_model = self.k_vars
self.df_resid = self.nobs - (self.k_vars + self.k_extra)
self.results_class = OrderedResults
def _check_inputs(self, endog, exog):
"""Handle endog that is pandas Categorical.
Checks if self.distrib is legal and provides Pandas ordered Categorical
support for endog.
Parameters
----------
endog : array_like
Endogenous, dependent variable, 1-D.
exog : array_like
Exogenous, explanatory variables.
Currently not used.
Returns
-------
endog : array_like or pandas Series
If the original endog is a pandas ordered Categorical Series,
then the returned endog are the ``codes``, i.e. integer
representation of ordere categorical variable
labels : None or list
If original endog is pandas ordered Categorical Series, then the
categories are returned. Otherwise ``labels`` is None.
is_pandas : bool
This is True if original endog is a pandas ordered Categorical
Series and False otherwise.
"""
if not isinstance(self.distr, stats.rv_continuous):
msg = (
f"{self.distr.name} is not a scipy.stats distribution."
)
warnings.warn(msg)
labels = None
is_pandas = False
if isinstance(endog, pd.Series):
if isinstance(endog.dtypes, CategoricalDtype):
if not endog.dtype.ordered:
warnings.warn("the endog has ordered == False, "
"risk of capturing a wrong order for the "
"categories. ordered == True preferred.",
Warning)
endog_name = endog.name
labels = endog.values.categories
endog = endog.cat.codes
if endog.min() == -1: # means there is a missing value
raise ValueError("missing values in categorical endog are "
"not supported")
endog.name = endog_name
is_pandas = True
return endog, labels, is_pandas
def _initialize_labels(self, labels, k_levels=None):
self.labels = labels
if k_levels is None:
self.k_levels = len(labels)
else:
self.k_levels = k_levels
if self.exog is not None:
self.nobs, self.k_vars = self.exog.shape
else: # no exog in model
self.nobs, self.k_vars = self.endog.shape[0], 0
threshold_names = [str(x) + '/' + str(y)
for x, y in zip(labels[:-1], labels[1:])]
# from GenericLikelihoodModel.fit
if self.exog is not None:
# avoid extending several times
if len(self.exog_names) > self.k_vars:
raise RuntimeError("something wrong with exog_names, too long")
self.exog_names.extend(threshold_names)
else:
self.data.xnames = threshold_names
[docs]
@classmethod
def from_formula(cls, formula, data, subset=None, drop_cols=None,
*args, **kwargs):
# we want an explicit Intercept in the model that we can remove
# Removing constant with "0 +" or "- 1" does not work for categ. exog
endog_name = formula.split("~")[0].strip()
original_endog = data[endog_name]
model = super().from_formula(
formula, data=data, drop_cols=["Intercept"], *args, **kwargs)
if model.endog.ndim == 2:
if not (isinstance(original_endog.dtype, CategoricalDtype)
and original_endog.dtype.ordered):
msg = ("Only ordered pandas Categorical are supported as "
"endog in formulas")
raise ValueError(msg)
labels = original_endog.values.categories
model._initialize_labels(labels)
model.endog = model.endog.argmax(1)
model.data.ynames = endog_name
return model
from_formula.__func__.__doc__ = Model.from_formula.__doc__
[docs]
def cdf(self, x):
"""Cdf evaluated at x.
Parameters
----------
x : array_like
Points at which cdf is evaluated. In the model `x` is the latent
variable plus threshold constants.
Returns
-------
Value of the cumulative distribution function of the underlying latent
variable evaluated at x.
"""
return self.distr.cdf(x)
[docs]
def pdf(self, x):
"""Pdf evaluated at x
Parameters
----------
x : array_like
Points at which cdf is evaluated. In the model `x` is the latent
variable plus threshold constants.
Returns
-------
Value of the probability density function of the underlying latent
variable evaluated at x.
"""
return self.distr.pdf(x)
[docs]
def prob(self, low, upp):
"""Interval probability.
Probability that value is in interval (low, upp], computed as
prob = cdf(upp) - cdf(low)
Parameters
----------
low : array_like
lower bound for interval
upp : array_like
upper bound for interval
Returns
-------
float or ndarray
Probability that value falls in interval (low, upp]
"""
return np.maximum(self.cdf(upp) - self.cdf(low), 0)
[docs]
def transform_threshold_params(self, params):
"""transformation of the parameters in the optimization
Parameters
----------
params : nd_array
Contains (exog_coef, transformed_thresholds) where exog_coef are
the coefficient for the explanatory variables in the linear term,
transformed threshold or cutoff points. The first, lowest threshold
is unchanged, all other thresholds are in terms of exponentiated
increments.
Returns
-------
thresh : nd_array
Thresh are the thresholds or cutoff constants for the intervals.
"""
th_params = params[-(self.k_levels - 1):]
thresh = np.concatenate((th_params[:1],
np.exp(th_params[1:]))).cumsum()
thresh = np.concatenate(([-np.inf], thresh, [np.inf]))
return thresh
[docs]
def transform_reverse_threshold_params(self, params):
"""obtain transformed thresholds from original thresholds or cutoffs
Parameters
----------
params : ndarray
Threshold values, cutoff constants for choice intervals, which
need to be monotonically increasing.
Returns
-------
thresh_params : ndarrray
Transformed threshold parameter.
The first, lowest threshold is unchanged, all other thresholds are
in terms of exponentiated increments.
Transformed parameters can be any real number without restrictions.
"""
thresh_params = np.concatenate((params[:1],
np.log(np.diff(params[:-1]))))
return thresh_params
[docs]
def predict(self, params, exog=None, offset=None, which="prob"):
"""
Predicted probabilities for each level of the ordinal endog.
Parameters
----------
params : ndarray
Parameters for the Model, (exog_coef, transformed_thresholds).
exog : array_like, optional
Design / exogenous data. If exog is None, model exog is used.
offset : array_like, optional
Offset is added to the linear prediction with coefficient
equal to 1. If offset is not provided and exog
is None, uses the model's offset if present. If not, uses
0 as the default value.
which : {"prob", "linpred", "cumprob"}
Determines which statistic is predicted.
- prob : predicted probabilities to be in each choice. 2-dim.
- linear : 1-dim linear prediction of the latent variable
``x b + offset``
- cumprob : predicted cumulative probability to be in choice k or
lower
Returns
-------
predicted values : ndarray
If which is "prob", then 2-dim predicted probabilities with
observations in rows and one column for each category or level of
the categorical dependent variable.
If which is "cumprob", then "prob" ar cumulatively added to get the
cdf at k, i.e. probability of observing choice k or lower.
If which is "linpred", then the conditional prediction of the
latent variable is returned. In this case, the return is
one-dimensional.
"""
# note, exog and offset handling is in linpred
thresh = self.transform_threshold_params(params)
xb = self._linpred(params, exog=exog, offset=offset)
if which == "linpred":
return xb
xb = xb[:, None]
low = thresh[:-1] - xb
upp = thresh[1:] - xb
if which == "prob":
prob = self.prob(low, upp)
return prob
elif which in ["cum", "cumprob"]:
cumprob = self.cdf(upp)
return cumprob
else:
raise ValueError("`which` is not available")
def _linpred(self, params, exog=None, offset=None):
"""Linear prediction of latent variable `x b + offset`.
Parameters
----------
params : ndarray
Parameters for the model, (exog_coef, transformed_thresholds)
exog : array_like, optional
Design / exogenous data. Is exog is None, model exog is used.
offset : array_like, optional
Offset is added to the linear prediction with coefficient
equal to 1. If offset is not provided and exog
is None, uses the model's offset if present. If not, uses
0 as the default value.
Returns
-------
linear : ndarray
1-dim linear prediction given by exog times linear params plus
offset. This is the prediction for the underlying latent variable.
If exog and offset are None, then the predicted values are zero.
"""
if exog is None:
exog = self.exog
if offset is None:
offset = self.offset
else:
if offset is None:
offset = 0
if offset is not None:
offset = np.asarray(offset)
if exog is not None:
_exog = np.asarray(exog)
_params = np.asarray(params)
linpred = _exog.dot(_params[:-(self.k_levels - 1)])
else: # means self.exog is also None
linpred = np.zeros(self.nobs)
if offset is not None:
linpred += offset
return linpred
def _bounds(self, params):
"""Integration bounds for the observation specific interval.
This defines the lower and upper bounds for the intervals of the
choices of all observations.
The bounds for observation are given by
a_{k_i-1} - linpred_i, a_k_i - linpred_i
where
- k_i is the choice in observation i.
- a_{k_i-1} and a_k_i are thresholds (cutoffs) for choice k_i
- linpred_i is the linear prediction for observation i
Parameters
----------
params : ndarray
Parameters for the model, (exog_coef, transformed_thresholds)
Return
------
low : ndarray
Lower bounds for choice intervals of each observation,
1-dim with length nobs
upp : ndarray
Upper bounds for choice intervals of each observation,
1-dim with length nobs.
"""
thresh = self.transform_threshold_params(params)
thresh_i_low = thresh[self.endog]
thresh_i_upp = thresh[self.endog + 1]
xb = self._linpred(params)
low = thresh_i_low - xb
upp = thresh_i_upp - xb
return low, upp
[docs]
@Appender(GenericLikelihoodModel.loglike.__doc__)
def loglike(self, params):
return self.loglikeobs(params).sum()
[docs]
def loglikeobs(self, params):
"""
Log-likelihood of OrderdModel for all observations.
Parameters
----------
params : array_like
The parameters of the model.
Returns
-------
loglike_obs : array_like
The log likelihood for each observation of the model evaluated
at ``params``.
"""
low, upp = self._bounds(params)
prob = self.prob(low, upp)
return np.log(prob + 1e-20)
[docs]
def score_obs_(self, params):
"""score, first derivative of loglike for each observations
This currently only implements the derivative with respect to the
exog parameters, but not with respect to threshold parameters.
"""
low, upp = self._bounds(params)
prob = self.prob(low, upp)
pdf_upp = self.pdf(upp)
pdf_low = self.pdf(low)
# TODO the following doesn't work yet because of the incremental exp
# parameterization. The following was written based on Greene for the
# simple non-incremental parameterization.
# k = self.k_levels - 1
# idx = self.endog
# score_factor = np.zeros((self.nobs, k + 1 + 2)) #+2 avoids idx bounds
#
# rows = np.arange(self.nobs)
# shift = 1
# score_factor[rows, shift + idx-1] = -pdf_low
# score_factor[rows, shift + idx] = pdf_upp
# score_factor[:, 0] = pdf_upp - pdf_low
score_factor = (pdf_upp - pdf_low)[:, None]
score_factor /= prob[:, None]
so = np.column_stack((-score_factor[:, :1] * self.exog,
score_factor[:, 1:]))
return so
@property
def start_params(self):
"""Start parameters for the optimization corresponding to null model.
The threshold are computed from the observed frequencies and
transformed to the exponential increments parameterization.
The parameters for explanatory variables are set to zero.
"""
# start params based on model without exog
freq = np.bincount(self.endog) / len(self.endog)
start_ppf = self.distr.ppf(np.clip(freq.cumsum(), 0, 1))
start_threshold = self.transform_reverse_threshold_params(start_ppf)
start_params = np.concatenate((np.zeros(self.k_vars), start_threshold))
return start_params
[docs]
@Appender(LikelihoodModel.fit.__doc__)
def fit(self, start_params=None, method='nm', maxiter=500, full_output=1,
disp=1, callback=None, retall=0, **kwargs):
fit_method = super().fit
mlefit = fit_method(start_params=start_params,
method=method, maxiter=maxiter,
full_output=full_output,
disp=disp, callback=callback, **kwargs)
# use the proper result class
ordmlefit = OrderedResults(self, mlefit)
# TODO: temporary, needs better fix, modelwc adds 1 by default
ordmlefit.hasconst = 0
result = OrderedResultsWrapper(ordmlefit)
return result
[docs]
class OrderedResults(GenericLikelihoodModelResults):
"""Results class for OrderedModel
This class inherits from GenericLikelihoodModelResults and not all
inherited methods might be appropriate in this case.
"""
[docs]
def pred_table(self):
"""prediction table
returns pandas DataFrame
"""
# todo: add category labels
categories = np.arange(self.model.k_levels)
observed = pd.Categorical(self.model.endog,
categories=categories, ordered=True)
predicted = pd.Categorical(self.predict().argmax(1),
categories=categories, ordered=True)
table = pd.crosstab(predicted,
observed.astype(int),
margins=True,
dropna=False).T.fillna(0)
return table
@cache_readonly
def llnull(self):
"""
Value of the loglikelihood of model without explanatory variables
"""
params_null = self.model.start_params
return self.model.loglike(params_null)
# next 3 are copied from discrete
@cache_readonly
def prsquared(self):
"""
McFadden's pseudo-R-squared. `1 - (llf / llnull)`
"""
return 1 - self.llf/self.llnull
@cache_readonly
def llr(self):
"""
Likelihood ratio chi-squared statistic; `-2*(llnull - llf)`
"""
return -2*(self.llnull - self.llf)
@cache_readonly
def llr_pvalue(self):
"""
The chi-squared probability of getting a log-likelihood ratio
statistic greater than llr. llr has a chi-squared distribution
with degrees of freedom `df_model`.
"""
# number of restrictions is number of exog
return stats.distributions.chi2.sf(self.llr, self.model.k_vars)
@cache_readonly
def resid_prob(self):
"""probability residual
Probability-scale residual is ``P(Y < y) − P(Y > y)`` where `Y` is the
observed choice and ``y`` is a random variable corresponding to the
predicted distribution.
References
----------
Shepherd BE, Li C, Liu Q (2016) Probability-scale residuals for
continuous, discrete, and censored data.
The Canadian Journal of Statistics. 44:463–476.
Li C and Shepherd BE (2012) A new residual for ordinal outcomes.
Biometrika. 99: 473–480
"""
from statsmodels.stats.diagnostic_gen import prob_larger_ordinal_choice
endog = self.model.endog
fitted = self.predict()
r = prob_larger_ordinal_choice(fitted)[1]
resid_prob = r[np.arange(endog.shape[0]), endog]
return resid_prob
class OrderedResultsWrapper(lm.RegressionResultsWrapper):
pass
wrap.populate_wrapper(OrderedResultsWrapper, OrderedResults)
Last update:
Nov 14, 2024