Skip to article frontmatterSkip to article content
Site not loading correctly?

This may be due to an incorrect BASE_URL configuration. See the MyST Documentation for reference.

The NFXP estimator of Class 10 reproduces Rust (1987) to the last printed digit. Today we take a closer look under the hood: how main components interact, where the time goes, what the inner loop contributes, what Ξ²\beta and the mileage grid do to the estimates, and whether it recovers parameters we generated ourselves.

Python can describe its own code: inspect lists what a module defines, with signatures and docstrings.

Source
zurcher.py
β”‚   Rust (1987) bus engine replacement model: model class and three solvers.
└── class zurcher
    β”‚   Harold Zurcher bus engine replacement model class, VFI version
    β”œβ”ˆβ”ˆ RC, beta, c, grid, trpr
    β”œβ•Œβ•Œ n
    β”‚       Attribute getter for n
    β”œβ•Œβ•Œ p
    β”‚       Transition probabilities theta_2, all but the last (residual) one.
    β•žβ•β• __init__(n=175, RC=11.7257, c=2.45569, p=[0.0937, 0.4475, 0.4459, 0.0127], beta=0.9999)
    β”‚       Init for the Zurcher model object
    β•žβ•β• __transition_probs()
    β”‚       Computing the transision probability matrix
    β•žβ•β• bellman(ev0, deriv=False)
    β”‚       Bellman operator for the model
    β•žβ•β• _start(ev0)
    β”‚       Starting point for the solvers: zeros unless one is given
    β•žβ•β• solve_vfi(maxiter=100, tol=1e-06, callback=None, ev0=None)
    β”‚       Solves the Rust model using value function iterations
    β•žβ•β• solve_nk(maxiter=100, tol=1e-06, callback=None, ev0=None)
    β”‚       Solves the model using the Newton-Kantorovich iterations
    β•žβ•β• solve_poly(maxiter=100, tol=1e-10, sa_min=5, sa_max=25, switch_tol=0.025, callback=None, ev0=None)
    β”‚       Solves the model using the poly-algorithm
    β•žβ•β• solve_show(solver='vfi', verbosity=0, plot=True, **kvargs)
    β”‚       Illustrate solution for given solver = {vfi,nk,poly} and

nfxp.py
β”‚   Nested fixed point (NFXP) estimation of the Rust (1987) bus engine model.
β”œβ”€β”€ DATAFILE (PosixPath)
β”œβ”€β”€ SOLVER (dict)
β”œβ”€β”€ read_busdata(path='busdata1234.csv', groups=(1, 2, 3, 4), n=175, max_miles=450000)
β”‚       Read Zurcher's maintenance records and discretize mileage on an n-point grid.
β”œβ”€β”€ describe_data(data)
β”‚       Print what the estimator will see
└── class Result
    β”‚   Output of estim_zurcher.estimate()
    β”œβ”ˆβ”ˆ names, theta, se, vcov, loglik, N, method, converged, time, n_solver_calls, n_inner_iter, beta, n, stages
    β•žβ•β• table()
    β”‚       Estimates and standard errors, one line per parameter
