School of Economics, Peking University
2026-03-20
To compute the steady state, we use the equilibrium conditions: \[ \begin{gathered} \frac{1}{c} = \beta \frac{1}{c'}\left(\alpha A {k'}^{\alpha-1}\right) \\ c + k' = A k^{\alpha} \end{gathered} \] Imposing \(x = x'\) for all variables, we get: \[ \begin{gathered} k_{ss} = \left(\frac{1}{\beta \alpha A}\right)^{\frac{1}{\alpha-1}} \\ c_{ss} = A k_{ss}^{\alpha} - k_{ss} \\ y_{ss} = A k_{ss}^{\alpha} \end{gathered} \]
[4.41224766 4.40600365 4.39975964 ... 4.60356373 4.59731972 4.59107571]
Note
Here we use index packing: the combination i * lk + j converts 2D indices (i, j) into a unique 1D index.
In each iteration:
# Initialize value function and operator and iterate
V0 = np.ones((lk))
V1 = np.zeros((lk))
start_time = time.time()
while abs(np.linalg.norm(V1 - V0)) > tolv:
V0 = np.copy(V1) # Use deepcopy here to avoid shallow copy.
for i in range(lk):
vtemp = u[i * lk : (i + 1) * lk] + beta * V0
V1[i] = np.max(vtemp)
print(V1[:5])
print(f"Time taken: {time.time() - start_time:.2f} seconds")[138.88421524 138.88686227 138.88949887 138.89212395 138.89474037]
Time taken: 1.57 seconds
When checking convergence of value function iteration, we often monitor
\[ \|V_{n+1}-V_n\|\le \texttt{tol}. \] Different norms imply different (sometimes stronger/weaker) stopping rules.
\[ \|x\|_\infty = \max_i |x_i|. \]
\[ |x|_2 = \left(\sum_i x_i^2\right)^{1/2}. \]
\[ |x|_1 = \sum_i |x_i|. \]
Absolute errors depend on the scale of \(V\). A relative rule is \[ \frac{|V_{n+1}-V_n|}{\max(1,|V_n|)} \le \texttt{tol}. \]
Given weights \(w_i>0\),
\[ |x|_{w,\infty} = \max_i \frac{|x_i|}{w_i}. \]
Useful if some grid points matter more (e.g., near the ergodic distribution).
4.4568082557816524e-10
Tip
Practical default: use the sup-norm (np.max(np.abs(V1 - V0))) for VFI convergence checks, because it matches the standard contraction-mapping theory.
optim[i] gives the index of the \(k'\) maximizer when the initial capital is \(k_i\).[129 129 130 130 131]
The analytical solution to the model is:
\[ \begin{gathered} v(k) = (1-\beta)^{-1} \Big[ \ln (A(1-\alpha \beta)) + \frac{\alpha \beta}{1-\alpha \beta} \ln (A \alpha \beta) \Big] + \frac{\alpha}{1-\alpha \beta} \ln (k) \\ k' = g_k(k) = \alpha \beta A k^{\alpha} \\ c = g_c(k) = (1-\alpha \beta) A k^{\alpha} \end{gathered} \]
To test our approximation, we compare the differences between the analytical and numerical solution.
Distance between true and approx. value function: 0.00038753
Note
Why is the distance between the true and approximated value function not zero?
The main reason is that the optimal policy function is not exact on the grid. If we increase the grid size, the distance will decrease.
Also, if we use a smaller tolerance, the distance will decrease.
Plot the true and approximated value functions:
Choose an initial value for \(k_0\). To do this, select a number between 1 and \(I_k\), indicating the position in the initial capital in the grid. Given this position, we use the capital policy function to extract the optimal first period capital.
We then create a loop that:
Note
We need \(lk = 500\) (\(lk = 2\) is too small) to demonstrate the proper picture. To achieve this: change lk in step 2, run all above, then rerun the next cell.
indk = 0
kopt = np.zeros(T + 1)
output = np.zeros(T)
cons = np.zeros(T)
kopt[0] = polk[indk]
for i in range(T):
indk = optim[indk]
output[i] = A * kopt[i] ** alpha
kopt[i + 1] = polk[indk]
cons[i] = output[i] - kopt[i + 1]
plt.figure(figsize=(6, 6))
plt.plot(range(T), output)
plt.title("Convergence of output towards its steady state")
plt.xticks(range(0, T + 1, 4))
plt.xlabel("Period")
plt.ylabel("Output")
plt.show()For each value of \(\theta, k\) and \(k'\), calculate: \[ \begin{gathered} c = \theta k^{\alpha} - k' \\ U(c) = \log(c) \end{gathered} \]
Create a matrix of dimensions \(g_k \times l_\theta\), where \(g_k = l_k \times l_k\).
[[0.3084292 0.29515779]
[0.30800211 0.29473071]
[0.30757503 0.29430362]
[0.30714795 0.29387654]
[0.30672086 0.29344945]]
To calculate the return function, simply take the log of the consumption matrix:
# Initialize value function and operator and iterate
V0 = np.ones((lk, lt))
V1 = np.zeros((lk, lt))
p = np.array([0.5, 0.5])
start_time = time.time()
while abs(np.linalg.norm(V1 - V0)) > tolv:
V0 = np.copy(V1)
for j in range(lt):
for i in range(lk):
vtemp = u[i * lk : (i + 1) * lk, j] + beta * V0 @ p
V1[i, j] = np.max(vtemp)
V0 = np.copy(V1)
print(V0[:5])
print(f"Time taken: {time.time() - start_time:.2f} seconds")[[-100.6080923 -100.66186288]
[-100.60544596 -100.65921516]
[-100.60280894 -100.65657915]
[-100.60018353 -100.65395331]
[-100.59756785 -100.65133759]]
Time taken: 5.05 seconds
For each point in the grid, we find the index of the capital maximizer using the max operator.
Using this matrix, the policy functions can be calculated as follows:
\[ \begin{gathered} \text{polk} = \begin{bmatrix} g_k(k_1, \theta_1) & g_k(k_1, \theta_2) \\ \vdots & \vdots \\ g_k(k_{l_k}, \theta_1) & g_k(k_{l_k}, \theta_2) \end{bmatrix}, \text{polc} = \begin{bmatrix} g_c(k_1, \theta_1) & g_c(k_1, \theta_2) \\ \vdots & \vdots \\ g_c(k_{l_k}, \theta_1) & g_c(k_{l_k}, \theta_2) \end{bmatrix} \\ \quad \text{and} \quad \text{polc} = \begin{bmatrix} \theta_1 k_1^\alpha & \theta_2 k_1^\alpha \\ \vdots & \vdots \\ \theta_1 k_{l_k}^\alpha & \theta_2 k_{l_k}^\alpha \end{bmatrix} - \text{polk} \end{gathered} \]
Choose an initial value for \(k_0\) and \(\theta_0\) and use the policy matrix to extract the optimal first-period capital.
We then create a loop that:
indk = 1
shock = 1
kopt = np.zeros(T + 1)
sho = np.zeros(T)
output = np.zeros(T)
cons = np.zeros(T)
kopt[0] = polk[indk, shock]
for i in range(T):
indk = optim[indk, shock]
sho[i] = theta[shock]
kopt[i + 1] = polk[indk, shock]
output[i] = theta[shock] * kopt[i] ** alpha
cons[i] = output[i] - kopt[i + 1]
shock = np.random.randint(0, 2)Let the (finite) state space be \(S= \{\theta_1,\dots,\theta_N\}\).
Stationary (invariant) distribution. A vector \(\pi\in\Delta^{N-1}\) is stationary if \[ \pi^\top=\pi^\top P,\qquad \pi_i\ge 0,\ \sum_i \pi_i=1. \] If the chain is irreducible and aperiodic (ergodic), then \(\pi\) is unique and for any initial distribution \(\mu_0\), \[ \mu_t^\top=\mu_0^\top P^t \to \pi^\top. \]
How expectations enter DP. If current shock is \(\theta=\theta_i\), \[ \mathbb E\!\left[V(k',\theta')\mid \theta=\theta_i\right]\ \approx\ \sum_{j=1}^N P_{ij}\,V(k',\theta_j). \]
It is standard to discretize log productivity to ensure positivity.
Assume \[ x_t=\rho x_{t-1}+\varepsilon_t,\qquad \varepsilon_t\sim N(0,\sigma^2),\qquad \theta_t=\exp(x_t). \]
(i) Choose a grid of \(N\) states for \(x\)
We use the quantecon library to construct a Markov Chain.
# Step 1: Specify the AR(1) process parameters and discretize with Tauchen's method
import quantecon as qe
rho = 0.95 # persistence of the AR(1) process
sigmae = 0.00712 # standard deviation of the error term
lt = 4 # number of grid states for the Markov chain
n_std = 3 # number of standard deviations to cover in the grid
# Use Tauchen's method to approximate the AR(1) with a Markov chain
x_mc = qe.markov.approximation.tauchen(rho=rho, sigma=sigmae, n_std=n_std, n=lt, mu=0)# Step 2: Extract transition matrix and productivity states, and compute stationary distribution
P = x_mc.P # transition matrix (to match standard notation)
x_grid = x_mc.state_values # discretized grid for the state variable
teta = np.exp(x_grid) # productivity in levels, since shock is in logs
invdist = np.array(x_mc.stationary_distributions) # stationary distribution
print(f"Stationary distribution: {invdist}")
print(f"Productivity states: {teta}")
print(f"Transition matrix: {P[0,:]}")
print(f"Check (sum of first row): {np.sum(P[0,:])}")Stationary distribution: [[0.05317954 0.44682046 0.44682046 0.05317954]]
Productivity states: [0.93388054 0.97745576 1.02306421 1.07080077]
Transition matrix: [0.99675735 0.00324265 0. 0. ]
Check (sum of first row): 1.0
Note
The transition matrix P[i,j] denotes the probability of transitioning from state i to state j. That is: \[
P_{ij} = \Pr(x_{t+1} \in [b_{j-}, b_{j+}] \mid x_t = x_i) = \Pr(x_{t+1}=x_j \mid x_t = x_i)
\]
The equilibrium is given by: \[ \begin{gathered} c^{-\gamma} = \beta E[(c')^{-\gamma} (\alpha \theta' A {k'}^{\alpha-1} + 1-\delta) ] \\ c + i = \theta A k^\alpha \\ i = k' - (1-\delta)k \end{gathered} \]
Imposing \(\theta_{ss} = E \theta' = E \theta\) and \(x = x'\) gives:
\[ \begin{gathered} k_{ss} = \left(\frac{1-\beta(1-\delta)}{\beta \alpha A \theta_{ss}}\right)^{\frac{1}{\alpha-1}} \\ i_{ss} = \delta k_{ss} \\ c_{ss} = \theta_{ss} A k_{ss}^{\alpha} - i_{ss} \\ y_{ss} = A \theta_{ss} k_{ss}^{\alpha} \end{gathered} \]
[1.94925376]
Caution
Note that k_ss is actually a nd.array of shape (1,). This is because Eteta is a nd.array of shape (1,) not a scalar (float).
Create a grid for the shock and for \(k\) with \(l_k\) values, i.e. \(k \in [k_1 < \ldots < k_{l_k}]\).
For each \(\theta, k\) and \(k'\), calculate: \[ \begin{aligned} c = \theta A k^\alpha + (1-\delta)k - k' \\ U(c) = \frac{c^{1-\gamma}}{1-\gamma} \end{aligned} \]
To calculate the return function, take logs if \(\gamma = 1\), or compute \(\frac{c^{1-\gamma}}{1-\gamma}\) otherwise.
Our candidate solution for the value function is an \(l_k \times l_\theta\) matrix.
# Initialize of the value function
V0 = np.ones((lk, lt))
V1 = np.zeros((lk, lt))
start_time = time.time()
while abs(np.linalg.norm(V1 - V0)) > tolv:
V0 = np.copy(V1)
for j in range(lt):
for i in range(lk):
vtemp = U[i * lk : (i + 1) * lk, j] + beta * V0 @ P[j, :]
V1[i, j] = np.max(vtemp)
V0 = np.copy(V1)
print(V0[0, :])
end_time = time.time()
print(f"Time taken: {end_time - start_time} seconds")[-10.5240125 -9.98068286 -9.45989273 -8.96829951]
Time taken: 2.6193039417266846 seconds
[ 0 20 42 64]
optim[i, j] is the index of the next period capital maximizer.T = 2000 # simulate longer if you want moments
burn = 200 # burn-in to remove dependence on initial state
# initial indices
ik = lk // 2 # start near middle of k grid
s = np.argmax(invdist) # start at most likely shock state (or pick 0)
k_path = np.empty(T+1)
c_path = np.empty(T)
y_path = np.empty(T)
i_path = np.empty(T)
theta_path = np.empty(T, dtype=float)
s_path = np.empty(T, dtype=int)
k_path[0] = k[ik]
rng = np.random.default_rng(123) # reproduciblefor t in range(T):
theta = teta[s]
theta_path[t] = theta
s_path[t] = s
# policy: choose next capital index and level
ik_next = optim[ik, s]
k_next = k[ik_next]
# compute flows from resource constraint
y = theta * A * (k_path[t] ** alf)
i = k_next - (1 - delta) * k_path[t]
c = y - i
# store
y_path[t] = y
i_path[t] = i
c_path[t] = c
k_path[t+1] = k_next
# update indices
ik = ik_next
s = rng.choice(lt, p=P[s, :]) # Markov transition# drop burn-in
k_sim = k_path[burn:]
c_sim = c_path[burn:]
y_sim = y_path[burn:]
i_sim = i_path[burn:]
theta_sim = theta_path[burn:]
print("Simulation moments (post burn-in):")
print(f"E[k]={k_sim.mean():.4f} E[c]={c_sim.mean():.4f} E[y]={y_sim.mean():.4f}")
print(f"min(c)={c_sim.min():.4f} min(i)={i_sim.min():.4f}")Simulation moments (post burn-in):
E[k]=1.9399 E[c]=1.0469 E[y]=1.2409
min(c)=0.9491 min(i)=0.1719
The social planner solves the following problem:
\[ \begin{gathered} \max_{\{c_t, k_{t+1}\}} E_0 \sum_{t=0}^\infty \beta^t \frac{c_t^{1 - \gamma}}{1 - \gamma} \\ c_t + i_t \leq \theta_t A k_t^{\alpha} \\ k_{t+1} = i_t + (1 - \delta) k_t \\ i_t \geq 0 \end{gathered} \]
\(k_0, \theta_0\) given, \(c_t, k_t \geq 0\) for all \(t\).
The code is basically the same as above, except for a small modification in step 2.