Download the notebook here
First maximum likelihood estimation with estimatig¶
This notebook shows how to do a simple maximum likelihood (ml) estimation with estimagic. As illustrating example we implement an ordered logit model from scratch.
Load and process the data
Set up a likelihood function
Maximize the likelihood function
Calculate standard errors, P-values and confidence intervals
The user only needs to do step 1 and 2. The rest is done by estimate_ml.
To be very clear: Estimagic is not a package to estimate logit models or other models that are implemented in Stata, statsmodels or anywhere else. Its purpose is to estimate parameters with custom likelihood or method of simulated moments functions. We just use an ordered logit model as an example of a very simple likelihood function.
The example we will use to test our model is taken from the Stata Documentation.
[1]:
import numpy as np
import pandas as pd
from patsy import dmatrices
from scipy import stats
from estimagic import estimate_ml
Process the user input¶
We choose an R-style formula as a convenient way of specifying the ordered logit model and use patsy to construct matrices from the dataset.
We will need four inputs:
A DataFrame with start parameters for the optimization.
An array with the dependent variable.
A 2d array with explanatory variables.
Constraints for the optimization that keep the cutoffs increasing.
We construct all of those inputs using the ordered_logit_processing function. You could also do those steps in a simple script.
[2]:
def ordered_logit_processing(formula, data):
"""Process user input for an ordered logit model."""
# extract data arrays
y, x = dmatrices(formula + " - 1", data, return_type="dataframe")
y = y[y.columns[0]]
# extract dimensions
num_choices = len(y.unique())
beta_names = list(x.columns)
num_betas = len(beta_names)
num_cutoffs = num_choices - 1
# set-up index for params_df
names = beta_names + list(range(num_cutoffs))
categories = ["beta"] * num_betas + ["cutoff"] * num_cutoffs
index = pd.MultiIndex.from_tuples(zip(categories, names), names=["type", "name"])
# make params_df
np.random.seed(5471)
start_params = pd.DataFrame(index=index)
start_params["value"] = np.hstack(
[
np.random.uniform(low=-0.5, high=0.5, size=len(x.columns)),
np.arange(num_cutoffs) * 2,
]
)
start_params["group"] = start_params.index.get_level_values("type")
# make constraints
constr = [{"loc": "cutoff", "type": "increasing"}]
# turn pandas objects into numpy arrays
y_arr = y.to_numpy().astype(int)
x_arr = x.to_numpy()
return start_params, y_arr, x_arr, constr
Defining the loglike function¶
[3]:
def ordered_logit_loglike(params, y, x):
"""Likelihood function of an orderd logit model."""
# parse the parameter vector into its quantities
beta = params.loc["beta", "value"].to_numpy()
cutoffs = params.loc["cutoff", "value"].to_numpy()
# calculate deterministic part of utilities
xb = x.dot(beta)
# evaluate likelihood
upper_cutoffs = np.hstack([cutoffs, np.inf])[y]
lower_cutoffs = np.hstack([-np.inf, cutoffs])[y]
upper_cdf = stats.logistic.cdf(upper_cutoffs - xb)
lower_cdf = stats.logistic.cdf(lower_cutoffs - xb)
contributions = np.log(upper_cdf - lower_cdf)
res = {"contributions": contributions, "value": contributions.sum()}
return res
A few remarks are in order:
There are numerically better ways to calculate the likelihood, we chose this implementation for brevity and readability.
The loglike function takes params and other arguments. You are completely flexible in the number and names of the other arguments as long as the first argument is params.
The loglike function returns a dictionary with the entries “contributions” and “value”. The “contributions” are the log likelihood evaluations of each individual in the dataset. The “value” are their sum. The “value” entry could be omitted, the “contributions” entry is mandatory.
Estimating the model¶
[4]:
data = pd.read_pickle("ologit.pickle")
formula = "apply ~ pared + public + gpa"
start_params, y, x, constraints = ordered_logit_processing(formula, data)
res = estimate_ml(
loglike=ordered_logit_loglike,
params=start_params,
optimize_options={"algorithm": "scipy_lbfgsb"},
constraints=constraints,
loglike_kwargs={"y": y, "x": x},
)
[5]:
res["summary_jacobian"].round(3)
[5]:
| value | standard_error | p_value | ci_lower | ci_upper | stars | ||
|---|---|---|---|---|---|---|---|
| type | name | ||||||
| beta | pared | 1.048 | 0.276 | 0.000 | 0.507 | 1.589 | *** |
| public | -0.059 | 0.269 | 0.811 | -0.587 | 0.469 | ||
| gpa | 0.616 | 0.275 | 0.025 | 0.077 | 1.155 | ** | |
| cutoff | 0 | 2.203 | 0.822 | 0.007 | 0.592 | 3.815 | *** |
| 1 | 4.299 | 0.846 | 0.000 | 2.641 | 5.956 | *** |
What’s in the results?¶
The result of estimate_ml is a dictionary with the following entries:
[6]:
res.keys()
[6]:
dict_keys(['summary_jacobian', 'cov_jacobian', 'jacobian', 'hessian', 'optimize_res', 'jacobian_numdiff_info'])
Importantly, we might have several summaries and several cov entries. This is because we always all possible types of standard errors, so you can compare them easily (for example using our estimation table functions).
In the current example we only have jacobian based standard errors because we did not provide a closed form hessian function and numerical hessians are not yet implemented. With a closed form hessian we would also get hessian based and robust standard errors. Even cluster and strata robust standard errors are possible if you provide the relevant information.
If numerical optimizations or derivative calculations were performed, the full output of those steps is also part of the results dictionary.
Compare to STATA’s results¶
[7]:
stata_results = pd.read_csv("stata_ologit_results.csv")
stata_results.round(3)
[7]:
| name | stata_value | stata_standard_error | stata_p_value | stata_ci_lower | stata_ci_upper | |
|---|---|---|---|---|---|---|
| 0 | pared | 1.048 | 0.266 | 0.000 | 0.527 | 1.569 |
| 1 | public | -0.059 | 0.298 | 0.844 | -0.642 | 0.525 |
| 2 | gpa | 0.616 | 0.261 | 0.018 | 0.105 | 1.127 |
| 3 | cut1 | 2.203 | 0.780 | NaN | 0.675 | 3.731 |
| 4 | cut2 | 4.299 | 0.804 | NaN | 2.722 | 5.875 |
This looks pretty good! The parameter estimates line up perfectly. The standard errors are slightly off. This comes from the following differences between our implementation and the stata one: - We currently only support jacobian based standard errors in combination with constraints and use a parametric bootstrap to enforce the constraints. This introduces a sampling error. - We used numerical derivatives to keep the example simple. Stata implements a closed form derivative.