└── class estim_zurcher(zurcher)
    β”‚   Nested fixed point maximum likelihood estimator for the Zurcher model.
    β”œβ”ˆβ”ˆ J, N, RC, c, d, data, dx, ev, max_miles, n_inner_iter, n_solver_calls, names, solver, warm_start, x, x_from, x_to
    β”œβ•Œβ•Œ theta
    β”‚       Parameters as the vector (RC, c, p_0, ..., p_{J-1}) the likelihood is written in
    β•žβ•β• __init__(data, n=None, beta=0.9999, solver=None, warm_start=True, J=None, **params)
    β”‚       Attach the data, then build the model.
    β•žβ•β• reset()
    β”‚       Forget the warm start and zero the inner loop counters
    β•žβ•β• transition_freq()
    β”‚       Transition probabilities from the increment frequencies: the closed form MLE
    β•žβ•β• feasible(theta)
    β”‚       The transition probabilities in theta must form a distribution: the likelihood
    β•žβ•β• snapshot()
    β”‚       Parameters and warm start, so a diagnostic can leave the estimator as it found it
    β•žβ•β• restore(snap)
    β”‚       Undo what happened since snapshot()
    β•žβ•β• thousand_miles(x)
    β”‚       Grid value x in thousands of miles, for tables and plots
    β•žβ•β• solve_fixed_point(ev0=None, callback=None)
    β”‚       Solve the model at its current parameters with the settings in self.solver:
    β•žβ•β• solve(theta=None)
    β”‚       Move to theta if given and solve the model, warm started from the previous
    β•žβ•β• choice_values(ev)
    β”‚       Choice specific values v(x, keep), v(x, replace) and their logsum at a solution.
    β•žβ•β• loglik_choice_obs(theta)
    β”‚       Log choice probability log P(d | x) of every bus-month at theta
    β•žβ•β• loglik_transition_obs()
    β”‚       Log probability of the observed mileage increment of every bus-month, read off
    β•žβ•β• loglik_obs(theta)
    β”‚       Log-likelihood contribution of every bus-month, choice part plus transition part
    β•žβ•β• loglik(theta)
    β”‚       Log-likelihood, summed over bus-months
    β•žβ•β• loglik_choice(theta)
    β”‚       The choice part of the log-likelihood alone.  With p held fixed this is the
    β•žβ•β• score(theta)
    β”‚       N x K matrix of scores, K = theta.size, with the derivative of the fixed
    β•žβ•β• dbellman_dtheta(pk, L, K)
    β”‚       Derivative of the Bellman operator w.r.t. the first K parameters, EV held fixed, n x K
    β•žβ•β• dlog_transition()
    β”‚       Derivative of log Pi[x_from, x_to] w.r.t. p_0, ..., p_{J-1}, N x J: the derivative
    β•žβ•β• bhhh(theta0, tol=1e-10, maxiter=200, verbose=False)
    β”‚       Berndt-Hall-Hall-Hausman: Newton steps with the outer product of the scores
    β•žβ•β• line_search(theta, step, ll)
    β”‚       Backtracking: halve the step until the log-likelihood does not fall.
    β•žβ•β• objective(theta)
    β•žβ•β• objective_grad(theta)
    β•žβ•β• objective_hess(theta)
    β•žβ•β• maximize(theta0, method='bhhh', verbose=False)
    β”‚       Dispatch to the chosen optimizer; returns (theta, converged, raw result)
    β•žβ•β• estimate(theta0=(0.0, 0.0), method='bhhh', full=True, verbose=False)
    β”‚       Rust's three stages: transition probabilities from frequencies, then (RC, c)
    β•žβ•β• numerical_hessian(theta, h=1e-05)
    β”‚       Hessian of the log-likelihood by central differences of the analytical gradient.
    β•žβ•β• standard_errors(theta)
    β”‚       Three estimates of the covariance matrix: outer product of the scores (BHHH),

Representation of the state space and transition matricesΒΆ

@n.setter
def n(self, value):
    '''Attribute n setter'''
    if hasattr(self, '_zurcher__p'):   # p may not be set yet when n is assigned first
        assert len(self.__p) < value, 'More transition probability parameters than grid points'
    self.__n = value
    self.grid = np.arange(self.__n)
    if hasattr(self, '_zurcher__p'):
        self.trpr = self.__transition_probs()
def __transition_probs(self):
    '''Computing the transision probability matrix'''
    trpr = np.zeros((self.__n,self.__n))  # init
    probs = self.p + [1-sum(self.p)]  # ensure sum up to 1
    for i,p in enumerate(probs):
        trpr += np.diag([p]*(self.__n-i),k=i)
    trpr[:,-1] = 1.-np.sum(trpr[:,:-1],axis=1)  # last column absorbs the mass beyond the grid
    return trpr

grid is np.arange(n), the bins 0,…,nβˆ’10,\dots,n-1, and the maintenance cost 0.001β‹…cβ‹…x0.001 \cdot c \cdot x is evaluated at these indices, so cc is measured per bin. Both n and p are properties whose setters rebuild trpr, so the model object can never hold a grid and a matrix that disagree. The hasattr checks are there because __init__ assigns p before n, and the double underscore makes Python store self.__p as self._zurcher__p.

__transition_probs() builds Ξ (d=0)\Pi(d=0) of the motion rules in Class 9 as a sum of shifted diagonals: probability ΞΈ2j\theta_{2j} of moving up jj bins sits on the jj-th superdiagonal, with the residual 1βˆ’βˆ‘jΞΈ2j1-\sum_j \theta_{2j} as the last one. The rows near the top of the grid lose the mass that would fall beyond it, and the last line puts it back into the last column, which makes the top bin absorbing.

