Simulating Macroeconomic Models Under Uncertainty
“Monte Carlo is the great equalizer: it makes hard problems easy, at the cost of making easy problems slow.”
Cross-reference: Principles Ch. 27 (RBC model simulation, second moments, HP filter); Ch. 39 (future methods: particle filters, ML in macro) [P:Ch.27, P:Ch.39]
26.1 From Theory to Simulation¶
A macroeconomic model is a data-generating process: given parameters and shock sequences, it produces sequences of output, inflation, employment, and other observables. Simulation is the process of running the model forward in time, drawing shocks from their specified distributions, and computing the resulting model paths.
Simulation serves four purposes in quantitative macroeconomics:
Second-moment evaluation: Compute model-implied standard deviations, autocorrelations, and cross-correlations to compare against data (Chapter 17).
Impulse response generation: Average the response to a single shock across many simulations to estimate the IRF with uncertainty bands.
Policy evaluation: Simulate the model under different policy rules to compare welfare.
Likelihood approximation: For nonlinear models where the Kalman filter is unavailable, the particle filter approximates the likelihood using simulated particles.
This chapter develops the simulation pipeline from the ground up: random number generation, the Tauchen discretization (formalizing the derivation sketched in Chapter 5), the full RBC simulation workflow, bootstrap confidence bands, and the particle filter.
26.2 Pseudorandom Number Generation¶
Definition 26.1 (Pseudorandom Number Generator). A pseudorandom number generator (PRNG) is a deterministic algorithm that produces a sequence that is statistically indistinguishable from i.i.d. Uniform to standard tests.
The standard PRNG in modern scientific computing is the Mersenne Twister (period , passes all standard statistical tests). All languages use it by default: numpy.random, Julia’s MersenneTwister(), R’s set.seed().
Inverse CDF method: Any distribution with invertible CDF can be sampled via where :
Exponential():
Geometric():
Box–Muller transform: Two independent generate two standard normals:
Proof. The joint density of factors as , confirming independence and standard normality. The transformation uses the fact that and independently.
In practice, np.random.randn(), randn(n) in Julia, and rnorm(n) in R implement efficient normal sampling algorithms (typically Ziggurat method, faster than Box–Muller).
26.3 The Tauchen (1986) Discretization: Full Derivation¶
The Tauchen method approximates a continuous AR(1) with a discrete Markov chain. We derive it formally here, connecting to the sketch in Chapter 5.
Setup: , . We discretize to an -point grid .
Definition 26.2 (Tauchen Grid). Set:
and where (unconditional std dev) and (covers standard deviations).
Equal spacing: , so .
Definition 26.3 (Tauchen Transition Probabilities). The transition probability from state to state :
where . With the standard normal CDF:
Theorem 26.1 (Tauchen Approximation Quality). As with fixed , the discrete Markov chain converges in distribution to the continuous AR(1). The mean and variance of the discrete chain converge to 0 and respectively. The autocorrelation converges to .
Proof sketch. The -point chain has mean where is the stationary distribution. As , the grid covers the support of and the discrete probabilities converge to the normal probabilities, so and . Autocorrelation follows from the transition probability construction.
In APL, the Tauchen matrix is built via the ∘.- outer product — the computational core:
⍝ APL — Tauchen (1986) discretization
⎕IO←0 ⋄ ⎕ML←1
⍝ Normal CDF via Abramowitz & Stegun rational approximation (max error < 7.5e-8)
⍝ Use ⎕PY 'scipy.stats.norm.cdf' in production for full machine precision.
Phi ← {
t ← 1 ÷ 1 + 0.2316419 × |⍵
poly ← t × 0.319381530 + t × ¯0.356563782 + t × 1.781477937 + t × ¯1.821255978 + t × 1.330274429
p ← 1 - poly × (*¯0.5×⍵×⍵) ÷ (2×○1)*0.5
(⍵≥0) × p + (⍵<0) × 1-p ⍝ reflect for negative arguments
}
tauchen ← {rho sig_eps N m ← ⍵
sig_z ← sig_eps ÷ (1-rho*2)*0.5 ⍝ unconditional std dev of z
z_max ← m × sig_z ⋄ z_min ← -z_max
z ← z_min + (z_max-z_min) × (⍳N) ÷ N-1 ⍝ N-point uniform grid
dz ← (z_max-z_min) ÷ N-1 ⍝ grid spacing
⍝ diff[i;j] = z[j] - rho×z[i] (N×N matrix, row=from-state, col=to-state)
⍝ z∘.-rho×z gives element [i;j] = z[i] - rho×z[j] — wrong orientation
⍝ We need z[j] - rho×z[i], so transpose the result:
diff ← ⍉ z ∘.- rho × z ⍝ N×N: diff[i;j] = z[j]-rho×z[i]
⍝ Interior transition probabilities (N×N)
P ← (Phi (diff + dz÷2) ÷ sig_eps) - Phi (diff - dz÷2) ÷ sig_eps
⍝ Boundary corrections: absorb left tail into column 0, right tail into N-1
P[;0] ← Phi (z[0] + dz÷2 - rho × z) ÷ sig_eps ⍝ length-N column ← length-N vector ✓
P[;N-1] ← 1 - +/ P[;⍳N-1] ⍝ +/ sums ROWS of sub-matrix → length-N ✓
⍝ Row-normalize (rows should already sum to 1; guards against floating-point drift)
P ← P ÷ (+/P) ∘.× N⍴1 ⍝ broadcast row sums across columns
(⊂z) , ⊂P ⍝ return 2-element vector: grid, transition matrix
}
⍝ Test: TFP process for RBC model
rho_A←0.95 ⋄ sig_A←0.0072 ⋄ N←7
z_grid Pi_A ← tauchen rho_A sig_A N 3
z_grid ⍝ 7-point grid
Pi_A ⍝ row sums: all should be 1 (tolerance ~1e-8)26.4 Simulating Model Economies¶
Algorithm 26.1 (Model Simulation Pipeline).
Input: Policy functions , (from VFI or log-linearization), TFP transition matrix , initial condition , simulation length .
Draw shocks: Simulate the Markov chain using .
Compute decisions: At each , evaluate .
Update state: where .
Aggregate: Collect for .
Compute moments: Apply HP filter; compute standard deviations, autocorrelations, cross-correlations.
Markov chain simulation: Given the transition matrix and initial state , simulate a length- chain:
⍝ APL — Markov chain simulation
⎕IO←0 ⋄ ⎕ML←1
⍝ from the previous simulation
z_grid ← ¯0.06917536244 ¯0.0461169083 ¯0.02305845415 0 0.02305845415 0.0461169083 0.06917536244
Pi_A ← 7 7 ⍴ 0 0.1311581981 0.000007685716438 2.642330799E¯14 0 0 0.8688341162 0 0.8999075324
0.1000887944 0.000003673224831 7.66053887E¯15 0 0 0 0 0.9252292798
0.07476900736 0.000001712848737 2.109423747E¯15 1.110223025E¯16 0 0 0
0.9453427146 0.05465650617 7.792473004E¯7 4.440892099E¯16 0 0 0 0
0.960915503 0.03908415118 3.458554279E¯7 0 0 0 0 0 0.9726681005
0.02733189945 0 0 0 0 0 0 1
⍝ 1. Cumulative distribution rows of Pi
⍝ (Assuming Pi_A and z_grid are already in memory from the tauchen function)
Pi_cdf ← +\⍤1 ⊢ Pi_A
⍝ 2. The Path-Building Step Function
⍝ We modify this to take the WHOLE path, find the last step, and append the next one.
markov_step ← {
path ← ⍵
last ← ¯1 ↑ path ⍝ Get the current state (last item in path)
u ← ?0 ⍝ Uniform draw in [0,1)
next ← +/ u > last ⌷ Pi_cdf ⍝ Count how many CDF values u exceeds
path , next ⍝ Glue the new state to the end of the path
}
⍝ 3. Simulate T steps from state 3
T ← 1000 ⋄ j0 ← 3
⍝ Use Power (⍣) to apply markov_step exactly T times, starting with an array of just j0
chain ← markov_step⍣T ⊢ ,j0
⍝ 4. Economics
z_sim ← z_grid[chain] ⍝ Realized z values
A_sim ← *z_sim ⍝ TFP levels (exp of log-TFP)
⍝ --- Verification Output ---
⎕ ← 'Simulation complete. Length of chain: ' (≢chain)
⎕ ← 'Mean TFP: ' ((+/A_sim) ÷ ≢A_sim)26.5 Bootstrap Methods¶
The bootstrap generates confidence intervals and standard errors for statistics that have no analytical distribution theory.
Algorithm 26.2 (Residual Bootstrap for VAR IRFs).
Estimate the VAR to obtain , residuals , and IRF estimate .
For : a. Draw residuals with replacement: . b. Reconstruct bootstrap data: . c. Re-estimate the VAR and compute .
Report the 16th and 84th percentiles of as the 68% confidence band.
Theorem 26.2 (Bootstrap Consistency). Under weak regularity conditions (stationarity, ergodicity, finite moments), the bootstrap distribution of consistently estimates the distribution of . That is, bootstrap confidence intervals have asymptotically correct coverage.
Proof sketch. The bootstrap resamples residuals that converge (by ergodicity) to the true i.i.d. residual distribution. By the functional CLT, the bootstrap sample path distribution converges to the same Gaussian limit as the true distribution. See Gonçalves and Kilian (2004) for the full proof in the VAR context.
26.6 The Particle Filter for Nonlinear Models¶
When the state-space model is nonlinear — as in the DSGE model with occasionally-binding constraints, or the Markov-switching model — the Kalman filter (Chapter 20) is no longer optimal. The particle filter (Sequential Monte Carlo) approximates the distribution using a cloud of weighted particles.
Definition 26.4 (Particle Filter). A particle filter represents the distribution by weighted particles where .
Algorithm 26.3 (Bootstrap Particle Filter / Sequential Importance Resampling).
Initialize: Draw , set for all .
For :
Step 1 — Propagate: Draw (transition density).
Step 2 — Weight: Compute importance weights:
then normalize: .
Step 3 — Resample: When the effective sample size , resample particles from with replacement (systematic resampling); reset all weights to .
Log-likelihood contribution at :
The total log-likelihood approximates the Kalman filter log-likelihood for nonlinear models.
Connecting to the Kalman filter: For linear Gaussian models, the particle filter converges to the Kalman filter as : the particle distribution converges to the Gaussian filtering distribution. The Kalman filter is exact because the Gaussian is fully characterized by mean and variance; the particle filter is exact in the limit for any distribution.
import numpy as np
def particle_filter(y, f_transition, f_likelihood, a0_sampler, N=1000):
"""Bootstrap particle filter for nonlinear state-space model.
f_transition(alpha): sample next state given current state
f_likelihood(y_obs, alpha): log p(y | alpha)
a0_sampler(): sample initial state
"""
T = len(y)
# Initialize particles
particles = np.array([a0_sampler() for _ in range(N)])
weights = np.ones(N) / N
log_lik = 0.0
filtered_means = np.zeros(T)
for t in range(T):
# Propagate
particles = np.array([f_transition(a) for a in particles])
# Weight
log_w = np.array([f_likelihood(y[t], a) for a in particles])
log_w -= log_w.max() # numerical stability
w_unnorm = np.exp(log_w) * weights
# Log-likelihood contribution
log_lik += np.log(w_unnorm.mean())
# Normalize
weights = w_unnorm / w_unnorm.sum()
# Filtered mean
filtered_means[t] = np.dot(weights, particles)
# Resample if needed
N_eff = 1.0 / np.sum(weights**2)
if N_eff < N/2:
indices = np.random.choice(N, N, p=weights)
particles = particles[indices]
weights = np.ones(N) / N
return log_lik, filtered_means
# Example: nonlinear state-space model
# State: alpha_t = 0.8*alpha_{t-1} + eta_t, eta ~ N(0, 1)
# Obs: y_t = alpha_t^2/20 + eps_t, eps ~ N(0, 1)
np.random.seed(42); T = 100
alpha_true = np.zeros(T)
for t in range(1,T): alpha_true[t] = 0.8*alpha_true[t-1] + np.random.randn()
y_obs = alpha_true**2/20 + np.random.randn(T)
from scipy.stats import norm
f_trans = lambda a: 0.8*a + np.random.randn()
f_lik = lambda y, a: norm.logpdf(y, a**2/20, 1.0)
a0_sampler = lambda: np.random.randn()
log_lik, filtered = particle_filter(y_obs, f_trans, f_lik, a0_sampler, N=2000)
print(f"Particle filter log-likelihood: {log_lik:.2f}")
import matplotlib.pyplot as plt
plt.figure(figsize=(10,4))
plt.plot(alpha_true, 'b-', label='True state', alpha=0.7)
plt.plot(filtered, 'r-', label='PF filtered mean')
plt.legend(); plt.title('Particle Filter: Nonlinear State Estimation'); plt.show()26.7 Worked Example: RBC Second Moments via Simulation¶
Cross-reference: Principles Ch. 27.3 (calibration and second moments) [P:Ch.27.3]
Using the log-linearized RBC model solution from Chapter 28 (previewed here), simulate 10,000 periods and compute HP-filtered second moments.
import numpy as np
from scipy.stats import norm
# RBC model parameters and Tauchen discretization (from Ch.17/Ch.5)
alpha, delta, beta, sigma_crra = 0.36, 0.025, 0.99, 1.0
rho_A, sig_A = 0.95, 0.0072
# Tauchen discretization: 7-point grid for log(A)
N_A = 7; m = 3
sig_z = sig_A / np.sqrt(1-rho_A**2)
z_grid = np.linspace(-m*sig_z, m*sig_z, N_A)
A_grid = np.exp(z_grid)
dz = z_grid[1]-z_grid[0]
Pi = np.zeros((N_A, N_A))
for i in range(N_A):
mu_z = rho_A*z_grid[i]
Pi[i,0] = norm.cdf((z_grid[0]+dz/2-mu_z)/sig_A)
Pi[i,-1] = 1-norm.cdf((z_grid[-1]-dz/2-mu_z)/sig_A)
for j in range(1, N_A-1):
Pi[i,j] = norm.cdf((z_grid[j]+dz/2-mu_z)/sig_A) - norm.cdf((z_grid[j]-dz/2-mu_z)/sig_A)
Pi = Pi / Pi.sum(axis=1, keepdims=True) # normalize
# Simulate Markov chain
np.random.seed(42); T_sim = 12000
Pi_cdf = np.cumsum(Pi, axis=1)
A_idx = [N_A//2] # start at middle state
for t in range(T_sim-1):
u = np.random.rand()
A_idx.append(int(np.searchsorted(Pi_cdf[A_idx[-1]], u)))
A_sim = A_grid[A_idx]
# Log-linearized RBC decisions (approximate; see Ch.28 for exact computation)
# Approximation: output ~ A * k_ss^alpha * n_ss^(1-alpha) * (1 + hat_a)
# where hat_a = log(A/A_ss) and hat_y ~ (1/(1-alpha*beta*rho_A)) * hat_a
kstar = (alpha/(1/beta-1+delta))**(1/(1-alpha))
Yss = kstar**alpha; Css = Yss - delta*kstar; Iss = delta*kstar
psi_y = 1/(1-alpha*beta*rho_A) # output multiplier w.r.t. TFP (approximate)
psi_c = psi_y*(1-delta*alpha/(1/beta-1+delta))
psi_i = (psi_y - psi_c*Css/Yss)/Iss*Yss # rough investment multiplier
hat_a = np.log(A_sim) # log-deviation of TFP
Y_sim = Yss * np.exp(psi_y * hat_a)
C_sim = Css * np.exp(psi_c * hat_a)
I_sim = Iss * np.exp(psi_i * hat_a)
# HP filter
def hp_filter(x, lam=1600):
T = len(x)
from scipy.sparse import diags
from scipy.sparse.linalg import spsolve
e = np.ones(T)
D2 = diags([e[:-2], -2*e[:-1], e], [0,1,2], shape=(T-2,T))
I = diags(e)
tau = spsolve((I + lam*D2.T@D2).toarray(), x)
return x - tau
burnin = 2000
log_Y = np.log(Y_sim[burnin:]); log_C = np.log(C_sim[burnin:]); log_I = np.log(I_sim[burnin:])
cyc_Y = hp_filter(log_Y); cyc_C = hp_filter(log_C); cyc_I = hp_filter(log_I)
def stats(x, y=None):
std_x = np.std(x)*100
if y is None: return std_x, np.corrcoef(x[:-1],x[1:])[0,1]
return std_x, np.corrcoef(x,y)[0,1]
std_Y, ac_Y = stats(cyc_Y)
std_C, cor_CY = stats(cyc_C, cyc_Y)
std_I, cor_IY = stats(cyc_I, cyc_Y)
print(f"\nRBC Model Second Moments (HP-filtered, λ=1600):")
print(f"{'Variable':<12} {'Std(%)':>8} {'Rel.Std':>8} {'Corr(Y)':>8}")
print(f"{'Output Y':<12} {std_Y:>8.3f} {1.000:>8.3f} {1.000:>8.3f}")
print(f"{'Consump. C':<12} {std_C:>8.3f} {std_C/std_Y:>8.3f} {cor_CY:>8.3f}")
print(f"{'Invest. I':<12} {std_I:>8.3f} {std_I/std_Y:>8.3f} {cor_IY:>8.3f}")
print(f"\nData targets: Std(C)/Std(Y)≈0.50, Std(I)/Std(Y)≈3.08")
print(f" Corr(C,Y)≈0.88, Corr(I,Y)≈0.93")26.8 Programming Exercises¶
Exercise 26.1 (APL — Tauchen Discretization)¶
Implement the complete Tauchen algorithm in APL as described in Section 26.3. (a) The outer product diff ← (⍉z) ∘.- rho × z generates the matrix of conditional means in one expression. (b) Using ⎕PY to call scipy.stats.norm.cdf for the normal CDF, compute the full transition matrix. (c) Verify: row sums equal 1; the stationary distribution (eigenvector of ) matches approximately.
Exercise 26.2 (Python — Rouwenhorst Method)¶
The Rouwenhorst (1995) method is more accurate than Tauchen for highly persistent processes (). For : , . For : recurse using -point matrix. (a) Implement Rouwenhorst for and . (b) Compare the implied autocorrelation to the target for both Tauchen and Rouwenhorst. (c) Show Rouwenhorst matches exactly by construction.
Exercise 26.3 (Julia — Bootstrap IRF Bands)¶
using LinearAlgebra, Statistics
function bootstrap_irf(Y, p, H, B=500, identification=:cholesky)
T, n = size(Y)
# OLS VAR(p)
X = hcat([Y[p-j+1:T-j,:] for j in 1:p]...)
Ytgt = Y[p+1:end,:]
B_hat = (X'X) \ (X'Ytgt) # np×n
resid = Ytgt - X*B_hat
Sigma = resid'*resid/(T-p-n*p)
A1 = B_hat[1:n,:]' # companion (first lag only for simplicity)
P = cholesky(Sigma).L # Cholesky identification
# Point IRF
irf_point = zeros(H, n, n)
for h in 1:H, j in 1:n
shock = P[:,j]
irf_point[h,:,j] = (A1^(h-1))*shock
end
# Bootstrap
irf_boot = zeros(B, H, n, n)
for b in 1:B
# Resample residuals
idx = rand(1:size(resid,1), size(resid,1))
resid_b = resid[idx,:]
# Reconstruct data
Y_b = zeros(T, n); Y_b[1:p,:] = Y[1:p,:]
for t in p+1:T
Xb = vcat([Y_b[t-j,:] for j in 1:p]...)
Y_b[t,:] = B_hat'*Xb + resid_b[t-p,:]
end
# Re-estimate
Xb = hcat([Y_b[p-j+1:T-j,:] for j in 1:p]...)
Ytb = Y_b[p+1:end,:]
Bb = (Xb'Xb)\(Xb'Ytb)
rb = Ytb - Xb*Bb
Sb = rb'*rb/(T-p-n*p)
Pb = cholesky(Sb).L
Ab = Bb[1:n,:]'
for h in 1:H, j in 1:n
irf_boot[b,h,:,j] = (Ab^(h-1))*Pb[:,j]
end
end
return irf_point, irf_boot
end
# Simulate a 2-variable VAR and compute bootstrap bands
Random.seed!(42); T=200; n=2
A_true = [0.8 0.1; 0.0 0.7]; L_true = [0.01 0; 0.005 0.008]
Y = zeros(T,n)
for t in 2:T; Y[t,:] = A_true*Y[t-1,:] + L_true*randn(2); end
irf, irf_b = bootstrap_irf(Y, 1, 20, 200)
# 68% confidence bands
lo = dropdims(mapslices(x->quantile(x,0.16), irf_b[:,1,:,1], dims=1), dims=1)
hi = dropdims(mapslices(x->quantile(x,0.84), irf_b[:,1,:,1], dims=1), dims=1)
println("Bootstrap 68% band width at h=1: $(round(hi[1]-lo[1], digits=5))")Exercise 26.4 — Particle Filter vs. Kalman ()¶
For the linear Gaussian state-space model: , ; , . (a) Run the Kalman filter (exactly) and the particle filter () on the same 200-period dataset. (b) Compare the filtered means and log-likelihoods. (c) Show that the particle filter converges to the Kalman filter as . (d) Compute the RMSE of the filtered state relative to the true state for each method and .
26.9 Chapter Summary¶
Key results:
Pseudorandom generation: Box–Muller transform generates normals from uniforms (proved via change-of-variables); Mersenne Twister is the standard PRNG.
Tauchen (1986) discretization (Theorem 26.1): the -point Markov chain with transition matrix converges to the continuous AR(1) as . In APL: the core is
diff ← (⍉z) ∘.- rho × z(one outer product).Simulation pipeline: Draw Markov chain → compute decisions via policy functions → compute aggregate time series → HP-filter → compute second moments.
Bootstrap consistency (Theorem 26.2): residual bootstrap confidence bands for VAR IRFs have asymptotically correct coverage under stationarity and ergodicity.
Particle filter: Represents by weighted particles ; propagate → weight → resample. Log-likelihood approximates the Kalman likelihood for nonlinear models.
In APL: Markov chain simulation uses cumulative row sums and
⍸(interval index); the HP filter uses⌹on the banded penalty matrix; all second moments are computed via+.×and standard deviation operators.
Next: Part VII — Computational Methods for DSGE Models