2026-02-23
| McCall | Rust | |
|---|---|---|
| Agent | Unemployed worker | Bus superintendent |
| State | Wage offer | Accumulated mileage |
| Choice | Accept / Reject | Replace / Keep |
| Tradeoff | Good wage now vs. better offer later | Replacement cost vs. rising maintenance |
The Key Tradeoff
Replacing is expensive but resets the engine to new condition. Keeping is cheap today but maintenance costs rise with mileage.
\[ u(x_t, 0) = -c(x_t, \theta_1) \]
\[ u(x_t, 1) = -RC \]
\[ \tilde{u}(x_t, d) = u(x_t, d) + \varepsilon_t(d) \]
\[ \Pr(\Delta x = k) = \theta_{3,k+1} \quad \text{for } k = 0, 1, 2 \]
RustModel StructMcCallModel):@kwdef struct RustModel
β::Float64 = 0.95 # discount factor
S::Int = 50 # number of mileage bins
RC::Float64 = 20.0 # replacement cost
θ₁::Float64 = 0.04 # maintenance cost parameter
θ₃::Vector{Float64} = [0.36, 0.48, 0.16] # transition probabilities
x̄::Vector{Float64} = Float64.(0:S-1) # mileage states
c::Vector{Float64} = θ₁ * x̄ # cost at each mileage level
endRustModel
RustModel(0.95, 50, 20.0, 0.04, [0.36, 0.48, 0.16], [0.0, 1.0, 2.0, 3.0, 4.0, 5.0, 6.0, 7.0, 8.0, 9.0 … 40.0, 41.0, 42.0, 43.0, 44.0, 45.0, 46.0, 47.0, 48.0, 49.0], [0.0, 0.04, 0.08, 0.12, 0.16, 0.2, 0.24, 0.28, 0.32, 0.36 … 1.6, 1.6400000000000001, 1.68, 1.72, 1.76, 1.8, 1.84, 1.8800000000000001, 1.92, 1.96])
function buildTransition(θ₃, S)
K = length(θ₃) # number of possible increments
#Pre-allocate the transition matrices
for i in 1:S # loop over current states
for k in 1:K # loop over possible increments
#Fill in keep transition matrix
#Fill in replace transition matrix
end
end
#Return constructed transition matrices
endfunction buildTransition(θ₃, S)
K = length(θ₃) # number of possible increments
F0 = zeros(S, S) # keep transition matrix: mileage goes up
F1 = zeros(S, S) # replace transition matrix: mileage resets
for i in 1:S # loop over current states
for k in 1:K # loop over possible increments
j_keep = min(i + k - 1, S)
F0[i, j_keep] += θ₃[k] # keep transition: state i → state i + (k-1)
j_replace = min(k, S)
F1[i, j_replace] += θ₃[k] # replace transition: any state → state (k)
end
end
return F0, F1
endbuildTransition (generic function with 1 method)
([0.36 0.48 … 0.0 0.0; 0.0 0.36 … 0.0 0.0; … ; 0.0 0.0 … 0.36 0.64; 0.0 0.0 … 0.0 1.0], [0.36 0.48 … 0.0 0.0; 0.36 0.48 … 0.0 0.0; … ; 0.36 0.48 … 0.0 0.0; 0.36 0.48 … 0.0 0.0])
\[ \text{Option 0: } v_0 + \varepsilon_0 \qquad \text{Option 1: } v_1 + \varepsilon_1 \]
\[ d^* = \arg\max_d \left\{ v_d + \varepsilon_d \right\} \]
A Modeling Choice
We need to choose a distribution for \(\varepsilon\). This is a modeling assumption that affects both the math and the economics. Rust chose the Type I Extreme Value distribution.
\[ \mathbb{E}\left[\max_d \left\{ v_d + \varepsilon_d \right\}\right] \]
\[ F(\varepsilon) = e^{-e^{-\varepsilon}} \]
Theorem (Log-Sum-Exp)
If \(\varepsilon_0, \varepsilon_1\) are iid Type I Extreme Value, then:
\[ \mathbb{E}\left[\max\{v_0 + \varepsilon_0,\; v_1 + \varepsilon_1\}\right] = \log\left(e^{v_0} + e^{v_1}\right) + \gamma \]
where \(\gamma \approx 0.5772\) is Euler’s constant.
v0_val = 2.0
v1_val = 3.0
ε0 = -log.(-log.(rand(N))) # N extreme value draws
ε1 = -log.(-log.(rand(N))) # N independent draws
max_payoffs = max.(v0_val .+ ε0, v1_val .+ ε1) # simulated max for each draw
const γ_euler = Base.MathConstants.eulergamma # Euler's constant ≈ 0.5772
theoretical = log(exp(v0_val) + exp(v1_val)) + γ_euler
println("Simulated E[max]: ", round(mean(max_payoffs), digits=4))
println("Theoretical: ", round(theoretical, digits=4))Simulated E[max]: 3.8944
Theoretical: 3.8905
function verifyExpectedMaximum(v0_val, v1_val, N)
#N extreme value draws for ε0 and ε1
ε0 = -log.(-log.(rand(N)))
ε1 = -log.(-log.(rand(N)))
#simulate max payoff for each draw
max_payoffs = max.(v0_val .+ ε0, v1_val .+ ε1)
#compute theoretical expected maximum
theoretical = log(exp(v0_val) + exp(v1_val)) + γ_euler
#return simulated and theoretical expected maximum
return mean(max_payoffs), theoretical
endverifyExpectedMaximum (generic function with 1 method)
#Preallocate arrays
Δv_grid = LinRange(-4, 4, 50)
Emax_sim = zeros(length(Δv_grid))
Emax_theory = zeros(length(Δv_grid))
#Loop through each value of Δv
for (i, Δv) in enumerate(Δv_grid)
Emax_sim[i], Emax_theory[i] = verifyExpectedMaximum(0.0, Δv, N)
end
#Plot the results
plot(Δv_grid, Emax_theory, linewidth=2, label="log(eᵛ⁰ + eᵛ¹) + γ", legend=:topleft)
scatter!(Δv_grid, Emax_sim, label="Monte Carlo", markersize=3)
xlabel!("v₁ - v₀")
ylabel!("E[max{v₀ + ε₀, v₁ + ε₁}]")Theorem (Logit Formula)
If \(\varepsilon_0, \varepsilon_1\) are iid Type I Extreme Value, then:
\[ P(d = 1) = \Pr(v_1 + \varepsilon_1 > v_0 + \varepsilon_0) = \frac{e^{v_1}}{e^{v_0} + e^{v_1}} = \frac{1}{1 + e^{v_0 - v_1}} \]
Simulated P(d=1): 0.732
Theoretical P(d=1): 0.7311
function verifyChoiceProbabilities(v0_val, v1_val, N)
#N extreme value draws for ε0 and ε1
ε0 = -log.(-log.(rand(N)))
ε1 = -log.(-log.(rand(N)))
#simulate choices for each draw
choices = (v1_val .+ ε1) .> (v0_val .+ ε0)
#compute theoretical choice probability
theoretical = exp(v1_val) / (exp(v0_val) + exp(v1_val))
return mean(choices), theoretical
endverifyChoiceProbabilities (generic function with 1 method)
# Preallocate arrays
Δv_points = LinRange(-5, 5, 25)
P_mc = zeros(length(Δv_points))
P_theory = zeros(length(Δv_points))
#Loop through each value of Δv
for (i, Δv) in enumerate(Δv_points)
P_mc[i], P_theory[i] = verifyChoiceProbabilities(0.0, Δv, N)
end
#Plot the results
plot(Δv_points, P_theory, linewidth=2, label="Logit Formula", legend=:topleft)
scatter!(Δv_points, P_mc, label="Monte Carlo", markersize=5)
xlabel!("v₁ - v₀")
ylabel!("P(d = 1)")\[ \max_{d_t} \; \mathbb{E} \left[ \sum_{t=0}^{\infty} \beta^t \left( u(x_t, d_t, \theta_1) + \varepsilon_t(d_t) \right) \right] \]
Same structure as McCall, but the state evolves stochastically regardless of the action (mileage always goes up)
As always, the problem can be tackled with a let statement
\[ v^{\text{keep}}(x) = -c(x, \theta_1) + \beta \sum_{x'} F_0(x, x') \cdot V(x') \]
\[ v^{\text{replace}}(x) = -RC + \beta \sum_{x'} F_1(x, x') \cdot V(x') \]
\[ \max\left\{ v^{\text{keep}}(x) + \varepsilon_0, \;\; v^{\text{replace}}(x) + \varepsilon_1 \right\} \]
\[ \boxed{V(x) \equiv \mathbb{E}_\varepsilon\left[\max\left\{ v^{\text{keep}}(x) + \varepsilon_0, \;\; v^{\text{replace}}(x) + \varepsilon_1 \right\}\right]} \]
\[ V(x) = \log\left(e^{v^{\text{keep}}(x)} + e^{v^{\text{replace}}(x)}\right) + \gamma \]
\[ \boxed{V(x) = \log\left(\exp(v^{\text{keep}}(x)) + \exp(v^{\text{replace}}(x))\right)} \]
Fixed Point Problem
\(V\) appears on both sides: on the left directly, and on the right inside \(v^{\text{keep}}\) and \(v^{\text{replace}}\). Just like in McCall, we solve by iterating until convergence.
\[ P(d = 1 \mid x) = \frac{\exp(v^{\text{replace}}(x))}{\exp(v^{\text{keep}}(x)) + \exp(v^{\text{replace}}(x))} = \frac{1}{1 + \exp(v^{\text{keep}}(x) - v^{\text{replace}}(x))} \]
Static Logit vs. Dynamic Logit
This is the same logit formula we verified with Monte Carlo. The difference: \(v^{\text{keep}}\) and \(v^{\text{replace}}\) incorporate the entire future path of costs and decisions — the agent is forward-looking, not myopic.
\[ v^{\text{keep}} = -c + \beta \, F_0 \, V \] \[ v^{\text{replace}} = -RC \cdot \mathbf{1} + \beta \, F_1 \, V \]
F0 * V: matrix-vector multiplication computes \(\sum_{x'} F_0(x, x') V(x')\) for every \(x\)v_keep falls with mileage (higher maintenance costs)v_replace is nearly constant (resetting makes current mileage irrelevant)function iterateBellman(m::RustModel, V, F0, F1)
(; β, RC, c) = m
v_keep = -c + β * (F0 * V) # value of keeping
v_replace = (-RC) .+ β * (F1 * V) # value of replacing
vmax = max.(v_keep, v_replace) # for numerical stability
V_new = vmax + log.(exp.(v_keep - vmax) # log-sum-exp trick:
+ exp.(v_replace - vmax)) # prevents overflow
return V_new
enditerateBellman (generic function with 1 method)
Log-Sum-Exp Trick
We compute \(\log(e^a + e^b) = m + \log(e^{a-m} + e^{b-m})\) where \(m = \max(a,b)\). This prevents numerical overflow when \(a\) or \(b\) are large.
V = zeros(S) # initial guess: all zeros
dist = 1.0 # initialize distance
niter = 0 # count iterations
while dist > 1e-10 # loop until convergence
V_new = iterateBellman(m, V, F0, F1) # one Bellman step
dist = norm(V - V_new, Inf) # max absolute change
V = V_new # update
niter += 1
end
println("Converged in $niter iterations")Converged in 455 iterations
solveBellman FunctionsolveRust FunctionsolveRust Functionfunction solveRust(m::RustModel)
(; β, RC, c, θ₃, S) = m
#build transition matrices
F0, F1 = buildTransition(θ₃, S)
#solve the Bellman equation
V = solveBellman(m, F0, F1) # solve the DP
#compute choice probabilities
v_keep = -c + β * (F0 * V) # choice-specific values
v_replace = (-RC) .+ β * (F1 * V)
P_rep = 1 ./ (1 .+ exp.(v_keep - v_replace)) # logit choice probabilities
#return tuple with solution
return (V=V, P=P_rep, v_keep=v_keep, v_replace=v_replace)
endsolveRust (generic function with 1 method)
solveRust5-element Vector{Float64}:
2.0611536181902037e-9
4.202144358547094e-9
8.518414382484044e-9
1.716378823590513e-8
3.436078646097847e-8
| Comparative Static | McCall | Rust |
|---|---|---|
| Higher benefits / lower RC | More selective (higher reservation wage) | Less replacement (higher threshold mileage) |
| Higher firing rate / higher \(\theta_1\) | Less selective (lower reservation wage) | More replacement (lower threshold mileage) |
| Higher \(\beta\) | More selective (patient, wait for good offers) | More replacement (forward-looking, avoid future costs) |
Goal
Recover the structural parameters \((\theta_1, RC, \theta_3)\) from the observed data \((x_t, d_t)\).
\[ \ell(\theta) = \sum_{t=1}^{T} \left[ d_t \log P(d_t=1 \mid x_t, \theta) + (1-d_t) \log P(d_t=0 \mid x_t, \theta) \right] \]
Key Insight
The transition probabilities \(\theta_3\) can be estimated separately from \((RC, \theta_1)\):
function simulateRust(P_rep, θ₃, S, T; seed=42)
Random.seed!(seed)
x_sim = zeros(Int, T) # mileage states
d_sim = zeros(Int, T) # replacement decisions
x_sim[1] = 1 # start at state 1 (0 mileage)
for t in 1:T
d_sim[t] = rand() < P_rep[x_sim[t]] ? 1 : 0 # Bernoulli draw
if t < T
Δ = rand(Categorical(θ₃)) - 1 # mileage increment: 0, 1, or 2
if d_sim[t] == 1
x_sim[t+1] = min(1 + Δ, S) # replace: reset then increment
else
x_sim[t+1] = min(x_sim[t] + Δ, S) # keep: current + increment
end
end
end
return x_sim, d_sim
endsimulateRust (generic function with 1 method)
counts = zeros(3) # for Δ = 0, 1, 2
for t in 1:T-1
if d_sim[t] == 0 && x_sim[t] <= S - 2 # keep, away from boundary
Δ = x_sim[t+1] - x_sim[t]
counts[Δ + 1] += 1
elseif d_sim[t] == 1 # replace
Δ = x_sim[t+1] - 1 # next state = 1 + Δ
counts[Δ + 1] += 1
end
end
θ₃_hat = counts / sum(counts) # estimated transition probs
println("True θ₃: ", round.(m.θ₃, digits=3))
println("Estimated θ₃: ", round.(θ₃_hat, digits=3))True θ₃: [0.36, 0.48, 0.16]
Estimated θ₃: [0.351, 0.486, 0.163]
For each candidate \(\theta = (RC, \theta_1)\):
\[ \ell(\theta) = \sum_{t=1}^{T} \left[ d_t \log P(d_t=1 \mid x_t, \theta) + (1-d_t) \log P(d_t=0 \mid x_t, \theta) \right] \]
Outer loop: Search over \(\theta\) to maximize \(\ell(\theta)\)
Why “Nested”?
Every single evaluation of the likelihood requires solving the entire dynamic program. The fixed point (VFI) is nested inside the optimization.
For each candidate \(\theta = (RC, \theta_1)\):
\[ \ell(\theta) = \sum_{t=1}^{T} \left[ {\color{red}\underbrace{d_t \log P(d_t=1 \mid x_t, \theta)}_{\text{Replacement likelihood}}} + {\color{blue}\underbrace{(1-d_t) \log P(d_t=0 \mid x_t, \theta)}_{\text{Keep likelihood}}} \right] \]
Outer loop: Search over \(\theta\) to maximize \(\ell(\theta)\)
Why “Nested”?
Every single evaluation of the likelihood requires solving the entire dynamic program. The fixed point (VFI) is nested inside the optimization.
nfxpObjective (generic function with 1 method)
function nfxpObjective(params, x_data, d_data, β, θ₃_hat, S)
# unpack candidate parameters (RC, θ₁)
RC_est, θ₁_est = params
# check parameter bounds
if RC_est < 0 || θ₁_est < 0
return 1e10
end
# build trial model and solve the DP (inner loop)
m_trial = RustModel(β=β, S=S, RC=RC_est, θ₁=θ₁_est, θ₃=θ₃_hat)
sol = solveRust(m_trial)
# compute log-likelihood from choice probabilities
ll = 0.0
for t in eachindex(d_data)
s = x_data[t]
ll += d_data[t] * log(max(sol.P[s], 1e-15)) +
(1 - d_data[t]) * log(max(1 - sol.P[s], 1e-15))
end
# return negative log-likelihood (for minimization)
return -ll
endnfxpObjective (generic function with 1 method)
println("────────────────────────────────────────")
println("Parameter │ True │ Estimated")
println("────────────────────────────────────────")
println(" RC │ $(lpad(m.RC, 6)) │ $(lpad(round(RC_hat, digits=3), 6))")
println(" θ₁ │ $(lpad(m.θ₁, 6)) │ $(lpad(round(θ₁_hat, digits=4), 6))")
println("────────────────────────────────────────")────────────────────────────────────────
Parameter │ True │ Estimated
────────────────────────────────────────
RC │ 20.0 │ 21.22
θ₁ │ 0.04 │ 0.0431
────────────────────────────────────────
The Power of Structural Models
With the estimated structural parameters, we can perform counterfactual analysis:
A reduced-form model (e.g., a logit on current mileage) cannot answer these questions — it would break under the policy change. The structural model accounts for how agents re-optimize under new conditions.
Concepts
Julia Tools
max.(), exp.(), log.() broadcastingnorm() for convergence checks@kwdef, destructuring (; ...)Categorical for discrete simulationOptim.jl for maximum likelihoodRemember
Rust (1987) showed that dynamic programming models can be estimated from data. The key ingredients: (1) a structural model of forward-looking behavior, (2) distributional assumptions on unobservables, and (3) the NFXP algorithm to solve the model inside the estimator.
EC 410 | University of Oregon