Ξ (d=1)\Pi(d=1) is not built: every row of it equals the first row of Ξ (d=0)\Pi(d=0).

Structure of Bellman operatorΒΆ

def bellman(self,ev0,deriv=False):
    '''Bellman operator for the model
       Depending on deriv argument, returns 2 or 3 outputs (FrΓ©chet derivative)
    '''
    x = self.grid  # points in the next period state
    mcost = -0.001*x*self.c                         # 1-dim array of maintenance costs
    vx0 = mcost + self.beta * ev0                   # 1-dim array v(x,0), keep
    vx1 = mcost[0] - self.RC + self.beta * ev0[0]   # 1-dim array v(x,1), replace
    M = np.maximum(vx0,vx1)                         # de-max values to avoid exp(large number)
    logsum = M + np.log(np.exp(vx0-M) + np.exp(vx1-M))
    ev1 = self.trpr @ logsum                        # 1-dim array after matrix multiplication
    pk = 1/( np.exp(vx1-vx0)+1 )                    # choice prob to keep
    if not deriv:
        return ev1, pk

This is the matrix form of the Bellman operator, Ξ“(EV)=Ξ β‹…L(U(keep)+Ξ²EV,β€…β€ŠU(replace)+Ξ²EV[0])\Gamma(EV) = \Pi \cdot L\big(U(\text{keep}) + \beta EV,\; U(\text{replace}) + \beta EV[0]\big), in the expected value function space. The vector ev0 has one element per bin, EV(x,keep)EV(x,\text{keep}), and EV(x,replace)EV(x,\text{replace}) is not stored at all: replacing resets the mileage, so it equals EV(0,keep)EV(0,\text{keep}) = ev0[0] for every xx, and vx1 is a scalar. The logsum is computed with the de-maxing trick of Class 6, and pk is the logit probability of keeping, written so that it stays in [0,1][0,1] even when the exponent overflows.

  • Why is it enough to store one number per bin when the Bellman equation is written for EV(x,d)EV(x,d) with two choices?

  • trpr is dense, nΓ—nn \times n, although at most J+1J+1 elements per row are non-zero. Where would a sparse matrix pay off, and where would it not?

Computation of the FrΓ©chet derivativeΒΆ

The derivative is the last three lines of bellman(), computed only when deriv=True:

# FrΓ©chet derivative
dev1 = self.beta * self.trpr * pk[np.newaxis,:] # element-wise, pk in rows
dev1[:,0] += self.beta * self.trpr @ (1-pk)     # w.r.t. EV[0] special case
return ev1, pk, dev1

They are the two terms of the FrΓ©chet derivative derived in Class 9,

βˆ‚Ξ“βˆ‚EV=β Π diag(P)+Ξ²(Ξ PΛ‰,0,…,0)\frac{\partial \Gamma}{\partial EV} = \beta\, \Pi \,\text{diag}(P) + \beta \big( \Pi \bar P, 0, \dots, 0 \big)

The first term scales column jj of Ξ \Pi by the probability of keeping at bin jj, and self.trpr * pk[np.newaxis,:] does exactly that by broadcasting: pk becomes a row vector and multiplies every row of trpr element by element. Nothing nΓ—nn \times n is multiplied, so the cost is O(n2)O(n^2) rather than the O(n3)O(n^3) of trpr @ np.diag(pk). The second term is non-zero in the first column only, because EV[0]EV[0] enters the replacement value in every bin, and it is added in place.

The matrix is used twice. The Newton–Kantorovich step solves a linear system with Iβˆ’Ξ“β€²I - \Gamma':

ev1,pk,dev = self.bellman(ev0,deriv=True) # compute with FrΓ©chet derivative
ev1 = ev0 - np.linalg.solve(np.eye(self.n)-dev,ev0 - ev1)  # NK step

and the analytical gradient of the likelihood solves another system with the same matrix, see score() below.

Main structure of the NFXP estimator loopsΒΆ

estimate() is the whole estimator in one method: Rust’s three stages, each a call to the outer loop.

