{ "cells": [ { "cell_type": "markdown", "metadata": { "nbpresent": { "id": "375753da-1c6c-4b02-986a-6e3b185a5869" } }, "source": [ "# Implementing and vectorizing a Maximum Likelihood model with scipy\n", "\n", "**By: Matt Ranger**\n", "\n", "This notebook walks through the process of coding, testing, estimating, and vectorizing a Maximum Likelihood (MLE) model \"from scratch\" using scipy's numerical optimization package. Not all MLE models are available in pre-cooked packages, so this skill is necessary for some research topics.\n", "\n", "We will only be touching a simple model here (regular probit), so as not to get bogged down in extraneous complexities. This general method of estimation, testing, and vectorization will however work for a wide range of MLE models.\n", "\n", "**Probit**\n", "\n", "The basic probit model takes a binary dependent variable, $Y$, and assumes that $Pr(Y=1 | X) = \\Phi(X^T \\beta)$ where $X$ is the matrix of independent variables, $\\beta$ is the vector of parameters to estimate, and $\\Phi$ is the CDF of the standard normal distribution. We want to take the likelihood function $L=PR(\\beta|X, Y)$, and maximize it over $\\beta$ to get the most likely $\\beta$ paramaters given the data $X,Y$. We usually use the log of the likelihood function in practice, because it is simpler in both math and computation, and the maximum point is the same. The probit log likelihood is as follows:\n", "\n", "$$ln L(\\beta|X,Y) = \\sum_{i=1}^n[y_i ln \\Phi(x_i'\\beta)+(1-y_i)ln(1-\\Phi(x_i'\\beta)) ]$$\n", "\n", "Which we can translate into a naive python function like this:" ] }, { "cell_type": "code", "execution_count": 1, "metadata": { "collapsed": true, "nbpresent": { "id": "04729447-69e9-4243-8a65-67b442c979bb" } }, "outputs": [], "source": [ "import numpy as np\n", "from scipy.stats import norm\n", "\n", "def LogLikeProbit(betas, y, x):\n", " \"\"\"\n", " Probit Log Likelihood function\n", " Very slow naive Python version\n", " Input:\n", " betas is a np.array of parameters\n", " y is a one dimensional np.array of endogenous data\n", " x is a 2 dimensional np.array of exogenous data\n", " First vertical colmn of X is assumed to be constant term,\n", " corresponding to betas[0]\n", " returns:\n", " negative of log likehood value (scalar)\n", " \"\"\"\n", " result = 0\n", " #Sum operation\n", " for i in range(0, len(y)):\n", " #Get X_i * Beta value\n", " xb = np.dot(x[i], betas)\n", " \n", " #compute both binary probabilities from xb \n", " #Add to total log likelihood\n", " llf = y[i]*np.log(norm.cdf(xb)) + (1-y[i])*np.log(1 - norm.cdf(xb))\n", " result += llf\n", " return -result\n" ] }, { "cell_type": "markdown", "metadata": { "nbpresent": { "id": "aca7ed33-2da5-4fbf-a861-8a886f4020a8" } }, "source": [ "Note that we return the negative value of the result because we want to maximize over this function, and numerical optimizers are traditionally minimizers. Minimizing over the negative values will be the same as maximizing the function.\n", "\n", "**Generating a testing environment for your model**\n", "\n", "When creating a model from scratch, we need to know it is correct on data where we know the real values and distributions. Here is artificial data to test our probit model on:" ] }, { "cell_type": "code", "execution_count": 5, "metadata": { "collapsed": false, "nbpresent": { "id": "a96d544a-0f21-4927-ab68-b69b8bfa7bbe" } }, "outputs": [], "source": [ "######################\n", "#ARTIFICIAL DATA\n", "######################\n", "\n", "#sample size\n", "n = 1000\n", "\n", "#random generators\n", "z1 = np.random.randn(n)\n", "z2 = np.random.randn(n)\n", "\n", "#create artificial exogenous variables \n", "x1 = 0.8*z1 + 0.2*z2\n", "x2 = 0.2*z1 + 0.8*z2\n", "#create error term\n", "u = 2*np.random.randn(n)\n", "\n", "#create endogenous variable from x1, x2 and u\n", "ystar = 0.5 + 0.75*x1 - 0.75*x2 + u\n", "\n", "#create latent binary variable from ystar\n", "def create_dummy(data, cutoff):\n", " result = np.zeros(len(data))\n", " for i in range(0, len(data)):\n", " if data[i] >= cutoff:\n", " result[i] = 1\n", " else:\n", " result[i] = 0\n", " return result\n", "\n", "#get latent LHS variable\n", "y = create_dummy(ystar, 0.5)\n", "\n", "#prepend vector of ones to RHS variables matrix\n", "#for constant term\n", "const = np.ones(n)\n", "x = np.column_stack((const, np.column_stack((x1, x2))))\n", "\n" ] }, { "cell_type": "markdown", "metadata": { "nbpresent": { "id": "1e4a01db-cd92-48f8-bdaa-21c39456cfcb" } }, "source": [ "**Testing the model**\n", "\n", "We can now maximize the probit log likelihood to get the most likely vector of parameters given the artificial data using scipy's powerful numerical optimization library:" ] }, { "cell_type": "code", "execution_count": 6, "metadata": { "collapsed": false, "nbpresent": { "id": "088bfa34-1707-41b7-9167-e91f7309e068" } }, "outputs": [ { "data": { "text/plain": [ "array([ 0.05125986, 0.34315356, -0.4281118 ])" ] }, "execution_count": 6, "metadata": {}, "output_type": "execute_result" } ], "source": [ "from scipy.optimize import minimize\n", "\n", "#create beta hat vector to maximize on\n", "#will store the values of maximum likelihood beta parameters\n", "#Arbitrarily initialized to all zeros\n", "bhat = np.zeros(len(x[0]))\n", "\n", "#unvectorized MLE estimation\n", "probit_est = minimize(LogLikeProbit, bhat, args=(y,x), method='nelder-mead')\n", "\n", "#print vector of maximized betahats\n", "probit_est['x']" ] }, { "cell_type": "markdown", "metadata": { "nbpresent": { "id": "b4d30d94-3198-4ad7-8cef-dece2ce42156" } }, "source": [ "Note here that probit regression results can't be interpreted directly like OLS regression results. Parameter estimates are divided by $\\sigma$ (which we set to 2 when generating the error term). You can multiply the last two values in the results displayed ($\\hat{\\beta_1}$ and $\\hat{\\beta_2}$ ) by $\\sigma$ and compare that to our generated values of the constant, $x_1$ and $x_2$ (0.75 and -0.75 respectively).\n", "\n", "The $\\hat{\\beta}_0$ constant depends on the cutoff point that determines the binary variable value and $\\sigma$; we're not interested in its value for today.\n", "\n", "The estimates seem to be slightly off with our small samle size. $\\hat{\\beta_1}$ and $\\hat{\\beta_2}$ should be around 0.375 and -0.375.\n", "\n", "**Summary Statistics**\n", "\n", "This is a good time to get some summary statistics on our empirical estimate. In maximum likelihood estimation, the standard error is usually computed from the [CramÃ¨r-Rao lower bound](https://www.youtube.com/watch?v=i0JiSddCXMM). We can then use the standard error to get t- and p-values. The C-R lower bound is computed by the square root of the diagonal elements of the inverse of the Hessian at our estimated parameters. [Statsmodels'](https://github.com/statsmodels/statsmodels) numerical differentiation toolbox makes this easy:" ] }, { "cell_type": "code", "execution_count": 8, "metadata": { "collapsed": false, "nbpresent": { "id": "ca3d4373-7c99-4feb-800f-67211795bb5f" } }, "outputs": [ { "name": "stdout", "output_type": "stream", "text": [ "Beta Hats: [ 0.05125986 0.34315356 -0.4281118 ]\n", "SE: [ 0.04049858 0.05533922 0.05726539]\n", "t stat: [ 1.26571979 6.20091066 -7.47592581]\n", "P value: [ 2.05908521e-01 8.20432023e-10 1.67354497e-13]\n" ] } ], "source": [ "import statsmodels.tools.numdiff as smt\n", "import scipy as sc\n", "\n", "#Get inverse hessian for Cramer Rao lower bound\n", "b_estimates = probit_est['x']\n", "Hessian = smt.approx_hess3(b_estimates, LogLikeProbit, args=(y,x))\n", "invHessian = np.linalg.inv(Hessian)\n", "\n", "#Standard Errors from C-R LB\n", "#from diagonal elements of invHessian\n", "SE = np.zeros(len(invHessian))\n", "for i in range(0, len(invHessian)):\n", " SE[i] = np.sqrt(invHessian[i,i])\n", " \n", "#t and p values\n", "t_statistics = (b_estimates/SE)\n", "pval = (sc.stats.t.sf(np.abs(t_statistics), 999)*2)\n", "\n", "print(\"Beta Hats: \", b_estimates)\n", "print(\"SE: \", SE)\n", "print(\"t stat: \", t_statistics)\n", "print(\"P value: \", pval)" ] }, { "cell_type": "markdown", "metadata": { "nbpresent": { "id": "c4cc4237-78e8-4c49-ace7-370a74522601" } }, "source": [ "We can see our $\\hat{\\beta_1}$ and $\\hat{\\beta_2}$ estimates are within one standard error of the expected values.\n", "\n", "**Vectorizing**\n", "\n", "Now that we know this probit model works, it would be a good time to vectorize the naive python into a performant version.\n", "\n", "The naive model is very inefficient. More complex models will require nontrivial computational power to estimate, and proper vectorization can easily reduce a computation from taking hours to taking minutes.\n", "\n", "The main idea is to replace as much computation from \"pure python\" to optimized numpy/scipy functions. To do this we need to look closely at what our code is doing. Here is the main loop in the naive probit model:" ] }, { "cell_type": "code", "execution_count": null, "metadata": { "collapsed": true }, "outputs": [], "source": [ "for i in range(0, len(y)):\n", " xb = np.dot(X[i], betas)\n", " llf = y[i]*np.log(norm.cdf(xb)) + (1-y[i])*np.log(1 - norm.cdf(xb))\n", " result += llf" ] }, { "cell_type": "markdown", "metadata": {}, "source": [ "The outer loop is actually just a $\\sum_{i=0}^n$ operation, so we can replace the outer for loop by numpy's optimized sum function. To do this, we have to make a couple of changes to the code. \n", "\n", "- First, we replace the for loop by wrapping np.sum() around the code inside the loop. \n", "\n", "- Move the $X_i'\\beta$ computation outside the loop to run it only once.\n", "\n", "- Relace the conditional $y_i$ and $(1-y_i)$ in the loop by pythonic conditionals that are allowed inside the sum() function. The (y==1) and (y==0) conditional expressions can do this inside the sum function." ] }, { "cell_type": "code", "execution_count": null, "metadata": { "collapsed": true }, "outputs": [], "source": [ "xb = np.dot(x, betas)\n", "result = np.sum( \n", " (y==1)*np.log(1 - norm.cdf(xb)) + \n", " (y==0)*np.log(norm.cdf(xb))\n", " )" ] }, { "cell_type": "markdown", "metadata": {}, "source": [ "For further optimization, we can use the natural log's mathematical propreties and move the log calculation to the outer part of the loop, so it's calculated once for the entire sum, instead of at each observation:" ] }, { "cell_type": "code", "execution_count": null, "metadata": { "collapsed": true }, "outputs": [], "source": [ "xb = np.dot(x, betas)\n", "result = np.sum(np.log( \n", " (y==1)*(1 - norm.cdf(xb)) + \n", " (y==0)*(norm.cdf(xb))\n", " ))" ] }, { "cell_type": "markdown", "metadata": {}, "source": [ "So the new vectorized log likelihood function looks like this:" ] }, { "cell_type": "code", "execution_count": 9, "metadata": { "collapsed": true }, "outputs": [], "source": [ "def VectorizedProbitLL(betas, y, x):\n", " xb = np.dot(x, betas)\n", " result = np.sum(np.log( \n", " (y==0)*(1 - norm.cdf(xb)) + \n", " (y==1)*(norm.cdf(xb))\n", " ))\n", " return -result" ] }, { "cell_type": "markdown", "metadata": {}, "source": [ "Again, we return the negative value of the sum because optimization libraries generally minimize, and we're trying to maximize. We'll see a drastic difference in runtime right away:" ] }, { "cell_type": "code", "execution_count": 10, "metadata": { "collapsed": false }, "outputs": [ { "name": "stdout", "output_type": "stream", "text": [ "10 loops, best of 3: 98.4 ms per loop\n" ] } ], "source": [ "import timeit\n", "\n", "%timeit minimize(VectorizedProbitLL, bhat, args=(y,x), method='nelder-mead')" ] }, { "cell_type": "code", "execution_count": 11, "metadata": { "collapsed": false }, "outputs": [ { "name": "stdout", "output_type": "stream", "text": [ "1 loop, best of 3: 50.2 s per loop\n" ] } ], "source": [ "%timeit minimize(LogLikeProbit, bhat, args=(y,x), method='nelder-mead')" ] }, { "cell_type": "markdown", "metadata": {}, "source": [ "So the vectorized version is 400-500x faster (!!!) with the Nelder-Mead algorithm. This was done on an intel i5-4210u, a mid range laptop processor, for reference.\n", "\n", "Note that while 50 seconds is not a problem on our trivial sample size (n=1000 in the artificial data above), unvectorized code can become a serious problem on large datasets.\n", "\n", "There are many algorithms in scipy.optimize.minimize's module; [this is a good reference to help choose the best one](http://www.scipy-lectures.org/advanced/mathematical_optimization/).If in doubt, the [Nelder-Mead](http://nbviewer.jupyter.org/github/QuantEcon/QuantEcon.notebooks/blob/master/chase_nelder_mead.ipynb) algorithm is included in scipy's minimizer and is usually a good choice. Nelder-Mead doesn't require estimating derivatives of the function, and as such fails less often, at a cost of being slow to converge on larger datasets.\n", "\n", "BFGS is a popular algorithm due to its speed, but necessitates computing second derivatives at each iteration:" ] }, { "cell_type": "code", "execution_count": 12, "metadata": { "collapsed": false }, "outputs": [ { "name": "stdout", "output_type": "stream", "text": [ "10 loops, best of 3: 41.4 ms per loop\n" ] } ], "source": [ "%timeit minimize(VectorizedProbitLL, bhat, args=(y,x), method='bfgs')" ] }, { "cell_type": "markdown", "metadata": {}, "source": [ "BFGS is about twice as fast as Nelder-Mead in this case.\n", "\n", "Note that the BFGS algorithm sometimes throws a warning about a division by 0 here. This most likely comes from the fact that all our data is binary; methods that have to estimate derivatives have a harder time with sparse or binary data (among others)." ] } ], "metadata": { "anaconda-cloud": {}, "kernelspec": { "display_name": "Python [Root]", "language": "python", "name": "Python [Root]" }, "language_info": { "codemirror_mode": { "name": "ipython", "version": 3 }, "file_extension": ".py", "mimetype": "text/x-python", "name": "python", "nbconvert_exporter": "python", "pygments_lexer": "ipython3", "version": "3.5.2" } }, "nbformat": 4, "nbformat_minor": 0 }