def estimate(self, theta0=(0.0, 0.0), method='bhhh', full=True, verbose=False):
    '''Rust's three stages: transition probabilities from frequencies, then (RC, c)
    by partial likelihood with p fixed, then all parameters jointly.  The object
    ends up at the estimates; theta0 is the starting point for (RC, c).'''
    t0 = time.perf_counter()
    self.reset()
    stages = []
    # stage 1: closed form
    self.p = self.transition_freq()
    # stage 2: partial likelihood
    theta, ok, res = self.maximize(np.asarray(theta0, dtype=float)[:2], method, verbose)
    res['loglik_choice'] = self.loglik_choice(theta)   # the value of the partial likelihood
    stages.append(res)
    # stage 3: full likelihood, from the stage 1 and 2 estimates
    if full:
        theta, ok, res = self.maximize(np.append(theta, self.p), method, verbose)
        stages.append(res)
    self.theta = theta
    S = self.score(theta)
    vcov = np.linalg.inv(S.T @ S)                      # the inverse is wanted for itself here
    return Result(names=self.names[:theta.size], theta=theta, se=np.sqrt(np.diag(vcov)),
                  vcov=vcov, loglik=self.loglik(theta), N=self.N, method=method,
                  converged=ok, time=time.perf_counter() - t0,
                  n_solver_calls=self.n_solver_calls, n_inner_iter=self.n_inner_iter,
                  beta=self.beta, n=self.n, stages=stages)

Stage 1 sets the transition probabilities to their frequencies, a closed form. Stage 2 maximizes over (RC,c)(RC, c) with pp held there, stage 3 over all parameters from the stage 2 estimates, and the standard errors come from the outer product of the scores at the end. maximize() only dispatches: to our own bhhh() or to scipy.optimize.minimize with the objective, gradient and Hessian methods of the class. Underneath, the calls nest as in the nested loop of Class 10:

estimate()                                 three stages, standard errors
└── maximize(theta0, method)               outer loop: bhhh() or scipy.optimize.minimize
    β”œβ”€β”€ loglik(theta)                      objective and line search
    β”‚   └── loglik_obs(theta)
    β”‚       └── loglik_choice_obs(theta)
    β”‚           └── solve(theta)           moves the object to theta
    β”‚               └── solve_fixed_point  inner loop: NK, or poly-algorithm + NK
    └── score(theta)                       gradient; its outer product is the BHHH Hessian
        └── solve(theta)                   same theta: warm start, converges at once

The BHHH iterations of Class 10 add a line search on top, so one outer iteration costs one score() and one or more loglik() calls, each with a solve of the model.

  • One call of loglik() solves the model once. How many does one BHHH iteration need, counting the line search?

  • Why does estimate() run a partial likelihood stage first, instead of maximizing the full likelihood from the start?

Why functions needed?ΒΆ

transition_freq(self)
feasible(self, theta)
snapshot(self)
restore(self, snap)
thousand_miles(self, x)
def transition_freq(self):
    '''Transition probabilities from the increment frequencies: the closed form MLE
    away from the top of the grid, and the starting point of the full likelihood.
    An unobserved category gets half an observation so the start is inside the simplex.'''
    counts = np.bincount(self.dx, minlength=self.J + 1).astype(float)
    counts = np.maximum(counts, 0.5)
    return counts[:self.J] / counts.sum()

def feasible(self, theta):
    '''The transition probabilities in theta must form a distribution: the likelihood
    is -inf outside, and the outer loop must not step there'''
    p = theta[2:]
    return bool(np.all(p > 0) and p.sum() < 1)

def snapshot(self):
    '''Parameters and warm start, so a diagnostic can leave the estimator as it found it'''
    return self.theta, None if self.ev is None else self.ev.copy()

def restore(self, snap):
    '''Undo what happened since snapshot()'''
    self.theta, self.ev = snap

def thousand_miles(self, x):
    '''Grid value x in thousands of miles, for tables and plots'''
    return x * self.max_miles / self.n / 1000

Warm start mechanismΒΆ

Why need functions solve_fixed_point(self, ev0=None, callback=None) and solve(self, theta=None)?

# Inner loop settings.  The fixed point is always finished with Newton-Kantorovich
# steps, whose stopping rule is trustworthy: NK converges quadratically, so a step
# smaller than tol means the true error is far smaller still.  A contraction step
# of size tol, in contrast, can still be tol/(1-beta) away from the fixed point.
# tol is relative to the scale of EV, because the NK linear system has condition
# number of order 1/(1-beta) and its precision floor scales with |EV|.
SOLVER = dict(
    tol=1e-10,          # relative tolerance of the final NK steps
    nk_maxiter=50,      # NK steps before declaring a divergence
    cold=dict(tol=1e-6, maxiter=2000, sa_min=5, sa_max=50, switch_tol=1e-3),  # poly-algorithm from zero
)
def solve_fixed_point(self, ev0=None, callback=None):
    '''Solve the model at its current parameters with the settings in self.solver:
    NK steps from ev0 if given, otherwise the poly-algorithm from zero followed by
    NK steps; if NK diverges from a poor warm start, start cold.  The NK tolerance
    is relative to the scale of EV: set from the starting point and, if the solution
    lives on a smaller scale, the last steps are repeated at the tolerance that
    scale demands.  Returns EV and P(keep|x).'''
    tol, nk_maxiter, cold = self.solver['tol'], self.solver['nk_maxiter'], self.solver['cold']
    if ev0 is None:
        ev0, _ = self.solve_poly(callback=callback, **cold)
    for attempt in range(2):
        try:
            with np.errstate(all='ignore'):       # a diverging NK step overflows before it raises
                tol0 = tol * max(1.0, np.abs(ev0).max())
                ev, pk = self.solve_nk(ev0=ev0, tol=tol0, maxiter=nk_maxiter, callback=callback)
                tol1 = tol * max(1.0, np.abs(ev).max())
                if tol1 < tol0:
                    ev, pk = self.solve_nk(ev0=ev, tol=tol1, maxiter=nk_maxiter, callback=callback)
            return ev, pk
        except RuntimeError:
            if attempt == 1:
                raise
            ev0, _ = self.solve_poly(callback=callback, **cold)   # cold restart

def solve(self, theta=None):
    '''Move to theta if given and solve the model, warm started from the previous
    solution when allowed.  Returns EV, P(keep|x) and the FrΓ©chet derivative.'''
    if theta is not None:
        self.theta = theta
    ev0 = self.ev if (self.warm_start and self.ev is not None and self.ev.size == self.n) else None

    def count(**kw):
        self.n_inner_iter += 1

    ev, pk = self.solve_fixed_point(ev0=ev0, callback=count)
    self.n_solver_calls += 1
    self.ev = ev
    return self.bellman(ev, deriv=True)   # ev, pk, dev at the fixed point

The two methods split the work between how to solve and from where. solve_fixed_point() is the inner loop proper. Given a starting point it runs Newton–Kantorovich iterations straight away; without one it first runs the poly-algorithm of Class 9 from zero with the cold settings, loose enough to stop as soon as NK can take over. If NK diverges from a poor starting point, solve_nk() raises RuntimeError and the method restarts cold. The tolerance is relative to the size of EVEV, which is of order 103 here, as the comment above SOLVER explains.

solve() decides the starting point. With warm_start=True it hands over self.ev, the solution at the previous parameter vector, stores the new solution for the next call and counts the Bellman operator evaluations through the callback. It returns bellman(ev, deriv=True), so every caller also gets the choice probabilities and the FrΓ©chet derivative at the fixed point. Between two evaluations of the outer loop the parameters move very little, EVΞΈEV_\theta moves very little with them, and NK from the previous solution converges in about three steps, where the poly-algorithm from zero needs about fifteen (at the estimates, Ξ²=0.9999\beta = 0.9999).

  • score() calls solve() at the same ΞΈ\theta that loglik() has just solved. What does that cost with the warm start, and what without it?

  • After a failed line search step far from the optimum, self.ev holds the solution at a rejected ΞΈ\theta. Is that a problem for the next evaluation?

Collection of log-likelihood evaluatorsΒΆ

def choice_values(self, ev):
    '''Choice specific values v(x, keep), v(x, replace) and their logsum at a solution.
    The Bellman operator of the parent computes them but does not return them.'''
    cost = 0.001 * self.c * self.grid
    v0 = -cost + self.beta * ev
    v1 = -cost[0] - self.RC + self.beta * ev[0]
    return v0, v1, np.logaddexp(v0, v1)

# ---------------------------------------------------------------- likelihood
def loglik_choice_obs(self, theta):
    '''Log choice probability log P(d | x) of every bus-month at theta'''
    theta = np.asarray(theta, dtype=float)
    if not self.feasible(theta):
        return np.full(self.N, -np.inf)
    ev, _, _ = self.solve(theta)
    v0, v1, L = self.choice_values(ev)
    logP0, logP1 = v0 - L, v1 - L           # log probabilities directly: never log of a probability
    return np.where(self.d == 0, logP0[self.x], logP1[self.x])

def loglik_transition_obs(self):
    '''Log probability of the observed mileage increment of every bus-month, read off
    the model's own transition matrix: p_dx away from the top of the grid, and the
    cumulative probability where the top bin absorbs'''
    with np.errstate(divide='ignore'):     # a zero probability is a legitimate -inf
        return np.log(self.trpr[self.x_from, self.x_to])

def loglik_obs(self, theta):
    '''Log-likelihood contribution of every bus-month, choice part plus transition part'''
    choice = self.loglik_choice_obs(theta)     # moves to theta first, so trpr is at theta below
    return choice + self.loglik_transition_obs()

def loglik(self, theta):
    '''Log-likelihood, summed over bus-months'''
    return float(np.sum(self.loglik_obs(theta)))

def loglik_choice(self, theta):
    '''The choice part of the log-likelihood alone.  With p held fixed this is the
    partial likelihood of stage 2 up to a constant, and it is the number the
    reference implementations report for that stage.'''
    return float(np.sum(self.loglik_choice_obs(theta)))

The likelihood of Class 10 is a sum over bus-months of a choice part and a transition part, and each method returns one piece of it:

The transition part reads the model’s own matrix at [x_from, x_to], which gives pΞ”xp_{\Delta x} in the middle of the grid and the cumulative probability in the top bin, where the matrix absorbs everything beyond it. The objective* methods further down wrap the same numbers for scipy, which minimizes: minus the mean log-likelihood, its gradient, and the outer product of the scores.

How are gradient of the likelihood and individual scores computed?ΒΆ

def score(self, theta):
    '''N x K matrix of scores, K = theta.size, with the derivative of the fixed
    point from the implicit function theorem'''
    theta = np.asarray(theta, dtype=float)
    K = theta.size
    if not self.feasible(theta):             # the likelihood is -inf there: no direction to report
        return np.zeros((self.N, K))
    ev, pk, dev = self.solve(theta)
    _, _, L = self.choice_values(ev)
    # implicit function theorem on the fixed point EV = Gamma(EV)
    dEV = np.linalg.solve(np.eye(self.n) - dev, self.dbellman_dtheta(pk, L, K))
    # choice part: (1 - d - P(keep|x)) times the derivative of v(x, keep) - v(x, replace)
    dv = self.beta * (dEV[self.x, :] - dEV[0, :])
    dv[:, 0] += 1.0
    dv[:, 1] += -0.001 * self.x
    S = (1 - self.d - pk[self.x])[:, None] * dv
    if K > 2:
        S[:, 2:] += self.dlog_transition()
    return S
def dbellman_dtheta(self, pk, L, K):
    '''Derivative of the Bellman operator w.r.t. the first K parameters, EV held fixed, n x K'''
    n, grid, J = self.n, self.grid, self.J
    dG = np.zeros((n, K))
    dG[:, 0] = -(self.trpr @ (1 - pk))                     # RC: replace value moves by -1
    dG[:, 1] = -0.001 * (self.trpr @ (pk * grid))          # c: keep value moves by -0.001 x
    for j in range(K - 2):
        # dPi/dp_j: +1 in column min(i+j, n-1), -1 in column min(i+J, n-1) of row i
        dG[:, 2 + j] = L[np.minimum(grid + j, n - 1)] - L[np.minimum(grid + J, n - 1)]
    return dG

def dlog_transition(self):
    '''Derivative of log Pi[x_from, x_to] w.r.t. p_0, ..., p_{J-1}, N x J: the derivative
    D_j of the matrix over its entry.  Away from the top bin this is +1/p_dx when
    dx == j and -1/p_J when dx == J.'''
    n, J = self.n, self.J
    pr = self.trpr[self.x_from, self.x_to]
    col_J = np.minimum(self.x_from + J, n - 1)
    D = np.empty((self.N, J))
    for j in range(J):
        col_j = np.minimum(self.x_from + j, n - 1)
        D[:, j] = ((self.x_to == col_j).astype(float) - (self.x_to == col_J)) / pr
    return D

score() returns an NΓ—KN \times K matrix, one row of βˆ‚β„“i/βˆ‚ΞΈ\partial \ell_i / \partial \theta per bus-month, which is what BHHH needs. It follows the analytical gradient of Class 10 step by step.

  1. Derivative of the fixed point. By the implicit function theorem, βˆ‚EV/βˆ‚ΞΈ=(Iβˆ’Ξ“β€²)βˆ’1β€‰βˆ‚Ξ“/βˆ‚ΞΈ\partial EV/\partial\theta = (I - \Gamma')^{-1}\, \partial\Gamma/\partial\theta. dev is the FrΓ©chet derivative that solve() returned, and the linear solve uses the same matrix Iβˆ’Ξ“β€²I-\Gamma' as the NK step.

  2. Derivative of the Bellman operator with EVEV held fixed, in dbellman_dtheta(). By the derivative of the logsum it is Ξ \Pi applied to the probability-weighted derivatives of the choice values: βˆ’Ξ (1βˆ’P)-\Pi(1-P) for RCRC, which enters the replacement value only, and βˆ’0.001 Π(Pβ‹…x)-0.001\,\Pi(P \cdot x) for cc. For pjp_j the matrix Ξ \Pi itself changes, by +1 in the column jj bins ahead and -1 in the column of the residual category JJ, applied to the logsum L.

  3. Choice part. log⁑P(d∣x)\log P(d|x) is a binary logit in Ξ”v(x)=v(x,keep)βˆ’v(x,replace)\Delta v(x) = v(x,\text{keep}) - v(x,\text{replace}), so its derivative is (1βˆ’dβˆ’P(keep∣x))β€‰βˆ‚Ξ”v(x)/βˆ‚ΞΈ(1 - d - P(\text{keep}|x))\, \partial \Delta v(x) / \partial\theta, and βˆ‚Ξ”v(x)/βˆ‚ΞΈ=Ξ²(βˆ‚EV[x]βˆ’βˆ‚EV[0])\partial \Delta v(x)/\partial\theta = \beta\big(\partial EV[x] - \partial EV[0]\big) plus the direct effects, +1 for RCRC and βˆ’0.001 x-0.001\,x for cc.

  4. Transition part, in dlog_transition(): +1/pΞ”x+1/p_{\Delta x} for the category that was observed and βˆ’1/pJ-1/p_J through the residual probability, with the top bin handled by comparing columns rather than increments.

The FrΓ©chet derivative, the implicit function theorem and the choice probabilities are all by-products of solving the model, which is why the gradient costs about one more linear solve than the likelihood itself. The replication notebook checks the result against finite differences of loglik().

  • Which part of score() would change if the maintenance cost were quadratic in mileage?

  • Why is the score of the transition probabilities not zero at the stage 2 estimates, where pp is at its closed-form maximizer?

ExercisesΒΆ

The four exercises below are taken from Exercise set 3 of the Dynamic Programming course at the University of Copenhagen (Spring 2020) by Patrick Kofod Mogensen and Maria Juul Hansen. The questions in the discussion blocks are quoted from it; the code is rewritten for the model and estimator classes of this course. All of them start from the data and the estimator of Class 10:

from nfxp import read_busdata, describe_data, estim_zurcher

data = read_busdata()                        # bus groups 1-4, 175 bins, 450 000 miles
describe_data(data)
result = estim_zurcher(data).estimate()      # BHHH, beta = 0.9999, warm start
print(result)

Run time profilerΒΆ

A profiler answers β€œwhich function is slow” by measuring run times. The standard library one, cProfile, reports time per function; in a notebook %prun is a shortcut for it:

import cProfile, pstats

cProfile.run('estim_zurcher(data).estimate()', 'nfxp.prof')
pstats.Stats('nfxp.prof').sort_stats('cumulative').print_stats(15)

Read the cumulative column top down: the outer loop, the score and the inner loop are nested, so each line includes the ones below it. For line-by-line timing of a single function install line_profiler (uv pip install line_profiler), load it with %load_ext line_profiler and run %lprun -f est.score est.estimate().

The optimizer options are the method argument of estimate(). bhhh and trust-ncg use the analytical score and the BHHH approximation of the Hessian, bfgs uses the analytical score and no Hessian, bfgs-numerical replaces the score by finite differences, and nelder-mead uses function values only:

for method in ('bhhh', 'trust-ncg', 'bfgs', 'bfgs-numerical', 'nelder-mead'):
    r = estim_zurcher(data).estimate(method=method)
    print(f'{method:<15} RC {r.theta[0]:8.4f}  c {r.theta[1]:7.4f}  loglik {r.loglik:11.4f}  '
          f'{r.time:6.2f} s  {r.n_solver_calls:5d} solves  converged {r.converged}')

Try using line_profiler in python. This gives you a lot of information about the performance of your code.

  • Now try changing the optimizer options, and turn the use of the non-numerical Hessian off. What happens?

  • Now also try it with the analytical gradient off, what happens?

The effect of the warm startΒΆ

solve() starts the fixed point iterations from the previous solution, self.ev, and the switch is the warm_start argument of estim_zurcher. The Result counts both loops: n_solver_calls is the number of times the model was solved, n_inner_iter the number of Bellman operator evaluations those solves took in total.

for warm in (True, False):
    r = estim_zurcher(data, warm_start=warm).estimate()
    print(f'warm start {warm!s:<5}  {r.time:5.2f} s  {r.n_solver_calls} solves  '
          f'{r.n_inner_iter} inner iterations  RC {r.theta[0]:.4f}  c {r.theta[1]:.4f}')

The mechanism is explained in the warm start section above.

We use the latest EV guess to start the nfxp.solve-procedure even though we change ΞΈ\theta from one likelihood iteration to another. Why do you think we do that?

  • What if we started with EV = 0 in each iteration? Try that and see what happens with the parameters and the numerical performance.

Estimation under different Ξ²\betaΒΆ

Ξ²\beta is an argument of estim_zurcher, not an element of theta, so it is fixed in estimation. Estimate the model on a grid of discount factors and compare the cost parameters and the log-likelihood:

for beta in (0.0, 0.5, 0.9, 0.99, 0.999, 0.9999):
    r = estim_zurcher(data, beta=beta).estimate()
    print(f'beta {beta:<7} RC {r.theta[0]:8.4f}  c {r.theta[1]:8.4f}  loglik {r.loglik:11.4f}')

Try estimate the model for different values of Ξ²\beta.

  • Why can we not estimate Ξ²\beta?

  • When estimating with different Ξ²\beta, do the changes in the estimates of cc and/or RCRC make intuitively sense?

  • Can you think of some data/variation, which could allow us to identify Ξ²\beta?

Alternative specification of the mileage processΒΆ

The discretization is part of the model. read_busdata(n, max_miles) chooses the number of bins and where the grid ends, and every bus with more mileage than max_miles lands in the top bin, which is absorbing unless the engine is replaced. The cost function is 0.001β‹…cβ‹…x0.001 \cdot c \cdot x with xx the bin index, and the transition probabilities are probabilities of moving a number of bins, so both depend on the width of a bin. The three specifications of the exercise, next to the baseline:

for n, max_miles in ((175, 450_000), (350, 900_000), (175, 900_000), (88, 225_000)):
    d = read_busdata(n=n, max_miles=max_miles)
    r = estim_zurcher(d).estimate()
    print(f'n {n:3d}  max {max_miles:7,d}  bin {max_miles / n:6.0f} miles  J {r.theta.size - 2}  '
          f'RC {r.theta[0]:7.4f}  c {r.theta[1]:7.4f}  loglik {r.loglik:10.3f}  '
          f'top bin {(d.x == n - 1).mean():.1%}')

Try setting the maximum number of miles (odometer reading) to 900. Now the absorbing state is much higher.

  • If we adjust the number of grid points as well, so that we have a comparable model (multiply the number of grids by 2), do we get a better fit?

  • Try to lower the number of grid points to 175 again. How do the parameters change? Are the changes intuitive?

  • What if you change the max to 225 and half the number of grids (hint: what goes wrong?)?

ReferencesΒΆ
  1. Rust, J. (1987). Optimal Replacement of GMC Bus Engines: An Empirical Model of Harold Zurcher. Econometrica, 55(5), 999. 10.2307/1911259
  2. Rust, J. (2000). Nested Fixed Point Algorithm Documentation Manual [Techreport]. Yale University.
  3. Su, C.-L., & Judd, K. L. (2012). Constrained Optimization Approaches to Estimation of Structural Models. Econometrica, 80(5), 2213–2230. 10.3982/ECTA7925
  4. Iskhakov, F., Lee, J., Rust, J., Schjerning, B., & Seo, K. (2016). Constrained optimization approaches to estimation of structural models: Comment. Econometrica, 84(1), 365–370. 10.3982/ECTA12605
  5. Abbring, J. H., & Daljord, Ø. (2020). Identifying the discount factor in dynamic discrete choice models. Quantitative Economics, 11(2), 471–501. 10.3982/QE1352